mirror of
https://github.com/expo/eas-cli.git
synced 2026-11-14 10:08:06 -10:00
Compare commits
40
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1c31702653 | ||
|
|
b053e0926a | ||
|
|
6eeb15bdce | ||
|
|
279eb1595c | ||
|
|
256b02d3e9 | ||
|
|
9f4fef7626 | ||
|
|
8cc324e11e | ||
|
|
f33b4c131d | ||
|
|
e3eb3ad5ea | ||
|
|
1292465c57 | ||
|
|
00d2022680 | ||
|
|
5aaaf1b224 | ||
|
|
aaf14b8bab | ||
|
|
65413d1640 | ||
|
|
71ee9469aa | ||
|
|
7223a19fc8 | ||
|
|
3f4e826315 | ||
|
|
ea7cc6b1ea | ||
|
|
b5281d64d1 | ||
|
|
e51fe11aca | ||
|
|
16767f3feb | ||
|
|
c42b1a004c | ||
|
|
8a4bf6f068 | ||
|
|
03e1c18d34 | ||
|
|
2bf8822b1d | ||
|
|
460f98da76 | ||
|
|
3a7182bb82 | ||
|
|
f963dc711b | ||
|
|
a059852bd9 | ||
|
|
33e0b0901c | ||
|
|
8ba877ab81 | ||
|
|
9f6e387888 | ||
|
|
c10691c339 | ||
|
|
155b65c8fc | ||
|
|
cdbc95295c | ||
|
|
414ccb92c0 | ||
|
|
aabf519a9d | ||
|
|
88d9627286 | ||
|
|
59ce6d0b65 | ||
|
|
8e64a9c73e |
No files matched your search
@@ -0,0 +1,17 @@
|
||||
name: build-android
|
||||
on:
|
||||
workflow_dispatch: {}
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build Android
|
||||
type: build
|
||||
runs_on: linux-large
|
||||
params:
|
||||
platform: android
|
||||
profile: system-test
|
||||
custom_job:
|
||||
name: Custom Job
|
||||
steps:
|
||||
- name: Echo
|
||||
run: echo "This is a custom job step"
|
||||
@@ -0,0 +1,16 @@
|
||||
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
|
||||
@@ -0,0 +1,205 @@
|
||||
name: build-and-deploy-worker
|
||||
# Assumes main branch always specifies an environment (staging/production).
|
||||
# If environment is empty: PR testing build (no upload, no deploy)
|
||||
# If environment is set: Build (unless cached on main), upload, and deploy
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
environment:
|
||||
description: '"staging", "production" or empty'
|
||||
type: string
|
||||
required: true
|
||||
workflow_call:
|
||||
inputs:
|
||||
environment:
|
||||
description: '"staging", "production" or empty'
|
||||
type: string
|
||||
required: true
|
||||
|
||||
jobs:
|
||||
build-and-deploy:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
id-token: write
|
||||
steps:
|
||||
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4
|
||||
|
||||
- uses: volta-cli/action@5c175f92dea6f48441c436471e6479dbc192e194 # v4
|
||||
|
||||
- uses: ./.github/internal-actions/setup-gcloud
|
||||
|
||||
- name: Check if worker already exists
|
||||
id: check_worker_exists
|
||||
# Check if tarballs for this SHA already exist
|
||||
# If they do, we can skip building regardless of branch
|
||||
if: inputs.environment != ''
|
||||
run: |
|
||||
for platform in android ios; do
|
||||
GCP_TARBALL="worker-$platform-sha1-$GITHUB_SHA.tar.gz"
|
||||
|
||||
if ! gsutil stat gs://eas-build-worker-tarballs/$GCP_TARBALL; then
|
||||
echo "worker_exists=false" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
done
|
||||
|
||||
echo "worker_exists=true" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Start EAS Workflow to build worker tarballs
|
||||
id: eas_workflow
|
||||
# Build if: PR testing (no env), or deploying but tarballs don't exist
|
||||
if: inputs.environment == '' || steps.check_worker_exists.outputs.worker_exists == 'false'
|
||||
working-directory: packages/worker
|
||||
env:
|
||||
EXPO_TOKEN: ${{ secrets.EXPO_DEV_EXPO_GITHUB_ROBOT_ACCESS_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
# Only upload artifacts from EAS if GHA will upload them to GCS
|
||||
# This avoids unnecessary artifact uploads for PR builds
|
||||
SHOULD_UPLOAD_ARTIFACT="${{ inputs.environment != '' && steps.check_worker_exists.outputs.worker_exists == 'false' }}"
|
||||
|
||||
# Run workflow and capture output (includes run ID)
|
||||
WORKFLOW_OUTPUT=$(npx -y eas-cli@latest workflow:run build-worker.yml \
|
||||
--ref ${{ github.sha }} \
|
||||
--input upload_artifact=$SHOULD_UPLOAD_ARTIFACT \
|
||||
--non-interactive \
|
||||
--no-wait \
|
||||
--json)
|
||||
|
||||
WORKFLOW_RUN_ID=$(echo "$WORKFLOW_OUTPUT" | jq -r '.id')
|
||||
if [ -z "$WORKFLOW_RUN_ID" ] || [ "$WORKFLOW_RUN_ID" = "null" ]; then
|
||||
echo "Error: Could not determine workflow run ID"
|
||||
echo "Workflow output:"
|
||||
echo "$WORKFLOW_OUTPUT" | jq .
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "workflow_run_id=$WORKFLOW_RUN_ID" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Wait for EAS workflow and capture artifacts
|
||||
id: eas_artifacts
|
||||
if: inputs.environment == '' || steps.check_worker_exists.outputs.worker_exists == 'false'
|
||||
working-directory: packages/worker
|
||||
env:
|
||||
EXPO_TOKEN: ${{ secrets.EXPO_DEV_EXPO_GITHUB_ROBOT_ACCESS_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
WORKFLOW_RUN_ID="${{ steps.eas_workflow.outputs.workflow_run_id }}"
|
||||
if [ -z "$WORKFLOW_RUN_ID" ]; then
|
||||
echo "Error: Missing workflow run ID"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
SHOULD_UPLOAD_ARTIFACT="${{ inputs.environment != '' && steps.check_worker_exists.outputs.worker_exists == 'false' }}"
|
||||
|
||||
while true; do
|
||||
VIEW_OUTPUT=$(npx -y eas-cli@latest workflow:view "$WORKFLOW_RUN_ID" \
|
||||
--non-interactive \
|
||||
--json)
|
||||
STATUS=$(echo "$VIEW_OUTPUT" | jq -r '.status')
|
||||
|
||||
case "$STATUS" in
|
||||
SUCCESS)
|
||||
break
|
||||
;;
|
||||
FAILURE)
|
||||
echo "EAS workflow failed."
|
||||
echo "$VIEW_OUTPUT" | jq .
|
||||
exit 1
|
||||
;;
|
||||
CANCELED)
|
||||
echo "EAS workflow canceled."
|
||||
echo "$VIEW_OUTPUT" | jq .
|
||||
exit 1
|
||||
;;
|
||||
ACTION_REQUIRED)
|
||||
echo "EAS workflow requires action."
|
||||
echo "$VIEW_OUTPUT" | jq .
|
||||
exit 1
|
||||
;;
|
||||
NEW|IN_PROGRESS)
|
||||
sleep 7
|
||||
;;
|
||||
*)
|
||||
echo "Unexpected workflow status: $STATUS"
|
||||
echo "$VIEW_OUTPUT" | jq .
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Skip artifact extraction if we didn't request uploads
|
||||
if [ "$SHOULD_UPLOAD_ARTIFACT" == "false" ]; then
|
||||
echo "Skipping artifact download - upload was disabled for this run"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Extract artifact download URLs from workflow output
|
||||
# Artifacts are in jobs[].artifacts[] for custom jobs
|
||||
DOWNLOAD_IOS_URL=$(echo "$VIEW_OUTPUT" | jq -r '
|
||||
.jobs[]
|
||||
| .artifacts[]?
|
||||
| select(.name == "worker-ios-tarball")
|
||||
| .downloadUrl
|
||||
' | head -1)
|
||||
|
||||
DOWNLOAD_ANDROID_URL=$(echo "$VIEW_OUTPUT" | jq -r '
|
||||
.jobs[]
|
||||
| .artifacts[]?
|
||||
| select(.name == "worker-android-tarball")
|
||||
| .downloadUrl
|
||||
' | head -1)
|
||||
|
||||
if [ -z "$DOWNLOAD_IOS_URL" ] || [ "$DOWNLOAD_IOS_URL" = "null" ]; then
|
||||
echo "Error: Could not find iOS artifact download URL"
|
||||
echo "$VIEW_OUTPUT" | jq .
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -z "$DOWNLOAD_ANDROID_URL" ] || [ "$DOWNLOAD_ANDROID_URL" = "null" ]; then
|
||||
echo "Error: Could not find Android artifact download URL"
|
||||
echo "$VIEW_OUTPUT" | jq .
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Mask the download URLs
|
||||
echo "::add-mask::$DOWNLOAD_IOS_URL"
|
||||
echo "::add-mask::$DOWNLOAD_ANDROID_URL"
|
||||
echo "download_ios_url=$DOWNLOAD_IOS_URL" >> "$GITHUB_OUTPUT"
|
||||
echo "download_android_url=$DOWNLOAD_ANDROID_URL" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Cancel EAS workflow on GitHub cancellation
|
||||
if: cancelled()
|
||||
working-directory: packages/worker
|
||||
env:
|
||||
EXPO_TOKEN: ${{ secrets.EXPO_DEV_EXPO_GITHUB_ROBOT_ACCESS_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
WORKFLOW_RUN_ID="${{ steps.eas_workflow.outputs.workflow_run_id }}"
|
||||
if [ -n "$WORKFLOW_RUN_ID" ]; then
|
||||
npx -y eas-cli@latest workflow:cancel "$WORKFLOW_RUN_ID" --non-interactive
|
||||
fi
|
||||
|
||||
- name: Upload worker tarballs to GCP
|
||||
# Upload when deploying if we built something new
|
||||
# Don't upload PR builds (no environment) or when using cached tarballs
|
||||
if: inputs.environment != '' && steps.check_worker_exists.outputs.worker_exists == 'false'
|
||||
run: |
|
||||
IOS_GCP_TARBALL="worker-ios-sha1-$GITHUB_SHA.tar.gz"
|
||||
ANDROID_GCP_TARBALL="worker-android-sha1-$GITHUB_SHA.tar.gz"
|
||||
curl -L "${{ steps.eas_artifacts.outputs.download_ios_url }}" | gsutil cp - gs://eas-build-worker-tarballs/$IOS_GCP_TARBALL
|
||||
curl -L "${{ steps.eas_artifacts.outputs.download_android_url }}" | gsutil cp - gs://eas-build-worker-tarballs/$ANDROID_GCP_TARBALL
|
||||
|
||||
- name: Promote worker to ${{ inputs.environment }}
|
||||
if: inputs.environment != ''
|
||||
run: |
|
||||
for platform in android ios; do
|
||||
GCP_TARBALL="worker-$platform-sha1-$GITHUB_SHA.tar.gz"
|
||||
WORKER_STAGING_TARBALL="worker-$platform-${{ inputs.environment }}.tar.gz"
|
||||
|
||||
gsutil cp gs://eas-build-worker-tarballs/$GCP_TARBALL gs://eas-build-worker-tarballs/$WORKER_STAGING_TARBALL
|
||||
done
|
||||
@@ -9,6 +9,8 @@ jobs:
|
||||
release:
|
||||
name: Create a release
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
outputs:
|
||||
upload_url: ${{ steps.create_release.outputs.upload_url }}
|
||||
steps:
|
||||
@@ -24,9 +26,11 @@ jobs:
|
||||
name: Build for Linux
|
||||
runs-on: ubuntu-latest
|
||||
needs: release
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/setup-node@v2
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
- run: yarn install --frozen-lockfile --check-files
|
||||
@@ -69,9 +73,11 @@ jobs:
|
||||
# Use macOS to get the correct platform specific dependencies
|
||||
runs-on: macos-latest
|
||||
needs: release
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/setup-node@v2
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
- run: brew install python-setuptools
|
||||
@@ -112,9 +118,11 @@ jobs:
|
||||
name: Build for Windows
|
||||
runs-on: ubuntu-latest
|
||||
needs: release
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/setup-node@v2
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
- run: yarn install --frozen-lockfile --check-files
|
||||
@@ -152,9 +160,11 @@ jobs:
|
||||
name: Publish to npm
|
||||
runs-on: ubuntu-latest
|
||||
needs: [build-linux, build-mac, build-windows]
|
||||
permissions:
|
||||
id-token: write
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/setup-node@v2
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
registry-url: 'https://registry.npmjs.org/'
|
||||
scope: 'expo'
|
||||
@@ -165,31 +175,25 @@ jobs:
|
||||
run: yarn build
|
||||
- name: Publish packages to npm
|
||||
run: yarn lerna publish from-package --yes --no-verify-access
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_ACCESS_TOKEN }}
|
||||
- name: Add latest-eas-build-staging tag
|
||||
run: |
|
||||
VERSION=$(cat lerna.json | jq -r .version)
|
||||
echo //registry.npmjs.org/:_authToken=$NODE_AUTH_TOKEN > ~/.npmrc
|
||||
npm dist-tag add eas-cli@$VERSION latest-eas-build-staging
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_ACCESS_TOKEN }}
|
||||
- name: Add latest-eas-build tag
|
||||
run: |
|
||||
VERSION=$(cat lerna.json | jq -r .version)
|
||||
echo //registry.npmjs.org/:_authToken=$NODE_AUTH_TOKEN > ~/.npmrc
|
||||
npm dist-tag add eas-cli@$VERSION latest-eas-build
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_ACCESS_TOKEN }}
|
||||
release-changelog:
|
||||
name: Update changelog and publish release
|
||||
runs-on: ubuntu-latest
|
||||
needs: publish-to-npm
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
token: ${{ secrets.EXPO_BOT_PAT }}
|
||||
- uses: actions/setup-node@v2
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
- name: Install dependencies
|
||||
|
||||
@@ -15,7 +15,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
node: ['20', '22']
|
||||
node: ['20', '22', '24']
|
||||
include:
|
||||
- node: '20'
|
||||
coverage: true
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
name: system-tests
|
||||
|
||||
on:
|
||||
workflow_dispatch: {}
|
||||
workflow_call: {}
|
||||
|
||||
jobs:
|
||||
system-tests:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
EXPO_TOKEN: ${{ secrets.STAGING_EXPO_DEV_EXPO_SERVICES_GITHUB_ROBOT_ACCESS_TOKEN }}
|
||||
EXPO_STAGING: "1"
|
||||
steps:
|
||||
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4
|
||||
|
||||
- uses: volta-cli/action@5c175f92dea6f48441c436471e6479dbc192e194 # v4
|
||||
|
||||
- name: Create test app
|
||||
run: npx -y create-expo-app@latest ${{ runner.temp }}/test-app --template tabs
|
||||
|
||||
- name: Link to EAS project
|
||||
working-directory: ${{ runner.temp }}/test-app
|
||||
run: npx -y eas-cli@latest init --id 6ae8028d-4e6d-4912-8eb2-87d4e6122880 --force --non-interactive
|
||||
|
||||
- name: Add bundle identifiers to app.json
|
||||
working-directory: ${{ runner.temp }}/test-app
|
||||
run: |
|
||||
jq '.expo.ios.bundleIdentifier = "dev.expo.workersystemtests" | .expo.android.package = "dev.expo.workersystemtests"' app.json > app.json.tmp
|
||||
mv app.json.tmp app.json
|
||||
|
||||
- name: Configure EAS Build
|
||||
working-directory: ${{ runner.temp }}/test-app
|
||||
run: npx -y eas-cli@latest build:configure --platform all
|
||||
|
||||
- name: Add system-test profile to eas.json
|
||||
working-directory: ${{ runner.temp }}/test-app
|
||||
run: |
|
||||
jq '.build["system-test"] = { "ios": { "simulator": true }, "android": { "withoutCredentials": true } }' eas.json > eas.json.tmp
|
||||
mv eas.json.tmp eas.json
|
||||
|
||||
- name: Setup EAS workflow
|
||||
working-directory: ${{ runner.temp }}/test-app
|
||||
run: |
|
||||
mkdir -p .eas/workflows
|
||||
cp ${{ github.workspace }}/.github/fixtures/eas-workflow-build-android.yml .eas/workflows/build-android.yml
|
||||
|
||||
- name: Run iOS build and Android workflow concurrently
|
||||
working-directory: ${{ runner.temp }}/test-app
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
BUILD_JSON=$(npx -y eas-cli@latest build --platform ios --profile system-test --non-interactive --no-wait --json)
|
||||
BUILD_ID=$(echo "$BUILD_JSON" | jq -r 'if type=="array" then .[0].id // .[0].build.id // .[0].buildId else .id // .build.id // .buildId end // empty')
|
||||
if [ -z "$BUILD_ID" ]; then
|
||||
echo "Error: could not determine build ID"
|
||||
echo "$BUILD_JSON" | jq .
|
||||
exit 1
|
||||
fi
|
||||
echo "BUILD_ID=$BUILD_ID" >> "$GITHUB_ENV"
|
||||
echo "Resolved BUILD_ID=$BUILD_ID"
|
||||
|
||||
WORKFLOW_JSON=$(npx -y eas-cli@latest workflow:run build-android.yml --non-interactive --no-wait --json)
|
||||
WORKFLOW_RUN_ID=$(echo "$WORKFLOW_JSON" | jq -r '.id // .workflowRun.id // .workflowRunId // empty')
|
||||
if [ -z "$WORKFLOW_RUN_ID" ]; then
|
||||
echo "Error: could not determine workflow run ID"
|
||||
echo "$WORKFLOW_JSON" | jq .
|
||||
exit 1
|
||||
fi
|
||||
echo "WORKFLOW_RUN_ID=$WORKFLOW_RUN_ID" >> "$GITHUB_ENV"
|
||||
echo "Resolved WORKFLOW_RUN_ID=$WORKFLOW_RUN_ID"
|
||||
|
||||
is_terminal_build_status() {
|
||||
case "$1" in
|
||||
FINISHED|ERRORED|CANCELED) return 0 ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
is_terminal_workflow_status() {
|
||||
case "$1" in
|
||||
SUCCESS|FAILURE|CANCELED|ACTION_REQUIRED) return 0 ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
BUILD_STATUS=""
|
||||
WORKFLOW_STATUS=""
|
||||
TIMEOUT_SECONDS=1800
|
||||
START_TIME=$(date +%s)
|
||||
|
||||
while true; do
|
||||
ELAPSED=$(( $(date +%s) - START_TIME ))
|
||||
if [ "$ELAPSED" -ge "$TIMEOUT_SECONDS" ]; then
|
||||
echo "Timeout: polling exceeded 30 minutes"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
BUILD_STATUS=$(npx -y eas-cli@latest build:view "$BUILD_ID" --json | jq -r '.status // empty')
|
||||
WORKFLOW_STATUS=$(npx -y eas-cli@latest workflow:view "$WORKFLOW_RUN_ID" --non-interactive --json | jq -r '.status // empty')
|
||||
|
||||
echo "iOS build status: $BUILD_STATUS"
|
||||
echo "Android workflow status: $WORKFLOW_STATUS"
|
||||
|
||||
if is_terminal_build_status "$BUILD_STATUS" && is_terminal_workflow_status "$WORKFLOW_STATUS"; then
|
||||
break
|
||||
fi
|
||||
|
||||
sleep 7
|
||||
done
|
||||
|
||||
EXIT_CODE=0
|
||||
|
||||
if [ "$BUILD_STATUS" != "FINISHED" ]; then
|
||||
echo "Build did not finish successfully: $BUILD_STATUS"
|
||||
EXIT_CODE=1
|
||||
fi
|
||||
|
||||
if [ "$WORKFLOW_STATUS" != "SUCCESS" ]; then
|
||||
echo "Workflow did not finish successfully: $WORKFLOW_STATUS"
|
||||
EXIT_CODE=1
|
||||
fi
|
||||
|
||||
exit "$EXIT_CODE"
|
||||
|
||||
- name: Cancel EAS runs on workflow cancellation
|
||||
if: always() && (cancelled() || failure())
|
||||
working-directory: ${{ runner.temp }}/test-app
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
if [ -n "${BUILD_ID:-}" ]; then
|
||||
echo "Canceling EAS build: $BUILD_ID"
|
||||
BUILD_CANCEL_OUTPUT=$(npx -y eas-cli@latest build:cancel "$BUILD_ID" --non-interactive 2>&1 || true)
|
||||
if [ -n "$BUILD_CANCEL_OUTPUT" ]; then
|
||||
echo "$BUILD_CANCEL_OUTPUT"
|
||||
else
|
||||
echo "build:cancel produced no output"
|
||||
fi
|
||||
BUILD_STATUS=$(npx -y eas-cli@latest build:view "$BUILD_ID" --json 2>/dev/null | jq -r '.status // empty' || true)
|
||||
if [ -n "$BUILD_STATUS" ]; then
|
||||
echo "Post-cancel build status: $BUILD_STATUS"
|
||||
else
|
||||
echo "Post-cancel build status unavailable"
|
||||
fi
|
||||
fi
|
||||
if [ -n "${WORKFLOW_RUN_ID:-}" ]; then
|
||||
echo "Canceling EAS workflow run: $WORKFLOW_RUN_ID"
|
||||
WORKFLOW_CANCEL_OUTPUT=$(npx -y eas-cli@latest workflow:cancel "$WORKFLOW_RUN_ID" --non-interactive 2>&1 || true)
|
||||
if [ -n "$WORKFLOW_CANCEL_OUTPUT" ]; then
|
||||
echo "$WORKFLOW_CANCEL_OUTPUT"
|
||||
else
|
||||
echo "workflow:cancel produced no output"
|
||||
fi
|
||||
WORKFLOW_STATUS=$(npx -y eas-cli@latest workflow:view "$WORKFLOW_RUN_ID" --non-interactive --json 2>/dev/null | jq -r '.status // empty' || true)
|
||||
if [ -n "$WORKFLOW_STATUS" ]; then
|
||||
echo "Post-cancel workflow status: $WORKFLOW_STATUS"
|
||||
else
|
||||
echo "Post-cancel workflow status unavailable"
|
||||
fi
|
||||
fi
|
||||
@@ -0,0 +1,75 @@
|
||||
name: worker
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "packages/worker/**"
|
||||
- .github/workflows/worker.yml
|
||||
- .github/workflows/build-and-deploy-worker.yml
|
||||
pull_request:
|
||||
paths:
|
||||
- "packages/worker/**"
|
||||
- .github/workflows/worker.yml
|
||||
- .github/workflows/build-and-deploy-worker.yml
|
||||
|
||||
concurrency:
|
||||
group: worker-${{ github.ref }}-${{ github.event_name }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
worker-checks:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4
|
||||
|
||||
- uses: volta-cli/action@5c175f92dea6f48441c436471e6479dbc192e194 # v4
|
||||
|
||||
- run: yarn install --frozen-lockfile --check-files
|
||||
|
||||
- name: Run unit tests
|
||||
working-directory: packages/worker
|
||||
run: yarn test:unit
|
||||
|
||||
- name: Run integration tests
|
||||
working-directory: packages/worker
|
||||
run: yarn test:integration
|
||||
|
||||
worker-build:
|
||||
needs:
|
||||
- worker-checks
|
||||
uses: ./.github/workflows/build-and-deploy-worker.yml
|
||||
secrets: inherit
|
||||
with:
|
||||
environment: ${{ github.ref == 'refs/heads/main' && 'staging' || '' }}
|
||||
|
||||
system-tests-staging:
|
||||
if: github.ref == 'refs/heads/main' && !failure() && !cancelled()
|
||||
needs:
|
||||
- worker-build
|
||||
- worker-checks
|
||||
uses: ./.github/workflows/worker-system-tests.yml
|
||||
secrets: inherit
|
||||
|
||||
deploy-worker-production:
|
||||
if: github.ref == 'refs/heads/main' && !failure() && !cancelled()
|
||||
needs:
|
||||
- system-tests-staging
|
||||
uses: ./.github/workflows/build-and-deploy-worker.yml
|
||||
secrets: inherit
|
||||
with:
|
||||
environment: production
|
||||
|
||||
notify-slack:
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- deploy-worker-production
|
||||
if: github.ref == 'refs/heads/main' && always() && !cancelled()
|
||||
steps:
|
||||
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4
|
||||
|
||||
- name: Notify Slack
|
||||
uses: ./.github/internal-actions/notify-slack-on-fail-or-recover
|
||||
with:
|
||||
channel: turtle-bots
|
||||
slack_webhook: ${{ secrets.SLACK_EAS_WORKFLOWS_WEBHOOK }}
|
||||
@@ -8,10 +8,23 @@ This is the log of notable changes to EAS CLI and related packages.
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- Add `--no-bytecode` and `--source-maps [mode]` flags to `eas update`. ([#3339](https://github.com/expo/eas-cli/pull/3339) by [@brentvatne](https://github.com/brentvatne))
|
||||
- Add App Clip bundle identifier registration support for multi-target iOS builds. ([#3300](https://github.com/expo/eas-cli/pull/3300) by [@evanbacon](https://github.com/evanbacon))
|
||||
- Add `--runtime-version` and `--platform` filters to `eas update:list`. ([#3261](https://github.com/expo/eas-cli/pull/3261) by [@HarelSultan](https://github.com/HarelSultan))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- Fix `--source-maps` flag compatibility with older SDKs that only support it as a boolean flag. ([#3341](https://github.com/expo/eas-cli/pull/3341) by [@brentvatne](https://github.com/brentvatne))
|
||||
- Use `--dump-sourcemaps` as fallback when `--source-maps` is not provided to `eas update`, for backwards compatibility. ([8cc324e1](https://github.com/expo/eas-cli/commit/8cc324e1) by [@brentvatne](https://github.com/brentvatne))
|
||||
- Fix `metadata:pull` failing for apps with only a live version by falling back to live app version and info. ([#3299](https://github.com/expo/eas-cli/pull/3299) by [@EvanBacon](https://github.com/EvanBacon))
|
||||
- eas init should fix and validate project name and slug. ([#3277](https://github.com/expo/eas-cli/pull/3277) by [@douglowder](https://github.com/douglowder))
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
- Test with node 24. ([#3270](https://github.com/expo/eas-cli/pull/3270) by [@wschurman](https://github.com/wschurman))
|
||||
- Delete channel in background. ([#3278](https://github.com/expo/eas-cli/pull/3278) by [@quinlanj](https://github.com/quinlanj))
|
||||
- Add warning to channel:delete. ([#3335](https://github.com/expo/eas-cli/pull/3335) by [@quinlanj](https://github.com/quinlanj))
|
||||
|
||||
## [16.28.0](https://github.com/expo/eas-cli/releases/tag/v16.28.0) - 2025-11-20
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Monorepo Structure
|
||||
|
||||
This is a **Lerna-based monorepo** with four packages:
|
||||
|
||||
1. **[packages/eas-cli](./packages/eas-cli/CLAUDE.md)** - The main CLI tool (published as `eas-cli`)
|
||||
|
||||
- 95+ commands organized by domain (build, submit, update, channel, credentials, etc.)
|
||||
- Built on oclif (Open CLI Framework)
|
||||
- Entry point: `packages/eas-cli/bin/run`
|
||||
|
||||
2. **[packages/eas-json](./packages/eas-json/CLAUDE.md)** - EAS configuration parser (published as `@expo/eas-json`)
|
||||
|
||||
- Validates `eas.json` configuration files using Joi schemas
|
||||
- Manages build, submit, and deploy profiles
|
||||
- Supports JSON5 via golden-fleece
|
||||
|
||||
3. **[packages/eas-build-cache-provider](./packages/eas-build-cache-provider/CLAUDE.md)** - Build cache plugin (published as `eas-build-cache-provider`)
|
||||
|
||||
- Optimizes build caching for Expo CLI
|
||||
|
||||
4. **[packages/worker](./packages/worker/CLAUDE.md)** - Turtle Worker service (private, not published)
|
||||
- Runs on EAS build VMs/pods
|
||||
- WebSocket server for communication with Turtle Launcher
|
||||
- Wraps `@expo/build-tools` to execute actual React Native builds
|
||||
|
||||
> **Note**: Each package has its own CLAUDE.md with package-specific guidance. Click the links above to view them.
|
||||
|
||||
## Common Development Commands
|
||||
|
||||
### Setup
|
||||
|
||||
```bash
|
||||
yarn install # Install all dependencies
|
||||
yarn build # Build all packages in the monorepo
|
||||
```
|
||||
|
||||
### Development
|
||||
|
||||
```bash
|
||||
yarn start # Watch mode with parallel builds across all packages
|
||||
yarn watch # Alias for start
|
||||
|
||||
# Development with relaxed TypeScript rules (allows unused variables)
|
||||
yarn start-allow-unused
|
||||
```
|
||||
|
||||
### Testing
|
||||
|
||||
```bash
|
||||
yarn test # Run all tests across packages (Jest multi-project)
|
||||
yarn test --watch # Run tests in watch mode
|
||||
|
||||
# Run tests for specific package
|
||||
cd packages/eas-cli && yarn test
|
||||
cd packages/eas-json && yarn test
|
||||
cd packages/worker && yarn test
|
||||
```
|
||||
|
||||
### Type Checking & Linting
|
||||
|
||||
```bash
|
||||
yarn typecheck # TypeScript type check all packages
|
||||
yarn lint # ESLint across all packages
|
||||
```
|
||||
|
||||
### Running EAS CLI Locally
|
||||
|
||||
```bash
|
||||
# From repository root:
|
||||
yarn eas <command> # Run via root package script
|
||||
|
||||
# Or use the binary directly:
|
||||
packages/eas-cli/bin/run <command>
|
||||
|
||||
# Recommended: Create an alias for development
|
||||
alias easd="$(pwd)/packages/eas-cli/bin/run"
|
||||
easd build --help
|
||||
```
|
||||
|
||||
## Build System
|
||||
|
||||
Each package has independent TypeScript compilation:
|
||||
|
||||
- `eas-cli`: `src/` → `build/`
|
||||
- `eas-json`: `src/` → `build/`
|
||||
- `eas-build-cache-provider`: `src/` → `build/`
|
||||
- `worker`: `src/` → `dist/`
|
||||
|
||||
TypeScript configs:
|
||||
|
||||
- `tsconfig.json` - Base configuration (extends @tsconfig/node18)
|
||||
- `tsconfig.build.json` - Production builds
|
||||
- `tsconfig.allowUnused.json` - Development with relaxed rules
|
||||
|
||||
## Testing Architecture
|
||||
|
||||
- **Framework**: Jest with multi-project configuration
|
||||
- **Root config**: `jest.config.ts` (combines eas-cli, eas-json, worker)
|
||||
- **Test locations**: `__tests__/` directories alongside source files
|
||||
- **Mocking**:
|
||||
- File system: `memfs`
|
||||
- HTTP: `nock`
|
||||
- Utilities: `ts-mockito`, `mockdate`
|
||||
|
||||
Run a single test file:
|
||||
|
||||
```bash
|
||||
yarn test <path-to-test-file>
|
||||
# Example:
|
||||
yarn test packages/eas-cli/src/project/__tests__/projectUtils-test.ts
|
||||
```
|
||||
|
||||
## Environment Variables
|
||||
|
||||
```bash
|
||||
# API Configuration
|
||||
export EXPO_STAGING=1 # Use staging API (https://staging-api.expo.dev)
|
||||
export EXPO_LOCAL=1 # Use local API (http://127.0.0.1:3000)
|
||||
|
||||
# Development
|
||||
export EAS_NO_VCS=1 # Disable version control checks
|
||||
export EXPO_DEBUG=1 # Enable debug logging
|
||||
```
|
||||
|
||||
## Code Style & Conventions
|
||||
|
||||
### Validation
|
||||
|
||||
All changes should be validated with TypeScript and the linter before committing:
|
||||
|
||||
```bash
|
||||
yarn typecheck # Validate TypeScript types
|
||||
yarn lint # Run ESLint
|
||||
```
|
||||
|
||||
### Logging
|
||||
|
||||
Use the `Log` module from `@expo/logger`:
|
||||
|
||||
```typescript
|
||||
import { Log } from '@expo/logger';
|
||||
|
||||
Log.log('Info message');
|
||||
Log.warn('Warning message');
|
||||
Log.error('Error message');
|
||||
```
|
||||
|
||||
### Pull Request conventions
|
||||
|
||||
When creating pull requests, make sure to adhere to [PULL_REQUEST_TEMPLATE](./.github/PULL_REQUEST_TEMPLATE).
|
||||
|
||||
## Release Process
|
||||
|
||||
1. Update `CHANGELOG.md` in the appropriate package
|
||||
2. Version bump is automated based on changelog:
|
||||
- Breaking changes → MAJOR
|
||||
- New features → MINOR
|
||||
- Otherwise → PATCH
|
||||
3. GitHub Actions workflow handles release automation
|
||||
4. Notifications sent to Slack #eas-cli channel
|
||||
|
||||
## Important Notes
|
||||
|
||||
- **Node Version**: Requires Node.js >= 18.0.0 (managed via Volta)
|
||||
- **Package Manager**: Uses Yarn 1.22.21
|
||||
- **Compilation Target**: CommonJS with Node resolution
|
||||
+3
-1
@@ -4,9 +4,11 @@ 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],
|
||||
projects: [easCliConfig, easJsonConfig, workerConfig],
|
||||
testPathIgnorePatterns: ['.*'],
|
||||
watchPlugins: ['jest-watch-typeahead/filename', 'jest-watch-typeahead/testname'],
|
||||
collectCoverageFrom: ['<rootDir>/src/**/*.{ts,tsx,js,jsx}'],
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"npmClient": "yarn",
|
||||
"version": "16.28.0",
|
||||
"version": "16.30.0",
|
||||
"$schema": "node_modules/lerna/schemas/lerna-schema.json"
|
||||
}
|
||||
+4
-4
@@ -21,21 +21,21 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/jest": "29.5.11",
|
||||
"@types/node": "20.11.0",
|
||||
"@types/node": "20.14.8",
|
||||
"eslint": "8.56.0",
|
||||
"eslint-config-universe": "13.0.0",
|
||||
"eslint-plugin-async-protect": "3.1.0",
|
||||
"eslint-plugin-node": "11.1.0",
|
||||
"jest": "29.7.0",
|
||||
"jest-watch-typeahead": "2.2.2",
|
||||
"lerna": "8.2.3",
|
||||
"lerna": "9.0.3",
|
||||
"oclif": "^3.9.0",
|
||||
"prettier": "3.1.1",
|
||||
"ts-jest": "29.1.1",
|
||||
"typescript": "5.3.3"
|
||||
"typescript": "5.5.4"
|
||||
},
|
||||
"volta": {
|
||||
"node": "20.11.0",
|
||||
"node": "20.19.4",
|
||||
"yarn": "1.22.21"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
# CLAUDE.md - eas-build-cache-provider
|
||||
|
||||
This file provides guidance for working with the **eas-build-cache-provider** package.
|
||||
|
||||
## Overview
|
||||
|
||||
A build cache provider plugin for Expo CLI. Published as `eas-build-cache-provider` on npm.
|
||||
|
||||
- Provides remote caching for Expo builds
|
||||
- Optimizes build times by caching intermediate build artifacts
|
||||
- Integrates with EAS infrastructure
|
||||
|
||||
## Development Commands
|
||||
|
||||
```bash
|
||||
yarn build # TypeScript compilation (src → build)
|
||||
yarn watch # Watch mode
|
||||
yarn typecheck # Type check
|
||||
yarn test # Run tests
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
### Installation
|
||||
|
||||
Install as a dev dependency in your Expo project:
|
||||
|
||||
```bash
|
||||
npm install --save-dev eas-build-cache-provider
|
||||
# or
|
||||
yarn add --dev eas-build-cache-provider
|
||||
```
|
||||
|
||||
### Configuration
|
||||
|
||||
Update your **app.json** to enable the EAS build cache provider:
|
||||
|
||||
```json
|
||||
{
|
||||
"expo": {
|
||||
"experiments": {
|
||||
"buildCacheProvider": "eas"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## How It Works
|
||||
|
||||
The build cache provider:
|
||||
1. Hooks into Expo CLI's build process
|
||||
2. Uploads build cache artifacts to EAS infrastructure
|
||||
3. Downloads cached artifacts on subsequent builds
|
||||
4. Improves build times by reusing cached dependencies and build outputs
|
||||
|
||||
## Important Notes
|
||||
|
||||
- **Experimental**: This is an experimental feature
|
||||
- **EAS Integration**: Requires EAS account and project setup
|
||||
- **Cache Scope**: Cache is scoped per project and platform
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "eas-build-cache-provider",
|
||||
"description": "A build cache provider plugin for the Expo CLI",
|
||||
"version": "16.4.2",
|
||||
"version": "16.30.0",
|
||||
"author": "Expo <[email protected]>",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"dependencies": {
|
||||
@@ -24,7 +24,7 @@
|
||||
"@types/getenv": "^1.0.0",
|
||||
"memfs": "3.4.13",
|
||||
"rimraf": "3.0.2",
|
||||
"typescript": "5.3.3"
|
||||
"typescript": "5.5.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
@@ -33,7 +33,11 @@
|
||||
"license": "MIT",
|
||||
"main": "build/index.js",
|
||||
"types": "build/index.d.ts",
|
||||
"repository": "expo/eas-cli",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/expo/eas-cli.git",
|
||||
"directory": "packages/eas-build-cache-provider"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc --project tsconfig.build.json",
|
||||
"build-allow-unused": "tsc --project tsconfig.allowUnused.json",
|
||||
@@ -50,9 +54,5 @@
|
||||
],
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"volta": {
|
||||
"node": "20.11.0",
|
||||
"yarn": "1.22.21"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
# 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
|
||||
+97
-94
@@ -176,7 +176,7 @@ ALIASES
|
||||
$ eas login
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/account/login.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/account/login.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/account/login.ts](https://github.com/expo/eas-cli/blob/v16.30.0/packages/eas-cli/src/commands/account/login.ts)_
|
||||
|
||||
## `eas account:logout`
|
||||
|
||||
@@ -193,7 +193,7 @@ ALIASES
|
||||
$ eas logout
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/account/logout.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/account/logout.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/account/logout.ts](https://github.com/expo/eas-cli/blob/v16.30.0/packages/eas-cli/src/commands/account/logout.ts)_
|
||||
|
||||
## `eas account:view`
|
||||
|
||||
@@ -210,7 +210,7 @@ ALIASES
|
||||
$ eas whoami
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/account/view.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/account/view.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/account/view.ts](https://github.com/expo/eas-cli/blob/v16.30.0/packages/eas-cli/src/commands/account/view.ts)_
|
||||
|
||||
## `eas analytics [STATUS]`
|
||||
|
||||
@@ -224,7 +224,7 @@ DESCRIPTION
|
||||
display or change analytics settings
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/analytics.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/analytics.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/analytics.ts](https://github.com/expo/eas-cli/blob/v16.30.0/packages/eas-cli/src/commands/analytics.ts)_
|
||||
|
||||
## `eas autocomplete [SHELL]`
|
||||
|
||||
@@ -276,7 +276,7 @@ DESCRIPTION
|
||||
create a branch
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/branch/create.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/branch/create.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/branch/create.ts](https://github.com/expo/eas-cli/blob/v16.30.0/packages/eas-cli/src/commands/branch/create.ts)_
|
||||
|
||||
## `eas branch:delete [NAME]`
|
||||
|
||||
@@ -297,7 +297,7 @@ DESCRIPTION
|
||||
delete a branch
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/branch/delete.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/branch/delete.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/branch/delete.ts](https://github.com/expo/eas-cli/blob/v16.30.0/packages/eas-cli/src/commands/branch/delete.ts)_
|
||||
|
||||
## `eas branch:list`
|
||||
|
||||
@@ -317,7 +317,7 @@ DESCRIPTION
|
||||
list all branches
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/branch/list.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/branch/list.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/branch/list.ts](https://github.com/expo/eas-cli/blob/v16.30.0/packages/eas-cli/src/commands/branch/list.ts)_
|
||||
|
||||
## `eas branch:rename`
|
||||
|
||||
@@ -337,7 +337,7 @@ DESCRIPTION
|
||||
rename a branch
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/branch/rename.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/branch/rename.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/branch/rename.ts](https://github.com/expo/eas-cli/blob/v16.30.0/packages/eas-cli/src/commands/branch/rename.ts)_
|
||||
|
||||
## `eas branch:view [NAME]`
|
||||
|
||||
@@ -360,7 +360,7 @@ DESCRIPTION
|
||||
view a branch
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/branch/view.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/branch/view.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/branch/view.ts](https://github.com/expo/eas-cli/blob/v16.30.0/packages/eas-cli/src/commands/branch/view.ts)_
|
||||
|
||||
## `eas build`
|
||||
|
||||
@@ -401,7 +401,7 @@ DESCRIPTION
|
||||
start a build
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/build/index.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/build/index.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/build/index.ts](https://github.com/expo/eas-cli/blob/v16.30.0/packages/eas-cli/src/commands/build/index.ts)_
|
||||
|
||||
## `eas build:cancel [BUILD_ID]`
|
||||
|
||||
@@ -420,7 +420,7 @@ DESCRIPTION
|
||||
cancel a build
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/build/cancel.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/build/cancel.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/build/cancel.ts](https://github.com/expo/eas-cli/blob/v16.30.0/packages/eas-cli/src/commands/build/cancel.ts)_
|
||||
|
||||
## `eas build:configure`
|
||||
|
||||
@@ -437,7 +437,7 @@ DESCRIPTION
|
||||
configure the project to support EAS Build
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/build/configure.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/build/configure.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/build/configure.ts](https://github.com/expo/eas-cli/blob/v16.30.0/packages/eas-cli/src/commands/build/configure.ts)_
|
||||
|
||||
## `eas build:delete [BUILD_ID]`
|
||||
|
||||
@@ -456,7 +456,7 @@ DESCRIPTION
|
||||
delete a build
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/build/delete.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/build/delete.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/build/delete.ts](https://github.com/expo/eas-cli/blob/v16.30.0/packages/eas-cli/src/commands/build/delete.ts)_
|
||||
|
||||
## `eas build:dev`
|
||||
|
||||
@@ -476,7 +476,7 @@ DESCRIPTION
|
||||
run dev client simulator/emulator build with matching fingerprint or create a new one
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/build/dev.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/build/dev.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/build/dev.ts](https://github.com/expo/eas-cli/blob/v16.30.0/packages/eas-cli/src/commands/build/dev.ts)_
|
||||
|
||||
## `eas build:download`
|
||||
|
||||
@@ -497,7 +497,7 @@ DESCRIPTION
|
||||
download simulator/emulator builds for a given fingerprint hash
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/build/download.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/build/download.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/build/download.ts](https://github.com/expo/eas-cli/blob/v16.30.0/packages/eas-cli/src/commands/build/download.ts)_
|
||||
|
||||
## `eas build:inspect`
|
||||
|
||||
@@ -532,7 +532,7 @@ DESCRIPTION
|
||||
inspect the state of the project at specific build stages, useful for troubleshooting
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/build/inspect.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/build/inspect.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/build/inspect.ts](https://github.com/expo/eas-cli/blob/v16.30.0/packages/eas-cli/src/commands/build/inspect.ts)_
|
||||
|
||||
## `eas build:list`
|
||||
|
||||
@@ -584,7 +584,7 @@ DESCRIPTION
|
||||
list all builds for your project
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/build/list.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/build/list.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/build/list.ts](https://github.com/expo/eas-cli/blob/v16.30.0/packages/eas-cli/src/commands/build/list.ts)_
|
||||
|
||||
## `eas build:resign`
|
||||
|
||||
@@ -613,7 +613,7 @@ DESCRIPTION
|
||||
re-sign a build archive
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/build/resign.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/build/resign.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/build/resign.ts](https://github.com/expo/eas-cli/blob/v16.30.0/packages/eas-cli/src/commands/build/resign.ts)_
|
||||
|
||||
## `eas build:run`
|
||||
|
||||
@@ -639,7 +639,7 @@ DESCRIPTION
|
||||
run simulator/emulator builds from eas-cli
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/build/run.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/build/run.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/build/run.ts](https://github.com/expo/eas-cli/blob/v16.30.0/packages/eas-cli/src/commands/build/run.ts)_
|
||||
|
||||
## `eas build:submit`
|
||||
|
||||
@@ -693,7 +693,7 @@ DESCRIPTION
|
||||
get the latest version from EAS servers
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/build/version/get.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/build/version/get.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/build/version/get.ts](https://github.com/expo/eas-cli/blob/v16.30.0/packages/eas-cli/src/commands/build/version/get.ts)_
|
||||
|
||||
## `eas build:version:set`
|
||||
|
||||
@@ -712,7 +712,7 @@ DESCRIPTION
|
||||
update version of an app
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/build/version/set.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/build/version/set.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/build/version/set.ts](https://github.com/expo/eas-cli/blob/v16.30.0/packages/eas-cli/src/commands/build/version/set.ts)_
|
||||
|
||||
## `eas build:version:sync`
|
||||
|
||||
@@ -731,7 +731,7 @@ DESCRIPTION
|
||||
update a version in native code with a value stored on EAS servers
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/build/version/sync.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/build/version/sync.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/build/version/sync.ts](https://github.com/expo/eas-cli/blob/v16.30.0/packages/eas-cli/src/commands/build/version/sync.ts)_
|
||||
|
||||
## `eas build:view [BUILD_ID]`
|
||||
|
||||
@@ -748,7 +748,7 @@ DESCRIPTION
|
||||
view a build for your project
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/build/view.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/build/view.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/build/view.ts](https://github.com/expo/eas-cli/blob/v16.30.0/packages/eas-cli/src/commands/build/view.ts)_
|
||||
|
||||
## `eas channel:create [NAME]`
|
||||
|
||||
@@ -769,7 +769,7 @@ DESCRIPTION
|
||||
create a channel
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/channel/create.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/channel/create.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/channel/create.ts](https://github.com/expo/eas-cli/blob/v16.30.0/packages/eas-cli/src/commands/channel/create.ts)_
|
||||
|
||||
## `eas channel:delete [NAME]`
|
||||
|
||||
@@ -790,7 +790,7 @@ DESCRIPTION
|
||||
Delete a channel
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/channel/delete.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/channel/delete.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/channel/delete.ts](https://github.com/expo/eas-cli/blob/v16.30.0/packages/eas-cli/src/commands/channel/delete.ts)_
|
||||
|
||||
## `eas channel:edit [NAME]`
|
||||
|
||||
@@ -812,7 +812,7 @@ DESCRIPTION
|
||||
point a channel at a new branch
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/channel/edit.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/channel/edit.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/channel/edit.ts](https://github.com/expo/eas-cli/blob/v16.30.0/packages/eas-cli/src/commands/channel/edit.ts)_
|
||||
|
||||
## `eas channel:list`
|
||||
|
||||
@@ -832,7 +832,7 @@ DESCRIPTION
|
||||
list all channels
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/channel/list.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/channel/list.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/channel/list.ts](https://github.com/expo/eas-cli/blob/v16.30.0/packages/eas-cli/src/commands/channel/list.ts)_
|
||||
|
||||
## `eas channel:pause [NAME]`
|
||||
|
||||
@@ -854,7 +854,7 @@ DESCRIPTION
|
||||
pause a channel to stop it from sending updates
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/channel/pause.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/channel/pause.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/channel/pause.ts](https://github.com/expo/eas-cli/blob/v16.30.0/packages/eas-cli/src/commands/channel/pause.ts)_
|
||||
|
||||
## `eas channel:resume [NAME]`
|
||||
|
||||
@@ -876,7 +876,7 @@ DESCRIPTION
|
||||
resume a channel to start sending updates
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/channel/resume.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/channel/resume.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/channel/resume.ts](https://github.com/expo/eas-cli/blob/v16.30.0/packages/eas-cli/src/commands/channel/resume.ts)_
|
||||
|
||||
## `eas channel:rollout [CHANNEL]`
|
||||
|
||||
@@ -909,7 +909,7 @@ DESCRIPTION
|
||||
Roll a new branch out on a channel incrementally.
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/channel/rollout.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/channel/rollout.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/channel/rollout.ts](https://github.com/expo/eas-cli/blob/v16.30.0/packages/eas-cli/src/commands/channel/rollout.ts)_
|
||||
|
||||
## `eas channel:view [NAME]`
|
||||
|
||||
@@ -932,7 +932,7 @@ DESCRIPTION
|
||||
view a channel
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/channel/view.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/channel/view.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/channel/view.ts](https://github.com/expo/eas-cli/blob/v16.30.0/packages/eas-cli/src/commands/channel/view.ts)_
|
||||
|
||||
## `eas config`
|
||||
|
||||
@@ -953,7 +953,7 @@ DESCRIPTION
|
||||
display project configuration (app.json + eas.json)
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/config.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/config.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/config.ts](https://github.com/expo/eas-cli/blob/v16.30.0/packages/eas-cli/src/commands/config.ts)_
|
||||
|
||||
## `eas credentials`
|
||||
|
||||
@@ -970,7 +970,7 @@ DESCRIPTION
|
||||
manage credentials
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/credentials/index.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/credentials/index.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/credentials/index.ts](https://github.com/expo/eas-cli/blob/v16.30.0/packages/eas-cli/src/commands/credentials/index.ts)_
|
||||
|
||||
## `eas credentials:configure-build`
|
||||
|
||||
@@ -988,7 +988,7 @@ DESCRIPTION
|
||||
Set up credentials for building your project.
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/credentials/configure-build.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/credentials/configure-build.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/credentials/configure-build.ts](https://github.com/expo/eas-cli/blob/v16.30.0/packages/eas-cli/src/commands/credentials/configure-build.ts)_
|
||||
|
||||
## `eas deploy [options]`
|
||||
|
||||
@@ -1016,7 +1016,7 @@ ALIASES
|
||||
$ eas worker:deploy
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/deploy/index.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/deploy/index.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/deploy/index.ts](https://github.com/expo/eas-cli/blob/v16.30.0/packages/eas-cli/src/commands/deploy/index.ts)_
|
||||
|
||||
## `eas deploy:alias`
|
||||
|
||||
@@ -1041,7 +1041,7 @@ ALIASES
|
||||
$ eas deploy:promote
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/deploy/alias/index.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/deploy/alias/index.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/deploy/alias/index.ts](https://github.com/expo/eas-cli/blob/v16.30.0/packages/eas-cli/src/commands/deploy/alias/index.ts)_
|
||||
|
||||
## `eas deploy:alias:delete [ALIAS_NAME]`
|
||||
|
||||
@@ -1062,7 +1062,7 @@ ALIASES
|
||||
$ eas worker:alias:delete
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/deploy/alias/delete.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/deploy/alias/delete.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/deploy/alias/delete.ts](https://github.com/expo/eas-cli/blob/v16.30.0/packages/eas-cli/src/commands/deploy/alias/delete.ts)_
|
||||
|
||||
## `eas deploy:delete [DEPLOYMENT_ID]`
|
||||
|
||||
@@ -1083,7 +1083,7 @@ ALIASES
|
||||
$ eas worker:delete
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/deploy/delete.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/deploy/delete.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/deploy/delete.ts](https://github.com/expo/eas-cli/blob/v16.30.0/packages/eas-cli/src/commands/deploy/delete.ts)_
|
||||
|
||||
## `eas deploy:promote`
|
||||
|
||||
@@ -1120,7 +1120,7 @@ DESCRIPTION
|
||||
register new Apple Devices to use for internal distribution
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/device/create.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/device/create.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/device/create.ts](https://github.com/expo/eas-cli/blob/v16.30.0/packages/eas-cli/src/commands/device/create.ts)_
|
||||
|
||||
## `eas device:delete`
|
||||
|
||||
@@ -1140,7 +1140,7 @@ DESCRIPTION
|
||||
remove a registered device from your account
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/device/delete.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/device/delete.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/device/delete.ts](https://github.com/expo/eas-cli/blob/v16.30.0/packages/eas-cli/src/commands/device/delete.ts)_
|
||||
|
||||
## `eas device:list`
|
||||
|
||||
@@ -1161,7 +1161,7 @@ DESCRIPTION
|
||||
list all registered devices for your account
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/device/list.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/device/list.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/device/list.ts](https://github.com/expo/eas-cli/blob/v16.30.0/packages/eas-cli/src/commands/device/list.ts)_
|
||||
|
||||
## `eas device:rename`
|
||||
|
||||
@@ -1182,7 +1182,7 @@ DESCRIPTION
|
||||
rename a registered device
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/device/rename.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/device/rename.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/device/rename.ts](https://github.com/expo/eas-cli/blob/v16.30.0/packages/eas-cli/src/commands/device/rename.ts)_
|
||||
|
||||
## `eas device:view [UDID]`
|
||||
|
||||
@@ -1196,7 +1196,7 @@ DESCRIPTION
|
||||
view a device for your project
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/device/view.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/device/view.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/device/view.ts](https://github.com/expo/eas-cli/blob/v16.30.0/packages/eas-cli/src/commands/device/view.ts)_
|
||||
|
||||
## `eas diagnostics`
|
||||
|
||||
@@ -1210,7 +1210,7 @@ DESCRIPTION
|
||||
display environment info
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/diagnostics.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/diagnostics.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/diagnostics.ts](https://github.com/expo/eas-cli/blob/v16.30.0/packages/eas-cli/src/commands/diagnostics.ts)_
|
||||
|
||||
## `eas env:create [ENVIRONMENT]`
|
||||
|
||||
@@ -1240,7 +1240,7 @@ DESCRIPTION
|
||||
create an environment variable for the current project or account
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/env/create.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/env/create.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/env/create.ts](https://github.com/expo/eas-cli/blob/v16.30.0/packages/eas-cli/src/commands/env/create.ts)_
|
||||
|
||||
## `eas env:delete [ENVIRONMENT]`
|
||||
|
||||
@@ -1265,7 +1265,7 @@ DESCRIPTION
|
||||
delete an environment variable for the current project or account
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/env/delete.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/env/delete.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/env/delete.ts](https://github.com/expo/eas-cli/blob/v16.30.0/packages/eas-cli/src/commands/env/delete.ts)_
|
||||
|
||||
## `eas env:exec ENVIRONMENT BASH_COMMAND`
|
||||
|
||||
@@ -1287,7 +1287,7 @@ DESCRIPTION
|
||||
execute a command with environment variables from the selected environment
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/env/exec.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/env/exec.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/env/exec.ts](https://github.com/expo/eas-cli/blob/v16.30.0/packages/eas-cli/src/commands/env/exec.ts)_
|
||||
|
||||
## `eas env:get [ENVIRONMENT]`
|
||||
|
||||
@@ -1312,7 +1312,7 @@ DESCRIPTION
|
||||
view an environment variable for the current project or account
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/env/get.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/env/get.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/env/get.ts](https://github.com/expo/eas-cli/blob/v16.30.0/packages/eas-cli/src/commands/env/get.ts)_
|
||||
|
||||
## `eas env:list [ENVIRONMENT]`
|
||||
|
||||
@@ -1338,7 +1338,7 @@ DESCRIPTION
|
||||
list environment variables for the current project or account
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/env/list.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/env/list.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/env/list.ts](https://github.com/expo/eas-cli/blob/v16.30.0/packages/eas-cli/src/commands/env/list.ts)_
|
||||
|
||||
## `eas env:pull [ENVIRONMENT]`
|
||||
|
||||
@@ -1360,7 +1360,7 @@ DESCRIPTION
|
||||
pull environment variables for the selected environment to .env file
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/env/pull.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/env/pull.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/env/pull.ts](https://github.com/expo/eas-cli/blob/v16.30.0/packages/eas-cli/src/commands/env/pull.ts)_
|
||||
|
||||
## `eas env:push [ENVIRONMENT]`
|
||||
|
||||
@@ -1382,7 +1382,7 @@ DESCRIPTION
|
||||
push environment variables from .env file to the selected environment
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/env/push.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/env/push.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/env/push.ts](https://github.com/expo/eas-cli/blob/v16.30.0/packages/eas-cli/src/commands/env/push.ts)_
|
||||
|
||||
## `eas env:update [ENVIRONMENT]`
|
||||
|
||||
@@ -1414,7 +1414,7 @@ DESCRIPTION
|
||||
update an environment variable on the current project or account
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/env/update.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/env/update.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/env/update.ts](https://github.com/expo/eas-cli/blob/v16.30.0/packages/eas-cli/src/commands/env/update.ts)_
|
||||
|
||||
## `eas fingerprint:compare [HASH1] [HASH2]`
|
||||
|
||||
@@ -1458,7 +1458,7 @@ EXAMPLES
|
||||
$ eas fingerprint:compare <FINGERPRINT-HASH> --update-id <UPDATE-ID> # Compare fingerprint from update against provided fingerprint
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/fingerprint/compare.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/fingerprint/compare.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/fingerprint/compare.ts](https://github.com/expo/eas-cli/blob/v16.30.0/packages/eas-cli/src/commands/fingerprint/compare.ts)_
|
||||
|
||||
## `eas fingerprint:generate`
|
||||
|
||||
@@ -1488,7 +1488,7 @@ EXAMPLES
|
||||
$ eas fingerprint:generate --json --non-interactive --platform android # Output fingerprint json to stdout
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/fingerprint/generate.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/fingerprint/generate.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/fingerprint/generate.ts](https://github.com/expo/eas-cli/blob/v16.30.0/packages/eas-cli/src/commands/fingerprint/generate.ts)_
|
||||
|
||||
## `eas help [COMMAND]`
|
||||
|
||||
@@ -1596,7 +1596,7 @@ DESCRIPTION
|
||||
validate the local store configuration
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/metadata/lint.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/metadata/lint.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/metadata/lint.ts](https://github.com/expo/eas-cli/blob/v16.30.0/packages/eas-cli/src/commands/metadata/lint.ts)_
|
||||
|
||||
## `eas metadata:pull`
|
||||
|
||||
@@ -1613,7 +1613,7 @@ DESCRIPTION
|
||||
generate the local store configuration from the app stores
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/metadata/pull.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/metadata/pull.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/metadata/pull.ts](https://github.com/expo/eas-cli/blob/v16.30.0/packages/eas-cli/src/commands/metadata/pull.ts)_
|
||||
|
||||
## `eas metadata:push`
|
||||
|
||||
@@ -1630,7 +1630,7 @@ DESCRIPTION
|
||||
sync the local store configuration to the app stores
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/metadata/push.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/metadata/push.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/metadata/push.ts](https://github.com/expo/eas-cli/blob/v16.30.0/packages/eas-cli/src/commands/metadata/push.ts)_
|
||||
|
||||
## `eas new [PATH]`
|
||||
|
||||
@@ -1681,7 +1681,7 @@ DESCRIPTION
|
||||
open the project page in a web browser
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/open.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/open.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/open.ts](https://github.com/expo/eas-cli/blob/v16.30.0/packages/eas-cli/src/commands/open.ts)_
|
||||
|
||||
## `eas project:info`
|
||||
|
||||
@@ -1695,7 +1695,7 @@ DESCRIPTION
|
||||
information about the current project
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/project/info.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/project/info.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/project/info.ts](https://github.com/expo/eas-cli/blob/v16.30.0/packages/eas-cli/src/commands/project/info.ts)_
|
||||
|
||||
## `eas project:init`
|
||||
|
||||
@@ -1718,7 +1718,7 @@ ALIASES
|
||||
$ eas init
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/project/init.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/project/init.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/project/init.ts](https://github.com/expo/eas-cli/blob/v16.30.0/packages/eas-cli/src/commands/project/init.ts)_
|
||||
|
||||
## `eas project:new [PATH]`
|
||||
|
||||
@@ -1741,7 +1741,7 @@ ALIASES
|
||||
$ eas new
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/project/new.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/project/new.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/project/new.ts](https://github.com/expo/eas-cli/blob/v16.30.0/packages/eas-cli/src/commands/project/new.ts)_
|
||||
|
||||
## `eas project:onboarding [TARGET_PROJECT_DIRECTORY]`
|
||||
|
||||
@@ -1759,7 +1759,7 @@ ALIASES
|
||||
$ eas onboarding
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/project/onboarding.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/project/onboarding.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/project/onboarding.ts](https://github.com/expo/eas-cli/blob/v16.30.0/packages/eas-cli/src/commands/project/onboarding.ts)_
|
||||
|
||||
## `eas submit`
|
||||
|
||||
@@ -1794,7 +1794,7 @@ ALIASES
|
||||
$ eas build:submit
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/submit.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/submit.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/submit.ts](https://github.com/expo/eas-cli/blob/v16.30.0/packages/eas-cli/src/commands/submit.ts)_
|
||||
|
||||
## `eas update`
|
||||
|
||||
@@ -1834,7 +1834,7 @@ DESCRIPTION
|
||||
publish an update group
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/update/index.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/update/index.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/update/index.ts](https://github.com/expo/eas-cli/blob/v16.30.0/packages/eas-cli/src/commands/update/index.ts)_
|
||||
|
||||
## `eas update:configure`
|
||||
|
||||
@@ -1854,7 +1854,7 @@ DESCRIPTION
|
||||
configure the project to support EAS Update
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/update/configure.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/update/configure.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/update/configure.ts](https://github.com/expo/eas-cli/blob/v16.30.0/packages/eas-cli/src/commands/update/configure.ts)_
|
||||
|
||||
## `eas update:delete GROUPID`
|
||||
|
||||
@@ -1875,7 +1875,7 @@ DESCRIPTION
|
||||
delete all the updates in an update group
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/update/delete.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/update/delete.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/update/delete.ts](https://github.com/expo/eas-cli/blob/v16.30.0/packages/eas-cli/src/commands/update/delete.ts)_
|
||||
|
||||
## `eas update:edit [GROUPID]`
|
||||
|
||||
@@ -1899,7 +1899,7 @@ DESCRIPTION
|
||||
edit all the updates in an update group
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/update/edit.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/update/edit.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/update/edit.ts](https://github.com/expo/eas-cli/blob/v16.30.0/packages/eas-cli/src/commands/update/edit.ts)_
|
||||
|
||||
## `eas update:list`
|
||||
|
||||
@@ -1907,21 +1907,24 @@ view the recent updates
|
||||
|
||||
```
|
||||
USAGE
|
||||
$ eas update:list [--branch <value> | --all] [--offset <value>] [--limit <value>] [--json --non-interactive]
|
||||
$ eas update:list [--branch <value> | --all] [-p android|ios|all] [--runtime-version <value>] [--offset
|
||||
<value>] [--limit <value>] [--json --non-interactive]
|
||||
|
||||
FLAGS
|
||||
--all List updates on all branches
|
||||
--branch=<value> List updates only on this branch
|
||||
--json Enable JSON output, non-JSON messages will be printed to stderr.
|
||||
--limit=<value> The number of items to fetch each query. Defaults to 25 and is capped at 50.
|
||||
--non-interactive Run the command in non-interactive mode.
|
||||
--offset=<value> Start queries from specified index. Use for paginating results. Defaults to 0.
|
||||
-p, --platform=(android|ios|all) Filter updates by platform
|
||||
--all List updates on all branches
|
||||
--branch=<value> List updates only on this branch
|
||||
--json Enable JSON output, non-JSON messages will be printed to stderr.
|
||||
--limit=<value> The number of items to fetch each query. Defaults to 25 and is capped at 50.
|
||||
--non-interactive Run the command in non-interactive mode.
|
||||
--offset=<value> Start queries from specified index. Use for paginating results. Defaults to 0.
|
||||
--runtime-version=<value> Filter updates by runtime version
|
||||
|
||||
DESCRIPTION
|
||||
view the recent updates
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/update/list.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/update/list.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/update/list.ts](https://github.com/expo/eas-cli/blob/v16.30.0/packages/eas-cli/src/commands/update/list.ts)_
|
||||
|
||||
## `eas update:republish`
|
||||
|
||||
@@ -1957,7 +1960,7 @@ DESCRIPTION
|
||||
roll back to an existing update
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/update/republish.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/update/republish.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/update/republish.ts](https://github.com/expo/eas-cli/blob/v16.30.0/packages/eas-cli/src/commands/update/republish.ts)_
|
||||
|
||||
## `eas update:revert-update-rollout`
|
||||
|
||||
@@ -1984,7 +1987,7 @@ DESCRIPTION
|
||||
revert a rollout update for a project
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/update/revert-update-rollout.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/update/revert-update-rollout.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/update/revert-update-rollout.ts](https://github.com/expo/eas-cli/blob/v16.30.0/packages/eas-cli/src/commands/update/revert-update-rollout.ts)_
|
||||
|
||||
## `eas update:roll-back-to-embedded`
|
||||
|
||||
@@ -2012,7 +2015,7 @@ DESCRIPTION
|
||||
roll back to the embedded update
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/update/roll-back-to-embedded.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/update/roll-back-to-embedded.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/update/roll-back-to-embedded.ts](https://github.com/expo/eas-cli/blob/v16.30.0/packages/eas-cli/src/commands/update/roll-back-to-embedded.ts)_
|
||||
|
||||
## `eas update:rollback`
|
||||
|
||||
@@ -2033,7 +2036,7 @@ DESCRIPTION
|
||||
instead execute "eas update:republish" or "eas update:roll-back-to-embedded".
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/update/rollback.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/update/rollback.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/update/rollback.ts](https://github.com/expo/eas-cli/blob/v16.30.0/packages/eas-cli/src/commands/update/rollback.ts)_
|
||||
|
||||
## `eas update:view GROUPID`
|
||||
|
||||
@@ -2053,7 +2056,7 @@ DESCRIPTION
|
||||
update group details
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/update/view.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/update/view.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/update/view.ts](https://github.com/expo/eas-cli/blob/v16.30.0/packages/eas-cli/src/commands/update/view.ts)_
|
||||
|
||||
## `eas upload`
|
||||
|
||||
@@ -2074,7 +2077,7 @@ DESCRIPTION
|
||||
upload a local build and generate a sharable link
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/upload.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/upload.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/upload.ts](https://github.com/expo/eas-cli/blob/v16.30.0/packages/eas-cli/src/commands/upload.ts)_
|
||||
|
||||
## `eas webhook:create`
|
||||
|
||||
@@ -2095,7 +2098,7 @@ DESCRIPTION
|
||||
create a webhook
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/webhook/create.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/webhook/create.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/webhook/create.ts](https://github.com/expo/eas-cli/blob/v16.30.0/packages/eas-cli/src/commands/webhook/create.ts)_
|
||||
|
||||
## `eas webhook:delete [ID]`
|
||||
|
||||
@@ -2115,7 +2118,7 @@ DESCRIPTION
|
||||
delete a webhook
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/webhook/delete.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/webhook/delete.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/webhook/delete.ts](https://github.com/expo/eas-cli/blob/v16.30.0/packages/eas-cli/src/commands/webhook/delete.ts)_
|
||||
|
||||
## `eas webhook:list`
|
||||
|
||||
@@ -2133,7 +2136,7 @@ DESCRIPTION
|
||||
list webhooks
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/webhook/list.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/webhook/list.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/webhook/list.ts](https://github.com/expo/eas-cli/blob/v16.30.0/packages/eas-cli/src/commands/webhook/list.ts)_
|
||||
|
||||
## `eas webhook:update`
|
||||
|
||||
@@ -2155,7 +2158,7 @@ DESCRIPTION
|
||||
update a webhook
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/webhook/update.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/webhook/update.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/webhook/update.ts](https://github.com/expo/eas-cli/blob/v16.30.0/packages/eas-cli/src/commands/webhook/update.ts)_
|
||||
|
||||
## `eas webhook:view ID`
|
||||
|
||||
@@ -2172,7 +2175,7 @@ DESCRIPTION
|
||||
view a webhook
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/webhook/view.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/webhook/view.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/webhook/view.ts](https://github.com/expo/eas-cli/blob/v16.30.0/packages/eas-cli/src/commands/webhook/view.ts)_
|
||||
|
||||
## `eas whoami`
|
||||
|
||||
@@ -2292,7 +2295,7 @@ DESCRIPTION
|
||||
to cancel.
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/workflow/cancel.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/workflow/cancel.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/workflow/cancel.ts](https://github.com/expo/eas-cli/blob/v16.30.0/packages/eas-cli/src/commands/workflow/cancel.ts)_
|
||||
|
||||
## `eas workflow:create [NAME]`
|
||||
|
||||
@@ -2312,7 +2315,7 @@ DESCRIPTION
|
||||
create a new workflow configuration YAML file
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/workflow/create.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/workflow/create.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/workflow/create.ts](https://github.com/expo/eas-cli/blob/v16.30.0/packages/eas-cli/src/commands/workflow/create.ts)_
|
||||
|
||||
## `eas workflow:logs [ID]`
|
||||
|
||||
@@ -2336,7 +2339,7 @@ DESCRIPTION
|
||||
If no ID is passed in, you will be prompted to select from recent workflow runs for the current project.
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/workflow/logs.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/workflow/logs.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/workflow/logs.ts](https://github.com/expo/eas-cli/blob/v16.30.0/packages/eas-cli/src/commands/workflow/logs.ts)_
|
||||
|
||||
## `eas workflow:run [FILE]`
|
||||
|
||||
@@ -2376,7 +2379,7 @@ FLAG DESCRIPTIONS
|
||||
Exit codes: 0 = success, 11 = failure, 12 = canceled, 13 = wait aborted.
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/workflow/run.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/workflow/run.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/workflow/run.ts](https://github.com/expo/eas-cli/blob/v16.30.0/packages/eas-cli/src/commands/workflow/run.ts)_
|
||||
|
||||
## `eas workflow:runs`
|
||||
|
||||
@@ -2401,7 +2404,7 @@ DESCRIPTION
|
||||
list recent workflow runs for this project, with their IDs, statuses, and timestamps
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/workflow/runs.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/workflow/runs.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/workflow/runs.ts](https://github.com/expo/eas-cli/blob/v16.30.0/packages/eas-cli/src/commands/workflow/runs.ts)_
|
||||
|
||||
## `eas workflow:status [WORKFLOW_RUN_ID]`
|
||||
|
||||
@@ -2429,7 +2432,7 @@ FLAG DESCRIPTIONS
|
||||
Exit codes: 0 = success, 11 = failure, 12 = canceled, 13 = wait aborted.
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/workflow/status.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/workflow/status.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/workflow/status.ts](https://github.com/expo/eas-cli/blob/v16.30.0/packages/eas-cli/src/commands/workflow/status.ts)_
|
||||
|
||||
## `eas workflow:validate PATH`
|
||||
|
||||
@@ -2449,7 +2452,7 @@ DESCRIPTION
|
||||
validate a workflow configuration yaml file
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/workflow/validate.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/workflow/validate.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/workflow/validate.ts](https://github.com/expo/eas-cli/blob/v16.30.0/packages/eas-cli/src/commands/workflow/validate.ts)_
|
||||
|
||||
## `eas workflow:view [ID]`
|
||||
|
||||
@@ -2471,5 +2474,5 @@ DESCRIPTION
|
||||
workflow runs for the current project.
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/workflow/view.ts](https://github.com/expo/eas-cli/blob/v16.28.0/packages/eas-cli/src/commands/workflow/view.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/workflow/view.ts](https://github.com/expo/eas-cli/blob/v16.30.0/packages/eas-cli/src/commands/workflow/view.ts)_
|
||||
<!-- commandsstop -->
|
||||
Generated
+1353
-563
File diff suppressed because it is too large.
Load diff
@@ -1,25 +1,25 @@
|
||||
{
|
||||
"name": "eas-cli",
|
||||
"description": "EAS command line tool",
|
||||
"version": "16.28.0",
|
||||
"version": "16.30.0",
|
||||
"author": "Expo <[email protected]>",
|
||||
"bin": {
|
||||
"eas": "./bin/run"
|
||||
},
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"dependencies": {
|
||||
"@expo/apple-utils": "2.1.12",
|
||||
"@expo/apple-utils": "2.1.13",
|
||||
"@expo/code-signing-certificates": "0.0.5",
|
||||
"@expo/config": "10.0.6",
|
||||
"@expo/config-plugins": "9.0.12",
|
||||
"@expo/eas-build-job": "1.0.243",
|
||||
"@expo/eas-json": "16.25.0",
|
||||
"@expo/eas-json": "16.30.0",
|
||||
"@expo/env": "^1.0.0",
|
||||
"@expo/json-file": "8.3.3",
|
||||
"@expo/logger": "1.0.221",
|
||||
"@expo/multipart-body-parser": "2.0.0",
|
||||
"@expo/osascript": "2.1.4",
|
||||
"@expo/package-manager": "1.7.0",
|
||||
"@expo/package-manager": "1.9.10",
|
||||
"@expo/pkcs12": "0.1.3",
|
||||
"@expo/plist": "0.2.0",
|
||||
"@expo/plugin-help": "5.1.23",
|
||||
@@ -58,6 +58,7 @@
|
||||
"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",
|
||||
@@ -107,6 +108,7 @@
|
||||
"@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",
|
||||
@@ -129,7 +131,7 @@
|
||||
"rimraf": "3.0.2",
|
||||
"ts-mockito": "2.6.1",
|
||||
"ts-node": "10.9.2",
|
||||
"typescript": "5.3.3"
|
||||
"typescript": "5.5.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
@@ -220,7 +222,11 @@
|
||||
"-v"
|
||||
]
|
||||
},
|
||||
"repository": "expo/eas-cli",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/expo/eas-cli.git",
|
||||
"directory": "packages/eas-cli"
|
||||
},
|
||||
"scripts": {
|
||||
"postpack": "rimraf oclif.manifest.json",
|
||||
"prepack": "yarn rebuild && node ./scripts/prepack.js",
|
||||
@@ -238,9 +244,5 @@
|
||||
"verify-graphql-code": "./scripts/verify-graphql.sh",
|
||||
"clean": "rimraf dist build tmp node_modules yarn-error.log",
|
||||
"copy-new-templates": "rimraf build/commandUtils/new/templates && mkdir -p build/commandUtils/new && cp -r src/commandUtils/new/templates build/commandUtils/new"
|
||||
},
|
||||
"volta": {
|
||||
"node": "20.11.0",
|
||||
"yarn": "1.22.21"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
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,19 +1,12 @@
|
||||
import gql from 'graphql-tag';
|
||||
|
||||
import { scheduleChannelDeletionAsync } from '../../channel/delete';
|
||||
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';
|
||||
@@ -77,7 +70,7 @@ export default class ChannelDelete extends EasCommand {
|
||||
if (!nonInteractive) {
|
||||
Log.addNewLineIfNone();
|
||||
Log.warn(
|
||||
`You are about to permanently delete channel: "${channelName}".\nThis action is irreversible.`
|
||||
`You are about to permanently delete channel: "${channelName}". Deleting this channel will also permanently remove all associated builds from your account. \nThis action is irreversible.`
|
||||
);
|
||||
Log.newLine();
|
||||
const confirmed = await toggleConfirmAsync({ message: 'Are you sure you wish to proceed?' });
|
||||
@@ -87,39 +80,14 @@ export default class ChannelDelete extends EasCommand {
|
||||
}
|
||||
}
|
||||
|
||||
const deletionResult = await deleteChannelOnAppAsync(graphqlClient, {
|
||||
channelId,
|
||||
});
|
||||
const receipt = await scheduleChannelDeletionAsync(graphqlClient, { channelId });
|
||||
const successfulReceipt = await pollForBackgroundJobReceiptAsync(graphqlClient, receipt);
|
||||
Log.debug('Deletion result', { successfulReceipt });
|
||||
|
||||
if (jsonFlag) {
|
||||
printJsonOnlyOutput(deletionResult);
|
||||
printJsonOnlyOutput({ id: channelId });
|
||||
} 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
@@ -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,6 +8,7 @@ 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';
|
||||
@@ -276,8 +277,9 @@ export default class ProjectInit extends EasCommand {
|
||||
throw new Error('No account selected for project. Canceling.');
|
||||
}
|
||||
|
||||
const projectName = exp.slug;
|
||||
const projectName = validSlugName(exp.slug); // This filters out invalid characters
|
||||
const projectFullName = `@${accountName}/${projectName}`;
|
||||
validateFullNameAndSlug(projectFullName, projectName);
|
||||
const existingProjectIdOnServer = await findProjectIdByAccountNameAndSlugNullableAsync(
|
||||
graphqlClient,
|
||||
accountName,
|
||||
|
||||
@@ -5,7 +5,7 @@ import nullthrows from 'nullthrows';
|
||||
import path from 'path';
|
||||
import { instance, mock } from 'ts-mockito';
|
||||
|
||||
import UpdatePublish from '..';
|
||||
import UpdatePublish, { preprocessSourceMapsArg } from '..';
|
||||
import { ensureBranchExistsAsync } from '../../../branch/queries';
|
||||
import {
|
||||
DynamicPrivateProjectConfigContextField,
|
||||
@@ -328,3 +328,34 @@ function mockTestExport({
|
||||
|
||||
return { inputDir: exportDir, platforms, runtimeVersion };
|
||||
}
|
||||
|
||||
describe(preprocessSourceMapsArg, () => {
|
||||
it('passes through when --source-maps has a value', () => {
|
||||
const argv = ['--source-maps', 'inline', '--message', 'test'];
|
||||
expect(preprocessSourceMapsArg(argv)).toEqual(argv);
|
||||
});
|
||||
|
||||
it('inserts "true" when --source-maps has no value', () => {
|
||||
// At end of args
|
||||
expect(preprocessSourceMapsArg(['--branch', 'main', '--source-maps'])).toEqual([
|
||||
'--branch',
|
||||
'main',
|
||||
'--source-maps',
|
||||
'true',
|
||||
]);
|
||||
// Followed by another flag
|
||||
expect(preprocessSourceMapsArg(['--source-maps', '--message', 'test'])).toEqual([
|
||||
'--source-maps',
|
||||
'true',
|
||||
'--message',
|
||||
'test',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('hidden flags', () => {
|
||||
it('has --no-bytecode and --source-maps as hidden flags', () => {
|
||||
expect(UpdatePublish.flags['no-bytecode'].hidden).toBe(true);
|
||||
expect(UpdatePublish.flags['source-maps'].hidden).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -168,7 +168,7 @@ export default class UpdateEdit extends EasCommand {
|
||||
return await PublishMutation.setRolloutPercentageAsync(
|
||||
graphqlClient,
|
||||
u.updateId,
|
||||
rolloutPercentageToSet!
|
||||
rolloutPercentageToSet
|
||||
);
|
||||
})
|
||||
);
|
||||
|
||||
@@ -68,6 +68,28 @@ import formatFields from '../../utils/formatFields';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
|
||||
import { maybeWarnAboutEasOutagesAsync } from '../../utils/statuspageService';
|
||||
|
||||
/**
|
||||
* Preprocess argv to handle --source-maps with optional value.
|
||||
* If --source-maps is followed by another flag (starts with -) or end of args,
|
||||
* insert 'true' as the default value.
|
||||
*/
|
||||
export function preprocessSourceMapsArg(argv: string[]): string[] {
|
||||
const result: string[] = [];
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const arg = argv[i];
|
||||
result.push(arg);
|
||||
|
||||
if (arg === '--source-maps') {
|
||||
const nextArg = argv[i + 1];
|
||||
// If no next arg or next arg is a flag, insert 'true' as the value
|
||||
if (nextArg === undefined || nextArg.startsWith('-')) {
|
||||
result.push('true');
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
type RawUpdateFlags = {
|
||||
auto: boolean;
|
||||
branch?: string;
|
||||
@@ -77,6 +99,8 @@ type RawUpdateFlags = {
|
||||
'input-dir': string;
|
||||
'skip-bundler': boolean;
|
||||
'clear-cache': boolean;
|
||||
'no-bytecode': boolean;
|
||||
'source-maps'?: string;
|
||||
'private-key-path'?: string;
|
||||
'emit-metadata': boolean;
|
||||
'rollout-percentage'?: number;
|
||||
@@ -94,6 +118,8 @@ type UpdateFlags = {
|
||||
inputDir: string;
|
||||
skipBundler: boolean;
|
||||
clearCache: boolean;
|
||||
noBytecode: boolean;
|
||||
sourceMaps?: string;
|
||||
privateKeyPath?: string;
|
||||
emitMetadata: boolean;
|
||||
rolloutPercentage?: number;
|
||||
@@ -132,6 +158,16 @@ export default class UpdatePublish extends EasCommand {
|
||||
description: `Clear the bundler cache before publishing`,
|
||||
default: false,
|
||||
}),
|
||||
'no-bytecode': Flags.boolean({
|
||||
description: `Skip generating Hermes bytecode (output plain JavaScript instead)`,
|
||||
default: false,
|
||||
hidden: true,
|
||||
}),
|
||||
'source-maps': Flags.string({
|
||||
description: `Emit source maps. Options: true (default), inline, false`,
|
||||
default: 'true',
|
||||
hidden: true,
|
||||
}),
|
||||
'emit-metadata': Flags.boolean({
|
||||
description: `Emit "eas-update-metadata.json" in the bundle folder with detailed information about the generated updates`,
|
||||
default: false,
|
||||
@@ -169,7 +205,9 @@ export default class UpdatePublish extends EasCommand {
|
||||
};
|
||||
|
||||
async runAsync(): Promise<void> {
|
||||
const { flags: rawFlags } = await this.parse(UpdatePublish);
|
||||
// Preprocess argv to handle --source-maps with optional value
|
||||
const preprocessedArgv = preprocessSourceMapsArg(this.argv);
|
||||
const { flags: rawFlags } = await this.parse(UpdatePublish, preprocessedArgv);
|
||||
const paginatedQueryOptions = getPaginatedQueryOptions(rawFlags);
|
||||
const {
|
||||
auto: autoFlag,
|
||||
@@ -179,6 +217,8 @@ export default class UpdatePublish extends EasCommand {
|
||||
inputDir,
|
||||
skipBundler,
|
||||
clearCache,
|
||||
noBytecode,
|
||||
sourceMaps,
|
||||
privateKeyPath,
|
||||
json: jsonFlag,
|
||||
nonInteractive,
|
||||
@@ -264,6 +304,8 @@ export default class UpdatePublish extends EasCommand {
|
||||
exp,
|
||||
platformFlag: requestedPlatform,
|
||||
clearCache,
|
||||
noBytecode,
|
||||
sourceMaps,
|
||||
extraEnv: maybeServerEnv,
|
||||
});
|
||||
bundleSpinner.succeed('Exported bundle(s)');
|
||||
@@ -755,6 +797,8 @@ export default class UpdatePublish extends EasCommand {
|
||||
inputDir: flags['input-dir'],
|
||||
skipBundler,
|
||||
clearCache: flags['clear-cache'] ? true : !!flags['environment'],
|
||||
noBytecode: flags['no-bytecode'] ?? false,
|
||||
sourceMaps: flags['source-maps'],
|
||||
platform: flags.platform,
|
||||
privateKeyPath: flags['private-key-path'],
|
||||
rolloutPercentage: flags['rollout-percentage'],
|
||||
|
||||
@@ -8,6 +8,8 @@ import {
|
||||
getLimitFlagWithCustomValues,
|
||||
getPaginatedQueryOptions,
|
||||
} from '../../commandUtils/pagination';
|
||||
import { AppPlatform } from '../../graphql/generated';
|
||||
import { RequestedPlatform } from '../../platform';
|
||||
import {
|
||||
listAndRenderUpdateGroupsOnAppAsync,
|
||||
listAndRenderUpdateGroupsOnBranchAsync,
|
||||
@@ -27,6 +29,14 @@ 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,
|
||||
@@ -39,7 +49,13 @@ 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 } = flags;
|
||||
const {
|
||||
branch: branchFlag,
|
||||
all,
|
||||
json: jsonFlag,
|
||||
'non-interactive': nonInteractive,
|
||||
platform: requestedPlatform,
|
||||
} = flags;
|
||||
const {
|
||||
projectId,
|
||||
loggedIn: { graphqlClient },
|
||||
@@ -52,9 +68,16 @@ 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 {
|
||||
@@ -62,6 +85,7 @@ export default class UpdateList extends EasCommand {
|
||||
await listAndRenderUpdateGroupsOnBranchAsync(graphqlClient, {
|
||||
projectId,
|
||||
branchName: branchFlag,
|
||||
filter,
|
||||
paginatedQueryOptions,
|
||||
});
|
||||
} else {
|
||||
@@ -87,9 +111,20 @@ export default class UpdateList extends EasCommand {
|
||||
await listAndRenderUpdateGroupsOnBranchAsync(graphqlClient, {
|
||||
projectId,
|
||||
branchName: selectedBranch.name,
|
||||
filter,
|
||||
paginatedQueryOptions,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const toAppPlatform = (requestedPlatform?: RequestedPlatform): AppPlatform | undefined => {
|
||||
if (!requestedPlatform || requestedPlatform === RequestedPlatform.All) {
|
||||
return undefined;
|
||||
} else if (requestedPlatform === RequestedPlatform.Android) {
|
||||
return AppPlatform.Android;
|
||||
} else {
|
||||
return AppPlatform.Ios;
|
||||
}
|
||||
};
|
||||
@@ -24,7 +24,7 @@ export class SetUpTargetBuildCredentials {
|
||||
constructor(private readonly options: Options) {}
|
||||
|
||||
async runAsync(ctx: CredentialsContext): Promise<IosAppBuildCredentialsFragment> {
|
||||
const { app, entitlements } = this.options;
|
||||
const { app, entitlements, target } = this.options;
|
||||
|
||||
await ctx.bestEffortAppStoreAuthenticateAsync();
|
||||
|
||||
@@ -35,7 +35,11 @@ export class SetUpTargetBuildCredentials {
|
||||
bundleIdentifier: app.bundleIdentifier,
|
||||
projectName: app.projectName,
|
||||
},
|
||||
{ entitlements, usesBroadcastPushNotifications: ctx.usesBroadcastPushNotifications }
|
||||
{
|
||||
entitlements,
|
||||
usesBroadcastPushNotifications: ctx.usesBroadcastPushNotifications,
|
||||
parentBundleIdentifier: target.parentBundleIdentifier,
|
||||
}
|
||||
);
|
||||
}
|
||||
try {
|
||||
|
||||
@@ -11,9 +11,22 @@ import { assertContractMessagesAsync } from './contractMessages';
|
||||
import Log from '../../../log';
|
||||
import { ora } from '../../../ora';
|
||||
|
||||
/**
|
||||
* The entitlement key that indicates a target is an App Clip.
|
||||
* When present, the target requires special bundle ID registration with a parent app relationship.
|
||||
*/
|
||||
const APP_CLIP_PARENT_APPLICATION_IDENTIFIERS_ENTITLEMENT =
|
||||
'com.apple.developer.parent-application-identifiers';
|
||||
|
||||
export interface IosCapabilitiesOptions {
|
||||
entitlements: JSONObject;
|
||||
usesBroadcastPushNotifications: boolean;
|
||||
/**
|
||||
* The bundle identifier of the parent app. Required for App Clips.
|
||||
* App Clips are detected by the presence of the
|
||||
* `com.apple.developer.parent-application-identifiers` entitlement.
|
||||
*/
|
||||
parentBundleIdentifier?: string;
|
||||
}
|
||||
|
||||
export interface AppLookupParams {
|
||||
@@ -43,7 +56,13 @@ export async function ensureBundleIdExistsWithNameAsync(
|
||||
options?: IosCapabilitiesOptions
|
||||
): Promise<void> {
|
||||
const context = getRequestContext(authCtx);
|
||||
const spinner = ora(`Linking bundle identifier ${chalk.dim(bundleIdentifier)}`).start();
|
||||
|
||||
// Detect App Clips by the presence of the parent-application-identifiers entitlement
|
||||
const isAppClip = Boolean(
|
||||
options?.entitlements?.[APP_CLIP_PARENT_APPLICATION_IDENTIFIERS_ENTITLEMENT]
|
||||
);
|
||||
const typeLabel = isAppClip ? 'App Clip bundle identifier' : 'bundle identifier';
|
||||
const spinner = ora(`Linking ${typeLabel} ${chalk.dim(bundleIdentifier)}`).start();
|
||||
|
||||
let bundleId: BundleId | null;
|
||||
try {
|
||||
@@ -51,23 +70,37 @@ export async function ensureBundleIdExistsWithNameAsync(
|
||||
bundleId = await BundleId.findAsync(context, { identifier: bundleIdentifier });
|
||||
|
||||
if (!bundleId) {
|
||||
spinner.text = `Registering bundle identifier ${chalk.dim(bundleIdentifier)}`;
|
||||
// If it doesn't exist, create it
|
||||
bundleId = await BundleId.createAsync(context, {
|
||||
name,
|
||||
identifier: bundleIdentifier,
|
||||
});
|
||||
spinner.text = `Registering ${typeLabel} ${chalk.dim(bundleIdentifier)}`;
|
||||
|
||||
if (isAppClip && options?.parentBundleIdentifier) {
|
||||
// App Clips require the parent bundle ID's opaque ID
|
||||
bundleId = await createAppClipBundleIdAsync(context, {
|
||||
name,
|
||||
bundleIdentifier,
|
||||
parentBundleIdentifier: options.parentBundleIdentifier,
|
||||
});
|
||||
} else {
|
||||
// If it doesn't exist, create it
|
||||
bundleId = await BundleId.createAsync(context, {
|
||||
name,
|
||||
identifier: bundleIdentifier,
|
||||
});
|
||||
}
|
||||
}
|
||||
spinner.succeed(`Bundle identifier registered ${chalk.dim(bundleIdentifier)}`);
|
||||
spinner.succeed(
|
||||
`${isAppClip ? 'App Clip bundle' : 'Bundle'} identifier registered ${chalk.dim(
|
||||
bundleIdentifier
|
||||
)}`
|
||||
);
|
||||
} catch (err: any) {
|
||||
if (err.message.match(/An App ID with Identifier '(.*)' is not available/)) {
|
||||
spinner.fail(
|
||||
`The bundle identifier ${chalk.bold(bundleIdentifier)} is not available to team "${
|
||||
`The ${typeLabel} ${chalk.bold(bundleIdentifier)} is not available to team "${
|
||||
authCtx.team.name
|
||||
}" (${authCtx.team.id}), change it in your app config and try again.`
|
||||
);
|
||||
} else {
|
||||
spinner.fail(`Failed to register bundle identifier ${chalk.dim(bundleIdentifier)}`);
|
||||
spinner.fail(`Failed to register ${typeLabel} ${chalk.dim(bundleIdentifier)}`);
|
||||
|
||||
// Assert contract errors for easier resolution when the user has an expired developer account.
|
||||
if (
|
||||
@@ -93,6 +126,42 @@ export async function ensureBundleIdExistsWithNameAsync(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an App Clip bundle identifier.
|
||||
* App Clips require the parent bundle ID's opaque ID for registration.
|
||||
*/
|
||||
async function createAppClipBundleIdAsync(
|
||||
context: RequestContext,
|
||||
{
|
||||
name,
|
||||
bundleIdentifier,
|
||||
parentBundleIdentifier,
|
||||
}: {
|
||||
name: string;
|
||||
bundleIdentifier: string;
|
||||
parentBundleIdentifier: string;
|
||||
}
|
||||
): Promise<BundleId> {
|
||||
// First, find the parent bundle ID to get its opaque ID
|
||||
const parentBundleId = await BundleId.findAsync(context, {
|
||||
identifier: parentBundleIdentifier,
|
||||
});
|
||||
|
||||
if (!parentBundleId) {
|
||||
throw new Error(
|
||||
`Cannot create App Clip bundle identifier "${bundleIdentifier}" because the parent bundle identifier "${parentBundleIdentifier}" is not registered. ` +
|
||||
`Please ensure the parent app's bundle identifier is registered first.`
|
||||
);
|
||||
}
|
||||
|
||||
// Create the App Clip bundle ID with the parent relationship
|
||||
return await BundleId.createAppClipAsync(context, {
|
||||
name,
|
||||
identifier: bundleIdentifier,
|
||||
parentBundleIdId: parentBundleId.id,
|
||||
});
|
||||
}
|
||||
|
||||
export async function syncCapabilitiesAsync(
|
||||
bundleId: BundleId,
|
||||
{ entitlements, ...rest }: IosCapabilitiesOptions
|
||||
|
||||
@@ -3,7 +3,7 @@ import { ManageIos } from './ManageIos';
|
||||
import { Analytics } from '../../analytics/AnalyticsManager';
|
||||
import { DynamicConfigContextFn } from '../../commandUtils/context/DynamicProjectConfigContextField';
|
||||
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { selectPlatformAsync } from '../../platform';
|
||||
import { selectPlatformWithExitOptionAsync } 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 selectPlatformAsync(this.flagPlatform);
|
||||
const platform = await selectPlatformWithExitOptionAsync(this.flagPlatform);
|
||||
|
||||
if (platform === 'ios') {
|
||||
await new ManageIos(this, process.cwd()).runAsync();
|
||||
|
||||
+189
-100
@@ -691,13 +691,6 @@ 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 */
|
||||
@@ -1388,6 +1381,7 @@ 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'];
|
||||
@@ -1612,6 +1606,12 @@ 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,13 +1983,102 @@ export type AppNotificationPreferenceInput = {
|
||||
type: NotificationType;
|
||||
};
|
||||
|
||||
export type AppNotificationSubscriptionInput = {
|
||||
appId: Scalars['ID']['input'];
|
||||
event: NotificationEvent;
|
||||
type: NotificationType;
|
||||
userId: Scalars['ID']['input'];
|
||||
export type AppObserve = {
|
||||
__typename?: 'AppObserve';
|
||||
byAppVersion: Array<AppVersionMetrics>;
|
||||
events: AppObserveEventsConnection;
|
||||
};
|
||||
|
||||
|
||||
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'
|
||||
@@ -2001,6 +2090,13 @@ 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'];
|
||||
@@ -2273,6 +2369,13 @@ 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 */
|
||||
@@ -2653,6 +2756,7 @@ export type AppleTeam = {
|
||||
appleTeamName?: Maybe<Scalars['String']['output']>;
|
||||
appleTeamType?: Maybe<AppleTeamType>;
|
||||
id: Scalars['ID']['output'];
|
||||
lastDeletionAttemptTime?: Maybe<Scalars['DateTime']['output']>;
|
||||
};
|
||||
|
||||
|
||||
@@ -2685,6 +2789,8 @@ 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;
|
||||
};
|
||||
@@ -2696,6 +2802,11 @@ export type AppleTeamMutationCreateAppleTeamArgs = {
|
||||
};
|
||||
|
||||
|
||||
export type AppleTeamMutationScheduleAppleTeamDeletionArgs = {
|
||||
id: Scalars['ID']['input'];
|
||||
};
|
||||
|
||||
|
||||
export type AppleTeamMutationUpdateAppleTeamArgs = {
|
||||
appleTeamUpdateInput: AppleTeamUpdateInput;
|
||||
id: Scalars['ID']['input'];
|
||||
@@ -2941,7 +3052,8 @@ export enum BackgroundJobResultType {
|
||||
AuditLogsExport = 'AUDIT_LOGS_EXPORT',
|
||||
GithubBuild = 'GITHUB_BUILD',
|
||||
UserAuditLogsExport = 'USER_AUDIT_LOGS_EXPORT',
|
||||
Void = 'VOID'
|
||||
Void = 'VOID',
|
||||
WorkflowsInsightsExport = 'WORKFLOWS_INSIGHTS_EXPORT'
|
||||
}
|
||||
|
||||
export enum BackgroundJobState {
|
||||
@@ -5851,6 +5963,18 @@ 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'];
|
||||
@@ -5902,46 +6026,6 @@ 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'];
|
||||
@@ -6055,6 +6139,13 @@ 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'];
|
||||
@@ -6379,11 +6470,6 @@ 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 */
|
||||
@@ -6416,6 +6502,7 @@ export type RootMutation = {
|
||||
workflowJobApproval: WorkflowJobApprovalMutation;
|
||||
workflowRevision: WorkflowRevisionMutation;
|
||||
workflowRun: WorkflowRunMutation;
|
||||
workflowsInsights: WorkflowsInsightsMutation;
|
||||
};
|
||||
|
||||
|
||||
@@ -6733,8 +6820,6 @@ 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;
|
||||
@@ -6773,12 +6858,6 @@ 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'];
|
||||
@@ -7199,11 +7278,6 @@ export enum SubmissionStatus {
|
||||
InQueue = 'IN_QUEUE'
|
||||
}
|
||||
|
||||
export type SubscribeToNotificationResult = {
|
||||
__typename?: 'SubscribeToNotificationResult';
|
||||
notificationSubscription: NotificationSubscription;
|
||||
};
|
||||
|
||||
export type SubscriptionDetails = {
|
||||
__typename?: 'SubscriptionDetails';
|
||||
addons: Array<AddonDetails>;
|
||||
@@ -7268,11 +7342,6 @@ export type UniqueUsersOverTimeData = {
|
||||
data: LineChartData;
|
||||
};
|
||||
|
||||
export type UnsubscribeFromNotificationResult = {
|
||||
__typename?: 'UnsubscribeFromNotificationResult';
|
||||
notificationSubscription: NotificationSubscription;
|
||||
};
|
||||
|
||||
export type Update = ActivityTimelineProjectActivity & {
|
||||
__typename?: 'Update';
|
||||
activityTimestamp: Scalars['DateTime']['output'];
|
||||
@@ -7325,6 +7394,7 @@ 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;
|
||||
@@ -7420,6 +7490,7 @@ 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;
|
||||
@@ -7450,7 +7521,10 @@ 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 */
|
||||
/**
|
||||
* delete an EAS channel that doesn't point to any branches
|
||||
* @deprecated Use scheduleUpdateChannelDeletion instead
|
||||
*/
|
||||
deleteUpdateChannel: DeleteUpdateChannelResult;
|
||||
/**
|
||||
* Edit an EAS channel.
|
||||
@@ -7463,6 +7537,8 @@ 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;
|
||||
};
|
||||
|
||||
|
||||
@@ -7493,6 +7569,11 @@ export type UpdateChannelMutationResumeUpdateChannelArgs = {
|
||||
channelId: Scalars['ID']['input'];
|
||||
};
|
||||
|
||||
|
||||
export type UpdateChannelMutationScheduleUpdateChannelDeletionArgs = {
|
||||
channelId: Scalars['ID']['input'];
|
||||
};
|
||||
|
||||
export type UpdateChannelRuntimeInsights = {
|
||||
__typename?: 'UpdateChannelRuntimeInsights';
|
||||
cumulativeMetricsOverTime: ChannelRuntimeCumulativeMetricsOverTimeData;
|
||||
@@ -7825,8 +7906,7 @@ export type User = 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>;
|
||||
newEmailPendingVerification?: Maybe<Scalars['String']['output']>;
|
||||
/** Pending UserInvitations for this user. Only resolves for the viewer. */
|
||||
pendingUserInvitations: Array<UserInvitation>;
|
||||
pinnedApps: Array<App>;
|
||||
@@ -7869,12 +7949,6 @@ 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'];
|
||||
@@ -7938,8 +8012,6 @@ 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 */
|
||||
@@ -7977,12 +8049,6 @@ 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'];
|
||||
@@ -9345,6 +9411,7 @@ 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']>;
|
||||
@@ -9461,6 +9528,18 @@ 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>>;
|
||||
@@ -9474,6 +9553,16 @@ 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;
|
||||
@@ -9563,6 +9652,13 @@ 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'];
|
||||
@@ -9601,13 +9697,6 @@ 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'];
|
||||
|
||||
@@ -12,12 +12,6 @@ jest.mock('../../config/writer');
|
||||
|
||||
describe(AgeRatingTask, () => {
|
||||
describe('prepareAsync', () => {
|
||||
it('aborts when version is not loaded', async () => {
|
||||
const promise = new AgeRatingTask().prepareAsync({ context: {} as any });
|
||||
|
||||
await expect(promise).rejects.toThrow('not prepared');
|
||||
});
|
||||
|
||||
it('loads age rating from app version', async () => {
|
||||
const scope = nock('https://api.appstoreconnect.apple.com')
|
||||
.get(`/v1/${AppStoreVersion.type}/stub-id/ageRatingDeclaration`)
|
||||
|
||||
@@ -35,14 +35,16 @@ describe(AppInfoTask, () => {
|
||||
});
|
||||
|
||||
describe('downloadAsync', () => {
|
||||
it('aborts when app info is not loaded', async () => {
|
||||
it('skips when app info is not loaded', async () => {
|
||||
const writer = jest.mocked(new AppleConfigWriter());
|
||||
const promise = new AppInfoTask().downloadAsync({
|
||||
|
||||
await new AppInfoTask().downloadAsync({
|
||||
config: writer,
|
||||
context: { info: undefined, infoLocales: [] } as any,
|
||||
});
|
||||
|
||||
await expect(promise).rejects.toThrow('info not initialized');
|
||||
// Should not call any setter methods when info is not available
|
||||
expect(writer.setCategories).not.toBeCalled();
|
||||
});
|
||||
|
||||
it('sets categories when info is loaded', async () => {
|
||||
|
||||
@@ -11,12 +11,6 @@ jest.mock('../../config/writer');
|
||||
|
||||
describe(AppReviewDetailTask, () => {
|
||||
describe('prepareAsync', () => {
|
||||
it('aborts when version is not loaded', async () => {
|
||||
const promise = new AppReviewDetailTask().prepareAsync({ context: {} as any });
|
||||
|
||||
await expect(promise).rejects.toThrow('version not init');
|
||||
});
|
||||
|
||||
it('loads review details from app version', async () => {
|
||||
const scope = nock('https://api.appstoreconnect.apple.com')
|
||||
.get(`/v1/${AppStoreVersion.type}/stub-id/appStoreReviewDetail`)
|
||||
|
||||
@@ -254,13 +254,16 @@ describe(AppVersionTask, () => {
|
||||
});
|
||||
|
||||
describe('downloadAsync', () => {
|
||||
it('aborts when version is not loaded', async () => {
|
||||
const promise = new AppVersionTask().downloadAsync({
|
||||
config: new AppleConfigWriter(),
|
||||
it('skips when version is not loaded', async () => {
|
||||
const writer = jest.mocked(new AppleConfigWriter());
|
||||
|
||||
await new AppVersionTask().downloadAsync({
|
||||
config: writer,
|
||||
context: { version: undefined } as any,
|
||||
});
|
||||
|
||||
await expect(promise).rejects.toThrow('not initialized');
|
||||
// Should not call any setter methods when version is not available
|
||||
expect(writer.setVersion).not.toBeCalled();
|
||||
});
|
||||
|
||||
it('sets version when loaded', async () => {
|
||||
|
||||
@@ -15,8 +15,7 @@ export class AgeRatingTask extends AppleTask {
|
||||
public name = (): string => 'age rating declarations';
|
||||
|
||||
public async prepareAsync({ context }: TaskPrepareOptions): Promise<void> {
|
||||
assert(context.version, `App version information is not prepared, can't update age rating`);
|
||||
context.ageRating = (await context.version.getAgeRatingDeclarationAsync()) ?? undefined;
|
||||
context.ageRating = (await context.version!.getAgeRatingDeclarationAsync()) ?? undefined;
|
||||
}
|
||||
|
||||
public async downloadAsync({ config, context }: TaskDownloadOptions): Promise<void> {
|
||||
|
||||
@@ -18,19 +18,32 @@ export class AppInfoTask extends AppleTask {
|
||||
public name = (): string => 'app information';
|
||||
|
||||
public async prepareAsync({ context }: TaskPrepareOptions): Promise<void> {
|
||||
const info = await retryIfNullAsync(() => context.app.getEditAppInfoAsync());
|
||||
assert(info, 'Could not resolve the editable app info to update');
|
||||
let info = await retryIfNullAsync(() => context.app.getEditAppInfoAsync());
|
||||
|
||||
// Fallback to live app info if no editable info is found (for metadata:pull)
|
||||
if (!info) {
|
||||
info = await context.app.getLiveAppInfoAsync();
|
||||
}
|
||||
|
||||
if (!info) {
|
||||
// No app info available - this is OK for metadata:pull
|
||||
// when the app hasn't been set up yet
|
||||
return;
|
||||
}
|
||||
|
||||
context.info = info;
|
||||
context.infoLocales = await info.getLocalizationsAsync();
|
||||
}
|
||||
|
||||
public async downloadAsync({ config, context }: TaskDownloadOptions): Promise<void> {
|
||||
assert(context.info, `App info not initialized, can't download info`);
|
||||
if (!context.info) {
|
||||
// No app info available to download - skip silently
|
||||
return;
|
||||
}
|
||||
|
||||
config.setCategories(context.info.attributes);
|
||||
|
||||
for (const locale of context.infoLocales) {
|
||||
for (const locale of context.infoLocales ?? []) {
|
||||
config.setInfoLocale(locale.attributes);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,8 +16,7 @@ export class AppReviewDetailTask extends AppleTask {
|
||||
public name = (): string => 'app review detail';
|
||||
|
||||
public async prepareAsync({ context }: TaskPrepareOptions): Promise<void> {
|
||||
assert(context.version, `App version not initialized, can't download store review details`);
|
||||
context.reviewDetail = (await context.version.getAppStoreReviewDetailAsync()) ?? undefined;
|
||||
context.reviewDetail = (await context.version!.getAppStoreReviewDetailAsync()) ?? undefined;
|
||||
}
|
||||
|
||||
public async downloadAsync({ config, context }: TaskDownloadOptions): Promise<void> {
|
||||
|
||||
@@ -57,7 +57,11 @@ export class AppVersionTask extends AppleTask {
|
||||
this.options
|
||||
);
|
||||
|
||||
assert(version, 'Could not resolve a live or editable app version');
|
||||
if (!version) {
|
||||
// No live or editable version available - this is OK for metadata:pull
|
||||
// when the app hasn't been submitted yet
|
||||
return;
|
||||
}
|
||||
|
||||
context.version = version;
|
||||
context.versionIsFirst = versionIsFirst;
|
||||
@@ -67,13 +71,16 @@ export class AppVersionTask extends AppleTask {
|
||||
}
|
||||
|
||||
public async downloadAsync({ config, context }: TaskDownloadOptions): Promise<void> {
|
||||
assert(context.version, `App version not initialized, can't download version`);
|
||||
if (!context.version) {
|
||||
// No version available to download - skip silently
|
||||
return;
|
||||
}
|
||||
|
||||
config.setVersion(context.version.attributes);
|
||||
config.setVersionReleaseType(context.version.attributes);
|
||||
config.setVersionReleasePhased(context.versionPhasedRelease?.attributes);
|
||||
|
||||
for (const locale of context.versionLocales) {
|
||||
for (const locale of context.versionLocales ?? []) {
|
||||
config.setVersionLocale(locale.attributes);
|
||||
}
|
||||
}
|
||||
@@ -211,6 +218,12 @@ async function resolveVersionAsync(
|
||||
version = await retryIfNullAsync(() => app.getEditAppStoreVersionAsync({ platform }));
|
||||
}
|
||||
|
||||
// Fallback to live version if no editable version is found (for metadata:pull)
|
||||
if (!version) {
|
||||
version = await app.getLiveAppStoreVersionAsync({ platform });
|
||||
versionIsLive = !!version;
|
||||
}
|
||||
|
||||
const versions = await app.getAppStoreVersionsAsync({
|
||||
query: { limit: 2, filter: { platform } },
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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> = {
|
||||
@@ -47,21 +48,42 @@ 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 { resolvedPlatform } = await promptAsync({
|
||||
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({
|
||||
type: 'select',
|
||||
message: 'Select platform',
|
||||
name: 'resolvedPlatform',
|
||||
choices: [
|
||||
{ title: 'Android', value: Platform.ANDROID },
|
||||
{ title: 'iOS', value: Platform.IOS },
|
||||
],
|
||||
choices: platformChoices,
|
||||
});
|
||||
return resolvedPlatform;
|
||||
if (result.resolvedPlatform === 'Exit') {
|
||||
Log.addNewLineIfNone();
|
||||
Log.log('Exiting');
|
||||
process.exit(0);
|
||||
}
|
||||
return result.resolvedPlatform;
|
||||
}
|
||||
|
||||
export function toPlatforms(requestedPlatform: RequestedPlatform): Platform[] {
|
||||
|
||||
@@ -9,9 +9,11 @@ import { AssetMetadataStatus } from '../../graphql/generated';
|
||||
import { PublishMutation } from '../../graphql/mutations/PublishMutation';
|
||||
import { PublishQuery } from '../../graphql/queries/PublishQuery';
|
||||
import { RequestedPlatform } from '../../platform';
|
||||
import { expoCommandAsync } from '../../utils/expoCli';
|
||||
import {
|
||||
MetadataJoi,
|
||||
RawAsset,
|
||||
buildBundlesAsync,
|
||||
buildUnsortedUpdateInfoGroupAsync,
|
||||
collectAssetsAsync,
|
||||
convertAssetToUpdateInfoGroupFormatAsync,
|
||||
@@ -21,6 +23,7 @@ import {
|
||||
getAssetHashFromPath,
|
||||
getBase64URLEncoding,
|
||||
getOriginalPathFromAssetMap,
|
||||
getSourceMapExportCommandArgs,
|
||||
getStorageKey,
|
||||
getStorageKeyForAssetAsync,
|
||||
guessContentTypeFromExtension,
|
||||
@@ -30,6 +33,11 @@ import {
|
||||
|
||||
jest.mock('../../uploads');
|
||||
jest.mock('fs');
|
||||
jest.mock('../../utils/expoCli', () => ({
|
||||
expoCommandAsync: jest.fn(),
|
||||
shouldUseVersionedExpoCLI: jest.fn(() => true),
|
||||
shouldUseVersionedExpoCLIWithExplicitPlatforms: jest.fn(() => true),
|
||||
}));
|
||||
|
||||
const dummyFileBuffer = Buffer.from('dummy-file');
|
||||
|
||||
@@ -702,3 +710,142 @@ describe(uploadAssetsAsync, () => {
|
||||
expect(onAssetUploadResultsChangedFn).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe(getSourceMapExportCommandArgs, () => {
|
||||
it('returns --dump-sourcemap when sourceMaps is undefined, empty array when "false"', () => {
|
||||
expect(getSourceMapExportCommandArgs({ sourceMaps: undefined, sdkVersion: '50.0.0' })).toEqual([
|
||||
'--dump-sourcemap',
|
||||
]);
|
||||
expect(getSourceMapExportCommandArgs({ sourceMaps: 'false', sdkVersion: '55.0.0' })).toEqual(
|
||||
[]
|
||||
);
|
||||
});
|
||||
|
||||
it('returns --source-maps with value only for SDK >= 55', () => {
|
||||
// SDK < 55: no value
|
||||
expect(getSourceMapExportCommandArgs({ sourceMaps: 'inline', sdkVersion: '50.0.0' })).toEqual([
|
||||
'--source-maps',
|
||||
]);
|
||||
// SDK >= 55: includes value
|
||||
expect(getSourceMapExportCommandArgs({ sourceMaps: 'inline', sdkVersion: '55.0.0' })).toEqual([
|
||||
'--source-maps',
|
||||
'inline',
|
||||
]);
|
||||
// "true" never includes a value
|
||||
expect(getSourceMapExportCommandArgs({ sourceMaps: 'true', sdkVersion: '55.0.0' })).toEqual([
|
||||
'--source-maps',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe(buildBundlesAsync, () => {
|
||||
const projectDir = '/test-project';
|
||||
const inputDir = 'dist';
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.mocked(expoCommandAsync).mockClear();
|
||||
await fs.mkdir(projectDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(projectDir, 'package.json'),
|
||||
JSON.stringify({ name: 'test', version: '1.0.0' })
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.remove(projectDir);
|
||||
});
|
||||
|
||||
it('uses --source-maps instead of --dump-sourcemap when provided', async () => {
|
||||
// SDK < 55: --source-maps is a boolean flag, value not supported
|
||||
// When sourceMaps is 'inline' but SDK < 55, pass --source-maps without the value
|
||||
await buildBundlesAsync({
|
||||
projectDir,
|
||||
inputDir,
|
||||
exp: { sdkVersion: '50.0.0' },
|
||||
platformFlag: 'all',
|
||||
sourceMaps: 'inline',
|
||||
});
|
||||
let args = jest.mocked(expoCommandAsync).mock.calls[0][1];
|
||||
expect(args).toContain('--source-maps');
|
||||
expect(args).not.toContain('inline');
|
||||
expect(args).not.toContain('--dump-sourcemap');
|
||||
|
||||
// SDK >= 55: --source-maps supports a value (e.g., 'inline')
|
||||
jest.mocked(expoCommandAsync).mockClear();
|
||||
await buildBundlesAsync({
|
||||
projectDir,
|
||||
inputDir,
|
||||
exp: { sdkVersion: '55.0.0' },
|
||||
platformFlag: 'all',
|
||||
sourceMaps: 'inline',
|
||||
});
|
||||
expect(expoCommandAsync).toHaveBeenCalledWith(
|
||||
projectDir,
|
||||
expect.arrayContaining(['--source-maps', 'inline']),
|
||||
expect.any(Object)
|
||||
);
|
||||
expect(jest.mocked(expoCommandAsync).mock.calls[0][1]).not.toContain('--dump-sourcemap');
|
||||
|
||||
// When sourceMaps is 'true', pass --source-maps without a value (regardless of SDK version)
|
||||
jest.mocked(expoCommandAsync).mockClear();
|
||||
await buildBundlesAsync({
|
||||
projectDir,
|
||||
inputDir,
|
||||
exp: { sdkVersion: '55.0.0' },
|
||||
platformFlag: 'all',
|
||||
sourceMaps: 'true',
|
||||
});
|
||||
args = jest.mocked(expoCommandAsync).mock.calls[0][1];
|
||||
expect(args).toContain('--source-maps');
|
||||
expect(args).not.toContain('true');
|
||||
expect(args).not.toContain('--dump-sourcemap');
|
||||
|
||||
// When sourceMaps is "false", don't pass any source map flags
|
||||
jest.mocked(expoCommandAsync).mockClear();
|
||||
await buildBundlesAsync({
|
||||
projectDir,
|
||||
inputDir,
|
||||
exp: { sdkVersion: '50.0.0' },
|
||||
platformFlag: 'all',
|
||||
sourceMaps: 'false',
|
||||
});
|
||||
expect(jest.mocked(expoCommandAsync).mock.calls[0][1]).not.toContain('--source-maps');
|
||||
expect(jest.mocked(expoCommandAsync).mock.calls[0][1]).not.toContain('--dump-sourcemap');
|
||||
|
||||
// When sourceMaps is undefined, fall back to --dump-sourcemap
|
||||
jest.mocked(expoCommandAsync).mockClear();
|
||||
await buildBundlesAsync({
|
||||
projectDir,
|
||||
inputDir,
|
||||
exp: { sdkVersion: '50.0.0' },
|
||||
platformFlag: 'all',
|
||||
});
|
||||
expect(jest.mocked(expoCommandAsync).mock.calls[0][1]).not.toContain('--source-maps');
|
||||
expect(jest.mocked(expoCommandAsync).mock.calls[0][1]).toContain('--dump-sourcemap');
|
||||
});
|
||||
|
||||
it('passes --no-bytecode only when true', async () => {
|
||||
await buildBundlesAsync({
|
||||
projectDir,
|
||||
inputDir,
|
||||
exp: { sdkVersion: '50.0.0' },
|
||||
platformFlag: 'all',
|
||||
noBytecode: true,
|
||||
});
|
||||
expect(expoCommandAsync).toHaveBeenCalledWith(
|
||||
projectDir,
|
||||
expect.arrayContaining(['--no-bytecode']),
|
||||
expect.any(Object)
|
||||
);
|
||||
|
||||
jest.mocked(expoCommandAsync).mockClear();
|
||||
await buildBundlesAsync({
|
||||
projectDir,
|
||||
inputDir,
|
||||
exp: { sdkVersion: '50.0.0' },
|
||||
platformFlag: 'all',
|
||||
noBytecode: false,
|
||||
});
|
||||
expect(jest.mocked(expoCommandAsync).mock.calls[0][1]).not.toContain('--no-bytecode');
|
||||
});
|
||||
});
|
||||
@@ -11,6 +11,7 @@ import mime from 'mime';
|
||||
import nullthrows from 'nullthrows';
|
||||
import path from 'path';
|
||||
import promiseLimit from 'promise-limit';
|
||||
import semver from 'semver';
|
||||
|
||||
import { maybeUploadFingerprintAsync } from './maybeUploadFingerprintAsync';
|
||||
import { isModernExpoUpdatesCLIWithRuntimeVersionCommandSupportedAsync } from './projectUtils';
|
||||
@@ -208,6 +209,8 @@ export async function buildBundlesAsync({
|
||||
exp,
|
||||
platformFlag,
|
||||
clearCache,
|
||||
noBytecode,
|
||||
sourceMaps,
|
||||
extraEnv,
|
||||
}: {
|
||||
projectDir: string;
|
||||
@@ -215,6 +218,8 @@ export async function buildBundlesAsync({
|
||||
exp: Pick<ExpoConfig, 'sdkVersion' | 'web'>;
|
||||
platformFlag: ExpoCLIExportPlatformFlag;
|
||||
clearCache?: boolean;
|
||||
noBytecode?: boolean;
|
||||
sourceMaps?: string;
|
||||
extraEnv?: Record<string, string | undefined> | undefined;
|
||||
}): Promise<void> {
|
||||
const packageJSON = JsonFile.read(path.resolve(projectDir, 'package.json'));
|
||||
@@ -252,16 +257,19 @@ export async function buildBundlesAsync({
|
||||
? ['--platform', 'ios', '--platform', 'android']
|
||||
: ['--platform', platformFlag];
|
||||
|
||||
const sourceMapArgs = getSourceMapExportCommandArgs({ sourceMaps, sdkVersion: exp.sdkVersion });
|
||||
|
||||
await expoCommandAsync(
|
||||
projectDir,
|
||||
[
|
||||
'export',
|
||||
'--output-dir',
|
||||
inputDir,
|
||||
'--dump-sourcemap',
|
||||
...sourceMapArgs,
|
||||
'--dump-assetmap',
|
||||
...platformArgs,
|
||||
...(clearCache ? ['--clear'] : []),
|
||||
...(noBytecode ? ['--no-bytecode'] : []),
|
||||
],
|
||||
{
|
||||
extraEnv,
|
||||
@@ -280,16 +288,19 @@ export async function buildBundlesAsync({
|
||||
);
|
||||
}
|
||||
|
||||
const sourceMapArgs = getSourceMapExportCommandArgs({ sourceMaps, sdkVersion: exp.sdkVersion });
|
||||
|
||||
await expoCommandAsync(
|
||||
projectDir,
|
||||
[
|
||||
'export',
|
||||
'--output-dir',
|
||||
inputDir,
|
||||
'--dump-sourcemap',
|
||||
...sourceMapArgs,
|
||||
'--dump-assetmap',
|
||||
`--platform=${platformFlag}`,
|
||||
...(clearCache ? ['--clear'] : []),
|
||||
...(noBytecode ? ['--no-bytecode'] : []),
|
||||
],
|
||||
{
|
||||
extraEnv,
|
||||
@@ -1111,3 +1122,34 @@ export async function getUpdateRolloutInfoGroupAsync(
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the command line arguments for source map generation in expo export.
|
||||
*
|
||||
* Uses --source-maps if provided, otherwise falls back to --dump-sourcemap.
|
||||
* SDK 55+ supports --source-maps with a value (e.g., 'inline'), but older SDKs
|
||||
* only support it as a boolean flag. Passing a value to older SDKs causes it
|
||||
* to be parsed as the project root positional argument.
|
||||
*/
|
||||
export function getSourceMapExportCommandArgs({
|
||||
sourceMaps,
|
||||
sdkVersion,
|
||||
}: {
|
||||
sourceMaps: string | undefined;
|
||||
sdkVersion: string | undefined;
|
||||
}): string[] {
|
||||
if (!sourceMaps) {
|
||||
return ['--dump-sourcemap'];
|
||||
}
|
||||
|
||||
if (sourceMaps === 'false') {
|
||||
return [];
|
||||
}
|
||||
|
||||
const supportsSourceMapModes = sdkVersion && semver.satisfies(sdkVersion, '>=55.0.0');
|
||||
if (supportsSourceMapModes && sourceMaps !== 'true') {
|
||||
return ['--source-maps', sourceMaps];
|
||||
}
|
||||
|
||||
return ['--source-maps'];
|
||||
}
|
||||
@@ -36,9 +36,11 @@ export async function listAndRenderUpdateGroupsOnAppAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
{
|
||||
projectId,
|
||||
filter,
|
||||
paginatedQueryOptions,
|
||||
}: {
|
||||
projectId: string;
|
||||
filter?: ViewUpdateGroupsOnAppQueryVariables['filter'];
|
||||
paginatedQueryOptions: PaginatedQueryOptions;
|
||||
}
|
||||
): Promise<void> {
|
||||
@@ -47,6 +49,7 @@ export async function listAndRenderUpdateGroupsOnAppAsync(
|
||||
limit: paginatedQueryOptions.limit ?? UPDATE_GROUPS_LIMIT,
|
||||
offset: paginatedQueryOptions.offset,
|
||||
appId: projectId,
|
||||
filter,
|
||||
});
|
||||
renderUpdateGroupsOnApp({ updateGroups, paginatedQueryOptions });
|
||||
} else {
|
||||
@@ -54,7 +57,7 @@ export async function listAndRenderUpdateGroupsOnAppAsync(
|
||||
limit: paginatedQueryOptions.limit ?? UPDATE_GROUPS_LIMIT,
|
||||
offset: paginatedQueryOptions.offset,
|
||||
queryToPerform: (limit, offset) =>
|
||||
queryUpdateGroupsOnAppAsync(graphqlClient, { limit, offset, appId: projectId }),
|
||||
queryUpdateGroupsOnAppAsync(graphqlClient, { limit, offset, appId: projectId, filter }),
|
||||
promptOptions: {
|
||||
title: 'Load more update groups?',
|
||||
renderListItems: updateGroups => {
|
||||
@@ -70,10 +73,12 @@ export async function listAndRenderUpdateGroupsOnBranchAsync(
|
||||
{
|
||||
projectId,
|
||||
branchName,
|
||||
filter,
|
||||
paginatedQueryOptions,
|
||||
}: {
|
||||
projectId: string;
|
||||
branchName: string;
|
||||
filter?: ViewUpdateGroupsOnBranchQueryVariables['filter'];
|
||||
paginatedQueryOptions: PaginatedQueryOptions;
|
||||
}
|
||||
): Promise<void> {
|
||||
@@ -83,6 +88,7 @@ export async function listAndRenderUpdateGroupsOnBranchAsync(
|
||||
offset: paginatedQueryOptions.offset,
|
||||
appId: projectId,
|
||||
branchName,
|
||||
filter,
|
||||
});
|
||||
renderUpdateGroupsOnBranch({ updateGroups, branchName, paginatedQueryOptions });
|
||||
} else {
|
||||
@@ -95,6 +101,7 @@ 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 = 2137;
|
||||
const TEST_SERVER_PORT = 9999;
|
||||
|
||||
const fixturesPath = path.join(__dirname, 'fixtures');
|
||||
const transparentPngPath = path.join(fixturesPath, 'icon-alpha.png');
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
# 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,7 +1,7 @@
|
||||
{
|
||||
"name": "@expo/eas-json",
|
||||
"description": "A library for interacting with eas.json",
|
||||
"version": "16.25.0",
|
||||
"version": "16.30.0",
|
||||
"author": "Expo <[email protected]>",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"dependencies": {
|
||||
@@ -23,7 +23,7 @@
|
||||
"@types/fs-extra": "11.0.4",
|
||||
"memfs": "3.4.13",
|
||||
"rimraf": "3.0.2",
|
||||
"typescript": "5.3.3"
|
||||
"typescript": "5.5.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
@@ -32,7 +32,11 @@
|
||||
"license": "MIT",
|
||||
"main": "build/index.js",
|
||||
"types": "build/index.d.ts",
|
||||
"repository": "expo/eas-cli",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/expo/eas-cli.git",
|
||||
"directory": "packages/eas-json"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc --project tsconfig.build.json",
|
||||
"build-allow-unused": "tsc --project tsconfig.allowUnused.json",
|
||||
@@ -49,9 +53,5 @@
|
||||
],
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"volta": {
|
||||
"node": "20.11.0",
|
||||
"yarn": "1.22.21"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
/.bundle/
|
||||
/.yardoc
|
||||
/_yardoc/
|
||||
/coverage/
|
||||
/doc/
|
||||
/pkg/
|
||||
/spec/reports/
|
||||
/tmp/
|
||||
|
||||
# rspec failure tracking
|
||||
.rspec_status
|
||||
@@ -0,0 +1,3 @@
|
||||
--format documentation
|
||||
--color
|
||||
--require spec_helper
|
||||
@@ -0,0 +1,8 @@
|
||||
Metrics/BlockLength:
|
||||
Exclude:
|
||||
- "spec/**/*"
|
||||
Lint/AmbiguousBlockAssociation:
|
||||
Exclude:
|
||||
- "spec/**/*"
|
||||
Metrics/MethodLength:
|
||||
Max: 20
|
||||
@@ -0,0 +1,12 @@
|
||||
# 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'
|
||||
@@ -0,0 +1,142 @@
|
||||
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
|
||||
@@ -0,0 +1,8 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'bundler/gem_tasks'
|
||||
require 'rspec/core/rake_task'
|
||||
|
||||
RSpec::Core::RakeTask.new(:spec)
|
||||
|
||||
task default: :spec
|
||||
Executable
+15
@@ -0,0 +1,15 @@
|
||||
#!/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__)
|
||||
Executable
+8
@@ -0,0 +1,8 @@
|
||||
#!/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
|
||||
@@ -0,0 +1,38 @@
|
||||
# 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
|
||||
@@ -0,0 +1,3 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'expo_cocoapods_proxy'
|
||||
@@ -0,0 +1,4 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'expo_cocoapods_proxy/gem_version'
|
||||
require 'expo_cocoapods_proxy/proxy'
|
||||
@@ -0,0 +1,6 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module ExpoCocoaPodsProxy
|
||||
VERSION = '0.1.0'
|
||||
NAME = 'expo-cocoapods-proxy'
|
||||
end
|
||||
@@ -0,0 +1,171 @@
|
||||
# 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
|
||||
@@ -0,0 +1,203 @@
|
||||
# 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
|
||||
@@ -0,0 +1,16 @@
|
||||
# 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
|
||||
@@ -0,0 +1,19 @@
|
||||
#!/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
|
||||
@@ -0,0 +1,44 @@
|
||||
name: Build Worker
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
upload_artifact:
|
||||
description: Whether to upload artifacts
|
||||
type: boolean
|
||||
default: true
|
||||
|
||||
jobs:
|
||||
build_android_worker:
|
||||
name: Build Android Worker
|
||||
runs_on: linux-medium
|
||||
image: latest
|
||||
steps:
|
||||
- uses: eas/checkout
|
||||
|
||||
- name: Build Android worker tarball
|
||||
run: ./package.sh worker-android.tar.gz android
|
||||
|
||||
- name: Upload Android worker tarball as artifact
|
||||
if: ${{ inputs.upload_artifact }}
|
||||
uses: eas/upload_artifact
|
||||
with:
|
||||
name: worker-android-tarball
|
||||
path: packages/worker/worker-android.tar.gz
|
||||
|
||||
build_ios_worker:
|
||||
name: Build iOS Worker
|
||||
runs_on: macos-medium
|
||||
image: latest
|
||||
steps:
|
||||
- uses: eas/checkout
|
||||
|
||||
- name: Build iOS worker tarball
|
||||
run: ./package.sh worker-ios.tar.gz ios
|
||||
|
||||
- name: Upload iOS worker tarball as artifact
|
||||
if: ${{ inputs.upload_artifact }}
|
||||
uses: eas/upload_artifact
|
||||
with:
|
||||
name: worker-ios-tarball
|
||||
path: packages/worker/worker-ios.tar.gz
|
||||
@@ -0,0 +1,9 @@
|
||||
#!/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
|
||||
@@ -0,0 +1,31 @@
|
||||
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',
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,5 @@
|
||||
node_modules/
|
||||
dist/
|
||||
|
||||
workingdir/*
|
||||
!workingdir/.gitkeep
|
||||
@@ -0,0 +1,158 @@
|
||||
# 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)
|
||||
@@ -0,0 +1,44 @@
|
||||
# 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 }}`.
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"expo": {
|
||||
"name": "EAS Worker",
|
||||
"slug": "eas-worker",
|
||||
"platforms": ["ios"],
|
||||
"extra": {
|
||||
"eas": {
|
||||
"projectId": "90798684-1384-4ce4-a73b-9f3fa3e3e368"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
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,
|
||||
};
|
||||
@@ -0,0 +1,10 @@
|
||||
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;
|
||||
@@ -0,0 +1,16 @@
|
||||
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');
|
||||
@@ -0,0 +1,20 @@
|
||||
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(),
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
{
|
||||
"name": "@expo/worker",
|
||||
"version": "16.30.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"start": "yarn watch",
|
||||
"watch": "yarn compile --watch --preserveWatchOutput",
|
||||
"build": "npm run clean && npm run compile",
|
||||
"clean": "rm -rf ./dist",
|
||||
"clean-all": "yarn clean && rm -rf node_modules",
|
||||
"compile": "tsc -p tsconfig.build.json",
|
||||
"prepublishOnly": "npm run build",
|
||||
"test": "yarn test:unit",
|
||||
"test:unit": "./resources/jest --config jest.config.unit.ts",
|
||||
"test:unit:watch": "./resources/jest --config jest.config.unit.ts --watch",
|
||||
"test:integration": "./resources/jest --runInBand --config jest.config.integration.js",
|
||||
"test:integration:watch": "./resources/jest --runInBand --config jest.config.integration.js --watchAll",
|
||||
"typecheck": "tsc"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"dependencies": {
|
||||
"@babel/core": "7.24.7",
|
||||
"@expo/build-tools": "1.0.271",
|
||||
"@expo/config-plugins": "8.0.8",
|
||||
"@expo/config-types": "52.0.1",
|
||||
"@expo/downloader": "1.0.260",
|
||||
"@expo/eas-build-job": "1.0.265",
|
||||
"@expo/logger": "1.0.260",
|
||||
"@expo/results": "1.0.0",
|
||||
"@expo/rudder-sdk-node": "1.1.1",
|
||||
"@expo/spawn-async": "1.7.2",
|
||||
"@expo/steps": "1.0.271",
|
||||
"@expo/template-file": "1.0.260",
|
||||
"@expo/turtle-spawn": "1.0.260",
|
||||
"@hapi/boom": "10.0.1",
|
||||
"@sentry/node": "7.77.0",
|
||||
"check-disk-space": "3.4.0",
|
||||
"fast-glob": "3.3.2",
|
||||
"filesize": "7.0.0",
|
||||
"flatted": "3.2.5",
|
||||
"fs-extra": "11.2.0",
|
||||
"koa": "3.1.1",
|
||||
"koa-body": "7.0.1",
|
||||
"koa-router": "14.0.0",
|
||||
"lodash": "4.17.21",
|
||||
"micromatch": "4.0.8",
|
||||
"node-fetch": "2.7.0",
|
||||
"node-os-utils": "1.3.7",
|
||||
"nullthrows": "1.1.1",
|
||||
"semver": "7.6.3",
|
||||
"tar": "6.2.1",
|
||||
"uuid": "3.4.0",
|
||||
"ws": "8.18.0",
|
||||
"xml2js": "0.6.2",
|
||||
"zod": "4.1.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/babel__core": "7.20.5",
|
||||
"@types/fs-extra": "11.0.4",
|
||||
"@types/koa": "3.0.1",
|
||||
"@types/koa-router": "7.4.9",
|
||||
"@types/lodash": "4.17.4",
|
||||
"@types/micromatch": "4.0.7",
|
||||
"@types/node": "20.14.8",
|
||||
"@types/node-fetch": "2.6.7",
|
||||
"@types/node-os-utils": "1.3.0",
|
||||
"@types/semver": "7.5.8",
|
||||
"@types/supertest": "6.0.3",
|
||||
"@types/tar": "6.1.10",
|
||||
"@types/uuid": "8.3.4",
|
||||
"@types/ws": "8.5.10",
|
||||
"jest": "29.7.0",
|
||||
"memfs": "3.4.1",
|
||||
"supertest": "7.2.2",
|
||||
"ts-jest": "29.1.4",
|
||||
"typescript": "5.5.4"
|
||||
}
|
||||
}
|
||||
Executable
+81
@@ -0,0 +1,81 @@
|
||||
#!/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
|
||||
Executable
+11
@@ -0,0 +1,11 @@
|
||||
#!/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
|
||||
Executable
+12
@@ -0,0 +1,12 @@
|
||||
#!/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 "$@"
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# Links/unlinks local eas-build packages for development.
|
||||
#
|
||||
# Prerequisites:
|
||||
# Run `yarn link` in each package directory in eas-build repo first:
|
||||
# cd /path/to/eas-build
|
||||
# for pkg in build-tools steps eas-build-job downloader logger template-file turtle-spawn; do
|
||||
# (cd packages/$pkg && yarn link)
|
||||
# done
|
||||
#
|
||||
# Usage:
|
||||
# ./resources/link-eas-build-packages.sh # Link all packages
|
||||
# ./resources/link-eas-build-packages.sh --unlink # Unlink and restore npm versions
|
||||
|
||||
set -e
|
||||
|
||||
PACKAGES=(
|
||||
"@expo/build-tools"
|
||||
"@expo/steps"
|
||||
"@expo/eas-build-job"
|
||||
"@expo/downloader"
|
||||
"@expo/logger"
|
||||
"@expo/template-file"
|
||||
"@expo/turtle-spawn"
|
||||
)
|
||||
|
||||
if [[ "$1" == "--unlink" ]]; then
|
||||
CMD="unlink"
|
||||
echo "Unlinking eas-build packages..."
|
||||
else
|
||||
CMD="link"
|
||||
echo "Linking eas-build packages..."
|
||||
fi
|
||||
|
||||
for PACKAGE in "${PACKAGES[@]}"; do
|
||||
echo "yarn $CMD $PACKAGE"
|
||||
yarn $CMD "$PACKAGE"
|
||||
done
|
||||
|
||||
if [[ "$1" == "--unlink" ]]; then
|
||||
echo ""
|
||||
echo "Restoring npm versions..."
|
||||
yarn install --force
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "Done!"
|
||||
@@ -0,0 +1,10 @@
|
||||
#!/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"
|
||||
@@ -0,0 +1,131 @@
|
||||
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),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
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);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,228 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,189 @@
|
||||
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;
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,321 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
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(),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { fs } from 'memfs';
|
||||
|
||||
const fsRealpath = fs.realpath;
|
||||
(fsRealpath as any).native = fsRealpath;
|
||||
|
||||
module.exports = { ...fs, realpath: fsRealpath };
|
||||
@@ -0,0 +1,3 @@
|
||||
import { fs } from 'memfs';
|
||||
|
||||
module.exports = fs.promises;
|
||||
@@ -0,0 +1,11 @@
|
||||
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();
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
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(),
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,164 @@
|
||||
// @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'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,611 @@
|
||||
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}`,
|
||||
},
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,130 @@
|
||||
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}`
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
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/',
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
export enum Environment {
|
||||
DEVELOPMENT = 'development',
|
||||
STAGING = 'staging',
|
||||
TEST = 'test',
|
||||
PRODUCTION = 'production',
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import { BuildContext } from '@expo/build-tools';
|
||||
import { EnvironmentSecretType, Job } from '@expo/eas-build-job';
|
||||
import fs from 'fs-extra';
|
||||
|
||||
import config from './config';
|
||||
import { Environment } from './constants';
|
||||
import { ResourceClass } from './external/turtle';
|
||||
|
||||
const RESOURCE_CLASS_DESCRIPTION: Record<ResourceClass, string> = {
|
||||
[ResourceClass.ANDROID_N2_1_3_12]: 'Intel, 4 vCPUs, 16 GB RAM',
|
||||
[ResourceClass.ANDROID_N2_2_6_24]: 'Intel, 8 vCPUs, 32 GB RAM',
|
||||
[ResourceClass.IOS_M1_4_16]: 'M1, 2 cores, 8 GB RAM',
|
||||
[ResourceClass.IOS_M2_2_8]: 'M2, 2 cores, 8 GB RAM',
|
||||
[ResourceClass.IOS_M2_PRO_4_12]: 'M2 Pro, 4 cores, 12 GB RAM',
|
||||
[ResourceClass.IOS_M4_PRO_5_20]: 'M4 Pro, 5 cores, 20 GB RAM',
|
||||
[ResourceClass.IOS_M4_PRO_10_40]: 'M4 Pro, 10 cores, 40 GB RAM',
|
||||
[ResourceClass.IOS_M2_4_22]: 'M2, 4 cores, 22 GB RAM',
|
||||
[ResourceClass.LINUX_C3D_STANDARD_4]: 'AMD, 4 vCPUs, 16 GB RAM',
|
||||
[ResourceClass.LINUX_C3D_STANDARD_8]: 'AMD, 8 vCPUs, 32 GB RAM',
|
||||
[ResourceClass.LINUX_C4D_STANDARD_4]: 'AMD, 4 vCPUs, 15 GB RAM',
|
||||
[ResourceClass.LINUX_C4D_STANDARD_8]: 'AMD, 8 vCPUs, 31 GB RAM',
|
||||
};
|
||||
|
||||
export function displayWorkerRuntimeInfo(ctx: BuildContext<Job>): void {
|
||||
printVMSpecs(ctx);
|
||||
printImageDescription(ctx);
|
||||
printImageMessage(ctx);
|
||||
printEnvs(ctx);
|
||||
}
|
||||
|
||||
function printVMSpecs(ctx: BuildContext<Job>): void {
|
||||
const { resourceClass } = config;
|
||||
|
||||
if (resourceClass) {
|
||||
const resourceClassDescription = RESOURCE_CLASS_DESCRIPTION[resourceClass];
|
||||
|
||||
if (resourceClassDescription) {
|
||||
ctx.logger.info(resourceClassDescription);
|
||||
} else {
|
||||
ctx.logger.debug(
|
||||
`Resource class is unsupported: ${resourceClass}, valid values: ${Object.keys(
|
||||
RESOURCE_CLASS_DESCRIPTION
|
||||
)}}`
|
||||
);
|
||||
}
|
||||
ctx.logger.info('');
|
||||
}
|
||||
}
|
||||
|
||||
function printImageMessage(ctx: BuildContext<Job>): void {
|
||||
const imageMessage = ctx.metadata?.trackingContext?.imageMessage;
|
||||
if (imageMessage) {
|
||||
ctx.logger.info(imageMessage);
|
||||
}
|
||||
}
|
||||
|
||||
function printImageDescription(ctx: BuildContext<Job>): void {
|
||||
const workerDescriptionPath = config.workerDescriptionPath;
|
||||
if (!fs.existsSync(workerDescriptionPath)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { logger } = ctx;
|
||||
let description = fs.readFileSync(workerDescriptionPath, 'utf8');
|
||||
description = description.replaceAll(
|
||||
'macos-sequoia-15.6.1-xcode-26.1',
|
||||
'macos-sequoia-15.6-xcode-26.1'
|
||||
);
|
||||
description
|
||||
.trim()
|
||||
.split('\n')
|
||||
.forEach(line => {
|
||||
logger.info(line);
|
||||
});
|
||||
}
|
||||
|
||||
function printEnvs(ctx: BuildContext<Job>): void {
|
||||
const { logger, job } = ctx;
|
||||
const publicEnv: Record<string, string> = {};
|
||||
const secretEnv: Record<string, string> = {};
|
||||
const instanceEnv: Record<string, string> = {};
|
||||
|
||||
// skip development and testing to avoid leaking local credentials from envs to bucket
|
||||
if (config.env !== Environment.DEVELOPMENT && config.env !== Environment.TEST) {
|
||||
Object.entries(ctx.env).forEach(([key, value]) => {
|
||||
instanceEnv[key] = value;
|
||||
});
|
||||
}
|
||||
Object.entries(job.builderEnvironment?.env ?? ({} as Record<string, string>)).forEach(
|
||||
([key, value]) => {
|
||||
publicEnv[key] = value;
|
||||
delete instanceEnv[key];
|
||||
}
|
||||
);
|
||||
|
||||
job.secrets?.environmentSecrets?.forEach(({ name, type }) => {
|
||||
if (type === EnvironmentSecretType.FILE) {
|
||||
secretEnv[name] = ctx.env[name];
|
||||
} else {
|
||||
secretEnv[name] = '********';
|
||||
}
|
||||
delete publicEnv[name];
|
||||
delete instanceEnv[name];
|
||||
});
|
||||
|
||||
const hasPublicEnv = Object.keys(publicEnv).length > 0;
|
||||
const hasSecretEnv = Object.keys(secretEnv).length > 0;
|
||||
const hasInstanceEnv = Object.keys(instanceEnv).length > 0;
|
||||
|
||||
if (hasPublicEnv) {
|
||||
logger.info('');
|
||||
logger.info('Project environment variables:');
|
||||
Object.entries(publicEnv).forEach(([key, value]) => {
|
||||
logger.info(` ${key}=${value}`);
|
||||
});
|
||||
}
|
||||
if (hasSecretEnv) {
|
||||
logger.info('');
|
||||
logger.info('Environment secrets:');
|
||||
Object.entries(secretEnv).forEach(([key, value]) => {
|
||||
logger.info(` ${key}=${value}`);
|
||||
});
|
||||
}
|
||||
if (hasInstanceEnv) {
|
||||
logger.info('');
|
||||
logger.info('EAS Build environment variables:');
|
||||
Object.entries(instanceEnv).forEach(([key, value]) => {
|
||||
logger.info(` ${key}=${value}`);
|
||||
});
|
||||
}
|
||||
logger.info('');
|
||||
}
|
||||
Loaded 100 of 130 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user