Compare commits

..
Author SHA1 Message Date
Dominik Sokal 9b36ae0ef8 convert to ESM 2021-10-11 16:15:21 +02:00
Dominik Sokal 1a4cb107af add clean script 2021-10-11 16:12:42 +02:00
695 changed files with 39445 additions and 86196 deletions

No files matched your search

+5
View File
@@ -11,6 +11,7 @@ module.exports = {
ignoreDeclarationSort: true,
},
],
'flowtype/no-types-missing-file-annotation': 'off',
curly: 'warn',
'import/no-cycle': 'error',
'import/no-extraneous-dependencies': [
@@ -26,6 +27,10 @@ module.exports = {
name: 'lodash',
message: "Don't use lodash, it's heavy!",
},
{
name: 'node-fetch',
message: 'Use got instead, node-fetch is used only for the GraphQL client.',
},
],
},
],
Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

-4
View File
@@ -1,5 +1 @@
* text eol=lf
*.png binary
*.jpg binary
**/yarn.lock linguist-generated
+2 -13
View File
@@ -1,5 +1,5 @@
name: "\U0001F41B Bug Report in EAS CLI"
labels: 'needs review'
labels: 'bug'
description: 'You want to report a reproducible bug or regression in EAS CLI.'
body:
- type: markdown
@@ -14,11 +14,6 @@ body:
- type: markdown
attributes:
value: If you leave out sections there is a high likelihood your issue will be closed. If you have a question, not a bug report, please post it on our [forums](https://forums.expo.dev/) instead.
- type: input
attributes:
label: Build/Submit details page URL
description: If you're reporting an issue with EAS Build and/or EAS Submit, please provide the URL to the details page URL. The page can be only viewed by the Expo team members. Providing the URL is optional but it makes debugging the issue easier.
placeholder: https://expo.dev/accounts/ACCOUNT_NAME/projects/PROJECT_NAME/builds/BUILD_ID
- type: textarea
validations:
required: true
@@ -26,18 +21,12 @@ body:
label: Summary
description: Describe the issue in 1 or 2 sentences
placeholder: Clearly describe what the expected behavior is vs. what is actually happening. This should be as short as possible, while still communicating all the necessary information. If your summary is just 'X library/method isn't working', then you need to continue debugging yourself and provide more information.
- type: input
validations:
required: true
attributes:
label: Managed or bare?
description: Is your project managed or bare? Bare - Android and/or iOS native projects exist in your repo; Managed - you don't have native code (custom or 3rd party) in your project. See the [Workflows](https://docs.expo.dev/introduction/managed-vs-bare/) page for more information.
- type: textarea
validations:
required: true
attributes:
label: Environment
description: Run `npx expo-env-info` and `npx expo-doctor`, and paste the output here.
placeholder: Run `eas diagnostics` and paste the output here
- type: textarea
validations:
required: false
+1 -1
View File
@@ -1,5 +1,5 @@
name: "\U0001F64B Enhancement"
labels: 'enhancement,needs review'
labels: 'enhancement'
description: 'Want us to add something to EAS CLI?'
body:
- type: markdown
+1
View File
@@ -3,6 +3,7 @@
# 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.
- [ ] I've tagged the changelog entry with `[EAS BUILD API]` if it's a part of a breaking change to EAS Build API (only possible when updating `@expo/eas-build-job` package).
# Why
@@ -1,4 +0,0 @@
{
"printWidth": 100,
"singleQuote": true
}
@@ -1,53 +0,0 @@
name: notify-slack-on-fail-or-recover
inputs:
channel:
required: true
type: string
slack_webhook:
required: true
type: string
runs:
using: 'composite'
steps:
- name: Get previous workflow run status
uses: actions/github-script@v6
id: run-status
with:
script: |
const script = require('.github/internal-actions/notify-slack-on-fail-or-recover/main.js');
return await script({github, context, core});
- name: Commit subject
shell: bash
run: |
subject="$(git show --format=%s --quiet | sed 's/"/\\"/g')"
echo "${{ fromJSON(steps.run-status.outputs.result).previous }}"
echo "SUBJECT=$subject" >> "$GITHUB_ENV"
- name: Send Slack Success Notification
if: fromJSON(steps.run-status.outputs.result).previous == 'failure' && fromJSON(steps.run-status.outputs.result).current == 'success'
uses: rtCamp/action-slack-notify@v2
env:
SLACK_CHANNEL: ${{ inputs.channel }}
SLACK_COLOR: good
SLACK_ICON: https://github.com/expo-bot.png?size=48
SLACK_TITLE: 'Fixed, was failing'
SLACK_MESSAGE: ${{ env.SUBJECT }}
SLACK_USERNAME: 'EAS CLI CI'
SLACK_WEBHOOK: ${{ inputs.slack_webhook }}
SLACK_FOOTER: ''
- name: Send Slack Failure Notification
if: fromJSON(steps.run-status.outputs.result).current == 'failure'
uses: rtCamp/action-slack-notify@v2
env:
SLACK_CHANNEL: ${{ inputs.channel }}
SLACK_COLOR: danger
SLACK_ICON: https://github.com/expo-bot.png?size=48
SLACK_TITLE: 'Failed'
SLACK_MESSAGE: ${{ env.SUBJECT }}
SLACK_USERNAME: 'EAS CLI CI'
SLACK_WEBHOOK: ${{ inputs.slack_webhook }}
SLACK_FOOTER: ''
@@ -1,155 +0,0 @@
function updateOctokitInstance(github) {
updateGithubRestActions(github);
addConvenienceMethods(github);
}
function addConvenienceMethods(github) {
Object.assign(github, {
// checks if a run is a failure from the perspective of sending Slack
// notifications. Works for both runs and run attempts, which should be the
// same object shapes.
// When a run has not been completed, it fetches the jobs for the workflow
// to check if the job is "failing" instead of "failed".
async isFailedRun(run) {
console.log(`checking run: ${run.id}`);
console.log(`status: ${run.status}`);
console.log(`conclusion: ${run.conclusion}`);
console.log(`run_attempt: ${run.run_attempt}`);
if (run.status === 'completed') {
console.log('completed -- checking conclusion');
return run.conclusion === 'failure' || run.conclusion === 'timed_out';
}
const owner = run.repository.owner.login;
const repo = run.repository.name;
const jobsResp = await github.rest.actions.listJobsForWorkflowRun({
owner,
repo,
run_id: run.id,
});
console.log('checking jobs');
const jobs = jobsResp.data;
for (const job of jobs.jobs) {
if (job.conclusion === 'failure' || job.conclusion === 'timed_out') {
console.log('job failed');
return true;
} else if (job.status === 'completed') {
continue;
}
for (const step of job.steps) {
if (step.conclusion === 'failure') {
console.log('job step failed');
return true;
}
}
}
return false;
},
});
}
// github-script's Octokit/REST plugins are out of date, so we have to
// add our own custom endpoint method
// see https://octokit.github.io/rest.js/v18#custom-endpoint-methods for
// documentation
function updateGithubRestActions(github) {
Object.assign(github.rest.actions, {
getWorkflowRunAttempt: github.request.defaults({
method: 'GET',
url: '/repos/{owner}/{repo}/actions/runs/{run_id}/attempts/{attempt_number}',
params: {
owner: {
required: true,
type: 'string',
},
repo: {
required: true,
type: 'string',
},
run_id: {
required: true,
type: 'number',
},
attempt_number: {
required: true,
type: 'number',
},
},
}),
});
}
// Returns { current: 'failure' } | { current: 'success', previous: 'success' | 'failure' } to indicate the failure status
// of the current and previous runs
module.exports = async ({ github }) => {
const { GITHUB_REPOSITORY, GITHUB_RUN_ID, GITHUB_REF_NAME } = process.env;
const [owner, repo] = GITHUB_REPOSITORY.split('/');
updateOctokitInstance(github);
const workflowRunResp = await github.rest.actions.getWorkflowRun({
owner,
repo,
run_id: GITHUB_RUN_ID,
});
const currentRun = workflowRunResp.data;
const listWorkflowRunsResp = await github.rest.actions.listWorkflowRuns({
owner,
repo,
workflow_id: currentRun.workflow_id,
branch: GITHUB_REF_NAME,
});
const { workflow_runs } = listWorkflowRunsResp.data;
if (workflow_runs.length === 1 && workflow_runs[0].run_attempt === 1) {
return false;
}
const previousRuns = workflow_runs.filter(
(run) => run.status === 'completed' && run.conclusion !== 'cancelled'
);
if (await github.isFailedRun(currentRun)) {
return { current: 'failure', previous: null };
}
if (currentRun.run_attempt === 1 && previousRuns.length > 0) {
return {
current: 'success',
previous: (await github.isFailedRun(previousRuns[0])) ? 'failure' : 'success',
};
}
for (let i = currentRun.run_attempt - 1; i > 0; i--) {
const previousAttemptResp = await github.rest.actions.getWorkflowRunAttempt({
owner,
repo,
run_id: currentRun.id,
attempt_number: i,
});
const previousAttempt = previousAttemptResp.data;
if (
previousAttempt.conclusion === 'success' ||
previousAttempt.conclusion === 'failure' ||
previousAttempt.conclusion === 'timed_out'
) {
return {
current: 'success',
previous: (await github.isFailedRun(previousAttempt)) ? 'failure' : 'success',
};
}
}
if (previousRuns.length > 0) {
return {
current: 'success',
previous: (await github.isFailedRun(previousRuns[0])) ? 'failure' : 'success',
};
}
return { current: 'success', previous: 'success' };
};
@@ -1,12 +0,0 @@
{
"name": "notify-slack-on-fail-or-recover",
"version": "1.0.0",
"description": "A GitHub Action to notify Expo Slack of build failures and recovery.",
"main": "main.js",
"author": "Expo",
"license": "UNLICENSED",
"private": true,
"devDependencies": {
"prettier": "^2.5.1"
}
}
@@ -1,8 +0,0 @@
# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
# yarn lockfile v1
[email protected]:
version "2.5.1"
resolved "https://registry.yarnpkg.com/prettier/-/prettier-2.5.1.tgz#fff75fa9d519c54cf0fce328c1017d94546bc56a"
integrity sha512-vBZcPRUR5MZJwoyi3ZoyQlc1rXeEck8KgeC9AwwOn+exuxLxq5toTRDTSaVrXHxelDMHy9zlicw8u66yxoSUFg==
+4 -6
View File
@@ -10,11 +10,9 @@ jobs:
if: ${{ github.event.issue.pull_request && github.event.issue.user.login == github.event.comment.user.login && startsWith(github.event.comment.body, '/changelog-entry ') }}
steps:
- uses: actions/checkout@v2
- uses: actions/setup-node@v1
with:
token: ${{ secrets.EXPO_BOT_PAT }}
- uses: actions/setup-node@v2
with:
node-version: 18
node-version: '16'
- name: Checkout Pull Request
run: hub pr checkout ${{ github.event.issue.number }}
env:
@@ -30,8 +28,8 @@ jobs:
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:]]*//' )
CATEGORY=$( cut -d ' ' -f 1 <<< "$PARAMS" | xargs )
MESSAGE=$( cut -d ' ' -f 2- <<< "$PARAMS" | xargs )
yarn changelog-entry "$CATEGORY" "$MESSAGE"
env:
GITHUB_PR_AUTHOR: ${{ github.event.issue.user.login }}
+18
View File
@@ -0,0 +1,18 @@
name: Installer tests
on:
push:
branches: [main]
pull_request:
types: [opened, synchronize]
jobs:
test:
name: Run the installer
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [ubuntu-latest, macos-latest]
steps:
- uses: actions/checkout@v2
- run: bash bin/install.sh
- run: eas --version
-146
View File
@@ -1,146 +0,0 @@
name: Issue Triage
on:
issues:
types: [labeled]
jobs:
needs-repro:
runs-on: ubuntu-20.04
if: "${{ contains(github.event.label.name, 'incomplete issue: missing or invalid repro') }}"
steps:
- uses: actions/github-script@v3
with:
github-token: ${{ secrets.EXPO_BOT_GITHUB_TOKEN }}
script: |
github.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: `Hi there! It looks like your issue requires a minimal reproducible example, but it is invalid or absent. Please prepare such an example and share it in a new issue.
**The best way to get attention to your issue is to provide a clean and easy way for a developer to reproduce the issue on their own machine.** Please do not provide your entire project, or a project with more code than is necessary to reproduce the issue.
A side benefit of going through the process of narrowing down the minimal amount of code needed to reproduce the issue is that you may get lucky and discover that the bug is due to a mistake in your application code that you can quickly fix on your own.
### Resources
- ["How to create a Minimal, Reproducible Example"](https://stackoverflow.com/help/minimal-reproducible-example)
- ["How to narrow down the source of an error"](https://expo.fyi/manual-debugging)
### Common concerns
#### "I've only been able to reproduce it in private, proprietary code"
You may not have spent enough time narrowing down the root cause of the issue. Try out the techniques discussed in this [manual debugging guide](https://expo.fyi/manual-debugging) to learn how to isolate the problem from the rest of your codebase.
#### "I didn't have time to create one"
That's understandable, it can take some time to prepare. We ask that you hold off on filing an issue until you are able to fully complete the required fields in the issue template.
#### "You can reproduce it by yourself by creating a project and following these steps"
This is useful knowledge, but it's still valuable to have the resulting project that is produced from running the steps, where you have verified you can reproduce the issue.
`})
github.issues.update({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
state: 'closed'
})
needs-info:
runs-on: ubuntu-20.04
if: "${{ contains(github.event.label.name, 'incomplete issue: missing info') }}"
steps:
- uses: actions/github-script@v3
with:
github-token: ${{ secrets.EXPO_BOT_GITHUB_TOKEN }}
script: |
github.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: `Hello! There isn't enough information provided in your issue description for us to be able to help you. The best way to get help is to provide information to enable other developers to understand the issue and reproduce it on their own machines.
Please create a new issue and fill out the entire issue template to the best of your ability. Refer to the following resources for more information on how to create a good issue report.
### Resources
- ["How do I ask a good question?"](https://stackoverflow.com/help/how-to-ask)
- ["How to create a Minimal, Reproducible Example"](https://stackoverflow.com/help/minimal-reproducible-example)
- ["How to narrow down the source of an error"](https://expo.fyi/manual-debugging)
`})
github.issues.update({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
state: 'closed'
})
issue-accepted:
runs-on: ubuntu-20.04
if: github.event.label.name == 'issue accepted'
steps:
- uses: actions/github-script@v3
with:
github-token: ${{ secrets.EXPO_BOT_GITHUB_TOKEN }}
script: |
github.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: `Thank you for filing this issue!
This comment acknowledges we believe this may be a bug and there’s enough information to investigate it.
However, we can’t promise any sort of timeline for resolution. We prioritize issues based on severity, breadth of impact, and alignment with our roadmap. If you’d like to help move it more quickly, you can continue to investigate it more deeply and/or you can open a pull request that fixes the cause.
`})
question:
runs-on: ubuntu-20.04
if: "${{ contains(github.event.label.name, 'invalid issue: question') }}"
steps:
- uses: actions/github-script@v3
with:
github-token: ${{ secrets.EXPO_BOT_GITHUB_TOKEN }}
script: |
github.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: `Hello! Our GitHub issues are reserved for bug reports.
If you have a question about Expo or related tools, please post on our forums at https://forums.expo.dev/ or join our Discord at https://chat.expo.dev/.
### Resources
- ["How do I ask a good question?"](https://stackoverflow.com/help/how-to-ask)
- ["Join the community"](https://docs.expo.dev/next-steps/community/)
`})
github.issues.update({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
state: 'closed'
})
feature-request:
runs-on: ubuntu-20.04
if: "${{ contains(github.event.label.name, 'invalid issue: feature request') }}"
steps:
- uses: actions/github-script@v3
with:
github-token: ${{ secrets.EXPO_BOT_GITHUB_TOKEN }}
script: |
github.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: `Hello! Our GitHub issues are reserved for bug reports.
If you would like to request a feature, please post to https://expo.canny.io/feature-requests.
`})
github.issues.update({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
state: 'closed'
})
+27 -52
View File
@@ -25,30 +25,22 @@ jobs:
needs: release
steps:
- uses: actions/checkout@v2
- uses: actions/setup-node@v2
- uses: actions/setup-node@v1
with:
node-version: 18
node-version: '16'
- run: yarn install --frozen-lockfile --check-files
- run: yarn build
- name: Build tarballs
working-directory: ${{ github.workspace }}/packages/eas-cli
run: |
yarn pretarball-ci
yarn oclif pack:tarballs --no-xz --targets linux-x64,linux-arm
- id: x64
run: echo "filename=$(ls eas-*-x64.tar.gz)" >> $GITHUB_OUTPUT
working-directory: ${{ github.workspace }}/packages/eas-cli/dist
- id: arm
run: echo "filename=$(ls eas-*-arm.tar.gz)" >> $GITHUB_OUTPUT
working-directory: ${{ github.workspace }}/packages/eas-cli/dist
run: yarn oclif-dev pack --targets linux-x64,linux-arm
- name: Upload linux-x64 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.x64.outputs.filename }}
asset_name: ${{ steps.x64.outputs.filename }}
asset_path: packages/eas-cli/dist/eas-linux-x64.tar.gz
asset_name: eas-linux-x64.tar.gz
asset_content_type: application/gzip
- name: Upload linux-arm tarball
uses: actions/upload-release-asset@v1
@@ -56,8 +48,8 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ needs.release.outputs.upload_url }}
asset_path: packages/eas-cli/dist/${{ steps.arm.outputs.filename }}
asset_name: ${{ steps.arm.outputs.filename }}
asset_path: packages/eas-cli/dist/eas-linux-arm.tar.gz
asset_name: eas-linux-arm.tar.gz
asset_content_type: application/gzip
build-mac:
name: Build for macOS
@@ -66,27 +58,22 @@ jobs:
needs: release
steps:
- uses: actions/checkout@v2
- uses: actions/setup-node@v2
- uses: actions/setup-node@v1
with:
node-version: 18
node-version: '16'
- run: yarn install --frozen-lockfile --check-files
- run: yarn build
- name: Build tarballs
working-directory: ${{ github.workspace }}/packages/eas-cli
run: |
yarn pretarball-ci
yarn oclif pack:tarballs --no-xz --targets darwin-x64
- id: x64
run: echo "filename=$(ls eas-*-x64.tar.gz)" >> $GITHUB_OUTPUT
working-directory: ${{ github.workspace }}/packages/eas-cli/dist
run: yarn oclif-dev pack --targets darwin-x64
- name: Upload darwin-x64 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.x64.outputs.filename }}
asset_name: ${{ steps.x64.outputs.filename }}
asset_path: packages/eas-cli/dist/eas-darwin-x64.tar.gz
asset_name: eas-darwin-x64.tar.gz
asset_content_type: application/gzip
build-windows:
name: Build for Windows
@@ -94,29 +81,27 @@ jobs:
needs: release
steps:
- uses: actions/checkout@v2
- uses: actions/setup-node@v2
- uses: actions/setup-node@v1
with:
node-version: 18
node-version: '16'
- run: yarn install --frozen-lockfile --check-files
- run: sudo apt-get install nsis
- run: yarn build
- run: |
yarn pretarball-ci
yarn oclif pack:win
- run: yarn oclif-dev pack:win
working-directory: ${{ github.workspace }}/packages/eas-cli
- id: x64
run: echo "filename=$(ls eas-*-x64.exe)" >> $GITHUB_OUTPUT
working-directory: ${{ github.workspace }}/packages/eas-cli/dist/win32
run: echo "::set-output name=filename::$(ls eas-*-x64.exe)"
working-directory: ${{ github.workspace }}/packages/eas-cli/dist/win
- id: x86
run: echo "filename=$(ls eas-*-x86.exe)" >> $GITHUB_OUTPUT
working-directory: ${{ github.workspace }}/packages/eas-cli/dist/win32
run: echo "::set-output name=filename::$(ls eas-*-x86.exe)"
working-directory: ${{ github.workspace }}/packages/eas-cli/dist/win
- name: Upload x64 installer
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/win32/${{ steps.x64.outputs.filename }}
asset_path: packages/eas-cli/dist/win/${{ steps.x64.outputs.filename }}
asset_name: ${{ steps.x64.outputs.filename }}
asset_content_type: application/gzip
- name: Upload x86 installer
@@ -125,7 +110,7 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ needs.release.outputs.upload_url }}
asset_path: packages/eas-cli/dist/win32/${{ steps.x86.outputs.filename }}
asset_path: packages/eas-cli/dist/win/${{ steps.x86.outputs.filename }}
asset_name: ${{ steps.x86.outputs.filename }}
asset_content_type: application/gzip
publish-to-npm:
@@ -134,11 +119,11 @@ jobs:
needs: [build-linux, build-mac, build-windows]
steps:
- uses: actions/checkout@v2
- uses: actions/setup-node@v2
- uses: actions/setup-node@v1
with:
registry-url: 'https://registry.npmjs.org/'
scope: 'expo'
node-version: 18
node-version: '16'
- name: Install dependencies
run: yarn install --frozen-lockfile --check-files
- name: Build
@@ -147,25 +132,15 @@ jobs:
run: yarn lerna publish from-package --yes --no-verify-access
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_ACCESS_TOKEN }}
- name: Add latest-eas-build-staging tag
run: |
VERSION=$(cat lerna.json | jq -r .version)
echo //registry.npmjs.org/:_authToken=$NODE_AUTH_TOKEN > ~/.npmrc
npm dist-tag add eas-cli@$VERSION latest-eas-build-staging
echo "Run \"npm dist-tag add eas-cli@$VERSION latest-eas-build\" to promote it to production."
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_ACCESS_TOKEN }}
release-changelog:
name: Update changelog and publish release
runs-on: ubuntu-latest
needs: publish-to-npm
steps:
- uses: actions/checkout@v2
- uses: actions/setup-node@v1
with:
token: ${{ secrets.EXPO_BOT_PAT }}
- uses: actions/setup-node@v2
with:
node-version: 18
node-version: '16'
- name: Install dependencies
working-directory: ./scripts
run: yarn install --frozen-lockfile --check-files
@@ -199,9 +174,9 @@ jobs:
env:
SLACK_CHANNEL: eas-cli
SLACK_COLOR: ${{ job.status }}
SLACK_ICON: https://github.com/expo-bot.png?size=48
SLACK_ICON: https://github.com/expo-ci.png?size=48
SLACK_TITLE: 'Released eas-cli@${{ env.EAS_CLI_VERSION }}'
SLACK_MESSAGE: ${{ env.SLACK_CHANGELOG }}
SLACK_USERNAME: 'EAS CLI CI'
SLACK_USERNAME: 'Expo CI'
SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }}
SLACK_FOOTER: ''
+2 -4
View File
@@ -7,14 +7,12 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/setup-node@v2
- uses: actions/setup-node@v1
with:
node-version: 18
node-version: '16'
- uses: preactjs/compressed-size-action@v2
with:
build-script: 'build:tarballs:linux'
repo-token: '${{ secrets.GITHUB_TOKEN }}'
pattern: './packages/eas-cli/dist/*.tar.gz'
compression: 'none'
strip-hash: "v\\d+.\\d+.\\d+-\\w+-"
clean-script: 'clean'
-24
View File
@@ -1,24 +0,0 @@
name: Close inactive issues
on:
workflow_dispatch:
schedule:
- cron: "0 0 * * *"
jobs:
close-issues:
runs-on: ubuntu-latest
steps:
- uses: actions/stale@v4
with:
ascending: false
operations-per-run: 300
days-before-issue-stale: 30
days-before-issue-close: 7
stale-issue-label: "stale"
stale-issue-message: "This issue is stale because it has been open for 30 days with no activity. If there is no activity in the next 7 days, the issue will be closed."
close-issue-message: "This issue was closed because it has been inactive for 7 days since being marked as stale. Please open a new issue if you believe you are encountering a related problem."
days-before-pr-stale: -1
days-before-pr-close: -1
enable-statistics: true
exempt-issue-labels: "needs review"
exempt-all-assignees: true
-26
View File
@@ -1,26 +0,0 @@
name: Test scripts
defaults:
run:
working-directory: scripts
on:
push:
branches: [main]
paths:
- 'scripts/**'
pull_request:
paths:
- 'scripts/**'
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Setup node
uses: actions/setup-node@v2
with:
node-version: 18
- run: yarn install --frozen-lockfile --check-files
- run: yarn typecheck
+4 -22
View File
@@ -10,15 +10,15 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
node: ['14', '16', '18']
node: ['12', '14', '16']
include:
- node: '18'
- node: '16'
coverage: true
name: Test with Node ${{ matrix.node }}
steps:
- uses: actions/checkout@v2
- name: Setup node
uses: actions/setup-node@v2
uses: actions/setup-node@v1
with:
node-version: ${{ matrix.node }}
- run: yarn install --frozen-lockfile --check-files
@@ -27,27 +27,9 @@ jobs:
if: ${{ !matrix.coverage }}
- run: yarn test --coverage
if: ${{ matrix.coverage }}
env:
# Oclif seems to cause OOM errors when testing with coverage enabled.
# Remove this whenever we solved those OOM errors.
NODE_OPTIONS: --max-old-space-size=4096
- run: yarn lint --max-warnings=0
- run: yarn install --frozen-lockfile --check-files
working-directory: ./scripts
- run: yarn lint-changelog
- run: shellcheck bin/*.sh
- uses: codecov/codecov-action@v1
if: ${{ matrix.coverage }}
with:
token: ${{ secrets.CODECOV_TOKEN }}
notify-slack:
runs-on: ubuntu-latest
needs: test
name: Notify Slack
if: ${{ github.ref == 'refs/heads/main' && always() }}
steps:
- uses: actions/checkout@v2
- name: Notify Slack
uses: ./.github/internal-actions/notify-slack-on-fail-or-recover
with:
channel: eas-cli
slack_webhook: ${{ secrets.SLACK_WEBHOOK }}
+10 -17
View File
@@ -1,21 +1,14 @@
# Build directories
packages/*/build
packages/eas-cli/dist
packages/eas-cli/tmp
scripts/build
# Code editors
.idea
.history
.vscode
# macOS
.DS_Store
# Node
node_modules
*-debug.log
*-error.log
# Tests
**/build/**
!packages/eas-cli/src/commands/build/**
!packages/eas-cli/src/build/**
dist
/package-lock.json
tmp
node_modules
coverage
.history
.DS_Store
+2 -3
View File
@@ -2,8 +2,7 @@
"printWidth": 100,
"tabWidth": 2,
"singleQuote": true,
"bracketSameLine": true,
"jsxBracketSameLine": true,
"trailingComma": "es5",
"arrowParens": "avoid",
"endOfLine": "auto"
"arrowParens": "avoid"
}
+21 -1128
View File
File diff suppressed because it is too large. Load diff
+2 -11
View File
@@ -1,16 +1,7 @@
* @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-cli/src/submit @barthap @dsokal
packages/eas-cli/src/commands/submit.ts @barthap @dsokal
packages/eas-json @dsokal @wkozyra95
+9 -47
View File
@@ -1,48 +1,10 @@
# Development
## Releasing a new version
Set up an alias for the EAS CLI so you can try it in projects all around your computer. The project is compiled on the fly so you don't need to run a build (watch) command.
> The only exception is when you just cloned the repository or there have been changes in `packages/eas-json`. In that case, you'll have to run `yarn build` in the root.
Open your `.zshrc` or other config file and add:
```
alias easd="/PATH/TO/eas-cli/bin/run"
```
Then use it with `easd` like `easd build`.
If you're making changes to `packages/eas-json` or prefer/need to work with production code, start the build command in watch mode with:
```
yarn start
```
## 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.
- End `async` functions with `Async` like `runAsync`. This is just how we format functions at Expo.
- Utilize the unified `Log` module instead of `console.log`.
## Using local and staging API
For development against local API:
- Set up the API server as described at https://github.com/expo/universe/tree/main/server/www.
- Set the `EXPO_LOCAL=1` env variable either on every command run (e.g. `EXPO_LOCAL=1 easd ...`) or more permamently with `export EXPO_LOCAL=1` (works in a current shell session).
For development against staging API:
- Set the `EXPO_STAGING=1` env variable either on every command run (e.g. `EXPO_STAGING=1 easd ...`) or more permamently with `export EXPO_STAGING=1` (works in a current shell session).
## Working on local builds (`eas build --local`)
See https://github.com/expo/eas-build/blob/main/DEVELOPMENT.md for how to set up your environment when making changes to [`eas-cli-local-build-plugin`](https://github.com/expo/eas-build/tree/main/packages/local-build-plugin) and/or [`build-tools`](https://github.com/expo/eas-build/tree/main/packages/build-tools).
## Testing
Run `yarn test` either in the repository root or in a package directory that you're working on.
## Releasing
See [RELEASING.md](./RELEASING.md).
1. Run `yarn release` in the repository root folder.
2. Wait, the `release` workflow on GitHub Actions is going to take care of the rest (including publishing packages to npm and updating the changelog).
3. Check the changelog. If any of the entries have been tagged with `[EAS BUILD API]`
- Open `package.json` and note the version of `@expo/eas-build-job` used in the eas-cli.
- Find the `Publish` commit in [expo/eas-build](https://github.com/expo/eas-build/commit/) for that version. In most cases, it will be the latest `Publish` commit.
- Find the `eas-cli-local-build-plugin` version published in that commit.
![example publish commit](./.gh-assets/eas-build-publish-commit.png)
- Run `npm dist-tags add eas-cli-local-build-plugin@VERSION latest` with the version from the previous step.
-18
View File
@@ -1,18 +0,0 @@
# Releasing EAS CLI
1. Run `yarn release` in the repository root folder. The next version is chosen automatically based on the changelog entries. If you want to use different version, pass the version string as a positional argument to the command, e.g. `yarn release 1.2.3`.
2. That's it! GitHub Actions is going to take care of the rest. Watch the #eas-cli Slack channel for a successful release notification.
## Choosing the next version
As stated above, the next EAS CLI version is chosen automatically based on the existing changelog entries under the unreleased section in **CHANGELOG.md**.
The algorithm works as follows:
- 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, 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`.
+131
View File
@@ -0,0 +1,131 @@
#!/bin/bash
# This script downloads the latest release version of EAS CLI from GitHub and
# installs it in /usr/local/bin
{
# All code is inside a block to ensure the script executes only when
# downloaded completely.
set -euo pipefail
prefix="${EAS_PREFIX:-/usr/local}"
temp_dir="$(mktemp -d)"
if [ -t 1 ] && [ -z "${NO_COLOR:-}" ]
then
# Use colors when stdout is a terminal and NO_COLOR isn't set.
text_bold=$'\033[1m'
text_normal=$'\033[22m'
text_red=$'\033[31m'
text_default_color=$'\033[39m'
else
# Disable colors
text_bold=""
text_normal=""
text_red=""
text_default_color=""
fi
abort() {
echo -e "$text_bold$text_red$1$text_normal$text_default_color"
exit 1
}
install() {
needs_sudo=""
# Make sure path exists and can be written to by the current user.
if ! mkdir -p "$prefix/lib" || [[ ! -w "$prefix/lib" ]]
then
needs_sudo=true
fi
if ! mkdir -p "$prefix/bin" || [[ ! -w "$prefix/bin" ]]
then
needs_sudo=true
fi
if ! rm -rf "$prefix/lib/eas"
then
needs_sudo=true
fi
# Run commands with sudo if necessary.
if [ -n "$needs_sudo" ]
then
if [ "$EUID" -ne 0 ]
then
echo "Installing requires superuser access."
echo "The sudo command will prompt for your password."
fi
sudo rm -rf "$prefix/lib/eas"
sudo mv "$temp_dir/eas" "$prefix/lib/eas"
sudo ln -fs "$prefix/lib/eas/bin/eas" "$prefix/bin/eas"
else
mv "$temp_dir/eas" "$prefix/lib/eas"
ln -fs "$prefix/lib/eas/bin/eas" "$prefix/bin/eas"
fi
}
if [[ ! ":$PATH:" == *":$prefix/bin:"* ]]; then
abort "Your path is missing $prefix/bin, you need to add this to \$PATH use this installer."
fi
hardware="$(uname -m)"
case "$hardware" in
x86_64)
arch="x64"
;;
arm*)
arch="arm"
;;
*)
abort "Unsupported CPU architecture $hardware."
;;
esac
case "$(uname -s)" in
Darwin*)
platform="darwin"
if [[ "$arch" != "x64" ]]; then
abort "Unsupported CPU architecture $hardware on macOS."
fi
;;
Linux*)
platform="linux"
;;
*)
abort "This installer is supported only on macOS, Linux, and Windows Subsystem for Linux."
;;
esac
url="https://github.com/expo/eas-cli/releases/latest/download/eas-$platform-$arch.tar.gz"
echo "Installing EAS CLI from $url"
mkdir -p "$temp_dir"
cd "$temp_dir"
echo
echo "Downloading..."
if [ "$(command -v curl)" ]
then
curl --location --progress-bar "$url" | tar xz
else
wget -O- "$url" | tar xz
fi
# Move the installation over and link the executable (may require sudo)
install
# Test the bundled node binary and if it doesn't work remove it (fall back to
# node installed in path).
"$prefix/lib/eas/bin/node" -v > /dev/null || {
rm "$prefix/lib/eas/bin/node"
}
# Test the CLI
executable=$(command -v eas) || abort "Could not run eas after installation."
echo
echo -e "EAS CLI is installed to ${executable}"
eas --version
echo
echo -e "Type ${text_bold}eas --help${text_normal} to get started."
}
+1 -1
View File
@@ -1 +1 @@
../packages/eas-cli/bin/dev
../packages/eas-cli/bin/run
+1 -2
View File
@@ -1,6 +1,5 @@
{
"npmClient": "yarn",
"useWorkspaces": true,
"version": "3.9.3",
"$schema": "node_modules/lerna/schemas/lerna-schema.json"
"version": "0.31.1"
}
+20 -25
View File
@@ -1,40 +1,35 @@
{
"name": "eas-cli-root",
"private": true,
"workspaces": [
"packages/*"
],
"workspaces": ["packages/*"],
"scripts": {
"build": "lerna run build",
"build:tarballs:linux": "yarn build-for-size-check",
"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:tarballs:linux": "yarn build && yarn workspace eas-cli oclif-dev pack --targets linux-x64",
"typecheck": "lerna run typecheck",
"start": "lerna run watch --stream --parallel",
"start": "lerna run watch --parallel",
"watch": "yarn start",
"eas": "packages/eas-cli/bin/run",
"lint": "eslint .",
"lint-changelog": "./scripts/bin/run lint-changelog",
"release": "lerna run rebuild && ./scripts/bin/run release",
"lint": "eslint . --ext .ts --ext .js",
"release": "lerna version",
"test": "jest",
"clean": "lerna run clean && rm -rf node_modules coverage"
"clean": "lerna run clean"
},
"devDependencies": {
"@types/jest": "29.2.5",
"@types/node": "18.11.18",
"eslint": "8.31.0",
"eslint-config-universe": "11.1.1",
"eslint-plugin-async-protect": "3.0.0",
"@expo/oclif-dev-cli": "1.24.0-expo",
"@types/jest": "27.0.1",
"@types/node": "16.7.8",
"eslint": "7.32.0",
"eslint-config-universe": "8.0.0",
"eslint-plugin-async-protect": "1.1.0",
"eslint-plugin-node": "11.1.0",
"jest": "29.3.1",
"jest-watch-typeahead": "2.2.1",
"lerna": "6.4.1",
"oclif": "3.4.3",
"prettier": "2.8.2",
"ts-jest": "29.0.3",
"typescript": "4.9.4"
"jest": "27.1.0",
"jest-watch-typeahead": "0.6.4",
"lerna": "4.0.0",
"prettier": "2.3.2",
"ts-jest": "27.0.5",
"typescript": "4.4.2"
},
"volta": {
"node": "18.6.0",
"yarn": "1.22.19"
"node": "16.10.0",
"yarn": "1.22.15"
}
}
-18
View File
@@ -2,24 +2,6 @@ const rootEslintrc = require('../../.eslintrc.js');
module.exports = {
rules: {
'no-restricted-properties': [
'warn',
{
object: 'it',
property: 'only',
message: 'it.only should not be committed to main.',
},
{
object: 'test',
property: 'only',
message: 'test.only should not be committed to main.',
},
{
object: 'describe',
property: 'only',
message: 'describe.only should not be committed to main.',
},
],
'no-restricted-imports': [
'error',
{
+141 -990
View File
File diff suppressed because it is too large. Load diff
@@ -1,15 +1,13 @@
const fs = require('fs/promises');
#!/usr/bin/env sh
const path = './src/graphql/generated.ts';
OUTFILE="src/graphql/generated.ts"
(async () => {
const generatedCode = await fs.readFile(path, 'utf8');
const comment = `/**
content="/**
* This file was generated using GraphQL Codegen
* Command: yarn generate-graphql-code
* Run this during development for automatic type generation when editing GraphQL documents
* For more info and docs, visit https://graphql-code-generator.com/
*/\n\n`;
*/
"
await fs.writeFile(path, comment + generatedCode);
})();
printf '%s\n%s\n' "$content" "$(cat $OUTFILE)" > $OUTFILE
-17
View File
@@ -1,17 +0,0 @@
#!/usr/bin/env node
const oclif = require('@oclif/core');
const path = require('path');
const project = path.join(__dirname, '..', 'tsconfig.json');
// In dev mode -> use ts-node and dev plugins
process.env.NODE_ENV = 'development';
require('ts-node').register({ project });
// In dev mode, always show stack traces
oclif.settings.disableJsonFlag = true
// Start the CLI
oclif.run().then(oclif.flush).catch(oclif.Errors.handle);
-3
View File
@@ -1,3 +0,0 @@
@echo off
node "%~dp0\dev" %*
+3 -3
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env node
require('@oclif/core')
require('@oclif/command')
.run()
.then(require('@oclif/core/flush'))
.catch(require('@oclif/core/handle'));
.then(require('@oclif/command/flush'))
.catch(require('@oclif/errors/handle'));
+2 -4
View File
@@ -1,12 +1,10 @@
overwrite: true
schema: 'https://staging-api.expo.dev/graphql'
schema: 'https://staging.exp.host/--/graphql'
documents:
- 'src/graphql/**/!(*.d).{ts,tsx}'
- 'src/credentials/ios/api/graphql/**/!(*.d).{ts,tsx}'
- 'src/credentials/android/api/graphql/**/!(*.d).{ts,tsx}'
- 'src/commands/**/*.ts'
- 'src/branch/**/*.ts'
- 'src/channel/**/*.ts'
generates:
src/graphql/generated.ts:
plugins:
@@ -16,7 +14,7 @@ generates:
dedupeOperationSuffix: true
hooks:
afterOneFileWrite:
- 'node ./scripts/annotate-graphql-codegen.js'
- ./annotate-graphql-codegen.sh
./graphql.schema.json:
plugins:
- 'introspection'
+19365 -32473
View File
File diff suppressed because it is too large. Load diff
+77 -115
View File
@@ -1,130 +1,111 @@
{
"name": "eas-cli",
"type": "module",
"description": "EAS command line tool",
"version": "3.9.3",
"version": "0.31.1",
"author": "Expo <[email protected]>",
"bin": {
"eas": "./bin/run"
},
"bugs": "https://github.com/expo/eas-cli/issues",
"dependencies": {
"@expo/apple-utils": "1.2.0",
"@expo/code-signing-certificates": "0.0.5",
"@expo/config": "7.0.3",
"@expo/config-plugins": "5.0.4",
"@expo/config-types": "47.0.0",
"@expo/eas-build-job": "1.0.7",
"@expo/eas-json": "3.9.2",
"@expo/json-file": "8.2.37",
"@expo/multipart-body-parser": "1.1.0",
"@expo/osascript": "2.0.33",
"@expo/package-manager": "0.0.57",
"@expo/pkcs12": "0.0.8",
"@expo/plist": "0.0.20",
"@expo/plugin-help": "5.1.22",
"@expo/plugin-warn-if-update-available": "2.5.0",
"@expo/prebuild-config": "5.0.7",
"@expo/apple-utils": "0.0.0-alpha.25",
"@expo/config": "6.0.2",
"@expo/config-plugins": "4.0.2",
"@expo/eas-build-job": "0.2.52",
"@expo/eas-json": "^0.31.0",
"@expo/json-file": "8.2.33",
"@expo/pkcs12": "0.0.4",
"@expo/plist": "0.0.14",
"@expo/plugin-warn-if-update-available": "1.7.0",
"@expo/prebuild-config": "3.0.2",
"@expo/results": "1.0.0",
"@expo/rudder-sdk-node": "1.1.1",
"@expo/spawn-async": "1.7.0",
"@expo/steps": "1.0.8",
"@expo/rudder-sdk-node": "1.1.0",
"@expo/sdk-runtime-versions": "1.0.0",
"@expo/spawn-async": "1.5.0",
"@expo/timeago.js": "1.0.0",
"@oclif/core": "1.23.2",
"@oclif/plugin-autocomplete": "1.3.10",
"@segment/ajv-human-errors": "^2.1.2",
"@urql/core": "3.1.1",
"@urql/exchange-retry": "1.0.0",
"ajv": "8.11.0",
"ajv-formats": "2.1.1",
"better-opn": "3.0.2",
"@oclif/command": "1.8.0",
"@oclif/config": "1.17.0",
"@oclif/errors": "1.3.5",
"@oclif/plugin-help": "3.2.3",
"@urql/core": "2.3.1",
"@urql/exchange-retry": "0.3.0",
"chalk": "4.1.2",
"cli-progress": "3.11.2",
"dateformat": "4.6.3",
"dotenv": "16.0.3",
"cli-table3": "0.6.0",
"dateformat": "4.5.1",
"env-paths": "2.2.0",
"envinfo": "7.8.1",
"fast-deep-equal": "3.1.3",
"fast-glob": "3.2.12",
"fast-glob": "3.2.7",
"figures": "3.2.0",
"form-data": "4.0.0",
"fs-extra": "10.1.0",
"form-data": "3.0.0",
"fs-extra": "10.0.0",
"getenv": "1.0.0",
"gradle-to-js": "2.0.1",
"graphql": "16.6.0",
"graphql-tag": "2.12.6",
"https-proxy-agent": "5.0.1",
"ignore": "5.2.4",
"got": "11.8.2",
"gradle-to-js": "2.0.0",
"graphql": "15.5.1",
"graphql-tag": "2.12.5",
"ignore": "5.1.8",
"indent-string": "4.0.0",
"jks-js": "1.1.0",
"joi": "17.7.0",
"jks-js": "1.0.1",
"joi": "17.4.2",
"keychain": "1.3.0",
"log-symbols": "4.1.0",
"mime": "3.0.0",
"minimatch": "5.1.2",
"nanoid": "3.3.4",
"node-fetch": "2.6.7",
"node-forge": "1.3.1",
"mime": "2.5.2",
"minimatch": "3.0.4",
"nanoid": "3.1.25",
"node-fetch": "2.6.1",
"node-forge": "0.10.0",
"nullthrows": "1.1.1",
"ora": "5.1.0",
"pkg-dir": "4.2.0",
"pngjs": "6.0.0",
"promise-limit": "2.7.0",
"promise-retry": "2.0.1",
"prompts": "2.4.2",
"prompts": "2.3.2",
"qrcode-terminal": "0.12.0",
"resolve-from": "5.0.0",
"semver": "7.3.8",
"semver": "7.3.5",
"slash": "3.0.0",
"tar": "6.1.13",
"strip-ansi": "6.0.0",
"tar": "6.1.11",
"terminal-link": "2.1.1",
"tslib": "2.4.1",
"tslib": "2.3.1",
"turndown": "7.1.1",
"untildify": "4.0.0",
"uuid": "9.0.0",
"uuid": "8.3.2",
"wrap-ansi": "7.0.0"
},
"devDependencies": {
"@graphql-codegen/cli": "2.16.3",
"@graphql-codegen/introspection": "2.2.3",
"@graphql-codegen/typescript": "2.8.7",
"@graphql-codegen/typescript-operations": "2.5.12",
"@types/cli-progress": "3.11.0",
"@graphql-codegen/cli": "^1.19.2",
"@graphql-codegen/introspection": "^1.18.0",
"@graphql-codegen/typescript": "^1.17.11",
"@graphql-codegen/typescript-operations": "^1.17.10",
"@types/dateformat": "3.0.1",
"@types/envinfo": "7.8.1",
"@types/express": "4.17.15",
"@types/fs-extra": "9.0.13",
"@types/form-data": "^2.5.0",
"@types/fs-extra": "9.0.12",
"@types/getenv": "^1.0.0",
"@types/mime": "3.0.1",
"@types/node-fetch": "2.6.2",
"@types/node-forge": "1.3.1",
"@types/pngjs": "6.0.1",
"@types/promise-retry": "1.1.3",
"@types/prompts": "2.4.2",
"@types/semver": "7.3.13",
"@types/tar": "6.1.3",
"@types/tough-cookie": "4.0.2",
"@types/uuid": "9.0.0",
"@types/mime": "2.0.3",
"@types/node-fetch": "^2.5.7",
"@types/node-forge": "^0.9.5",
"@types/prompts": "^2.0.9",
"@types/semver": "7.3.8",
"@types/tar": "4.0.5",
"@types/tough-cookie": "4.0.1",
"@types/uuid": "8.3.1",
"@types/wrap-ansi": "3.0.0",
"axios": "0.27.2",
"axios": "0.21.4",
"eslint-plugin-graphql": "4.0.0",
"express": "4.18.2",
"memfs": "3.4.13",
"mockdate": "3.0.5",
"nock": "13.2.9",
"rimraf": "3.0.2",
"ts-deepmerge": "4.0.0",
"ts-mockito": "2.6.1",
"ts-node": "10.9.1",
"typescript": "4.9.4"
"memfs": "3.2.2",
"mockdate": "^3.0.2",
"nock": "^13.0.5",
"tempy": "0.7.0",
"ts-deepmerge": "1.0.8"
},
"engines": {
"node": ">=14.0.0"
"node": ">=12.0.0"
},
"files": [
"/bin",
"/build",
"/oclif.manifest.json",
"/schema"
"/oclif.manifest.json"
],
"homepage": "https://github.com/expo/eas-cli",
"keywords": [
@@ -137,16 +118,12 @@
"bin": "eas",
"commands": "./build/commands",
"plugins": [
"@oclif/plugin-autocomplete",
"@expo/plugin-help",
"@oclif/plugin-help",
"@expo/plugin-warn-if-update-available"
],
"topics": {
"account": {
"description": "manage account"
},
"branch": {
"description": "manage update branches"
"description": "manage your account"
},
"build": {
"description": "build app binaries"
@@ -155,13 +132,10 @@
"description": "manage update channels"
},
"device": {
"description": "manage Apple devices for Internal Distribution"
},
"metadata": {
"description": "manage store configuration"
"description": "manage your Apple devices for internal distribution"
},
"project": {
"description": "manage project"
"description": "manage your project"
},
"release": {
"description": "manage update releases"
@@ -178,11 +152,11 @@
},
"warn-if-update-available": {
"timeoutInDays": 0.5,
"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"
"message": "<%= chalk.bold('★') %> <%= chalk.bold(config.name + '@' + latest) %> <%= chalk.bold('is now available, please upgrade.') %>\n<%= chalk.dim('Proceeding with outdated version') %>\n"
},
"update": {
"node": {
"version": "18.6.0"
"version": "12.13.0"
},
"s3": {
"templates": {
@@ -192,30 +166,18 @@
}
}
}
},
"additionalHelpFlags": [
"-h"
],
"additionalVersionFlags": [
"-v"
]
}
},
"repository": "expo/eas-cli",
"scripts": {
"postpack": "rimraf oclif.manifest.json",
"prepack": "yarn rebuild && node ./scripts/prepack.js",
"rebuild": "rimraf build && yarn build",
"pretarball-ci": "./scripts/pretarball-ci.sh",
"postpack": "rm -f oclif.manifest.json",
"prepack": "rm -rf build && yarn build && yarn oclif-dev manifest && yarn oclif-dev readme && node patch-readme",
"build": "tsc --project tsconfig.build.json",
"watch": "yarn build --watch --preserveWatchOutput",
"typecheck": "tsc",
"test": "jest",
"version": "yarn oclif readme && node scripts/patch-readme && git add README.md",
"version": "yarn oclif-dev readme && node patch-readme && git add README.md",
"generate-graphql-code": "graphql-codegen --config graphql-codegen.yml",
"clean": "rimraf dist build tmp node_modules yarn-error.log"
},
"volta": {
"node": "18.6.0",
"yarn": "1.22.19"
"clean": "rm -rf build node_modules"
}
}
@@ -1,7 +1,7 @@
const fs = require('fs/promises');
const { promises: fs } = require('fs');
(async () => {
// Patch `oclif readme` path and link generation
// Patch `oclif-dev readme` path and link generation
let readmeContent = await fs.readFile('README.md', 'utf8');
readmeContent = readmeContent.replace(/\[build\//g, '[src/');
readmeContent = readmeContent.replace(/build\/commands/g, 'packages/eas-cli/src/commands');
-793
View File
@@ -1,793 +0,0 @@
{
"$schema": "http://json-schema.org/draft-06/schema",
"type": "object",
"definitions": {
"apple": {
"AppleKidsAge": {
"enum": [
"FIVE_AND_UNDER",
"SIX_TO_EIGHT",
"NINE_TO_ELEVEN"
]
},
"AppleRating": {
"enum": [
"NONE",
"INFREQUENT_OR_MILD",
"FREQUENT_OR_INTENSE"
]
},
"AppleCategory": {
"type": "array",
"description": "App Store categories for the app, can add up to two categories. The first item in this list is the primary category, the second is the secondary and optional category. `STICKERS` and `GAMES` categories can add up to two additional subcategories, written as nested array.",
"markdownDescription": "App Store categories for the app, can add up to two categories. The first item in this list is the primary category, the second is the secondary and optional category. `STICKERS` and `GAMES` categories can add up to two additional subcategories, written as nested array. E.g.\n\n- `[\"SPORTS\"]`\n- `[\"SPORTS\", \"LIFESTYLE\"]`\n- `[[\"GAMES\", \"GAMES_CARD\"], \"ENTERTAINMENT\"]`\n- `[\"GAMES\", \"WEATHER\"]`",
"minItems": 1,
"maxItems": 2,
"uniqueItems": true,
"defaultSnippets": [
{
"label": "Basic categories",
"description": "Add two simple categories",
"body": [
"$1",
"$2"
]
},
{
"label": "Category with subcategories",
"description": "Add a category containing subcategories",
"body": [
[
"${1|GAMES,STICKERS|}",
"$3",
"$4"
]
]
}
],
"items": {
"oneOf": [
{
"$ref": "#/definitions/apple/AppleCategory/categories/root"
},
{
"$ref": "#/definitions/apple/AppleCategory/categories/parent"
},
{
"type": "array",
"description": "The GAMES category can have additional subcategories",
"minItems": 1,
"maxItems": 3,
"uniqueItems": true,
"items": [
{
"const": "GAMES"
},
{
"$ref": "#/definitions/apple/AppleCategory/categories/games"
},
{
"$ref": "#/definitions/apple/AppleCategory/categories/games"
}
]
},
{
"type": "array",
"description": "The STICKERS category can have additional subcategories",
"minItems": 1,
"maxItems": 3,
"uniqueItems": true,
"items": [
{
"const": "STICKERS"
},
{
"$ref": "#/definitions/apple/AppleCategory/categories/stickers"
},
{
"$ref": "#/definitions/apple/AppleCategory/categories/stickers"
}
]
}
]
},
"categories": {
"root": {
"enum": [
"BUSINESS",
"SOCIAL_NETWORKING",
"MEDICAL",
"FOOD_AND_DRINK",
"EDUCATION",
"BOOKS",
"SPORTS",
"FINANCE",
"REFERENCE",
"GRAPHICS_AND_DESIGN",
"DEVELOPER_TOOLS",
"HEALTH_AND_FITNESS",
"MUSIC",
"WEATHER",
"TRAVEL",
"ENTERTAINMENT",
"LIFESTYLE",
"MAGAZINES_AND_NEWSPAPERS",
"UTILITIES",
"SHOPPING",
"PRODUCTIVITY",
"NEWS",
"PHOTO_AND_VIDEO",
"NAVIGATION"
]
},
"parent": {
"enum": [
"GAMES",
"STICKERS"
]
},
"games": {
"enum": [
"GAMES_SPORTS",
"GAMES_WORD",
"GAMES_MUSIC",
"GAMES_ADVENTURE",
"GAMES_ACTION",
"GAMES_ROLE_PLAYING",
"GAMES_CASUAL",
"GAMES_BOARD",
"GAMES_TRIVIA",
"GAMES_CARD",
"GAMES_PUZZLE",
"GAMES_CASINO",
"GAMES_STRATEGY",
"GAMES_SIMULATION",
"GAMES_RACING",
"GAMES_FAMILY"
]
},
"stickers": {
"enum": [
"STICKERS_PLACES_AND_OBJECTS",
"STICKERS_EMOJI_AND_EXPRESSIONS",
"STICKERS_CELEBRATIONS",
"STICKERS_CELEBRITIES",
"STICKERS_MOVIES_AND_TV",
"STICKERS_SPORTS_AND_ACTIVITIES",
"STICKERS_EATING_AND_DRINKING",
"STICKERS_CHARACTERS",
"STICKERS_ANIMALS",
"STICKERS_FASHION",
"STICKERS_ART",
"STICKERS_GAMING",
"STICKERS_KIDS_AND_FAMILY",
"STICKERS_PEOPLE",
"STICKERS_MUSIC"
]
}
}
},
"AppleInfo": {
"type": "object",
"additionalProperties": false,
"required": [
"title",
"privacyPolicyUrl"
],
"defaultSnippets": [
{
"label": "Basic",
"description": "Create the minimum required locale properties",
"body": {
"title": "${1:App title}",
"privacyPolicyUrl": "https://${3:www.}"
}
},
{
"label": "Full",
"description": "Create all available locale properties",
"body": {
"title": "${1:App title}",
"subtitle": "${2:Subtitle for your app}",
"description": "${3:A longer description of what your app does}",
"keywords": [
"${4:keyword}"
],
"releaseNotes": "${5:Bug fixes and improved stability}",
"promoText": "${6:Short tagline for your app}",
"marketingUrl": "https://${7:www.}",
"supportUrl": "https://${8:www.}",
"privacyPolicyUrl": "https://${9:www.}",
"privacyChoicesUrl": "https://${10:www.}"
}
}
],
"properties": {
"title": {
"type": "string",
"description": "Name of the app in the store. This should be similar to the installed app name.",
"minLength": 2,
"maxLength": 30,
"meta": {
"storeInfo": "The name will be reviewed before it is made available on the App Store."
}
},
"subtitle": {
"type": "string",
"description": "Subtext for the app. Example: 'A Fun Game For Friends'",
"maxLength": 30,
"meta": {
"storeInfo": "The subtitle will be reviewed before it is made available on the App Store."
}
},
"description": {
"type": "string",
"description": "Main description of what the app does",
"minLength": 10,
"maxLength": 4000,
"meta": {
"versioned": true,
"liveEdits": true
}
},
"keywords": {
"type": "array",
"description": "List of keywords to help users find the app in the App Store",
"uniqueItems": true,
"items": {
"maxLength": 100,
"type": "string"
},
"meta": {
"versioned": true
}
},
"releaseNotes": {
"type": "string",
"description": "Changes since the last public version",
"maxLength": 4000,
"meta": {
"versioned": true,
"liveEdits": true
}
},
"promoText": {
"type": "string",
"description": "Short tagline for the app",
"maxLength": 170,
"meta": {
"versioned": true,
"liveEdits": true,
"aso": false
}
},
"marketingUrl": {
"type": "string",
"description": "URL to the app marketing page",
"maxLength": 255,
"meta": {
"versioned": true,
"liveEdits": true
}
},
"supportUrl": {
"type": "string",
"description": "URL to the app support page",
"maxLength": 255,
"meta": {
"versioned": true,
"liveEdits": true
}
},
"privacyPolicyText": {
"type": "string",
"description": "Privacy policy for Apple TV"
},
"privacyPolicyUrl": {
"type": "string",
"description": "A URL that links to your privacy policy. A privacy policy is required for all apps.",
"maxLength": 255,
"meta": {
"versioned": true,
"liveEdits": true
}
},
"privacyChoicesUrl": {
"type": "string",
"description": "A URL where users can modify and delete the data collected from the app, or decide how their data is used and shared.",
"maxLength": 255,
"meta": {
"versioned": true,
"liveEdits": true,
"optional": true
}
}
}
},
"AppleAdvisory": {
"type": "object",
"additionalProperties": false,
"required": [
"alcoholTobaccoOrDrugUseOrReferences",
"contests",
"gamblingSimulated",
"horrorOrFearThemes",
"matureOrSuggestiveThemes",
"medicalOrTreatmentInformation",
"profanityOrCrudeHumor",
"sexualContentGraphicAndNudity",
"sexualContentOrNudity",
"violenceCartoonOrFantasy",
"violenceRealistic",
"violenceRealisticProlongedGraphicOrSadistic",
"gambling",
"unrestrictedWebAccess",
"kidsAgeBand",
"seventeenPlus"
],
"defaultSnippets": [
{
"label": "Basic",
"description": "Create an advisory that disables all age-rated properties, for when your app does nothing",
"body": {
"alcoholTobaccoOrDrugUseOrReferences": "NONE",
"contests": "NONE",
"gamblingSimulated": "NONE",
"horrorOrFearThemes": "NONE",
"matureOrSuggestiveThemes": "NONE",
"medicalOrTreatmentInformation": "NONE",
"profanityOrCrudeHumor": "NONE",
"sexualContentGraphicAndNudity": "NONE",
"sexualContentOrNudity": "NONE",
"violenceCartoonOrFantasy": "NONE",
"violenceRealistic": "NONE",
"violenceRealisticProlongedGraphicOrSadistic": "NONE",
"gambling": false,
"unrestrictedWebAccess": false,
"kidsAgeBand": null,
"seventeenPlus": false
}
},
{
"label": "Full",
"description": "Create an advisory that enables some of the age-rated properties",
"body": {
"alcoholTobaccoOrDrugUseOrReferences": "${1|NONE,INFREQUENT_OR_MILD,FREQUENT_OR_INTENSE|}",
"contests": "${2|NONE,INFREQUENT_OR_MILD,FREQUENT_OR_INTENSE|}",
"gamblingSimulated": "${3|NONE,INFREQUENT_OR_MILD,FREQUENT_OR_INTENSE|}",
"horrorOrFearThemes": "${4|NONE,INFREQUENT_OR_MILD,FREQUENT_OR_INTENSE|}",
"matureOrSuggestiveThemes": "${5|NONE,INFREQUENT_OR_MILD,FREQUENT_OR_INTENSE|}",
"medicalOrTreatmentInformation": "${6|NONE,INFREQUENT_OR_MILD,FREQUENT_OR_INTENSE|}",
"profanityOrCrudeHumor": "${7|NONE,INFREQUENT_OR_MILD,FREQUENT_OR_INTENSE|}",
"sexualContentGraphicAndNudity": "${8|NONE,INFREQUENT_OR_MILD,FREQUENT_OR_INTENSE|}",
"sexualContentOrNudity": "${9|NONE,INFREQUENT_OR_MILD,FREQUENT_OR_INTENSE|}",
"violenceCartoonOrFantasy": "${10|NONE,INFREQUENT_OR_MILD,FREQUENT_OR_INTENSE|}",
"violenceRealistic": "${11|NONE,INFREQUENT_OR_MILD,FREQUENT_OR_INTENSE|}",
"violenceRealisticProlongedGraphicOrSadistic": "${12|NONE,INFREQUENT_OR_MILD,FREQUENT_OR_INTENSE|}",
"unrestrictedWebAccess": false,
"gambling": false,
"kidsAgeBand": null,
"seventeenPlus": false
}
}
],
"properties": {
"alcoholTobaccoOrDrugUseOrReferences": {
"$ref": "#/definitions/apple/AppleRating",
"description": "Does the app contain alcohol, tobacco, or drug use or references?"
},
"contests": {
"$ref": "#/definitions/apple/AppleRating",
"description": "Does the app contain contests?"
},
"gamblingSimulated": {
"$ref": "#/definitions/apple/AppleRating",
"description": "Does the app contain simulated gambling?"
},
"medicalOrTreatmentInformation": {
"$ref": "#/definitions/apple/AppleRating",
"description": "Does the app contain medical or treatment information?"
},
"profanityOrCrudeHumor": {
"$ref": "#/definitions/apple/AppleRating",
"description": "Does the app contain profanity or crude humor?"
},
"sexualContentGraphicAndNudity": {
"$ref": "#/definitions/apple/AppleRating",
"description": "Does the app contain graphic sexual content and nudity?"
},
"sexualContentOrNudity": {
"$ref": "#/definitions/apple/AppleRating",
"description": "Does the app contain sexual content or nudity?"
},
"horrorOrFearThemes": {
"$ref": "#/definitions/apple/AppleRating",
"description": "Does the app contain horror or fear themes?"
},
"matureOrSuggestiveThemes": {
"$ref": "#/definitions/apple/AppleRating",
"description": "Does the app contain mature or suggestive themes?"
},
"violenceCartoonOrFantasy": {
"$ref": "#/definitions/apple/AppleRating",
"description": "Does the app contain cartoon or fantasy violence?"
},
"violenceRealisticProlongedGraphicOrSadistic": {
"$ref": "#/definitions/apple/AppleRating",
"description": "Does the app contain prolonged graphic or sadistic realistic violence?"
},
"violenceRealistic": {
"$ref": "#/definitions/apple/AppleRating",
"description": "Does the app contain realistic violence?"
},
"unrestrictedWebAccess": {
"type": "boolean",
"description": "Does your app contain unrestricted web access, such as with an embedded browser?"
},
"gambling": {
"type": "boolean",
"description": "Does your app contain gambling?"
},
"kidsAgeBand": {
"oneOf": [
{
"type": "null"
},
{
"$ref": "#/definitions/apple/AppleKidsAge"
}
],
"description": "When parents visit the Kids category on the App Store, they expect the apps they find will protect their children’s data, provide only age-appropriate content, and require a parental gate in order to link out of the app, request permissions, or present purchasing opportunities. It’s critical that no personally identifiable information or device information be transmitted to third parties, and that advertisements are human-reviewed for age appropriateness in order to be displayed.\n@see https://developer-mdn.apple.com/news/?id=091202019a",
"markdownDescription": "When parents visit the Kids category on the App Store, they expect the apps they find will protect their children’s data, provide only age-appropriate content, and require a parental gate in order to link out of the app, request permissions, or present purchasing opportunities. It’s critical that no personally identifiable information or device information be transmitted to third parties, and that advertisements are human-reviewed for age appropriateness in order to be displayed.\n\n[Learn more](https://developer-mdn.apple.com/news/?id=091202019a)"
},
"seventeenPlus": {
"type": "boolean",
"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)"
}
}
},
"AppleRelease": {
"type": "object",
"description": "Configure how the app is released in the app store.",
"additionalProperties": false,
"properties": {
"automaticRelease": {
"description": "If and how the app should automatically be released when approved by App Store review.",
"markdownDescription": "If and how the app should automatically be released when approved by App Store review.\n- `false` - Manually release the app after store approval. (default behavior)\n- `true` - Automatically release after store approval.\n- `Date` - Automatically schedule release on this date, after store approval.\n\nApple does not guarantee that your app is available at the chosen scheduled release date.",
"oneOf": [
{
"type": "string",
"format": "date-time"
},
{
"type": "boolean"
}
],
"defaultSnippets": [
{
"label": "Manual release",
"description": "Manually release the app after store approval",
"body": false
},
{
"label": "Automatic release",
"description": "Automatically release the app after store approval",
"body": true
},
{
"label": "Scheduled release",
"description": "Automatically release the app on this date, and store approval",
"body": "$CURRENT_YEAR-${1:$CURRENT_MONTH}-${2:$CURRENT_DATE}T${3:$CURRENT_HOUR}:00:00+00:00"
}
]
},
"phasedRelease": {
"type": "boolean",
"description": "Phased release for automatic updates lets you gradually release this update over a 7-day period to users who have turned on automatic updates. Keep in mind that this version will still be available to all users as a manual update from the App Store. You can pause the phased release for up to 30 days or release this update to all users at any time.\n@see https://help.apple.com/app-store-connect/#/dev3d65fcee1",
"markdownDescription": "Phased release for automatic updates lets you gradually release this update over a 7-day period to users who have turned on automatic updates.\n\nKeep in mind that this version will still be available to all users as a manual update from the App Store.\n\nYou can pause the phased release for up to 30 days or release this update to all users at any time.\n\n[Learn More](https://help.apple.com/app-store-connect/#/dev3d65fcee1)"
}
}
},
"AppleReview": {
"type": "object",
"description": "Info provided to the App Store review team to help them understand how to use your app.",
"required": [
"firstName",
"lastName",
"email",
"phone"
],
"defaultSnippets": [
{
"label": "Basic",
"description": "Only define the contact details in case communcation is needed with the App Store review team.",
"body": {
"firstName": "$1",
"lastName": "$2",
"email": "$3",
"phone": "+$4"
}
},
{
"label": "Full",
"description": "Define the demo account credentials, additional info, and contact details for the App Store review team.",
"body": {
"firstName": "$1",
"lastName": "$2",
"email": "$3",
"phone": "+$4",
"demoUsername": "$5",
"demoPassword": "$6",
"demoRequired": true,
"notes": "$7"
}
}
],
"properties": {
"firstName": {
"type": "string",
"description": "First name of contact in case communication is needed with the App Store review team.",
"minLength": 1
},
"lastName": {
"type": "string",
"description": "Last name of contact in case communication is needed with the App Store review team.",
"minLength": 1
},
"email": {
"type": "string",
"description": "Email address of contact in case communication is needed with the App Store review team.",
"format": "email"
},
"phone": {
"type": "string",
"description": "Phone number of contact in case communication is needed with the App Store review team. Preface the phone number with ‘+’ followed by the country code (for example, +44 844 209 0611)"
},
"demoUsername": {
"type": "string",
"description": "The user name to sign in to your app to review its features."
},
"demoPassword": {
"type": "string",
"description": "The password to sign in to your app to review its features."
},
"demoRequired": {
"type": "boolean",
"description": "A Boolean value that indicates if sign-in information is required to review all the features of your app. If users sign in using social media, provide information for an account for review. Credentials must be valid and active for duration of review.",
"markdownDescription": "A Boolean value that indicates if sign-in information is required to review all the features of your app. If users sign in using social media, provide information for an account for review.\n\n**Credentials must be valid and active for duration of review.**"
},
"notes": {
"type": "string",
"description": "Additional information about your app that can help during the review process. Do not include demo account details.",
"markdownDescription": "Additional information about your app that can help during the review process.\n\n**Do not include demo account details.**",
"minLength": 2,
"maxLength": 4000
}
}
}
},
"AppleConfig": {
"type": "object",
"description": "Configuration that is specific to the Apple App Store.",
"additionalProperties": false,
"properties": {
"version": {
"type": "string",
"description": "The version string to edit, or create, if it doesn't exist. All the metadata will be stored on this app version.",
"minLength": 1,
"defaultSnippets": [
{
"body": "${1:1}.${2:0}"
}
]
},
"copyright": {
"type": "string",
"description": "The updated company copyright. Example: 2021 Evan Bacon",
"defaultSnippets": [
{
"body": "$CURRENT_YEAR ${1:name}"
}
],
"meta": {
"versioned": true,
"liveEdits": true,
"storeInfo": "The name of the person or entity that owns the exclusive rights to your app, preceded by the year the rights were obtained (for example, \"2008 Acme Inc.\"). Do not provide a URL."
}
},
"advisory": {
"$ref": "#/definitions/apple/AppleAdvisory"
},
"categories": {
"$ref": "#/definitions/apple/AppleCategory"
},
"info": {
"description": "Localized core app info",
"type": "object",
"additionalProperties": false,
"properties": {
"ar-SA": {
"$ref": "#/definitions/apple/AppleInfo",
"description": "Arabic"
},
"ca": {
"$ref": "#/definitions/apple/AppleInfo",
"description": "Catalan"
},
"zh-Hans": {
"$ref": "#/definitions/apple/AppleInfo",
"description": "Chinese (Simplified)"
},
"zh-Hant": {
"$ref": "#/definitions/apple/AppleInfo",
"description": "Chinese (Traditional)"
},
"hr": {
"$ref": "#/definitions/apple/AppleInfo",
"description": "Croatian"
},
"cs": {
"$ref": "#/definitions/apple/AppleInfo",
"description": "Czech"
},
"da": {
"$ref": "#/definitions/apple/AppleInfo",
"description": "Danish"
},
"nl-NL": {
"$ref": "#/definitions/apple/AppleInfo",
"description": "Dutch"
},
"en-AU": {
"$ref": "#/definitions/apple/AppleInfo",
"description": "English (Australia)"
},
"en-CA": {
"$ref": "#/definitions/apple/AppleInfo",
"description": "English (Canada)"
},
"en-GB": {
"$ref": "#/definitions/apple/AppleInfo",
"description": "English (U.K.)"
},
"en-US": {
"$ref": "#/definitions/apple/AppleInfo",
"description": "English (U.S.)"
},
"fi": {
"$ref": "#/definitions/apple/AppleInfo",
"description": "Finnish"
},
"fr-FR": {
"$ref": "#/definitions/apple/AppleInfo",
"description": "French"
},
"fr-CA": {
"$ref": "#/definitions/apple/AppleInfo",
"description": "French (Canada)"
},
"de-DE": {
"$ref": "#/definitions/apple/AppleInfo",
"description": "German"
},
"el": {
"$ref": "#/definitions/apple/AppleInfo",
"description": "Greek"
},
"he": {
"$ref": "#/definitions/apple/AppleInfo",
"description": "Hebrew"
},
"hi": {
"$ref": "#/definitions/apple/AppleInfo",
"description": "Hindi"
},
"hu": {
"$ref": "#/definitions/apple/AppleInfo",
"description": "Hungarian"
},
"id": {
"$ref": "#/definitions/apple/AppleInfo",
"description": "Indonesian"
},
"it": {
"$ref": "#/definitions/apple/AppleInfo",
"description": "Italian"
},
"ja": {
"$ref": "#/definitions/apple/AppleInfo",
"description": "Japanese"
},
"ko": {
"$ref": "#/definitions/apple/AppleInfo",
"description": "Korean"
},
"ms": {
"$ref": "#/definitions/apple/AppleInfo",
"description": "Malay"
},
"no": {
"$ref": "#/definitions/apple/AppleInfo",
"description": "Norwegian"
},
"pl": {
"$ref": "#/definitions/apple/AppleInfo",
"description": "Polish"
},
"pt-BR": {
"$ref": "#/definitions/apple/AppleInfo",
"description": "Portuguese (Brazil)"
},
"pt-PT": {
"$ref": "#/definitions/apple/AppleInfo",
"description": "Portuguese (Portugal)"
},
"ro": {
"$ref": "#/definitions/apple/AppleInfo",
"description": "Romanian"
},
"ru": {
"$ref": "#/definitions/apple/AppleInfo",
"description": "Russian"
},
"sk": {
"$ref": "#/definitions/apple/AppleInfo",
"description": "Slovak"
},
"es-MX": {
"$ref": "#/definitions/apple/AppleInfo",
"description": "Spanish (Mexico)"
},
"es-ES": {
"$ref": "#/definitions/apple/AppleInfo",
"description": "Spanish (Spain)"
},
"sv": {
"$ref": "#/definitions/apple/AppleInfo",
"description": "Swedish"
},
"th": {
"$ref": "#/definitions/apple/AppleInfo",
"description": "Thai"
},
"tr": {
"$ref": "#/definitions/apple/AppleInfo",
"description": "Turkish"
},
"uk": {
"$ref": "#/definitions/apple/AppleInfo",
"description": "Ukrainian"
},
"vi": {
"$ref": "#/definitions/apple/AppleInfo",
"description": "Vietnamese"
}
}
},
"release": {
"$ref": "#/definitions/apple/AppleRelease",
"description": "Release strategy"
},
"review": {
"$ref": "#/definitions/apple/AppleReview",
"description": "Info for the App Store reviewer"
}
}
}
},
"properties": {
"configVersion": {
"enum": [0],
"description": "The EAS metadata store configuration version"
},
"apple": {
"$ref": "#/definitions/AppleConfig"
}
},
"additionalProperties": false,
"required": [
"configVersion",
"apple"
]
}
-7
View File
@@ -1,7 +0,0 @@
const spawn = require('@expo/spawn-async');
(async () => {
if (!process.env.CLI_SIZE_CHECK) {
await spawn('yarn', ['oclif', 'manifest'], { stdio: 'inherit' });
}
})();
-12
View File
@@ -1,12 +0,0 @@
#!/usr/bin/env bash
set -eo pipefail
EAS_CLI_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )"/.. && pwd )"
PACKAGE_JSON_PATH="$EAS_CLI_DIR/package.json"
if [[ "$OSTYPE" == "darwin"* ]]; then
sed -i '' "s/\\\"@expo\/eas-json\\\": \\\".*\\\"/\\\"@expo\/eas-json\\\": \\\"file:..\/..\/..\/eas-json\\\"/g" $PACKAGE_JSON_PATH
else
sed -i "s/\\\"@expo\/eas-json\\\": \\\".*\\\"/\\\"@expo\/eas-json\\\": \\\"file:..\/..\/..\/eas-json\\\"/g" $PACKAGE_JSON_PATH
fi
+14 -10
View File
@@ -1,20 +1,24 @@
import { JSONValue } from '@expo/json-file';
import { HTTPError, RequestError } from 'got/dist/source';
export class ApiV2Error extends Error {
override readonly name = 'ApiV2Error';
export default class ApiV2Error extends RequestError {
readonly name = 'ApiV2Error';
readonly expoApiV2ErrorCode: string;
readonly expoApiV2ErrorDetails?: JSONValue;
readonly expoApiV2ErrorServerStack?: string;
readonly expoApiV2ErrorMetadata?: object;
constructor(response: {
message: string;
code: string;
stack?: string;
details?: JSONValue;
metadata?: object;
}) {
super(response.message);
constructor(
originalError: HTTPError,
response: {
message: string;
code: string;
stack?: string;
details?: JSONValue;
metadata?: object;
}
) {
super(response.message, originalError, originalError.request);
this.expoApiV2ErrorCode = response.code;
this.expoApiV2ErrorDetails = response.details;
this.expoApiV2ErrorServerStack = response.stack;
+9 -12
View File
@@ -1,13 +1,14 @@
import assert from 'assert';
import { RequestError } from 'got/dist/source';
import nock from 'nock';
import { ApiV2Error } from '../ApiV2Error';
import { ApiV2Client, getExpoApiBaseUrl } from '../api';
import ApiV2Error from '../ApiV2Error';
import { apiClient, getExpoApiBaseUrl } from '../api';
describe(ApiV2Client, () => {
describe('apiClient', () => {
it('converts Expo APIv2 error to ApiV2Error', async () => {
nock(getExpoApiBaseUrl())
.post('/v2/test')
.post('/--/api/v2/test')
.reply(400, {
errors: [
{
@@ -22,9 +23,7 @@ describe(ApiV2Client, () => {
let error: Error | null = null;
try {
await new ApiV2Client({ accessToken: null, sessionSecret: null }).postAsync('test', {
body: {},
});
await apiClient.post('test');
} catch (e: any) {
error = e;
}
@@ -40,17 +39,15 @@ describe(ApiV2Client, () => {
});
it('does not convert non-APIv2 error to ApiV2Error', async () => {
nock(getExpoApiBaseUrl()).post('/v2/test').reply(500, 'Something went wrong');
nock(getExpoApiBaseUrl()).post('/--/api/v2/test').reply(500, 'Something went wrong');
let error: Error | null = null;
try {
await new ApiV2Client({ accessToken: null, sessionSecret: null }).postAsync('test', {
body: {},
});
await apiClient.post('test');
} catch (e: any) {
error = e;
}
expect(error).not.toBeNull();
expect(error).toBeInstanceOf(RequestError);
expect(error).not.toBeInstanceOf(ApiV2Error);
});
});
@@ -0,0 +1,60 @@
import { Actor } from '../user/User';
interface MockProject {
projectRoot: string;
projectTree: Record<string, string>;
appJSON: AppJSON;
packageJSON: PackageJSON;
}
interface PackageJSON {
name: string;
version: string;
description: string;
main: string;
}
interface AppJSON {
expo: {
name: string;
version: string;
slug: string;
sdkVersion: string;
owner?: string;
[key: string]: any;
};
}
export function createTestProject(
user: Actor,
appJSONExtraData?: Record<string, object | string>
): MockProject {
const projectRoot = '/test-project';
const packageJSON: PackageJSON = {
name: 'testing123',
version: '0.1.0',
description: 'fake description',
main: 'index.js',
};
const owner = appJSONExtraData?.owner as string | undefined;
const appJSON: AppJSON = {
expo: {
name: 'testing 123',
version: '0.1.0',
slug: 'testing-123',
sdkVersion: '33.0.0',
owner: owner ?? (user.__typename === 'User' ? user.username : undefined),
...appJSONExtraData,
},
};
return {
appJSON,
packageJSON,
projectRoot,
projectTree: {
[projectRoot + '/package.json']: JSON.stringify(packageJSON, null, 2),
[projectRoot + '/app.json']: JSON.stringify(appJSON, null, 2),
},
};
}
+3
View File
@@ -0,0 +1,3 @@
export function asMock(fn: any): jest.Mock {
return fn as jest.Mock;
}
+135
View File
@@ -0,0 +1,135 @@
import RudderAnalytics from '@expo/rudder-sdk-node';
import os from 'os';
import { URL } from 'url';
import { v4 as uuidv4 } from 'uuid';
import UserSettings from './user/UserSettings';
/**
* We use require() to exclude package.json from TypeScript's analysis since it lives outside
* the src directory and would change the directory structure of the emitted files
* under the build directory
*/
const packageJSON = require('../package.json');
const PLATFORM_TO_ANALYTICS_PLATFORM: { [platform: string]: string } = {
darwin: 'Mac',
win32: 'Windows',
linux: 'Linux',
};
let rudderstackClient: RudderAnalytics | null = null;
let userIdentified = false;
let identifyData: {
userId: string;
deviceId: string;
traits: Record<string, any>;
} | null = null;
export async function initAsync(): Promise<void> {
// TODO: remove after some time
const amplitudeEnabled = await UserSettings.getAsync('amplitudeEnabled', null);
if (amplitudeEnabled !== null) {
await UserSettings.setAsync('analyticsEnabled', amplitudeEnabled);
await UserSettings.deleteKeyAsync('amplitudeEnabled');
}
const amplitudeDeviceId = await UserSettings.getAsync('amplitudeDeviceId', null);
if (amplitudeDeviceId !== null) {
await UserSettings.setAsync('analyticsDeviceId', amplitudeDeviceId);
await UserSettings.deleteKeyAsync('amplitudeDeviceId');
}
// TODO: cut here
if (process.env.DISABLE_EAS_ANALYTICS) {
await UserSettings.setAsync('analyticsEnabled', false);
}
const analyticsEnabled = await UserSettings.getAsync('analyticsEnabled', true);
if (analyticsEnabled) {
const config =
process.env.EXPO_STAGING || process.env.EXPO_LOCAL
? {
// staging environment
rudderstackWriteKey: '1wpX20Da4ltFGSXbPFYUL00Chb7',
rudderstackDataPlaneURL: 'https://cdp.expo.dev',
}
: {
// prod environment
rudderstackWriteKey: '1wpXLFxmujq86etH6G6cc90hPcC',
rudderstackDataPlaneURL: 'https://cdp.expo.dev',
};
rudderstackClient = new RudderAnalytics(
config.rudderstackWriteKey,
new URL('/v1/batch', config.rudderstackDataPlaneURL).toString(),
{
flushInterval: 300,
}
);
}
}
export async function setUserDataAsync(userId: string, traits: Record<string, any>): Promise<void> {
const savedDeviceId = await UserSettings.getAsync('analyticsDeviceId', null);
const deviceId = savedDeviceId ?? uuidv4();
if (!savedDeviceId) {
await UserSettings.setAsync('analyticsDeviceId', deviceId);
}
identifyData = {
userId,
deviceId,
traits,
};
ensureUserIdentified();
}
export async function flushAsync(): Promise<void> {
if (rudderstackClient) {
await rudderstackClient.flush();
}
}
export function logEvent(name: string, properties: Record<string, any> = {}): void {
if (!rudderstackClient) {
return;
}
ensureUserIdentified();
const { userId, deviceId } = identifyData ?? {};
const commonEventProperties = { source_version: packageJSON?.version, source: 'eas cli' };
const identity = { userId: userId ?? undefined, anonymousId: deviceId ?? uuidv4() };
rudderstackClient.track({
event: name,
properties: { ...properties, ...commonEventProperties },
...identity,
context: getRudderStackContext(),
});
}
function ensureUserIdentified(): void {
if (!rudderstackClient || userIdentified || !identifyData) {
return;
}
rudderstackClient.identify({
userId: identifyData.userId,
anonymousId: identifyData.deviceId,
traits: identifyData.traits,
});
userIdentified = true;
}
function getRudderStackContext(): Record<string, any> {
const platform = PLATFORM_TO_ANALYTICS_PLATFORM[os.platform()] || os.platform();
return {
os: { name: platform, version: os.release() },
device: { type: platform, model: platform },
app: { name: 'eas cli', version: packageJSON?.version ?? undefined },
};
}
export enum AnalyticsEvent {
ACTION = 'action', // generic event type which is used to determine the 'daily active user' stat, include an `action: eas ${subcommand}` property inside of the event properties object
}
@@ -1,234 +0,0 @@
import RudderAnalytics from '@expo/rudder-sdk-node';
import os from 'os';
import { URL } from 'url';
import { v4 as uuidv4 } from 'uuid';
import { Actor, getActorDisplayName } from '../user/User';
import UserSettings from '../user/UserSettings';
import { easCliVersion } from '../utils/easCli';
const PLATFORM_TO_ANALYTICS_PLATFORM: Partial<Record<NodeJS.Platform, string>> = {
darwin: 'Mac',
win32: 'Windows',
linux: 'Linux',
};
export type AnalyticsEvent = CommandEvent | BuildEvent | SubmissionEvent | MetadataEvent;
export enum CommandEvent {
ACTION = 'action', // generic event type which is used to determine the 'daily active user' stat, include an `action: eas ${subcommand}` property inside of the event properties object
}
export enum SubmissionEvent {
SUBMIT_COMMAND = 'submit cli submit command',
SUBMIT_COMMAND_ATTEMPT = 'submit cli attempt',
SUBMIT_COMMAND_SUCCESS = 'submit cli success',
SUBMIT_COMMAND_FAIL = 'submit cli fail',
GATHER_CREDENTIALS_ATTEMPT = 'submit cli gather credentials attempt',
GATHER_CREDENTIALS_SUCCESS = 'submit cli gather credentials success',
GATHER_CREDENTIALS_FAIL = 'submit cli gather credentials fail',
GATHER_ARCHIVE_ATTEMPT = 'submit cli gather archive attempt',
GATHER_ARCHIVE_SUCCESS = 'submit cli gather archive success',
GATHER_ARCHIVE_FAIL = 'submit cli gather archive fail',
SUBMIT_REQUEST_ATTEMPT = 'submit cli request attempt',
SUBMIT_REQUEST_SUCCESS = 'submit cli request success',
SUBMIT_REQUEST_FAIL = 'submit cli request fail',
API_KEY_DOWNLOAD_FAIL = 'submit cli credentials api key download fail',
API_KEY_DOWNLOAD_RETRY = 'submit cli credentials api key download fail temporary',
API_KEY_DOWNLOAD_SUCCESS = 'submit cli credentials api key download succeed',
}
export enum BuildEvent {
BUILD_COMMAND = 'build cli build command',
PROJECT_UPLOAD_ATTEMPT = 'build cli project upload attempt',
PROJECT_UPLOAD_SUCCESS = 'build cli project upload success',
PROJECT_UPLOAD_FAIL = 'build cli project upload fail',
GATHER_CREDENTIALS_ATTEMPT = 'build cli gather credentials attempt',
GATHER_CREDENTIALS_SUCCESS = 'build cli gather credentials success',
GATHER_CREDENTIALS_FAIL = 'build cli gather credentials fail',
CONFIGURE_PROJECT_ATTEMPT = 'build cli configure project attempt',
CONFIGURE_PROJECT_SUCCESS = 'build cli configure project success',
CONFIGURE_PROJECT_FAIL = 'build cli configure project fail',
BUILD_REQUEST_ATTEMPT = 'build cli build request attempt',
BUILD_REQUEST_SUCCESS = 'build cli build request success',
BUILD_REQUEST_FAIL = 'build cli build request fail',
BUILD_STATUS_COMMAND = 'build cli build status',
CREDENTIALS_SYNC_COMMAND = 'build cli credentials sync command',
CREDENTIALS_SYNC_UPDATE_LOCAL_ATTEMPT = 'build cli credentials sync update local attempt',
CREDENTIALS_SYNC_UPDATE_LOCAL_SUCCESS = 'build cli credentials sync update local success',
CREDENTIALS_SYNC_UPDATE_LOCAL_FAIL = 'build cli credentials sync update local fail',
CREDENTIALS_SYNC_UPDATE_REMOTE_ATTEMPT = 'build cli credentials sync update remote attempt',
CREDENTIALS_SYNC_UPDATE_REMOTE_SUCCESS = 'build cli credentials sync update remote success',
CREDENTIALS_SYNC_UPDATE_REMOTE_FAIL = 'build cli credentials sync update remote fail',
ANDROID_KEYSTORE_CREATE = 'build cli credentials keystore create',
}
export enum MetadataEvent {
APPLE_METADATA_DOWNLOAD = 'metadata cli download apple response',
APPLE_METADATA_UPLOAD = 'metadata cli upload apple response',
}
export type AnalyticsEventProperties = Record<string, string | number | boolean>;
/**
* The interface for commands to use to log events to analytics.
*/
export interface Analytics {
logEvent(name: AnalyticsEvent, properties: AnalyticsEventProperties): void;
}
/**
* The interface for commands to use to orchestrate the analytics system. Should only be necessary to use
* this within EASCommand.
*/
export interface AnalyticsWithOrchestration extends Analytics {
setActor(actor: Actor): void;
flushAsync(): Promise<void>;
}
const USER_SETTINGS_KEY_AMPLITUDE_ENABLED = 'amplitudeEnabled';
const USER_SETTINGS_KEY_AMPLITUDE_DEVICE_ID = 'amplitudeDeviceId';
const USER_SETTINGS_KEY_ANALYTICS_ENABLED = 'analyticsEnabled';
const USER_SETTINGS_KEY_ANALYTICS_DEVICE_ID = 'analyticsDeviceId';
/**
* Sets the user's analytics enabled preference. Note that this will only take effect
* upon the next run of the CLI.
*/
export async function setAnalyticsEnabledAsync(enabled: boolean): Promise<void> {
await UserSettings.setAsync(USER_SETTINGS_KEY_ANALYTICS_ENABLED, enabled);
}
/**
* Returns the user's analytics enabled preference.
*/
export async function getAnalyticsEnabledAsync(): Promise<boolean> {
const analyticsEnabled = await UserSettings.getAsync(USER_SETTINGS_KEY_ANALYTICS_ENABLED, null);
return !!analyticsEnabled;
}
/**
* Create an instance of Analytics based on the user's analytics enabled preferences.
*/
export async function createAnalyticsAsync(): Promise<AnalyticsWithOrchestration> {
// TODO: remove after some time
const amplitudeEnabled = await UserSettings.getAsync(USER_SETTINGS_KEY_AMPLITUDE_ENABLED, null);
if (amplitudeEnabled !== null) {
await UserSettings.setAsync(USER_SETTINGS_KEY_ANALYTICS_ENABLED, amplitudeEnabled);
await UserSettings.deleteKeyAsync(USER_SETTINGS_KEY_AMPLITUDE_ENABLED);
}
const amplitudeDeviceId = await UserSettings.getAsync(
USER_SETTINGS_KEY_AMPLITUDE_DEVICE_ID,
null
);
if (amplitudeDeviceId !== null) {
await UserSettings.setAsync(USER_SETTINGS_KEY_ANALYTICS_DEVICE_ID, amplitudeDeviceId);
await UserSettings.deleteKeyAsync(USER_SETTINGS_KEY_AMPLITUDE_DEVICE_ID);
}
if (process.env.DISABLE_EAS_ANALYTICS) {
await UserSettings.setAsync(USER_SETTINGS_KEY_ANALYTICS_ENABLED, false);
}
const analyticsEnabled =
!process.env.https_proxy && // disable analytics if running behind proxy
(await UserSettings.getAsync(USER_SETTINGS_KEY_ANALYTICS_ENABLED, true));
if (!analyticsEnabled) {
return new NoOpAnalytics();
}
const persistedDeviceId = await UserSettings.getAsync(
USER_SETTINGS_KEY_ANALYTICS_DEVICE_ID,
null
);
const deviceId = persistedDeviceId ?? uuidv4();
if (!persistedDeviceId) {
await UserSettings.setAsync(USER_SETTINGS_KEY_ANALYTICS_DEVICE_ID, deviceId);
}
return new RudderstackAnalytics(deviceId);
}
class NoOpAnalytics implements AnalyticsWithOrchestration {
logEvent(): void {}
setActor(): void {}
async flushAsync(): Promise<void> {}
}
const RudderstackAnalyticsConfig =
process.env.EXPO_STAGING || process.env.EXPO_LOCAL
? {
// staging environment
rudderstackWriteKey: '1wpX20Da4ltFGSXbPFYUL00Chb7',
rudderstackDataPlaneURL: 'https://cdp.expo.dev',
}
: {
// prod environment
rudderstackWriteKey: '1wpXLFxmujq86etH6G6cc90hPcC',
rudderstackDataPlaneURL: 'https://cdp.expo.dev',
};
class RudderstackAnalytics implements AnalyticsWithOrchestration {
private readonly rudderstackClient = new RudderAnalytics(
RudderstackAnalyticsConfig.rudderstackWriteKey,
new URL('/v1/batch', RudderstackAnalyticsConfig.rudderstackDataPlaneURL).toString(),
{
flushInterval: 300,
}
);
private identifiedActor: Actor | null = null;
constructor(private readonly persistentDeviceId: string) {
// identify once with just anonymous ID. Once the actor is fetched, re-indentify with
// both so that they can be associated
this.rudderstackClient.identify({
anonymousId: persistentDeviceId,
});
}
public setActor(actor: Actor): void {
if (this.identifiedActor) {
return;
}
this.rudderstackClient.identify({
userId: actor.id,
anonymousId: this.persistentDeviceId,
traits: {
username: getActorDisplayName(actor),
user_id: actor.id,
user_type: actor.__typename,
},
});
this.identifiedActor = actor;
}
public logEvent(name: AnalyticsEvent, properties: AnalyticsEventProperties): void {
const userId = this.identifiedActor?.id;
const deviceId = this.persistentDeviceId;
const commonEventProperties = { source_version: easCliVersion, source: 'eas cli' };
const identity = { userId: userId ?? undefined, anonymousId: deviceId ?? uuidv4() };
this.rudderstackClient.track({
event: name,
properties: { ...properties, ...commonEventProperties },
...identity,
context: this.getRudderStackContext(),
});
}
public async flushAsync(): Promise<void> {
await this.rudderstackClient.flush();
}
private getRudderStackContext(): Record<string, any> {
const platform = PLATFORM_TO_ANALYTICS_PLATFORM[os.platform()] || os.platform();
return {
os: { name: platform, version: os.release() },
device: { type: platform, model: platform },
app: { name: 'eas cli', version: easCliVersion ?? undefined },
};
}
}
-30
View File
@@ -1,30 +0,0 @@
import { Analytics, AnalyticsEvent, AnalyticsEventProperties } from './AnalyticsManager';
export async function withAnalyticsAsync<Result>(
analytics: Analytics,
fn: () => Promise<Result>,
{
attemptEvent,
successEvent,
failureEvent,
properties,
}: {
attemptEvent: AnalyticsEvent;
successEvent: AnalyticsEvent;
failureEvent: AnalyticsEvent;
properties: AnalyticsEventProperties;
}
): Promise<Result> {
try {
analytics.logEvent(attemptEvent, properties);
const result = await fn();
analytics.logEvent(successEvent, properties);
return result;
} catch (error: any) {
analytics.logEvent(failureEvent, {
...properties,
reason: error.message,
});
throw error;
}
}
+39 -86
View File
@@ -1,87 +1,50 @@
import { JSONValue } from '@expo/json-file';
import got, { HTTPError, NormalizedOptions, RequestError } from 'got';
import { ApiV2Error } from './ApiV2Error';
import fetch, { RequestError, RequestInit } from './fetch';
import ApiV2Error from './ApiV2Error';
import { getAccessToken, getSessionSecret } from './user/sessionStorage';
interface RequestOptions {
body: JSONValue;
}
export class ApiV2Client {
constructor(
private readonly authInfo: {
accessToken: string | null;
sessionSecret: string | null;
}
) {}
public async putAsync(path: string, options: RequestOptions): Promise<any> {
return await this.requestAsync(path, { method: 'PUT', body: JSON.stringify(options.body) });
}
public async postAsync(path: string, options: RequestOptions): Promise<any> {
return await this.requestAsync(path, { method: 'POST', body: JSON.stringify(options.body) });
}
public async deleteAsync(path: string): Promise<any> {
return await this.requestAsync(path, { method: 'DELETE' });
}
public async getAsync(path: string): Promise<any> {
return await this.requestAsync(path, { method: 'GET' });
}
private getAuthHeaders(): Record<string, string> {
const token = this.authInfo.accessToken;
if (token) {
return { authorization: `Bearer ${token}` };
}
const sessionSecret = this.authInfo.sessionSecret;
if (sessionSecret) {
return { 'expo-session': sessionSecret };
}
return {};
}
private async requestAsync(path: string, options: RequestInit): Promise<any> {
try {
const response = await fetch(`${getExpoApiBaseUrl()}/v2/${path}`, {
...options,
headers: {
'Content-Type': 'application/json',
...this.getAuthHeaders(),
},
});
return await response.json();
} catch (err) {
await ApiV2Client.handleApiErrorAsync(err);
}
}
private static async handleApiErrorAsync(err: any): Promise<void> {
if (err instanceof RequestError) {
let result: { [key: string]: any };
try {
result = (await err.response.json()) as { [key: string]: any };
} catch {
throw new Error(`Malformed api response: ${await err.response.text()}`);
}
if (result.errors?.length) {
throw new ApiV2Error(result.errors[0]);
}
} else {
throw err;
}
}
}
export const apiClient = got.extend({
prefixUrl: getExpoApiBaseUrl() + '/--/api/v2/',
hooks: {
beforeRequest: [
(options: NormalizedOptions) => {
const token = getAccessToken();
if (token) {
options.headers.authorization = `Bearer ${token}`;
return;
}
const sessionSecret = getSessionSecret();
if (sessionSecret) {
options.headers['expo-session'] = sessionSecret;
}
},
],
beforeError: [
(error: RequestError): RequestError => {
if (error instanceof HTTPError) {
let result: { [key: string]: any };
try {
result = JSON.parse(error.response.body as string);
} catch (e2) {
return error;
}
if (result.errors?.length) {
return new ApiV2Error(error, result.errors[0]);
}
}
return error;
},
],
},
});
export function getExpoApiBaseUrl(): string {
if (process.env.EXPO_STAGING) {
return `https://staging-api.expo.dev`;
return `https://staging.exp.host`;
} else if (process.env.EXPO_LOCAL) {
return `http://127.0.0.1:3000`;
} else {
return `https://api.expo.dev`;
return `https://exp.host`;
}
}
@@ -94,13 +57,3 @@ export function getExpoWebsiteBaseUrl(): string {
return `https://expo.dev`;
}
}
export function getEASUpdateURL(projectId: string): string {
if (process.env.EXPO_STAGING) {
return new URL(projectId, `https://staging-u.expo.dev`).href;
} else if (process.env.EXPO_LOCAL) {
return new URL(`expo-updates/${projectId}`, `http://127.0.0.1:3000`).href;
} else {
return new URL(projectId, `https://u.expo.dev`).href;
}
}
-184
View File
@@ -1,184 +0,0 @@
import chalk from 'chalk';
import gql from 'graphql-tag';
import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient';
import { PaginatedQueryOptions } from '../commandUtils/pagination';
import { withErrorHandlingAsync } from '../graphql/client';
import {
CreateUpdateBranchForAppMutation,
CreateUpdateBranchForAppMutationVariables,
UpdateBranch,
UpdateBranchFragment,
} from '../graphql/generated';
import { BranchQuery } from '../graphql/queries/BranchQuery';
import Log from '../log';
import { formatBranch, getBranchDescription } from '../update/utils';
import { printJsonOnlyOutput } from '../utils/json';
import {
SelectPromptEntry,
paginatedQueryWithConfirmPromptAsync,
paginatedQueryWithSelectPromptAsync,
} from '../utils/queries';
import { BranchNotFoundError } from './utils';
export const BRANCHES_LIMIT = 50;
export async function selectBranchOnAppAsync(
graphqlClient: ExpoGraphqlClient,
{
projectId,
promptTitle,
displayTextForListItem,
paginatedQueryOptions,
}: {
projectId: string;
displayTextForListItem: (queryItem: UpdateBranchFragment) => SelectPromptEntry;
promptTitle: string;
paginatedQueryOptions: PaginatedQueryOptions;
}
): Promise<UpdateBranchFragment> {
if (paginatedQueryOptions.nonInteractive) {
throw new Error('Unable to select a branch in non-interactive mode.');
}
const selectedBranch = await paginatedQueryWithSelectPromptAsync({
limit: paginatedQueryOptions.limit ?? BRANCHES_LIMIT,
offset: paginatedQueryOptions.offset,
queryToPerform: (limit, offset) =>
queryBranchesOnProjectAsync(graphqlClient, limit, offset, projectId),
promptOptions: {
title: promptTitle,
getIdentifierForQueryItem: updateBranchFragment => updateBranchFragment.id,
makePartialChoiceObject: displayTextForListItem,
},
});
if (!selectedBranch) {
throw new Error(`Could not find any branches for project "${projectId}"`);
}
return selectedBranch;
}
export async function listAndRenderBranchesOnAppAsync(
graphqlClient: ExpoGraphqlClient,
{
projectId,
paginatedQueryOptions,
}: {
projectId: string;
paginatedQueryOptions: PaginatedQueryOptions;
}
): Promise<void> {
if (paginatedQueryOptions.nonInteractive) {
const branches = await queryBranchesOnProjectAsync(
graphqlClient,
paginatedQueryOptions.limit ?? BRANCHES_LIMIT,
paginatedQueryOptions.offset,
projectId
);
renderPageOfBranches(branches, paginatedQueryOptions);
} else {
await paginatedQueryWithConfirmPromptAsync({
limit: paginatedQueryOptions.limit ?? BRANCHES_LIMIT,
offset: paginatedQueryOptions.offset,
queryToPerform: (limit, offset) =>
queryBranchesOnProjectAsync(graphqlClient, limit, offset, projectId),
promptOptions: {
title: 'Load more branches?',
renderListItems: branches => renderPageOfBranches(branches, paginatedQueryOptions),
},
});
}
}
async function queryBranchesOnProjectAsync(
graphqlClient: ExpoGraphqlClient,
limit: number,
offset: number,
projectId: string
): Promise<UpdateBranchFragment[]> {
return await BranchQuery.listBranchesOnAppAsync(graphqlClient, {
appId: projectId,
limit,
offset,
});
}
function renderPageOfBranches(
currentPage: UpdateBranchFragment[],
{ json }: PaginatedQueryOptions
): void {
if (json) {
printJsonOnlyOutput(currentPage);
} else {
Log.addNewLineIfNone();
Log.log(chalk.bold('Branches:'));
Log.addNewLineIfNone();
Log.log(
currentPage
.map(branch => formatBranch(getBranchDescription(branch)))
.join(`\n\n${chalk.dim('———')}\n\n`)
);
}
}
export async function createUpdateBranchOnAppAsync(
graphqlClient: ExpoGraphqlClient,
{ appId, name }: CreateUpdateBranchForAppMutationVariables
): Promise<Pick<UpdateBranch, 'id' | 'name'>> {
const result = await withErrorHandlingAsync(
graphqlClient
.mutation<CreateUpdateBranchForAppMutation, CreateUpdateBranchForAppMutationVariables>(
gql`
mutation CreateUpdateBranchForApp($appId: ID!, $name: String!) {
updateBranch {
createUpdateBranchForApp(appId: $appId, name: $name) {
id
name
}
}
}
`,
{
appId,
name,
}
)
.toPromise()
);
const newBranch = result.updateBranch.createUpdateBranchForApp;
if (!newBranch) {
throw new Error(`Could not create branch ${name}.`);
}
return newBranch;
}
export async function ensureBranchExistsAsync(
graphqlClient: ExpoGraphqlClient,
{
appId,
branchName,
}: {
appId: string;
branchName: string;
}
): Promise<{ branchId: string; createdBranch: boolean }> {
try {
const updateBranch = await BranchQuery.getBranchByNameAsync(graphqlClient, {
appId,
name: branchName,
});
const { id } = updateBranch;
return { branchId: id, createdBranch: false };
} catch (error) {
if (error instanceof BranchNotFoundError) {
const newUpdateBranch = await createUpdateBranchOnAppAsync(graphqlClient, {
appId,
name: branchName,
});
return { branchId: newUpdateBranch.id, createdBranch: true };
} else {
throw error;
}
}
}
-14
View File
@@ -1,14 +0,0 @@
import { getVcsClient } from '../vcs';
export async function getDefaultBranchNameAsync(): Promise<string> {
return (
(await getVcsClient().getBranchNameAsync()) ||
`branch-${Math.random().toString(36).substring(2, 4)}`
);
}
export class BranchNotFoundError extends Error {
constructor(message?: string) {
super(message ?? 'Branch not found.');
}
}
@@ -1,96 +0,0 @@
import { instance, mock } from 'ts-mockito';
import { v4 as uuid } from 'uuid';
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
import { selectBuildToCancelAsync } from '../../commands/build/cancel';
import {
AppPlatform,
BuildFragment,
BuildPriority,
BuildResourceClass,
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(selectBuildToCancelAsync.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);
expect(selectBuildToCancelAsync(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);
expect(selectBuildToCancelAsync(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,
resourceClass: BuildResourceClass.AndroidMedium,
};
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

@@ -1,362 +0,0 @@
import { Platform } from '@expo/eas-build-job';
import { CombinedError } from '@urql/core';
import { GraphQLError } from 'graphql/error';
import { EasCommandError } from '../../commandUtils/errors';
import Build from '../../commands/build';
import Log, { link } from '../../log';
import { handleBuildRequestError } from '../build';
import {
EasBuildDownForMaintenanceError,
EasBuildFreeTierDisabledAndroidError,
EasBuildFreeTierDisabledError,
EasBuildFreeTierDisabledIOSError,
EasBuildTooManyPendingBuildsError,
RequestValidationError,
TurtleDeprecatedJobFormatError,
} from '../errors';
beforeEach(() => {
jest.resetAllMocks();
});
const EXPECTED_STRINGIFIED_GRAPHQL_ERROR_JSON = `[
{
"message": "Error 1"
},
{
"message": "Error 2"
},
{
"message": "Error 3"
}
]`;
const EXPECTED_EAS_BUILD_DOWN_MESSAGE = `EAS Build is down for maintenance. Try again later. Check ${link(
'https://status.expo.dev/'
)} for updates.`;
const EXPECTED_MAX_BUILD_COUNT_MESSAGE_ANDROID = `You have already reached the maximum number of pending Android builds for your account. Try again later.`;
const EXPECTED_MAX_BUILD_COUNT_MESSAGE_IOS = `You have already reached the maximum number of pending iOS builds for your account. Try again later.`;
const EXPECTED_GENERIC_MESSAGE =
'Build request failed. Make sure you are using the latest eas-cli version. If the problem persists, report the issue.';
const getGraphQLError = (message: string, errorCode: string): GraphQLError => {
return new GraphQLError(message, null, null, null, null, null, {
errorCode,
});
};
const assertReThrownError = (
caughtError: CombinedError | Error,
expectedType: typeof EasCommandError,
expectedMessage: string = 'Error 1'
): void => {
expect(caughtError).toBeInstanceOf(expectedType);
expect((caughtError as Error).message).toEqual(expectedMessage);
};
describe(Build.name, () => {
describe('handle build request errors', () => {
describe('logs graphQL errors to debug if present', () => {
it('does it with Android', async () => {
const platform = Platform.ANDROID;
const logDebugSpy = jest.spyOn(Log, 'debug');
const graphQLErrors = ['Error 1', 'Error 2', 'Error 3'];
const error = new CombinedError({ graphQLErrors });
try {
handleBuildRequestError(error, platform);
} catch {}
expect(logDebugSpy).toBeCalledWith(EXPECTED_STRINGIFIED_GRAPHQL_ERROR_JSON);
});
it('does it with iOS', async () => {
const platform = Platform.IOS;
const logDebugSpy = jest.spyOn(Log, 'debug');
const graphQLErrors = ['Error 1', 'Error 2', 'Error 3'];
const error = new CombinedError({ graphQLErrors });
try {
handleBuildRequestError(error, platform);
} catch {}
expect(logDebugSpy).toBeCalledWith(EXPECTED_STRINGIFIED_GRAPHQL_ERROR_JSON);
});
});
describe('logs nothing to debug if graphQLErrors not present', () => {
it('does it with Android', async () => {
const platform = Platform.ANDROID;
const logDebugSpy = jest.spyOn(Log, 'debug');
const error = new Error('Generic error');
try {
handleBuildRequestError(error, platform);
} catch {}
expect(logDebugSpy).toBeCalledWith(undefined);
});
it('does it with iOS', async () => {
const platform = Platform.IOS;
const logDebugSpy = jest.spyOn(Log, 'debug');
const error = new Error('Generic error');
try {
handleBuildRequestError(error, platform);
} catch {}
expect(logDebugSpy).toBeCalledWith(undefined);
});
});
describe('error is server-side defined error', () => {
describe('throws correct EasCommandError subclass', () => {
describe('for TURTLE_DEPRECATED_JOB_FORMAT', () => {
it('does it with Android', async () => {
const platform = Platform.ANDROID;
const graphQLError = getGraphQLError('Error 1', 'TURTLE_DEPRECATED_JOB_FORMAT');
const graphQLErrors = [graphQLError];
const error = new CombinedError({ graphQLErrors });
try {
handleBuildRequestError(error, platform);
} catch (caughtError) {
assertReThrownError(caughtError as Error, TurtleDeprecatedJobFormatError);
}
});
it('does it with iOS', async () => {
const platform = Platform.IOS;
const graphQLError = getGraphQLError('Error 1', 'TURTLE_DEPRECATED_JOB_FORMAT');
const graphQLErrors = [graphQLError];
const error = new CombinedError({ graphQLErrors });
try {
handleBuildRequestError(error, platform);
} catch (caughtError) {
assertReThrownError(caughtError as Error, TurtleDeprecatedJobFormatError);
}
});
});
describe('for EAS_BUILD_FREE_TIER_DISABLED', () => {
it('does it with Android', async () => {
const platform = Platform.ANDROID;
const graphQLError = getGraphQLError('Error 1', 'EAS_BUILD_FREE_TIER_DISABLED');
const graphQLErrors = [graphQLError];
const error = new CombinedError({ graphQLErrors });
try {
handleBuildRequestError(error, platform);
} catch (caughtError) {
assertReThrownError(caughtError as Error, EasBuildFreeTierDisabledError);
}
});
it('does it with iOS', async () => {
const platform = Platform.IOS;
const graphQLError = getGraphQLError('Error 1', 'EAS_BUILD_FREE_TIER_DISABLED');
const graphQLErrors = [graphQLError];
const error = new CombinedError({ graphQLErrors });
try {
handleBuildRequestError(error, platform);
} catch (caughtError) {
assertReThrownError(caughtError as Error, EasBuildFreeTierDisabledError);
}
});
});
it('does it for EAS_BUILD_FREE_TIER_DISABLED_IOS', async () => {
const platform = Platform.IOS;
const graphQLError = getGraphQLError('Error 1', 'EAS_BUILD_FREE_TIER_DISABLED_IOS');
const graphQLErrors = [graphQLError];
const error = new CombinedError({ graphQLErrors });
try {
handleBuildRequestError(error, platform);
} catch (caughtError) {
assertReThrownError(caughtError as Error, EasBuildFreeTierDisabledIOSError);
}
});
it('does it for EAS_BUILD_FREE_TIER_DISABLED_ANDROID', async () => {
const platform = Platform.ANDROID;
const graphQLError = getGraphQLError('Error 1', 'EAS_BUILD_FREE_TIER_DISABLED_ANDROID');
const graphQLErrors = [graphQLError];
const error = new CombinedError({ graphQLErrors });
try {
handleBuildRequestError(error, platform);
} catch (caughtError) {
assertReThrownError(caughtError as Error, EasBuildFreeTierDisabledAndroidError);
}
});
describe('for VALIDATION_ERROR', () => {
it('does it with Android', async () => {
const platform = Platform.ANDROID;
const graphQLError = getGraphQLError('Error 1', 'VALIDATION_ERROR');
const graphQLErrors = [graphQLError];
const error = new CombinedError({ graphQLErrors });
try {
handleBuildRequestError(error, platform);
} catch (caughtError) {
assertReThrownError(caughtError as Error, RequestValidationError);
}
});
it('does it with iOS', async () => {
const platform = Platform.IOS;
const graphQLError = getGraphQLError('Error 1', 'VALIDATION_ERROR');
const graphQLErrors = [graphQLError];
const error = new CombinedError({ graphQLErrors });
try {
handleBuildRequestError(error, platform);
} catch (caughtError) {
assertReThrownError(caughtError as Error, RequestValidationError);
}
});
});
});
});
describe('error is other expected graphQL-related error', () => {
describe('throws correct EasCommandError subclass', () => {
describe('for EAS_BUILD_DOWN_FOR_MAINTENANCE', () => {
it('does it with Android', async () => {
const platform = Platform.ANDROID;
const graphQLError = getGraphQLError('Error 1', 'EAS_BUILD_DOWN_FOR_MAINTENANCE');
const graphQLErrors = [graphQLError];
const error = new CombinedError({ graphQLErrors });
try {
handleBuildRequestError(error, platform);
} catch (caughtError) {
assertReThrownError(
caughtError as Error,
EasBuildDownForMaintenanceError,
EXPECTED_EAS_BUILD_DOWN_MESSAGE
);
}
});
it('does it with iOS', async () => {
const platform = Platform.IOS;
const graphQLError = getGraphQLError('Error 1', 'EAS_BUILD_DOWN_FOR_MAINTENANCE');
const graphQLErrors = [graphQLError];
const error = new CombinedError({ graphQLErrors });
try {
handleBuildRequestError(error, platform);
} catch (caughtError) {
assertReThrownError(
caughtError as Error,
EasBuildDownForMaintenanceError,
EXPECTED_EAS_BUILD_DOWN_MESSAGE
);
}
});
});
describe('for EAS_BUILD_TOO_MANY_PENDING_BUILDS', () => {
it('does it with Android', async () => {
const platform = Platform.ANDROID;
const graphQLError = getGraphQLError('Error 1', 'EAS_BUILD_TOO_MANY_PENDING_BUILDS');
const graphQLErrors = [graphQLError];
const error = new CombinedError({ graphQLErrors });
try {
handleBuildRequestError(error, platform);
} catch (caughtError) {
assertReThrownError(
caughtError as Error,
EasBuildTooManyPendingBuildsError,
EXPECTED_MAX_BUILD_COUNT_MESSAGE_ANDROID
);
}
});
it('does it with iOS', async () => {
const platform = Platform.IOS;
const graphQLError = getGraphQLError('Error 1', 'EAS_BUILD_TOO_MANY_PENDING_BUILDS');
const graphQLErrors = [graphQLError];
const error = new CombinedError({ graphQLErrors });
try {
handleBuildRequestError(error, platform);
} catch (caughtError) {
assertReThrownError(
caughtError as Error,
EasBuildTooManyPendingBuildsError,
EXPECTED_MAX_BUILD_COUNT_MESSAGE_IOS
);
}
});
});
});
});
describe('error is unexpected graphQL-related error', () => {
describe('throws base Error class with custom message', () => {
it('does it with Android', async () => {
const platform = Platform.ANDROID;
const graphQLError = getGraphQLError('Error 1', 'UNKNOWN_GRAPHQL_ERROR');
const graphQLErrors = [graphQLError];
const error = new CombinedError({ graphQLErrors });
try {
handleBuildRequestError(error, platform);
} catch (caughtError) {
assertReThrownError(caughtError as Error, Error, EXPECTED_GENERIC_MESSAGE);
}
});
it('does it with iOS', async () => {
const platform = Platform.IOS;
const graphQLError = getGraphQLError('Error 1', 'UNKNOWN_GRAPHQL_ERROR');
const graphQLErrors = [graphQLError];
const error = new CombinedError({ graphQLErrors });
try {
handleBuildRequestError(error, platform);
} catch (caughtError) {
assertReThrownError(caughtError as Error, Error, EXPECTED_GENERIC_MESSAGE);
}
});
});
});
describe('error is unexpected non-graphQL-related error', () => {
describe('throws base Error class with propagated message', () => {
it('does it with Android', async () => {
const platform = Platform.ANDROID;
const error = new Error('Non-graphQL-related error');
try {
handleBuildRequestError(error, platform);
} catch (caughtError) {
expect(caughtError).toStrictEqual(error);
}
});
it('does it with iOS', async () => {
const platform = Platform.IOS;
const error = new Error('Non-graphQL-related error');
try {
handleBuildRequestError(error, platform);
} catch (caughtError) {
expect(caughtError).toStrictEqual(error);
}
});
});
});
});
});
@@ -1,14 +0,0 @@
import { truncateGitCommitMessage } from '../metadata';
describe(truncateGitCommitMessage, () => {
it('returns undefined if no message was passed', () => {
expect(truncateGitCommitMessage(undefined)).toBeUndefined();
});
it('returns commit message', () => {
expect(truncateGitCommitMessage('a'.repeat(10))).toBe('a'.repeat(10));
});
it('truncates long commit messages', () => {
expect(truncateGitCommitMessage('a'.repeat(5000))).toBe(`${'a'.repeat(4093)}...`);
});
});
@@ -1,142 +0,0 @@
import { Platform, Workflow } from '@expo/eas-build-job';
import { ExitError } from '@oclif/core/lib/errors';
import path from 'path';
import { instance, mock, when } from 'ts-mockito';
import { CommonContext } from '../context';
import { validatePNGsForManagedProjectAsync } from '../validate';
const fixturesPath = path.join(__dirname, 'fixtures');
describe(validatePNGsForManagedProjectAsync, () => {
it('does not validate PNGs for generic projects', async () => {
const ctxMock = mock<CommonContext<Platform.ANDROID>>();
when(ctxMock.workflow).thenReturn(Workflow.GENERIC);
const ctx = instance(ctxMock);
await expect(validatePNGsForManagedProjectAsync(ctx)).resolves.not.toThrow();
});
it('does not validate PNGs for projects with SDK >= 47', async () => {
const ctxMock = mock<CommonContext<Platform.ANDROID>>();
when(ctxMock.workflow).thenReturn(Workflow.GENERIC);
when(ctxMock.exp).thenReturn({
name: 'blah',
slug: 'blah',
android: { adaptiveIcon: { foregroundImage: 'assets/icon.jpg' } },
sdkVersion: '47.0.0',
});
const ctx = instance(ctxMock);
await expect(validatePNGsForManagedProjectAsync(ctx)).resolves.not.toThrow();
});
describe(Platform.ANDROID, () => {
it('exits if foregroundImage is not a file with .png extension', async () => {
const ctxMock = mock<CommonContext<Platform.ANDROID>>();
when(ctxMock.workflow).thenReturn(Workflow.MANAGED);
when(ctxMock.platform).thenReturn(Platform.ANDROID);
when(ctxMock.exp).thenReturn({
name: 'blah',
slug: 'blah',
android: { adaptiveIcon: { foregroundImage: 'assets/icon.jpg' } },
sdkVersion: '46.0.0',
});
const ctx = instance(ctxMock);
await expect(validatePNGsForManagedProjectAsync(ctx)).rejects.toThrow(ExitError);
});
it('exits if foregroundImage is not a png file', async () => {
const ctxMock = mock<CommonContext<Platform.ANDROID>>();
when(ctxMock.workflow).thenReturn(Workflow.MANAGED);
when(ctxMock.platform).thenReturn(Platform.ANDROID);
when(ctxMock.exp).thenReturn({
name: 'blah',
slug: 'blah',
android: { adaptiveIcon: { foregroundImage: path.join(fixturesPath, 'icon-jpg.png') } },
sdkVersion: '46.0.0',
});
const ctx = instance(ctxMock);
await expect(validatePNGsForManagedProjectAsync(ctx)).rejects.toThrow(ExitError);
});
it('does not throw if foregroundImage is a png file', async () => {
const ctxMock = mock<CommonContext<Platform.ANDROID>>();
when(ctxMock.workflow).thenReturn(Workflow.MANAGED);
when(ctxMock.platform).thenReturn(Platform.ANDROID);
when(ctxMock.exp).thenReturn({
name: 'blah',
slug: 'blah',
android: { adaptiveIcon: { foregroundImage: path.join(fixturesPath, 'icon-alpha.png') } },
sdkVersion: '46.0.0',
});
const ctx = instance(ctxMock);
await expect(validatePNGsForManagedProjectAsync(ctx)).resolves.not.toThrow();
});
});
// Validating iOS PNGs is currently disabled
// See https://github.com/expo/eas-cli/pull/1477 for context
xdescribe(Platform.IOS, () => {
it('exits if icon is not a file with .png extension', async () => {
const ctxMock = mock<CommonContext<Platform.IOS>>();
when(ctxMock.workflow).thenReturn(Workflow.MANAGED);
when(ctxMock.platform).thenReturn(Platform.IOS);
when(ctxMock.exp).thenReturn({
name: 'blah',
slug: 'blah',
ios: { icon: path.join(fixturesPath, 'assets/icon.jpg') },
sdkVersion: '46.0.0',
});
const ctx = instance(ctxMock);
await expect(validatePNGsForManagedProjectAsync(ctx)).rejects.toThrow(ExitError);
});
it('exits if icon is not a png file', async () => {
const ctxMock = mock<CommonContext<Platform.IOS>>();
when(ctxMock.workflow).thenReturn(Workflow.MANAGED);
when(ctxMock.platform).thenReturn(Platform.IOS);
when(ctxMock.exp).thenReturn({
name: 'blah',
slug: 'blah',
ios: { icon: path.join(fixturesPath, 'icon-jpg.png') },
sdkVersion: '46.0.0',
});
const ctx = instance(ctxMock);
await expect(validatePNGsForManagedProjectAsync(ctx)).rejects.toThrow(ExitError);
});
it('exits if icon has alpha channel (transparency)', async () => {
const ctxMock = mock<CommonContext<Platform.IOS>>();
when(ctxMock.workflow).thenReturn(Workflow.MANAGED);
when(ctxMock.platform).thenReturn(Platform.IOS);
when(ctxMock.exp).thenReturn({
name: 'blah',
slug: 'blah',
ios: { icon: path.join(fixturesPath, 'icon-alpha.png') },
sdkVersion: '46.0.0',
});
const ctx = instance(ctxMock);
await expect(validatePNGsForManagedProjectAsync(ctx)).rejects.toThrow(ExitError);
});
it('does not throw if icon is a png file and does not have alpha channel', async () => {
const ctxMock = mock<CommonContext<Platform.IOS>>();
when(ctxMock.workflow).thenReturn(Workflow.MANAGED);
when(ctxMock.platform).thenReturn(Platform.IOS);
when(ctxMock.exp).thenReturn({
name: 'blah',
slug: 'blah',
ios: { icon: path.join(fixturesPath, 'icon-no-alpha.png') },
sdkVersion: '46.0.0',
});
const ctx = instance(ctxMock);
await expect(validatePNGsForManagedProjectAsync(ctx)).resolves.not.toThrow();
});
});
});
@@ -0,0 +1,115 @@
import { ExpoConfig } from '@expo/config';
import { AndroidConfig, AndroidManifest } from '@expo/config-plugins';
import fs from 'fs-extra';
import Log from '../../log';
import { getProjectAccountName } from '../../project/projectUtils';
import { ensureLoggedInAsync } from '../../user/actions';
import { ensureValidVersions } from '../utils/updates';
export async function configureUpdatesAsync(projectDir: string, exp: ExpoConfig): Promise<void> {
ensureValidVersions(exp);
const accountName = getProjectAccountName(exp, await ensureLoggedInAsync());
const buildGradlePath = AndroidConfig.Paths.getAppBuildGradleFilePath(projectDir);
const buildGradleContents = await fs.readFile(buildGradlePath, 'utf8');
if (!AndroidConfig.Updates.isBuildGradleConfigured(projectDir, buildGradleContents)) {
const updatedBuildGradleContents =
AndroidConfig.Updates.ensureBuildGradleContainsConfigurationScript(
projectDir,
buildGradleContents
);
await fs.writeFile(buildGradlePath, updatedBuildGradleContents);
}
const androidManifestPath = await AndroidConfig.Paths.getAndroidManifestAsync(projectDir);
const androidManifest = await getAndroidManifestAsync(projectDir);
if (!AndroidConfig.Updates.isMainApplicationMetaDataSynced(exp, androidManifest, accountName)) {
const result = AndroidConfig.Updates.setUpdatesConfig(exp, androidManifest, accountName);
await AndroidConfig.Manifest.writeAndroidManifestAsync(androidManifestPath, result);
}
}
export async function syncUpdatesConfigurationAsync(
projectDir: string,
exp: ExpoConfig
): Promise<void> {
ensureValidVersions(exp);
const accountName = getProjectAccountName(exp, await ensureLoggedInAsync());
try {
await ensureUpdatesConfiguredAsync(projectDir, exp);
} catch (error) {
Log.error(
'expo-updates module is not configured. Please run "eas build:configure" first to configure the project'
);
throw error;
}
const androidManifestPath = await AndroidConfig.Paths.getAndroidManifestAsync(projectDir);
let androidManifest = await getAndroidManifestAsync(projectDir);
if (!AndroidConfig.Updates.areVersionsSynced(exp, androidManifest)) {
androidManifest = AndroidConfig.Updates.setVersionsConfig(exp, androidManifest);
await AndroidConfig.Manifest.writeAndroidManifestAsync(androidManifestPath, androidManifest);
}
if (!AndroidConfig.Updates.isMainApplicationMetaDataSynced(exp, androidManifest, accountName)) {
Log.warn(
'Native project configuration is not synced with values present in your app.json, run "eas build:configure" to make sure all values are applied in the native project'
);
}
}
async function ensureUpdatesConfiguredAsync(projectDir: string, exp: ExpoConfig): Promise<void> {
const buildGradlePath = AndroidConfig.Paths.getAppBuildGradleFilePath(projectDir);
const buildGradleContents = await fs.readFile(buildGradlePath, 'utf8');
if (!AndroidConfig.Updates.isBuildGradleConfigured(projectDir, buildGradleContents)) {
const gradleScriptApply = AndroidConfig.Updates.formatApplyLineForBuildGradle(projectDir);
throw new Error(`Missing ${gradleScriptApply} in ${buildGradlePath}`);
}
const androidManifest = await getAndroidManifestAsync(projectDir);
if (!AndroidConfig.Updates.isMainApplicationMetaDataSet(androidManifest)) {
throw new Error('Missing values in AndroidManifest.xml');
}
}
export async function readReleaseChannelSafelyAsync(projectDir: string): Promise<string | null> {
try {
const androidManifest = await getAndroidManifestAsync(projectDir);
return AndroidConfig.Manifest.getMainApplicationMetaDataValue(
androidManifest,
AndroidConfig.Updates.Config.RELEASE_CHANNEL
);
} catch (err) {
return null;
}
}
export async function readChannelSafelyAsync(projectDir: string): Promise<string | null> {
try {
const androidManifest = await getAndroidManifestAsync(projectDir);
const stringifiedRequestHeaders = AndroidConfig.Manifest.getMainApplicationMetaDataValue(
androidManifest,
AndroidConfig.Updates.Config.UPDATES_CONFIGURATION_REQUEST_HEADERS_KEY
);
if (!stringifiedRequestHeaders) {
return null;
}
return JSON.parse(stringifiedRequestHeaders)['expo-channel-name'] ?? null;
} catch (err) {
return null;
}
}
async function getAndroidManifestAsync(projectDir: string): Promise<AndroidManifest> {
const androidManifestPath = await AndroidConfig.Paths.getAndroidManifestAsync(projectDir);
if (!androidManifestPath) {
throw new Error(`Could not find AndroidManifest.xml in project directory: "${projectDir}"`);
}
return AndroidConfig.Manifest.readAndroidManifestAsync(androidManifestPath);
}
@@ -31,7 +31,7 @@ beforeEach(async () => {
await fs.mkdirp(os.tmpdir());
});
// bare workflow
// generic workflow
describe(bumpVersionAsync, () => {
it('throws an informative error when multiple flavors are detected in the android project', async () => {
await initProjectWithGradleFileAsync(
@@ -69,64 +69,64 @@ describe(bumpVersionAsync, () => {
});
it('bumps expo.android.versionCode and buildGradle versionCode when strategy = BumpStrategy.VERSION_CODE', async () => {
const nativeVersionCode = 100; // this should be overwritten by bumpVersionAsync
const fakeExp = initBareWorkflowProject({ versionCode: nativeVersionCode });
const fakeExp = initGenericProject({ versionCode: nativeVersionCode });
await bumpVersionAsync({
bumpStrategy: BumpStrategy.VERSION_CODE,
projectDir: '/app',
projectDir: '/repo',
exp: fakeExp,
});
const appJSON = await fs.readJSON('/app/app.json');
const appJSON = await fs.readJSON('/repo/app.json');
expect(fakeExp.version).toBe('3.0.0');
expect(fakeExp.android?.versionCode).toBe(124);
expect(appJSON.expo.version).toBe('3.0.0');
expect(appJSON.expo.android.versionCode).toBe(124);
const buildGradle = await getAppBuildGradleAsync('/app');
const buildGradle = await getAppBuildGradleAsync('/repo');
assert(buildGradle);
expect(resolveConfigValue(buildGradle, 'versionCode')).toBe('124');
expect(resolveConfigValue(buildGradle, 'versionName')).toBe('3.0.0');
});
it('bumps expo.version and gradle versionCode when strategy = BumpStrategy.APP_VERSION', async () => {
it('bumps expo.version and gradle versionCode when strategy = BumpStrategy.SHORT_VERSION', async () => {
const nativeVersionName = '1.0.0'; // this should be overwritten by bumpVersionAsync
const fakeExp = initBareWorkflowProject({ versionName: nativeVersionName });
const fakeExp = initGenericProject({ versionName: nativeVersionName });
await bumpVersionAsync({
bumpStrategy: BumpStrategy.APP_VERSION,
projectDir: '/app',
projectDir: '/repo',
exp: fakeExp,
});
const appJSON = await fs.readJSON('/app/app.json');
const appJSON = await fs.readJSON('/repo/app.json');
expect(fakeExp.version).toBe('3.0.1');
expect(fakeExp.android?.versionCode).toBe(123);
expect(appJSON.expo.version).toBe('3.0.1');
expect(appJSON.expo.android.versionCode).toBe(123);
const buildGradle = await getAppBuildGradleAsync('/app');
const buildGradle = await getAppBuildGradleAsync('/repo');
assert(buildGradle);
expect(resolveConfigValue(buildGradle, 'versionCode')).toBe('123');
expect(resolveConfigValue(buildGradle, 'versionName')).toBe('3.0.1');
});
it('does not bump any version when strategy = BumpStrategy.NOOP', async () => {
const fakeExp = initBareWorkflowProject();
const fakeExp = initGenericProject();
await bumpVersionAsync({
bumpStrategy: BumpStrategy.NOOP,
projectDir: '/app',
projectDir: '/repo',
exp: fakeExp,
});
const appJSON = await fs.readJSON('/app/app.json');
const appJSON = await fs.readJSON('/repo/app.json');
expect(fakeExp.version).toBe('3.0.0');
expect(fakeExp.android?.versionCode).toBe(123);
expect(appJSON.expo.version).toBe('3.0.0');
expect(appJSON.expo.android.versionCode).toBe(123);
const buildGradle = await getAppBuildGradleAsync('/app');
const buildGradle = await getAppBuildGradleAsync('/repo');
assert(buildGradle);
expect(resolveConfigValue(buildGradle, 'versionCode')).toBe('123');
expect(resolveConfigValue(buildGradle, 'versionName')).toBe('3.0.0');
@@ -140,11 +140,11 @@ describe(bumpVersionInAppJsonAsync, () => {
await bumpVersionInAppJsonAsync({
bumpStrategy: BumpStrategy.VERSION_CODE,
projectDir: '/app',
projectDir: '/repo',
exp: fakeExp,
});
const appJSON = await fs.readJSON('/app/app.json');
const appJSON = await fs.readJSON('/repo/app.json');
expect(fakeExp.version).toBe('5.0.0');
expect(fakeExp.android?.versionCode).toBe(127);
expect(appJSON.expo.version).toBe('5.0.0');
@@ -156,11 +156,11 @@ describe(bumpVersionInAppJsonAsync, () => {
await bumpVersionInAppJsonAsync({
bumpStrategy: BumpStrategy.APP_VERSION,
projectDir: '/app',
projectDir: '/repo',
exp: fakeExp,
});
const appJSON = await fs.readJSON('/app/app.json');
const appJSON = await fs.readJSON('/repo/app.json');
expect(fakeExp.version).toBe('5.0.1');
expect(fakeExp.android?.versionCode).toBe(126);
expect(appJSON.expo.version).toBe('5.0.1');
@@ -172,11 +172,11 @@ describe(bumpVersionInAppJsonAsync, () => {
await bumpVersionInAppJsonAsync({
bumpStrategy: BumpStrategy.NOOP,
projectDir: '/app',
projectDir: '/repo',
exp: fakeExp,
});
const appJSON = await fs.readJSON('/app/app.json');
const appJSON = await fs.readJSON('/repo/app.json');
expect(fakeExp.version).toBe('5.0.0');
expect(fakeExp.android?.versionCode).toBe(126);
expect(appJSON.expo.version).toBe('5.0.0');
@@ -185,11 +185,11 @@ describe(bumpVersionInAppJsonAsync, () => {
});
describe(maybeResolveVersionsAsync, () => {
describe('bare project', () => {
describe('generic project', () => {
it('reads the versions from native code', async () => {
const exp = initBareWorkflowProject();
const exp = initGenericProject();
const { appVersion, appBuildVersion } = await maybeResolveVersionsAsync(
'/app',
'/repo',
exp,
{} as BuildProfile<Platform.ANDROID>
);
@@ -201,7 +201,7 @@ describe(maybeResolveVersionsAsync, () => {
it('reads the versions from expo config', async () => {
const exp = initManagedProject();
const { appVersion, appBuildVersion } = await maybeResolveVersionsAsync(
'/app',
'/repo',
exp,
{} as BuildProfile<Platform.ANDROID>
);
@@ -211,7 +211,7 @@ describe(maybeResolveVersionsAsync, () => {
});
});
function initBareWorkflowProject({
function initGenericProject({
versionCode = 123,
versionName = '3.0.0',
}: {
@@ -240,7 +240,7 @@ function initBareWorkflowProject({
}`,
'./android/app/src/main/AndroidManifest.xml': 'fake',
},
'/app'
'/repo'
);
return fakeExp;
@@ -261,7 +261,7 @@ function initManagedProject(): ExpoConfig {
expo: fakeExp,
}),
},
'/app'
'/repo'
);
return fakeExp;
+24 -52
View File
@@ -1,42 +1,36 @@
import { Android, Job, Metadata, Platform, Workflow } from '@expo/eas-build-job';
import { AppVersionSource } from '@expo/eas-json';
import chalk from 'chalk';
import nullthrows from 'nullthrows';
import AndroidCredentialsProvider, {
AndroidCredentials,
} from '../../credentials/android/AndroidCredentialsProvider';
import { BuildParamsInput } from '../../graphql/generated';
import { BuildMutation, BuildResult } from '../../graphql/mutations/BuildMutation';
import Log from '../../log';
import {
ensureApplicationIdIsDefinedForManagedProjectAsync,
getApplicationIdAsync,
} from '../../project/android/applicationId';
import { resolveGradleBuildContextAsync } from '../../project/android/gradle';
import { GradleBuildContext, resolveGradleBuildContextAsync } from '../../project/android/gradle';
import { toggleConfirmAsync } from '../../prompts';
import { findAccountByName } from '../../user/Account';
import {
BuildRequestSender,
CredentialsResult,
JobData,
prepareBuildRequestForPlatformAsync,
} from '../build';
import { AndroidBuildContext, BuildContext, CommonContext } from '../context';
import { BuildContext } from '../context';
import { transformMetadata } from '../graphql';
import { logCredentialsSource } from '../utils/credentials';
import {
checkGoogleServicesFileAsync,
checkNodeEnvVariable,
validatePNGsForManagedProjectAsync,
} from '../validate';
import { checkGoogleServicesFileAsync, checkNodeEnvVariable } from '../validate';
import { validateAndSyncProjectConfigurationAsync } from './configure';
import { transformJob } from './graphql';
import { prepareJobAsync } from './prepareJob';
import { syncProjectConfigurationAsync } from './syncProjectConfiguration';
import { resolveRemoteVersionCodeAsync } from './version';
export async function createAndroidContextAsync(
ctx: CommonContext<Platform.ANDROID>
): Promise<AndroidBuildContext> {
export async function prepareAndroidBuildAsync(
ctx: BuildContext<Platform.ANDROID>
): Promise<BuildRequestSender> {
const { buildProfile } = ctx;
if (buildProfile.distribution === 'internal' && buildProfile.gradleCommand?.match(/bundle/)) {
@@ -50,55 +44,35 @@ This means that it will most likely produce an AAB and you will not be able to i
Log.newLine();
const confirmed = await toggleConfirmAsync({ message: 'Would you like to proceed?' });
if (!confirmed) {
Log.error('Update eas.json and come back again.');
Log.error('Please update eas.json and come back again.');
process.exit(1);
}
}
checkNodeEnvVariable(ctx);
await checkGoogleServicesFileAsync(ctx);
await validatePNGsForManagedProjectAsync(ctx);
const gradleContext = await resolveGradleBuildContextAsync(ctx.projectDir, buildProfile);
if (ctx.workflow === Workflow.MANAGED) {
await ensureApplicationIdIsDefinedForManagedProjectAsync(ctx);
await ensureApplicationIdIsDefinedForManagedProjectAsync(ctx.projectDir, ctx.exp);
}
const applicationId = await getApplicationIdAsync(ctx.projectDir, ctx.exp, gradleContext);
const versionCodeOverride =
ctx.easJsonCliConfig?.appVersionSource === AppVersionSource.REMOTE
? await resolveRemoteVersionCodeAsync(ctx.graphqlClient, {
projectDir: ctx.projectDir,
projectId: ctx.projectId,
exp: ctx.exp,
applicationId,
buildProfile,
})
: undefined;
return { applicationId, gradleContext, versionCodeOverride };
}
export async function prepareAndroidBuildAsync(
ctx: BuildContext<Platform.ANDROID>
): Promise<BuildRequestSender> {
return await prepareBuildRequestForPlatformAsync({
ctx,
ensureCredentialsAsync: async (ctx: BuildContext<Platform.ANDROID>) => {
return await ensureAndroidCredentialsAsync(ctx);
return await ensureAndroidCredentialsAsync(ctx, gradleContext);
},
syncProjectConfigurationAsync: async () => {
await syncProjectConfigurationAsync(ctx.graphqlClient, {
ensureProjectConfiguredAsync: async () => {
await validateAndSyncProjectConfigurationAsync({
projectDir: ctx.projectDir,
exp: ctx.exp,
localAutoIncrement:
ctx.easJsonCliConfig?.appVersionSource === AppVersionSource.REMOTE
? false
: ctx.buildProfile.autoIncrement,
projectId: ctx.projectId,
buildProfile,
});
},
getMetadataContext: () => ({
gradleContext,
}),
prepareJobAsync: async (
ctx: BuildContext<Platform.ANDROID>,
jobData: JobData<AndroidCredentials>
@@ -108,28 +82,26 @@ export async function prepareAndroidBuildAsync(
sendBuildRequestAsync: async (
appId: string,
job: Android.Job,
metadata: Metadata,
buildParams: BuildParamsInput
metadata: Metadata
): Promise<BuildResult> => {
const graphqlMetadata = transformMetadata(metadata);
const graphqlJob = transformJob(job);
return await BuildMutation.createAndroidBuildAsync(ctx.graphqlClient, {
return await BuildMutation.createAndroidBuildAsync({
appId,
job: graphqlJob,
metadata: graphqlMetadata,
buildParams,
});
},
});
}
function shouldProvideCredentials(ctx: BuildContext<Platform.ANDROID>): boolean {
const isCustomBuild = ctx.buildProfile.config !== undefined;
return !ctx.buildProfile.withoutCredentials && !isCustomBuild;
return !ctx.buildProfile.withoutCredentials;
}
async function ensureAndroidCredentialsAsync(
ctx: BuildContext<Platform.ANDROID>
ctx: BuildContext<Platform.ANDROID>,
gradleContext?: GradleBuildContext
): Promise<CredentialsResult<AndroidCredentials> | undefined> {
if (!shouldProvideCredentials(ctx)) {
return;
@@ -137,12 +109,12 @@ async function ensureAndroidCredentialsAsync(
const androidApplicationIdentifier = await getApplicationIdAsync(
ctx.projectDir,
ctx.exp,
ctx.android.gradleContext
gradleContext
);
const provider = new AndroidCredentialsProvider(ctx.credentialsCtx, {
app: {
account: nullthrows(
ctx.user.accounts.find(a => a.name === ctx.accountName),
findAccountByName(ctx.user.accounts, ctx.accountName),
`You do not have access to account: ${ctx.accountName}`
),
projectName: ctx.projectName,
@@ -0,0 +1,75 @@
import { ExpoConfig } from '@expo/config';
import { AndroidConfig } from '@expo/config-plugins';
import { Platform, Workflow } from '@expo/eas-build-job';
import { AndroidBuildProfile, AndroidVersionAutoIncrement } from '@expo/eas-json';
import Log from '../../log';
import {
ensureApplicationIdIsDefinedForManagedProjectAsync,
warnIfAndroidPackageDefinedInAppConfigForGenericProject,
} from '../../project/android/applicationId';
import { resolveWorkflowAsync } from '../../project/workflow';
import vcs from '../../vcs';
import { ConfigureContext } from '../context';
import { isExpoUpdatesInstalled } from '../utils/updates';
import { configureUpdatesAsync, syncUpdatesConfigurationAsync } from './UpdatesModule';
import { BumpStrategy, bumpVersionAsync, bumpVersionInAppJsonAsync } from './version';
export async function configureAndroidAsync(ctx: ConfigureContext): Promise<void> {
if (!ctx.hasAndroidNativeProject) {
await ensureApplicationIdIsDefinedForManagedProjectAsync(ctx.projectDir, ctx.exp);
return;
}
warnIfAndroidPackageDefinedInAppConfigForGenericProject(ctx.projectDir, ctx.exp);
await AndroidConfig.EasBuild.configureEasBuildAsync(ctx.projectDir);
const easGradlePath = AndroidConfig.EasBuild.getEasBuildGradlePath(ctx.projectDir);
await vcs.trackFileAsync(easGradlePath);
if (isExpoUpdatesInstalled(ctx.projectDir)) {
await configureUpdatesAsync(ctx.projectDir, ctx.exp);
}
Log.withTick('Android project configured');
}
export async function validateAndSyncProjectConfigurationAsync({
projectDir,
exp,
buildProfile,
}: {
projectDir: string;
exp: ExpoConfig;
buildProfile: AndroidBuildProfile;
}): Promise<void> {
const workflow = await resolveWorkflowAsync(projectDir, Platform.ANDROID);
const { autoIncrement } = buildProfile;
const versionBumpStrategy = resolveVersionBumpStrategy(autoIncrement ?? false);
if (workflow === Workflow.GENERIC) {
if (!(await AndroidConfig.EasBuild.isEasBuildGradleConfiguredAsync(projectDir))) {
throw new Error(
'Project is not configured. Please run "eas build:configure" to configure the project.'
);
}
if (isExpoUpdatesInstalled(projectDir)) {
await syncUpdatesConfigurationAsync(projectDir, exp);
}
await bumpVersionAsync({ projectDir, exp, bumpStrategy: versionBumpStrategy });
} else {
await bumpVersionInAppJsonAsync({ projectDir, exp, bumpStrategy: versionBumpStrategy });
}
}
function resolveVersionBumpStrategy(autoIncrement: AndroidVersionAutoIncrement): BumpStrategy {
if (autoIncrement === true) {
return BumpStrategy.VERSION_CODE;
} else if (autoIncrement === false) {
return BumpStrategy.NOOP;
} else if (autoIncrement === 'versionCode') {
return BumpStrategy.VERSION_CODE;
} else {
return BumpStrategy.APP_VERSION;
}
}
+5 -15
View File
@@ -1,17 +1,11 @@
import { Android } from '@expo/eas-build-job';
import { AndroidBuildType, AndroidJobInput } from '../../graphql/generated';
import {
transformBuildMode,
transformBuildTrigger,
transformProjectArchive,
transformWorkflow,
} from '../graphql';
import { transformProjectArchive, transformWorkflow } from '../graphql';
export function transformJob(job: Android.Job): AndroidJobInput {
return {
type: transformWorkflow(job.type),
triggeredBy: transformBuildTrigger(job.triggeredBy),
projectArchive: transformProjectArchive(job.projectArchive),
projectRootDirectory: job.projectRootDirectory,
releaseChannel: job.releaseChannel,
@@ -19,23 +13,19 @@ export function transformJob(job: Android.Job): AndroidJobInput {
secrets: job.secrets,
builderEnvironment: job.builderEnvironment,
cache: job.cache,
version: job.version?.versionCode ? { versionCode: job.version.versionCode } : undefined,
gradleCommand: job.gradleCommand,
applicationArchivePath: job.applicationArchivePath,
buildArtifactPaths: job.buildArtifactPaths,
artifactPath: job.artifactPath,
username: job.username,
buildType: job.buildType && transformBuildType(job.buildType),
developmentClient: job.developmentClient,
experimental: job.experimental,
mode: transformBuildMode(job.mode),
customBuildConfig: job.customBuildConfig,
};
}
function transformBuildType(buildType: Android.BuildType): AndroidBuildType {
if (buildType === Android.BuildType.APK) {
return AndroidBuildType.Apk;
} else {
} else if (buildType === Android.BuildType.APP_BUNDLE) {
return AndroidBuildType.AppBundle;
} else {
return AndroidBuildType.DevelopmentClient;
}
}
@@ -1,20 +1,12 @@
import {
Android,
ArchiveSource,
BuildMode,
BuildTrigger,
Job,
Platform,
sanitizeJob,
} from '@expo/eas-build-job';
import { BuildProfile } from '@expo/eas-json';
import { Android, ArchiveSource, Job, Platform, sanitizeJob } from '@expo/eas-build-job';
import { AndroidBuildProfile } from '@expo/eas-json';
import path from 'path';
import slash from 'slash';
import { AndroidCredentials } from '../../credentials/android/AndroidCredentialsProvider';
import { getCustomBuildConfigPath } from '../../project/customBuildConfig';
import { getUsername } from '../../project/projectUtils';
import { getVcsClient } from '../../vcs';
import { ensureLoggedInAsync } from '../../user/actions';
import vcs from '../../vcs';
import { BuildContext } from '../context';
interface JobData {
@@ -25,16 +17,17 @@ interface JobData {
const cacheDefaults = {
disabled: false,
customPaths: [],
cacheDefaultPaths: true,
};
export async function prepareJobAsync(
ctx: BuildContext<Platform.ANDROID>,
jobData: JobData
): Promise<Job> {
const username = getUsername(ctx.exp, ctx.user);
const buildProfile: BuildProfile<Platform.ANDROID> = ctx.buildProfile;
const username = getUsername(ctx.exp, await ensureLoggedInAsync());
const buildProfile: AndroidBuildProfile = ctx.buildProfile;
const projectRootDirectory =
slash(path.relative(await getVcsClient().getRootPathAsync(), ctx.projectDir)) || '.';
slash(path.relative(await vcs.getRootPathAsync(), ctx.projectDir)) || '.';
const { credentials } = jobData;
const buildCredentials = credentials
? {
@@ -49,15 +42,11 @@ export async function prepareJobAsync(
}
: {};
let buildType = buildProfile.buildType;
if (!buildType && !buildProfile.gradleCommand && buildProfile.distribution === 'internal') {
let buildType = ctx.buildProfile.buildType;
if (!buildType && !buildProfile.gradleCommand && ctx.buildProfile.distribution === 'internal') {
buildType = Android.BuildType.APK;
}
const maybeCustomBuildConfigPath = buildProfile.config
? getCustomBuildConfigPath(buildProfile.config)
: undefined;
const job: Android.Job = {
type: ctx.workflow,
platform: Platform.ANDROID,
@@ -79,29 +68,14 @@ export async function prepareJobAsync(
secrets: {
...buildCredentials,
},
releaseChannel: buildProfile.releaseChannel,
updates: { channel: buildProfile.channel },
developmentClient: buildProfile.developmentClient,
releaseChannel: ctx.buildProfile.releaseChannel,
updates: { channel: ctx.buildProfile.channel },
gradleCommand: buildProfile.gradleCommand,
applicationArchivePath: buildProfile.applicationArchivePath ?? buildProfile.artifactPath,
buildArtifactPaths: buildProfile.buildArtifactPaths,
buildType,
artifactPath: buildProfile.artifactPath,
username,
...(ctx.android.versionCodeOverride && {
version: {
versionCode: ctx.android.versionCodeOverride,
},
}),
experimental: {
prebuildCommand: buildProfile.prebuildCommand,
},
mode: buildProfile.config ? BuildMode.CUSTOM : BuildMode.BUILD,
triggeredBy: BuildTrigger.EAS_CLI,
...(maybeCustomBuildConfigPath && {
customBuildConfig: {
path: maybeCustomBuildConfigPath,
},
}),
buildType: buildProfile.developmentClient ? Android.BuildType.DEVELOPMENT_CLIENT : buildType,
};
return sanitizeJob(job);
@@ -1,73 +0,0 @@
import { ExpoConfig } from '@expo/config';
import { AndroidConfig } from '@expo/config-plugins';
import { Platform, Workflow } from '@expo/eas-build-job';
import { AndroidVersionAutoIncrement } from '@expo/eas-json';
import chalk from 'chalk';
import fs from 'fs-extra';
import path from 'path';
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
import Log from '../../log';
import { isExpoUpdatesInstalled } from '../../project/projectUtils';
import { resolveWorkflowAsync } from '../../project/workflow';
import { syncUpdatesConfigurationAsync } from '../../update/android/UpdatesModule';
import { BumpStrategy, bumpVersionAsync, bumpVersionInAppJsonAsync } from './version';
export async function syncProjectConfigurationAsync(
graphqlClient: ExpoGraphqlClient,
{
projectDir,
exp,
localAutoIncrement,
projectId,
}: {
projectDir: string;
exp: ExpoConfig;
localAutoIncrement?: AndroidVersionAutoIncrement;
projectId: string;
}
): Promise<void> {
const workflow = await resolveWorkflowAsync(projectDir, Platform.ANDROID);
const versionBumpStrategy = resolveVersionBumpStrategy(localAutoIncrement ?? false);
if (workflow === Workflow.GENERIC) {
await cleanUpOldEasBuildGradleScriptAsync(projectDir);
if (isExpoUpdatesInstalled(projectDir)) {
await syncUpdatesConfigurationAsync(graphqlClient, projectDir, exp, projectId);
}
await bumpVersionAsync({ projectDir, exp, bumpStrategy: versionBumpStrategy });
} else {
await bumpVersionInAppJsonAsync({ projectDir, exp, bumpStrategy: versionBumpStrategy });
}
}
function resolveVersionBumpStrategy(autoIncrement: AndroidVersionAutoIncrement): BumpStrategy {
if (autoIncrement === true) {
return BumpStrategy.VERSION_CODE;
} else if (autoIncrement === false) {
return BumpStrategy.NOOP;
} else if (autoIncrement === 'versionCode') {
return BumpStrategy.VERSION_CODE;
} else {
return BumpStrategy.APP_VERSION;
}
}
// TODO: remove this after a few months
export async function cleanUpOldEasBuildGradleScriptAsync(projectDir: string): Promise<void> {
const easBuildGradlePath = path.join(projectDir, 'android', 'app', 'eas-build.gradle');
if (await fs.pathExists(easBuildGradlePath)) {
Log.withTick(`Removing ${chalk.bold('eas-build.gradle')} as it's not longer necessary`);
await fs.remove(easBuildGradlePath);
const buildGradlePath = AndroidConfig.Paths.getAppBuildGradleFilePath(projectDir);
const buildGradleContents = await fs.readFile(buildGradlePath, 'utf-8');
const buildGradleContentsWithoutApply = buildGradleContents.replace(
/apply from: ["'].\/eas-build.gradle["']\n/,
''
);
if (buildGradleContentsWithoutApply !== buildGradleContents) {
await fs.writeFile(buildGradlePath, buildGradleContentsWithoutApply);
}
}
}
+12 -129
View File
@@ -1,22 +1,16 @@
import { ExpoConfig } from '@expo/config';
import { AndroidConfig, Updates } from '@expo/config-plugins';
import { AndroidConfig } from '@expo/config-plugins';
import { Platform, Workflow } from '@expo/eas-build-job';
import { BuildProfile } from '@expo/eas-json';
import chalk from 'chalk';
import fs from 'fs-extra';
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
import { AppPlatform } from '../../graphql/generated';
import { AppVersionMutation } from '../../graphql/mutations/AppVersionMutation';
import { AppVersionQuery } from '../../graphql/queries/AppVersionQuery';
import Log from '../../log';
import { ora } from '../../ora';
import {
getAppBuildGradleAsync,
parseGradleCommand,
resolveConfigValue,
} from '../../project/android/gradleUtils';
import { getNextVersionCode } from '../../project/android/versions';
import { resolveWorkflowAsync } from '../../project/workflow';
import { updateAppJsonConfigAsync } from '../utils/appJson';
import { bumpAppVersionAsync, ensureStaticConfigExists } from '../utils/version';
@@ -52,10 +46,9 @@ export async function bumpVersionAsync({
await bumpVersionInAppJsonAsync({ bumpStrategy, projectDir, exp });
Log.log('Updated versions in app.json');
await updateNativeVersionsAsync({
await writeVersionsToBuildGradleAsync({
projectDir,
version: exp.version,
versionCode: exp.android?.versionCode,
exp,
});
Log.log('Synchronized versions with build gradle');
}
@@ -81,7 +74,7 @@ export async function bumpVersionInAppJsonAsync({
await bumpAppVersionAsync({ appVersion, projectDir, exp });
} else {
const versionCode = AndroidConfig.Version.getVersionCode(exp);
const bumpedVersionCode = getNextVersionCode(versionCode);
const bumpedVersionCode = versionCode + 1;
Log.log(
`Bumping ${chalk.bold('expo.android.versionCode')} from ${chalk.bold(
versionCode
@@ -112,7 +105,7 @@ export async function maybeResolveVersionsAsync(
appBuildVersion:
resolveConfigValue(buildGradle, 'versionCode', parsedGradleCommand?.flavor) ?? '1',
};
} catch {
} catch (err: any) {
return {};
}
} else {
@@ -123,33 +116,21 @@ export async function maybeResolveVersionsAsync(
}
}
export async function updateNativeVersionsAsync({
async function writeVersionsToBuildGradleAsync({
projectDir,
version,
versionCode,
exp,
}: {
projectDir: string;
version?: string;
versionCode?: number;
}): Promise<void> {
exp: ExpoConfig;
}): Promise<string> {
const buildGradle = await readBuildGradleAsync(projectDir);
if (!buildGradle) {
throw new Error('This project is missing a build.gradle file.');
}
let updatedBuildGradle = buildGradle;
if (version !== undefined) {
updatedBuildGradle = updatedBuildGradle.replace(
new RegExp(`versionName ".*"`),
`versionName "${version}"`
);
}
if (versionCode !== undefined) {
updatedBuildGradle = updatedBuildGradle.replace(
new RegExp(`versionCode.*`),
`versionCode ${versionCode}`
);
}
let updatedBuildGradle = AndroidConfig.Version.setVersionName(exp, buildGradle);
updatedBuildGradle = AndroidConfig.Version.setVersionCode(exp, updatedBuildGradle);
await writeBuildGradleAsync({ projectDir, buildGradle: updatedBuildGradle });
return updatedBuildGradle;
}
async function readBuildGradleAsync(projectDir: string): Promise<string | undefined> {
@@ -170,101 +151,3 @@ async function writeBuildGradleAsync({
const buildGradlePath = AndroidConfig.Paths.getAppBuildGradleFilePath(projectDir);
await fs.writeFile(buildGradlePath, buildGradle);
}
/**
* Returns buildNumber that will be used for the next build. If current build profile
* has an 'autoIncrement' option set, it increments the version on server.
*/
export async function resolveRemoteVersionCodeAsync(
graphqlClient: ExpoGraphqlClient,
{
projectDir,
projectId,
exp,
applicationId,
buildProfile,
}: {
projectDir: string;
projectId: string;
exp: ExpoConfig;
applicationId: string;
buildProfile: BuildProfile<Platform.ANDROID>;
}
): Promise<string> {
const remoteVersions = await AppVersionQuery.latestVersionAsync(
graphqlClient,
projectId,
AppPlatform.Android,
applicationId
);
const localVersions = await maybeResolveVersionsAsync(projectDir, exp, buildProfile);
let currentBuildVersion: string;
if (remoteVersions?.buildVersion) {
currentBuildVersion = remoteVersions.buildVersion;
} else {
if (localVersions.appBuildVersion) {
Log.warn(
'No remote versions are configured for this project, versionCode will be initialized based on the value from the local project.'
);
currentBuildVersion = localVersions.appBuildVersion;
} else {
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.`
);
throw new Error('Remote versions are not configured.');
}
}
if (!buildProfile.autoIncrement && remoteVersions?.buildVersion) {
return currentBuildVersion;
} else if (!buildProfile.autoIncrement && !remoteVersions?.buildVersion) {
const spinner = ora(
`Initializing versionCode with ${chalk.bold(currentBuildVersion)}.`
).start();
try {
await AppVersionMutation.createAppVersionAsync(graphqlClient, {
appId: projectId,
platform: AppPlatform.Android,
applicationIdentifier: applicationId,
storeVersion: localVersions.appVersion ?? exp.version ?? '1.0.0',
buildVersion: currentBuildVersion,
runtimeVersion: Updates.getRuntimeVersionNullable(exp, Platform.ANDROID) ?? undefined,
});
spinner.succeed(`Initialized versionCode with ${chalk.bold(currentBuildVersion)}.`);
} catch (err) {
spinner.fail(`Failed to initialize versionCode with ${chalk.bold(currentBuildVersion)}.`);
throw err;
}
return currentBuildVersion;
} else {
const nextBuildVersion = getNextVersionCode(currentBuildVersion);
const spinner = ora(
`Incrementing versionCode from ${chalk.bold(currentBuildVersion)} to ${chalk.bold(
nextBuildVersion
)}.`
).start();
try {
await AppVersionMutation.createAppVersionAsync(graphqlClient, {
appId: projectId,
platform: AppPlatform.Android,
applicationIdentifier: applicationId,
storeVersion: localVersions.appVersion ?? exp.version ?? '1.0.0',
buildVersion: String(nextBuildVersion),
runtimeVersion: Updates.getRuntimeVersionNullable(exp, Platform.ANDROID) ?? undefined,
});
spinner.succeed(
`Incremented versionCode from ${chalk.bold(currentBuildVersion)} to ${chalk.bold(
nextBuildVersion
)}.`
);
} catch (err) {
spinner.fail(
`Failed to increment versionCode from ${chalk.bold(currentBuildVersion)} to ${chalk.bold(
nextBuildVersion
)}.`
);
throw err;
}
return String(nextBuildVersion);
}
}
+205 -504
View File
@@ -1,54 +1,24 @@
import { ArchiveSource, ArchiveSourceType, Job, Metadata, Platform } from '@expo/eas-build-job';
import { CredentialsSource, EasJsonAccessor, EasJsonUtils, ResourceClass } from '@expo/eas-json';
import assert from 'assert';
import { CredentialsSource } from '@expo/eas-json';
import chalk from 'chalk';
import cliProgress from 'cli-progress';
import fs from 'fs-extra';
import nullthrows from 'nullthrows';
import { BuildEvent } from '../analytics/AnalyticsManager';
import { withAnalyticsAsync } from '../analytics/common';
import { getExpoWebsiteBaseUrl } from '../api';
import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient';
import { EasCommandError } from '../commandUtils/errors';
import {
AppPlatform,
BuildFragment,
BuildParamsInput,
BuildPriority,
BuildResourceClass,
BuildStatus,
UploadSessionType,
} from '../graphql/generated';
import { BuildMutation, BuildResult } from '../graphql/mutations/BuildMutation';
import { BuildFragment, BuildStatus, UploadSessionType } from '../graphql/generated';
import { BuildResult } from '../graphql/mutations/BuildMutation';
import { BuildQuery } from '../graphql/queries/BuildQuery';
import Log, { learnMore, link } from '../log';
import { Ora, ora } from '../ora';
import {
appPlatformDisplayNames,
appPlatformEmojis,
requestedPlatformDisplayNames,
} from '../platform';
import { confirmAsync } from '../prompts';
import { uploadFileAtPathToGCSAsync } from '../uploads';
import Log, { learnMore } from '../log';
import { ora } from '../ora';
import { requestedPlatformDisplayNames } from '../platform';
import { uploadAsync } from '../uploads';
import { formatBytes } from '../utils/files';
import { printJsonOnlyOutput } from '../utils/json';
import { createProgressTracker } from '../utils/progress';
import { sleepAsync } from '../utils/promise';
import { getVcsClient } from '../vcs';
import vcs 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 { runLocalBuildAsync } from './local';
import { MetadataContext, collectMetadataAsync } from './metadata';
import { TrackingContext } from './types';
import Analytics, { Event } from './utils/analytics';
import { printDeprecationWarnings } from './utils/printBuildInfo';
import { makeProjectTarballAsync, reviewAndCommitChangesAsync } from './utils/repository';
@@ -68,24 +38,14 @@ interface Builder<TPlatform extends Platform, Credentials, TJob extends Job> {
ensureCredentialsAsync(
ctx: BuildContext<TPlatform>
): Promise<CredentialsResult<Credentials> | undefined>;
syncProjectConfigurationAsync(ctx: BuildContext<TPlatform>): Promise<void>;
ensureProjectConfiguredAsync(ctx: BuildContext<TPlatform>): Promise<void>;
getMetadataContext: () => MetadataContext<TPlatform>;
prepareJobAsync(ctx: BuildContext<TPlatform>, jobData: JobData<Credentials>): Promise<Job>;
sendBuildRequestAsync(
appId: string,
job: TJob,
metadata: Metadata,
buildParams: BuildParamsInput
): Promise<BuildResult>;
sendBuildRequestAsync(appId: string, job: TJob, metadata: Metadata): Promise<BuildResult>;
}
export type BuildRequestSender = () => Promise<BuildFragment | undefined>;
function resolveBuildParamsInput<T extends Platform>(ctx: BuildContext<T>): BuildParamsInput {
return {
resourceClass: ctx.resourceClass,
};
}
export async function prepareBuildRequestForPlatformAsync<
TPlatform extends Platform,
Credentials,
@@ -93,28 +53,22 @@ export async function prepareBuildRequestForPlatformAsync<
>(builder: Builder<TPlatform, Credentials, TJob>): Promise<BuildRequestSender> {
const { ctx } = builder;
const credentialsResult = await withAnalyticsAsync(
ctx.analytics,
async () => await builder.ensureCredentialsAsync(ctx),
{
attemptEvent: BuildEvent.GATHER_CREDENTIALS_ATTEMPT,
successEvent: BuildEvent.GATHER_CREDENTIALS_SUCCESS,
failureEvent: BuildEvent.GATHER_CREDENTIALS_FAIL,
properties: ctx.analyticsEventProperties,
successEvent: Event.GATHER_CREDENTIALS_SUCCESS,
failureEvent: Event.GATHER_CREDENTIALS_FAIL,
trackingCtx: ctx.trackingCtx,
}
);
if (!ctx.skipProjectConfiguration) {
await withAnalyticsAsync(async () => await builder.ensureProjectConfiguredAsync(ctx), {
successEvent: Event.CONFIGURE_PROJECT_SUCCESS,
failureEvent: Event.CONFIGURE_PROJECT_FAIL,
trackingCtx: ctx.trackingCtx,
});
}
await withAnalyticsAsync(
ctx.analytics,
async () => await builder.syncProjectConfigurationAsync(ctx),
{
attemptEvent: BuildEvent.CONFIGURE_PROJECT_ATTEMPT,
successEvent: BuildEvent.CONFIGURE_PROJECT_SUCCESS,
failureEvent: BuildEvent.CONFIGURE_PROJECT_FAIL,
properties: ctx.analyticsEventProperties,
}
);
if (await getVcsClient().isCommitRequiredAsync()) {
if (await vcs.hasUncommittedChangesAsync()) {
Log.addNewLineIfNone();
await reviewAndCommitChangesAsync(
`[EAS Build] Run EAS Build for ${requestedPlatformDisplayNames[ctx.platform as Platform]}`,
@@ -122,119 +76,80 @@ export async function prepareBuildRequestForPlatformAsync<
);
}
let projectArchive: ArchiveSource | undefined;
if (ctx.localBuildOptions.localBuildMode === LocalBuildMode.LOCAL_BUILD_PLUGIN) {
projectArchive = {
type: ArchiveSourceType.PATH,
path: (await makeProjectTarballAsync()).path,
};
} else if (ctx.localBuildOptions.localBuildMode === LocalBuildMode.INTERNAL) {
projectArchive = {
type: ArchiveSourceType.PATH,
path: process.cwd(),
};
} else if (!ctx.localBuildOptions.localBuildMode) {
projectArchive = {
type: ArchiveSourceType.GCS,
bucketKey: await uploadProjectAsync(ctx),
};
}
assert(projectArchive);
const projectArchive = ctx.local
? ({
type: ArchiveSourceType.PATH,
path: (await makeProjectTarballAsync()).path,
} as const)
: ({
type: ArchiveSourceType.S3,
bucketKey: await uploadProjectAsync(ctx),
} as const);
const metadata = await collectMetadataAsync(ctx);
const buildParams = resolveBuildParamsInput(ctx);
const metadataContext = builder.getMetadataContext();
const metadata = await collectMetadataAsync(ctx, metadataContext);
const job = await builder.prepareJobAsync(ctx, {
projectArchive,
credentials: credentialsResult?.credentials,
});
return async () => {
if (ctx.localBuildOptions.localBuildMode === LocalBuildMode.LOCAL_BUILD_PLUGIN) {
await runLocalBuildAsync(job, metadata, ctx.localBuildOptions);
if (ctx.local) {
await runLocalBuildAsync(job);
return undefined;
} else if (ctx.localBuildOptions.localBuildMode === LocalBuildMode.INTERNAL) {
await BuildMutation.updateBuildMetadataAsync(ctx.graphqlClient, {
buildId: nullthrows(process.env.EAS_BUILD_ID),
metadata: transformMetadata(metadata),
});
printJsonOnlyOutput({ job, metadata });
return undefined;
} else if (!ctx.localBuildOptions.localBuildMode) {
try {
return await sendBuildRequestAsync(builder, job, metadata, buildParams);
} catch (error: any) {
handleBuildRequestError(error, job.platform);
}
} else {
throw new Error('Unknown localBuildMode.');
try {
return await sendBuildRequestAsync(builder, job, metadata);
} catch (error: any) {
if (error?.graphQLErrors?.[0]?.extensions?.errorCode === 'TURTLE_DEPRECATED_JOB_FORMAT') {
Log.error('EAS Build API has changed, please upgrade to the latest eas-cli version.');
throw new Error('Build request failed.');
} else if (
error?.graphQLErrors?.[0]?.extensions?.errorCode === 'EAS_BUILD_DOWN_FOR_MAINTENANCE'
) {
Log.error(
'EAS Build is down for maintenance, please try again later. Check https://status.expo.dev/ for updates.'
);
throw new Error('Build request failed.');
} else if (
error?.graphQLErrors?.[0]?.extensions?.errorCode === 'EAS_BUILD_FREE_TIER_DISABLED'
) {
Log.error(
'EAS Build free tier is temporarily disabled, please try again later. Check https://status.expo.dev/ for updates.'
);
throw new Error('Build request failed.');
} else if (
error?.graphQLErrors?.[0]?.extensions?.errorCode === 'EAS_BUILD_TOO_MANY_PENDING_BUILDS'
) {
Log.error(
`You have already reached the maximum number of pending ${
requestedPlatformDisplayNames[job.platform]
} builds for your account. Try again later.`
);
throw new Error('Build request failed.');
} else if (error?.graphQLErrors) {
Log.error(
'Build request failed. Make sure you are using the latest eas-cli version. If the problem persists, please report the issue.'
);
}
throw error;
}
}
};
}
const SERVER_SIDE_DEFINED_ERRORS: Record<string, typeof EasCommandError> = {
TURTLE_DEPRECATED_JOB_FORMAT: TurtleDeprecatedJobFormatError,
EAS_BUILD_FREE_TIER_DISABLED: EasBuildFreeTierDisabledError,
EAS_BUILD_FREE_TIER_DISABLED_IOS: EasBuildFreeTierDisabledIOSError,
EAS_BUILD_FREE_TIER_DISABLED_ANDROID: EasBuildFreeTierDisabledAndroidError,
VALIDATION_ERROR: RequestValidationError,
};
export function handleBuildRequestError(error: any, platform: Platform): never {
Log.debug(JSON.stringify(error.graphQLErrors, null, 2));
const graphQLErrorCode: string = error?.graphQLErrors?.[0]?.extensions?.errorCode;
if (graphQLErrorCode in SERVER_SIDE_DEFINED_ERRORS) {
const ErrorClass: typeof EasCommandError = SERVER_SIDE_DEFINED_ERRORS[graphQLErrorCode];
throw new ErrorClass(error?.graphQLErrors?.[0]?.message);
} else if (graphQLErrorCode === 'EAS_BUILD_DOWN_FOR_MAINTENANCE') {
throw new EasBuildDownForMaintenanceError(
`EAS Build is down for maintenance. Try again later. Check ${link(
'https://status.expo.dev/'
)} for updates.`
);
} else if (graphQLErrorCode === 'EAS_BUILD_TOO_MANY_PENDING_BUILDS') {
throw new EasBuildTooManyPendingBuildsError(
`You have already reached the maximum number of pending ${requestedPlatformDisplayNames[platform]} builds for your account. Try again later.`
);
} else if (error?.graphQLErrors) {
throw new Error(
'Build request failed. Make sure you are using the latest eas-cli version. If the problem persists, report the issue.'
);
}
throw error;
}
async function uploadProjectAsync<TPlatform extends Platform>(
ctx: BuildContext<TPlatform>
): Promise<string> {
let projectTarballPath;
try {
return await withAnalyticsAsync(
ctx.analytics,
async () => {
Log.newLine();
Log.log(
`Compressing project files and uploading to EAS Build. ${learnMore(
'https://expo.fyi/eas-build-archive'
)}`
);
const projectTarball = await makeProjectTarballAsync();
if (projectTarball.size > 1024 * 1024 * 100) {
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;
const bucketKey = await uploadFileAtPathToGCSAsync(
ctx.graphqlClient,
UploadSessionType.EasBuildGcsProjectSources,
const { bucketKey } = await uploadAsync(
UploadSessionType.EasBuildProjectSources,
projectTarball.path,
createProgressTracker({
total: projectTarball.size,
@@ -242,16 +157,18 @@ async function uploadProjectAsync<TPlatform extends Platform>(
`Uploading to EAS Build (${formatBytes(projectTarball.size * ratio)} / ${formatBytes(
projectTarball.size
)})`,
completedMessage: (duration: string) => `Uploaded to EAS ${chalk.dim(duration)}`,
completedMessage: (duration: string) =>
`Uploaded to EAS ${chalk.dim(duration)} ${learnMore(
'https://expo.fyi/eas-build-archive'
)}`,
})
);
return bucketKey;
},
{
attemptEvent: BuildEvent.PROJECT_UPLOAD_ATTEMPT,
successEvent: BuildEvent.PROJECT_UPLOAD_SUCCESS,
failureEvent: BuildEvent.PROJECT_UPLOAD_FAIL,
properties: ctx.analyticsEventProperties,
successEvent: Event.PROJECT_UPLOAD_SUCCESS,
failureEvent: Event.PROJECT_UPLOAD_FAIL,
trackingCtx: ctx.trackingCtx,
}
);
} finally {
@@ -264,12 +181,10 @@ async function uploadProjectAsync<TPlatform extends Platform>(
async function sendBuildRequestAsync<TPlatform extends Platform, Credentials, TJob extends Job>(
builder: Builder<TPlatform, Credentials, TJob>,
job: TJob,
metadata: Metadata,
buildParams: BuildParamsInput
metadata: Metadata
): Promise<BuildFragment> {
const { ctx } = builder;
return await withAnalyticsAsync(
ctx.analytics,
async () => {
if (Log.isDebug) {
Log.log(`Starting ${requestedPlatformDisplayNames[job.platform]} build`);
@@ -278,353 +193,139 @@ async function sendBuildRequestAsync<TPlatform extends Platform, Credentials, TJ
const { build, deprecationInfo } = await builder.sendBuildRequestAsync(
ctx.projectId,
job,
metadata,
buildParams
metadata
);
printDeprecationWarnings(deprecationInfo);
return build;
},
{
attemptEvent: BuildEvent.BUILD_REQUEST_ATTEMPT,
successEvent: BuildEvent.BUILD_REQUEST_SUCCESS,
failureEvent: BuildEvent.BUILD_REQUEST_FAIL,
properties: ctx.analyticsEventProperties,
successEvent: Event.BUILD_REQUEST_SUCCESS,
failureEvent: Event.BUILD_REQUEST_FAIL,
trackingCtx: ctx.trackingCtx,
}
);
}
export type MaybeBuildFragment = BuildFragment | null;
async function withAnalyticsAsync<Result>(
fn: () => Promise<Result>,
analytics: {
successEvent: Event;
failureEvent: Event;
trackingCtx: TrackingContext;
}
): Promise<Result> {
try {
const result = await fn();
Analytics.logEvent(analytics.successEvent, analytics.trackingCtx);
return result;
} catch (error: any) {
Analytics.logEvent(analytics.failureEvent, {
...analytics.trackingCtx,
reason: error.message,
});
throw error;
}
}
export async function waitForBuildEndAsync(
graphqlClient: ExpoGraphqlClient,
{
buildIds,
accountName,
projectDir,
nonInteractive,
}: { buildIds: string[]; accountName: string; projectDir: string; nonInteractive: boolean },
{ intervalSec = 10 } = {}
): Promise<MaybeBuildFragment[]> {
let spinner;
let originalSpinnerText;
if (buildIds.length === 1) {
Log.log('Waiting for build to complete. You can press Ctrl+C to exit.');
originalSpinnerText = 'Waiting for build to complete.';
spinner = ora(originalSpinnerText).start();
} else {
originalSpinnerText = 'Waiting for builds to complete. You can press Ctrl+C to exit.';
spinner = ora('Waiting for builds to complete. You can press Ctrl+C to exit.').start();
}
while (true) {
const builds = await getBuildsSafelyAsync(graphqlClient, buildIds);
const { refetch } =
builds.length === 1
? await handleSingleBuildProgressAsync(
{ build: builds[0], accountName, projectDir, nonInteractive },
{ spinner }
)
: await handleMultipleBuildsProgressAsync({ builds }, { spinner, originalSpinnerText });
if (!refetch) {
return builds;
buildIds: string[],
{ timeoutSec = 3600, intervalSec = 30 } = {}
): Promise<(BuildFragment | null)[]> {
Log.log(
`Waiting for build${buildIds.length > 1 ? 's' : ''} to complete. You can press Ctrl+C to exit.`
);
const spinner = ora().start();
let time = new Date().getTime();
const endTime = time + timeoutSec * 1000;
while (time <= endTime) {
const builds: (BuildFragment | null)[] = await Promise.all(
buildIds.map(async buildId => {
try {
return await BuildQuery.byIdAsync(buildId, { useCache: false });
} catch (err) {
Log.debug('Failed to fetch the build status', err);
return null;
}
})
);
if (builds.length === 1) {
const [build] = builds;
if (build !== null) {
switch (build.status) {
case BuildStatus.Finished:
spinner.succeed('Build finished');
return builds;
case BuildStatus.New:
spinner.text = 'Build created';
break;
case BuildStatus.InQueue:
spinner.text = 'Build queued...';
break;
case BuildStatus.Canceled:
spinner.text = 'Build canceled';
spinner.stopAndPersist();
return builds;
case BuildStatus.InProgress:
spinner.text = 'Build in progress...';
break;
case BuildStatus.Errored:
spinner.fail('Build failed');
if (build.error) {
return builds;
} else {
throw new Error(`Standalone build failed!`);
}
default:
spinner.warn('Unknown status');
throw new Error(`Unknown build status: ${build.status} - aborting!`);
}
} else {
if (!spinner.text) {
spinner.text = 'Could not fetch the build status. Check your network connection.';
}
}
} else {
if (builds.filter(build => build?.status === BuildStatus.Finished).length === builds.length) {
spinner.succeed('All builds have finished');
return builds;
} else if (
builds.filter(build =>
build?.status
? [BuildStatus.Finished, BuildStatus.Errored, BuildStatus.Canceled].includes(
build.status
)
: false
).length === builds.length
) {
spinner.fail('Some of the builds were canceled or failed.');
return builds;
} else {
const newBuilds = builds.filter(build => build?.status === BuildStatus.New).length;
const inQueue = builds.filter(build => build?.status === BuildStatus.InQueue).length;
const inProgress = builds.filter(build => build?.status === BuildStatus.InProgress).length;
const errored = builds.filter(build => build?.status === BuildStatus.Errored).length;
const finished = builds.filter(build => build?.status === BuildStatus.Finished).length;
const canceled = builds.filter(build => build?.status === BuildStatus.Canceled).length;
const unknown = builds.length - newBuilds - inQueue - inProgress - errored - finished;
spinner.text = [
newBuilds && `Builds created: ${newBuilds}`,
inQueue && `Builds in queue: ${inQueue}`,
inProgress && `Builds in progress: ${inProgress}`,
canceled && `Builds canceled: ${canceled}`,
errored && chalk.red(`Builds failed: ${errored}`),
finished && chalk.green(`Builds finished: ${finished}`),
unknown && chalk.red(`Builds with unknown status: ${unknown}`),
]
.filter(i => i)
.join('\t');
}
}
time = new Date().getTime();
await sleepAsync(intervalSec * 1000);
}
}
async function getBuildsSafelyAsync(
graphqlClient: ExpoGraphqlClient,
buildIds: string[]
): Promise<MaybeBuildFragment[]> {
const promises = buildIds.map(async buildId => {
try {
return await BuildQuery.byIdAsync(graphqlClient, buildId, { useCache: false });
} catch (err) {
Log.debug('Failed to fetch the build status', err);
return null;
}
});
return await Promise.all(promises);
}
interface BuildProgressResult {
refetch: boolean;
}
let queueProgressBarStarted = false;
const queueProgressBar = new cliProgress.SingleBar(
{ format: '|{bar}| {estimatedWaitTime}' },
cliProgress.Presets.rect
);
async function handleSingleBuildProgressAsync(
{
build,
accountName,
projectDir,
nonInteractive,
}: {
build: MaybeBuildFragment;
accountName: string;
projectDir: string;
nonInteractive: boolean;
},
{ spinner }: { spinner: Ora }
): Promise<BuildProgressResult> {
if (build === null) {
spinner.text = 'Could not fetch the build status. Check your network connection.';
return { refetch: true };
}
if (queueProgressBarStarted && build?.status && build.status !== BuildStatus.InQueue) {
if (build.status === BuildStatus.InProgress) {
queueProgressBar.update(queueProgressBar.getTotal(), {
estimatedWaitTime: '',
});
}
queueProgressBar.stop();
Log.newLine();
queueProgressBarStarted = false;
spinner.start('Build is about to start');
}
switch (build.status) {
case BuildStatus.Finished:
spinner.succeed('Build finished');
return { refetch: false };
case BuildStatus.New:
spinner.text = `Build is waiting to enter the queue. Check your concurrency limit at ${link(
formatAccountSubscriptionsUrl(accountName)
)}.`;
break;
case BuildStatus.InQueue: {
spinner.text = 'Build queued...';
const progressBarPayload =
typeof build.estimatedWaitTimeLeftSeconds === 'number'
? { estimatedWaitTime: formatEstimatedWaitTime(build.estimatedWaitTimeLeftSeconds) }
: { estimatedWaitTime: '' };
if (
!queueProgressBarStarted &&
typeof build.initialQueuePosition === 'number' &&
typeof build.queuePosition === 'number' &&
typeof build.estimatedWaitTimeLeftSeconds === 'number'
) {
spinner.stopAndPersist();
if (build.priority !== BuildPriority.High) {
Log.newLine();
Log.log('Start builds sooner in the priority queue.');
Log.log(
`Sign up for EAS Production or Enterprise at ${link(
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.log(`Waiting in ${priorityToQueueDisplayName[build.priority]}`);
queueProgressBar.start(
build.initialQueuePosition + 1,
build.initialQueuePosition - build.queuePosition + 1,
progressBarPayload
);
queueProgressBarStarted = true;
}
if (typeof build.queuePosition === 'number') {
queueProgressBar.update(build.queuePosition, progressBarPayload);
}
break;
}
case BuildStatus.Canceled:
spinner.fail('Build canceled');
return { refetch: false };
case BuildStatus.InProgress:
spinner.text = 'Build in progress...';
break;
case BuildStatus.Errored:
spinner.fail('Build failed');
if (build.error) {
return { refetch: false };
} else {
throw new Error('Standalone build failed!');
}
default:
spinner.warn('Unknown status');
throw new Error(`Unknown build status: ${build.status} - aborting!`);
}
return { refetch: true };
}
const priorityToQueueDisplayName: Record<BuildPriority, string> = {
[BuildPriority.Normal]: 'Free tier queue',
[BuildPriority.NormalPlus]: 'Free tier queue',
[BuildPriority.High]: 'priority queue',
};
const statusToDisplayName: Record<BuildStatus, string> = {
[BuildStatus.New]: 'waiting to enter the queue (concurrency limit reached)',
[BuildStatus.InQueue]: 'in queue',
[BuildStatus.InProgress]: 'in progress',
[BuildStatus.Canceled]: 'canceled',
[BuildStatus.Finished]: 'finished',
[BuildStatus.Errored]: 'failed',
};
const platforms = [AppPlatform.Android, AppPlatform.Ios];
async function handleMultipleBuildsProgressAsync(
{ builds: maybeBuilds }: { builds: MaybeBuildFragment[] },
{ spinner, originalSpinnerText }: { spinner: Ora; originalSpinnerText: string }
): Promise<BuildProgressResult> {
const buildCount = maybeBuilds.length;
const builds = maybeBuilds.filter<BuildFragment>(isBuildFragment);
const allFinished =
builds.filter(build => build.status === BuildStatus.Finished).length === buildCount;
const allSettled =
builds.filter(build =>
[BuildStatus.Finished, BuildStatus.Errored, BuildStatus.Canceled].includes(build.status)
).length === buildCount;
if (allSettled) {
if (allFinished) {
spinner.succeed(formatSettledBuildsText(builds));
} else {
spinner.fail(formatSettledBuildsText(builds));
}
return { refetch: false };
} else {
spinner.text = formatPendingBuildsText(originalSpinnerText, builds);
return { refetch: true };
}
}
function formatSettledBuildsText(builds: BuildFragment[]): string {
return platforms
.map(platform => {
const build = nullthrows(
builds.find(build => build.platform === platform),
`Build for platform ${platform} must be defined in this context`
);
return `${appPlatformEmojis[platform]} ${
appPlatformDisplayNames[platform]
} build - status: ${chalk.bold(statusToDisplayName[build.status])}`;
})
.join('\n ');
}
function formatPendingBuildsText(originalSpinnerText: string, builds: BuildFragment[]): string {
return [
originalSpinnerText,
...platforms.map(platform => {
const build = builds.find(build => build.platform === platform);
const status = build ? statusToDisplayName[build.status] : 'unknown';
let extraInfo = '';
if (
build?.status === BuildStatus.InQueue &&
typeof build.initialQueuePosition === 'number' &&
typeof build.queuePosition === 'number'
) {
const percent = Math.floor(
((build.initialQueuePosition - build.queuePosition + 1) /
(build.initialQueuePosition + 1)) *
100
);
const estimatedWaitTime =
typeof build.estimatedWaitTimeLeftSeconds === 'number'
? ` - ${formatEstimatedWaitTime(build.estimatedWaitTimeLeftSeconds)}`
: '';
extraInfo = ` - queue progress: ${chalk.bold(`${percent}%`)}${estimatedWaitTime}`;
}
return `${appPlatformEmojis[platform]} ${
appPlatformDisplayNames[platform]
} build - status: ${chalk.bold(status)}${extraInfo}`;
}),
].join('\n ');
}
function isBuildFragment(maybeBuild: MaybeBuildFragment): maybeBuild is BuildFragment {
return maybeBuild !== null;
}
function formatEstimatedWaitTime(estimatedWaitTimeLeftSeconds: number): string {
if (estimatedWaitTimeLeftSeconds < 5 * 60) {
return 'starting soon...';
} else {
const n = Math.floor(estimatedWaitTimeLeftSeconds / (10 * 60)) + 1;
return `starting in about ${n}0 minutes...`;
}
}
function formatAccountSubscriptionsUrl(accountName: string): string {
return new URL(
`/accounts/${accountName}/settings/subscriptions`,
getExpoWebsiteBaseUrl()
).toString();
}
async function updateIosBuildProfilesToUseM1WorkersAsync(projectDir: string): Promise<void> {
const easJsonAccessor = EasJsonAccessor.fromProjectPath(projectDir);
await easJsonAccessor.readRawJsonAsync();
const profileNames = await EasJsonUtils.getBuildProfileNamesAsync(easJsonAccessor);
easJsonAccessor.patch(easJsonRawObject => {
for (const profileName of profileNames) {
easJsonRawObject.build[profileName].ios = {
...easJsonRawObject.build[profileName].ios,
resourceClass: ResourceClass.M_MEDIUM,
};
}
return easJsonRawObject;
});
await easJsonAccessor.writeAsync();
Log.withTick('Updated eas.json. Your next builds will run on M1 workers.');
spinner.warn('Timed out');
throw new Error(
'Timeout reached! It is taking longer than expected to finish the build, aborting...'
);
}
+132 -73
View File
@@ -1,84 +1,116 @@
import { getConfig } from '@expo/config';
import { Platform, Workflow } from '@expo/eas-build-job';
import { EasJson, EasJsonAccessor, ResourceClass } from '@expo/eas-json';
import { EasJson, EasJsonReader } from '@expo/eas-json';
import { error } from '@oclif/errors';
import chalk from 'chalk';
import fs from 'fs-extra';
import Log from '../log';
import Log, { learnMore } from '../log';
import { RequestedPlatform } from '../platform';
import { resolveWorkflowAsync } from '../project/workflow';
import { easCliVersion } from '../utils/easCli';
import { getVcsClient } from '../vcs';
import { maybeBailOnRepoStatusAsync, reviewAndCommitChangesAsync } from './utils/repository';
import { confirmAsync, promptAsync } from '../prompts';
import { ensureLoggedInAsync } from '../user/actions';
import vcs from '../vcs';
import { configureAndroidAsync } from './android/configure';
import { ConfigureContext } from './context';
import { configureIosAsync } from './ios/configure';
import { commitPromptAsync, maybeBailOnRepoStatusAsync } from './utils/repository';
interface ConfigureParams {
projectDir: string;
nonInteractive: boolean;
}
const configureCommitMessage = {
[RequestedPlatform.Android]: 'Configure EAS Build for Android',
[RequestedPlatform.Ios]: 'Configure EAS Build for iOS',
[RequestedPlatform.All]: 'Configure EAS Build',
};
/**
* Creates eas.json if it does not exist.
*
* Returns:
* - false - if eas.json already exists
* - true - if eas.json was created by the function
*/
export async function ensureProjectConfiguredAsync(
configureParams: ConfigureParams
): Promise<boolean> {
if (await fs.pathExists(EasJsonAccessor.formatEasJsonPath(configureParams.projectDir))) {
return false;
projectDir: string,
requestedPlatform: RequestedPlatform
): Promise<void> {
if (await fs.pathExists(EasJsonReader.formatEasJsonPath(projectDir))) {
return;
}
await configureAsync(configureParams);
return true;
const message = 'This project is not configured to build with EAS. Set it up now?';
const confirm = await confirmAsync({ message });
if (confirm) {
await configureAsync({
projectDir,
platform: requestedPlatform,
});
if (await vcs.hasUncommittedChangesAsync()) {
error(
'Build process requires clean working tree, please commit all your changes and run `eas build` again',
{ exit: 1 }
);
}
} else {
error(
`Aborting, please run ${chalk.bold('eas build:configure')} or create eas.json (${learnMore(
'https://docs.expo.dev/build/eas-json'
)})`,
{ exit: 1 }
);
}
}
async function configureAsync({ projectDir, nonInteractive }: ConfigureParams): Promise<void> {
export async function configureAsync(options: {
platform: RequestedPlatform;
projectDir: string;
}): Promise<void> {
await vcs.ensureRepoExistsAsync();
await maybeBailOnRepoStatusAsync();
await createEasJsonAsync(projectDir);
const { exp } = getConfig(options.projectDir, { skipSDKVersionRequirement: true });
if (await getVcsClient().isCommitRequiredAsync()) {
const ctx: ConfigureContext = {
user: await ensureLoggedInAsync(),
projectDir: options.projectDir,
exp,
requestedPlatform: options.platform,
shouldConfigureAndroid: [RequestedPlatform.All, RequestedPlatform.Android].includes(
options.platform
),
shouldConfigureIos: [RequestedPlatform.All, RequestedPlatform.Ios].includes(options.platform),
hasAndroidNativeProject:
(await resolveWorkflowAsync(options.projectDir, Platform.ANDROID)) === Workflow.GENERIC,
hasIosNativeProject:
(await resolveWorkflowAsync(options.projectDir, Platform.IOS)) === Workflow.GENERIC,
};
Log.newLine();
await ensureEasJsonExistsAsync(ctx);
if (ctx.shouldConfigureAndroid) {
await configureAndroidAsync(ctx);
}
if (ctx.shouldConfigureIos) {
await configureIosAsync(ctx);
}
if (await vcs.hasUncommittedChangesAsync()) {
Log.newLine();
await reviewAndCommitChangesAsync('Configure EAS Build', {
nonInteractive,
});
await reviewAndCommitChangesAsync(configureCommitMessage[options.platform]);
} else {
Log.newLine();
Log.withTick('No changes were necessary, the project is already configured correctly.');
}
}
const EAS_JSON_MANAGED_DEFAULT: EasJson = {
cli: {
version: `>= ${easCliVersion}`,
},
build: {
release: {},
development: {
developmentClient: true,
distribution: 'internal',
ios: {
resourceClass: ResourceClass.M_MEDIUM,
},
},
preview: {
distribution: 'internal',
ios: {
resourceClass: ResourceClass.M_MEDIUM,
},
},
production: {
ios: {
resourceClass: ResourceClass.M_MEDIUM,
},
},
},
submit: {
production: {},
release: {},
},
};
const EAS_JSON_BARE_DEFAULT: EasJson = {
cli: {
version: `>= ${easCliVersion}`,
},
const EAS_JSON_GENERIC_DEFAULT: EasJson = {
build: {
release: {},
development: {
distribution: 'internal',
android: {
@@ -86,39 +118,66 @@ const EAS_JSON_BARE_DEFAULT: EasJson = {
},
ios: {
buildConfiguration: 'Debug',
resourceClass: ResourceClass.M_MEDIUM,
},
},
preview: {
distribution: 'internal',
ios: {
resourceClass: ResourceClass.M_MEDIUM,
},
},
production: {
ios: {
resourceClass: ResourceClass.M_MEDIUM,
},
},
},
submit: {
production: {},
release: {},
},
};
async function createEasJsonAsync(projectDir: string): Promise<void> {
const easJsonPath = EasJsonAccessor.formatEasJsonPath(projectDir);
export async function ensureEasJsonExistsAsync(ctx: ConfigureContext): Promise<void> {
const easJsonPath = EasJsonReader.formatEasJsonPath(ctx.projectDir);
if (await fs.pathExists(easJsonPath)) {
const reader = new EasJsonReader(ctx.projectDir);
await reader.readAndValidateAsync();
Log.withTick('Validated eas.json');
return;
}
const hasAndroidNativeProject =
(await resolveWorkflowAsync(projectDir, Platform.ANDROID)) === Workflow.GENERIC;
const hasIosNativeProject =
(await resolveWorkflowAsync(projectDir, Platform.IOS)) === Workflow.GENERIC;
const easJson =
hasAndroidNativeProject || hasIosNativeProject
? EAS_JSON_BARE_DEFAULT
ctx.hasAndroidNativeProject && ctx.hasIosNativeProject
? EAS_JSON_GENERIC_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')}`);
await vcs.trackFileAsync(easJsonPath);
Log.withTick('Generated eas.json');
}
enum ShouldCommitChanges {
Yes,
ShowDiffFirst,
Skip,
}
async function reviewAndCommitChangesAsync(
initialCommitMessage: string,
askedFirstTime: boolean = true
): Promise<void> {
const { selected } = await promptAsync({
type: 'select',
name: 'selected',
message: 'Can we commit these changes to git for you?',
choices: [
{ title: 'Yes', value: ShouldCommitChanges.Yes },
...(askedFirstTime
? [{ title: 'Show the diff and ask me again', value: ShouldCommitChanges.ShowDiffFirst }]
: []),
{
title: 'Skip committing changes, I will do it later on my own',
value: ShouldCommitChanges.Skip,
},
],
});
if (selected === ShouldCommitChanges.Yes) {
await commitPromptAsync({ initialCommitMessage });
Log.withTick('Committed changes');
} else if (selected === ShouldCommitChanges.ShowDiffFirst) {
await vcs.showDiffAsync();
await reviewAndCommitChangesAsync(initialCommitMessage, false);
}
}
+132 -33
View File
@@ -1,56 +1,155 @@
import { ExpoConfig } from '@expo/config';
import { Platform, Workflow } from '@expo/eas-build-job';
import { BuildProfile, EasJson } from '@expo/eas-json';
import { AndroidBuildProfile, BuildProfile, IosBuildProfile } from '@expo/eas-json';
import JsonFile from '@expo/json-file';
import resolveFrom from 'resolve-from';
import { v4 as uuidv4 } from 'uuid';
import { Analytics, AnalyticsEventProperties } from '../analytics/AnalyticsManager';
import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient';
import { CredentialsContext } from '../credentials/context';
import { Target } from '../credentials/ios/types';
import { BuildResourceClass } from '../graphql/generated';
import { GradleBuildContext } from '../project/android/gradle';
import { XcodeBuildContext } from '../project/ios/scheme';
import { RequestedPlatform } from '../platform';
import { getExpoConfig } from '../project/expoConfig';
import { getProjectAccountName, getProjectIdAsync } from '../project/projectUtils';
import { resolveWorkflowAsync } from '../project/workflow';
import { findAccountByName } from '../user/Account';
import { Actor } from '../user/User';
import { LocalBuildOptions } from './local';
import { ensureLoggedInAsync } from '../user/actions';
import { TrackingContext } from './types';
import Analytics, { Event } from './utils/analytics';
export type CommonContext<T extends Platform> = Omit<BuildContext<T>, 'android' | 'ios'>;
export interface AndroidBuildContext {
applicationId: string;
gradleContext?: GradleBuildContext;
versionCodeOverride?: string;
}
export interface IosBuildContext {
bundleIdentifier: string;
applicationTarget: Target;
targets: Target[];
xcodeBuildContext: XcodeBuildContext;
buildNumberOverride?: string;
export interface ConfigureContext {
user: Actor;
projectDir: string;
exp: ExpoConfig;
requestedPlatform: RequestedPlatform;
shouldConfigureAndroid: boolean;
shouldConfigureIos: boolean;
hasAndroidNativeProject: boolean;
hasIosNativeProject: boolean;
}
export interface BuildContext<T extends Platform> {
accountName: string;
easJsonCliConfig: EasJson['cli'];
buildProfile: BuildProfile<T>;
buildProfileName: string;
resourceClass: BuildResourceClass;
clearCache: boolean;
credentialsCtx: CredentialsContext;
exp: ExpoConfig;
localBuildOptions: LocalBuildOptions;
local: boolean;
nonInteractive: boolean;
noWait: boolean;
runFromCI: boolean;
platform: T;
projectDir: string;
projectId: string;
projectName: string;
message?: string;
analyticsEventProperties: AnalyticsEventProperties;
skipProjectConfiguration: boolean;
trackingCtx: TrackingContext;
user: Actor;
graphqlClient: ExpoGraphqlClient;
analytics: Analytics;
workflow: Workflow;
android: T extends Platform.ANDROID ? AndroidBuildContext : undefined;
ios: T extends Platform.IOS ? IosBuildContext : undefined;
}
export async function createBuildContextAsync<T extends Platform>({
buildProfileName,
buildProfile,
clearCache = false,
local,
nonInteractive = false,
platform,
projectDir,
skipProjectConfiguration = false,
}: {
buildProfileName: string;
buildProfile: BuildProfile<T>;
clearCache: boolean;
local: boolean;
nonInteractive: boolean;
platform: T;
projectDir: string;
skipProjectConfiguration: boolean;
}): Promise<BuildContext<T>> {
const exp = getExpoConfig(projectDir, { env: buildProfile.env });
const user = await ensureLoggedInAsync();
const accountName = getProjectAccountName(exp, user);
const projectName = exp.slug;
const projectId = await getProjectIdAsync(exp, { env: buildProfile.env });
const workflow = await resolveWorkflowAsync(projectDir, platform);
const accountId = findAccountByName(user.accounts, accountName)?.id;
const credentialsCtx = new CredentialsContext({
exp,
nonInteractive,
projectDir,
user,
});
const devClientProperties = getDevClientEventProperties({
platform,
projectDir,
buildProfile,
});
const trackingCtx = {
tracking_id: uuidv4(),
platform,
...(accountId && { account_id: accountId }),
account_name: accountName,
project_id: projectId,
project_type: workflow,
...devClientProperties,
};
Analytics.logEvent(Event.BUILD_COMMAND, trackingCtx);
return {
accountName,
buildProfile,
buildProfileName,
clearCache,
credentialsCtx,
exp,
local,
nonInteractive,
platform,
projectDir,
projectId,
projectName,
skipProjectConfiguration,
trackingCtx,
user,
workflow,
};
}
function getDevClientEventProperties({
platform,
projectDir,
buildProfile,
}: {
platform: Platform;
projectDir: string;
buildProfile: AndroidBuildProfile | IosBuildProfile;
}): Partial<TrackingContext> {
let includesDevClient;
const version = tryGetDevClientVersion(projectDir);
if (platform === Platform.ANDROID && 'gradleCommand' in buildProfile) {
includesDevClient = Boolean(version && buildProfile.gradleCommand?.includes('Debug'));
} else if (platform === Platform.IOS && 'buildConfiguration' in buildProfile) {
includesDevClient = Boolean(version && buildProfile.buildConfiguration === 'Debug');
} else if (buildProfile.developmentClient) {
includesDevClient = true;
} else {
includesDevClient = false;
}
if (version) {
return { dev_client: includesDevClient, dev_client_version: version };
} else {
return { dev_client: includesDevClient };
}
}
function tryGetDevClientVersion(projectDir: string): string | null {
try {
const pkg = JsonFile.read(resolveFrom(projectDir, 'expo-dev-client/package.json'));
return pkg.version?.toString() ?? null;
} catch {
return null;
}
}
-172
View File
@@ -1,172 +0,0 @@
import { Platform } from '@expo/eas-build-job';
import { BuildProfile, EasJson, ResourceClass } from '@expo/eas-json';
import JsonFile from '@expo/json-file';
import getenv from 'getenv';
import resolveFrom from 'resolve-from';
import { v4 as uuidv4 } from 'uuid';
import { Analytics, AnalyticsEventProperties, BuildEvent } from '../analytics/AnalyticsManager';
import { DynamicConfigContextFn } from '../commandUtils/context/DynamicProjectConfigContextField';
import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient';
import { CredentialsContext } from '../credentials/context';
import { getOwnerAccountForProjectIdAsync } from '../project/projectUtils';
import { resolveWorkflowAsync } from '../project/workflow';
import { Actor } from '../user/User';
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>({
buildProfileName,
buildProfile,
easJsonCliConfig,
clearCache = false,
localBuildOptions,
nonInteractive,
noWait,
platform,
projectDir,
resourceClassFlag,
message,
actor,
graphqlClient,
analytics,
getDynamicProjectConfigAsync,
}: {
buildProfileName: string;
buildProfile: BuildProfile<T>;
easJsonCliConfig: EasJson['cli'];
clearCache: boolean;
localBuildOptions: LocalBuildOptions;
nonInteractive: boolean;
noWait: boolean;
platform: T;
projectDir: string;
resourceClassFlag?: ResourceClass;
message?: string;
actor: Actor;
graphqlClient: ExpoGraphqlClient;
analytics: Analytics;
getDynamicProjectConfigAsync: DynamicConfigContextFn;
}): Promise<BuildContext<T>> {
const { exp, projectId } = await getDynamicProjectConfigAsync({ env: buildProfile.env });
const projectName = exp.slug;
const account = await getOwnerAccountForProjectIdAsync(graphqlClient, projectId);
const workflow = await resolveWorkflowAsync(projectDir, platform);
const accountId = account.id;
const runFromCI = getenv.boolish('CI', false);
const credentialsCtx = new CredentialsContext({
projectInfo: { exp, projectId },
nonInteractive,
projectDir,
user: actor,
graphqlClient,
analytics,
env: buildProfile.env,
easJsonCliConfig,
});
const devClientProperties = getDevClientEventProperties({
platform,
projectDir,
buildProfile,
});
const analyticsEventProperties = {
tracking_id: uuidv4(),
platform,
...(exp.sdkVersion && { sdk_version: exp.sdkVersion }),
...(accountId && { account_id: accountId }),
project_id: projectId,
project_type: workflow,
...devClientProperties,
no_wait: noWait,
run_from_ci: runFromCI,
};
analytics.logEvent(BuildEvent.BUILD_COMMAND, analyticsEventProperties);
const resourceClass = await resolveBuildResourceClassAsync(
buildProfile,
platform,
projectDir,
exp,
resourceClassFlag
);
const commonContext: CommonContext<T> = {
accountName: account.name,
buildProfile,
buildProfileName,
resourceClass,
easJsonCliConfig,
clearCache,
credentialsCtx,
exp,
localBuildOptions,
nonInteractive,
noWait,
platform,
projectDir,
projectId,
projectName,
analyticsEventProperties,
user: actor,
graphqlClient,
analytics,
workflow,
message,
runFromCI,
};
if (platform === Platform.ANDROID) {
const common = commonContext as CommonContext<Platform.ANDROID>;
return {
...common,
android: await createAndroidContextAsync(common),
} as BuildContext<T>;
} else {
const common = commonContext as CommonContext<Platform.IOS>;
return {
...common,
ios: await createIosContextAsync(common),
} as BuildContext<T>;
}
}
function getDevClientEventProperties({
platform,
projectDir,
buildProfile,
}: {
platform: Platform;
projectDir: string;
buildProfile: BuildProfile;
}): Partial<AnalyticsEventProperties> {
let includesDevClient;
const version = tryGetDevClientVersion(projectDir);
if (platform === Platform.ANDROID && 'gradleCommand' in buildProfile) {
includesDevClient = Boolean(version && buildProfile.gradleCommand?.includes('Debug'));
} else if (platform === Platform.IOS && 'buildConfiguration' in buildProfile) {
includesDevClient = Boolean(version && buildProfile.buildConfiguration === 'Debug');
} else if (buildProfile.developmentClient) {
includesDevClient = true;
} else {
includesDevClient = false;
}
if (version) {
return { dev_client: includesDevClient, dev_client_version: version };
} else {
return { dev_client: includesDevClient };
}
}
function tryGetDevClientVersion(projectDir: string): string | null {
try {
const pkg = JsonFile.read(resolveFrom(projectDir, 'expo-dev-client/package.json'));
return pkg.version?.toString() ?? null;
} catch {
return null;
}
}
-15
View File
@@ -1,15 +0,0 @@
import { EasCommandError } from '../commandUtils/errors';
export class TurtleDeprecatedJobFormatError extends EasCommandError {}
export class EasBuildFreeTierDisabledError extends EasCommandError {}
export class EasBuildFreeTierDisabledIOSError extends EasCommandError {}
export class EasBuildFreeTierDisabledAndroidError extends EasCommandError {}
export class RequestValidationError extends EasCommandError {}
export class EasBuildDownForMaintenanceError extends EasCommandError {}
export class EasBuildTooManyPendingBuildsError extends EasCommandError {}
+1 -37
View File
@@ -1,19 +1,11 @@
import {
ArchiveSource,
ArchiveSourceType,
BuildTrigger,
Metadata,
Workflow,
} from '@expo/eas-build-job';
import { ArchiveSource, ArchiveSourceType, Metadata, Workflow } from '@expo/eas-build-job';
import {
BuildCredentialsSource,
BuildIosEnterpriseProvisioning,
BuildMetadataInput,
BuildMode,
BuildWorkflow,
DistributionType,
BuildTrigger as GraphQLBuildTrigger,
ProjectArchiveSourceInput,
ProjectArchiveSourceType,
} from '../graphql/generated';
@@ -24,11 +16,6 @@ export function transformProjectArchive(archiveSource: ArchiveSource): ProjectAr
type: ProjectArchiveSourceType.S3,
bucketKey: archiveSource.bucketKey,
};
} else if (archiveSource.type === ArchiveSourceType.GCS) {
return {
type: ProjectArchiveSourceType.Gcs,
bucketKey: archiveSource.bucketKey,
};
} else if (archiveSource.type === ArchiveSourceType.URL) {
return {
type: ProjectArchiveSourceType.Url,
@@ -42,7 +29,6 @@ export function transformProjectArchive(archiveSource: ArchiveSource): ProjectAr
export function transformMetadata(metadata: Metadata): BuildMetadataInput {
return {
...metadata,
buildMode: metadata.buildMode && transformBuildMode(metadata.buildMode),
credentialsSource:
metadata.credentialsSource && transformCredentialsSource(metadata.credentialsSource),
distribution: metadata.distribution && transformDistribution(metadata.distribution),
@@ -90,25 +76,3 @@ export function transformIosEnterpriseProvisioning(
return BuildIosEnterpriseProvisioning.Universal;
}
}
// TODO: check what in metadata
export function transformBuildMode(buildMode: string): BuildMode {
if (buildMode === 'build') {
return BuildMode.Build;
} else if (buildMode === 'resign') {
return BuildMode.Resign;
} else if (buildMode === 'custom') {
return BuildMode.Custom;
} else {
throw new Error(`Unsupported build mode: ${buildMode}`);
}
}
export function transformBuildTrigger(buildTrigger: BuildTrigger): GraphQLBuildTrigger {
if (buildTrigger === BuildTrigger.EAS_CLI) {
return GraphQLBuildTrigger.EasCli;
} else if (buildTrigger === BuildTrigger.GIT_BASED_INTEGRATION) {
return GraphQLBuildTrigger.GitBasedIntegration;
}
throw new Error('Unknown build trigger');
}
@@ -0,0 +1,111 @@
import { ExpoConfig } from '@expo/config';
import { IOSConfig } from '@expo/config-plugins';
import fs from 'fs-extra';
import Log from '../../log';
import { getProjectAccountName } from '../../project/projectUtils';
import { ensureLoggedInAsync } from '../../user/actions';
import vcs from '../../vcs';
import { ensureValidVersions } from '../utils/updates';
import { readPlistAsync, writePlistAsync } from './plist';
export async function configureUpdatesAsync(projectDir: string, exp: ExpoConfig): Promise<void> {
ensureValidVersions(exp);
const accountName = getProjectAccountName(exp, await ensureLoggedInAsync());
let xcodeProject = IOSConfig.XcodeUtils.getPbxproj(projectDir);
if (!IOSConfig.Updates.isShellScriptBuildPhaseConfigured(projectDir, xcodeProject)) {
xcodeProject = IOSConfig.Updates.ensureBundleReactNativePhaseContainsConfigurationScript(
projectDir,
xcodeProject
);
await fs.writeFile(
IOSConfig.Paths.getPBXProjectPath(projectDir),
// eslint-disable-next-line node/no-sync
xcodeProject.writeSync()
);
}
let expoPlist = await readExpoPlistAsync(projectDir);
if (!IOSConfig.Updates.isPlistConfigurationSynced(exp, expoPlist, accountName)) {
expoPlist = IOSConfig.Updates.setUpdatesConfig(exp, expoPlist, accountName);
await writeExpoPlistAsync(projectDir, expoPlist);
}
// TODO: ensure ExpoPlist in pbxproj
}
export async function syncUpdatesConfigurationAsync(
projectDir: string,
exp: ExpoConfig
): Promise<void> {
ensureValidVersions(exp);
const accountName = getProjectAccountName(exp, await ensureLoggedInAsync());
try {
await ensureUpdatesConfiguredAsync(projectDir, exp);
} catch (error) {
Log.error(
'expo-updates module is not configured. Please run "eas build:configure" first to configure the project'
);
throw error;
}
let expoPlist = await readExpoPlistAsync(projectDir);
if (!IOSConfig.Updates.isPlistVersionConfigurationSynced(exp, expoPlist)) {
expoPlist = IOSConfig.Updates.setVersionsConfig(exp, expoPlist);
await writeExpoPlistAsync(projectDir, expoPlist);
}
if (!IOSConfig.Updates.isPlistConfigurationSynced(exp, expoPlist, accountName)) {
Log.warn(
'Native project configuration is not synced with values present in your app.json, run "eas build:configure" to make sure all values are applied in the native project'
);
}
}
async function ensureUpdatesConfiguredAsync(projectDir: string, exp: ExpoConfig): Promise<void> {
const xcodeProject = IOSConfig.XcodeUtils.getPbxproj(projectDir);
if (!IOSConfig.Updates.isShellScriptBuildPhaseConfigured(projectDir, xcodeProject)) {
const script = 'expo-updates/scripts/create-manifest-ios.sh';
const buildPhase = '"Bundle React Native code and images"';
throw new Error(`Path to ${script} is missing in a ${buildPhase} build phase.`);
}
const expoPlist = await readExpoPlistAsync(projectDir);
if (!IOSConfig.Updates.isPlistConfigurationSet(expoPlist)) {
throw new Error('Missing values in Expo.plist');
}
}
async function readExpoPlistAsync(projectDir: string): Promise<IOSConfig.ExpoPlist> {
const expoPlistPath = IOSConfig.Paths.getExpoPlistPath(projectDir);
return (await readPlistAsync(expoPlistPath)) as IOSConfig.ExpoPlist;
}
async function writeExpoPlistAsync(
projectDir: string,
expoPlist: IOSConfig.ExpoPlist
): Promise<void> {
const expoPlistPath = IOSConfig.Paths.getExpoPlistPath(projectDir);
await writePlistAsync(expoPlistPath, expoPlist);
await vcs.trackFileAsync(expoPlistPath);
}
export async function readReleaseChannelSafelyAsync(projectDir: string): Promise<string | null> {
try {
const expoPlist = await readExpoPlistAsync(projectDir);
return expoPlist[IOSConfig.Updates.Config.RELEASE_CHANNEL] ?? null;
} catch (err) {
return null;
}
}
export async function readChannelSafelyAsync(projectDir: string): Promise<string | null> {
try {
const expoPlist: any = await readExpoPlistAsync(projectDir); // TODO-JJ remove any once IOSConfig.ExpoPlist is updated to include `EXUpdatesRequestHeaders : Record<string,string>`
const updatesRequestHeaders = expoPlist['EXUpdatesRequestHeaders'] ?? {}; //TODO-JJ 'EXUpdatesRequestHeaders' IosConfig.Updates.Config.UPDATES_CONFIGURATION_REQUEST_HEADERS_KEY once https://github.com/expo/expo-cli/pull/3571 is published
return updatesRequestHeaders['expo-channel-name'] ?? null;
} catch (err) {
return null;
}
}
@@ -3,9 +3,8 @@ import { IOSConfig } from '@expo/config-plugins';
import fs from 'fs-extra';
import { vol } from 'memfs';
import os from 'os';
import type { XCBuildConfiguration } from 'xcode';
import { readPlistAsync } from '../../../utils/plist';
import { readPlistAsync } from '../plist';
import {
BumpStrategy,
bumpVersionAsync,
@@ -18,12 +17,6 @@ import {
jest.mock('fs');
const getXCBuildConfigurationFromPbxproj = jest.spyOn(
IOSConfig.Target,
'getXCBuildConfigurationFromPbxproj'
);
const getPbxproj = jest.spyOn(IOSConfig.XcodeUtils, 'getPbxproj');
afterAll(async () => {
// do not remove the following line
// this fixes a weird error with tempy in @expo/image-utils
@@ -56,36 +49,16 @@ describe(evaluateTemplateString, () => {
});
});
// bare workflow
// generic workflow
describe(bumpVersionAsync, () => {
beforeEach(() => {
getPbxproj.mockImplementation(() => ({} as any));
});
afterEach(() => {
getXCBuildConfigurationFromPbxproj.mockReset();
getPbxproj.mockReset();
});
it('bumps expo.ios.buildNumber and CFBundleVersion when strategy = BumpStrategy.BUILD_NUMBER', async () => {
const fakeExp = initBareWorkflowProject();
getXCBuildConfigurationFromPbxproj.mockImplementation(
() =>
({
buildSettings: {
INFOPLIST_FILE: 'myproject/Info.plist',
},
} as XCBuildConfiguration)
);
const fakeExp = initGenericProject();
await bumpVersionAsync({
bumpStrategy: BumpStrategy.BUILD_NUMBER,
projectDir: '/repo',
exp: fakeExp,
targets: [
{
targetName: 'example',
bundleIdentifier: 'example.com',
entitlements: {},
},
],
buildSettings: {},
});
const appJSON = await fs.readJSON('/repo/app.json');
@@ -101,27 +74,15 @@ describe(bumpVersionAsync, () => {
});
it('bumps expo.ios.buildNumber and CFBundleVersion for non default Info.plist location', async () => {
const fakeExp = initBareWorkflowProject({ infoPlistName: 'Info2.plist' });
const fakeExp = initGenericProject({ infoPlistName: 'Info2.plist' });
getXCBuildConfigurationFromPbxproj.mockImplementation(
() =>
({
buildSettings: {
INFOPLIST_FILE: '$(SRCROOT)/myproject/Info2.plist',
},
} as XCBuildConfiguration)
);
await bumpVersionAsync({
bumpStrategy: BumpStrategy.BUILD_NUMBER,
projectDir: '/repo',
exp: fakeExp,
targets: [
{
targetName: 'example',
bundleIdentifier: 'example.com',
entitlements: {},
},
],
buildSettings: {
INFOPLIST_FILE: '$(SRCROOT)/myproject/Info2.plist',
},
});
const appJSON = await fs.readJSON('/repo/app.json');
@@ -136,27 +97,14 @@ describe(bumpVersionAsync, () => {
expect(infoPlist['CFBundleVersion']).toBe('2');
});
it('bumps expo.version and CFBundleShortVersionString when strategy = BumpStrategy.APP_VERSION', async () => {
const fakeExp = initBareWorkflowProject();
getXCBuildConfigurationFromPbxproj.mockImplementation(
() =>
({
buildSettings: {
INFOPLIST_FILE: 'myproject/Info.plist',
},
} as XCBuildConfiguration)
);
it('bumps expo.version and CFBundleShortVersionString when strategy = BumpStrategy.SHORT_VERSION', async () => {
const fakeExp = initGenericProject();
await bumpVersionAsync({
bumpStrategy: BumpStrategy.APP_VERSION,
projectDir: '/repo',
exp: fakeExp,
targets: [
{
targetName: 'example',
bundleIdentifier: 'example.com',
entitlements: {},
},
],
buildSettings: {},
});
const appJSON = await fs.readJSON('/repo/app.json');
@@ -172,26 +120,13 @@ describe(bumpVersionAsync, () => {
});
it('does not bump any version when strategy = BumpStrategy.NOOP', async () => {
const fakeExp = initBareWorkflowProject();
getXCBuildConfigurationFromPbxproj.mockImplementation(
() =>
({
buildSettings: {
INFOPLIST_FILE: 'myproject/Info.plist',
},
} as XCBuildConfiguration)
);
const fakeExp = initGenericProject();
await bumpVersionAsync({
bumpStrategy: BumpStrategy.NOOP,
projectDir: '/repo',
exp: fakeExp,
targets: [
{
targetName: 'example',
bundleIdentifier: 'example.com',
entitlements: {},
},
],
buildSettings: {},
});
const appJSON = await fs.readJSON('/repo/app.json');
@@ -259,9 +194,9 @@ describe(bumpVersionInAppJsonAsync, () => {
});
describe(readBuildNumberAsync, () => {
describe('bare project', () => {
describe('generic project', () => {
it('reads the build number from native code', async () => {
const exp = initBareWorkflowProject();
const exp = initGenericProject();
const buildNumber = await readBuildNumberAsync('/repo', exp, {});
expect(buildNumber).toBe('1');
});
@@ -277,14 +212,14 @@ describe(readBuildNumberAsync, () => {
});
describe(readShortVersionAsync, () => {
describe('bare project', () => {
describe('generic project', () => {
it('reads the short version from native code', async () => {
const exp = initBareWorkflowProject();
const exp = initGenericProject();
const appVersion = await readShortVersionAsync('/repo', exp, {});
expect(appVersion).toBe('1.0.0');
});
it('evaluates interpolated build number', async () => {
const exp = initBareWorkflowProject({
const exp = initGenericProject({
appVersion: '$(CURRENT_PROJECT_VERSION)',
});
const buildNumber = await readShortVersionAsync('/repo', exp, {
@@ -296,7 +231,7 @@ describe(readShortVersionAsync, () => {
describe('managed project', () => {
it('reads the version from app config', async () => {
const exp = initBareWorkflowProject();
const exp = initGenericProject();
const appVersion = await readShortVersionAsync('/repo', exp, {});
expect(appVersion).toBe('1.0.0');
});
@@ -351,11 +286,11 @@ describe(getInfoPlistPath, () => {
});
});
function initBareWorkflowProject({
function initGenericProject({
appVersion = '1.0.0',
buildNumber = '1',
version = '1',
infoPlistName = 'Info.plist',
}: { appVersion?: string; buildNumber?: string; infoPlistName?: string } = {}): ExpoConfig {
}: { appVersion?: string; version?: string; infoPlistName?: string } = {}): ExpoConfig {
const fakeExp: ExpoConfig = {
name: 'myproject',
slug: 'myproject',
@@ -376,7 +311,7 @@ function initBareWorkflowProject({
<key>CFBundleShortVersionString</key>
<string>${appVersion}</string>
<key>CFBundleVersion</key>
<string>${buildNumber}</string>
<string>${version}</string>
</dict>
</plist>`,
},
+57 -61
View File
@@ -1,38 +1,39 @@
import { IOSConfig } from '@expo/config-plugins';
import { Ios, Job, Metadata, Platform, Workflow } from '@expo/eas-build-job';
import { AppVersionSource } from '@expo/eas-json';
import type { XCBuildConfiguration } from 'xcode';
import { IosCredentials } from '../../credentials/ios/types';
import { BuildParamsInput } from '../../graphql/generated';
import { IosCredentials, Target } from '../../credentials/ios/types';
import { BuildMutation, BuildResult } from '../../graphql/mutations/BuildMutation';
import { ensureBundleIdentifierIsDefinedForManagedProjectAsync } from '../../project/ios/bundleIdentifier';
import { resolveXcodeBuildContextAsync } from '../../project/ios/scheme';
import { findApplicationTarget, resolveTargetsAsync } from '../../project/ios/target';
import { BuildRequestSender, JobData, prepareBuildRequestForPlatformAsync } from '../build';
import { BuildContext, CommonContext, IosBuildContext } from '../context';
import { BuildContext } from '../context';
import { transformMetadata } from '../graphql';
import {
checkGoogleServicesFileAsync,
checkNodeEnvVariable,
validatePNGsForManagedProjectAsync,
} from '../validate';
import { IosMetadataContext } from '../metadata';
import { checkGoogleServicesFileAsync, checkNodeEnvVariable } from '../validate';
import { validateAndSyncProjectConfigurationAsync } from './configure';
import { ensureIosCredentialsAsync } from './credentials';
import { transformJob } from './graphql';
import { prepareJobAsync } from './prepareJob';
import { syncProjectConfigurationAsync } from './syncProjectConfiguration';
import { resolveRemoteBuildNumberAsync } from './version';
export async function createIosContextAsync(
ctx: CommonContext<Platform.IOS>
): Promise<IosBuildContext> {
interface BuildConfiguration {
targetName?: string;
buildConfiguration?: string;
buildSettings: XCBuildConfiguration['buildSettings'];
}
export async function prepareIosBuildAsync(
ctx: BuildContext<Platform.IOS>
): Promise<BuildRequestSender> {
const { buildProfile } = ctx;
if (ctx.workflow === Workflow.MANAGED) {
await ensureBundleIdentifierIsDefinedForManagedProjectAsync(ctx);
await ensureBundleIdentifierIsDefinedForManagedProjectAsync(ctx.projectDir, ctx.exp);
}
checkNodeEnvVariable(ctx);
await checkGoogleServicesFileAsync(ctx);
await validatePNGsForManagedProjectAsync(ctx);
const xcodeBuildContext = await resolveXcodeBuildContextAsync(
{
@@ -42,75 +43,70 @@ export async function createIosContextAsync(
},
buildProfile
);
const targets = await resolveTargetsAsync({
projectDir: ctx.projectDir,
exp: ctx.exp,
xcodeBuildContext,
env: buildProfile.env,
});
const applicationTarget = findApplicationTarget(targets);
const buildNumberOverride =
ctx.easJsonCliConfig?.appVersionSource === AppVersionSource.REMOTE
? await resolveRemoteBuildNumberAsync(ctx.graphqlClient, {
projectDir: ctx.projectDir,
projectId: ctx.projectId,
exp: ctx.exp,
applicationTarget,
buildProfile,
})
: undefined;
return {
bundleIdentifier: applicationTarget.bundleIdentifier,
applicationTarget,
targets,
xcodeBuildContext,
buildNumberOverride,
};
}
const targets = await resolveTargetsAsync(
{
projectDir: ctx.projectDir,
exp: ctx.exp,
},
xcodeBuildContext
);
const buildConfiguration = resolveBuildConfiguration(ctx, targets);
export async function prepareIosBuildAsync(
ctx: BuildContext<Platform.IOS>
): Promise<BuildRequestSender> {
return await prepareBuildRequestForPlatformAsync({
ctx,
ensureCredentialsAsync: async (ctx: BuildContext<Platform.IOS>) => {
return ensureIosCredentialsAsync(ctx, ctx.ios.targets);
return ensureIosCredentialsAsync(ctx, targets);
},
syncProjectConfigurationAsync: async () => {
await syncProjectConfigurationAsync(ctx.graphqlClient, {
ensureProjectConfiguredAsync: async () => {
await validateAndSyncProjectConfigurationAsync({
projectDir: ctx.projectDir,
exp: ctx.exp,
targets: ctx.ios.targets,
localAutoIncrement:
ctx.easJsonCliConfig?.appVersionSource === AppVersionSource.REMOTE
? false
: ctx.buildProfile.autoIncrement,
projectId: ctx.projectId,
buildProfile,
buildSettings: buildConfiguration.buildSettings,
});
},
getMetadataContext: (): IosMetadataContext => {
return buildConfiguration;
},
prepareJobAsync: async (
ctx: BuildContext<Platform.IOS>,
jobData: JobData<IosCredentials>
): Promise<Job> => {
return await prepareJobAsync(ctx, {
...jobData,
buildScheme: ctx.ios.xcodeBuildContext.buildScheme,
});
return await prepareJobAsync(ctx, { ...jobData, buildScheme: xcodeBuildContext.buildScheme });
},
sendBuildRequestAsync: async (
appId: string,
job: Ios.Job,
metadata: Metadata,
buildParams: BuildParamsInput
metadata: Metadata
): Promise<BuildResult> => {
const graphqlMetadata = transformMetadata(metadata);
const graphqlJob = transformJob(job);
return await BuildMutation.createIosBuildAsync(ctx.graphqlClient, {
return await BuildMutation.createIosBuildAsync({
appId,
job: graphqlJob,
metadata: graphqlMetadata,
buildParams,
});
},
});
}
function resolveBuildConfiguration(
ctx: BuildContext<Platform.IOS>,
targets: Target[]
): BuildConfiguration {
if (ctx.workflow === Workflow.MANAGED) {
return { buildSettings: {} };
}
const applicationTarget = findApplicationTarget(targets);
const project = IOSConfig.XcodeUtils.getPbxproj(ctx.projectDir);
const xcBuildConfiguration = IOSConfig.Target.getXCBuildConfigurationFromPbxproj(project, {
targetName: applicationTarget.targetName,
buildConfiguration: applicationTarget.buildConfiguration,
});
const buildSettings = xcBuildConfiguration?.buildSettings ?? {};
return {
buildSettings,
targetName: applicationTarget.targetName,
buildConfiguration: applicationTarget.buildConfiguration,
};
}
@@ -0,0 +1,66 @@
import { ExpoConfig } from '@expo/config';
import { Platform, Workflow } from '@expo/eas-build-job';
import { IosBuildProfile, IosVersionAutoIncrement } from '@expo/eas-json';
import type { XCBuildConfiguration } from 'xcode';
import Log from '../../log';
import {
ensureBundleIdentifierIsDefinedForManagedProjectAsync,
warnIfBundleIdentifierDefinedInAppConfigForGenericProject,
} from '../../project/ios/bundleIdentifier';
import { resolveWorkflowAsync } from '../../project/workflow';
import { ConfigureContext } from '../context';
import { isExpoUpdatesInstalled } from '../utils/updates';
import { configureUpdatesAsync, syncUpdatesConfigurationAsync } from './UpdatesModule';
import { BumpStrategy, bumpVersionAsync, bumpVersionInAppJsonAsync } from './version';
export async function configureIosAsync(ctx: ConfigureContext): Promise<void> {
if (!ctx.hasIosNativeProject) {
await ensureBundleIdentifierIsDefinedForManagedProjectAsync(ctx.projectDir, ctx.exp);
return;
}
warnIfBundleIdentifierDefinedInAppConfigForGenericProject(ctx.projectDir, ctx.exp);
if (isExpoUpdatesInstalled(ctx.projectDir)) {
await configureUpdatesAsync(ctx.projectDir, ctx.exp);
}
Log.withTick('iOS project configured');
}
export async function validateAndSyncProjectConfigurationAsync({
projectDir,
exp,
buildProfile,
buildSettings,
}: {
projectDir: string;
exp: ExpoConfig;
buildProfile: IosBuildProfile;
buildSettings: XCBuildConfiguration['buildSettings'];
}): Promise<void> {
const workflow = await resolveWorkflowAsync(projectDir, Platform.IOS);
const { autoIncrement } = buildProfile;
const versionBumpStrategy = resolveVersionBumpStrategy(autoIncrement ?? false);
if (workflow === Workflow.GENERIC) {
if (isExpoUpdatesInstalled(projectDir)) {
await syncUpdatesConfigurationAsync(projectDir, exp);
}
await bumpVersionAsync({ projectDir, exp, bumpStrategy: versionBumpStrategy, buildSettings });
} else {
await bumpVersionInAppJsonAsync({ projectDir, exp, bumpStrategy: versionBumpStrategy });
}
}
function resolveVersionBumpStrategy(autoIncrement: IosVersionAutoIncrement): BumpStrategy {
if (autoIncrement === true) {
return BumpStrategy.BUILD_NUMBER;
} else if (autoIncrement === false) {
return BumpStrategy.NOOP;
} else if (autoIncrement === 'buildNumber') {
return BumpStrategy.BUILD_NUMBER;
} else {
return BumpStrategy.APP_VERSION;
}
}
+7 -27
View File
@@ -1,9 +1,8 @@
import { Platform } from '@expo/eas-build-job';
import { BuildProfile } from '@expo/eas-json';
import { CredentialsContext } from '../../credentials/context';
import IosCredentialsProvider from '../../credentials/ios/IosCredentialsProvider';
import { getAppFromContextAsync } from '../../credentials/ios/actions/BuildCredentialsUtils';
import { getAppFromContext } from '../../credentials/ios/actions/BuildCredentialsUtils';
import { resolveEntitlementsJsonAsync } from '../../credentials/ios/appstore/entitlements';
import { IosCredentials, Target } from '../../credentials/ios/types';
import { CredentialsResult } from '../build';
import { BuildContext } from '../context';
@@ -18,8 +17,11 @@ export async function ensureIosCredentialsAsync(
}
const provider = new IosCredentialsProvider(buildCtx.credentialsCtx, {
app: await getAppFromContextAsync(buildCtx.credentialsCtx),
app: getAppFromContext(buildCtx.credentialsCtx),
targets,
iosCapabilitiesOptions: {
entitlements: await resolveEntitlementsJsonAsync(buildCtx.projectDir, buildCtx.workflow),
},
distribution: buildCtx.buildProfile.distribution ?? 'store',
enterpriseProvisioning: buildCtx.buildProfile.enterpriseProvisioning,
});
@@ -33,28 +35,6 @@ export async function ensureIosCredentialsAsync(
};
}
export async function ensureIosCredentialsForBuildResignAsync(
credentialsCtx: CredentialsContext,
targets: Target[],
buildProfile: BuildProfile<Platform.IOS>
): Promise<CredentialsResult<IosCredentials>> {
const provider = new IosCredentialsProvider(credentialsCtx, {
app: await getAppFromContextAsync(credentialsCtx),
targets,
distribution: 'internal',
enterpriseProvisioning: buildProfile.enterpriseProvisioning,
});
const { credentialsSource } = buildProfile;
logCredentialsSource(credentialsSource, Platform.IOS);
return {
credentials: await provider.getCredentialsAsync(credentialsSource),
source: credentialsSource,
};
}
function shouldProvideCredentials(buildCtx: BuildContext<Platform.IOS>): boolean {
const isCustomBuild = buildCtx.buildProfile.config !== undefined;
return !buildCtx.buildProfile.simulator && !isCustomBuild;
return !buildCtx.buildProfile.simulator;
}
+35 -19
View File
@@ -1,41 +1,48 @@
import { Ios } from '@expo/eas-build-job';
import { Env, Ios } from '@expo/eas-build-job';
import nullthrows from 'nullthrows';
import { IosJobInput, IosJobSecretsInput } from '../../graphql/generated';
import {
transformBuildMode,
transformBuildTrigger,
transformProjectArchive,
transformWorkflow,
} from '../graphql';
DistributionType,
IosBuildType,
IosJobInput,
IosJobSecretsInput,
} from '../../graphql/generated';
import { transformProjectArchive, transformWorkflow } from '../graphql';
export function transformJob(job: Ios.Job): IosJobInput {
return {
type: transformWorkflow(job.type),
triggeredBy: transformBuildTrigger(job.triggeredBy),
projectArchive: transformProjectArchive(job.projectArchive),
projectRootDirectory: nullthrows(job.projectRootDirectory),
projectRootDirectory: job.projectRootDirectory,
releaseChannel: job.releaseChannel,
updates: job.updates,
secrets: job.secrets ? transformIosSecrets(job.secrets) : undefined,
distribution: job.distribution && transformDistributionType(job.distribution),
secrets: transformIosSecrets(job.secrets),
builderEnvironment: job.builderEnvironment,
cache: job.cache,
version: job.version?.buildNumber ? { buildNumber: job.version.buildNumber } : undefined,
scheme: job.scheme,
buildConfiguration: job.buildConfiguration,
applicationArchivePath: job.applicationArchivePath,
buildArtifactPaths: job.buildArtifactPaths,
artifactPath: job.artifactPath,
buildType: job.buildType && transformBuildType(job.buildType),
username: job.username,
developmentClient: job.developmentClient,
simulator: job.simulator,
experimental: job.experimental,
mode: transformBuildMode(job.mode),
customBuildConfig: job.customBuildConfig,
};
}
export function transformIosSecrets(secrets: {
function transformDistributionType(distributionType: Ios.DistributionType): DistributionType {
if (distributionType === 'store') {
return DistributionType.Store;
} else if (distributionType === 'internal') {
return DistributionType.Internal;
} else {
return DistributionType.Simulator;
}
}
function transformIosSecrets(secrets: {
buildCredentials?: Ios.BuildCredentials;
env?: Env;
}): IosJobSecretsInput {
const buildCredentials: IosJobSecretsInput['buildCredentials'] = [];
@@ -51,5 +58,14 @@ export function transformIosSecrets(secrets: {
return {
buildCredentials,
environmentSecrets: secrets.env,
};
}
function transformBuildType(buildType: Ios.BuildType): IosBuildType {
if (buildType === Ios.BuildType.DEVELOPMENT_CLIENT) {
return IosBuildType.DevelopmentClient;
} else {
return IosBuildType.Release;
}
}
@@ -3,17 +3,12 @@ import plist from '@expo/plist';
import fs from 'fs-extra';
import path from 'path';
export async function readPlistAsync(plistPath: string): Promise<object | null> {
export async function readPlistAsync(plistPath: string): Promise<object> {
if (await fs.pathExists(plistPath)) {
const expoPlistContent = await fs.readFile(plistPath, 'utf8');
try {
return plist.parse(expoPlistContent);
} catch (err: any) {
err.message = `Failed to parse ${plistPath}. ${err.message}`;
throw err;
}
return plist.parse(expoPlistContent);
} else {
return null;
return {};
}
}
+26 -69
View File
@@ -1,22 +1,11 @@
import {
ArchiveSource,
BuildMode,
BuildTrigger,
Ios,
Job,
Platform,
sanitizeJob,
} from '@expo/eas-build-job';
import { BuildProfile } from '@expo/eas-json';
import nullthrows from 'nullthrows';
import { ArchiveSource, Ios, Job, Platform, sanitizeJob } from '@expo/eas-build-job';
import path from 'path';
import slash from 'slash';
import { IosCredentials, TargetCredentials } from '../../credentials/ios/types';
import { IosJobSecretsInput } from '../../graphql/generated';
import { getCustomBuildConfigPath } from '../../project/customBuildConfig';
import { getUsername } from '../../project/projectUtils';
import { getVcsClient } from '../../vcs';
import { ensureLoggedInAsync } from '../../user/actions';
import vcs from '../../vcs';
import { BuildContext } from '../context';
interface JobData {
@@ -28,17 +17,17 @@ interface JobData {
const cacheDefaults = {
disabled: false,
customPaths: [],
cacheDefaultPaths: true,
};
export async function prepareJobAsync(
ctx: BuildContext<Platform.IOS>,
jobData: JobData
): Promise<Job> {
const username = getUsername(ctx.exp, ctx.user);
const buildProfile: BuildProfile<Platform.IOS> = ctx.buildProfile;
const projectRootDirectory =
slash(path.relative(await getVcsClient().getRootPathAsync(), ctx.projectDir)) || '.';
const buildCredentials: Ios.BuildSecrets['buildCredentials'] = {};
slash(path.relative(await vcs.getRootPathAsync(), ctx.projectDir)) || '.';
const username = getUsername(ctx.exp, await ensureLoggedInAsync());
const buildCredentials: Ios.Job['secrets']['buildCredentials'] = {};
if (jobData.credentials) {
const targetNames = Object.keys(jobData.credentials);
for (const targetName of targetNames) {
@@ -46,77 +35,45 @@ export async function prepareJobAsync(
}
}
const maybeCustomBuildConfigPath = buildProfile.config
? getCustomBuildConfigPath(buildProfile.config)
: undefined;
const job: Ios.Job = {
type: ctx.workflow,
platform: Platform.IOS,
projectArchive: jobData.projectArchive,
projectRootDirectory,
distribution: ctx.buildProfile.simulator ? 'simulator' : ctx.buildProfile.distribution,
builderEnvironment: {
image: buildProfile.image,
node: buildProfile.node,
yarn: buildProfile.yarn,
bundler: buildProfile.bundler,
cocoapods: buildProfile.cocoapods,
fastlane: buildProfile.fastlane,
expoCli: buildProfile.expoCli,
env: buildProfile.env,
image: ctx.buildProfile.image,
node: ctx.buildProfile.node,
yarn: ctx.buildProfile.yarn,
bundler: ctx.buildProfile.bundler,
cocoapods: ctx.buildProfile.cocoapods,
fastlane: ctx.buildProfile.fastlane,
expoCli: ctx.buildProfile.expoCli,
env: ctx.buildProfile.env,
},
cache: {
...cacheDefaults,
...buildProfile.cache,
...ctx.buildProfile.cache,
clear: ctx.clearCache,
},
secrets: {
buildCredentials,
},
releaseChannel: buildProfile.releaseChannel,
updates: { channel: buildProfile.channel },
developmentClient: buildProfile.developmentClient,
simulator: buildProfile.simulator,
releaseChannel: ctx.buildProfile.releaseChannel,
updates: { channel: ctx.buildProfile.channel },
scheme: jobData.buildScheme,
buildConfiguration: buildProfile.buildConfiguration,
applicationArchivePath: buildProfile.applicationArchivePath ?? buildProfile.artifactPath,
buildArtifactPaths: buildProfile.buildArtifactPaths,
buildConfiguration: ctx.buildProfile.buildConfiguration,
artifactPath: ctx.buildProfile.artifactPath,
buildType: ctx.buildProfile.developmentClient
? Ios.BuildType.DEVELOPMENT_CLIENT
: Ios.BuildType.RELEASE,
username,
...(ctx.ios.buildNumberOverride && {
version: {
buildNumber: ctx.ios.buildNumberOverride,
},
}),
experimental: {
prebuildCommand: buildProfile.prebuildCommand,
},
mode: buildProfile.config ? BuildMode.CUSTOM : BuildMode.BUILD,
triggeredBy: BuildTrigger.EAS_CLI,
...(maybeCustomBuildConfigPath && {
customBuildConfig: {
path: maybeCustomBuildConfigPath,
},
}),
};
return sanitizeJob(job);
}
export function prepareCredentialsToResign(credentials: IosCredentials): IosJobSecretsInput {
const buildCredentials: IosJobSecretsInput['buildCredentials'] = [];
for (const targetName of Object.keys(credentials ?? {})) {
buildCredentials.push({
targetName,
provisioningProfileBase64: nullthrows(credentials?.[targetName].provisioningProfile),
distributionCertificate: {
dataBase64: nullthrows(credentials?.[targetName].distributionCertificate.certificateP12),
password: nullthrows(credentials?.[targetName].distributionCertificate.certificatePassword),
},
});
}
return { buildCredentials };
}
function prepareTargetCredentials(targetCredentials: TargetCredentials): Ios.TargetCredentials {
return {
provisioningProfileBase64: targetCredentials.provisioningProfile,
@@ -1,51 +0,0 @@
import { ExpoConfig } from '@expo/config';
import { Platform, Workflow } from '@expo/eas-build-job';
import { IosVersionAutoIncrement } from '@expo/eas-json';
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
import { Target } from '../../credentials/ios/types';
import { isExpoUpdatesInstalled } from '../../project/projectUtils';
import { resolveWorkflowAsync } from '../../project/workflow';
import { syncUpdatesConfigurationAsync } from '../../update/ios/UpdatesModule';
import { BumpStrategy, bumpVersionAsync, bumpVersionInAppJsonAsync } from './version';
export async function syncProjectConfigurationAsync(
graphqlClient: ExpoGraphqlClient,
{
projectDir,
exp,
targets,
localAutoIncrement,
projectId,
}: {
projectDir: string;
exp: ExpoConfig;
targets: Target[];
localAutoIncrement?: IosVersionAutoIncrement;
projectId: string;
}
): Promise<void> {
const workflow = await resolveWorkflowAsync(projectDir, Platform.IOS);
const versionBumpStrategy = resolveVersionBumpStrategy(localAutoIncrement ?? false);
if (workflow === Workflow.GENERIC) {
if (isExpoUpdatesInstalled(projectDir)) {
await syncUpdatesConfigurationAsync(graphqlClient, projectDir, exp, projectId);
}
await bumpVersionAsync({ projectDir, exp, bumpStrategy: versionBumpStrategy, targets });
} else {
await bumpVersionInAppJsonAsync({ projectDir, exp, bumpStrategy: versionBumpStrategy });
}
}
function resolveVersionBumpStrategy(autoIncrement: IosVersionAutoIncrement): BumpStrategy {
if (autoIncrement === true) {
return BumpStrategy.BUILD_NUMBER;
} else if (autoIncrement === false) {
return BumpStrategy.NOOP;
} else if (autoIncrement === 'buildNumber') {
return BumpStrategy.BUILD_NUMBER;
} else {
return BumpStrategy.APP_VERSION;
}
}
+47 -198
View File
@@ -1,26 +1,16 @@
import { ExpoConfig } from '@expo/config';
import { IOSConfig, Updates } from '@expo/config-plugins';
import { ExpoConfig, getConfigFilePaths } from '@expo/config';
import { IOSConfig } from '@expo/config-plugins';
import { Platform, Workflow } from '@expo/eas-build-job';
import { BuildProfile } from '@expo/eas-json';
import chalk from 'chalk';
import path from 'path';
import type { XCBuildConfiguration } from 'xcode';
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
import { Target } from '../../credentials/ios/types';
import { AppPlatform } from '../../graphql/generated';
import { AppVersionMutation } from '../../graphql/mutations/AppVersionMutation';
import { AppVersionQuery } from '../../graphql/queries/AppVersionQuery';
import Log from '../../log';
import { ora } from '../../ora';
import { findApplicationTarget } from '../../project/ios/target';
import { getNextBuildNumber, isValidBuildNumber } from '../../project/ios/versions';
import { resolveWorkflowAsync } from '../../project/workflow';
import { promptAsync } from '../../prompts';
import uniqBy from '../../utils/expodash/uniqBy';
import { readPlistAsync, writePlistAsync } from '../../utils/plist';
import { updateAppJsonConfigAsync } from '../utils/appJson';
import { bumpAppVersionAsync, ensureStaticConfigExists } from '../utils/version';
import { bumpAppVersionAsync } from '../utils/version';
import { readPlistAsync, writePlistAsync } from './plist';
export enum BumpStrategy {
APP_VERSION,
@@ -32,25 +22,21 @@ export async function bumpVersionAsync({
bumpStrategy,
projectDir,
exp,
targets,
buildSettings,
}: {
projectDir: string;
exp: ExpoConfig;
bumpStrategy: BumpStrategy;
targets: Target[];
buildSettings: XCBuildConfiguration['buildSettings'];
}): Promise<void> {
if (bumpStrategy === BumpStrategy.NOOP) {
return;
}
ensureStaticConfigExists(projectDir);
const infoPlist = await readInfoPlistAsync(projectDir, buildSettings);
await bumpVersionInAppJsonAsync({ bumpStrategy, projectDir, exp });
Log.log('Updated versions in app.json');
await updateNativeVersionsAsync({
projectDir,
version: exp.version,
buildNumber: exp.ios?.buildNumber,
targets,
});
await writeVersionsToInfoPlistAsync({ projectDir, exp, infoPlist, buildSettings });
Log.log('Synchronized versions with Info.plist');
}
@@ -73,8 +59,10 @@ export async function bumpVersionInAppJsonAsync({
await bumpAppVersionAsync({ appVersion, projectDir, exp });
} else {
const buildNumber = IOSConfig.Version.getBuildNumber(exp);
if (isValidBuildNumber(buildNumber)) {
const bumpedBuildNumber = getNextBuildNumber(buildNumber);
if (buildNumber.match(/^\d+(\.\d+)*$/)) {
const comps = buildNumber.split('.');
comps[comps.length - 1] = String(Number(comps[comps.length - 1]) + 1);
const bumpedBuildNumber = comps.join('.');
Log.log(
`Bumping ${chalk.bold('expo.ios.buildNumber')} from ${chalk.bold(
buildNumber
@@ -133,31 +121,35 @@ export async function readBuildNumberAsync(
export async function maybeResolveVersionsAsync(
projectDir: string,
exp: ExpoConfig,
targets: Target[]
buildSettings: XCBuildConfiguration['buildSettings']
): Promise<{ appVersion?: string; appBuildVersion?: string }> {
const applicationTarget = findApplicationTarget(targets);
try {
return {
appBuildVersion: await readBuildNumberAsync(
projectDir,
exp,
applicationTarget.buildSettings ?? {}
),
appVersion: await readShortVersionAsync(
projectDir,
exp,
applicationTarget.buildSettings ?? {}
),
appBuildVersion: await readBuildNumberAsync(projectDir, exp, buildSettings),
appVersion: await readShortVersionAsync(projectDir, exp, buildSettings),
};
} catch (err: any) {
Log.warn('Failed to read app versions.');
Log.debug(err);
Log.warn(err.message);
Log.warn('Proceeding anyway...');
} catch {
return {};
}
}
async function writeVersionsToInfoPlistAsync({
projectDir,
exp,
infoPlist,
buildSettings,
}: {
projectDir: string;
exp: ExpoConfig;
infoPlist: IOSConfig.InfoPlist;
buildSettings: XCBuildConfiguration['buildSettings'];
}): Promise<IOSConfig.InfoPlist> {
let updatedInfoPlist = IOSConfig.Version.setVersion(exp, infoPlist);
updatedInfoPlist = IOSConfig.Version.setBuildNumber(exp, updatedInfoPlist);
await writeInfoPlistAsync({ projectDir, infoPlist: updatedInfoPlist, buildSettings });
return updatedInfoPlist;
}
export function getInfoPlistPath(
projectDir: string,
buildSettings: XCBuildConfiguration['buildSettings']
@@ -182,66 +174,30 @@ async function readInfoPlistAsync(
buildSettings: XCBuildConfiguration['buildSettings']
): Promise<IOSConfig.InfoPlist> {
const infoPlistPath = getInfoPlistPath(projectDir, buildSettings);
return ((await readPlistAsync(infoPlistPath)) ?? {}) as IOSConfig.InfoPlist;
return (await readPlistAsync(infoPlistPath)) as IOSConfig.InfoPlist;
}
export async function updateNativeVersionsAsync({
async function writeInfoPlistAsync({
projectDir,
version,
buildNumber,
targets,
infoPlist,
buildSettings,
}: {
projectDir: string;
version?: string;
buildNumber?: string;
targets: Target[];
infoPlist: IOSConfig.InfoPlist;
buildSettings: XCBuildConfiguration['buildSettings'];
}): Promise<void> {
const project = IOSConfig.XcodeUtils.getPbxproj(projectDir);
const iosDir = path.join(projectDir, 'ios');
const infoPlistPath = getInfoPlistPath(projectDir, buildSettings);
await writePlistAsync(infoPlistPath, infoPlist);
}
const infoPlistFiles: string[] = [];
for (const target of targets) {
const { targetName, buildConfiguration } = target;
const xcBuildConfiguration = IOSConfig.Target.getXCBuildConfigurationFromPbxproj(project, {
targetName,
buildConfiguration,
});
const infoPlist = xcBuildConfiguration?.buildSettings?.INFOPLIST_FILE;
if (infoPlist) {
const evaluatedInfoPlistPath = trimQuotes(
evaluateTemplateString(infoPlist, {
SRCROOT: iosDir,
})
);
const absolutePath = path.isAbsolute(evaluatedInfoPlistPath)
? evaluatedInfoPlistPath
: path.join(iosDir, evaluatedInfoPlistPath);
infoPlistFiles.push(path.normalize(absolutePath));
}
}
const uniqueInfoPlistPaths = uniqBy(infoPlistFiles, i => i);
for (const infoPlistPath of uniqueInfoPlistPaths) {
const infoPlist = (await readPlistAsync(infoPlistPath)) as IOSConfig.InfoPlist;
if (buildNumber) {
infoPlist.CFBundleVersion = buildNumber;
}
if (version) {
infoPlist.CFBundleShortVersionString = version;
}
await writePlistAsync(infoPlistPath, infoPlist);
function ensureStaticConfigExists(projectDir: string): void {
const paths = getConfigFilePaths(projectDir);
if (!paths.staticConfigPath) {
throw new Error('autoIncrement option is not supported when using app.config.js');
}
}
function trimQuotes(s: string): string {
return s?.startsWith('"') && s.endsWith('"') ? s.slice(1, -1) : s;
}
export function evaluateTemplateString(
s: string,
buildSettings: XCBuildConfiguration['buildSettings']
): string {
// necessary because XCBuildConfiguration['buildSettings'] is not a plain object
const vars = { ...buildSettings };
export function evaluateTemplateString(s: string, vars: Record<string, any>): string {
return s.replace(/\$\((\w+)\)/g, (match, key) => {
if (vars.hasOwnProperty(key)) {
const value = String(vars[key]);
@@ -251,110 +207,3 @@ export function evaluateTemplateString(
}
});
}
/**
* Returns buildNumber that will be used for the next build. If current build profile
* has an 'autoIncrement' option set, it increments the version on server.
*/
export async function resolveRemoteBuildNumberAsync(
graphqlClient: ExpoGraphqlClient,
{
projectDir,
projectId,
exp,
applicationTarget,
buildProfile,
}: {
projectDir: string;
projectId: string;
exp: ExpoConfig;
applicationTarget: Target;
buildProfile: BuildProfile<Platform.IOS>;
}
): Promise<string> {
const remoteVersions = await AppVersionQuery.latestVersionAsync(
graphqlClient,
projectId,
AppPlatform.Ios,
applicationTarget.bundleIdentifier
);
const localBuildNumber = await readBuildNumberAsync(
projectDir,
exp,
applicationTarget.buildSettings ?? {}
);
const localShortVersion = await readShortVersionAsync(
projectDir,
exp,
applicationTarget.buildSettings ?? {}
);
let currentBuildVersion: string;
if (remoteVersions?.buildVersion) {
currentBuildVersion = remoteVersions.buildVersion;
} else {
if (localBuildNumber) {
Log.warn(
'No remote versions are configured for this project, buildNumber will be initialized based on the value from the local project.'
);
currentBuildVersion = localBuildNumber;
} else {
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.`
);
throw new Error('Remote versions are not configured.');
}
}
if (!buildProfile.autoIncrement && remoteVersions?.buildVersion) {
return currentBuildVersion;
} else if (!buildProfile.autoIncrement && !remoteVersions?.buildVersion) {
const spinner = ora(
`Initializing buildNumber with ${chalk.bold(currentBuildVersion)}.`
).start();
try {
await AppVersionMutation.createAppVersionAsync(graphqlClient, {
appId: projectId,
platform: AppPlatform.Ios,
applicationIdentifier: applicationTarget.bundleIdentifier,
storeVersion: localShortVersion ?? '1.0.0',
buildVersion: currentBuildVersion,
runtimeVersion: Updates.getRuntimeVersionNullable(exp, Platform.IOS) ?? undefined,
});
spinner.succeed(`Initialized buildNumber with ${chalk.bold(currentBuildVersion)}.`);
} catch (err) {
spinner.fail(`Failed to initialize buildNumber with ${chalk.bold(currentBuildVersion)}.`);
throw err;
}
return currentBuildVersion;
} else {
const nextBuildVersion = getNextBuildNumber(currentBuildVersion);
const spinner = ora(
`Incrementing buildNumber from ${chalk.bold(currentBuildVersion)} to ${chalk.bold(
nextBuildVersion
)}.`
).start();
try {
await AppVersionMutation.createAppVersionAsync(graphqlClient, {
appId: projectId,
platform: AppPlatform.Ios,
applicationIdentifier: applicationTarget.bundleIdentifier,
storeVersion: localShortVersion ?? '1.0.0',
buildVersion: nextBuildVersion,
runtimeVersion: Updates.getRuntimeVersionNullable(exp, Platform.IOS) ?? undefined,
});
spinner.succeed(
`Incremented buildNumber from ${chalk.bold(currentBuildVersion)} to ${chalk.bold(
nextBuildVersion
)}.`
);
} catch (err) {
spinner.fail(
`Failed to increment buildNumber from ${chalk.bold(currentBuildVersion)} to ${chalk.bold(
nextBuildVersion
)}.`
);
throw err;
}
return nextBuildVersion;
}
}
+33 -93
View File
@@ -1,105 +1,45 @@
import { Job, Metadata } from '@expo/eas-build-job';
import { Job } from '@expo/eas-build-job';
import spawnAsync from '@expo/spawn-async';
import { ChildProcess } from 'child_process';
import semver from 'semver';
import chalk from 'chalk';
import fs from 'fs-extra';
import path from 'path';
import { ora } from '../ora';
import Log from '../log';
const PLUGIN_PACKAGE_NAME = 'eas-cli-local-build-plugin';
const PLUGIN_PACKAGE_VERSION = '0.0.128';
export enum LocalBuildMode {
/**
* Local build that users can run on their own machines. Instead
* of sending build request to EAS Servers it's passing it as an argument
* to local-build-plugin, that will run the build locally.
*
* Triggered when running `eas build --local`.
*/
LOCAL_BUILD_PLUGIN = 'local-build-plugin',
/**
* Type of local build that is not accessible to users directly. When
* cloud build is triggered by git based integration, we are running
* in this mode. Instead of sending build request to EAS Servers it's
* printing it to the stdout as JSON, so EAS Build worker can read it.
*/
INTERNAL = 'internal',
}
export interface LocalBuildOptions {
localBuildMode?: LocalBuildMode;
skipCleanup?: boolean;
skipNativeBuild?: boolean;
artifactsDir?: string;
artifactPath?: string;
workingdir?: string;
verbose?: boolean;
}
export async function runLocalBuildAsync(
job: Job,
metadata: Metadata,
options: LocalBuildOptions
): Promise<void> {
const { command, args } = await getCommandAndArgsAsync(job, metadata);
let spinner;
if (!options.verbose) {
spinner = ora().start(options.skipNativeBuild ? 'Preparing project' : 'Building project');
}
let childProcess: ChildProcess | undefined;
const interruptHandler = (): void => {
if (childProcess) {
childProcess.kill();
}
};
process.on('SIGINT', interruptHandler);
export async function runLocalBuildAsync(job: Job): Promise<void> {
const execNameOrPath =
process.env.EAS_LOCAL_BUILD_PLUGIN_PATH ??
(await findWithNodeResolutionAsync()) ??
PLUGIN_PACKAGE_NAME;
try {
const spawnPromise = spawnAsync(command, args, {
stdio: options.verbose ? 'inherit' : 'pipe',
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 } : {}),
},
const arg = Buffer.from(JSON.stringify({ job })).toString('base64');
await spawnAsync(execNameOrPath, [arg], {
stdio: 'inherit',
});
childProcess = spawnPromise.child;
await spawnPromise;
} finally {
process.removeListener('SIGINT', interruptHandler);
spinner?.stop();
}
}
async function getCommandAndArgsAsync(
job: Job,
metadata: Metadata
): Promise<{ command: string; args: string[] }> {
const jobAndMetadataBase64 = Buffer.from(JSON.stringify({ job, metadata })).toString('base64');
if (process.env.EAS_LOCAL_BUILD_PLUGIN_PATH) {
return {
command: process.env.EAS_LOCAL_BUILD_PLUGIN_PATH,
args: [jobAndMetadataBase64],
};
} else {
const args = [`${PLUGIN_PACKAGE_NAME}@${PLUGIN_PACKAGE_VERSION}`, jobAndMetadataBase64];
if (await isAtLeastNpm7Async()) {
// npx shipped with npm >= 7.0.0 requires the "-y" flag to run commands without
// prompting the user to install a package that is used for the first time
args.unshift('-y');
} catch (err: any) {
if (err.code === 'ENOENT') {
Log.warn(`Could not resolve executable ${execNameOrPath}.`);
Log.warn(
`Install ${PLUGIN_PACKAGE_NAME} package from npm e.g. ${chalk.bold(
`npm install -g ${PLUGIN_PACKAGE_NAME}`
)} and make sure it's in PATH.`
);
throw err;
}
return {
command: 'npx',
args,
};
}
}
async function isAtLeastNpm7Async(): Promise<boolean> {
const version = (await spawnAsync('npm', ['--version'])).stdout.trim();
return semver.gte(version, '7.0.0');
async function findWithNodeResolutionAsync(): Promise<string | undefined> {
try {
const resolvedPath = path.resolve(
path.dirname(require.resolve(PLUGIN_PACKAGE_NAME)),
'../bin/run'
);
if (await fs.pathExists(resolvedPath)) {
return resolvedPath;
}
} catch {}
return undefined;
}
+60 -78
View File
@@ -1,108 +1,111 @@
import { Updates } from '@expo/config-plugins';
import { BuildMode, Metadata, Platform, sanitizeMetadata } from '@expo/eas-build-job';
import { Metadata, Platform, sanitizeMetadata } from '@expo/eas-build-job';
import { IosEnterpriseProvisioning } from '@expo/eas-json';
import fs from 'fs-extra';
import resolveFrom from 'resolve-from';
import type { XCBuildConfiguration } from 'xcode';
import Log from '../log';
import { getUsername, isExpoUpdatesInstalled } from '../project/projectUtils';
import { getApplicationIdAsync } from '../project/android/applicationId';
import { GradleBuildContext } from '../project/android/gradle';
import { getBundleIdentifierAsync } from '../project/ios/bundleIdentifier';
import { getUsername } from '../project/projectUtils';
import { ensureLoggedInAsync } from '../user/actions';
import vcs from '../vcs';
import {
readChannelSafelyAsync as readAndroidChannelSafelyAsync,
readReleaseChannelSafelyAsync as readAndroidReleaseChannelSafelyAsync,
} from '../update/android/UpdatesModule';
} from './android/UpdatesModule';
import { maybeResolveVersionsAsync as maybeResolveAndroidVersionsAsync } from './android/version';
import { BuildContext } from './context';
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 { BuildContext } from './context';
} from './ios/UpdatesModule';
import { maybeResolveVersionsAsync as maybeResolveIosVersionsAsync } from './ios/version';
import { isExpoUpdatesInstalled } from './utils/updates';
/**
* We use require() to exclude package.json from TypeScript's analysis since it lives outside
* the src directory and would change the directory structure of the emitted files
* under the build directory
*/
const packageJSON = require('../../package.json');
export type MetadataContext<T extends Platform> = T extends Platform.ANDROID
? AndroidMetadataContext
: IosMetadataContext;
export interface AndroidMetadataContext {
gradleContext?: GradleBuildContext;
}
export interface IosMetadataContext {
buildSettings: XCBuildConfiguration['buildSettings'];
targetName?: string;
buildConfiguration?: string;
}
export async function collectMetadataAsync<T extends Platform>(
ctx: BuildContext<T>
ctx: BuildContext<T>,
platformContext: MetadataContext<T>
): Promise<Metadata> {
const vcsClient = getVcsClient();
const channelOrReleaseChannel = await resolveChannelOrReleaseChannelAsync(ctx);
const distribution =
('simulator' in ctx.buildProfile && ctx.buildProfile.simulator
? 'simulator'
: ctx.buildProfile.distribution) ?? 'store';
const metadata: Metadata = {
trackingContext: ctx.analyticsEventProperties,
...(await maybeResolveVersionsAsync(ctx)),
cliVersion: easCliVersion,
const metadata = {
trackingContext: ctx.trackingCtx,
...(await maybeResolveVersionsAsync(ctx, platformContext)),
cliVersion: packageJSON.version,
workflow: ctx.workflow,
credentialsSource: ctx.buildProfile.credentialsSource,
sdkVersion: ctx.exp.sdkVersion,
runtimeVersion: Updates.getRuntimeVersionNullable(ctx.exp, ctx.platform) ?? undefined,
reactNativeVersion: await getReactNativeVersionAsync(ctx.projectDir),
runtimeVersion: ctx.exp.runtimeVersion,
...channelOrReleaseChannel,
distribution,
appName: ctx.exp.name,
appIdentifier: resolveAppIdentifier(ctx),
appIdentifier: await resolveAppIdentifierAsync(ctx, platformContext),
buildProfile: ctx.buildProfileName,
gitCommitHash: await vcsClient.getCommitHashAsync(),
gitCommitMessage: truncateGitCommitMessage(
(await vcsClient.getLastCommitMessageAsync()) ?? undefined
),
isGitWorkingTreeDirty: await vcsClient.hasUncommittedChangesAsync(),
username: getUsername(ctx.exp, ctx.user),
message: ctx.message,
gitCommitHash: await vcs.getCommitHashAsync(),
username: getUsername(ctx.exp, await ensureLoggedInAsync()),
...(ctx.platform === Platform.IOS && {
iosEnterpriseProvisioning: resolveIosEnterpriseProvisioning(
ctx as BuildContext<Platform.IOS>
),
}),
runWithNoWaitFlag: ctx.noWait,
runFromCI: ctx.runFromCI,
buildMode: ctx.buildProfile.config ? BuildMode.CUSTOM : BuildMode.BUILD,
};
return sanitizeMetadata(metadata);
}
async function maybeResolveVersionsAsync<T extends Platform>(
ctx: BuildContext<T>
ctx: BuildContext<T>,
platformContext: MetadataContext<T>
): Promise<{ appBuildVersion?: string; appVersion?: string }> {
if (ctx.platform === Platform.IOS) {
const iosContext = ctx as BuildContext<Platform.IOS>;
const resolvedVersion = await maybeResolveIosVersionsAsync(
const iosContext = platformContext as IosMetadataContext;
return await maybeResolveIosVersionsAsync(
ctx.projectDir,
ctx.exp,
iosContext.ios.targets
iosContext?.buildSettings ?? {}
);
if (iosContext.ios.buildNumberOverride) {
return {
...resolvedVersion,
appBuildVersion: iosContext.ios.buildNumberOverride,
};
}
return resolvedVersion;
} else if (ctx.platform === Platform.ANDROID) {
const androidCtx = ctx as BuildContext<Platform.ANDROID>;
const resolvedVersion = await maybeResolveAndroidVersionsAsync(
ctx.projectDir,
ctx.exp,
androidCtx.buildProfile
);
if (androidCtx.android.versionCodeOverride) {
return {
...resolvedVersion,
appBuildVersion: androidCtx.android.versionCodeOverride,
};
}
return resolvedVersion;
return await maybeResolveAndroidVersionsAsync(ctx.projectDir, ctx.exp, androidCtx.buildProfile);
} else {
throw new Error(`Unsupported platform ${ctx.platform}`);
}
}
function resolveAppIdentifier<T extends Platform>(ctx: BuildContext<T>): string {
async function resolveAppIdentifierAsync<T extends Platform>(
ctx: BuildContext<T>,
platformContext: MetadataContext<T>
): Promise<string> {
if (ctx.platform === Platform.IOS) {
return (ctx as BuildContext<Platform.IOS>).ios.bundleIdentifier;
const iosContext = platformContext as IosMetadataContext;
return await getBundleIdentifierAsync(ctx.projectDir, ctx.exp, {
targetName: iosContext.targetName,
buildConfiguration: iosContext.buildConfiguration,
});
} else {
return (ctx as BuildContext<Platform.ANDROID>).android.applicationId;
const androidContext = platformContext as AndroidMetadataContext;
return await getApplicationIdAsync(ctx.projectDir, ctx.exp, androidContext.gradleContext);
}
}
@@ -156,29 +159,8 @@ async function getNativeChannelAsync<T extends Platform>(
return undefined;
}
export async function getReactNativeVersionAsync(projectDir: string): Promise<string | undefined> {
try {
const reactNativePackageJsonPath = resolveFrom(projectDir, 'react-native/package.json');
return (await fs.readJson(reactNativePackageJsonPath)).version;
} catch (err) {
Log.debug('Failed to resolve react-native version:');
Log.debug(err);
return undefined;
}
}
function resolveIosEnterpriseProvisioning(
ctx: BuildContext<Platform.IOS>
): IosEnterpriseProvisioning | undefined {
return ctx.buildProfile.enterpriseProvisioning;
}
export function truncateGitCommitMessage(
msg: string | undefined,
maxLength: number = 4096
): string | undefined {
if (msg === undefined) {
return undefined;
}
return msg.length > maxLength ? `${msg.substring(0, maxLength - 3)}...` : msg;
}
-193
View File
@@ -1,193 +0,0 @@
import chalk from 'chalk';
import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient';
import { PaginatedQueryOptions } from '../commandUtils/pagination';
import { AppPlatform, BuildFilter, BuildFragment } from '../graphql/generated';
import { BuildQuery } from '../graphql/queries/BuildQuery';
import Log from '../log';
import { fromNow } from '../utils/date';
import { printJsonOnlyOutput } from '../utils/json';
import {
paginatedQueryWithConfirmPromptAsync,
paginatedQueryWithSelectPromptAsync,
} from '../utils/queries';
import { formatGraphQLBuild } from './utils/formatBuild';
export const BUILDS_LIMIT = 50;
export async function listAndRenderBuildsOnAppAsync(
graphqlClient: ExpoGraphqlClient,
{
projectId,
projectDisplayName,
filter,
paginatedQueryOptions,
}: {
projectId: string;
projectDisplayName: string;
filter?: BuildFilter;
paginatedQueryOptions: PaginatedQueryOptions;
}
): Promise<void> {
if (paginatedQueryOptions.nonInteractive) {
const builds = await BuildQuery.viewBuildsOnAppAsync(graphqlClient, {
appId: projectId,
limit: paginatedQueryOptions.limit ?? BUILDS_LIMIT,
offset: paginatedQueryOptions.offset,
filter,
});
renderPageOfBuilds({ builds, projectDisplayName, paginatedQueryOptions });
} else {
await paginatedQueryWithConfirmPromptAsync({
limit: paginatedQueryOptions.limit ?? BUILDS_LIMIT,
offset: paginatedQueryOptions.offset,
queryToPerform: (limit, offset) =>
BuildQuery.viewBuildsOnAppAsync(graphqlClient, {
appId: projectId,
limit,
offset,
filter,
}),
promptOptions: {
title: 'Load more builds?',
renderListItems: builds =>
renderPageOfBuilds({ builds, projectDisplayName, paginatedQueryOptions }),
},
});
}
}
export async function listAndSelectBuildOnAppAsync(
graphqlClient: ExpoGraphqlClient,
{
projectId,
title,
filter,
paginatedQueryOptions,
selectPromptDisabledFunction,
selectPromptWarningMessage,
}: {
projectId: string;
title: string;
filter?: BuildFilter;
paginatedQueryOptions: PaginatedQueryOptions;
selectPromptDisabledFunction?: (build: BuildFragment) => boolean;
selectPromptWarningMessage?: string;
}
): Promise<BuildFragment | null> {
if (paginatedQueryOptions.nonInteractive) {
throw new Error('Unable to select a build in non-interactive mode.');
} else {
const selectedBuild = await paginatedQueryWithSelectPromptAsync({
limit: paginatedQueryOptions.limit ?? BUILDS_LIMIT,
offset: paginatedQueryOptions.offset,
queryToPerform: (limit, offset) =>
BuildQuery.viewBuildsOnAppAsync(graphqlClient, {
appId: projectId,
limit,
offset,
filter,
}),
promptOptions: {
title,
getIdentifierForQueryItem: build => build.id,
makePartialChoiceObject: createBuildToPartialChoiceMaker(selectPromptDisabledFunction),
selectPromptWarningMessage,
},
});
return selectedBuild ?? null;
}
}
function createBuildToPartialChoiceMaker(
selectPromptDisabledFunction?: (build: BuildFragment) => boolean
) {
return (
build: BuildFragment
): {
title: string;
description: string;
disabled?: boolean;
} => {
const splitCommitMessage = build.gitCommitMessage?.split('\n');
const formattedCommitData =
build.gitCommitHash && splitCommitMessage && splitCommitMessage.length > 0
? `${build.gitCommitHash.slice(0, 7)} "${chalk.bold(
splitCommitMessage[0] + (splitCommitMessage.length > 1 ? '…' : '')
)}"`
: null;
const descriptionItems: { name: string; value: string | null }[] = [
{ name: 'Version', value: build.appVersion ? chalk.bold(build.appVersion) : null },
{ name: 'Commit', value: formattedCommitData },
{
name: 'Message',
value: build.message
? chalk.bold(
build.message.length > 200 ? `${build.message.slice(0, 200)}...` : build.message
)
: null,
},
{
name: build.platform === AppPlatform.Ios ? 'Build number' : 'Version code',
value: build.appBuildVersion ? chalk.bold(build.appBuildVersion) : null,
},
];
const filteredDescriptionArray: string[] = descriptionItems
.filter(item => item.value)
.map(item => `${chalk.bold(item.name)}: ${item.value}`);
return {
title: `${chalk.bold(`ID:`)} ${build.id} (${chalk.bold(
`${fromNow(new Date(build.completedAt))} ago`
)})`,
description: filteredDescriptionArray.length > 0 ? filteredDescriptionArray.join('\n') : '',
disabled: selectPromptDisabledFunction?.(build),
};
};
}
export async function getLatestBuildAsync(
graphqlClient: ExpoGraphqlClient,
{
projectId,
filter,
}: {
projectId: string;
filter?: BuildFilter;
}
): Promise<BuildFragment | null> {
const builds = await BuildQuery.viewBuildsOnAppAsync(graphqlClient, {
appId: projectId,
limit: 1,
offset: 0,
filter,
});
if (builds.length === 0) {
return null;
}
return builds[0];
}
function renderPageOfBuilds({
builds,
projectDisplayName,
paginatedQueryOptions,
}: {
builds: BuildFragment[];
projectDisplayName: string;
paginatedQueryOptions: PaginatedQueryOptions;
}): void {
if (paginatedQueryOptions.json) {
printJsonOnlyOutput(builds);
} else {
const list = builds.map(build => formatGraphQLBuild(build)).join(`\n\n${chalk.dim('———')}\n\n`);
Log.addNewLineIfNone();
Log.log(chalk.bold(`Builds for ${projectDisplayName}:`));
Log.log(`\n${list}`);
}
}
@@ -1,412 +0,0 @@
import { Platform, Workflow } from '@expo/eas-build-job';
import {
AppVersionSource,
BuildProfile,
EasJson,
EasJsonAccessor,
EasJsonUtils,
ResourceClass,
SubmitProfile,
} from '@expo/eas-json';
import assert from 'assert';
import chalk from 'chalk';
import nullthrows from 'nullthrows';
import { Analytics } from '../analytics/AnalyticsManager';
import { DynamicConfigContextFn } from '../commandUtils/context/DynamicProjectConfigContextField';
import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient';
import {
AppPlatform,
BuildFragment,
BuildStatus,
BuildWithSubmissionsFragment,
SubmissionFragment,
} from '../graphql/generated';
import { BuildQuery } from '../graphql/queries/BuildQuery';
import { toAppPlatform, toPlatform } from '../graphql/types/AppPlatform';
import Log from '../log';
import {
RequestedPlatform,
appPlatformDisplayNames,
appPlatformEmojis,
toPlatforms,
} from '../platform';
import { validateCustomBuildConfigAsync } from '../project/customBuildConfig';
import { checkExpoSdkIsSupportedAsync } from '../project/expoSdk';
import { validateMetroConfigForManagedWorkflowAsync } from '../project/metroConfig';
import { validateAppVersionRuntimePolicySupportAsync } from '../project/projectUtils';
import {
validateAppConfigForRemoteVersionSource,
validateBuildProfileVersionSettings,
} from '../project/remoteVersionSource';
import { confirmAsync } from '../prompts';
import { runAsync } from '../run/run';
import { isRunnableOnSimulatorOrEmulator } from '../run/utils';
import { createSubmissionContextAsync } from '../submit/context';
import {
exitWithNonZeroCodeIfSomeSubmissionsDidntFinish,
submitAsync,
waitToCompleteAsync as waitForSubmissionsToCompleteAsync,
} from '../submit/submit';
import { printSubmissionDetailsUrls } from '../submit/utils/urls';
import { validateBuildProfileConfigMatchesProjectConfigAsync } from '../update/utils';
import { Actor } from '../user/User';
import { downloadAndMaybeExtractAppAsync } from '../utils/download';
import { truthy } from '../utils/expodash/filter';
import { printJsonOnlyOutput } from '../utils/json';
import { ProfileData, getProfilesAsync } from '../utils/profiles';
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 sdkVersionChecked = false;
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;
}
export async function runBuildAndSubmitAsync(
graphqlClient: ExpoGraphqlClient,
analytics: Analytics,
projectDir: string,
flags: BuildFlags,
actor: Actor,
getDynamicProjectConfigAsync: DynamicConfigContextFn
): Promise<void> {
await getVcsClient().ensureRepoExistsAsync();
await ensureRepoIsCleanAsync(flags.nonInteractive);
await ensureProjectConfiguredAsync({
projectDir,
nonInteractive: flags.nonInteractive,
});
const easJsonAccessor = EasJsonAccessor.fromProjectPath(projectDir);
const easJsonCliConfig: EasJson['cli'] =
(await EasJsonUtils.getCliConfigAsync(easJsonAccessor)) ?? {};
const platforms = toPlatforms(flags.requestedPlatform);
const buildProfiles = await getProfilesAsync({
type: 'build',
easJsonAccessor,
platforms,
profileName: flags.profile ?? undefined,
});
await ensureExpoDevClientInstalledForDevClientBuildsAsync({
projectDir,
nonInteractive: flags.nonInteractive,
buildProfiles,
});
for (const buildProfile of buildProfiles) {
validateBuildProfileVersionSettings(buildProfile, easJsonCliConfig);
await validateCustomBuildConfigAsync(projectDir, buildProfile.profile);
}
const startedBuilds: {
build: BuildWithSubmissionsFragment | BuildFragment;
buildProfile: ProfileData<'build'>;
}[] = [];
const buildCtxByPlatform: { [p in AppPlatform]?: BuildContext<Platform> } = {};
for (const buildProfile of buildProfiles) {
const { build: maybeBuild, buildCtx } = await prepareAndStartBuildAsync({
projectDir,
flags,
moreBuilds: platforms.length > 1,
buildProfile,
easJsonCliConfig,
actor,
graphqlClient,
analytics,
getDynamicProjectConfigAsync,
});
if (maybeBuild) {
startedBuilds.push({ build: maybeBuild, buildProfile });
}
buildCtxByPlatform[toAppPlatform(buildProfile.platform)] = buildCtx;
}
if (flags.localBuildOptions.localBuildMode) {
return;
}
Log.newLine();
printLogsUrls(startedBuilds.map(startedBuild => startedBuild.build));
Log.newLine();
const submissions: SubmissionFragment[] = [];
if (flags.autoSubmit) {
const submitProfiles = await getProfilesAsync({
easJsonAccessor,
platforms,
profileName: flags.submitProfile,
type: 'submit',
});
for (const startedBuild of startedBuilds) {
const submitProfile = nullthrows(
submitProfiles.find(
({ platform }) => toAppPlatform(platform) === startedBuild.build.platform
)
).profile;
const submission = await prepareAndStartSubmissionAsync({
build: startedBuild.build,
buildCtx: nullthrows(buildCtxByPlatform[startedBuild.build.platform]),
moreBuilds: startedBuilds.length > 1,
projectDir,
buildProfile: startedBuild.buildProfile.profile,
submitProfile,
nonInteractive: flags.nonInteractive,
});
startedBuild.build = await BuildQuery.withSubmissionsByIdAsync(
graphqlClient,
startedBuild.build.id
);
submissions.push(submission);
}
Log.newLine();
printSubmissionDetailsUrls(submissions);
Log.newLine();
}
if (!flags.wait) {
if (flags.json) {
printJsonOnlyOutput(startedBuilds.map(buildInfo => buildInfo.build));
}
return;
}
const { accountName } = Object.values(buildCtxByPlatform)[0];
const builds = await waitForBuildEndAsync(graphqlClient, {
buildIds: startedBuilds.map(({ build }) => build.id),
accountName,
projectDir,
nonInteractive: flags.nonInteractive,
});
if (!flags.json) {
printBuildResults(builds);
}
const haveAllBuildsFailedOrCanceled = builds.every(
build => build?.status && [BuildStatus.Errored, BuildStatus.Canceled].includes(build?.status)
);
await maybeDownloadAndRunSimulatorBuildsAsync(builds, flags);
if (haveAllBuildsFailedOrCanceled || !flags.autoSubmit) {
if (flags.json) {
printJsonOnlyOutput(builds);
}
exitWithNonZeroCodeIfSomeBuildsFailed(builds);
} else {
const completedSubmissions = await waitForSubmissionsToCompleteAsync(
graphqlClient,
submissions
);
if (flags.json) {
printJsonOnlyOutput(
await Promise.all(
builds
.filter((i): i is BuildWithSubmissionsFragment => !!i)
.map(build => BuildQuery.withSubmissionsByIdAsync(graphqlClient, build.id))
)
);
}
exitWithNonZeroCodeIfSomeSubmissionsDidntFinish(completedSubmissions);
}
}
async function prepareAndStartBuildAsync({
projectDir,
flags,
moreBuilds,
buildProfile,
easJsonCliConfig,
actor,
graphqlClient,
analytics,
getDynamicProjectConfigAsync,
}: {
projectDir: string;
flags: BuildFlags;
moreBuilds: boolean;
buildProfile: ProfileData<'build'>;
easJsonCliConfig: EasJson['cli'];
actor: Actor;
graphqlClient: ExpoGraphqlClient;
analytics: Analytics;
getDynamicProjectConfigAsync: DynamicConfigContextFn;
}): Promise<{ build: BuildFragment | undefined; buildCtx: BuildContext<Platform> }> {
const buildCtx = await createBuildContextAsync({
buildProfileName: buildProfile.profileName,
resourceClassFlag: flags.resourceClass,
clearCache: flags.clearCache,
buildProfile: buildProfile.profile,
nonInteractive: flags.nonInteractive,
noWait: !flags.wait,
platform: buildProfile.platform,
projectDir,
localBuildOptions: flags.localBuildOptions,
easJsonCliConfig,
message: flags.message,
actor,
graphqlClient,
analytics,
getDynamicProjectConfigAsync,
});
if (moreBuilds) {
Log.newLine();
const appPlatform = toAppPlatform(buildProfile.platform);
Log.log(
`${appPlatformEmojis[appPlatform]} ${chalk.bold(
`${appPlatformDisplayNames[appPlatform]} build`
)}`
);
}
await validateBuildProfileConfigMatchesProjectConfigAsync(
buildCtx.exp,
buildProfile,
buildCtx.projectId,
flags.nonInteractive
);
await validateAppVersionRuntimePolicySupportAsync(buildCtx.projectDir, buildCtx.exp);
if (easJsonCliConfig?.appVersionSource === AppVersionSource.REMOTE) {
validateAppConfigForRemoteVersionSource(buildCtx.exp, buildProfile.platform);
}
if (buildCtx.workflow === Workflow.MANAGED) {
if (!sdkVersionChecked) {
await checkExpoSdkIsSupportedAsync(buildCtx);
sdkVersionChecked = true;
}
if (!metroConfigValidated) {
await validateMetroConfigForManagedWorkflowAsync(buildCtx);
metroConfigValidated = true;
}
}
const build = await startBuildAsync(buildCtx);
return {
build,
buildCtx,
};
}
async function startBuildAsync(ctx: BuildContext<Platform>): Promise<BuildFragment | undefined> {
let sendBuildRequestAsync: BuildRequestSender;
if (ctx.platform === Platform.ANDROID) {
sendBuildRequestAsync = await prepareAndroidBuildAsync(ctx as BuildContext<Platform.ANDROID>);
} else {
sendBuildRequestAsync = await prepareIosBuildAsync(ctx as BuildContext<Platform.IOS>);
}
return await sendBuildRequestAsync();
}
async function prepareAndStartSubmissionAsync({
build,
buildCtx,
moreBuilds,
projectDir,
buildProfile,
submitProfile,
nonInteractive,
}: {
build: BuildFragment;
buildCtx: BuildContext<Platform>;
moreBuilds: boolean;
projectDir: string;
buildProfile: BuildProfile;
submitProfile: SubmitProfile;
nonInteractive: boolean;
}): Promise<SubmissionFragment> {
const platform = toPlatform(build.platform);
const submissionCtx = await createSubmissionContextAsync({
platform,
projectDir,
profile: submitProfile,
archiveFlags: { id: build.id },
nonInteractive,
env: buildProfile.env,
credentialsCtx: buildCtx.credentialsCtx,
applicationIdentifier: buildCtx.android?.applicationId ?? buildCtx.ios?.bundleIdentifier,
actor: buildCtx.user,
graphqlClient: buildCtx.graphqlClient,
analytics: buildCtx.analytics,
projectId: buildCtx.projectId,
exp: buildCtx.exp,
});
if (moreBuilds) {
Log.newLine();
Log.log(
`${appPlatformEmojis[build.platform]} ${chalk.bold(
`${appPlatformDisplayNames[build.platform]} submission`
)}`
);
}
return await submitAsync(submissionCtx);
}
function exitWithNonZeroCodeIfSomeBuildsFailed(maybeBuilds: (BuildFragment | null)[]): void {
const failedBuilds = (maybeBuilds.filter(i => i) as BuildFragment[]).filter(
i => i.status === BuildStatus.Errored
);
if (failedBuilds.length > 0) {
process.exit(1);
}
}
async function downloadAndRunAsync(build: BuildFragment): Promise<void> {
assert(build.artifacts?.applicationArchiveUrl);
const buildPath = await downloadAndMaybeExtractAppAsync(
build.artifacts.applicationArchiveUrl,
build.platform
);
await runAsync(buildPath, build.platform);
}
async function maybeDownloadAndRunSimulatorBuildsAsync(
builds: MaybeBuildFragment[],
flags: BuildFlags
): Promise<void> {
const simBuilds = builds.filter(truthy).filter(isRunnableOnSimulatorOrEmulator);
if (simBuilds.length > 0 && !flags.autoSubmit && !flags.nonInteractive) {
for (const simBuild of simBuilds) {
if (simBuild.platform === AppPlatform.Android || process.platform === 'darwin') {
Log.newLine();
const confirm = await confirmAsync({
message: `Install and run the ${
simBuild.platform === AppPlatform.Android ? 'Android' : 'iOS'
} build on ${simBuild.platform === AppPlatform.Android ? 'an emulator' : 'a simulator'}?`,
});
if (confirm) {
await downloadAndRunAsync(simBuild);
}
}
}
}
}
+2
View File
@@ -12,3 +12,5 @@ export enum BuildDistributionType {
INTERNAL = 'internal',
SIMULATOR = 'simulator',
}
export type TrackingContext = Record<string, string | number | boolean>;
@@ -0,0 +1,29 @@
import { logEvent } from '../../analytics';
export enum Event {
BUILD_COMMAND = 'build cli build command',
PROJECT_UPLOAD_SUCCESS = 'build cli project upload success',
PROJECT_UPLOAD_FAIL = 'build cli project upload fail',
GATHER_CREDENTIALS_SUCCESS = 'build cli gather credentials success',
GATHER_CREDENTIALS_FAIL = 'build cli gather credentials fail',
CONFIGURE_PROJECT_SUCCESS = 'build cli configure project success',
CONFIGURE_PROJECT_FAIL = 'build cli configure project fail',
BUILD_REQUEST_SUCCESS = 'build cli build request success',
BUILD_REQUEST_FAIL = 'build cli build request fail',
BUILD_STATUS_COMMAND = 'build cli build status',
CREDENTIALS_SYNC_COMMAND = 'build cli credentials sync command',
CREDENTIALS_SYNC_UPDATE_LOCAL_SUCCESS = 'build cli credentials sync update local success',
CREDENTIALS_SYNC_UPDATE_LOCAL_FAIL = 'build cli credentials sync update local fail',
CREDENTIALS_SYNC_UPDATE_REMOTE_SUCCESS = 'build cli credentials sync update remote success',
CREDENTIALS_SYNC_UPDATE_REMOTE_FAIL = 'build cli credentials sync update remote fail',
ANDROID_KEYSTORE_CREATE = 'build cli credentials keystore create',
}
export default {
logEvent(name: Event, properties: Record<string, any>) {
logEvent(name, properties);
},
};
+3 -12
View File
@@ -1,6 +1,6 @@
import * as ExpoConfig from '@expo/config';
import JsonFile from '@expo/json-file';
import assert from 'assert';
import fs from 'fs-extra';
export async function updateAppJsonConfigAsync(
{
@@ -15,19 +15,10 @@ export async function updateAppJsonConfigAsync(
const paths = ExpoConfig.getConfigFilePaths(projectDir);
assert(paths.staticConfigPath, "can't update dynamic config");
const rawStaticConfig = readAppJson(paths.staticConfigPath);
const rawStaticConfig = await fs.readJSON(paths.staticConfigPath);
rawStaticConfig.expo = rawStaticConfig.expo ?? {};
modifyConfig(rawStaticConfig.expo);
await JsonFile.writeAsync(paths.staticConfigPath, rawStaticConfig, { json5: false });
await fs.writeJson(paths.staticConfigPath, rawStaticConfig, { spaces: 2 });
modifyConfig(exp);
}
// TODO: remove this once @expo/config exports getStaticConfig
export function readAppJson(appJsonPath: string): any {
const config = JsonFile.read(appJsonPath, { json5: true });
if (config) {
return config as any;
}
throw new Error(`Failed to read app.json`);
}
+26 -25
View File
@@ -1,5 +1,6 @@
import { Workflow } from '@expo/eas-build-job';
import { Errors } from '@oclif/core';
import { Platform, Workflow } from '@expo/eas-build-job';
import { EasJsonReader } from '@expo/eas-json';
import { error } from '@oclif/errors';
import chalk from 'chalk';
import resolveFrom from 'resolve-from';
@@ -8,38 +9,40 @@ import Log, { learnMore } from '../../log';
import { appPlatformDisplayNames } from '../../platform';
import { resolveWorkflowAsync } from '../../project/workflow';
import { confirmAsync } from '../../prompts';
import { expoCommandAsync } from '../../utils/expoCli';
import { ProfileData } from '../../utils/profiles';
import { getVcsClient } from '../../vcs';
import { expoCommandAsync } from '../../utils/expoCommand';
import zipObject from '../../utils/expodash/zipObject';
import { reviewAndCommitChangesAsync } from './repository';
export async function ensureExpoDevClientInstalledForDevClientBuildsAsync({
projectDir,
platforms,
profile,
nonInteractive = false,
buildProfiles = [],
}: {
projectDir: string;
platforms: Platform[];
profile: string;
nonInteractive?: boolean;
buildProfiles?: ProfileData<'build'>[];
}): Promise<void> {
if (await isExpoDevClientInstalledAsync(projectDir)) {
return;
}
const buildProfilesWithDevelopmentClientRequired = buildProfiles.filter(
buildProfile => buildProfile.profile.developmentClient
const easJsonReader = new EasJsonReader(projectDir);
const devClientPerPlatformList = await Promise.all(
platforms.map(async platform => {
const buildProfile = await easJsonReader.readBuildProfileAsync(platform, profile);
return buildProfile.developmentClient ?? false;
})
);
const isDevelopmentClientRequired = buildProfilesWithDevelopmentClientRequired.some(Boolean);
if (!isDevelopmentClientRequired) {
const isDevClientRequired = devClientPerPlatformList.some(i => i);
if (!isDevClientRequired) {
return;
}
const platformsToCheck = buildProfilesWithDevelopmentClientRequired.map(
({ platform }) => platform
);
const devClientPerPlatform = zipObject(platforms, devClientPerPlatformList);
const platformsToCheck = platforms.filter(platform => devClientPerPlatform[platform]);
const workflowPerPlatformList = await Promise.all(
platformsToCheck.map(platform => resolveWorkflowAsync(projectDir, platform))
);
@@ -64,15 +67,15 @@ export async function ensureExpoDevClientInstalledForDevClientBuildsAsync({
if (install) {
await installExpoDevClientAsync(projectDir, { nonInteractive });
} else {
Errors.error(`Install ${chalk.bold('expo-dev-client')} manually and come back later.`, {
error(`Install ${chalk.bold('expo-dev-client')} on your own and come back later.`, {
exit: 1,
});
}
} else {
Log.warn(`You'll need to install ${chalk.bold('expo-dev-client')} manually.`);
Log.warn(`Unfortunately, you need to install ${chalk.bold('expo-dev-client')} on your own.`);
Log.warn(
learnMore('https://docs.expo.dev/clients/installation/', {
learnMoreMessage: 'See installation instructions on how to do it.',
learnMoreMessage: 'See installation instructions on how to do it',
dim: false,
})
);
@@ -83,7 +86,7 @@ export async function ensureExpoDevClientInstalledForDevClientBuildsAsync({
initial: false,
});
if (!shouldContinue) {
Errors.error('Come back later', { exit: 1 });
error('Come back later', { exit: 1 });
}
}
}
@@ -107,9 +110,7 @@ async function installExpoDevClientAsync(
Log.newLine();
await expoCommandAsync(projectDir, ['install', 'expo-dev-client']);
Log.newLine();
if (await getVcsClient().isCommitRequiredAsync()) {
await reviewAndCommitChangesAsync('Install expo-dev-client', {
nonInteractive,
});
}
await reviewAndCommitChangesAsync('Install expo-dev-client', {
nonInteractive,
});
}
@@ -38,10 +38,6 @@ export function formatGraphQLBuild(build: BuildFragment): string {
}
},
},
{
label: 'Message',
value: build.message,
},
{
label: 'Distribution',
value: build.distribution?.toLowerCase(),
@@ -124,5 +120,11 @@ export function formatGraphQLBuild(build: BuildFragment): string {
}
const getActorName = (build: BuildFragment): string => {
return build.initiatingActor?.displayName || 'unknown';
if (!build.initiatingActor) {
return 'unknown';
} else if (build.initiatingActor.__typename === 'User') {
return build.initiatingActor.username;
} else {
return build.initiatingActor.firstName ?? 'unknown';
}
};
@@ -1,4 +1,5 @@
import assert from 'assert';
import chalk from 'chalk';
import indentString from 'indent-string';
import qrcodeTerminal from 'qrcode-terminal';
@@ -11,64 +12,44 @@ import {
EasBuildDeprecationInfo,
EasBuildDeprecationInfoType,
} from '../../graphql/generated';
import Log, { learnMore, link } from '../../log';
import Log, { learnMore } from '../../log';
import { appPlatformDisplayNames, appPlatformEmojis } from '../../platform';
import { printJsonOnlyOutput } from '../../utils/json';
import { getBuildLogsUrl, getInternalDistributionInstallUrl } from './url';
function terminalLinkFallback(url: string, text: string): string {
return `${text} (${url})`;
}
const errorCodeToErrorMessageOverride: Record<string, (build: BuildFragment) => string> = {
EAS_BUILD_UNKNOWN_FASTLANE_ERROR: build =>
`The ${link(getBuildLogsUrl(build, 'run-fastlane'), {
text: '"Run fastlane"',
fallback: terminalLinkFallback(getBuildLogsUrl(build, 'run-fastlane'), '"Run fastlane"'),
})} step failed with an unknown error. Refer to the ${link(
getBuildLogsUrl(build, 'xcode-logs'),
{
text: '"Xcode logs"',
fallback: terminalLinkFallback(getBuildLogsUrl(build, 'xcode-logs'), '"Xcode logs"'),
}
)} phase for additional, more detailed logs`,
EAS_BUILD_UNKNOWN_GRADLE_ERROR: build =>
`Gradle build failed with unknown error. See logs for the ${link(
getBuildLogsUrl(build, 'run-gradlew'),
{
text: '"Run gradlew"',
fallback: terminalLinkFallback(getBuildLogsUrl(build, 'run-gradlew'), '"Run gradlew"'),
}
)} phase for more information.`,
};
export function printLogsUrls(builds: BuildFragment[]): void {
if (builds.length === 1) {
Log.log(`Build details: ${link(getBuildLogsUrl(builds[0]))}`);
Log.log(`Build details: ${chalk.underline(getBuildLogsUrl(builds[0]))}`);
} else {
builds.forEach(build => {
const logsUrl = getBuildLogsUrl(build);
Log.log(
`${appPlatformEmojis[build.platform]} ${
appPlatformDisplayNames[build.platform]
} build details: ${link(logsUrl)}`
} build details: ${chalk.underline(logsUrl)}`
);
});
}
}
export function printBuildResults(builds: (BuildFragment | null)[]): void {
if (builds.length === 1) {
const [build] = builds;
assert(build, 'Build should be defined');
printBuildResult(build);
export function printBuildResults(builds: (BuildFragment | null)[], json: boolean): void {
if (json) {
printJsonOnlyOutput(builds);
} else {
(builds.filter(i => i) as BuildFragment[]).forEach(build => printBuildResult(build));
Log.newLine();
if (builds.length === 1) {
const [build] = builds;
assert(build, 'Build should be defined');
printBuildResult(build);
} else {
(builds.filter(i => i) as BuildFragment[]).forEach(build => printBuildResult(build));
}
}
}
function printBuildResult(build: BuildFragment): void {
Log.addNewLineIfNone();
if (build.status === BuildStatus.Errored) {
Log.addNewLineIfNone();
const userError = build.error;
Log.error(
`${appPlatformEmojis[build.platform]} ${
@@ -76,12 +57,11 @@ function printBuildResult(build: BuildFragment): void {
} build failed${userError ? ':' : ''}`
);
if (userError) {
printUserError(userError, build);
printUserError(userError);
}
return;
}
if (build.status === BuildStatus.Canceled) {
Log.addNewLineIfNone();
Log.error(
`${appPlatformEmojis[build.platform]} ${
appPlatformDisplayNames[build.platform]
@@ -91,7 +71,6 @@ function printBuildResult(build: BuildFragment): void {
}
if (build.distribution === DistributionType.Internal) {
Log.addNewLineIfNone();
const logsUrl = getBuildLogsUrl(build);
// It's tricky to install the .apk file directly on Android so let's fallback
// to the build details page and let people press the button to download there
@@ -105,18 +84,13 @@ function printBuildResult(build: BuildFragment): void {
appPlatformDisplayNames[build.platform]
} devices (or scan the QR code) to install the app:`
);
Log.log(`${link(logsUrl)}`);
Log.log(`${chalk.underline(logsUrl)}`);
} else {
// TODO: it looks like buildUrl could possibly be undefined, based on the code below.
// we should account for this case better if it is possible
const url = build.artifacts?.buildUrl;
if (url) {
Log.addNewLineIfNone();
Log.log(
`${appPlatformEmojis[build.platform]} ${appPlatformDisplayNames[build.platform]} app:`
);
Log.log(link(url));
}
const url = build.artifacts?.buildUrl ?? '';
Log.log(`${appPlatformEmojis[build.platform]} ${appPlatformDisplayNames[build.platform]} app:`);
Log.log(`${chalk.underline(url)}`);
}
}
@@ -125,34 +99,24 @@ export function printDeprecationWarnings(deprecationInfo?: EasBuildDeprecationIn
return;
}
if (deprecationInfo.type === EasBuildDeprecationInfoType.Internal) {
Log.warn('This command is using API that soon will be deprecated. Upgrade EAS CLI.');
Log.warn('This command is using API that soon will be deprecated, please update eas-cli.');
Log.warn("Changes won't affect your project config.");
Log.warn(deprecationInfo.message);
} else if (deprecationInfo.type === EasBuildDeprecationInfoType.UserFacing) {
Log.warn('This command is using API that soon will be deprecated.');
Log.warn('This command is using API that soon will be deprecated, please update eas-cli.');
Log.warn(
'There might be some changes necessary to your project config, latest eas-cli will provide more specific error messages.'
);
Log.warn(deprecationInfo.message);
} else {
Log.warn('An unexpected warning was encountered. Report it as a bug:');
Log.warn('An unexpected warning was encountered. Please report it as a bug:');
Log.warn(deprecationInfo);
}
}
export function printUserError(error: BuildError, build: BuildFragment): void {
const maybeErrorMessageOverride = maybeGetErrorMessageOverride(error, build);
if (maybeErrorMessageOverride) {
Log.error(maybeErrorMessageOverride);
} else {
Log.error(error.message);
if (error.docsUrl) {
Log.error(learnMore(error.docsUrl, { dim: false }));
}
export function printUserError(error: BuildError): void {
Log.error(error.message);
if (error.docsUrl) {
Log.error(learnMore(error.docsUrl, { dim: false }));
}
}
function maybeGetErrorMessageOverride(error: BuildError, build: BuildFragment): string | null {
if (!(error.errorCode in errorCodeToErrorMessageOverride)) {
return null;
}
return errorCodeToErrorMessageOverride[error.errorCode](build);
}
+12 -20
View File
@@ -10,10 +10,10 @@ import { confirmAsync, promptAsync } from '../../prompts';
import { formatBytes } from '../../utils/files';
import { getTmpDirectory } from '../../utils/paths';
import { endTimer, formatMilliseconds, startTimer } from '../../utils/timer';
import { getVcsClient } from '../../vcs';
import vcs from '../../vcs';
export async function maybeBailOnRepoStatusAsync(): Promise<void> {
if (!(await getVcsClient().isCommitRequiredAsync())) {
if (!(await vcs.hasUncommittedChangesAsync())) {
return;
}
Log.addNewLineIfNone();
@@ -28,23 +28,23 @@ export async function maybeBailOnRepoStatusAsync(): Promise<void> {
});
if (!answer) {
throw new Error('Commit all changes. Aborting...');
throw new Error('Please commit all changes. Aborting...');
}
}
export async function ensureRepoIsCleanAsync(nonInteractive = false): Promise<void> {
if (!(await getVcsClient().isCommitRequiredAsync())) {
if (!(await vcs.hasUncommittedChangesAsync())) {
return;
}
Log.addNewLineIfNone();
Log.warn(`${chalk.bold('Warning!')} Your repository working tree is dirty.`);
Log.log(
`This operation needs to be run on a clean working tree. ${chalk.bold(
'Commit all your changes before proceeding'
`This operation needs to be run on a clean working tree, please ${chalk.bold(
'commit all your changes before proceeding'
)}.`
);
if (nonInteractive) {
throw new Error('Commit all changes. Aborting...');
throw new Error('Please commit all changes. Aborting...');
}
const answer = await confirmAsync({
message: `Commit changes to git?`,
@@ -52,7 +52,7 @@ export async function ensureRepoIsCleanAsync(nonInteractive = false): Promise<vo
if (answer) {
await commitPromptAsync({ commitAllFiles: true });
} else {
throw new Error('Commit all changes. Aborting...');
throw new Error('Please commit all changes. Aborting...');
}
}
@@ -70,11 +70,7 @@ export async function commitPromptAsync({
initial: initialCommitMessage,
validate: (input: string) => input !== '',
});
await getVcsClient().commitAsync({
commitAllFiles,
commitMessage: message,
nonInteractive: false,
});
await vcs.commitAsync({ commitAllFiles, commitMessage: message });
}
export async function makeProjectTarballAsync(): Promise<{ path: string; size: number }> {
@@ -99,7 +95,7 @@ export async function makeProjectTarballAsync(): Promise<{ path: string; size: n
startTimer(compressTimerLabel);
try {
await getVcsClient().makeShallowCopyAsync(shallowClonePath);
await vcs.makeShallowCopyAsync(shallowClonePath);
await tar.create({ cwd: shallowClonePath, file: tarPath, prefix: 'project', gzip: true }, [
'.',
]);
@@ -137,11 +133,7 @@ export async function reviewAndCommitChangesAsync(
{ nonInteractive, askedFirstTime = true }: { nonInteractive: boolean; askedFirstTime?: boolean }
): Promise<void> {
if (process.env.EAS_BUILD_AUTOCOMMIT) {
await getVcsClient().commitAsync({
commitMessage: initialCommitMessage,
commitAllFiles: false,
nonInteractive,
});
await vcs.commitAsync({ commitMessage: initialCommitMessage, commitAllFiles: false });
Log.withTick('Committed changes.');
return;
}
@@ -174,7 +166,7 @@ export async function reviewAndCommitChangesAsync(
await commitPromptAsync({ initialCommitMessage });
Log.withTick('Committed changes.');
} else if (selected === ShouldCommitChanges.ShowDiffFirst) {
await getVcsClient().showDiffAsync();
await vcs.showDiffAsync();
await reviewAndCommitChangesAsync(initialCommitMessage, {
nonInteractive,
askedFirstTime: false,
@@ -1,123 +0,0 @@
import { ExpoConfig } from '@expo/config-types';
import { Platform } from '@expo/eas-build-job';
import { BuildProfile, ResourceClass } from '@expo/eas-json';
import chalk from 'chalk';
import semver from 'semver';
import { BuildResourceClass } from '../../graphql/generated';
import Log from '../../log';
import { getReactNativeVersionAsync } from '../metadata';
type AndroidResourceClass = Exclude<
ResourceClass,
| ResourceClass.M1_EXPERIMENTAL
| ResourceClass.M1_MEDIUM
| ResourceClass.M1_LARGE
| ResourceClass.INTEL_MEDIUM
| ResourceClass.M_MEDIUM
| ResourceClass.M_LARGE
>;
const iosResourceClassToBuildResourceClassMapping: Record<ResourceClass, BuildResourceClass> = {
[ResourceClass.DEFAULT]: BuildResourceClass.IosDefault,
[ResourceClass.LARGE]: BuildResourceClass.IosLarge,
[ResourceClass.M1_EXPERIMENTAL]: BuildResourceClass.IosMMedium,
[ResourceClass.M1_MEDIUM]: BuildResourceClass.IosMMedium,
[ResourceClass.M1_LARGE]: BuildResourceClass.IosMLarge,
[ResourceClass.INTEL_MEDIUM]: BuildResourceClass.IosIntelMedium,
[ResourceClass.MEDIUM]: BuildResourceClass.IosMedium,
[ResourceClass.M_MEDIUM]: BuildResourceClass.IosMMedium,
[ResourceClass.M_LARGE]: BuildResourceClass.IosMLarge,
};
const androidResourceClassToBuildResourceClassMapping: Record<
AndroidResourceClass,
BuildResourceClass
> = {
[ResourceClass.DEFAULT]: BuildResourceClass.AndroidDefault,
[ResourceClass.LARGE]: BuildResourceClass.AndroidLarge,
[ResourceClass.MEDIUM]: BuildResourceClass.AndroidMedium,
};
export async function resolveBuildResourceClassAsync<T extends Platform>(
profile: BuildProfile<T>,
platform: Platform,
projectDir: string,
exp: ExpoConfig,
resourceClassFlag?: ResourceClass
): Promise<BuildResourceClass> {
const profileResourceClass = profile.resourceClass;
if (profileResourceClass && resourceClassFlag && resourceClassFlag !== profileResourceClass) {
Log.warn(
`Build profile specifies the "${profileResourceClass}" resource class but you passed "${resourceClassFlag}" to --resource-class.\nUsing the "${resourceClassFlag}" as the override.`
);
}
const selectedResourceClass = resourceClassFlag ?? profileResourceClass;
return platform === Platform.IOS
? await resolveIosResourceClassAsync(exp, projectDir, resourceClassFlag ?? profileResourceClass)
: resolveAndroidResourceClass(selectedResourceClass);
}
function resolveAndroidResourceClass(selectedResourceClass?: ResourceClass): BuildResourceClass {
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`);
}
const resourceClass = selectedResourceClass ?? ResourceClass.DEFAULT;
return androidResourceClassToBuildResourceClassMapping[resourceClass as AndroidResourceClass];
}
async function resolveIosResourceClassAsync(
exp: ExpoConfig,
projectDir: string,
selectedResourceClass?: ResourceClass
): Promise<BuildResourceClass> {
const resourceClass =
selectedResourceClass ?? (await resolveIosDefaultRequestedResourceClassAsync(exp, projectDir));
if ([ResourceClass.M1_EXPERIMENTAL, ResourceClass.M1_MEDIUM].includes(resourceClass)) {
Log.warn(
`Resource class ${chalk.bold(resourceClass)} is deprecated. Use ${chalk.bold(
'm-medium'
)} instead.`
);
}
if (resourceClass === ResourceClass.M1_LARGE) {
Log.warn(
`Resource class ${chalk.bold('m1-large')} is deprecated. Use ${chalk.bold(
'm-large'
)} instead.`
);
}
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;
}
}
@@ -0,0 +1,14 @@
import { ExpoConfig, getPackageJson } from '@expo/config';
export function isExpoUpdatesInstalled(projectDir: string): boolean {
const packageJson = getPackageJson(projectDir);
return !!(packageJson.dependencies && 'expo-updates' in packageJson.dependencies);
}
export function ensureValidVersions(exp: ExpoConfig): void {
if (!exp.runtimeVersion && !exp.sdkVersion) {
throw new Error(
"Couldn't find either 'runtimeVersion' or 'sdkVersion' to configure 'expo-updates'. Please specify at least one of these properties under the 'expo' key in 'app.json'"
);
}
}
+9 -28
View File
@@ -4,31 +4,25 @@ import { getExpoApiBaseUrl, getExpoWebsiteBaseUrl } from '../../api';
import { AppPlatform, BuildFragment } from '../../graphql/generated';
export function getProjectDashboardUrl(accountName: string, projectName: string): string {
return new URL(
`/accounts/${accountName}/projects/${projectName}`,
getExpoWebsiteBaseUrl()
).toString();
return `${getExpoWebsiteBaseUrl()}/accounts/${accountName}/projects/${projectName}`;
}
export function getBuildLogsUrl(build: BuildFragment, hash?: string): string {
export function getBuildLogsUrl(build: BuildFragment): string {
const { project } = build;
const url =
project.__typename !== 'App'
? `/builds/${build.id}`
: `/accounts/${project.ownerAccount.name}/projects/${project.slug}/builds/${build.id}${
hash ? `#${hash}` : ''
}`;
return new URL(url, getExpoWebsiteBaseUrl()).toString();
if (project.__typename === 'App') {
return `${getExpoWebsiteBaseUrl()}/accounts/${project.ownerAccount.name}/builds/${build.id}`;
} else {
return `${getExpoWebsiteBaseUrl()}/builds/${build.id}`;
}
}
export function getArtifactUrl(artifactId: string): string {
return new URL(`/artifacts/${artifactId}`, getExpoWebsiteBaseUrl()).toString();
return `${getExpoWebsiteBaseUrl()}/artifacts/${artifactId}`;
}
export function getInternalDistributionInstallUrl(build: BuildFragment): string {
if (build.platform === AppPlatform.Ios) {
return `itms-services://?action=download-manifest;url=${getExpoApiBaseUrl()}/v2/projects/${
return `itms-services://?action=download-manifest;url=${getExpoApiBaseUrl()}/--/api/v2/projects/${
build.project.id
}/builds/${build.id}/manifest.plist`;
}
@@ -37,16 +31,3 @@ export function getInternalDistributionInstallUrl(build: BuildFragment): string
return build.artifacts.buildUrl;
}
export function getUpdateGroupUrl(
accountName: string,
projectName: string,
updateGroupId: string
): string {
return new URL(
`/accounts/${encodeURIComponent(accountName)}/projects/${encodeURIComponent(
projectName
)}/updates/${encodeURIComponent(updateGroupId)}`,
getExpoWebsiteBaseUrl()
).toString();
}
+7 -144
View File
@@ -1,15 +1,12 @@
import { Platform, Workflow } from '@expo/eas-build-job';
import { Errors } from '@oclif/core';
import fs from 'fs-extra';
import path from 'path';
import semver from 'semver';
import Log, { learnMore } from '../log';
import { isPNGAsync } from '../utils/image';
import { getVcsClient } from '../vcs';
import { CommonContext } from './context';
import vcs from '../vcs';
import { BuildContext } from './context';
export function checkNodeEnvVariable(ctx: CommonContext<Platform>): void {
export function checkNodeEnvVariable(ctx: BuildContext<Platform>): void {
if (ctx.buildProfile.env?.NODE_ENV === 'production') {
Log.warn(
'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.'
@@ -19,7 +16,7 @@ export function checkNodeEnvVariable(ctx: CommonContext<Platform>): void {
}
export async function checkGoogleServicesFileAsync<T extends Platform>(
ctx: CommonContext<T>
ctx: BuildContext<T>
): Promise<void> {
if (ctx.workflow === Workflow.GENERIC || ctx.buildProfile?.env?.GOOGLE_SERVICES_FILE) {
return;
@@ -28,12 +25,12 @@ export async function checkGoogleServicesFileAsync<T extends Platform>(
if (!googleServicesFilePath) {
return;
}
const rootDir = path.normalize(await getVcsClient().getRootPathAsync());
const rootDir = path.normalize(await vcs.getRootPathAsync());
const absGoogleServicesFilePath = path.resolve(ctx.projectDir, googleServicesFilePath);
if (
(await fs.pathExists(absGoogleServicesFilePath)) &&
(!isInsideDirectory(absGoogleServicesFilePath, rootDir) ||
(await getVcsClient().isFileIgnoredAsync(path.relative(rootDir, absGoogleServicesFilePath))))
(await vcs.isFileIgnoredAsync(path.relative(rootDir, absGoogleServicesFilePath))))
) {
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.`
@@ -44,7 +41,7 @@ export async function checkGoogleServicesFileAsync<T extends Platform>(
)}`
);
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.`
'If you are using that file for compatibility with the classic build service (expo build) you can silence this warning by setting GOOGLE_SERVICES_FILE in your build profile in eas.json to any non-falsy value.'
);
Log.newLine();
}
@@ -53,137 +50,3 @@ export async function checkGoogleServicesFileAsync<T extends Platform>(
function isInsideDirectory(file: string, directory: string): boolean {
return file.startsWith(directory);
}
export async function validatePNGsForManagedProjectAsync<T extends Platform>(
ctx: CommonContext<T>
): Promise<void> {
if (ctx.workflow !== Workflow.MANAGED) {
return;
}
// don't run PNG checks on SDK 47 and newer
// see https://github.com/expo/eas-cli/pull/1477#issuecomment-1293914917
if (!ctx.exp.sdkVersion || semver.satisfies(ctx.exp.sdkVersion, '>= 47.0.0')) {
return;
}
if (ctx.platform === Platform.ANDROID) {
await validateAndroidPNGsAsync(ctx as CommonContext<Platform.ANDROID>);
}
// Validating iOS PNGs is currently disabled
// See https://github.com/expo/eas-cli/pull/1477 for context
//
// else {
// await validateIosPNGsAsync(ctx as CommonContext<Platform.IOS>);
// }
}
type ConfigPng = { configPath: string; pngPath: string | undefined };
async function validateAndroidPNGsAsync(ctx: CommonContext<Platform.ANDROID>): Promise<void> {
const pngs: ConfigPng[] = [
{
configPath: 'exp.icon',
pngPath: ctx.exp.icon,
},
{
configPath: 'exp.android.icon',
pngPath: ctx.exp.android?.icon,
},
{
configPath: 'exp.android.adaptiveIcon.foregroundImage',
pngPath: ctx.exp.android?.adaptiveIcon?.foregroundImage,
},
{
configPath: 'exp.android.adaptiveIcon.backgroundImage',
pngPath: ctx.exp.android?.adaptiveIcon?.backgroundImage,
},
{
configPath: 'exp.splash.image',
pngPath: ctx.exp.splash?.image,
},
{
configPath: 'exp.notification.icon',
pngPath: ctx.exp.notification?.icon,
},
];
await validatePNGsAsync(pngs);
}
// Validating iOS PNGs is currently disabled
// See https://github.com/expo/eas-cli/pull/1477 for context
//
// async function validateIosPNGsAsync(ctx: CommonContext<Platform.IOS>): Promise<void> {
// const pngs: ConfigPng[] = [
// {
// configPath: 'exp.icon',
// pngPath: ctx.exp.icon,
// },
// {
// configPath: 'exp.ios.icon',
// pngPath: ctx.exp.ios?.icon,
// },
// {
// configPath: 'exp.splash.image',
// pngPath: ctx.exp.splash?.image,
// },
// {
// configPath: 'exp.notification.icon',
// pngPath: ctx.exp.notification?.icon,
// },
// ];
// await validatePNGsAsync(pngs);
// const icon = ctx.exp.ios?.icon ?? ctx.exp.icon;
// if (!icon) {
// return;
// }
// const iconConfigPath = `expo${ctx.exp.ios?.icon ? '.ios' : ''}.icon`;
// try {
// await ensurePNGIsNotTransparentAsync(icon);
// } catch (err: any) {
// if (err instanceof ImageTransparencyError) {
// Log.error(
// `Your iOS app icon (${iconConfigPath}) can't have transparency if you wish to upload your app to the Apple App Store.`
// );
// Log.error(learnMore('https://expo.fyi/remove-alpha-channel', { dim: false }));
// Errors.exit(1);
// } else {
// throw err;
// }
// }
// }
async function validatePNGsAsync(configPngs: ConfigPng[]): Promise<void> {
const validationPromises = configPngs.map(configPng => validatePNGAsync(configPng));
const validationResults = await Promise.allSettled(validationPromises);
const failedValidations = validationResults.filter(
(result): result is PromiseRejectedResult => result.status === 'rejected'
);
if (failedValidations.length === 0) {
return;
}
Log.error('PNG images validation failed:');
for (const { reason } of failedValidations) {
const error: Error = reason;
Log.error(`- ${error.message}`);
}
Errors.exit(1);
}
async function validatePNGAsync({ configPath, pngPath }: ConfigPng): Promise<void> {
if (!pngPath) {
return;
}
if (!pngPath.endsWith('.png')) {
throw new Error(`"${configPath}" is not a PNG file`);
}
if (!(await isPNGAsync(pngPath))) {
throw new Error(`"${configPath}" is not valid PNG`);
}
}
Loaded 100 of 695 files, more files were not shown because too many files have changed in this diff. Show more