Compare commits

..
Author SHA1 Message Date
Quinlan Jung 0bcd94e83f [eas-cli] eas updates: warn users if they near plan limits 2023-04-19 16:55:35 -07:00
589 changed files with 13339 additions and 73709 deletions

No files matched your search

-1
View File
@@ -5,4 +5,3 @@
packages/eas-cli/src/graphql/generated.ts packages/eas-cli/src/graphql/generated.ts
node_modules node_modules
ts-declarations
+6
View File
@@ -30,6 +30,12 @@ module.exports = {
}, },
], ],
'no-underscore-dangle': ['error', { allow: ['__typename'] }], 'no-underscore-dangle': ['error', { allow: ['__typename'] }],
'@typescript-eslint/explicit-function-return-type': [
'warn',
{
allowExpressions: true,
},
],
'async-protect/async-suffix': 'error', 'async-protect/async-suffix': 'error',
'node/no-sync': 'error', 'node/no-sync': 'error',
}, },
-5
View File
@@ -1,5 +0,0 @@
blank_issues_enabled: false
contact_links:
- name: Expo Developers Discord
url: https://chat.expo.dev/
about: Join other developers in discussions regarding Expo and EAS.
+5 -2
View File
@@ -1,5 +1,8 @@
<!-- If this PR requires a changelog entry, add it by commenting the PR with the command `/changelog-entry [breaking-change|new-feature|bug-fix|chore] [message]`. --> <!-- Thanks for contributing to _EAS CLI_! Before you submit your pull request, please make sure to check the following boxes by putting an x in the [ ] (don't: [x ], [ x], do: [x]) -->
<!-- You can skip the changelog check by labeling the PR with "no changelog". -->
# Checklist
- [ ] I've added an entry to [CHANGELOG.md](https://github.com/expo/eas-cli/blob/main/CHANGELOG.md#main) if necessary. You can comment this pull request with `/changelog-entry [breaking-change|new-feature|bug-fix|chore] [message]` and CHANGELOG.md will be updated automatically.
# Why # Why
-20
View File
@@ -1,20 +0,0 @@
commentConfiguration:
preamble: 'Subscribed to pull request'
epilogue: 'Generated by [CodeMention](https://github.com/expo/expo/actions/workflows/codemention.yaml)'
rules:
- patterns: ['**/*']
mentions: ['szdziedzic', 'khamilowicz', 'sjchmiela', 'radoslawkrzemien']
- patterns: ['packages/eas-cli/src/update/**']
mentions: ['wschurman', 'quinlanj']
- patterns: ['packages/eas-cli/src/commands/update/**']
mentions: ['wschurman', 'quinlanj']
- patterns: ['packages/eas-cli/src/channel/**']
mentions: ['wschurman', 'quinlanj']
- patterns: ['packages/eas-cli/src/commands/channel/**']
mentions: ['wschurman', 'quinlanj']
- patterns: ['packages/eas-cli/schema/**']
mentions: ['bycedric']
- patterns: ['packages/eas-cli/src/metadata/**']
mentions: ['bycedric']
- patterns: ['packages/eas-cli/src/commands/metadata/**']
mentions: ['bycedric']
+39 -31
View File
@@ -1,40 +1,48 @@
name: CHANGELOG.md PR check
on: on:
pull_request: issue_comment:
types: types: [created]
- opened
- synchronize
- labeled
- unlabeled
concurrency: name: Changelog
group: changelog-${{ github.ref }}
cancel-in-progress: true
jobs: jobs:
pr-changelog-entry: pr-commented:
name: Check CHANGELOG.md updated
runs-on: ubuntu-latest runs-on: ubuntu-latest
if: ${{ github.event.issue.pull_request && github.event.issue.user.login == github.event.comment.user.login && startsWith(github.event.comment.body, '/changelog-entry ') }}
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v2
- name: Check if CHANGELOG.md was updated
id: changelog-updated
uses: tj-actions/changed-files@v41
with: with:
files: CHANGELOG.md token: ${{ secrets.EXPO_BOT_PAT }}
- name: Fail if CHANGELOG.md was not updated and the "no changelog" label is absent - uses: actions/setup-node@v2
if: steps.changelog-updated.outputs.any_changed == 'false' && !contains(github.event.pull_request.labels.*.name, 'no changelog')
uses: actions/github-script@v6
with: with:
script: | node-version: 18
core.setFailed('Please add a changelog entry!') - name: Checkout Pull Request
- uses: mshick/[email protected] run: hub pr checkout ${{ github.event.issue.number }}
if: always() env:
with: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
message-id: changelog-entry-check - name: Install dependencies
refresh-message-position: true working-directory: ./scripts
message: ${{ contains(github.event.pull_request.labels.*.name, 'no changelog') && '⏩ The changelog entry check has been skipped since the "no changelog" label is present.' || '✅ Thank you for adding the changelog entry!' }} run: yarn install --frozen-lockfile --check-files
message-failure: | - name: Add changelog entry
❌ It looks like a changelog entry is missing for this PR. Add it manually to CHANGELOG.md. working-directory: ./scripts
⏩ If this PR doesn't require a changelog entry, such as if it's an internal change that doesn't affect the user experience, you can add the "no changelog" label to the PR. run: |
PARAMS=${PR_COMMENT_BODY#/changelog-entry }
if [[ ! "$PARAMS" =~ " " ]]; then
echo "Invalid /changelog-entry command"
exit 1
fi
CATEGORY=$( cut -d ' ' -f 1 <<< "$PARAMS" | sed -e 's/^[[:space:]]*//' )
MESSAGE=$( cut -d ' ' -f 2- <<< "$PARAMS" | sed -e 's/^[[:space:]]*//' )
yarn changelog-entry "$CATEGORY" "$MESSAGE"
env:
GITHUB_PR_AUTHOR: ${{ github.event.issue.user.login }}
GITHUB_PR_NUMBER: ${{ github.event.issue.number }}
PR_COMMENT_BODY: ${{ github.event.comment.body }}
- name: Commit and push
run: |
# use the last commit author name and email
git config --global user.name "$(git --no-pager log --format=format:'%an' -n 1)"
git config --global user.email "$(git --no-pager log --format=format:'%ae' -n 1)"
git add CHANGELOG.md
git commit -m "update CHANGELOG.md"
git push
-14
View File
@@ -1,14 +0,0 @@
name: codemention
on:
pull_request_target:
types: [opened, synchronize, ready_for_review]
jobs:
codemention:
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: write
steps:
- uses: tobyhs/[email protected]
with:
githubToken: ${{ secrets.GITHUB_TOKEN }}
+8 -29
View File
@@ -27,7 +27,7 @@ jobs:
- uses: actions/checkout@v2 - uses: actions/checkout@v2
- uses: actions/setup-node@v2 - uses: actions/setup-node@v2
with: with:
node-version: 20 node-version: 18
- run: yarn install --frozen-lockfile --check-files - run: yarn install --frozen-lockfile --check-files
- run: yarn build - run: yarn build
- name: Build tarballs - name: Build tarballs
@@ -68,23 +68,17 @@ jobs:
- uses: actions/checkout@v2 - uses: actions/checkout@v2
- uses: actions/setup-node@v2 - uses: actions/setup-node@v2
with: with:
node-version: 20 node-version: 18
- run: brew install python-setuptools
env:
HOMEBREW_NO_AUTO_UPDATE: 1
- run: yarn install --frozen-lockfile --check-files - run: yarn install --frozen-lockfile --check-files
- run: yarn build - run: yarn build
- name: Build tarballs - name: Build tarballs
working-directory: ${{ github.workspace }}/packages/eas-cli working-directory: ${{ github.workspace }}/packages/eas-cli
run: | run: |
yarn pretarball-ci yarn pretarball-ci
yarn oclif pack:tarballs --no-xz --targets darwin-x64,darwin-arm64 yarn oclif pack:tarballs --no-xz --targets darwin-x64
- id: x64 - id: x64
run: echo "filename=$(ls eas-*-x64.tar.gz)" >> $GITHUB_OUTPUT run: echo "filename=$(ls eas-*-x64.tar.gz)" >> $GITHUB_OUTPUT
working-directory: ${{ github.workspace }}/packages/eas-cli/dist working-directory: ${{ github.workspace }}/packages/eas-cli/dist
- id: arm64
run: echo "filename=$(ls eas-*-arm64.tar.gz)" >> $GITHUB_OUTPUT
working-directory: ${{ github.workspace }}/packages/eas-cli/dist
- name: Upload darwin-x64 tarball - name: Upload darwin-x64 tarball
uses: actions/upload-release-asset@v1 uses: actions/upload-release-asset@v1
env: env:
@@ -94,15 +88,6 @@ jobs:
asset_path: packages/eas-cli/dist/${{ steps.x64.outputs.filename }} asset_path: packages/eas-cli/dist/${{ steps.x64.outputs.filename }}
asset_name: ${{ steps.x64.outputs.filename }} asset_name: ${{ steps.x64.outputs.filename }}
asset_content_type: application/gzip asset_content_type: application/gzip
- name: Upload darwin-arm64 tarball
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ needs.release.outputs.upload_url }}
asset_path: packages/eas-cli/dist/${{ steps.arm64.outputs.filename }}
asset_name: ${{ steps.arm64.outputs.filename }}
asset_content_type: application/gzip
build-windows: build-windows:
name: Build for Windows name: Build for Windows
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -111,7 +96,7 @@ jobs:
- uses: actions/checkout@v2 - uses: actions/checkout@v2
- uses: actions/setup-node@v2 - uses: actions/setup-node@v2
with: with:
node-version: 20 node-version: 18
- run: yarn install --frozen-lockfile --check-files - run: yarn install --frozen-lockfile --check-files
- run: sudo apt-get install nsis - run: sudo apt-get install nsis
- run: yarn build - run: yarn build
@@ -153,7 +138,7 @@ jobs:
with: with:
registry-url: 'https://registry.npmjs.org/' registry-url: 'https://registry.npmjs.org/'
scope: 'expo' scope: 'expo'
node-version: 20 node-version: 18
- name: Install dependencies - name: Install dependencies
run: yarn install --frozen-lockfile --check-files run: yarn install --frozen-lockfile --check-files
- name: Build - name: Build
@@ -167,13 +152,7 @@ jobs:
VERSION=$(cat lerna.json | jq -r .version) VERSION=$(cat lerna.json | jq -r .version)
echo //registry.npmjs.org/:_authToken=$NODE_AUTH_TOKEN > ~/.npmrc echo //registry.npmjs.org/:_authToken=$NODE_AUTH_TOKEN > ~/.npmrc
npm dist-tag add eas-cli@$VERSION latest-eas-build-staging npm dist-tag add eas-cli@$VERSION latest-eas-build-staging
env: echo "Run \"npm dist-tag add eas-cli@$VERSION latest-eas-build\" to promote it to production."
NODE_AUTH_TOKEN: ${{ secrets.NPM_ACCESS_TOKEN }}
- name: Add latest-eas-build tag
run: |
VERSION=$(cat lerna.json | jq -r .version)
echo //registry.npmjs.org/:_authToken=$NODE_AUTH_TOKEN > ~/.npmrc
npm dist-tag add eas-cli@$VERSION latest-eas-build
env: env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_ACCESS_TOKEN }} NODE_AUTH_TOKEN: ${{ secrets.NPM_ACCESS_TOKEN }}
release-changelog: release-changelog:
@@ -186,7 +165,7 @@ jobs:
token: ${{ secrets.EXPO_BOT_PAT }} token: ${{ secrets.EXPO_BOT_PAT }}
- uses: actions/setup-node@v2 - uses: actions/setup-node@v2
with: with:
node-version: 20 node-version: 18
- name: Install dependencies - name: Install dependencies
working-directory: ./scripts working-directory: ./scripts
run: yarn install --frozen-lockfile --check-files run: yarn install --frozen-lockfile --check-files
@@ -196,7 +175,7 @@ jobs:
working-directory: ./scripts working-directory: ./scripts
run: yarn --silent release-changelog $EAS_CLI_VERSION > /tmp/current-changelog run: yarn --silent release-changelog $EAS_CLI_VERSION > /tmp/current-changelog
- name: Publish release - name: Publish release
run: gh release edit v$EAS_CLI_VERSION --draft=false -F /tmp/current-changelog run: hub release edit v$EAS_CLI_VERSION --draft=false -F /tmp/current-changelog
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Commit and push - name: Commit and push
+1 -4
View File
@@ -2,9 +2,6 @@ name: Size
on: on:
pull_request: pull_request:
types: [synchronize, opened] types: [synchronize, opened]
concurrency:
group: size-${{ github.ref }}
cancel-in-progress: true
jobs: jobs:
compare: compare:
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -12,7 +9,7 @@ jobs:
- uses: actions/checkout@v2 - uses: actions/checkout@v2
- uses: actions/setup-node@v2 - uses: actions/setup-node@v2
with: with:
node-version: 20 node-version: 18
- uses: preactjs/compressed-size-action@v2 - uses: preactjs/compressed-size-action@v2
with: with:
build-script: 'build:tarballs:linux' build-script: 'build:tarballs:linux'
+1 -5
View File
@@ -13,10 +13,6 @@ on:
paths: paths:
- 'scripts/**' - 'scripts/**'
concurrency:
group: test-scripts-${{ github.ref }}
cancel-in-progress: true
jobs: jobs:
test: test:
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -25,6 +21,6 @@ jobs:
- name: Setup node - name: Setup node
uses: actions/setup-node@v2 uses: actions/setup-node@v2
with: with:
node-version: 20 node-version: 18
- run: yarn install --frozen-lockfile --check-files - run: yarn install --frozen-lockfile --check-files
- run: yarn typecheck - run: yarn typecheck
+5 -16
View File
@@ -1,23 +1,18 @@
name: Run tests name: Run tests
on: on:
push: push:
branches: [main] branches: [main]
pull_request: pull_request:
types: [opened, synchronize] types: [opened, synchronize]
concurrency:
group: test-${{ github.ref }}
cancel-in-progress: true
jobs: jobs:
test: test:
runs-on: ubuntu-latest runs-on: ubuntu-latest
strategy: strategy:
matrix: matrix:
node: ['18', '20', '22'] node: ['14', '16', '18']
include: include:
- node: '20' - node: '18'
coverage: true coverage: true
name: Test with Node ${{ matrix.node }} name: Test with Node ${{ matrix.node }}
steps: steps:
@@ -28,31 +23,25 @@ jobs:
node-version: ${{ matrix.node }} node-version: ${{ matrix.node }}
- run: yarn install --frozen-lockfile --check-files - run: yarn install --frozen-lockfile --check-files
- run: yarn typecheck - run: yarn typecheck
- run: |
git config --global user.email "[email protected]"
git config --global user.name "Test"
- run: yarn test - run: yarn test
if: ${{ !matrix.coverage }} if: ${{ !matrix.coverage }}
env:
NODE_OPTIONS: --max-old-space-size=8192
- run: yarn test --coverage - run: yarn test --coverage
if: ${{ matrix.coverage }} if: ${{ matrix.coverage }}
env: env:
# Oclif seems to cause OOM errors when testing with coverage enabled. # Oclif seems to cause OOM errors when testing with coverage enabled.
# Remove this whenever we solved those OOM errors. # Remove this whenever we solved those OOM errors.
NODE_OPTIONS: --max-old-space-size=8192 NODE_OPTIONS: --max-old-space-size=4096
- run: yarn lint --max-warnings=0 - run: yarn lint --max-warnings=0
- run: yarn install --frozen-lockfile --check-files - run: yarn install --frozen-lockfile --check-files
working-directory: ./scripts working-directory: ./scripts
- run: yarn lint-changelog
- uses: codecov/codecov-action@v1 - uses: codecov/codecov-action@v1
if: ${{ matrix.coverage }} if: ${{ matrix.coverage }}
with: with:
token: ${{ secrets.CODECOV_TOKEN }} token: ${{ secrets.CODECOV_TOKEN }}
- run: yarn lint-changelog
notify-slack: notify-slack:
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: [test] needs: test
name: Notify Slack name: Notify Slack
if: ${{ github.ref == 'refs/heads/main' && always() }} if: ${{ github.ref == 'refs/heads/main' && always() }}
steps: steps:
@@ -1,81 +0,0 @@
name: Open a PR bumping eas-build modules
on: workflow_dispatch
jobs:
open-pr:
name: Open a PR bumping eas-build modules
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
ref: main
token: ${{ secrets.EXPO_BOT_PAT }}
- name: Setup node
uses: actions/setup-node@v2
with:
node-version: 20
- run: yarn install --frozen-lockfile
- name: Configure git
run: |
git config --global user.email "[email protected]"
git config --global user.name "Expo Bot"
- name: Upgrade eas-build modules
run: |
for DIR in packages/eas-cli packages/eas-json; do
pushd $DIR >/dev/null 2>&1
# packages are ordered according to output of `lerna list --toposort`
# https://github.com/expo/turtle-v2/pull/568
for PACKAGE in "@expo/downloader" "@expo/eas-build-job" "@expo/logger" "@expo/template-file" "@expo/steps" "@expo/turtle-spawn" "@expo/build-tools"; do
if grep -q $PACKAGE package.json; then
echo "[$DIR] yarn --exact add $PACKAGE"
yarn add --exact $PACKAGE
fi
done
popd >/dev/null 2>&1
done
- name: Set a branch name
id: bump-branch-name
run: |
HASH="$(echo $RANDOM | md5sum | head -c 10)"
BRANCH_NAME="@expo-bot/eas-build-bump-$HASH"
echo "BRANCH_NAME=$BRANCH_NAME" >> $GITHUB_OUTPUT
- name: Create a branch, commit, and push
run: |
BRANCH_NAME="${{ steps.bump-branch-name.outputs.BRANCH_NAME }}"
git checkout -b $BRANCH_NAME
git add .
git commit -m "Upgrade eas-build dependencies"
git push origin $BRANCH_NAME
- name: Open a PR to main
uses: repo-sync/pull-request@v2
id: open-pr
with:
source_branch: '${{ steps.bump-branch-name.outputs.BRANCH_NAME }}'
destination_branch: 'main'
pr_title: 'Upgrade eas-build dependencies'
pr_body: ':arrow_up: Upgrades [`eas-build`](https://github.com/expo/eas-build) dependencies.'
pr_reviewer: 'szdziedzic,radoslawkrzemien,sjchmiela,khamilowicz'
github_token: ${{ secrets.EXPO_BOT_PAT }}
- uses: actions/github-script@v3
with:
github-token: ${{ secrets.EXPO_BOT_PAT }}
script: |
github.issues.createComment({
issue_number: ${{ steps.open-pr.outputs.pr_number }},
owner: context.repo.owner,
repo: context.repo.repo,
body: "/changelog-entry chore Upgrade [`eas-build`](https://github.com/expo/eas-build) dependencies."
})
-10
View File
@@ -1,10 +0,0 @@
{
"eslint.enable": true,
"search.exclude": {
"**/node_modules": true,
"**/bower_components": true,
"**/*.code-search": true,
"**/src/**/build/**": false,
"**/src/build/**": false,
}
}
+1 -1347
View File
File diff suppressed because it is too large. Load diff
+16
View File
@@ -0,0 +1,16 @@
* @dsokal @wkozyra95
packages/eas-cli/src/build @dsokal @wkozyra95
packages/eas-cli/src/commands/build @dsokal @wkozyra95
packages/eas-cli/src/submit @dsokal
packages/eas-cli/src/commands/submit.ts @dsokal
packages/eas-cli/src/update @EvanBacon @byCedric @wschurman
packages/eas-cli/src/commands/update @EvanBacon @byCedric @wschurman
packages/eas-cli/schema @bycedric
packages/eas-cli/src/metadata @bycedric
packages/eas-cli/src/commands/metadata @bycedric
packages/eas-json @dsokal @wkozyra95
-19
View File
@@ -18,25 +18,6 @@ If you're making changes to `packages/eas-json` or prefer/need to work with prod
yarn start yarn start
``` ```
If it is easier for you to work with the code locally with compiler options set to:
```json
...
"compilerOptions": {
...
"noUnusedLocals": false,
"noUnusedParameters": false,
...
}
...
```
you can consider using:
```
yarn start-allow-unused
```
## Format ## Format
- Be sure to update the [`CHANGELOG.md`](./CHANGELOG.md) with changes for every PR. There is a changelog bot that can generate the proper entry for you. The instructions on how to use it are in the PR description placeholder. - Be sure to update the [`CHANGELOG.md`](./CHANGELOG.md) with changes for every PR. There is a changelog bot that can generate the proper entry for you. The instructions on how to use it are in the PR description placeholder.
+4
View File
@@ -12,3 +12,7 @@ The algorithm works as follows:
- If there are any entries in the "🛠 Breaking changes" section, bump the MAJOR version. - If there are any entries in the "🛠 Breaking changes" section, bump the MAJOR version.
- Otherwise, if there are any entries in the "🎉 New features", bump the MINOR version. - Otherwise, if there are any entries in the "🎉 New features", bump the MINOR version.
- Otherwise, bump the PATCH version. - Otherwise, bump the PATCH version.
## Updating EAS CLI version on EAS Build
The release process promotes the new version to staging. After verifying it works there, you can promote it to production with `npm dist-tag add eas-cli@{version} latest-eas-build`.
-5
View File
@@ -1,5 +0,0 @@
# Security Policy
## Reporting a Vulnerability
Instead of opening a GitHub issue for security vulnerabilities, send an email to our secure inbox: secure@expo.io.
+7
View File
@@ -0,0 +1,7 @@
module.exports = {
projects: [
require('./packages/eas-cli/jest.config.js'),
require('./packages/eas-json/jest.config.js'),
],
testPathIgnorePatterns: ['.*'],
};
-15
View File
@@ -1,15 +0,0 @@
import type { Config } from 'jest';
// eslint-disable-next-line import/no-relative-packages
import easCliConfig from './packages/eas-cli/jest.config';
// eslint-disable-next-line import/no-relative-packages
import easJsonConfig from './packages/eas-json/jest.config';
const config: Config = {
projects: [easCliConfig, easJsonConfig],
testPathIgnorePatterns: ['.*'],
watchPlugins: ['jest-watch-typeahead/filename', 'jest-watch-typeahead/testname'],
collectCoverageFrom: ['<rootDir>/src/**/*.{ts,tsx,js,jsx}'],
};
export default config;
+7
View File
@@ -0,0 +1,7 @@
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
testRegex: '/__tests__/.*(test|spec)\\.[jt]sx?$',
watchPlugins: ['jest-watch-typeahead/filename', 'jest-watch-typeahead/testname'],
collectCoverageFrom: ['<rootDir>/src/**/*.{ts,tsx,js,jsx}'],
};
-9
View File
@@ -1,9 +0,0 @@
import type { Config } from 'jest';
const config: Config = {
preset: 'ts-jest',
testEnvironment: 'node',
testRegex: '/__tests__/.*(test|spec)\\.[jt]sx?$',
};
export default config;
+2 -1
View File
@@ -1,5 +1,6 @@
{ {
"npmClient": "yarn", "npmClient": "yarn",
"version": "15.0.12", "useWorkspaces": true,
"version": "3.9.3",
"$schema": "node_modules/lerna/schemas/lerna-schema.json" "$schema": "node_modules/lerna/schemas/lerna-schema.json"
} }
+14 -15
View File
@@ -10,7 +10,6 @@
"build-for-size-check": "yarn build && yarn workspace eas-cli pretarball-ci && CLI_SIZE_CHECK=1 yarn workspace eas-cli oclif pack:tarballs --no-xz --targets linux-x64", "build-for-size-check": "yarn build && yarn workspace eas-cli pretarball-ci && CLI_SIZE_CHECK=1 yarn workspace eas-cli oclif pack:tarballs --no-xz --targets linux-x64",
"typecheck": "lerna run typecheck", "typecheck": "lerna run typecheck",
"start": "lerna run watch --stream --parallel", "start": "lerna run watch --stream --parallel",
"start-allow-unused": "lerna run watch-allow-unused --stream --parallel",
"watch": "yarn start", "watch": "yarn start",
"eas": "packages/eas-cli/bin/run", "eas": "packages/eas-cli/bin/run",
"lint": "eslint .", "lint": "eslint .",
@@ -20,22 +19,22 @@
"clean": "lerna run clean && rm -rf node_modules coverage" "clean": "lerna run clean && rm -rf node_modules coverage"
}, },
"devDependencies": { "devDependencies": {
"@types/jest": "29.5.11", "@types/jest": "29.2.5",
"@types/node": "20.11.0", "@types/node": "18.11.18",
"eslint": "8.56.0", "eslint": "8.31.0",
"eslint-config-universe": "13.0.0", "eslint-config-universe": "11.1.1",
"eslint-plugin-async-protect": "3.1.0", "eslint-plugin-async-protect": "3.0.0",
"eslint-plugin-node": "11.1.0", "eslint-plugin-node": "11.1.0",
"jest": "29.7.0", "jest": "29.3.1",
"jest-watch-typeahead": "2.2.2", "jest-watch-typeahead": "2.2.1",
"lerna": "7.4.2", "lerna": "6.4.1",
"oclif": "^3.9.0", "oclif": "3.4.3",
"prettier": "3.1.1", "prettier": "2.8.2",
"ts-jest": "29.1.1", "ts-jest": "29.0.3",
"typescript": "5.3.3" "typescript": "4.9.4"
}, },
"volta": { "volta": {
"node": "20.11.0", "node": "18.6.0",
"yarn": "1.22.21" "yarn": "1.22.19"
} }
} }
+220 -931
View File
File diff suppressed because it is too large. Load diff
-3
View File
@@ -12,6 +12,3 @@ if (process.env.TMPDIR) {
(fs.realpath as any).native = jest.fn(); (fs.realpath as any).native = jest.fn();
module.exports = fs; module.exports = fs;
// Ensure requiring node:fs returns the mock too. This is needed for expo/config and expo/json-file.
jest.mock('node:fs', () => fs);
-1
View File
@@ -7,7 +7,6 @@ documents:
- 'src/commands/**/*.ts' - 'src/commands/**/*.ts'
- 'src/branch/**/*.ts' - 'src/branch/**/*.ts'
- 'src/channel/**/*.ts' - 'src/channel/**/*.ts'
- 'src/worker/**/*.ts'
generates: generates:
src/graphql/generated.ts: src/graphql/generated.ts:
plugins: plugins:
+2057 -26466
View File
File diff suppressed because it is too large. Load diff
+7
View File
@@ -0,0 +1,7 @@
module.exports = {
...require('../../jest/jest.config.js'),
rootDir: __dirname,
roots: ['src', '__mocks__'],
globalSetup: './jest/global-setup.ts',
setupFilesAfterEnv: ['./jest/setup-after-env.ts'],
};
-14
View File
@@ -1,14 +0,0 @@
import type { Config } from 'jest';
// eslint-disable-next-line import/no-relative-packages
import baseConfig from '../../jest/jest.config';
const config: Config = {
...baseConfig,
rootDir: __dirname,
roots: ['src', '__mocks__'],
globalSetup: './jest/global-setup.ts',
setupFilesAfterEnv: ['./jest/setup-after-env.ts'],
};
export default config;
-1
View File
@@ -2,5 +2,4 @@ module.exports = async () => {
process.env.TZ = 'UTC'; process.env.TZ = 'UTC';
process.env.EAS_NO_VCS = '1'; process.env.EAS_NO_VCS = '1';
process.env.EAS_PROJECT_ROOT = '/app'; process.env.EAS_PROJECT_ROOT = '/app';
process.env.FORCE_COLOR = '1';
}; };
+60 -79
View File
@@ -1,135 +1,124 @@
{ {
"name": "eas-cli", "name": "eas-cli",
"description": "EAS command line tool", "description": "EAS command line tool",
"version": "15.0.12", "version": "3.9.3",
"author": "Expo <[email protected]>", "author": "Expo <[email protected]>",
"bin": { "bin": {
"eas": "./bin/run" "eas": "./bin/run"
}, },
"bugs": "https://github.com/expo/eas-cli/issues", "bugs": "https://github.com/expo/eas-cli/issues",
"dependencies": { "dependencies": {
"@expo/apple-utils": "2.1.11", "@expo/apple-utils": "1.2.0",
"@expo/code-signing-certificates": "0.0.5", "@expo/code-signing-certificates": "0.0.5",
"@expo/config": "10.0.6", "@expo/config": "7.0.3",
"@expo/config-plugins": "9.0.12", "@expo/config-plugins": "5.0.4",
"@expo/eas-build-job": "1.0.165", "@expo/config-types": "47.0.0",
"@expo/eas-json": "15.0.0", "@expo/eas-build-job": "1.0.7",
"@expo/env": "^1.0.0", "@expo/eas-json": "3.9.2",
"@expo/json-file": "8.3.3", "@expo/json-file": "8.2.37",
"@expo/logger": "1.0.117", "@expo/multipart-body-parser": "1.1.0",
"@expo/multipart-body-parser": "2.0.0", "@expo/osascript": "2.0.33",
"@expo/osascript": "2.1.4", "@expo/package-manager": "0.0.57",
"@expo/package-manager": "1.7.0", "@expo/pkcs12": "0.0.8",
"@expo/pkcs12": "0.1.3", "@expo/plist": "0.0.20",
"@expo/plist": "0.2.0", "@expo/plugin-help": "5.1.22",
"@expo/plugin-help": "5.1.23", "@expo/plugin-warn-if-update-available": "2.5.0",
"@expo/plugin-warn-if-update-available": "2.5.1", "@expo/prebuild-config": "5.0.7",
"@expo/prebuild-config": "8.0.17",
"@expo/results": "1.0.0", "@expo/results": "1.0.0",
"@expo/rudder-sdk-node": "1.1.1", "@expo/rudder-sdk-node": "1.1.1",
"@expo/spawn-async": "1.7.2", "@expo/spawn-async": "1.7.0",
"@expo/steps": "1.0.166", "@expo/steps": "1.0.8",
"@expo/timeago.js": "1.0.0", "@expo/timeago.js": "1.0.0",
"@oclif/core": "^1.26.2", "@oclif/core": "1.23.2",
"@oclif/plugin-autocomplete": "^2.3.10", "@oclif/plugin-autocomplete": "1.3.10",
"@segment/ajv-human-errors": "^2.1.2", "@segment/ajv-human-errors": "^2.1.2",
"@urql/core": "4.0.11", "@urql/core": "3.1.1",
"@urql/exchange-retry": "1.2.0", "@urql/exchange-retry": "1.0.0",
"ajv": "8.11.0", "ajv": "8.11.0",
"ajv-formats": "2.1.1", "ajv-formats": "2.1.1",
"better-opn": "3.0.2", "better-opn": "3.0.2",
"chalk": "4.1.2", "chalk": "4.1.2",
"cli-progress": "3.12.0", "cli-progress": "3.11.2",
"dateformat": "4.6.3", "dateformat": "4.6.3",
"diff": "7.0.0", "dotenv": "16.0.3",
"dotenv": "16.3.1",
"env-paths": "2.2.0", "env-paths": "2.2.0",
"envinfo": "7.11.0", "envinfo": "7.8.1",
"fast-deep-equal": "3.1.3", "fast-deep-equal": "3.1.3",
"fast-glob": "3.3.2", "fast-glob": "3.2.12",
"figures": "3.2.0", "figures": "3.2.0",
"form-data": "4.0.0", "form-data": "4.0.0",
"fs-extra": "11.2.0", "fs-extra": "10.1.0",
"getenv": "1.0.0", "getenv": "1.0.0",
"gradle-to-js": "2.0.1", "gradle-to-js": "2.0.1",
"graphql": "16.8.1", "graphql": "16.6.0",
"graphql-tag": "2.12.6", "graphql-tag": "2.12.6",
"https-proxy-agent": "5.0.1", "https-proxy-agent": "5.0.1",
"ignore": "5.3.0", "ignore": "5.2.4",
"indent-string": "4.0.0", "indent-string": "4.0.0",
"jks-js": "1.1.0", "jks-js": "1.1.0",
"joi": "17.11.0", "joi": "17.7.0",
"jsonwebtoken": "9.0.0", "keychain": "1.3.0",
"keychain": "1.5.0",
"log-symbols": "4.1.0", "log-symbols": "4.1.0",
"mime": "3.0.0", "mime": "3.0.0",
"minimatch": "5.1.2", "minimatch": "5.1.2",
"minizlib": "3.0.1", "nanoid": "3.3.4",
"nanoid": "3.3.8",
"node-fetch": "2.6.7", "node-fetch": "2.6.7",
"node-forge": "1.3.1", "node-forge": "1.3.1",
"nullthrows": "1.1.1", "nullthrows": "1.1.1",
"ora": "5.1.0", "ora": "5.1.0",
"pkg-dir": "4.2.0", "pkg-dir": "4.2.0",
"pngjs": "7.0.0", "pngjs": "6.0.0",
"promise-limit": "2.7.0", "promise-limit": "2.7.0",
"promise-retry": "2.0.1", "promise-retry": "2.0.1",
"prompts": "2.4.2", "prompts": "2.4.2",
"qrcode-terminal": "0.12.0", "qrcode-terminal": "0.12.0",
"resolve-from": "5.0.0", "resolve-from": "5.0.0",
"semver": "7.5.4", "semver": "7.3.8",
"slash": "3.0.0", "slash": "3.0.0",
"tar": "6.2.1", "tar": "6.1.13",
"tar-stream": "3.1.7",
"terminal-link": "2.1.1", "terminal-link": "2.1.1",
"tslib": "2.6.2", "tslib": "2.4.1",
"turndown": "7.1.2", "turndown": "7.1.1",
"untildify": "4.0.0", "untildify": "4.0.0",
"uuid": "9.0.1", "uuid": "9.0.0",
"wrap-ansi": "7.0.0", "wrap-ansi": "7.0.0"
"yaml": "2.6.0",
"zod": "^3.23.8"
}, },
"devDependencies": { "devDependencies": {
"@graphql-codegen/cli": "5.0.0", "@graphql-codegen/cli": "2.16.3",
"@graphql-codegen/introspection": "4.0.0", "@graphql-codegen/introspection": "2.2.3",
"@graphql-codegen/typescript": "4.0.1", "@graphql-codegen/typescript": "2.8.7",
"@graphql-codegen/typescript-operations": "4.0.1", "@graphql-codegen/typescript-operations": "2.5.12",
"@tsconfig/node18": "18.2.4", "@types/cli-progress": "3.11.0",
"@types/cli-progress": "3.11.5",
"@types/dateformat": "3.0.1", "@types/dateformat": "3.0.1",
"@types/diff": "6.0.0", "@types/envinfo": "7.8.1",
"@types/envinfo": "7.8.3",
"@types/express": "4.17.15", "@types/express": "4.17.15",
"@types/fs-extra": "11.0.4", "@types/fs-extra": "9.0.13",
"@types/getenv": "^1.0.0", "@types/getenv": "^1.0.0",
"@types/jsonwebtoken": "8.5.1",
"@types/mime": "3.0.1", "@types/mime": "3.0.1",
"@types/node-fetch": "2.6.2", "@types/node-fetch": "2.6.2",
"@types/node-forge": "1.3.1", "@types/node-forge": "1.3.1",
"@types/pngjs": "6.0.4", "@types/pngjs": "6.0.1",
"@types/promise-retry": "1.1.3", "@types/promise-retry": "1.1.3",
"@types/prompts": "2.4.2", "@types/prompts": "2.4.2",
"@types/semver": "7.5.6", "@types/semver": "7.3.13",
"@types/tar": "6.1.10", "@types/tar": "6.1.3",
"@types/tar-stream": "3.1.3",
"@types/tough-cookie": "4.0.2", "@types/tough-cookie": "4.0.2",
"@types/uuid": "9.0.7", "@types/uuid": "9.0.0",
"@types/wrap-ansi": "3.0.0", "@types/wrap-ansi": "3.0.0",
"axios": "0.28.1", "axios": "0.27.2",
"eslint-plugin-graphql": "4.0.0", "eslint-plugin-graphql": "4.0.0",
"express": "4.20.0", "express": "4.18.2",
"memfs": "3.4.13", "memfs": "3.4.13",
"mockdate": "3.0.5", "mockdate": "3.0.5",
"nock": "13.4.0", "nock": "13.2.9",
"rimraf": "3.0.2", "rimraf": "3.0.2",
"ts-deepmerge": "6.2.0", "ts-deepmerge": "4.0.0",
"ts-mockito": "2.6.1", "ts-mockito": "2.6.1",
"ts-node": "10.9.2", "ts-node": "10.9.1",
"typescript": "5.3.3" "typescript": "4.9.4"
}, },
"engines": { "engines": {
"node": ">=18.0.0" "node": ">=14.0.0"
}, },
"files": [ "files": [
"/bin", "/bin",
@@ -180,22 +169,16 @@
"secret": { "secret": {
"description": "manage project and account secrets" "description": "manage project and account secrets"
}, },
"env": {
"description": "manage project and account environment variables"
},
"update": { "update": {
"description": "manage individual updates" "description": "manage individual updates"
}, },
"webhook": { "webhook": {
"description": "manage webhooks" "description": "manage webhooks"
},
"workflow": {
"description": "manage workflows"
} }
}, },
"warn-if-update-available": { "warn-if-update-available": {
"timeoutInDays": 0.5, "timeoutInDays": 0.5,
"message": "<%= chalk('★') %> <%= chalk.bold(config.name + '@' + latest) %> <%= chalk.bold('is now available.') %>\n<%= chalk.dim('To upgrade, run:') %>\n<%= chalk.dim(chalk.bold('npm install -g eas-cli')) %>\n<%= chalk.dim('Proceeding with outdated version.') %>\n" "message": "<%= chalk('★') %> <%= chalk.bold(config.name + '@' + latest) %> <%= chalk.bold('is now available.') %>\n<%= chalk.dim('To upgrade, run ') %><%= chalk.dim(chalk.bold('npm install -g eas-cli')) %><%= chalk.dim('.') %>\n<%= chalk.dim('Proceeding with outdated version.') %>\n"
}, },
"update": { "update": {
"node": { "node": {
@@ -224,9 +207,7 @@
"rebuild": "rimraf build && yarn build", "rebuild": "rimraf build && yarn build",
"pretarball-ci": "./scripts/pretarball-ci.sh", "pretarball-ci": "./scripts/pretarball-ci.sh",
"build": "tsc --project tsconfig.build.json", "build": "tsc --project tsconfig.build.json",
"build-allow-unused": "tsc --project tsconfig.allowUnused.json",
"watch": "yarn build --watch --preserveWatchOutput", "watch": "yarn build --watch --preserveWatchOutput",
"watch-allow-unused": "yarn build-allow-unused --watch --preserveWatchOutput",
"typecheck": "tsc", "typecheck": "tsc",
"test": "jest", "test": "jest",
"version": "yarn oclif readme && node scripts/patch-readme && git add README.md", "version": "yarn oclif readme && node scripts/patch-readme && git add README.md",
@@ -234,7 +215,7 @@
"clean": "rimraf dist build tmp node_modules yarn-error.log" "clean": "rimraf dist build tmp node_modules yarn-error.log"
}, },
"volta": { "volta": {
"node": "20.11.0", "node": "18.6.0",
"yarn": "1.22.21" "yarn": "1.22.19"
} }
} }
+18 -53
View File
@@ -1,20 +1,6 @@
{ {
"$schema": "http://json-schema.org/draft-06/schema", "$schema": "http://json-schema.org/draft-06/schema",
"type": "object", "type": "object",
"properties": {
"configVersion": {
"enum": [0],
"description": "The EAS metadata store configuration version"
},
"apple": {
"$ref": "#/definitions/AppleConfig"
}
},
"additionalProperties": false,
"required": [
"configVersion",
"apple"
],
"definitions": { "definitions": {
"apple": { "apple": {
"AppleKidsAge": { "AppleKidsAge": {
@@ -337,8 +323,7 @@
"gambling", "gambling",
"unrestrictedWebAccess", "unrestrictedWebAccess",
"kidsAgeBand", "kidsAgeBand",
"ageRatingOverride", "seventeenPlus"
"koreaAgeRatingOverride"
], ],
"defaultSnippets": [ "defaultSnippets": [
{ {
@@ -360,9 +345,7 @@
"gambling": false, "gambling": false,
"unrestrictedWebAccess": false, "unrestrictedWebAccess": false,
"kidsAgeBand": null, "kidsAgeBand": null,
"ageRatingOverride": "NONE", "seventeenPlus": false
"koreaAgeRatingOverride": "NONE",
"lootBox": false
} }
}, },
{ {
@@ -384,9 +367,7 @@
"unrestrictedWebAccess": false, "unrestrictedWebAccess": false,
"gambling": false, "gambling": false,
"kidsAgeBand": null, "kidsAgeBand": null,
"ageRatingOverride": "${13|NONE,SEVENTEEN_PLUS,UNRATED|}", "seventeenPlus": false
"koreaAgeRatingOverride": "${14|NONE,FIFTEEN_PLUS,NINETEEN_PLUS|}",
"lootBox": false
} }
} }
], ],
@@ -461,24 +442,8 @@
}, },
"seventeenPlus": { "seventeenPlus": {
"type": "boolean", "type": "boolean",
"deprecated": true,
"deprecationMessage": "This property is deprecated, use `ageRatingOverride` instead.",
"description": "If your app rates 12+ or lower, and you believe its content may not be suitable for children under 17, you can manually set the age rating to 17+.\n@see https://help.apple.com/app-store-connect/#/dev599d50efb", "description": "If your app rates 12+ or lower, and you believe its content may not be suitable for children under 17, you can manually set the age rating to 17+.\n@see https://help.apple.com/app-store-connect/#/dev599d50efb",
"markdownDescription": "If your app rates 12+ or lower, and you believe its content may not be suitable for children under 17, you can manually set the age rating to 17+.\n\n[Learn more](https://help.apple.com/app-store-connect/#/dev599d50efb)" "markdownDescription": "If your app rates 12+ or lower, and you believe its content may not be suitable for children under 17, you can manually set the age rating to 17+.\n\n[Learn more](https://help.apple.com/app-store-connect/#/dev599d50efb)"
},
"ageRatingOverride": {
"$ref": "#/definitions/apple/AppleAgeRatingOverride",
"description": "If your app rates 12+ or lower, and you believe its content may not be suitable for children under 17, you can manually set the age rating to 17+.\n\nIf your app will be distributed through alternative app marketplaces or websites in the European Union and you believe your app is [Unrated](https://developer.apple.com/help/app-store-connect/reference/age-ratings), set this to `UNRATED`.\n\n@see https://help.apple.com/app-store-connect/#/dev599d50efb",
"markdownDescription": "If your app rates 12+ or lower, and you believe its content may not be suitable for children under 17, you can manually set the age rating to 17+.\n\nIf your app will be distributed through alternative app marketplaces or websites in the European Union and you believe your app is [Unrated](https://developer.apple.com/help/app-store-connect/reference/age-ratings), set this to `UNRATED`.\n\n[Learn more](https://help.apple.com/app-store-connect/#/dev599d50efb)"
},
"koreaAgeRatingOverride": {
"$ref": "#/definitions/apple/AppleKoreaAgeRatingOverride",
"description": "If your app rates 12+ or lower, and you believe its content may not be suitable for children under 15 or 19, you can manually set the age rating to 15+ or 19+.\n\nIf your app will be distributed through alternative app marketplaces or websites in the European Union and you believe your app is [Unrated](https://developer.apple.com/help/app-store-connect/reference/age-ratings), set this to `UNRATED`.\n\n@see https://help.apple.com/app-store-connect/#/dev599d50efb",
"markdownDescription": "If your app rates 12+ or lower, and you believe its content may not be suitable for children under 15 or 19, you can manually set the age rating to 15+ or 19+.\n\nIf your app will be distributed through alternative app marketplaces or websites in the European Union and you believe your app is [Unrated](https://developer.apple.com/help/app-store-connect/reference/age-ratings), set this to `UNRATED`.\n\n[Learn more](https://help.apple.com/app-store-connect/#/dev599d50efb)"
},
"lootBox": {
"type": "boolean",
"description": "Does your app contain purchasable loot boxes? These virtual containers provide players with in-game items, including functional cards or cosmetic modifications, based on random chance."
} }
} }
}, },
@@ -600,20 +565,6 @@
"maxLength": 4000 "maxLength": 4000
} }
} }
},
"AppleAgeRatingOverride": {
"enum": [
"NONE",
"SEVENTEEN_PLUS",
"UNRATED"
]
},
"AppleKoreaAgeRatingOverride": {
"enum": [
"NONE",
"FIFTEEN_PLUS",
"NINETEEN_PLUS"
]
} }
}, },
"AppleConfig": { "AppleConfig": {
@@ -824,5 +775,19 @@
} }
} }
} }
} },
"properties": {
"configVersion": {
"enum": [0],
"description": "The EAS metadata store configuration version"
},
"apple": {
"$ref": "#/definitions/AppleConfig"
}
},
"additionalProperties": false,
"required": [
"configVersion",
"apple"
]
} }
+2 -1
View File
@@ -3,7 +3,8 @@ const fs = require('fs/promises');
(async () => { (async () => {
// Patch `oclif readme` path and link generation // Patch `oclif readme` path and link generation
let readmeContent = await fs.readFile('README.md', 'utf8'); let readmeContent = await fs.readFile('README.md', 'utf8');
readmeContent = readmeContent.replace(/src\/commands/g, 'packages/eas-cli/src/commands'); readmeContent = readmeContent.replace(/\[build\//g, '[src/');
readmeContent = readmeContent.replace(/build\/commands/g, 'packages/eas-cli/src/commands');
await fs.writeFile('README.md', readmeContent); await fs.writeFile('README.md', readmeContent);
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
-40
View File
@@ -1,40 +0,0 @@
module.exports = {
extends: ['universe/shared/typescript-analysis'],
overrides: [
{
files: ['*.ts', '*.d.ts'],
parserOptions: {
project: './packages/eas-cli/tsconfig.json',
},
rules: {
'@typescript-eslint/explicit-function-return-type': [
'warn',
{
allowExpressions: true,
},
],
'@typescript-eslint/prefer-nullish-coalescing': ['warn', { ignorePrimitives: true }],
'@typescript-eslint/no-confusing-void-expression': 'warn',
'@typescript-eslint/await-thenable': 'error',
'@typescript-eslint/no-misused-promises': [
'error',
{
checksVoidReturn: false,
},
],
'@typescript-eslint/no-floating-promises': 'error',
'no-void': ['warn', { allowAsStatement: true }],
'no-return-await': 'off',
'@typescript-eslint/return-await': ['error', 'always'],
'@typescript-eslint/no-confusing-non-null-assertion': 'warn',
'@typescript-eslint/no-extra-non-null-assertion': 'warn',
'@typescript-eslint/prefer-as-const': 'warn',
'@typescript-eslint/prefer-includes': 'warn',
'@typescript-eslint/prefer-readonly': 'warn',
'@typescript-eslint/prefer-string-starts-ends-with': 'warn',
'@typescript-eslint/prefer-ts-expect-error': 'warn',
'@typescript-eslint/no-unnecessary-type-assertion': 'warn',
},
},
],
};
@@ -1,5 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`BuildVersionGetView reading version aborts when the appVersionSource is not specified and the user chooses to set it to LOCAL 1`] = `"Aborting..."`;
exports[`BuildVersionGetView reading version when appVersionSource is set to local 1`] = `"This project is not configured for using remote version source. Add {"cli": { "appVersionSource": "remote" }} in eas.json or re-run this command without "--non-interactive" flag."`;
@@ -1,232 +0,0 @@
import chalk from 'chalk';
import {
getMockEasJson,
mockCommandContext,
mockProjectId,
mockTestCommand,
withLocalVersionSource,
withRemoteVersionSource,
} from './utils';
import BuildVersionGetView from '../../commands/build/version/get';
import { AppVersionQuery } from '../../graphql/queries/AppVersionQuery';
import Log from '../../log';
import { AppVersionSourceUpdateOption } from '../../project/remoteVersionSource';
import * as prompts from '../../prompts';
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
jest.mock('../../project/applicationIdentifier');
jest.mock('fs');
jest.mock('../../log');
jest.mock('../../utils/json');
jest.mock('../../graphql/queries/AppVersionQuery');
jest.mock('../../prompts');
describe(BuildVersionGetView, () => {
afterEach(() => {
jest.clearAllMocks();
});
test('reading version for platform android', async () => {
const ctx = mockCommandContext(BuildVersionGetView, {
easJson: withRemoteVersionSource(getMockEasJson()),
});
jest.mocked(AppVersionQuery.latestVersionAsync).mockImplementation(async () => ({
buildVersion: '100',
storeVersion: '1.0.0',
}));
const cmd = mockTestCommand(BuildVersionGetView, ['--platform=android'], ctx);
await cmd.run();
expect(AppVersionQuery.latestVersionAsync).toHaveBeenCalledWith(
ctx.loggedIn.graphqlClient,
mockProjectId,
'ANDROID',
'eas.test.com'
);
expect(Log.log).toHaveBeenCalledWith(`Android versionCode - ${chalk.bold('100')}`);
expect(enableJsonOutput).not.toHaveBeenCalled();
expect(printJsonOnlyOutput).not.toHaveBeenCalled();
});
test('reading version for platform android when the appVersionSource is not specified and the user chooses to set it to REMOTE', async () => {
const ctx = mockCommandContext(BuildVersionGetView, {});
jest.mocked(AppVersionQuery.latestVersionAsync).mockImplementation(async () => ({
buildVersion: '100',
storeVersion: '1.0.0',
}));
const cmd = mockTestCommand(BuildVersionGetView, ['--platform=android'], ctx);
jest
.mocked(prompts.selectAsync)
.mockImplementationOnce(async () => AppVersionSourceUpdateOption.SET_TO_REMOTE);
await cmd.run();
expect(AppVersionQuery.latestVersionAsync).toHaveBeenCalledWith(
ctx.loggedIn.graphqlClient,
mockProjectId,
'ANDROID',
'eas.test.com'
);
expect(Log.log).toHaveBeenCalledWith(`Android versionCode - ${chalk.bold('100')}`);
expect(enableJsonOutput).not.toHaveBeenCalled();
expect(printJsonOnlyOutput).not.toHaveBeenCalled();
});
test('reading version for platform android when no remote version is set', async () => {
const ctx = mockCommandContext(BuildVersionGetView, {
easJson: withRemoteVersionSource(getMockEasJson()),
});
jest.mocked(AppVersionQuery.latestVersionAsync).mockImplementation(async () => null);
const cmd = mockTestCommand(BuildVersionGetView, ['--platform=android'], ctx);
await cmd.run();
expect(AppVersionQuery.latestVersionAsync).toHaveBeenCalledWith(
ctx.loggedIn.graphqlClient,
mockProjectId,
'ANDROID',
'eas.test.com'
);
expect(Log.log).toHaveBeenCalledWith(`No remote versions are configured for this project.`);
expect(enableJsonOutput).not.toHaveBeenCalled();
expect(printJsonOnlyOutput).not.toHaveBeenCalled();
});
test('reading version with --json flag for platform android', async () => {
const ctx = mockCommandContext(BuildVersionGetView, {
easJson: withRemoteVersionSource(getMockEasJson()),
});
jest.mocked(AppVersionQuery.latestVersionAsync).mockImplementation(async () => ({
buildVersion: '100',
storeVersion: '1.0.0',
}));
const cmd = mockTestCommand(
BuildVersionGetView,
['--non-interactive', '--json', '--platform=android'],
ctx
);
await cmd.run();
expect(AppVersionQuery.latestVersionAsync).toHaveBeenCalledWith(
ctx.loggedIn.graphqlClient,
mockProjectId,
'ANDROID',
'eas.test.com'
);
expect(enableJsonOutput).toHaveBeenCalled();
expect(printJsonOnlyOutput).toHaveBeenCalledWith({
versionCode: '100',
});
});
test('reading version with --json flag for platform ios', async () => {
const ctx = mockCommandContext(BuildVersionGetView, {
easJson: withRemoteVersionSource(getMockEasJson()),
});
jest.mocked(AppVersionQuery.latestVersionAsync).mockImplementation(async () => ({
buildVersion: '100',
storeVersion: '1.0.0',
}));
const cmd = mockTestCommand(
BuildVersionGetView,
['--non-interactive', '--json', '--platform=ios'],
ctx
);
await cmd.run();
expect(AppVersionQuery.latestVersionAsync).toHaveBeenCalledWith(
ctx.loggedIn.graphqlClient,
mockProjectId,
'IOS',
'eas.test.com'
);
expect(enableJsonOutput).toHaveBeenCalled();
expect(printJsonOnlyOutput).toHaveBeenCalledWith({
buildNumber: '100',
});
});
test('reading version with --json flag when no remote version is set', async () => {
const ctx = mockCommandContext(BuildVersionGetView, {
easJson: withRemoteVersionSource(getMockEasJson()),
});
jest.mocked(AppVersionQuery.latestVersionAsync).mockImplementation(async () => null);
const cmd = mockTestCommand(
BuildVersionGetView,
['--non-interactive', '--json', '--platform=android'],
ctx
);
await cmd.run();
expect(AppVersionQuery.latestVersionAsync).toHaveBeenCalledWith(
ctx.loggedIn.graphqlClient,
mockProjectId,
'ANDROID',
'eas.test.com'
);
expect(enableJsonOutput).toHaveBeenCalled();
expect(printJsonOnlyOutput).toHaveBeenCalledWith({});
});
test('reading version when appVersionSource is set to local ', async () => {
const ctx = mockCommandContext(BuildVersionGetView, {
easJson: withLocalVersionSource(getMockEasJson()),
});
jest.mocked(AppVersionQuery.latestVersionAsync).mockImplementation(async () => ({
buildVersion: '100',
storeVersion: '1.0.0',
}));
const cmd = mockTestCommand(
BuildVersionGetView,
['--non-interactive', '--json', '--platform=android'],
ctx
);
await expect(cmd.run()).rejects.toThrowErrorMatchingSnapshot();
});
test('reading version aborts when the appVersionSource is not specified and the user chooses to set it to LOCAL', async () => {
const ctx = mockCommandContext(BuildVersionGetView, {});
jest.mocked(AppVersionQuery.latestVersionAsync).mockImplementation(async () => ({
buildVersion: '100',
storeVersion: '1.0.0',
}));
jest
.mocked(prompts.selectAsync)
.mockImplementationOnce(async () => AppVersionSourceUpdateOption.SET_TO_LOCAL);
const cmd = mockTestCommand(BuildVersionGetView, ['--platform=android'], ctx);
await expect(cmd.run()).rejects.toThrowErrorMatchingSnapshot();
});
test('reading version aborts when the appVersionSource is not specified and the user chooses to configure it manually', async () => {
const ctx = mockCommandContext(BuildVersionGetView, {});
jest.mocked(AppVersionQuery.latestVersionAsync).mockImplementation(async () => ({
buildVersion: '100',
storeVersion: '1.0.0',
}));
jest
.mocked(prompts.selectAsync)
.mockImplementationOnce(async () => AppVersionSourceUpdateOption.ABORT);
const cmd = mockTestCommand(BuildVersionGetView, ['--platform=android'], ctx);
await expect(cmd.run()).rejects.toThrowError('Aborted.');
});
test('reading version sets appVersionSource to LOCAL and aborts when the appVersionSource is not specified and is run in non-interactive mode', async () => {
const ctx = mockCommandContext(BuildVersionGetView, {});
jest.mocked(AppVersionQuery.latestVersionAsync).mockImplementation(async () => ({
buildVersion: '100',
storeVersion: '1.0.0',
}));
const cmd = mockTestCommand(
BuildVersionGetView,
['--non-interactive', '--json', '--platform=android'],
ctx
);
await expect(cmd.run()).rejects.toThrowError(
`This project is not configured for using remote version source. Add ${chalk.bold(
'{"cli": { "appVersionSource": "remote" }}'
)} in eas.json or re-run this command without "--non-interactive" flag.`
);
});
});
@@ -1,207 +0,0 @@
import fs from 'fs-extra';
import path from 'path';
import {
getMockAppFragment,
getMockEasJson,
mockCommandContext,
mockProjectId,
mockTestCommand,
withLocalVersionSource,
withRemoteVersionSource,
} from './utils';
import BuildVersionSetView from '../../commands/build/version/set';
import { AppVersionMutation } from '../../graphql/mutations/AppVersionMutation';
import { AppQuery } from '../../graphql/queries/AppQuery';
import { AppVersionQuery } from '../../graphql/queries/AppVersionQuery';
import Log from '../../log';
import { AppVersionSourceUpdateOption } from '../../project/remoteVersionSource';
import * as prompts from '../../prompts';
jest.mock('../../project/applicationIdentifier');
jest.mock('../../graphql/queries/AppVersionQuery');
jest.mock('../../graphql/queries/AppQuery');
jest.mock('../../graphql/mutations/AppVersionMutation');
jest.mock('fs');
jest.mock('../../log');
jest.mock('../../prompts');
jest.mock('../../utils/json');
describe(BuildVersionSetView, () => {
afterEach(() => {
jest.clearAllMocks();
});
test('setting version for platform android', async () => {
const ctx = mockCommandContext(BuildVersionSetView, {
easJson: withRemoteVersionSource(getMockEasJson()),
});
jest.mocked(AppQuery.byIdAsync).mockImplementation(async () => getMockAppFragment());
jest.mocked(AppVersionQuery.latestVersionAsync).mockImplementation(async () => null);
jest.mocked(prompts.promptAsync).mockImplementation(async () => ({
version: '1000',
}));
const cmd = mockTestCommand(BuildVersionSetView, ['--platform=android'], ctx);
await cmd.run();
expect(AppVersionQuery.latestVersionAsync).toHaveBeenCalledWith(
ctx.loggedIn.graphqlClient,
mockProjectId,
'ANDROID',
'eas.test.com'
);
expect(AppVersionMutation.createAppVersionAsync).toHaveBeenCalledWith(
ctx.loggedIn.graphqlClient,
expect.objectContaining({
buildVersion: '1000',
storeVersion: '1.0.0',
})
);
});
test('setting version for platform android when the appVersionSource is not specified and the user chooses to set it to REMOTE', async () => {
const ctx = mockCommandContext(BuildVersionSetView, {});
jest.mocked(AppQuery.byIdAsync).mockImplementation(async () => getMockAppFragment());
jest.mocked(AppVersionQuery.latestVersionAsync).mockImplementation(async () => null);
jest.mocked(prompts.promptAsync).mockImplementationOnce(async () => ({
version: '1000',
}));
jest
.mocked(prompts.selectAsync)
.mockImplementationOnce(async () => AppVersionSourceUpdateOption.SET_TO_REMOTE);
const cmd = mockTestCommand(BuildVersionSetView, ['--platform=android'], ctx);
await cmd.run();
expect(AppVersionQuery.latestVersionAsync).toHaveBeenCalledWith(
ctx.loggedIn.graphqlClient,
mockProjectId,
'ANDROID',
'eas.test.com'
);
expect(AppVersionMutation.createAppVersionAsync).toHaveBeenCalledWith(
ctx.loggedIn.graphqlClient,
expect.objectContaining({
buildVersion: '1000',
storeVersion: '1.0.0',
})
);
});
test('printing current remote version before prompting for a new one', async () => {
const ctx = mockCommandContext(BuildVersionSetView, {
easJson: withRemoteVersionSource(getMockEasJson()),
});
jest.mocked(AppQuery.byIdAsync).mockImplementation(async () => getMockAppFragment());
jest.mocked(AppVersionQuery.latestVersionAsync).mockImplementation(async () => ({
buildVersion: '100',
storeVersion: '1.0.0',
}));
jest.mocked(prompts.promptAsync).mockImplementation(async () => ({
version: '1000',
}));
const cmd = mockTestCommand(BuildVersionSetView, ['--platform=android'], ctx);
await cmd.run();
expect(Log.log).toHaveBeenCalledWith(expect.stringMatching('configured with versionCode 100'));
expect(AppVersionQuery.latestVersionAsync).toHaveBeenCalledWith(
ctx.loggedIn.graphqlClient,
mockProjectId,
'ANDROID',
'eas.test.com'
);
});
test('printing info that there is no remote version configured', async () => {
const ctx = mockCommandContext(BuildVersionSetView, {
easJson: withRemoteVersionSource(getMockEasJson()),
});
jest.mocked(AppQuery.byIdAsync).mockImplementation(async () => getMockAppFragment());
jest.mocked(AppVersionQuery.latestVersionAsync).mockImplementation(async () => null);
jest.mocked(prompts.promptAsync).mockImplementation(async () => ({
version: '1000',
}));
const cmd = mockTestCommand(BuildVersionSetView, ['--platform=android'], ctx);
await cmd.run();
expect(Log.log).toHaveBeenCalledWith(
expect.stringMatching('does not have any versionCode configured')
);
expect(AppVersionQuery.latestVersionAsync).toHaveBeenCalledWith(
ctx.loggedIn.graphqlClient,
mockProjectId,
'ANDROID',
'eas.test.com'
);
});
test('setting version aborts when appVersionSource is set to local and users refuse auto configuration', async () => {
const ctx = mockCommandContext(BuildVersionSetView, {
easJson: withLocalVersionSource(getMockEasJson()),
});
jest.mocked(AppVersionQuery.latestVersionAsync).mockImplementation(async () => null);
jest.mocked(AppQuery.byIdAsync).mockImplementation(async () => getMockAppFragment());
jest.mocked(prompts.confirmAsync).mockImplementation(async () => false);
const cmd = mockTestCommand(BuildVersionSetView, ['--platform=android'], ctx);
await expect(cmd.run()).rejects.toThrowError('Aborting...');
expect(AppVersionMutation.createAppVersionAsync).not.toHaveBeenCalledWith();
});
test('setting version aborts when the appVersionSource is not specified and the user chooses to set it to LOCAL, and they refuse auto configuration', async () => {
const ctx = mockCommandContext(BuildVersionSetView, {});
jest.mocked(AppVersionQuery.latestVersionAsync).mockImplementation(async () => null);
jest.mocked(AppQuery.byIdAsync).mockImplementation(async () => getMockAppFragment());
jest
.mocked(prompts.selectAsync)
.mockImplementationOnce(async () => AppVersionSourceUpdateOption.SET_TO_LOCAL);
jest.mocked(prompts.confirmAsync).mockImplementationOnce(async () => false);
const cmd = mockTestCommand(BuildVersionSetView, ['--platform=android'], ctx);
await expect(cmd.run()).rejects.toThrowError('Aborting...');
expect(AppVersionMutation.createAppVersionAsync).not.toHaveBeenCalledWith();
});
test('setting version when appVersionSource is set to local and user allows auto configuration', async () => {
const ctx = mockCommandContext(BuildVersionSetView, {
easJson: withLocalVersionSource(getMockEasJson()),
});
jest.mocked(AppVersionQuery.latestVersionAsync).mockImplementation(async () => null);
jest.mocked(AppQuery.byIdAsync).mockImplementation(async () => getMockAppFragment());
jest.mocked(prompts.confirmAsync).mockImplementation(async () => true);
const cmd = mockTestCommand(BuildVersionSetView, ['--platform=android'], ctx);
await cmd.run();
const easJsonAfterCmd = await fs.readJson(path.join(ctx.projectDir, 'eas.json'));
expect(easJsonAfterCmd.cli.appVersionSource).toBe('remote');
});
test('setting version when the appVersionSource is not specified and the user chooses to set it to LOCAL, and they allow auto configuration', async () => {
const ctx = mockCommandContext(BuildVersionSetView, {});
jest.mocked(AppVersionQuery.latestVersionAsync).mockImplementation(async () => null);
jest.mocked(AppQuery.byIdAsync).mockImplementation(async () => getMockAppFragment());
jest
.mocked(prompts.selectAsync)
.mockImplementationOnce(async () => AppVersionSourceUpdateOption.SET_TO_LOCAL);
jest.mocked(prompts.confirmAsync).mockImplementationOnce(async () => true);
const cmd = mockTestCommand(BuildVersionSetView, ['--platform=android'], ctx);
await cmd.run();
const easJsonAfterCmd = await fs.readJson(path.join(ctx.projectDir, 'eas.json'));
expect(easJsonAfterCmd.cli.appVersionSource).toBe('remote');
});
test('setting version aborts when the appVersionSource is not specified and the user chooses to configure manually', async () => {
const ctx = mockCommandContext(BuildVersionSetView, {});
jest.mocked(AppVersionQuery.latestVersionAsync).mockImplementation(async () => null);
jest.mocked(AppQuery.byIdAsync).mockImplementation(async () => getMockAppFragment());
jest
.mocked(prompts.selectAsync)
.mockImplementationOnce(async () => AppVersionSourceUpdateOption.ABORT);
const cmd = mockTestCommand(BuildVersionSetView, ['--platform=android'], ctx);
await expect(cmd.run()).rejects.toThrowError('Aborted.');
expect(AppVersionMutation.createAppVersionAsync).not.toHaveBeenCalledWith();
});
});
@@ -1,271 +0,0 @@
import { Workflow } from '@expo/eas-build-job';
import fs from 'fs-extra';
import path from 'path';
import {
getMockAppFragment,
getMockEasJson,
mockCommandContext,
mockProjectId,
mockTestCommand,
withLocalVersionSource,
withRemoteVersionSource,
} from './utils';
import { updateNativeVersionsAsync as updateAndroidNativeVersionsAsync } from '../../build/android/version';
import { updateNativeVersionsAsync as updateIosNativeVersionsAsync } from '../../build/ios/version';
import BuildVersionSyncView from '../../commands/build/version/sync';
import { Target } from '../../credentials/ios/types';
import { AppVersionMutation } from '../../graphql/mutations/AppVersionMutation';
import { AppQuery } from '../../graphql/queries/AppQuery';
import { AppVersionQuery } from '../../graphql/queries/AppVersionQuery';
import { getAppBuildGradleAsync } from '../../project/android/gradleUtils';
import { resolveTargetsAsync } from '../../project/ios/target';
import { AppVersionSourceUpdateOption } from '../../project/remoteVersionSource';
import { resolveWorkflowAsync } from '../../project/workflow';
import * as prompts from '../../prompts';
jest.mock('../../build/android/version');
jest.mock('../../build/ios/version');
jest.mock('../../project/applicationIdentifier');
jest.mock('../../graphql/queries/AppVersionQuery');
jest.mock('../../graphql/queries/AppQuery');
jest.mock('../../graphql/mutations/AppVersionMutation');
jest.mock('../../project/workflow');
jest.mock('../../project/android/gradleUtils');
jest.mock('../../project/ios/target');
jest.mock('../../project/ios/scheme');
jest.mock('fs');
jest.mock('../../log');
jest.mock('../../prompts');
jest.mock('../../utils/json');
describe(BuildVersionSyncView, () => {
afterEach(() => {
jest.clearAllMocks();
});
test('syncing version for managed project on platform android', async () => {
const ctx = mockCommandContext(BuildVersionSyncView, {
easJson: withRemoteVersionSource(getMockEasJson()),
});
jest.mocked(AppQuery.byIdAsync).mockImplementation(async () => getMockAppFragment());
jest.mocked(AppVersionQuery.latestVersionAsync).mockImplementation(async () => ({
buildVersion: '1000',
storeVersion: '0.0.1',
}));
jest.mocked(prompts.promptAsync).mockImplementation(async () => ({
version: '1000',
}));
jest.mocked(resolveWorkflowAsync).mockImplementation(async () => Workflow.MANAGED);
const cmd = mockTestCommand(BuildVersionSyncView, ['--platform=android'], ctx);
const syncAndroidAsync = jest.spyOn(cmd, 'syncAndroidAsync' as any);
await cmd.run();
expect(AppVersionQuery.latestVersionAsync).toHaveBeenCalledWith(
ctx.loggedIn.graphqlClient,
mockProjectId,
'ANDROID',
'eas.test.com'
);
expect(syncAndroidAsync).not.toHaveBeenCalled();
});
test('syncing version for managed project on platform android when appVersionSource is not set and the user chooses to set it to REMOTE', async () => {
const ctx = mockCommandContext(BuildVersionSyncView, {});
jest.mocked(AppQuery.byIdAsync).mockImplementation(async () => getMockAppFragment());
jest.mocked(AppVersionQuery.latestVersionAsync).mockImplementation(async () => ({
buildVersion: '1000',
storeVersion: '0.0.1',
}));
jest.mocked(prompts.promptAsync).mockImplementationOnce(async () => ({
version: '1000',
}));
jest
.mocked(prompts.selectAsync)
.mockImplementationOnce(async () => AppVersionSourceUpdateOption.SET_TO_REMOTE);
jest.mocked(resolveWorkflowAsync).mockImplementation(async () => Workflow.MANAGED);
const cmd = mockTestCommand(BuildVersionSyncView, ['--platform=android'], ctx);
const syncAndroidAsync = jest.spyOn(cmd, 'syncAndroidAsync' as any);
await cmd.run();
expect(AppVersionQuery.latestVersionAsync).toHaveBeenCalledWith(
ctx.loggedIn.graphqlClient,
mockProjectId,
'ANDROID',
'eas.test.com'
);
expect(syncAndroidAsync).not.toHaveBeenCalled();
});
test('syncing version for bare project on platform android', async () => {
const ctx = mockCommandContext(BuildVersionSyncView, {
easJson: withRemoteVersionSource(getMockEasJson()),
});
jest.mocked(AppQuery.byIdAsync).mockImplementation(async () => getMockAppFragment());
jest.mocked(AppVersionQuery.latestVersionAsync).mockImplementation(async () => ({
buildVersion: '1000',
storeVersion: '0.0.1',
}));
jest.mocked(prompts.promptAsync).mockImplementation(async () => ({
version: '1000',
}));
jest.mocked(resolveWorkflowAsync).mockImplementation(async () => Workflow.GENERIC);
jest.mocked(getAppBuildGradleAsync).mockImplementation(async () => ({
android: {},
}));
const cmd = mockTestCommand(BuildVersionSyncView, ['--platform=android'], ctx);
const syncAndroidAsync = jest.spyOn(cmd, 'syncAndroidAsync' as any);
await cmd.run();
expect(AppVersionQuery.latestVersionAsync).toHaveBeenCalledWith(
ctx.loggedIn.graphqlClient,
mockProjectId,
'ANDROID',
'eas.test.com'
);
expect(syncAndroidAsync).toHaveBeenCalled();
expect(updateAndroidNativeVersionsAsync).toHaveBeenCalledWith({
projectDir: ctx.projectDir,
versionCode: 1000,
});
});
test('syncing version for managed project on platform ios', async () => {
const ctx = mockCommandContext(BuildVersionSyncView, {
easJson: withRemoteVersionSource(getMockEasJson()),
});
jest.mocked(AppQuery.byIdAsync).mockImplementation(async () => getMockAppFragment());
jest.mocked(AppVersionQuery.latestVersionAsync).mockImplementation(async () => ({
buildVersion: '1000',
storeVersion: '0.0.1',
}));
jest.mocked(prompts.promptAsync).mockImplementation(async () => ({
version: '1000',
}));
jest.mocked(resolveWorkflowAsync).mockImplementation(async () => Workflow.MANAGED);
const cmd = mockTestCommand(BuildVersionSyncView, ['--platform=ios'], ctx);
const syncIosAsync = jest.spyOn(cmd, 'syncIosAsync' as any);
await cmd.run();
expect(AppVersionQuery.latestVersionAsync).toHaveBeenCalledWith(
ctx.loggedIn.graphqlClient,
mockProjectId,
'IOS',
'eas.test.com'
);
expect(syncIosAsync).not.toHaveBeenCalled();
});
test('syncing version for bare project on platform ios', async () => {
const ctx = mockCommandContext(BuildVersionSyncView, {
easJson: withRemoteVersionSource(getMockEasJson()),
});
const fakeTarget: Target = {
targetName: 'testapp',
bundleIdentifier: 'eas.test.com',
entitlements: {},
};
jest.mocked(AppQuery.byIdAsync).mockImplementation(async () => getMockAppFragment());
jest.mocked(AppVersionQuery.latestVersionAsync).mockImplementation(async () => ({
buildVersion: '1000',
storeVersion: '0.0.1',
}));
jest.mocked(prompts.promptAsync).mockImplementation(async () => ({
version: '1000',
}));
jest.mocked(resolveWorkflowAsync).mockImplementation(async () => Workflow.GENERIC);
jest.mocked(resolveTargetsAsync).mockImplementation(async () => [fakeTarget]);
const cmd = mockTestCommand(BuildVersionSyncView, ['--platform=ios'], ctx);
const syncIosAsync = jest.spyOn(cmd, 'syncIosAsync' as any);
await cmd.run();
expect(AppVersionQuery.latestVersionAsync).toHaveBeenCalledWith(
ctx.loggedIn.graphqlClient,
mockProjectId,
'IOS',
'eas.test.com'
);
expect(syncIosAsync).toHaveBeenCalled();
expect(updateIosNativeVersionsAsync).toHaveBeenCalledWith({
buildNumber: '1000',
projectDir: ctx.projectDir,
targets: [fakeTarget],
});
});
test('syncing version aborts when appVersionSource is set to local and users refuse auto configuration', async () => {
const ctx = mockCommandContext(BuildVersionSyncView, {
easJson: withLocalVersionSource(getMockEasJson()),
});
jest.mocked(AppVersionQuery.latestVersionAsync).mockImplementation(async () => null);
jest.mocked(AppQuery.byIdAsync).mockImplementation(async () => getMockAppFragment());
jest.mocked(prompts.confirmAsync).mockImplementation(async () => false);
const cmd = mockTestCommand(BuildVersionSyncView, ['--platform=android'], ctx);
await expect(cmd.run()).rejects.toThrowError('Aborting...');
expect(AppVersionMutation.createAppVersionAsync).not.toHaveBeenCalledWith();
});
test('syncing version aborts when appVersionSource is not set and the user chooses to set it to LOCAL, and they refuse auto configuration', async () => {
const ctx = mockCommandContext(BuildVersionSyncView, {});
jest.mocked(AppVersionQuery.latestVersionAsync).mockImplementation(async () => null);
jest.mocked(AppQuery.byIdAsync).mockImplementation(async () => getMockAppFragment());
jest
.mocked(prompts.selectAsync)
.mockImplementationOnce(async () => AppVersionSourceUpdateOption.SET_TO_LOCAL);
jest.mocked(prompts.confirmAsync).mockImplementation(async () => false);
const cmd = mockTestCommand(BuildVersionSyncView, ['--platform=android'], ctx);
await expect(cmd.run()).rejects.toThrowError('Aborting...');
expect(AppVersionMutation.createAppVersionAsync).not.toHaveBeenCalledWith();
});
test('syncing version when appVersionSource is set to local and user allows auto configuration', async () => {
const ctx = mockCommandContext(BuildVersionSyncView, {
easJson: withLocalVersionSource(getMockEasJson()),
});
jest.mocked(AppVersionQuery.latestVersionAsync).mockImplementation(async () => null);
jest.mocked(AppQuery.byIdAsync).mockImplementation(async () => getMockAppFragment());
jest.mocked(prompts.confirmAsync).mockImplementation(async () => true);
const cmd = mockTestCommand(BuildVersionSyncView, ['--platform=android'], ctx);
await cmd.run();
const easJsonAfterCmd = await fs.readJson(path.join(ctx.projectDir, 'eas.json'));
expect(easJsonAfterCmd.cli.appVersionSource).toBe('remote');
});
test('syncing version when appVersionSource is not set and the user chooses to set it to LOCAL and they allow auto configuration', async () => {
const ctx = mockCommandContext(BuildVersionSyncView, {});
jest.mocked(AppVersionQuery.latestVersionAsync).mockImplementation(async () => null);
jest.mocked(AppQuery.byIdAsync).mockImplementation(async () => getMockAppFragment());
jest
.mocked(prompts.selectAsync)
.mockImplementationOnce(async () => AppVersionSourceUpdateOption.SET_TO_LOCAL);
jest.mocked(prompts.confirmAsync).mockImplementation(async () => true);
const cmd = mockTestCommand(BuildVersionSyncView, ['--platform=android'], ctx);
await cmd.run();
const easJsonAfterCmd = await fs.readJson(path.join(ctx.projectDir, 'eas.json'));
expect(easJsonAfterCmd.cli.appVersionSource).toBe('remote');
});
test('syncing version aborts when appVersionSource is not set and the user chooses to configure manually', async () => {
const ctx = mockCommandContext(BuildVersionSyncView, {});
jest.mocked(AppVersionQuery.latestVersionAsync).mockImplementation(async () => null);
jest.mocked(AppQuery.byIdAsync).mockImplementation(async () => getMockAppFragment());
jest
.mocked(prompts.selectAsync)
.mockImplementationOnce(async () => AppVersionSourceUpdateOption.ABORT);
const cmd = mockTestCommand(BuildVersionSyncView, ['--platform=android'], ctx);
await expect(cmd.run()).rejects.toThrowError('Aborted.');
expect(AppVersionMutation.createAppVersionAsync).not.toHaveBeenCalledWith();
});
});
@@ -1,170 +0,0 @@
import { ExpoConfig } from '@expo/config';
import { AppVersionSource, EasJson } from '@expo/eas-json';
import { Command, Config } from '@oclif/core';
import { vol } from 'memfs';
import path from 'path';
import { v4 as uuidv4 } from 'uuid';
import { ContextInput, ContextOutput } from '../../commandUtils/EasCommand';
import { DynamicConfigContextFn } from '../../commandUtils/context/DynamicProjectConfigContextField';
import { AppFragment, Role } from '../../graphql/generated';
export function getMockEasJson(): EasJson {
return {
cli: {},
build: {
production: {},
},
};
}
export const mockProjectId = 'fake-project-id';
export function getMockAppFragment(): AppFragment {
return {
id: mockProjectId,
slug: 'testapp',
name: 'testapp',
fullName: '@testuser/testpp',
ownerAccount: {
id: 'test-account-id',
name: 'testuser',
users: [
{
role: Role.Owner,
actor: {
id: 'test-user-id',
},
},
],
},
};
}
export function getMockExpoConfig(): ExpoConfig {
return {
name: 'testapp',
slug: 'tetsapp',
extra: {
projectId: mockProjectId,
},
};
}
export interface LocalProjectContext {
projectDir: string;
exp?: ExpoConfig;
appId?: string;
}
export function mockCommandContext<
C extends {
[name: string]: any;
} = object,
>(
commandClass: { contextDefinition: ContextInput<C> },
overrides: {
easJson?: EasJson;
exp?: ExpoConfig;
projectId?: string;
getDynamicPrivateProjectConfigAsync?: DynamicConfigContextFn;
getDynamicPublicProjectConfigAsync?: DynamicConfigContextFn;
}
): ContextOutput<C> {
const projectDir = path.join('/test', uuidv4());
vol.reset();
vol.fromJSON({ 'eas.json': JSON.stringify(overrides.easJson ?? getMockEasJson()) }, projectDir);
const contextDefinition = commandClass.contextDefinition;
const result: any = {};
for (const [contextKey] of Object.entries(contextDefinition)) {
if (contextKey === 'loggedIn') {
result.loggedIn = {};
}
if (contextKey === 'projectDir') {
result.projectDir = projectDir;
}
if (contextKey === 'maybeLoggedIn') {
result.maybeLoggedIn = {};
}
if (contextKey === 'sessionManager') {
result.sessionManager = {};
}
if (contextKey === 'projectConfig') {
result.projectConfig = {
exp: overrides.exp ?? getMockExpoConfig(),
projectId: overrides.projectId ?? mockProjectId,
projectDir,
};
}
if (contextKey === 'getDynamicPrivateProjectConfigAsync') {
result.getDynamicPrivateProjectConfigAsync = () => ({
exp: overrides.exp ?? getMockExpoConfig(),
projectId: overrides.projectId ?? mockProjectId,
projectDir,
});
}
if (contextKey === 'getDynamicPublicProjectConfigAsync') {
result.getDynamicPublicProjectConfigAsync = () => ({
exp: overrides.exp ?? getMockExpoConfig(),
projectId: overrides.projectId ?? mockProjectId,
projectDir,
});
}
}
return result;
}
export function mockTestCommand<T extends Command>(
commandConstructor: new (argv: string[], config: Config) => T,
argv: string[],
ctx: any
): T {
const cmd = new commandConstructor(argv, new Config({ root: __dirname }));
(cmd as any).getContextAsync = jest.fn(() => ctx);
return cmd;
}
export class NoErrorThrownError extends Error {}
export const getErrorAsync = async <TError = any>(
call: () => unknown
): Promise<TError | NoErrorThrownError> => {
try {
await call();
throw new NoErrorThrownError();
} catch (error: unknown) {
return error as TError;
}
};
export const getError = <TError = any>(call: () => unknown): TError | NoErrorThrownError => {
try {
call();
throw new NoErrorThrownError();
} catch (error: unknown) {
return error as TError;
}
};
export function withRemoteVersionSource(easJson: EasJson): EasJson {
return {
...easJson,
cli: {
...easJson.cli,
appVersionSource: AppVersionSource.REMOTE,
},
};
}
export function withLocalVersionSource(easJson: EasJson): EasJson {
return {
...easJson,
cli: {
...easJson.cli,
appVersionSource: AppVersionSource.LOCAL,
},
};
}
@@ -1,12 +0,0 @@
import { UpdateBranchBasicInfoFragment } from '../../graphql/generated';
export const testBasicBranchInfo: UpdateBranchBasicInfoFragment = {
id: 'c4fec95d-7ab1-4d35-bfe6-fe7ff05beda9',
name: 'development',
__typename: 'UpdateBranch',
};
export const testBasicBranchInfo2: UpdateBranchBasicInfoFragment = {
id: '43ccb8a4-8aaa-47f0-9790-dcc80933441b',
name: 'preview',
__typename: 'UpdateBranch',
};
@@ -1,107 +0,0 @@
import { EASUpdateAction, EASUpdateContext, NonInteractiveError } from '../../eas-update/utils';
import { UpdateBranchBasicInfoFragment } from '../../graphql/generated';
import { BranchQuery } from '../../graphql/queries/BranchQuery';
import {
Connection,
Edge,
FilterPagination,
QueryParams,
selectPaginatedAsync,
} from '../../utils/relay';
/**
* Select a branch for the project.
*
* @constructor
* @param {function} options.filterPredicate - A predicate to filter the branches that are shown to the user. It takes a branchInfo object as a parameter and returns a boolean.
* @param {string} options.printedType - The type of branch printed to the user. Defaults to 'branch'.
* @param {number} options.pageSize - The number of branches to show per page. Defaults to 100.
* @param {function} options.beforeEachFilterQuery Optional. If a filter predicate was specified, this callback function will be called before each query.
* @args externalQueryParams The query params for the pagination.
* @args totalNodesFetched The total number of nodes fetched so far.
* @args dataset The dataset so far.
* @param {function} options.afterEachFilterQuery Optional. If a filter predicate was specified, this callback function will be called after each query.
* @args externalQueryParams The query params for the pagination.
* @args totalNodesFetched The total number of nodes fetched so far.
* @args dataset The dataset so far.
* @args willFetchAgain If the query will fetch again to get a complete page.
*/
export class SelectBranch implements EASUpdateAction<UpdateBranchBasicInfoFragment | null> {
constructor(
private readonly options: {
filterPredicate?: (branchInfo: UpdateBranchBasicInfoFragment) => boolean;
printedType?: string;
pageSize?: number;
beforeEachFilterQuery?: (
externalQueryParams: QueryParams,
totalNodesFetched: number,
dataset: Edge<UpdateBranchBasicInfoFragment>[]
) => void;
afterEachFilterQuery?: (
externalQueryParams: QueryParams,
totalNodesFetched: number,
dataset: Edge<UpdateBranchBasicInfoFragment>[],
willFetchAgain: boolean
) => void;
} = {}
) {}
async queryAsync(
ctx: EASUpdateContext,
queryParams: QueryParams
): Promise<Connection<UpdateBranchBasicInfoFragment>> {
const { graphqlClient, app } = ctx;
const { projectId } = app;
return await BranchQuery.listBranchesBasicInfoPaginatedOnAppAsync(graphqlClient, {
appId: projectId,
...queryParams,
});
}
async filterQueryAsync(
ctx: EASUpdateContext,
queryParams: QueryParams,
filterPredicate: (branchInfo: UpdateBranchBasicInfoFragment) => boolean
): Promise<Connection<UpdateBranchBasicInfoFragment>> {
const queryAsync = async (
queryParams: QueryParams
): Promise<Connection<UpdateBranchBasicInfoFragment>> =>
await this.queryAsync(ctx, queryParams);
return await FilterPagination.getPageAsync({
queryParams,
queryAsync,
filterPredicate,
beforeEachQuery: this.options.beforeEachFilterQuery,
afterEachQuery: this.options.afterEachFilterQuery,
});
}
public async runAsync(ctx: EASUpdateContext): Promise<UpdateBranchBasicInfoFragment | null> {
const { nonInteractive } = ctx;
const { filterPredicate } = this.options;
const printedType = this.options.printedType ?? 'branch';
const pageSize = this.options.pageSize ?? 100;
if (nonInteractive) {
throw new NonInteractiveError(
`${printedType} selection cannot be run in non-interactive mode.`
);
}
const queryAsync = async (
queryParams: QueryParams
): Promise<Connection<UpdateBranchBasicInfoFragment>> =>
filterPredicate
? await this.filterQueryAsync(ctx, queryParams, filterPredicate)
: await this.queryAsync(ctx, queryParams);
const getTitleAsync = async (branchInfo: UpdateBranchBasicInfoFragment): Promise<string> =>
branchInfo.name;
return await selectPaginatedAsync({
queryAsync,
getTitleAsync,
printedType,
pageSize,
});
}
}
@@ -1,92 +0,0 @@
import { createCtxMock } from '../../../eas-update/__tests__/fixtures';
import { NonInteractiveError } from '../../../eas-update/utils';
import { UpdateBranchBasicInfoFragment } from '../../../graphql/generated';
import { BranchQuery } from '../../../graphql/queries/BranchQuery';
import { promptAsync } from '../../../prompts';
import { testBasicBranchInfo, testBasicBranchInfo2 } from '../../__tests__/fixtures';
import { SelectBranch } from '../SelectBranch';
jest.mock('../../../prompts');
jest.mock('../../../graphql/queries/BranchQuery');
describe(SelectBranch, () => {
afterEach(() => {
jest.clearAllMocks();
});
it('returns null if project has no branchs', async () => {
const ctx = createCtxMock();
jest.mocked(BranchQuery.listBranchesBasicInfoPaginatedOnAppAsync).mockResolvedValue({
edges: [],
pageInfo: {
hasPreviousPage: false,
hasNextPage: false,
},
});
const selectBranch = new SelectBranch();
const branch = await selectBranch.runAsync(ctx);
expect(branch).toBeNull();
});
it('still prompts if project has only one branch', async () => {
const ctx = createCtxMock();
jest.mocked(BranchQuery.listBranchesBasicInfoPaginatedOnAppAsync).mockResolvedValue({
edges: [{ node: testBasicBranchInfo, cursor: 'cursor' }],
pageInfo: {
hasPreviousPage: false,
hasNextPage: false,
},
});
jest.mocked(promptAsync).mockImplementation(async () => ({
item: testBasicBranchInfo,
}));
const selectBranch = new SelectBranch();
const branch = await selectBranch.runAsync(ctx);
expect(branch).toBe(testBasicBranchInfo);
expect(promptAsync).toHaveBeenCalledTimes(1);
});
it('returns the selected branch', async () => {
jest.mocked(BranchQuery.listBranchesBasicInfoPaginatedOnAppAsync).mockResolvedValue({
edges: [
{ node: testBasicBranchInfo, cursor: 'cursor' },
{ node: testBasicBranchInfo2, cursor: 'cursor' },
],
pageInfo: {
hasPreviousPage: false,
hasNextPage: false,
},
});
jest.mocked(promptAsync).mockImplementation(async () => ({
item: testBasicBranchInfo,
}));
const ctx = createCtxMock();
const selectBranch = new SelectBranch();
const branch = await selectBranch.runAsync(ctx);
expect(branch).toBe(testBasicBranchInfo);
});
it('returns the only available branch with a filter', async () => {
jest.mocked(BranchQuery.listBranchesBasicInfoPaginatedOnAppAsync).mockResolvedValue({
edges: [
{ node: testBasicBranchInfo, cursor: 'cursor' },
{ node: testBasicBranchInfo2, cursor: 'cursor' },
],
pageInfo: {
hasPreviousPage: false,
hasNextPage: false,
},
});
const ctx = createCtxMock();
const selectBranch = new SelectBranch({
filterPredicate: (branchInfo: UpdateBranchBasicInfoFragment) =>
branchInfo === testBasicBranchInfo,
});
const branch = await selectBranch.runAsync(ctx);
expect(branch).toBe(testBasicBranchInfo);
});
it('throws an error in Non-Interactive Mode', async () => {
const ctx = createCtxMock({
nonInteractive: true,
});
const selectBranch = new SelectBranch();
// dont fail if users are running in non-interactive mode
await expect(selectBranch.runAsync(ctx)).rejects.toThrowError(NonInteractiveError);
});
});
+10 -15
View File
@@ -1,19 +1,16 @@
import chalk from 'chalk'; import chalk from 'chalk';
import { print } from 'graphql';
import gql from 'graphql-tag'; import gql from 'graphql-tag';
import { BranchNotFoundError } from './utils';
import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient'; import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient';
import { PaginatedQueryOptions } from '../commandUtils/pagination'; import { PaginatedQueryOptions } from '../commandUtils/pagination';
import { withErrorHandlingAsync } from '../graphql/client'; import { withErrorHandlingAsync } from '../graphql/client';
import { import {
CreateUpdateBranchForAppMutation, CreateUpdateBranchForAppMutation,
CreateUpdateBranchForAppMutationVariables, CreateUpdateBranchForAppMutationVariables,
UpdateBranchBasicInfoFragment, UpdateBranch,
UpdateBranchFragment, UpdateBranchFragment,
} from '../graphql/generated'; } from '../graphql/generated';
import { BranchQuery } from '../graphql/queries/BranchQuery'; import { BranchQuery } from '../graphql/queries/BranchQuery';
import { UpdateBranchBasicInfoFragmentNode } from '../graphql/types/UpdateBranchBasicInfo';
import Log from '../log'; import Log from '../log';
import { formatBranch, getBranchDescription } from '../update/utils'; import { formatBranch, getBranchDescription } from '../update/utils';
import { printJsonOnlyOutput } from '../utils/json'; import { printJsonOnlyOutput } from '../utils/json';
@@ -22,6 +19,7 @@ import {
paginatedQueryWithConfirmPromptAsync, paginatedQueryWithConfirmPromptAsync,
paginatedQueryWithSelectPromptAsync, paginatedQueryWithSelectPromptAsync,
} from '../utils/queries'; } from '../utils/queries';
import { BranchNotFoundError } from './utils';
export const BRANCHES_LIMIT = 50; export const BRANCHES_LIMIT = 50;
@@ -86,9 +84,7 @@ export async function listAndRenderBranchesOnAppAsync(
queryBranchesOnProjectAsync(graphqlClient, limit, offset, projectId), queryBranchesOnProjectAsync(graphqlClient, limit, offset, projectId),
promptOptions: { promptOptions: {
title: 'Load more branches?', title: 'Load more branches?',
renderListItems: branches => { renderListItems: branches => renderPageOfBranches(branches, paginatedQueryOptions),
renderPageOfBranches(branches, paginatedQueryOptions);
},
}, },
}); });
} }
@@ -128,7 +124,7 @@ function renderPageOfBranches(
export async function createUpdateBranchOnAppAsync( export async function createUpdateBranchOnAppAsync(
graphqlClient: ExpoGraphqlClient, graphqlClient: ExpoGraphqlClient,
{ appId, name }: CreateUpdateBranchForAppMutationVariables { appId, name }: CreateUpdateBranchForAppMutationVariables
): Promise<UpdateBranchBasicInfoFragment> { ): Promise<Pick<UpdateBranch, 'id' | 'name'>> {
const result = await withErrorHandlingAsync( const result = await withErrorHandlingAsync(
graphqlClient graphqlClient
.mutation<CreateUpdateBranchForAppMutation, CreateUpdateBranchForAppMutationVariables>( .mutation<CreateUpdateBranchForAppMutation, CreateUpdateBranchForAppMutationVariables>(
@@ -137,17 +133,15 @@ export async function createUpdateBranchOnAppAsync(
updateBranch { updateBranch {
createUpdateBranchForApp(appId: $appId, name: $name) { createUpdateBranchForApp(appId: $appId, name: $name) {
id id
...UpdateBranchBasicInfoFragment name
} }
} }
} }
${print(UpdateBranchBasicInfoFragmentNode)}
`, `,
{ {
appId, appId,
name, name,
}, }
{ additionalTypenames: ['UpdateBranch'] }
) )
.toPromise() .toPromise()
); );
@@ -167,21 +161,22 @@ export async function ensureBranchExistsAsync(
appId: string; appId: string;
branchName: string; branchName: string;
} }
): Promise<{ branch: UpdateBranchBasicInfoFragment; createdBranch: boolean }> { ): Promise<{ branchId: string; createdBranch: boolean }> {
try { try {
const updateBranch = await BranchQuery.getBranchByNameAsync(graphqlClient, { const updateBranch = await BranchQuery.getBranchByNameAsync(graphqlClient, {
appId, appId,
name: branchName, name: branchName,
}); });
return { branch: updateBranch, createdBranch: false }; const { id } = updateBranch;
return { branchId: id, createdBranch: false };
} catch (error) { } catch (error) {
if (error instanceof BranchNotFoundError) { if (error instanceof BranchNotFoundError) {
const newUpdateBranch = await createUpdateBranchOnAppAsync(graphqlClient, { const newUpdateBranch = await createUpdateBranchOnAppAsync(graphqlClient, {
appId, appId,
name: branchName, name: branchName,
}); });
return { branch: newUpdateBranch, createdBranch: true }; return { branchId: newUpdateBranch.id, createdBranch: true };
} else { } else {
throw error; throw error;
} }
+6 -3
View File
@@ -1,7 +1,10 @@
import { Client } from '../vcs/vcs'; import { getVcsClient } from '../vcs';
export async function getDefaultBranchNameAsync(vcsClient: Client): Promise<string | null> { export async function getDefaultBranchNameAsync(): Promise<string> {
return await vcsClient.getBranchNameAsync(); return (
(await getVcsClient().getBranchNameAsync()) ||
`branch-${Math.random().toString(36).substring(2, 4)}`
);
} }
export class BranchNotFoundError extends Error { export class BranchNotFoundError extends Error {
@@ -3,7 +3,13 @@ import { v4 as uuid } from 'uuid';
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient'; import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
import { selectBuildToCancelAsync } from '../../commands/build/cancel'; import { selectBuildToCancelAsync } from '../../commands/build/cancel';
import { AppPlatform, BuildFragment, BuildPriority, BuildStatus } from '../../graphql/generated'; import {
AppPlatform,
BuildFragment,
BuildPriority,
BuildResourceClass,
BuildStatus,
} from '../../graphql/generated';
import { BuildQuery } from '../../graphql/queries/BuildQuery'; import { BuildQuery } from '../../graphql/queries/BuildQuery';
import { confirmAsync, selectAsync } from '../../prompts'; import { confirmAsync, selectAsync } from '../../prompts';
@@ -47,14 +53,14 @@ describe(selectBuildToCancelAsync.name, () => {
it('does not return build id when confirmation is rejected', async () => { it('does not return build id when confirmation is rejected', async () => {
const graphqlClient = instance(mock<ExpoGraphqlClient>()); const graphqlClient = instance(mock<ExpoGraphqlClient>());
jest.mocked(confirmAsync).mockResolvedValueOnce(false); jest.mocked(confirmAsync).mockResolvedValueOnce(false);
await expect(selectBuildToCancelAsync(graphqlClient, projectId, 'blah')).resolves.toEqual(null); expect(selectBuildToCancelAsync(graphqlClient, projectId, 'blah')).resolves.toEqual(null);
}); });
it('returns build id when confirmation is confirmed', async () => { it('returns build id when confirmation is confirmed', async () => {
const graphqlClient = instance(mock<ExpoGraphqlClient>()); const graphqlClient = instance(mock<ExpoGraphqlClient>());
jest.mocked(selectAsync).mockResolvedValueOnce(selectedBuildId); jest.mocked(selectAsync).mockResolvedValueOnce(selectedBuildId);
jest.mocked(confirmAsync).mockResolvedValueOnce(true); jest.mocked(confirmAsync).mockResolvedValueOnce(true);
await expect(selectBuildToCancelAsync(graphqlClient, projectId, 'blah')).resolves.toEqual( expect(selectBuildToCancelAsync(graphqlClient, projectId, 'blah')).resolves.toEqual(
selectedBuildId selectedBuildId
); );
}); });
@@ -85,6 +91,6 @@ function createMockBuildFragment({
}, },
}, },
status: BuildStatus.InQueue, status: BuildStatus.InQueue,
isForIosSimulator: false, resourceClass: BuildResourceClass.AndroidMedium,
}; };
} }
@@ -1,77 +0,0 @@
import { AppVersionSource, EasJsonAccessor } from '@expo/eas-json';
import fs from 'fs-extra';
import { vol } from 'memfs';
import { easCliVersion } from '../../utils/easCli';
import GitClient from '../../vcs/clients/git';
import { Client } from '../../vcs/vcs';
import { ensureProjectConfiguredAsync } from '../configure';
jest.mock('fs');
jest.mock('../../vcs/vcs');
jest.mock('../../vcs/clients/git');
beforeEach(async () => {
vol.reset();
});
describe(ensureProjectConfiguredAsync, () => {
it('returns false and does not configure if eas.json exists', async () => {
vol.fromJSON({
'./eas.json': JSON.stringify({
cli: {
version: `>= ${easCliVersion}`,
},
build: {
development: {
developmentClient: true,
distribution: 'internal',
},
preview: {
distribution: 'internal',
},
production: {},
},
submit: {
production: {},
},
}),
});
await expect(fs.pathExists(EasJsonAccessor.formatEasJsonPath('.'))).resolves.toBeTruthy();
const vcsClientMock = jest.mocked(new GitClient({ requireCommit: false }));
vcsClientMock.showChangedFilesAsync.mockImplementation(async () => {});
vcsClientMock.isCommitRequiredAsync.mockImplementation(async () => false);
vcsClientMock.trackFileAsync.mockImplementation(async () => {});
const result = await ensureProjectConfiguredAsync({
projectDir: '.',
nonInteractive: false,
vcsClient: vcsClientMock as unknown as Client,
});
expect(result).toBeFalsy();
await expect(fs.pathExists(EasJsonAccessor.formatEasJsonPath('.'))).resolves.toBeTruthy();
});
it('returns true and configures if eas.json does not exist', async () => {
const writeFileMock = jest.spyOn(fs, 'writeFile');
writeFileMock.mockImplementation(async (...args) => {
const easJsonPath = args[0] as string;
const easJsonData = args[1] as string;
vol.fromJSON({
[easJsonPath]: easJsonData,
});
});
await expect(fs.pathExists(EasJsonAccessor.formatEasJsonPath('.'))).resolves.toBeFalsy();
const vcsClientMock = jest.mocked(new GitClient({ requireCommit: false }));
vcsClientMock.showChangedFilesAsync.mockImplementation(async () => {});
vcsClientMock.isCommitRequiredAsync.mockImplementation(async () => false);
vcsClientMock.trackFileAsync.mockImplementation(async () => {});
const result = await ensureProjectConfiguredAsync({
projectDir: '.',
nonInteractive: false,
vcsClient: vcsClientMock as unknown as Client,
});
expect(result).toBeTruthy();
await expect(fs.pathExists(EasJsonAccessor.formatEasJsonPath('.'))).resolves.toBeTruthy();
const easJson = await EasJsonAccessor.fromProjectPath('.').readAsync();
expect(easJson.cli?.appVersionSource).toEqual(AppVersionSource.REMOTE);
});
});
@@ -1,90 +0,0 @@
import { instance, mock } from 'ts-mockito';
import { v4 as uuid } from 'uuid';
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
import { selectBuildToDeleteAsync } from '../../commands/build/delete';
import { AppPlatform, BuildFragment, BuildPriority, BuildStatus } from '../../graphql/generated';
import { BuildQuery } from '../../graphql/queries/BuildQuery';
import { confirmAsync, selectAsync } from '../../prompts';
jest.mock('../../ora', () => ({
ora: jest.fn().mockImplementation(() => ({
start: jest.fn().mockImplementation(() => ({
stop: jest.fn(),
fail: jest.fn(),
})),
})),
}));
jest.mock('../../prompts', () => {
return {
selectAsync: jest.fn(),
confirmAsync: jest.fn(),
};
});
jest.mock('../../graphql/queries/BuildQuery', () => {
const actual = jest.requireActual('../../graphql/queries/BuildQuery');
return {
BuildQuery: {
...actual.BuildQuery,
viewBuildsOnAppAsync: jest.fn(),
},
};
});
describe(selectBuildToDeleteAsync.name, () => {
const selectedBuildId = uuid();
const projectId = uuid();
beforeEach(() => {
jest
.mocked(BuildQuery.viewBuildsOnAppAsync)
.mockImplementation(async () => [
createMockBuildFragment({ projectId, buildId: selectedBuildId }),
]);
});
it('does not return build id when confirmation is rejected', async () => {
const graphqlClient = instance(mock<ExpoGraphqlClient>());
jest.mocked(confirmAsync).mockResolvedValueOnce(false);
await expect(selectBuildToDeleteAsync(graphqlClient, projectId, 'blah')).resolves.toEqual(null);
});
it('returns build id when confirmation is confirmed', async () => {
const graphqlClient = instance(mock<ExpoGraphqlClient>());
jest.mocked(selectAsync).mockResolvedValueOnce(selectedBuildId);
jest.mocked(confirmAsync).mockResolvedValueOnce(true);
await expect(selectBuildToDeleteAsync(graphqlClient, projectId, 'blah')).resolves.toEqual(
selectedBuildId
);
});
});
function createMockBuildFragment({
projectId,
buildId,
}: {
projectId: string;
buildId?: string;
}): BuildFragment {
return {
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
platform: AppPlatform.Android,
id: buildId ?? uuid(),
priority: BuildPriority.Normal,
project: {
__typename: 'App',
slug: 'test-project',
id: projectId,
name: 'test-project',
ownerAccount: {
__typename: 'Account',
id: uuid(),
name: 'test-account',
},
},
status: BuildStatus.InQueue,
isForIosSimulator: false,
};
}
@@ -1,9 +1,7 @@
import { Platform } from '@expo/eas-build-job'; import { Platform } from '@expo/eas-build-job';
import { CombinedError } from '@urql/core'; import { CombinedError } from '@urql/core';
import { GraphQLError } from 'graphql/error'; import { GraphQLError } from 'graphql/error';
import { v4 as uuidv4 } from 'uuid';
import { getError } from '../../__tests__/commands/utils';
import { EasCommandError } from '../../commandUtils/errors'; import { EasCommandError } from '../../commandUtils/errors';
import Build from '../../commands/build'; import Build from '../../commands/build';
import Log, { link } from '../../log'; import Log, { link } from '../../log';
@@ -13,9 +11,6 @@ import {
EasBuildFreeTierDisabledAndroidError, EasBuildFreeTierDisabledAndroidError,
EasBuildFreeTierDisabledError, EasBuildFreeTierDisabledError,
EasBuildFreeTierDisabledIOSError, EasBuildFreeTierDisabledIOSError,
EasBuildFreeTierIosLimitExceededError,
EasBuildFreeTierLimitExceededError,
EasBuildResourceClassNotAvailableInFreeTierError,
EasBuildTooManyPendingBuildsError, EasBuildTooManyPendingBuildsError,
RequestValidationError, RequestValidationError,
TurtleDeprecatedJobFormatError, TurtleDeprecatedJobFormatError,
@@ -27,18 +22,12 @@ beforeEach(() => {
const EXPECTED_STRINGIFIED_GRAPHQL_ERROR_JSON = `[ const EXPECTED_STRINGIFIED_GRAPHQL_ERROR_JSON = `[
{ {
"name": "GraphQLError",
"extensions": {},
"message": "Error 1" "message": "Error 1"
}, },
{ {
"name": "GraphQLError",
"extensions": {},
"message": "Error 2" "message": "Error 2"
}, },
{ {
"name": "GraphQLError",
"extensions": {},
"message": "Error 3" "message": "Error 3"
} }
]`; ]`;
@@ -53,11 +42,9 @@ const EXPECTED_MAX_BUILD_COUNT_MESSAGE_IOS = `You have already reached the maxim
const EXPECTED_GENERIC_MESSAGE = const EXPECTED_GENERIC_MESSAGE =
'Build request failed. Make sure you are using the latest eas-cli version. If the problem persists, report the issue.'; 'Build request failed. Make sure you are using the latest eas-cli version. If the problem persists, report the issue.';
const mockRequestId = uuidv4();
const getGraphQLError = (message: string, errorCode: string): GraphQLError => { const getGraphQLError = (message: string, errorCode: string): GraphQLError => {
return new GraphQLError(message, null, null, null, null, null, { return new GraphQLError(message, null, null, null, null, null, {
errorCode, errorCode,
requestId: mockRequestId,
}); });
}; };
@@ -127,135 +114,64 @@ describe(Build.name, () => {
}); });
describe('error is server-side defined error', () => { describe('error is server-side defined error', () => {
describe('for TURTLE_DEPRECATED_JOB_FORMAT', () => { describe('throws correct EasCommandError subclass', () => {
describe('with Android', () => { describe('for TURTLE_DEPRECATED_JOB_FORMAT', () => {
it('throws correct EasCommandError subclass', async () => { it('does it with Android', async () => {
const platform = Platform.ANDROID; const platform = Platform.ANDROID;
const graphQLError = getGraphQLError('Error 1', 'TURTLE_DEPRECATED_JOB_FORMAT'); const graphQLError = getGraphQLError('Error 1', 'TURTLE_DEPRECATED_JOB_FORMAT');
const graphQLErrors = [graphQLError]; const graphQLErrors = [graphQLError];
const error = new CombinedError({ graphQLErrors }); const error = new CombinedError({ graphQLErrors });
const handleBuildRequestErrorThrownError = getError<TurtleDeprecatedJobFormatError>( try {
() => { handleBuildRequestError(error, platform);
handleBuildRequestError(error, platform); } catch (caughtError) {
} assertReThrownError(caughtError as Error, TurtleDeprecatedJobFormatError);
); }
assertReThrownError(
handleBuildRequestErrorThrownError,
TurtleDeprecatedJobFormatError,
'Error 1'
);
}); });
it('throws correct EasBuildResourceClassNotAvailableInFreeTierError', async () => { it('does it with iOS', async () => {
const platform = Platform.ANDROID;
const graphQLError = getGraphQLError(
'Error 1',
'EAS_BUILD_RESOURCE_CLASS_NOT_AVAILABLE_IN_FREE_TIER'
);
const graphQLErrors = [graphQLError];
const error = new CombinedError({ graphQLErrors });
const handleBuildRequestErrorThrownError =
getError<EasBuildResourceClassNotAvailableInFreeTierError>(() => {
handleBuildRequestError(error, platform);
});
assertReThrownError(
handleBuildRequestErrorThrownError,
EasBuildResourceClassNotAvailableInFreeTierError,
'Error 1'
);
});
});
describe('with iOS', () => {
it('throws correct EasCommandError subclass', async () => {
const platform = Platform.IOS; const platform = Platform.IOS;
const graphQLError = getGraphQLError('Error 1', 'TURTLE_DEPRECATED_JOB_FORMAT'); const graphQLError = getGraphQLError('Error 1', 'TURTLE_DEPRECATED_JOB_FORMAT');
const graphQLErrors = [graphQLError]; const graphQLErrors = [graphQLError];
const error = new CombinedError({ graphQLErrors }); const error = new CombinedError({ graphQLErrors });
const handleBuildRequestErrorThrownError = getError<TurtleDeprecatedJobFormatError>( try {
() => { handleBuildRequestError(error, platform);
handleBuildRequestError(error, platform); } catch (caughtError) {
} assertReThrownError(caughtError as Error, TurtleDeprecatedJobFormatError);
); }
assertReThrownError(
handleBuildRequestErrorThrownError,
TurtleDeprecatedJobFormatError,
'Error 1'
);
});
it('throws correct EasBuildResourceClassNotAvailableInFreeTierError', async () => {
const platform = Platform.IOS;
const graphQLError = getGraphQLError(
'Error 1',
'EAS_BUILD_RESOURCE_CLASS_NOT_AVAILABLE_IN_FREE_TIER'
);
const graphQLErrors = [graphQLError];
const error = new CombinedError({ graphQLErrors });
const handleBuildRequestErrorThrownError =
getError<EasBuildResourceClassNotAvailableInFreeTierError>(() => {
handleBuildRequestError(error, platform);
});
assertReThrownError(
handleBuildRequestErrorThrownError,
EasBuildResourceClassNotAvailableInFreeTierError,
'Error 1'
);
}); });
}); });
});
describe('for EAS_BUILD_FREE_TIER_DISABLED', () => { describe('for EAS_BUILD_FREE_TIER_DISABLED', () => {
describe('with Android', () => { it('does it with Android', async () => {
it('throws correct EasCommandError subclass', async () => {
const platform = Platform.ANDROID; const platform = Platform.ANDROID;
const graphQLError = getGraphQLError('Error 1', 'EAS_BUILD_FREE_TIER_DISABLED'); const graphQLError = getGraphQLError('Error 1', 'EAS_BUILD_FREE_TIER_DISABLED');
const graphQLErrors = [graphQLError]; const graphQLErrors = [graphQLError];
const error = new CombinedError({ graphQLErrors }); const error = new CombinedError({ graphQLErrors });
const handleBuildRequestErrorThrownError = getError<EasBuildFreeTierDisabledError>( try {
() => { handleBuildRequestError(error, platform);
handleBuildRequestError(error, platform); } catch (caughtError) {
} assertReThrownError(caughtError as Error, EasBuildFreeTierDisabledError);
); }
assertReThrownError(
handleBuildRequestErrorThrownError,
EasBuildFreeTierDisabledError,
'Error 1'
);
}); });
});
describe('with iOS', () => { it('does it with iOS', async () => {
it('throws correct EasCommandError subclass', async () => {
const platform = Platform.IOS; const platform = Platform.IOS;
const graphQLError = getGraphQLError('Error 1', 'EAS_BUILD_FREE_TIER_DISABLED'); const graphQLError = getGraphQLError('Error 1', 'EAS_BUILD_FREE_TIER_DISABLED');
const graphQLErrors = [graphQLError]; const graphQLErrors = [graphQLError];
const error = new CombinedError({ graphQLErrors }); const error = new CombinedError({ graphQLErrors });
const handleBuildRequestErrorThrownError = getError<EasBuildFreeTierDisabledError>( try {
() => { handleBuildRequestError(error, platform);
handleBuildRequestError(error, platform); } catch (caughtError) {
} assertReThrownError(caughtError as Error, EasBuildFreeTierDisabledError);
); }
assertReThrownError(
handleBuildRequestErrorThrownError,
EasBuildFreeTierDisabledError,
'Error 1'
);
}); });
}); });
});
describe('for EAS_BUILD_FREE_TIER_DISABLED_IOS', () => { it('does it for EAS_BUILD_FREE_TIER_DISABLED_IOS', async () => {
it('throws correct EasCommandError subclass', async () => {
const platform = Platform.IOS; const platform = Platform.IOS;
const graphQLError = getGraphQLError('Error 1', 'EAS_BUILD_FREE_TIER_DISABLED_IOS'); const graphQLError = getGraphQLError('Error 1', 'EAS_BUILD_FREE_TIER_DISABLED_IOS');
const graphQLErrors = [graphQLError]; const graphQLErrors = [graphQLError];
@@ -264,332 +180,181 @@ describe(Build.name, () => {
try { try {
handleBuildRequestError(error, platform); handleBuildRequestError(error, platform);
} catch (caughtError) { } catch (caughtError) {
assertReThrownError(caughtError as Error, EasBuildFreeTierDisabledIOSError, 'Error 1'); assertReThrownError(caughtError as Error, EasBuildFreeTierDisabledIOSError);
} }
}); });
});
describe('for EAS_BUILD_FREE_TIER_DISABLED_ANDROID', () => { it('does it for EAS_BUILD_FREE_TIER_DISABLED_ANDROID', async () => {
it('throws correct EasCommandError subclass', async () => {
const platform = Platform.ANDROID; const platform = Platform.ANDROID;
const graphQLError = getGraphQLError('Error 1', 'EAS_BUILD_FREE_TIER_DISABLED_ANDROID'); const graphQLError = getGraphQLError('Error 1', 'EAS_BUILD_FREE_TIER_DISABLED_ANDROID');
const graphQLErrors = [graphQLError]; const graphQLErrors = [graphQLError];
const error = new CombinedError({ graphQLErrors }); const error = new CombinedError({ graphQLErrors });
const handleBuildRequestErrorThrownError = getError<EasBuildFreeTierDisabledAndroidError>( try {
() => { handleBuildRequestError(error, platform);
handleBuildRequestError(error, platform); } catch (caughtError) {
} assertReThrownError(caughtError as Error, EasBuildFreeTierDisabledAndroidError);
); }
assertReThrownError(
handleBuildRequestErrorThrownError,
EasBuildFreeTierDisabledAndroidError,
'Error 1'
);
}); });
});
describe('for EAS_BUILD_FREE_TIER_LIMIT_EXCEEDED', () => {
it('throws correct EasCommandError subclass', async () => {
const platform = Platform.ANDROID;
const graphQLError = getGraphQLError('Error 1', 'EAS_BUILD_FREE_TIER_LIMIT_EXCEEDED');
const graphQLErrors = [graphQLError];
const error = new CombinedError({ graphQLErrors });
const handleBuildRequestErrorThrownError = getError<EasBuildFreeTierLimitExceededError>( describe('for VALIDATION_ERROR', () => {
() => { it('does it with Android', async () => {
handleBuildRequestError(error, platform);
}
);
assertReThrownError(
handleBuildRequestErrorThrownError,
EasBuildFreeTierLimitExceededError,
'Error 1'
);
});
});
describe('for EAS_BUILD_FREE_TIER_IOS_LIMIT_EXCEEDED', () => {
it('throws correct EasCommandError subclass', async () => {
const platform = Platform.ANDROID;
const graphQLError = getGraphQLError('Error 1', 'EAS_BUILD_FREE_TIER_IOS_LIMIT_EXCEEDED');
const graphQLErrors = [graphQLError];
const error = new CombinedError({ graphQLErrors });
const handleBuildRequestErrorThrownError =
getError<EasBuildFreeTierIosLimitExceededError>(() => {
handleBuildRequestError(error, platform);
});
assertReThrownError(
handleBuildRequestErrorThrownError,
EasBuildFreeTierIosLimitExceededError,
'Error 1'
);
});
});
describe('for VALIDATION_ERROR', () => {
describe('with Android', () => {
it('throws correct EasCommandError subclass', async () => {
const platform = Platform.ANDROID; const platform = Platform.ANDROID;
const graphQLError = getGraphQLError('Error 1', 'VALIDATION_ERROR'); const graphQLError = getGraphQLError('Error 1', 'VALIDATION_ERROR');
const graphQLErrors = [graphQLError]; const graphQLErrors = [graphQLError];
const error = new CombinedError({ graphQLErrors }); const error = new CombinedError({ graphQLErrors });
const handleBuildRequestErrorThrownError = getError<RequestValidationError>(() => { try {
handleBuildRequestError(error, platform); handleBuildRequestError(error, platform);
}); } catch (caughtError) {
assertReThrownError(caughtError as Error, RequestValidationError);
assertReThrownError( }
handleBuildRequestErrorThrownError,
RequestValidationError,
'Error 1'
);
}); });
});
describe('with iOS', () => { it('does it with iOS', async () => {
it('throws correct EasCommandError subclass', async () => {
const platform = Platform.IOS; const platform = Platform.IOS;
const graphQLError = getGraphQLError('Error 1', 'VALIDATION_ERROR'); const graphQLError = getGraphQLError('Error 1', 'VALIDATION_ERROR');
const graphQLErrors = [graphQLError]; const graphQLErrors = [graphQLError];
const error = new CombinedError({ graphQLErrors }); const error = new CombinedError({ graphQLErrors });
const handleBuildRequestErrorThrownError = getError<RequestValidationError>(() => { try {
handleBuildRequestError(error, platform); handleBuildRequestError(error, platform);
}); } catch (caughtError) {
assertReThrownError(caughtError as Error, RequestValidationError);
assertReThrownError( }
handleBuildRequestErrorThrownError,
RequestValidationError,
'Error 1'
);
}); });
}); });
}); });
}); });
describe('error is other expected graphQL-related error', () => { describe('error is other expected graphQL-related error', () => {
describe('for EAS_BUILD_DOWN_FOR_MAINTENANCE', () => { describe('throws correct EasCommandError subclass', () => {
describe('with Android', () => { describe('for EAS_BUILD_DOWN_FOR_MAINTENANCE', () => {
it('throws correct EasCommandError subclass', async () => { it('does it with Android', async () => {
const platform = Platform.ANDROID; const platform = Platform.ANDROID;
const graphQLError = getGraphQLError('Error 1', 'EAS_BUILD_DOWN_FOR_MAINTENANCE'); const graphQLError = getGraphQLError('Error 1', 'EAS_BUILD_DOWN_FOR_MAINTENANCE');
const graphQLErrors = [graphQLError]; const graphQLErrors = [graphQLError];
const error = new CombinedError({ graphQLErrors }); const error = new CombinedError({ graphQLErrors });
const handleBuildRequestErrorThrownError = getError<EasBuildDownForMaintenanceError>( try {
() => { handleBuildRequestError(error, platform);
handleBuildRequestError(error, platform); } catch (caughtError) {
} assertReThrownError(
); caughtError as Error,
EasBuildDownForMaintenanceError,
assertReThrownError( EXPECTED_EAS_BUILD_DOWN_MESSAGE
handleBuildRequestErrorThrownError, );
EasBuildDownForMaintenanceError, }
EXPECTED_EAS_BUILD_DOWN_MESSAGE
);
}); });
});
describe('with iOS', () => { it('does it with iOS', async () => {
it('throws correct EasCommandError subclass', async () => {
const platform = Platform.IOS; const platform = Platform.IOS;
const graphQLError = getGraphQLError('Error 1', 'EAS_BUILD_DOWN_FOR_MAINTENANCE'); const graphQLError = getGraphQLError('Error 1', 'EAS_BUILD_DOWN_FOR_MAINTENANCE');
const graphQLErrors = [graphQLError]; const graphQLErrors = [graphQLError];
const error = new CombinedError({ graphQLErrors }); const error = new CombinedError({ graphQLErrors });
const handleBuildRequestErrorThrownError = getError<EasBuildDownForMaintenanceError>( try {
() => { handleBuildRequestError(error, platform);
handleBuildRequestError(error, platform); } catch (caughtError) {
} assertReThrownError(
); caughtError as Error,
EasBuildDownForMaintenanceError,
assertReThrownError( EXPECTED_EAS_BUILD_DOWN_MESSAGE
handleBuildRequestErrorThrownError, );
EasBuildDownForMaintenanceError, }
EXPECTED_EAS_BUILD_DOWN_MESSAGE
);
}); });
}); });
});
describe('for EAS_BUILD_TOO_MANY_PENDING_BUILDS', () => { describe('for EAS_BUILD_TOO_MANY_PENDING_BUILDS', () => {
describe('with Android', () => { it('does it with Android', async () => {
it('throws correct EasCommandError subclass', async () => {
const platform = Platform.ANDROID; const platform = Platform.ANDROID;
const graphQLError = getGraphQLError('Error 1', 'EAS_BUILD_TOO_MANY_PENDING_BUILDS'); const graphQLError = getGraphQLError('Error 1', 'EAS_BUILD_TOO_MANY_PENDING_BUILDS');
const graphQLErrors = [graphQLError]; const graphQLErrors = [graphQLError];
const error = new CombinedError({ graphQLErrors }); const error = new CombinedError({ graphQLErrors });
const handleBuildRequestErrorThrownError = getError<EasBuildTooManyPendingBuildsError>( try {
() => { handleBuildRequestError(error, platform);
handleBuildRequestError(error, platform); } catch (caughtError) {
} assertReThrownError(
); caughtError as Error,
EasBuildTooManyPendingBuildsError,
assertReThrownError( EXPECTED_MAX_BUILD_COUNT_MESSAGE_ANDROID
handleBuildRequestErrorThrownError, );
EasBuildTooManyPendingBuildsError, }
EXPECTED_MAX_BUILD_COUNT_MESSAGE_ANDROID
);
}); });
});
describe('with iOS', () => { it('does it with iOS', async () => {
it('throws correct EasCommandError subclass', async () => {
const platform = Platform.IOS; const platform = Platform.IOS;
const graphQLError = getGraphQLError('Error 1', 'EAS_BUILD_TOO_MANY_PENDING_BUILDS'); const graphQLError = getGraphQLError('Error 1', 'EAS_BUILD_TOO_MANY_PENDING_BUILDS');
const graphQLErrors = [graphQLError]; const graphQLErrors = [graphQLError];
const error = new CombinedError({ graphQLErrors }); const error = new CombinedError({ graphQLErrors });
const handleBuildRequestErrorThrownError = getError<EasBuildTooManyPendingBuildsError>( try {
() => { handleBuildRequestError(error, platform);
handleBuildRequestError(error, platform); } catch (caughtError) {
} assertReThrownError(
); caughtError as Error,
EasBuildTooManyPendingBuildsError,
assertReThrownError( EXPECTED_MAX_BUILD_COUNT_MESSAGE_IOS
handleBuildRequestErrorThrownError, );
EasBuildTooManyPendingBuildsError, }
EXPECTED_MAX_BUILD_COUNT_MESSAGE_IOS
);
}); });
}); });
}); });
}); });
describe('error is unexpected graphQL-related error', () => { describe('error is unexpected graphQL-related error', () => {
describe('with Android', () => { describe('throws base Error class with custom message', () => {
it('throws base Error class with custom message', async () => { it('does it with Android', async () => {
const platform = Platform.ANDROID; const platform = Platform.ANDROID;
const graphQLError = getGraphQLError('Error 1', 'UNKNOWN_GRAPHQL_ERROR'); const graphQLError = getGraphQLError('Error 1', 'UNKNOWN_GRAPHQL_ERROR');
const graphQLErrors = [graphQLError]; const graphQLErrors = [graphQLError];
const error = new CombinedError({ graphQLErrors }); const error = new CombinedError({ graphQLErrors });
const expectedMessage = `${EXPECTED_GENERIC_MESSAGE}\nRequest ID: ${mockRequestId}\nError message: Error 1`;
const handleBuildRequestErrorThrownError = getError<Error>(() => { try {
handleBuildRequestError(error, platform); handleBuildRequestError(error, platform);
}); } catch (caughtError) {
assertReThrownError(caughtError as Error, Error, EXPECTED_GENERIC_MESSAGE);
assertReThrownError(handleBuildRequestErrorThrownError, Error, expectedMessage); }
}); });
it('throws base Error class with message including all error messages if combined error has multiple GraphQL errors', async () => {
const platform = Platform.ANDROID;
const graphQLErrors = [
getGraphQLError('Error 1', 'UNKNOWN_GRAPHQL_ERROR'),
getGraphQLError('Error 2', 'OTHER_GRAPHQL_ERROR'),
getGraphQLError('Error 3', 'YET_ANOTHER_GRAPHQL_ERROR'),
];
const error = new CombinedError({ graphQLErrors });
const expectedMessage =
`${EXPECTED_GENERIC_MESSAGE}\n` +
`Request ID: ${mockRequestId}\n` +
'Error message: Error 1\n' +
`Request ID: ${mockRequestId}\n` +
'Error message: Error 2\n' +
`Request ID: ${mockRequestId}\n` +
'Error message: Error 3';
const handleBuildRequestErrorThrownError = getError<Error>(() => { it('does it with iOS', async () => {
handleBuildRequestError(error, platform);
});
assertReThrownError(handleBuildRequestErrorThrownError, Error, expectedMessage);
});
describe('without request ID', () => {
it('throws base Error class with custom message without request ID line', async () => {
const platform = Platform.ANDROID;
const graphQLError = getGraphQLError('Error 1', 'UNKNOWN_GRAPHQL_ERROR');
delete graphQLError.extensions.requestId;
const graphQLErrors = [graphQLError];
const error = new CombinedError({ graphQLErrors });
const expectedMessage = `${EXPECTED_GENERIC_MESSAGE}\nError message: Error 1`;
const handleBuildRequestErrorThrownError = getError<Error>(() => {
handleBuildRequestError(error, platform);
});
assertReThrownError(handleBuildRequestErrorThrownError, Error, expectedMessage);
});
});
});
describe('with iOS', () => {
it('throws base Error class with custom message', async () => {
const platform = Platform.IOS; const platform = Platform.IOS;
const graphQLError = getGraphQLError('Error 1', 'UNKNOWN_GRAPHQL_ERROR'); const graphQLError = getGraphQLError('Error 1', 'UNKNOWN_GRAPHQL_ERROR');
const graphQLErrors = [graphQLError]; const graphQLErrors = [graphQLError];
const error = new CombinedError({ graphQLErrors }); const error = new CombinedError({ graphQLErrors });
const expectedMessage = `${EXPECTED_GENERIC_MESSAGE}\nRequest ID: ${mockRequestId}\nError message: Error 1`;
const handleBuildRequestErrorThrownError = getError<Error>(() => { try {
handleBuildRequestError(error, platform); handleBuildRequestError(error, platform);
}); } catch (caughtError) {
assertReThrownError(caughtError as Error, Error, EXPECTED_GENERIC_MESSAGE);
assertReThrownError(handleBuildRequestErrorThrownError, Error, expectedMessage); }
});
it('throws base Error class with message including all error messages if combined error has multiple GraphQL errors', async () => {
const platform = Platform.IOS;
const graphQLErrors = [
getGraphQLError('Error 1', 'UNKNOWN_GRAPHQL_ERROR'),
getGraphQLError('Error 2', 'OTHER_GRAPHQL_ERROR'),
getGraphQLError('Error 3', 'YET_ANOTHER_GRAPHQL_ERROR'),
];
const error = new CombinedError({ graphQLErrors });
const expectedMessage =
`${EXPECTED_GENERIC_MESSAGE}\n` +
`Request ID: ${mockRequestId}\n` +
'Error message: Error 1\n' +
`Request ID: ${mockRequestId}\n` +
'Error message: Error 2\n' +
`Request ID: ${mockRequestId}\n` +
'Error message: Error 3';
const handleBuildRequestErrorThrownError = getError<Error>(() => {
handleBuildRequestError(error, platform);
});
assertReThrownError(handleBuildRequestErrorThrownError, Error, expectedMessage);
});
describe('without request ID', () => {
it('throws base Error class with custom message without request ID line', async () => {
const platform = Platform.IOS;
const graphQLError = getGraphQLError('Error 1', 'UNKNOWN_GRAPHQL_ERROR');
delete graphQLError.extensions.requestId;
const graphQLErrors = [graphQLError];
const error = new CombinedError({ graphQLErrors });
const expectedMessage = `${EXPECTED_GENERIC_MESSAGE}\nError message: Error 1`;
const handleBuildRequestErrorThrownError = getError<Error>(() => {
handleBuildRequestError(error, platform);
});
assertReThrownError(handleBuildRequestErrorThrownError, Error, expectedMessage);
});
}); });
}); });
}); });
describe('error is unexpected non-graphQL-related error', () => { describe('error is unexpected non-graphQL-related error', () => {
describe('with Android', () => { describe('throws base Error class with propagated message', () => {
it('throws base Error class with propagated message', async () => { it('does it with Android', async () => {
const platform = Platform.ANDROID; const platform = Platform.ANDROID;
const error = new Error('Non-graphQL-related error'); const error = new Error('Non-graphQL-related error');
const handleBuildRequestErrorThrownError = getError<Error>(() => { try {
handleBuildRequestError(error, platform); handleBuildRequestError(error, platform);
}); } catch (caughtError) {
expect(caughtError).toStrictEqual(error);
expect(handleBuildRequestErrorThrownError).toStrictEqual(error); }
}); });
});
describe('with iOS', () => { it('does it with iOS', async () => {
it('throws base Error class with propagated message', async () => {
const platform = Platform.IOS; const platform = Platform.IOS;
const error = new Error('Non-graphQL-related error'); const error = new Error('Non-graphQL-related error');
const handleBuildRequestErrorThrownError = getError<Error>(() => { try {
handleBuildRequestError(error, platform); handleBuildRequestError(error, platform);
}); } catch (caughtError) {
expect(caughtError).toStrictEqual(error);
expect(handleBuildRequestErrorThrownError).toStrictEqual(error); }
}); });
}); });
}); });
@@ -1,45 +1,22 @@
import { ExpoConfig } from '@expo/config'; import { ExpoConfig } from '@expo/config';
import { Platform } from '@expo/eas-build-job'; import { Platform } from '@expo/eas-build-job';
import { BuildProfile } from '@expo/eas-json'; import { BuildProfile } from '@expo/eas-json';
import { AndroidBuildProfile } from '@expo/eas-json/build/build/types';
import assert from 'assert'; import assert from 'assert';
import fs from 'fs-extra'; import fs from 'fs-extra';
import { vol } from 'memfs'; import { vol } from 'memfs';
import os from 'os'; import os from 'os';
import path from 'path'; import path from 'path';
import { instance, mock } from 'ts-mockito';
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
import { AppVersionMutation } from '../../../graphql/mutations/AppVersionMutation';
import { AppVersionQuery } from '../../../graphql/queries/AppVersionQuery';
import { getAppBuildGradleAsync, resolveConfigValue } from '../../../project/android/gradleUtils'; import { getAppBuildGradleAsync, resolveConfigValue } from '../../../project/android/gradleUtils';
import { resolveVcsClient } from '../../../vcs';
import { Client } from '../../../vcs/vcs';
import { import {
BumpStrategy, BumpStrategy,
bumpVersionAsync, bumpVersionAsync,
bumpVersionInAppJsonAsync, bumpVersionInAppJsonAsync,
maybeResolveVersionsAsync, maybeResolveVersionsAsync,
resolveRemoteVersionCodeAsync,
} from '../version'; } from '../version';
const fsReal = jest.requireActual('fs').promises as typeof fs; const fsReal = jest.requireActual('fs').promises as typeof fs;
jest.mock('fs'); jest.mock('fs');
jest.mock('../../../commandUtils/context/contextUtils/createGraphqlClient');
jest.mock('../../../vcs/vcs');
jest.mock('../../../graphql/queries/AppVersionQuery');
jest.mock('../../../graphql/mutations/AppVersionMutation');
jest.mock('../../../ora', () => ({
ora: jest.fn().mockImplementation(() => ({
start: jest.fn().mockImplementation(() => ({
succeed: jest.fn(),
stop: jest.fn(),
fail: jest.fn(),
})),
})),
}));
const vcsClient = resolveVcsClient();
afterAll(async () => { afterAll(async () => {
// do not remove the following line // do not remove the following line
@@ -214,8 +191,7 @@ describe(maybeResolveVersionsAsync, () => {
const { appVersion, appBuildVersion } = await maybeResolveVersionsAsync( const { appVersion, appBuildVersion } = await maybeResolveVersionsAsync(
'/app', '/app',
exp, exp,
{} as BuildProfile<Platform.ANDROID>, {} as BuildProfile<Platform.ANDROID>
vcsClient
); );
expect(appVersion).toBe('3.0.0'); expect(appVersion).toBe('3.0.0');
expect(appBuildVersion).toBe('123'); expect(appBuildVersion).toBe('123');
@@ -227,8 +203,7 @@ describe(maybeResolveVersionsAsync, () => {
const { appVersion, appBuildVersion } = await maybeResolveVersionsAsync( const { appVersion, appBuildVersion } = await maybeResolveVersionsAsync(
'/app', '/app',
exp, exp,
{} as BuildProfile<Platform.ANDROID>, {} as BuildProfile<Platform.ANDROID>
vcsClient
); );
expect(appVersion).toBe('5.0.0'); expect(appVersion).toBe('5.0.0');
expect(appBuildVersion).toBe('126'); expect(appBuildVersion).toBe('126');
@@ -236,160 +211,12 @@ describe(maybeResolveVersionsAsync, () => {
}); });
}); });
describe(resolveRemoteVersionCodeAsync, () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('uses current remote versionCode when remote version set, autoIncrement=false', async () => {
const graphQLClientMock = instance(mock<ExpoGraphqlClient>());
const vcsClientMock = instance(mock<Client>());
vcsClientMock.getRootPathAsync = async () => '/app';
jest.mocked(AppVersionQuery.latestVersionAsync).mockResolvedValue({
buildVersion: '11',
storeVersion: '1.2.3',
});
const createAppVersionAsyncSpy = jest.spyOn(AppVersionMutation, 'createAppVersionAsync');
const exp = initBareWorkflowProject();
const result = await resolveRemoteVersionCodeAsync(graphQLClientMock, {
projectDir: '/app',
projectId: 'fakeProjectId',
exp,
applicationId: 'fakeApplicationId',
buildProfile: {} as AndroidBuildProfile,
vcsClient: vcsClientMock,
});
expect(result).toBe('11');
expect(createAppVersionAsyncSpy).not.toHaveBeenCalled();
});
it('initializes versionCode from local files when remote version not set, autoIncrement=false', async () => {
const graphQLClientMock = instance(mock<ExpoGraphqlClient>());
const vcsClientMock = instance(mock<Client>());
vcsClientMock.getRootPathAsync = async () => '/app';
jest.mocked(AppVersionQuery.latestVersionAsync).mockResolvedValue(null);
const createAppVersionAsyncSpy = jest.spyOn(AppVersionMutation, 'createAppVersionAsync');
const exp = initBareWorkflowProject({ versionCode: 22, versionName: '2.3.4' });
const result = await resolveRemoteVersionCodeAsync(graphQLClientMock, {
projectDir: '/app',
projectId: 'fakeProjectId',
exp,
applicationId: 'fakeApplicationId',
buildProfile: {} as AndroidBuildProfile,
vcsClient: vcsClientMock,
});
expect(result).toBe('22');
expect(createAppVersionAsyncSpy).toHaveBeenCalled();
expect(createAppVersionAsyncSpy.mock.calls[0][1].storeVersion).toBe('2.3.4');
expect(createAppVersionAsyncSpy.mock.calls[0][1].buildVersion).toBe('22');
});
it('initializes versionCode starting with 1 when remote version not set and not set in local files, autoIncrement=false', async () => {
const graphQLClientMock = instance(mock<ExpoGraphqlClient>());
const vcsClientMock = instance(mock<Client>());
vcsClientMock.getRootPathAsync = async () => '/app';
jest.mocked(AppVersionQuery.latestVersionAsync).mockResolvedValue(null);
const createAppVersionAsyncSpy = jest.spyOn(AppVersionMutation, 'createAppVersionAsync');
const exp = initBareWorkflowProject({ versionCode: null, versionName: null });
const result = await resolveRemoteVersionCodeAsync(graphQLClientMock, {
projectDir: '/app',
projectId: 'fakeProjectId',
exp,
applicationId: 'fakeApplicationId',
buildProfile: {} as AndroidBuildProfile,
vcsClient: vcsClientMock,
});
expect(result).toBe('1');
expect(createAppVersionAsyncSpy).toHaveBeenCalled();
expect(createAppVersionAsyncSpy.mock.calls[0][1].storeVersion).toBe('1.0.0');
expect(createAppVersionAsyncSpy.mock.calls[0][1].buildVersion).toBe('1');
});
it('increments current remote versionCode when remote version set, autoIncrement=true', async () => {
const graphQLClientMock = instance(mock<ExpoGraphqlClient>());
const vcsClientMock = instance(mock<Client>());
vcsClientMock.getRootPathAsync = async () => '/app';
jest.mocked(AppVersionQuery.latestVersionAsync).mockResolvedValue({
buildVersion: '11',
storeVersion: '1.2.3',
});
const createAppVersionAsyncSpy = jest.spyOn(AppVersionMutation, 'createAppVersionAsync');
const exp = initBareWorkflowProject();
const result = await resolveRemoteVersionCodeAsync(graphQLClientMock, {
projectDir: '/app',
projectId: 'fakeProjectId',
exp,
applicationId: 'fakeApplicationId',
buildProfile: { autoIncrement: true } as AndroidBuildProfile,
vcsClient: vcsClientMock,
});
expect(result).toBe('12');
expect(createAppVersionAsyncSpy).toHaveBeenCalled();
expect(createAppVersionAsyncSpy.mock.calls[0][1].storeVersion).toBe('3.0.0');
expect(createAppVersionAsyncSpy.mock.calls[0][1].buildVersion).toBe('12');
});
it('increments versionCode from local files when remote version not set, autoIncrement=true', async () => {
const graphQLClientMock = instance(mock<ExpoGraphqlClient>());
const vcsClientMock = instance(mock<Client>());
vcsClientMock.getRootPathAsync = async () => '/app';
jest.mocked(AppVersionQuery.latestVersionAsync).mockResolvedValue(null);
const createAppVersionAsyncSpy = jest.spyOn(AppVersionMutation, 'createAppVersionAsync');
const exp = initBareWorkflowProject({ versionCode: 22, versionName: '2.3.4' });
const result = await resolveRemoteVersionCodeAsync(graphQLClientMock, {
projectDir: '/app',
projectId: 'fakeProjectId',
exp,
applicationId: 'fakeApplicationId',
buildProfile: { autoIncrement: true } as AndroidBuildProfile,
vcsClient: vcsClientMock,
});
expect(result).toBe('23');
expect(createAppVersionAsyncSpy).toHaveBeenCalled();
expect(createAppVersionAsyncSpy.mock.calls[0][1].storeVersion).toBe('2.3.4');
expect(createAppVersionAsyncSpy.mock.calls[0][1].buildVersion).toBe('23');
});
it('initializes versionCode starting with 1 when remote version not set and not set in local files, autoIncrement=true', async () => {
const graphQLClientMock = instance(mock<ExpoGraphqlClient>());
const vcsClientMock = instance(mock<Client>());
vcsClientMock.getRootPathAsync = async () => '/app';
jest.mocked(AppVersionQuery.latestVersionAsync).mockResolvedValue(null);
const createAppVersionAsyncSpy = jest.spyOn(AppVersionMutation, 'createAppVersionAsync');
const exp = initBareWorkflowProject({ versionCode: null, versionName: null });
const result = await resolveRemoteVersionCodeAsync(graphQLClientMock, {
projectDir: '/app',
projectId: 'fakeProjectId',
exp,
applicationId: 'fakeApplicationId',
buildProfile: { autoIncrement: true } as AndroidBuildProfile,
vcsClient: vcsClientMock,
});
expect(result).toBe('1');
expect(createAppVersionAsyncSpy).toHaveBeenCalled();
expect(createAppVersionAsyncSpy.mock.calls[0][1].storeVersion).toBe('1.0.0');
expect(createAppVersionAsyncSpy.mock.calls[0][1].buildVersion).toBe('1');
});
});
function initBareWorkflowProject({ function initBareWorkflowProject({
versionCode = 123, versionCode = 123,
versionName = '3.0.0', versionName = '3.0.0',
}: { }: {
versionCode?: number | null; versionCode?: number;
versionName?: string | null; versionName?: string;
} = {}): ExpoConfig { } = {}): ExpoConfig {
const fakeExp: ExpoConfig = { const fakeExp: ExpoConfig = {
name: 'myproject', name: 'myproject',
@@ -407,8 +234,8 @@ function initBareWorkflowProject({
'./android/app/build.gradle': `android { './android/app/build.gradle': `android {
defaultConfig { defaultConfig {
applicationId "com.expo.testapp" applicationId "com.expo.testapp"
${versionCode ? `versionCode "${versionCode}"` : ''} versionCode ${versionCode}
${versionName ? `versionName "${versionName}"` : ''} versionName "${versionName}"
} }
}`, }`,
'./android/app/src/main/AndroidManifest.xml': 'fake', './android/app/src/main/AndroidManifest.xml': 'fake',
+12 -24
View File
@@ -1,12 +1,8 @@
import { Android, Metadata, Platform, Workflow } from '@expo/eas-build-job'; import { Android, Job, Metadata, Platform, Workflow } from '@expo/eas-build-job';
import { AppVersionSource } from '@expo/eas-json'; import { AppVersionSource } from '@expo/eas-json';
import chalk from 'chalk'; import chalk from 'chalk';
import nullthrows from 'nullthrows'; import nullthrows from 'nullthrows';
import { transformJob } from './graphql';
import { prepareJobAsync } from './prepareJob';
import { syncProjectConfigurationAsync } from './syncProjectConfiguration';
import { resolveRemoteVersionCodeAsync } from './version';
import AndroidCredentialsProvider, { import AndroidCredentialsProvider, {
AndroidCredentials, AndroidCredentials,
} from '../../credentials/android/AndroidCredentialsProvider'; } from '../../credentials/android/AndroidCredentialsProvider';
@@ -33,6 +29,10 @@ import {
checkNodeEnvVariable, checkNodeEnvVariable,
validatePNGsForManagedProjectAsync, validatePNGsForManagedProjectAsync,
} from '../validate'; } from '../validate';
import { transformJob } from './graphql';
import { prepareJobAsync } from './prepareJob';
import { syncProjectConfigurationAsync } from './syncProjectConfiguration';
import { resolveRemoteVersionCodeAsync } from './version';
export async function createAndroidContextAsync( export async function createAndroidContextAsync(
ctx: CommonContext<Platform.ANDROID> ctx: CommonContext<Platform.ANDROID>
@@ -59,22 +59,13 @@ This means that it will most likely produce an AAB and you will not be able to i
await checkGoogleServicesFileAsync(ctx); await checkGoogleServicesFileAsync(ctx);
await validatePNGsForManagedProjectAsync(ctx); await validatePNGsForManagedProjectAsync(ctx);
const gradleContext = await resolveGradleBuildContextAsync( const gradleContext = await resolveGradleBuildContextAsync(ctx.projectDir, buildProfile);
ctx.projectDir,
buildProfile,
ctx.vcsClient
);
if (ctx.workflow === Workflow.MANAGED) { if (ctx.workflow === Workflow.MANAGED) {
await ensureApplicationIdIsDefinedForManagedProjectAsync(ctx); await ensureApplicationIdIsDefinedForManagedProjectAsync(ctx);
} }
const applicationId = await getApplicationIdAsync( const applicationId = await getApplicationIdAsync(ctx.projectDir, ctx.exp, gradleContext);
ctx.projectDir,
ctx.exp,
ctx.vcsClient,
gradleContext
);
const versionCodeOverride = const versionCodeOverride =
ctx.easJsonCliConfig?.appVersionSource === AppVersionSource.REMOTE ctx.easJsonCliConfig?.appVersionSource === AppVersionSource.REMOTE
? await resolveRemoteVersionCodeAsync(ctx.graphqlClient, { ? await resolveRemoteVersionCodeAsync(ctx.graphqlClient, {
@@ -83,7 +74,6 @@ This means that it will most likely produce an AAB and you will not be able to i
exp: ctx.exp, exp: ctx.exp,
applicationId, applicationId,
buildProfile, buildProfile,
vcsClient: ctx.vcsClient,
}) })
: undefined; : undefined;
@@ -99,21 +89,20 @@ export async function prepareAndroidBuildAsync(
return await ensureAndroidCredentialsAsync(ctx); return await ensureAndroidCredentialsAsync(ctx);
}, },
syncProjectConfigurationAsync: async () => { syncProjectConfigurationAsync: async () => {
await syncProjectConfigurationAsync({ await syncProjectConfigurationAsync(ctx.graphqlClient, {
projectDir: ctx.projectDir, projectDir: ctx.projectDir,
exp: ctx.exp, exp: ctx.exp,
localAutoIncrement: localAutoIncrement:
ctx.easJsonCliConfig?.appVersionSource === AppVersionSource.REMOTE ctx.easJsonCliConfig?.appVersionSource === AppVersionSource.REMOTE
? false ? false
: ctx.buildProfile.autoIncrement, : ctx.buildProfile.autoIncrement,
vcsClient: ctx.vcsClient, projectId: ctx.projectId,
env: ctx.env,
}); });
}, },
prepareJobAsync: async ( prepareJobAsync: async (
ctx: BuildContext<Platform.ANDROID>, ctx: BuildContext<Platform.ANDROID>,
jobData: JobData<AndroidCredentials> jobData: JobData<AndroidCredentials>
): Promise<Android.Job> => { ): Promise<Job> => {
return await prepareJobAsync(ctx, jobData); return await prepareJobAsync(ctx, jobData);
}, },
sendBuildRequestAsync: async ( sendBuildRequestAsync: async (
@@ -135,7 +124,8 @@ export async function prepareAndroidBuildAsync(
} }
function shouldProvideCredentials(ctx: BuildContext<Platform.ANDROID>): boolean { function shouldProvideCredentials(ctx: BuildContext<Platform.ANDROID>): boolean {
return !ctx.buildProfile.withoutCredentials; const isCustomBuild = ctx.buildProfile.config !== undefined;
return !ctx.buildProfile.withoutCredentials && !isCustomBuild;
} }
async function ensureAndroidCredentialsAsync( async function ensureAndroidCredentialsAsync(
@@ -147,11 +137,9 @@ async function ensureAndroidCredentialsAsync(
const androidApplicationIdentifier = await getApplicationIdAsync( const androidApplicationIdentifier = await getApplicationIdAsync(
ctx.projectDir, ctx.projectDir,
ctx.exp, ctx.exp,
ctx.vcsClient,
ctx.android.gradleContext ctx.android.gradleContext
); );
const provider = new AndroidCredentialsProvider(ctx.credentialsCtx, { const provider = new AndroidCredentialsProvider(ctx.credentialsCtx, {
name: ctx.buildProfile.keystoreName,
app: { app: {
account: nullthrows( account: nullthrows(
ctx.user.accounts.find(a => a.name === ctx.accountName), ctx.user.accounts.find(a => a.name === ctx.accountName),
@@ -2,13 +2,11 @@ import { Android } from '@expo/eas-build-job';
import { AndroidBuildType, AndroidJobInput } from '../../graphql/generated'; import { AndroidBuildType, AndroidJobInput } from '../../graphql/generated';
import { import {
loggerLevelToGraphQLWorkerLoggerLevel,
transformBuildMode, transformBuildMode,
transformBuildTrigger, transformBuildTrigger,
transformProjectArchive, transformProjectArchive,
transformWorkflow, transformWorkflow,
} from '../graphql'; } from '../graphql';
import { buildProfileEnvironmentToEnvironment } from '../utils/environment';
export function transformJob(job: Android.Job): AndroidJobInput { export function transformJob(job: Android.Job): AndroidJobInput {
return { return {
@@ -16,6 +14,7 @@ export function transformJob(job: Android.Job): AndroidJobInput {
triggeredBy: transformBuildTrigger(job.triggeredBy), triggeredBy: transformBuildTrigger(job.triggeredBy),
projectArchive: transformProjectArchive(job.projectArchive), projectArchive: transformProjectArchive(job.projectArchive),
projectRootDirectory: job.projectRootDirectory, projectRootDirectory: job.projectRootDirectory,
releaseChannel: job.releaseChannel,
updates: job.updates, updates: job.updates,
secrets: job.secrets, secrets: job.secrets,
builderEnvironment: job.builderEnvironment, builderEnvironment: job.builderEnvironment,
@@ -30,10 +29,6 @@ export function transformJob(job: Android.Job): AndroidJobInput {
experimental: job.experimental, experimental: job.experimental,
mode: transformBuildMode(job.mode), mode: transformBuildMode(job.mode),
customBuildConfig: job.customBuildConfig, customBuildConfig: job.customBuildConfig,
environment: buildProfileEnvironmentToEnvironment(job.environment),
loggerLevel: job.loggerLevel
? loggerLevelToGraphQLWorkerLoggerLevel[job.loggerLevel]
: undefined,
}; };
} }
@@ -3,16 +3,18 @@ import {
ArchiveSource, ArchiveSource,
BuildMode, BuildMode,
BuildTrigger, BuildTrigger,
Job,
Platform, Platform,
sanitizeBuildJob, sanitizeJob,
} from '@expo/eas-build-job'; } from '@expo/eas-build-job';
import { BuildProfile } from '@expo/eas-json'; import { BuildProfile } from '@expo/eas-json';
import path from 'path'; import path from 'path';
import slash from 'slash'; import slash from 'slash';
import { AndroidCredentials } from '../../credentials/android/AndroidCredentialsProvider'; import { AndroidCredentials } from '../../credentials/android/AndroidCredentialsProvider';
import { getCustomBuildConfigPathForJob } from '../../project/customBuildConfig'; import { getCustomBuildConfigPath } from '../../project/customBuildConfig';
import { getUsername } from '../../project/projectUtils'; import { getUsername } from '../../project/projectUtils';
import { getVcsClient } from '../../vcs';
import { BuildContext } from '../context'; import { BuildContext } from '../context';
interface JobData { interface JobData {
@@ -22,17 +24,17 @@ interface JobData {
const cacheDefaults = { const cacheDefaults = {
disabled: false, disabled: false,
paths: [], customPaths: [],
}; };
export async function prepareJobAsync( export async function prepareJobAsync(
ctx: BuildContext<Platform.ANDROID>, ctx: BuildContext<Platform.ANDROID>,
jobData: JobData jobData: JobData
): Promise<Android.Job> { ): Promise<Job> {
const username = getUsername(ctx.exp, ctx.user); const username = getUsername(ctx.exp, ctx.user);
const buildProfile: BuildProfile<Platform.ANDROID> = ctx.buildProfile; const buildProfile: BuildProfile<Platform.ANDROID> = ctx.buildProfile;
const projectRootDirectory = const projectRootDirectory =
slash(path.relative(await ctx.vcsClient.getRootPathAsync(), ctx.projectDir)) || '.'; slash(path.relative(await getVcsClient().getRootPathAsync(), ctx.projectDir)) || '.';
const { credentials } = jobData; const { credentials } = jobData;
const buildCredentials = credentials const buildCredentials = credentials
? { ? {
@@ -53,18 +55,9 @@ export async function prepareJobAsync(
} }
const maybeCustomBuildConfigPath = buildProfile.config const maybeCustomBuildConfigPath = buildProfile.config
? getCustomBuildConfigPathForJob(buildProfile.config) ? getCustomBuildConfigPath(buildProfile.config)
: undefined; : undefined;
let buildMode;
if (ctx.repack) {
buildMode = BuildMode.REPACK;
} else if (buildProfile.config) {
buildMode = BuildMode.CUSTOM;
} else {
buildMode = BuildMode.BUILD;
}
const job: Android.Job = { const job: Android.Job = {
type: ctx.workflow, type: ctx.workflow,
platform: Platform.ANDROID, platform: Platform.ANDROID,
@@ -73,10 +66,9 @@ export async function prepareJobAsync(
builderEnvironment: { builderEnvironment: {
image: buildProfile.image, image: buildProfile.image,
node: buildProfile.node, node: buildProfile.node,
pnpm: buildProfile.pnpm,
bun: buildProfile.bun,
yarn: buildProfile.yarn, yarn: buildProfile.yarn,
ndk: buildProfile.ndk, ndk: buildProfile.ndk,
expoCli: buildProfile.expoCli,
env: buildProfile.env, env: buildProfile.env,
}, },
cache: { cache: {
@@ -87,12 +79,12 @@ export async function prepareJobAsync(
secrets: { secrets: {
...buildCredentials, ...buildCredentials,
}, },
releaseChannel: buildProfile.releaseChannel,
updates: { channel: buildProfile.channel }, updates: { channel: buildProfile.channel },
developmentClient: buildProfile.developmentClient, developmentClient: buildProfile.developmentClient,
gradleCommand: buildProfile.gradleCommand, gradleCommand: buildProfile.gradleCommand,
applicationArchivePath: buildProfile.applicationArchivePath ?? buildProfile.artifactPath, applicationArchivePath: buildProfile.applicationArchivePath ?? buildProfile.artifactPath,
buildArtifactPaths: buildProfile.buildArtifactPaths, buildArtifactPaths: buildProfile.buildArtifactPaths,
environment: ctx.buildProfile.environment,
buildType, buildType,
username, username,
...(ctx.android.versionCodeOverride && { ...(ctx.android.versionCodeOverride && {
@@ -103,24 +95,14 @@ export async function prepareJobAsync(
experimental: { experimental: {
prebuildCommand: buildProfile.prebuildCommand, prebuildCommand: buildProfile.prebuildCommand,
}, },
mode: buildMode, mode: buildProfile.config ? BuildMode.CUSTOM : BuildMode.BUILD,
triggeredBy: BuildTrigger.EAS_CLI, triggeredBy: BuildTrigger.EAS_CLI,
...(maybeCustomBuildConfigPath && { ...(maybeCustomBuildConfigPath && {
customBuildConfig: { customBuildConfig: {
path: maybeCustomBuildConfigPath, path: maybeCustomBuildConfigPath,
}, },
}), }),
...(ctx.repack && {
customBuildConfig: {
path: '__eas/repack.yml',
},
}),
loggerLevel: ctx.loggerLevel,
// Technically, these are unused, but let's include them here for type consistency.
// See: https://github.com/expo/eas-build/pull/454
appId: ctx.projectId,
initiatingUserId: ctx.user.id,
}; };
return sanitizeBuildJob(job) as Android.Job; return sanitizeJob(job);
} }
@@ -1,38 +1,39 @@
import { ExpoConfig } from '@expo/config'; import { ExpoConfig } from '@expo/config';
import { AndroidConfig } from '@expo/config-plugins'; import { AndroidConfig } from '@expo/config-plugins';
import { Env, Platform, Workflow } from '@expo/eas-build-job'; import { Platform, Workflow } from '@expo/eas-build-job';
import { AndroidVersionAutoIncrement } from '@expo/eas-json'; import { AndroidVersionAutoIncrement } from '@expo/eas-json';
import chalk from 'chalk'; import chalk from 'chalk';
import fs from 'fs-extra'; import fs from 'fs-extra';
import path from 'path'; import path from 'path';
import { BumpStrategy, bumpVersionAsync, bumpVersionInAppJsonAsync } from './version'; import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
import Log from '../../log'; import Log from '../../log';
import { isExpoUpdatesInstalled } from '../../project/projectUtils'; import { isExpoUpdatesInstalled } from '../../project/projectUtils';
import { resolveWorkflowAsync } from '../../project/workflow'; import { resolveWorkflowAsync } from '../../project/workflow';
import { syncUpdatesConfigurationAsync } from '../../update/android/UpdatesModule'; import { syncUpdatesConfigurationAsync } from '../../update/android/UpdatesModule';
import { Client } from '../../vcs/vcs'; import { BumpStrategy, bumpVersionAsync, bumpVersionInAppJsonAsync } from './version';
export async function syncProjectConfigurationAsync({ export async function syncProjectConfigurationAsync(
projectDir, graphqlClient: ExpoGraphqlClient,
exp, {
localAutoIncrement, projectDir,
vcsClient, exp,
env, localAutoIncrement,
}: { projectId,
projectDir: string; }: {
exp: ExpoConfig; projectDir: string;
localAutoIncrement?: AndroidVersionAutoIncrement; exp: ExpoConfig;
vcsClient: Client; localAutoIncrement?: AndroidVersionAutoIncrement;
env: Env | undefined; projectId: string;
}): Promise<void> { }
const workflow = await resolveWorkflowAsync(projectDir, Platform.ANDROID, vcsClient); ): Promise<void> {
const workflow = await resolveWorkflowAsync(projectDir, Platform.ANDROID);
const versionBumpStrategy = resolveVersionBumpStrategy(localAutoIncrement ?? false); const versionBumpStrategy = resolveVersionBumpStrategy(localAutoIncrement ?? false);
if (workflow === Workflow.GENERIC) { if (workflow === Workflow.GENERIC) {
await cleanUpOldEasBuildGradleScriptAsync(projectDir); await cleanUpOldEasBuildGradleScriptAsync(projectDir);
if (isExpoUpdatesInstalled(projectDir)) { if (isExpoUpdatesInstalled(projectDir)) {
await syncUpdatesConfigurationAsync({ projectDir, exp, workflow, env }); await syncUpdatesConfigurationAsync(graphqlClient, projectDir, exp, projectId);
} }
await bumpVersionAsync({ projectDir, exp, bumpStrategy: versionBumpStrategy }); await bumpVersionAsync({ projectDir, exp, bumpStrategy: versionBumpStrategy });
} else { } else {
+10 -30
View File
@@ -18,7 +18,6 @@ import {
} from '../../project/android/gradleUtils'; } from '../../project/android/gradleUtils';
import { getNextVersionCode } from '../../project/android/versions'; import { getNextVersionCode } from '../../project/android/versions';
import { resolveWorkflowAsync } from '../../project/workflow'; import { resolveWorkflowAsync } from '../../project/workflow';
import { Client } from '../../vcs/vcs';
import { updateAppJsonConfigAsync } from '../utils/appJson'; import { updateAppJsonConfigAsync } from '../utils/appJson';
import { bumpAppVersionAsync, ensureStaticConfigExists } from '../utils/version'; import { bumpAppVersionAsync, ensureStaticConfigExists } from '../utils/version';
@@ -44,7 +43,7 @@ export async function bumpVersionAsync({
const buildGradle = await getAppBuildGradleAsync(projectDir); const buildGradle = await getAppBuildGradleAsync(projectDir);
const isMultiFlavor = const isMultiFlavor =
buildGradle.android?.productFlavors ?? buildGradle.android?.flavorDimensions; buildGradle.android?.productFlavors || buildGradle.android?.flavorDimensions;
if (isMultiFlavor) { if (isMultiFlavor) {
throw new Error( throw new Error(
'Automatic version bumping is not supported for multi-flavor Android projects.' 'Automatic version bumping is not supported for multi-flavor Android projects.'
@@ -97,10 +96,9 @@ export async function bumpVersionInAppJsonAsync({
export async function maybeResolveVersionsAsync( export async function maybeResolveVersionsAsync(
projectDir: string, projectDir: string,
exp: ExpoConfig, exp: ExpoConfig,
buildProfile: BuildProfile<Platform.ANDROID>, buildProfile: BuildProfile<Platform.ANDROID>
vcsClient: Client ): Promise<{ appVersion?: string; appBuildVersion?: string }> {
): Promise<{ appVersion?: string; appBuildVersion?: string; isVersionInitialized?: boolean }> { const workflow = await resolveWorkflowAsync(projectDir, Platform.ANDROID);
const workflow = await resolveWorkflowAsync(projectDir, Platform.ANDROID, vcsClient);
if (workflow === Workflow.GENERIC) { if (workflow === Workflow.GENERIC) {
const buildGradle = await getAppBuildGradleAsync(projectDir); const buildGradle = await getAppBuildGradleAsync(projectDir);
try { try {
@@ -113,11 +111,6 @@ export async function maybeResolveVersionsAsync(
resolveConfigValue(buildGradle, 'versionName', parsedGradleCommand?.flavor) ?? '1.0.0', resolveConfigValue(buildGradle, 'versionName', parsedGradleCommand?.flavor) ?? '1.0.0',
appBuildVersion: appBuildVersion:
resolveConfigValue(buildGradle, 'versionCode', parsedGradleCommand?.flavor) ?? '1', resolveConfigValue(buildGradle, 'versionCode', parsedGradleCommand?.flavor) ?? '1',
isVersionInitialized: !resolveConfigValue(
buildGradle,
'versionCode',
parsedGradleCommand?.flavor
),
}; };
} catch { } catch {
return {}; return {};
@@ -190,14 +183,12 @@ export async function resolveRemoteVersionCodeAsync(
exp, exp,
applicationId, applicationId,
buildProfile, buildProfile,
vcsClient,
}: { }: {
projectDir: string; projectDir: string;
projectId: string; projectId: string;
exp: ExpoConfig; exp: ExpoConfig;
applicationId: string; applicationId: string;
buildProfile: BuildProfile<Platform.ANDROID>; buildProfile: BuildProfile<Platform.ANDROID>;
vcsClient: Client;
} }
): Promise<string> { ): Promise<string> {
const remoteVersions = await AppVersionQuery.latestVersionAsync( const remoteVersions = await AppVersionQuery.latestVersionAsync(
@@ -207,20 +198,16 @@ export async function resolveRemoteVersionCodeAsync(
applicationId applicationId
); );
const localVersions = await maybeResolveVersionsAsync(projectDir, exp, buildProfile, vcsClient); const localVersions = await maybeResolveVersionsAsync(projectDir, exp, buildProfile);
let currentBuildVersion: string; let currentBuildVersion: string;
let shouldInitializeVersionCode = false;
if (remoteVersions?.buildVersion) { if (remoteVersions?.buildVersion) {
currentBuildVersion = remoteVersions.buildVersion; currentBuildVersion = remoteVersions.buildVersion;
} else { } else {
if (localVersions.appBuildVersion) { if (localVersions.appBuildVersion) {
Log.log( Log.warn(
chalk.green( 'No remote versions are configured for this project, versionCode will be initialized based on the value from the local project.'
'No remote versions are configured for this project, versionCode will be initialized based on the value from the local project.'
)
); );
currentBuildVersion = localVersions.appBuildVersion; currentBuildVersion = localVersions.appBuildVersion;
shouldInitializeVersionCode = localVersions.isVersionInitialized ?? false;
} else { } else {
Log.error( Log.error(
`Remote versions are not configured and EAS CLI was not able to read the current version from your project. Use "eas build:version:set" to initialize remote versions.` `Remote versions are not configured and EAS CLI was not able to read the current version from your project. Use "eas build:version:set" to initialize remote versions.`
@@ -230,10 +217,7 @@ export async function resolveRemoteVersionCodeAsync(
} }
if (!buildProfile.autoIncrement && remoteVersions?.buildVersion) { if (!buildProfile.autoIncrement && remoteVersions?.buildVersion) {
return currentBuildVersion; return currentBuildVersion;
} else if ( } else if (!buildProfile.autoIncrement && !remoteVersions?.buildVersion) {
(!buildProfile.autoIncrement && !remoteVersions?.buildVersion) ||
shouldInitializeVersionCode
) {
const spinner = ora( const spinner = ora(
`Initializing versionCode with ${chalk.bold(currentBuildVersion)}.` `Initializing versionCode with ${chalk.bold(currentBuildVersion)}.`
).start(); ).start();
@@ -244,9 +228,7 @@ export async function resolveRemoteVersionCodeAsync(
applicationIdentifier: applicationId, applicationIdentifier: applicationId,
storeVersion: localVersions.appVersion ?? exp.version ?? '1.0.0', storeVersion: localVersions.appVersion ?? exp.version ?? '1.0.0',
buildVersion: currentBuildVersion, buildVersion: currentBuildVersion,
runtimeVersion: runtimeVersion: Updates.getRuntimeVersionNullable(exp, Platform.ANDROID) ?? undefined,
(await Updates.getRuntimeVersionNullableAsync(projectDir, exp, Platform.ANDROID)) ??
undefined,
}); });
spinner.succeed(`Initialized versionCode with ${chalk.bold(currentBuildVersion)}.`); spinner.succeed(`Initialized versionCode with ${chalk.bold(currentBuildVersion)}.`);
} catch (err) { } catch (err) {
@@ -268,9 +250,7 @@ export async function resolveRemoteVersionCodeAsync(
applicationIdentifier: applicationId, applicationIdentifier: applicationId,
storeVersion: localVersions.appVersion ?? exp.version ?? '1.0.0', storeVersion: localVersions.appVersion ?? exp.version ?? '1.0.0',
buildVersion: String(nextBuildVersion), buildVersion: String(nextBuildVersion),
runtimeVersion: runtimeVersion: Updates.getRuntimeVersionNullable(exp, Platform.ANDROID) ?? undefined,
(await Updates.getRuntimeVersionNullableAsync(projectDir, exp, Platform.ANDROID)) ??
undefined,
}); });
spinner.succeed( spinner.succeed(
`Incremented versionCode from ${chalk.bold(currentBuildVersion)} to ${chalk.bold( `Incremented versionCode from ${chalk.bold(currentBuildVersion)} to ${chalk.bold(
+144 -267
View File
@@ -1,46 +1,11 @@
import { import { ArchiveSource, ArchiveSourceType, Job, Metadata, Platform } from '@expo/eas-build-job';
ArchiveSource, import { CredentialsSource, EasJsonAccessor, EasJsonUtils, ResourceClass } from '@expo/eas-json';
ArchiveSourceType,
BuildJob,
FingerprintSource,
Metadata,
Platform,
} from '@expo/eas-build-job';
import { CredentialsSource } from '@expo/eas-json';
import assert from 'assert'; import assert from 'assert';
import chalk from 'chalk'; import chalk from 'chalk';
import cliProgress from 'cli-progress'; import cliProgress from 'cli-progress';
import fs from 'fs-extra'; import fs from 'fs-extra';
import { GraphQLError } from 'graphql/error';
import nullthrows from 'nullthrows'; import nullthrows from 'nullthrows';
import { BuildContext } from './context';
import {
EasBuildDownForMaintenanceError,
EasBuildFreeTierDisabledAndroidError,
EasBuildFreeTierDisabledError,
EasBuildFreeTierDisabledIOSError,
EasBuildFreeTierIosLimitExceededError,
EasBuildFreeTierLimitExceededError,
EasBuildLegacyResourceClassNotAvailableError,
EasBuildProjectArchiveUploadError,
EasBuildResourceClassNotAvailableInFreeTierError,
EasBuildTooManyPendingBuildsError,
RequestValidationError,
TurtleDeprecatedJobFormatError,
} from './errors';
import { transformMetadata } from './graphql';
import { LocalBuildMode, runLocalBuildAsync } from './local';
import { collectMetadataAsync } from './metadata';
import { printDeprecationWarnings } from './utils/printBuildInfo';
import {
LocalFile,
assertProjectTarballSizeDoesNotExceedLimit,
makeProjectMetadataFileAsync,
makeProjectTarballAsync,
maybeWarnAboutProjectTarballSize,
reviewAndCommitChangesAsync,
} from './utils/repository';
import { BuildEvent } from '../analytics/AnalyticsManager'; import { BuildEvent } from '../analytics/AnalyticsManager';
import { withAnalyticsAsync } from '../analytics/common'; import { withAnalyticsAsync } from '../analytics/common';
import { getExpoWebsiteBaseUrl } from '../api'; import { getExpoWebsiteBaseUrl } from '../api';
@@ -51,6 +16,7 @@ import {
BuildFragment, BuildFragment,
BuildParamsInput, BuildParamsInput,
BuildPriority, BuildPriority,
BuildResourceClass,
BuildStatus, BuildStatus,
UploadSessionType, UploadSessionType,
} from '../graphql/generated'; } from '../graphql/generated';
@@ -59,19 +25,32 @@ import { BuildQuery } from '../graphql/queries/BuildQuery';
import Log, { learnMore, link } from '../log'; import Log, { learnMore, link } from '../log';
import { Ora, ora } from '../ora'; import { Ora, ora } from '../ora';
import { import {
RequestedPlatform,
appPlatformDisplayNames, appPlatformDisplayNames,
appPlatformEmojis, appPlatformEmojis,
requestedPlatformDisplayNames, requestedPlatformDisplayNames,
} from '../platform'; } from '../platform';
import { maybeUploadFingerprintAsync } from '../project/maybeUploadFingerprintAsync'; import { confirmAsync } from '../prompts';
import { resolveRuntimeVersionAsync } from '../project/resolveRuntimeVersionAsync';
import { uploadFileAtPathToGCSAsync } from '../uploads'; import { uploadFileAtPathToGCSAsync } from '../uploads';
import { formatBytes } from '../utils/files'; import { formatBytes } from '../utils/files';
import { createFingerprintAsync } from '../utils/fingerprintCli';
import { printJsonOnlyOutput } from '../utils/json'; import { printJsonOnlyOutput } from '../utils/json';
import { createProgressTracker } from '../utils/progress'; import { createProgressTracker } from '../utils/progress';
import { sleepAsync } from '../utils/promise'; import { sleepAsync } from '../utils/promise';
import { getVcsClient } from '../vcs';
import { BuildContext } from './context';
import {
EasBuildDownForMaintenanceError,
EasBuildFreeTierDisabledAndroidError,
EasBuildFreeTierDisabledError,
EasBuildFreeTierDisabledIOSError,
EasBuildTooManyPendingBuildsError,
RequestValidationError,
TurtleDeprecatedJobFormatError,
} from './errors';
import { transformMetadata } from './graphql';
import { LocalBuildMode, runLocalBuildAsync } from './local';
import { collectMetadataAsync } from './metadata';
import { printDeprecationWarnings } from './utils/printBuildInfo';
import { makeProjectTarballAsync, reviewAndCommitChangesAsync } from './utils/repository';
export interface CredentialsResult<Credentials> { export interface CredentialsResult<Credentials> {
source: CredentialsSource.LOCAL | CredentialsSource.REMOTE; source: CredentialsSource.LOCAL | CredentialsSource.REMOTE;
@@ -83,14 +62,14 @@ export interface JobData<Credentials> {
projectArchive: ArchiveSource; projectArchive: ArchiveSource;
} }
interface Builder<TPlatform extends Platform, Credentials, TJob extends BuildJob> { interface Builder<TPlatform extends Platform, Credentials, TJob extends Job> {
ctx: BuildContext<TPlatform>; ctx: BuildContext<TPlatform>;
ensureCredentialsAsync( ensureCredentialsAsync(
ctx: BuildContext<TPlatform> ctx: BuildContext<TPlatform>
): Promise<CredentialsResult<Credentials> | undefined>; ): Promise<CredentialsResult<Credentials> | undefined>;
syncProjectConfigurationAsync(ctx: BuildContext<TPlatform>): Promise<void>; syncProjectConfigurationAsync(ctx: BuildContext<TPlatform>): Promise<void>;
prepareJobAsync(ctx: BuildContext<TPlatform>, jobData: JobData<Credentials>): Promise<BuildJob>; prepareJobAsync(ctx: BuildContext<TPlatform>, jobData: JobData<Credentials>): Promise<Job>;
sendBuildRequestAsync( sendBuildRequestAsync(
appId: string, appId: string,
job: TJob, job: TJob,
@@ -101,21 +80,16 @@ interface Builder<TPlatform extends Platform, Credentials, TJob extends BuildJob
export type BuildRequestSender = () => Promise<BuildFragment | undefined>; export type BuildRequestSender = () => Promise<BuildFragment | undefined>;
function resolveBuildParamsInput<T extends Platform>( function resolveBuildParamsInput<T extends Platform>(ctx: BuildContext<T>): BuildParamsInput {
ctx: BuildContext<T>,
metadata: Metadata
): BuildParamsInput {
return { return {
resourceClass: ctx.resourceClass, resourceClass: ctx.resourceClass,
sdkVersion: metadata.sdkVersion,
reactNativeVersion: metadata.reactNativeVersion,
}; };
} }
export async function prepareBuildRequestForPlatformAsync< export async function prepareBuildRequestForPlatformAsync<
TPlatform extends Platform, TPlatform extends Platform,
Credentials, Credentials,
TJob extends BuildJob, TJob extends Job
>(builder: Builder<TPlatform, Credentials, TJob>): Promise<BuildRequestSender> { >(builder: Builder<TPlatform, Credentials, TJob>): Promise<BuildRequestSender> {
const { ctx } = builder; const { ctx } = builder;
const credentialsResult = await withAnalyticsAsync( const credentialsResult = await withAnalyticsAsync(
@@ -131,9 +105,7 @@ export async function prepareBuildRequestForPlatformAsync<
await withAnalyticsAsync( await withAnalyticsAsync(
ctx.analytics, ctx.analytics,
async () => { async () => await builder.syncProjectConfigurationAsync(ctx),
await builder.syncProjectConfigurationAsync(ctx);
},
{ {
attemptEvent: BuildEvent.CONFIGURE_PROJECT_ATTEMPT, attemptEvent: BuildEvent.CONFIGURE_PROJECT_ATTEMPT,
successEvent: BuildEvent.CONFIGURE_PROJECT_SUCCESS, successEvent: BuildEvent.CONFIGURE_PROJECT_SUCCESS,
@@ -142,27 +114,19 @@ export async function prepareBuildRequestForPlatformAsync<
} }
); );
if (await ctx.vcsClient.isCommitRequiredAsync()) { if (await getVcsClient().isCommitRequiredAsync()) {
Log.addNewLineIfNone(); Log.addNewLineIfNone();
const platformToRequestedPlatform: Record<Platform, RequestedPlatform> = {
[Platform.ANDROID]: RequestedPlatform.Android,
[Platform.IOS]: RequestedPlatform.Ios,
};
await reviewAndCommitChangesAsync( await reviewAndCommitChangesAsync(
ctx.vcsClient, `[EAS Build] Run EAS Build for ${requestedPlatformDisplayNames[ctx.platform as Platform]}`,
`[EAS Build] Run EAS Build for ${
requestedPlatformDisplayNames[platformToRequestedPlatform[ctx.platform]]
}`,
{ nonInteractive: ctx.nonInteractive } { nonInteractive: ctx.nonInteractive }
); );
} }
let projectArchive: ArchiveSource | undefined; let projectArchive: ArchiveSource | undefined;
if (ctx.localBuildOptions.localBuildMode === LocalBuildMode.LOCAL_BUILD_PLUGIN) { if (ctx.localBuildOptions.localBuildMode === LocalBuildMode.LOCAL_BUILD_PLUGIN) {
const projectPath = (await makeProjectTarballAsync(ctx.vcsClient)).path;
projectArchive = { projectArchive = {
type: ArchiveSourceType.PATH, type: ArchiveSourceType.PATH,
path: projectPath, path: (await makeProjectTarballAsync()).path,
}; };
} else if (ctx.localBuildOptions.localBuildMode === LocalBuildMode.INTERNAL) { } else if (ctx.localBuildOptions.localBuildMode === LocalBuildMode.INTERNAL) {
projectArchive = { projectArchive = {
@@ -172,15 +136,13 @@ export async function prepareBuildRequestForPlatformAsync<
} else if (!ctx.localBuildOptions.localBuildMode) { } else if (!ctx.localBuildOptions.localBuildMode) {
projectArchive = { projectArchive = {
type: ArchiveSourceType.GCS, type: ArchiveSourceType.GCS,
...(await uploadProjectAsync(ctx)), bucketKey: await uploadProjectAsync(ctx),
}; };
} }
assert(projectArchive); assert(projectArchive);
const runtimeAndFingerprintMetadata = const metadata = await collectMetadataAsync(ctx);
await computeAndMaybeUploadRuntimeAndFingerprintMetadataAsync(ctx); const buildParams = resolveBuildParamsInput(ctx);
const metadata = await collectMetadataAsync(ctx, runtimeAndFingerprintMetadata);
const buildParams = resolveBuildParamsInput(ctx, metadata);
const job = await builder.prepareJobAsync(ctx, { const job = await builder.prepareJobAsync(ctx, {
projectArchive, projectArchive,
credentials: credentialsResult?.credentials, credentials: credentialsResult?.credentials,
@@ -188,7 +150,7 @@ export async function prepareBuildRequestForPlatformAsync<
return async () => { return async () => {
if (ctx.localBuildOptions.localBuildMode === LocalBuildMode.LOCAL_BUILD_PLUGIN) { if (ctx.localBuildOptions.localBuildMode === LocalBuildMode.LOCAL_BUILD_PLUGIN) {
await runLocalBuildAsync(job, metadata, ctx.localBuildOptions, ctx.env); await runLocalBuildAsync(job, metadata, ctx.localBuildOptions);
return undefined; return undefined;
} else if (ctx.localBuildOptions.localBuildMode === LocalBuildMode.INTERNAL) { } else if (ctx.localBuildOptions.localBuildMode === LocalBuildMode.INTERNAL) {
await BuildMutation.updateBuildMetadataAsync(ctx.graphqlClient, { await BuildMutation.updateBuildMetadataAsync(ctx.graphqlClient, {
@@ -214,11 +176,6 @@ const SERVER_SIDE_DEFINED_ERRORS: Record<string, typeof EasCommandError> = {
EAS_BUILD_FREE_TIER_DISABLED: EasBuildFreeTierDisabledError, EAS_BUILD_FREE_TIER_DISABLED: EasBuildFreeTierDisabledError,
EAS_BUILD_FREE_TIER_DISABLED_IOS: EasBuildFreeTierDisabledIOSError, EAS_BUILD_FREE_TIER_DISABLED_IOS: EasBuildFreeTierDisabledIOSError,
EAS_BUILD_FREE_TIER_DISABLED_ANDROID: EasBuildFreeTierDisabledAndroidError, EAS_BUILD_FREE_TIER_DISABLED_ANDROID: EasBuildFreeTierDisabledAndroidError,
EAS_BUILD_FREE_TIER_LIMIT_EXCEEDED: EasBuildFreeTierLimitExceededError,
EAS_BUILD_FREE_TIER_IOS_LIMIT_EXCEEDED: EasBuildFreeTierIosLimitExceededError,
EAS_BUILD_RESOURCE_CLASS_NOT_AVAILABLE_IN_FREE_TIER:
EasBuildResourceClassNotAvailableInFreeTierError,
EAS_BUILD_LEGACY_RESOURCE_CLASS_NOT_AVAILABLE: EasBuildLegacyResourceClassNotAvailableError,
VALIDATION_ERROR: RequestValidationError, VALIDATION_ERROR: RequestValidationError,
}; };
@@ -240,19 +197,8 @@ export function handleBuildRequestError(error: any, platform: Platform): never {
`You have already reached the maximum number of pending ${requestedPlatformDisplayNames[platform]} builds for your account. Try again later.` `You have already reached the maximum number of pending ${requestedPlatformDisplayNames[platform]} builds for your account. Try again later.`
); );
} else if (error?.graphQLErrors) { } else if (error?.graphQLErrors) {
const errorMessage = error.graphQLErrors
.map((graphQLError: GraphQLError) => {
const requestIdLine = graphQLError?.extensions?.requestId
? `\nRequest ID: ${graphQLError.extensions.requestId}`
: '';
const errorMessageLine = graphQLError?.message
? `\nError message: ${graphQLError.message}`
: '';
return `${requestIdLine}${errorMessageLine}`;
})
.join('');
throw new Error( throw new Error(
`Build request failed. Make sure you are using the latest eas-cli version. If the problem persists, report the issue.${errorMessage}` 'Build request failed. Make sure you are using the latest eas-cli version. If the problem persists, report the issue.'
); );
} }
throw error; throw error;
@@ -260,10 +206,7 @@ export function handleBuildRequestError(error: any, platform: Platform): never {
async function uploadProjectAsync<TPlatform extends Platform>( async function uploadProjectAsync<TPlatform extends Platform>(
ctx: BuildContext<TPlatform> ctx: BuildContext<TPlatform>
): Promise<{ ): Promise<string> {
bucketKey: string;
metadataLocation?: string;
}> {
let projectTarballPath; let projectTarballPath;
try { try {
return await withAnalyticsAsync( return await withAnalyticsAsync(
@@ -275,33 +218,34 @@ async function uploadProjectAsync<TPlatform extends Platform>(
'https://expo.fyi/eas-build-archive' 'https://expo.fyi/eas-build-archive'
)}` )}`
); );
const projectTarball = await makeProjectTarballAsync(ctx.vcsClient); const projectTarball = await makeProjectTarballAsync();
maybeWarnAboutProjectTarballSize(projectTarball.size); if (projectTarball.size > 1024 * 1024 * 100) {
assertProjectTarballSizeDoesNotExceedLimit(projectTarball.size); Log.warn(
`Your project archive is ${formatBytes(
projectTarball.size
)}. You can reduce its size and the time it takes to upload by excluding files that are unnecessary for the build process in ${chalk.bold(
'.easignore'
)} file. ${learnMore('https://expo.fyi/eas-build-archive')}`
);
}
projectTarballPath = projectTarball.path; projectTarballPath = projectTarball.path;
const [bucketKey, { metadataLocation }] = await Promise.all([
uploadFileAtPathToGCSAsync(
ctx.graphqlClient,
UploadSessionType.EasBuildGcsProjectSources,
projectTarball.path,
createProgressTracker({
total: projectTarball.size,
message: ratio =>
`Uploading to EAS Build (${formatBytes(
projectTarball.size * ratio
)} / ${formatBytes(projectTarball.size)})`,
completedMessage: (duration: string) => `Uploaded to EAS ${chalk.dim(duration)}`,
})
),
uploadMetadataFileAsync<TPlatform>(projectTarball, ctx),
]);
if (metadataLocation) { const bucketKey = await uploadFileAtPathToGCSAsync(
return { bucketKey, metadataLocation }; ctx.graphqlClient,
} UploadSessionType.EasBuildGcsProjectSources,
return { bucketKey }; projectTarball.path,
createProgressTracker({
total: projectTarball.size,
message: ratio =>
`Uploading to EAS Build (${formatBytes(projectTarball.size * ratio)} / ${formatBytes(
projectTarball.size
)})`,
completedMessage: (duration: string) => `Uploaded to EAS ${chalk.dim(duration)}`,
})
);
return bucketKey;
}, },
{ {
attemptEvent: BuildEvent.PROJECT_UPLOAD_ATTEMPT, attemptEvent: BuildEvent.PROJECT_UPLOAD_ATTEMPT,
@@ -310,14 +254,6 @@ async function uploadProjectAsync<TPlatform extends Platform>(
properties: ctx.analyticsEventProperties, properties: ctx.analyticsEventProperties,
} }
); );
} catch (err: any) {
let errMessage = 'Failed to upload the project tarball to EAS Build';
if (err.message) {
errMessage += `\n\nReason: ${err.message}`;
}
throw new EasBuildProjectArchiveUploadError(errMessage);
} finally { } finally {
if (projectTarballPath) { if (projectTarballPath) {
await fs.remove(projectTarballPath); await fs.remove(projectTarballPath);
@@ -325,41 +261,7 @@ async function uploadProjectAsync<TPlatform extends Platform>(
} }
} }
async function uploadMetadataFileAsync<TPlatform extends Platform>( async function sendBuildRequestAsync<TPlatform extends Platform, Credentials, TJob extends Job>(
projectTarball: LocalFile,
ctx: BuildContext<TPlatform>
): Promise<{ metadataLocation: string | null }> {
let projectMetadataFile: LocalFile | null = null;
try {
projectMetadataFile = await makeProjectMetadataFileAsync(projectTarball.path);
const metadataLocation = await uploadFileAtPathToGCSAsync(
ctx.graphqlClient,
UploadSessionType.EasBuildGcsProjectMetadata,
projectMetadataFile.path
);
return { metadataLocation };
} catch (err: any) {
let errMessage = 'Failed to upload metadata to EAS Build';
if (err.message) {
errMessage += `\n\nReason: ${err.message}`;
}
Log.warn(errMessage);
return { metadataLocation: null };
} finally {
if (projectMetadataFile) {
await fs.remove(projectMetadataFile.path);
}
}
}
async function sendBuildRequestAsync<
TPlatform extends Platform,
Credentials,
TJob extends BuildJob,
>(
builder: Builder<TPlatform, Credentials, TJob>, builder: Builder<TPlatform, Credentials, TJob>,
job: TJob, job: TJob,
metadata: Metadata, metadata: Metadata,
@@ -396,7 +298,12 @@ export type MaybeBuildFragment = BuildFragment | null;
export async function waitForBuildEndAsync( export async function waitForBuildEndAsync(
graphqlClient: ExpoGraphqlClient, graphqlClient: ExpoGraphqlClient,
{ buildIds, accountName }: { buildIds: string[]; accountName: string }, {
buildIds,
accountName,
projectDir,
nonInteractive,
}: { buildIds: string[]; accountName: string; projectDir: string; nonInteractive: boolean },
{ intervalSec = 10 } = {} { intervalSec = 10 } = {}
): Promise<MaybeBuildFragment[]> { ): Promise<MaybeBuildFragment[]> {
let spinner; let spinner;
@@ -413,7 +320,10 @@ export async function waitForBuildEndAsync(
const builds = await getBuildsSafelyAsync(graphqlClient, buildIds); const builds = await getBuildsSafelyAsync(graphqlClient, buildIds);
const { refetch } = const { refetch } =
builds.length === 1 builds.length === 1
? await handleSingleBuildProgressAsync({ build: builds[0], accountName }, { spinner }) ? await handleSingleBuildProgressAsync(
{ build: builds[0], accountName, projectDir, nonInteractive },
{ spinner }
)
: await handleMultipleBuildsProgressAsync({ builds }, { spinner, originalSpinnerText }); : await handleMultipleBuildsProgressAsync({ builds }, { spinner, originalSpinnerText });
if (!refetch) { if (!refetch) {
return builds; return builds;
@@ -451,9 +361,13 @@ async function handleSingleBuildProgressAsync(
{ {
build, build,
accountName, accountName,
projectDir,
nonInteractive,
}: { }: {
build: MaybeBuildFragment; build: MaybeBuildFragment;
accountName: string; accountName: string;
projectDir: string;
nonInteractive: boolean;
}, },
{ spinner }: { spinner: Ora } { spinner }: { spinner: Ora }
): Promise<BuildProgressResult> { ): Promise<BuildProgressResult> {
@@ -479,8 +393,8 @@ async function handleSingleBuildProgressAsync(
spinner.succeed('Build finished'); spinner.succeed('Build finished');
return { refetch: false }; return { refetch: false };
case BuildStatus.New: case BuildStatus.New:
spinner.text = `Build concurrency limit reached for your account. Build will enter queue once a concurrency becomes available. Add additional concurrencies at ${link( spinner.text = `Build is waiting to enter the queue. Check your concurrency limit at ${link(
formatAccountBillingUrl(accountName) formatAccountSubscriptionsUrl(accountName)
)}.`; )}.`;
break; break;
case BuildStatus.InQueue: { case BuildStatus.InQueue: {
@@ -502,11 +416,59 @@ async function handleSingleBuildProgressAsync(
Log.log('Start builds sooner in the priority queue.'); Log.log('Start builds sooner in the priority queue.');
Log.log( Log.log(
`Sign up for EAS Production or Enterprise at ${link( `Sign up for EAS Production or Enterprise at ${link(
formatAccountBillingUrl(accountName) formatAccountSubscriptionsUrl(accountName)
)}` )}`
); );
} }
if (
build.platform === AppPlatform.Ios &&
[BuildResourceClass.IosIntelLarge, BuildResourceClass.IosIntelMedium].includes(
build.resourceClass
)
) {
let askToSwitchToM1 = false;
if (
build.priority === BuildPriority.High &&
build.estimatedWaitTimeLeftSeconds >= /* 10 minutes */ 10 * 60
) {
Log.newLine();
Log.warn(
`Warning: Priority queue wait time for legacy iOS Intel workers is longer than usual (more than 10 minutes).`
);
Log.warn(`We recommend switching to the new, faster M1 workers for iOS builds.`);
Log.warn(
learnMore('https://blog.expo.dev/m1-workers-on-eas-build-dcaa2c1333ad', {
learnMoreMessage: 'Learn more on switching to M1 workers.',
})
);
askToSwitchToM1 = true;
} else if (
build.priority !== BuildPriority.High &&
build.estimatedWaitTimeLeftSeconds >= /* 120 minutes */ 120 * 60
) {
Log.newLine();
Log.warn(
`Warning: Free tier queue wait time for legacy iOS Intel workers is longer than usual.`
);
Log.warn(`We recommend switching to the new, faster M1 workers for iOS builds.`);
Log.warn(
learnMore('https://blog.expo.dev/m1-workers-on-eas-build-dcaa2c1333ad', {
learnMoreMessage: 'Learn more on switching to M1 workers.',
})
);
askToSwitchToM1 = true;
}
if (!nonInteractive && askToSwitchToM1) {
const shouldSwitchToM1 = await confirmAsync({
message: `Switch iOS builds to M1 workers (modifies build profiles in eas.json)?`,
});
if (shouldSwitchToM1) {
await updateIosBuildProfilesToUseM1WorkersAsync(projectDir);
}
}
}
Log.newLine(); Log.newLine();
Log.log(`Waiting in ${priorityToQueueDisplayName[build.priority]}`); Log.log(`Waiting in ${priorityToQueueDisplayName[build.priority]}`);
queueProgressBar.start( queueProgressBar.start(
@@ -521,7 +483,6 @@ async function handleSingleBuildProgressAsync(
} }
break; break;
} }
case BuildStatus.PendingCancel:
case BuildStatus.Canceled: case BuildStatus.Canceled:
spinner.fail('Build canceled'); spinner.fail('Build canceled');
return { refetch: false }; return { refetch: false };
@@ -552,7 +513,6 @@ const statusToDisplayName: Record<BuildStatus, string> = {
[BuildStatus.New]: 'waiting to enter the queue (concurrency limit reached)', [BuildStatus.New]: 'waiting to enter the queue (concurrency limit reached)',
[BuildStatus.InQueue]: 'in queue', [BuildStatus.InQueue]: 'in queue',
[BuildStatus.InProgress]: 'in progress', [BuildStatus.InProgress]: 'in progress',
[BuildStatus.PendingCancel]: 'canceled',
[BuildStatus.Canceled]: 'canceled', [BuildStatus.Canceled]: 'canceled',
[BuildStatus.Finished]: 'finished', [BuildStatus.Finished]: 'finished',
[BuildStatus.Errored]: 'failed', [BuildStatus.Errored]: 'failed',
@@ -571,12 +531,7 @@ async function handleMultipleBuildsProgressAsync(
builds.filter(build => build.status === BuildStatus.Finished).length === buildCount; builds.filter(build => build.status === BuildStatus.Finished).length === buildCount;
const allSettled = const allSettled =
builds.filter(build => builds.filter(build =>
[ [BuildStatus.Finished, BuildStatus.Errored, BuildStatus.Canceled].includes(build.status)
BuildStatus.Finished,
BuildStatus.Errored,
BuildStatus.Canceled,
BuildStatus.PendingCancel,
].includes(build.status)
).length === buildCount; ).length === buildCount;
if (allSettled) { if (allSettled) {
@@ -649,105 +604,27 @@ function formatEstimatedWaitTime(estimatedWaitTimeLeftSeconds: number): string {
} }
} }
function formatAccountBillingUrl(accountName: string): string { function formatAccountSubscriptionsUrl(accountName: string): string {
return new URL(`/accounts/${accountName}/settings/billing`, getExpoWebsiteBaseUrl()).toString(); return new URL(
`/accounts/${accountName}/settings/subscriptions`,
getExpoWebsiteBaseUrl()
).toString();
} }
async function computeAndMaybeUploadRuntimeAndFingerprintMetadataAsync<T extends Platform>( async function updateIosBuildProfilesToUseM1WorkersAsync(projectDir: string): Promise<void> {
ctx: BuildContext<T> const easJsonAccessor = EasJsonAccessor.fromProjectPath(projectDir);
): Promise<{ await easJsonAccessor.readRawJsonAsync();
runtimeVersion?: string | undefined;
fingerprintHash?: string | undefined;
fingerprintSource?: FingerprintSource | undefined;
}> {
const runtimeAndFingerprintMetadata =
await computeAndMaybeUploadFingerprintFromExpoUpdatesAsync(ctx);
if (!runtimeAndFingerprintMetadata?.fingerprintHash) {
const fingerprint = await computeAndMaybeUploadFingerprintWithoutExpoUpdatesAsync(ctx);
return {
...runtimeAndFingerprintMetadata,
...fingerprint,
};
} else {
return {
...runtimeAndFingerprintMetadata,
fingerprintHash: runtimeAndFingerprintMetadata.runtimeVersion,
};
}
}
async function computeAndMaybeUploadFingerprintFromExpoUpdatesAsync<T extends Platform>( const profileNames = await EasJsonUtils.getBuildProfileNamesAsync(easJsonAccessor);
ctx: BuildContext<T> easJsonAccessor.patch(easJsonRawObject => {
): Promise<{ for (const profileName of profileNames) {
runtimeVersion?: string; easJsonRawObject.build[profileName].ios = {
fingerprintSource?: FingerprintSource; ...easJsonRawObject.build[profileName].ios,
fingerprintHash?: string; resourceClass: ResourceClass.M_MEDIUM,
}> { };
const resolvedRuntimeVersion = await resolveRuntimeVersionAsync({ }
exp: ctx.exp, return easJsonRawObject;
platform: ctx.platform,
workflow: ctx.workflow,
projectDir: ctx.projectDir,
env: ctx.env,
cwd: ctx.projectDir,
}); });
await easJsonAccessor.writeAsync();
if (!resolvedRuntimeVersion) { Log.withTick('Updated eas.json. Your next builds will run on M1 workers.');
return {};
}
/**
* It's ok for fingerprintSources or runtimeVersion to be empty
* fingerprintSources only exist if the project is using runtimeVersion.policy: fingerprint
*/
if (
!resolvedRuntimeVersion.expoUpdatesRuntimeFingerprint ||
!resolvedRuntimeVersion.runtimeVersion
) {
return {
runtimeVersion: resolvedRuntimeVersion.runtimeVersion ?? undefined,
};
}
const uploadedFingerprint = await maybeUploadFingerprintAsync({
hash: resolvedRuntimeVersion.runtimeVersion,
fingerprint: resolvedRuntimeVersion.expoUpdatesRuntimeFingerprint,
graphqlClient: ctx.graphqlClient,
localBuildMode: ctx.localBuildOptions.localBuildMode,
});
return {
runtimeVersion: uploadedFingerprint.hash,
fingerprintSource: uploadedFingerprint.fingerprintSource,
fingerprintHash: resolvedRuntimeVersion.expoUpdatesRuntimeFingerprintHash ?? undefined,
};
}
async function computeAndMaybeUploadFingerprintWithoutExpoUpdatesAsync<T extends Platform>(
ctx: BuildContext<T>,
{ debug }: { debug?: boolean } = {}
): Promise<{
fingerprintHash?: string;
fingerprintSource?: FingerprintSource;
}> {
const fingerprint = await createFingerprintAsync(ctx.projectDir, {
workflow: ctx.workflow,
platforms: [ctx.platform],
env: ctx.env,
});
if (!fingerprint) {
return {};
}
const uploadedFingerprint = await maybeUploadFingerprintAsync({
hash: fingerprint.hash,
fingerprint: {
fingerprintSources: fingerprint.sources,
isDebugFingerprintSource: debug ?? false,
},
graphqlClient: ctx.graphqlClient,
localBuildMode: ctx.localBuildOptions.localBuildMode,
});
return {
fingerprintHash: uploadedFingerprint.hash,
fingerprintSource: uploadedFingerprint.fingerprintSource,
};
} }
+67 -103
View File
@@ -1,21 +1,17 @@
import { AppVersionSource, EasJson, EasJsonAccessor } from '@expo/eas-json'; import { Platform, Workflow } from '@expo/eas-build-job';
import { EasJson, EasJsonAccessor, ResourceClass } from '@expo/eas-json';
import chalk from 'chalk'; import chalk from 'chalk';
import fs from 'fs-extra'; import fs from 'fs-extra';
import { maybeBailOnRepoStatusAsync, reviewAndCommitChangesAsync } from './utils/repository'; import Log from '../log';
import Log, { learnMore } from '../log'; import { resolveWorkflowAsync } from '../project/workflow';
import { ora } from '../ora';
import { easCliVersion } from '../utils/easCli'; import { easCliVersion } from '../utils/easCli';
import { Client } from '../vcs/vcs'; import { getVcsClient } from '../vcs';
import { maybeBailOnRepoStatusAsync, reviewAndCommitChangesAsync } from './utils/repository';
interface ConfigureParams { interface ConfigureParams {
projectDir: string; projectDir: string;
nonInteractive: boolean; nonInteractive: boolean;
vcsClient: Client;
}
export async function easJsonExistsAsync(projectDir: string): Promise<boolean> {
return await fs.pathExists(EasJsonAccessor.formatEasJsonPath(projectDir));
} }
/** /**
@@ -28,7 +24,7 @@ export async function easJsonExistsAsync(projectDir: string): Promise<boolean> {
export async function ensureProjectConfiguredAsync( export async function ensureProjectConfiguredAsync(
configureParams: ConfigureParams configureParams: ConfigureParams
): Promise<boolean> { ): Promise<boolean> {
if (await easJsonExistsAsync(configureParams.projectDir)) { if (await fs.pathExists(EasJsonAccessor.formatEasJsonPath(configureParams.projectDir))) {
return false; return false;
} }
@@ -36,110 +32,41 @@ export async function ensureProjectConfiguredAsync(
return true; return true;
} }
async function configureAsync({ async function configureAsync({ projectDir, nonInteractive }: ConfigureParams): Promise<void> {
projectDir, await maybeBailOnRepoStatusAsync();
nonInteractive,
vcsClient,
}: ConfigureParams): Promise<void> {
await maybeBailOnRepoStatusAsync(vcsClient, nonInteractive);
await createEasJsonAsync(projectDir, vcsClient); await createEasJsonAsync(projectDir);
if (await vcsClient.isCommitRequiredAsync()) { if (await getVcsClient().isCommitRequiredAsync()) {
Log.newLine(); Log.newLine();
await reviewAndCommitChangesAsync(vcsClient, 'Configure EAS Build', { await reviewAndCommitChangesAsync('Configure EAS Build', {
nonInteractive, nonInteractive,
}); });
} }
} }
export async function doesBuildProfileExistAsync({ const EAS_JSON_MANAGED_DEFAULT: EasJson = {
projectDir,
profileName,
}: {
projectDir: string;
profileName: string;
}): Promise<boolean> {
try {
const easJsonAccessor = EasJsonAccessor.fromProjectPath(projectDir);
const easJson = await easJsonAccessor.readRawJsonAsync();
if (!easJson.build?.[profileName]) {
return false;
}
return true;
} catch (error) {
Log.error(`We were unable to read ${chalk.bold('eas.json')} contents. Error: ${error}.`);
throw error;
}
}
export async function createBuildProfileAsync({
projectDir,
profileName,
profileContents,
vcsClient,
nonInteractive,
}: {
projectDir: string;
profileName: string;
profileContents: Record<string, any>;
vcsClient: Client;
nonInteractive: boolean;
}): Promise<void> {
const spinner = ora(`Adding "${profileName}" build profile to ${chalk.bold('eas.json')}`).start();
try {
const easJsonAccessor = EasJsonAccessor.fromProjectPath(projectDir);
await easJsonAccessor.readRawJsonAsync();
easJsonAccessor.patch(easJsonRawObject => {
return {
...easJsonRawObject,
build: {
...easJsonRawObject.build,
[profileName]: profileContents,
},
};
});
await easJsonAccessor.writeAsync();
spinner.succeed(
`Successfully added "${profileName}" build profile to ${chalk.bold('eas.json')}.`
);
if (await vcsClient.isCommitRequiredAsync()) {
Log.newLine();
await reviewAndCommitChangesAsync(
vcsClient,
`Add "${profileName}" build profile to eas.json`,
{
nonInteractive,
}
);
}
} catch (error) {
spinner.fail(
`We were not able to configure "${profileName}" build profile inside of ${chalk.bold(
'eas.json'
)}. Error: ${error}.`
);
throw error;
}
}
const EAS_JSON_DEFAULT: EasJson = {
cli: { cli: {
version: `>= ${easCliVersion}`, version: `>= ${easCliVersion}`,
appVersionSource: AppVersionSource.REMOTE,
}, },
build: { build: {
development: { development: {
developmentClient: true, developmentClient: true,
distribution: 'internal', distribution: 'internal',
ios: {
resourceClass: ResourceClass.M_MEDIUM,
},
}, },
preview: { preview: {
distribution: 'internal', distribution: 'internal',
ios: {
resourceClass: ResourceClass.M_MEDIUM,
},
}, },
production: { production: {
autoIncrement: true, ios: {
resourceClass: ResourceClass.M_MEDIUM,
},
}, },
}, },
submit: { submit: {
@@ -147,14 +74,51 @@ const EAS_JSON_DEFAULT: EasJson = {
}, },
}; };
async function createEasJsonAsync(projectDir: string, vcsClient: Client): Promise<void> { const EAS_JSON_BARE_DEFAULT: EasJson = {
cli: {
version: `>= ${easCliVersion}`,
},
build: {
development: {
distribution: 'internal',
android: {
gradleCommand: ':app:assembleDebug',
},
ios: {
buildConfiguration: 'Debug',
resourceClass: ResourceClass.M_MEDIUM,
},
},
preview: {
distribution: 'internal',
ios: {
resourceClass: ResourceClass.M_MEDIUM,
},
},
production: {
ios: {
resourceClass: ResourceClass.M_MEDIUM,
},
},
},
submit: {
production: {},
},
};
async function createEasJsonAsync(projectDir: string): Promise<void> {
const easJsonPath = EasJsonAccessor.formatEasJsonPath(projectDir); const easJsonPath = EasJsonAccessor.formatEasJsonPath(projectDir);
await fs.writeFile(easJsonPath, `${JSON.stringify(EAS_JSON_DEFAULT, null, 2)}\n`); const hasAndroidNativeProject =
await vcsClient.trackFileAsync(easJsonPath); (await resolveWorkflowAsync(projectDir, Platform.ANDROID)) === Workflow.GENERIC;
Log.withTick( const hasIosNativeProject =
`Generated ${chalk.bold('eas.json')}. ${learnMore( (await resolveWorkflowAsync(projectDir, Platform.IOS)) === Workflow.GENERIC;
'https://docs.expo.dev/build-reference/eas-json/' const easJson =
)}` hasAndroidNativeProject || hasIosNativeProject
); ? EAS_JSON_BARE_DEFAULT
: EAS_JSON_MANAGED_DEFAULT;
await fs.writeFile(easJsonPath, `${JSON.stringify(easJson, null, 2)}\n`);
await getVcsClient().trackFileAsync(easJsonPath);
Log.withTick(`Generated ${chalk.bold('eas.json')}`);
} }
+1 -12
View File
@@ -1,20 +1,16 @@
import { ExpoConfig } from '@expo/config'; import { ExpoConfig } from '@expo/config';
import { Platform, Workflow } from '@expo/eas-build-job'; import { Platform, Workflow } from '@expo/eas-build-job';
import { BuildProfile, EasJson } from '@expo/eas-json'; import { BuildProfile, EasJson } from '@expo/eas-json';
import { LoggerLevel } from '@expo/logger';
import { NodePackageManager } from '@expo/package-manager';
import { LocalBuildOptions } from './local';
import { Analytics, AnalyticsEventProperties } from '../analytics/AnalyticsManager'; import { Analytics, AnalyticsEventProperties } from '../analytics/AnalyticsManager';
import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient'; import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient';
import { CredentialsContext } from '../credentials/context'; import { CredentialsContext } from '../credentials/context';
import { Target } from '../credentials/ios/types'; import { Target } from '../credentials/ios/types';
import { BuildResourceClass } from '../graphql/generated'; import { BuildResourceClass } from '../graphql/generated';
import { GradleBuildContext } from '../project/android/gradle'; import { GradleBuildContext } from '../project/android/gradle';
import { CustomBuildConfigMetadata } from '../project/customBuildConfig';
import { XcodeBuildContext } from '../project/ios/scheme'; import { XcodeBuildContext } from '../project/ios/scheme';
import { Actor } from '../user/User'; import { Actor } from '../user/User';
import { Client } from '../vcs/vcs'; import { LocalBuildOptions } from './local';
export type CommonContext<T extends Platform> = Omit<BuildContext<T>, 'android' | 'ios'>; export type CommonContext<T extends Platform> = Omit<BuildContext<T>, 'android' | 'ios'>;
@@ -55,13 +51,6 @@ export interface BuildContext<T extends Platform> {
graphqlClient: ExpoGraphqlClient; graphqlClient: ExpoGraphqlClient;
analytics: Analytics; analytics: Analytics;
workflow: Workflow; workflow: Workflow;
customBuildConfigMetadata?: CustomBuildConfigMetadata;
android: T extends Platform.ANDROID ? AndroidBuildContext : undefined; android: T extends Platform.ANDROID ? AndroidBuildContext : undefined;
ios: T extends Platform.IOS ? IosBuildContext : undefined; ios: T extends Platform.IOS ? IosBuildContext : undefined;
developmentClient: boolean;
requiredPackageManager: NodePackageManager['name'] | null;
vcsClient: Client;
loggerLevel?: LoggerLevel;
repack: boolean;
env: Record<string, string>;
} }
+12 -46
View File
@@ -1,26 +1,22 @@
import { Platform } from '@expo/eas-build-job'; import { Platform } from '@expo/eas-build-job';
import { BuildProfile, EasJson, ResourceClass } from '@expo/eas-json'; import { BuildProfile, EasJson, ResourceClass } from '@expo/eas-json';
import JsonFile from '@expo/json-file'; import JsonFile from '@expo/json-file';
import { LoggerLevel } from '@expo/logger';
import { resolvePackageManager } from '@expo/package-manager';
import getenv from 'getenv'; import getenv from 'getenv';
import resolveFrom from 'resolve-from'; import resolveFrom from 'resolve-from';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
import { createAndroidContextAsync } from './android/build';
import { BuildContext, CommonContext } from './context';
import { createIosContextAsync } from './ios/build';
import { LocalBuildMode, LocalBuildOptions } from './local';
import { resolveBuildResourceClassAsync } from './utils/resourceClass';
import { Analytics, AnalyticsEventProperties, BuildEvent } from '../analytics/AnalyticsManager'; import { Analytics, AnalyticsEventProperties, BuildEvent } from '../analytics/AnalyticsManager';
import { DynamicConfigContextFn } from '../commandUtils/context/DynamicProjectConfigContextField'; import { DynamicConfigContextFn } from '../commandUtils/context/DynamicProjectConfigContextField';
import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient'; import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient';
import { CredentialsContext } from '../credentials/context'; import { CredentialsContext } from '../credentials/context';
import { CustomBuildConfigMetadata } from '../project/customBuildConfig';
import { getOwnerAccountForProjectIdAsync } from '../project/projectUtils'; import { getOwnerAccountForProjectIdAsync } from '../project/projectUtils';
import { resolveWorkflowAsync } from '../project/workflow'; import { resolveWorkflowAsync } from '../project/workflow';
import { Actor } from '../user/User'; import { Actor } from '../user/User';
import { Client } from '../vcs/vcs'; import { createAndroidContextAsync } from './android/build';
import { BuildContext, CommonContext } from './context';
import { createIosContextAsync } from './ios/build';
import { LocalBuildOptions } from './local';
import { resolveBuildResourceClassAsync } from './utils/resourceClass';
export async function createBuildContextAsync<T extends Platform>({ export async function createBuildContextAsync<T extends Platform>({
buildProfileName, buildProfileName,
@@ -37,13 +33,7 @@ export async function createBuildContextAsync<T extends Platform>({
actor, actor,
graphqlClient, graphqlClient,
analytics, analytics,
vcsClient, getDynamicProjectConfigAsync,
getDynamicPrivateProjectConfigAsync,
customBuildConfigMetadata,
buildLoggerLevel,
freezeCredentials,
repack,
env,
}: { }: {
buildProfileName: string; buildProfileName: string;
buildProfile: BuildProfile<T>; buildProfile: BuildProfile<T>;
@@ -59,30 +49,14 @@ export async function createBuildContextAsync<T extends Platform>({
actor: Actor; actor: Actor;
graphqlClient: ExpoGraphqlClient; graphqlClient: ExpoGraphqlClient;
analytics: Analytics; analytics: Analytics;
vcsClient: Client; getDynamicProjectConfigAsync: DynamicConfigContextFn;
getDynamicPrivateProjectConfigAsync: DynamicConfigContextFn;
customBuildConfigMetadata?: CustomBuildConfigMetadata;
buildLoggerLevel?: LoggerLevel;
freezeCredentials: boolean;
repack: boolean;
env: Record<string, string>;
}): Promise<BuildContext<T>> { }): Promise<BuildContext<T>> {
const { exp, projectId } = await getDynamicPrivateProjectConfigAsync({ const { exp, projectId } = await getDynamicProjectConfigAsync({ env: buildProfile.env });
env,
});
const projectName = exp.slug; const projectName = exp.slug;
const account = await getOwnerAccountForProjectIdAsync(graphqlClient, projectId); const account = await getOwnerAccountForProjectIdAsync(graphqlClient, projectId);
const workflow = await resolveWorkflowAsync(projectDir, platform, vcsClient); const workflow = await resolveWorkflowAsync(projectDir, platform);
const accountId = account.id; const accountId = account.id;
const runFromCI = getenv.boolish('CI', false); const runFromCI = getenv.boolish('CI', false);
const developmentClient =
buildProfile.developmentClient ??
(platform === Platform.ANDROID
? (buildProfile as BuildProfile<Platform.ANDROID>)?.gradleCommand === ':app:assembleDebug'
: (buildProfile as BuildProfile<Platform.IOS>)?.buildConfiguration === 'Debug') ??
false;
const requiredPackageManager = resolvePackageManager(projectDir);
const credentialsCtx = new CredentialsContext({ const credentialsCtx = new CredentialsContext({
projectInfo: { exp, projectId }, projectInfo: { exp, projectId },
@@ -91,10 +65,8 @@ export async function createBuildContextAsync<T extends Platform>({
user: actor, user: actor,
graphqlClient, graphqlClient,
analytics, analytics,
env, env: buildProfile.env,
easJsonCliConfig, easJsonCliConfig,
vcsClient,
freezeCredentials,
}); });
const devClientProperties = getDevClientEventProperties({ const devClientProperties = getDevClientEventProperties({
@@ -112,13 +84,14 @@ export async function createBuildContextAsync<T extends Platform>({
...devClientProperties, ...devClientProperties,
no_wait: noWait, no_wait: noWait,
run_from_ci: runFromCI, run_from_ci: runFromCI,
local: localBuildOptions.localBuildMode === LocalBuildMode.LOCAL_BUILD_PLUGIN,
}; };
analytics.logEvent(BuildEvent.BUILD_COMMAND, analyticsEventProperties); analytics.logEvent(BuildEvent.BUILD_COMMAND, analyticsEventProperties);
const resourceClass = await resolveBuildResourceClassAsync( const resourceClass = await resolveBuildResourceClassAsync(
buildProfile, buildProfile,
platform, platform,
projectDir,
exp,
resourceClassFlag resourceClassFlag
); );
@@ -142,16 +115,9 @@ export async function createBuildContextAsync<T extends Platform>({
user: actor, user: actor,
graphqlClient, graphqlClient,
analytics, analytics,
vcsClient,
workflow, workflow,
message, message,
runFromCI, runFromCI,
customBuildConfigMetadata,
developmentClient,
requiredPackageManager,
loggerLevel: buildLoggerLevel,
repack,
env,
}; };
if (platform === Platform.ANDROID) { if (platform === Platform.ANDROID) {
const common = commonContext as CommonContext<Platform.ANDROID>; const common = commonContext as CommonContext<Platform.ANDROID>;
-10
View File
@@ -8,18 +8,8 @@ export class EasBuildFreeTierDisabledIOSError extends EasCommandError {}
export class EasBuildFreeTierDisabledAndroidError extends EasCommandError {} export class EasBuildFreeTierDisabledAndroidError extends EasCommandError {}
export class EasBuildFreeTierLimitExceededError extends EasCommandError {}
export class EasBuildFreeTierIosLimitExceededError extends EasCommandError {}
export class EasBuildResourceClassNotAvailableInFreeTierError extends EasCommandError {}
export class EasBuildLegacyResourceClassNotAvailableError extends EasCommandError {}
export class RequestValidationError extends EasCommandError {} export class RequestValidationError extends EasCommandError {}
export class EasBuildDownForMaintenanceError extends EasCommandError {} export class EasBuildDownForMaintenanceError extends EasCommandError {}
export class EasBuildTooManyPendingBuildsError extends EasCommandError {} export class EasBuildTooManyPendingBuildsError extends EasCommandError {}
export class EasBuildProjectArchiveUploadError extends EasCommandError {}
@@ -1,147 +0,0 @@
import { Env } from '@expo/eas-build-job';
import { BuildProfile } from '@expo/eas-json';
import { isEnvironment } from './utils/environment';
import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient';
import { EnvironmentVariableEnvironment } from '../graphql/generated';
import { EnvironmentVariablesQuery } from '../graphql/queries/EnvironmentVariablesQuery';
import Log, { learnMore } from '../log';
export async function evaluateConfigWithEnvVarsAsync<Config extends { projectId: string }, Opts>({
buildProfile,
buildProfileName,
graphqlClient,
getProjectConfig,
opts,
}: {
buildProfile: BuildProfile;
buildProfileName: string;
graphqlClient: ExpoGraphqlClient | null;
opts: Opts;
getProjectConfig(opts: Opts): Promise<Config>;
}): Promise<Config & { env: Env }> {
if (!graphqlClient) {
Log.warn('An Expo user account is required to fetch environment variables.');
const config = await getProjectConfig(opts);
return { env: buildProfile.env ?? {}, ...config };
}
const { projectId } = await getProjectConfig({ env: buildProfile.env, ...opts });
const env = await resolveEnvVarsAsync({
buildProfile,
buildProfileName,
graphqlClient,
projectId,
});
const config = await getProjectConfig({ ...opts, env });
return { env, ...config };
}
async function resolveEnvVarsAsync({
buildProfile,
buildProfileName,
graphqlClient,
projectId,
}: {
buildProfile: BuildProfile;
buildProfileName: string;
graphqlClient: ExpoGraphqlClient;
projectId: string;
}): Promise<Env> {
const environment =
buildProfile.environment?.toUpperCase() ??
resolveSuggestedEnvironmentForBuildProfileConfiguration(buildProfile);
if (!isEnvironment(environment)) {
Log.log(
`Loaded "env" configuration for the "${buildProfileName}" profile: ${
buildProfile.env && Object.keys(buildProfile.env).length > 0
? Object.keys(buildProfile.env).join(', ')
: 'no environment variables specified'
}. ${learnMore('https://docs.expo.dev/build-reference/variables/')}`
);
return { ...buildProfile.env };
}
try {
const environmentVariables = await EnvironmentVariablesQuery.byAppIdWithSensitiveAsync(
graphqlClient,
{
appId: projectId,
environment,
}
);
const serverEnvVars = Object.fromEntries(
environmentVariables
.filter(({ name, value }) => name && value)
.map(({ name, value }) => [name, value])
) as Record<string, string>;
if (Object.keys(serverEnvVars).length > 0) {
Log.log(
`Environment variables with visibility "Plain text" and "Sensitive" loaded from the "${environment.toLowerCase()}" environment on EAS: ${Object.keys(
serverEnvVars
).join(', ')}.`
);
} else {
Log.log(
`No environment variables with visibility "Plain text" and "Sensitive" found for the "${environment.toLowerCase()}" environment on EAS.`
);
}
if (buildProfile.env && Object.keys(buildProfile.env).length > 0) {
Log.log(
`Environment variables loaded from the "${buildProfileName}" build profile "env" configuration: ${
buildProfile.env && Object.keys(buildProfile.env).join(', ')
}.`
);
}
if (
buildProfile.env &&
Object.keys(buildProfile.env).length > 0 &&
Object.keys(serverEnvVars).length > 0
) {
const overlappingKeys = Object.keys(serverEnvVars).filter(
key => buildProfile.env && Object.keys(buildProfile.env).includes(key)
);
if (overlappingKeys.length > 0) {
Log.warn(
`The following environment variables are defined in both the "${buildProfileName}" build profile "env" configuration and the "${environment.toLowerCase()}" environment on EAS: ${overlappingKeys.join(
', '
)}. The values from the build profile configuration will be used.`
);
}
}
Log.newLine();
return { ...serverEnvVars, ...buildProfile.env };
} catch (e) {
Log.error(`Failed to pull env variables for environment ${environment} from EAS servers`);
Log.error(e);
Log.error(
'This can possibly be a bug in EAS/EAS CLI. Report it here: https://github.com/expo/eas-cli/issues'
);
return { ...buildProfile.env };
}
}
function resolveSuggestedEnvironmentForBuildProfileConfiguration(
buildProfile: BuildProfile
): EnvironmentVariableEnvironment {
const environment =
buildProfile.distribution === 'store'
? EnvironmentVariableEnvironment.Production
: buildProfile.developmentClient
? EnvironmentVariableEnvironment.Development
: EnvironmentVariableEnvironment.Preview;
Log.log(
`Resolved "${environment.toLowerCase()}" environment for the build. ${learnMore(
'https://docs.expo.dev/eas/environment-variables/#setting-the-environment-for-your-builds'
)}`
);
return environment;
}
+19 -46
View File
@@ -1,36 +1,33 @@
import { import {
ArchiveSource, ArchiveSource,
ArchiveSourceType, ArchiveSourceType,
BuildMode,
BuildTrigger, BuildTrigger,
FingerprintSourceType,
Metadata, Metadata,
Workflow, Workflow,
} from '@expo/eas-build-job'; } from '@expo/eas-build-job';
import { LoggerLevel } from '@expo/logger';
import assert from 'assert';
import { import {
BuildCredentialsSource, BuildCredentialsSource,
BuildIosEnterpriseProvisioning, BuildIosEnterpriseProvisioning,
BuildMetadataInput, BuildMetadataInput,
BuildMode,
BuildWorkflow, BuildWorkflow,
DistributionType, DistributionType,
FingerprintSourceInput,
BuildMode as GraphQLBuildMode,
BuildTrigger as GraphQLBuildTrigger, BuildTrigger as GraphQLBuildTrigger,
FingerprintSourceType as GraphQLFingeprintSourceType,
ProjectArchiveSourceInput, ProjectArchiveSourceInput,
ProjectArchiveSourceType, ProjectArchiveSourceType,
WorkerLoggerLevel,
} from '../graphql/generated'; } from '../graphql/generated';
export function transformProjectArchive(archiveSource: ArchiveSource): ProjectArchiveSourceInput { export function transformProjectArchive(archiveSource: ArchiveSource): ProjectArchiveSourceInput {
if (archiveSource.type === ArchiveSourceType.GCS) { if (archiveSource.type === ArchiveSourceType.S3) {
return {
type: ProjectArchiveSourceType.S3,
bucketKey: archiveSource.bucketKey,
};
} else if (archiveSource.type === ArchiveSourceType.GCS) {
return { return {
type: ProjectArchiveSourceType.Gcs, type: ProjectArchiveSourceType.Gcs,
bucketKey: archiveSource.bucketKey, bucketKey: archiveSource.bucketKey,
metadataLocation: archiveSource.metadataLocation,
}; };
} else if (archiveSource.type === ArchiveSourceType.URL) { } else if (archiveSource.type === ArchiveSourceType.URL) {
return { return {
@@ -45,8 +42,7 @@ export function transformProjectArchive(archiveSource: ArchiveSource): ProjectAr
export function transformMetadata(metadata: Metadata): BuildMetadataInput { export function transformMetadata(metadata: Metadata): BuildMetadataInput {
return { return {
...metadata, ...metadata,
fingerprintSource: buildMode: metadata.buildMode && transformBuildMode(metadata.buildMode),
metadata.fingerprintSource && transformFingerprintSource(metadata.fingerprintSource),
credentialsSource: credentialsSource:
metadata.credentialsSource && transformCredentialsSource(metadata.credentialsSource), metadata.credentialsSource && transformCredentialsSource(metadata.credentialsSource),
distribution: metadata.distribution && transformDistribution(metadata.distribution), distribution: metadata.distribution && transformDistribution(metadata.distribution),
@@ -57,20 +53,6 @@ export function transformMetadata(metadata: Metadata): BuildMetadataInput {
}; };
} }
export function transformFingerprintSource(
fingerprintSource: NonNullable<Metadata['fingerprintSource']>
): FingerprintSourceInput | null {
if (fingerprintSource.type !== FingerprintSourceType.GCS) {
return null;
}
return {
type: GraphQLFingeprintSourceType.Gcs,
bucketKey: fingerprintSource.bucketKey,
isDebugFingerprint: fingerprintSource.isDebugFingerprint,
};
}
function transformCredentialsSource( function transformCredentialsSource(
credentialsSource: Metadata['credentialsSource'] credentialsSource: Metadata['credentialsSource']
): BuildCredentialsSource { ): BuildCredentialsSource {
@@ -109,17 +91,17 @@ export function transformIosEnterpriseProvisioning(
} }
} }
const buildModeToGraphQLBuildMode: Record<BuildMode, GraphQLBuildMode> = { // TODO: check what in metadata
[BuildMode.BUILD]: GraphQLBuildMode.Build, export function transformBuildMode(buildMode: string): BuildMode {
[BuildMode.CUSTOM]: GraphQLBuildMode.Custom, if (buildMode === 'build') {
[BuildMode.RESIGN]: GraphQLBuildMode.Resign, return BuildMode.Build;
[BuildMode.REPACK]: GraphQLBuildMode.Repack, } else if (buildMode === 'resign') {
}; return BuildMode.Resign;
} else if (buildMode === 'custom') {
export function transformBuildMode(buildMode: BuildMode): GraphQLBuildMode { return BuildMode.Custom;
const graphQLBuildMode = buildModeToGraphQLBuildMode[buildMode]; } else {
assert(graphQLBuildMode, `Unsupported build mode: ${buildMode}`); throw new Error(`Unsupported build mode: ${buildMode}`);
return graphQLBuildMode; }
} }
export function transformBuildTrigger(buildTrigger: BuildTrigger): GraphQLBuildTrigger { export function transformBuildTrigger(buildTrigger: BuildTrigger): GraphQLBuildTrigger {
@@ -130,12 +112,3 @@ export function transformBuildTrigger(buildTrigger: BuildTrigger): GraphQLBuildT
} }
throw new Error('Unknown build trigger'); throw new Error('Unknown build trigger');
} }
export const loggerLevelToGraphQLWorkerLoggerLevel: Record<LoggerLevel, WorkerLoggerLevel> = {
[LoggerLevel.TRACE]: WorkerLoggerLevel.Trace,
[LoggerLevel.DEBUG]: WorkerLoggerLevel.Debug,
[LoggerLevel.INFO]: WorkerLoggerLevel.Info,
[LoggerLevel.WARN]: WorkerLoggerLevel.Warn,
[LoggerLevel.ERROR]: WorkerLoggerLevel.Error,
[LoggerLevel.FATAL]: WorkerLoggerLevel.Fatal,
};
@@ -1,19 +1,11 @@
import { ExpoConfig } from '@expo/config'; import { ExpoConfig } from '@expo/config';
import { IOSConfig } from '@expo/config-plugins'; import { IOSConfig } from '@expo/config-plugins';
import { IosBuildProfile } from '@expo/eas-json/build/build/types';
import fs from 'fs-extra'; import fs from 'fs-extra';
import { vol } from 'memfs'; import { vol } from 'memfs';
import os from 'os'; import os from 'os';
import { instance, mock } from 'ts-mockito';
import type { XCBuildConfiguration } from 'xcode'; import type { XCBuildConfiguration } from 'xcode';
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
import { Target } from '../../../credentials/ios/types';
import { AppVersionMutation } from '../../../graphql/mutations/AppVersionMutation';
import { AppVersionQuery } from '../../../graphql/queries/AppVersionQuery';
import { readPlistAsync } from '../../../utils/plist'; import { readPlistAsync } from '../../../utils/plist';
import { resolveVcsClient } from '../../../vcs';
import { Client } from '../../../vcs/vcs';
import { import {
BumpStrategy, BumpStrategy,
bumpVersionAsync, bumpVersionAsync,
@@ -22,25 +14,9 @@ import {
getInfoPlistPath, getInfoPlistPath,
readBuildNumberAsync, readBuildNumberAsync,
readShortVersionAsync, readShortVersionAsync,
resolveRemoteBuildNumberAsync,
} from '../version'; } from '../version';
jest.mock('fs'); jest.mock('fs');
jest.mock('../../../commandUtils/context/contextUtils/createGraphqlClient');
jest.mock('../../../vcs/vcs');
jest.mock('../../../graphql/queries/AppVersionQuery');
jest.mock('../../../graphql/mutations/AppVersionMutation');
jest.mock('../../../ora', () => ({
ora: jest.fn().mockImplementation(() => ({
start: jest.fn().mockImplementation(() => ({
succeed: jest.fn(),
stop: jest.fn(),
fail: jest.fn(),
})),
})),
}));
const vcsClient = resolveVcsClient();
const getXCBuildConfigurationFromPbxproj = jest.spyOn( const getXCBuildConfigurationFromPbxproj = jest.spyOn(
IOSConfig.Target, IOSConfig.Target,
@@ -83,7 +59,7 @@ describe(evaluateTemplateString, () => {
// bare workflow // bare workflow
describe(bumpVersionAsync, () => { describe(bumpVersionAsync, () => {
beforeEach(() => { beforeEach(() => {
getPbxproj.mockImplementation((): any => {}); getPbxproj.mockImplementation(() => ({} as any));
}); });
afterEach(() => { afterEach(() => {
getXCBuildConfigurationFromPbxproj.mockReset(); getXCBuildConfigurationFromPbxproj.mockReset();
@@ -97,11 +73,11 @@ describe(bumpVersionAsync, () => {
buildSettings: { buildSettings: {
INFOPLIST_FILE: 'myproject/Info.plist', INFOPLIST_FILE: 'myproject/Info.plist',
}, },
}) as XCBuildConfiguration } as XCBuildConfiguration)
); );
await bumpVersionAsync({ await bumpVersionAsync({
bumpStrategy: BumpStrategy.BUILD_NUMBER, bumpStrategy: BumpStrategy.BUILD_NUMBER,
projectDir: '/app', projectDir: '/repo',
exp: fakeExp, exp: fakeExp,
targets: [ targets: [
{ {
@@ -112,9 +88,9 @@ describe(bumpVersionAsync, () => {
], ],
}); });
const appJSON = await fs.readJSON('/app/app.json'); const appJSON = await fs.readJSON('/repo/app.json');
const infoPlist = (await readPlistAsync( const infoPlist = (await readPlistAsync(
'/app/ios/myproject/Info.plist' '/repo/ios/myproject/Info.plist'
)) as IOSConfig.InfoPlist; )) as IOSConfig.InfoPlist;
expect(fakeExp.version).toBe('1.0.0'); expect(fakeExp.version).toBe('1.0.0');
expect(fakeExp.ios?.buildNumber).toBe('2'); expect(fakeExp.ios?.buildNumber).toBe('2');
@@ -133,11 +109,11 @@ describe(bumpVersionAsync, () => {
buildSettings: { buildSettings: {
INFOPLIST_FILE: '$(SRCROOT)/myproject/Info2.plist', INFOPLIST_FILE: '$(SRCROOT)/myproject/Info2.plist',
}, },
}) as XCBuildConfiguration } as XCBuildConfiguration)
); );
await bumpVersionAsync({ await bumpVersionAsync({
bumpStrategy: BumpStrategy.BUILD_NUMBER, bumpStrategy: BumpStrategy.BUILD_NUMBER,
projectDir: '/app', projectDir: '/repo',
exp: fakeExp, exp: fakeExp,
targets: [ targets: [
{ {
@@ -148,9 +124,9 @@ describe(bumpVersionAsync, () => {
], ],
}); });
const appJSON = await fs.readJSON('/app/app.json'); const appJSON = await fs.readJSON('/repo/app.json');
const infoPlist = (await readPlistAsync( const infoPlist = (await readPlistAsync(
'/app/ios/myproject/Info2.plist' '/repo/ios/myproject/Info2.plist'
)) as IOSConfig.InfoPlist; )) as IOSConfig.InfoPlist;
expect(fakeExp.version).toBe('1.0.0'); expect(fakeExp.version).toBe('1.0.0');
expect(fakeExp.ios?.buildNumber).toBe('2'); expect(fakeExp.ios?.buildNumber).toBe('2');
@@ -168,11 +144,11 @@ describe(bumpVersionAsync, () => {
buildSettings: { buildSettings: {
INFOPLIST_FILE: 'myproject/Info.plist', INFOPLIST_FILE: 'myproject/Info.plist',
}, },
}) as XCBuildConfiguration } as XCBuildConfiguration)
); );
await bumpVersionAsync({ await bumpVersionAsync({
bumpStrategy: BumpStrategy.APP_VERSION, bumpStrategy: BumpStrategy.APP_VERSION,
projectDir: '/app', projectDir: '/repo',
exp: fakeExp, exp: fakeExp,
targets: [ targets: [
{ {
@@ -183,9 +159,9 @@ describe(bumpVersionAsync, () => {
], ],
}); });
const appJSON = await fs.readJSON('/app/app.json'); const appJSON = await fs.readJSON('/repo/app.json');
const infoPlist = (await readPlistAsync( const infoPlist = (await readPlistAsync(
'/app/ios/myproject/Info.plist' '/repo/ios/myproject/Info.plist'
)) as IOSConfig.InfoPlist; )) as IOSConfig.InfoPlist;
expect(fakeExp.version).toBe('1.0.1'); expect(fakeExp.version).toBe('1.0.1');
expect(fakeExp.ios?.buildNumber).toBe('1'); expect(fakeExp.ios?.buildNumber).toBe('1');
@@ -203,11 +179,11 @@ describe(bumpVersionAsync, () => {
buildSettings: { buildSettings: {
INFOPLIST_FILE: 'myproject/Info.plist', INFOPLIST_FILE: 'myproject/Info.plist',
}, },
}) as XCBuildConfiguration } as XCBuildConfiguration)
); );
await bumpVersionAsync({ await bumpVersionAsync({
bumpStrategy: BumpStrategy.NOOP, bumpStrategy: BumpStrategy.NOOP,
projectDir: '/app', projectDir: '/repo',
exp: fakeExp, exp: fakeExp,
targets: [ targets: [
{ {
@@ -218,9 +194,9 @@ describe(bumpVersionAsync, () => {
], ],
}); });
const appJSON = await fs.readJSON('/app/app.json'); const appJSON = await fs.readJSON('/repo/app.json');
const infoPlist = (await readPlistAsync( const infoPlist = (await readPlistAsync(
'/app/ios/myproject/Info.plist' '/repo/ios/myproject/Info.plist'
)) as IOSConfig.InfoPlist; )) as IOSConfig.InfoPlist;
expect(fakeExp.version).toBe('1.0.0'); expect(fakeExp.version).toBe('1.0.0');
expect(fakeExp.ios?.buildNumber).toBe('1'); expect(fakeExp.ios?.buildNumber).toBe('1');
@@ -238,11 +214,11 @@ describe(bumpVersionInAppJsonAsync, () => {
await bumpVersionInAppJsonAsync({ await bumpVersionInAppJsonAsync({
bumpStrategy: BumpStrategy.BUILD_NUMBER, bumpStrategy: BumpStrategy.BUILD_NUMBER,
projectDir: '/app', projectDir: '/repo',
exp: fakeExp, exp: fakeExp,
}); });
const appJSON = await fs.readJSON('/app/app.json'); const appJSON = await fs.readJSON('/repo/app.json');
expect(fakeExp.version).toBe('1.0.0'); expect(fakeExp.version).toBe('1.0.0');
expect(fakeExp.ios?.buildNumber).toBe('2'); expect(fakeExp.ios?.buildNumber).toBe('2');
expect(appJSON.expo.version).toBe('1.0.0'); expect(appJSON.expo.version).toBe('1.0.0');
@@ -254,11 +230,11 @@ describe(bumpVersionInAppJsonAsync, () => {
await bumpVersionInAppJsonAsync({ await bumpVersionInAppJsonAsync({
bumpStrategy: BumpStrategy.APP_VERSION, bumpStrategy: BumpStrategy.APP_VERSION,
projectDir: '/app', projectDir: '/repo',
exp: fakeExp, exp: fakeExp,
}); });
const appJSON = await fs.readJSON('/app/app.json'); const appJSON = await fs.readJSON('/repo/app.json');
expect(fakeExp.version).toBe('1.0.1'); expect(fakeExp.version).toBe('1.0.1');
expect(fakeExp.ios?.buildNumber).toBe('1'); expect(fakeExp.ios?.buildNumber).toBe('1');
expect(appJSON.expo.version).toBe('1.0.1'); expect(appJSON.expo.version).toBe('1.0.1');
@@ -270,11 +246,11 @@ describe(bumpVersionInAppJsonAsync, () => {
await bumpVersionInAppJsonAsync({ await bumpVersionInAppJsonAsync({
bumpStrategy: BumpStrategy.NOOP, bumpStrategy: BumpStrategy.NOOP,
projectDir: '/app', projectDir: '/repo',
exp: fakeExp, exp: fakeExp,
}); });
const appJSON = await fs.readJSON('/app/app.json'); const appJSON = await fs.readJSON('/repo/app.json');
expect(fakeExp.version).toBe('1.0.0'); expect(fakeExp.version).toBe('1.0.0');
expect(fakeExp.ios?.buildNumber).toBe('1'); expect(fakeExp.ios?.buildNumber).toBe('1');
expect(appJSON.expo.version).toBe('1.0.0'); expect(appJSON.expo.version).toBe('1.0.0');
@@ -286,7 +262,7 @@ describe(readBuildNumberAsync, () => {
describe('bare project', () => { describe('bare project', () => {
it('reads the build number from native code', async () => { it('reads the build number from native code', async () => {
const exp = initBareWorkflowProject(); const exp = initBareWorkflowProject();
const buildNumber = await readBuildNumberAsync('/app', exp, {}, vcsClient); const buildNumber = await readBuildNumberAsync('/repo', exp, {});
expect(buildNumber).toBe('1'); expect(buildNumber).toBe('1');
}); });
}); });
@@ -294,7 +270,7 @@ describe(readBuildNumberAsync, () => {
describe('managed project', () => { describe('managed project', () => {
it('reads the build number from expo config', async () => { it('reads the build number from expo config', async () => {
const exp = initManagedProject(); const exp = initManagedProject();
const buildNumber = await readBuildNumberAsync('/app', exp, {}, vcsClient); const buildNumber = await readBuildNumberAsync('/repo', exp, {});
expect(buildNumber).toBe('1'); expect(buildNumber).toBe('1');
}); });
}); });
@@ -304,21 +280,16 @@ describe(readShortVersionAsync, () => {
describe('bare project', () => { describe('bare project', () => {
it('reads the short version from native code', async () => { it('reads the short version from native code', async () => {
const exp = initBareWorkflowProject(); const exp = initBareWorkflowProject();
const appVersion = await readShortVersionAsync('/app', exp, {}, vcsClient); const appVersion = await readShortVersionAsync('/repo', exp, {});
expect(appVersion).toBe('1.0.0'); expect(appVersion).toBe('1.0.0');
}); });
it('evaluates interpolated build number', async () => { it('evaluates interpolated build number', async () => {
const exp = initBareWorkflowProject({ const exp = initBareWorkflowProject({
appVersion: '$(CURRENT_PROJECT_VERSION)', appVersion: '$(CURRENT_PROJECT_VERSION)',
}); });
const buildNumber = await readShortVersionAsync( const buildNumber = await readShortVersionAsync('/repo', exp, {
'/app', CURRENT_PROJECT_VERSION: '1.0.0',
exp, });
{
CURRENT_PROJECT_VERSION: '1.0.0',
},
vcsClient
);
expect(buildNumber).toBe('1.0.0'); expect(buildNumber).toBe('1.0.0');
}); });
}); });
@@ -326,7 +297,7 @@ describe(readShortVersionAsync, () => {
describe('managed project', () => { describe('managed project', () => {
it('reads the version from app config', async () => { it('reads the version from app config', async () => {
const exp = initBareWorkflowProject(); const exp = initBareWorkflowProject();
const appVersion = await readShortVersionAsync('/app', exp, {}, vcsClient); const appVersion = await readShortVersionAsync('/repo', exp, {});
expect(appVersion).toBe('1.0.0'); expect(appVersion).toBe('1.0.0');
}); });
}); });
@@ -338,223 +309,66 @@ describe(getInfoPlistPath, () => {
{ {
'./ios/testapp/Info.plist': '', './ios/testapp/Info.plist': '',
}, },
'/app' '/repo'
); );
const plistPath = getInfoPlistPath('/app', {}); const plistPath = getInfoPlistPath('/repo', {});
expect(plistPath).toBe('/app/ios/testapp/Info.plist'); expect(plistPath).toBe('/repo/ios/testapp/Info.plist');
}); });
it('returns INFOPLIST_FILE if specified', () => { it('returns INFOPLIST_FILE if specified', () => {
vol.fromJSON( vol.fromJSON(
{ {
'./ios/testapp/Info.plist': '', './ios/testapp/Info.plist': '',
}, },
'/app' '/repo'
); );
const plistPath = getInfoPlistPath('/app', { INFOPLIST_FILE: './qwert/NotInfo.plist' }); const plistPath = getInfoPlistPath('/repo', { INFOPLIST_FILE: './qwert/NotInfo.plist' });
expect(plistPath).toBe('/app/ios/qwert/NotInfo.plist'); expect(plistPath).toBe('/repo/ios/qwert/NotInfo.plist');
}); });
it('evaluates SRCROOT in Info.plist', () => { it('evaluates SRCROOT in Info.plist', () => {
vol.fromJSON( vol.fromJSON(
{ {
'./ios/testapp/Info.plist': '', './ios/testapp/Info.plist': '',
}, },
'/app' '/repo'
); );
const plistPath = getInfoPlistPath('/app', { const plistPath = getInfoPlistPath('/repo', {
INFOPLIST_FILE: '$(SRCROOT)/qwert/NotInfo.plist', INFOPLIST_FILE: '$(SRCROOT)/qwert/NotInfo.plist',
}); });
expect(plistPath).toBe('/app/ios/qwert/NotInfo.plist'); expect(plistPath).toBe('/repo/ios/qwert/NotInfo.plist');
}); });
it('evaluates BuildSettings in Info.plist', () => { it('evaluates BuildSettings in Info.plist', () => {
vol.fromJSON( vol.fromJSON(
{ {
'./ios/testapp/Info.plist': '', './ios/testapp/Info.plist': '',
}, },
'/app' '/repo'
); );
const plistPath = getInfoPlistPath('/app', { const plistPath = getInfoPlistPath('/repo', {
INFOPLIST_FILE: '$(SRCROOT)/qwert/$(TARGET_NAME).plist', INFOPLIST_FILE: '$(SRCROOT)/qwert/$(TARGET_NAME).plist',
TARGET_NAME: 'NotInfo', TARGET_NAME: 'NotInfo',
}); });
expect(plistPath).toBe('/app/ios/qwert/NotInfo.plist'); expect(plistPath).toBe('/repo/ios/qwert/NotInfo.plist');
});
});
describe(resolveRemoteBuildNumberAsync, () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('uses current remote buildNumber when remote version set, autoIncrement=false', async () => {
const graphQLClientMock = instance(mock<ExpoGraphqlClient>());
const vcsClientMock = instance(mock<Client>());
vcsClientMock.getRootPathAsync = async () => '/app';
jest.mocked(AppVersionQuery.latestVersionAsync).mockResolvedValue({
buildVersion: '11',
storeVersion: '1.2.3',
});
const createAppVersionAsyncSpy = jest.spyOn(AppVersionMutation, 'createAppVersionAsync');
const exp = initBareWorkflowProject();
const result = await resolveRemoteBuildNumberAsync(graphQLClientMock, {
projectDir: '/app',
projectId: 'fakeProjectId',
exp,
applicationTarget: {} as Target,
buildProfile: {} as IosBuildProfile,
vcsClient: vcsClientMock,
});
expect(result).toBe('11');
expect(createAppVersionAsyncSpy).not.toHaveBeenCalled();
});
it('initializes buildNumber from local files when remote version not set, autoIncrement=false', async () => {
const graphQLClientMock = instance(mock<ExpoGraphqlClient>());
const vcsClientMock = instance(mock<Client>());
vcsClientMock.getRootPathAsync = async () => '/app';
jest.mocked(AppVersionQuery.latestVersionAsync).mockResolvedValue(null);
const createAppVersionAsyncSpy = jest.spyOn(AppVersionMutation, 'createAppVersionAsync');
const exp = initBareWorkflowProject({ buildNumber: '22', appVersion: '2.3.4' });
const result = await resolveRemoteBuildNumberAsync(graphQLClientMock, {
projectDir: '/app',
projectId: 'fakeProjectId',
exp,
applicationTarget: {} as Target,
buildProfile: {} as IosBuildProfile,
vcsClient: vcsClientMock,
});
expect(result).toBe('22');
expect(createAppVersionAsyncSpy).toHaveBeenCalled();
expect(createAppVersionAsyncSpy.mock.calls[0][1].storeVersion).toBe('2.3.4');
expect(createAppVersionAsyncSpy.mock.calls[0][1].buildVersion).toBe('22');
});
it('initializes buildNumber starting with 1 when remote version not set and local version is 1, autoIncrement=false', async () => {
const graphQLClientMock = instance(mock<ExpoGraphqlClient>());
const vcsClientMock = instance(mock<Client>());
vcsClientMock.getRootPathAsync = async () => '/app';
jest.mocked(AppVersionQuery.latestVersionAsync).mockResolvedValue(null);
const createAppVersionAsyncSpy = jest.spyOn(AppVersionMutation, 'createAppVersionAsync');
const exp = initBareWorkflowProject({ buildNumber: '1', appVersion: '1.0.0' });
const result = await resolveRemoteBuildNumberAsync(graphQLClientMock, {
projectDir: '/app',
projectId: 'fakeProjectId',
exp,
applicationTarget: {} as Target,
buildProfile: {} as IosBuildProfile,
vcsClient: vcsClientMock,
});
expect(result).toBe('1');
expect(createAppVersionAsyncSpy).toHaveBeenCalled();
expect(createAppVersionAsyncSpy.mock.calls[0][1].storeVersion).toBe('1.0.0');
expect(createAppVersionAsyncSpy.mock.calls[0][1].buildVersion).toBe('1');
});
it('increments current remote buildNumber when remote version set, autoIncrement=true', async () => {
const graphQLClientMock = instance(mock<ExpoGraphqlClient>());
const vcsClientMock = instance(mock<Client>());
vcsClientMock.getRootPathAsync = async () => '/app';
jest.mocked(AppVersionQuery.latestVersionAsync).mockResolvedValue({
buildVersion: '11',
storeVersion: '1.2.3',
});
const createAppVersionAsyncSpy = jest.spyOn(AppVersionMutation, 'createAppVersionAsync');
const exp = initBareWorkflowProject();
const result = await resolveRemoteBuildNumberAsync(graphQLClientMock, {
projectDir: '/app',
projectId: 'fakeProjectId',
exp,
applicationTarget: {} as Target,
buildProfile: { autoIncrement: true } as IosBuildProfile,
vcsClient: vcsClientMock,
});
expect(result).toBe('12');
expect(createAppVersionAsyncSpy).toHaveBeenCalled();
expect(createAppVersionAsyncSpy.mock.calls[0][1].storeVersion).toBe('1.0.0');
expect(createAppVersionAsyncSpy.mock.calls[0][1].buildVersion).toBe('12');
});
it('increments buildNumber from local files when remote version not set, autoIncrement=true', async () => {
const graphQLClientMock = instance(mock<ExpoGraphqlClient>());
const vcsClientMock = instance(mock<Client>());
vcsClientMock.getRootPathAsync = async () => '/app';
jest.mocked(AppVersionQuery.latestVersionAsync).mockResolvedValue(null);
const createAppVersionAsyncSpy = jest.spyOn(AppVersionMutation, 'createAppVersionAsync');
const exp = initBareWorkflowProject({ buildNumber: '22', appVersion: '2.3.4' });
const result = await resolveRemoteBuildNumberAsync(graphQLClientMock, {
projectDir: '/app',
projectId: 'fakeProjectId',
exp,
applicationTarget: {} as Target,
buildProfile: { autoIncrement: true } as IosBuildProfile,
vcsClient: vcsClientMock,
});
expect(result).toBe('23');
expect(createAppVersionAsyncSpy).toHaveBeenCalled();
expect(createAppVersionAsyncSpy.mock.calls[0][1].storeVersion).toBe('2.3.4');
expect(createAppVersionAsyncSpy.mock.calls[0][1].buildVersion).toBe('23');
});
it('initializes buildNumber starting with 1 when remote version not set and local version is 1, autoIncrement=true', async () => {
const graphQLClientMock = instance(mock<ExpoGraphqlClient>());
const vcsClientMock = instance(mock<Client>());
vcsClientMock.getRootPathAsync = async () => '/app';
jest.mocked(AppVersionQuery.latestVersionAsync).mockResolvedValue(null);
const createAppVersionAsyncSpy = jest.spyOn(AppVersionMutation, 'createAppVersionAsync');
const exp = initBareWorkflowProject({ buildNumber: '1', appVersion: '1.0.0' });
const result = await resolveRemoteBuildNumberAsync(graphQLClientMock, {
projectDir: '/app',
projectId: 'fakeProjectId',
exp,
applicationTarget: {} as Target,
buildProfile: { autoIncrement: true } as IosBuildProfile,
vcsClient: vcsClientMock,
});
expect(result).toBe('1');
expect(createAppVersionAsyncSpy).toHaveBeenCalled();
expect(createAppVersionAsyncSpy.mock.calls[0][1].storeVersion).toBe('1.0.0');
expect(createAppVersionAsyncSpy.mock.calls[0][1].buildVersion).toBe('1');
}); });
}); });
function initBareWorkflowProject({ function initBareWorkflowProject({
appVersion = '1.0.0', appVersion = '1.0.0',
buildNumber = '1', buildNumber = '1',
expoConfig = {},
infoPlistName = 'Info.plist', infoPlistName = 'Info.plist',
}: { }: { appVersion?: string; buildNumber?: string; infoPlistName?: string } = {}): ExpoConfig {
appVersion?: string;
buildNumber?: string;
infoPlistName?: string;
expoConfig?: { version?: string; ios?: object };
} = {}): ExpoConfig {
const fakeExp: ExpoConfig = { const fakeExp: ExpoConfig = {
name: 'myproject', name: 'myproject',
slug: 'myproject', slug: 'myproject',
version: '1.0.0', version: '1.0.0',
ios: { ios: {
buildNumber: '1', buildNumber: '1',
...expoConfig.ios,
}, },
...expoConfig,
}; };
vol.fromJSON( vol.fromJSON(
{ {
'./app.json': JSON.stringify({ './app.json': JSON.stringify({
expo: fakeExp, expo: fakeExp,
}), }),
'./ios/myproject.xcodeproj/project.pbxproj': '',
[`./ios/myproject/${infoPlistName}`]: `<?xml version="1.0" encoding="UTF-8"?> [`./ios/myproject/${infoPlistName}`]: `<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"> <plist version="1.0">
@@ -566,7 +380,7 @@ function initBareWorkflowProject({
</dict> </dict>
</plist>`, </plist>`,
}, },
'/app' '/repo'
); );
return fakeExp; return fakeExp;
@@ -588,7 +402,7 @@ function initManagedProject(): ExpoConfig {
expo: fakeExp, expo: fakeExp,
}), }),
}, },
'/app' '/repo'
); );
return fakeExp; return fakeExp;
+12 -18
View File
@@ -1,16 +1,10 @@
import { Ios, Metadata, Platform, Workflow } from '@expo/eas-build-job'; import { Ios, Job, Metadata, Platform, Workflow } from '@expo/eas-build-job';
import { AppVersionSource } from '@expo/eas-json'; import { AppVersionSource } from '@expo/eas-json';
import { ensureIosCredentialsAsync } from './credentials';
import { transformJob } from './graphql';
import { prepareJobAsync } from './prepareJob';
import { syncProjectConfigurationAsync } from './syncProjectConfiguration';
import { resolveRemoteBuildNumberAsync } from './version';
import { IosCredentials } from '../../credentials/ios/types'; import { IosCredentials } from '../../credentials/ios/types';
import { BuildParamsInput } from '../../graphql/generated'; import { BuildParamsInput } from '../../graphql/generated';
import { BuildMutation, BuildResult } from '../../graphql/mutations/BuildMutation'; import { BuildMutation, BuildResult } from '../../graphql/mutations/BuildMutation';
import { ensureBundleIdentifierIsDefinedForManagedProjectAsync } from '../../project/ios/bundleIdentifier'; import { ensureBundleIdentifierIsDefinedForManagedProjectAsync } from '../../project/ios/bundleIdentifier';
import { ensureNonExemptEncryptionIsDefinedForManagedProjectAsync } from '../../project/ios/exemptEncryption';
import { resolveXcodeBuildContextAsync } from '../../project/ios/scheme'; import { resolveXcodeBuildContextAsync } from '../../project/ios/scheme';
import { findApplicationTarget, resolveTargetsAsync } from '../../project/ios/target'; import { findApplicationTarget, resolveTargetsAsync } from '../../project/ios/target';
import { BuildRequestSender, JobData, prepareBuildRequestForPlatformAsync } from '../build'; import { BuildRequestSender, JobData, prepareBuildRequestForPlatformAsync } from '../build';
@@ -21,15 +15,19 @@ import {
checkNodeEnvVariable, checkNodeEnvVariable,
validatePNGsForManagedProjectAsync, validatePNGsForManagedProjectAsync,
} from '../validate'; } from '../validate';
import { ensureIosCredentialsAsync } from './credentials';
import { transformJob } from './graphql';
import { prepareJobAsync } from './prepareJob';
import { syncProjectConfigurationAsync } from './syncProjectConfiguration';
import { resolveRemoteBuildNumberAsync } from './version';
export async function createIosContextAsync( export async function createIosContextAsync(
ctx: CommonContext<Platform.IOS> ctx: CommonContext<Platform.IOS>
): Promise<IosBuildContext> { ): Promise<IosBuildContext> {
const { buildProfile, env } = ctx; const { buildProfile } = ctx;
if (ctx.workflow === Workflow.MANAGED) { if (ctx.workflow === Workflow.MANAGED) {
await ensureBundleIdentifierIsDefinedForManagedProjectAsync(ctx); await ensureBundleIdentifierIsDefinedForManagedProjectAsync(ctx);
await ensureNonExemptEncryptionIsDefinedForManagedProjectAsync(ctx);
} }
checkNodeEnvVariable(ctx); checkNodeEnvVariable(ctx);
@@ -41,7 +39,6 @@ export async function createIosContextAsync(
projectDir: ctx.projectDir, projectDir: ctx.projectDir,
nonInteractive: ctx.nonInteractive, nonInteractive: ctx.nonInteractive,
exp: ctx.exp, exp: ctx.exp,
vcsClient: ctx.vcsClient,
}, },
buildProfile buildProfile
); );
@@ -49,8 +46,7 @@ export async function createIosContextAsync(
projectDir: ctx.projectDir, projectDir: ctx.projectDir,
exp: ctx.exp, exp: ctx.exp,
xcodeBuildContext, xcodeBuildContext,
env, env: buildProfile.env,
vcsClient: ctx.vcsClient,
}); });
const applicationTarget = findApplicationTarget(targets); const applicationTarget = findApplicationTarget(targets);
const buildNumberOverride = const buildNumberOverride =
@@ -61,7 +57,6 @@ export async function createIosContextAsync(
exp: ctx.exp, exp: ctx.exp,
applicationTarget, applicationTarget,
buildProfile, buildProfile,
vcsClient: ctx.vcsClient,
}) })
: undefined; : undefined;
return { return {
@@ -79,10 +74,10 @@ export async function prepareIosBuildAsync(
return await prepareBuildRequestForPlatformAsync({ return await prepareBuildRequestForPlatformAsync({
ctx, ctx,
ensureCredentialsAsync: async (ctx: BuildContext<Platform.IOS>) => { ensureCredentialsAsync: async (ctx: BuildContext<Platform.IOS>) => {
return await ensureIosCredentialsAsync(ctx, ctx.ios.targets); return ensureIosCredentialsAsync(ctx, ctx.ios.targets);
}, },
syncProjectConfigurationAsync: async () => { syncProjectConfigurationAsync: async () => {
await syncProjectConfigurationAsync({ await syncProjectConfigurationAsync(ctx.graphqlClient, {
projectDir: ctx.projectDir, projectDir: ctx.projectDir,
exp: ctx.exp, exp: ctx.exp,
targets: ctx.ios.targets, targets: ctx.ios.targets,
@@ -90,14 +85,13 @@ export async function prepareIosBuildAsync(
ctx.easJsonCliConfig?.appVersionSource === AppVersionSource.REMOTE ctx.easJsonCliConfig?.appVersionSource === AppVersionSource.REMOTE
? false ? false
: ctx.buildProfile.autoIncrement, : ctx.buildProfile.autoIncrement,
vcsClient: ctx.vcsClient, projectId: ctx.projectId,
env: ctx.env,
}); });
}, },
prepareJobAsync: async ( prepareJobAsync: async (
ctx: BuildContext<Platform.IOS>, ctx: BuildContext<Platform.IOS>,
jobData: JobData<IosCredentials> jobData: JobData<IosCredentials>
): Promise<Ios.Job> => { ): Promise<Job> => {
return await prepareJobAsync(ctx, { return await prepareJobAsync(ctx, {
...jobData, ...jobData,
buildScheme: ctx.ios.xcodeBuildContext.buildScheme, buildScheme: ctx.ios.xcodeBuildContext.buildScheme,
@@ -55,5 +55,6 @@ export async function ensureIosCredentialsForBuildResignAsync(
} }
function shouldProvideCredentials(buildCtx: BuildContext<Platform.IOS>): boolean { function shouldProvideCredentials(buildCtx: BuildContext<Platform.IOS>): boolean {
return !buildCtx.buildProfile.simulator && !buildCtx.buildProfile.withoutCredentials; const isCustomBuild = buildCtx.buildProfile.config !== undefined;
return !buildCtx.buildProfile.simulator && !isCustomBuild;
} }
+1 -6
View File
@@ -3,13 +3,11 @@ import nullthrows from 'nullthrows';
import { IosJobInput, IosJobSecretsInput } from '../../graphql/generated'; import { IosJobInput, IosJobSecretsInput } from '../../graphql/generated';
import { import {
loggerLevelToGraphQLWorkerLoggerLevel,
transformBuildMode, transformBuildMode,
transformBuildTrigger, transformBuildTrigger,
transformProjectArchive, transformProjectArchive,
transformWorkflow, transformWorkflow,
} from '../graphql'; } from '../graphql';
import { buildProfileEnvironmentToEnvironment } from '../utils/environment';
export function transformJob(job: Ios.Job): IosJobInput { export function transformJob(job: Ios.Job): IosJobInput {
return { return {
@@ -17,6 +15,7 @@ export function transformJob(job: Ios.Job): IosJobInput {
triggeredBy: transformBuildTrigger(job.triggeredBy), triggeredBy: transformBuildTrigger(job.triggeredBy),
projectArchive: transformProjectArchive(job.projectArchive), projectArchive: transformProjectArchive(job.projectArchive),
projectRootDirectory: nullthrows(job.projectRootDirectory), projectRootDirectory: nullthrows(job.projectRootDirectory),
releaseChannel: job.releaseChannel,
updates: job.updates, updates: job.updates,
secrets: job.secrets ? transformIosSecrets(job.secrets) : undefined, secrets: job.secrets ? transformIosSecrets(job.secrets) : undefined,
builderEnvironment: job.builderEnvironment, builderEnvironment: job.builderEnvironment,
@@ -32,10 +31,6 @@ export function transformJob(job: Ios.Job): IosJobInput {
experimental: job.experimental, experimental: job.experimental,
mode: transformBuildMode(job.mode), mode: transformBuildMode(job.mode),
customBuildConfig: job.customBuildConfig, customBuildConfig: job.customBuildConfig,
environment: buildProfileEnvironmentToEnvironment(job.environment),
loggerLevel: job.loggerLevel
? loggerLevelToGraphQLWorkerLoggerLevel[job.loggerLevel]
: undefined,
}; };
} }
+12 -30
View File
@@ -3,8 +3,9 @@ import {
BuildMode, BuildMode,
BuildTrigger, BuildTrigger,
Ios, Ios,
Job,
Platform, Platform,
sanitizeBuildJob, sanitizeJob,
} from '@expo/eas-build-job'; } from '@expo/eas-build-job';
import { BuildProfile } from '@expo/eas-json'; import { BuildProfile } from '@expo/eas-json';
import nullthrows from 'nullthrows'; import nullthrows from 'nullthrows';
@@ -13,8 +14,9 @@ import slash from 'slash';
import { IosCredentials, TargetCredentials } from '../../credentials/ios/types'; import { IosCredentials, TargetCredentials } from '../../credentials/ios/types';
import { IosJobSecretsInput } from '../../graphql/generated'; import { IosJobSecretsInput } from '../../graphql/generated';
import { getCustomBuildConfigPathForJob } from '../../project/customBuildConfig'; import { getCustomBuildConfigPath } from '../../project/customBuildConfig';
import { getUsername } from '../../project/projectUtils'; import { getUsername } from '../../project/projectUtils';
import { getVcsClient } from '../../vcs';
import { BuildContext } from '../context'; import { BuildContext } from '../context';
interface JobData { interface JobData {
@@ -25,17 +27,17 @@ interface JobData {
const cacheDefaults = { const cacheDefaults = {
disabled: false, disabled: false,
paths: [], customPaths: [],
}; };
export async function prepareJobAsync( export async function prepareJobAsync(
ctx: BuildContext<Platform.IOS>, ctx: BuildContext<Platform.IOS>,
jobData: JobData jobData: JobData
): Promise<Ios.Job> { ): Promise<Job> {
const username = getUsername(ctx.exp, ctx.user); const username = getUsername(ctx.exp, ctx.user);
const buildProfile: BuildProfile<Platform.IOS> = ctx.buildProfile; const buildProfile: BuildProfile<Platform.IOS> = ctx.buildProfile;
const projectRootDirectory = const projectRootDirectory =
slash(path.relative(await ctx.vcsClient.getRootPathAsync(), ctx.projectDir)) || '.'; slash(path.relative(await getVcsClient().getRootPathAsync(), ctx.projectDir)) || '.';
const buildCredentials: Ios.BuildSecrets['buildCredentials'] = {}; const buildCredentials: Ios.BuildSecrets['buildCredentials'] = {};
if (jobData.credentials) { if (jobData.credentials) {
const targetNames = Object.keys(jobData.credentials); const targetNames = Object.keys(jobData.credentials);
@@ -45,18 +47,9 @@ export async function prepareJobAsync(
} }
const maybeCustomBuildConfigPath = buildProfile.config const maybeCustomBuildConfigPath = buildProfile.config
? getCustomBuildConfigPathForJob(buildProfile.config) ? getCustomBuildConfigPath(buildProfile.config)
: undefined; : undefined;
let buildMode;
if (ctx.repack) {
buildMode = BuildMode.REPACK;
} else if (buildProfile.config) {
buildMode = BuildMode.CUSTOM;
} else {
buildMode = BuildMode.BUILD;
}
const job: Ios.Job = { const job: Ios.Job = {
type: ctx.workflow, type: ctx.workflow,
platform: Platform.IOS, platform: Platform.IOS,
@@ -65,12 +58,11 @@ export async function prepareJobAsync(
builderEnvironment: { builderEnvironment: {
image: buildProfile.image, image: buildProfile.image,
node: buildProfile.node, node: buildProfile.node,
pnpm: buildProfile.pnpm,
bun: buildProfile.bun,
yarn: buildProfile.yarn, yarn: buildProfile.yarn,
bundler: buildProfile.bundler, bundler: buildProfile.bundler,
cocoapods: buildProfile.cocoapods, cocoapods: buildProfile.cocoapods,
fastlane: buildProfile.fastlane, fastlane: buildProfile.fastlane,
expoCli: buildProfile.expoCli,
env: buildProfile.env, env: buildProfile.env,
}, },
cache: { cache: {
@@ -81,6 +73,7 @@ export async function prepareJobAsync(
secrets: { secrets: {
buildCredentials, buildCredentials,
}, },
releaseChannel: buildProfile.releaseChannel,
updates: { channel: buildProfile.channel }, updates: { channel: buildProfile.channel },
developmentClient: buildProfile.developmentClient, developmentClient: buildProfile.developmentClient,
simulator: buildProfile.simulator, simulator: buildProfile.simulator,
@@ -88,7 +81,6 @@ export async function prepareJobAsync(
buildConfiguration: buildProfile.buildConfiguration, buildConfiguration: buildProfile.buildConfiguration,
applicationArchivePath: buildProfile.applicationArchivePath ?? buildProfile.artifactPath, applicationArchivePath: buildProfile.applicationArchivePath ?? buildProfile.artifactPath,
buildArtifactPaths: buildProfile.buildArtifactPaths, buildArtifactPaths: buildProfile.buildArtifactPaths,
environment: ctx.buildProfile.environment,
username, username,
...(ctx.ios.buildNumberOverride && { ...(ctx.ios.buildNumberOverride && {
version: { version: {
@@ -98,25 +90,15 @@ export async function prepareJobAsync(
experimental: { experimental: {
prebuildCommand: buildProfile.prebuildCommand, prebuildCommand: buildProfile.prebuildCommand,
}, },
mode: buildMode, mode: buildProfile.config ? BuildMode.CUSTOM : BuildMode.BUILD,
triggeredBy: BuildTrigger.EAS_CLI, triggeredBy: BuildTrigger.EAS_CLI,
...(maybeCustomBuildConfigPath && { ...(maybeCustomBuildConfigPath && {
customBuildConfig: { customBuildConfig: {
path: maybeCustomBuildConfigPath, path: maybeCustomBuildConfigPath,
}, },
}), }),
...(ctx.repack && {
customBuildConfig: {
path: '__eas/repack.yml',
},
}),
loggerLevel: ctx.loggerLevel,
// Technically, these are unused, but let's include them here for type consistency.
// See: https://github.com/expo/eas-build/pull/454
appId: ctx.projectId,
initiatingUserId: ctx.user.id,
}; };
return sanitizeBuildJob(job) as Ios.Job; return sanitizeJob(job);
} }
export function prepareCredentialsToResign(credentials: IosCredentials): IosJobSecretsInput { export function prepareCredentialsToResign(credentials: IosCredentials): IosJobSecretsInput {
@@ -1,35 +1,36 @@
import { ExpoConfig } from '@expo/config'; import { ExpoConfig } from '@expo/config';
import { Env, Platform, Workflow } from '@expo/eas-build-job'; import { Platform, Workflow } from '@expo/eas-build-job';
import { IosVersionAutoIncrement } from '@expo/eas-json'; import { IosVersionAutoIncrement } from '@expo/eas-json';
import { BumpStrategy, bumpVersionAsync, bumpVersionInAppJsonAsync } from './version'; import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
import { Target } from '../../credentials/ios/types'; import { Target } from '../../credentials/ios/types';
import { isExpoUpdatesInstalled } from '../../project/projectUtils'; import { isExpoUpdatesInstalled } from '../../project/projectUtils';
import { resolveWorkflowAsync } from '../../project/workflow'; import { resolveWorkflowAsync } from '../../project/workflow';
import { syncUpdatesConfigurationAsync } from '../../update/ios/UpdatesModule'; import { syncUpdatesConfigurationAsync } from '../../update/ios/UpdatesModule';
import { Client } from '../../vcs/vcs'; import { BumpStrategy, bumpVersionAsync, bumpVersionInAppJsonAsync } from './version';
export async function syncProjectConfigurationAsync({ export async function syncProjectConfigurationAsync(
projectDir, graphqlClient: ExpoGraphqlClient,
exp, {
targets, projectDir,
localAutoIncrement, exp,
vcsClient, targets,
env, localAutoIncrement,
}: { projectId,
projectDir: string; }: {
exp: ExpoConfig; projectDir: string;
targets: Target[]; exp: ExpoConfig;
localAutoIncrement?: IosVersionAutoIncrement; targets: Target[];
vcsClient: Client; localAutoIncrement?: IosVersionAutoIncrement;
env: Env | undefined; projectId: string;
}): Promise<void> { }
const workflow = await resolveWorkflowAsync(projectDir, Platform.IOS, vcsClient); ): Promise<void> {
const workflow = await resolveWorkflowAsync(projectDir, Platform.IOS);
const versionBumpStrategy = resolveVersionBumpStrategy(localAutoIncrement ?? false); const versionBumpStrategy = resolveVersionBumpStrategy(localAutoIncrement ?? false);
if (workflow === Workflow.GENERIC) { if (workflow === Workflow.GENERIC) {
if (isExpoUpdatesInstalled(projectDir)) { if (isExpoUpdatesInstalled(projectDir)) {
await syncUpdatesConfigurationAsync({ vcsClient, projectDir, exp, workflow, env }); await syncUpdatesConfigurationAsync(graphqlClient, projectDir, exp, projectId);
} }
await bumpVersionAsync({ projectDir, exp, bumpStrategy: versionBumpStrategy, targets }); await bumpVersionAsync({ projectDir, exp, bumpStrategy: versionBumpStrategy, targets });
} else { } else {
+18 -69
View File
@@ -11,7 +11,7 @@ import { Target } from '../../credentials/ios/types';
import { AppPlatform } from '../../graphql/generated'; import { AppPlatform } from '../../graphql/generated';
import { AppVersionMutation } from '../../graphql/mutations/AppVersionMutation'; import { AppVersionMutation } from '../../graphql/mutations/AppVersionMutation';
import { AppVersionQuery } from '../../graphql/queries/AppVersionQuery'; import { AppVersionQuery } from '../../graphql/queries/AppVersionQuery';
import Log, { learnMore } from '../../log'; import Log from '../../log';
import { ora } from '../../ora'; import { ora } from '../../ora';
import { findApplicationTarget } from '../../project/ios/target'; import { findApplicationTarget } from '../../project/ios/target';
import { getNextBuildNumber, isValidBuildNumber } from '../../project/ios/versions'; import { getNextBuildNumber, isValidBuildNumber } from '../../project/ios/versions';
@@ -19,12 +19,9 @@ import { resolveWorkflowAsync } from '../../project/workflow';
import { promptAsync } from '../../prompts'; import { promptAsync } from '../../prompts';
import uniqBy from '../../utils/expodash/uniqBy'; import uniqBy from '../../utils/expodash/uniqBy';
import { readPlistAsync, writePlistAsync } from '../../utils/plist'; import { readPlistAsync, writePlistAsync } from '../../utils/plist';
import { Client } from '../../vcs/vcs';
import { updateAppJsonConfigAsync } from '../utils/appJson'; import { updateAppJsonConfigAsync } from '../utils/appJson';
import { bumpAppVersionAsync, ensureStaticConfigExists } from '../utils/version'; import { bumpAppVersionAsync, ensureStaticConfigExists } from '../utils/version';
const SHORT_VERSION_REGEX = /^\d+(\.\d+){0,2}$/;
export enum BumpStrategy { export enum BumpStrategy {
APP_VERSION, APP_VERSION,
BUILD_NUMBER, BUILD_NUMBER,
@@ -100,48 +97,19 @@ export async function bumpVersionInAppJsonAsync({
} }
} }
function validateShortVersion({
shortVersion,
workflow,
}: {
shortVersion: string | undefined;
workflow: Workflow;
}): void {
if (shortVersion && !SHORT_VERSION_REGEX.test(shortVersion)) {
if (workflow === Workflow.MANAGED) {
Log.warn(
`The required format for "version" field from app.json/app.config.ts for App Store iOS builds is one to three period-separated integers, such as 10.14.1. The string can only contain numeric characters (0-9) and periods. Current value: ${shortVersion}. Edit the "version" field in your app.json/app.config.ts to match the format required by Apple. ${learnMore(
'https://developer.apple.com/documentation/bundleresources/information_property_list/cfbundleshortversionstring'
)}`
);
} else {
Log.warn(
`The required format for "CFBundleShortVersionString" in Info.plist for App Store iOS builds is one to three period-separated integers, such as 10.14.1. The string can only contain numeric characters (0-9) and periods. Current value: ${shortVersion}. Edit the "CFBundleShortVersionString" in your Info.plist to match the format required by Apple. ${learnMore(
'https://developer.apple.com/documentation/bundleresources/information_property_list/cfbundleshortversionstring'
)}`
);
}
}
}
export async function readShortVersionAsync( export async function readShortVersionAsync(
projectDir: string, projectDir: string,
exp: ExpoConfig, exp: ExpoConfig,
buildSettings: XCBuildConfiguration['buildSettings'], buildSettings: XCBuildConfiguration['buildSettings']
vcsClient: Client
): Promise<string | undefined> { ): Promise<string | undefined> {
const workflow = await resolveWorkflowAsync(projectDir, Platform.IOS, vcsClient); const workflow = await resolveWorkflowAsync(projectDir, Platform.IOS);
if (workflow === Workflow.GENERIC) { if (workflow === Workflow.GENERIC) {
const infoPlist = await readInfoPlistAsync(projectDir, buildSettings); const infoPlist = await readInfoPlistAsync(projectDir, buildSettings);
return (
const shortVersion =
infoPlist.CFBundleShortVersionString && infoPlist.CFBundleShortVersionString &&
evaluateTemplateString(infoPlist.CFBundleShortVersionString, buildSettings); evaluateTemplateString(infoPlist.CFBundleShortVersionString, buildSettings)
);
validateShortVersion({ shortVersion, workflow });
return shortVersion;
} else { } else {
validateShortVersion({ shortVersion: exp.version, workflow });
return exp.version; return exp.version;
} }
} }
@@ -149,10 +117,9 @@ export async function readShortVersionAsync(
export async function readBuildNumberAsync( export async function readBuildNumberAsync(
projectDir: string, projectDir: string,
exp: ExpoConfig, exp: ExpoConfig,
buildSettings: XCBuildConfiguration['buildSettings'], buildSettings: XCBuildConfiguration['buildSettings']
vcsClient: Client
): Promise<string | undefined> { ): Promise<string | undefined> {
const workflow = await resolveWorkflowAsync(projectDir, Platform.IOS, vcsClient); const workflow = await resolveWorkflowAsync(projectDir, Platform.IOS);
if (workflow === Workflow.GENERIC) { if (workflow === Workflow.GENERIC) {
const infoPlist = await readInfoPlistAsync(projectDir, buildSettings); const infoPlist = await readInfoPlistAsync(projectDir, buildSettings);
return ( return (
@@ -166,8 +133,7 @@ export async function readBuildNumberAsync(
export async function maybeResolveVersionsAsync( export async function maybeResolveVersionsAsync(
projectDir: string, projectDir: string,
exp: ExpoConfig, exp: ExpoConfig,
targets: Target[], targets: Target[]
vcsClient: Client
): Promise<{ appVersion?: string; appBuildVersion?: string }> { ): Promise<{ appVersion?: string; appBuildVersion?: string }> {
const applicationTarget = findApplicationTarget(targets); const applicationTarget = findApplicationTarget(targets);
try { try {
@@ -175,14 +141,12 @@ export async function maybeResolveVersionsAsync(
appBuildVersion: await readBuildNumberAsync( appBuildVersion: await readBuildNumberAsync(
projectDir, projectDir,
exp, exp,
applicationTarget.buildSettings ?? {}, applicationTarget.buildSettings ?? {}
vcsClient
), ),
appVersion: await readShortVersionAsync( appVersion: await readShortVersionAsync(
projectDir, projectDir,
exp, exp,
applicationTarget.buildSettings ?? {}, applicationTarget.buildSettings ?? {}
vcsClient
), ),
}; };
} catch (err: any) { } catch (err: any) {
@@ -300,14 +264,12 @@ export async function resolveRemoteBuildNumberAsync(
exp, exp,
applicationTarget, applicationTarget,
buildProfile, buildProfile,
vcsClient,
}: { }: {
projectDir: string; projectDir: string;
projectId: string; projectId: string;
exp: ExpoConfig; exp: ExpoConfig;
applicationTarget: Target; applicationTarget: Target;
buildProfile: BuildProfile<Platform.IOS>; buildProfile: BuildProfile<Platform.IOS>;
vcsClient: Client;
} }
): Promise<string> { ): Promise<string> {
const remoteVersions = await AppVersionQuery.latestVersionAsync( const remoteVersions = await AppVersionQuery.latestVersionAsync(
@@ -320,28 +282,22 @@ export async function resolveRemoteBuildNumberAsync(
const localBuildNumber = await readBuildNumberAsync( const localBuildNumber = await readBuildNumberAsync(
projectDir, projectDir,
exp, exp,
applicationTarget.buildSettings ?? {}, applicationTarget.buildSettings ?? {}
vcsClient
); );
const localShortVersion = await readShortVersionAsync( const localShortVersion = await readShortVersionAsync(
projectDir, projectDir,
exp, exp,
applicationTarget.buildSettings ?? {}, applicationTarget.buildSettings ?? {}
vcsClient
); );
let currentBuildVersion: string; let currentBuildVersion: string;
let shouldInitializeBuildNumber = false;
if (remoteVersions?.buildVersion) { if (remoteVersions?.buildVersion) {
currentBuildVersion = remoteVersions.buildVersion; currentBuildVersion = remoteVersions.buildVersion;
} else { } else {
if (localBuildNumber) { if (localBuildNumber) {
Log.log( Log.warn(
chalk.green( 'No remote versions are configured for this project, buildNumber will be initialized based on the value from the local project.'
'No remote versions are configured for this project, buildNumber will be initialized based on the value from the local project.'
)
); );
currentBuildVersion = localBuildNumber; currentBuildVersion = localBuildNumber;
shouldInitializeBuildNumber = localBuildNumber === '1';
} else { } else {
Log.error( Log.error(
`Remote versions are not configured and EAS CLI was not able to read the current version from your project. Use "eas build:version:set" to initialize remote versions.` `Remote versions are not configured and EAS CLI was not able to read the current version from your project. Use "eas build:version:set" to initialize remote versions.`
@@ -351,10 +307,7 @@ export async function resolveRemoteBuildNumberAsync(
} }
if (!buildProfile.autoIncrement && remoteVersions?.buildVersion) { if (!buildProfile.autoIncrement && remoteVersions?.buildVersion) {
return currentBuildVersion; return currentBuildVersion;
} else if ( } else if (!buildProfile.autoIncrement && !remoteVersions?.buildVersion) {
(!buildProfile.autoIncrement && !remoteVersions?.buildVersion) ||
shouldInitializeBuildNumber
) {
const spinner = ora( const spinner = ora(
`Initializing buildNumber with ${chalk.bold(currentBuildVersion)}.` `Initializing buildNumber with ${chalk.bold(currentBuildVersion)}.`
).start(); ).start();
@@ -365,9 +318,7 @@ export async function resolveRemoteBuildNumberAsync(
applicationIdentifier: applicationTarget.bundleIdentifier, applicationIdentifier: applicationTarget.bundleIdentifier,
storeVersion: localShortVersion ?? '1.0.0', storeVersion: localShortVersion ?? '1.0.0',
buildVersion: currentBuildVersion, buildVersion: currentBuildVersion,
runtimeVersion: runtimeVersion: Updates.getRuntimeVersionNullable(exp, Platform.IOS) ?? undefined,
(await Updates.getRuntimeVersionNullableAsync(projectDir, exp, Platform.IOS)) ??
undefined,
}); });
spinner.succeed(`Initialized buildNumber with ${chalk.bold(currentBuildVersion)}.`); spinner.succeed(`Initialized buildNumber with ${chalk.bold(currentBuildVersion)}.`);
} catch (err) { } catch (err) {
@@ -389,9 +340,7 @@ export async function resolveRemoteBuildNumberAsync(
applicationIdentifier: applicationTarget.bundleIdentifier, applicationIdentifier: applicationTarget.bundleIdentifier,
storeVersion: localShortVersion ?? '1.0.0', storeVersion: localShortVersion ?? '1.0.0',
buildVersion: nextBuildVersion, buildVersion: nextBuildVersion,
runtimeVersion: runtimeVersion: Updates.getRuntimeVersionNullable(exp, Platform.IOS) ?? undefined,
(await Updates.getRuntimeVersionNullableAsync(projectDir, exp, Platform.IOS)) ??
undefined,
}); });
spinner.succeed( spinner.succeed(
`Incremented buildNumber from ${chalk.bold(currentBuildVersion)} to ${chalk.bold( `Incremented buildNumber from ${chalk.bold(currentBuildVersion)} to ${chalk.bold(
+12 -22
View File
@@ -3,11 +3,10 @@ import spawnAsync from '@expo/spawn-async';
import { ChildProcess } from 'child_process'; import { ChildProcess } from 'child_process';
import semver from 'semver'; import semver from 'semver';
import Log from '../log';
import { ora } from '../ora'; import { ora } from '../ora';
const PLUGIN_PACKAGE_NAME = 'eas-cli-local-build-plugin'; const PLUGIN_PACKAGE_NAME = 'eas-cli-local-build-plugin';
const PLUGIN_PACKAGE_VERSION = '1.0.168'; const PLUGIN_PACKAGE_VERSION = '0.0.128';
export enum LocalBuildMode { export enum LocalBuildMode {
/** /**
@@ -40,8 +39,7 @@ export interface LocalBuildOptions {
export async function runLocalBuildAsync( export async function runLocalBuildAsync(
job: Job, job: Job,
metadata: Metadata, metadata: Metadata,
options: LocalBuildOptions, options: LocalBuildOptions
env: Record<string, string>
): Promise<void> { ): Promise<void> {
const { command, args } = await getCommandAndArgsAsync(job, metadata); const { command, args } = await getCommandAndArgsAsync(job, metadata);
let spinner; let spinner;
@@ -56,26 +54,18 @@ export async function runLocalBuildAsync(
}; };
process.on('SIGINT', interruptHandler); process.on('SIGINT', interruptHandler);
try { try {
const mergedEnv = {
...env,
...process.env,
EAS_LOCAL_BUILD_WORKINGDIR: options.workingdir ?? process.env.EAS_LOCAL_BUILD_WORKINGDIR,
...(options.skipCleanup || options.skipNativeBuild
? { EAS_LOCAL_BUILD_SKIP_CLEANUP: '1' }
: {}),
...(options.skipNativeBuild ? { EAS_LOCAL_BUILD_SKIP_NATIVE_BUILD: '1' } : {}),
...(options.artifactsDir ? { EAS_LOCAL_BUILD_ARTIFACTS_DIR: options.artifactsDir } : {}),
...(options.artifactPath ? { EAS_LOCAL_BUILD_ARTIFACT_PATH: options.artifactPath } : {}),
};
// log command execution to assist in debugging local builds
Log.debug('Running local build, using local-build-plugin', {
command,
args,
env: mergedEnv,
});
const spawnPromise = spawnAsync(command, args, { const spawnPromise = spawnAsync(command, args, {
stdio: options.verbose ? 'inherit' : 'pipe', stdio: options.verbose ? 'inherit' : 'pipe',
env: mergedEnv, env: {
...process.env,
EAS_LOCAL_BUILD_WORKINGDIR: options.workingdir ?? process.env.EAS_LOCAL_BUILD_WORKINGDIR,
...(options.skipCleanup || options.skipNativeBuild
? { EAS_LOCAL_BUILD_SKIP_CLEANUP: '1' }
: {}),
...(options.skipNativeBuild ? { EAS_LOCAL_BUILD_SKIP_NATIVE_BUILD: '1' } : {}),
...(options.artifactsDir ? { EAS_LOCAL_BUILD_ARTIFACTS_DIR: options.artifactsDir } : {}),
...(options.artifactPath ? { EAS_LOCAL_BUILD_ARTIFACT_PATH: options.artifactPath } : {}),
},
}); });
childProcess = spawnPromise.child; childProcess = spawnPromise.child;
await spawnPromise; await spawnPromise;
+50 -39
View File
@@ -1,29 +1,34 @@
import { FingerprintSource, Metadata, Platform, sanitizeMetadata } from '@expo/eas-build-job'; import { Updates } from '@expo/config-plugins';
import { BuildMode, Metadata, Platform, sanitizeMetadata } from '@expo/eas-build-job';
import { IosEnterpriseProvisioning } from '@expo/eas-json'; import { IosEnterpriseProvisioning } from '@expo/eas-json';
import fs from 'fs-extra'; import fs from 'fs-extra';
import resolveFrom from 'resolve-from'; import resolveFrom from 'resolve-from';
import Log from '../log';
import { getUsername, isExpoUpdatesInstalled } from '../project/projectUtils';
import {
readChannelSafelyAsync as readAndroidChannelSafelyAsync,
readReleaseChannelSafelyAsync as readAndroidReleaseChannelSafelyAsync,
} from '../update/android/UpdatesModule';
import {
readChannelSafelyAsync as readIosChannelSafelyAsync,
readReleaseChannelSafelyAsync as readIosReleaseChannelSafelyAsync,
} from '../update/ios/UpdatesModule';
import { easCliVersion } from '../utils/easCli';
import { getVcsClient } from '../vcs';
import { maybeResolveVersionsAsync as maybeResolveAndroidVersionsAsync } from './android/version'; import { maybeResolveVersionsAsync as maybeResolveAndroidVersionsAsync } from './android/version';
import { BuildContext } from './context'; import { BuildContext } from './context';
import { maybeResolveVersionsAsync as maybeResolveIosVersionsAsync } from './ios/version'; import { maybeResolveVersionsAsync as maybeResolveIosVersionsAsync } from './ios/version';
import { LocalBuildMode } from './local';
import { BuildDistributionType } from './types';
import Log from '../log';
import { getUsername, isExpoUpdatesInstalled } from '../project/projectUtils';
import { readChannelSafelyAsync as readAndroidChannelSafelyAsync } from '../update/android/UpdatesModule';
import { readChannelSafelyAsync as readIosChannelSafelyAsync } from '../update/ios/UpdatesModule';
import { easCliVersion } from '../utils/easCli';
export async function collectMetadataAsync<T extends Platform>( export async function collectMetadataAsync<T extends Platform>(
ctx: BuildContext<T>, ctx: BuildContext<T>
runtimeAndFingerprintMetadata: {
runtimeVersion?: string | undefined;
fingerprintHash?: string | undefined;
fingerprintSource?: FingerprintSource | undefined;
}
): Promise<Metadata> { ): Promise<Metadata> {
const channelObject = await resolveChannelAsync(ctx); const vcsClient = getVcsClient();
const distribution = ctx.buildProfile.distribution ?? BuildDistributionType.STORE; const channelOrReleaseChannel = await resolveChannelOrReleaseChannelAsync(ctx);
const distribution =
('simulator' in ctx.buildProfile && ctx.buildProfile.simulator
? 'simulator'
: ctx.buildProfile.distribution) ?? 'store';
const metadata: Metadata = { const metadata: Metadata = {
trackingContext: ctx.analyticsEventProperties, trackingContext: ctx.analyticsEventProperties,
...(await maybeResolveVersionsAsync(ctx)), ...(await maybeResolveVersionsAsync(ctx)),
@@ -31,23 +36,18 @@ export async function collectMetadataAsync<T extends Platform>(
workflow: ctx.workflow, workflow: ctx.workflow,
credentialsSource: ctx.buildProfile.credentialsSource, credentialsSource: ctx.buildProfile.credentialsSource,
sdkVersion: ctx.exp.sdkVersion, sdkVersion: ctx.exp.sdkVersion,
runtimeVersion: runtimeAndFingerprintMetadata?.runtimeVersion, runtimeVersion: Updates.getRuntimeVersionNullable(ctx.exp, ctx.platform) ?? undefined,
fingerprintHash: runtimeAndFingerprintMetadata?.fingerprintHash,
fingerprintSource: runtimeAndFingerprintMetadata?.fingerprintSource,
reactNativeVersion: await getReactNativeVersionAsync(ctx.projectDir), reactNativeVersion: await getReactNativeVersionAsync(ctx.projectDir),
...channelObject, ...channelOrReleaseChannel,
distribution, distribution,
appName: ctx.exp.name, appName: ctx.exp.name,
appIdentifier: resolveAppIdentifier(ctx), appIdentifier: resolveAppIdentifier(ctx),
buildProfile: ctx.buildProfileName, buildProfile: ctx.buildProfileName,
gitCommitHash: await ctx.vcsClient.getCommitHashAsync(), gitCommitHash: await vcsClient.getCommitHashAsync(),
gitCommitMessage: truncateGitCommitMessage( gitCommitMessage: truncateGitCommitMessage(
(await ctx.vcsClient.getLastCommitMessageAsync()) ?? undefined (await vcsClient.getLastCommitMessageAsync()) ?? undefined
), ),
isGitWorkingTreeDirty: isGitWorkingTreeDirty: await vcsClient.hasUncommittedChangesAsync(),
ctx.localBuildOptions.localBuildMode === LocalBuildMode.INTERNAL
? false
: await ctx.vcsClient.hasUncommittedChangesAsync(),
username: getUsername(ctx.exp, ctx.user), username: getUsername(ctx.exp, ctx.user),
message: ctx.message, message: ctx.message,
...(ctx.platform === Platform.IOS && { ...(ctx.platform === Platform.IOS && {
@@ -57,12 +57,7 @@ export async function collectMetadataAsync<T extends Platform>(
}), }),
runWithNoWaitFlag: ctx.noWait, runWithNoWaitFlag: ctx.noWait,
runFromCI: ctx.runFromCI, runFromCI: ctx.runFromCI,
customWorkflowName: ctx.customBuildConfigMetadata?.workflowName, buildMode: ctx.buildProfile.config ? BuildMode.CUSTOM : BuildMode.BUILD,
developmentClient: ctx.developmentClient,
requiredPackageManager: ctx.requiredPackageManager ?? undefined,
selectedImage: ctx.buildProfile.image,
customNodeVersion: ctx.buildProfile.node,
simulator: 'simulator' in ctx.buildProfile && ctx.buildProfile.simulator,
}; };
return sanitizeMetadata(metadata); return sanitizeMetadata(metadata);
} }
@@ -75,8 +70,7 @@ async function maybeResolveVersionsAsync<T extends Platform>(
const resolvedVersion = await maybeResolveIosVersionsAsync( const resolvedVersion = await maybeResolveIosVersionsAsync(
ctx.projectDir, ctx.projectDir,
ctx.exp, ctx.exp,
iosContext.ios.targets, iosContext.ios.targets
ctx.vcsClient
); );
if (iosContext.ios.buildNumberOverride) { if (iosContext.ios.buildNumberOverride) {
return { return {
@@ -90,8 +84,7 @@ async function maybeResolveVersionsAsync<T extends Platform>(
const resolvedVersion = await maybeResolveAndroidVersionsAsync( const resolvedVersion = await maybeResolveAndroidVersionsAsync(
ctx.projectDir, ctx.projectDir,
ctx.exp, ctx.exp,
androidCtx.buildProfile, androidCtx.buildProfile
ctx.vcsClient
); );
if (androidCtx.android.versionCodeOverride) { if (androidCtx.android.versionCodeOverride) {
return { return {
@@ -113,21 +106,39 @@ function resolveAppIdentifier<T extends Platform>(ctx: BuildContext<T>): string
} }
} }
async function resolveChannelAsync<T extends Platform>( async function resolveChannelOrReleaseChannelAsync<T extends Platform>(
ctx: BuildContext<T> ctx: BuildContext<T>
): Promise<{ channel: string } | null> { ): Promise<{ channel: string } | { releaseChannel: string } | null> {
if (!isExpoUpdatesInstalled(ctx.projectDir)) { if (!isExpoUpdatesInstalled(ctx.projectDir)) {
return null; return null;
} }
if (ctx.buildProfile.channel) { if (ctx.buildProfile.channel) {
return { channel: ctx.buildProfile.channel }; return { channel: ctx.buildProfile.channel };
} }
if (ctx.buildProfile.releaseChannel) {
return { releaseChannel: ctx.buildProfile.releaseChannel };
}
const channel = await getNativeChannelAsync(ctx); const channel = await getNativeChannelAsync(ctx);
if (channel) { if (channel) {
return { channel }; return { channel };
} }
const releaseChannel = await getNativeReleaseChannelAsync(ctx);
return { releaseChannel };
}
return null; async function getNativeReleaseChannelAsync<T extends Platform>(
ctx: BuildContext<T>
): Promise<string> {
switch (ctx.platform) {
case Platform.ANDROID: {
return (await readAndroidReleaseChannelSafelyAsync(ctx.projectDir)) ?? 'default';
}
case Platform.IOS: {
return (await readIosReleaseChannelSafelyAsync(ctx.projectDir)) ?? 'default';
}
default:
return 'default';
}
} }
async function getNativeChannelAsync<T extends Platform>( async function getNativeChannelAsync<T extends Platform>(
+3 -5
View File
@@ -1,6 +1,5 @@
import chalk from 'chalk'; import chalk from 'chalk';
import { formatGraphQLBuild } from './utils/formatBuild';
import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient'; import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient';
import { PaginatedQueryOptions } from '../commandUtils/pagination'; import { PaginatedQueryOptions } from '../commandUtils/pagination';
import { AppPlatform, BuildFilter, BuildFragment } from '../graphql/generated'; import { AppPlatform, BuildFilter, BuildFragment } from '../graphql/generated';
@@ -12,6 +11,7 @@ import {
paginatedQueryWithConfirmPromptAsync, paginatedQueryWithConfirmPromptAsync,
paginatedQueryWithSelectPromptAsync, paginatedQueryWithSelectPromptAsync,
} from '../utils/queries'; } from '../utils/queries';
import { formatGraphQLBuild } from './utils/formatBuild';
export const BUILDS_LIMIT = 50; export const BUILDS_LIMIT = 50;
@@ -50,9 +50,8 @@ export async function listAndRenderBuildsOnAppAsync(
}), }),
promptOptions: { promptOptions: {
title: 'Load more builds?', title: 'Load more builds?',
renderListItems: builds => { renderListItems: builds =>
renderPageOfBuilds({ builds, projectDisplayName, paginatedQueryOptions }); renderPageOfBuilds({ builds, projectDisplayName, paginatedQueryOptions }),
},
}, },
}); });
} }
@@ -133,7 +132,6 @@ function createBuildToPartialChoiceMaker(
name: build.platform === AppPlatform.Ios ? 'Build number' : 'Version code', name: build.platform === AppPlatform.Ios ? 'Build number' : 'Version code',
value: build.appBuildVersion ? chalk.bold(build.appBuildVersion) : null, value: build.appBuildVersion ? chalk.bold(build.appBuildVersion) : null,
}, },
{ name: 'Profile', value: build.buildProfile ? chalk.bold(build.buildProfile) : null },
]; ];
const filteredDescriptionArray: string[] = descriptionItems const filteredDescriptionArray: string[] = descriptionItems
+67 -302
View File
@@ -1,32 +1,18 @@
import { ExpoConfig } from '@expo/config'; import { Platform, Workflow } from '@expo/eas-build-job';
import { Env, Platform, Workflow } from '@expo/eas-build-job';
import { import {
AppVersionSource, AppVersionSource,
BuildProfile,
EasJson, EasJson,
EasJsonAccessor, EasJsonAccessor,
EasJsonUtils, EasJsonUtils,
ResourceClass, ResourceClass,
SubmitProfile, SubmitProfile,
} from '@expo/eas-json'; } from '@expo/eas-json';
import { LoggerLevel } from '@expo/logger';
import assert from 'assert'; import assert from 'assert';
import chalk from 'chalk'; import chalk from 'chalk';
import { pathExists } from 'fs-extra';
import nullthrows from 'nullthrows'; import nullthrows from 'nullthrows';
import { prepareAndroidBuildAsync } from './android/build';
import { BuildRequestSender, MaybeBuildFragment, waitForBuildEndAsync } from './build';
import { ensureProjectConfiguredAsync } from './configure';
import { BuildContext } from './context';
import { createBuildContextAsync } from './createContext';
import { evaluateConfigWithEnvVarsAsync } from './evaluateConfigWithEnvVarsAsync';
import { prepareIosBuildAsync } from './ios/build';
import { LocalBuildMode, LocalBuildOptions } from './local';
import { ensureExpoDevClientInstalledForDevClientBuildsAsync } from './utils/devClient';
import { printBuildResults, printLogsUrls } from './utils/printBuildInfo';
import { ensureRepoIsCleanAsync } from './utils/repository';
import { Analytics } from '../analytics/AnalyticsManager'; import { Analytics } from '../analytics/AnalyticsManager';
import { createAndLinkChannelAsync, doesChannelExistAsync } from '../channel/queries';
import { DynamicConfigContextFn } from '../commandUtils/context/DynamicProjectConfigContextField'; import { DynamicConfigContextFn } from '../commandUtils/context/DynamicProjectConfigContextField';
import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient'; import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient';
import { import {
@@ -38,32 +24,23 @@ import {
} from '../graphql/generated'; } from '../graphql/generated';
import { BuildQuery } from '../graphql/queries/BuildQuery'; import { BuildQuery } from '../graphql/queries/BuildQuery';
import { toAppPlatform, toPlatform } from '../graphql/types/AppPlatform'; import { toAppPlatform, toPlatform } from '../graphql/types/AppPlatform';
import Log, { learnMore } from '../log'; import Log from '../log';
import { import {
RequestedPlatform, RequestedPlatform,
appPlatformDisplayNames, appPlatformDisplayNames,
appPlatformEmojis, appPlatformEmojis,
toPlatforms, toPlatforms,
} from '../platform'; } from '../platform';
import { import { validateCustomBuildConfigAsync } from '../project/customBuildConfig';
CustomBuildConfigMetadata,
validateCustomBuildConfigAsync,
} from '../project/customBuildConfig';
import { checkExpoSdkIsSupportedAsync } from '../project/expoSdk'; import { checkExpoSdkIsSupportedAsync } from '../project/expoSdk';
import { validateMetroConfigForManagedWorkflowAsync } from '../project/metroConfig'; import { validateMetroConfigForManagedWorkflowAsync } from '../project/metroConfig';
import { validateAppVersionRuntimePolicySupportAsync } from '../project/projectUtils';
import { import {
isExpoUpdatesInstalledAsDevDependency,
isExpoUpdatesInstalledOrAvailable,
isUsingEASUpdate,
validateAppVersionRuntimePolicySupportAsync,
} from '../project/projectUtils';
import {
ensureAppVersionSourceIsSetAsync,
validateAppConfigForRemoteVersionSource, validateAppConfigForRemoteVersionSource,
validateBuildProfileVersionSettingsAsync, validateBuildProfileVersionSettings,
} from '../project/remoteVersionSource'; } from '../project/remoteVersionSource';
import { confirmAsync } from '../prompts'; import { confirmAsync } from '../prompts';
import { getEasBuildRunCachedAppPath, runAsync } from '../run/run'; import { runAsync } from '../run/run';
import { isRunnableOnSimulatorOrEmulator } from '../run/utils'; import { isRunnableOnSimulatorOrEmulator } from '../run/utils';
import { createSubmissionContextAsync } from '../submit/context'; import { createSubmissionContextAsync } from '../submit/context';
import { import {
@@ -72,13 +49,23 @@ import {
waitToCompleteAsync as waitForSubmissionsToCompleteAsync, waitToCompleteAsync as waitForSubmissionsToCompleteAsync,
} from '../submit/submit'; } from '../submit/submit';
import { printSubmissionDetailsUrls } from '../submit/utils/urls'; import { printSubmissionDetailsUrls } from '../submit/utils/urls';
import { ensureEASUpdateIsConfiguredAsync } from '../update/configure'; import { validateBuildProfileConfigMatchesProjectConfigAsync } from '../update/utils';
import { Actor } from '../user/User'; import { Actor } from '../user/User';
import { downloadAndMaybeExtractAppAsync } from '../utils/download'; import { downloadAndMaybeExtractAppAsync } from '../utils/download';
import { truthy } from '../utils/expodash/filter'; import { truthy } from '../utils/expodash/filter';
import { printJsonOnlyOutput } from '../utils/json'; import { printJsonOnlyOutput } from '../utils/json';
import { ProfileData, getProfilesAsync } from '../utils/profiles'; import { ProfileData, getProfilesAsync } from '../utils/profiles';
import { Client } from '../vcs/vcs'; import { getVcsClient } from '../vcs';
import { prepareAndroidBuildAsync } from './android/build';
import { BuildRequestSender, MaybeBuildFragment, waitForBuildEndAsync } from './build';
import { ensureProjectConfiguredAsync } from './configure';
import { BuildContext } from './context';
import { createBuildContextAsync } from './createContext';
import { prepareIosBuildAsync } from './ios/build';
import { LocalBuildOptions } from './local';
import { ensureExpoDevClientInstalledForDevClientBuildsAsync } from './utils/devClient';
import { printBuildResults, printLogsUrls } from './utils/printBuildInfo';
import { ensureRepoIsCleanAsync } from './utils/repository';
let metroConfigValidated = false; let metroConfigValidated = false;
let sdkVersionChecked = false; let sdkVersionChecked = false;
@@ -95,41 +82,22 @@ export interface BuildFlags {
localBuildOptions: LocalBuildOptions; localBuildOptions: LocalBuildOptions;
resourceClass?: ResourceClass; resourceClass?: ResourceClass;
message?: string; message?: string;
buildLoggerLevel?: LoggerLevel;
freezeCredentials: boolean;
repack: boolean;
} }
export async function runBuildAndSubmitAsync({ export async function runBuildAndSubmitAsync(
graphqlClient, graphqlClient: ExpoGraphqlClient,
analytics, analytics: Analytics,
vcsClient, projectDir: string,
projectDir, flags: BuildFlags,
flags, actor: Actor,
actor, getDynamicProjectConfigAsync: DynamicConfigContextFn
getDynamicPrivateProjectConfigAsync, ): Promise<void> {
downloadSimBuildAutoConfirm, await getVcsClient().ensureRepoExistsAsync();
envOverride, await ensureRepoIsCleanAsync(flags.nonInteractive);
}: {
graphqlClient: ExpoGraphqlClient;
analytics: Analytics;
vcsClient: Client;
projectDir: string;
flags: BuildFlags;
actor: Actor;
getDynamicPrivateProjectConfigAsync: DynamicConfigContextFn;
downloadSimBuildAutoConfirm?: boolean;
envOverride?: Env;
}): Promise<{
buildIds: string[];
}> {
await vcsClient.ensureRepoExistsAsync();
await ensureRepoIsCleanAsync(vcsClient, flags.nonInteractive);
await ensureProjectConfiguredAsync({ await ensureProjectConfiguredAsync({
projectDir, projectDir,
nonInteractive: flags.nonInteractive, nonInteractive: flags.nonInteractive,
vcsClient,
}); });
const easJsonAccessor = EasJsonAccessor.fromProjectPath(projectDir); const easJsonAccessor = EasJsonAccessor.fromProjectPath(projectDir);
const easJsonCliConfig: EasJson['cli'] = const easJsonCliConfig: EasJson['cli'] =
@@ -141,59 +109,17 @@ export async function runBuildAndSubmitAsync({
easJsonAccessor, easJsonAccessor,
platforms, platforms,
profileName: flags.profile ?? undefined, profileName: flags.profile ?? undefined,
projectDir,
}); });
for (const buildProfile of buildProfiles) {
if (buildProfile.profile.image && ['default', 'stable'].includes(buildProfile.profile.image)) {
Log.warn(
`The "image" field in the build profile "${buildProfile.profileName}" is set to "${buildProfile.profile.image}". This tag is deprecated and will be removed in the future. Use other images or tags listed here: https://docs.expo.dev/build-reference/infrastructure/`
);
} else if (
buildProfile.profile.image &&
[
'ubuntu-20.04-jdk-11-ndk-r19c',
'ubuntu-20.04-jdk-8-ndk-r19c',
'ubuntu-20.04-jdk-11-ndk-r21e',
'ubuntu-20.04-jdk-8-ndk-r21e',
'ubuntu-22.04-jdk-8-ndk-r21e',
'ubuntu-20.04-jdk-11-ndk-r23b',
].includes(buildProfile.profile.image)
) {
Log.warn(
`The "image" field in the build profile "${buildProfile.profileName}" is set to "${
buildProfile.profile.image
}". This image is deprecated and will be removed on September 1st, 2024. ${learnMore(
'https://expo.dev/changelog/2024/07-12-eas-build-upcoming-android-images-updates'
)}`
);
}
}
await ensureExpoDevClientInstalledForDevClientBuildsAsync({ await ensureExpoDevClientInstalledForDevClientBuildsAsync({
projectDir, projectDir,
nonInteractive: flags.nonInteractive, nonInteractive: flags.nonInteractive,
buildProfiles, buildProfiles,
vcsClient,
}); });
const customBuildConfigMetadataByPlatform: { [p in AppPlatform]?: CustomBuildConfigMetadata } =
{};
for (const buildProfile of buildProfiles) { for (const buildProfile of buildProfiles) {
await validateBuildProfileVersionSettingsAsync( validateBuildProfileVersionSettings(buildProfile, easJsonCliConfig);
buildProfile, await validateCustomBuildConfigAsync(projectDir, buildProfile.profile);
easJsonCliConfig,
projectDir,
flags
);
const maybeMetadata = await validateCustomBuildConfigAsync({
projectDir,
profile: buildProfile.profile,
vcsClient,
});
if (maybeMetadata) {
customBuildConfigMetadataByPlatform[toAppPlatform(buildProfile.platform)] = maybeMetadata;
}
} }
const startedBuilds: { const startedBuilds: {
@@ -203,18 +129,6 @@ export async function runBuildAndSubmitAsync({
const buildCtxByPlatform: { [p in AppPlatform]?: BuildContext<Platform> } = {}; const buildCtxByPlatform: { [p in AppPlatform]?: BuildContext<Platform> } = {};
for (const buildProfile of buildProfiles) { for (const buildProfile of buildProfiles) {
const platform = toAppPlatform(buildProfile.platform);
const { env } = !envOverride
? await evaluateConfigWithEnvVarsAsync({
buildProfile: buildProfile.profile,
buildProfileName: buildProfile.profileName,
graphqlClient,
getProjectConfig: getDynamicPrivateProjectConfigAsync,
opts: { env: buildProfile.profile.env },
})
: { env: envOverride };
const { build: maybeBuild, buildCtx } = await prepareAndStartBuildAsync({ const { build: maybeBuild, buildCtx } = await prepareAndStartBuildAsync({
projectDir, projectDir,
flags, flags,
@@ -224,36 +138,21 @@ export async function runBuildAndSubmitAsync({
actor, actor,
graphqlClient, graphqlClient,
analytics, analytics,
vcsClient, getDynamicProjectConfigAsync,
getDynamicPrivateProjectConfigAsync,
customBuildConfigMetadata: customBuildConfigMetadataByPlatform[platform],
env,
}); });
if (maybeBuild) { if (maybeBuild) {
startedBuilds.push({ build: maybeBuild, buildProfile }); startedBuilds.push({ build: maybeBuild, buildProfile });
} }
buildCtxByPlatform[platform] = buildCtx; buildCtxByPlatform[toAppPlatform(buildProfile.platform)] = buildCtx;
} }
if (flags.localBuildOptions.localBuildMode === LocalBuildMode.LOCAL_BUILD_PLUGIN) { if (flags.localBuildOptions.localBuildMode) {
return { return;
buildIds: startedBuilds.map(({ build }) => build.id),
};
} }
if (flags.localBuildOptions.localBuildMode === LocalBuildMode.INTERNAL) { Log.newLine();
const startedBuild = await BuildQuery.byIdAsync( printLogsUrls(startedBuilds.map(startedBuild => startedBuild.build));
graphqlClient, Log.newLine();
nullthrows(process.env.EAS_BUILD_ID, 'EAS_BUILD_ID is not defined')
);
startedBuilds.push({ build: startedBuild, buildProfile: buildProfiles[0] });
}
if (!flags.localBuildOptions.localBuildMode) {
Log.newLine();
printLogsUrls(startedBuilds.map(startedBuild => startedBuild.build));
Log.newLine();
}
const submissions: SubmissionFragment[] = []; const submissions: SubmissionFragment[] = [];
if (flags.autoSubmit) { if (flags.autoSubmit) {
@@ -262,7 +161,6 @@ export async function runBuildAndSubmitAsync({
platforms, platforms,
profileName: flags.submitProfile, profileName: flags.submitProfile,
type: 'submit', type: 'submit',
projectDir,
}); });
for (const startedBuild of startedBuilds) { for (const startedBuild of startedBuilds) {
const submitProfile = nullthrows( const submitProfile = nullthrows(
@@ -275,9 +173,9 @@ export async function runBuildAndSubmitAsync({
buildCtx: nullthrows(buildCtxByPlatform[startedBuild.build.platform]), buildCtx: nullthrows(buildCtxByPlatform[startedBuild.build.platform]),
moreBuilds: startedBuilds.length > 1, moreBuilds: startedBuilds.length > 1,
projectDir, projectDir,
buildProfile: startedBuild.buildProfile.profile,
submitProfile, submitProfile,
nonInteractive: flags.nonInteractive, nonInteractive: flags.nonInteractive,
selectedSubmitProfileName: flags.submitProfile,
}); });
startedBuild.build = await BuildQuery.withSubmissionsByIdAsync( startedBuild.build = await BuildQuery.withSubmissionsByIdAsync(
graphqlClient, graphqlClient,
@@ -286,44 +184,34 @@ export async function runBuildAndSubmitAsync({
submissions.push(submission); submissions.push(submission);
} }
if (!flags.localBuildOptions.localBuildMode) { Log.newLine();
Log.newLine(); printSubmissionDetailsUrls(submissions);
printSubmissionDetailsUrls(submissions); Log.newLine();
Log.newLine();
}
}
if (flags.localBuildOptions.localBuildMode) {
return {
buildIds: startedBuilds.map(({ build }) => build.id),
};
} }
if (!flags.wait) { if (!flags.wait) {
if (flags.json) { if (flags.json) {
printJsonOnlyOutput(startedBuilds.map(buildInfo => buildInfo.build)); printJsonOnlyOutput(startedBuilds.map(buildInfo => buildInfo.build));
} }
return { return;
buildIds: startedBuilds.map(({ build }) => build.id),
};
} }
const { accountName } = Object.values(buildCtxByPlatform)[0]; const { accountName } = Object.values(buildCtxByPlatform)[0];
const builds = await waitForBuildEndAsync(graphqlClient, { const builds = await waitForBuildEndAsync(graphqlClient, {
buildIds: startedBuilds.map(({ build }) => build.id), buildIds: startedBuilds.map(({ build }) => build.id),
accountName, accountName,
projectDir,
nonInteractive: flags.nonInteractive,
}); });
if (!flags.json) { if (!flags.json) {
printBuildResults(builds); printBuildResults(builds);
} }
const haveAllBuildsFailedOrCanceled = builds.every( const haveAllBuildsFailedOrCanceled = builds.every(
build => build => build?.status && [BuildStatus.Errored, BuildStatus.Canceled].includes(build?.status)
build?.status &&
[BuildStatus.Errored, BuildStatus.Canceled, BuildStatus.PendingCancel].includes(build?.status)
); );
await maybeDownloadAndRunSimulatorBuildsAsync(builds, flags, downloadSimBuildAutoConfirm); await maybeDownloadAndRunSimulatorBuildsAsync(builds, flags);
if (haveAllBuildsFailedOrCanceled || !flags.autoSubmit) { if (haveAllBuildsFailedOrCanceled || !flags.autoSubmit) {
if (flags.json) { if (flags.json) {
@@ -346,9 +234,6 @@ export async function runBuildAndSubmitAsync({
} }
exitWithNonZeroCodeIfSomeSubmissionsDidntFinish(completedSubmissions); exitWithNonZeroCodeIfSomeSubmissionsDidntFinish(completedSubmissions);
} }
return {
buildIds: startedBuilds.map(({ build }) => build.id),
};
} }
async function prepareAndStartBuildAsync({ async function prepareAndStartBuildAsync({
@@ -360,10 +245,7 @@ async function prepareAndStartBuildAsync({
actor, actor,
graphqlClient, graphqlClient,
analytics, analytics,
vcsClient, getDynamicProjectConfigAsync,
getDynamicPrivateProjectConfigAsync,
customBuildConfigMetadata,
env,
}: { }: {
projectDir: string; projectDir: string;
flags: BuildFlags; flags: BuildFlags;
@@ -373,10 +255,7 @@ async function prepareAndStartBuildAsync({
actor: Actor; actor: Actor;
graphqlClient: ExpoGraphqlClient; graphqlClient: ExpoGraphqlClient;
analytics: Analytics; analytics: Analytics;
vcsClient: Client; getDynamicProjectConfigAsync: DynamicConfigContextFn;
getDynamicPrivateProjectConfigAsync: DynamicConfigContextFn;
customBuildConfigMetadata?: CustomBuildConfigMetadata;
env: Env;
}): Promise<{ build: BuildFragment | undefined; buildCtx: BuildContext<Platform> }> { }): Promise<{ build: BuildFragment | undefined; buildCtx: BuildContext<Platform> }> {
const buildCtx = await createBuildContextAsync({ const buildCtx = await createBuildContextAsync({
buildProfileName: buildProfile.profileName, buildProfileName: buildProfile.profileName,
@@ -393,13 +272,7 @@ async function prepareAndStartBuildAsync({
actor, actor,
graphqlClient, graphqlClient,
analytics, analytics,
vcsClient, getDynamicProjectConfigAsync,
getDynamicPrivateProjectConfigAsync,
customBuildConfigMetadata,
buildLoggerLevel: flags.buildLoggerLevel ?? (Log.isDebug ? LoggerLevel.DEBUG : undefined),
freezeCredentials: flags.freezeCredentials,
repack: flags.repack,
env,
}); });
if (moreBuilds) { if (moreBuilds) {
@@ -412,51 +285,14 @@ async function prepareAndStartBuildAsync({
); );
} }
if (buildProfile.profile.channel) { await validateBuildProfileConfigMatchesProjectConfigAsync(
await validateExpoUpdatesInstalledAsProjectDependencyAsync({ buildCtx.exp,
exp: buildCtx.exp, buildProfile,
projectId: buildCtx.projectId, buildCtx.projectId,
projectDir, flags.nonInteractive
vcsClient: buildCtx.vcsClient, );
sdkVersion: buildCtx.exp.sdkVersion,
nonInteractive: flags.nonInteractive,
buildProfile,
env: buildProfile.profile.env,
});
if (isUsingEASUpdate(buildCtx.exp, buildCtx.projectId)) {
const doesChannelExist = await doesChannelExistAsync(graphqlClient, {
appId: buildCtx.projectId,
channelName: buildProfile.profile.channel,
});
if (!doesChannelExist) {
await createAndLinkChannelAsync(graphqlClient, {
appId: buildCtx.projectId,
channelName: buildProfile.profile.channel,
});
}
}
}
await validateAppVersionRuntimePolicySupportAsync(buildCtx.projectDir, buildCtx.exp); await validateAppVersionRuntimePolicySupportAsync(buildCtx.projectDir, buildCtx.exp);
if (
easJsonCliConfig?.appVersionSource === undefined &&
buildProfile.profile.autoIncrement !== 'version'
) {
if (buildProfile.profile.autoIncrement !== true) {
Log.warn(
`The field "cli.appVersionSource" is not set, but it will be required in the future. ${learnMore(
'https://docs.expo.dev/build-reference/app-versions/'
)}`
);
} else {
const easJsonAccessor = EasJsonAccessor.fromProjectPath(projectDir);
easJsonCliConfig = await ensureAppVersionSourceIsSetAsync(
easJsonAccessor,
easJsonCliConfig,
flags.nonInteractive
);
}
}
if (easJsonCliConfig?.appVersionSource === AppVersionSource.REMOTE) { if (easJsonCliConfig?.appVersionSource === AppVersionSource.REMOTE) {
validateAppConfigForRemoteVersionSource(buildCtx.exp, buildProfile.platform); validateAppConfigForRemoteVersionSource(buildCtx.exp, buildProfile.platform);
} }
@@ -493,16 +329,16 @@ async function prepareAndStartSubmissionAsync({
buildCtx, buildCtx,
moreBuilds, moreBuilds,
projectDir, projectDir,
buildProfile,
submitProfile, submitProfile,
selectedSubmitProfileName,
nonInteractive, nonInteractive,
}: { }: {
build: BuildFragment; build: BuildFragment;
buildCtx: BuildContext<Platform>; buildCtx: BuildContext<Platform>;
moreBuilds: boolean; moreBuilds: boolean;
projectDir: string; projectDir: string;
buildProfile: BuildProfile;
submitProfile: SubmitProfile; submitProfile: SubmitProfile;
selectedSubmitProfileName?: string;
nonInteractive: boolean; nonInteractive: boolean;
}): Promise<SubmissionFragment> { }): Promise<SubmissionFragment> {
const platform = toPlatform(build.platform); const platform = toPlatform(build.platform);
@@ -512,7 +348,7 @@ async function prepareAndStartSubmissionAsync({
profile: submitProfile, profile: submitProfile,
archiveFlags: { id: build.id }, archiveFlags: { id: build.id },
nonInteractive, nonInteractive,
env: buildCtx.env, env: buildProfile.env,
credentialsCtx: buildCtx.credentialsCtx, credentialsCtx: buildCtx.credentialsCtx,
applicationIdentifier: buildCtx.android?.applicationId ?? buildCtx.ios?.bundleIdentifier, applicationIdentifier: buildCtx.android?.applicationId ?? buildCtx.ios?.bundleIdentifier,
actor: buildCtx.user, actor: buildCtx.user,
@@ -520,9 +356,6 @@ async function prepareAndStartSubmissionAsync({
analytics: buildCtx.analytics, analytics: buildCtx.analytics,
projectId: buildCtx.projectId, projectId: buildCtx.projectId,
exp: buildCtx.exp, exp: buildCtx.exp,
vcsClient: buildCtx.vcsClient,
isVerboseFastlaneEnabled: false,
specifiedProfile: selectedSubmitProfileName,
}); });
if (moreBuilds) { if (moreBuilds) {
@@ -546,29 +379,18 @@ function exitWithNonZeroCodeIfSomeBuildsFailed(maybeBuilds: (BuildFragment | nul
} }
} }
export async function downloadAndRunAsync(build: BuildFragment): Promise<void> { async function downloadAndRunAsync(build: BuildFragment): Promise<void> {
assert(build.artifacts?.applicationArchiveUrl); assert(build.artifacts?.applicationArchiveUrl);
const cachedAppPath = getEasBuildRunCachedAppPath(build.project.id, build.id, build.platform);
if (await pathExists(cachedAppPath)) {
Log.newLine();
Log.log(`Using cached app...`);
await runAsync(cachedAppPath, build.platform);
return;
}
const buildPath = await downloadAndMaybeExtractAppAsync( const buildPath = await downloadAndMaybeExtractAppAsync(
build.artifacts.applicationArchiveUrl, build.artifacts.applicationArchiveUrl,
build.platform, build.platform
cachedAppPath
); );
await runAsync(buildPath, build.platform); await runAsync(buildPath, build.platform);
} }
async function maybeDownloadAndRunSimulatorBuildsAsync( async function maybeDownloadAndRunSimulatorBuildsAsync(
builds: MaybeBuildFragment[], builds: MaybeBuildFragment[],
flags: BuildFlags, flags: BuildFlags
autoConfirm?: boolean
): Promise<void> { ): Promise<void> {
const simBuilds = builds.filter(truthy).filter(isRunnableOnSimulatorOrEmulator); const simBuilds = builds.filter(truthy).filter(isRunnableOnSimulatorOrEmulator);
@@ -576,15 +398,11 @@ async function maybeDownloadAndRunSimulatorBuildsAsync(
for (const simBuild of simBuilds) { for (const simBuild of simBuilds) {
if (simBuild.platform === AppPlatform.Android || process.platform === 'darwin') { if (simBuild.platform === AppPlatform.Android || process.platform === 'darwin') {
Log.newLine(); Log.newLine();
const confirm = const confirm = await confirmAsync({
autoConfirm ?? message: `Install and run the ${
(await confirmAsync({ simBuild.platform === AppPlatform.Android ? 'Android' : 'iOS'
message: `Install and run the ${ } build on ${simBuild.platform === AppPlatform.Android ? 'an emulator' : 'a simulator'}?`,
simBuild.platform === AppPlatform.Android ? 'Android' : 'iOS' });
} build on ${
simBuild.platform === AppPlatform.Android ? 'an emulator' : 'a simulator'
}?`,
}));
if (confirm) { if (confirm) {
await downloadAndRunAsync(simBuild); await downloadAndRunAsync(simBuild);
} }
@@ -592,56 +410,3 @@ async function maybeDownloadAndRunSimulatorBuildsAsync(
} }
} }
} }
async function validateExpoUpdatesInstalledAsProjectDependencyAsync({
exp,
projectId,
projectDir,
vcsClient,
buildProfile,
nonInteractive,
sdkVersion,
env,
}: {
exp: ExpoConfig;
projectId: string;
projectDir: string;
vcsClient: Client;
buildProfile: ProfileData<'build'>;
nonInteractive: boolean;
sdkVersion?: string;
env: Env | undefined;
}): Promise<void> {
if (isExpoUpdatesInstalledOrAvailable(projectDir, sdkVersion)) {
return;
}
if (isExpoUpdatesInstalledAsDevDependency(projectDir)) {
Log.warn(
`The build profile "${buildProfile.profileName}" uses the channel "${buildProfile.profile.channel}", but you've added "expo-updates" as a dev dependency. To make channels work for your builds, move "expo-updates" from dev dependencies to the main dependencies in your project.`
);
} else if (nonInteractive) {
Log.warn(
`The build profile "${buildProfile.profileName}" has specified the channel "${buildProfile.profile.channel}", but the "expo-updates" package hasn't been installed. To use channels for your builds, install the "expo-updates" package by running "npx expo install expo-updates" followed by "eas update:configure".`
);
} else {
Log.warn(
`The build profile "${buildProfile.profileName}" specifies the channel "${buildProfile.profile.channel}", but the "expo-updates" package is missing. To use channels in your builds, install the "expo-updates" package and run "eas update:configure".`
);
const installExpoUpdates = await confirmAsync({
message: `Would you like to install the "expo-updates" package and configure EAS Update now?`,
});
if (installExpoUpdates) {
await ensureEASUpdateIsConfiguredAsync({
exp,
projectId,
projectDir,
platform: RequestedPlatform.All,
vcsClient,
env,
});
Log.withTick('Installed expo-updates and configured EAS Update.');
throw new Error('Command must be re-run to pick up new updates configuration.');
}
}
}
-25
View File
@@ -1,14 +1,7 @@
import { ResourceClass } from '@expo/eas-json';
import { LoggerLevel } from '@expo/logger';
import { LocalBuildOptions } from './local';
import { RequestedPlatform } from '../platform';
export enum BuildStatus { export enum BuildStatus {
NEW = 'new', NEW = 'new',
IN_QUEUE = 'in-queue', IN_QUEUE = 'in-queue',
IN_PROGRESS = 'in-progress', IN_PROGRESS = 'in-progress',
PENDING_CANCEL = 'pending-cancel',
ERRORED = 'errored', ERRORED = 'errored',
FINISHED = 'finished', FINISHED = 'finished',
CANCELED = 'canceled', CANCELED = 'canceled',
@@ -17,23 +10,5 @@ export enum BuildStatus {
export enum BuildDistributionType { export enum BuildDistributionType {
STORE = 'store', STORE = 'store',
INTERNAL = 'internal', INTERNAL = 'internal',
/** @deprecated Use simulator flag instead */
SIMULATOR = 'simulator', SIMULATOR = 'simulator',
} }
export interface BuildFlags {
requestedPlatform: RequestedPlatform;
profile?: string;
nonInteractive: boolean;
wait: boolean;
clearCache: boolean;
json: boolean;
autoSubmit: boolean;
submitProfile?: string;
localBuildOptions: LocalBuildOptions;
resourceClass?: ResourceClass;
message?: string;
buildLoggerLevel?: LoggerLevel;
freezeCredentials: boolean;
repack: boolean;
}
+6 -22
View File
@@ -3,7 +3,6 @@ import { Errors } from '@oclif/core';
import chalk from 'chalk'; import chalk from 'chalk';
import resolveFrom from 'resolve-from'; import resolveFrom from 'resolve-from';
import { reviewAndCommitChangesAsync } from './repository';
import { toAppPlatform } from '../../graphql/types/AppPlatform'; import { toAppPlatform } from '../../graphql/types/AppPlatform';
import Log, { learnMore } from '../../log'; import Log, { learnMore } from '../../log';
import { appPlatformDisplayNames } from '../../platform'; import { appPlatformDisplayNames } from '../../platform';
@@ -11,16 +10,15 @@ import { resolveWorkflowAsync } from '../../project/workflow';
import { confirmAsync } from '../../prompts'; import { confirmAsync } from '../../prompts';
import { expoCommandAsync } from '../../utils/expoCli'; import { expoCommandAsync } from '../../utils/expoCli';
import { ProfileData } from '../../utils/profiles'; import { ProfileData } from '../../utils/profiles';
import { Client } from '../../vcs/vcs'; import { getVcsClient } from '../../vcs';
import { reviewAndCommitChangesAsync } from './repository';
export async function ensureExpoDevClientInstalledForDevClientBuildsAsync({ export async function ensureExpoDevClientInstalledForDevClientBuildsAsync({
projectDir, projectDir,
vcsClient,
nonInteractive = false, nonInteractive = false,
buildProfiles = [], buildProfiles = [],
}: { }: {
projectDir: string; projectDir: string;
vcsClient: Client;
nonInteractive?: boolean; nonInteractive?: boolean;
buildProfiles?: ProfileData<'build'>[]; buildProfiles?: ProfileData<'build'>[];
}): Promise<void> { }): Promise<void> {
@@ -43,7 +41,7 @@ export async function ensureExpoDevClientInstalledForDevClientBuildsAsync({
); );
const workflowPerPlatformList = await Promise.all( const workflowPerPlatformList = await Promise.all(
platformsToCheck.map(platform => resolveWorkflowAsync(projectDir, platform, vcsClient)) platformsToCheck.map(platform => resolveWorkflowAsync(projectDir, platform))
); );
Log.newLine(); Log.newLine();
@@ -57,19 +55,6 @@ export async function ensureExpoDevClientInstalledForDevClientBuildsAsync({
'expo-dev-client' 'expo-dev-client'
)} installed for your project.` )} installed for your project.`
); );
if (nonInteractive) {
Log.error(`You'll need to install ${chalk.bold('expo-dev-client')} manually.`);
Log.error(
learnMore('https://docs.expo.dev/clients/installation/', {
learnMoreMessage: 'See installation instructions on how to do it.',
dim: false,
})
);
Errors.error(`Install ${chalk.bold('expo-dev-client')} manually and try again later.`, {
exit: 1,
});
}
const areAllManaged = workflowPerPlatformList.every(i => i === Workflow.MANAGED); const areAllManaged = workflowPerPlatformList.every(i => i === Workflow.MANAGED);
if (areAllManaged) { if (areAllManaged) {
const install = await confirmAsync({ const install = await confirmAsync({
@@ -77,7 +62,7 @@ export async function ensureExpoDevClientInstalledForDevClientBuildsAsync({
instructions: 'The command will abort unless you agree.', instructions: 'The command will abort unless you agree.',
}); });
if (install) { if (install) {
await installExpoDevClientAsync(projectDir, vcsClient, { nonInteractive }); await installExpoDevClientAsync(projectDir, { nonInteractive });
} else { } else {
Errors.error(`Install ${chalk.bold('expo-dev-client')} manually and come back later.`, { Errors.error(`Install ${chalk.bold('expo-dev-client')} manually and come back later.`, {
exit: 1, exit: 1,
@@ -115,7 +100,6 @@ async function isExpoDevClientInstalledAsync(projectDir: string): Promise<boolea
async function installExpoDevClientAsync( async function installExpoDevClientAsync(
projectDir: string, projectDir: string,
vcsClient: Client,
{ nonInteractive }: { nonInteractive: boolean } { nonInteractive }: { nonInteractive: boolean }
): Promise<void> { ): Promise<void> {
Log.newLine(); Log.newLine();
@@ -123,8 +107,8 @@ async function installExpoDevClientAsync(
Log.newLine(); Log.newLine();
await expoCommandAsync(projectDir, ['install', 'expo-dev-client']); await expoCommandAsync(projectDir, ['install', 'expo-dev-client']);
Log.newLine(); Log.newLine();
if (await vcsClient.isCommitRequiredAsync()) { if (await getVcsClient().isCommitRequiredAsync()) {
await reviewAndCommitChangesAsync(vcsClient, 'Install expo-dev-client', { await reviewAndCommitChangesAsync('Install expo-dev-client', {
nonInteractive, nonInteractive,
}); });
} }
@@ -1,26 +0,0 @@
import { BuildProfile } from '@expo/eas-json';
import { EnvironmentVariableEnvironment } from '../../graphql/generated';
type Environment = NonNullable<BuildProfile['environment']>;
const BuildProfileEnvironmentToEnvironment: Record<Environment, EnvironmentVariableEnvironment> = {
production: EnvironmentVariableEnvironment.Production,
preview: EnvironmentVariableEnvironment.Preview,
development: EnvironmentVariableEnvironment.Development,
};
export function isEnvironment(env: string): env is EnvironmentVariableEnvironment {
return Object.values(EnvironmentVariableEnvironment).includes(
env as EnvironmentVariableEnvironment
);
}
export function buildProfileEnvironmentToEnvironment(
environment: BuildProfile['environment']
): EnvironmentVariableEnvironment | null {
if (!environment) {
return null;
}
return BuildProfileEnvironmentToEnvironment[environment];
}
@@ -1,6 +1,5 @@
import chalk from 'chalk'; import chalk from 'chalk';
import { getBuildLogsUrl } from './url';
import { import {
AppPlatform, AppPlatform,
BuildFragment, BuildFragment,
@@ -8,6 +7,7 @@ import {
} from '../../graphql/generated'; } from '../../graphql/generated';
import { appPlatformDisplayNames } from '../../platform'; import { appPlatformDisplayNames } from '../../platform';
import formatFields from '../../utils/formatFields'; import formatFields from '../../utils/formatFields';
import { getBuildLogsUrl } from './url';
export function formatGraphQLBuild(build: BuildFragment): string { export function formatGraphQLBuild(build: BuildFragment): string {
const actor = getActorName(build); const actor = getActorName(build);
@@ -27,7 +27,6 @@ export function formatGraphQLBuild(build: BuildFragment): string {
return chalk.blue('in queue'); return chalk.blue('in queue');
case GraphQLBuildStatus.InProgress: case GraphQLBuildStatus.InProgress:
return chalk.blue('in progress'); return chalk.blue('in progress');
case GraphQLBuildStatus.PendingCancel:
case GraphQLBuildStatus.Canceled: case GraphQLBuildStatus.Canceled:
return chalk.gray('canceled'); return chalk.gray('canceled');
case GraphQLBuildStatus.Finished: case GraphQLBuildStatus.Finished:
@@ -39,10 +38,6 @@ export function formatGraphQLBuild(build: BuildFragment): string {
} }
}, },
}, },
{
label: 'Profile',
value: build.buildProfile,
},
{ {
label: 'Message', label: 'Message',
value: build.message, value: build.message,
@@ -55,6 +50,10 @@ export function formatGraphQLBuild(build: BuildFragment): string {
label: 'Enterprise Provisioning', label: 'Enterprise Provisioning',
value: build.iosEnterpriseProvisioning?.toLowerCase(), value: build.iosEnterpriseProvisioning?.toLowerCase(),
}, },
{
label: 'Release Channel',
value: build.releaseChannel,
},
{ {
label: 'Channel', label: 'Channel',
value: build.channel, value: build.channel,
@@ -91,7 +90,6 @@ export function formatGraphQLBuild(build: BuildFragment): string {
case GraphQLBuildStatus.InQueue: case GraphQLBuildStatus.InQueue:
case GraphQLBuildStatus.InProgress: case GraphQLBuildStatus.InProgress:
return '<in progress>'; return '<in progress>';
case GraphQLBuildStatus.PendingCancel:
case GraphQLBuildStatus.Canceled: case GraphQLBuildStatus.Canceled:
case GraphQLBuildStatus.Errored: case GraphQLBuildStatus.Errored:
return null; return null;
@@ -2,7 +2,6 @@ import assert from 'assert';
import indentString from 'indent-string'; import indentString from 'indent-string';
import qrcodeTerminal from 'qrcode-terminal'; import qrcodeTerminal from 'qrcode-terminal';
import { getBuildLogsUrl, getInternalDistributionInstallUrl } from './url';
import { import {
AppPlatform, AppPlatform,
BuildError, BuildError,
@@ -14,6 +13,7 @@ import {
} from '../../graphql/generated'; } from '../../graphql/generated';
import Log, { learnMore, link } from '../../log'; import Log, { learnMore, link } from '../../log';
import { appPlatformDisplayNames, appPlatformEmojis } from '../../platform'; import { appPlatformDisplayNames, appPlatformEmojis } from '../../platform';
import { getBuildLogsUrl, getInternalDistributionInstallUrl } from './url';
function terminalLinkFallback(url: string, text: string): string { function terminalLinkFallback(url: string, text: string): string {
return `${text} (${url})`; return `${text} (${url})`;
@@ -62,9 +62,7 @@ export function printBuildResults(builds: (BuildFragment | null)[]): void {
assert(build, 'Build should be defined'); assert(build, 'Build should be defined');
printBuildResult(build); printBuildResult(build);
} else { } else {
(builds.filter(i => i) as BuildFragment[]).forEach(build => { (builds.filter(i => i) as BuildFragment[]).forEach(build => printBuildResult(build));
printBuildResult(build);
});
} }
} }
@@ -82,7 +80,7 @@ function printBuildResult(build: BuildFragment): void {
} }
return; return;
} }
if ([BuildStatus.Canceled, BuildStatus.PendingCancel].includes(build.status)) { if (build.status === BuildStatus.Canceled) {
Log.addNewLineIfNone(); Log.addNewLineIfNone();
Log.error( Log.error(
`${appPlatformEmojis[build.platform]} ${ `${appPlatformEmojis[build.platform]} ${
@@ -99,9 +97,9 @@ function printBuildResult(build: BuildFragment): void {
// to the build details page and let people press the button to download there // to the build details page and let people press the button to download there
const qrcodeUrl = const qrcodeUrl =
build.platform === AppPlatform.Ios ? getInternalDistributionInstallUrl(build) : logsUrl; build.platform === AppPlatform.Ios ? getInternalDistributionInstallUrl(build) : logsUrl;
qrcodeTerminal.generate(qrcodeUrl, { small: true }, code => { qrcodeTerminal.generate(qrcodeUrl, { small: true }, code =>
Log.log(`${indentString(code, 2)}\n`); Log.log(`${indentString(code, 2)}\n`)
}); );
Log.log( Log.log(
`${appPlatformEmojis[build.platform]} Open this link on your ${ `${appPlatformEmojis[build.platform]} Open this link on your ${
appPlatformDisplayNames[build.platform] appPlatformDisplayNames[build.platform]
+21 -93
View File
@@ -4,19 +4,16 @@ import path from 'path';
import tar from 'tar'; import tar from 'tar';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
import Log, { learnMore } from '../../log'; import Log from '../../log';
import { ora } from '../../ora'; import { ora } from '../../ora';
import { confirmAsync, promptAsync } from '../../prompts'; import { confirmAsync, promptAsync } from '../../prompts';
import { formatBytes } from '../../utils/files'; import { formatBytes } from '../../utils/files';
import { getTmpDirectory } from '../../utils/paths'; import { getTmpDirectory } from '../../utils/paths';
import { endTimer, formatMilliseconds, startTimer } from '../../utils/timer'; import { endTimer, formatMilliseconds, startTimer } from '../../utils/timer';
import { Client } from '../../vcs/vcs'; import { getVcsClient } from '../../vcs';
export async function maybeBailOnRepoStatusAsync( export async function maybeBailOnRepoStatusAsync(): Promise<void> {
vcsClient: Client, if (!(await getVcsClient().isCommitRequiredAsync())) {
nonInteractive: boolean
): Promise<void> {
if (!(await vcsClient.isCommitRequiredAsync())) {
return; return;
} }
Log.addNewLineIfNone(); Log.addNewLineIfNone();
@@ -31,20 +28,12 @@ export async function maybeBailOnRepoStatusAsync(
}); });
if (!answer) { if (!answer) {
if (nonInteractive) {
Log.log('The following files need to be committed:');
await vcsClient.showChangedFilesAsync();
}
throw new Error('Commit all changes. Aborting...'); throw new Error('Commit all changes. Aborting...');
} }
} }
export async function ensureRepoIsCleanAsync( export async function ensureRepoIsCleanAsync(nonInteractive = false): Promise<void> {
vcsClient: Client, if (!(await getVcsClient().isCommitRequiredAsync())) {
nonInteractive = false
): Promise<void> {
if (!(await vcsClient.isCommitRequiredAsync())) {
return; return;
} }
Log.addNewLineIfNone(); Log.addNewLineIfNone();
@@ -55,31 +44,25 @@ export async function ensureRepoIsCleanAsync(
)}.` )}.`
); );
if (nonInteractive) { if (nonInteractive) {
Log.log('The following files need to be committed:');
await vcsClient.showChangedFilesAsync();
throw new Error('Commit all changes. Aborting...'); throw new Error('Commit all changes. Aborting...');
} }
const answer = await confirmAsync({ const answer = await confirmAsync({
message: `Commit changes to git?`, message: `Commit changes to git?`,
}); });
if (answer) { if (answer) {
await commitPromptAsync(vcsClient, { commitAllFiles: true }); await commitPromptAsync({ commitAllFiles: true });
} else { } else {
throw new Error('Commit all changes. Aborting...'); throw new Error('Commit all changes. Aborting...');
} }
} }
export async function commitPromptAsync( export async function commitPromptAsync({
vcsClient: Client, initialCommitMessage,
{ commitAllFiles,
initialCommitMessage, }: {
commitAllFiles, initialCommitMessage?: string;
}: { commitAllFiles?: boolean;
initialCommitMessage?: string; } = {}): Promise<void> {
commitAllFiles?: boolean;
} = {}
): Promise<void> {
const { message } = await promptAsync({ const { message } = await promptAsync({
type: 'text', type: 'text',
name: 'message', name: 'message',
@@ -87,40 +70,14 @@ export async function commitPromptAsync(
initial: initialCommitMessage, initial: initialCommitMessage,
validate: (input: string) => input !== '', validate: (input: string) => input !== '',
}); });
await vcsClient.commitAsync({ await getVcsClient().commitAsync({
commitAllFiles, commitAllFiles,
commitMessage: message, commitMessage: message,
nonInteractive: false, nonInteractive: false,
}); });
} }
export type LocalFile = { export async function makeProjectTarballAsync(): Promise<{ path: string; size: number }> {
path: string;
size: number;
};
export async function makeProjectMetadataFileAsync(archivePath: string): Promise<LocalFile> {
await fs.mkdirp(getTmpDirectory());
const metadataLocation = path.join(getTmpDirectory(), `${uuidv4()}-eas-build-metadata.json`);
const archiveContent: string[] = [];
await tar.list({
file: archivePath,
onentry: (entry: tar.ReadEntry) => {
if (entry.type === 'File' && !entry.path.includes('.git/')) {
archiveContent.push(entry.path);
}
},
});
await fs.writeJSON(metadataLocation, {
archiveContent,
});
return { path: metadataLocation, size: await fs.stat(metadataLocation).then(stat => stat.size) };
}
export async function makeProjectTarballAsync(vcsClient: Client): Promise<LocalFile> {
const spinner = ora('Compressing project files'); const spinner = ora('Compressing project files');
await fs.mkdirp(getTmpDirectory()); await fs.mkdirp(getTmpDirectory());
@@ -142,7 +99,7 @@ export async function makeProjectTarballAsync(vcsClient: Client): Promise<LocalF
startTimer(compressTimerLabel); startTimer(compressTimerLabel);
try { try {
await vcsClient.makeShallowCopyAsync(shallowClonePath); await getVcsClient().makeShallowCopyAsync(shallowClonePath);
await tar.create({ cwd: shallowClonePath, file: tarPath, prefix: 'project', gzip: true }, [ await tar.create({ cwd: shallowClonePath, file: tarPath, prefix: 'project', gzip: true }, [
'.', '.',
]); ]);
@@ -169,34 +126,6 @@ export async function makeProjectTarballAsync(vcsClient: Client): Promise<LocalF
return { size, path: tarPath }; return { size, path: tarPath };
} }
export function maybeWarnAboutProjectTarballSize(size: number): void {
if (size <= 150 /* MiB */ * 1024 /* KiB */ * 1024 /* B */) {
return;
}
Log.warn(
`Your project archive is ${formatBytes(
size
)}. You can reduce its size and the time it takes to upload by excluding files that are unnecessary for the build process in ${chalk.bold(
'.easignore'
)} file. ${learnMore('https://expo.fyi/eas-build-archive')}`
);
}
const MAX_ALLOWED_PROJECT_TARBALL_SIZE =
2 /* GiB */ * 1024 /* MiB */ * 1024 /* KiB */ * 1024; /* B */
export function assertProjectTarballSizeDoesNotExceedLimit(size: number): void {
if (size <= MAX_ALLOWED_PROJECT_TARBALL_SIZE) {
return;
}
throw new Error(
`Project archive is too big. Maximum allowed size is ${formatBytes(
MAX_ALLOWED_PROJECT_TARBALL_SIZE
)}.`
);
}
enum ShouldCommitChanges { enum ShouldCommitChanges {
Yes, Yes,
ShowDiffFirst, ShowDiffFirst,
@@ -204,12 +133,11 @@ enum ShouldCommitChanges {
} }
export async function reviewAndCommitChangesAsync( export async function reviewAndCommitChangesAsync(
vcsClient: Client,
initialCommitMessage: string, initialCommitMessage: string,
{ nonInteractive, askedFirstTime = true }: { nonInteractive: boolean; askedFirstTime?: boolean } { nonInteractive, askedFirstTime = true }: { nonInteractive: boolean; askedFirstTime?: boolean }
): Promise<void> { ): Promise<void> {
if (process.env.EAS_BUILD_AUTOCOMMIT) { if (process.env.EAS_BUILD_AUTOCOMMIT) {
await vcsClient.commitAsync({ await getVcsClient().commitAsync({
commitMessage: initialCommitMessage, commitMessage: initialCommitMessage,
commitAllFiles: false, commitAllFiles: false,
nonInteractive, nonInteractive,
@@ -243,11 +171,11 @@ export async function reviewAndCommitChangesAsync(
"Aborting, run the command again once you're ready. Make sure to commit any changes you've made." "Aborting, run the command again once you're ready. Make sure to commit any changes you've made."
); );
} else if (selected === ShouldCommitChanges.Yes) { } else if (selected === ShouldCommitChanges.Yes) {
await commitPromptAsync(vcsClient, { initialCommitMessage }); await commitPromptAsync({ initialCommitMessage });
Log.withTick('Committed changes.'); Log.withTick('Committed changes.');
} else if (selected === ShouldCommitChanges.ShowDiffFirst) { } else if (selected === ShouldCommitChanges.ShowDiffFirst) {
await vcsClient.showDiffAsync(); await getVcsClient().showDiffAsync();
await reviewAndCommitChangesAsync(vcsClient, initialCommitMessage, { await reviewAndCommitChangesAsync(initialCommitMessage, {
nonInteractive, nonInteractive,
askedFirstTime: false, askedFirstTime: false,
}); });
@@ -1,19 +1,30 @@
import { ExpoConfig } from '@expo/config-types';
import { Platform } from '@expo/eas-build-job'; import { Platform } from '@expo/eas-build-job';
import { BuildProfile, ResourceClass } from '@expo/eas-json'; import { BuildProfile, ResourceClass } from '@expo/eas-json';
import chalk from 'chalk'; import chalk from 'chalk';
import semver from 'semver';
import { BuildResourceClass } from '../../graphql/generated'; import { BuildResourceClass } from '../../graphql/generated';
import Log, { link } from '../../log'; import Log from '../../log';
import { getReactNativeVersionAsync } from '../metadata';
type AndroidResourceClass = Exclude< type AndroidResourceClass = Exclude<
ResourceClass, ResourceClass,
ResourceClass.M1_MEDIUM | ResourceClass.M_MEDIUM | ResourceClass.M_LARGE | ResourceClass.M1_EXPERIMENTAL
| ResourceClass.M1_MEDIUM
| ResourceClass.M1_LARGE
| ResourceClass.INTEL_MEDIUM
| ResourceClass.M_MEDIUM
| ResourceClass.M_LARGE
>; >;
const iosResourceClassToBuildResourceClassMapping: Record<ResourceClass, BuildResourceClass> = { const iosResourceClassToBuildResourceClassMapping: Record<ResourceClass, BuildResourceClass> = {
[ResourceClass.DEFAULT]: BuildResourceClass.IosDefault, [ResourceClass.DEFAULT]: BuildResourceClass.IosDefault,
[ResourceClass.LARGE]: BuildResourceClass.IosLarge, [ResourceClass.LARGE]: BuildResourceClass.IosLarge,
[ResourceClass.M1_EXPERIMENTAL]: BuildResourceClass.IosMMedium,
[ResourceClass.M1_MEDIUM]: BuildResourceClass.IosMMedium, [ResourceClass.M1_MEDIUM]: BuildResourceClass.IosMMedium,
[ResourceClass.M1_LARGE]: BuildResourceClass.IosMLarge,
[ResourceClass.INTEL_MEDIUM]: BuildResourceClass.IosIntelMedium,
[ResourceClass.MEDIUM]: BuildResourceClass.IosMedium, [ResourceClass.MEDIUM]: BuildResourceClass.IosMedium,
[ResourceClass.M_MEDIUM]: BuildResourceClass.IosMMedium, [ResourceClass.M_MEDIUM]: BuildResourceClass.IosMMedium,
[ResourceClass.M_LARGE]: BuildResourceClass.IosMLarge, [ResourceClass.M_LARGE]: BuildResourceClass.IosMLarge,
@@ -31,25 +42,35 @@ const androidResourceClassToBuildResourceClassMapping: Record<
export async function resolveBuildResourceClassAsync<T extends Platform>( export async function resolveBuildResourceClassAsync<T extends Platform>(
profile: BuildProfile<T>, profile: BuildProfile<T>,
platform: Platform, platform: Platform,
projectDir: string,
exp: ExpoConfig,
resourceClassFlag?: ResourceClass resourceClassFlag?: ResourceClass
): Promise<BuildResourceClass> { ): Promise<BuildResourceClass> {
const profileResourceClass = profile.resourceClass; const profileResourceClass = profile.resourceClass;
if (profileResourceClass && resourceClassFlag && resourceClassFlag !== profileResourceClass) { if (profileResourceClass && resourceClassFlag && resourceClassFlag !== profileResourceClass) {
Log.warn( Log.warn(
`Build profile specifies the "${profileResourceClass}" resource class but you passed "${resourceClassFlag}" to --resource-class.\nUsing "${resourceClassFlag}" as the override.` `Build profile specifies the "${profileResourceClass}" resource class but you passed "${resourceClassFlag}" to --resource-class.\nUsing the "${resourceClassFlag}" as the override.`
); );
} }
const selectedResourceClass = resourceClassFlag ?? profileResourceClass; const selectedResourceClass = resourceClassFlag ?? profileResourceClass;
return platform === Platform.IOS return platform === Platform.IOS
? resolveIosResourceClass(resourceClassFlag, profileResourceClass) ? await resolveIosResourceClassAsync(exp, projectDir, resourceClassFlag ?? profileResourceClass)
: resolveAndroidResourceClass(selectedResourceClass); : resolveAndroidResourceClass(selectedResourceClass);
} }
function resolveAndroidResourceClass(selectedResourceClass?: ResourceClass): BuildResourceClass { function resolveAndroidResourceClass(selectedResourceClass?: ResourceClass): BuildResourceClass {
if (selectedResourceClass && ResourceClass.M1_MEDIUM === selectedResourceClass) { if (
selectedResourceClass &&
[
ResourceClass.M1_EXPERIMENTAL,
ResourceClass.M1_MEDIUM,
ResourceClass.M1_LARGE,
ResourceClass.INTEL_MEDIUM,
].includes(selectedResourceClass)
) {
throw new Error(`Resource class ${selectedResourceClass} is only available for iOS builds`); throw new Error(`Resource class ${selectedResourceClass} is only available for iOS builds`);
} }
@@ -58,21 +79,15 @@ function resolveAndroidResourceClass(selectedResourceClass?: ResourceClass): Bui
return androidResourceClassToBuildResourceClassMapping[resourceClass as AndroidResourceClass]; return androidResourceClassToBuildResourceClassMapping[resourceClass as AndroidResourceClass];
} }
function resolveIosResourceClass( async function resolveIosResourceClassAsync(
resourceClassFlag?: ResourceClass, exp: ExpoConfig,
profileResourceClass?: ResourceClass projectDir: string,
): BuildResourceClass { selectedResourceClass?: ResourceClass
const resourceClass = resourceClassFlag ?? profileResourceClass ?? ResourceClass.DEFAULT; ): Promise<BuildResourceClass> {
const resourceClass =
selectedResourceClass ?? (await resolveIosDefaultRequestedResourceClassAsync(exp, projectDir));
if (resourceClassFlag === ResourceClass.LARGE) { if ([ResourceClass.M1_EXPERIMENTAL, ResourceClass.M1_MEDIUM].includes(resourceClass)) {
throw new Error(
`Experimental "large" resource class for Intel iOS workers is no longer available. Remove the specified resource class to use the default, or learn more about all available resource classes: ${link(
'https://docs.expo.dev/build-reference/eas-json/'
)}`
);
}
if (ResourceClass.M1_MEDIUM === resourceClass) {
Log.warn( Log.warn(
`Resource class ${chalk.bold(resourceClass)} is deprecated. Use ${chalk.bold( `Resource class ${chalk.bold(resourceClass)} is deprecated. Use ${chalk.bold(
'm-medium' 'm-medium'
@@ -80,13 +95,29 @@ function resolveIosResourceClass(
); );
} }
if (ResourceClass.M_LARGE === resourceClass) { if (resourceClass === ResourceClass.M1_LARGE) {
Log.warn( Log.warn(
`Resource class ${chalk.bold(resourceClass)} is deprecated. Use ${chalk.bold( `Resource class ${chalk.bold('m1-large')} is deprecated. Use ${chalk.bold(
'large' 'm-large'
)} instead.` )} instead.`
); );
} }
return iosResourceClassToBuildResourceClassMapping[resourceClass]; return iosResourceClassToBuildResourceClassMapping[resourceClass];
} }
async function resolveIosDefaultRequestedResourceClassAsync(
exp: ExpoConfig,
projectDir: string
): Promise<ResourceClass> {
const { sdkVersion } = exp;
const reactNativeVersion = await getReactNativeVersionAsync(projectDir);
if (
(sdkVersion && semver.satisfies(sdkVersion, '>=48')) ||
(reactNativeVersion && semver.satisfies(reactNativeVersion, '>=0.71.0'))
) {
return ResourceClass.M_MEDIUM;
} else {
return ResourceClass.DEFAULT;
}
}
-22
View File
@@ -50,25 +50,3 @@ export function getUpdateGroupUrl(
getExpoWebsiteBaseUrl() getExpoWebsiteBaseUrl()
).toString(); ).toString();
} }
export function getWorkflowRunUrl(
accountName: string,
projectName: string,
workflowRunId: string
): string {
return new URL(
`/accounts/${encodeURIComponent(accountName)}/projects/${encodeURIComponent(
projectName
)}/workflows/${workflowRunId}`,
getExpoWebsiteBaseUrl()
).toString();
}
export function getProjectGitHubSettingsUrl(accountName: string, projectName: string): string {
return new URL(
`/accounts/${encodeURIComponent(accountName)}/projects/${encodeURIComponent(
projectName
)}/github`,
getExpoWebsiteBaseUrl()
).toString();
}
+1 -1
View File
@@ -3,9 +3,9 @@ import chalk from 'chalk';
import nullthrows from 'nullthrows'; import nullthrows from 'nullthrows';
import semver from 'semver'; import semver from 'semver';
import { updateAppJsonConfigAsync } from './appJson';
import Log from '../../log'; import Log from '../../log';
import { promptAsync } from '../../prompts'; import { promptAsync } from '../../prompts';
import { updateAppJsonConfigAsync } from './appJson';
export function ensureStaticConfigExists(projectDir: string): void { export function ensureStaticConfigExists(projectDir: string): void {
const paths = getConfigFilePaths(projectDir); const paths = getConfigFilePaths(projectDir);
+11 -14
View File
@@ -4,14 +4,15 @@ import fs from 'fs-extra';
import path from 'path'; import path from 'path';
import semver from 'semver'; import semver from 'semver';
import { CommonContext } from './context';
import Log, { learnMore } from '../log'; import Log, { learnMore } from '../log';
import { isPNGAsync } from '../utils/image'; import { isPNGAsync } from '../utils/image';
import { getVcsClient } from '../vcs';
import { CommonContext } from './context';
export function checkNodeEnvVariable(ctx: CommonContext<Platform>): void { export function checkNodeEnvVariable(ctx: CommonContext<Platform>): void {
if (ctx.env?.NODE_ENV === 'production') { if (ctx.buildProfile.env?.NODE_ENV === 'production') {
Log.warn( Log.warn(
'You set NODE_ENV=production in the build profile or environment variables. Remember that it will be available during the entire build process. In particular, it will make yarn/npm install only production packages.' 'You set NODE_ENV=production in the build profile. Remember that it will be available during the entire build process. In particular, it will make yarn/npm install only production packages.'
); );
Log.newLine(); Log.newLine();
} }
@@ -27,28 +28,24 @@ export async function checkGoogleServicesFileAsync<T extends Platform>(
if (!googleServicesFilePath) { if (!googleServicesFilePath) {
return; return;
} }
const googleServicesEnvVar = const rootDir = path.normalize(await getVcsClient().getRootPathAsync());
ctx.platform === Platform.ANDROID
? ctx.env.GOOGLE_SERVICES_JSON
: ctx.env.GOOGLE_SERVICES_INFO_PLIST;
const rootDir = path.normalize(await ctx.vcsClient.getRootPathAsync());
const absGoogleServicesFilePath = path.resolve(ctx.projectDir, googleServicesFilePath); const absGoogleServicesFilePath = path.resolve(ctx.projectDir, googleServicesFilePath);
if ( if (
(await fs.pathExists(absGoogleServicesFilePath)) && (await fs.pathExists(absGoogleServicesFilePath)) &&
(!isInsideDirectory(absGoogleServicesFilePath, rootDir) || (!isInsideDirectory(absGoogleServicesFilePath, rootDir) ||
(await ctx.vcsClient.isFileIgnoredAsync( (await getVcsClient().isFileIgnoredAsync(path.relative(rootDir, absGoogleServicesFilePath))))
path.relative(rootDir, absGoogleServicesFilePath)
))) &&
!googleServicesEnvVar
) { ) {
Log.warn( Log.warn(
`File specified via "${ctx.platform}.googleServicesFile" field in your app.json is not checked in to your repository and won't be uploaded to the builder.` `File specified via "${ctx.platform}.googleServicesFile" field in your app.json is not checked in to your repository and won't be uploaded to the builder.`
); );
Log.warn( Log.warn(
`Use EAS file environment variables with secret or sensitive visibility to pass all values that you don't want to include in your version control to build process. ${learnMore( `Use EAS Secret to pass all values that you don't want to include in your version control. ${learnMore(
'https://docs.expo.dev/eas/environment-variables/#file-environment-variables' 'https://docs.expo.dev/build-reference/variables/#using-secrets-in-environment-variables'
)}` )}`
); );
Log.warn(
`If you are using that file for compatibility with the classic build service (expo build) you can silence this warning by setting your build profile's env.GOOGLE_SERVICES_FILE in eas.json to any non-empty string.`
);
Log.newLine(); Log.newLine();
} }
} }
@@ -1,22 +0,0 @@
import { BranchBasicInfo, ChannelBasicInfo, UpdateChannelInfoWithBranches } from '../utils';
export const testChannelBasicInfo: ChannelBasicInfo = {
id: '9309afc2-9752-40db-8ef7-4abc10744c61',
name: 'production',
branchMapping:
'{"data":[{"branchId":"754bf17f-efc0-46ab-8a59-a03f20e53e9b","branchMappingLogic":{"operand":0.15,"clientKey":"rolloutToken","branchMappingOperator":"hash_lt"}},{"branchId":"6941a8dd-5c0a-48bc-8876-f49c88ed419f","branchMappingLogic":"true"}],"version":0}',
};
export const testBasicBranchInfo1: BranchBasicInfo = {
id: '754bf17f-efc0-46ab-8a59-a03f20e53e9b',
name: 'wrong-channel',
};
export const testBasicBranchInfo2: BranchBasicInfo = {
id: '6941a8dd-5c0a-48bc-8876-f49c88ed419f',
name: 'production',
};
export const channelInfoWithBranches: UpdateChannelInfoWithBranches<BranchBasicInfo> = {
...testChannelBasicInfo,
updateBranches: [testBasicBranchInfo1, testBasicBranchInfo2],
};
@@ -1,72 +0,0 @@
import { testChannelBasicInfo } from './branch-mapping-fixtures';
import {
emptyBranchMapping,
rolloutBranchMapping,
standardBranchMapping,
} from '../../rollout/__tests__/fixtures';
import {
BranchMappingValidationError,
assertVersion,
getAlwaysTrueBranchMapping,
getBranchMapping,
getEmptyBranchMapping,
getStandardBranchId,
isAlwaysTrueBranchMapping,
isEmptyBranchMapping,
} from '../branch-mapping';
describe(assertVersion, () => {
it('throws if the branch mapping is not the correct version', () => {
expect(() => {
assertVersion(testChannelBasicInfo, 5);
}).toThrowError(BranchMappingValidationError);
});
it('asserts the correct version', () => {
assertVersion(testChannelBasicInfo, 0);
expect(getBranchMapping(testChannelBasicInfo.branchMapping).version).toBe(0);
});
});
describe(getEmptyBranchMapping, () => {
it('gets an empty branch mapping', () => {
expect(isEmptyBranchMapping(getEmptyBranchMapping())).toBe(true);
});
});
describe(isEmptyBranchMapping, () => {
it('detects empty branch mappings', () => {
expect(isEmptyBranchMapping(emptyBranchMapping)).toBe(true);
expect(isEmptyBranchMapping(standardBranchMapping)).toBe(false);
expect(isEmptyBranchMapping(rolloutBranchMapping)).toBe(false);
});
});
describe(isAlwaysTrueBranchMapping, () => {
it('detects always true branch mappings', () => {
expect(isAlwaysTrueBranchMapping(standardBranchMapping)).toBe(true);
expect(isAlwaysTrueBranchMapping(rolloutBranchMapping)).toBe(false);
expect(isAlwaysTrueBranchMapping(emptyBranchMapping)).toBe(false);
});
});
describe(getAlwaysTrueBranchMapping, () => {
it('gets an always true branch mapping', () => {
const alwaysTrueBranchMapping = getAlwaysTrueBranchMapping('test-id');
expect(isAlwaysTrueBranchMapping(alwaysTrueBranchMapping)).toBe(true);
});
});
describe(getStandardBranchId, () => {
it('throws if the branch mapping is not a standard mapping', () => {
expect(() => getStandardBranchId(testChannelBasicInfo)).toThrowError(
BranchMappingValidationError
);
});
it('gets a standard branch id', () => {
const channelObjectWithStandardMapping = { ...testChannelBasicInfo };
channelObjectWithStandardMapping.branchMapping = JSON.stringify(standardBranchMapping);
expect(getStandardBranchId(channelObjectWithStandardMapping)).toBe(
standardBranchMapping.data[0].branchId
);
});
});
@@ -1,149 +0,0 @@
import { UpdateChannelBasicInfoFragment } from '../../graphql/generated';
import { UpdateBranchObject, UpdateChannelObject } from '../../graphql/queries/ChannelQuery';
export const testUpdateBranch1: UpdateBranchObject = {
id: '754bf17f-efc0-46ab-8a59-a03f20e53e9b',
name: 'wrong-channel',
updateGroups: [
[
{
id: 'bbe3e63a-f620-4603-8c4d-258f3e51590d',
group: '16ca6dba-e63b-48b0-baa3-15a894ee9434',
message: 'fix bug',
createdAt: '2023-07-17T22:48:59.278Z',
runtimeVersion: 'exposdk:48.0.0',
platform: 'ios',
manifestFragment:
'{"extra":{"expoClient":{"ios":{"supportsTablet":true,"bundleIdentifier":"com.quintest113.updatestest4"},"web":{"favicon":"./assets/favicon.png"},"icon":"./assets/icon.png","name":"updates-test-4","slug":"updates-test-4","extra":{"eas":{"projectId":"789a8784-0186-4f41-8bd2-7f219291a2b8"}},"splash":{"image":"./assets/splash.png","resizeMode":"contain","backgroundColor":"#ffffff"},"android":{"package":"com.quintest113.updatestest4","adaptiveIcon":{"backgroundColor":"#ffffff","foregroundImage":"./assets/adaptive-icon.png"}},"updates":{"url":"https://u.expo.dev/789a8784-0186-4f41-8bd2-7f219291a2b8"},"version":"1.0.0","platforms":["ios","android"],"sdkVersion":"48.0.0","orientation":"portrait","runtimeVersion":{"policy":"sdkVersion"},"currentFullName":"@quintest113/updates-test-4","originalFullName":"@quintest113/updates-test-4","userInterfaceStyle":"light","assetBundlePatterns":["**/*"]}},"assets":[{"bundleKey":"7d40544b395c5949f4646f5e150fe020","fileSHA256":"LN_rjlzN55dvcBL7jM5zryIpAp7iHU8VCf7RFIccbNg","storageKey":"Pi65ns2w9xLI5G_m9aGrJ5wK5FpWcGcInSSFwnA_0aA","contentType":"image/png","fileExtension":".png"},{"bundleKey":"cdd04e13d4ec83ff0cd13ec8dabdc341","fileSHA256":"irHji01LPI9giDYxtJo9bTPHaEHzuUcCMTlb4xn90Ks","storageKey":"oJZ9JX1m8IKNDgnIqcKOo5KUFOoa_NCaxruigZu65E0","contentType":"image/png","fileExtension":".png"},{"bundleKey":"a132ecc4ba5c1517ff83c0fb321bc7fc","fileSHA256":"K3RDqaWOksoRpXW9wRWYYVpX3eMFzYznDWAmBrMCzZg","storageKey":"X3OAkUu5P_LqyKFx1bNkA_qDQFLlSjCYNAlVEaOamHE","contentType":"image/png","fileExtension":".png"},{"bundleKey":"0ea69b5077e7c4696db85dbcba75b0e1","fileSHA256":"Pd0Hc-0n4j0leJ8wFzGzrEVa2L5aonoKG4OPicbSciU","storageKey":"FwJkAbrRNahBGX1KXnTAgiiwyzENu3D7DeEzCwmInyo","contentType":"image/png","fileExtension":".png"},{"bundleKey":"f5b790e2ac193b3d41015edb3551f9b8","fileSHA256":"vCfIss7SLYKt1AAi0hxjsgOMEDceLJB6ZWjQf6gTU4k","storageKey":"GQtYm7r0G9N3WJyrymjZ26p8eNMIb7rzqwSVgGco5EU","contentType":"image/png","fileExtension":".png"},{"bundleKey":"5223c8d9b0d08b82a5670fb5f71faf78","fileSHA256":"LJXowmdeeZM1_QBGuwfb2gbLfs_1HtN5IsYwnWGcxNM","storageKey":"jeqOrdQBNY4hgl04B3wtGs8QNWMDmjvbjIMUXPtOBpw","contentType":"image/png","fileExtension":".png"},{"bundleKey":"02d5848f62ade9617cf96894727d32e7","fileSHA256":"u1-8zHOj9oxS8maH-7JbShJIq1-KEV7FXnrGpEUcR-4","storageKey":"LMUvUZZpdGFb7KqaB_l9N4TXTkFn1Pw92ws9fWDNZPw","contentType":"model/gltf-binary","fileExtension":".glb"},{"bundleKey":"6013c27e421ab23c826713af3ebec891","fileSHA256":"dutQNnvzTCS4NgwvbeMbvhro5QnW0GJUeY_4rrvlcLY","storageKey":"85KB9Jr7eqz1Jwt1mYQGRzTy6ZXyO0j7ZcaLImCbzkU","contentType":"image/jpeg","fileExtension":".jpg"}],"launchAsset":{"bundleKey":"3d0ac172fe41ab941a8c68cd7e3a17ee","fileSHA256":"jjnar-ZPrb_g0Bx5tcFfjJrP3v8HdeAlXMk__xN7I1U","storageKey":"bimf-UXq5IFH5nNHCIHdcQ2MyJwTnwDVBb-P8hr2CZw","contentType":"application/javascript","fileExtension":".bundle"}}',
isRollBackToEmbedded: false,
manifestPermalink: 'https://u.expo.dev/update/bbe3e63a-f620-4603-8c4d-258f3e51590d',
gitCommitHash: 'e91c5bb733eff675dd8a3722ed5d4f885a4b10ce',
actor: {
__typename: 'User',
id: '026ace2b-c1f8-4a02-818d-6b52c100bdf2',
username: 'quintest113',
},
branch: {
id: '754bf17f-efc0-46ab-8a59-a03f20e53e9b',
name: 'wrong-channel',
__typename: 'UpdateBranch',
},
codeSigningInfo: null,
__typename: 'Update',
},
{
id: 'db22401b-d321-4629-a068-21d6d1f19c02',
group: '16ca6dba-e63b-48b0-baa3-15a894ee9434',
message: 'fix bug',
createdAt: '2023-07-17T22:48:59.278Z',
runtimeVersion: 'exposdk:48.0.0',
platform: 'android',
manifestFragment:
'{"extra":{"expoClient":{"ios":{"supportsTablet":true,"bundleIdentifier":"com.quintest113.updatestest4"},"web":{"favicon":"./assets/favicon.png"},"icon":"./assets/icon.png","name":"updates-test-4","slug":"updates-test-4","extra":{"eas":{"projectId":"789a8784-0186-4f41-8bd2-7f219291a2b8"}},"splash":{"image":"./assets/splash.png","resizeMode":"contain","backgroundColor":"#ffffff"},"android":{"package":"com.quintest113.updatestest4","adaptiveIcon":{"backgroundColor":"#ffffff","foregroundImage":"./assets/adaptive-icon.png"}},"updates":{"url":"https://u.expo.dev/789a8784-0186-4f41-8bd2-7f219291a2b8"},"version":"1.0.0","platforms":["ios","android"],"sdkVersion":"48.0.0","orientation":"portrait","runtimeVersion":{"policy":"sdkVersion"},"currentFullName":"@quintest113/updates-test-4","originalFullName":"@quintest113/updates-test-4","userInterfaceStyle":"light","assetBundlePatterns":["**/*"]}},"assets":[{"bundleKey":"778ffc9fe8773a878e9c30a6304784de","fileSHA256":"i2Gkx-9w3JJ1PwSUl2SC9m_UFQ7CPfx3KrZeEDc6-lU","storageKey":"idnX8z03q4vGLzhYMPaQGBADjhqvWe67_afPqJ1vGzM","contentType":"image/png","fileExtension":".png"},{"bundleKey":"376d6a4c7f622917c39feb23671ef71d","fileSHA256":"QJsG0VpGM-5viirUgoKq0M4zQ_TYyPfLhAhN5mrM54I","storageKey":"46DQ95-UdSKiRkQgt5FyXkE1FrKOT9tBZP3nS7auKlg","contentType":"image/png","fileExtension":".png"},{"bundleKey":"c79c3606a1cf168006ad3979763c7e0c","fileSHA256":"kGZm3WiTRq2iMs42ia3vkHtCV_obWT8DY40rlJf2SIQ","storageKey":"k-3xHp3vP8mR36WdccUZVPTRJKpP-zlyboWdkXCIQpQ","contentType":"image/png","fileExtension":".png"},{"bundleKey":"02bc1fa7c0313217bde2d65ccbff40c9","fileSHA256":"_6fuRbdkBbpzkhSVAI99aMneY5X0tpQdsGNGX244IyA","storageKey":"lfqeUjiWFXwowhxVDBEzxsbTOEzYxMLebljy5Bh1mpU","contentType":"image/png","fileExtension":".png"},{"bundleKey":"35ba0eaec5a4f5ed12ca16fabeae451d","fileSHA256":"hM9es7ICUPaeDkczvrTaX0F_BoKJKlrRteYlcHU8IbE","storageKey":"fMoMrsUeB5xWw_Ugn8FqGJf-M2pld2hkXKR704z0MM8","contentType":"image/png","fileExtension":".png"},{"bundleKey":"5223c8d9b0d08b82a5670fb5f71faf78","fileSHA256":"LJXowmdeeZM1_QBGuwfb2gbLfs_1HtN5IsYwnWGcxNM","storageKey":"jeqOrdQBNY4hgl04B3wtGs8QNWMDmjvbjIMUXPtOBpw","contentType":"image/png","fileExtension":".png"},{"bundleKey":"02d5848f62ade9617cf96894727d32e7","fileSHA256":"u1-8zHOj9oxS8maH-7JbShJIq1-KEV7FXnrGpEUcR-4","storageKey":"LMUvUZZpdGFb7KqaB_l9N4TXTkFn1Pw92ws9fWDNZPw","contentType":"model/gltf-binary","fileExtension":".glb"},{"bundleKey":"6013c27e421ab23c826713af3ebec891","fileSHA256":"dutQNnvzTCS4NgwvbeMbvhro5QnW0GJUeY_4rrvlcLY","storageKey":"85KB9Jr7eqz1Jwt1mYQGRzTy6ZXyO0j7ZcaLImCbzkU","contentType":"image/jpeg","fileExtension":".jpg"}],"launchAsset":{"bundleKey":"4cc2ee49988e438ac066827b809721de","fileSHA256":"ctfK72f8EdS1IQmDvJLya38RMSma16beJcMoQToZIe0","storageKey":"QdEoKuCQPvVPA71gY52wqJ9vmtV4ObnCcPE2FP4SIdo","contentType":"application/javascript","fileExtension":".bundle"}}',
isRollBackToEmbedded: false,
manifestPermalink: 'https://u.expo.dev/update/db22401b-d321-4629-a068-21d6d1f19c02',
gitCommitHash: 'e91c5bb733eff675dd8a3722ed5d4f885a4b10ce',
actor: {
__typename: 'User',
id: '026ace2b-c1f8-4a02-818d-6b52c100bdf2',
username: 'quintest113',
},
branch: {
id: '754bf17f-efc0-46ab-8a59-a03f20e53e9b',
name: 'wrong-channel',
__typename: 'UpdateBranch',
},
codeSigningInfo: null,
__typename: 'Update',
},
],
],
__typename: 'UpdateBranch',
};
export const testUpdateBranch2: UpdateBranchObject = {
id: '6941a8dd-5c0a-48bc-8876-f49c88ed419f',
name: 'production',
updateGroups: [
[
{
id: 'f04aafc1-0bf5-4548-8c35-726b467e7c40',
group: 'e40ad156-e9af-4cc2-8e9d-c7b5c328db48',
message: 'fix bug',
createdAt: '2023-06-23T23:37:10.004Z',
runtimeVersion: 'exposdk:48.0.0',
platform: 'ios',
manifestFragment:
'{"extra":{"expoClient":{"ios":{"supportsTablet":true,"bundleIdentifier":"com.quintest113.updatestest4"},"web":{"favicon":"./assets/favicon.png"},"icon":"./assets/icon.png","name":"updates-test-4","slug":"updates-test-4","extra":{"eas":{"projectId":"789a8784-0186-4f41-8bd2-7f219291a2b8"}},"splash":{"image":"./assets/splash.png","resizeMode":"contain","backgroundColor":"#ffffff"},"android":{"package":"com.quintest113.updatestest4","adaptiveIcon":{"backgroundColor":"#ffffff","foregroundImage":"./assets/adaptive-icon.png"}},"updates":{"url":"https://u.expo.dev/789a8784-0186-4f41-8bd2-7f219291a2b8"},"version":"1.0.0","platforms":["ios","android"],"sdkVersion":"48.0.0","orientation":"portrait","runtimeVersion":{"policy":"sdkVersion"},"currentFullName":"@quintest113/updates-test-4","originalFullName":"@quintest113/updates-test-4","userInterfaceStyle":"light","assetBundlePatterns":["**/*"]}},"assets":[{"bundleKey":"7d40544b395c5949f4646f5e150fe020","fileSHA256":"LN_rjlzN55dvcBL7jM5zryIpAp7iHU8VCf7RFIccbNg","storageKey":"Pi65ns2w9xLI5G_m9aGrJ5wK5FpWcGcInSSFwnA_0aA","contentType":"image/png","fileExtension":".png"},{"bundleKey":"cdd04e13d4ec83ff0cd13ec8dabdc341","fileSHA256":"irHji01LPI9giDYxtJo9bTPHaEHzuUcCMTlb4xn90Ks","storageKey":"oJZ9JX1m8IKNDgnIqcKOo5KUFOoa_NCaxruigZu65E0","contentType":"image/png","fileExtension":".png"},{"bundleKey":"a132ecc4ba5c1517ff83c0fb321bc7fc","fileSHA256":"K3RDqaWOksoRpXW9wRWYYVpX3eMFzYznDWAmBrMCzZg","storageKey":"X3OAkUu5P_LqyKFx1bNkA_qDQFLlSjCYNAlVEaOamHE","contentType":"image/png","fileExtension":".png"},{"bundleKey":"0ea69b5077e7c4696db85dbcba75b0e1","fileSHA256":"Pd0Hc-0n4j0leJ8wFzGzrEVa2L5aonoKG4OPicbSciU","storageKey":"FwJkAbrRNahBGX1KXnTAgiiwyzENu3D7DeEzCwmInyo","contentType":"image/png","fileExtension":".png"},{"bundleKey":"f5b790e2ac193b3d41015edb3551f9b8","fileSHA256":"vCfIss7SLYKt1AAi0hxjsgOMEDceLJB6ZWjQf6gTU4k","storageKey":"GQtYm7r0G9N3WJyrymjZ26p8eNMIb7rzqwSVgGco5EU","contentType":"image/png","fileExtension":".png"},{"bundleKey":"5223c8d9b0d08b82a5670fb5f71faf78","fileSHA256":"LJXowmdeeZM1_QBGuwfb2gbLfs_1HtN5IsYwnWGcxNM","storageKey":"jeqOrdQBNY4hgl04B3wtGs8QNWMDmjvbjIMUXPtOBpw","contentType":"image/png","fileExtension":".png"},{"bundleKey":"02d5848f62ade9617cf96894727d32e7","fileSHA256":"u1-8zHOj9oxS8maH-7JbShJIq1-KEV7FXnrGpEUcR-4","storageKey":"LMUvUZZpdGFb7KqaB_l9N4TXTkFn1Pw92ws9fWDNZPw","contentType":"model/gltf-binary","fileExtension":".glb"},{"bundleKey":"6013c27e421ab23c826713af3ebec891","fileSHA256":"dutQNnvzTCS4NgwvbeMbvhro5QnW0GJUeY_4rrvlcLY","storageKey":"85KB9Jr7eqz1Jwt1mYQGRzTy6ZXyO0j7ZcaLImCbzkU","contentType":"image/jpeg","fileExtension":".jpg"}],"launchAsset":{"bundleKey":"76ec49faddaef30ba78fe50edc5ab3d6","fileSHA256":"OLUdgP-fZI2shwGS5qlIk7nWnybL_13RJl5xR85CDBU","storageKey":"OCvI0-34zApbdo3eSGfIaQUbWdZeLeKH-nJSeedKTFs","contentType":"application/javascript","fileExtension":".bundle"}}',
isRollBackToEmbedded: false,
manifestPermalink: 'https://u.expo.dev/update/f04aafc1-0bf5-4548-8c35-726b467e7c40',
gitCommitHash: 'e91c5bb733eff675dd8a3722ed5d4f885a4b10ce',
actor: {
__typename: 'User',
id: '026ace2b-c1f8-4a02-818d-6b52c100bdf2',
username: 'quintest113',
},
branch: {
id: '6941a8dd-5c0a-48bc-8876-f49c88ed419f',
name: 'production',
__typename: 'UpdateBranch',
},
codeSigningInfo: null,
__typename: 'Update',
},
{
id: 'd8d15786-2727-407f-bf3e-f4bda7e397d9',
group: 'e40ad156-e9af-4cc2-8e9d-c7b5c328db48',
message: 'fix bug',
createdAt: '2023-06-23T23:37:10.004Z',
runtimeVersion: 'exposdk:48.0.0',
platform: 'android',
manifestFragment:
'{"extra":{"expoClient":{"ios":{"supportsTablet":true,"bundleIdentifier":"com.quintest113.updatestest4"},"web":{"favicon":"./assets/favicon.png"},"icon":"./assets/icon.png","name":"updates-test-4","slug":"updates-test-4","extra":{"eas":{"projectId":"789a8784-0186-4f41-8bd2-7f219291a2b8"}},"splash":{"image":"./assets/splash.png","resizeMode":"contain","backgroundColor":"#ffffff"},"android":{"package":"com.quintest113.updatestest4","adaptiveIcon":{"backgroundColor":"#ffffff","foregroundImage":"./assets/adaptive-icon.png"}},"updates":{"url":"https://u.expo.dev/789a8784-0186-4f41-8bd2-7f219291a2b8"},"version":"1.0.0","platforms":["ios","android"],"sdkVersion":"48.0.0","orientation":"portrait","runtimeVersion":{"policy":"sdkVersion"},"currentFullName":"@quintest113/updates-test-4","originalFullName":"@quintest113/updates-test-4","userInterfaceStyle":"light","assetBundlePatterns":["**/*"]}},"assets":[{"bundleKey":"778ffc9fe8773a878e9c30a6304784de","fileSHA256":"i2Gkx-9w3JJ1PwSUl2SC9m_UFQ7CPfx3KrZeEDc6-lU","storageKey":"idnX8z03q4vGLzhYMPaQGBADjhqvWe67_afPqJ1vGzM","contentType":"image/png","fileExtension":".png"},{"bundleKey":"376d6a4c7f622917c39feb23671ef71d","fileSHA256":"QJsG0VpGM-5viirUgoKq0M4zQ_TYyPfLhAhN5mrM54I","storageKey":"46DQ95-UdSKiRkQgt5FyXkE1FrKOT9tBZP3nS7auKlg","contentType":"image/png","fileExtension":".png"},{"bundleKey":"c79c3606a1cf168006ad3979763c7e0c","fileSHA256":"kGZm3WiTRq2iMs42ia3vkHtCV_obWT8DY40rlJf2SIQ","storageKey":"k-3xHp3vP8mR36WdccUZVPTRJKpP-zlyboWdkXCIQpQ","contentType":"image/png","fileExtension":".png"},{"bundleKey":"02bc1fa7c0313217bde2d65ccbff40c9","fileSHA256":"_6fuRbdkBbpzkhSVAI99aMneY5X0tpQdsGNGX244IyA","storageKey":"lfqeUjiWFXwowhxVDBEzxsbTOEzYxMLebljy5Bh1mpU","contentType":"image/png","fileExtension":".png"},{"bundleKey":"35ba0eaec5a4f5ed12ca16fabeae451d","fileSHA256":"hM9es7ICUPaeDkczvrTaX0F_BoKJKlrRteYlcHU8IbE","storageKey":"fMoMrsUeB5xWw_Ugn8FqGJf-M2pld2hkXKR704z0MM8","contentType":"image/png","fileExtension":".png"},{"bundleKey":"5223c8d9b0d08b82a5670fb5f71faf78","fileSHA256":"LJXowmdeeZM1_QBGuwfb2gbLfs_1HtN5IsYwnWGcxNM","storageKey":"jeqOrdQBNY4hgl04B3wtGs8QNWMDmjvbjIMUXPtOBpw","contentType":"image/png","fileExtension":".png"},{"bundleKey":"02d5848f62ade9617cf96894727d32e7","fileSHA256":"u1-8zHOj9oxS8maH-7JbShJIq1-KEV7FXnrGpEUcR-4","storageKey":"LMUvUZZpdGFb7KqaB_l9N4TXTkFn1Pw92ws9fWDNZPw","contentType":"model/gltf-binary","fileExtension":".glb"},{"bundleKey":"6013c27e421ab23c826713af3ebec891","fileSHA256":"dutQNnvzTCS4NgwvbeMbvhro5QnW0GJUeY_4rrvlcLY","storageKey":"85KB9Jr7eqz1Jwt1mYQGRzTy6ZXyO0j7ZcaLImCbzkU","contentType":"image/jpeg","fileExtension":".jpg"}],"launchAsset":{"bundleKey":"f4c6981626dbabcf51078d07a1611f4e","fileSHA256":"q-k1hSWl49aXpSg4jOSaVVnYFSFL6xf7HVl_TQE-ObM","storageKey":"FwK1PaZqtpL0QZIgaHsxU1AV93JhMdu9K1wZizXROAc","contentType":"application/javascript","fileExtension":".bundle"}}',
isRollBackToEmbedded: false,
manifestPermalink: 'https://u.expo.dev/update/d8d15786-2727-407f-bf3e-f4bda7e397d9',
gitCommitHash: 'e91c5bb733eff675dd8a3722ed5d4f885a4b10ce',
actor: {
__typename: 'User',
id: '026ace2b-c1f8-4a02-818d-6b52c100bdf2',
username: 'quintest113',
},
branch: {
id: '6941a8dd-5c0a-48bc-8876-f49c88ed419f',
name: 'production',
__typename: 'UpdateBranch',
},
codeSigningInfo: null,
__typename: 'Update',
},
],
],
__typename: 'UpdateBranch',
};
export const testChannelObject: UpdateChannelObject = {
id: '9309afc2-9752-40db-8ef7-4abc10744c61',
name: 'production',
createdAt: '2023-05-16T21:26:16.946Z',
updatedAt: '2023-05-16T21:26:16.946Z',
branchMapping:
'{"data":[{"branchId":"754bf17f-efc0-46ab-8a59-a03f20e53e9b","branchMappingLogic":{"operand":0.15,"clientKey":"rolloutToken","branchMappingOperator":"hash_lt"}},{"branchId":"6941a8dd-5c0a-48bc-8876-f49c88ed419f","branchMappingLogic":"true"}],"version":0}',
updateBranches: [testUpdateBranch1, testUpdateBranch2],
isPaused: false,
__typename: 'UpdateChannel',
};
export const testBasicChannelInfo: UpdateChannelBasicInfoFragment = {
id: '9309afc2-9752-40db-8ef7-4abc10744c61',
name: 'production',
branchMapping:
'{"data":[{"branchId":"754bf17f-efc0-46ab-8a59-a03f20e53e9b","branchMappingLogic":{"operand":0.1,"clientKey":"rolloutToken","branchMappingOperator":"hash_lt"}},{"branchId":"6941a8dd-5c0a-48bc-8876-f49c88ed419f","branchMappingLogic":"true"}],"version":0}',
__typename: 'UpdateChannel',
};
export const testBasicChannelInfo2: UpdateChannelBasicInfoFragment = {
id: '9d5c7bf0-d52d-474c-9140-c1bad7f0de9d',
name: 'staging',
branchMapping:
'{"data":[{"branchId":"d7d68e32-d9c9-4a8d-8d1b-21e53100a5e8","branchMappingLogic":{"operand":0.1,"clientKey":"rolloutToken","branchMappingOperator":"hash_lt"}},{"branchId":"f9f708c2-0c91-4360-b2a4-0b61834aef4a","branchMappingLogic":"true"}],"version":0}',
__typename: 'UpdateChannel',
};
@@ -1,57 +0,0 @@
import { format } from '@expo/timeago.js';
import { testChannelObject, testUpdateBranch1, testUpdateBranch2 } from './fixtures';
import { getAlwaysTrueBranchMapping, getEmptyBranchMapping } from '../branch-mapping';
import { getDescriptionByBranchId } from '../print-utils';
describe(getDescriptionByBranchId, () => {
it('should get descriptions for multiple branches with multiple update groups', () => {
const descriptionByBranchId = getDescriptionByBranchId(testChannelObject);
expect(Object.values(descriptionByBranchId)).toHaveLength(2);
expect(descriptionByBranchId[testUpdateBranch1.id]).toEqual({
branch: 'wrong-channel',
branchRolloutPercentage: 15,
update: {
codeSigningKey: undefined,
group: '16ca6dba-e63b-48b0-baa3-15a894ee9434',
isRollBackToEmbedded: false,
message: `"fix bug" (${format('2023-07-17T22:48:59.278Z', 'en_US')} by quintest113)`,
platforms: 'android, ios',
runtimeVersion: 'exposdk:48.0.0',
},
});
expect(descriptionByBranchId[testUpdateBranch2.id]).toEqual({
branch: 'production',
branchRolloutPercentage: 85,
update: {
codeSigningKey: undefined,
group: 'e40ad156-e9af-4cc2-8e9d-c7b5c328db48',
isRollBackToEmbedded: false,
message: `"fix bug" (${format('2023-06-23T23:37:10.004Z', 'en_US')} by quintest113)`,
platforms: 'android, ios',
runtimeVersion: 'exposdk:48.0.0',
},
});
});
it('should get descriptions for branches with no updates', () => {
const noUpdatesBranch = { ...testUpdateBranch1, updateGroups: [] };
const oneBranchChannel = {
...testChannelObject,
updateBranches: [noUpdatesBranch],
branchMapping: JSON.stringify(getAlwaysTrueBranchMapping(noUpdatesBranch.id)),
};
const descriptionByBranchId = getDescriptionByBranchId(oneBranchChannel);
expect(Object.values(descriptionByBranchId)).toHaveLength(1);
expect(descriptionByBranchId[testUpdateBranch1.id]).toEqual({ branch: 'wrong-channel' });
});
it('should get descriptions for no branches', () => {
const noBranchChannel = {
...testChannelObject,
updateBranches: [],
branchMapping: JSON.stringify(getEmptyBranchMapping()),
};
const descriptionByBranchId = getDescriptionByBranchId(noBranchChannel);
expect(Object.values(descriptionByBranchId)).toHaveLength(0);
});
});
@@ -1,11 +0,0 @@
import { channelInfoWithBranches, testBasicBranchInfo1 } from './branch-mapping-fixtures';
import { getUpdateBranch } from '../utils';
describe(getUpdateBranch, () => {
it('gets the update branch', () => {
expect(getUpdateBranch(channelInfoWithBranches, '754bf17f-efc0-46ab-8a59-a03f20e53e9b')).toBe(
testBasicBranchInfo1
);
expect(() => getUpdateBranch(channelInfoWithBranches, 'not-a-branch-ID')).toThrow();
});
});
@@ -1,107 +0,0 @@
import { EASUpdateAction, EASUpdateContext, NonInteractiveError } from '../../eas-update/utils';
import { UpdateChannelBasicInfoFragment } from '../../graphql/generated';
import { ChannelQuery } from '../../graphql/queries/ChannelQuery';
import {
Connection,
Edge,
FilterPagination,
QueryParams,
selectPaginatedAsync,
} from '../../utils/relay';
/**
* Select a channel for the project.
*
* @constructor
* @param {function} options.filterPredicate - A predicate to filter the channels that are shown to the user. It takes a channelInfo object as a parameter and returns a boolean.
* @param {string} options.printedType - The type of channel printed to the user. Defaults to 'channel'.
* @param {number} options.pageSize - The number of channels to show per page. Defaults to 100.
* @param {function} options.beforeEachFilterQuery Optional. If a filter predicate was specified, this callback function will be called before each query.
* @args externalQueryParams The query params for the pagination.
* @args totalNodesFetched The total number of nodes fetched so far.
* @args dataset The dataset so far.
* @param {function} options.afterEachFilterQuery Optional. If a filter predicate was specified, this callback function will be called after each query.
* @args externalQueryParams The query params for the pagination.
* @args totalNodesFetched The total number of nodes fetched so far.
* @args dataset The dataset so far.
* @args willFetchAgain If the query will fetch again to get a complete page.
*/
export class SelectChannel implements EASUpdateAction<UpdateChannelBasicInfoFragment | null> {
constructor(
private readonly options: {
filterPredicate?: (channelInfo: UpdateChannelBasicInfoFragment) => boolean;
printedType?: string;
pageSize?: number;
beforeEachFilterQuery?: (
externalQueryParams: QueryParams,
totalNodesFetched: number,
dataset: Edge<UpdateChannelBasicInfoFragment>[]
) => void;
afterEachFilterQuery?: (
externalQueryParams: QueryParams,
totalNodesFetched: number,
dataset: Edge<UpdateChannelBasicInfoFragment>[],
willFetchAgain: boolean
) => void;
} = {}
) {}
async queryAsync(
ctx: EASUpdateContext,
queryParams: QueryParams
): Promise<Connection<UpdateChannelBasicInfoFragment>> {
const { graphqlClient, app } = ctx;
const { projectId } = app;
return await ChannelQuery.viewUpdateChannelsBasicInfoPaginatedOnAppAsync(graphqlClient, {
appId: projectId,
...queryParams,
});
}
async filterQueryAsync(
ctx: EASUpdateContext,
queryParams: QueryParams,
filterPredicate: (channelInfo: UpdateChannelBasicInfoFragment) => boolean
): Promise<Connection<UpdateChannelBasicInfoFragment>> {
const queryAsync = async (
queryParams: QueryParams
): Promise<Connection<UpdateChannelBasicInfoFragment>> =>
await this.queryAsync(ctx, queryParams);
return await FilterPagination.getPageAsync({
queryParams,
queryAsync,
filterPredicate,
beforeEachQuery: this.options.beforeEachFilterQuery,
afterEachQuery: this.options.afterEachFilterQuery,
});
}
public async runAsync(ctx: EASUpdateContext): Promise<UpdateChannelBasicInfoFragment | null> {
const { nonInteractive } = ctx;
const { filterPredicate } = this.options;
const printedType = this.options.printedType ?? 'channel';
const pageSize = this.options.pageSize ?? 100;
if (nonInteractive) {
throw new NonInteractiveError(
`${printedType} selection cannot be run in non-interactive mode.`
);
}
const queryAsync = async (
queryParams: QueryParams
): Promise<Connection<UpdateChannelBasicInfoFragment>> =>
filterPredicate
? await this.filterQueryAsync(ctx, queryParams, filterPredicate)
: await this.queryAsync(ctx, queryParams);
const getTitleAsync = async (channelInfo: UpdateChannelBasicInfoFragment): Promise<string> =>
channelInfo.name;
return await selectPaginatedAsync({
queryAsync,
getTitleAsync,
printedType,
pageSize,
});
}
}
@@ -1,92 +0,0 @@
import { createCtxMock } from '../../../eas-update/__tests__/fixtures';
import { NonInteractiveError } from '../../../eas-update/utils';
import { UpdateChannelBasicInfoFragment } from '../../../graphql/generated';
import { ChannelQuery } from '../../../graphql/queries/ChannelQuery';
import { promptAsync } from '../../../prompts';
import { testBasicChannelInfo, testBasicChannelInfo2 } from '../../__tests__/fixtures';
import { SelectChannel } from '../SelectChannel';
jest.mock('../../../prompts');
jest.mock('../../../graphql/queries/ChannelQuery');
describe(SelectChannel, () => {
afterEach(() => {
jest.clearAllMocks();
});
it('returns null if project has no channels', async () => {
const ctx = createCtxMock();
jest.mocked(ChannelQuery.viewUpdateChannelsBasicInfoPaginatedOnAppAsync).mockResolvedValue({
edges: [],
pageInfo: {
hasPreviousPage: false,
hasNextPage: false,
},
});
const selectChannel = new SelectChannel();
const channel = await selectChannel.runAsync(ctx);
expect(channel).toBeNull();
});
it('still prompts if project has only one channel', async () => {
const ctx = createCtxMock();
jest.mocked(ChannelQuery.viewUpdateChannelsBasicInfoPaginatedOnAppAsync).mockResolvedValue({
edges: [{ node: testBasicChannelInfo, cursor: 'cursor' }],
pageInfo: {
hasPreviousPage: false,
hasNextPage: false,
},
});
jest.mocked(promptAsync).mockImplementation(async () => ({
item: testBasicChannelInfo,
}));
const selectChannel = new SelectChannel();
const channel = await selectChannel.runAsync(ctx);
expect(channel).toBe(testBasicChannelInfo);
expect(promptAsync).toHaveBeenCalledTimes(1);
});
it('returns the selected channel', async () => {
jest.mocked(ChannelQuery.viewUpdateChannelsBasicInfoPaginatedOnAppAsync).mockResolvedValue({
edges: [
{ node: testBasicChannelInfo, cursor: 'cursor' },
{ node: testBasicChannelInfo2, cursor: 'cursor' },
],
pageInfo: {
hasPreviousPage: false,
hasNextPage: false,
},
});
jest.mocked(promptAsync).mockImplementation(async () => ({
item: testBasicChannelInfo,
}));
const ctx = createCtxMock();
const selectChannel = new SelectChannel();
const channel = await selectChannel.runAsync(ctx);
expect(channel).toBe(testBasicChannelInfo);
});
it('returns the only available channel with a filter', async () => {
jest.mocked(ChannelQuery.viewUpdateChannelsBasicInfoPaginatedOnAppAsync).mockResolvedValue({
edges: [
{ node: testBasicChannelInfo, cursor: 'cursor' },
{ node: testBasicChannelInfo2, cursor: 'cursor' },
],
pageInfo: {
hasPreviousPage: false,
hasNextPage: false,
},
});
const ctx = createCtxMock();
const selectChannel = new SelectChannel({
filterPredicate: (channelInfo: UpdateChannelBasicInfoFragment) =>
channelInfo === testBasicChannelInfo,
});
const channel = await selectChannel.runAsync(ctx);
expect(channel).toBe(testBasicChannelInfo);
});
it('throws an error in Non-Interactive Mode', async () => {
const ctx = createCtxMock({
nonInteractive: true,
});
const selectChannel = new SelectChannel();
// dont fail if users are running in non-interactive mode
await expect(selectChannel.runAsync(ctx)).rejects.toThrowError(NonInteractiveError);
});
});
@@ -1,214 +0,0 @@
import { ChannelBasicInfo } from './utils';
// TODO(quin): move this into a common package with www
export type BranchMappingOperator =
| '=='
| '!='
| '<'
| '>'
| '<='
| '>='
| 'in'
| 'regex'
| 'hash_lt'
| 'hash_lte'
| 'hash_gt'
| 'hash_gte';
export type BranchMappingObject = {
clientKey: string;
branchMappingOperator: BranchMappingOperator;
operand: number | string | string[];
};
export type BranchMappingAlwaysTrue = 'true';
export type BranchMappingStatement =
| ['and' | 'or', ...BranchMappingNode[]]
| ['not', BranchMappingNode];
export type BranchMappingNode =
| BranchMappingAlwaysTrue
| BranchMappingObject
| BranchMappingStatement;
export type BranchMapping = {
version: number;
data: {
branchId: string;
branchMappingLogic: BranchMappingNode;
}[];
};
export type AlwaysTrueBranchMapping = {
version: number;
data: [
{
branchId: string;
branchMappingLogic: BranchMappingAlwaysTrue;
},
];
};
export type EmptyBranchMapping = {
version: number;
data: [];
};
export function getEmptyBranchMapping(): EmptyBranchMapping {
return {
version: 0,
data: [],
};
}
export function getAlwaysTrueBranchMapping(branchId: string): AlwaysTrueBranchMapping {
return {
version: 0,
data: [
{
branchId,
branchMappingLogic: 'true',
},
],
};
}
export function hasEmptyBranchMap(channelInfo: ChannelBasicInfo): boolean {
const branchMapping = getBranchMapping(channelInfo.branchMapping);
return isEmptyBranchMapping(branchMapping);
}
export function hasStandardBranchMap(channelInfo: ChannelBasicInfo): boolean {
const branchMapping = getBranchMapping(channelInfo.branchMapping);
return isAlwaysTrueBranchMapping(branchMapping);
}
export function getStandardBranchId(channelInfo: ChannelBasicInfo): string {
const branchMapping = getBranchMapping(channelInfo.branchMapping);
assertAlwaysTrueBranchMapping(branchMapping);
return getBranchIdFromStandardMapping(branchMapping);
}
export function isEmptyBranchMapping(
branchMapping: BranchMapping
): branchMapping is EmptyBranchMapping {
return branchMapping.data.length === 0;
}
export function isAlwaysTrueBranchMapping(
branchMapping: BranchMapping
): branchMapping is AlwaysTrueBranchMapping {
const numBranches = branchMapping.data.length;
if (numBranches !== 1) {
return false;
}
const branchMappingLogic = branchMapping.data[0].branchMappingLogic;
return isAlwaysTrue(branchMappingLogic);
}
function getBranchIdFromStandardMapping(branchMapping: AlwaysTrueBranchMapping): string {
return branchMapping.data[0].branchId;
}
export function getBranchIds(branchMapping: BranchMapping): string[] {
return branchMapping.data.map(data => data.branchId);
}
export function getBranchMapping(branchMappingString: string): BranchMapping {
try {
return JSON.parse(branchMappingString);
} catch {
throw new Error(`Could not parse branchMapping string into a JSON: "${branchMappingString}"`);
}
}
export function getNodesFromStatement(statement: BranchMappingStatement): BranchMappingNode[] {
return statement.slice(1) as BranchMappingNode[];
}
export function isAndStatement(
statement: BranchMappingStatement
): statement is BranchMappingStatement {
return statement[0] === 'and';
}
export function isStatement(node: BranchMappingNode): node is BranchMappingStatement {
return Array.isArray(node);
}
export function isNodeObject(node: BranchMappingNode): node is BranchMappingObject {
return typeof node === 'object' && !isStatement(node);
}
export function andStatement(nodes: BranchMappingNode[]): ['and', ...BranchMappingNode[]] {
return ['and', ...nodes];
}
export function isAlwaysTrue(node: BranchMappingNode): boolean {
return node === 'true';
}
export function alwaysTrue(): BranchMappingAlwaysTrue {
return 'true';
}
export function equalsOperator(): BranchMappingOperator {
return '==';
}
export function hashLtOperator(): BranchMappingOperator {
return 'hash_lt';
}
function isVersion(branchMapping: BranchMapping, version: number): boolean {
return branchMapping.version === version;
}
export function assertVersion(channelInfo: ChannelBasicInfo, version: number): void {
const branchMapping = getBranchMapping(channelInfo.branchMapping);
if (!isVersion(branchMapping, version)) {
throw new BranchMappingValidationError(
`Expected branch mapping version ${version}. Received: ${JSON.stringify(branchMapping)}`
);
}
}
export function assertStatement(node: BranchMappingNode): asserts node is BranchMappingStatement {
if (!isStatement(node)) {
throw new BranchMappingValidationError(
'Branch mapping node must be a statement. Received: ' + JSON.stringify(node)
);
}
}
export function assertNodeObject(node: BranchMappingNode): asserts node is BranchMappingObject {
if (!isNodeObject(node)) {
throw new BranchMappingValidationError(
'Branch mapping node must be an object. Received: ' + JSON.stringify(node)
);
}
}
export function assertNumber(operand: string | number | string[]): asserts operand is number {
if (typeof operand !== 'number') {
throw new BranchMappingValidationError(
'Expected a number. Received: ' + JSON.stringify(operand)
);
}
}
export function assertString(operand: string | number | string[]): asserts operand is string {
if (typeof operand !== 'string') {
throw new BranchMappingValidationError(
'Expected a string. Received: ' + JSON.stringify(operand)
);
}
}
function assertAlwaysTrueBranchMapping(
branchMapping: BranchMapping
): asserts branchMapping is AlwaysTrueBranchMapping {
if (!isAlwaysTrueBranchMapping(branchMapping)) {
throw new BranchMappingValidationError(
'Expected standard branch mapping. Received: ' + JSON.stringify(branchMapping)
);
}
}
export class BranchMappingValidationError extends Error {}
@@ -1,71 +0,0 @@
import chalk from 'chalk';
import { assertVersion, hasEmptyBranchMap, hasStandardBranchMap } from './branch-mapping';
import { UpdateChannelObject } from '../graphql/queries/ChannelQuery';
import Log from '../log';
import { getRollout, isRollout } from '../rollout/branch-mapping';
import {
FormattedBranchDescription,
formatBranch,
getUpdateGroupDescriptionsWithBranch,
} from '../update/utils';
/**
* Log all the branches associated with the channel and their most recent update group
*/
export function logChannelDetails(channel: UpdateChannelObject): void {
assertVersion(channel, 0);
const doesSupportMapping =
hasEmptyBranchMap(channel) || hasStandardBranchMap(channel) || isRollout(channel);
if (!doesSupportMapping) {
Log.log(chalk.dim('Custom branch mapping detected.'));
return;
}
const branchDescriptionByBranchId = getDescriptionByBranchId(channel);
const entries = Object.entries(branchDescriptionByBranchId);
if (entries.length === 0) {
Log.log(chalk.dim('No branches are pointed to this channel.'));
} else {
Log.log(
entries
.map(([_branchId, description]) => formatBranch(description))
.join(`\n\n${chalk.dim('———')}\n\n`)
);
}
}
export function getDescriptionByBranchId(
channel: UpdateChannelObject
): Record<string, FormattedBranchDescription> {
return channel.updateBranches.reduce(
(acc, branch) => {
const maybeRollout = isRollout(channel) ? getRollout(channel) : null;
let maybePercentOnBranch: number | undefined = undefined;
if (maybeRollout) {
maybePercentOnBranch =
maybeRollout.rolledOutBranchId === branch.id
? maybeRollout.percentRolledOut
: 100 - maybeRollout.percentRolledOut;
}
if (branch.updateGroups.length === 0) {
acc[branch.id] = { branch: branch.name, branchRolloutPercentage: maybePercentOnBranch };
return acc;
}
const updateGroupsWithBranchDescriptions = getUpdateGroupDescriptionsWithBranch(
branch.updateGroups
);
// display the most recent update group
const updateGroupWithBranchDescription = updateGroupsWithBranchDescriptions[0];
const { branch: branchName, ...updateGroup } = updateGroupWithBranchDescription;
acc[branch.id] = {
branch: branchName,
branchRolloutPercentage: maybePercentOnBranch,
update: updateGroup,
};
return acc;
},
{} as Record<string, FormattedBranchDescription>
);
}
+9 -125
View File
@@ -1,12 +1,6 @@
import chalk from 'chalk'; import chalk from 'chalk';
import { print } from 'graphql';
import gql from 'graphql-tag'; import gql from 'graphql-tag';
import { ChannelNotFoundError } from './errors';
import { logChannelDetails } from './print-utils';
import { ChannelBasicInfo } from './utils';
import { createUpdateBranchOnAppAsync } from '../branch/queries';
import { BranchNotFoundError } from '../branch/utils';
import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient'; import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient';
import { PaginatedQueryOptions } from '../commandUtils/pagination'; import { PaginatedQueryOptions } from '../commandUtils/pagination';
import { withErrorHandlingAsync } from '../graphql/client'; import { withErrorHandlingAsync } from '../graphql/client';
@@ -18,15 +12,14 @@ import {
} from '../graphql/generated'; } from '../graphql/generated';
import { BranchQuery, UpdateBranchOnChannelObject } from '../graphql/queries/BranchQuery'; import { BranchQuery, UpdateBranchOnChannelObject } from '../graphql/queries/BranchQuery';
import { ChannelQuery, UpdateChannelObject } from '../graphql/queries/ChannelQuery'; import { ChannelQuery, UpdateChannelObject } from '../graphql/queries/ChannelQuery';
import { UpdateChannelBasicInfoFragmentNode } from '../graphql/types/UpdateChannelBasicInfo'; import Log from '../log';
import Log, { learnMore } from '../log';
import { getDisplayNameForProjectIdAsync } from '../project/projectUtils';
import formatFields from '../utils/formatFields'; import formatFields from '../utils/formatFields';
import { printJsonOnlyOutput } from '../utils/json'; import { printJsonOnlyOutput } from '../utils/json';
import { import {
paginatedQueryWithConfirmPromptAsync, paginatedQueryWithConfirmPromptAsync,
paginatedQueryWithSelectPromptAsync, paginatedQueryWithSelectPromptAsync,
} from '../utils/queries'; } from '../utils/queries';
import { logChannelDetails } from './utils';
export const CHANNELS_LIMIT = 25; export const CHANNELS_LIMIT = 25;
@@ -89,9 +82,7 @@ export async function listAndRenderChannelsOnAppAsync(
queryChannelsOnAppAsync(graphqlClient, { limit, offset, appId: projectId }), queryChannelsOnAppAsync(graphqlClient, { limit, offset, appId: projectId }),
promptOptions: { promptOptions: {
title: 'Load more channels?', title: 'Load more channels?',
renderListItems: channels => { renderListItems: channels => renderPageOfChannels(channels, paginatedQueryOptions),
renderPageOfChannels(channels, paginatedQueryOptions);
},
}, },
}); });
} }
@@ -110,11 +101,7 @@ export async function listAndRenderBranchesAndUpdatesOnChannelAsync(
} }
): Promise<void> { ): Promise<void> {
const channel = await ChannelQuery.viewUpdateChannelAsync(graphqlClient, { appId, channelName }); const channel = await ChannelQuery.viewUpdateChannelAsync(graphqlClient, { appId, channelName });
renderChannelHeaderContent({ renderChannelHeaderContent({ channelName: channel.name, channelId: channel.id });
channelName: channel.name,
channelId: channel.id,
isPaused: channel.isPaused,
});
if (paginatedQueryOptions.nonInteractive) { if (paginatedQueryOptions.nonInteractive) {
const branches = await queryBranchesAndUpdateGroupsOnChannelAsync(graphqlClient, { const branches = await queryBranchesAndUpdateGroupsOnChannelAsync(graphqlClient, {
@@ -137,9 +124,8 @@ export async function listAndRenderBranchesAndUpdatesOnChannelAsync(
}), }),
promptOptions: { promptOptions: {
title: 'Load more channels?', title: 'Load more channels?',
renderListItems: branches => { renderListItems: branches =>
renderPageOfBranchesOnChannel(channel, branches, paginatedQueryOptions); renderPageOfBranchesOnChannel(channel, branches, paginatedQueryOptions),
},
}, },
}); });
} }
@@ -171,11 +157,7 @@ function renderPageOfChannels(
printJsonOnlyOutput({ currentPage }); printJsonOnlyOutput({ currentPage });
} else { } else {
for (const channel of currentPage) { for (const channel of currentPage) {
renderChannelHeaderContent({ renderChannelHeaderContent({ channelName: channel.name, channelId: channel.id });
channelName: channel.name,
channelId: channel.id,
isPaused: channel.isPaused,
});
Log.addNewLineIfNone(); Log.addNewLineIfNone();
logChannelDetails(channel); logChannelDetails(channel);
@@ -204,11 +186,9 @@ function renderPageOfBranchesOnChannel(
function renderChannelHeaderContent({ function renderChannelHeaderContent({
channelName, channelName,
channelId, channelId,
isPaused,
}: { }: {
channelName: string; channelName: string;
channelId: string; channelId: string;
isPaused: boolean;
}): void { }): void {
Log.addNewLineIfNone(); Log.addNewLineIfNone();
Log.log(chalk.bold('Channel:')); Log.log(chalk.bold('Channel:'));
@@ -216,7 +196,6 @@ function renderChannelHeaderContent({
formatFields([ formatFields([
{ label: 'Name', value: channelName }, { label: 'Name', value: channelName },
{ label: 'ID', value: channelId }, { label: 'ID', value: channelId },
{ label: 'Status', value: isPaused ? 'Paused' : 'Active' },
]) ])
); );
Log.addNewLineIfNone(); Log.addNewLineIfNone();
@@ -248,11 +227,11 @@ export async function createChannelOnAppAsync(
updateChannel { updateChannel {
createUpdateChannelForApp(appId: $appId, name: $name, branchMapping: $branchMapping) { createUpdateChannelForApp(appId: $appId, name: $name, branchMapping: $branchMapping) {
id id
...UpdateChannelBasicInfoFragment name
branchMapping
} }
} }
} }
${print(UpdateChannelBasicInfoFragmentNode)}
`, `,
{ {
appId, appId,
@@ -283,98 +262,3 @@ export async function ensureChannelExistsAsync(
} }
} }
} }
export async function doesChannelExistAsync(
graphqlClient: ExpoGraphqlClient,
{ appId, channelName }: { appId: string; channelName: string }
): Promise<boolean> {
try {
await ChannelQuery.viewUpdateChannelBasicInfoAsync(graphqlClient, {
appId,
channelName,
});
} catch (err) {
if (err instanceof ChannelNotFoundError) {
return false;
}
throw err;
}
return true;
}
/**
* Creates a channel and links it to a branch with the same name.
*
* @param appId the app ID, also known as the project ID
* @param channelName the name of the channel to create
* @param shouldPrintJson print only the JSON output
*/
export async function createAndLinkChannelAsync(
graphqlClient: ExpoGraphqlClient,
{
appId,
channelName,
shouldPrintJson,
}: { appId: string; channelName: string; shouldPrintJson?: boolean }
): Promise<ChannelBasicInfo> {
let branchId: string;
let hasCreatedBranch = false;
try {
const branch = await BranchQuery.getBranchByNameAsync(graphqlClient, {
appId,
name: channelName,
});
branchId = branch.id;
} catch (error) {
if (error instanceof BranchNotFoundError) {
const newBranch = await createUpdateBranchOnAppAsync(graphqlClient, {
appId,
name: channelName,
});
branchId = newBranch.id;
hasCreatedBranch = true;
} else {
throw error;
}
}
const {
updateChannel: { createUpdateChannelForApp: newChannel },
} = await createChannelOnAppAsync(graphqlClient, {
appId,
channelName,
branchId,
});
if (!newChannel) {
throw new Error(
`Could not create channel with name ${channelName} on project with id ${appId}`
);
}
if (shouldPrintJson) {
printJsonOnlyOutput(newChannel);
} else {
const projectDisplayName = await getDisplayNameForProjectIdAsync(graphqlClient, appId);
const learnMoreLink = learnMore('https://docs.expo.dev/eas-update/eas-cli/');
Log.addNewLineIfNone();
if (hasCreatedBranch) {
Log.withTick(
`Created update channel ${chalk.bold(`"${newChannel.name}"`)} and branch ${chalk.bold(
`"${newChannel.name}"`
)} on ${chalk.bold(projectDisplayName)} project. ${learnMoreLink}`
);
} else {
Log.withTick(
`Created update channel ${chalk.bold(`"${newChannel.name}"`)} on ${chalk.bold(
projectDisplayName
)} project and connected it with existing ${chalk.bold(
`"${newChannel.name}"`
)} branch. ${learnMoreLink}`
);
}
}
return newChannel;
}
+109 -27
View File
@@ -1,35 +1,117 @@
export type ChannelBasicInfo = { import assert from 'assert';
id: string; import chalk from 'chalk';
name: string;
branchMapping: string; import { UpdateChannelObject } from '../graphql/queries/ChannelQuery';
}; import Log from '../log';
export type BranchBasicInfo = { import {
id: string; FormattedBranchDescription,
name: string; formatBranch,
getUpdateGroupDescriptionsWithBranch,
} from '../update/utils';
export type BranchMapping = {
version: number;
data: {
branchId: string;
branchMappingLogic: {
operand: number;
clientKey: string;
branchMappingOperator: string;
} & string;
}[];
}; };
export type UpdateChannelInfoWithBranches<Branch extends BranchBasicInfo> = ChannelBasicInfo & { /**
updateBranches: Branch[]; * Get the branch mapping and determine whether it is a rollout.
}; * Ensure that the branch mapping is properly formatted.
*/
export function getBranchMapping(branchMappingString?: string): {
branchMapping: BranchMapping;
isRollout: boolean;
rolloutPercent?: number;
} {
if (!branchMappingString) {
throw new Error('Missing branch mapping.');
}
let branchMapping: BranchMapping;
try {
branchMapping = JSON.parse(branchMappingString);
} catch {
throw new Error(`Could not parse branchMapping string into a JSON: "${branchMappingString}"`);
}
assert(branchMapping, 'Branch Mapping must be defined.');
function getUpdateBranchNullable<Branch extends BranchBasicInfo>( if (branchMapping.version !== 0) {
channel: UpdateChannelInfoWithBranches<Branch>, throw new Error('Branch mapping must be version 0.');
branchId: string }
): Branch | null {
const updateBranches = channel.updateBranches; const isRollout = branchMapping.data.length === 2;
const updateBranch = updateBranches.find(branch => branch.id === branchId); const rolloutPercent = branchMapping.data[0]?.branchMappingLogic.operand;
return updateBranch ?? null;
switch (branchMapping.data.length) {
case 0:
break;
case 1:
if (branchMapping.data[0].branchMappingLogic !== 'true') {
throw new Error('Branch mapping logic for a single branch must be "true"');
}
break;
case 2:
if (branchMapping.data[0].branchMappingLogic.clientKey !== 'rolloutToken') {
throw new Error('Client key of initial branch mapping must be "rolloutToken"');
}
if (branchMapping.data[0].branchMappingLogic.branchMappingOperator !== 'hash_lt') {
throw new Error('Branch mapping operator of initial branch mapping must be "hash_lt"');
}
if (rolloutPercent == null) {
throw new Error('Branch mapping is missing a "rolloutPercent"');
}
if (branchMapping.data[1].branchMappingLogic !== 'true') {
throw new Error('Branch mapping logic for a the second branch of a rollout must be "true"');
}
break;
default:
throw new Error('Branch mapping data must have length less than or equal to 2.');
}
return { branchMapping, isRollout, rolloutPercent };
} }
export function getUpdateBranch<Branch extends BranchBasicInfo>( export function logChannelDetails(channel: UpdateChannelObject): void {
channel: UpdateChannelInfoWithBranches<Branch>, const { branchMapping, isRollout, rolloutPercent } = getBranchMapping(channel.branchMapping);
branchId: string if (branchMapping.data.length > 2) {
): Branch { throw new Error('Branch Mapping data must have length less than or equal to 2.');
const updateBranch = getUpdateBranchNullable(channel, branchId); }
if (!updateBranch) {
throw new Error( const rolloutBranchIds = branchMapping.data.map(data => data.branchId);
`Could not find branch with id "${branchId}" in branch-mapping of channel "${channel.name}"` const branchDescription = channel.updateBranches.flatMap(branch => {
const updateGroupWithBranchDescriptions = getUpdateGroupDescriptionsWithBranch(
branch.updateGroups
);
const isRolloutBranch = isRollout && rolloutBranchIds.includes(branch.id);
const isBaseBranch = rolloutBranchIds.length > 0 && rolloutBranchIds[0] === branch.id;
let rolloutPercentNumber: number | undefined = undefined;
if (isRolloutBranch) {
rolloutPercentNumber = isBaseBranch ? rolloutPercent! * 100 : (1 - rolloutPercent!) * 100;
}
return updateGroupWithBranchDescriptions.map(
({ branch, ...updateGroup }): FormattedBranchDescription => ({
branch,
branchRolloutPercentage: rolloutPercentNumber,
update: updateGroup,
})
);
});
if (branchDescription.length === 0) {
Log.log(chalk.dim('No branches are pointed to this channel.'));
} else {
Log.log(
branchDescription
.map(description => formatBranch(description))
.join(`\n\n${chalk.dim('———')}\n\n`)
); );
} }
return updateBranch;
} }
+23 -115
View File
@@ -1,72 +1,43 @@
import { Command } from '@oclif/core'; import { Command } from '@oclif/core';
import { CombinedError } from '@urql/core'; import { CombinedError } from '@urql/core';
import chalk from 'chalk';
import { GraphQLError } from 'graphql/error';
import nullthrows from 'nullthrows'; import nullthrows from 'nullthrows';
import AnalyticsContextField from './context/AnalyticsContextField';
import ContextField from './context/ContextField';
import DynamicLoggedInContextField from './context/DynamicLoggedInContextField';
import {
DynamicPrivateProjectConfigContextField,
DynamicPublicProjectConfigContextField,
} from './context/DynamicProjectConfigContextField';
import LoggedInContextField from './context/LoggedInContextField';
import MaybeLoggedInContextField from './context/MaybeLoggedInContextField';
import { OptionalPrivateProjectConfigContextField } from './context/OptionalPrivateProjectConfigContextField';
import { PrivateProjectConfigContextField } from './context/PrivateProjectConfigContextField';
import ProjectDirContextField from './context/ProjectDirContextField';
import { ProjectIdContextField } from './context/ProjectIdContextField';
import { ServerSideEnvironmentVariablesContextField } from './context/ServerSideEnvironmentVariablesContextField';
import SessionManagementContextField from './context/SessionManagementContextField';
import VcsClientContextField from './context/VcsClientContextField';
import { EasCommandError } from './errors';
import { import {
AnalyticsWithOrchestration, AnalyticsWithOrchestration,
CommandEvent, CommandEvent,
createAnalyticsAsync, createAnalyticsAsync,
} from '../analytics/AnalyticsManager'; } from '../analytics/AnalyticsManager';
import { EnvironmentVariableEnvironment } from '../graphql/generated';
import Log from '../log'; import Log from '../log';
import SessionManager from '../user/SessionManager'; import SessionManager from '../user/SessionManager';
import { Client } from '../vcs/vcs'; import AnalyticsContextField from './context/AnalyticsContextField';
import ContextField from './context/ContextField';
import { DynamicProjectConfigContextField } from './context/DynamicProjectConfigContextField';
import LoggedInContextField from './context/LoggedInContextField';
import MaybeLoggedInContextField from './context/MaybeLoggedInContextField';
import { OptionalProjectConfigContextField } from './context/OptionalProjectConfigContextField';
import ProjectConfigContextField from './context/ProjectConfigContextField';
import ProjectDirContextField from './context/ProjectDirContextField';
import SessionManagementContextField from './context/SessionManagementContextField';
import { EasCommandError } from './errors';
export type ContextInput< type ContextInput<
T extends { T extends {
[name: string]: any; [name: string]: any;
} = object, } = object
> = { > = {
[P in keyof T]: ContextField<T[P]>; [P in keyof T]: ContextField<T[P]>;
}; };
export type ContextOutput< type ContextOutput<
T extends { T extends {
[name: string]: any; [name: string]: any;
} = object, } = object
> = { > = {
[P in keyof T]: T[P]; [P in keyof T]: T[P];
}; };
type GetContextType<Type> = {
[Property in keyof Type]: any;
};
const BASE_GRAPHQL_ERROR_MESSAGE: string = 'GraphQL request failed.'; const BASE_GRAPHQL_ERROR_MESSAGE: string = 'GraphQL request failed.';
interface BaseGetContextAsyncArgs {
nonInteractive: boolean;
vcsClientOverride?: Client;
}
interface GetContextAsyncArgsWithRequiredServerSideEnvironmentArgument
extends BaseGetContextAsyncArgs {
withServerSideEnvironment: EnvironmentVariableEnvironment | null;
}
interface GetContextAsyncArgsWithoutServerSideEnvironmentArgument extends BaseGetContextAsyncArgs {
withServerSideEnvironment?: never;
}
export default abstract class EasCommand extends Command { export default abstract class EasCommand extends Command {
protected static readonly ContextOptions = { protected static readonly ContextOptions = {
/** /**
@@ -83,16 +54,8 @@ export default abstract class EasCommand extends Command {
MaybeLoggedIn: { MaybeLoggedIn: {
maybeLoggedIn: new MaybeLoggedInContextField(), maybeLoggedIn: new MaybeLoggedInContextField(),
}, },
/**
* Specify this context if the logged-in requirement is only necessary in a particular execution of the command.
*/
DynamicLoggedIn: {
// eslint-disable-next-line async-protect/async-suffix
getDynamicLoggedInAsync: new DynamicLoggedInContextField(),
},
/** /**
* Specify this context requirement if the command needs to mutate the user session. * Specify this context requirement if the command needs to mutate the user session.
* @deprecated Should not be used outside of session management commands, which currently only includes `login` and `logout`.
*/ */
SessionManagment: { SessionManagment: {
sessionManager: new SessionManagementContextField(), sessionManager: new SessionManagementContextField(),
@@ -102,7 +65,7 @@ export default abstract class EasCommand extends Command {
* run within a project directory, null otherwise. * run within a project directory, null otherwise.
*/ */
OptionalProjectConfig: { OptionalProjectConfig: {
optionalPrivateProjectConfig: new OptionalPrivateProjectConfigContextField(), projectConfig: new OptionalProjectConfigContextField(),
}, },
/** /**
* Require this command to be run in a project directory. Return the project directory in the context. * Require this command to be run in a project directory. Return the project directory in the context.
@@ -115,9 +78,7 @@ export default abstract class EasCommand extends Command {
*/ */
DynamicProjectConfig: { DynamicProjectConfig: {
// eslint-disable-next-line async-protect/async-suffix // eslint-disable-next-line async-protect/async-suffix
getDynamicPublicProjectConfigAsync: new DynamicPublicProjectConfigContextField(), getDynamicProjectConfigAsync: new DynamicProjectConfigContextField(),
// eslint-disable-next-line async-protect/async-suffix
getDynamicPrivateProjectConfigAsync: new DynamicPrivateProjectConfigContextField(),
}, },
/** /**
* Require the project to be identified and registered on server. Returns the project config in the context. * Require the project to be identified and registered on server. Returns the project config in the context.
@@ -126,7 +87,7 @@ export default abstract class EasCommand extends Command {
*/ */
ProjectConfig: { ProjectConfig: {
loggedIn: new LoggedInContextField(), loggedIn: new LoggedInContextField(),
privateProjectConfig: new PrivateProjectConfigContextField(), projectConfig: new ProjectConfigContextField(),
}, },
/** /**
* Analytics manager. Returns the analytics manager in the context for use by the command. * Analytics manager. Returns the analytics manager in the context for use by the command.
@@ -134,19 +95,6 @@ export default abstract class EasCommand extends Command {
Analytics: { Analytics: {
analytics: new AnalyticsContextField(), analytics: new AnalyticsContextField(),
}, },
Vcs: {
vcsClient: new VcsClientContextField(),
},
ServerSideEnvironmentVariables: {
// eslint-disable-next-line async-protect/async-suffix
getServerSideEnvironmentVariablesAsync: new ServerSideEnvironmentVariablesContextField(),
},
/**
* Require the project to be identified and registered on server. Returns the project ID evaluated from the app config.
*/
ProjectId: {
projectId: new ProjectIdContextField(),
},
}; };
/** /**
@@ -178,22 +126,10 @@ export default abstract class EasCommand extends Command {
protected async getContextAsync< protected async getContextAsync<
C extends { C extends {
[name: string]: any; [name: string]: any;
} = object, } = object
>( >(
commandClass: { contextDefinition: ContextInput<C> }, commandClass: { contextDefinition: ContextInput<C> },
{ { nonInteractive }: { nonInteractive: boolean }
nonInteractive,
vcsClientOverride,
// if specified and not null, the env vars from the selected environment will be fetched from the server
// to resolve dynamic config (if dynamic config context is used) and enable getServerSideEnvironmentVariablesAsync function (if server side environment variables context is used)
withServerSideEnvironment,
}: C extends
| GetContextType<typeof EasCommand.ContextOptions.ProjectConfig>
| GetContextType<typeof EasCommand.ContextOptions.DynamicProjectConfig>
| GetContextType<typeof EasCommand.ContextOptions.OptionalProjectConfig>
| GetContextType<typeof EasCommand.ContextOptions.ServerSideEnvironmentVariables>
? GetContextAsyncArgsWithRequiredServerSideEnvironmentArgument
: GetContextAsyncArgsWithoutServerSideEnvironmentArgument
): Promise<ContextOutput<C>> { ): Promise<ContextOutput<C>> {
const contextDefinition = commandClass.contextDefinition; const contextDefinition = commandClass.contextDefinition;
@@ -206,8 +142,6 @@ export default abstract class EasCommand extends Command {
nonInteractive, nonInteractive,
sessionManager: this.sessionManager, sessionManager: this.sessionManager,
analytics: this.analytics, analytics: this.analytics,
vcsClientOverride,
withServerSideEnvironment,
}), }),
]); ]);
} }
@@ -240,13 +174,13 @@ export default abstract class EasCommand extends Command {
action: `eas ${this.id}`, action: `eas ${this.id}`,
}); });
return await this.runAsync(); return this.runAsync();
} }
// eslint-disable-next-line async-protect/async-suffix // eslint-disable-next-line async-protect/async-suffix
override async finally(err: Error): Promise<any> { override async finally(err: Error): Promise<any> {
await this.analytics.flushAsync(); await this.analytics.flushAsync();
return await super.finally(err); return super.finally(err);
} }
protected override catch(err: Error): Promise<any> { protected override catch(err: Error): Promise<any> {
@@ -254,39 +188,13 @@ export default abstract class EasCommand extends Command {
if (err instanceof EasCommandError) { if (err instanceof EasCommandError) {
Log.error(err.message); Log.error(err.message);
} else if (err instanceof CombinedError && err?.graphQLErrors) { } else if (err instanceof CombinedError && err?.graphQLErrors) {
const cleanGQLErrorsMessage = err?.graphQLErrors const cleanMessage = err.message.replace('[GraphQL] ', '');
.map((graphQLError: GraphQLError) => {
const messageLine = graphQLError.message.replace('[GraphQL] ', '');
const requestIdLine = graphQLError.extensions?.requestId
? `\nRequest ID: ${err.graphQLErrors[0].extensions.requestId}`
: '';
const defaultMsg = `${messageLine}${requestIdLine}`;
if (graphQLError.extensions?.errorCode === 'UNAUTHORIZED_ERROR') {
return `${chalk.bold(
`You don't have the required permissions to perform this operation.`
)}\n\nThis can sometimes happen if you are logged in as incorrect user.\nRun ${chalk.bold(
'eas whoami'
)} to check the username you are logged in as.\nRun ${chalk.bold(
'eas login'
)} to change the account.\n\nOriginal error message: ${defaultMsg}`;
}
return defaultMsg;
})
.join('\n');
const cleanMessage = err.networkError
? `${cleanGQLErrorsMessage}\n${err.networkError.message}`
: cleanGQLErrorsMessage;
Log.error(cleanMessage); Log.error(cleanMessage);
baseMessage = BASE_GRAPHQL_ERROR_MESSAGE; baseMessage = BASE_GRAPHQL_ERROR_MESSAGE;
} else { } else {
Log.error(err.message); Log.error(err.message);
} }
Log.debug(err); Log.debug(err);
const sanitizedError = new Error(baseMessage); throw new Error(baseMessage);
sanitizedError.stack = err.stack;
throw sanitizedError;
} }
} }
@@ -1,6 +1,4 @@
import { CombinedError } from '@urql/core'; import { CombinedError } from '@urql/core';
import { GraphQLError } from 'graphql/error';
import { v4 as uuidv4 } from 'uuid';
import { AnalyticsWithOrchestration, createAnalyticsAsync } from '../../analytics/AnalyticsManager'; import { AnalyticsWithOrchestration, createAnalyticsAsync } from '../../analytics/AnalyticsManager';
import Log from '../../log'; import Log from '../../log';
@@ -19,7 +17,6 @@ jest.mock('../../analytics/AnalyticsManager', () => {
jest.mock('../../log'); jest.mock('../../log');
let originalProcessArgv: string[]; let originalProcessArgv: string[];
const mockRequestId = uuidv4();
beforeAll(() => { beforeAll(() => {
originalProcessArgv = process.argv; originalProcessArgv = process.argv;
@@ -140,76 +137,6 @@ describe(EasCommand.name, () => {
expect(logDebugSpy).toBeCalledWith(error); expect(logDebugSpy).toBeCalledWith(error);
}); });
it('logs the cleaned message with request ID if present', async () => {
const TestEasCommand = createTestEasCommand();
const logErrorSpy = jest.spyOn(Log, 'error');
const logDebugSpy = jest.spyOn(Log, 'debug');
const runAsyncMock = jest.spyOn(TestEasCommand.prototype as any, 'runAsync');
const graphQLError = new GraphQLError(
'Unexpected GraphQL error message',
null,
null,
null,
null,
null,
{
errorCode: 'UNKNOWN_GRAPHQL_ERROR',
requestId: mockRequestId,
}
);
const graphQLErrors = [graphQLError];
const error = new CombinedError({ graphQLErrors });
runAsyncMock.mockImplementation(() => {
throw error;
});
try {
await TestEasCommand.run();
} catch {}
expect(logErrorSpy).toBeCalledWith(
`Unexpected GraphQL error message\nRequest ID: ${mockRequestId}`
);
expect(logDebugSpy).toBeCalledWith(error);
});
it('logs the cleaned messages with request IDs if multiple GraphQL errors present', async () => {
const TestEasCommand = createTestEasCommand();
const logErrorSpy = jest.spyOn(Log, 'error');
const logDebugSpy = jest.spyOn(Log, 'debug');
const runAsyncMock = jest.spyOn(TestEasCommand.prototype as any, 'runAsync');
const graphQLErrors = [
new GraphQLError('Unexpected GraphQL error message', null, null, null, null, null, {
errorCode: 'UNKNOWN_GRAPHQL_ERROR',
requestId: mockRequestId,
}),
new GraphQLError('Other GraphQL error message', null, null, null, null, null, {
errorCode: 'OTHER_GRAPHQL_ERROR',
requestId: mockRequestId,
}),
new GraphQLError('Yet another GraphQL error message', null, null, null, null, null, {
errorCode: 'YET_ANOTHER_GRAPHQL_ERROR',
requestId: mockRequestId,
}),
];
const error = new CombinedError({ graphQLErrors });
runAsyncMock.mockImplementation(() => {
throw error;
});
try {
await TestEasCommand.run();
} catch {}
expect(logErrorSpy).toBeCalledWith(
'Unexpected GraphQL error message\n' +
`Request ID: ${mockRequestId}\n` +
'Other GraphQL error message\n' +
`Request ID: ${mockRequestId}\n` +
'Yet another GraphQL error message\n' +
`Request ID: ${mockRequestId}`
);
expect(logDebugSpy).toBeCalledWith(error);
});
it('re-throws the error with default base message', async () => { it('re-throws the error with default base message', async () => {
const TestEasCommand = createTestEasCommand(); const TestEasCommand = createTestEasCommand();
const runAsyncMock = jest.spyOn(TestEasCommand.prototype as any, 'runAsync'); const runAsyncMock = jest.spyOn(TestEasCommand.prototype as any, 'runAsync');
@@ -224,7 +151,7 @@ describe(EasCommand.name, () => {
} }
}); });
it('re-throws the error with a different default base message in case of CombinedError', async () => { it('re-throws the error with a different default base message in case of graphQLError', async () => {
const TestEasCommand = createTestEasCommand(); const TestEasCommand = createTestEasCommand();
const runAsyncMock = jest.spyOn(TestEasCommand.prototype as any, 'runAsync'); const runAsyncMock = jest.spyOn(TestEasCommand.prototype as any, 'runAsync');
runAsyncMock.mockImplementation(() => { runAsyncMock.mockImplementation(() => {
@@ -238,34 +165,6 @@ describe(EasCommand.name, () => {
expect((caughtError as Error).message).toEqual('GraphQL request failed.'); expect((caughtError as Error).message).toEqual('GraphQL request failed.');
} }
}); });
it('re-throws the error with a different default base message in case of CombinedError with multiple GraphQLErrors', async () => {
const TestEasCommand = createTestEasCommand();
const runAsyncMock = jest.spyOn(TestEasCommand.prototype as any, 'runAsync');
runAsyncMock.mockImplementation(() => {
const graphQLErrors = [
new GraphQLError('Unexpected GraphQL error message', null, null, null, null, null, {
errorCode: 'UNEXPECTED_GRAPHQL_ERROR',
requestId: mockRequestId,
}),
new GraphQLError('Other GraphQL error message', null, null, null, null, null, {
errorCode: 'OTHER_GRAPHQL_ERROR',
requestId: mockRequestId,
}),
new GraphQLError('Yet another GraphQL error message', null, null, null, null, null, {
errorCode: 'YET_ANOTHER_GRAPHQL_ERROR',
requestId: mockRequestId,
}),
];
throw new CombinedError({ graphQLErrors });
});
try {
await TestEasCommand.run();
} catch (caughtError) {
expect(caughtError).toBeInstanceOf(Error);
expect((caughtError as Error).message).toEqual('GraphQL request failed.');
}
});
}); });
}); });
}); });
-107
View File
@@ -1,107 +0,0 @@
import chalk from 'chalk';
import { ExpoGraphqlClient } from './context/contextUtils/createGraphqlClient';
import {
AppPlatform,
Build,
BuildFilter,
BuildFragment,
BuildStatus,
InputMaybe,
} from '../graphql/generated';
import { BuildQuery } from '../graphql/queries/BuildQuery';
import { RequestedPlatform, appPlatformEmojis } from '../platform';
const platformToAppPlatform: Record<
Exclude<RequestedPlatform, RequestedPlatform.All>,
AppPlatform
> = {
android: AppPlatform.Android,
ios: AppPlatform.Ios,
};
export async function ensureBuildExistsAsync(
graphqlClient: ExpoGraphqlClient,
buildId: string
): Promise<void> {
try {
await BuildQuery.byIdAsync(graphqlClient, buildId);
} catch {
throw new Error(`Couldn't find a build matching the id ${buildId}`);
}
}
export async function fetchBuildsAsync({
graphqlClient,
projectId,
filters,
}: {
graphqlClient: ExpoGraphqlClient;
projectId: string;
filters?: {
statuses?: BuildStatus[];
platform?: RequestedPlatform;
profile?: string;
hasFingerprint?: boolean;
};
}): Promise<BuildFragment[]> {
let builds: BuildFragment[];
const queryFilters: InputMaybe<BuildFilter> = {};
if (filters?.platform && filters.platform !== 'all') {
queryFilters['platform'] = platformToAppPlatform[filters.platform];
}
if (filters?.profile) {
queryFilters['buildProfile'] = filters.profile;
}
if (filters?.hasFingerprint) {
queryFilters['hasFingerprint'] = filters.hasFingerprint;
}
if (!filters?.statuses) {
builds = await BuildQuery.viewBuildsOnAppAsync(graphqlClient, {
appId: projectId,
offset: 0,
limit: 10,
filter: queryFilters ? queryFilters : undefined,
});
} else {
builds = (
await Promise.all(
filters.statuses.map(status =>
BuildQuery.viewBuildsOnAppAsync(graphqlClient, {
appId: projectId,
offset: 0,
limit: 10,
filter: queryFilters ? { ...queryFilters, status } : { status },
})
)
)
).flat();
}
builds.sort((buildA, buildB) => (buildA.createdAt > buildB.createdAt ? -1 : 1));
return builds;
}
export function formatBuild(
build: Pick<Build, 'id' | 'platform' | 'status' | 'createdAt'>
): string {
const platform = appPlatformEmojis[build.platform];
const startTime = new Date(build.createdAt).toLocaleString();
let statusText: string;
if (build.status === BuildStatus.New) {
statusText = 'new';
} else if (build.status === BuildStatus.InQueue) {
statusText = 'in queue';
} else if (build.status === BuildStatus.InProgress) {
statusText = 'in progress';
} else if (build.status === BuildStatus.Finished) {
statusText = 'finished';
} else if (build.status === BuildStatus.Errored) {
statusText = 'errored';
} else if ([BuildStatus.PendingCancel, BuildStatus.Canceled].includes(build.status)) {
statusText = 'canceled';
} else {
statusText = 'unknown';
}
const status = chalk.blue(statusText);
return `${platform} Started at: ${startTime}, Status: ${status}, Id: ${build.id}`;
}
@@ -1,5 +1,5 @@
import ContextField, { ContextOptions } from './ContextField';
import { Analytics } from '../../analytics/AnalyticsManager'; import { Analytics } from '../../analytics/AnalyticsManager';
import ContextField, { ContextOptions } from './ContextField';
export default class AnalyticsContextField extends ContextField<Analytics> { export default class AnalyticsContextField extends ContextField<Analytics> {
async getValueAsync({ analytics }: ContextOptions): Promise<Analytics> { async getValueAsync({ analytics }: ContextOptions): Promise<Analytics> {
@@ -1,17 +1,10 @@
import { Analytics } from '../../analytics/AnalyticsManager'; import { Analytics } from '../../analytics/AnalyticsManager';
import { EnvironmentVariableEnvironment } from '../../graphql/generated';
import SessionManager from '../../user/SessionManager'; import SessionManager from '../../user/SessionManager';
import { Client } from '../../vcs/vcs';
export interface ContextOptions { export interface ContextOptions {
sessionManager: SessionManager; sessionManager: SessionManager;
analytics: Analytics; analytics: Analytics;
nonInteractive: boolean; nonInteractive: boolean;
vcsClientOverride?: Client;
/**
* If specified, env variables from the selected environment will be fetched from the server and used to evaluate the dynamic config.
*/
withServerSideEnvironment?: EnvironmentVariableEnvironment | null;
} }
export default abstract class ContextField<T> { export default abstract class ContextField<T> {
@@ -1,36 +0,0 @@
import ContextField, { ContextOptions } from './ContextField';
import { ExpoGraphqlClient, createGraphqlClient } from './contextUtils/createGraphqlClient';
import { LoggedInAuthenticationInfo } from '../../user/SessionManager';
import { Actor } from '../../user/User';
import FeatureGateEnvOverrides from '../gating/FeatureGateEnvOverrides';
import FeatureGating from '../gating/FeatureGating';
export type DynamicLoggedInContextFn = () => Promise<{
actor: Actor;
featureGating: FeatureGating;
graphqlClient: ExpoGraphqlClient;
authenticationInfo: LoggedInAuthenticationInfo;
}>;
export default class DynamicLoggedInContextField extends ContextField<DynamicLoggedInContextFn> {
async getValueAsync({
nonInteractive,
sessionManager,
}: ContextOptions): Promise<DynamicLoggedInContextFn> {
return async () => {
const { actor, authenticationInfo } = await sessionManager.ensureLoggedInAsync({
nonInteractive,
});
const featureGateServerValues: { [key: string]: boolean } = actor?.featureGates ?? {};
const graphqlClient = createGraphqlClient(authenticationInfo);
return {
actor,
featureGating: new FeatureGating(featureGateServerValues, new FeatureGateEnvOverrides()),
graphqlClient,
authenticationInfo,
};
};
}
}
Loaded 100 of 589 files, more files were not shown because too many files have changed in this diff. Show more