Compare commits

..
15 Commits
Author SHA1 Message Date
Expo CI 866febb1ba v21.6.0 2026-08-05 23:02:14 +00:00
Brent Vatne d98d26d962 [reviewer] Bump AI code review to @expo/code-review-cli 0.10.0 (#4158) 2026-08-05 15:51:35 -07:00
Douglas Lowder d78adfeb0f [eas-build-job][build-tools] Replace eas-cli npm dist-tags with cli-versions.json (#4147)
# Why

Add `cli-versions.json` as the source of truth for the staging/production eas-cli versions, to remove the dependency on NPM tags to determine the correct version used in workflow jobs.

# How

Adding new function `fetchEasCliVersionsAsync` in the eas-build-job package. This reads the file from main on GitHub; build-tools uses it to build the eas-cli@<version> npx argument and falls back to the `latest-eas-build*` dist-tags on any failure.  The release workflow now bumps cli-versions.json in the release commit and no longer posts the manual npm dist-tag reminder.

# Test Plan

New unit tests added.
2026-08-05 15:31:36 -07:00
Szymon DziedzicandClaude Opus 5 54f0b5368c [eas-cli] Point gated accounts at the EAS Simulator waitlist (#4151)
`simulator:availability` now prints the waitlist link when the
`device-run-sessions` gate is off, and `--json` carries a `waitlistUrl`
field. `eas simulator` checks the gate before creating a session and
fails with the same message instead of a generic permission error.
Expo admins skip the check so the server stays the authority for them.

Co-authored-by: Claude Opus 5 <[email protected]>
2026-08-05 19:41:52 +02:00
Szymon DziedzicandClaude Opus 5 f30267f05f [eas-cli] Add eas sim as a shortcut for eas simulator commands (#4150)
* [eas-cli] Add `eas sim` as a shortcut for `eas simulator` commands

Alias every `eas simulator:*` command as `eas sim:*`, and alias the bare
command so `eas sim` starts a session just like `eas simulator`.

Co-Authored-By: Claude Opus 5 <[email protected]>

* update CHANGELOG.md

Co-Authored-By: Claude Opus 5 <[email protected]>

---------

Co-authored-by: Claude Opus 5 <[email protected]>
2026-08-05 19:35:29 +02:00
Szymon Świerk 86fc9ebb80 [eas-cli][build-tools][steps] Validate local composite functions referenced from workflow hooks; extract the shared loader into steps (#4064)
* [eas-cli] Validate local composite functions referenced from workflow hooks

* extract the local composite function loader into @expo/steps

* skip validation when project_root_directory is set

* Validate defaults.hooks and remove project_root_directory skip
2026-08-05 14:29:44 +02:00
Szymon Dziedzic 94f473b1f3 [eas-cli] Make simulator the index command (#4112)
* [eas-cli] Make simulator the index command

* Update changelog

* [eas-cli] Keep simulator commands hidden

* [eas-cli] Add simulator command topic
2026-08-05 13:14:57 +02:00
Brent Vatne a3a1d84ebd [reviewer] Migrate reviews from GPT to Claude (sonnet 5, opus for security) (#4148) 2026-08-04 15:40:52 -07:00
Szymon Dziedzic eab32ecb9f [eas-cli] Add --name filter to simulator:list (#4118)
* [eas-cli] Add --name filter to simulator:list

* [eas-cli] Add changelog entry
2026-08-04 11:27:45 +02:00
Gabe Debes cdf4e811d5 Revert "[build-tools] Pin the default agent-device version for remote sessions (#4142)" (#4144)
This reverts commit f56c054be4.
2026-08-03 14:01:18 -07:00
Expo CI d003bffa29 update CHANGELOG.md 2026-08-03 20:09:34 +00:00
Expo CI 7d7ebb27f6 v21.5.1 2026-08-03 20:06:47 +00:00
Brent Vatne 9276e22ff2 Temporarily disable adding npm dist-tags in CI, post Slack reminder instead 2026-08-03 13:03:57 -07:00
Brent Vatne f41312cf38 update CHANGELOG.md 2026-08-03 13:00:19 -07:00
Gabe Debes f56c054be4 [build-tools] Pin the default agent-device version for remote sessions (#4142) 2026-08-03 12:59:51 -07:00
48 changed files with 1577 additions and 835 deletions

No files matched your search

+3 -4
View File
@@ -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
+17 -16
View File
@@ -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" }
}
}
}
+3 -3
View File
@@ -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
+10 -3
View File
@@ -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 }}
+18 -10
View File
@@ -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 }}
-64
View File
@@ -1,64 +0,0 @@
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
+18 -15
View File
@@ -37,20 +37,20 @@ jobs:
run: yarn build
- name: Publish packages to npm
run: yarn run -T lerna publish from-package --yes --no-verify-access
- name: Add latest-eas-build-staging tag
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_ACCESS_TOKEN }}
- name: Verify eas-cli was published as latest on npm
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
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
@@ -78,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: |
+22
View File
@@ -8,6 +8,26 @@ This is the log of notable changes to EAS CLI and related packages.
### 🎉 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))
### 🐛 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))
### 🧹 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
- [eas-cli] Add `eas submit:list`, `eas submit:view`, `eas submit:retry`, `eas submit:cancel`, and `eas submit:status` commands; list and view include the runtime version and fingerprint of the submitted build, and status shows the live App Store version and TestFlight build states cross-referenced with EAS submissions (Google Play app status is not available through EAS yet). ([#4134](https://github.com/expo/eas-cli/pull/4134) by [@brentvatne](https://github.com/brentvatne))
- [eas-build-job] Add optional `ssh` field on build/job payloads. ([#4083](https://github.com/expo/eas-cli/pull/4083) by [@gwdp](https://github.com/gwdp))
- [eas-build-job] Add an `SSH_SESSION` build phase for upcoming worker SSH support. ([#4029](https://github.com/expo/eas-cli/pull/4029) by [@gwdp](https://github.com/gwdp))
@@ -17,12 +37,14 @@ 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
- [eas-cli] clean up error handling in local builds. ([#4105](https://github.com/expo/eas-cli/pull/4105) by [@douglowder](https://github.com/douglowder))
- [build-tools] Install ffmpeg when it is missing so Argent screen recording works in EAS Simulator sessions. ([#4110](https://github.com/expo/eas-cli/pull/4110) by [@szdziedzic](https://github.com/szdziedzic))
- [eas-cli] Stop simulator job runs when `eas simulator:start` is canceled before the session is ready. ([#4113](https://github.com/expo/eas-cli/pull/4113) by [@sjchmiela](https://github.com/sjchmiela))
- [build-tools] Pin the default `agent-device` version for remote sessions instead of installing `latest`. ([#4142](https://github.com/expo/eas-cli/pull/4142) by [@gwdp](https://github.com/gwdp))
### 🧹 Chores
+13
View File
@@ -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**.
+4
View File
@@ -0,0 +1,4 @@
{
"STAGING": "21.5.0",
"PRODUCTION": "21.5.0"
}
+1 -1
View File
@@ -1,5 +1,5 @@
{
"npmClient": "yarn",
"version": "21.5.0",
"version": "21.6.0",
"$schema": "node_modules/lerna/schemas/lerna-schema.json"
}
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/build-tools",
"version": "21.5.0",
"version": "21.6.0",
"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.6.0",
"@expo/template-file": "21.0.2",
"@expo/turtle-spawn": "21.0.0",
"@expo/xcpretty": "^4.3.1",
+7 -7
View File
@@ -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,
+3 -3
View File
@@ -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) {
+13 -8
View File
@@ -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();
@@ -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),
});
}
@@ -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({
+53 -4
View File
@@ -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.6.0",
"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.6.0"
},
"devDependencies": {
"@expo/package-manager": "1.9.10",
+1 -1
View File
@@ -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();
}
});
});
+62
View File
@@ -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);
}
}
+3
View File
@@ -7,6 +7,9 @@ export {
BuildMode,
BuildTrigger,
EasCliNpmTags,
EasCliVersions,
EasCliVersionsFetchTimeoutError,
fetchEasCliVersionsAsync,
Env,
EnvironmentSecret,
EnvironmentSecretType,
+337 -125
View File
File diff suppressed because it is too large. Load diff
+7 -4
View File
@@ -1,6 +1,6 @@
{
"name": "eas-cli",
"version": "21.5.0",
"version": "21.6.0",
"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.6.0",
"@expo/timeago.js": "1.0.0",
"@oclif/core": "^4.8.3",
"@oclif/plugin-autocomplete": "^3.2.40",
@@ -221,6 +221,9 @@
"secret": {
"description": "manage project and account secrets"
},
"simulator": {
"description": "manage remote simulator sessions"
},
"testflight": {
"description": "read TestFlight feedback and crashes"
},
@@ -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();
});
@@ -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';
@@ -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';
+15
View File
@@ -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> = {
+2 -2
View File
@@ -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",
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "eas-cli-local-build-plugin",
"version": "21.5.0",
"version": "21.6.0",
"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.0",
"@expo/eas-build-job": "21.5.0",
"@expo/build-tools": "21.6.0",
"@expo/eas-build-job": "21.6.0",
"@expo/logger": "21.0.0",
"@expo/spawn-async": "^1.7.2",
"@expo/turtle-spawn": "21.0.0",
+4 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/steps",
"version": "21.5.0",
"version": "21.6.0",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "BUSL-1.1",
"author": "Expo <[email protected]>",
@@ -27,7 +27,7 @@
"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",
@@ -37,7 +37,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) {
+4 -4
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/worker",
"version": "21.5.0",
"version": "21.6.0",
"private": true,
"scripts": {
"start": "yarn watch",
@@ -19,16 +19,16 @@
},
"dependencies": {
"@babel/core": "7.24.7",
"@expo/build-tools": "21.5.0",
"@expo/build-tools": "21.6.0",
"@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.6.0",
"@expo/template-file": "21.0.2",
"@expo/turtle-spawn": "21.0.0",
"@hapi/boom": "10.0.1",
Generated
+18 -17
View File
@@ -2291,14 +2291,14 @@ __metadata:
languageName: node
linkType: hard
"@expo/build-tools@npm:21.5.0, @expo/build-tools@workspace:packages/build-tools":
"@expo/build-tools@npm:21.6.0, @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.6.0"
"@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.6.0, @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"
@@ -2911,6 +2911,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 +2958,16 @@ __metadata:
resolution: "@expo/worker@workspace:packages/worker"
dependencies:
"@babel/core": "npm:7.24.7"
"@expo/build-tools": "npm:21.5.0"
"@expo/build-tools": "npm:21.6.0"
"@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.6.0"
"@expo/template-file": "npm:21.0.2"
"@expo/turtle-spawn": "npm:21.0.0"
"@hapi/boom": "npm:10.0.1"
@@ -9722,7 +9723,7 @@ __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.6.0"
"@jest/globals": "npm:^29.7.0"
"@types/jest": "npm:^29.5.12"
"@types/node": "npm:20.14.2"
@@ -10294,8 +10295,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.0"
"@expo/eas-build-job": "npm:21.5.0"
"@expo/build-tools": "npm:21.6.0"
"@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 +10353,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 +10369,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.6.0"
"@expo/timeago.js": "npm:1.0.0"
"@graphql-codegen/cli": "npm:6.3.1"
"@graphql-codegen/introspection": "npm:5.0.2"