mirror of
https://github.com/expo/eas-cli.git
synced 2026-11-13 20:28:08 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
60032049ec | ||
|
|
7a63315be5 | ||
|
|
295d49681b | ||
|
|
c93614ec55 | ||
|
|
e8e135431c | ||
|
|
42a487e695 | ||
|
|
2a062dba96 | ||
|
|
f923181857 | ||
|
|
412366f2f6 | ||
|
|
6c57bbd57b | ||
|
|
42cf2cb997 | ||
|
|
e383931af6 | ||
|
|
9827171bf7 | ||
|
|
866febb1ba | ||
|
|
d98d26d962 | ||
|
|
d78adfeb0f | ||
|
|
54f0b5368c | ||
|
|
f30267f05f | ||
|
|
86fc9ebb80 | ||
|
|
94f473b1f3 | ||
|
|
a3a1d84ebd | ||
|
|
eab32ecb9f | ||
|
|
cdf4e811d5 | ||
|
|
d003bffa29 |
No files matched your search
@@ -1,10 +1,9 @@
|
||||
---
|
||||
description: Security and secrets. Injection, credential or secret leakage, unsafe shell/child-process use, missing validation at trust boundaries.
|
||||
alwaysRun: true
|
||||
# No model override: security runs on the config default (gpt-5.5, on the
|
||||
# subscription). To give it a stronger model, use the metered openai-api alias
|
||||
# from the mixed auth.providers setup (see the @expo/code-review-cli README) —
|
||||
# pro-tier models are excluded from the subscription.
|
||||
# Security is the highest-stakes agent, so it runs on the Opus tier (mirrors
|
||||
# the expo/code-review-cli repo's own roster).
|
||||
model: anthropic/claude-opus-5
|
||||
---
|
||||
|
||||
# Security & secrets
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
{
|
||||
// Default model for every pass — served by the ChatGPT/Codex subscription (see
|
||||
// auth below), so reviews have no marginal cost. Per-agent frontmatter can
|
||||
// override this. Override everything at runtime with REVIEWER_MODEL
|
||||
// (e.g. REVIEWER_MODEL=openai/gpt-5.4-mini for a cheaper local run).
|
||||
"model": "openai/gpt-5.5",
|
||||
// Default model for every pass. Anthropic models run through the Claude Code
|
||||
// CLI engine (`claude -p`), inferred per agent from the model id — see the
|
||||
// auth entry below for the credential. Per-agent frontmatter can override
|
||||
// this (security runs Opus). Override everything at runtime with
|
||||
// REVIEWER_MODEL (e.g. REVIEWER_MODEL=anthropic/claude-haiku-4-5 for a
|
||||
// cheaper local run).
|
||||
"model": "anthropic/claude-sonnet-5",
|
||||
|
||||
// Agents are every markdown file in agents/. shared.md (prepended to all) and
|
||||
// coordinator.md are reserved. Per-agent overrides go in each file's frontmatter.
|
||||
@@ -30,17 +32,16 @@
|
||||
"breakGlass": { "marker": "/skip-review" },
|
||||
"commentTag": "expo-ai-code-reviewer",
|
||||
|
||||
// ChatGPT/Codex subscription (OAuth): tokenEnv holds the ACCESS token from an
|
||||
// `opencode auth login` ChatGPT sign-in (`ecr setup-auth` extracts it) — a
|
||||
// plain bearer with no rotation involvement; re-mint before its ~10-day expiry
|
||||
// (doctor + the run preflight warn first). NEVER use the refresh token here:
|
||||
// it is single-use and dies on first rotation. All models above are on the
|
||||
// subscription's allowlist, so reviews draw no metered spend. (To add pro-tier
|
||||
// models later, see the mixed auth.providers setup in the @expo/code-review-cli
|
||||
// README.)
|
||||
// Anthropic models always run through the Claude Code CLI — the engine is
|
||||
// inferred per agent from the `anthropic/…` model id, so this entry only
|
||||
// supplies the credential. The tokenEnv holds the shared review credential
|
||||
// (an `sk-ant-oat…` token from `claude setup-token`, or an `sk-ant-api…`
|
||||
// Console key — the CLI reads either; the engine classifies by shape).
|
||||
// ECR_EXPECTED_TOKEN_ENV (repo variable / workflow fallback) must equal this
|
||||
// tokenEnv. Locally, an active `claude` login is enough.
|
||||
"auth": {
|
||||
"mode": "oauth",
|
||||
"provider": "openai",
|
||||
"tokenEnv": "CODEX_OAUTH_ACCESS_TOKEN"
|
||||
"providers": {
|
||||
"anthropic": { "tokenEnv": "CLAUDE_CODE_REVIEW_SHARED_API_TOKEN" }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
# No model override: the coordinator runs on the config default (gpt-5.5, on the
|
||||
# subscription). It only consolidates text (no repo tools), so the default is
|
||||
# plenty; override here if consolidation quality ever needs a stronger model.
|
||||
# No model override: the coordinator runs on the config default
|
||||
# (claude-sonnet-5). It only consolidates text (no repo tools), so the default
|
||||
# is plenty; override here if consolidation quality ever needs a stronger model.
|
||||
---
|
||||
|
||||
# Coordinator — consolidation & decision
|
||||
|
||||
@@ -19,7 +19,7 @@ permissions:
|
||||
|
||||
env:
|
||||
# Published reviewer run via npx (override with repo variable ECR_VERSION).
|
||||
ECR_VERSION: ${{ vars.ECR_VERSION || '^0.6.0' }}
|
||||
ECR_VERSION: ${{ vars.ECR_VERSION || '^0.10.0' }}
|
||||
|
||||
concurrency:
|
||||
group: ai-code-review-cmd-${{ github.event.issue.number }}
|
||||
@@ -116,13 +116,20 @@ jobs:
|
||||
curl -fsSL https://getunblocked.com/install.sh | bash
|
||||
echo "$HOME/.unblocked/bin" >> "$GITHUB_PATH"
|
||||
|
||||
# The anthropic/… models run through the Claude Code CLI (claude-code
|
||||
# engine). Pinned exactly — see expo-code-review.yml.
|
||||
- name: Install Claude Code CLI
|
||||
if: steps.cmd.outputs.run == 'true'
|
||||
run: npm install -g @anthropic-ai/[email protected]
|
||||
|
||||
- name: Run AI review
|
||||
if: steps.cmd.outputs.run == 'true'
|
||||
continue-on-error: true
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
CODEX_OAUTH_ACCESS_TOKEN: ${{ secrets.CODEX_OAUTH_ACCESS_TOKEN }}
|
||||
ECR_EXPECTED_TOKEN_ENV: ${{ vars.ECR_EXPECTED_TOKEN_ENV || 'CODEX_OAUTH_ACCESS_TOKEN' }}
|
||||
# Shared Anthropic review credential — see expo-code-review.yml.
|
||||
CLAUDE_CODE_REVIEW_SHARED_API_TOKEN: ${{ secrets.CLAUDE_CODE_REVIEW_SHARED_API_TOKEN }}
|
||||
ECR_EXPECTED_TOKEN_ENV: ${{ vars.ECR_EXPECTED_TOKEN_ENV || 'CLAUDE_CODE_REVIEW_SHARED_API_TOKEN' }}
|
||||
# Unblocked CLI headless auth (PAT or team access token). Absent =>
|
||||
# the CLI stays unauthenticated; the review still runs.
|
||||
UNBLOCKED_API_TOKEN: ${{ secrets.UNBLOCKED_API_TOKEN }}
|
||||
|
||||
@@ -18,7 +18,7 @@ permissions:
|
||||
|
||||
env:
|
||||
# Published reviewer run via npx (override with repo variable ECR_VERSION).
|
||||
ECR_VERSION: ${{ vars.ECR_VERSION || '^0.6.0' }}
|
||||
ECR_VERSION: ${{ vars.ECR_VERSION || '^0.10.0' }}
|
||||
|
||||
concurrency:
|
||||
group: ai-code-review-dismiss-${{ github.event.issue.number }}
|
||||
|
||||
@@ -13,9 +13,9 @@ permissions:
|
||||
env:
|
||||
# The reviewer is the published @expo/code-review-cli package, run via npx — the
|
||||
# repo only carries the `.expo-code-review/` config, not the engine source.
|
||||
# Override with a repo variable ECR_VERSION to pin/bump; defaults to 0.6.x
|
||||
# (0.6.0 = trusted base-commit configuration + PR-head runtime-config scrub).
|
||||
ECR_VERSION: ${{ vars.ECR_VERSION || '^0.6.0' }}
|
||||
# Override with a repo variable ECR_VERSION to pin/bump; defaults to 0.10.x
|
||||
# (the claude-code engine the anthropic/… models below run on).
|
||||
ECR_VERSION: ${{ vars.ECR_VERSION || '^0.10.0' }}
|
||||
|
||||
concurrency:
|
||||
group: ai-code-review-${{ github.event.pull_request.number }}
|
||||
@@ -125,6 +125,14 @@ jobs:
|
||||
curl -fsSL https://getunblocked.com/install.sh | bash
|
||||
echo "$HOME/.unblocked/bin" >> "$GITHUB_PATH"
|
||||
|
||||
# The anthropic/… models in .expo-code-review/config.jsonc run through the
|
||||
# Claude Code CLI (the claude-code engine). Pinned exactly — an unpinned
|
||||
# CLI would silently drift on every CI run; bump it deliberately (mirrors
|
||||
# expo/code-review-cli's own workflow).
|
||||
- name: Install Claude Code CLI
|
||||
if: steps.resolve.outputs.run == 'true'
|
||||
run: npm install -g @anthropic-ai/[email protected]
|
||||
|
||||
# SECURITY: the TRUSTED BASE checkout above includes .expo-code-review/
|
||||
# config.jsonc, whose auth.tokenEnv names the env var the CLI forwards as the
|
||||
# model credential. `ecr verify-config` is the canonical guard (ships with the
|
||||
@@ -136,7 +144,7 @@ jobs:
|
||||
- name: Guard config tokenEnv (root + routing + all scopes)
|
||||
if: steps.resolve.outputs.run == 'true'
|
||||
env:
|
||||
ECR_EXPECTED_TOKEN_ENV: ${{ vars.ECR_EXPECTED_TOKEN_ENV || 'CODEX_OAUTH_ACCESS_TOKEN' }}
|
||||
ECR_EXPECTED_TOKEN_ENV: ${{ vars.ECR_EXPECTED_TOKEN_ENV || 'CLAUDE_CODE_REVIEW_SHARED_API_TOKEN' }}
|
||||
run: npx --yes -p "@expo/code-review-cli@$ECR_VERSION" ecr verify-config
|
||||
|
||||
- name: Run AI review
|
||||
@@ -148,14 +156,14 @@ jobs:
|
||||
env:
|
||||
# GitHub token scoped by the permissions block above (PR comments only).
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
# ChatGPT/Codex subscription ACCESS token (auth.mode "oauth" in
|
||||
# .expo-code-review/config.jsonc reads this env var) — used as-is, no
|
||||
# rotation involvement; re-mint before its ~10-day expiry. All reviews
|
||||
# run on the subscription.
|
||||
CODEX_OAUTH_ACCESS_TOKEN: ${{ secrets.CODEX_OAUTH_ACCESS_TOKEN }}
|
||||
# Shared Anthropic review credential (auth.providers.anthropic in
|
||||
# .expo-code-review/config.jsonc reads this env var). The claude-code
|
||||
# engine forwards it to the Claude Code CLI as the only credential —
|
||||
# an `sk-ant-oat…` setup-token or an `sk-ant-api…` Console key both work.
|
||||
CLAUDE_CODE_REVIEW_SHARED_API_TOKEN: ${{ secrets.CLAUDE_CODE_REVIEW_SHARED_API_TOKEN }}
|
||||
# Layer-1 runtime auth lock (mirrors the guard step): the CLI refuses to
|
||||
# run when the tokenEnv the loaded config honors differs from this.
|
||||
ECR_EXPECTED_TOKEN_ENV: ${{ vars.ECR_EXPECTED_TOKEN_ENV || 'CODEX_OAUTH_ACCESS_TOKEN' }}
|
||||
ECR_EXPECTED_TOKEN_ENV: ${{ vars.ECR_EXPECTED_TOKEN_ENV || 'CLAUDE_CODE_REVIEW_SHARED_API_TOKEN' }}
|
||||
# Unblocked CLI headless auth (PAT or team access token). Absent =>
|
||||
# the CLI stays unauthenticated; the review still runs.
|
||||
UNBLOCKED_API_TOKEN: ${{ secrets.UNBLOCKED_API_TOKEN }}
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
# NOTE (2026-08-03): We can no longer add npm dist-tags from CI.
|
||||
# This workflow will fail if run without dry_run. Run the npm dist-tag
|
||||
# commands manually instead until CI tagging is restored.
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
staging_only:
|
||||
type: boolean
|
||||
required: true
|
||||
default: false
|
||||
description: "Only move the latest-eas-build-staging tag, not the latest-eas-build tag"
|
||||
version:
|
||||
type: string
|
||||
required: true
|
||||
description: "Version number to move the tag to"
|
||||
dry_run:
|
||||
type: boolean
|
||||
required: true
|
||||
default: true
|
||||
description: "Run the workflow in dry run mode, without making any NPM changes"
|
||||
|
||||
name: Move NPM tags used by EAS builds and workflows
|
||||
|
||||
jobs:
|
||||
move-tag:
|
||||
name: Move NPM tags used by EAS builds and workflows
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.EXPO_BOT_PAT }}
|
||||
NPM_ACCESS_TOKEN: ${{ secrets.NPM_ACCESS_TOKEN }}
|
||||
INPUT_VERSION: ${{ github.event.inputs.version }}
|
||||
INPUT_DRY_RUN: ${{ github.event.inputs.dry_run }}
|
||||
INPUT_STAGING_ONLY: ${{ github.event.inputs.staging_only }}
|
||||
steps:
|
||||
- uses: actions/setup-node@7c12f8017d5436eb855f1ed4399f037a36fbd9e8 # v2
|
||||
with:
|
||||
registry-url: "https://registry.npmjs.org/"
|
||||
scope: "expo"
|
||||
node-version: 22
|
||||
- name: Resolve version (fails if the version does not exist)
|
||||
run: |
|
||||
set -o pipefail
|
||||
npm pack --dry-run eas-cli@$INPUT_VERSION
|
||||
- name: Add latest-eas-build-staging tag
|
||||
run: |
|
||||
cmd="npm dist-tag add eas-cli@$INPUT_VERSION latest-eas-build-staging"
|
||||
if [[ "$INPUT_DRY_RUN" == "true" ]]; then
|
||||
echo "Running in dry run mode, skipping version check"
|
||||
echo "cmd = $cmd"
|
||||
exit 0
|
||||
fi
|
||||
echo //registry.npmjs.org/:_authToken=$NPM_ACCESS_TOKEN > ~/.npmrc
|
||||
$cmd
|
||||
- name: Add latest-eas-build tag
|
||||
run: |
|
||||
cmd="npm dist-tag add eas-cli@$INPUT_VERSION latest-eas-build"
|
||||
if [[ "$INPUT_DRY_RUN" == "true" ]]; then
|
||||
echo "Running in dry run mode, skipping version check"
|
||||
echo "cmd = $cmd"
|
||||
exit 0
|
||||
fi
|
||||
if [[ "$INPUT_STAGING_ONLY" == "true" ]]; then
|
||||
echo "Only moving the staging tag, skipping the production tag"
|
||||
exit 0
|
||||
fi
|
||||
echo //registry.npmjs.org/:_authToken=$NPM_ACCESS_TOKEN > ~/.npmrc
|
||||
$cmd
|
||||
@@ -0,0 +1,58 @@
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
type: string
|
||||
required: false
|
||||
description: "eas-cli version to set as PRODUCTION in cli-versions.json. Defaults to the current STAGING value."
|
||||
|
||||
name: Promote eas-cli to production
|
||||
|
||||
jobs:
|
||||
promote-production:
|
||||
name: Bump PRODUCTION in cli-versions.json
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
env:
|
||||
INPUT_VERSION: ${{ github.event.inputs.version }}
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
with:
|
||||
token: ${{ secrets.EXPO_BOT_PAT }}
|
||||
- name: Ensure main is checked out
|
||||
run: |
|
||||
git fetch
|
||||
git checkout main
|
||||
- name: Resolve version
|
||||
run: |
|
||||
version="$INPUT_VERSION"
|
||||
if [ -z "$version" ]; then
|
||||
version="$(jq -r .STAGING cli-versions.json)"
|
||||
fi
|
||||
echo "PRODUCTION_VERSION=$version" >> "$GITHUB_ENV"
|
||||
- name: Verify version is an exact, published eas-cli version
|
||||
run: |
|
||||
resolved="$(npm view "eas-cli@$PRODUCTION_VERSION" version 2>/dev/null || true)"
|
||||
if [ "$resolved" != "$PRODUCTION_VERSION" ]; then
|
||||
echo "'$PRODUCTION_VERSION' is not an exact published eas-cli version." >&2
|
||||
echo "npm resolved it to: '${resolved:-<nothing>}'." >&2
|
||||
echo "Provide an exact version such as 21.5.0, not a range or dist-tag." >&2
|
||||
exit 1
|
||||
fi
|
||||
- name: Bump PRODUCTION in cli-versions.json
|
||||
run: |
|
||||
jq --arg version "$PRODUCTION_VERSION" '.PRODUCTION = $version' \
|
||||
cli-versions.json > cli-versions.json.tmp
|
||||
mv cli-versions.json.tmp cli-versions.json
|
||||
- name: Commit and push
|
||||
run: |
|
||||
if git diff --quiet cli-versions.json; then
|
||||
echo "PRODUCTION is already $PRODUCTION_VERSION; nothing to do."
|
||||
exit 0
|
||||
fi
|
||||
git config --global user.email "[email protected]"
|
||||
git config --global user.name "Expo CI"
|
||||
git add cli-versions.json
|
||||
git commit -m "bump PRODUCTION to $PRODUCTION_VERSION in cli-versions.json"
|
||||
git push
|
||||
@@ -37,40 +37,20 @@ jobs:
|
||||
run: yarn build
|
||||
- name: Publish packages to npm
|
||||
run: yarn run -T lerna publish from-package --yes --no-verify-access
|
||||
# Temporarily disabled (2026-08-03): we can no longer add npm dist-tags from CI.
|
||||
# The tags must be added manually. A Slack reminder with the commands is posted below.
|
||||
# - name: Add latest-eas-build-staging tag
|
||||
# env:
|
||||
# NODE_AUTH_TOKEN: ${{ secrets.NPM_ACCESS_TOKEN }}
|
||||
# run: |
|
||||
# VERSION=$(cat lerna.json | jq -r .version)
|
||||
# echo //registry.npmjs.org/:_authToken=$NODE_AUTH_TOKEN > ~/.npmrc
|
||||
# npm dist-tag add eas-cli@$VERSION latest-eas-build-staging
|
||||
# - name: Add latest-eas-build tag
|
||||
# env:
|
||||
# NODE_AUTH_TOKEN: ${{ secrets.NPM_ACCESS_TOKEN }}
|
||||
# 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
|
||||
- name: Resolve version
|
||||
run: echo "EAS_CLI_VERSION=$(jq -r .version lerna.json)" >> $GITHUB_ENV
|
||||
- name: Slack reminder to add npm tags manually
|
||||
uses: rtCamp/action-slack-notify@e31e87e03dd19038e411e38ae27cbad084a90661 # v2
|
||||
env:
|
||||
SLACK_CHANNEL: eas-cli
|
||||
SLACK_COLOR: warning
|
||||
SLACK_ICON: https://github.com/expo-bot.png?size=48
|
||||
SLACK_TITLE: "Manual action needed: add npm tags for eas-cli@${{ env.EAS_CLI_VERSION }}"
|
||||
SLACK_MESSAGE: |
|
||||
Don't forget to add these tags to the npm packages. Run:
|
||||
```
|
||||
npm dist-tag add eas-cli@${{ env.EAS_CLI_VERSION }} latest-eas-build-staging
|
||||
npm dist-tag add eas-cli@${{ env.EAS_CLI_VERSION }} latest-eas-build
|
||||
```
|
||||
SLACK_USERNAME: "EAS CLI CI"
|
||||
SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }}
|
||||
SLACK_FOOTER: ""
|
||||
- name: Verify eas-cli was published as latest on npm
|
||||
run: |
|
||||
expected="$(jq -r .version lerna.json)"
|
||||
for attempt in $(seq 1 10); do
|
||||
published="$(npm view eas-cli@latest version 2>/dev/null || true)"
|
||||
if [ "$published" = "$expected" ]; then
|
||||
echo "Verified eas-cli@$expected is latest on npm."
|
||||
exit 0
|
||||
fi
|
||||
echo "Attempt $attempt: npm latest is '$published', expected '$expected'. Retrying in 15s..."
|
||||
sleep 15
|
||||
done
|
||||
echo "eas-cli@$expected was not published as latest on npm; the publish step failed." >&2
|
||||
exit 1
|
||||
release-changelog:
|
||||
name: Update changelog and publish release
|
||||
runs-on: ubuntu-latest
|
||||
@@ -98,10 +78,13 @@ jobs:
|
||||
run: |
|
||||
git fetch
|
||||
git checkout main
|
||||
jq --arg version "$EAS_CLI_VERSION" '.STAGING = $version' \
|
||||
cli-versions.json > cli-versions.json.tmp
|
||||
mv cli-versions.json.tmp cli-versions.json
|
||||
git config --global user.email "[email protected]"
|
||||
git config --global user.name "Expo CI"
|
||||
git add CHANGELOG.md
|
||||
git commit --allow-empty -m "update CHANGELOG.md"
|
||||
git add cli-versions.json CHANGELOG.md
|
||||
git commit --allow-empty -m "update CHANGELOG.md and bump STAGING in cli-versions.json"
|
||||
git push
|
||||
- name: Prepare changelog for Slack
|
||||
run: |
|
||||
|
||||
@@ -12,6 +12,39 @@ This is the log of notable changes to EAS CLI and related packages.
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
## [21.7.0](https://github.com/expo/eas-cli/releases/tag/v21.7.0) - 2026-08-07
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- [build-tools] Label serve-sim metrics artifacts with the sim's device name (from the metrics meta) instead of the raw udid. ([#4127](https://github.com/expo/eas-cli/pull/4127) by [@gwdp](https://github.com/gwdp))
|
||||
- [build-tools] Set the artifact kind for Argent session artifacts from the MIME type reported by the tool server, so screenshots and screen recordings are grouped and labelled on the simulator session page instead of appearing in an unclassified file list. ([#4154](https://github.com/expo/eas-cli/pull/4154) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- [eas-cli] Fail when `--private-key-path` is passed without `updates.codeSigningCertificate` in the resolved app config, instead of ignoring the flag and publishing an unsigned update. ([#4161](https://github.com/expo/eas-cli/pull/4161) by [@gwdp](https://github.com/gwdp))
|
||||
|
||||
## [21.6.0](https://github.com/expo/eas-cli/releases/tag/v21.6.0) - 2026-08-05
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- [eas-build-job] Replace eas-cli npm dist-tags with cli-versions.json. ([#4147](https://github.com/expo/eas-cli/pull/4147) by [@douglowder](https://github.com/douglowder))
|
||||
- [eas-cli] Make `eas simulator` the canonical command and keep `eas simulator:start` as an alias. ([#4112](https://github.com/expo/eas-cli/pull/4112) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
- [eas-cli] Validate local composite functions referenced from workflow job hooks during `eas workflow:validate`. ([#4064](https://github.com/expo/eas-cli/pull/4064) by [@sswrk](https://github.com/sswrk))
|
||||
- [eas-cli] Add `eas sim` as a shortcut for `eas simulator` commands, e.g. `eas sim:list` runs `eas simulator:list`. ([#4150](https://github.com/expo/eas-cli/pull/4150) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
- [build-tools] Support an optional `max_idle_time_minutes` input on `eas/start_argent_remote_session` and `eas/start_agent_device_remote_session`; the step stops the device run session after that many minutes without observed session events. ([#4157](https://github.com/expo/eas-cli/pull/4157) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- [eas-cli] Point accounts without EAS Simulator access at the waitlist: `eas simulator:availability` includes the link (and a `waitlistUrl` field in `--json`), and `eas simulator` now fails early with the same message instead of a generic permission error. ([#4151](https://github.com/expo/eas-cli/pull/4151) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
- [build-tools] Revert "Pin the default `agent-device` version for remote sessions" now that `agent-device` 0.20.5 fixes the broken release. ([#4144](https://github.com/expo/eas-cli/pull/4144) by [@gwdp](https://github.com/gwdp))
|
||||
- [build-tools] Make the configure-version gradle script compatible with AGP 9, which removes the `applicationVariants` API; per-output version overrides now fall back to the `androidComponents` variant API. ([#4155](https://github.com/expo/eas-cli/pull/4155) by [@gabrieldonadel](https://github.com/gabrieldonadel))
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
- [reviewer] Migrate AI code reviews from GPT to Claude models on the claude-code engine (Sonnet 5 by default, Opus 5 for the security agent). ([#4148](https://github.com/expo/eas-cli/pull/4148) by [@brentvatne](https://github.com/brentvatne))
|
||||
|
||||
## [21.5.1](https://github.com/expo/eas-cli/releases/tag/v21.5.1) - 2026-08-03
|
||||
|
||||
## [21.5.0](https://github.com/expo/eas-cli/releases/tag/v21.5.0) - 2026-08-03
|
||||
|
||||
### 🎉 New features
|
||||
@@ -25,6 +58,7 @@ This is the log of notable changes to EAS CLI and related packages.
|
||||
- [build-tools] Pass a metrics CORS origin to serve-sim so the dashboard can read a session's live CPU/memory stream cross-origin. ([#3990](https://github.com/expo/eas-cli/pull/3990) by [@gwdp](https://github.com/gwdp))
|
||||
- [build-tools] Collect each iOS session's serve-sim CPU/memory `/metrics` to a file and upload it as a device run session artifact at teardown. ([#3995](https://github.com/expo/eas-cli/pull/3995) by [@gwdp](https://github.com/gwdp))
|
||||
- [build-tools] Load local composite function catalogs for hook steps, in both steps-based and native builds. ([#4063](https://github.com/expo/eas-cli/pull/4063) by [@sswrk](https://github.com/sswrk))
|
||||
- [eas-cli] Add `--name` filter to `eas simulator:list`. ([#4118](https://github.com/expo/eas-cli/pull/4118) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
|
||||
@@ -3,6 +3,19 @@
|
||||
1. Invoke the GitHub workflow ["Manually trigger a release"](https://github.com/expo/eas-cli/actions/workflows/trigger-release.yml). The next version is chosen automatically based on the changelog entries. If you want to use different version, pass the version string as an input to the workflow, in semver format, e.g. "1.2.3"
|
||||
2. That's it! GitHub Actions is going to take care of the rest. Watch the #eas-cli Slack channel for a successful release notification.
|
||||
|
||||
The release also sets the `STAGING` version in `cli-versions.json`. It does not change the `PRODUCTION` version. See [EAS Build CLI versions](#eas-build-cli-versions).
|
||||
|
||||
## EAS Build CLI versions
|
||||
|
||||
`cli-versions.json` at the repository root sets which `eas-cli` version EAS Build installs. It has two fields:
|
||||
|
||||
- `STAGING`: the version used on the staging build servers.
|
||||
- `PRODUCTION`: the version used on the production build servers.
|
||||
|
||||
This file replaces the old `latest-eas-build` and `latest-eas-build-staging` npm dist-tags. The build servers read the file from the `main` branch.
|
||||
|
||||
A release updates `STAGING` to the new version automatically. To promote a version to production, invoke the GitHub workflow ["Promote eas-cli to production"](https://github.com/expo/eas-cli/actions/workflows/promote-eas-cli-production.yml). Pass the version to promote as an input. If you leave the input empty, the workflow uses the current `STAGING` version. The workflow checks that the version is published on npm before it updates `PRODUCTION`.
|
||||
|
||||
## Choosing the next version
|
||||
|
||||
As stated above, the next EAS CLI version is chosen automatically based on the existing changelog entries under the unreleased section in **CHANGELOG.md**.
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"STAGING": "21.7.0",
|
||||
"PRODUCTION": "21.5.0"
|
||||
}
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"npmClient": "yarn",
|
||||
"version": "21.5.1",
|
||||
"version": "21.7.1",
|
||||
"$schema": "node_modules/lerna/schemas/lerna-schema.json"
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/build-tools",
|
||||
"version": "21.5.1",
|
||||
"version": "21.7.1",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "BUSL-1.1",
|
||||
"author": "Expo <[email protected]>",
|
||||
@@ -39,14 +39,14 @@
|
||||
"@expo/config": "55.0.10",
|
||||
"@expo/config-plugins": "55.0.7",
|
||||
"@expo/downloader": "21.0.0",
|
||||
"@expo/eas-build-job": "21.5.0",
|
||||
"@expo/eas-build-job": "21.6.0",
|
||||
"@expo/env": "^0.4.0",
|
||||
"@expo/logger": "21.0.0",
|
||||
"@expo/package-manager": "1.9.10",
|
||||
"@expo/plist": "^0.3.5",
|
||||
"@expo/results": "^1.0.0",
|
||||
"@expo/spawn-async": "1.7.2",
|
||||
"@expo/steps": "21.5.0",
|
||||
"@expo/steps": "21.7.1",
|
||||
"@expo/template-file": "21.0.2",
|
||||
"@expo/turtle-spawn": "21.0.0",
|
||||
"@expo/xcpretty": "^4.3.1",
|
||||
|
||||
@@ -4,6 +4,8 @@ import {
|
||||
BuildStepGlobalContext,
|
||||
BuildWorkflow,
|
||||
StepsConfigParser,
|
||||
buildLocalCompositeFunctionCatalogAsync,
|
||||
createLocalCompositeFunctionLoader,
|
||||
errors,
|
||||
} from '@expo/steps';
|
||||
import assert from 'assert';
|
||||
@@ -17,10 +19,6 @@ import { Artifacts, BuildContext } from '../context';
|
||||
import { CustomBuildContext } from '../customBuildContext';
|
||||
import { Datadog } from '../datadog';
|
||||
import { findAndUploadXcodeBuildLogsAsync } from '../ios/xcodeBuildLogs';
|
||||
import {
|
||||
buildCompositeFunctionCatalogAsync,
|
||||
createCompositeFunctionLoader,
|
||||
} from '../steps/compositeFunctions';
|
||||
import { getEasFunctionGroups } from '../steps/easFunctionGroups';
|
||||
import { getEasFunctions } from '../steps/easFunctions';
|
||||
import { retryAsync } from '../utils/retry';
|
||||
@@ -72,11 +70,13 @@ export async function runCustomBuildAsync(ctx: BuildContext<BuildJob>): Promise<
|
||||
steps: ctx.job.steps,
|
||||
hooks: ctx.job.hooks,
|
||||
// Eager for job steps (always run), lazy loader for hooks (running anchors only).
|
||||
compositeFunctionCatalog: await buildCompositeFunctionCatalogAsync(projectRoot, {
|
||||
steps: ctx.job.steps,
|
||||
compositeFunctionCatalog: await buildLocalCompositeFunctionCatalogAsync(projectRoot, {
|
||||
rootSteps: ctx.job.steps,
|
||||
logger: ctx.logger,
|
||||
}),
|
||||
loadCompositeFunction: createLocalCompositeFunctionLoader(projectRoot, {
|
||||
logger: ctx.logger,
|
||||
}),
|
||||
loadCompositeFunction: createCompositeFunctionLoader(projectRoot, ctx.logger),
|
||||
})
|
||||
: new BuildConfigParser(globalContext, {
|
||||
externalFunctions: easFunctions,
|
||||
|
||||
@@ -13,13 +13,13 @@ import {
|
||||
BuildStepGlobalContext,
|
||||
HookEntry,
|
||||
constructHookEntriesAsync,
|
||||
createLocalCompositeFunctionLoader,
|
||||
extendCompositeFunctionCatalogFromStepsAsync,
|
||||
validateHookStepsAsync,
|
||||
} from '@expo/steps';
|
||||
|
||||
import { BuildContext } from '../context';
|
||||
import { CustomBuildContext } from '../customBuildContext';
|
||||
import { createCompositeFunctionLoader } from '../steps/compositeFunctions';
|
||||
import { getEasFunctionGroups } from '../steps/easFunctionGroups';
|
||||
import { getEasFunctions } from '../steps/easFunctions';
|
||||
|
||||
@@ -99,9 +99,9 @@ export async function parseJobHooksAsync<TJob extends BuildJob>(
|
||||
const hookEntriesByKey: Partial<Record<HookKey, HookEntry[]>> = {};
|
||||
const orderedSteps: BuildStep[] = [];
|
||||
const compositeFunctionCatalog: CompositeFunctionCatalog = {};
|
||||
const loadCompositeFunction = createCompositeFunctionLoader(
|
||||
const loadCompositeFunction = createLocalCompositeFunctionLoader(
|
||||
ctx.getReactNativeProjectDirectory(),
|
||||
ctx.logger
|
||||
{ logger: ctx.logger }
|
||||
);
|
||||
for (const anchor of wrappedAnchors) {
|
||||
for (const side of ['before', 'after'] as const) {
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
import { BuildPhase, Generic } from '@expo/eas-build-job';
|
||||
import { Result, asyncResult } from '@expo/results';
|
||||
import { BuildStepGlobalContext, BuildWorkflow, StepsConfigParser, errors } from '@expo/steps';
|
||||
import {
|
||||
BuildStepGlobalContext,
|
||||
BuildWorkflow,
|
||||
StepsConfigParser,
|
||||
buildLocalCompositeFunctionCatalogAsync,
|
||||
createLocalCompositeFunctionLoader,
|
||||
errors,
|
||||
} from '@expo/steps';
|
||||
import fs from 'fs/promises';
|
||||
import nullthrows from 'nullthrows';
|
||||
|
||||
import { prepareProjectSourcesAsync } from './common/projectSources';
|
||||
import { BuildContext } from './context';
|
||||
import { CustomBuildContext } from './customBuildContext';
|
||||
import {
|
||||
buildCompositeFunctionCatalogAsync,
|
||||
createCompositeFunctionLoader,
|
||||
} from './steps/compositeFunctions';
|
||||
import { getEasFunctionGroups } from './steps/easFunctionGroups';
|
||||
import { getEasFunctions } from './steps/easFunctions';
|
||||
import { uploadJobOutputsToWwwAsync } from './utils/outputs';
|
||||
@@ -51,8 +54,8 @@ export async function runGenericJobAsync(
|
||||
try {
|
||||
const projectRoot = ctx.getReactNativeProjectDirectory(customBuildCtx.projectSourceDirectory);
|
||||
// Eager for job steps (always run), lazy loader for hooks (running anchors only).
|
||||
const compositeFunctionCatalog = await buildCompositeFunctionCatalogAsync(projectRoot, {
|
||||
steps: ctx.job.steps,
|
||||
const compositeFunctionCatalog = await buildLocalCompositeFunctionCatalogAsync(projectRoot, {
|
||||
rootSteps: ctx.job.steps,
|
||||
logger: ctx.logger,
|
||||
});
|
||||
|
||||
@@ -62,7 +65,9 @@ export async function runGenericJobAsync(
|
||||
steps: ctx.job.steps,
|
||||
hooks: ctx.job.hooks,
|
||||
compositeFunctionCatalog,
|
||||
loadCompositeFunction: createCompositeFunctionLoader(projectRoot, ctx.logger),
|
||||
loadCompositeFunction: createLocalCompositeFunctionLoader(projectRoot, {
|
||||
logger: ctx.logger,
|
||||
}),
|
||||
});
|
||||
|
||||
return await parser.parseAsync();
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Writable } from 'stream';
|
||||
import { retryAsync } from '../utils/retry';
|
||||
|
||||
const MAX_BATCH_BYTES = 200_000;
|
||||
const REQUEST_TIMEOUT_MS = 10_000;
|
||||
|
||||
interface BufferedLogEntry {
|
||||
enqueuedAt: number;
|
||||
@@ -161,6 +162,7 @@ export default class HttpLogStream extends Writable {
|
||||
'Content-Type': 'application/x-ndjson',
|
||||
},
|
||||
body: logs.map(log => log.serialized).join('\n'),
|
||||
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
|
||||
@@ -186,4 +186,21 @@ describe(HttpLogStream.name, () => {
|
||||
const [firstAttemptLogs, secondAttemptLogs] = parseRequestLogs();
|
||||
expect(secondAttemptLogs).toEqual(firstAttemptLogs);
|
||||
});
|
||||
|
||||
it('sets a ten-second timeout on HTTP uploads', async () => {
|
||||
fetchMock.mockResolvedValue(createResponse());
|
||||
const abortSignalTimeoutSpy = jest.spyOn(AbortSignal, 'timeout');
|
||||
const stream = new HttpLogStream({
|
||||
url: 'https://logs.expo.test/build-id',
|
||||
headers: { Authorization: 'Bearer token' },
|
||||
logger: createLogger({ name: 'test' }),
|
||||
});
|
||||
|
||||
stream.write({ logId: 'stable-id', msg: 'Do not hang' });
|
||||
await stream.cleanUp();
|
||||
|
||||
expect(abortSignalTimeoutSpy).toHaveBeenCalledWith(10_000);
|
||||
expect(fetchMock.mock.calls[0][1]?.signal).toBeInstanceOf(AbortSignal);
|
||||
abortSignalTimeoutSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
@@ -1,201 +0,0 @@
|
||||
import { promises as fs } from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
|
||||
import {
|
||||
buildCompositeFunctionCatalogAsync,
|
||||
createCompositeFunctionLoader,
|
||||
} from '../compositeFunctions';
|
||||
|
||||
async function makeProjectWithCompositeFunctionAsync(
|
||||
functionName: string,
|
||||
contents: string
|
||||
): Promise<string> {
|
||||
const projectRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'eas-functions-test-'));
|
||||
const functionDir = path.join(projectRoot, '.eas', 'functions', functionName);
|
||||
await fs.mkdir(functionDir, { recursive: true });
|
||||
await fs.writeFile(path.join(functionDir, 'function.yml'), contents, 'utf-8');
|
||||
return projectRoot;
|
||||
}
|
||||
|
||||
const setupCompositeFunctionContents = [
|
||||
'name: Setup',
|
||||
'inputs:',
|
||||
' - name: greeting',
|
||||
' type: string',
|
||||
' default_value: hello',
|
||||
'outputs:',
|
||||
' version:',
|
||||
' value: ${{ steps.read.outputs.version }}',
|
||||
'runs:',
|
||||
' steps:',
|
||||
' - id: read',
|
||||
' run: set-output version "1.0.0"',
|
||||
].join('\n');
|
||||
|
||||
describe(buildCompositeFunctionCatalogAsync, () => {
|
||||
it('discovers, reads and validates referenced local composite functions keyed by normalized ref', async () => {
|
||||
const projectRoot = await makeProjectWithCompositeFunctionAsync(
|
||||
'setup',
|
||||
setupCompositeFunctionContents
|
||||
);
|
||||
|
||||
const catalog = await buildCompositeFunctionCatalogAsync(projectRoot, {
|
||||
steps: [{ uses: './.eas/functions/setup', id: 'setup' }],
|
||||
});
|
||||
|
||||
expect(Object.keys(catalog)).toEqual(['./.eas/functions/setup']);
|
||||
const action = catalog['./.eas/functions/setup'];
|
||||
expect(action.name).toBe('Setup');
|
||||
expect(action.runs.steps).toHaveLength(1);
|
||||
expect(action.outputs?.version.value).toBe('${{ steps.read.outputs.version }}');
|
||||
});
|
||||
|
||||
it('loads nested composite functions transitively referenced by other composite functions', async () => {
|
||||
const projectRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'eas-functions-nested-'));
|
||||
const innerDir = path.join(projectRoot, '.eas', 'functions', 'inner');
|
||||
const outerDir = path.join(projectRoot, '.eas', 'functions', 'outer');
|
||||
await fs.mkdir(innerDir, { recursive: true });
|
||||
await fs.mkdir(outerDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(innerDir, 'function.yml'),
|
||||
['runs:', ' steps:', ' - run: echo inner'].join('\n'),
|
||||
'utf-8'
|
||||
);
|
||||
await fs.writeFile(
|
||||
path.join(outerDir, 'function.yml'),
|
||||
['runs:', ' steps:', ' - uses: ./.eas/functions/inner'].join('\n'),
|
||||
'utf-8'
|
||||
);
|
||||
|
||||
const catalog = await buildCompositeFunctionCatalogAsync(projectRoot, {
|
||||
steps: [{ uses: './.eas/functions/outer', id: 'outer' }],
|
||||
});
|
||||
|
||||
expect(Object.keys(catalog).sort()).toEqual([
|
||||
'./.eas/functions/inner',
|
||||
'./.eas/functions/outer',
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns an empty catalog when there are no referenced composite functions', async () => {
|
||||
const projectRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'eas-functions-empty-'));
|
||||
const catalog = await buildCompositeFunctionCatalogAsync(projectRoot, {
|
||||
steps: [{ run: 'echo hi' }],
|
||||
});
|
||||
expect(catalog).toEqual({});
|
||||
});
|
||||
|
||||
it('ignores unreferenced malformed composite functions on disk', async () => {
|
||||
const projectRoot = await makeProjectWithCompositeFunctionAsync(
|
||||
'broken',
|
||||
['name: Broken', 'runs:', ' steps: []'].join('\n')
|
||||
);
|
||||
|
||||
const catalog = await buildCompositeFunctionCatalogAsync(projectRoot, {
|
||||
steps: [{ run: 'echo hi' }],
|
||||
});
|
||||
expect(catalog).toEqual({});
|
||||
});
|
||||
|
||||
it('resolves composite functions referenced by an arbitrary path (arbitrary path style)', async () => {
|
||||
const projectRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'eas-functions-arbitrary-'));
|
||||
const functionDir = path.join(projectRoot, 'internal-functions', 'deploy');
|
||||
await fs.mkdir(functionDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(functionDir, 'function.yml'),
|
||||
['runs:', ' steps:', ' - run: echo deploy'].join('\n'),
|
||||
'utf-8'
|
||||
);
|
||||
|
||||
const catalog = await buildCompositeFunctionCatalogAsync(projectRoot, {
|
||||
steps: [{ uses: './internal-functions/deploy', id: 'deploy' }],
|
||||
});
|
||||
|
||||
expect(Object.keys(catalog)).toEqual(['./internal-functions/deploy']);
|
||||
});
|
||||
|
||||
it('resolves composite functions defined with a function.yaml extension', async () => {
|
||||
const projectRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'eas-functions-yaml-'));
|
||||
const functionDir = path.join(projectRoot, '.eas', 'functions', 'setup');
|
||||
await fs.mkdir(functionDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(functionDir, 'function.yaml'),
|
||||
['runs:', ' steps:', ' - run: echo hi'].join('\n'),
|
||||
'utf-8'
|
||||
);
|
||||
|
||||
const catalog = await buildCompositeFunctionCatalogAsync(projectRoot, {
|
||||
steps: [{ uses: './.eas/functions/setup', id: 'setup' }],
|
||||
});
|
||||
|
||||
expect(Object.keys(catalog)).toEqual(['./.eas/functions/setup']);
|
||||
});
|
||||
|
||||
it('loads a shared composite function above the EAS project root', async () => {
|
||||
const sourceRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'eas-functions-monorepo-'));
|
||||
const projectRoot = path.join(sourceRoot, 'apps', 'my-app');
|
||||
const functionDir = path.join(sourceRoot, 'shared', 'functions', 'setup');
|
||||
await fs.mkdir(projectRoot, { recursive: true });
|
||||
await fs.mkdir(functionDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(functionDir, 'function.yml'),
|
||||
['runs:', ' steps:', ' - run: echo shared'].join('\n'),
|
||||
'utf-8'
|
||||
);
|
||||
|
||||
const catalog = await buildCompositeFunctionCatalogAsync(projectRoot, {
|
||||
steps: [{ uses: '../../shared/functions/setup', id: 'setup' }],
|
||||
});
|
||||
|
||||
expect(Object.keys(catalog)).toEqual(['../../shared/functions/setup']);
|
||||
});
|
||||
|
||||
it('throws a clear error for a referenced composite function that does not exist', async () => {
|
||||
const projectRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'eas-functions-missing-'));
|
||||
await expect(
|
||||
buildCompositeFunctionCatalogAsync(projectRoot, {
|
||||
steps: [{ uses: './.eas/functions/missing', id: 'missing' }],
|
||||
})
|
||||
).rejects.toThrow(
|
||||
/Local composite function "\.\/\.eas\/functions\/missing" was referenced by a step but no such composite function exists/
|
||||
);
|
||||
});
|
||||
|
||||
it('throws a clear error for a malformed referenced composite function config', async () => {
|
||||
const projectRoot = await makeProjectWithCompositeFunctionAsync(
|
||||
'broken',
|
||||
['name: Broken', 'runs:', ' steps: []'].join('\n')
|
||||
);
|
||||
await expect(
|
||||
buildCompositeFunctionCatalogAsync(projectRoot, {
|
||||
steps: [{ uses: './.eas/functions/broken', id: 'broken' }],
|
||||
})
|
||||
).rejects.toThrow(/must declare at least one step under "runs.steps"/);
|
||||
});
|
||||
});
|
||||
|
||||
describe(createCompositeFunctionLoader, () => {
|
||||
it('loads function.yml from disk for a normalized path', async () => {
|
||||
const projectRoot = await makeProjectWithCompositeFunctionAsync(
|
||||
'setup',
|
||||
setupCompositeFunctionContents
|
||||
);
|
||||
|
||||
const loader = createCompositeFunctionLoader(projectRoot);
|
||||
const config = await loader('./.eas/functions/setup');
|
||||
|
||||
expect(config.name).toBe('Setup');
|
||||
expect(config.runs.steps).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('rejects for a path with no composite function on disk', async () => {
|
||||
const projectRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'eas-functions-loader-'));
|
||||
|
||||
const loader = createCompositeFunctionLoader(projectRoot);
|
||||
|
||||
await expect(loader('./.eas/functions/missing')).rejects.toThrow(
|
||||
/no such composite function exists/
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,93 +0,0 @@
|
||||
/**
|
||||
* Loads local function.yml files from the project and builds the catalog consumed by
|
||||
* {@link StepsConfigParser}. Keeps filesystem I/O in build-tools; expansion logic lives in @expo/steps.
|
||||
*/
|
||||
import { bunyan } from '@expo/logger';
|
||||
import {
|
||||
CompositeFunctionCatalog,
|
||||
CompositeFunctionConfig,
|
||||
CompositeFunctionConfigZ,
|
||||
Step,
|
||||
} from '@expo/eas-build-job';
|
||||
import {
|
||||
buildCompositeFunctionCatalogFromStepsAsync,
|
||||
resolveLocalCompositeFunctionPath,
|
||||
} from '@expo/steps';
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import YAML from 'yaml';
|
||||
import { ZodError, z } from 'zod';
|
||||
|
||||
async function loadLocalCompositeFunctionConfigAsync(
|
||||
projectRoot: string,
|
||||
compositeFunctionPath: string,
|
||||
{ logger }: { logger?: bunyan } = {}
|
||||
): Promise<CompositeFunctionConfig> {
|
||||
const resolvedPath = resolveLocalCompositeFunctionPath(projectRoot, compositeFunctionPath);
|
||||
|
||||
for (const ext of ['yml', 'yaml'] as const) {
|
||||
const absolutePath = path.join(resolvedPath, `function.${ext}`);
|
||||
let rawContents: string;
|
||||
try {
|
||||
rawContents = await fs.readFile(absolutePath, 'utf-8');
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException)?.code === 'ENOENT') {
|
||||
continue;
|
||||
}
|
||||
throw new Error(
|
||||
`Failed to read local composite function "${compositeFunctionPath}" from ${absolutePath}`,
|
||||
{
|
||||
cause: err as Error,
|
||||
}
|
||||
);
|
||||
}
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = YAML.parse(rawContents);
|
||||
} catch (err) {
|
||||
throw new Error(
|
||||
`Failed to parse local composite function "${compositeFunctionPath}" YAML at ${absolutePath}`,
|
||||
{
|
||||
cause: err as Error,
|
||||
}
|
||||
);
|
||||
}
|
||||
let config: CompositeFunctionConfig;
|
||||
try {
|
||||
config = CompositeFunctionConfigZ.parse(parsed);
|
||||
} catch (err) {
|
||||
if (err instanceof ZodError) {
|
||||
throw new Error(
|
||||
`Invalid composite function "${compositeFunctionPath}": ${z.prettifyError(err)}`
|
||||
);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
logger?.debug(
|
||||
`Loaded local composite function "${compositeFunctionPath}" from ${path.relative(projectRoot, absolutePath)}`
|
||||
);
|
||||
return config;
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Local composite function "${compositeFunctionPath}" was referenced by a step but no such composite function exists. A local composite function is resolved from a "function.yml" (or "function.yaml") file at the referenced path relative to the EAS project root (e.g. "uses: ${compositeFunctionPath}" resolves "${compositeFunctionPath}/function.yml"). The recommended convention is to keep composite functions under ".eas/functions/<name>".`
|
||||
);
|
||||
}
|
||||
|
||||
export function createCompositeFunctionLoader(
|
||||
projectRoot: string,
|
||||
logger?: bunyan
|
||||
): (compositeFunctionPath: string) => Promise<CompositeFunctionConfig> {
|
||||
return compositeFunctionPath =>
|
||||
loadLocalCompositeFunctionConfigAsync(projectRoot, compositeFunctionPath, { logger });
|
||||
}
|
||||
|
||||
export async function buildCompositeFunctionCatalogAsync(
|
||||
projectRoot: string,
|
||||
{ steps, logger }: { steps: readonly Step[]; logger?: bunyan }
|
||||
): Promise<CompositeFunctionCatalog> {
|
||||
return buildCompositeFunctionCatalogFromStepsAsync({
|
||||
rootSteps: steps,
|
||||
loadCompositeFunction: createCompositeFunctionLoader(projectRoot, logger),
|
||||
});
|
||||
}
|
||||
+8
-2
@@ -68,7 +68,10 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => {
|
||||
jest.mocked(pollArgentArtifactsForUploadAsync).mockResolvedValue(undefined);
|
||||
mockStopAsync.mockResolvedValue(undefined);
|
||||
mockTunnelStopAsync.mockResolvedValue(undefined);
|
||||
jest.mocked(startArgentEventCollectionAsync).mockResolvedValue({ stopAsync: mockStopAsync });
|
||||
jest.mocked(startArgentEventCollectionAsync).mockResolvedValue({
|
||||
stopAsync: mockStopAsync,
|
||||
getLastEventObservedAt: () => undefined,
|
||||
});
|
||||
|
||||
jest.mocked(getDeviceRunSessionIdOrThrow).mockReturnValue('device-run-session-id');
|
||||
jest.mocked(getNgrokTunnelDomainOrThrow).mockReturnValue('tunnel.example.com');
|
||||
@@ -108,7 +111,10 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => {
|
||||
global: { runtimePlatform: BuildRuntimePlatform.LINUX },
|
||||
} as unknown as BuildStepContext,
|
||||
{
|
||||
inputs: { package_version: { value: undefined } },
|
||||
inputs: {
|
||||
package_version: { value: undefined },
|
||||
max_idle_time_minutes: { value: undefined },
|
||||
},
|
||||
outputs: {},
|
||||
env: { EXISTING: 'value' },
|
||||
} as never
|
||||
|
||||
@@ -31,7 +31,6 @@ import {
|
||||
} from '../utils/remoteDeviceRunSession';
|
||||
|
||||
const AGENT_DEVICE_PACKAGE_NAME = 'agent-device';
|
||||
export const DEFAULT_AGENT_DEVICE_VERSION = '0.20.3';
|
||||
const AGENT_DEVICE_REPO_URL = 'https://github.com/callstack/agent-device.git';
|
||||
const SRC_DIR = '/tmp/agent-device-src';
|
||||
const AGENT_DEVICE_STATE_DIR = path.join(os.homedir(), '.agent-device');
|
||||
@@ -56,6 +55,11 @@ export function createStartAgentDeviceRemoteSessionBuildFunction(
|
||||
required: false,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'max_idle_time_minutes',
|
||||
required: false,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
|
||||
}),
|
||||
],
|
||||
fn: async ({ logger, global }, { inputs, env, signal }) => {
|
||||
// Fail fast before any expensive setup if the injected env
|
||||
@@ -66,11 +70,12 @@ export function createStartAgentDeviceRemoteSessionBuildFunction(
|
||||
const ngrokTunnelDomain = getNgrokTunnelDomainOrThrow(env);
|
||||
const ngrokAuthtoken = getNgrokAuthtokenOrThrow(env);
|
||||
|
||||
const packageVersion =
|
||||
(inputs.package_version.value as string | undefined) ?? DEFAULT_AGENT_DEVICE_VERSION;
|
||||
const packageVersion = inputs.package_version.value as string | undefined;
|
||||
// A missing or non-positive value disables the idle timeout (opt-in feature).
|
||||
const maxIdleTimeMinutes = inputs.max_idle_time_minutes.value as number | undefined;
|
||||
const { runtimePlatform } = global;
|
||||
logger.info(
|
||||
`Starting agent-device remote session (version: ${packageVersion}, runtime: ${runtimePlatform}).`
|
||||
`Starting agent-device remote session (version: ${packageVersion ?? 'latest'}, runtime: ${runtimePlatform}).`
|
||||
);
|
||||
|
||||
if (runtimePlatform === BuildRuntimePlatform.DARWIN) {
|
||||
@@ -142,6 +147,13 @@ export function createStartAgentDeviceRemoteSessionBuildFunction(
|
||||
deviceRunSessionId,
|
||||
logger,
|
||||
signal,
|
||||
idleTimeout:
|
||||
maxIdleTimeMinutes !== undefined && maxIdleTimeMinutes > 0
|
||||
? {
|
||||
maxIdleTimeMinutes,
|
||||
getLastEventObservedAt: eventCollection.getLastEventObservedAt,
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
} finally {
|
||||
if (serveSim) {
|
||||
@@ -188,7 +200,7 @@ async function startAgentDeviceDaemonAsync({
|
||||
env,
|
||||
logger,
|
||||
}: {
|
||||
packageVersion: string;
|
||||
packageVersion: string | undefined;
|
||||
env: BuildStepEnv;
|
||||
logger: bunyan;
|
||||
}): Promise<DetachedProcessHandle> {
|
||||
@@ -225,7 +237,7 @@ async function startAgentDeviceDaemonAsync({
|
||||
},
|
||||
extras: {
|
||||
packageSpec,
|
||||
packageVersion,
|
||||
packageVersion: packageVersion ?? 'latest',
|
||||
bunVersion,
|
||||
bunInstallConfigured: Boolean(env.BUN_INSTALL?.trim()),
|
||||
},
|
||||
@@ -243,11 +255,15 @@ async function startAgentDeviceDaemonFromGitAsync({
|
||||
env,
|
||||
logger,
|
||||
}: {
|
||||
packageVersion: string;
|
||||
packageVersion: string | undefined;
|
||||
env: BuildStepEnv;
|
||||
logger: bunyan;
|
||||
}): Promise<DetachedProcessHandle> {
|
||||
logger.info(`Cloning agent-device @ v${packageVersion} into ${SRC_DIR}.`);
|
||||
logger.info(
|
||||
packageVersion
|
||||
? `Cloning agent-device @ v${packageVersion} into ${SRC_DIR}.`
|
||||
: `Cloning agent-device (latest) into ${SRC_DIR}.`
|
||||
);
|
||||
await cloneAgentDeviceAsync({ packageVersion, env, logger });
|
||||
|
||||
logger.info('Installing agent-device dependencies.');
|
||||
@@ -271,11 +287,11 @@ async function cloneAgentDeviceAsync({
|
||||
env,
|
||||
logger,
|
||||
}: {
|
||||
packageVersion: string;
|
||||
packageVersion: string | undefined;
|
||||
env: BuildStepEnv;
|
||||
logger: bunyan;
|
||||
}): Promise<void> {
|
||||
const branchArgs = ['--branch', `v${packageVersion}`];
|
||||
const branchArgs = packageVersion ? ['--branch', `v${packageVersion}`] : [];
|
||||
await spawn('git', ['clone', '--depth', '1', ...branchArgs, AGENT_DEVICE_REPO_URL, SRC_DIR], {
|
||||
env,
|
||||
logger,
|
||||
@@ -315,8 +331,8 @@ async function getBunVersionForDiagnosticsAsync(env: BuildStepEnv): Promise<stri
|
||||
}
|
||||
}
|
||||
|
||||
function createAgentDevicePackageSpec(packageVersion: string): string {
|
||||
const versionSpec = packageVersion.replace(/^v(?=\d)/, '');
|
||||
function createAgentDevicePackageSpec(packageVersion: string | undefined): string {
|
||||
const versionSpec = packageVersion ? packageVersion.replace(/^v(?=\d)/, '') : 'latest';
|
||||
return `${AGENT_DEVICE_PACKAGE_NAME}@${versionSpec}`;
|
||||
}
|
||||
|
||||
|
||||
@@ -68,6 +68,11 @@ export function createStartArgentRemoteSessionBuildFunction(
|
||||
required: false,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'max_idle_time_minutes',
|
||||
required: false,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
|
||||
}),
|
||||
],
|
||||
fn: async ({ logger, global }, { inputs, env, signal }) => {
|
||||
// Fail fast before any expensive setup if the injected env
|
||||
@@ -79,6 +84,8 @@ export function createStartArgentRemoteSessionBuildFunction(
|
||||
const ngrokAuthtoken = getNgrokAuthtokenOrThrow(env);
|
||||
|
||||
const packageVersion = inputs.package_version.value as string | undefined;
|
||||
// A missing or non-positive value disables the idle timeout (opt-in feature).
|
||||
const maxIdleTimeMinutes = inputs.max_idle_time_minutes.value as number | undefined;
|
||||
warnIfArgentPackageVersionCannotBeVerified({ packageVersion, logger });
|
||||
const versionSpec = packageVersion ?? 'latest';
|
||||
const { runtimePlatform } = global;
|
||||
@@ -215,6 +222,13 @@ export function createStartArgentRemoteSessionBuildFunction(
|
||||
deviceRunSessionId,
|
||||
logger,
|
||||
signal,
|
||||
idleTimeout:
|
||||
maxIdleTimeMinutes !== undefined && maxIdleTimeMinutes > 0
|
||||
? {
|
||||
maxIdleTimeMinutes,
|
||||
getLastEventObservedAt: eventCollection.getLastEventObservedAt,
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
} finally {
|
||||
if (serveSim) {
|
||||
|
||||
@@ -159,6 +159,37 @@ describe(startAgentDeviceEventCollectionAsync, () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('tracks the local arrival time of the most recent event', async () => {
|
||||
const stateDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'agent-device-events-'));
|
||||
const eventsDir = path.join(stateDir, 'sessions', 'default');
|
||||
await fs.promises.mkdir(eventsDir, { recursive: true });
|
||||
const collection = await startAgentDeviceEventCollectionAsync({
|
||||
ctx: createContext(),
|
||||
deviceRunSessionId: 'session-id',
|
||||
stateDir,
|
||||
logger: createLogger(),
|
||||
pollIntervalMs: 10,
|
||||
});
|
||||
|
||||
try {
|
||||
expect(collection.getLastEventObservedAt()).toBeUndefined();
|
||||
|
||||
const before = new Date();
|
||||
await fs.promises.writeFile(
|
||||
path.join(eventsDir, 'events.ndjson'),
|
||||
`${JSON.stringify(createAgentDeviceEvent({ requestId: 'request-1', command: 'tap' }))}\n`
|
||||
);
|
||||
await waitForAsync(() => expect(mockEventLogStream.write).toHaveBeenCalledTimes(1));
|
||||
|
||||
const observedAt = collection.getLastEventObservedAt();
|
||||
expect(observedAt).toBeInstanceOf(Date);
|
||||
expect(observedAt!.getTime()).toBeGreaterThanOrEqual(before.getTime());
|
||||
} finally {
|
||||
await collection.stopAsync();
|
||||
await fs.promises.rm(stateDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('preserves unknown kinds and tolerates optional upstream field changes', async () => {
|
||||
const stateDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'agent-device-events-'));
|
||||
const eventsDir = path.join(stateDir, 'sessions', 'default');
|
||||
@@ -547,6 +578,9 @@ describe(startAgentDeviceEventCollectionAsync, () => {
|
||||
);
|
||||
expect(mockEventLogStream.init).not.toHaveBeenCalled();
|
||||
expect(HttpLogStream).not.toHaveBeenCalled();
|
||||
// Without collection, activity is invisible: the collection reports fresh
|
||||
// activity so an enabled idle timeout never stops the session.
|
||||
expect(collection.getLastEventObservedAt()).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
it('continues persisting the artifact when real-time log setup fails', async () => {
|
||||
|
||||
@@ -135,10 +135,69 @@ describe(uploadArgentArtifactAsync, () => {
|
||||
artifactId: 'artifact-id',
|
||||
name: 'report.json (artifact-id)',
|
||||
filename: 'report.json',
|
||||
kind: undefined,
|
||||
size: data.length,
|
||||
stream: expect.anything(),
|
||||
});
|
||||
});
|
||||
|
||||
async function uploadAsync(artifact: {
|
||||
id: string;
|
||||
filename: string;
|
||||
mimeType: string;
|
||||
isDirectory?: boolean;
|
||||
}): Promise<CustomBuildContext> {
|
||||
const ctx = {} as unknown as CustomBuildContext;
|
||||
jest
|
||||
.mocked(fetch)
|
||||
.mockResolvedValueOnce(new Response(Readable.from([Buffer.from('artifact-data')])));
|
||||
jest
|
||||
.mocked(uploadDeviceRunSessionArtifactAsync)
|
||||
.mockImplementationOnce(async (_ctx, { stream }) => {
|
||||
await readStreamAsync(stream);
|
||||
});
|
||||
|
||||
await uploadArgentArtifactAsync(ctx, {
|
||||
deviceRunSessionId: 'drs-id',
|
||||
toolsUrl: 'http://127.0.0.1:1234',
|
||||
logger: createLoggerMock(),
|
||||
artifact,
|
||||
});
|
||||
|
||||
return ctx;
|
||||
}
|
||||
|
||||
it.each([
|
||||
{ mimeType: 'image/png', filename: 'screen.png', kind: 'screenshot' },
|
||||
{ mimeType: 'image/jpeg', filename: 'screen.jpg', kind: 'screenshot' },
|
||||
// Media types are case-insensitive and may carry parameters.
|
||||
{ mimeType: 'IMAGE/PNG; charset=binary', filename: 'screen.png', kind: 'screenshot' },
|
||||
{ mimeType: 'video/mp4', filename: 'session.mp4', kind: 'screen-recording' },
|
||||
{ mimeType: 'video/quicktime', filename: 'session.mov', kind: 'screen-recording' },
|
||||
{ mimeType: 'application/json', filename: 'report.json', kind: undefined },
|
||||
{ mimeType: 'text/plain', filename: 'log.txt', kind: undefined },
|
||||
])('uploads $mimeType with kind $kind', async ({ mimeType, filename, kind }) => {
|
||||
const ctx = await uploadAsync({ id: 'artifact-id', filename, mimeType });
|
||||
|
||||
expect(jest.mocked(uploadDeviceRunSessionArtifactAsync)).toHaveBeenCalledWith(
|
||||
ctx,
|
||||
expect.objectContaining({ filename, kind })
|
||||
);
|
||||
});
|
||||
|
||||
it('leaves directory artifacts unclassified because they are uploaded as a tarball', async () => {
|
||||
const ctx = await uploadAsync({
|
||||
id: 'artifact-id',
|
||||
filename: 'screenshots',
|
||||
mimeType: 'image/png',
|
||||
isDirectory: true,
|
||||
});
|
||||
|
||||
expect(jest.mocked(uploadDeviceRunSessionArtifactAsync)).toHaveBeenCalledWith(
|
||||
ctx,
|
||||
expect.objectContaining({ filename: 'screenshots.tar.gz', kind: undefined })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe(pollArgentArtifactsForUploadAsync, () => {
|
||||
|
||||
@@ -51,7 +51,8 @@ function createStatusCtxMock(
|
||||
| { status: 'NEW' | 'IN_PROGRESS' | 'STOPPED' | 'ERRORED' }
|
||||
| { error: Error }
|
||||
| { data: unknown }
|
||||
)[]
|
||||
)[],
|
||||
{ ensureStoppedError }: { ensureStoppedError?: Error } = {}
|
||||
): CustomBuildContext {
|
||||
const query = jest.fn(() => {
|
||||
const result = results.shift();
|
||||
@@ -80,9 +81,25 @@ function createStatusCtxMock(
|
||||
};
|
||||
});
|
||||
|
||||
const mutation = jest.fn(() => ({
|
||||
toPromise: async () => {
|
||||
if (ensureStoppedError) {
|
||||
return { error: ensureStoppedError };
|
||||
}
|
||||
return {
|
||||
data: {
|
||||
deviceRunSession: {
|
||||
ensureDeviceRunSessionStopped: { id: 'drs-id', status: 'STOPPED' },
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
}));
|
||||
|
||||
return {
|
||||
graphqlClient: {
|
||||
query,
|
||||
mutation,
|
||||
},
|
||||
} as unknown as CustomBuildContext;
|
||||
}
|
||||
@@ -423,6 +440,97 @@ describe(waitForDeviceRunSessionStoppedAsync, () => {
|
||||
{ level: 'warning' }
|
||||
);
|
||||
});
|
||||
|
||||
describe('with an idle timeout', () => {
|
||||
beforeEach(() => {
|
||||
// Fake timers make Date.now() advance by exactly the poll interval per
|
||||
// loop iteration, so idle time accumulates deterministically.
|
||||
jest.useFakeTimers();
|
||||
jest.mocked(setTimeoutAsync).mockImplementation(async delayMs => {
|
||||
jest.advanceTimersByTime(delayMs ?? 0);
|
||||
return undefined;
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
function manyInProgressStatuses(): { status: 'IN_PROGRESS' }[] {
|
||||
return Array.from({ length: 20 }, () => ({ status: 'IN_PROGRESS' as const }));
|
||||
}
|
||||
|
||||
it('stops the session when no activity is observed within the max idle time', async () => {
|
||||
const ctx = createStatusCtxMock(manyInProgressStatuses());
|
||||
const logger = createLoggerMock();
|
||||
|
||||
await waitForDeviceRunSessionStoppedAsync({
|
||||
ctx,
|
||||
deviceRunSessionId: 'drs-id',
|
||||
logger,
|
||||
idleTimeout: {
|
||||
maxIdleTimeMinutes: 1,
|
||||
getLastEventObservedAt: () => undefined,
|
||||
},
|
||||
});
|
||||
|
||||
// One minute at the 5-second poll interval is 12 status polls.
|
||||
expect(ctx.graphqlClient.query).toHaveBeenCalledTimes(12);
|
||||
expect(ctx.graphqlClient.mutation).toHaveBeenCalledTimes(1);
|
||||
expect(logger.info).toHaveBeenCalledWith(
|
||||
'Device run session drs-id had no activity for 1 minute(s) (max idle time). Stopping the session.'
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps the session alive while events keep arriving', async () => {
|
||||
const ctx = createStatusCtxMock([...manyInProgressStatuses(), { status: 'STOPPED' }]);
|
||||
const logger = createLoggerMock();
|
||||
|
||||
await waitForDeviceRunSessionStoppedAsync({
|
||||
ctx,
|
||||
deviceRunSessionId: 'drs-id',
|
||||
logger,
|
||||
idleTimeout: {
|
||||
maxIdleTimeMinutes: 1,
|
||||
// Fresh activity on every check; 20 polls exceed one minute, so the
|
||||
// session would have been stopped without these events.
|
||||
getLastEventObservedAt: () => new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
expect(ctx.graphqlClient.query).toHaveBeenCalledTimes(21);
|
||||
expect(ctx.graphqlClient.mutation).not.toHaveBeenCalled();
|
||||
expect(logger.info).toHaveBeenCalledWith('Device run session drs-id was stopped.');
|
||||
});
|
||||
|
||||
it('still returns when the session cannot be marked stopped', async () => {
|
||||
const ctx = createStatusCtxMock(manyInProgressStatuses(), {
|
||||
ensureStoppedError: new Error('forbidden'),
|
||||
});
|
||||
const logger = createLoggerMock();
|
||||
|
||||
await waitForDeviceRunSessionStoppedAsync({
|
||||
ctx,
|
||||
deviceRunSessionId: 'drs-id',
|
||||
logger,
|
||||
idleTimeout: {
|
||||
maxIdleTimeMinutes: 1,
|
||||
getLastEventObservedAt: () => undefined,
|
||||
},
|
||||
});
|
||||
|
||||
expect(ctx.graphqlClient.mutation).toHaveBeenCalledTimes(1);
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
{ err: expect.any(Error) },
|
||||
'Could not mark device run session drs-id as stopped. The session job ends anyway.'
|
||||
);
|
||||
expect(jest.mocked(Sentry).capture).toHaveBeenCalledWith(
|
||||
'Could not mark idle device run session as stopped',
|
||||
expect.any(Error),
|
||||
{ level: 'warning', extras: { deviceRunSessionId: 'drs-id' } }
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe(ensureFfmpegInstalledAsync, () => {
|
||||
|
||||
@@ -83,6 +83,36 @@ describe(uploadServeSimMetricsFileAsync, () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('labels the artifact with the device name when the meta carries one', async () => {
|
||||
await writeFile(filePath, NDJSON);
|
||||
jest
|
||||
.mocked(uploadDeviceRunSessionArtifactAsync)
|
||||
.mockImplementation(async (_ctx, { stream }) => {
|
||||
await readStreamAsync(stream);
|
||||
});
|
||||
|
||||
await uploadServeSimMetricsFileAsync(ctx, {
|
||||
deviceRunSessionId: 'session-id',
|
||||
udid: 'AAAA',
|
||||
filePath,
|
||||
metadata: {
|
||||
deviceName: 'iPhone 15 Pro',
|
||||
hostCores: 8,
|
||||
sampleIntervalMs: 1000,
|
||||
schemaVersion: 1,
|
||||
},
|
||||
logger: createLoggerMock(),
|
||||
});
|
||||
|
||||
expect(uploadDeviceRunSessionArtifactAsync).toHaveBeenCalledWith(
|
||||
ctx,
|
||||
expect.objectContaining({
|
||||
name: 'Performance metrics (iPhone 15 Pro)',
|
||||
metadata: expect.objectContaining({ deviceName: 'iPhone 15 Pro', udid: 'AAAA' }),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('skips the upload when the file is missing', async () => {
|
||||
await uploadServeSimMetricsFileAsync(ctx, {
|
||||
deviceRunSessionId: 'session-id',
|
||||
|
||||
@@ -6,6 +6,7 @@ import { z } from 'zod';
|
||||
import { type CustomBuildContext } from '../../customBuildContext';
|
||||
import {
|
||||
type DeviceRunSessionEvent,
|
||||
type DeviceRunSessionEventCollection,
|
||||
type DeviceRunSessionEventParseFailure,
|
||||
startDeviceRunSessionEventCollectionAsync,
|
||||
} from './deviceRunSessionEvents';
|
||||
@@ -46,7 +47,7 @@ export async function startAgentDeviceEventCollectionAsync({
|
||||
stateDir: string;
|
||||
logger: bunyan;
|
||||
pollIntervalMs?: number;
|
||||
}): Promise<{ stopAsync: () => Promise<void> }> {
|
||||
}): Promise<DeviceRunSessionEventCollection> {
|
||||
return startDeviceRunSessionEventCollectionAsync({
|
||||
ctx,
|
||||
deviceRunSessionId,
|
||||
|
||||
+144
-8
@@ -23,7 +23,12 @@ android {
|
||||
versionName = versionNameVal
|
||||
}
|
||||
}
|
||||
applicationVariants.all { variant ->
|
||||
}
|
||||
|
||||
// AGP 9's new DSL removes the applicationVariants API, we set the version on each output through the androidComponents API instead.
|
||||
def easAndroidExtension = project.extensions.getByName('android')
|
||||
if (easAndroidExtension.hasProperty('applicationVariants')) {
|
||||
easAndroidExtension.applicationVariants.all { variant ->
|
||||
variant.outputs.each { output ->
|
||||
if (versionCodeVal) {
|
||||
output.versionCodeOverride = Integer.parseInt(versionCodeVal)
|
||||
@@ -33,6 +38,18 @@ android {
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
def easAndroidComponents = project.extensions.getByName('androidComponents')
|
||||
easAndroidComponents.onVariants(easAndroidComponents.selector().all()) { variant ->
|
||||
variant.outputs.each { output ->
|
||||
if (versionCodeVal) {
|
||||
output.versionCode.set(Integer.parseInt(versionCodeVal))
|
||||
}
|
||||
if (versionNameVal) {
|
||||
output.versionName.set(versionNameVal)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"
|
||||
`;
|
||||
@@ -56,7 +73,12 @@ android {
|
||||
versionName = versionNameVal
|
||||
}
|
||||
}
|
||||
applicationVariants.all { variant ->
|
||||
}
|
||||
|
||||
// AGP 9's new DSL removes the applicationVariants API, we set the version on each output through the androidComponents API instead.
|
||||
def easAndroidExtension = project.extensions.getByName('android')
|
||||
if (easAndroidExtension.hasProperty('applicationVariants')) {
|
||||
easAndroidExtension.applicationVariants.all { variant ->
|
||||
variant.outputs.each { output ->
|
||||
if (versionCodeVal) {
|
||||
output.versionCodeOverride = Integer.parseInt(versionCodeVal)
|
||||
@@ -66,6 +88,18 @@ android {
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
def easAndroidComponents = project.extensions.getByName('androidComponents')
|
||||
easAndroidComponents.onVariants(easAndroidComponents.selector().all()) { variant ->
|
||||
variant.outputs.each { output ->
|
||||
if (versionCodeVal) {
|
||||
output.versionCode.set(Integer.parseInt(versionCodeVal))
|
||||
}
|
||||
if (versionNameVal) {
|
||||
output.versionName.set(versionNameVal)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"
|
||||
`;
|
||||
@@ -91,7 +125,12 @@ android {
|
||||
versionName = versionNameVal
|
||||
}
|
||||
}
|
||||
applicationVariants.all { variant ->
|
||||
}
|
||||
|
||||
// AGP 9's new DSL removes the applicationVariants API, we set the version on each output through the androidComponents API instead.
|
||||
def easAndroidExtension = project.extensions.getByName('android')
|
||||
if (easAndroidExtension.hasProperty('applicationVariants')) {
|
||||
easAndroidExtension.applicationVariants.all { variant ->
|
||||
variant.outputs.each { output ->
|
||||
if (versionCodeVal) {
|
||||
output.versionCodeOverride = Integer.parseInt(versionCodeVal)
|
||||
@@ -101,6 +140,18 @@ android {
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
def easAndroidComponents = project.extensions.getByName('androidComponents')
|
||||
easAndroidComponents.onVariants(easAndroidComponents.selector().all()) { variant ->
|
||||
variant.outputs.each { output ->
|
||||
if (versionCodeVal) {
|
||||
output.versionCode.set(Integer.parseInt(versionCodeVal))
|
||||
}
|
||||
if (versionNameVal) {
|
||||
output.versionName.set(versionNameVal)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"
|
||||
`;
|
||||
@@ -126,7 +177,12 @@ android {
|
||||
versionName = versionNameVal
|
||||
}
|
||||
}
|
||||
applicationVariants.all { variant ->
|
||||
}
|
||||
|
||||
// AGP 9's new DSL removes the applicationVariants API, we set the version on each output through the androidComponents API instead.
|
||||
def easAndroidExtension = project.extensions.getByName('android')
|
||||
if (easAndroidExtension.hasProperty('applicationVariants')) {
|
||||
easAndroidExtension.applicationVariants.all { variant ->
|
||||
variant.outputs.each { output ->
|
||||
if (versionCodeVal) {
|
||||
output.versionCodeOverride = Integer.parseInt(versionCodeVal)
|
||||
@@ -136,6 +192,18 @@ android {
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
def easAndroidComponents = project.extensions.getByName('androidComponents')
|
||||
easAndroidComponents.onVariants(easAndroidComponents.selector().all()) { variant ->
|
||||
variant.outputs.each { output ->
|
||||
if (versionCodeVal) {
|
||||
output.versionCode.set(Integer.parseInt(versionCodeVal))
|
||||
}
|
||||
if (versionNameVal) {
|
||||
output.versionName.set(versionNameVal)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"
|
||||
`;
|
||||
@@ -163,7 +231,12 @@ android {
|
||||
versionName = versionNameVal
|
||||
}
|
||||
}
|
||||
applicationVariants.all { variant ->
|
||||
}
|
||||
|
||||
// AGP 9's new DSL removes the applicationVariants API, we set the version on each output through the androidComponents API instead.
|
||||
def easAndroidExtension = project.extensions.getByName('android')
|
||||
if (easAndroidExtension.hasProperty('applicationVariants')) {
|
||||
easAndroidExtension.applicationVariants.all { variant ->
|
||||
variant.outputs.each { output ->
|
||||
if (versionCodeVal) {
|
||||
output.versionCodeOverride = Integer.parseInt(versionCodeVal)
|
||||
@@ -173,6 +246,18 @@ android {
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
def easAndroidComponents = project.extensions.getByName('androidComponents')
|
||||
easAndroidComponents.onVariants(easAndroidComponents.selector().all()) { variant ->
|
||||
variant.outputs.each { output ->
|
||||
if (versionCodeVal) {
|
||||
output.versionCode.set(Integer.parseInt(versionCodeVal))
|
||||
}
|
||||
if (versionNameVal) {
|
||||
output.versionName.set(versionNameVal)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"
|
||||
`;
|
||||
@@ -200,7 +285,12 @@ android {
|
||||
versionName = versionNameVal
|
||||
}
|
||||
}
|
||||
applicationVariants.all { variant ->
|
||||
}
|
||||
|
||||
// AGP 9's new DSL removes the applicationVariants API, we set the version on each output through the androidComponents API instead.
|
||||
def easAndroidExtension = project.extensions.getByName('android')
|
||||
if (easAndroidExtension.hasProperty('applicationVariants')) {
|
||||
easAndroidExtension.applicationVariants.all { variant ->
|
||||
variant.outputs.each { output ->
|
||||
if (versionCodeVal) {
|
||||
output.versionCodeOverride = Integer.parseInt(versionCodeVal)
|
||||
@@ -210,6 +300,18 @@ android {
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
def easAndroidComponents = project.extensions.getByName('androidComponents')
|
||||
easAndroidComponents.onVariants(easAndroidComponents.selector().all()) { variant ->
|
||||
variant.outputs.each { output ->
|
||||
if (versionCodeVal) {
|
||||
output.versionCode.set(Integer.parseInt(versionCodeVal))
|
||||
}
|
||||
if (versionNameVal) {
|
||||
output.versionName.set(versionNameVal)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"
|
||||
`;
|
||||
@@ -237,7 +339,12 @@ android {
|
||||
versionName = versionNameVal
|
||||
}
|
||||
}
|
||||
applicationVariants.all { variant ->
|
||||
}
|
||||
|
||||
// AGP 9's new DSL removes the applicationVariants API, we set the version on each output through the androidComponents API instead.
|
||||
def easAndroidExtension = project.extensions.getByName('android')
|
||||
if (easAndroidExtension.hasProperty('applicationVariants')) {
|
||||
easAndroidExtension.applicationVariants.all { variant ->
|
||||
variant.outputs.each { output ->
|
||||
if (versionCodeVal) {
|
||||
output.versionCodeOverride = Integer.parseInt(versionCodeVal)
|
||||
@@ -247,6 +354,18 @@ android {
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
def easAndroidComponents = project.extensions.getByName('androidComponents')
|
||||
easAndroidComponents.onVariants(easAndroidComponents.selector().all()) { variant ->
|
||||
variant.outputs.each { output ->
|
||||
if (versionCodeVal) {
|
||||
output.versionCode.set(Integer.parseInt(versionCodeVal))
|
||||
}
|
||||
if (versionNameVal) {
|
||||
output.versionName.set(versionNameVal)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"
|
||||
`;
|
||||
@@ -274,7 +393,12 @@ android {
|
||||
versionName = versionNameVal
|
||||
}
|
||||
}
|
||||
applicationVariants.all { variant ->
|
||||
}
|
||||
|
||||
// AGP 9's new DSL removes the applicationVariants API, we set the version on each output through the androidComponents API instead.
|
||||
def easAndroidExtension = project.extensions.getByName('android')
|
||||
if (easAndroidExtension.hasProperty('applicationVariants')) {
|
||||
easAndroidExtension.applicationVariants.all { variant ->
|
||||
variant.outputs.each { output ->
|
||||
if (versionCodeVal) {
|
||||
output.versionCodeOverride = Integer.parseInt(versionCodeVal)
|
||||
@@ -284,6 +408,18 @@ android {
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
def easAndroidComponents = project.extensions.getByName('androidComponents')
|
||||
easAndroidComponents.onVariants(easAndroidComponents.selector().all()) { variant ->
|
||||
variant.outputs.each { output ->
|
||||
if (versionCodeVal) {
|
||||
output.versionCode.set(Integer.parseInt(versionCodeVal))
|
||||
}
|
||||
if (versionNameVal) {
|
||||
output.versionName.set(versionNameVal)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"
|
||||
`;
|
||||
@@ -18,6 +18,18 @@ const ARGENT_ARTIFACT_UPLOAD_POLL_INTERVAL_MS = 5_000;
|
||||
const ARGENT_ARTIFACT_UPLOAD_CLEANUP_TIMEOUT_MS = 30_000;
|
||||
const ARGENT_ARTIFACT_FETCH_TIMEOUT_MS = 10_000;
|
||||
|
||||
// Kinds the EAS dashboard groups device run session artifacts by. Only media types the dashboard
|
||||
// renders specially are mapped; anything else stays unclassified and lands in its "Other" group.
|
||||
// Note that these kinds only affect grouping and labelling. The dashboard gates its inline video
|
||||
// player on the `__eas_screen_recording` metadata flag, which Argent artifacts deliberately do not
|
||||
// set, so a `screen-recording` kind here does not add a player to the session page.
|
||||
const ARGENT_ARTIFACT_KIND_BY_MIME_TYPE = new Map<string, string>([
|
||||
['image/png', 'screenshot'],
|
||||
['image/jpeg', 'screenshot'],
|
||||
['video/mp4', 'screen-recording'],
|
||||
['video/quicktime', 'screen-recording'],
|
||||
]);
|
||||
|
||||
const ArgentArtifactSchema = z.object({
|
||||
id: z.string(),
|
||||
filename: z.string(),
|
||||
@@ -30,6 +42,17 @@ const ArgentArtifactsListResponseSchema = z.object({
|
||||
|
||||
type ArgentArtifact = z.infer<typeof ArgentArtifactSchema>;
|
||||
|
||||
function getArgentArtifactKind(artifact: ArgentArtifact): string | undefined {
|
||||
// Directories are repackaged as a tarball before upload, so the reported media type describes the
|
||||
// contents rather than the file we actually store.
|
||||
if (artifact.isDirectory) {
|
||||
return undefined;
|
||||
}
|
||||
// Media types are case-insensitive and may carry parameters, e.g. `image/png; charset=binary`.
|
||||
const mediaType = artifact.mimeType.split(';')[0].trim().toLowerCase();
|
||||
return ARGENT_ARTIFACT_KIND_BY_MIME_TYPE.get(mediaType);
|
||||
}
|
||||
|
||||
export async function pollArgentArtifactsForUploadAsync(
|
||||
ctx: CustomBuildContext,
|
||||
{
|
||||
@@ -184,7 +207,7 @@ export async function uploadArgentArtifactAsync(
|
||||
artifactId: artifact.id,
|
||||
name: `${filename} (${artifact.id})`,
|
||||
filename,
|
||||
kind: undefined,
|
||||
kind: getArgentArtifactKind(artifact),
|
||||
size,
|
||||
stream: createReadStream(temporaryArtifactPath),
|
||||
});
|
||||
|
||||
@@ -6,6 +6,7 @@ import { z } from 'zod';
|
||||
import { type CustomBuildContext } from '../../customBuildContext';
|
||||
import {
|
||||
type DeviceRunSessionEvent,
|
||||
type DeviceRunSessionEventCollection,
|
||||
type DeviceRunSessionEventParseFailure,
|
||||
startDeviceRunSessionEventCollectionAsync,
|
||||
} from './deviceRunSessionEvents';
|
||||
@@ -75,7 +76,7 @@ export async function startArgentEventCollectionAsync({
|
||||
eventLogPath: string;
|
||||
logger: bunyan;
|
||||
pollIntervalMs?: number;
|
||||
}): Promise<{ stopAsync: () => Promise<void> }> {
|
||||
}): Promise<DeviceRunSessionEventCollection> {
|
||||
return startDeviceRunSessionEventCollectionAsync({
|
||||
ctx,
|
||||
deviceRunSessionId,
|
||||
|
||||
@@ -94,6 +94,17 @@ type EventFileState = {
|
||||
decoder: StringDecoder;
|
||||
};
|
||||
|
||||
export type DeviceRunSessionEventCollection = {
|
||||
stopAsync: () => Promise<void>;
|
||||
/**
|
||||
* Local arrival time of the most recently collected event, or `undefined`
|
||||
* when no event has been observed yet. Uses the collector's clock rather
|
||||
* than the event's embedded timestamp so producer clock skew cannot affect
|
||||
* idle detection.
|
||||
*/
|
||||
getLastEventObservedAt: () => Date | undefined;
|
||||
};
|
||||
|
||||
export async function startDeviceRunSessionEventCollectionAsync({
|
||||
ctx,
|
||||
deviceRunSessionId,
|
||||
@@ -106,7 +117,7 @@ export async function startDeviceRunSessionEventCollectionAsync({
|
||||
source: DeviceRunSessionEventSource;
|
||||
logger: bunyan;
|
||||
pollIntervalMs?: number;
|
||||
}): Promise<{ stopAsync: () => Promise<void> }> {
|
||||
}): Promise<DeviceRunSessionEventCollection> {
|
||||
const { producer } = source;
|
||||
let didReportEventLogFailure = false;
|
||||
const reportEventLogFailure = (error: Error, operation: 'setup' | 'cleanup'): void => {
|
||||
@@ -136,7 +147,13 @@ export async function startDeviceRunSessionEventCollectionAsync({
|
||||
const error = err instanceof Error ? err : new Error(String(err));
|
||||
logger.warn({ err: error }, 'Could not persist device run session events to the artifact.');
|
||||
reportEventLogFailure(error, 'setup');
|
||||
return { stopAsync: async () => {} };
|
||||
return {
|
||||
stopAsync: async () => {},
|
||||
// Collection is disabled, so session activity is invisible here. Report
|
||||
// fresh activity so an enabled idle timeout never stops a session it
|
||||
// cannot observe.
|
||||
getLastEventObservedAt: () => new Date(),
|
||||
};
|
||||
}
|
||||
|
||||
let didReportRealtimeLogFailure = false;
|
||||
@@ -166,6 +183,7 @@ export async function startDeviceRunSessionEventCollectionAsync({
|
||||
|
||||
const states = new Map<string, EventFileState>();
|
||||
const controller = new AbortController();
|
||||
let lastEventObservedAt: Date | undefined;
|
||||
let parseFailureCount = 0;
|
||||
const parseFailureCounts: Record<DeviceRunSessionEventParseFailure, number> = {
|
||||
'invalid-json': 0,
|
||||
@@ -190,6 +208,7 @@ export async function startDeviceRunSessionEventCollectionAsync({
|
||||
source,
|
||||
deviceRunSessionId,
|
||||
writeEvent: event => {
|
||||
lastEventObservedAt = new Date();
|
||||
eventLogStream.write(event);
|
||||
realtimeLogStream?.write({ ...event, logId: event.eventId });
|
||||
},
|
||||
@@ -259,6 +278,7 @@ export async function startDeviceRunSessionEventCollectionAsync({
|
||||
});
|
||||
|
||||
return {
|
||||
getLastEventObservedAt: () => lastEventObservedAt,
|
||||
stopAsync: async () => {
|
||||
controller.abort();
|
||||
await pollingPromise;
|
||||
@@ -310,7 +330,7 @@ function createRealtimeLogStream(
|
||||
? 'http://localhost:4999/logs/'
|
||||
: ctx.env.EXPO_STAGING
|
||||
? 'https://staging-logs.expo.dev/logs/'
|
||||
: undefined;
|
||||
: 'https://logs.expo.dev/logs/';
|
||||
const jobRunId = ctx.env.EAS_BUILD_ID;
|
||||
const robotAccessToken = ctx.job.secrets?.robotAccessToken;
|
||||
if (!baseUrl || !jobRunId || !robotAccessToken) {
|
||||
|
||||
@@ -50,6 +50,17 @@ const DEVICE_RUN_SESSION_STATUS_QUERY = graphql(`
|
||||
}
|
||||
`);
|
||||
|
||||
const ENSURE_DEVICE_RUN_SESSION_STOPPED_MUTATION = graphql(`
|
||||
mutation EnsureDeviceRunSessionStopped($deviceRunSessionId: ID!) {
|
||||
deviceRunSession {
|
||||
ensureDeviceRunSessionStopped(deviceRunSessionId: $deviceRunSessionId) {
|
||||
id
|
||||
status
|
||||
}
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
const DEVICE_RUN_SESSION_STATUS_POLL_INTERVAL_MS = 5_000;
|
||||
|
||||
export function getDeviceRunSessionIdOrThrow(env: BuildStepEnv): string {
|
||||
@@ -118,23 +129,56 @@ export async function selectXcodeDeveloperDirectoryAsync({
|
||||
});
|
||||
}
|
||||
|
||||
export type DeviceRunSessionIdleTimeout = {
|
||||
/** Stop the session after this many minutes without observed activity. */
|
||||
maxIdleTimeMinutes: number;
|
||||
/**
|
||||
* Local arrival time of the most recent session event, or `undefined` when
|
||||
* no event has been observed yet. The idle clock starts when the wait
|
||||
* begins, so a session nobody ever connects to still times out.
|
||||
*/
|
||||
getLastEventObservedAt: () => Date | undefined;
|
||||
};
|
||||
|
||||
export async function waitForDeviceRunSessionStoppedAsync({
|
||||
ctx,
|
||||
deviceRunSessionId,
|
||||
logger,
|
||||
signal,
|
||||
idleTimeout,
|
||||
}: {
|
||||
ctx: CustomBuildContext;
|
||||
deviceRunSessionId: string;
|
||||
logger: bunyan;
|
||||
signal?: AbortSignal;
|
||||
idleTimeout?: DeviceRunSessionIdleTimeout;
|
||||
}): Promise<void> {
|
||||
logger.info(
|
||||
`Remote session is live. Polling device run session ${deviceRunSessionId} until it is stopped.`
|
||||
);
|
||||
if (idleTimeout) {
|
||||
logger.info(
|
||||
`The session stops automatically after ${idleTimeout.maxIdleTimeMinutes} minute(s) without activity.`
|
||||
);
|
||||
}
|
||||
let pollErrorCount = 0;
|
||||
let lastActivityAt = new Date();
|
||||
|
||||
while (!signal?.aborted) {
|
||||
if (idleTimeout) {
|
||||
const lastEventObservedAt = idleTimeout.getLastEventObservedAt();
|
||||
if (lastEventObservedAt && lastEventObservedAt > lastActivityAt) {
|
||||
lastActivityAt = lastEventObservedAt;
|
||||
}
|
||||
if (Date.now() - lastActivityAt.getTime() >= idleTimeout.maxIdleTimeMinutes * 60_000) {
|
||||
logger.info(
|
||||
`Device run session ${deviceRunSessionId} had no activity for ` +
|
||||
`${idleTimeout.maxIdleTimeMinutes} minute(s) (max idle time). Stopping the session.`
|
||||
);
|
||||
await ensureDeviceRunSessionStoppedSafelyAsync({ ctx, deviceRunSessionId, logger });
|
||||
return;
|
||||
}
|
||||
}
|
||||
try {
|
||||
const result = await ctx.graphqlClient
|
||||
.query(DEVICE_RUN_SESSION_STATUS_QUERY, { deviceRunSessionId })
|
||||
@@ -174,6 +218,37 @@ export async function waitForDeviceRunSessionStoppedAsync({
|
||||
}
|
||||
}
|
||||
|
||||
// Best effort: when this fails, the caller still tears the session down and the
|
||||
// job run finishes, which clients also treat as the session ending.
|
||||
async function ensureDeviceRunSessionStoppedSafelyAsync({
|
||||
ctx,
|
||||
deviceRunSessionId,
|
||||
logger,
|
||||
}: {
|
||||
ctx: CustomBuildContext;
|
||||
deviceRunSessionId: string;
|
||||
logger: bunyan;
|
||||
}): Promise<void> {
|
||||
try {
|
||||
const result = await ctx.graphqlClient
|
||||
.mutation(ENSURE_DEVICE_RUN_SESSION_STOPPED_MUTATION, { deviceRunSessionId })
|
||||
.toPromise();
|
||||
if (result.error) {
|
||||
throw result.error;
|
||||
}
|
||||
} catch (err) {
|
||||
const error = err instanceof Error ? err : new Error(String(err));
|
||||
Sentry.capture('Could not mark idle device run session as stopped', error, {
|
||||
level: 'warning',
|
||||
extras: { deviceRunSessionId },
|
||||
});
|
||||
logger.warn(
|
||||
{ err: error },
|
||||
`Could not mark device run session ${deviceRunSessionId} as stopped. The session job ends anyway.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function sleepUntilAbortedAsync(
|
||||
timeoutMs: number,
|
||||
signal: AbortSignal | undefined
|
||||
|
||||
@@ -36,17 +36,19 @@ export async function uploadServeSimMetricsFileAsync(
|
||||
logger.info(`serve-sim metrics file for ${udid} is empty; skipping upload.`);
|
||||
return;
|
||||
}
|
||||
const deviceName = typeof metadata?.deviceName === 'string' ? metadata.deviceName : undefined;
|
||||
try {
|
||||
logger.info(`Uploading serve-sim metrics for ${udid} (${formatBytes(size)}).`);
|
||||
logger.info(`Uploading serve-sim metrics for ${deviceName ?? udid} (${formatBytes(size)}).`);
|
||||
await uploadDeviceRunSessionArtifactAsync(ctx, {
|
||||
deviceRunSessionId,
|
||||
artifactId: `metrics-${udid}`,
|
||||
name: `Performance metrics (${udid.slice(0, 8)})`,
|
||||
name: `Performance metrics (${deviceName ?? udid.slice(0, 8)})`,
|
||||
filename: METRICS_ARTIFACT_FILENAME,
|
||||
kind: 'performance-metrics',
|
||||
metadata: {
|
||||
__eas_type: 'performance-metrics',
|
||||
udid,
|
||||
...(deviceName && { deviceName }),
|
||||
...(metadata && {
|
||||
hostCores: metadata.hostCores,
|
||||
sampleIntervalMs: metadata.sampleIntervalMs,
|
||||
|
||||
@@ -20,7 +20,12 @@ android {
|
||||
versionName = versionNameVal
|
||||
}
|
||||
}
|
||||
applicationVariants.all { variant ->
|
||||
}
|
||||
|
||||
// AGP 9's new DSL removes the applicationVariants API, we set the version on each output through the androidComponents API instead.
|
||||
def easAndroidExtension = project.extensions.getByName('android')
|
||||
if (easAndroidExtension.hasProperty('applicationVariants')) {
|
||||
easAndroidExtension.applicationVariants.all { variant ->
|
||||
variant.outputs.each { output ->
|
||||
if (versionCodeVal) {
|
||||
output.versionCodeOverride = Integer.parseInt(versionCodeVal)
|
||||
@@ -30,5 +35,17 @@ android {
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
def easAndroidComponents = project.extensions.getByName('androidComponents')
|
||||
easAndroidComponents.onVariants(easAndroidComponents.selector().all()) { variant ->
|
||||
variant.outputs.each { output ->
|
||||
if (versionCodeVal) {
|
||||
output.versionCode.set(Integer.parseInt(versionCodeVal))
|
||||
}
|
||||
if (versionNameVal) {
|
||||
output.versionName.set(versionNameVal)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
@@ -1,8 +1,17 @@
|
||||
import { EasCliNpmTags } from '@expo/eas-build-job';
|
||||
import {
|
||||
EasCliNpmTags,
|
||||
EasCliVersionsFetchTimeoutError,
|
||||
fetchEasCliVersionsAsync,
|
||||
} from '@expo/eas-build-job';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
|
||||
import { resolveEasCommandPrefixAndEnvAsync, runEasCliCommand } from '../easCli';
|
||||
import {
|
||||
resetCachedEasCliVersionsForTest,
|
||||
resolveEasCommandPrefixAndEnvAsync,
|
||||
runEasCliCommand,
|
||||
} from '../easCli';
|
||||
import { isAtLeastNpm7Async } from '../packageManager';
|
||||
import { Sentry } from '../../sentry';
|
||||
|
||||
jest.mock('@expo/turtle-spawn', () => ({
|
||||
__esModule: true,
|
||||
@@ -12,12 +21,29 @@ jest.mock('../packageManager', () => ({
|
||||
...jest.requireActual('../packageManager'),
|
||||
isAtLeastNpm7Async: jest.fn(async () => true),
|
||||
}));
|
||||
jest.mock('@expo/eas-build-job', () => ({
|
||||
...jest.requireActual('@expo/eas-build-job'),
|
||||
fetchEasCliVersionsAsync: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../sentry', () => ({
|
||||
Sentry: { capture: jest.fn() },
|
||||
}));
|
||||
|
||||
const STAGING_VERSION = 'staging-version';
|
||||
const PRODUCTION_VERSION = 'production-version';
|
||||
|
||||
describe(resolveEasCommandPrefixAndEnvAsync, () => {
|
||||
const originalEnvironment = process.env.ENVIRONMENT;
|
||||
|
||||
beforeEach(() => {
|
||||
resetCachedEasCliVersionsForTest();
|
||||
jest.mocked(spawn).mockReset();
|
||||
jest.mocked(Sentry.capture).mockReset();
|
||||
jest.mocked(fetchEasCliVersionsAsync).mockReset();
|
||||
jest.mocked(fetchEasCliVersionsAsync).mockResolvedValue({
|
||||
STAGING: STAGING_VERSION,
|
||||
PRODUCTION: PRODUCTION_VERSION,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -40,9 +66,10 @@ describe(resolveEasCommandPrefixAndEnvAsync, () => {
|
||||
['--help'],
|
||||
expect.objectContaining({ stdio: ['ignore', 'pipe', 'pipe'] })
|
||||
);
|
||||
expect(fetchEasCliVersionsAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('resolves staging tag in development when easd --help fails', async () => {
|
||||
it('resolves the staging version in development when easd --help fails', async () => {
|
||||
process.env.ENVIRONMENT = 'development';
|
||||
jest.mocked(spawn).mockResolvedValueOnce({ status: 1 } as any);
|
||||
|
||||
@@ -50,12 +77,12 @@ describe(resolveEasCommandPrefixAndEnvAsync, () => {
|
||||
|
||||
expect(result).toEqual({
|
||||
cmd: 'npx',
|
||||
args: ['-y', `eas-cli@${EasCliNpmTags.STAGING}`],
|
||||
args: ['-y', `eas-cli@${STAGING_VERSION}`],
|
||||
extraEnv: {},
|
||||
});
|
||||
});
|
||||
|
||||
it('resolves staging tag in development when easd probe throws', async () => {
|
||||
it('resolves the staging version in development when easd probe throws', async () => {
|
||||
process.env.ENVIRONMENT = 'development';
|
||||
jest.mocked(spawn).mockRejectedValueOnce(new Error('easd not installed') as any);
|
||||
|
||||
@@ -63,31 +90,80 @@ describe(resolveEasCommandPrefixAndEnvAsync, () => {
|
||||
|
||||
expect(result).toEqual({
|
||||
cmd: 'npx',
|
||||
args: ['-y', `eas-cli@${EasCliNpmTags.STAGING}`],
|
||||
args: ['-y', `eas-cli@${STAGING_VERSION}`],
|
||||
extraEnv: {},
|
||||
});
|
||||
});
|
||||
|
||||
it('resolves staging tag and EXPO_STAGING env in staging environment', async () => {
|
||||
it('resolves the staging version and EXPO_STAGING env in staging environment', async () => {
|
||||
process.env.ENVIRONMENT = 'staging';
|
||||
const result = await resolveEasCommandPrefixAndEnvAsync();
|
||||
expect(result).toEqual({
|
||||
cmd: 'npx',
|
||||
args: ['-y', `eas-cli@${EasCliNpmTags.STAGING}`],
|
||||
args: ['-y', `eas-cli@${STAGING_VERSION}`],
|
||||
extraEnv: { EXPO_STAGING: '1' },
|
||||
});
|
||||
expect(spawn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('resolves production tag by default', async () => {
|
||||
it('resolves the production version by default', async () => {
|
||||
process.env.ENVIRONMENT = 'production';
|
||||
const result = await resolveEasCommandPrefixAndEnvAsync();
|
||||
expect(result).toEqual({
|
||||
cmd: 'npx',
|
||||
args: ['-y', `eas-cli@${EasCliNpmTags.PRODUCTION}`],
|
||||
args: ['-y', `eas-cli@${PRODUCTION_VERSION}`],
|
||||
extraEnv: {},
|
||||
});
|
||||
expect(spawn).not.toHaveBeenCalled();
|
||||
expect(Sentry.capture).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('fetches cli-versions.json only once across multiple calls', async () => {
|
||||
process.env.ENVIRONMENT = 'production';
|
||||
await resolveEasCommandPrefixAndEnvAsync();
|
||||
await resolveEasCommandPrefixAndEnvAsync();
|
||||
expect(fetchEasCliVersionsAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('falls back to the npm dist-tags and reports to Sentry when fetching cli-versions.json fails', async () => {
|
||||
const fetchError = new Error('not found');
|
||||
jest.mocked(fetchEasCliVersionsAsync).mockRejectedValue(fetchError);
|
||||
process.env.ENVIRONMENT = 'staging';
|
||||
const stagingResult = await resolveEasCommandPrefixAndEnvAsync();
|
||||
expect(stagingResult).toEqual({
|
||||
cmd: 'npx',
|
||||
args: ['-y', `eas-cli@${EasCliNpmTags.STAGING}`],
|
||||
extraEnv: { EXPO_STAGING: '1' },
|
||||
});
|
||||
|
||||
process.env.ENVIRONMENT = 'production';
|
||||
const productionResult = await resolveEasCommandPrefixAndEnvAsync();
|
||||
expect(productionResult).toEqual({
|
||||
cmd: 'npx',
|
||||
args: ['-y', `eas-cli@${EasCliNpmTags.PRODUCTION}`],
|
||||
extraEnv: {},
|
||||
});
|
||||
|
||||
expect(Sentry.capture).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Failed to fetch cli-versions.json'),
|
||||
fetchError,
|
||||
expect.objectContaining({ level: 'warning' })
|
||||
);
|
||||
});
|
||||
|
||||
it('reports a timeout-specific Sentry error when the fetch times out', async () => {
|
||||
const timeoutError = new EasCliVersionsFetchTimeoutError('https://example.com', 10_000);
|
||||
jest.mocked(fetchEasCliVersionsAsync).mockRejectedValue(timeoutError);
|
||||
process.env.ENVIRONMENT = 'production';
|
||||
|
||||
const result = await resolveEasCommandPrefixAndEnvAsync();
|
||||
expect(result.args).toContain(`eas-cli@${EasCliNpmTags.PRODUCTION}`);
|
||||
|
||||
expect(Sentry.capture).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Timed out fetching cli-versions.json'),
|
||||
timeoutError,
|
||||
expect.objectContaining({ level: 'warning' })
|
||||
);
|
||||
});
|
||||
|
||||
it('omits -y when npm is older than v7', async () => {
|
||||
@@ -96,7 +172,7 @@ describe(resolveEasCommandPrefixAndEnvAsync, () => {
|
||||
const result = await resolveEasCommandPrefixAndEnvAsync();
|
||||
expect(result).toEqual({
|
||||
cmd: 'npx',
|
||||
args: [`eas-cli@${EasCliNpmTags.PRODUCTION}`],
|
||||
args: [`eas-cli@${PRODUCTION_VERSION}`],
|
||||
extraEnv: {},
|
||||
});
|
||||
});
|
||||
@@ -106,7 +182,13 @@ describe(runEasCliCommand, () => {
|
||||
const originalEnvironment = process.env.ENVIRONMENT;
|
||||
|
||||
beforeEach(() => {
|
||||
resetCachedEasCliVersionsForTest();
|
||||
jest.mocked(spawn).mockReset();
|
||||
jest.mocked(fetchEasCliVersionsAsync).mockReset();
|
||||
jest.mocked(fetchEasCliVersionsAsync).mockResolvedValue({
|
||||
STAGING: STAGING_VERSION,
|
||||
PRODUCTION: PRODUCTION_VERSION,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -126,7 +208,7 @@ describe(runEasCliCommand, () => {
|
||||
|
||||
expect(spawn).toHaveBeenCalledWith(
|
||||
'npx',
|
||||
expect.arrayContaining([`eas-cli@${EasCliNpmTags.STAGING}`, 'deploy', '--json']),
|
||||
expect.arrayContaining([`eas-cli@${STAGING_VERSION}`, 'deploy', '--json']),
|
||||
expect.objectContaining({
|
||||
cwd: '/tmp/project',
|
||||
env: expect.objectContaining({
|
||||
|
||||
@@ -1,7 +1,53 @@
|
||||
import spawn, { SpawnOptions, SpawnResult } from '@expo/turtle-spawn';
|
||||
import { EasCliNpmTags, Env } from '@expo/eas-build-job';
|
||||
import {
|
||||
EasCliNpmTags,
|
||||
EasCliVersions,
|
||||
EasCliVersionsFetchTimeoutError,
|
||||
Env,
|
||||
fetchEasCliVersionsAsync,
|
||||
} from '@expo/eas-build-job';
|
||||
|
||||
import { isAtLeastNpm7Async } from './packageManager';
|
||||
import { Sentry } from '../sentry';
|
||||
|
||||
let cachedEasCliVersionsPromise: Promise<EasCliVersions> | undefined;
|
||||
|
||||
/**
|
||||
* Resolves the `eas-cli` versions to install for staging/production. Prefers the
|
||||
* versions committed to `cli-versions.json` (fetched from expo/eas-cli); on any
|
||||
* failure (e.g. GitHub is unreachable or slow) reports to Sentry and falls back
|
||||
* to the `latest-eas-build*` npm dist-tags so the build can still proceed.
|
||||
*
|
||||
* Memoized: `resolveEasCommandPrefixAndEnvAsync` runs more than once per build,
|
||||
* so the (never-rejecting) result promise is cached to fetch GitHub at most once
|
||||
* per process.
|
||||
*/
|
||||
async function resolveEasCliVersionsAsync(): Promise<EasCliVersions> {
|
||||
if (!cachedEasCliVersionsPromise) {
|
||||
cachedEasCliVersionsPromise = fetchEasCliVersionsWithFallbackAsync();
|
||||
}
|
||||
return await cachedEasCliVersionsPromise;
|
||||
}
|
||||
|
||||
async function fetchEasCliVersionsWithFallbackAsync(): Promise<EasCliVersions> {
|
||||
try {
|
||||
return await fetchEasCliVersionsAsync();
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof EasCliVersionsFetchTimeoutError
|
||||
? 'Timed out fetching cli-versions.json; falling back to npm dist-tags'
|
||||
: 'Failed to fetch cli-versions.json; falling back to npm dist-tags';
|
||||
Sentry.capture(message, error instanceof Error ? error : new Error(String(error)), {
|
||||
level: 'warning',
|
||||
});
|
||||
return { STAGING: EasCliNpmTags.STAGING, PRODUCTION: EasCliNpmTags.PRODUCTION };
|
||||
}
|
||||
}
|
||||
|
||||
/** Test-only: clears the memoized versions promise so each test fetches fresh. */
|
||||
export function resetCachedEasCliVersionsForTest(): void {
|
||||
cachedEasCliVersionsPromise = undefined;
|
||||
}
|
||||
|
||||
async function probeEasdAsync(): Promise<boolean> {
|
||||
try {
|
||||
@@ -24,21 +70,24 @@ export async function resolveEasCommandPrefixAndEnvAsync(): Promise<{
|
||||
if (await probeEasdAsync()) {
|
||||
return { cmd: 'easd', args: [], extraEnv: {} };
|
||||
}
|
||||
const versions = await resolveEasCliVersionsAsync();
|
||||
return {
|
||||
cmd: 'npx',
|
||||
args: [...npxArgsPrefix, `eas-cli@${EasCliNpmTags.STAGING}`],
|
||||
args: [...npxArgsPrefix, `eas-cli@${versions.STAGING}`],
|
||||
extraEnv: {},
|
||||
};
|
||||
} else if (process.env.ENVIRONMENT === 'staging') {
|
||||
const versions = await resolveEasCliVersionsAsync();
|
||||
return {
|
||||
cmd: 'npx',
|
||||
args: [...npxArgsPrefix, `eas-cli@${EasCliNpmTags.STAGING}`],
|
||||
args: [...npxArgsPrefix, `eas-cli@${versions.STAGING}`],
|
||||
extraEnv: { EXPO_STAGING: '1' },
|
||||
};
|
||||
} else {
|
||||
const versions = await resolveEasCliVersionsAsync();
|
||||
return {
|
||||
cmd: 'npx',
|
||||
args: [...npxArgsPrefix, `eas-cli@${EasCliNpmTags.PRODUCTION}`],
|
||||
args: [...npxArgsPrefix, `eas-cli@${versions.PRODUCTION}`],
|
||||
extraEnv: {},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "create-eas-build-function",
|
||||
"version": "21.5.0",
|
||||
"version": "21.7.1",
|
||||
"description": "Create functions for use in EAS Build custom builds.",
|
||||
"keywords": [
|
||||
"expo",
|
||||
@@ -35,7 +35,7 @@
|
||||
"clean": "rimraf ./build/ \"*.tsbuildinfo\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/steps": "21.5.0"
|
||||
"@expo/steps": "21.7.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@expo/package-manager": "1.9.10",
|
||||
@@ -44,6 +44,7 @@
|
||||
"@types/node": "20.14.2",
|
||||
"@types/prompts": "^2.4.9",
|
||||
"@vercel/ncc": "^0.38.1",
|
||||
"arg": "^5.0.2",
|
||||
"chalk": "4.1.2",
|
||||
"fs-extra": "^11.2.0",
|
||||
"ora": "^6.3.1",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/eas-build-job",
|
||||
"version": "21.5.0",
|
||||
"version": "21.6.0",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "MIT",
|
||||
"author": "Expo <[email protected]>",
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import { EnvSchema, SshSettingsZ, StaticWorkflowInterpolationContextZ } from '../common';
|
||||
import {
|
||||
EasCliVersionsFetchTimeoutError,
|
||||
EnvSchema,
|
||||
SshSettingsZ,
|
||||
StaticWorkflowInterpolationContextZ,
|
||||
fetchEasCliVersionsAsync,
|
||||
} from '../common';
|
||||
|
||||
describe('EnvSchema', () => {
|
||||
it('accepts explicit undefined values', () => {
|
||||
@@ -594,3 +600,78 @@ describe('GitHub context event payload passthrough', () => {
|
||||
expect(parsed.github?.event).toMatchObject(context.github.event);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchEasCliVersionsAsync', () => {
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('fetches and parses cli-versions.json', async () => {
|
||||
const fetchSpy = jest.spyOn(global, 'fetch').mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ STAGING: '21.5.1', PRODUCTION: '21.5.0' }),
|
||||
} as Response);
|
||||
|
||||
await expect(fetchEasCliVersionsAsync()).resolves.toEqual({
|
||||
STAGING: '21.5.1',
|
||||
PRODUCTION: '21.5.0',
|
||||
});
|
||||
expect(fetchSpy).toHaveBeenCalledWith(
|
||||
'https://raw.githubusercontent.com/expo/eas-cli/main/cli-versions.json',
|
||||
expect.objectContaining({ signal: expect.any(AbortSignal) })
|
||||
);
|
||||
});
|
||||
|
||||
it('throws on a non-OK response', async () => {
|
||||
jest.spyOn(global, 'fetch').mockResolvedValue({
|
||||
ok: false,
|
||||
status: 404,
|
||||
json: async () => ({}),
|
||||
} as Response);
|
||||
|
||||
await expect(fetchEasCliVersionsAsync()).rejects.toThrow(/HTTP 404/);
|
||||
});
|
||||
|
||||
it('throws when the payload is missing a required property', async () => {
|
||||
jest.spyOn(global, 'fetch').mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ STAGING: '21.5.1' }),
|
||||
} as Response);
|
||||
|
||||
await expect(fetchEasCliVersionsAsync()).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('throws when a version is not valid semver', async () => {
|
||||
jest.spyOn(global, 'fetch').mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ STAGING: 'latest-eas-build-staging', PRODUCTION: '21.5.0' }),
|
||||
} as Response);
|
||||
|
||||
await expect(fetchEasCliVersionsAsync()).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('throws a timeout error when the request exceeds the timeout', async () => {
|
||||
jest.useFakeTimers();
|
||||
try {
|
||||
jest.spyOn(global, 'fetch').mockImplementation(
|
||||
(_url, init) =>
|
||||
new Promise<Response>((_resolve, reject) => {
|
||||
(init?.signal as AbortSignal | undefined)?.addEventListener('abort', () => {
|
||||
reject(new Error('The operation was aborted.'));
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
const assertion = expect(fetchEasCliVersionsAsync()).rejects.toBeInstanceOf(
|
||||
EasCliVersionsFetchTimeoutError
|
||||
);
|
||||
await jest.advanceTimersByTimeAsync(30_000);
|
||||
await assertion;
|
||||
} finally {
|
||||
jest.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import Joi from 'joi';
|
||||
import semver from 'semver';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { BuildPhase, BuildPhaseResult } from './logs';
|
||||
@@ -399,3 +400,64 @@ export enum EasCliNpmTags {
|
||||
STAGING = 'latest-eas-build-staging',
|
||||
PRODUCTION = 'latest-eas-build',
|
||||
}
|
||||
|
||||
/**
|
||||
* The `eas-cli` versions used by EAS Build for the staging and production
|
||||
* environments. Replaces the `latest-eas-build*` npm dist-tags: instead of
|
||||
* moving dist-tags, the versions are committed to `cli-versions.json` at the
|
||||
* root of the expo/eas-cli repository.
|
||||
*/
|
||||
export interface EasCliVersions {
|
||||
STAGING: string;
|
||||
PRODUCTION: string;
|
||||
}
|
||||
|
||||
const SemverStringZ = z
|
||||
.string()
|
||||
.refine(value => semver.valid(value) !== null, { message: 'Expected a valid semver version.' });
|
||||
|
||||
const EasCliVersionsZ = z.object({
|
||||
STAGING: SemverStringZ,
|
||||
PRODUCTION: SemverStringZ,
|
||||
});
|
||||
|
||||
const CLI_VERSIONS_URL = 'https://raw.githubusercontent.com/expo/eas-cli/main/cli-versions.json';
|
||||
const CLI_VERSIONS_FETCH_TIMEOUT_MS = 10_000;
|
||||
|
||||
/**
|
||||
* Thrown by {@link fetchEasCliVersionsAsync} when the request does not complete
|
||||
* within {@link CLI_VERSIONS_FETCH_TIMEOUT_MS}. Callers can distinguish this
|
||||
* from other fetch failures (e.g. to report it separately).
|
||||
*/
|
||||
export class EasCliVersionsFetchTimeoutError extends Error {
|
||||
constructor(url: string, timeoutMs: number) {
|
||||
super(`Timed out after ${timeoutMs}ms fetching ${url}.`);
|
||||
this.name = 'EasCliVersionsFetchTimeoutError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches and parses `cli-versions.json` from the `main` branch of
|
||||
* expo/eas-cli. Throws {@link EasCliVersionsFetchTimeoutError} if the request
|
||||
* exceeds {@link CLI_VERSIONS_FETCH_TIMEOUT_MS}, or a generic error if the file
|
||||
* cannot be fetched or does not match the expected shape; callers should fall
|
||||
* back to {@link EasCliNpmTags}.
|
||||
*/
|
||||
export async function fetchEasCliVersionsAsync(): Promise<EasCliVersions> {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), CLI_VERSIONS_FETCH_TIMEOUT_MS);
|
||||
try {
|
||||
const response = await fetch(CLI_VERSIONS_URL, { signal: controller.signal });
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch ${CLI_VERSIONS_URL} (HTTP ${response.status}).`);
|
||||
}
|
||||
return EasCliVersionsZ.parse(await response.json());
|
||||
} catch (error) {
|
||||
if (controller.signal.aborted) {
|
||||
throw new EasCliVersionsFetchTimeoutError(CLI_VERSIONS_URL, CLI_VERSIONS_FETCH_TIMEOUT_MS);
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,9 @@ export {
|
||||
BuildMode,
|
||||
BuildTrigger,
|
||||
EasCliNpmTags,
|
||||
EasCliVersions,
|
||||
EasCliVersionsFetchTimeoutError,
|
||||
fetchEasCliVersionsAsync,
|
||||
Env,
|
||||
EnvironmentSecret,
|
||||
EnvironmentSecretType,
|
||||
|
||||
+337
-125
File diff suppressed because it is too large.
Load diff
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "eas-cli",
|
||||
"version": "21.5.0",
|
||||
"version": "21.7.1",
|
||||
"description": "EAS command line tool",
|
||||
"keywords": [
|
||||
"cli",
|
||||
@@ -48,8 +48,8 @@
|
||||
"@expo/code-signing-certificates": "0.0.5",
|
||||
"@expo/config": "55.0.10",
|
||||
"@expo/config-plugins": "55.0.7",
|
||||
"@expo/eas-build-job": "21.5.0",
|
||||
"@expo/eas-json": "21.5.0",
|
||||
"@expo/eas-build-job": "21.6.0",
|
||||
"@expo/eas-json": "21.6.0",
|
||||
"@expo/env": "^1.0.0",
|
||||
"@expo/json-file": "8.3.3",
|
||||
"@expo/logger": "21.0.0",
|
||||
@@ -64,7 +64,7 @@
|
||||
"@expo/results": "1.0.0",
|
||||
"@expo/rudder-sdk-node": "1.1.1",
|
||||
"@expo/spawn-async": "1.7.2",
|
||||
"@expo/steps": "21.5.0",
|
||||
"@expo/steps": "21.7.1",
|
||||
"@expo/timeago.js": "1.0.0",
|
||||
"@oclif/core": "^4.8.3",
|
||||
"@oclif/plugin-autocomplete": "^3.2.40",
|
||||
@@ -118,6 +118,7 @@
|
||||
"prompts": "2.4.2",
|
||||
"qrcode-terminal": "0.12.0",
|
||||
"resolve-from": "5.0.0",
|
||||
"sandbox-cli-detector": "0.2.0",
|
||||
"semver": "7.5.4",
|
||||
"set-interval-async": "3.0.3",
|
||||
"slash": "3.0.0",
|
||||
@@ -221,6 +222,9 @@
|
||||
"secret": {
|
||||
"description": "manage project and account secrets"
|
||||
},
|
||||
"simulator": {
|
||||
"description": "manage remote simulator sessions"
|
||||
},
|
||||
"testflight": {
|
||||
"description": "read TestFlight feedback and crashes"
|
||||
},
|
||||
|
||||
@@ -7,6 +7,7 @@ import { Actor, getActorDisplayName } from '../user/User';
|
||||
import UserSettings from '../user/UserSettings';
|
||||
import { easCliVersion } from '../utils/easCli';
|
||||
import { getAgentTelemetryContext } from './agent';
|
||||
import { getSandboxTelemetryContext } from './sandbox';
|
||||
|
||||
const PLATFORM_TO_ANALYTICS_PLATFORM: Partial<Record<NodeJS.Platform, string>> = {
|
||||
darwin: 'Mac',
|
||||
@@ -227,10 +228,12 @@ class RudderstackAnalytics implements AnalyticsWithOrchestration {
|
||||
private getRudderStackContext(): Record<string, any> {
|
||||
const platform = PLATFORM_TO_ANALYTICS_PLATFORM[os.platform()] || os.platform();
|
||||
const agent = getAgentTelemetryContext();
|
||||
const sandboxProvider = getSandboxTelemetryContext();
|
||||
return {
|
||||
os: { name: platform, version: os.release() },
|
||||
device: { type: platform, model: platform },
|
||||
app: { name: 'eas cli', version: easCliVersion ?? undefined },
|
||||
...(sandboxProvider ? { sandbox_provider: sandboxProvider } : {}),
|
||||
...(agent ? { agent } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import RudderAnalytics from '@expo/rudder-sdk-node';
|
||||
import UserSettings from '../../user/UserSettings';
|
||||
import { CommandEvent, createAnalyticsAsync } from '../AnalyticsManager';
|
||||
import { getAgentTelemetryContext } from '../agent';
|
||||
import { getSandboxTelemetryContext } from '../sandbox';
|
||||
|
||||
const mockIdentify = jest.fn();
|
||||
const mockTrack = jest.fn();
|
||||
@@ -28,8 +29,12 @@ jest.mock('../../user/UserSettings', () => ({
|
||||
jest.mock('../agent', () => ({
|
||||
getAgentTelemetryContext: jest.fn(),
|
||||
}));
|
||||
jest.mock('../sandbox', () => ({
|
||||
getSandboxTelemetryContext: jest.fn(),
|
||||
}));
|
||||
|
||||
const getAgentTelemetryContextMock = jest.mocked(getAgentTelemetryContext);
|
||||
const getSandboxTelemetryContextMock = jest.mocked(getSandboxTelemetryContext);
|
||||
const userSettingsMock = jest.mocked(UserSettings);
|
||||
|
||||
const originalHttpsProxy = process.env.https_proxy;
|
||||
@@ -42,6 +47,8 @@ beforeEach(() => {
|
||||
jest.mocked(RudderAnalytics).mockClear();
|
||||
getAgentTelemetryContextMock.mockReset();
|
||||
getAgentTelemetryContextMock.mockReturnValue(null);
|
||||
getSandboxTelemetryContextMock.mockReset();
|
||||
getSandboxTelemetryContextMock.mockReturnValue(null);
|
||||
userSettingsMock.getAsync.mockImplementation(async (key, defaultValue) => {
|
||||
if (key === 'analyticsDeviceId') {
|
||||
return 'persistent-device-id';
|
||||
@@ -68,14 +75,17 @@ afterAll(() => {
|
||||
}
|
||||
});
|
||||
|
||||
it('omits agent context when no agent is detected', async () => {
|
||||
it('omits agent and sandbox context when neither is detected', async () => {
|
||||
const analytics = await createAnalyticsAsync();
|
||||
|
||||
analytics.logEvent(CommandEvent.ACTION, { action: 'eas build' });
|
||||
|
||||
expect(mockTrack).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
context: expect.not.objectContaining({ agent: expect.anything() }),
|
||||
context: expect.not.objectContaining({
|
||||
agent: expect.anything(),
|
||||
sandbox_provider: expect.anything(),
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
@@ -97,3 +107,18 @@ it('adds detected agent context to analytics events', async () => {
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('adds detected sandbox context to analytics events', async () => {
|
||||
getSandboxTelemetryContextMock.mockReturnValue('e2b');
|
||||
const analytics = await createAnalyticsAsync();
|
||||
|
||||
analytics.logEvent(CommandEvent.ACTION, { action: 'eas build' });
|
||||
|
||||
expect(mockTrack).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
context: expect.objectContaining({
|
||||
sandbox_provider: 'e2b',
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
export {};
|
||||
|
||||
const detectSandboxMock = jest.fn();
|
||||
const logDebugMock = jest.fn();
|
||||
|
||||
jest.mock('sandbox-cli-detector', () => ({
|
||||
detectSandbox: detectSandboxMock,
|
||||
}));
|
||||
|
||||
jest.mock('../../log', () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
debug: logDebugMock,
|
||||
},
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetModules();
|
||||
detectSandboxMock.mockReset();
|
||||
logDebugMock.mockReset();
|
||||
});
|
||||
|
||||
it('returns detected sandbox telemetry context', async () => {
|
||||
detectSandboxMock.mockReturnValue({
|
||||
detected: true,
|
||||
sandbox: { id: 'e2b', name: 'E2B' },
|
||||
});
|
||||
const { getSandboxTelemetryContext } = await import('../sandbox');
|
||||
|
||||
expect(getSandboxTelemetryContext()).toBe('e2b');
|
||||
});
|
||||
|
||||
it('returns null when no known sandbox is detected', async () => {
|
||||
detectSandboxMock.mockReturnValue({ detected: false, sandbox: null });
|
||||
const { getSandboxTelemetryContext } = await import('../sandbox');
|
||||
|
||||
expect(getSandboxTelemetryContext()).toBeNull();
|
||||
});
|
||||
|
||||
it('caches the detected sandbox telemetry context', async () => {
|
||||
detectSandboxMock.mockReturnValue({
|
||||
detected: true,
|
||||
sandbox: { id: 'e2b', name: 'E2B' },
|
||||
});
|
||||
const { getSandboxTelemetryContext } = await import('../sandbox');
|
||||
|
||||
expect(getSandboxTelemetryContext()).toBe('e2b');
|
||||
expect(getSandboxTelemetryContext()).toBe('e2b');
|
||||
expect(detectSandboxMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('returns null and logs debug details when detection throws', async () => {
|
||||
detectSandboxMock.mockImplementation(() => {
|
||||
throw new Error('boom');
|
||||
});
|
||||
const { getSandboxTelemetryContext } = await import('../sandbox');
|
||||
|
||||
expect(getSandboxTelemetryContext()).toBeNull();
|
||||
expect(logDebugMock).toHaveBeenCalledWith('Failed to detect sandbox:', 'boom');
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
import { detectSandbox } from 'sandbox-cli-detector';
|
||||
|
||||
import Log from '../log';
|
||||
|
||||
type SandboxTelemetryContext = string | null;
|
||||
|
||||
let sandboxTelemetryContext: SandboxTelemetryContext | undefined;
|
||||
|
||||
export function getSandboxTelemetryContext(): SandboxTelemetryContext {
|
||||
if (sandboxTelemetryContext === undefined) {
|
||||
sandboxTelemetryContext = resolveSandboxTelemetryContext();
|
||||
}
|
||||
|
||||
return sandboxTelemetryContext;
|
||||
}
|
||||
|
||||
function resolveSandboxTelemetryContext(): SandboxTelemetryContext {
|
||||
try {
|
||||
const { detected, sandbox } = detectSandbox();
|
||||
if (!detected || sandbox == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return sandbox.id;
|
||||
} catch (error: any) {
|
||||
Log.debug('Failed to detect sandbox:', error?.message ?? error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -35,18 +35,8 @@ describe(validateWorkflowLocalCompositeFunctionsAsync, () => {
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('resolves local composite functions from the EAS project directory in monorepos', async () => {
|
||||
const repositoryRoot = await fs.mkdtemp(
|
||||
path.join(os.tmpdir(), 'eas-workflow-functions-monorepo-')
|
||||
);
|
||||
const projectDir = path.join(repositoryRoot, 'apps', 'mobile');
|
||||
await fs.mkdir(projectDir, { recursive: true });
|
||||
await makeProjectWithCompositeFunctionAsync(
|
||||
projectDir,
|
||||
'setup',
|
||||
['runs:', ' steps:', ' - run: echo setup'].join('\n')
|
||||
);
|
||||
|
||||
it('throws when a local composite function referenced from job steps does not exist', async () => {
|
||||
const projectRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'eas-workflow-functions-missing-'));
|
||||
const workflow = {
|
||||
jobs: {
|
||||
job: {
|
||||
@@ -56,81 +46,12 @@ describe(validateWorkflowLocalCompositeFunctionsAsync, () => {
|
||||
};
|
||||
|
||||
await expect(
|
||||
validateWorkflowLocalCompositeFunctionsAsync(workflow, projectDir)
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('does not treat repository-root composite functions as valid when the EAS project is in a subdirectory', async () => {
|
||||
const repositoryRoot = await fs.mkdtemp(
|
||||
path.join(os.tmpdir(), 'eas-workflow-functions-monorepo-')
|
||||
);
|
||||
const projectDir = path.join(repositoryRoot, 'apps', 'mobile');
|
||||
await fs.mkdir(projectDir, { recursive: true });
|
||||
await makeProjectWithCompositeFunctionAsync(
|
||||
repositoryRoot,
|
||||
'setup',
|
||||
['runs:', ' steps:', ' - run: echo setup'].join('\n')
|
||||
);
|
||||
|
||||
const workflow = {
|
||||
jobs: {
|
||||
job: {
|
||||
steps: [{ uses: './.eas/functions/setup' }],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
await expect(
|
||||
validateWorkflowLocalCompositeFunctionsAsync(workflow, projectDir)
|
||||
validateWorkflowLocalCompositeFunctionsAsync(workflow, projectRoot)
|
||||
).rejects.toThrow(
|
||||
/Local composite function "\.\/\.eas\/functions\/setup" was referenced by a step but no such composite function exists/
|
||||
);
|
||||
});
|
||||
|
||||
it('ignores unreferenced malformed composite functions on disk', async () => {
|
||||
const projectRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'eas-workflow-functions-test-'));
|
||||
await makeProjectWithCompositeFunctionAsync(
|
||||
projectRoot,
|
||||
'broken',
|
||||
['name: Broken', 'runs:', ' steps: []'].join('\n')
|
||||
);
|
||||
const workflow = {
|
||||
jobs: {
|
||||
job: {
|
||||
steps: [{ run: 'echo hi' }],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
await expect(
|
||||
validateWorkflowLocalCompositeFunctionsAsync(workflow, projectRoot)
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('validates composite functions referenced by an arbitrary path (arbitrary path style)', async () => {
|
||||
const projectRoot = await fs.mkdtemp(
|
||||
path.join(os.tmpdir(), 'eas-workflow-functions-arbitrary-')
|
||||
);
|
||||
const functionDir = path.join(projectRoot, 'internal-functions', 'deploy');
|
||||
await fs.mkdir(functionDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(functionDir, 'function.yml'),
|
||||
['runs:', ' steps:', ' - run: echo deploy'].join('\n'),
|
||||
'utf-8'
|
||||
);
|
||||
const workflow = {
|
||||
jobs: {
|
||||
job: {
|
||||
steps: [{ uses: './internal-functions/deploy' }],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
await expect(
|
||||
validateWorkflowLocalCompositeFunctionsAsync(workflow, projectRoot)
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('rejects interpolated local composite function references', async () => {
|
||||
const projectRoot = await fs.mkdtemp(
|
||||
path.join(os.tmpdir(), 'eas-workflow-functions-interpolated-')
|
||||
@@ -148,12 +69,38 @@ describe(validateWorkflowLocalCompositeFunctionsAsync, () => {
|
||||
).rejects.toThrow(/must not contain interpolation/);
|
||||
});
|
||||
|
||||
it('throws when a referenced local composite function does not exist', async () => {
|
||||
const projectRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'eas-workflow-functions-missing-'));
|
||||
it('validates local composite functions referenced from job hooks', async () => {
|
||||
const projectRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'eas-workflow-functions-hooks-'));
|
||||
await makeProjectWithCompositeFunctionAsync(
|
||||
projectRoot,
|
||||
'setup',
|
||||
['runs:', ' steps:', ' - run: echo setup'].join('\n')
|
||||
);
|
||||
const workflow = {
|
||||
jobs: {
|
||||
job: {
|
||||
steps: [{ uses: './.eas/functions/setup' }],
|
||||
steps: [{ run: 'echo hi' }],
|
||||
hooks: {
|
||||
before_install_node_modules: [{ uses: './.eas/functions/setup' }],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
await expect(
|
||||
validateWorkflowLocalCompositeFunctionsAsync(workflow, projectRoot)
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('throws when a composite function referenced from a job hook does not exist', async () => {
|
||||
const projectRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'eas-workflow-functions-hooks-'));
|
||||
const workflow = {
|
||||
jobs: {
|
||||
job: {
|
||||
steps: [{ run: 'echo hi' }],
|
||||
hooks: {
|
||||
before_install_node_modules: [{ uses: './.eas/functions/setup' }],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -165,17 +112,20 @@ describe(validateWorkflowLocalCompositeFunctionsAsync, () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('validates cyclic local composite functions (cycles are reported at expansion time)', async () => {
|
||||
const projectRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'eas-workflow-functions-cycle-'));
|
||||
await makeProjectWithCompositeFunctionAsync(
|
||||
projectRoot,
|
||||
'loop',
|
||||
['runs:', ' steps:', ' - uses: ./.eas/functions/loop'].join('\n')
|
||||
);
|
||||
it('ignores non-object hooks and non-array hook values', async () => {
|
||||
const projectRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'eas-workflow-functions-hooks-'));
|
||||
const workflow = {
|
||||
jobs: {
|
||||
job: {
|
||||
steps: [{ uses: './.eas/functions/loop' }],
|
||||
garbageHooks: {
|
||||
steps: [{ run: 'echo hi' }],
|
||||
hooks: 'not an object',
|
||||
},
|
||||
garbageHookValue: {
|
||||
steps: [{ run: 'echo hi' }],
|
||||
hooks: {
|
||||
before_install_node_modules: 'not an array',
|
||||
after_install_node_modules: { uses: './.eas/functions/missing' },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -185,22 +135,24 @@ describe(validateWorkflowLocalCompositeFunctionsAsync, () => {
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('validates indirectly cyclic local composite functions (cycles are reported at expansion time)', async () => {
|
||||
const projectRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'eas-workflow-functions-cycle-'));
|
||||
await makeProjectWithCompositeFunctionAsync(
|
||||
projectRoot,
|
||||
'a',
|
||||
['runs:', ' steps:', ' - uses: ./.eas/functions/b'].join('\n')
|
||||
it('validates local composite functions referenced from defaults.hooks', async () => {
|
||||
const projectRoot = await fs.mkdtemp(
|
||||
path.join(os.tmpdir(), 'eas-workflow-functions-default-hooks-')
|
||||
);
|
||||
await makeProjectWithCompositeFunctionAsync(
|
||||
projectRoot,
|
||||
'b',
|
||||
['runs:', ' steps:', ' - uses: ./.eas/functions/a'].join('\n')
|
||||
'setup',
|
||||
['runs:', ' steps:', ' - run: echo setup'].join('\n')
|
||||
);
|
||||
const workflow = {
|
||||
defaults: {
|
||||
hooks: {
|
||||
before_install_node_modules: [{ uses: './.eas/functions/setup' }],
|
||||
},
|
||||
},
|
||||
jobs: {
|
||||
job: {
|
||||
steps: [{ uses: './.eas/functions/a' }],
|
||||
steps: [{ run: 'echo hi' }],
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -210,53 +162,124 @@ describe(validateWorkflowLocalCompositeFunctionsAsync, () => {
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('validates a shared local composite function referenced along multiple paths', async () => {
|
||||
const projectRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'eas-workflow-functions-diamond-'));
|
||||
await makeProjectWithCompositeFunctionAsync(
|
||||
projectRoot,
|
||||
'shared',
|
||||
['runs:', ' steps:', ' - run: echo shared'].join('\n')
|
||||
);
|
||||
await makeProjectWithCompositeFunctionAsync(
|
||||
projectRoot,
|
||||
'left',
|
||||
['runs:', ' steps:', ' - uses: ./.eas/functions/shared'].join('\n')
|
||||
);
|
||||
await makeProjectWithCompositeFunctionAsync(
|
||||
projectRoot,
|
||||
'right',
|
||||
['runs:', ' steps:', ' - uses: ./.eas/functions/shared'].join('\n')
|
||||
it('throws when a composite function referenced from defaults.hooks does not exist', async () => {
|
||||
const projectRoot = await fs.mkdtemp(
|
||||
path.join(os.tmpdir(), 'eas-workflow-functions-default-hooks-missing-')
|
||||
);
|
||||
const workflow = {
|
||||
defaults: {
|
||||
hooks: {
|
||||
before_install_node_modules: [{ uses: './.eas/functions/setup' }],
|
||||
},
|
||||
},
|
||||
jobs: {
|
||||
job: {
|
||||
steps: [{ uses: './.eas/functions/left' }, { uses: './.eas/functions/right' }],
|
||||
steps: [{ run: 'echo hi' }],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
await expect(
|
||||
validateWorkflowLocalCompositeFunctionsAsync(workflow, projectRoot)
|
||||
).rejects.toThrow(
|
||||
/Local composite function "\.\/\.eas\/functions\/setup" was referenced by a step but no such composite function exists/
|
||||
);
|
||||
});
|
||||
|
||||
it('ignores non-object defaults and non-object defaults.hooks', async () => {
|
||||
const projectRoot = await fs.mkdtemp(
|
||||
path.join(os.tmpdir(), 'eas-workflow-functions-default-hooks-garbage-')
|
||||
);
|
||||
const jobs = {
|
||||
job: {
|
||||
steps: [{ run: 'echo hi' }],
|
||||
},
|
||||
};
|
||||
|
||||
await expect(
|
||||
validateWorkflowLocalCompositeFunctionsAsync({ defaults: 'garbage', jobs }, projectRoot)
|
||||
).resolves.toBeUndefined();
|
||||
await expect(
|
||||
validateWorkflowLocalCompositeFunctionsAsync(
|
||||
{ defaults: { hooks: 'garbage' }, jobs },
|
||||
projectRoot
|
||||
)
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('throws when a referenced local composite function is malformed', async () => {
|
||||
const projectRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'eas-workflow-functions-test-'));
|
||||
it('resolves "./" paths against the project directory, not the repository root', async () => {
|
||||
const repoRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'eas-workflow-functions-monorepo-'));
|
||||
const projectDir = path.join(repoRoot, 'apps', 'mobile');
|
||||
await makeProjectWithCompositeFunctionAsync(
|
||||
projectRoot,
|
||||
'broken',
|
||||
['name: Broken', 'runs:', ' steps: []'].join('\n')
|
||||
repoRoot,
|
||||
'notify',
|
||||
['runs:', ' steps:', ' - run: echo repo root'].join('\n')
|
||||
);
|
||||
await makeProjectWithCompositeFunctionAsync(
|
||||
projectDir,
|
||||
'notify',
|
||||
['runs:', ' steps:', ' - run: echo app'].join('\n')
|
||||
);
|
||||
const workflow = {
|
||||
jobs: {
|
||||
job: {
|
||||
steps: [{ uses: './.eas/functions/broken' }],
|
||||
steps: [{ uses: './.eas/functions/notify' }],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
await expect(
|
||||
validateWorkflowLocalCompositeFunctionsAsync(workflow, projectRoot)
|
||||
).rejects.toThrow(/must declare at least one step under "runs.steps"/);
|
||||
validateWorkflowLocalCompositeFunctionsAsync(workflow, projectDir)
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('does not fall back to the repository root when the function is missing under the project directory', async () => {
|
||||
const repoRoot = await fs.mkdtemp(
|
||||
path.join(os.tmpdir(), 'eas-workflow-functions-monorepo-no-fallback-')
|
||||
);
|
||||
const projectDir = path.join(repoRoot, 'apps', 'mobile');
|
||||
await fs.mkdir(projectDir, { recursive: true });
|
||||
await makeProjectWithCompositeFunctionAsync(
|
||||
repoRoot,
|
||||
'notify',
|
||||
['runs:', ' steps:', ' - run: echo repo root'].join('\n')
|
||||
);
|
||||
const workflow = {
|
||||
jobs: {
|
||||
job: {
|
||||
steps: [{ uses: './.eas/functions/notify' }],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
await expect(
|
||||
validateWorkflowLocalCompositeFunctionsAsync(workflow, projectDir)
|
||||
).rejects.toThrow(
|
||||
/Local composite function "\.\/\.eas\/functions\/notify" was referenced by a step but no such composite function exists/
|
||||
);
|
||||
});
|
||||
|
||||
it('validates functions referenced with an explicit path above the project directory', async () => {
|
||||
const repoRoot = await fs.mkdtemp(
|
||||
path.join(os.tmpdir(), 'eas-workflow-functions-monorepo-upward-')
|
||||
);
|
||||
const projectDir = path.join(repoRoot, 'apps', 'mobile');
|
||||
await fs.mkdir(projectDir, { recursive: true });
|
||||
await makeProjectWithCompositeFunctionAsync(
|
||||
repoRoot,
|
||||
'notify',
|
||||
['runs:', ' steps:', ' - run: echo repo root'].join('\n')
|
||||
);
|
||||
const workflow = {
|
||||
jobs: {
|
||||
job: {
|
||||
steps: [{ uses: '../../.eas/functions/notify' }],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
await expect(
|
||||
validateWorkflowLocalCompositeFunctionsAsync(workflow, projectDir)
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -1,95 +1,39 @@
|
||||
import { CompositeFunctionConfig, CompositeFunctionConfigZ } from '@expo/eas-build-job';
|
||||
import {
|
||||
buildCompositeFunctionCatalogFromStepsAsync,
|
||||
resolveLocalCompositeFunctionPath,
|
||||
} from '@expo/steps';
|
||||
import { promises as fs } from 'fs';
|
||||
import path from 'path';
|
||||
import * as YAML from 'yaml';
|
||||
import { z } from 'zod';
|
||||
import { buildLocalCompositeFunctionCatalogAsync } from '@expo/steps';
|
||||
|
||||
import Log from '../../log';
|
||||
|
||||
const COMPOSITE_FUNCTION_FILE_EXTENSIONS = ['yml', 'yaml'] as const;
|
||||
|
||||
export async function validateWorkflowLocalCompositeFunctionsAsync(
|
||||
parsedYaml: any,
|
||||
projectDir: string
|
||||
): Promise<void> {
|
||||
await buildCompositeFunctionCatalogFromStepsAsync({
|
||||
await buildLocalCompositeFunctionCatalogAsync(projectDir, {
|
||||
rootSteps: stepsFromWorkflow(parsedYaml),
|
||||
loadCompositeFunction: compositeFunctionPath =>
|
||||
loadLocalCompositeFunctionConfigAsync(projectDir, compositeFunctionPath),
|
||||
logger: {
|
||||
debug: message => {
|
||||
Log.debug(message);
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function loadLocalCompositeFunctionConfigAsync(
|
||||
projectDir: string,
|
||||
compositeFunctionPath: string
|
||||
): Promise<CompositeFunctionConfig> {
|
||||
const resolvedPath = resolveLocalCompositeFunctionPath(projectDir, compositeFunctionPath);
|
||||
|
||||
for (const ext of COMPOSITE_FUNCTION_FILE_EXTENSIONS) {
|
||||
const config = await readCompositeFunctionConfigFileAsync(
|
||||
compositeFunctionPath,
|
||||
path.join(resolvedPath, `function.${ext}`)
|
||||
);
|
||||
if (config) {
|
||||
Log.debug(`Validated local composite function "${compositeFunctionPath}"`);
|
||||
return config;
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Local composite function "${compositeFunctionPath}" was referenced by a step but no such composite function exists. A local composite function is resolved from a "function.yml" (or "function.yaml") file at the referenced path relative to the EAS project root (e.g. "uses: ${compositeFunctionPath}" resolves "${compositeFunctionPath}/function.yml"). The recommended convention is to keep composite functions under ".eas/functions/<name>".`
|
||||
);
|
||||
}
|
||||
|
||||
async function readCompositeFunctionConfigFileAsync(
|
||||
compositeFunctionPath: string,
|
||||
absolutePath: string
|
||||
): Promise<CompositeFunctionConfig | null> {
|
||||
let rawContents: string;
|
||||
try {
|
||||
rawContents = await fs.readFile(absolutePath, 'utf-8');
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException)?.code === 'ENOENT') {
|
||||
return null;
|
||||
}
|
||||
throw new Error(
|
||||
`Failed to read local composite function "${compositeFunctionPath}" from ${absolutePath}`,
|
||||
{
|
||||
cause: err as Error,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = YAML.parse(rawContents);
|
||||
} catch (err) {
|
||||
throw new Error(
|
||||
`Failed to parse local composite function "${compositeFunctionPath}" YAML at ${absolutePath}`,
|
||||
{
|
||||
cause: err as Error,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const result = CompositeFunctionConfigZ.safeParse(parsed);
|
||||
if (!result.success) {
|
||||
throw new Error(
|
||||
`Invalid composite function "${compositeFunctionPath}": ${z.prettifyError(result.error)}`
|
||||
);
|
||||
}
|
||||
|
||||
return result.data;
|
||||
}
|
||||
|
||||
function stepsFromWorkflow(parsedYaml: any): any[] {
|
||||
const jobs = parsedYaml?.jobs;
|
||||
if (!jobs || typeof jobs !== 'object') {
|
||||
return [];
|
||||
}
|
||||
return Object.values(jobs).flatMap((job: any) => (Array.isArray(job?.steps) ? job.steps : []));
|
||||
return [
|
||||
...Object.values(jobs).flatMap((job: any) => [
|
||||
...(Array.isArray(job?.steps) ? job.steps : []),
|
||||
...hookStepsFrom(job),
|
||||
]),
|
||||
...hookStepsFrom(parsedYaml?.defaults),
|
||||
];
|
||||
}
|
||||
|
||||
function hookStepsFrom(jobOrDefaults: any): any[] {
|
||||
const hooks = jobOrDefaults?.hooks;
|
||||
if (!hooks || typeof hooks !== 'object') {
|
||||
return [];
|
||||
}
|
||||
return Object.values(hooks).flatMap((steps: any) => (Array.isArray(steps) ? steps : []));
|
||||
}
|
||||
@@ -3,11 +3,18 @@ import { Config } from '@oclif/core';
|
||||
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { DeviceRunSessionAvailabilityQuery } from '../../../graphql/queries/DeviceRunSessionAvailabilityQuery';
|
||||
import Log from '../../../log';
|
||||
import { EAS_SIMULATOR_WAITLIST_URL } from '../../../simulator/utils';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json';
|
||||
import SimulatorAvailability from '../availability';
|
||||
|
||||
jest.mock('../../../graphql/queries/DeviceRunSessionAvailabilityQuery');
|
||||
jest.mock('../../../log');
|
||||
jest.mock('../../../log', () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
log: jest.fn(),
|
||||
},
|
||||
link: jest.fn((url: string) => url),
|
||||
}));
|
||||
jest.mock('../../../ora', () => ({
|
||||
ora: jest.fn(() => {
|
||||
const spinner = {
|
||||
@@ -69,7 +76,7 @@ describe(SimulatorAvailability, () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('emits JSON with available false when not enabled', async () => {
|
||||
it('emits JSON with available false and the waitlist URL when not enabled', async () => {
|
||||
mockByAppIdAsync.mockResolvedValue({ accountName: 'testuser', available: false });
|
||||
|
||||
const command = createCommand(['--json']);
|
||||
@@ -78,10 +85,23 @@ describe(SimulatorAvailability, () => {
|
||||
expect(mockPrintJsonOnlyOutput).toHaveBeenCalledWith({
|
||||
available: false,
|
||||
accountName: 'testuser',
|
||||
waitlistUrl: EAS_SIMULATOR_WAITLIST_URL,
|
||||
});
|
||||
});
|
||||
|
||||
it('logs a graceful message when not enabled', async () => {
|
||||
it('omits the waitlist URL from JSON when enabled', async () => {
|
||||
mockByAppIdAsync.mockResolvedValue({ accountName: 'testuser', available: true });
|
||||
|
||||
const command = createCommand(['--json']);
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockPrintJsonOnlyOutput).toHaveBeenCalledWith({
|
||||
available: true,
|
||||
accountName: 'testuser',
|
||||
});
|
||||
});
|
||||
|
||||
it('logs a graceful message pointing at the waitlist when not enabled', async () => {
|
||||
mockByAppIdAsync.mockResolvedValue({ accountName: 'testuser', available: false });
|
||||
|
||||
const command = createCommand([]);
|
||||
@@ -89,7 +109,8 @@ describe(SimulatorAvailability, () => {
|
||||
|
||||
expect(mockByAppIdAsync).toHaveBeenCalledWith(graphqlClient, projectId);
|
||||
expect(mockLog).toHaveBeenCalledWith(
|
||||
"EAS Simulator isn't available on testuser yet — it's coming soon."
|
||||
"EAS Simulator isn't available on testuser yet — it's coming soon.\n" +
|
||||
`Join the waitlist to get access: ${EAS_SIMULATOR_WAITLIST_URL}`
|
||||
);
|
||||
expect(mockPrintJsonOnlyOutput).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
+40
-7
@@ -11,6 +11,7 @@ import {
|
||||
JobRunStatus,
|
||||
} from '../../../graphql/generated';
|
||||
import { DeviceRunSessionMutation } from '../../../graphql/mutations/DeviceRunSessionMutation';
|
||||
import { DeviceRunSessionAvailabilityQuery } from '../../../graphql/queries/DeviceRunSessionAvailabilityQuery';
|
||||
import { DeviceRunSessionQuery } from '../../../graphql/queries/DeviceRunSessionQuery';
|
||||
import Log from '../../../log';
|
||||
import { ora } from '../../../ora';
|
||||
@@ -22,10 +23,11 @@ import {
|
||||
loadSimulatorEnvAsync,
|
||||
resetSimulatorEnvAsync,
|
||||
} from '../../../simulator/env';
|
||||
import SimulatorStart from '../start';
|
||||
import Simulator from '../index';
|
||||
|
||||
jest.mock('fs-extra');
|
||||
jest.mock('../../../graphql/mutations/DeviceRunSessionMutation');
|
||||
jest.mock('../../../graphql/queries/DeviceRunSessionAvailabilityQuery');
|
||||
jest.mock('../../../graphql/queries/DeviceRunSessionQuery');
|
||||
jest.mock('../../../log', () => ({
|
||||
__esModule: true,
|
||||
@@ -72,6 +74,7 @@ const mockCreateDeviceRunSessionAsync = jest.mocked(
|
||||
const mockEnsureDeviceRunSessionStoppedAsync = jest.mocked(
|
||||
DeviceRunSessionMutation.ensureDeviceRunSessionStoppedAsync
|
||||
);
|
||||
const mockAvailabilityByAppIdAsync = jest.mocked(DeviceRunSessionAvailabilityQuery.byAppIdAsync);
|
||||
const mockByIdAsync = jest.mocked(DeviceRunSessionQuery.byIdAsync);
|
||||
const mockLoadSimulatorEnvAsync = jest.mocked(loadSimulatorEnvAsync);
|
||||
const mockResetSimulatorEnvAsync = jest.mocked(resetSimulatorEnvAsync);
|
||||
@@ -142,13 +145,14 @@ function getMockOclifConfig(): Config {
|
||||
return config;
|
||||
}
|
||||
|
||||
describe(SimulatorStart, () => {
|
||||
describe(Simulator, () => {
|
||||
const mockConfig = getMockOclifConfig();
|
||||
const previousDeviceRunSessionId = process.env[EAS_SIMULATOR_SESSION_ID];
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
delete process.env[EAS_SIMULATOR_SESSION_ID];
|
||||
mockAvailabilityByAppIdAsync.mockResolvedValue({ accountName: 'testuser', available: true });
|
||||
mockCreateDeviceRunSessionAsync.mockResolvedValue(makeCreatedDeviceRunSession());
|
||||
mockEnsureDeviceRunSessionStoppedAsync.mockResolvedValue({
|
||||
id: 'session-123',
|
||||
@@ -168,20 +172,49 @@ describe(SimulatorStart, () => {
|
||||
}
|
||||
});
|
||||
|
||||
function createCommand(argv: string[]): {
|
||||
command: SimulatorStart;
|
||||
function createCommand(
|
||||
argv: string[],
|
||||
{ isExpoAdmin = false }: { isExpoAdmin?: boolean } = {}
|
||||
): {
|
||||
command: Simulator;
|
||||
getContextAsync: jest.SpyInstance;
|
||||
} {
|
||||
const command = new SimulatorStart(argv, mockConfig);
|
||||
const command = new Simulator(argv, mockConfig);
|
||||
// @ts-expect-error getContextAsync is protected
|
||||
const getContextAsync = jest.spyOn(command, 'getContextAsync').mockResolvedValue({
|
||||
loggedIn: { graphqlClient },
|
||||
loggedIn: { actor: { isExpoAdmin }, graphqlClient },
|
||||
projectDir,
|
||||
projectId: 'project-123',
|
||||
});
|
||||
return { command, getContextAsync };
|
||||
}
|
||||
|
||||
it('fails with the waitlist link without creating a session when the account is gated', async () => {
|
||||
mockAvailabilityByAppIdAsync.mockResolvedValue({ accountName: 'testuser', available: false });
|
||||
|
||||
const { command } = createCommand(['--platform', 'ios', '--non-interactive']);
|
||||
|
||||
await expect(command.runAsync()).rejects.toThrow(
|
||||
"EAS Simulator isn't available on testuser yet — it's coming soon.\n" +
|
||||
'Join the waitlist to get access: https://expo.dev/services/simulators'
|
||||
);
|
||||
expect(mockAvailabilityByAppIdAsync).toHaveBeenCalledWith(graphqlClient, 'project-123');
|
||||
expect(mockCreateDeviceRunSessionAsync).not.toHaveBeenCalled();
|
||||
expect(mockPromptAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('skips the gate check for Expo admins on a gated account', async () => {
|
||||
mockAvailabilityByAppIdAsync.mockResolvedValue({ accountName: 'testuser', available: false });
|
||||
|
||||
const { command } = createCommand(['--platform', 'ios', '--non-interactive'], {
|
||||
isExpoAdmin: true,
|
||||
});
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockAvailabilityByAppIdAsync).not.toHaveBeenCalled();
|
||||
expect(mockCreateDeviceRunSessionAsync).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('prints environment variables without saving when outputting env', async () => {
|
||||
const { command, getContextAsync } = createCommand([
|
||||
'--platform',
|
||||
@@ -192,7 +225,7 @@ describe(SimulatorStart, () => {
|
||||
]);
|
||||
await command.runAsync();
|
||||
|
||||
expect(getContextAsync).toHaveBeenCalledWith(SimulatorStart, {
|
||||
expect(getContextAsync).toHaveBeenCalledWith(Simulator, {
|
||||
nonInteractive: true,
|
||||
});
|
||||
expect(mockCreateDeviceRunSessionAsync).toHaveBeenCalledWith(graphqlClient, {
|
||||
@@ -164,6 +164,8 @@ describe(SimulatorList, () => {
|
||||
'argent',
|
||||
'--platform',
|
||||
'ios',
|
||||
'--name',
|
||||
'checkout',
|
||||
'--limit',
|
||||
'25',
|
||||
'--after',
|
||||
@@ -179,6 +181,7 @@ describe(SimulatorList, () => {
|
||||
statuses: [DeviceRunSessionStatus.InProgress, DeviceRunSessionStatus.New],
|
||||
types: [DeviceRunSessionType.Argent],
|
||||
platforms: [AppPlatform.Ios],
|
||||
name: 'checkout',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,10 +6,15 @@ import {
|
||||
import { DeviceRunSessionAvailabilityQuery } from '../../graphql/queries/DeviceRunSessionAvailabilityQuery';
|
||||
import Log from '../../log';
|
||||
import { ora } from '../../ora';
|
||||
import {
|
||||
EAS_SIMULATOR_WAITLIST_URL,
|
||||
formatSimulatorUnavailableMessage,
|
||||
} from '../../simulator/utils';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
|
||||
|
||||
export default class SimulatorAvailability extends EasCommand {
|
||||
static override hidden = true;
|
||||
static override aliases = ['sim:availability'];
|
||||
static override description =
|
||||
'[EXPERIMENTAL] check whether EAS Simulator is enabled for the current project account';
|
||||
|
||||
@@ -52,7 +57,12 @@ export default class SimulatorAvailability extends EasCommand {
|
||||
}
|
||||
|
||||
if (jsonFlag) {
|
||||
printJsonOnlyOutput({ available, accountName });
|
||||
printJsonOnlyOutput({
|
||||
available,
|
||||
accountName,
|
||||
// Only present when gated, so scripts and agents can pass the waitlist link on.
|
||||
...(available ? {} : { waitlistUrl: EAS_SIMULATOR_WAITLIST_URL }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -61,6 +71,6 @@ export default class SimulatorAvailability extends EasCommand {
|
||||
return;
|
||||
}
|
||||
|
||||
Log.log(`EAS Simulator isn't available on ${accountName} yet — it's coming soon.`);
|
||||
Log.log(formatSimulatorUnavailableMessage(accountName));
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,7 @@ const POST_STOP_REFRESH_COUNT = 2;
|
||||
|
||||
export default class SimulatorEvents extends EasCommand {
|
||||
static override hidden = true;
|
||||
static override aliases = ['sim:events'];
|
||||
static override description =
|
||||
'[EXPERIMENTAL] show activity events from a remote simulator session';
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { SIMULATOR_DOTENV_FILE_NAME, loadSimulatorEnvAsync } from '../../simulat
|
||||
|
||||
export default class SimulatorExec extends EasCommand {
|
||||
static override hidden = true;
|
||||
static override aliases = ['sim:exec'];
|
||||
static override description = `[EXPERIMENTAL] execute a simulator command with ${SIMULATOR_DOTENV_FILE_NAME} environment loaded`;
|
||||
static override strict = false;
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@ type DeviceRunSessionArtifact = DeviceRunSessionById['artifacts'][number];
|
||||
|
||||
export default class SimulatorGet extends EasCommand {
|
||||
static override hidden = true;
|
||||
static override aliases = ['sim:get'];
|
||||
static override description =
|
||||
'[EXPERIMENTAL] get info about a remote simulator session on EAS by its simulator session ID';
|
||||
|
||||
|
||||
+21
-5
@@ -4,6 +4,7 @@ import nullthrows from 'nullthrows';
|
||||
import { getDeviceRunSessionUrl } from '../../build/utils/url';
|
||||
import EasCommand from '../../commandUtils/EasCommand';
|
||||
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { EasCommandError } from '../../commandUtils/errors';
|
||||
import {
|
||||
EasNonInteractiveAndJsonFlags,
|
||||
resolveNonInteractiveAndJsonFlags,
|
||||
@@ -15,6 +16,7 @@ import {
|
||||
JobRunStatus,
|
||||
} from '../../graphql/generated';
|
||||
import { DeviceRunSessionMutation } from '../../graphql/mutations/DeviceRunSessionMutation';
|
||||
import { DeviceRunSessionAvailabilityQuery } from '../../graphql/queries/DeviceRunSessionAvailabilityQuery';
|
||||
import { DeviceRunSessionQuery } from '../../graphql/queries/DeviceRunSessionQuery';
|
||||
import Log, { link } from '../../log';
|
||||
import { ora } from '../../ora';
|
||||
@@ -31,6 +33,7 @@ import {
|
||||
DEVICE_RUN_SESSION_TYPE_FLAG_VALUES,
|
||||
DeviceRunSessionRemoteConfig,
|
||||
formatRemoteSessionInstructions,
|
||||
formatSimulatorUnavailableMessage,
|
||||
getRemoteSessionEnvironmentVariables,
|
||||
} from '../../simulator/utils';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
|
||||
@@ -49,9 +52,9 @@ const APP_PLATFORM_BY_FLAG_VALUE: Record<PlatformFlagValue, AppPlatform> = {
|
||||
ios: AppPlatform.Ios,
|
||||
};
|
||||
|
||||
export default class SimulatorStart extends EasCommand {
|
||||
export default class Simulator extends EasCommand {
|
||||
static override hidden = true;
|
||||
static override aliases = ['simulator'];
|
||||
static override aliases = ['simulator:start', 'sim', 'sim:start'];
|
||||
static override description =
|
||||
'[EXPERIMENTAL] start a remote simulator session on EAS and get instructions to connect to it';
|
||||
|
||||
@@ -100,7 +103,7 @@ export default class SimulatorStart extends EasCommand {
|
||||
};
|
||||
|
||||
async runAsync(): Promise<void> {
|
||||
const { flags } = await this.parse(SimulatorStart);
|
||||
const { flags } = await this.parse(Simulator);
|
||||
const { json: jsonFlag, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
|
||||
|
||||
if (jsonFlag) {
|
||||
@@ -110,11 +113,24 @@ export default class SimulatorStart extends EasCommand {
|
||||
const {
|
||||
projectId,
|
||||
projectDir,
|
||||
loggedIn: { graphqlClient },
|
||||
} = await this.getContextAsync(SimulatorStart, {
|
||||
loggedIn: { actor, graphqlClient },
|
||||
} = await this.getContextAsync(Simulator, {
|
||||
nonInteractive,
|
||||
});
|
||||
|
||||
// The server would reject the session anyway, but check the gate first so gated
|
||||
// accounts get the waitlist link instead of a generic permission error. Expo admins
|
||||
// skip the check so they can work on any account and let the server decide.
|
||||
if (!actor.isExpoAdmin) {
|
||||
const { accountName, available } = await DeviceRunSessionAvailabilityQuery.byAppIdAsync(
|
||||
graphqlClient,
|
||||
projectId
|
||||
);
|
||||
if (!available) {
|
||||
throw new EasCommandError(formatSimulatorUnavailableMessage(accountName));
|
||||
}
|
||||
}
|
||||
|
||||
// The server rejects blank names, so trim here and treat a whitespace-only
|
||||
// --name as if it had been omitted rather than surfacing a validation error.
|
||||
const name = flags.name?.trim() || undefined;
|
||||
@@ -50,6 +50,7 @@ const PLATFORM_BY_FLAG_VALUE = Object.fromEntries(
|
||||
|
||||
export default class SimulatorList extends EasCommand {
|
||||
static override hidden = true;
|
||||
static override aliases = ['sim:list'];
|
||||
static override description =
|
||||
'[EXPERIMENTAL] list remote simulator sessions for the current project';
|
||||
|
||||
@@ -69,6 +70,9 @@ export default class SimulatorList extends EasCommand {
|
||||
options: Object.values(PLATFORM_FLAG_VALUES),
|
||||
multiple: true,
|
||||
})(),
|
||||
name: Flags.string({
|
||||
description: 'Filter by session name (case-insensitive prefix match)',
|
||||
}),
|
||||
limit: getLimitFlagWithCustomValues({ defaultTo: DEFAULT_LIMIT, limit: MAX_LIMIT }),
|
||||
after: Flags.string({
|
||||
description:
|
||||
@@ -107,6 +111,9 @@ export default class SimulatorList extends EasCommand {
|
||||
if (flags.platform && flags.platform.length > 0) {
|
||||
filter.platforms = flags.platform.map(value => PLATFORM_BY_FLAG_VALUE[value]);
|
||||
}
|
||||
if (flags.name) {
|
||||
filter.name = flags.name;
|
||||
}
|
||||
|
||||
const limit = flags.limit ?? DEFAULT_LIMIT;
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
|
||||
|
||||
export default class SimulatorStop extends EasCommand {
|
||||
static override hidden = true;
|
||||
static override aliases = ['sim:stop'];
|
||||
static override description =
|
||||
'[EXPERIMENTAL] stop a remote simulator session on EAS by its simulator session ID';
|
||||
|
||||
|
||||
@@ -1,8 +1,23 @@
|
||||
import { DeviceRunSessionByIdQuery, DeviceRunSessionType } from '../graphql/generated';
|
||||
import { link } from '../log';
|
||||
|
||||
type DeviceRunSessionByIdResult = DeviceRunSessionByIdQuery['deviceRunSessions']['byId'];
|
||||
export type DeviceRunSessionRemoteConfig = NonNullable<DeviceRunSessionByIdResult['remoteConfig']>;
|
||||
|
||||
/** Landing page where accounts without access can request it. */
|
||||
export const EAS_SIMULATOR_WAITLIST_URL = 'https://expo.dev/services/simulators';
|
||||
|
||||
/**
|
||||
* Message shown when EAS Simulator is not enabled for the account. Shared by every
|
||||
* command that can hit the gate so users always get the same waitlist pointer.
|
||||
*/
|
||||
export function formatSimulatorUnavailableMessage(accountName: string): string {
|
||||
return [
|
||||
`EAS Simulator isn't available on ${accountName} yet — it's coming soon.`,
|
||||
`Join the waitlist to get access: ${link(EAS_SIMULATOR_WAITLIST_URL)}`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
// Mapping enum -> CLI flag value. Declared as Record<DeviceRunSessionType, string>
|
||||
// so adding a new enum value in codegen fails the build until it is wired up here.
|
||||
export const DEVICE_RUN_SESSION_TYPE_FLAG_VALUES: Record<DeviceRunSessionType, string> = {
|
||||
|
||||
@@ -28,6 +28,32 @@ function generateMultipartBody(stringifiedManifest: string): FormData {
|
||||
}
|
||||
|
||||
describe(getCodeSigningInfoAsync, () => {
|
||||
it('returns undefined when codeSigningCertificate and private key path are not specified', async () => {
|
||||
await expect(
|
||||
getCodeSigningInfoAsync(
|
||||
{
|
||||
name: 'wat',
|
||||
slug: 'test',
|
||||
},
|
||||
undefined
|
||||
)
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('throws when private key path is specified without codeSigningCertificate', async () => {
|
||||
await expect(
|
||||
getCodeSigningInfoAsync(
|
||||
{
|
||||
name: 'wat',
|
||||
slug: 'test',
|
||||
},
|
||||
'test'
|
||||
)
|
||||
).rejects.toThrow(
|
||||
'--private-key-path was specified, but updates.codeSigningCertificate is not set in the resolved app config'
|
||||
);
|
||||
});
|
||||
|
||||
it('throws when codeSigningMetadata is not specified for EAS', async () => {
|
||||
await expect(
|
||||
getCodeSigningInfoAsync(
|
||||
|
||||
@@ -30,6 +30,11 @@ export async function getCodeSigningInfoAsync(
|
||||
): Promise<CodeSigningInfo | undefined> {
|
||||
const codeSigningCertificatePath = config.updates?.codeSigningCertificate;
|
||||
if (!codeSigningCertificatePath) {
|
||||
if (privateKeyPath) {
|
||||
throw new Error(
|
||||
'--private-key-path was specified, but updates.codeSigningCertificate is not set in the resolved app config. Code signing requires both the certificate in app config and the private key. Ensure codeSigningCertificate (and codeSigningMetadata) are present for this publish, or omit --private-key-path if you do not intend to sign.\nLearn more: https://docs.expo.dev/eas-update/code-signing/'
|
||||
);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/eas-json",
|
||||
"version": "21.5.0",
|
||||
"version": "21.6.0",
|
||||
"description": "A library for interacting with eas.json",
|
||||
"homepage": "https://github.com/expo/eas-cli",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
@@ -32,7 +32,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "7.23.5",
|
||||
"@expo/eas-build-job": "21.5.0",
|
||||
"@expo/eas-build-job": "21.6.0",
|
||||
"chalk": "4.1.2",
|
||||
"env-string": "1.0.1",
|
||||
"fs-extra": "11.2.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "eas-cli-local-build-plugin",
|
||||
"version": "21.5.1",
|
||||
"version": "21.7.1",
|
||||
"description": "Tool for running EAS compatible builds on a local machine.",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "BUSL-1.1",
|
||||
@@ -26,8 +26,8 @@
|
||||
"clean": "rimraf node_modules dist coverage \"*.tsbuildinfo\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/build-tools": "21.5.1",
|
||||
"@expo/eas-build-job": "21.5.0",
|
||||
"@expo/build-tools": "21.7.1",
|
||||
"@expo/eas-build-job": "21.6.0",
|
||||
"@expo/logger": "21.0.0",
|
||||
"@expo/spawn-async": "^1.7.2",
|
||||
"@expo/turtle-spawn": "21.0.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/steps",
|
||||
"version": "21.5.0",
|
||||
"version": "21.7.1",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "BUSL-1.1",
|
||||
"author": "Expo <[email protected]>",
|
||||
@@ -27,17 +27,19 @@
|
||||
"clean": "rimraf node_modules dist coverage \"*.tsbuildinfo\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/eas-build-job": "21.5.0",
|
||||
"@expo/eas-build-job": "21.6.0",
|
||||
"@expo/logger": "21.0.0",
|
||||
"@expo/spawn-async": "^1.7.2",
|
||||
"arg": "^5.0.2",
|
||||
"fast-glob": "^3.3.2",
|
||||
"fs-extra": "^11.2.0",
|
||||
"joi": "^17.13.1",
|
||||
"jsep": "^1.3.8",
|
||||
"lodash.clonedeep": "^4.5.0",
|
||||
"lodash.get": "^4.4.2",
|
||||
"uuid": "^9.0.1",
|
||||
"yaml": "^2.4.3"
|
||||
"yaml": "^2.4.3",
|
||||
"zod": "^4.3.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@jest/globals": "^29.7.0",
|
||||
|
||||
@@ -1,14 +1,30 @@
|
||||
import { CompositeFunctionConfigZ } from '@expo/eas-build-job';
|
||||
import fs from 'fs/promises';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
|
||||
import {
|
||||
buildCompositeFunctionCatalogFromStepsAsync,
|
||||
buildLocalCompositeFunctionCatalogAsync,
|
||||
createLocalCompositeFunctionLoader,
|
||||
extendCompositeFunctionCatalogFromStepsAsync,
|
||||
isLocalCompositeFunctionPath,
|
||||
loadLocalCompositeFunctionConfigAsync,
|
||||
parseLocalCompositeFunctionPath,
|
||||
resolveLocalCompositeFunctionPath,
|
||||
} from '../localCompositeFunctions';
|
||||
|
||||
async function makeCompositeFunctionAsync(
|
||||
projectRoot: string,
|
||||
functionName: string,
|
||||
contents: string,
|
||||
{ fileName = 'function.yml' }: { fileName?: string } = {}
|
||||
): Promise<void> {
|
||||
const functionDir = path.join(projectRoot, '.eas', 'functions', functionName);
|
||||
await fs.mkdir(functionDir, { recursive: true });
|
||||
await fs.writeFile(path.join(functionDir, fileName), contents, 'utf-8');
|
||||
}
|
||||
|
||||
describe(isLocalCompositeFunctionPath, () => {
|
||||
it('recognizes relative paths as local composite function paths', () => {
|
||||
expect(isLocalCompositeFunctionPath('./.eas/functions/setup')).toBe(true);
|
||||
@@ -295,3 +311,307 @@ describe(resolveLocalCompositeFunctionPath, () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe(loadLocalCompositeFunctionConfigAsync, () => {
|
||||
it('loads and validates a function.yml file', async () => {
|
||||
const projectRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'steps-functions-test-'));
|
||||
await makeCompositeFunctionAsync(
|
||||
projectRoot,
|
||||
'setup',
|
||||
['name: Setup', 'runs:', ' steps:', ' - run: echo setup'].join('\n')
|
||||
);
|
||||
|
||||
const config = await loadLocalCompositeFunctionConfigAsync(
|
||||
projectRoot,
|
||||
'./.eas/functions/setup'
|
||||
);
|
||||
|
||||
expect(config.name).toBe('Setup');
|
||||
expect(config.runs.steps).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('loads a function.yaml file when function.yml is absent', async () => {
|
||||
const projectRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'steps-functions-test-'));
|
||||
await makeCompositeFunctionAsync(
|
||||
projectRoot,
|
||||
'setup',
|
||||
['runs:', ' steps:', ' - run: echo setup'].join('\n'),
|
||||
{ fileName: 'function.yaml' }
|
||||
);
|
||||
|
||||
const config = await loadLocalCompositeFunctionConfigAsync(
|
||||
projectRoot,
|
||||
'./.eas/functions/setup'
|
||||
);
|
||||
|
||||
expect(config.runs.steps).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('prefers function.yml when both function.yml and function.yaml exist', async () => {
|
||||
const projectRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'steps-functions-test-'));
|
||||
await makeCompositeFunctionAsync(
|
||||
projectRoot,
|
||||
'setup',
|
||||
['name: FromYml', 'runs:', ' steps:', ' - run: echo yml'].join('\n')
|
||||
);
|
||||
await makeCompositeFunctionAsync(
|
||||
projectRoot,
|
||||
'setup',
|
||||
['name: FromYaml', 'runs:', ' steps:', ' - run: echo yaml'].join('\n'),
|
||||
{ fileName: 'function.yaml' }
|
||||
);
|
||||
|
||||
const config = await loadLocalCompositeFunctionConfigAsync(
|
||||
projectRoot,
|
||||
'./.eas/functions/setup'
|
||||
);
|
||||
|
||||
expect(config.name).toBe('FromYml');
|
||||
});
|
||||
|
||||
it('throws a clear error when no function file exists at the referenced path', async () => {
|
||||
const projectRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'steps-functions-test-'));
|
||||
|
||||
await expect(
|
||||
loadLocalCompositeFunctionConfigAsync(projectRoot, './.eas/functions/missing')
|
||||
).rejects.toThrow(
|
||||
/Local composite function "\.\/\.eas\/functions\/missing" was referenced by a step but no such composite function exists/
|
||||
);
|
||||
});
|
||||
|
||||
it('throws a parse error with a cause for invalid YAML syntax', async () => {
|
||||
const projectRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'steps-functions-test-'));
|
||||
await makeCompositeFunctionAsync(projectRoot, 'broken', 'runs: [unclosed');
|
||||
|
||||
const error: Error = await loadLocalCompositeFunctionConfigAsync(
|
||||
projectRoot,
|
||||
'./.eas/functions/broken'
|
||||
).then(
|
||||
() => {
|
||||
throw new Error('expected loadLocalCompositeFunctionConfigAsync to throw');
|
||||
},
|
||||
err => err
|
||||
);
|
||||
|
||||
expect(error.message).toMatch(
|
||||
/Failed to parse local composite function "\.\/\.eas\/functions\/broken" YAML at /
|
||||
);
|
||||
expect(error.cause).toBeInstanceOf(Error);
|
||||
});
|
||||
|
||||
it('throws a validation error for a config that fails schema validation', async () => {
|
||||
const projectRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'steps-functions-test-'));
|
||||
await makeCompositeFunctionAsync(
|
||||
projectRoot,
|
||||
'broken',
|
||||
['name: Broken', 'runs:', ' steps: []'].join('\n')
|
||||
);
|
||||
|
||||
await expect(
|
||||
loadLocalCompositeFunctionConfigAsync(projectRoot, './.eas/functions/broken')
|
||||
).rejects.toThrow(
|
||||
/Invalid composite function "\.\/\.eas\/functions\/broken": .*must declare at least one step under "runs\.steps"/s
|
||||
);
|
||||
});
|
||||
|
||||
it('throws a read error with a cause for a non-ENOENT filesystem error', async () => {
|
||||
const projectRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'steps-functions-test-'));
|
||||
// A directory named function.yml makes readFile fail with EISDIR instead of ENOENT.
|
||||
await fs.mkdir(path.join(projectRoot, '.eas', 'functions', 'setup', 'function.yml'), {
|
||||
recursive: true,
|
||||
});
|
||||
|
||||
const error: Error = await loadLocalCompositeFunctionConfigAsync(
|
||||
projectRoot,
|
||||
'./.eas/functions/setup'
|
||||
).then(
|
||||
() => {
|
||||
throw new Error('expected loadLocalCompositeFunctionConfigAsync to throw');
|
||||
},
|
||||
err => err
|
||||
);
|
||||
|
||||
expect(error.message).toMatch(
|
||||
/Failed to read local composite function "\.\/\.eas\/functions\/setup" from /
|
||||
);
|
||||
expect((error.cause as NodeJS.ErrnoException).code).toBe('EISDIR');
|
||||
});
|
||||
|
||||
it('resolves the referenced path relative to the project root, including paths above it', async () => {
|
||||
const repositoryRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'steps-functions-monorepo-'));
|
||||
const projectRoot = path.join(repositoryRoot, 'apps', 'mobile');
|
||||
await makeCompositeFunctionAsync(
|
||||
projectRoot,
|
||||
'setup',
|
||||
['name: UnderProjectRoot', 'runs:', ' steps:', ' - run: echo setup'].join('\n')
|
||||
);
|
||||
const sharedFunctionDir = path.join(repositoryRoot, 'shared', 'functions', 'deploy');
|
||||
await fs.mkdir(sharedFunctionDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(sharedFunctionDir, 'function.yml'),
|
||||
['name: AboveProjectRoot', 'runs:', ' steps:', ' - run: echo deploy'].join('\n'),
|
||||
'utf-8'
|
||||
);
|
||||
|
||||
const underRoot = await loadLocalCompositeFunctionConfigAsync(
|
||||
projectRoot,
|
||||
'./.eas/functions/setup'
|
||||
);
|
||||
const aboveRoot = await loadLocalCompositeFunctionConfigAsync(
|
||||
projectRoot,
|
||||
'../../shared/functions/deploy'
|
||||
);
|
||||
|
||||
expect(underRoot.name).toBe('UnderProjectRoot');
|
||||
expect(aboveRoot.name).toBe('AboveProjectRoot');
|
||||
});
|
||||
|
||||
it('logs a debug message with the loaded file path', async () => {
|
||||
const projectRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'steps-functions-test-'));
|
||||
await makeCompositeFunctionAsync(
|
||||
projectRoot,
|
||||
'setup',
|
||||
['runs:', ' steps:', ' - run: echo setup'].join('\n')
|
||||
);
|
||||
const logger = { debug: jest.fn() };
|
||||
|
||||
await loadLocalCompositeFunctionConfigAsync(projectRoot, './.eas/functions/setup', { logger });
|
||||
|
||||
expect(logger.debug).toHaveBeenCalledWith(
|
||||
`Loaded local composite function "./.eas/functions/setup" from ${path.join(
|
||||
'.eas',
|
||||
'functions',
|
||||
'setup',
|
||||
'function.yml'
|
||||
)}`
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe(createLocalCompositeFunctionLoader, () => {
|
||||
it('loads function.yml from disk for a normalized path', async () => {
|
||||
const projectRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'steps-functions-loader-'));
|
||||
await makeCompositeFunctionAsync(
|
||||
projectRoot,
|
||||
'setup',
|
||||
['name: Setup', 'runs:', ' steps:', ' - run: echo setup'].join('\n')
|
||||
);
|
||||
|
||||
const loader = createLocalCompositeFunctionLoader(projectRoot);
|
||||
const config = await loader('./.eas/functions/setup');
|
||||
|
||||
expect(config.name).toBe('Setup');
|
||||
expect(config.runs.steps).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('rejects for a path with no composite function on disk', async () => {
|
||||
const projectRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'steps-functions-loader-'));
|
||||
|
||||
const loader = createLocalCompositeFunctionLoader(projectRoot);
|
||||
|
||||
await expect(loader('./.eas/functions/missing')).rejects.toThrow(
|
||||
/no such composite function exists/
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe(buildLocalCompositeFunctionCatalogAsync, () => {
|
||||
it('builds a catalog keyed by normalized ref, loading transitively nested functions from disk', async () => {
|
||||
const projectRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'steps-functions-catalog-'));
|
||||
await makeCompositeFunctionAsync(
|
||||
projectRoot,
|
||||
'outer',
|
||||
['runs:', ' steps:', ' - uses: ./.eas/functions/inner'].join('\n')
|
||||
);
|
||||
await makeCompositeFunctionAsync(
|
||||
projectRoot,
|
||||
'inner',
|
||||
['runs:', ' steps:', ' - run: echo inner'].join('\n')
|
||||
);
|
||||
|
||||
const catalog = await buildLocalCompositeFunctionCatalogAsync(projectRoot, {
|
||||
rootSteps: [{ uses: './.eas/functions/outer/', id: 'outer' }],
|
||||
});
|
||||
|
||||
expect(Object.keys(catalog).sort()).toEqual([
|
||||
'./.eas/functions/inner',
|
||||
'./.eas/functions/outer',
|
||||
]);
|
||||
});
|
||||
|
||||
it('loads a composite function referenced by an arbitrary path outside .eas/functions', async () => {
|
||||
const projectRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'steps-functions-catalog-'));
|
||||
const functionDir = path.join(projectRoot, 'internal-functions', 'deploy');
|
||||
await fs.mkdir(functionDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(functionDir, 'function.yml'),
|
||||
['runs:', ' steps:', ' - run: echo deploy'].join('\n'),
|
||||
'utf-8'
|
||||
);
|
||||
|
||||
const catalog = await buildLocalCompositeFunctionCatalogAsync(projectRoot, {
|
||||
rootSteps: [{ uses: './internal-functions/deploy' }],
|
||||
});
|
||||
|
||||
expect(Object.keys(catalog)).toEqual(['./internal-functions/deploy']);
|
||||
});
|
||||
|
||||
it('builds a catalog for a self-referencing function (cycles are reported at expansion time)', async () => {
|
||||
const projectRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'steps-functions-catalog-'));
|
||||
await makeCompositeFunctionAsync(
|
||||
projectRoot,
|
||||
'loop',
|
||||
['runs:', ' steps:', ' - uses: ./.eas/functions/loop'].join('\n')
|
||||
);
|
||||
|
||||
const catalog = await buildLocalCompositeFunctionCatalogAsync(projectRoot, {
|
||||
rootSteps: [{ uses: './.eas/functions/loop' }],
|
||||
});
|
||||
|
||||
expect(Object.keys(catalog)).toEqual(['./.eas/functions/loop']);
|
||||
});
|
||||
|
||||
it('builds a catalog for a function shared along multiple reference paths', async () => {
|
||||
const projectRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'steps-functions-catalog-'));
|
||||
await makeCompositeFunctionAsync(
|
||||
projectRoot,
|
||||
'shared',
|
||||
['runs:', ' steps:', ' - run: echo shared'].join('\n')
|
||||
);
|
||||
await makeCompositeFunctionAsync(
|
||||
projectRoot,
|
||||
'left',
|
||||
['runs:', ' steps:', ' - uses: ./.eas/functions/shared'].join('\n')
|
||||
);
|
||||
await makeCompositeFunctionAsync(
|
||||
projectRoot,
|
||||
'right',
|
||||
['runs:', ' steps:', ' - uses: ./.eas/functions/shared'].join('\n')
|
||||
);
|
||||
|
||||
const catalog = await buildLocalCompositeFunctionCatalogAsync(projectRoot, {
|
||||
rootSteps: [{ uses: './.eas/functions/left' }, { uses: './.eas/functions/right' }],
|
||||
});
|
||||
|
||||
expect(Object.keys(catalog).sort()).toEqual([
|
||||
'./.eas/functions/left',
|
||||
'./.eas/functions/right',
|
||||
'./.eas/functions/shared',
|
||||
]);
|
||||
});
|
||||
|
||||
it('ignores unreferenced malformed function files on disk', async () => {
|
||||
const projectRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'steps-functions-catalog-'));
|
||||
await makeCompositeFunctionAsync(
|
||||
projectRoot,
|
||||
'broken',
|
||||
['name: Broken', 'runs:', ' steps: []'].join('\n')
|
||||
);
|
||||
|
||||
const catalog = await buildLocalCompositeFunctionCatalogAsync(projectRoot, {
|
||||
rootSteps: [{ run: 'echo hi' }],
|
||||
});
|
||||
|
||||
expect(catalog).toEqual({});
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,13 @@
|
||||
import { CompositeFunctionCatalog, CompositeFunctionConfig, Step } from '@expo/eas-build-job';
|
||||
import {
|
||||
CompositeFunctionCatalog,
|
||||
CompositeFunctionConfig,
|
||||
CompositeFunctionConfigZ,
|
||||
Step,
|
||||
} from '@expo/eas-build-job';
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import YAML from 'yaml';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { BuildConfigError } from '../errors';
|
||||
|
||||
@@ -90,6 +98,88 @@ export function resolveLocalCompositeFunctionPath(
|
||||
return path.resolve(projectRoot, compositeFunctionPath);
|
||||
}
|
||||
|
||||
export interface LocalCompositeFunctionLogger {
|
||||
debug(message: string): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads and validates the function.yml (or function.yaml) file of a local composite function.
|
||||
* `compositeFunctionPath` is the normalized ref returned by {@link parseLocalCompositeFunctionPath}.
|
||||
*/
|
||||
export async function loadLocalCompositeFunctionConfigAsync(
|
||||
projectRoot: string,
|
||||
compositeFunctionPath: string,
|
||||
{ logger }: { logger?: LocalCompositeFunctionLogger } = {}
|
||||
): Promise<CompositeFunctionConfig> {
|
||||
const resolvedPath = resolveLocalCompositeFunctionPath(projectRoot, compositeFunctionPath);
|
||||
|
||||
for (const ext of ['yml', 'yaml'] as const) {
|
||||
const absolutePath = path.join(resolvedPath, `function.${ext}`);
|
||||
let rawContents: string;
|
||||
try {
|
||||
rawContents = await fs.readFile(absolutePath, 'utf-8');
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException)?.code === 'ENOENT') {
|
||||
continue;
|
||||
}
|
||||
throw new Error(
|
||||
`Failed to read local composite function "${compositeFunctionPath}" from ${absolutePath}`,
|
||||
{
|
||||
cause: err as Error,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = YAML.parse(rawContents);
|
||||
} catch (err) {
|
||||
throw new Error(
|
||||
`Failed to parse local composite function "${compositeFunctionPath}" YAML at ${absolutePath}`,
|
||||
{
|
||||
cause: err as Error,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const result = CompositeFunctionConfigZ.safeParse(parsed);
|
||||
if (!result.success) {
|
||||
throw new Error(
|
||||
`Invalid composite function "${compositeFunctionPath}": ${z.prettifyError(result.error)}`
|
||||
);
|
||||
}
|
||||
|
||||
logger?.debug(
|
||||
`Loaded local composite function "${compositeFunctionPath}" from ${path.relative(projectRoot, absolutePath)}`
|
||||
);
|
||||
return result.data;
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Local composite function "${compositeFunctionPath}" was referenced by a step but no such composite function exists. A local composite function is resolved from a "function.yml" (or "function.yaml") file at the referenced path relative to the EAS project root (e.g. "uses: ${compositeFunctionPath}" resolves "${compositeFunctionPath}/function.yml"). The recommended convention is to keep composite functions under ".eas/functions/<name>".`
|
||||
);
|
||||
}
|
||||
|
||||
/** Loader for the lazy hook path: bound to a project root, passed to {@link StepsConfigParser}. */
|
||||
export function createLocalCompositeFunctionLoader(
|
||||
projectRoot: string,
|
||||
{ logger }: { logger?: LocalCompositeFunctionLogger } = {}
|
||||
): (compositeFunctionPath: string) => Promise<CompositeFunctionConfig> {
|
||||
return async compositeFunctionPath =>
|
||||
await loadLocalCompositeFunctionConfigAsync(projectRoot, compositeFunctionPath, { logger });
|
||||
}
|
||||
|
||||
/** Builds the catalog of composite functions transitively referenced by `rootSteps`, loading each from disk. */
|
||||
export async function buildLocalCompositeFunctionCatalogAsync(
|
||||
projectRoot: string,
|
||||
{ rootSteps, logger }: { rootSteps: readonly Step[]; logger?: LocalCompositeFunctionLogger }
|
||||
): Promise<CompositeFunctionCatalog> {
|
||||
return await buildCompositeFunctionCatalogFromStepsAsync({
|
||||
rootSteps,
|
||||
loadCompositeFunction: createLocalCompositeFunctionLoader(projectRoot, { logger }),
|
||||
});
|
||||
}
|
||||
|
||||
function collectLocalCompositeFunctionPathsFromSteps(steps: readonly Step[]): Set<string> {
|
||||
const paths = new Set<string>();
|
||||
for (const step of steps) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/worker",
|
||||
"version": "21.5.1",
|
||||
"version": "21.7.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"start": "yarn watch",
|
||||
@@ -19,16 +19,16 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@babel/core": "7.24.7",
|
||||
"@expo/build-tools": "21.5.1",
|
||||
"@expo/build-tools": "21.7.1",
|
||||
"@expo/config-plugins": "55.0.7",
|
||||
"@expo/config-types": "55.0.5",
|
||||
"@expo/downloader": "21.0.0",
|
||||
"@expo/eas-build-job": "21.5.0",
|
||||
"@expo/eas-build-job": "21.6.0",
|
||||
"@expo/logger": "21.0.0",
|
||||
"@expo/results": "1.0.0",
|
||||
"@expo/rudder-sdk-node": "1.1.1",
|
||||
"@expo/spawn-async": "1.7.2",
|
||||
"@expo/steps": "21.5.0",
|
||||
"@expo/steps": "21.7.1",
|
||||
"@expo/template-file": "21.0.2",
|
||||
"@expo/turtle-spawn": "21.0.0",
|
||||
"@hapi/boom": "10.0.1",
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { Environment } from '../constants';
|
||||
|
||||
describe('config', () => {
|
||||
const originalEnvironment = process.env.ENVIRONMENT;
|
||||
|
||||
afterEach(() => {
|
||||
process.env.ENVIRONMENT = originalEnvironment;
|
||||
jest.resetModules();
|
||||
});
|
||||
|
||||
it.each([
|
||||
[Environment.DEVELOPMENT, 'http://localhost:4999/logs/'],
|
||||
[Environment.STAGING, 'https://staging-logs.expo.dev/logs/'],
|
||||
[Environment.PRODUCTION, 'https://logs.expo.dev/logs/'],
|
||||
[Environment.TEST, null],
|
||||
])('uses the expected EAS Logs URL in %s', (environment, expectedBaseUrl) => {
|
||||
process.env.ENVIRONMENT = environment;
|
||||
|
||||
jest.isolateModules(() => {
|
||||
const config = require('../config').default;
|
||||
expect(config.loggers.http.baseUrl).toBe(expectedBaseUrl);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -50,7 +50,9 @@ export default {
|
||||
? 'http://localhost:4999/logs/'
|
||||
: process.env.ENVIRONMENT === 'staging'
|
||||
? 'https://staging-logs.expo.dev/logs/'
|
||||
: null,
|
||||
: process.env.ENVIRONMENT === 'production'
|
||||
? 'https://logs.expo.dev/logs/'
|
||||
: null,
|
||||
},
|
||||
},
|
||||
buildCache: {
|
||||
|
||||
@@ -2291,14 +2291,14 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@expo/build-tools@npm:21.5.1, @expo/build-tools@workspace:packages/build-tools":
|
||||
"@expo/build-tools@npm:21.7.1, @expo/build-tools@workspace:packages/build-tools":
|
||||
version: 0.0.0-use.local
|
||||
resolution: "@expo/build-tools@workspace:packages/build-tools"
|
||||
dependencies:
|
||||
"@expo/config": "npm:55.0.10"
|
||||
"@expo/config-plugins": "npm:55.0.7"
|
||||
"@expo/downloader": "npm:21.0.0"
|
||||
"@expo/eas-build-job": "npm:21.5.0"
|
||||
"@expo/eas-build-job": "npm:21.6.0"
|
||||
"@expo/env": "npm:^0.4.0"
|
||||
"@expo/logger": "npm:21.0.0"
|
||||
"@expo/package-manager": "npm:1.9.10"
|
||||
@@ -2306,7 +2306,7 @@ __metadata:
|
||||
"@expo/repack-app": "npm:~0.6.1"
|
||||
"@expo/results": "npm:^1.0.0"
|
||||
"@expo/spawn-async": "npm:1.7.2"
|
||||
"@expo/steps": "npm:21.5.0"
|
||||
"@expo/steps": "npm:21.7.1"
|
||||
"@expo/template-file": "npm:21.0.2"
|
||||
"@expo/turtle-spawn": "npm:21.0.0"
|
||||
"@expo/xcpretty": "npm:^4.3.1"
|
||||
@@ -2547,7 +2547,7 @@ __metadata:
|
||||
languageName: unknown
|
||||
linkType: soft
|
||||
|
||||
"@expo/eas-build-job@npm:21.5.0, @expo/eas-build-job@workspace:packages/eas-build-job":
|
||||
"@expo/eas-build-job@npm:21.6.0, @expo/eas-build-job@workspace:packages/eas-build-job":
|
||||
version: 0.0.0-use.local
|
||||
resolution: "@expo/eas-build-job@workspace:packages/eas-build-job"
|
||||
dependencies:
|
||||
@@ -2566,12 +2566,12 @@ __metadata:
|
||||
languageName: unknown
|
||||
linkType: soft
|
||||
|
||||
"@expo/eas-json@npm:21.5.0, @expo/eas-json@workspace:packages/eas-json":
|
||||
"@expo/eas-json@npm:21.6.0, @expo/eas-json@workspace:packages/eas-json":
|
||||
version: 0.0.0-use.local
|
||||
resolution: "@expo/eas-json@workspace:packages/eas-json"
|
||||
dependencies:
|
||||
"@babel/code-frame": "npm:7.23.5"
|
||||
"@expo/eas-build-job": "npm:21.5.0"
|
||||
"@expo/eas-build-job": "npm:21.6.0"
|
||||
"@tsconfig/node20": "npm:20.1.8"
|
||||
"@types/babel__code-frame": "npm:7.0.3"
|
||||
"@types/fs-extra": "npm:11.0.4"
|
||||
@@ -2884,11 +2884,11 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@expo/steps@npm:21.5.0, @expo/steps@workspace:packages/steps":
|
||||
"@expo/steps@npm:21.7.1, @expo/steps@workspace:packages/steps":
|
||||
version: 0.0.0-use.local
|
||||
resolution: "@expo/steps@workspace:packages/steps"
|
||||
dependencies:
|
||||
"@expo/eas-build-job": "npm:21.5.0"
|
||||
"@expo/eas-build-job": "npm:21.6.0"
|
||||
"@expo/logger": "npm:21.0.0"
|
||||
"@expo/spawn-async": "npm:^1.7.2"
|
||||
"@jest/globals": "npm:^29.7.0"
|
||||
@@ -2898,6 +2898,7 @@ __metadata:
|
||||
"@types/node": "npm:20.14.2"
|
||||
arg: "npm:^5.0.2"
|
||||
eslint-plugin-async-protect: "npm:^3.1.0"
|
||||
fast-glob: "npm:^3.3.2"
|
||||
fs-extra: "npm:^11.2.0"
|
||||
jest: "npm:^29.7.0"
|
||||
joi: "npm:^17.13.1"
|
||||
@@ -2911,6 +2912,7 @@ __metadata:
|
||||
typescript: "npm:^5.5.4"
|
||||
uuid: "npm:^9.0.1"
|
||||
yaml: "npm:^2.4.3"
|
||||
zod: "npm:^4.3.5"
|
||||
languageName: unknown
|
||||
linkType: soft
|
||||
|
||||
@@ -2957,16 +2959,16 @@ __metadata:
|
||||
resolution: "@expo/worker@workspace:packages/worker"
|
||||
dependencies:
|
||||
"@babel/core": "npm:7.24.7"
|
||||
"@expo/build-tools": "npm:21.5.1"
|
||||
"@expo/build-tools": "npm:21.7.1"
|
||||
"@expo/config-plugins": "npm:55.0.7"
|
||||
"@expo/config-types": "npm:55.0.5"
|
||||
"@expo/downloader": "npm:21.0.0"
|
||||
"@expo/eas-build-job": "npm:21.5.0"
|
||||
"@expo/eas-build-job": "npm:21.6.0"
|
||||
"@expo/logger": "npm:21.0.0"
|
||||
"@expo/results": "npm:1.0.0"
|
||||
"@expo/rudder-sdk-node": "npm:1.1.1"
|
||||
"@expo/spawn-async": "npm:1.7.2"
|
||||
"@expo/steps": "npm:21.5.0"
|
||||
"@expo/steps": "npm:21.7.1"
|
||||
"@expo/template-file": "npm:21.0.2"
|
||||
"@expo/turtle-spawn": "npm:21.0.0"
|
||||
"@hapi/boom": "npm:10.0.1"
|
||||
@@ -9722,12 +9724,13 @@ __metadata:
|
||||
resolution: "create-eas-build-function@workspace:packages/create-eas-build-function"
|
||||
dependencies:
|
||||
"@expo/package-manager": "npm:1.9.10"
|
||||
"@expo/steps": "npm:21.5.0"
|
||||
"@expo/steps": "npm:21.7.1"
|
||||
"@jest/globals": "npm:^29.7.0"
|
||||
"@types/jest": "npm:^29.5.12"
|
||||
"@types/node": "npm:20.14.2"
|
||||
"@types/prompts": "npm:^2.4.9"
|
||||
"@vercel/ncc": "npm:^0.38.1"
|
||||
arg: "npm:^5.0.2"
|
||||
chalk: "npm:4.1.2"
|
||||
fs-extra: "npm:^11.2.0"
|
||||
ora: "npm:^6.3.1"
|
||||
@@ -10294,8 +10297,8 @@ __metadata:
|
||||
version: 0.0.0-use.local
|
||||
resolution: "eas-cli-local-build-plugin@workspace:packages/local-build-plugin"
|
||||
dependencies:
|
||||
"@expo/build-tools": "npm:21.5.1"
|
||||
"@expo/eas-build-job": "npm:21.5.0"
|
||||
"@expo/build-tools": "npm:21.7.1"
|
||||
"@expo/eas-build-job": "npm:21.6.0"
|
||||
"@expo/logger": "npm:21.0.0"
|
||||
"@expo/spawn-async": "npm:^1.7.2"
|
||||
"@expo/turtle-spawn": "npm:21.0.0"
|
||||
@@ -10352,8 +10355,8 @@ __metadata:
|
||||
"@expo/code-signing-certificates": "npm:0.0.5"
|
||||
"@expo/config": "npm:55.0.10"
|
||||
"@expo/config-plugins": "npm:55.0.7"
|
||||
"@expo/eas-build-job": "npm:21.5.0"
|
||||
"@expo/eas-json": "npm:21.5.0"
|
||||
"@expo/eas-build-job": "npm:21.6.0"
|
||||
"@expo/eas-json": "npm:21.6.0"
|
||||
"@expo/env": "npm:^1.0.0"
|
||||
"@expo/json-file": "npm:8.3.3"
|
||||
"@expo/logger": "npm:21.0.0"
|
||||
@@ -10368,7 +10371,7 @@ __metadata:
|
||||
"@expo/results": "npm:1.0.0"
|
||||
"@expo/rudder-sdk-node": "npm:1.1.1"
|
||||
"@expo/spawn-async": "npm:1.7.2"
|
||||
"@expo/steps": "npm:21.5.0"
|
||||
"@expo/steps": "npm:21.7.1"
|
||||
"@expo/timeago.js": "npm:1.0.0"
|
||||
"@graphql-codegen/cli": "npm:6.3.1"
|
||||
"@graphql-codegen/introspection": "npm:5.0.2"
|
||||
@@ -10454,6 +10457,7 @@ __metadata:
|
||||
qrcode-terminal: "npm:0.12.0"
|
||||
resolve-from: "npm:5.0.0"
|
||||
rimraf: "npm:3.0.2"
|
||||
sandbox-cli-detector: "npm:0.2.0"
|
||||
semver: "npm:7.5.4"
|
||||
set-interval-async: "npm:3.0.3"
|
||||
slash: "npm:3.0.0"
|
||||
@@ -17326,6 +17330,15 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"sandbox-cli-detector@npm:0.2.0":
|
||||
version: 0.2.0
|
||||
resolution: "sandbox-cli-detector@npm:0.2.0"
|
||||
bin:
|
||||
sandbox-cli-detector: dist/cli.js
|
||||
checksum: 10c0/e70964918150079a3183948a8adadcde736823da2ad1135b4ccead2baa8d24d6487ddd222c030f94c9ef54e84bc41aa525a5452ef4cb9239f6f64b2ac03337bc
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"sax@npm:>=0.6.0":
|
||||
version: 1.2.4
|
||||
resolution: "sax@npm:1.2.4"
|
||||
|
||||
Reference in new issue
Block a user