mirror of
https://github.com/expo/eas-cli.git
synced 2026-11-13 20:28:08 -10:00
Compare commits
293
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a7f002fb47 | ||
|
|
04049a8d1f | ||
|
|
5bdd46a2a9 | ||
|
|
a7e88bd7cb | ||
|
|
78b75c6224 | ||
|
|
52f54fc7ae | ||
|
|
7ddd630cfd | ||
|
|
8751a2835f | ||
|
|
da9829a853 | ||
|
|
cc822b18f3 | ||
|
|
bcab3bcb09 | ||
|
|
a5dc0bd27c | ||
|
|
54127c4e37 | ||
|
|
9097ab2537 | ||
|
|
4e2029a5c5 | ||
|
|
ad02a056d7 | ||
|
|
ea9f734b11 | ||
|
|
7db9d79c55 | ||
|
|
2e31b42b76 | ||
|
|
66d02d9724 | ||
|
|
7c96cc10cf | ||
|
|
523a781ba5 | ||
|
|
6cf8bd5c5a | ||
|
|
67e9ea706b | ||
|
|
c22f410889 | ||
|
|
9ff47af4fa | ||
|
|
324dce8fc0 | ||
|
|
b30c86f0c0 | ||
|
|
008279e1dd | ||
|
|
d00814f84b | ||
|
|
81aa127e28 | ||
|
|
d321a0bccb | ||
|
|
a1d86549ed | ||
|
|
f7e9af913c | ||
|
|
137f9d4ac7 | ||
|
|
2ac863a9e3 | ||
|
|
11be9eba49 | ||
|
|
b36db15601 | ||
|
|
93c5dc1a32 | ||
|
|
c86550c0c8 | ||
|
|
36b4296d3c | ||
|
|
8c08541ca3 | ||
|
|
5a1406d39c | ||
|
|
78ea88cc5f | ||
|
|
6cb02d0ccd | ||
|
|
d6c7623718 | ||
|
|
32142b4fe8 | ||
|
|
6bdee0f395 | ||
|
|
baf94a21c3 | ||
|
|
162d64bce6 | ||
|
|
d16c1fc757 | ||
|
|
e42ea7e2ec | ||
|
|
5fea1ad16e | ||
|
|
2a57a2eb3d | ||
|
|
5628b78523 | ||
|
|
19d1e6bffd | ||
|
|
88b068511c | ||
|
|
842efcf3ab | ||
|
|
f5f08aa506 | ||
|
|
9f7f412cfb | ||
|
|
e369ec1e25 | ||
|
|
b353441f31 | ||
|
|
4db35e73c6 | ||
|
|
10bc872dc9 | ||
|
|
fedc39ea48 | ||
|
|
6046723769 | ||
|
|
afaee9df38 | ||
|
|
de39d6f00d | ||
|
|
401751fffa | ||
|
|
7f7f1c6a9a | ||
|
|
279d6716d2 | ||
|
|
92977d9a53 | ||
|
|
31871a9eda | ||
|
|
f122f42149 | ||
|
|
a9a49d6b97 | ||
|
|
1d94414334 | ||
|
|
0b73dc32f6 | ||
|
|
ef8ad24639 | ||
|
|
24dbe6e7cc | ||
|
|
aad026febb | ||
|
|
6ae8125c0e | ||
|
|
87d143b310 | ||
|
|
346dee9ea5 | ||
|
|
4308d21927 | ||
|
|
207f623338 | ||
|
|
601101844c | ||
|
|
7027eaa974 | ||
|
|
b3203345ea | ||
|
|
5ed2d01146 | ||
|
|
6fc64d337f | ||
|
|
f7ed4cfa20 | ||
|
|
47968a1b8b | ||
|
|
7c779b3dc0 | ||
|
|
50474ec5e9 | ||
|
|
46ff97f173 | ||
|
|
b263dffbde | ||
|
|
8d57828f7e | ||
|
|
fd9561cfc3 | ||
|
|
93ce451bc7 | ||
|
|
554e433548 | ||
|
|
12a1cb7011 | ||
|
|
360de90eea | ||
|
|
ea02fcebda | ||
|
|
0b5194b1a5 | ||
|
|
05799aba9c | ||
|
|
2cece65dbf | ||
|
|
17b6b4ec45 | ||
|
|
4f892c66e7 | ||
|
|
fd99365655 | ||
|
|
7dca2c8021 | ||
|
|
55bce8d82a | ||
|
|
1f5533e5e3 | ||
|
|
51d0555baa | ||
|
|
2137176a65 | ||
|
|
3fda52c8d7 | ||
|
|
b252b5b582 | ||
|
|
2befe410cf | ||
|
|
1463e0de74 | ||
|
|
e963558ef6 | ||
|
|
fe57c055b6 | ||
|
|
045840111e | ||
|
|
9a6068c1f2 | ||
|
|
d06a77bd6d | ||
|
|
7bcf83d141 | ||
|
|
cd9323e115 | ||
|
|
34145c38ca | ||
|
|
57dd372957 | ||
|
|
5232641217 | ||
|
|
3c3d3150c9 | ||
|
|
fd322d4189 | ||
|
|
77254f6be1 | ||
|
|
44fcbbaf17 | ||
|
|
db841b45ee | ||
|
|
568669d384 | ||
|
|
aa50bf11ce | ||
|
|
119133360b | ||
|
|
3f0f25dd46 | ||
|
|
e821ae95e1 | ||
|
|
f4e51b4fbc | ||
|
|
963bea9b03 | ||
|
|
9bf2020b2b | ||
|
|
50e695b5a3 | ||
|
|
f3577deac4 | ||
|
|
f11c0b4d34 | ||
|
|
88411e8fc0 | ||
|
|
c199173704 | ||
|
|
800098b958 | ||
|
|
146468914f | ||
|
|
0e48417983 | ||
|
|
cf61f585e6 | ||
|
|
cc700ae263 | ||
|
|
4e94c60c7f | ||
|
|
37f8e117e1 | ||
|
|
46095d2da4 | ||
|
|
6c01856617 | ||
|
|
f8cd50e0ed | ||
|
|
fd0a129a4f | ||
|
|
c44e07bfeb | ||
|
|
951583e6ea | ||
|
|
3f9247ca84 | ||
|
|
c38638e94e | ||
|
|
3b46466a9d | ||
|
|
806085cbf1 | ||
|
|
398b41cc81 | ||
|
|
d23a97238b | ||
|
|
5b6f312ccb | ||
|
|
2bcab0f2b4 | ||
|
|
b90ce63067 | ||
|
|
be02aced9d | ||
|
|
f788b081c6 | ||
|
|
654f973d0e | ||
|
|
2e614cbde9 | ||
|
|
b7150163d9 | ||
|
|
0922121e84 | ||
|
|
4ace7d20fb | ||
|
|
4ae9aed8f6 | ||
|
|
54d6079406 | ||
|
|
f08adad1e7 | ||
|
|
346e47448a | ||
|
|
b0fde7b465 | ||
|
|
d42efadf51 | ||
|
|
e302390324 | ||
|
|
3a239f8cf9 | ||
|
|
b5bc8a8f6e | ||
|
|
6cbff89b75 | ||
|
|
118c008917 | ||
|
|
3b24d37f3c | ||
|
|
df425201b3 | ||
|
|
9636507130 | ||
|
|
2dd50ad21a | ||
|
|
30441b3db4 | ||
|
|
789c314a8d | ||
|
|
4760314ddf | ||
|
|
c8e57f000c | ||
|
|
3c82a2f8a8 | ||
|
|
0e7e926cec | ||
|
|
30364fd679 | ||
|
|
267fa0e43b | ||
|
|
e154f19751 | ||
|
|
4bca79f828 | ||
|
|
7e01661f70 | ||
|
|
ab18e3e27e | ||
|
|
3e7a112097 | ||
|
|
4cd6b8d228 | ||
|
|
400244194b | ||
|
|
dcf3a2d4fd | ||
|
|
0464ac27cd | ||
|
|
e0d2126f0a | ||
|
|
bd46da748d | ||
|
|
d8c68561c5 | ||
|
|
dfc314d5be | ||
|
|
2285d5771a | ||
|
|
aa22163688 | ||
|
|
5e5395b0c6 | ||
|
|
c94a1e7d9c | ||
|
|
aefadf9376 | ||
|
|
7acea869ff | ||
|
|
02af3f587f | ||
|
|
0d0da1a47a | ||
|
|
eec0b88258 | ||
|
|
98be401942 | ||
|
|
8c2689a27b | ||
|
|
1b34118d85 | ||
|
|
0f75fe407e | ||
|
|
efb7e534f4 | ||
|
|
53f89c0df1 | ||
|
|
1bbe7dfb72 | ||
|
|
da4b56a58d | ||
|
|
1080600af6 | ||
|
|
dcb6713f12 | ||
|
|
24a1d2d581 | ||
|
|
8af88c2d29 | ||
|
|
d416ac2162 | ||
|
|
7bf0493a4d | ||
|
|
91f32cc377 | ||
|
|
cf54f62bac | ||
|
|
3ef3e8447c | ||
|
|
ad64e1afad | ||
|
|
a0800cf7ef | ||
|
|
34436ee674 | ||
|
|
1072b75927 | ||
|
|
c0c9d2e7df | ||
|
|
19b831667f | ||
|
|
09db7da23b | ||
|
|
f08dde2469 | ||
|
|
213b5d240c | ||
|
|
16ebdce913 | ||
|
|
121ff7a718 | ||
|
|
360393f647 | ||
|
|
82d66054dc | ||
|
|
d8849dbf8c | ||
|
|
4d6b5f435b | ||
|
|
8fe7dfea29 | ||
|
|
8750155c25 | ||
|
|
a0fce1c773 | ||
|
|
cad3dca5df | ||
|
|
2ef6e79395 | ||
|
|
44270814a7 | ||
|
|
95155681db | ||
|
|
53fd599d0a | ||
|
|
b51201d010 | ||
|
|
f86c079733 | ||
|
|
843ec7407b | ||
|
|
537cd7872a | ||
|
|
007c40bed4 | ||
|
|
ba9f8d8ddd | ||
|
|
e4bd933e81 | ||
|
|
a6434bf6b8 | ||
|
|
19bad84109 | ||
|
|
5d9f8f9d76 | ||
|
|
d9d884d211 | ||
|
|
f902bb845b | ||
|
|
03c76c1e0e | ||
|
|
1d0d329cdb | ||
|
|
7ed1efb7d6 | ||
|
|
b7bfec0366 | ||
|
|
a5308f0d37 | ||
|
|
3ad644bb9d | ||
|
|
233e5451a8 | ||
|
|
ce954cdecc | ||
|
|
59bd1caf98 | ||
|
|
86877fdda3 | ||
|
|
9299c9b7cc | ||
|
|
64fb2e5b3a | ||
|
|
91fdbac773 | ||
|
|
507eadb914 | ||
|
|
f1cde7a291 | ||
|
|
8b9ad47d16 | ||
|
|
192984b47d | ||
|
|
2c36810ce7 | ||
|
|
bfa63f95fe | ||
|
|
c414b9f975 | ||
|
|
6946a085cb |
No files matched your search
@@ -37,7 +37,7 @@ body:
|
||||
required: true
|
||||
attributes:
|
||||
label: Environment
|
||||
description: Run `npx expo-env-info` and `expo doctor`, and paste the output here.
|
||||
description: Run `npx expo-env-info` and `npx expo-doctor`, and paste the output here.
|
||||
- type: textarea
|
||||
validations:
|
||||
required: false
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: Expo Developers Discord
|
||||
url: https://chat.expo.dev/
|
||||
about: Join other developers in discussions regarding Expo and EAS.
|
||||
@@ -1,8 +1,5 @@
|
||||
<!-- Thanks for contributing to _EAS CLI_! Before you submit your pull request, please make sure to check the following boxes by putting an x in the [ ] (don't: [x ], [ x], do: [x]) -->
|
||||
|
||||
# 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.
|
||||
<!-- If this PR requires a changelog entry, add it by commenting the PR with the command `/changelog-entry [breaking-change|new-feature|bug-fix|chore] [message]`. -->
|
||||
<!-- You can skip the changelog check by labeling the PR with "no changelog". -->
|
||||
|
||||
# Why
|
||||
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"printWidth": 100,
|
||||
"singleQuote": true
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
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: ''
|
||||
@@ -0,0 +1,155 @@
|
||||
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' };
|
||||
};
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
# 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==
|
||||
@@ -0,0 +1,48 @@
|
||||
name: Changelog bot
|
||||
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
|
||||
jobs:
|
||||
pr-commented:
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ github.event.issue.pull_request && github.event.issue.user.login == github.event.comment.user.login && startsWith(github.event.comment.body, '/changelog-entry ') }}
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
with:
|
||||
token: ${{ secrets.EXPO_BOT_PAT }}
|
||||
- uses: actions/setup-node@v2
|
||||
with:
|
||||
node-version: 18
|
||||
- name: Checkout Pull Request
|
||||
run: hub pr checkout ${{ github.event.issue.number }}
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Install dependencies
|
||||
working-directory: ./scripts
|
||||
run: yarn install --frozen-lockfile --check-files
|
||||
- name: Add changelog entry
|
||||
working-directory: ./scripts
|
||||
run: |
|
||||
PARAMS=${PR_COMMENT_BODY#/changelog-entry }
|
||||
if [[ ! "$PARAMS" =~ " " ]]; then
|
||||
echo "Invalid /changelog-entry command"
|
||||
exit 1
|
||||
fi
|
||||
CATEGORY=$( cut -d ' ' -f 1 <<< "$PARAMS" | sed -e 's/^[[:space:]]*//' )
|
||||
MESSAGE=$( cut -d ' ' -f 2- <<< "$PARAMS" | sed -e 's/^[[:space:]]*//' )
|
||||
yarn changelog-entry "$CATEGORY" "$MESSAGE"
|
||||
env:
|
||||
GITHUB_PR_AUTHOR: ${{ github.event.issue.user.login }}
|
||||
GITHUB_PR_NUMBER: ${{ github.event.issue.number }}
|
||||
PR_COMMENT_BODY: ${{ github.event.comment.body }}
|
||||
- name: Commit and push
|
||||
run: |
|
||||
# use the last commit author name and email
|
||||
git config --global user.name "$(git --no-pager log --format=format:'%an' -n 1)"
|
||||
git config --global user.email "$(git --no-pager log --format=format:'%ae' -n 1)"
|
||||
|
||||
git add CHANGELOG.md
|
||||
git commit -m "update CHANGELOG.md"
|
||||
git push
|
||||
@@ -1,48 +1,37 @@
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
name: CHANGELOG.md PR check
|
||||
|
||||
name: Changelog
|
||||
on:
|
||||
pull_request:
|
||||
types:
|
||||
- opened
|
||||
- synchronize
|
||||
- labeled
|
||||
- unlabeled
|
||||
|
||||
jobs:
|
||||
pr-commented:
|
||||
pr-changelog-entry:
|
||||
name: Check CHANGELOG.md updated
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ github.event.issue.pull_request && github.event.issue.user.login == github.event.comment.user.login && startsWith(github.event.comment.body, '/changelog-entry ') }}
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Check if CHANGELOG.md was updated
|
||||
id: changelog-updated
|
||||
uses: tj-actions/changed-files@v35
|
||||
with:
|
||||
token: ${{ secrets.EXPO_BOT_PAT }}
|
||||
- uses: actions/setup-node@v2
|
||||
files: CHANGELOG.md
|
||||
- name: Fail if CHANGELOG.md was not updated and the "no changelog" label is absent
|
||||
if: steps.changelog-updated.outputs.any_changed == 'false' && !contains(github.event.pull_request.labels.*.name, 'no changelog')
|
||||
uses: actions/github-script@v6
|
||||
with:
|
||||
node-version: 18
|
||||
- name: Checkout Pull Request
|
||||
run: hub pr checkout ${{ github.event.issue.number }}
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Install dependencies
|
||||
working-directory: ./scripts
|
||||
run: yarn install --frozen-lockfile --check-files
|
||||
- name: Add changelog entry
|
||||
working-directory: ./scripts
|
||||
run: |
|
||||
PARAMS=${PR_COMMENT_BODY#/changelog-entry }
|
||||
if [[ ! "$PARAMS" =~ " " ]]; then
|
||||
echo "Invalid /changelog-entry command"
|
||||
exit 1
|
||||
fi
|
||||
CATEGORY=$( cut -d ' ' -f 1 <<< "$PARAMS" | sed -e 's/^[[:space:]]*//' )
|
||||
MESSAGE=$( cut -d ' ' -f 2- <<< "$PARAMS" | sed -e 's/^[[:space:]]*//' )
|
||||
yarn changelog-entry "$CATEGORY" "$MESSAGE"
|
||||
env:
|
||||
GITHUB_PR_AUTHOR: ${{ github.event.issue.user.login }}
|
||||
GITHUB_PR_NUMBER: ${{ github.event.issue.number }}
|
||||
PR_COMMENT_BODY: ${{ github.event.comment.body }}
|
||||
- name: Commit and push
|
||||
run: |
|
||||
# use the last commit author name and email
|
||||
git config --global user.name "$(git --no-pager log --format=format:'%an' -n 1)"
|
||||
git config --global user.email "$(git --no-pager log --format=format:'%ae' -n 1)"
|
||||
|
||||
git add CHANGELOG.md
|
||||
git commit -m "update CHANGELOG.md"
|
||||
git push
|
||||
script: |
|
||||
core.setFailed('Please add a changelog entry!')
|
||||
- uses: mshick/[email protected]
|
||||
if: always()
|
||||
with:
|
||||
message-id: changelog-entry-check
|
||||
refresh-message-position: true
|
||||
message: ${{ contains(github.event.pull_request.labels.*.name, 'no changelog') && '⏩ The changelog entry check has been skipped since the "no changelog" label is present.' || '✅ Thank you for adding the changelog entry!' }}
|
||||
message-failure: |
|
||||
❌ It looks like a changelog entry is missing for this PR. You have two options: you can add it manually, or you can use the changelog bot to do it for you.
|
||||
🤖 To use the bot, simply comment on this PR with the command `/changelog-entry [breaking-change|new-feature|bug-fix|chore] [message]`.
|
||||
⏩ If this PR doesn't require a changelog entry, such as if it's an internal change that doesn't affect the user experience, you can add the "no changelog" label to the PR.
|
||||
@@ -29,7 +29,7 @@ jobs:
|
||||
with:
|
||||
node-version: 18
|
||||
- run: yarn install --frozen-lockfile --check-files
|
||||
- run: yarn build
|
||||
- run: yarn build-ci
|
||||
- name: Build tarballs
|
||||
working-directory: ${{ github.workspace }}/packages/eas-cli
|
||||
run: |
|
||||
@@ -70,7 +70,7 @@ jobs:
|
||||
with:
|
||||
node-version: 18
|
||||
- run: yarn install --frozen-lockfile --check-files
|
||||
- run: yarn build
|
||||
- run: yarn build-ci
|
||||
- name: Build tarballs
|
||||
working-directory: ${{ github.workspace }}/packages/eas-cli
|
||||
run: |
|
||||
@@ -99,7 +99,7 @@ jobs:
|
||||
node-version: 18
|
||||
- run: yarn install --frozen-lockfile --check-files
|
||||
- run: sudo apt-get install nsis
|
||||
- run: yarn build
|
||||
- run: yarn build-ci
|
||||
- run: |
|
||||
yarn pretarball-ci
|
||||
yarn oclif pack:win
|
||||
@@ -142,7 +142,7 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: yarn install --frozen-lockfile --check-files
|
||||
- name: Build
|
||||
run: yarn build
|
||||
run: yarn build-ci
|
||||
- name: Publish packages to npm
|
||||
run: yarn lerna publish from-package --yes --no-verify-access
|
||||
env:
|
||||
@@ -161,6 +161,8 @@ jobs:
|
||||
needs: publish-to-npm
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
with:
|
||||
token: ${{ secrets.EXPO_BOT_PAT }}
|
||||
- uses: actions/setup-node@v2
|
||||
with:
|
||||
node-version: 18
|
||||
@@ -197,9 +199,9 @@ jobs:
|
||||
env:
|
||||
SLACK_CHANNEL: eas-cli
|
||||
SLACK_COLOR: ${{ job.status }}
|
||||
SLACK_ICON: https://github.com/expo-ci.png?size=48
|
||||
SLACK_ICON: https://github.com/expo-bot.png?size=48
|
||||
SLACK_TITLE: 'Released eas-cli@${{ env.EAS_CLI_VERSION }}'
|
||||
SLACK_MESSAGE: ${{ env.SLACK_CHANGELOG }}
|
||||
SLACK_USERNAME: 'Expo CI'
|
||||
SLACK_USERNAME: 'EAS CLI CI'
|
||||
SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }}
|
||||
SLACK_FOOTER: ''
|
||||
@@ -1,4 +1,5 @@
|
||||
name: Run tests
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
@@ -25,17 +26,32 @@ jobs:
|
||||
- run: yarn typecheck
|
||||
- run: yarn test
|
||||
if: ${{ !matrix.coverage }}
|
||||
env:
|
||||
NODE_OPTIONS: --max-old-space-size=8192
|
||||
- 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
|
||||
NODE_OPTIONS: --max-old-space-size=8192
|
||||
- run: yarn lint --max-warnings=0
|
||||
- run: yarn install --frozen-lockfile --check-files
|
||||
working-directory: ./scripts
|
||||
- run: yarn lint-changelog
|
||||
- uses: codecov/codecov-action@v1
|
||||
if: ${{ matrix.coverage }}
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
- run: yarn lint-changelog
|
||||
|
||||
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 }}
|
||||
+461
@@ -8,8 +8,467 @@ This is the log of notable changes to EAS CLI and related packages.
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
## [5.3.0](https://github.com/expo/eas-cli/releases/tag/v5.3.0) - 2023-09-25
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- Update: expose expo cli `--dev` flag as an argument. ([#2050](https://github.com/expo/eas-cli/pull/2050) by [@nderscore](https://github.com/nderscore))
|
||||
- Support `bun` option in eas.json. ([#2055](https://github.com/expo/eas-cli/pull/2055) by [@kadikraman](https://github.com/kadikraman))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- Support node aliases in .nvmrc. ([#2052](https://github.com/expo/eas-cli/pull/2052) by [@khamilowicz](https://github.com/khamilowicz))
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
- Rollouts: more robust printing function. ([#2047](https://github.com/expo/eas-cli/pull/2047) by [@quinlanj](https://github.com/quinlanj))
|
||||
|
||||
## [5.2.0](https://github.com/expo/eas-cli/releases/tag/v5.2.0) - 2023-09-05
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- Rollouts: json output for ci. ([#2037](https://github.com/expo/eas-cli/pull/2037) by [@quinlanj](https://github.com/quinlanj))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- Update: only print channel-branch pairing if we created a channel. ([#2036](https://github.com/expo/eas-cli/pull/2036) by [@quinlanj](https://github.com/quinlanj))
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
- Added rollout tests. ([#2042](https://github.com/expo/eas-cli/pull/2042) by [@quinlanj](https://github.com/quinlanj))
|
||||
- Remove unreachable codesigning option. ([#2041](https://github.com/expo/eas-cli/pull/2041) by [@quinlanj](https://github.com/quinlanj))
|
||||
- Fix generated graphql tsc errors. ([#2039](https://github.com/expo/eas-cli/pull/2039) by [@quinlanj](https://github.com/quinlanj))
|
||||
- Rollouts: view action for CI. ([#2040](https://github.com/expo/eas-cli/pull/2040) by [@quinlanj](https://github.com/quinlanj))
|
||||
|
||||
## [5.1.0](https://github.com/expo/eas-cli/releases/tag/v5.1.0) - 2023-09-01
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- Support `pnpm` option in eas.json. ([#1988](https://github.com/expo/eas-cli/pull/1988) by [@khamilowicz](https://github.com/khamilowicz))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- Make app config error not repeat indefinitely. ([#2020](https://github.com/expo/eas-cli/pull/2020) by [@radoslawkrzemien](https://github.com/radoslawkrzemien))
|
||||
|
||||
## [5.0.2](https://github.com/expo/eas-cli/releases/tag/v5.0.2) - 2023-08-29
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- Add proper expo cli `--platform` flag handling when exporting updates. ([#1939](https://github.com/expo/eas-cli/pull/1939) by [@byCedric](https://github.com/byCedric))
|
||||
|
||||
## [5.0.1](https://github.com/expo/eas-cli/releases/tag/v5.0.1) - 2023-08-28
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- Pass `platform` argument to expo-cli correctly when using the `eas update` command. ([#2028](https://github.com/expo/eas-cli/pull/2028) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
|
||||
## [5.0.0](https://github.com/expo/eas-cli/releases/tag/v5.0.0) - 2023-08-28
|
||||
|
||||
### 🛠 Breaking changes
|
||||
|
||||
- Only export at most ios and android dist for EAS updates. ([#2002](https://github.com/expo/eas-cli/pull/2002) by [@wschurman](https://github.com/wschurman))
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- Add rollback disambiguation command. ([#2004](https://github.com/expo/eas-cli/pull/2004) by [@wschurman](https://github.com/wschurman))
|
||||
- Detect devices that fail to be provisioned, list them to the user and show the explanation message with the link to the devices page to check actual status. ([#2011](https://github.com/expo/eas-cli/pull/2011) by [@radoslawkrzemien](https://github.com/radoslawkrzemien))
|
||||
- Add info to EAS Update asset upload process about asset counts and limits. ([#2013](https://github.com/expo/eas-cli/pull/2013) by [@wschurman](https://github.com/wschurman))
|
||||
- .nvmrc support for setting node version. ([#1954](https://github.com/expo/eas-cli/pull/1954) by [@khamilowicz](https://github.com/khamilowicz))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- Support republishing roll back to embedded updates. ([#2006](https://github.com/expo/eas-cli/pull/2006) by [@wschurman](https://github.com/wschurman))
|
||||
- Configure updates as well when somebody tries to run a build with channel set. ([#2016](https://github.com/expo/eas-cli/pull/2016) by [@wschurman](https://github.com/wschurman))
|
||||
- Fix printing bug: branch with no update. ([#2023](https://github.com/expo/eas-cli/pull/2023) by [@quinlanj](https://github.com/quinlanj))
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
- More branch map utility functions. ([#2001](https://github.com/expo/eas-cli/pull/2001) by [@quinlanj](https://github.com/quinlanj))
|
||||
- More branch mapping utils. ([#2003](https://github.com/expo/eas-cli/pull/2003) by [@quinlanj](https://github.com/quinlanj))
|
||||
- Create a linked channel on build if not exists. ([#2017](https://github.com/expo/eas-cli/pull/2017) by [@quinlanj](https://github.com/quinlanj))
|
||||
- Add `developmentClient` to metadata. ([#2015](https://github.com/expo/eas-cli/pull/2015) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
|
||||
## [4.1.2](https://github.com/expo/eas-cli/releases/tag/v4.1.2) - 2023-08-10
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
- Make branch mapping utility files have no dependencies. ([#1993](https://github.com/expo/eas-cli/pull/1993) by [@quinlanj](https://github.com/quinlanj))
|
||||
|
||||
## [4.1.1](https://github.com/expo/eas-cli/releases/tag/v4.1.1) - 2023-08-08
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
- Logger to say website support is coming soon for rollouts. ([#1997](https://github.com/expo/eas-cli/pull/1997) by [@quinlanj](https://github.com/quinlanj))
|
||||
|
||||
## [4.1.0](https://github.com/expo/eas-cli/releases/tag/v4.1.0) - 2023-08-08
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- Pass credentials to custom iOS builds. ([#1989](https://github.com/expo/eas-cli/pull/1989) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
- Add the `withoutCredentials` option as a common build profile field in `eas.json`. ([#1994](https://github.com/expo/eas-cli/pull/1994) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
|
||||
## [4.0.1](https://github.com/expo/eas-cli/releases/tag/v4.0.1) - 2023-08-08
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- Fix `eas channel:*` to work with empty branch mappings. ([#1992](https://github.com/expo/eas-cli/pull/1992) by [@quinlanj](https://github.com/quinlanj))
|
||||
|
||||
## [4.0.0](https://github.com/expo/eas-cli/releases/tag/v4.0.0) - 2023-08-07
|
||||
|
||||
### 🛠 Breaking changes
|
||||
|
||||
- Release redesigned `eas channel:rollout` into developer preview. The set of flag arguments are different, in addition to the workflow. ([#1986](https://github.com/expo/eas-cli/pull/1986) by [@quinlanj](https://github.com/quinlanj))
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- Option to add current Apple Silicon device without the need to manually provide the provisioning UDID. ([#1943](https://github.com/expo/eas-cli/pull/1943) by [@radoslawkrzemien](https://github.com/radoslawkrzemien))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- Fix rollout-preview ending with republish with code signing. ([#1978](https://github.com/expo/eas-cli/pull/1978) by [@wschurman](https://github.com/wschurman))
|
||||
- Rollouts: fix fp precision. ([#1985](https://github.com/expo/eas-cli/pull/1985) by [@quinlanj](https://github.com/quinlanj))
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
- Fixing more grammar errors. ([#1980](https://github.com/expo/eas-cli/pull/1980) by [@quinlanj](https://github.com/quinlanj))
|
||||
- Handle rollout edit edge case. ([#1981](https://github.com/expo/eas-cli/pull/1981) by [@quinlanj](https://github.com/quinlanj))
|
||||
- Linked expo.fyi rollout article. ([#1991](https://github.com/expo/eas-cli/pull/1991) by [@quinlanj](https://github.com/quinlanj))
|
||||
- Rollout ux improvements. ([#1984](https://github.com/expo/eas-cli/pull/1984) by [@quinlanj](https://github.com/quinlanj))
|
||||
|
||||
## [3.18.3](https://github.com/expo/eas-cli/releases/tag/v3.18.3) - 2023-08-03
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- Fix republishing with code signing. ([#1973](https://github.com/expo/eas-cli/pull/1973) by [@wschurman](https://github.com/wschurman))
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
- Add relationships flag to rollouts-preview. ([#1972](https://github.com/expo/eas-cli/pull/1972) by [@quinlanj](https://github.com/quinlanj))
|
||||
- Get channel:{view,list,edit} to play nice with rollouts. ([#1974](https://github.com/expo/eas-cli/pull/1974) by [@quinlanj](https://github.com/quinlanj))
|
||||
- Use 'roll out' instead of rollout for verbs. ([#1979](https://github.com/expo/eas-cli/pull/1979) by [@quinlanj](https://github.com/quinlanj))
|
||||
|
||||
## [3.18.2](https://github.com/expo/eas-cli/releases/tag/v3.18.2) - 2023-08-03
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- Revert adding `.nvmrc` support for setting node version. ([#1976](https://github.com/expo/eas-cli/pull/1976) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
- Use just a comma instead of `, ` when concatenating `keywords` in `eas metadata`. ([#1967](https://github.com/expo/eas-cli/pull/1967) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
|
||||
## [3.18.1](https://github.com/expo/eas-cli/releases/tag/v3.18.1) - 2023-08-03
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- Pass correct group into `update:republish`. ([#1971](https://github.com/expo/eas-cli/pull/1971) by [@quinlanj](https://github.com/quinlanj))
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
- Make new rollouts version available for internal dogfooding. ([#1966](https://github.com/expo/eas-cli/pull/1966) by [@quinlanj](https://github.com/quinlanj))
|
||||
- Change default runtime version policy for EAS Update to appVersion. ([#1968](https://github.com/expo/eas-cli/pull/1968) by [@quinlanj](https://github.com/quinlanj))
|
||||
|
||||
## [3.18.0](https://github.com/expo/eas-cli/releases/tag/v3.18.0) - 2023-08-02
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- .nvmrc support for setting node version. ([#1954](https://github.com/expo/eas-cli/pull/1954) by [@khamilowicz](https://github.com/khamilowicz))
|
||||
- Provide credentials for custom Android builds. ([#1969](https://github.com/expo/eas-cli/pull/1969) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
- More branch mapping utility. ([#1957](https://github.com/expo/eas-cli/pull/1957) by [@quinlanj](https://github.com/quinlanj))
|
||||
- Utility classes to select existing rollouts and channels. ([#1958](https://github.com/expo/eas-cli/pull/1958) by [@quinlanj](https://github.com/quinlanj))
|
||||
|
||||
## [3.17.1](https://github.com/expo/eas-cli/releases/tag/v3.17.1) - 2023-07-27
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
- Unify channel graphql query types. ([#1949](https://github.com/expo/eas-cli/pull/1949) by [@quinlanj](https://github.com/quinlanj))
|
||||
- Revert UpdateBranchWithCurrentGroupFragment. ([#1952](https://github.com/expo/eas-cli/pull/1952) by [@quinlanj](https://github.com/quinlanj))
|
||||
- Fetch entire relay compliant dataset. ([#1953](https://github.com/expo/eas-cli/pull/1953) by [@quinlanj](https://github.com/quinlanj))
|
||||
|
||||
## [3.17.0](https://github.com/expo/eas-cli/releases/tag/v3.17.0) - 2023-07-24
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- Add `rollout` option for configuring Android submissions. ([#1938](https://github.com/expo/eas-cli/pull/1938) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
- Added branch mapping utility functions. ([#1944](https://github.com/expo/eas-cli/pull/1944) by [@quinlanj](https://github.com/quinlanj))
|
||||
- Amend branch mapping utility functions. ([#1945](https://github.com/expo/eas-cli/pull/1945) by [@quinlanj](https://github.com/quinlanj))
|
||||
- Handle error thrown when `intel-medium` resource class is not available as server-side defined error. ([#1947](https://github.com/expo/eas-cli/pull/1947) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
- Remove `intel-medium` from `eas.schema.json`, so it's not suggested as a valid value by our VSCode plugin. ([#1947](https://github.com/expo/eas-cli/pull/1947) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
|
||||
## [3.16.0](https://github.com/expo/eas-cli/releases/tag/v3.16.0) - 2023-07-18
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- Add styling to SSO auth redirect completion page. ([#1929](https://github.com/expo/eas-cli/pull/1929) by [@wschurman](https://github.com/wschurman))
|
||||
- Ignore entitlements from native template when `/ios` is gitignored. ([#1906](https://github.com/expo/eas-cli/pull/1906) by [@byCedric](https://github.com/byCedric))
|
||||
- Use node server default port selection for SSO login server. ([#1930](https://github.com/expo/eas-cli/pull/1930) by [@wschurman](https://github.com/wschurman))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- Fix incorrect handling of valid `inProgress` Android submission release status. ([#1934](https://github.com/expo/eas-cli/pull/1934) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
- Print friendly error msg in case account doesn't have required permission. ([#1867](https://github.com/expo/eas-cli/pull/1867) by [@firasrg](https://github.com/firasrg))
|
||||
- Limit project file upload size to 2GB. ([#1928](https://github.com/expo/eas-cli/pull/1928) by [@khamilowicz](https://github.com/khamilowicz))
|
||||
- Bump urql graphql client major version. ([#1936](https://github.com/expo/eas-cli/pull/1936) by [@quinlanj](https://github.com/quinlanj))
|
||||
|
||||
## [3.15.1](https://github.com/expo/eas-cli/releases/tag/v3.15.1) - 2023-07-11
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- Ensure useClassicUpdates is not set when using EAS Update commands. ([#1915](https://github.com/expo/eas-cli/pull/1915) by [@ide](https://github.com/ide))
|
||||
- Handle multiple GraphQLErrors when receiving a CombinedError. ([#1924](https://github.com/expo/eas-cli/pull/1924) by [@radoslawkrzemien](https://github.com/radoslawkrzemien))
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
- Bump Expo package dependencies. ([#1911](https://github.com/expo/eas-cli/pull/1911) by [@brentvatne](https://github.com/brentvatne))
|
||||
- Better bare workflow runtimeVersion error. ([#1910](https://github.com/expo/eas-cli/pull/1910) by [@quinlanj](https://github.com/quinlanj))
|
||||
- Fix runtime version print logs. ([#1925](https://github.com/expo/eas-cli/pull/1925) by [@quinlanj](https://github.com/quinlanj))
|
||||
- Add info about new `macos-ventura-13.4-xcode-14.3.1` image to `eas.schema.json`. ([#1920](https://github.com/expo/eas-cli/pull/1920) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
|
||||
## [3.15.0](https://github.com/expo/eas-cli/releases/tag/v3.15.0) - 2023-06-30
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- Show build profile when selecting a build for eas build:run. ([#1901](https://github.com/expo/eas-cli/pull/1901) by [@keith-kurak](https://github.com/keith-kurak))
|
||||
- Adds -m alias to --message in update/republish and removed README comment. ([#1905](https://github.com/expo/eas-cli/pull/1905) by [@pusongqi](https://github.com/pusongqi))
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
- Better runtimeVersion output. ([#1894](https://github.com/expo/eas-cli/pull/1894) by [@quinlanj](https://github.com/quinlanj))
|
||||
- Print better error message when uploading project archive tarball fails. ([#1897](https://github.com/expo/eas-cli/pull/1897) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
- Adds support for 2 new server-side errors related to build limits. ([#1921](https://github.com/expo/eas-cli/pull/1921) by [@sundeeppeswani](https://github.com/sundeeppeswani))
|
||||
|
||||
## [3.14.0](https://github.com/expo/eas-cli/releases/tag/v3.14.0) - 2023-06-20
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- Added support for SSO users. ([#1875](https://github.com/expo/eas-cli/pull/1875) by [@lzkb](https://github.com/lzkb))
|
||||
- Added new bundle identifier capabilities and entitlements from WWDC23. ([#1870](https://github.com/expo/eas-cli/pull/1870) by [@EvanBacon](https://github.com/EvanBacon))
|
||||
- Selecting default keystore via CLI. ([#1889](https://github.com/expo/eas-cli/pull/1889) by [@khamilowicz](https://github.com/khamilowicz))
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
- Change sso flag display. ([#1890](https://github.com/expo/eas-cli/pull/1890) by [@lzkb](https://github.com/lzkb))
|
||||
- Build:configure -- add channels to eas.json if using eas updates. ([#1887](https://github.com/expo/eas-cli/pull/1887) by [@quinlanj](https://github.com/quinlanj))
|
||||
- Eas update: Error gracefully if no git repo. ([#1884](https://github.com/expo/eas-cli/pull/1884) by [@quinlanj](https://github.com/quinlanj))
|
||||
- Error gracefully if expo pkg not installed. ([#1883](https://github.com/expo/eas-cli/pull/1883) by [@quinlanj](https://github.com/quinlanj))
|
||||
- Update `eas build:configure` command to show the link of `eas.json` when generated. ([#1878](https://github.com/expo/eas-cli/pull/1878) by [@amandeepmittal](https://github.com/amandeepmittal))
|
||||
- Create app.json or add the "expo" key if either are missing, before modifying or reading the file. ([#1881](https://github.com/expo/eas-cli/pull/1881) by [@brentvatne](https://github.com/brentvatne))
|
||||
- Include the original stack in re-thrown errors thrown from EAS CLI commands. ([#1882](https://github.com/expo/eas-cli/pull/1882) by [@brentvatne](https://github.com/brentvatne))
|
||||
- Make `update:configure` less verbose. ([#1888](https://github.com/expo/eas-cli/pull/1888) by [@quinlanj](https://github.com/quinlanj))
|
||||
- Improve validation for values from app config. ([#1893](https://github.com/expo/eas-cli/pull/1893) by [@wkozyra95](https://github.com/wkozyra95))
|
||||
- Improve `expo-updates` validation for builds with `channel` property set. ([#1885](https://github.com/expo/eas-cli/pull/1885) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
|
||||
## [3.13.3](https://github.com/expo/eas-cli/releases/tag/v3.13.3) - 2023-06-05
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- Show original GraphQL error message in case of an unexpected error. ([#1862](https://github.com/expo/eas-cli/pull/1862) by [@dsokal](https://github.com/dsokal))
|
||||
- Fix updates synchronization of native files to include strings.xml for bare projects. ([#1865](https://github.com/expo/eas-cli/pull/1865) by [@wschurman](https://github.com/wschurman))
|
||||
|
||||
## [3.13.2](https://github.com/expo/eas-cli/releases/tag/v3.13.2) - 2023-05-26
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
- Refactor getExpoConfig to remove dangerous default. ([#1857](https://github.com/expo/eas-cli/pull/1857) by [@wschurman](https://github.com/wschurman))
|
||||
- Add support for pending-cancel build status. ([#1855](https://github.com/expo/eas-cli/pull/1855) by [@radoslawkrzemien](https://github.com/radoslawkrzemien))
|
||||
|
||||
## [3.13.1](https://github.com/expo/eas-cli/releases/tag/v3.13.1) - 2023-05-24
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
- Short format for selecting devices prompt. ([#1840](https://github.com/expo/eas-cli/pull/1840) by [@khamilowicz](https://github.com/khamilowicz))
|
||||
- Improve typescript types for user display. ([#1851](https://github.com/expo/eas-cli/pull/1851) by [@wschurman](https://github.com/wschurman))
|
||||
- Add error for `large` resource class not available on the free plan to server-side defined errors. ([#1848](https://github.com/expo/eas-cli/pull/1848) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
|
||||
## [3.13.0](https://github.com/expo/eas-cli/releases/tag/v3.13.0) - 2023-05-17
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- Add clear cache flag to eas update. ([#1839](https://github.com/expo/eas-cli/pull/1839) by [@quinlanj](https://github.com/quinlanj))
|
||||
- Print EAS Update assets that timed out during upload or processing. ([#1849](https://github.com/expo/eas-cli/pull/1849) by [@wschurman](https://github.com/wschurman))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- Fix building iOS projects with framework targets. ([#1835](https://github.com/expo/eas-cli/pull/1835) by [@dsokal](https://github.com/dsokal))
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
- Ignore dirty workingdir when building from GitHub. ([#1842](https://github.com/expo/eas-cli/pull/1842) by [@wkozyra95](https://github.com/wkozyra95))
|
||||
- Remove App Specific Password prompt. ([#1843](https://github.com/expo/eas-cli/pull/1843) by [@quinlanj](https://github.com/quinlanj))
|
||||
- Validate `CFBundleShortVersionString`. ([#1846](https://github.com/expo/eas-cli/pull/1846) by [@khamilowicz](https://github.com/khamilowicz))
|
||||
|
||||
## [3.12.1](https://github.com/expo/eas-cli/releases/tag/v3.12.1) - 2023-05-15
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- Don't submit expired builds. ([#1837](https://github.com/expo/eas-cli/pull/1837) by [@dsokal](https://github.com/dsokal))
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
- Change red apple icon to green apple icon on successful build. ([#1828](https://github.com/expo/eas-cli/pull/1828) by [@dchhetri](https://github.com/dchhetri))
|
||||
|
||||
## [3.12.0](https://github.com/expo/eas-cli/releases/tag/v3.12.0) - 2023-05-08
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- Add `build:version:get` command. ([#1815](https://github.com/expo/eas-cli/pull/1815) by [@wkozyra95](https://github.com/wkozyra95))
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
- Upgrade dependencies. ([#1824](https://github.com/expo/eas-cli/pull/1824) by [@dsokal](https://github.com/dsokal))
|
||||
|
||||
## [3.11.0](https://github.com/expo/eas-cli/releases/tag/v3.11.0) - 2023-05-05
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- Add account list to `eas whoami`/`eas account:view`. ([#1814](https://github.com/expo/eas-cli/pull/1814) by [@wschurman](https://github.com/wschurman))
|
||||
- Add `large` resource class for iOS as allowed value in `eas.json`. ([#1817](https://github.com/expo/eas-cli/pull/1817) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
- Print request ID on unforeseen GraphQL error for easier tracking and follow up. ([#1813](https://github.com/expo/eas-cli/pull/1813) by [@radoslawkrzemien](https://github.com/radoslawkrzemien))
|
||||
|
||||
## [3.10.2](https://github.com/expo/eas-cli/releases/tag/v3.10.2) - 2023-04-25
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
- Add info about Xcode 14.3 image to `eas.schema.json`. ([#1808](https://github.com/expo/eas-cli/pull/1808) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
- Allow users to select an app to run using the `build:run` command if multiple apps are found in the tarball. ([#1807](https://github.com/expo/eas-cli/pull/1807) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
- Don't resolve `default` resource class for iOS in CLI. ([#1734](https://github.com/expo/eas-cli/pull/1734) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
|
||||
## [3.10.1](https://github.com/expo/eas-cli/releases/tag/v3.10.1) - 2023-04-25
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
- Add `cache.paths` field and deprecate `cache.customPaths`. ([#1794](https://github.com/expo/eas-cli/pull/1794) by [@radoslawkrzemien](https://github.com/radoslawkrzemien))
|
||||
|
||||
## [3.10.0](https://github.com/expo/eas-cli/releases/tag/v3.10.0) - 2023-04-21
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- Added eas device:rename command. ([#1787](https://github.com/expo/eas-cli/pull/1787) by [@keith-kurak](https://github.com/keith-kurak))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- Fix `device:delete` not disabling on Apple. ([#1803](https://github.com/expo/eas-cli/pull/1803) by [@keith-kurak](https://github.com/keith-kurak))
|
||||
- Fix message truncation for updates. ([#1801](https://github.com/expo/eas-cli/pull/1801) by [@wschurman](https://github.com/wschurman))
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
- Throw an error if somebody tries to start iOS build with the `large` resource class selected using the deprecated `--resource-class` flag. ([#1795](https://github.com/expo/eas-cli/pull/1795) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
|
||||
## [3.9.3](https://github.com/expo/eas-cli/releases/tag/v3.9.3) - 2023-04-18
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- Fix prompts in `eas update`. ([#1797](https://github.com/expo/eas-cli/pull/1797) by [@wschurman](https://github.com/wschurman))
|
||||
|
||||
## [3.9.2](https://github.com/expo/eas-cli/releases/tag/v3.9.2) - 2023-04-13
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- Fixes the rollout percentages when ending a rollout. ([#1781](https://github.com/expo/eas-cli/pull/1781) by [@jonsamp](https://github.com/jonsamp))
|
||||
- Print meaningful error message if `extra.eas.projectId` is not a string. ([#1788](https://github.com/expo/eas-cli/pull/1788) by [@dsokal](https://github.com/dsokal))
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
- Show output of `expo install expo-updates`. ([#1782](https://github.com/expo/eas-cli/pull/1782) by [@wkozyra95](https://github.com/wkozyra95))
|
||||
- Print deprecation warnings for deprecated `eas.json` fields. ([#1768](https://github.com/expo/eas-cli/pull/1768) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
|
||||
## [3.9.1](https://github.com/expo/eas-cli/releases/tag/v3.9.1) - 2023-04-10
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- Update apple utilities to work around the maintenance error. ([#1780](https://github.com/expo/eas-cli/pull/1780) by [@byCedric](https://github.com/byCedric))
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
- Don't use defaults for the `cache.cacheDefaultPaths` build profile field. ([#1769](https://github.com/expo/eas-cli/pull/1769) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
|
||||
## [3.9.0](https://github.com/expo/eas-cli/releases/tag/v3.9.0) - 2023-04-06
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- Add support for roll back to embedded updates. ([#1754](https://github.com/expo/eas-cli/pull/1754), [#1755](https://github.com/expo/eas-cli/pull/1755) by [@wschurman](https://github.com/wschurman))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- Update `expoCommandAsync` to support the new Expo CLI bin path. ([#1772](https://github.com/expo/eas-cli/pull/1772) by [@gabrieldonadel](https://github.com/gabrieldonadel))
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
- Use the `eas-cli` npm tag for checking for the local build plugin updates. ([#1759](https://github.com/expo/eas-cli/pull/1759) by [@dsokal](https://github.com/dsokal))
|
||||
- Ignore `requireCommit` option in `eas build:internal`. ([#1760](https://github.com/expo/eas-cli/pull/1760) by [@wkozyra95](https://github.com/wkozyra95))
|
||||
- Support parsing eas.json from a string. ([#1766](https://github.com/expo/eas-cli/pull/1766) by [@wkozyra95](https://github.com/wkozyra95))
|
||||
|
||||
## [3.8.1](https://github.com/expo/eas-cli/releases/tag/v3.8.1) - 2023-03-16
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- Ensure public config is used as update. ([#1745](https://github.com/expo/eas-cli/pull/1745) by [@byCedric](https://github.com/byCedric))
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
- Unify how the command errors are displayed. ([#1738](https://github.com/expo/eas-cli/pull/1738) by [@radoslawkrzemien](https://github.com/radoslawkrzemien))
|
||||
- Unify the case of base error messages for command failures. ([#1744](https://github.com/expo/eas-cli/pull/1744) by [@radoslawkrzemien](https://github.com/radoslawkrzemien))
|
||||
|
||||
## [3.8.0](https://github.com/expo/eas-cli/releases/tag/v3.8.0) - 2023-03-13
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- Add new `m-medium` and `m-large` resource classes. ([#1739](https://github.com/expo/eas-cli/pull/1739) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
|
||||
## [3.7.2](https://github.com/expo/eas-cli/releases/tag/v3.7.2) - 2023-02-24
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- Implement Apple's proprietary Hashcash algorithm for signing authentication requests. ([#1719](https://github.com/expo/eas-cli/pull/1719) by [@EvanBacon](https://github.com/EvanBacon))
|
||||
|
||||
## [3.7.1](https://github.com/expo/eas-cli/releases/tag/v3.7.1) - 2023-02-23
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- Bump `@expo/apple-utils` to fix Apple developer auth. ([03c76c1](https://github.com/expo/eas-cli/commit/03c76c1e0efb9ec55e40b45e33c80b3479f920c3) by [@brentvatne](https://github.com/brentvatne))
|
||||
|
||||
## [3.7.0](https://github.com/expo/eas-cli/releases/tag/v3.7.0) - 2023-02-23
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- Support variant detection for package\*UniversalApk Gradle Commands. ([#1708](https://github.com/expo/eas-cli/pull/1708) by [@frw](https://github.com/frw))
|
||||
|
||||
## [3.6.1](https://github.com/expo/eas-cli/releases/tag/v3.6.1) - 2023-02-20
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- Disable analytics when running behind a proxy. ([#1696](https://github.com/expo/eas-cli/pull/1696) by [@wkozyra95](https://github.com/wkozyra95))
|
||||
- Clarify missing owner field error message for Robot users. ([#1702](https://github.com/expo/eas-cli/pull/1702) by [@wschurman](https://github.com/wschurman))
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
- Validate the platform for local builds earlier. ([#1698](https://github.com/expo/eas-cli/pull/1698) by [@wkozyra95](https://github.com/wkozyra95))
|
||||
- Display build message when picking build in `eas submit`, `eas build:resign`, and `eas build:run` commands. ([#1700](https://github.com/expo/eas-cli/pull/1700) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
- Add a link directly to a build phase logs in the EAS CLI build error message. ([#1699](https://github.com/expo/eas-cli/pull/1699) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
|
||||
## [3.6.0](https://github.com/expo/eas-cli/releases/tag/v3.6.0) - 2023-02-14
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- Use `sdkVersion` as default runtime version policy when running `eas update:configure`. ([#1669](https://github.com/expo/eas-cli/pull/1669) by [@jonsamp](https://github.com/jonsamp))
|
||||
- Warn when project ID but no owner specified and mismatch logged in user. ([#1667](https://github.com/expo/eas-cli/pull/1667) by [@wschurman](https://github.com/wschurman))
|
||||
- Return the build `message` if one is available. ([#1691](https://github.com/expo/eas-cli/pull/1691) by [@raulriera](https://github.com/raulriera))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
@@ -17,6 +476,8 @@ This is the log of notable changes to EAS CLI and related packages.
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
- Add Xcode 14.2 image to the VSCode schema. ([#1693](https://github.com/expo/eas-cli/pull/1693) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
|
||||
## [3.5.2](https://github.com/expo/eas-cli/releases/tag/v3.5.2) - 2023-02-05
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
+6
-6
@@ -1,10 +1,10 @@
|
||||
* @dsokal @wkozyra95
|
||||
* @szdziedzic @khamilowicz
|
||||
|
||||
packages/eas-cli/src/build @dsokal @wkozyra95
|
||||
packages/eas-cli/src/commands/build @dsokal @wkozyra95
|
||||
packages/eas-cli/src/build @szdziedzic @khamilowicz
|
||||
packages/eas-cli/src/commands/build @szdziedzic @khamilowicz
|
||||
|
||||
packages/eas-cli/src/submit @dsokal
|
||||
packages/eas-cli/src/commands/submit.ts @dsokal
|
||||
packages/eas-cli/src/submit @szdziedzic @khamilowicz
|
||||
packages/eas-cli/src/commands/submit.ts @szdziedzic @khamilowicz
|
||||
|
||||
packages/eas-cli/src/update @EvanBacon @byCedric @wschurman
|
||||
packages/eas-cli/src/commands/update @EvanBacon @byCedric @wschurman
|
||||
@@ -13,4 +13,4 @@ packages/eas-cli/schema @bycedric
|
||||
packages/eas-cli/src/metadata @bycedric
|
||||
packages/eas-cli/src/commands/metadata @bycedric
|
||||
|
||||
packages/eas-json @dsokal @wkozyra95
|
||||
packages/eas-json @szdziedzic @khamilowicz
|
||||
@@ -0,0 +1,5 @@
|
||||
# Security Policy
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
Instead of opening a GitHub issue for security vulnerabilities, send an email to our secure inbox: secure@expo.io.
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"npmClient": "yarn",
|
||||
"useWorkspaces": true,
|
||||
"version": "3.5.2",
|
||||
"version": "5.3.0",
|
||||
"$schema": "node_modules/lerna/schemas/lerna-schema.json"
|
||||
}
|
||||
+2
-1
@@ -6,6 +6,7 @@
|
||||
],
|
||||
"scripts": {
|
||||
"build": "lerna run build",
|
||||
"build-ci": "lerna run build-ci",
|
||||
"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",
|
||||
"typecheck": "lerna run typecheck",
|
||||
@@ -14,7 +15,7 @@
|
||||
"eas": "packages/eas-cli/bin/run",
|
||||
"lint": "eslint .",
|
||||
"lint-changelog": "./scripts/bin/run lint-changelog",
|
||||
"release": "./scripts/bin/run release",
|
||||
"release": "lerna run rebuild && ./scripts/bin/run release",
|
||||
"test": "jest",
|
||||
"clean": "lerna run clean && rm -rf node_modules coverage"
|
||||
},
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
Generated
+7495
-990
File diff suppressed because it is too large.
Load diff
@@ -2,4 +2,5 @@ module.exports = async () => {
|
||||
process.env.TZ = 'UTC';
|
||||
process.env.EAS_NO_VCS = '1';
|
||||
process.env.EAS_PROJECT_ROOT = '/app';
|
||||
process.env.FORCE_COLOR = '1';
|
||||
};
|
||||
@@ -1,20 +1,20 @@
|
||||
{
|
||||
"name": "eas-cli",
|
||||
"description": "EAS command line tool",
|
||||
"version": "3.5.2",
|
||||
"version": "5.3.0",
|
||||
"author": "Expo <[email protected]>",
|
||||
"bin": {
|
||||
"eas": "./bin/run"
|
||||
},
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"dependencies": {
|
||||
"@expo/apple-utils": "0.0.0-alpha.37",
|
||||
"@expo/apple-utils": "1.3.1",
|
||||
"@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": "0.2.103",
|
||||
"@expo/eas-json": "3.5.0",
|
||||
"@expo/config": "8.1.2",
|
||||
"@expo/config-plugins": "7.2.4",
|
||||
"@expo/config-types": "49.0.0",
|
||||
"@expo/eas-build-job": "1.0.43",
|
||||
"@expo/eas-json": "5.3.0",
|
||||
"@expo/json-file": "8.2.37",
|
||||
"@expo/multipart-body-parser": "1.1.0",
|
||||
"@expo/osascript": "2.0.33",
|
||||
@@ -23,16 +23,17 @@
|
||||
"@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/prebuild-config": "6.2.5",
|
||||
"@expo/results": "1.0.0",
|
||||
"@expo/rudder-sdk-node": "1.1.1",
|
||||
"@expo/spawn-async": "1.7.0",
|
||||
"@expo/steps": "1.0.34",
|
||||
"@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",
|
||||
"@urql/core": "4.0.11",
|
||||
"@urql/exchange-retry": "1.2.0",
|
||||
"ajv": "8.11.0",
|
||||
"ajv-formats": "2.1.1",
|
||||
"better-opn": "3.0.2",
|
||||
@@ -72,7 +73,7 @@
|
||||
"prompts": "2.4.2",
|
||||
"qrcode-terminal": "0.12.0",
|
||||
"resolve-from": "5.0.0",
|
||||
"semver": "7.3.8",
|
||||
"semver": "7.5.2",
|
||||
"slash": "3.0.0",
|
||||
"tar": "6.1.13",
|
||||
"terminal-link": "2.1.1",
|
||||
@@ -202,11 +203,13 @@
|
||||
"repository": "expo/eas-cli",
|
||||
"scripts": {
|
||||
"postpack": "rimraf oclif.manifest.json",
|
||||
"prepack": "rimraf build && yarn build && node ./scripts/prepack.js",
|
||||
"prepack": "yarn rebuild && node ./scripts/prepack.js",
|
||||
"rebuild": "rimraf build && yarn build-ci",
|
||||
"pretarball-ci": "./scripts/pretarball-ci.sh",
|
||||
"build": "tsc --project tsconfig.build.json",
|
||||
"build-ci": "tsc --project tsconfig.build-ci.json",
|
||||
"watch": "yarn build --watch --preserveWatchOutput",
|
||||
"typecheck": "tsc",
|
||||
"typecheck": "tsc --project tsconfig.build-ci.json",
|
||||
"test": "jest",
|
||||
"version": "yarn oclif readme && node scripts/patch-readme && git add README.md",
|
||||
"generate-graphql-code": "graphql-codegen --config graphql-codegen.yml",
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`BuildVersionGetView reading version when appVersionSource is set to local 1`] = `"This project is not configured for using remote version source. Add [1m{"cli": { "appVersionSource": "remote" }}[22m in eas.json or re-run this command without "--non-interactive" flag."`;
|
||||
@@ -0,0 +1,161 @@
|
||||
import { AppVersionSource, EasJson } from '@expo/eas-json';
|
||||
import chalk from 'chalk';
|
||||
|
||||
import BuildVersionGetView from '../../commands/build/version/get';
|
||||
import { AppVersionQuery } from '../../graphql/queries/AppVersionQuery';
|
||||
import Log from '../../log';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
|
||||
import { getMockEasJson, mockCommandContext, mockProjectId, mockTestCommand } from './utils';
|
||||
|
||||
jest.mock('../../project/applicationIdentifier');
|
||||
jest.mock('fs');
|
||||
jest.mock('../../log');
|
||||
jest.mock('../../utils/json');
|
||||
jest.mock('../../graphql/queries/AppVersionQuery');
|
||||
|
||||
function withRemoteVersionSource(easJson: EasJson): EasJson {
|
||||
return {
|
||||
...easJson,
|
||||
cli: {
|
||||
...easJson.cli,
|
||||
appVersionSource: AppVersionSource.REMOTE,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe(BuildVersionGetView, () => {
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
test('reading version for platform android', async () => {
|
||||
const ctx = mockCommandContext(BuildVersionGetView, {
|
||||
easJson: withRemoteVersionSource(getMockEasJson()),
|
||||
});
|
||||
jest.mocked(AppVersionQuery.latestVersionAsync).mockImplementation(async () => ({
|
||||
buildVersion: '100',
|
||||
storeVersion: '1.0.0',
|
||||
}));
|
||||
|
||||
const cmd = mockTestCommand(BuildVersionGetView, ['--platform=android'], ctx);
|
||||
await cmd.run();
|
||||
expect(AppVersionQuery.latestVersionAsync).toHaveBeenCalledWith(
|
||||
ctx.loggedIn.graphqlClient,
|
||||
mockProjectId,
|
||||
'ANDROID',
|
||||
'eas.test.com'
|
||||
);
|
||||
expect(Log.log).toHaveBeenCalledWith(`Android versionCode - ${chalk.bold('100')}`);
|
||||
expect(enableJsonOutput).not.toHaveBeenCalled();
|
||||
expect(printJsonOnlyOutput).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('reading version for platform android when no remote version is set', async () => {
|
||||
const ctx = mockCommandContext(BuildVersionGetView, {
|
||||
easJson: withRemoteVersionSource(getMockEasJson()),
|
||||
});
|
||||
jest.mocked(AppVersionQuery.latestVersionAsync).mockImplementation(async () => null);
|
||||
|
||||
const cmd = mockTestCommand(BuildVersionGetView, ['--platform=android'], ctx);
|
||||
await cmd.run();
|
||||
expect(AppVersionQuery.latestVersionAsync).toHaveBeenCalledWith(
|
||||
ctx.loggedIn.graphqlClient,
|
||||
mockProjectId,
|
||||
'ANDROID',
|
||||
'eas.test.com'
|
||||
);
|
||||
expect(Log.log).toHaveBeenCalledWith(`No remote versions are configured for this project.`);
|
||||
expect(enableJsonOutput).not.toHaveBeenCalled();
|
||||
expect(printJsonOnlyOutput).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('reading version with --json flag for platform android', async () => {
|
||||
const ctx = mockCommandContext(BuildVersionGetView, {
|
||||
easJson: withRemoteVersionSource(getMockEasJson()),
|
||||
});
|
||||
jest.mocked(AppVersionQuery.latestVersionAsync).mockImplementation(async () => ({
|
||||
buildVersion: '100',
|
||||
storeVersion: '1.0.0',
|
||||
}));
|
||||
|
||||
const cmd = mockTestCommand(
|
||||
BuildVersionGetView,
|
||||
['--non-interactive', '--json', '--platform=android'],
|
||||
ctx
|
||||
);
|
||||
await cmd.run();
|
||||
expect(AppVersionQuery.latestVersionAsync).toHaveBeenCalledWith(
|
||||
ctx.loggedIn.graphqlClient,
|
||||
mockProjectId,
|
||||
'ANDROID',
|
||||
'eas.test.com'
|
||||
);
|
||||
expect(enableJsonOutput).toHaveBeenCalled();
|
||||
expect(printJsonOnlyOutput).toHaveBeenCalledWith({
|
||||
versionCode: '100',
|
||||
});
|
||||
});
|
||||
|
||||
test('reading version with --json flag for platform ios', async () => {
|
||||
const ctx = mockCommandContext(BuildVersionGetView, {
|
||||
easJson: withRemoteVersionSource(getMockEasJson()),
|
||||
});
|
||||
jest.mocked(AppVersionQuery.latestVersionAsync).mockImplementation(async () => ({
|
||||
buildVersion: '100',
|
||||
storeVersion: '1.0.0',
|
||||
}));
|
||||
|
||||
const cmd = mockTestCommand(
|
||||
BuildVersionGetView,
|
||||
['--non-interactive', '--json', '--platform=ios'],
|
||||
ctx
|
||||
);
|
||||
await cmd.run();
|
||||
expect(AppVersionQuery.latestVersionAsync).toHaveBeenCalledWith(
|
||||
ctx.loggedIn.graphqlClient,
|
||||
mockProjectId,
|
||||
'IOS',
|
||||
'eas.test.com'
|
||||
);
|
||||
expect(enableJsonOutput).toHaveBeenCalled();
|
||||
expect(printJsonOnlyOutput).toHaveBeenCalledWith({
|
||||
buildNumber: '100',
|
||||
});
|
||||
});
|
||||
|
||||
test('reading version with --json flag when no remote version is set', async () => {
|
||||
const ctx = mockCommandContext(BuildVersionGetView, {
|
||||
easJson: withRemoteVersionSource(getMockEasJson()),
|
||||
});
|
||||
jest.mocked(AppVersionQuery.latestVersionAsync).mockImplementation(async () => null);
|
||||
|
||||
const cmd = mockTestCommand(
|
||||
BuildVersionGetView,
|
||||
['--non-interactive', '--json', '--platform=android'],
|
||||
ctx
|
||||
);
|
||||
await cmd.run();
|
||||
expect(AppVersionQuery.latestVersionAsync).toHaveBeenCalledWith(
|
||||
ctx.loggedIn.graphqlClient,
|
||||
mockProjectId,
|
||||
'ANDROID',
|
||||
'eas.test.com'
|
||||
);
|
||||
expect(enableJsonOutput).toHaveBeenCalled();
|
||||
expect(printJsonOnlyOutput).toHaveBeenCalledWith({});
|
||||
});
|
||||
|
||||
test('reading version when appVersionSource is set to local ', async () => {
|
||||
const ctx = mockCommandContext(BuildVersionGetView, {});
|
||||
jest.mocked(AppVersionQuery.latestVersionAsync).mockImplementation(async () => ({
|
||||
buildVersion: '100',
|
||||
storeVersion: '1.0.0',
|
||||
}));
|
||||
|
||||
const cmd = mockTestCommand(
|
||||
BuildVersionGetView,
|
||||
['--non-interactive', '--json', '--platform=android'],
|
||||
ctx
|
||||
);
|
||||
await expect(cmd.run()).rejects.toThrowErrorMatchingSnapshot();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,140 @@
|
||||
import { AppVersionSource, EasJson } from '@expo/eas-json';
|
||||
import fs from 'fs-extra';
|
||||
import path from 'path';
|
||||
|
||||
import BuildVersionSetView from '../../commands/build/version/set';
|
||||
import { AppVersionMutation } from '../../graphql/mutations/AppVersionMutation';
|
||||
import { AppQuery } from '../../graphql/queries/AppQuery';
|
||||
import { AppVersionQuery } from '../../graphql/queries/AppVersionQuery';
|
||||
import Log from '../../log';
|
||||
import * as prompts from '../../prompts';
|
||||
import {
|
||||
getMockAppFragment,
|
||||
getMockEasJson,
|
||||
mockCommandContext,
|
||||
mockProjectId,
|
||||
mockTestCommand,
|
||||
} from './utils';
|
||||
|
||||
jest.mock('../../project/applicationIdentifier');
|
||||
jest.mock('../../graphql/queries/AppVersionQuery');
|
||||
jest.mock('../../graphql/queries/AppQuery');
|
||||
jest.mock('../../graphql/mutations/AppVersionMutation');
|
||||
jest.mock('fs');
|
||||
jest.mock('../../log');
|
||||
jest.mock('../../prompts');
|
||||
jest.mock('../../utils/json');
|
||||
|
||||
function withRemoteVersionSource(easJson: EasJson): EasJson {
|
||||
return {
|
||||
...easJson,
|
||||
cli: {
|
||||
...easJson.cli,
|
||||
appVersionSource: AppVersionSource.REMOTE,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe(BuildVersionSetView, () => {
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
test('setting version for platform android', async () => {
|
||||
const ctx = mockCommandContext(BuildVersionSetView, {
|
||||
easJson: withRemoteVersionSource(getMockEasJson()),
|
||||
});
|
||||
jest.mocked(AppQuery.byIdAsync).mockImplementation(async () => getMockAppFragment());
|
||||
jest.mocked(AppVersionQuery.latestVersionAsync).mockImplementation(async () => null);
|
||||
jest.mocked(prompts.promptAsync).mockImplementation(async () => ({
|
||||
version: '1000',
|
||||
}));
|
||||
|
||||
const cmd = mockTestCommand(BuildVersionSetView, ['--platform=android'], ctx);
|
||||
await cmd.run();
|
||||
expect(AppVersionQuery.latestVersionAsync).toHaveBeenCalledWith(
|
||||
ctx.loggedIn.graphqlClient,
|
||||
mockProjectId,
|
||||
'ANDROID',
|
||||
'eas.test.com'
|
||||
);
|
||||
expect(AppVersionMutation.createAppVersionAsync).toHaveBeenCalledWith(
|
||||
ctx.loggedIn.graphqlClient,
|
||||
expect.objectContaining({
|
||||
buildVersion: '1000',
|
||||
storeVersion: '1.0.0',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test('printing current remote version before prompting for a new one', async () => {
|
||||
const ctx = mockCommandContext(BuildVersionSetView, {
|
||||
easJson: withRemoteVersionSource(getMockEasJson()),
|
||||
});
|
||||
jest.mocked(AppQuery.byIdAsync).mockImplementation(async () => getMockAppFragment());
|
||||
jest.mocked(AppVersionQuery.latestVersionAsync).mockImplementation(async () => ({
|
||||
buildVersion: '100',
|
||||
storeVersion: '1.0.0',
|
||||
}));
|
||||
jest.mocked(prompts.promptAsync).mockImplementation(async () => ({
|
||||
version: '1000',
|
||||
}));
|
||||
|
||||
const cmd = mockTestCommand(BuildVersionSetView, ['--platform=android'], ctx);
|
||||
await cmd.run();
|
||||
expect(Log.log).toHaveBeenCalledWith(expect.stringMatching('configured with versionCode 100'));
|
||||
expect(AppVersionQuery.latestVersionAsync).toHaveBeenCalledWith(
|
||||
ctx.loggedIn.graphqlClient,
|
||||
mockProjectId,
|
||||
'ANDROID',
|
||||
'eas.test.com'
|
||||
);
|
||||
});
|
||||
|
||||
test('printing info that there is no remote version configured', async () => {
|
||||
const ctx = mockCommandContext(BuildVersionSetView, {
|
||||
easJson: withRemoteVersionSource(getMockEasJson()),
|
||||
});
|
||||
jest.mocked(AppQuery.byIdAsync).mockImplementation(async () => getMockAppFragment());
|
||||
jest.mocked(AppVersionQuery.latestVersionAsync).mockImplementation(async () => null);
|
||||
jest.mocked(prompts.promptAsync).mockImplementation(async () => ({
|
||||
version: '1000',
|
||||
}));
|
||||
|
||||
const cmd = mockTestCommand(BuildVersionSetView, ['--platform=android'], ctx);
|
||||
await cmd.run();
|
||||
expect(Log.log).toHaveBeenCalledWith(
|
||||
expect.stringMatching('does not have any versionCode configured')
|
||||
);
|
||||
expect(AppVersionQuery.latestVersionAsync).toHaveBeenCalledWith(
|
||||
ctx.loggedIn.graphqlClient,
|
||||
mockProjectId,
|
||||
'ANDROID',
|
||||
'eas.test.com'
|
||||
);
|
||||
});
|
||||
|
||||
test('setting version aborts when appVersionSource is set to local and users refuse auto configuration', async () => {
|
||||
const ctx = mockCommandContext(BuildVersionSetView, {});
|
||||
jest.mocked(AppVersionQuery.latestVersionAsync).mockImplementation(async () => null);
|
||||
jest.mocked(AppQuery.byIdAsync).mockImplementation(async () => getMockAppFragment());
|
||||
jest.mocked(prompts.confirmAsync).mockImplementation(async () => false);
|
||||
|
||||
const cmd = mockTestCommand(BuildVersionSetView, ['--platform=android'], ctx);
|
||||
await expect(cmd.run()).rejects.toThrowError('Aborting...');
|
||||
expect(AppVersionMutation.createAppVersionAsync).not.toHaveBeenCalledWith();
|
||||
});
|
||||
|
||||
test('setting version when appVersionSource is set to local and user allows auto configuration', async () => {
|
||||
const ctx = mockCommandContext(BuildVersionSetView, {});
|
||||
jest.mocked(AppVersionQuery.latestVersionAsync).mockImplementation(async () => null);
|
||||
jest.mocked(AppQuery.byIdAsync).mockImplementation(async () => getMockAppFragment());
|
||||
jest.mocked(prompts.confirmAsync).mockImplementation(async () => true);
|
||||
|
||||
const cmd = mockTestCommand(BuildVersionSetView, ['--platform=android'], ctx);
|
||||
await cmd.run();
|
||||
|
||||
const easJsonAfterCmd = await fs.readJson(path.join(ctx.projectDir, 'eas.json'));
|
||||
expect(easJsonAfterCmd.cli.appVersionSource).toBe('remote');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,204 @@
|
||||
import { Workflow } from '@expo/eas-build-job';
|
||||
import { AppVersionSource, EasJson } from '@expo/eas-json';
|
||||
import fs from 'fs-extra';
|
||||
import path from 'path';
|
||||
|
||||
import { updateNativeVersionsAsync as updateAndroidNativeVersionsAsync } from '../../build/android/version';
|
||||
import { updateNativeVersionsAsync as updateIosNativeVersionsAsync } from '../../build/ios/version';
|
||||
import BuildVersionSyncView from '../../commands/build/version/sync';
|
||||
import { Target } from '../../credentials/ios/types';
|
||||
import { AppVersionMutation } from '../../graphql/mutations/AppVersionMutation';
|
||||
import { AppQuery } from '../../graphql/queries/AppQuery';
|
||||
import { AppVersionQuery } from '../../graphql/queries/AppVersionQuery';
|
||||
import { getAppBuildGradleAsync } from '../../project/android/gradleUtils';
|
||||
import { resolveTargetsAsync } from '../../project/ios/target';
|
||||
import { resolveWorkflowAsync } from '../../project/workflow';
|
||||
import * as prompts from '../../prompts';
|
||||
import {
|
||||
getMockAppFragment,
|
||||
getMockEasJson,
|
||||
mockCommandContext,
|
||||
mockProjectId,
|
||||
mockTestCommand,
|
||||
} from './utils';
|
||||
|
||||
jest.mock('../../build/android/version');
|
||||
jest.mock('../../build/ios/version');
|
||||
jest.mock('../../project/applicationIdentifier');
|
||||
jest.mock('../../graphql/queries/AppVersionQuery');
|
||||
jest.mock('../../graphql/queries/AppQuery');
|
||||
jest.mock('../../graphql/mutations/AppVersionMutation');
|
||||
jest.mock('../../project/workflow');
|
||||
jest.mock('../../project/android/gradleUtils');
|
||||
jest.mock('../../project/ios/target');
|
||||
jest.mock('../../project/ios/scheme');
|
||||
jest.mock('fs');
|
||||
jest.mock('../../log');
|
||||
jest.mock('../../prompts');
|
||||
jest.mock('../../utils/json');
|
||||
|
||||
function withRemoteVersionSource(easJson: EasJson): EasJson {
|
||||
return {
|
||||
...easJson,
|
||||
cli: {
|
||||
...easJson.cli,
|
||||
appVersionSource: AppVersionSource.REMOTE,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe(BuildVersionSyncView, () => {
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
test('syncing version for managed project on platform android', async () => {
|
||||
const ctx = mockCommandContext(BuildVersionSyncView, {
|
||||
easJson: withRemoteVersionSource(getMockEasJson()),
|
||||
});
|
||||
jest.mocked(AppQuery.byIdAsync).mockImplementation(async () => getMockAppFragment());
|
||||
jest.mocked(AppVersionQuery.latestVersionAsync).mockImplementation(async () => ({
|
||||
buildVersion: '1000',
|
||||
storeVersion: '0.0.1',
|
||||
}));
|
||||
jest.mocked(prompts.promptAsync).mockImplementation(async () => ({
|
||||
version: '1000',
|
||||
}));
|
||||
jest.mocked(resolveWorkflowAsync).mockImplementation(async () => Workflow.MANAGED);
|
||||
|
||||
const cmd = mockTestCommand(BuildVersionSyncView, ['--platform=android'], ctx);
|
||||
const syncAndroidAsync = jest.spyOn(cmd, 'syncAndroidAsync' as any);
|
||||
|
||||
await cmd.run();
|
||||
expect(AppVersionQuery.latestVersionAsync).toHaveBeenCalledWith(
|
||||
ctx.loggedIn.graphqlClient,
|
||||
mockProjectId,
|
||||
'ANDROID',
|
||||
'eas.test.com'
|
||||
);
|
||||
expect(syncAndroidAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('syncing version for bare project on platform android', async () => {
|
||||
const ctx = mockCommandContext(BuildVersionSyncView, {
|
||||
easJson: withRemoteVersionSource(getMockEasJson()),
|
||||
});
|
||||
jest.mocked(AppQuery.byIdAsync).mockImplementation(async () => getMockAppFragment());
|
||||
jest.mocked(AppVersionQuery.latestVersionAsync).mockImplementation(async () => ({
|
||||
buildVersion: '1000',
|
||||
storeVersion: '0.0.1',
|
||||
}));
|
||||
jest.mocked(prompts.promptAsync).mockImplementation(async () => ({
|
||||
version: '1000',
|
||||
}));
|
||||
jest.mocked(resolveWorkflowAsync).mockImplementation(async () => Workflow.GENERIC);
|
||||
jest.mocked(getAppBuildGradleAsync).mockImplementation(async () => ({
|
||||
android: {},
|
||||
}));
|
||||
|
||||
const cmd = mockTestCommand(BuildVersionSyncView, ['--platform=android'], ctx);
|
||||
const syncAndroidAsync = jest.spyOn(cmd, 'syncAndroidAsync' as any);
|
||||
|
||||
await cmd.run();
|
||||
expect(AppVersionQuery.latestVersionAsync).toHaveBeenCalledWith(
|
||||
ctx.loggedIn.graphqlClient,
|
||||
mockProjectId,
|
||||
'ANDROID',
|
||||
'eas.test.com'
|
||||
);
|
||||
expect(syncAndroidAsync).toHaveBeenCalled();
|
||||
expect(updateAndroidNativeVersionsAsync).toHaveBeenCalledWith({
|
||||
projectDir: ctx.projectDir,
|
||||
versionCode: 1000,
|
||||
});
|
||||
});
|
||||
|
||||
test('syncing version for managed project on platform ios', async () => {
|
||||
const ctx = mockCommandContext(BuildVersionSyncView, {
|
||||
easJson: withRemoteVersionSource(getMockEasJson()),
|
||||
});
|
||||
jest.mocked(AppQuery.byIdAsync).mockImplementation(async () => getMockAppFragment());
|
||||
jest.mocked(AppVersionQuery.latestVersionAsync).mockImplementation(async () => ({
|
||||
buildVersion: '1000',
|
||||
storeVersion: '0.0.1',
|
||||
}));
|
||||
jest.mocked(prompts.promptAsync).mockImplementation(async () => ({
|
||||
version: '1000',
|
||||
}));
|
||||
jest.mocked(resolveWorkflowAsync).mockImplementation(async () => Workflow.MANAGED);
|
||||
|
||||
const cmd = mockTestCommand(BuildVersionSyncView, ['--platform=ios'], ctx);
|
||||
const syncIosAsync = jest.spyOn(cmd, 'syncIosAsync' as any);
|
||||
|
||||
await cmd.run();
|
||||
expect(AppVersionQuery.latestVersionAsync).toHaveBeenCalledWith(
|
||||
ctx.loggedIn.graphqlClient,
|
||||
mockProjectId,
|
||||
'IOS',
|
||||
'eas.test.com'
|
||||
);
|
||||
expect(syncIosAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('syncing version for bare project on platform ios', async () => {
|
||||
const ctx = mockCommandContext(BuildVersionSyncView, {
|
||||
easJson: withRemoteVersionSource(getMockEasJson()),
|
||||
});
|
||||
const fakeTarget: Target = {
|
||||
targetName: 'testapp',
|
||||
bundleIdentifier: 'eas.test.com',
|
||||
entitlements: {},
|
||||
};
|
||||
jest.mocked(AppQuery.byIdAsync).mockImplementation(async () => getMockAppFragment());
|
||||
jest.mocked(AppVersionQuery.latestVersionAsync).mockImplementation(async () => ({
|
||||
buildVersion: '1000',
|
||||
storeVersion: '0.0.1',
|
||||
}));
|
||||
jest.mocked(prompts.promptAsync).mockImplementation(async () => ({
|
||||
version: '1000',
|
||||
}));
|
||||
jest.mocked(resolveWorkflowAsync).mockImplementation(async () => Workflow.GENERIC);
|
||||
jest.mocked(resolveTargetsAsync).mockImplementation(async () => [fakeTarget]);
|
||||
|
||||
const cmd = mockTestCommand(BuildVersionSyncView, ['--platform=ios'], ctx);
|
||||
const syncIosAsync = jest.spyOn(cmd, 'syncIosAsync' as any);
|
||||
|
||||
await cmd.run();
|
||||
expect(AppVersionQuery.latestVersionAsync).toHaveBeenCalledWith(
|
||||
ctx.loggedIn.graphqlClient,
|
||||
mockProjectId,
|
||||
'IOS',
|
||||
'eas.test.com'
|
||||
);
|
||||
expect(syncIosAsync).toHaveBeenCalled();
|
||||
expect(updateIosNativeVersionsAsync).toHaveBeenCalledWith({
|
||||
buildNumber: '1000',
|
||||
projectDir: ctx.projectDir,
|
||||
targets: [fakeTarget],
|
||||
});
|
||||
});
|
||||
|
||||
test('syncing version aborts when appVersionSource is set to local and users refuse auto configuration', async () => {
|
||||
const ctx = mockCommandContext(BuildVersionSyncView, {});
|
||||
jest.mocked(AppVersionQuery.latestVersionAsync).mockImplementation(async () => null);
|
||||
jest.mocked(AppQuery.byIdAsync).mockImplementation(async () => getMockAppFragment());
|
||||
jest.mocked(prompts.confirmAsync).mockImplementation(async () => false);
|
||||
|
||||
const cmd = mockTestCommand(BuildVersionSyncView, ['--platform=android'], ctx);
|
||||
await expect(cmd.run()).rejects.toThrowError('Aborting...');
|
||||
expect(AppVersionMutation.createAppVersionAsync).not.toHaveBeenCalledWith();
|
||||
});
|
||||
|
||||
test('syncing version when appVersionSource is set to local and user allows auto configuration', async () => {
|
||||
const ctx = mockCommandContext(BuildVersionSyncView, {});
|
||||
jest.mocked(AppVersionQuery.latestVersionAsync).mockImplementation(async () => null);
|
||||
jest.mocked(AppQuery.byIdAsync).mockImplementation(async () => getMockAppFragment());
|
||||
jest.mocked(prompts.confirmAsync).mockImplementation(async () => true);
|
||||
|
||||
const cmd = mockTestCommand(BuildVersionSyncView, ['--platform=android'], ctx);
|
||||
await cmd.run();
|
||||
|
||||
const easJsonAfterCmd = await fs.readJson(path.join(ctx.projectDir, 'eas.json'));
|
||||
expect(easJsonAfterCmd.cli.appVersionSource).toBe('remote');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,149 @@
|
||||
import { ExpoConfig } from '@expo/config-types';
|
||||
import { EasJson } from '@expo/eas-json';
|
||||
import { Command, Config } from '@oclif/core';
|
||||
import { vol } from 'memfs';
|
||||
import path from 'path';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
import { ContextInput, ContextOutput } from '../../commandUtils/EasCommand';
|
||||
import { DynamicConfigContextFn } from '../../commandUtils/context/DynamicProjectConfigContextField';
|
||||
import { AppFragment, Role } from '../../graphql/generated';
|
||||
|
||||
export function getMockEasJson(): EasJson {
|
||||
return {
|
||||
cli: {},
|
||||
build: {
|
||||
production: {},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const mockProjectId = 'fake-project-id';
|
||||
|
||||
export function getMockAppFragment(): AppFragment {
|
||||
return {
|
||||
id: mockProjectId,
|
||||
slug: 'testapp',
|
||||
fullName: '@testuser/testpp',
|
||||
ownerAccount: {
|
||||
id: 'test-account-id',
|
||||
name: 'testuser',
|
||||
users: [
|
||||
{
|
||||
role: Role.Owner,
|
||||
actor: {
|
||||
id: 'test-user-id',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function getMockExpoConfig(): ExpoConfig {
|
||||
return {
|
||||
name: 'testapp',
|
||||
slug: 'tetsapp',
|
||||
extra: {
|
||||
projectId: mockProjectId,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export interface LocalProjectContext {
|
||||
projectDir: string;
|
||||
exp?: ExpoConfig;
|
||||
appId?: string;
|
||||
}
|
||||
|
||||
export function mockCommandContext<
|
||||
C extends {
|
||||
[name: string]: any;
|
||||
} = object
|
||||
>(
|
||||
commandClass: { contextDefinition: ContextInput<C> },
|
||||
overrides: {
|
||||
easJson?: EasJson;
|
||||
exp?: ExpoConfig;
|
||||
projectId?: string;
|
||||
getDynamicPrivateProjectConfigAsync?: DynamicConfigContextFn;
|
||||
getDynamicPublicProjectConfigAsync?: DynamicConfigContextFn;
|
||||
}
|
||||
): ContextOutput<C> {
|
||||
const projectDir = path.join('/test', uuidv4());
|
||||
vol.reset();
|
||||
vol.fromJSON({ 'eas.json': JSON.stringify(overrides.easJson ?? getMockEasJson()) }, projectDir);
|
||||
const contextDefinition = commandClass.contextDefinition;
|
||||
|
||||
const result: any = {};
|
||||
for (const [contextKey] of Object.entries(contextDefinition)) {
|
||||
if (contextKey === 'loggedIn') {
|
||||
result.loggedIn = {};
|
||||
}
|
||||
|
||||
if (contextKey === 'projectDir') {
|
||||
result.projectDir = projectDir;
|
||||
}
|
||||
if (contextKey === 'maybeLoggedIn') {
|
||||
result.maybeLoggedIn = {};
|
||||
}
|
||||
if (contextKey === 'sessionManager') {
|
||||
result.sessionManager = {};
|
||||
}
|
||||
if (contextKey === 'projectConfig') {
|
||||
result.projectConfig = {
|
||||
exp: overrides.exp ?? getMockExpoConfig(),
|
||||
projectId: overrides.projectId ?? mockProjectId,
|
||||
projectDir,
|
||||
};
|
||||
}
|
||||
if (contextKey === 'getDynamicPrivateProjectConfigAsync') {
|
||||
result.getDynamicPrivateProjectConfigAsync = () => ({
|
||||
exp: overrides.exp ?? getMockExpoConfig(),
|
||||
projectId: overrides.projectId ?? mockProjectId,
|
||||
projectDir,
|
||||
});
|
||||
}
|
||||
|
||||
if (contextKey === 'getDynamicPublicProjectConfigAsync') {
|
||||
result.getDynamicPublicProjectConfigAsync = () => ({
|
||||
exp: overrides.exp ?? getMockExpoConfig(),
|
||||
projectId: overrides.projectId ?? mockProjectId,
|
||||
projectDir,
|
||||
});
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function mockTestCommand<T extends Command>(
|
||||
commandConstructor: new (argv: string[], config: Config) => T,
|
||||
argv: string[],
|
||||
ctx: any
|
||||
): T {
|
||||
const cmd = new commandConstructor(argv, new Config({ root: __dirname }));
|
||||
(cmd as any).getContextAsync = jest.fn(() => ctx);
|
||||
return cmd;
|
||||
}
|
||||
|
||||
export class NoErrorThrownError extends Error {}
|
||||
|
||||
export const getErrorAsync = async <TError = any>(
|
||||
call: () => unknown
|
||||
): Promise<TError | NoErrorThrownError> => {
|
||||
try {
|
||||
await call();
|
||||
throw new NoErrorThrownError();
|
||||
} catch (error: unknown) {
|
||||
return error as TError;
|
||||
}
|
||||
};
|
||||
|
||||
export const getError = <TError = any>(call: () => unknown): TError | NoErrorThrownError => {
|
||||
try {
|
||||
call();
|
||||
throw new NoErrorThrownError();
|
||||
} catch (error: unknown) {
|
||||
return error as TError;
|
||||
}
|
||||
};
|
||||
@@ -133,7 +133,9 @@ export async function createAnalyticsAsync(): Promise<AnalyticsWithOrchestration
|
||||
await UserSettings.setAsync(USER_SETTINGS_KEY_ANALYTICS_ENABLED, false);
|
||||
}
|
||||
|
||||
const analyticsEnabled = await UserSettings.getAsync(USER_SETTINGS_KEY_ANALYTICS_ENABLED, true);
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { UpdateBranchBasicInfoFragment } from '../../graphql/generated';
|
||||
|
||||
export const testBasicBranchInfo: UpdateBranchBasicInfoFragment = {
|
||||
id: 'c4fec95d-7ab1-4d35-bfe6-fe7ff05beda9',
|
||||
name: 'development',
|
||||
__typename: 'UpdateBranch',
|
||||
};
|
||||
export const testBasicBranchInfo2: UpdateBranchBasicInfoFragment = {
|
||||
id: '43ccb8a4-8aaa-47f0-9790-dcc80933441b',
|
||||
name: 'preview',
|
||||
__typename: 'UpdateBranch',
|
||||
};
|
||||
@@ -0,0 +1,107 @@
|
||||
import { EASUpdateAction, EASUpdateContext, NonInteractiveError } from '../../eas-update/utils';
|
||||
import { UpdateBranchBasicInfoFragment } from '../../graphql/generated';
|
||||
import { BranchQuery } from '../../graphql/queries/BranchQuery';
|
||||
import {
|
||||
Connection,
|
||||
Edge,
|
||||
FilterPagination,
|
||||
QueryParams,
|
||||
selectPaginatedAsync,
|
||||
} from '../../utils/relay';
|
||||
|
||||
/**
|
||||
* Select a branch for the project.
|
||||
*
|
||||
* @constructor
|
||||
* @param {function} options.filterPredicate - A predicate to filter the branches that are shown to the user. It takes a branchInfo object as a parameter and returns a boolean.
|
||||
* @param {string} options.printedType - The type of branch printed to the user. Defaults to 'branch'.
|
||||
* @param {number} options.pageSize - The number of branches to show per page. Defaults to 100.
|
||||
* @param {function} options.beforeEachFilterQuery Optional. If a filter predicate was specified, this callback function will be called before each query.
|
||||
* @args externalQueryParams The query params for the pagination.
|
||||
* @args totalNodesFetched The total number of nodes fetched so far.
|
||||
* @args dataset The dataset so far.
|
||||
* @param {function} options.afterEachFilterQuery Optional. If a filter predicate was specified, this callback function will be called after each query.
|
||||
* @args externalQueryParams The query params for the pagination.
|
||||
* @args totalNodesFetched The total number of nodes fetched so far.
|
||||
* @args dataset The dataset so far.
|
||||
* @args willFetchAgain If the query will fetch again to get a complete page.
|
||||
*/
|
||||
export class SelectBranch implements EASUpdateAction<UpdateBranchBasicInfoFragment | null> {
|
||||
constructor(
|
||||
private options: {
|
||||
filterPredicate?: (branchInfo: UpdateBranchBasicInfoFragment) => boolean;
|
||||
printedType?: string;
|
||||
pageSize?: number;
|
||||
beforeEachFilterQuery?: (
|
||||
externalQueryParams: QueryParams,
|
||||
totalNodesFetched: number,
|
||||
dataset: Edge<UpdateBranchBasicInfoFragment>[]
|
||||
) => void;
|
||||
afterEachFilterQuery?: (
|
||||
externalQueryParams: QueryParams,
|
||||
totalNodesFetched: number,
|
||||
dataset: Edge<UpdateBranchBasicInfoFragment>[],
|
||||
willFetchAgain: boolean
|
||||
) => void;
|
||||
} = {}
|
||||
) {}
|
||||
|
||||
async queryAsync(
|
||||
ctx: EASUpdateContext,
|
||||
queryParams: QueryParams
|
||||
): Promise<Connection<UpdateBranchBasicInfoFragment>> {
|
||||
const { graphqlClient, app } = ctx;
|
||||
const { projectId } = app;
|
||||
return await BranchQuery.listBranchesBasicInfoPaginatedOnAppAsync(graphqlClient, {
|
||||
appId: projectId,
|
||||
...queryParams,
|
||||
});
|
||||
}
|
||||
|
||||
async filterQueryAsync(
|
||||
ctx: EASUpdateContext,
|
||||
queryParams: QueryParams,
|
||||
filterPredicate: (branchInfo: UpdateBranchBasicInfoFragment) => boolean
|
||||
): Promise<Connection<UpdateBranchBasicInfoFragment>> {
|
||||
const queryAsync = async (
|
||||
queryParams: QueryParams
|
||||
): Promise<Connection<UpdateBranchBasicInfoFragment>> =>
|
||||
await this.queryAsync(ctx, queryParams);
|
||||
return await FilterPagination.getPageAsync({
|
||||
queryParams,
|
||||
queryAsync,
|
||||
filterPredicate,
|
||||
beforeEachQuery: this.options.beforeEachFilterQuery,
|
||||
afterEachQuery: this.options.afterEachFilterQuery,
|
||||
});
|
||||
}
|
||||
|
||||
public async runAsync(ctx: EASUpdateContext): Promise<UpdateBranchBasicInfoFragment | null> {
|
||||
const { nonInteractive } = ctx;
|
||||
const { filterPredicate } = this.options;
|
||||
const printedType = this.options.printedType ?? 'branch';
|
||||
const pageSize = this.options.pageSize ?? 100;
|
||||
if (nonInteractive) {
|
||||
throw new NonInteractiveError(
|
||||
`${printedType} selection cannot be run in non-interactive mode.`
|
||||
);
|
||||
}
|
||||
|
||||
const queryAsync = async (
|
||||
queryParams: QueryParams
|
||||
): Promise<Connection<UpdateBranchBasicInfoFragment>> =>
|
||||
filterPredicate
|
||||
? this.filterQueryAsync(ctx, queryParams, filterPredicate)
|
||||
: this.queryAsync(ctx, queryParams);
|
||||
|
||||
const getTitleAsync = async (branchInfo: UpdateBranchBasicInfoFragment): Promise<string> =>
|
||||
branchInfo.name;
|
||||
|
||||
return await selectPaginatedAsync({
|
||||
queryAsync,
|
||||
getTitleAsync,
|
||||
printedType,
|
||||
pageSize,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { createCtxMock } from '../../../eas-update/__tests__/fixtures';
|
||||
import { NonInteractiveError } from '../../../eas-update/utils';
|
||||
import { UpdateBranchBasicInfoFragment } from '../../../graphql/generated';
|
||||
import { BranchQuery } from '../../../graphql/queries/BranchQuery';
|
||||
import { promptAsync } from '../../../prompts';
|
||||
import { testBasicBranchInfo, testBasicBranchInfo2 } from '../../__tests__/fixtures';
|
||||
import { SelectBranch } from '../SelectBranch';
|
||||
|
||||
jest.mock('../../../prompts');
|
||||
jest.mock('../../../graphql/queries/BranchQuery');
|
||||
describe(SelectBranch, () => {
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
it('returns null if project has no branchs', async () => {
|
||||
const ctx = createCtxMock();
|
||||
jest.mocked(BranchQuery.listBranchesBasicInfoPaginatedOnAppAsync).mockResolvedValue({
|
||||
edges: [],
|
||||
pageInfo: {
|
||||
hasPreviousPage: false,
|
||||
hasNextPage: false,
|
||||
},
|
||||
});
|
||||
const selectBranch = new SelectBranch();
|
||||
const branch = await selectBranch.runAsync(ctx);
|
||||
expect(branch).toBeNull();
|
||||
});
|
||||
it('still prompts if project has only one branch', async () => {
|
||||
const ctx = createCtxMock();
|
||||
jest.mocked(BranchQuery.listBranchesBasicInfoPaginatedOnAppAsync).mockResolvedValue({
|
||||
edges: [{ node: testBasicBranchInfo, cursor: 'cursor' }],
|
||||
pageInfo: {
|
||||
hasPreviousPage: false,
|
||||
hasNextPage: false,
|
||||
},
|
||||
});
|
||||
jest.mocked(promptAsync).mockImplementation(async () => ({
|
||||
item: testBasicBranchInfo,
|
||||
}));
|
||||
const selectBranch = new SelectBranch();
|
||||
const branch = await selectBranch.runAsync(ctx);
|
||||
expect(branch).toBe(testBasicBranchInfo);
|
||||
expect(promptAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
it('returns the selected branch', async () => {
|
||||
jest.mocked(BranchQuery.listBranchesBasicInfoPaginatedOnAppAsync).mockResolvedValue({
|
||||
edges: [
|
||||
{ node: testBasicBranchInfo, cursor: 'cursor' },
|
||||
{ node: testBasicBranchInfo2, cursor: 'cursor' },
|
||||
],
|
||||
pageInfo: {
|
||||
hasPreviousPage: false,
|
||||
hasNextPage: false,
|
||||
},
|
||||
});
|
||||
jest.mocked(promptAsync).mockImplementation(async () => ({
|
||||
item: testBasicBranchInfo,
|
||||
}));
|
||||
const ctx = createCtxMock();
|
||||
const selectBranch = new SelectBranch();
|
||||
const branch = await selectBranch.runAsync(ctx);
|
||||
expect(branch).toBe(testBasicBranchInfo);
|
||||
});
|
||||
it('returns the only available branch with a filter', async () => {
|
||||
jest.mocked(BranchQuery.listBranchesBasicInfoPaginatedOnAppAsync).mockResolvedValue({
|
||||
edges: [
|
||||
{ node: testBasicBranchInfo, cursor: 'cursor' },
|
||||
{ node: testBasicBranchInfo2, cursor: 'cursor' },
|
||||
],
|
||||
pageInfo: {
|
||||
hasPreviousPage: false,
|
||||
hasNextPage: false,
|
||||
},
|
||||
});
|
||||
const ctx = createCtxMock();
|
||||
const selectBranch = new SelectBranch({
|
||||
filterPredicate: (branchInfo: UpdateBranchBasicInfoFragment) =>
|
||||
branchInfo === testBasicBranchInfo,
|
||||
});
|
||||
const branch = await selectBranch.runAsync(ctx);
|
||||
expect(branch).toBe(testBasicBranchInfo);
|
||||
});
|
||||
it('throws an error in Non-Interactive Mode', async () => {
|
||||
const ctx = createCtxMock({
|
||||
nonInteractive: true,
|
||||
});
|
||||
const selectBranch = new SelectBranch();
|
||||
|
||||
// dont fail if users are running in non-interactive mode
|
||||
await expect(selectBranch.runAsync(ctx)).rejects.toThrowError(NonInteractiveError);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,597 @@
|
||||
import { Platform } from '@expo/eas-build-job';
|
||||
import { CombinedError } from '@urql/core';
|
||||
import { GraphQLError } from 'graphql/error';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
import { getError } from '../../__tests__/commands/utils';
|
||||
import { EasCommandError } from '../../commandUtils/errors';
|
||||
import Build from '../../commands/build';
|
||||
import Log, { link } from '../../log';
|
||||
import { handleBuildRequestError } from '../build';
|
||||
import {
|
||||
EasBuildDownForMaintenanceError,
|
||||
EasBuildFreeTierDisabledAndroidError,
|
||||
EasBuildFreeTierDisabledError,
|
||||
EasBuildFreeTierDisabledIOSError,
|
||||
EasBuildFreeTierIosLimitExceededError,
|
||||
EasBuildFreeTierLimitExceededError,
|
||||
EasBuildResourceClassNotAvailableInFreeTierError,
|
||||
EasBuildTooManyPendingBuildsError,
|
||||
RequestValidationError,
|
||||
TurtleDeprecatedJobFormatError,
|
||||
} from '../errors';
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
const EXPECTED_STRINGIFIED_GRAPHQL_ERROR_JSON = `[
|
||||
{
|
||||
"name": "GraphQLError",
|
||||
"extensions": {},
|
||||
"message": "Error 1"
|
||||
},
|
||||
{
|
||||
"name": "GraphQLError",
|
||||
"extensions": {},
|
||||
"message": "Error 2"
|
||||
},
|
||||
{
|
||||
"name": "GraphQLError",
|
||||
"extensions": {},
|
||||
"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 mockRequestId = uuidv4();
|
||||
const getGraphQLError = (message: string, errorCode: string): GraphQLError => {
|
||||
return new GraphQLError(message, null, null, null, null, null, {
|
||||
errorCode,
|
||||
requestId: mockRequestId,
|
||||
});
|
||||
};
|
||||
|
||||
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('for TURTLE_DEPRECATED_JOB_FORMAT', () => {
|
||||
describe('with Android', () => {
|
||||
it('throws correct EasCommandError subclass', async () => {
|
||||
const platform = Platform.ANDROID;
|
||||
const graphQLError = getGraphQLError('Error 1', 'TURTLE_DEPRECATED_JOB_FORMAT');
|
||||
const graphQLErrors = [graphQLError];
|
||||
const error = new CombinedError({ graphQLErrors });
|
||||
|
||||
const handleBuildRequestErrorThrownError = getError<TurtleDeprecatedJobFormatError>(
|
||||
() => {
|
||||
handleBuildRequestError(error, platform);
|
||||
}
|
||||
);
|
||||
|
||||
assertReThrownError(
|
||||
handleBuildRequestErrorThrownError,
|
||||
TurtleDeprecatedJobFormatError,
|
||||
'Error 1'
|
||||
);
|
||||
});
|
||||
|
||||
it('throws correct EasBuildResourceClassNotAvailableInFreeTierError', async () => {
|
||||
const platform = Platform.ANDROID;
|
||||
const graphQLError = getGraphQLError(
|
||||
'Error 1',
|
||||
'EAS_BUILD_RESOURCE_CLASS_NOT_AVAILABLE_IN_FREE_TIER'
|
||||
);
|
||||
const graphQLErrors = [graphQLError];
|
||||
const error = new CombinedError({ graphQLErrors });
|
||||
|
||||
const handleBuildRequestErrorThrownError =
|
||||
getError<EasBuildResourceClassNotAvailableInFreeTierError>(() => {
|
||||
handleBuildRequestError(error, platform);
|
||||
});
|
||||
|
||||
assertReThrownError(
|
||||
handleBuildRequestErrorThrownError,
|
||||
EasBuildResourceClassNotAvailableInFreeTierError,
|
||||
'Error 1'
|
||||
);
|
||||
});
|
||||
});
|
||||
describe('with iOS', () => {
|
||||
it('throws correct EasCommandError subclass', async () => {
|
||||
const platform = Platform.IOS;
|
||||
const graphQLError = getGraphQLError('Error 1', 'TURTLE_DEPRECATED_JOB_FORMAT');
|
||||
const graphQLErrors = [graphQLError];
|
||||
const error = new CombinedError({ graphQLErrors });
|
||||
|
||||
const handleBuildRequestErrorThrownError = getError<TurtleDeprecatedJobFormatError>(
|
||||
() => {
|
||||
handleBuildRequestError(error, platform);
|
||||
}
|
||||
);
|
||||
|
||||
assertReThrownError(
|
||||
handleBuildRequestErrorThrownError,
|
||||
TurtleDeprecatedJobFormatError,
|
||||
'Error 1'
|
||||
);
|
||||
});
|
||||
|
||||
it('throws correct EasBuildResourceClassNotAvailableInFreeTierError', async () => {
|
||||
const platform = Platform.IOS;
|
||||
const graphQLError = getGraphQLError(
|
||||
'Error 1',
|
||||
'EAS_BUILD_RESOURCE_CLASS_NOT_AVAILABLE_IN_FREE_TIER'
|
||||
);
|
||||
const graphQLErrors = [graphQLError];
|
||||
const error = new CombinedError({ graphQLErrors });
|
||||
|
||||
const handleBuildRequestErrorThrownError =
|
||||
getError<EasBuildResourceClassNotAvailableInFreeTierError>(() => {
|
||||
handleBuildRequestError(error, platform);
|
||||
});
|
||||
|
||||
assertReThrownError(
|
||||
handleBuildRequestErrorThrownError,
|
||||
EasBuildResourceClassNotAvailableInFreeTierError,
|
||||
'Error 1'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('for EAS_BUILD_FREE_TIER_DISABLED', () => {
|
||||
describe('with Android', () => {
|
||||
it('throws correct EasCommandError subclass', async () => {
|
||||
const platform = Platform.ANDROID;
|
||||
const graphQLError = getGraphQLError('Error 1', 'EAS_BUILD_FREE_TIER_DISABLED');
|
||||
const graphQLErrors = [graphQLError];
|
||||
const error = new CombinedError({ graphQLErrors });
|
||||
|
||||
const handleBuildRequestErrorThrownError = getError<EasBuildFreeTierDisabledError>(
|
||||
() => {
|
||||
handleBuildRequestError(error, platform);
|
||||
}
|
||||
);
|
||||
|
||||
assertReThrownError(
|
||||
handleBuildRequestErrorThrownError,
|
||||
EasBuildFreeTierDisabledError,
|
||||
'Error 1'
|
||||
);
|
||||
});
|
||||
});
|
||||
describe('with iOS', () => {
|
||||
it('throws correct EasCommandError subclass', async () => {
|
||||
const platform = Platform.IOS;
|
||||
const graphQLError = getGraphQLError('Error 1', 'EAS_BUILD_FREE_TIER_DISABLED');
|
||||
const graphQLErrors = [graphQLError];
|
||||
const error = new CombinedError({ graphQLErrors });
|
||||
|
||||
const handleBuildRequestErrorThrownError = getError<EasBuildFreeTierDisabledError>(
|
||||
() => {
|
||||
handleBuildRequestError(error, platform);
|
||||
}
|
||||
);
|
||||
|
||||
assertReThrownError(
|
||||
handleBuildRequestErrorThrownError,
|
||||
EasBuildFreeTierDisabledError,
|
||||
'Error 1'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
describe('for EAS_BUILD_FREE_TIER_DISABLED_IOS', () => {
|
||||
it('throws correct EasCommandError subclass', 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, 'Error 1');
|
||||
}
|
||||
});
|
||||
});
|
||||
describe('for EAS_BUILD_FREE_TIER_DISABLED_ANDROID', () => {
|
||||
it('throws correct EasCommandError subclass', async () => {
|
||||
const platform = Platform.ANDROID;
|
||||
const graphQLError = getGraphQLError('Error 1', 'EAS_BUILD_FREE_TIER_DISABLED_ANDROID');
|
||||
const graphQLErrors = [graphQLError];
|
||||
const error = new CombinedError({ graphQLErrors });
|
||||
|
||||
const handleBuildRequestErrorThrownError = getError<EasBuildFreeTierDisabledAndroidError>(
|
||||
() => {
|
||||
handleBuildRequestError(error, platform);
|
||||
}
|
||||
);
|
||||
|
||||
assertReThrownError(
|
||||
handleBuildRequestErrorThrownError,
|
||||
EasBuildFreeTierDisabledAndroidError,
|
||||
'Error 1'
|
||||
);
|
||||
});
|
||||
});
|
||||
describe('for EAS_BUILD_FREE_TIER_LIMIT_EXCEEDED', () => {
|
||||
it('throws correct EasCommandError subclass', async () => {
|
||||
const platform = Platform.ANDROID;
|
||||
const graphQLError = getGraphQLError('Error 1', 'EAS_BUILD_FREE_TIER_LIMIT_EXCEEDED');
|
||||
const graphQLErrors = [graphQLError];
|
||||
const error = new CombinedError({ graphQLErrors });
|
||||
|
||||
const handleBuildRequestErrorThrownError = getError<EasBuildFreeTierLimitExceededError>(
|
||||
() => {
|
||||
handleBuildRequestError(error, platform);
|
||||
}
|
||||
);
|
||||
|
||||
assertReThrownError(
|
||||
handleBuildRequestErrorThrownError,
|
||||
EasBuildFreeTierLimitExceededError,
|
||||
'Error 1'
|
||||
);
|
||||
});
|
||||
});
|
||||
describe('for EAS_BUILD_FREE_TIER_IOS_LIMIT_EXCEEDED', () => {
|
||||
it('throws correct EasCommandError subclass', async () => {
|
||||
const platform = Platform.ANDROID;
|
||||
const graphQLError = getGraphQLError('Error 1', 'EAS_BUILD_FREE_TIER_IOS_LIMIT_EXCEEDED');
|
||||
const graphQLErrors = [graphQLError];
|
||||
const error = new CombinedError({ graphQLErrors });
|
||||
|
||||
const handleBuildRequestErrorThrownError =
|
||||
getError<EasBuildFreeTierIosLimitExceededError>(() => {
|
||||
handleBuildRequestError(error, platform);
|
||||
});
|
||||
|
||||
assertReThrownError(
|
||||
handleBuildRequestErrorThrownError,
|
||||
EasBuildFreeTierIosLimitExceededError,
|
||||
'Error 1'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('for VALIDATION_ERROR', () => {
|
||||
describe('with Android', () => {
|
||||
it('throws correct EasCommandError subclass', async () => {
|
||||
const platform = Platform.ANDROID;
|
||||
const graphQLError = getGraphQLError('Error 1', 'VALIDATION_ERROR');
|
||||
const graphQLErrors = [graphQLError];
|
||||
const error = new CombinedError({ graphQLErrors });
|
||||
|
||||
const handleBuildRequestErrorThrownError = getError<RequestValidationError>(() => {
|
||||
handleBuildRequestError(error, platform);
|
||||
});
|
||||
|
||||
assertReThrownError(
|
||||
handleBuildRequestErrorThrownError,
|
||||
RequestValidationError,
|
||||
'Error 1'
|
||||
);
|
||||
});
|
||||
});
|
||||
describe('with iOS', () => {
|
||||
it('throws correct EasCommandError subclass', async () => {
|
||||
const platform = Platform.IOS;
|
||||
const graphQLError = getGraphQLError('Error 1', 'VALIDATION_ERROR');
|
||||
const graphQLErrors = [graphQLError];
|
||||
const error = new CombinedError({ graphQLErrors });
|
||||
|
||||
const handleBuildRequestErrorThrownError = getError<RequestValidationError>(() => {
|
||||
handleBuildRequestError(error, platform);
|
||||
});
|
||||
|
||||
assertReThrownError(
|
||||
handleBuildRequestErrorThrownError,
|
||||
RequestValidationError,
|
||||
'Error 1'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('error is other expected graphQL-related error', () => {
|
||||
describe('for EAS_BUILD_DOWN_FOR_MAINTENANCE', () => {
|
||||
describe('with Android', () => {
|
||||
it('throws correct EasCommandError subclass', async () => {
|
||||
const platform = Platform.ANDROID;
|
||||
const graphQLError = getGraphQLError('Error 1', 'EAS_BUILD_DOWN_FOR_MAINTENANCE');
|
||||
const graphQLErrors = [graphQLError];
|
||||
const error = new CombinedError({ graphQLErrors });
|
||||
|
||||
const handleBuildRequestErrorThrownError = getError<EasBuildDownForMaintenanceError>(
|
||||
() => {
|
||||
handleBuildRequestError(error, platform);
|
||||
}
|
||||
);
|
||||
|
||||
assertReThrownError(
|
||||
handleBuildRequestErrorThrownError,
|
||||
EasBuildDownForMaintenanceError,
|
||||
EXPECTED_EAS_BUILD_DOWN_MESSAGE
|
||||
);
|
||||
});
|
||||
});
|
||||
describe('with iOS', () => {
|
||||
it('throws correct EasCommandError subclass', async () => {
|
||||
const platform = Platform.IOS;
|
||||
const graphQLError = getGraphQLError('Error 1', 'EAS_BUILD_DOWN_FOR_MAINTENANCE');
|
||||
const graphQLErrors = [graphQLError];
|
||||
const error = new CombinedError({ graphQLErrors });
|
||||
|
||||
const handleBuildRequestErrorThrownError = getError<EasBuildDownForMaintenanceError>(
|
||||
() => {
|
||||
handleBuildRequestError(error, platform);
|
||||
}
|
||||
);
|
||||
|
||||
assertReThrownError(
|
||||
handleBuildRequestErrorThrownError,
|
||||
EasBuildDownForMaintenanceError,
|
||||
EXPECTED_EAS_BUILD_DOWN_MESSAGE
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('for EAS_BUILD_TOO_MANY_PENDING_BUILDS', () => {
|
||||
describe('with Android', () => {
|
||||
it('throws correct EasCommandError subclass', async () => {
|
||||
const platform = Platform.ANDROID;
|
||||
const graphQLError = getGraphQLError('Error 1', 'EAS_BUILD_TOO_MANY_PENDING_BUILDS');
|
||||
const graphQLErrors = [graphQLError];
|
||||
const error = new CombinedError({ graphQLErrors });
|
||||
|
||||
const handleBuildRequestErrorThrownError = getError<EasBuildTooManyPendingBuildsError>(
|
||||
() => {
|
||||
handleBuildRequestError(error, platform);
|
||||
}
|
||||
);
|
||||
|
||||
assertReThrownError(
|
||||
handleBuildRequestErrorThrownError,
|
||||
EasBuildTooManyPendingBuildsError,
|
||||
EXPECTED_MAX_BUILD_COUNT_MESSAGE_ANDROID
|
||||
);
|
||||
});
|
||||
});
|
||||
describe('with iOS', () => {
|
||||
it('throws correct EasCommandError subclass', async () => {
|
||||
const platform = Platform.IOS;
|
||||
const graphQLError = getGraphQLError('Error 1', 'EAS_BUILD_TOO_MANY_PENDING_BUILDS');
|
||||
const graphQLErrors = [graphQLError];
|
||||
const error = new CombinedError({ graphQLErrors });
|
||||
|
||||
const handleBuildRequestErrorThrownError = getError<EasBuildTooManyPendingBuildsError>(
|
||||
() => {
|
||||
handleBuildRequestError(error, platform);
|
||||
}
|
||||
);
|
||||
|
||||
assertReThrownError(
|
||||
handleBuildRequestErrorThrownError,
|
||||
EasBuildTooManyPendingBuildsError,
|
||||
EXPECTED_MAX_BUILD_COUNT_MESSAGE_IOS
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('error is unexpected graphQL-related error', () => {
|
||||
describe('with Android', () => {
|
||||
it('throws base Error class with custom message', async () => {
|
||||
const platform = Platform.ANDROID;
|
||||
const graphQLError = getGraphQLError('Error 1', 'UNKNOWN_GRAPHQL_ERROR');
|
||||
const graphQLErrors = [graphQLError];
|
||||
const error = new CombinedError({ graphQLErrors });
|
||||
const expectedMessage = `${EXPECTED_GENERIC_MESSAGE}\nRequest ID: ${mockRequestId}\nError message: Error 1`;
|
||||
|
||||
const handleBuildRequestErrorThrownError = getError<Error>(() => {
|
||||
handleBuildRequestError(error, platform);
|
||||
});
|
||||
|
||||
assertReThrownError(handleBuildRequestErrorThrownError, Error, expectedMessage);
|
||||
});
|
||||
it('throws base Error class with message including all error messages if combined error has multiple GraphQL errors', async () => {
|
||||
const platform = Platform.ANDROID;
|
||||
const graphQLErrors = [
|
||||
getGraphQLError('Error 1', 'UNKNOWN_GRAPHQL_ERROR'),
|
||||
getGraphQLError('Error 2', 'OTHER_GRAPHQL_ERROR'),
|
||||
getGraphQLError('Error 3', 'YET_ANOTHER_GRAPHQL_ERROR'),
|
||||
];
|
||||
const error = new CombinedError({ graphQLErrors });
|
||||
const expectedMessage =
|
||||
`${EXPECTED_GENERIC_MESSAGE}\n` +
|
||||
`Request ID: ${mockRequestId}\n` +
|
||||
'Error message: Error 1\n' +
|
||||
`Request ID: ${mockRequestId}\n` +
|
||||
'Error message: Error 2\n' +
|
||||
`Request ID: ${mockRequestId}\n` +
|
||||
'Error message: Error 3';
|
||||
|
||||
const handleBuildRequestErrorThrownError = getError<Error>(() => {
|
||||
handleBuildRequestError(error, platform);
|
||||
});
|
||||
|
||||
assertReThrownError(handleBuildRequestErrorThrownError, Error, expectedMessage);
|
||||
});
|
||||
describe('without request ID', () => {
|
||||
it('throws base Error class with custom message without request ID line', async () => {
|
||||
const platform = Platform.ANDROID;
|
||||
const graphQLError = getGraphQLError('Error 1', 'UNKNOWN_GRAPHQL_ERROR');
|
||||
delete graphQLError.extensions.requestId;
|
||||
const graphQLErrors = [graphQLError];
|
||||
const error = new CombinedError({ graphQLErrors });
|
||||
const expectedMessage = `${EXPECTED_GENERIC_MESSAGE}\nError message: Error 1`;
|
||||
|
||||
const handleBuildRequestErrorThrownError = getError<Error>(() => {
|
||||
handleBuildRequestError(error, platform);
|
||||
});
|
||||
|
||||
assertReThrownError(handleBuildRequestErrorThrownError, Error, expectedMessage);
|
||||
});
|
||||
});
|
||||
});
|
||||
describe('with iOS', () => {
|
||||
it('throws base Error class with custom message', async () => {
|
||||
const platform = Platform.IOS;
|
||||
const graphQLError = getGraphQLError('Error 1', 'UNKNOWN_GRAPHQL_ERROR');
|
||||
const graphQLErrors = [graphQLError];
|
||||
const error = new CombinedError({ graphQLErrors });
|
||||
const expectedMessage = `${EXPECTED_GENERIC_MESSAGE}\nRequest ID: ${mockRequestId}\nError message: Error 1`;
|
||||
|
||||
const handleBuildRequestErrorThrownError = getError<Error>(() => {
|
||||
handleBuildRequestError(error, platform);
|
||||
});
|
||||
|
||||
assertReThrownError(handleBuildRequestErrorThrownError, Error, expectedMessage);
|
||||
});
|
||||
it('throws base Error class with message including all error messages if combined error has multiple GraphQL errors', async () => {
|
||||
const platform = Platform.IOS;
|
||||
const graphQLErrors = [
|
||||
getGraphQLError('Error 1', 'UNKNOWN_GRAPHQL_ERROR'),
|
||||
getGraphQLError('Error 2', 'OTHER_GRAPHQL_ERROR'),
|
||||
getGraphQLError('Error 3', 'YET_ANOTHER_GRAPHQL_ERROR'),
|
||||
];
|
||||
const error = new CombinedError({ graphQLErrors });
|
||||
const expectedMessage =
|
||||
`${EXPECTED_GENERIC_MESSAGE}\n` +
|
||||
`Request ID: ${mockRequestId}\n` +
|
||||
'Error message: Error 1\n' +
|
||||
`Request ID: ${mockRequestId}\n` +
|
||||
'Error message: Error 2\n' +
|
||||
`Request ID: ${mockRequestId}\n` +
|
||||
'Error message: Error 3';
|
||||
|
||||
const handleBuildRequestErrorThrownError = getError<Error>(() => {
|
||||
handleBuildRequestError(error, platform);
|
||||
});
|
||||
|
||||
assertReThrownError(handleBuildRequestErrorThrownError, Error, expectedMessage);
|
||||
});
|
||||
describe('without request ID', () => {
|
||||
it('throws base Error class with custom message without request ID line', async () => {
|
||||
const platform = Platform.IOS;
|
||||
const graphQLError = getGraphQLError('Error 1', 'UNKNOWN_GRAPHQL_ERROR');
|
||||
delete graphQLError.extensions.requestId;
|
||||
const graphQLErrors = [graphQLError];
|
||||
const error = new CombinedError({ graphQLErrors });
|
||||
const expectedMessage = `${EXPECTED_GENERIC_MESSAGE}\nError message: Error 1`;
|
||||
|
||||
const handleBuildRequestErrorThrownError = getError<Error>(() => {
|
||||
handleBuildRequestError(error, platform);
|
||||
});
|
||||
|
||||
assertReThrownError(handleBuildRequestErrorThrownError, Error, expectedMessage);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('error is unexpected non-graphQL-related error', () => {
|
||||
describe('with Android', () => {
|
||||
it('throws base Error class with propagated message', async () => {
|
||||
const platform = Platform.ANDROID;
|
||||
const error = new Error('Non-graphQL-related error');
|
||||
|
||||
const handleBuildRequestErrorThrownError = getError<Error>(() => {
|
||||
handleBuildRequestError(error, platform);
|
||||
});
|
||||
|
||||
expect(handleBuildRequestErrorThrownError).toStrictEqual(error);
|
||||
});
|
||||
});
|
||||
describe('with iOS', () => {
|
||||
it('throws base Error class with propagated message', async () => {
|
||||
const platform = Platform.IOS;
|
||||
const error = new Error('Non-graphQL-related error');
|
||||
|
||||
const handleBuildRequestErrorThrownError = getError<Error>(() => {
|
||||
handleBuildRequestError(error, platform);
|
||||
});
|
||||
|
||||
expect(handleBuildRequestErrorThrownError).toStrictEqual(error);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,12 @@
|
||||
import { Android } from '@expo/eas-build-job';
|
||||
|
||||
import { AndroidBuildType, AndroidJobInput } from '../../graphql/generated';
|
||||
import { transformBuildTrigger, transformProjectArchive, transformWorkflow } from '../graphql';
|
||||
import {
|
||||
transformBuildMode,
|
||||
transformBuildTrigger,
|
||||
transformProjectArchive,
|
||||
transformWorkflow,
|
||||
} from '../graphql';
|
||||
|
||||
export function transformJob(job: Android.Job): AndroidJobInput {
|
||||
return {
|
||||
@@ -22,6 +27,8 @@ export function transformJob(job: Android.Job): AndroidJobInput {
|
||||
buildType: job.buildType && transformBuildType(job.buildType),
|
||||
developmentClient: job.developmentClient,
|
||||
experimental: job.experimental,
|
||||
mode: transformBuildMode(job.mode),
|
||||
customBuildConfig: job.customBuildConfig,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ 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 { BuildContext } from '../context';
|
||||
@@ -23,8 +24,7 @@ interface JobData {
|
||||
|
||||
const cacheDefaults = {
|
||||
disabled: false,
|
||||
customPaths: [],
|
||||
cacheDefaultPaths: true,
|
||||
paths: [],
|
||||
};
|
||||
|
||||
export async function prepareJobAsync(
|
||||
@@ -49,11 +49,15 @@ export async function prepareJobAsync(
|
||||
}
|
||||
: {};
|
||||
|
||||
let buildType = ctx.buildProfile.buildType;
|
||||
if (!buildType && !buildProfile.gradleCommand && ctx.buildProfile.distribution === 'internal') {
|
||||
let buildType = buildProfile.buildType;
|
||||
if (!buildType && !buildProfile.gradleCommand && 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,
|
||||
@@ -62,6 +66,8 @@ export async function prepareJobAsync(
|
||||
builderEnvironment: {
|
||||
image: buildProfile.image,
|
||||
node: buildProfile.node,
|
||||
pnpm: buildProfile.pnpm,
|
||||
bun: buildProfile.bun,
|
||||
yarn: buildProfile.yarn,
|
||||
ndk: buildProfile.ndk,
|
||||
expoCli: buildProfile.expoCli,
|
||||
@@ -75,8 +81,8 @@ export async function prepareJobAsync(
|
||||
secrets: {
|
||||
...buildCredentials,
|
||||
},
|
||||
releaseChannel: ctx.buildProfile.releaseChannel,
|
||||
updates: { channel: ctx.buildProfile.channel },
|
||||
releaseChannel: buildProfile.releaseChannel,
|
||||
updates: { channel: buildProfile.channel },
|
||||
developmentClient: buildProfile.developmentClient,
|
||||
gradleCommand: buildProfile.gradleCommand,
|
||||
applicationArchivePath: buildProfile.applicationArchivePath ?? buildProfile.artifactPath,
|
||||
@@ -89,10 +95,15 @@ export async function prepareJobAsync(
|
||||
},
|
||||
}),
|
||||
experimental: {
|
||||
prebuildCommand: ctx.buildProfile.prebuildCommand,
|
||||
prebuildCommand: buildProfile.prebuildCommand,
|
||||
},
|
||||
mode: BuildMode.BUILD,
|
||||
mode: buildProfile.config ? BuildMode.CUSTOM : BuildMode.BUILD,
|
||||
triggeredBy: BuildTrigger.EAS_CLI,
|
||||
...(maybeCustomBuildConfigPath && {
|
||||
customBuildConfig: {
|
||||
path: maybeCustomBuildConfigPath,
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
return sanitizeJob(job);
|
||||
|
||||
@@ -4,12 +4,14 @@ import assert from 'assert';
|
||||
import chalk from 'chalk';
|
||||
import cliProgress from 'cli-progress';
|
||||
import fs from 'fs-extra';
|
||||
import { GraphQLError } from 'graphql/error';
|
||||
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,
|
||||
@@ -36,6 +38,20 @@ import { createProgressTracker } from '../utils/progress';
|
||||
import { sleepAsync } from '../utils/promise';
|
||||
import { getVcsClient } from '../vcs';
|
||||
import { BuildContext } from './context';
|
||||
import {
|
||||
EasBuildDownForMaintenanceError,
|
||||
EasBuildFreeTierDisabledAndroidError,
|
||||
EasBuildFreeTierDisabledError,
|
||||
EasBuildFreeTierDisabledIOSError,
|
||||
EasBuildFreeTierIosLimitExceededError,
|
||||
EasBuildFreeTierLimitExceededError,
|
||||
EasBuildLegacyResourceClassNotAvailableError,
|
||||
EasBuildProjectArchiveUploadError,
|
||||
EasBuildResourceClassNotAvailableInFreeTierError,
|
||||
EasBuildTooManyPendingBuildsError,
|
||||
RequestValidationError,
|
||||
TurtleDeprecatedJobFormatError,
|
||||
} from './errors';
|
||||
import { transformMetadata } from './graphql';
|
||||
import { LocalBuildMode, runLocalBuildAsync } from './local';
|
||||
import { collectMetadataAsync } from './metadata';
|
||||
@@ -70,9 +86,14 @@ interface Builder<TPlatform extends Platform, Credentials, TJob extends Job> {
|
||||
|
||||
export type BuildRequestSender = () => Promise<BuildFragment | undefined>;
|
||||
|
||||
function resolveBuildParamsInput<T extends Platform>(ctx: BuildContext<T>): BuildParamsInput {
|
||||
function resolveBuildParamsInput<T extends Platform>(
|
||||
ctx: BuildContext<T>,
|
||||
metadata: Metadata
|
||||
): BuildParamsInput {
|
||||
return {
|
||||
resourceClass: ctx.resourceClass,
|
||||
sdkVersion: metadata.sdkVersion,
|
||||
reactNativeVersion: metadata.reactNativeVersion,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -132,7 +153,7 @@ export async function prepareBuildRequestForPlatformAsync<
|
||||
assert(projectArchive);
|
||||
|
||||
const metadata = await collectMetadataAsync(ctx);
|
||||
const buildParams = resolveBuildParamsInput(ctx);
|
||||
const buildParams = resolveBuildParamsInput(ctx, metadata);
|
||||
const job = await builder.prepareJobAsync(ctx, {
|
||||
projectArchive,
|
||||
credentials: credentialsResult?.credentials,
|
||||
@@ -161,39 +182,50 @@ export async function prepareBuildRequestForPlatformAsync<
|
||||
};
|
||||
}
|
||||
|
||||
const SERVER_SIDE_DEFINED_ERRORS = [
|
||||
'TURTLE_DEPRECATED_JOB_FORMAT',
|
||||
'EAS_BUILD_FREE_TIER_DISABLED',
|
||||
'EAS_BUILD_FREE_TIER_DISABLED_IOS',
|
||||
'EAS_BUILD_FREE_TIER_DISABLED_ANDROID',
|
||||
'VALIDATION_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,
|
||||
EAS_BUILD_FREE_TIER_LIMIT_EXCEEDED: EasBuildFreeTierLimitExceededError,
|
||||
EAS_BUILD_FREE_TIER_IOS_LIMIT_EXCEEDED: EasBuildFreeTierIosLimitExceededError,
|
||||
EAS_BUILD_RESOURCE_CLASS_NOT_AVAILABLE_IN_FREE_TIER:
|
||||
EasBuildResourceClassNotAvailableInFreeTierError,
|
||||
EAS_BUILD_LEGACY_RESOURCE_CLASS_NOT_AVAILABLE: EasBuildLegacyResourceClassNotAvailableError,
|
||||
VALIDATION_ERROR: RequestValidationError,
|
||||
};
|
||||
|
||||
function handleBuildRequestError(error: any, platform: Platform): never {
|
||||
export function handleBuildRequestError(error: any, platform: Platform): never {
|
||||
Log.debug(JSON.stringify(error.graphQLErrors, null, 2));
|
||||
|
||||
if (SERVER_SIDE_DEFINED_ERRORS.includes(error?.graphQLErrors?.[0]?.extensions?.errorCode)) {
|
||||
Log.error(error?.graphQLErrors?.[0]?.message);
|
||||
throw new Error('Build request failed.');
|
||||
} else if (
|
||||
error?.graphQLErrors?.[0]?.extensions?.errorCode === 'EAS_BUILD_DOWN_FOR_MAINTENANCE'
|
||||
) {
|
||||
Log.error(
|
||||
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.`
|
||||
);
|
||||
throw new Error('Build request failed.');
|
||||
} else if (
|
||||
error?.graphQLErrors?.[0]?.extensions?.errorCode === 'EAS_BUILD_TOO_MANY_PENDING_BUILDS'
|
||||
) {
|
||||
Log.error(
|
||||
} 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.`
|
||||
);
|
||||
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, report the issue.'
|
||||
const errorMessage = error.graphQLErrors
|
||||
.map((graphQLError: GraphQLError) => {
|
||||
const requestIdLine = graphQLError?.extensions?.requestId
|
||||
? `\nRequest ID: ${graphQLError.extensions.requestId}`
|
||||
: '';
|
||||
const errorMessageLine = graphQLError?.message
|
||||
? `\nError message: ${graphQLError.message}`
|
||||
: '';
|
||||
return `${requestIdLine}${errorMessageLine}`;
|
||||
})
|
||||
.join('');
|
||||
throw new Error(
|
||||
`Build request failed. Make sure you are using the latest eas-cli version. If the problem persists, report the issue.${errorMessage}`
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
@@ -225,6 +257,10 @@ async function uploadProjectAsync<TPlatform extends Platform>(
|
||||
);
|
||||
}
|
||||
|
||||
if (projectTarball.size > 2 * 1024 * 1024 * 1024) {
|
||||
throw new Error('Project archive is too big. Maximum allowed size is 2GB.');
|
||||
}
|
||||
|
||||
projectTarballPath = projectTarball.path;
|
||||
|
||||
const bucketKey = await uploadFileAtPathToGCSAsync(
|
||||
@@ -249,6 +285,12 @@ async function uploadProjectAsync<TPlatform extends Platform>(
|
||||
properties: ctx.analyticsEventProperties,
|
||||
}
|
||||
);
|
||||
} catch (err: any) {
|
||||
let errMessage = 'Failed to upload the project tarball to EAS Build';
|
||||
if (err.message) {
|
||||
errMessage += `\n\nReason: ${err.message}`;
|
||||
}
|
||||
throw new EasBuildProjectArchiveUploadError(errMessage);
|
||||
} finally {
|
||||
if (projectTarballPath) {
|
||||
await fs.remove(projectTarballPath);
|
||||
@@ -478,6 +520,7 @@ async function handleSingleBuildProgressAsync(
|
||||
}
|
||||
break;
|
||||
}
|
||||
case BuildStatus.PendingCancel:
|
||||
case BuildStatus.Canceled:
|
||||
spinner.fail('Build canceled');
|
||||
return { refetch: false };
|
||||
@@ -508,6 +551,7 @@ const statusToDisplayName: Record<BuildStatus, string> = {
|
||||
[BuildStatus.New]: 'waiting to enter the queue (concurrency limit reached)',
|
||||
[BuildStatus.InQueue]: 'in queue',
|
||||
[BuildStatus.InProgress]: 'in progress',
|
||||
[BuildStatus.PendingCancel]: 'canceled',
|
||||
[BuildStatus.Canceled]: 'canceled',
|
||||
[BuildStatus.Finished]: 'finished',
|
||||
[BuildStatus.Errored]: 'failed',
|
||||
@@ -526,7 +570,12 @@ async function handleMultipleBuildsProgressAsync(
|
||||
builds.filter(build => build.status === BuildStatus.Finished).length === buildCount;
|
||||
const allSettled =
|
||||
builds.filter(build =>
|
||||
[BuildStatus.Finished, BuildStatus.Errored, BuildStatus.Canceled].includes(build.status)
|
||||
[
|
||||
BuildStatus.Finished,
|
||||
BuildStatus.Errored,
|
||||
BuildStatus.Canceled,
|
||||
BuildStatus.PendingCancel,
|
||||
].includes(build.status)
|
||||
).length === buildCount;
|
||||
|
||||
if (allSettled) {
|
||||
@@ -607,7 +656,7 @@ function formatAccountSubscriptionsUrl(accountName: string): string {
|
||||
}
|
||||
|
||||
async function updateIosBuildProfilesToUseM1WorkersAsync(projectDir: string): Promise<void> {
|
||||
const easJsonAccessor = new EasJsonAccessor(projectDir);
|
||||
const easJsonAccessor = EasJsonAccessor.fromProjectPath(projectDir);
|
||||
await easJsonAccessor.readRawJsonAsync();
|
||||
|
||||
const profileNames = await EasJsonUtils.getBuildProfileNamesAsync(easJsonAccessor);
|
||||
@@ -615,7 +664,7 @@ async function updateIosBuildProfilesToUseM1WorkersAsync(projectDir: string): Pr
|
||||
for (const profileName of profileNames) {
|
||||
easJsonRawObject.build[profileName].ios = {
|
||||
...easJsonRawObject.build[profileName].ios,
|
||||
resourceClass: ResourceClass.M1_MEDIUM,
|
||||
resourceClass: ResourceClass.M_MEDIUM,
|
||||
};
|
||||
}
|
||||
return easJsonRawObject;
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { Platform, Workflow } from '@expo/eas-build-job';
|
||||
import { EasJson, EasJsonAccessor, ResourceClass } from '@expo/eas-json';
|
||||
import { EasJson, EasJsonAccessor } from '@expo/eas-json';
|
||||
import chalk from 'chalk';
|
||||
import fs from 'fs-extra';
|
||||
|
||||
import Log from '../log';
|
||||
import Log, { learnMore } from '../log';
|
||||
import { resolveWorkflowAsync } from '../project/workflow';
|
||||
import { easCliVersion } from '../utils/easCli';
|
||||
import { getVcsClient } from '../vcs';
|
||||
@@ -14,6 +14,10 @@ interface ConfigureParams {
|
||||
nonInteractive: boolean;
|
||||
}
|
||||
|
||||
export async function easJsonExistsAsync(projectDir: string): Promise<boolean> {
|
||||
return await fs.pathExists(EasJsonAccessor.formatEasJsonPath(projectDir));
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates eas.json if it does not exist.
|
||||
*
|
||||
@@ -24,7 +28,7 @@ interface ConfigureParams {
|
||||
export async function ensureProjectConfiguredAsync(
|
||||
configureParams: ConfigureParams
|
||||
): Promise<boolean> {
|
||||
if (await fs.pathExists(EasJsonAccessor.formatEasJsonPath(configureParams.projectDir))) {
|
||||
if (await easJsonExistsAsync(configureParams.projectDir)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -53,21 +57,11 @@ const EAS_JSON_MANAGED_DEFAULT: EasJson = {
|
||||
development: {
|
||||
developmentClient: true,
|
||||
distribution: 'internal',
|
||||
ios: {
|
||||
resourceClass: ResourceClass.M1_MEDIUM,
|
||||
},
|
||||
},
|
||||
preview: {
|
||||
distribution: 'internal',
|
||||
ios: {
|
||||
resourceClass: ResourceClass.M1_MEDIUM,
|
||||
},
|
||||
},
|
||||
production: {
|
||||
ios: {
|
||||
resourceClass: ResourceClass.M1_MEDIUM,
|
||||
},
|
||||
},
|
||||
production: {},
|
||||
},
|
||||
submit: {
|
||||
production: {},
|
||||
@@ -86,20 +80,12 @@ const EAS_JSON_BARE_DEFAULT: EasJson = {
|
||||
},
|
||||
ios: {
|
||||
buildConfiguration: 'Debug',
|
||||
resourceClass: ResourceClass.M1_MEDIUM,
|
||||
},
|
||||
},
|
||||
preview: {
|
||||
distribution: 'internal',
|
||||
ios: {
|
||||
resourceClass: ResourceClass.M1_MEDIUM,
|
||||
},
|
||||
},
|
||||
production: {
|
||||
ios: {
|
||||
resourceClass: ResourceClass.M1_MEDIUM,
|
||||
},
|
||||
},
|
||||
production: {},
|
||||
},
|
||||
submit: {
|
||||
production: {},
|
||||
@@ -120,5 +106,9 @@ async function createEasJsonAsync(projectDir: string): Promise<void> {
|
||||
|
||||
await fs.writeFile(easJsonPath, `${JSON.stringify(easJson, null, 2)}\n`);
|
||||
await getVcsClient().trackFileAsync(easJsonPath);
|
||||
Log.withTick(`Generated ${chalk.bold('eas.json')}`);
|
||||
Log.withTick(
|
||||
`Generated ${chalk.bold('eas.json')}. ${learnMore(
|
||||
'https://docs.expo.dev/build-reference/eas-json/'
|
||||
)}`
|
||||
);
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { CredentialsContext } from '../credentials/context';
|
||||
import { Target } from '../credentials/ios/types';
|
||||
import { BuildResourceClass } from '../graphql/generated';
|
||||
import { GradleBuildContext } from '../project/android/gradle';
|
||||
import { CustomBuildConfigMetadata } from '../project/customBuildConfig';
|
||||
import { XcodeBuildContext } from '../project/ios/scheme';
|
||||
import { Actor } from '../user/User';
|
||||
import { LocalBuildOptions } from './local';
|
||||
@@ -51,6 +52,8 @@ export interface BuildContext<T extends Platform> {
|
||||
graphqlClient: ExpoGraphqlClient;
|
||||
analytics: Analytics;
|
||||
workflow: Workflow;
|
||||
customBuildConfigMetadata?: CustomBuildConfigMetadata;
|
||||
android: T extends Platform.ANDROID ? AndroidBuildContext : undefined;
|
||||
ios: T extends Platform.IOS ? IosBuildContext : undefined;
|
||||
developmentClient: boolean;
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import { Analytics, AnalyticsEventProperties, BuildEvent } from '../analytics/An
|
||||
import { DynamicConfigContextFn } from '../commandUtils/context/DynamicProjectConfigContextField';
|
||||
import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { CredentialsContext } from '../credentials/context';
|
||||
import { CustomBuildConfigMetadata } from '../project/customBuildConfig';
|
||||
import { getOwnerAccountForProjectIdAsync } from '../project/projectUtils';
|
||||
import { resolveWorkflowAsync } from '../project/workflow';
|
||||
import { Actor } from '../user/User';
|
||||
@@ -33,7 +34,8 @@ export async function createBuildContextAsync<T extends Platform>({
|
||||
actor,
|
||||
graphqlClient,
|
||||
analytics,
|
||||
getDynamicProjectConfigAsync,
|
||||
getDynamicPrivateProjectConfigAsync,
|
||||
customBuildConfigMetadata,
|
||||
}: {
|
||||
buildProfileName: string;
|
||||
buildProfile: BuildProfile<T>;
|
||||
@@ -49,14 +51,21 @@ export async function createBuildContextAsync<T extends Platform>({
|
||||
actor: Actor;
|
||||
graphqlClient: ExpoGraphqlClient;
|
||||
analytics: Analytics;
|
||||
getDynamicProjectConfigAsync: DynamicConfigContextFn;
|
||||
getDynamicPrivateProjectConfigAsync: DynamicConfigContextFn;
|
||||
customBuildConfigMetadata?: CustomBuildConfigMetadata;
|
||||
}): Promise<BuildContext<T>> {
|
||||
const { exp, projectId } = await getDynamicProjectConfigAsync({ env: buildProfile.env });
|
||||
const { exp, projectId } = await getDynamicPrivateProjectConfigAsync({ 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 developmentClient =
|
||||
buildProfile.developmentClient ??
|
||||
(platform === Platform.ANDROID
|
||||
? (buildProfile as BuildProfile<Platform.ANDROID>)?.gradleCommand === ':app:assembleDebug'
|
||||
: (buildProfile as BuildProfile<Platform.IOS>)?.buildConfiguration === 'Debug') ??
|
||||
false;
|
||||
|
||||
const credentialsCtx = new CredentialsContext({
|
||||
projectInfo: { exp, projectId },
|
||||
@@ -90,8 +99,6 @@ export async function createBuildContextAsync<T extends Platform>({
|
||||
const resourceClass = await resolveBuildResourceClassAsync(
|
||||
buildProfile,
|
||||
platform,
|
||||
projectDir,
|
||||
exp,
|
||||
resourceClassFlag
|
||||
);
|
||||
|
||||
@@ -118,6 +125,8 @@ export async function createBuildContextAsync<T extends Platform>({
|
||||
workflow,
|
||||
message,
|
||||
runFromCI,
|
||||
customBuildConfigMetadata,
|
||||
developmentClient,
|
||||
};
|
||||
if (platform === Platform.ANDROID) {
|
||||
const common = commonContext as CommonContext<Platform.ANDROID>;
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
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 EasBuildFreeTierLimitExceededError extends EasCommandError {}
|
||||
|
||||
export class EasBuildFreeTierIosLimitExceededError extends EasCommandError {}
|
||||
|
||||
export class EasBuildResourceClassNotAvailableInFreeTierError extends EasCommandError {}
|
||||
|
||||
export class EasBuildLegacyResourceClassNotAvailableError extends EasCommandError {}
|
||||
|
||||
export class RequestValidationError extends EasCommandError {}
|
||||
|
||||
export class EasBuildDownForMaintenanceError extends EasCommandError {}
|
||||
|
||||
export class EasBuildTooManyPendingBuildsError extends EasCommandError {}
|
||||
|
||||
export class EasBuildProjectArchiveUploadError extends EasCommandError {}
|
||||
@@ -91,11 +91,16 @@ export function transformIosEnterpriseProvisioning(
|
||||
}
|
||||
}
|
||||
|
||||
export function transformBuildMode(buildMode: Metadata['buildMode']): BuildMode {
|
||||
// TODO: check what in metadata
|
||||
export function transformBuildMode(buildMode: string): BuildMode {
|
||||
if (buildMode === 'build') {
|
||||
return BuildMode.Build;
|
||||
} else {
|
||||
} else if (buildMode === 'resign') {
|
||||
return BuildMode.Resign;
|
||||
} else if (buildMode === 'custom') {
|
||||
return BuildMode.Custom;
|
||||
} else {
|
||||
throw new Error(`Unsupported build mode: ${buildMode}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -77,7 +77,7 @@ describe(bumpVersionAsync, () => {
|
||||
);
|
||||
await bumpVersionAsync({
|
||||
bumpStrategy: BumpStrategy.BUILD_NUMBER,
|
||||
projectDir: '/repo',
|
||||
projectDir: '/app',
|
||||
exp: fakeExp,
|
||||
targets: [
|
||||
{
|
||||
@@ -88,9 +88,9 @@ describe(bumpVersionAsync, () => {
|
||||
],
|
||||
});
|
||||
|
||||
const appJSON = await fs.readJSON('/repo/app.json');
|
||||
const appJSON = await fs.readJSON('/app/app.json');
|
||||
const infoPlist = (await readPlistAsync(
|
||||
'/repo/ios/myproject/Info.plist'
|
||||
'/app/ios/myproject/Info.plist'
|
||||
)) as IOSConfig.InfoPlist;
|
||||
expect(fakeExp.version).toBe('1.0.0');
|
||||
expect(fakeExp.ios?.buildNumber).toBe('2');
|
||||
@@ -113,7 +113,7 @@ describe(bumpVersionAsync, () => {
|
||||
);
|
||||
await bumpVersionAsync({
|
||||
bumpStrategy: BumpStrategy.BUILD_NUMBER,
|
||||
projectDir: '/repo',
|
||||
projectDir: '/app',
|
||||
exp: fakeExp,
|
||||
targets: [
|
||||
{
|
||||
@@ -124,9 +124,9 @@ describe(bumpVersionAsync, () => {
|
||||
],
|
||||
});
|
||||
|
||||
const appJSON = await fs.readJSON('/repo/app.json');
|
||||
const appJSON = await fs.readJSON('/app/app.json');
|
||||
const infoPlist = (await readPlistAsync(
|
||||
'/repo/ios/myproject/Info2.plist'
|
||||
'/app/ios/myproject/Info2.plist'
|
||||
)) as IOSConfig.InfoPlist;
|
||||
expect(fakeExp.version).toBe('1.0.0');
|
||||
expect(fakeExp.ios?.buildNumber).toBe('2');
|
||||
@@ -148,7 +148,7 @@ describe(bumpVersionAsync, () => {
|
||||
);
|
||||
await bumpVersionAsync({
|
||||
bumpStrategy: BumpStrategy.APP_VERSION,
|
||||
projectDir: '/repo',
|
||||
projectDir: '/app',
|
||||
exp: fakeExp,
|
||||
targets: [
|
||||
{
|
||||
@@ -159,9 +159,9 @@ describe(bumpVersionAsync, () => {
|
||||
],
|
||||
});
|
||||
|
||||
const appJSON = await fs.readJSON('/repo/app.json');
|
||||
const appJSON = await fs.readJSON('/app/app.json');
|
||||
const infoPlist = (await readPlistAsync(
|
||||
'/repo/ios/myproject/Info.plist'
|
||||
'/app/ios/myproject/Info.plist'
|
||||
)) as IOSConfig.InfoPlist;
|
||||
expect(fakeExp.version).toBe('1.0.1');
|
||||
expect(fakeExp.ios?.buildNumber).toBe('1');
|
||||
@@ -183,7 +183,7 @@ describe(bumpVersionAsync, () => {
|
||||
);
|
||||
await bumpVersionAsync({
|
||||
bumpStrategy: BumpStrategy.NOOP,
|
||||
projectDir: '/repo',
|
||||
projectDir: '/app',
|
||||
exp: fakeExp,
|
||||
targets: [
|
||||
{
|
||||
@@ -194,9 +194,9 @@ describe(bumpVersionAsync, () => {
|
||||
],
|
||||
});
|
||||
|
||||
const appJSON = await fs.readJSON('/repo/app.json');
|
||||
const appJSON = await fs.readJSON('/app/app.json');
|
||||
const infoPlist = (await readPlistAsync(
|
||||
'/repo/ios/myproject/Info.plist'
|
||||
'/app/ios/myproject/Info.plist'
|
||||
)) as IOSConfig.InfoPlist;
|
||||
expect(fakeExp.version).toBe('1.0.0');
|
||||
expect(fakeExp.ios?.buildNumber).toBe('1');
|
||||
@@ -214,11 +214,11 @@ describe(bumpVersionInAppJsonAsync, () => {
|
||||
|
||||
await bumpVersionInAppJsonAsync({
|
||||
bumpStrategy: BumpStrategy.BUILD_NUMBER,
|
||||
projectDir: '/repo',
|
||||
projectDir: '/app',
|
||||
exp: fakeExp,
|
||||
});
|
||||
|
||||
const appJSON = await fs.readJSON('/repo/app.json');
|
||||
const appJSON = await fs.readJSON('/app/app.json');
|
||||
expect(fakeExp.version).toBe('1.0.0');
|
||||
expect(fakeExp.ios?.buildNumber).toBe('2');
|
||||
expect(appJSON.expo.version).toBe('1.0.0');
|
||||
@@ -230,11 +230,11 @@ describe(bumpVersionInAppJsonAsync, () => {
|
||||
|
||||
await bumpVersionInAppJsonAsync({
|
||||
bumpStrategy: BumpStrategy.APP_VERSION,
|
||||
projectDir: '/repo',
|
||||
projectDir: '/app',
|
||||
exp: fakeExp,
|
||||
});
|
||||
|
||||
const appJSON = await fs.readJSON('/repo/app.json');
|
||||
const appJSON = await fs.readJSON('/app/app.json');
|
||||
expect(fakeExp.version).toBe('1.0.1');
|
||||
expect(fakeExp.ios?.buildNumber).toBe('1');
|
||||
expect(appJSON.expo.version).toBe('1.0.1');
|
||||
@@ -246,11 +246,11 @@ describe(bumpVersionInAppJsonAsync, () => {
|
||||
|
||||
await bumpVersionInAppJsonAsync({
|
||||
bumpStrategy: BumpStrategy.NOOP,
|
||||
projectDir: '/repo',
|
||||
projectDir: '/app',
|
||||
exp: fakeExp,
|
||||
});
|
||||
|
||||
const appJSON = await fs.readJSON('/repo/app.json');
|
||||
const appJSON = await fs.readJSON('/app/app.json');
|
||||
expect(fakeExp.version).toBe('1.0.0');
|
||||
expect(fakeExp.ios?.buildNumber).toBe('1');
|
||||
expect(appJSON.expo.version).toBe('1.0.0');
|
||||
@@ -262,7 +262,7 @@ describe(readBuildNumberAsync, () => {
|
||||
describe('bare project', () => {
|
||||
it('reads the build number from native code', async () => {
|
||||
const exp = initBareWorkflowProject();
|
||||
const buildNumber = await readBuildNumberAsync('/repo', exp, {});
|
||||
const buildNumber = await readBuildNumberAsync('/app', exp, {});
|
||||
expect(buildNumber).toBe('1');
|
||||
});
|
||||
});
|
||||
@@ -270,7 +270,7 @@ describe(readBuildNumberAsync, () => {
|
||||
describe('managed project', () => {
|
||||
it('reads the build number from expo config', async () => {
|
||||
const exp = initManagedProject();
|
||||
const buildNumber = await readBuildNumberAsync('/repo', exp, {});
|
||||
const buildNumber = await readBuildNumberAsync('/app', exp, {});
|
||||
expect(buildNumber).toBe('1');
|
||||
});
|
||||
});
|
||||
@@ -280,24 +280,38 @@ describe(readShortVersionAsync, () => {
|
||||
describe('bare project', () => {
|
||||
it('reads the short version from native code', async () => {
|
||||
const exp = initBareWorkflowProject();
|
||||
const appVersion = await readShortVersionAsync('/repo', exp, {});
|
||||
const appVersion = await readShortVersionAsync('/app', exp, {});
|
||||
expect(appVersion).toBe('1.0.0');
|
||||
});
|
||||
it('evaluates interpolated build number', async () => {
|
||||
const exp = initBareWorkflowProject({
|
||||
appVersion: '$(CURRENT_PROJECT_VERSION)',
|
||||
});
|
||||
const buildNumber = await readShortVersionAsync('/repo', exp, {
|
||||
const buildNumber = await readShortVersionAsync('/app', exp, {
|
||||
CURRENT_PROJECT_VERSION: '1.0.0',
|
||||
});
|
||||
expect(buildNumber).toBe('1.0.0');
|
||||
});
|
||||
|
||||
it('fails when build number is invalid', async () => {
|
||||
const exp = initBareWorkflowProject({
|
||||
appVersion: '$(CURRENT_PROJECT_VERSION)',
|
||||
});
|
||||
|
||||
await expect(
|
||||
readShortVersionAsync('/app', exp, {
|
||||
CURRENT_PROJECT_VERSION: '0.0.7.1.028',
|
||||
})
|
||||
).rejects.toThrowError(
|
||||
'CFBundleShortVersionString (version field in app.json/app.config.js) must be a period-separated list of three non-negative integers. Current value: 0.0.7.1.028'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('managed project', () => {
|
||||
it('reads the version from app config', async () => {
|
||||
const exp = initBareWorkflowProject();
|
||||
const appVersion = await readShortVersionAsync('/repo', exp, {});
|
||||
const appVersion = await readShortVersionAsync('/app', exp, {});
|
||||
expect(appVersion).toBe('1.0.0');
|
||||
});
|
||||
});
|
||||
@@ -309,66 +323,75 @@ describe(getInfoPlistPath, () => {
|
||||
{
|
||||
'./ios/testapp/Info.plist': '',
|
||||
},
|
||||
'/repo'
|
||||
'/app'
|
||||
);
|
||||
const plistPath = getInfoPlistPath('/repo', {});
|
||||
expect(plistPath).toBe('/repo/ios/testapp/Info.plist');
|
||||
const plistPath = getInfoPlistPath('/app', {});
|
||||
expect(plistPath).toBe('/app/ios/testapp/Info.plist');
|
||||
});
|
||||
it('returns INFOPLIST_FILE if specified', () => {
|
||||
vol.fromJSON(
|
||||
{
|
||||
'./ios/testapp/Info.plist': '',
|
||||
},
|
||||
'/repo'
|
||||
'/app'
|
||||
);
|
||||
const plistPath = getInfoPlistPath('/repo', { INFOPLIST_FILE: './qwert/NotInfo.plist' });
|
||||
expect(plistPath).toBe('/repo/ios/qwert/NotInfo.plist');
|
||||
const plistPath = getInfoPlistPath('/app', { INFOPLIST_FILE: './qwert/NotInfo.plist' });
|
||||
expect(plistPath).toBe('/app/ios/qwert/NotInfo.plist');
|
||||
});
|
||||
it('evaluates SRCROOT in Info.plist', () => {
|
||||
vol.fromJSON(
|
||||
{
|
||||
'./ios/testapp/Info.plist': '',
|
||||
},
|
||||
'/repo'
|
||||
'/app'
|
||||
);
|
||||
const plistPath = getInfoPlistPath('/repo', {
|
||||
const plistPath = getInfoPlistPath('/app', {
|
||||
INFOPLIST_FILE: '$(SRCROOT)/qwert/NotInfo.plist',
|
||||
});
|
||||
expect(plistPath).toBe('/repo/ios/qwert/NotInfo.plist');
|
||||
expect(plistPath).toBe('/app/ios/qwert/NotInfo.plist');
|
||||
});
|
||||
it('evaluates BuildSettings in Info.plist', () => {
|
||||
vol.fromJSON(
|
||||
{
|
||||
'./ios/testapp/Info.plist': '',
|
||||
},
|
||||
'/repo'
|
||||
'/app'
|
||||
);
|
||||
const plistPath = getInfoPlistPath('/repo', {
|
||||
const plistPath = getInfoPlistPath('/app', {
|
||||
INFOPLIST_FILE: '$(SRCROOT)/qwert/$(TARGET_NAME).plist',
|
||||
TARGET_NAME: 'NotInfo',
|
||||
});
|
||||
expect(plistPath).toBe('/repo/ios/qwert/NotInfo.plist');
|
||||
expect(plistPath).toBe('/app/ios/qwert/NotInfo.plist');
|
||||
});
|
||||
});
|
||||
|
||||
function initBareWorkflowProject({
|
||||
appVersion = '1.0.0',
|
||||
buildNumber = '1',
|
||||
expoConfig = {},
|
||||
infoPlistName = 'Info.plist',
|
||||
}: { appVersion?: string; buildNumber?: string; infoPlistName?: string } = {}): ExpoConfig {
|
||||
}: {
|
||||
appVersion?: string;
|
||||
buildNumber?: string;
|
||||
infoPlistName?: string;
|
||||
expoConfig?: { version?: string; ios?: object };
|
||||
} = {}): ExpoConfig {
|
||||
const fakeExp: ExpoConfig = {
|
||||
name: 'myproject',
|
||||
slug: 'myproject',
|
||||
version: '1.0.0',
|
||||
ios: {
|
||||
buildNumber: '1',
|
||||
...expoConfig.ios,
|
||||
},
|
||||
...expoConfig,
|
||||
};
|
||||
vol.fromJSON(
|
||||
{
|
||||
'./app.json': JSON.stringify({
|
||||
expo: fakeExp,
|
||||
}),
|
||||
'./ios/myproject.xcodeproj/project.pbxproj': '',
|
||||
[`./ios/myproject/${infoPlistName}`]: `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
@@ -380,7 +403,7 @@ function initBareWorkflowProject({
|
||||
</dict>
|
||||
</plist>`,
|
||||
},
|
||||
'/repo'
|
||||
'/app'
|
||||
);
|
||||
|
||||
return fakeExp;
|
||||
@@ -402,7 +425,7 @@ function initManagedProject(): ExpoConfig {
|
||||
expo: fakeExp,
|
||||
}),
|
||||
},
|
||||
'/repo'
|
||||
'/app'
|
||||
);
|
||||
|
||||
return fakeExp;
|
||||
|
||||
@@ -55,5 +55,5 @@ export async function ensureIosCredentialsForBuildResignAsync(
|
||||
}
|
||||
|
||||
function shouldProvideCredentials(buildCtx: BuildContext<Platform.IOS>): boolean {
|
||||
return !buildCtx.buildProfile.simulator;
|
||||
return !buildCtx.buildProfile.simulator && !buildCtx.buildProfile.withoutCredentials;
|
||||
}
|
||||
@@ -2,7 +2,12 @@ import { Ios } from '@expo/eas-build-job';
|
||||
import nullthrows from 'nullthrows';
|
||||
|
||||
import { IosJobInput, IosJobSecretsInput } from '../../graphql/generated';
|
||||
import { transformBuildTrigger, transformProjectArchive, transformWorkflow } from '../graphql';
|
||||
import {
|
||||
transformBuildMode,
|
||||
transformBuildTrigger,
|
||||
transformProjectArchive,
|
||||
transformWorkflow,
|
||||
} from '../graphql';
|
||||
|
||||
export function transformJob(job: Ios.Job): IosJobInput {
|
||||
return {
|
||||
@@ -12,7 +17,7 @@ export function transformJob(job: Ios.Job): IosJobInput {
|
||||
projectRootDirectory: nullthrows(job.projectRootDirectory),
|
||||
releaseChannel: job.releaseChannel,
|
||||
updates: job.updates,
|
||||
secrets: transformIosSecrets(job.secrets),
|
||||
secrets: job.secrets ? transformIosSecrets(job.secrets) : undefined,
|
||||
builderEnvironment: job.builderEnvironment,
|
||||
cache: job.cache,
|
||||
version: job.version?.buildNumber ? { buildNumber: job.version.buildNumber } : undefined,
|
||||
@@ -24,6 +29,8 @@ export function transformJob(job: Ios.Job): IosJobInput {
|
||||
developmentClient: job.developmentClient,
|
||||
simulator: job.simulator,
|
||||
experimental: job.experimental,
|
||||
mode: transformBuildMode(job.mode),
|
||||
customBuildConfig: job.customBuildConfig,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -7,12 +7,14 @@ import {
|
||||
Platform,
|
||||
sanitizeJob,
|
||||
} from '@expo/eas-build-job';
|
||||
import { BuildProfile } from '@expo/eas-json';
|
||||
import nullthrows from 'nullthrows';
|
||||
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 { BuildContext } from '../context';
|
||||
@@ -25,18 +27,18 @@ interface JobData {
|
||||
|
||||
const cacheDefaults = {
|
||||
disabled: false,
|
||||
customPaths: [],
|
||||
cacheDefaultPaths: true,
|
||||
paths: [],
|
||||
};
|
||||
|
||||
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 username = getUsername(ctx.exp, ctx.user);
|
||||
const buildCredentials: Ios.Job['secrets']['buildCredentials'] = {};
|
||||
const buildCredentials: Ios.BuildSecrets['buildCredentials'] = {};
|
||||
if (jobData.credentials) {
|
||||
const targetNames = Object.keys(jobData.credentials);
|
||||
for (const targetName of targetNames) {
|
||||
@@ -44,38 +46,43 @@ 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,
|
||||
builderEnvironment: {
|
||||
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,
|
||||
image: buildProfile.image,
|
||||
node: buildProfile.node,
|
||||
pnpm: buildProfile.pnpm,
|
||||
bun: buildProfile.bun,
|
||||
yarn: buildProfile.yarn,
|
||||
bundler: buildProfile.bundler,
|
||||
cocoapods: buildProfile.cocoapods,
|
||||
fastlane: buildProfile.fastlane,
|
||||
expoCli: buildProfile.expoCli,
|
||||
env: buildProfile.env,
|
||||
},
|
||||
cache: {
|
||||
...cacheDefaults,
|
||||
...ctx.buildProfile.cache,
|
||||
...buildProfile.cache,
|
||||
clear: ctx.clearCache,
|
||||
},
|
||||
secrets: {
|
||||
buildCredentials,
|
||||
},
|
||||
releaseChannel: ctx.buildProfile.releaseChannel,
|
||||
updates: { channel: ctx.buildProfile.channel },
|
||||
developmentClient: ctx.buildProfile.developmentClient,
|
||||
simulator: ctx.buildProfile.simulator,
|
||||
releaseChannel: buildProfile.releaseChannel,
|
||||
updates: { channel: buildProfile.channel },
|
||||
developmentClient: buildProfile.developmentClient,
|
||||
simulator: buildProfile.simulator,
|
||||
scheme: jobData.buildScheme,
|
||||
buildConfiguration: ctx.buildProfile.buildConfiguration,
|
||||
applicationArchivePath:
|
||||
ctx.buildProfile.applicationArchivePath ?? ctx.buildProfile.artifactPath,
|
||||
buildArtifactPaths: ctx.buildProfile.buildArtifactPaths,
|
||||
buildConfiguration: buildProfile.buildConfiguration,
|
||||
applicationArchivePath: buildProfile.applicationArchivePath ?? buildProfile.artifactPath,
|
||||
buildArtifactPaths: buildProfile.buildArtifactPaths,
|
||||
username,
|
||||
...(ctx.ios.buildNumberOverride && {
|
||||
version: {
|
||||
@@ -83,10 +90,15 @@ export async function prepareJobAsync(
|
||||
},
|
||||
}),
|
||||
experimental: {
|
||||
prebuildCommand: ctx.buildProfile.prebuildCommand,
|
||||
prebuildCommand: buildProfile.prebuildCommand,
|
||||
},
|
||||
mode: BuildMode.BUILD,
|
||||
mode: buildProfile.config ? BuildMode.CUSTOM : BuildMode.BUILD,
|
||||
triggeredBy: BuildTrigger.EAS_CLI,
|
||||
...(maybeCustomBuildConfigPath && {
|
||||
customBuildConfig: {
|
||||
path: maybeCustomBuildConfigPath,
|
||||
},
|
||||
}),
|
||||
};
|
||||
return sanitizeJob(job);
|
||||
}
|
||||
|
||||
@@ -22,6 +22,8 @@ import { readPlistAsync, writePlistAsync } from '../../utils/plist';
|
||||
import { updateAppJsonConfigAsync } from '../utils/appJson';
|
||||
import { bumpAppVersionAsync, ensureStaticConfigExists } from '../utils/version';
|
||||
|
||||
const SHORT_VERSION_REGEX = /^\d+.\d+.\d+$/;
|
||||
|
||||
export enum BumpStrategy {
|
||||
APP_VERSION,
|
||||
BUILD_NUMBER,
|
||||
@@ -97,6 +99,14 @@ export async function bumpVersionInAppJsonAsync({
|
||||
}
|
||||
}
|
||||
|
||||
function validateShortVersion(shortVersion: string | undefined): void {
|
||||
if (shortVersion && !SHORT_VERSION_REGEX.test(shortVersion)) {
|
||||
throw new Error(
|
||||
`CFBundleShortVersionString (version field in app.json/app.config.js) must be a period-separated list of three non-negative integers. Current value: ${shortVersion}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function readShortVersionAsync(
|
||||
projectDir: string,
|
||||
exp: ExpoConfig,
|
||||
@@ -105,11 +115,15 @@ export async function readShortVersionAsync(
|
||||
const workflow = await resolveWorkflowAsync(projectDir, Platform.IOS);
|
||||
if (workflow === Workflow.GENERIC) {
|
||||
const infoPlist = await readInfoPlistAsync(projectDir, buildSettings);
|
||||
return (
|
||||
|
||||
const shortVersion =
|
||||
infoPlist.CFBundleShortVersionString &&
|
||||
evaluateTemplateString(infoPlist.CFBundleShortVersionString, buildSettings)
|
||||
);
|
||||
evaluateTemplateString(infoPlist.CFBundleShortVersionString, buildSettings);
|
||||
|
||||
validateShortVersion(shortVersion);
|
||||
return shortVersion;
|
||||
} else {
|
||||
validateShortVersion(exp.version);
|
||||
return exp.version;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import semver from 'semver';
|
||||
import { ora } from '../ora';
|
||||
|
||||
const PLUGIN_PACKAGE_NAME = 'eas-cli-local-build-plugin';
|
||||
const PLUGIN_PACKAGE_VERSION = '0.0.128';
|
||||
const PLUGIN_PACKAGE_VERSION = '1.0.44';
|
||||
|
||||
export enum LocalBuildMode {
|
||||
/**
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Updates } from '@expo/config-plugins';
|
||||
import { Metadata, Platform, sanitizeMetadata } from '@expo/eas-build-job';
|
||||
import { BuildMode, Metadata, Platform, sanitizeMetadata } from '@expo/eas-build-job';
|
||||
import { IosEnterpriseProvisioning } from '@expo/eas-json';
|
||||
import fs from 'fs-extra';
|
||||
import resolveFrom from 'resolve-from';
|
||||
@@ -19,6 +19,7 @@ import { getVcsClient } from '../vcs';
|
||||
import { maybeResolveVersionsAsync as maybeResolveAndroidVersionsAsync } from './android/version';
|
||||
import { BuildContext } from './context';
|
||||
import { maybeResolveVersionsAsync as maybeResolveIosVersionsAsync } from './ios/version';
|
||||
import { LocalBuildMode } from './local';
|
||||
|
||||
export async function collectMetadataAsync<T extends Platform>(
|
||||
ctx: BuildContext<T>
|
||||
@@ -47,7 +48,10 @@ export async function collectMetadataAsync<T extends Platform>(
|
||||
gitCommitMessage: truncateGitCommitMessage(
|
||||
(await vcsClient.getLastCommitMessageAsync()) ?? undefined
|
||||
),
|
||||
isGitWorkingTreeDirty: await vcsClient.hasUncommittedChangesAsync(),
|
||||
isGitWorkingTreeDirty:
|
||||
ctx.localBuildOptions.localBuildMode === LocalBuildMode.INTERNAL
|
||||
? false
|
||||
: await vcsClient.hasUncommittedChangesAsync(),
|
||||
username: getUsername(ctx.exp, ctx.user),
|
||||
message: ctx.message,
|
||||
...(ctx.platform === Platform.IOS && {
|
||||
@@ -57,6 +61,9 @@ export async function collectMetadataAsync<T extends Platform>(
|
||||
}),
|
||||
runWithNoWaitFlag: ctx.noWait,
|
||||
runFromCI: ctx.runFromCI,
|
||||
buildMode: ctx.buildProfile.config ? BuildMode.CUSTOM : BuildMode.BUILD,
|
||||
customWorkflowName: ctx.customBuildConfigMetadata?.workflowName,
|
||||
developmentClient: ctx.developmentClient,
|
||||
};
|
||||
return sanitizeMetadata(metadata);
|
||||
}
|
||||
|
||||
@@ -99,10 +99,6 @@ export async function listAndSelectBuildOnAppAsync(
|
||||
}
|
||||
}
|
||||
|
||||
function formatBuildChoiceValue(value: string | undefined | null): string {
|
||||
return value ? chalk.bold(value) : 'Unknown';
|
||||
}
|
||||
|
||||
function createBuildToPartialChoiceMaker(
|
||||
selectPromptDisabledFunction?: (build: BuildFragment) => boolean
|
||||
) {
|
||||
@@ -119,19 +115,35 @@ function createBuildToPartialChoiceMaker(
|
||||
? `${build.gitCommitHash.slice(0, 7)} "${chalk.bold(
|
||||
splitCommitMessage[0] + (splitCommitMessage.length > 1 ? '…' : '')
|
||||
)}"`
|
||||
: 'Unknown';
|
||||
: 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,
|
||||
},
|
||||
{ name: 'Profile', value: build.buildProfile ? chalk.bold(build.buildProfile) : 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: [
|
||||
`\t${chalk.bold(`Version:`)} ${formatBuildChoiceValue(build.appVersion)}`,
|
||||
`\t${chalk.bold(
|
||||
build.platform === AppPlatform.Ios ? 'Build number:' : 'Version code:'
|
||||
)} ${formatBuildChoiceValue(build.appBuildVersion)}`,
|
||||
`\t${chalk.bold(`Commit:`)} ${formattedCommitData}`,
|
||||
].join('\n'),
|
||||
description: filteredDescriptionArray.length > 0 ? filteredDescriptionArray.join('\n') : '',
|
||||
disabled: selectPromptDisabledFunction?.(build),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { ExpoConfig } from '@expo/config-types';
|
||||
import { Platform, Workflow } from '@expo/eas-build-job';
|
||||
import {
|
||||
AppVersionSource,
|
||||
@@ -13,6 +14,7 @@ import chalk from 'chalk';
|
||||
import nullthrows from 'nullthrows';
|
||||
|
||||
import { Analytics } from '../analytics/AnalyticsManager';
|
||||
import { createAndLinkChannelAsync, doesChannelExistAsync } from '../channel/queries';
|
||||
import { DynamicConfigContextFn } from '../commandUtils/context/DynamicProjectConfigContextField';
|
||||
import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import {
|
||||
@@ -24,16 +26,25 @@ import {
|
||||
} from '../graphql/generated';
|
||||
import { BuildQuery } from '../graphql/queries/BuildQuery';
|
||||
import { toAppPlatform, toPlatform } from '../graphql/types/AppPlatform';
|
||||
import Log from '../log';
|
||||
import Log, { learnMore } from '../log';
|
||||
import {
|
||||
RequestedPlatform,
|
||||
appPlatformDisplayNames,
|
||||
appPlatformEmojis,
|
||||
toPlatforms,
|
||||
} from '../platform';
|
||||
import {
|
||||
CustomBuildConfigMetadata,
|
||||
validateCustomBuildConfigAsync,
|
||||
} from '../project/customBuildConfig';
|
||||
import { checkExpoSdkIsSupportedAsync } from '../project/expoSdk';
|
||||
import { validateMetroConfigForManagedWorkflowAsync } from '../project/metroConfig';
|
||||
import { validateAppVersionRuntimePolicySupportAsync } from '../project/projectUtils';
|
||||
import {
|
||||
isExpoUpdatesInstalledAsDevDependency,
|
||||
isExpoUpdatesInstalledOrAvailable,
|
||||
isUsingEASUpdate,
|
||||
validateAppVersionRuntimePolicySupportAsync,
|
||||
} from '../project/projectUtils';
|
||||
import {
|
||||
validateAppConfigForRemoteVersionSource,
|
||||
validateBuildProfileVersionSettings,
|
||||
@@ -48,6 +59,7 @@ import {
|
||||
waitToCompleteAsync as waitForSubmissionsToCompleteAsync,
|
||||
} from '../submit/submit';
|
||||
import { printSubmissionDetailsUrls } from '../submit/utils/urls';
|
||||
import { ensureEASUpdateIsConfiguredAsync } from '../update/configure';
|
||||
import { validateBuildProfileConfigMatchesProjectConfigAsync } from '../update/utils';
|
||||
import { Actor } from '../user/User';
|
||||
import { downloadAndMaybeExtractAppAsync } from '../utils/download';
|
||||
@@ -89,7 +101,7 @@ export async function runBuildAndSubmitAsync(
|
||||
projectDir: string,
|
||||
flags: BuildFlags,
|
||||
actor: Actor,
|
||||
getDynamicProjectConfigAsync: DynamicConfigContextFn
|
||||
getDynamicPrivateProjectConfigAsync: DynamicConfigContextFn
|
||||
): Promise<void> {
|
||||
await getVcsClient().ensureRepoExistsAsync();
|
||||
await ensureRepoIsCleanAsync(flags.nonInteractive);
|
||||
@@ -98,7 +110,7 @@ export async function runBuildAndSubmitAsync(
|
||||
projectDir,
|
||||
nonInteractive: flags.nonInteractive,
|
||||
});
|
||||
const easJsonAccessor = new EasJsonAccessor(projectDir);
|
||||
const easJsonAccessor = EasJsonAccessor.fromProjectPath(projectDir);
|
||||
const easJsonCliConfig: EasJson['cli'] =
|
||||
(await EasJsonUtils.getCliConfigAsync(easJsonAccessor)) ?? {};
|
||||
|
||||
@@ -108,7 +120,15 @@ export async function runBuildAndSubmitAsync(
|
||||
easJsonAccessor,
|
||||
platforms,
|
||||
profileName: flags.profile ?? undefined,
|
||||
projectDir,
|
||||
});
|
||||
Log.log(
|
||||
`Loaded "env" configuration for the "${buildProfiles[0].profileName}" profile: ${
|
||||
buildProfiles[0].profile.env
|
||||
? Object.keys(buildProfiles[0].profile.env).join(', ')
|
||||
: 'no environment variables specified'
|
||||
}. ${learnMore('https://docs.expo.dev/build-reference/variables/')}`
|
||||
);
|
||||
|
||||
await ensureExpoDevClientInstalledForDevClientBuildsAsync({
|
||||
projectDir,
|
||||
@@ -116,8 +136,14 @@ export async function runBuildAndSubmitAsync(
|
||||
buildProfiles,
|
||||
});
|
||||
|
||||
const customBuildConfigMetadataByPlatform: { [p in AppPlatform]?: CustomBuildConfigMetadata } =
|
||||
{};
|
||||
for (const buildProfile of buildProfiles) {
|
||||
validateBuildProfileVersionSettings(buildProfile, easJsonCliConfig);
|
||||
const maybeMetadata = await validateCustomBuildConfigAsync(projectDir, buildProfile.profile);
|
||||
if (maybeMetadata) {
|
||||
customBuildConfigMetadataByPlatform[toAppPlatform(buildProfile.platform)] = maybeMetadata;
|
||||
}
|
||||
}
|
||||
|
||||
const startedBuilds: {
|
||||
@@ -127,6 +153,7 @@ export async function runBuildAndSubmitAsync(
|
||||
const buildCtxByPlatform: { [p in AppPlatform]?: BuildContext<Platform> } = {};
|
||||
|
||||
for (const buildProfile of buildProfiles) {
|
||||
const platform = toAppPlatform(buildProfile.platform);
|
||||
const { build: maybeBuild, buildCtx } = await prepareAndStartBuildAsync({
|
||||
projectDir,
|
||||
flags,
|
||||
@@ -136,12 +163,13 @@ export async function runBuildAndSubmitAsync(
|
||||
actor,
|
||||
graphqlClient,
|
||||
analytics,
|
||||
getDynamicProjectConfigAsync,
|
||||
getDynamicPrivateProjectConfigAsync,
|
||||
customBuildConfigMetadata: customBuildConfigMetadataByPlatform[platform],
|
||||
});
|
||||
if (maybeBuild) {
|
||||
startedBuilds.push({ build: maybeBuild, buildProfile });
|
||||
}
|
||||
buildCtxByPlatform[toAppPlatform(buildProfile.platform)] = buildCtx;
|
||||
buildCtxByPlatform[platform] = buildCtx;
|
||||
}
|
||||
|
||||
if (flags.localBuildOptions.localBuildMode) {
|
||||
@@ -159,6 +187,7 @@ export async function runBuildAndSubmitAsync(
|
||||
platforms,
|
||||
profileName: flags.submitProfile,
|
||||
type: 'submit',
|
||||
projectDir,
|
||||
});
|
||||
for (const startedBuild of startedBuilds) {
|
||||
const submitProfile = nullthrows(
|
||||
@@ -206,7 +235,9 @@ export async function runBuildAndSubmitAsync(
|
||||
}
|
||||
|
||||
const haveAllBuildsFailedOrCanceled = builds.every(
|
||||
build => build?.status && [BuildStatus.Errored, BuildStatus.Canceled].includes(build?.status)
|
||||
build =>
|
||||
build?.status &&
|
||||
[BuildStatus.Errored, BuildStatus.Canceled, BuildStatus.PendingCancel].includes(build?.status)
|
||||
);
|
||||
|
||||
await maybeDownloadAndRunSimulatorBuildsAsync(builds, flags);
|
||||
@@ -243,7 +274,8 @@ async function prepareAndStartBuildAsync({
|
||||
actor,
|
||||
graphqlClient,
|
||||
analytics,
|
||||
getDynamicProjectConfigAsync,
|
||||
getDynamicPrivateProjectConfigAsync,
|
||||
customBuildConfigMetadata,
|
||||
}: {
|
||||
projectDir: string;
|
||||
flags: BuildFlags;
|
||||
@@ -253,7 +285,8 @@ async function prepareAndStartBuildAsync({
|
||||
actor: Actor;
|
||||
graphqlClient: ExpoGraphqlClient;
|
||||
analytics: Analytics;
|
||||
getDynamicProjectConfigAsync: DynamicConfigContextFn;
|
||||
getDynamicPrivateProjectConfigAsync: DynamicConfigContextFn;
|
||||
customBuildConfigMetadata?: CustomBuildConfigMetadata;
|
||||
}): Promise<{ build: BuildFragment | undefined; buildCtx: BuildContext<Platform> }> {
|
||||
const buildCtx = await createBuildContextAsync({
|
||||
buildProfileName: buildProfile.profileName,
|
||||
@@ -270,7 +303,8 @@ async function prepareAndStartBuildAsync({
|
||||
actor,
|
||||
graphqlClient,
|
||||
analytics,
|
||||
getDynamicProjectConfigAsync,
|
||||
getDynamicPrivateProjectConfigAsync,
|
||||
customBuildConfigMetadata,
|
||||
});
|
||||
|
||||
if (moreBuilds) {
|
||||
@@ -289,6 +323,29 @@ async function prepareAndStartBuildAsync({
|
||||
buildCtx.projectId,
|
||||
flags.nonInteractive
|
||||
);
|
||||
if (buildProfile.profile.channel) {
|
||||
await validateExpoUpdatesInstalledAsProjectDependencyAsync({
|
||||
graphqlClient,
|
||||
exp: buildCtx.exp,
|
||||
projectId: buildCtx.projectId,
|
||||
projectDir,
|
||||
sdkVersion: buildCtx.exp.sdkVersion,
|
||||
nonInteractive: flags.nonInteractive,
|
||||
buildProfile,
|
||||
});
|
||||
if (isUsingEASUpdate(buildCtx.exp, buildCtx.projectId)) {
|
||||
const doesChannelExist = await doesChannelExistAsync(graphqlClient, {
|
||||
appId: buildCtx.projectId,
|
||||
channelName: buildProfile.profile.channel,
|
||||
});
|
||||
if (!doesChannelExist) {
|
||||
await createAndLinkChannelAsync(graphqlClient, {
|
||||
appId: buildCtx.projectId,
|
||||
channelName: buildProfile.profile.channel,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await validateAppVersionRuntimePolicySupportAsync(buildCtx.projectDir, buildCtx.exp);
|
||||
if (easJsonCliConfig?.appVersionSource === AppVersionSource.REMOTE) {
|
||||
@@ -408,3 +465,52 @@ async function maybeDownloadAndRunSimulatorBuildsAsync(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function validateExpoUpdatesInstalledAsProjectDependencyAsync({
|
||||
exp,
|
||||
graphqlClient,
|
||||
projectId,
|
||||
projectDir,
|
||||
buildProfile,
|
||||
nonInteractive,
|
||||
sdkVersion,
|
||||
}: {
|
||||
graphqlClient: ExpoGraphqlClient;
|
||||
exp: ExpoConfig;
|
||||
projectId: string;
|
||||
projectDir: string;
|
||||
buildProfile: ProfileData<'build'>;
|
||||
nonInteractive: boolean;
|
||||
sdkVersion?: string;
|
||||
}): Promise<void> {
|
||||
if (isExpoUpdatesInstalledOrAvailable(projectDir, sdkVersion)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isExpoUpdatesInstalledAsDevDependency(projectDir)) {
|
||||
Log.warn(
|
||||
`The build profile "${buildProfile.profileName}" uses the channel "${buildProfile.profile.channel}", but you've added "expo-updates" as a dev dependency. To make channels work for your builds, move "expo-updates" from dev dependencies to the main dependencies in your project.`
|
||||
);
|
||||
} else if (nonInteractive) {
|
||||
Log.warn(
|
||||
`The build profile "${buildProfile.profileName}" has specified the channel "${buildProfile.profile.channel}", but the "expo-updates" package hasn't been installed. To use channels for your builds, install the "expo-updates" package by running "npx expo install expo-updates" followed by "eas update:configure".`
|
||||
);
|
||||
} else {
|
||||
Log.warn(
|
||||
`The build profile "${buildProfile.profileName}" specifies the channel "${buildProfile.profile.channel}", but the "expo-updates" package is missing. To use channels in your builds, install the "expo-updates" package and run "eas update:configure".`
|
||||
);
|
||||
const installExpoUpdates = await confirmAsync({
|
||||
message: `Would you like to install the "expo-updates" package and configure EAS Update now?`,
|
||||
});
|
||||
if (installExpoUpdates) {
|
||||
await ensureEASUpdateIsConfiguredAsync(graphqlClient, {
|
||||
exp,
|
||||
projectId,
|
||||
projectDir,
|
||||
platform: RequestedPlatform.All,
|
||||
});
|
||||
Log.withTick('Installed expo-updates and configured EAS Update.');
|
||||
throw new Error('Command must be re-run to pick up new updates configuration.');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ export enum BuildStatus {
|
||||
NEW = 'new',
|
||||
IN_QUEUE = 'in-queue',
|
||||
IN_PROGRESS = 'in-progress',
|
||||
PENDING_CANCEL = 'pending-cancel',
|
||||
ERRORED = 'errored',
|
||||
FINISHED = 'finished',
|
||||
CANCELED = 'canceled',
|
||||
|
||||
@@ -27,6 +27,7 @@ export function formatGraphQLBuild(build: BuildFragment): string {
|
||||
return chalk.blue('in queue');
|
||||
case GraphQLBuildStatus.InProgress:
|
||||
return chalk.blue('in progress');
|
||||
case GraphQLBuildStatus.PendingCancel:
|
||||
case GraphQLBuildStatus.Canceled:
|
||||
return chalk.gray('canceled');
|
||||
case GraphQLBuildStatus.Finished:
|
||||
@@ -38,6 +39,10 @@ export function formatGraphQLBuild(build: BuildFragment): string {
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Message',
|
||||
value: build.message,
|
||||
},
|
||||
{
|
||||
label: 'Distribution',
|
||||
value: build.distribution?.toLowerCase(),
|
||||
@@ -86,6 +91,7 @@ export function formatGraphQLBuild(build: BuildFragment): string {
|
||||
case GraphQLBuildStatus.InQueue:
|
||||
case GraphQLBuildStatus.InProgress:
|
||||
return '<in progress>';
|
||||
case GraphQLBuildStatus.PendingCancel:
|
||||
case GraphQLBuildStatus.Canceled:
|
||||
case GraphQLBuildStatus.Errored:
|
||||
return null;
|
||||
|
||||
@@ -15,6 +15,32 @@ import Log, { learnMore, link } from '../../log';
|
||||
import { appPlatformDisplayNames, appPlatformEmojis } from '../../platform';
|
||||
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]))}`);
|
||||
@@ -31,7 +57,6 @@ export function printLogsUrls(builds: BuildFragment[]): void {
|
||||
}
|
||||
|
||||
export function printBuildResults(builds: (BuildFragment | null)[]): void {
|
||||
Log.newLine();
|
||||
if (builds.length === 1) {
|
||||
const [build] = builds;
|
||||
assert(build, 'Build should be defined');
|
||||
@@ -42,8 +67,8 @@ export function printBuildResults(builds: (BuildFragment | null)[]): void {
|
||||
}
|
||||
|
||||
function printBuildResult(build: BuildFragment): void {
|
||||
Log.addNewLineIfNone();
|
||||
if (build.status === BuildStatus.Errored) {
|
||||
Log.addNewLineIfNone();
|
||||
const userError = build.error;
|
||||
Log.error(
|
||||
`${appPlatformEmojis[build.platform]} ${
|
||||
@@ -51,11 +76,12 @@ function printBuildResult(build: BuildFragment): void {
|
||||
} build failed${userError ? ':' : ''}`
|
||||
);
|
||||
if (userError) {
|
||||
printUserError(userError);
|
||||
printUserError(userError, build);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (build.status === BuildStatus.Canceled) {
|
||||
if ([BuildStatus.Canceled, BuildStatus.PendingCancel].includes(build.status)) {
|
||||
Log.addNewLineIfNone();
|
||||
Log.error(
|
||||
`${appPlatformEmojis[build.platform]} ${
|
||||
appPlatformDisplayNames[build.platform]
|
||||
@@ -65,6 +91,7 @@ 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
|
||||
@@ -82,9 +109,14 @@ function printBuildResult(build: BuildFragment): void {
|
||||
} 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 ?? '';
|
||||
Log.log(`${appPlatformEmojis[build.platform]} ${appPlatformDisplayNames[build.platform]} app:`);
|
||||
Log.log(`${link(url)}`);
|
||||
const url = build.artifacts?.buildUrl;
|
||||
if (url) {
|
||||
Log.addNewLineIfNone();
|
||||
Log.log(
|
||||
`${appPlatformEmojis[build.platform]} ${appPlatformDisplayNames[build.platform]} app:`
|
||||
);
|
||||
Log.log(link(url));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,9 +137,22 @@ export function printDeprecationWarnings(deprecationInfo?: EasBuildDeprecationIn
|
||||
}
|
||||
}
|
||||
|
||||
export function printUserError(error: BuildError): void {
|
||||
Log.error(error.message);
|
||||
if (error.docsUrl) {
|
||||
Log.error(learnMore(error.docsUrl, { dim: false }));
|
||||
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 }));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function maybeGetErrorMessageOverride(error: BuildError, build: BuildFragment): string | null {
|
||||
if (!(error.errorCode in errorCodeToErrorMessageOverride)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return errorCodeToErrorMessageOverride[error.errorCode](build);
|
||||
}
|
||||
@@ -1,31 +1,34 @@
|
||||
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';
|
||||
import Log, { link } from '../../log';
|
||||
|
||||
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.IosM1Large,
|
||||
[ResourceClass.M1_MEDIUM]: BuildResourceClass.IosM1Medium,
|
||||
[ResourceClass.M1_LARGE]: BuildResourceClass.IosM1Large,
|
||||
[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<
|
||||
Exclude<
|
||||
ResourceClass,
|
||||
| ResourceClass.M1_EXPERIMENTAL
|
||||
| ResourceClass.M1_MEDIUM
|
||||
| ResourceClass.M1_LARGE
|
||||
| ResourceClass.INTEL_MEDIUM
|
||||
>,
|
||||
AndroidResourceClass,
|
||||
BuildResourceClass
|
||||
> = {
|
||||
[ResourceClass.DEFAULT]: BuildResourceClass.AndroidDefault,
|
||||
@@ -36,22 +39,20 @@ const androidResourceClassToBuildResourceClassMapping: Record<
|
||||
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.`
|
||||
`Build profile specifies the "${profileResourceClass}" resource class but you passed "${resourceClassFlag}" to --resource-class.\nUsing "${resourceClassFlag}" as the override.`
|
||||
);
|
||||
}
|
||||
|
||||
const selectedResourceClass = resourceClassFlag ?? profileResourceClass;
|
||||
|
||||
return platform === Platform.IOS
|
||||
? await resolveIosResourceClassAsync(exp, projectDir, resourceClassFlag ?? profileResourceClass)
|
||||
? resolveIosResourceClass(resourceClassFlag, profileResourceClass)
|
||||
: resolveAndroidResourceClass(selectedResourceClass);
|
||||
}
|
||||
|
||||
@@ -70,44 +71,38 @@ function resolveAndroidResourceClass(selectedResourceClass?: ResourceClass): Bui
|
||||
|
||||
const resourceClass = selectedResourceClass ?? ResourceClass.DEFAULT;
|
||||
|
||||
return androidResourceClassToBuildResourceClassMapping[
|
||||
resourceClass as Exclude<
|
||||
ResourceClass,
|
||||
| ResourceClass.M1_EXPERIMENTAL
|
||||
| ResourceClass.M1_MEDIUM
|
||||
| ResourceClass.M1_LARGE
|
||||
| ResourceClass.INTEL_MEDIUM
|
||||
>
|
||||
];
|
||||
return androidResourceClassToBuildResourceClassMapping[resourceClass as AndroidResourceClass];
|
||||
}
|
||||
|
||||
async function resolveIosResourceClassAsync(
|
||||
exp: ExpoConfig,
|
||||
projectDir: string,
|
||||
selectedResourceClass?: ResourceClass
|
||||
): Promise<BuildResourceClass> {
|
||||
const resourceClass =
|
||||
selectedResourceClass ?? (await resolveIosDefaultRequestedResourceClassAsync(exp, projectDir));
|
||||
function resolveIosResourceClass(
|
||||
resourceClassFlag?: ResourceClass,
|
||||
profileResourceClass?: ResourceClass
|
||||
): BuildResourceClass {
|
||||
const resourceClass = resourceClassFlag ?? profileResourceClass ?? ResourceClass.DEFAULT;
|
||||
|
||||
if (resourceClass === ResourceClass.M1_EXPERIMENTAL) {
|
||||
Log.warn(`Resource class ${chalk.bold('m1-experimental')} is deprecated.`);
|
||||
if (resourceClassFlag === ResourceClass.LARGE) {
|
||||
throw new Error(
|
||||
`Experimental "large" resource class for Intel iOS workers is no longer available. Remove the specified resource class to use the default, or learn more about all available resource classes: ${link(
|
||||
'https://docs.expo.dev/build-reference/eas-json/'
|
||||
)}`
|
||||
);
|
||||
}
|
||||
|
||||
if ([ResourceClass.M1_EXPERIMENTAL, ResourceClass.M1_MEDIUM].includes(resourceClass)) {
|
||||
Log.warn(
|
||||
`Resource class ${chalk.bold(resourceClass)} is deprecated. Use ${chalk.bold(
|
||||
'm-medium'
|
||||
)} instead.`
|
||||
);
|
||||
}
|
||||
|
||||
if ([ResourceClass.M_LARGE, ResourceClass.M1_LARGE].includes(resourceClass)) {
|
||||
Log.warn(
|
||||
`Resource class ${chalk.bold(resourceClass)} is deprecated. Use ${chalk.bold(
|
||||
'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.M1_MEDIUM;
|
||||
} else {
|
||||
return ResourceClass.DEFAULT;
|
||||
}
|
||||
}
|
||||
@@ -10,12 +10,14 @@ export function getProjectDashboardUrl(accountName: string, projectName: string)
|
||||
).toString();
|
||||
}
|
||||
|
||||
export function getBuildLogsUrl(build: BuildFragment): string {
|
||||
export function getBuildLogsUrl(build: BuildFragment, hash?: string): string {
|
||||
const { project } = build;
|
||||
const url =
|
||||
project.__typename !== 'App'
|
||||
? `/builds/${build.id}`
|
||||
: `/accounts/${project.ownerAccount.name}/projects/${project.slug}/builds/${build.id}`;
|
||||
: `/accounts/${project.ownerAccount.name}/projects/${project.slug}/builds/${build.id}${
|
||||
hash ? `#${hash}` : ''
|
||||
}`;
|
||||
|
||||
return new URL(url, getExpoWebsiteBaseUrl()).toString();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { BranchBasicInfo, ChannelBasicInfo, UpdateChannelInfoWithBranches } from '../utils';
|
||||
|
||||
export const testChannelBasicInfo: ChannelBasicInfo = {
|
||||
id: '9309afc2-9752-40db-8ef7-4abc10744c61',
|
||||
name: 'production',
|
||||
branchMapping:
|
||||
'{"data":[{"branchId":"754bf17f-efc0-46ab-8a59-a03f20e53e9b","branchMappingLogic":{"operand":0.15,"clientKey":"rolloutToken","branchMappingOperator":"hash_lt"}},{"branchId":"6941a8dd-5c0a-48bc-8876-f49c88ed419f","branchMappingLogic":"true"}],"version":0}',
|
||||
};
|
||||
|
||||
export const testBasicBranchInfo1: BranchBasicInfo = {
|
||||
id: '754bf17f-efc0-46ab-8a59-a03f20e53e9b',
|
||||
name: 'wrong-channel',
|
||||
};
|
||||
|
||||
export const testBasicBranchInfo2: BranchBasicInfo = {
|
||||
id: '6941a8dd-5c0a-48bc-8876-f49c88ed419f',
|
||||
name: 'production',
|
||||
};
|
||||
export const channelInfoWithBranches: UpdateChannelInfoWithBranches<BranchBasicInfo> = {
|
||||
...testChannelBasicInfo,
|
||||
updateBranches: [testBasicBranchInfo1, testBasicBranchInfo2],
|
||||
};
|
||||
@@ -0,0 +1,70 @@
|
||||
import {
|
||||
emptyBranchMapping,
|
||||
rolloutBranchMapping,
|
||||
standardBranchMapping,
|
||||
} from '../../rollout/__tests__/fixtures';
|
||||
import {
|
||||
BranchMappingValidationError,
|
||||
assertVersion,
|
||||
getAlwaysTrueBranchMapping,
|
||||
getBranchMapping,
|
||||
getEmptyBranchMapping,
|
||||
getStandardBranchId,
|
||||
isAlwaysTrueBranchMapping,
|
||||
isEmptyBranchMapping,
|
||||
} from '../branch-mapping';
|
||||
import { testChannelBasicInfo } from './branch-mapping-fixtures';
|
||||
|
||||
describe(assertVersion, () => {
|
||||
it('throws if the branch mapping is not the correct version', () => {
|
||||
expect(() => assertVersion(testChannelBasicInfo, 5)).toThrowError(BranchMappingValidationError);
|
||||
});
|
||||
it('asserts the correct version', () => {
|
||||
assertVersion(testChannelBasicInfo, 0);
|
||||
expect(getBranchMapping(testChannelBasicInfo.branchMapping).version).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe(getEmptyBranchMapping, () => {
|
||||
it('gets an empty branch mapping', () => {
|
||||
expect(isEmptyBranchMapping(getEmptyBranchMapping())).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe(isEmptyBranchMapping, () => {
|
||||
it('detects empty branch mappings', () => {
|
||||
expect(isEmptyBranchMapping(emptyBranchMapping)).toBe(true);
|
||||
expect(isEmptyBranchMapping(standardBranchMapping)).toBe(false);
|
||||
expect(isEmptyBranchMapping(rolloutBranchMapping)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe(isAlwaysTrueBranchMapping, () => {
|
||||
it('detects always true branch mappings', () => {
|
||||
expect(isAlwaysTrueBranchMapping(standardBranchMapping)).toBe(true);
|
||||
expect(isAlwaysTrueBranchMapping(rolloutBranchMapping)).toBe(false);
|
||||
expect(isAlwaysTrueBranchMapping(emptyBranchMapping)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe(getAlwaysTrueBranchMapping, () => {
|
||||
it('gets an always true branch mapping', () => {
|
||||
const alwaysTrueBranchMapping = getAlwaysTrueBranchMapping('test-id');
|
||||
expect(isAlwaysTrueBranchMapping(alwaysTrueBranchMapping)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe(getStandardBranchId, () => {
|
||||
it('throws if the branch mapping is not a standard mapping', () => {
|
||||
expect(() => getStandardBranchId(testChannelBasicInfo)).toThrowError(
|
||||
BranchMappingValidationError
|
||||
);
|
||||
});
|
||||
it('gets a standard branch id', () => {
|
||||
const channelObjectWithStandardMapping = { ...testChannelBasicInfo };
|
||||
channelObjectWithStandardMapping.branchMapping = JSON.stringify(standardBranchMapping);
|
||||
expect(getStandardBranchId(channelObjectWithStandardMapping)).toBe(
|
||||
standardBranchMapping.data[0].branchId
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,148 @@
|
||||
import { UpdateChannelBasicInfoFragment } from '../../graphql/generated';
|
||||
import { UpdateBranchObject, UpdateChannelObject } from '../../graphql/queries/ChannelQuery';
|
||||
|
||||
export const testUpdateBranch1: UpdateBranchObject = {
|
||||
id: '754bf17f-efc0-46ab-8a59-a03f20e53e9b',
|
||||
name: 'wrong-channel',
|
||||
updateGroups: [
|
||||
[
|
||||
{
|
||||
id: 'bbe3e63a-f620-4603-8c4d-258f3e51590d',
|
||||
group: '16ca6dba-e63b-48b0-baa3-15a894ee9434',
|
||||
message: 'fix bug',
|
||||
createdAt: '2023-07-17T22:48:59.278Z',
|
||||
runtimeVersion: 'exposdk:48.0.0',
|
||||
platform: 'ios',
|
||||
manifestFragment:
|
||||
'{"extra":{"expoClient":{"ios":{"supportsTablet":true,"bundleIdentifier":"com.quintest113.updatestest4"},"web":{"favicon":"./assets/favicon.png"},"icon":"./assets/icon.png","name":"updates-test-4","slug":"updates-test-4","extra":{"eas":{"projectId":"789a8784-0186-4f41-8bd2-7f219291a2b8"}},"splash":{"image":"./assets/splash.png","resizeMode":"contain","backgroundColor":"#ffffff"},"android":{"package":"com.quintest113.updatestest4","adaptiveIcon":{"backgroundColor":"#ffffff","foregroundImage":"./assets/adaptive-icon.png"}},"updates":{"url":"https://u.expo.dev/789a8784-0186-4f41-8bd2-7f219291a2b8"},"version":"1.0.0","platforms":["ios","android"],"sdkVersion":"48.0.0","orientation":"portrait","runtimeVersion":{"policy":"sdkVersion"},"currentFullName":"@quintest113/updates-test-4","originalFullName":"@quintest113/updates-test-4","userInterfaceStyle":"light","assetBundlePatterns":["**/*"]}},"assets":[{"bundleKey":"7d40544b395c5949f4646f5e150fe020","fileSHA256":"LN_rjlzN55dvcBL7jM5zryIpAp7iHU8VCf7RFIccbNg","storageKey":"Pi65ns2w9xLI5G_m9aGrJ5wK5FpWcGcInSSFwnA_0aA","contentType":"image/png","fileExtension":".png"},{"bundleKey":"cdd04e13d4ec83ff0cd13ec8dabdc341","fileSHA256":"irHji01LPI9giDYxtJo9bTPHaEHzuUcCMTlb4xn90Ks","storageKey":"oJZ9JX1m8IKNDgnIqcKOo5KUFOoa_NCaxruigZu65E0","contentType":"image/png","fileExtension":".png"},{"bundleKey":"a132ecc4ba5c1517ff83c0fb321bc7fc","fileSHA256":"K3RDqaWOksoRpXW9wRWYYVpX3eMFzYznDWAmBrMCzZg","storageKey":"X3OAkUu5P_LqyKFx1bNkA_qDQFLlSjCYNAlVEaOamHE","contentType":"image/png","fileExtension":".png"},{"bundleKey":"0ea69b5077e7c4696db85dbcba75b0e1","fileSHA256":"Pd0Hc-0n4j0leJ8wFzGzrEVa2L5aonoKG4OPicbSciU","storageKey":"FwJkAbrRNahBGX1KXnTAgiiwyzENu3D7DeEzCwmInyo","contentType":"image/png","fileExtension":".png"},{"bundleKey":"f5b790e2ac193b3d41015edb3551f9b8","fileSHA256":"vCfIss7SLYKt1AAi0hxjsgOMEDceLJB6ZWjQf6gTU4k","storageKey":"GQtYm7r0G9N3WJyrymjZ26p8eNMIb7rzqwSVgGco5EU","contentType":"image/png","fileExtension":".png"},{"bundleKey":"5223c8d9b0d08b82a5670fb5f71faf78","fileSHA256":"LJXowmdeeZM1_QBGuwfb2gbLfs_1HtN5IsYwnWGcxNM","storageKey":"jeqOrdQBNY4hgl04B3wtGs8QNWMDmjvbjIMUXPtOBpw","contentType":"image/png","fileExtension":".png"},{"bundleKey":"02d5848f62ade9617cf96894727d32e7","fileSHA256":"u1-8zHOj9oxS8maH-7JbShJIq1-KEV7FXnrGpEUcR-4","storageKey":"LMUvUZZpdGFb7KqaB_l9N4TXTkFn1Pw92ws9fWDNZPw","contentType":"model/gltf-binary","fileExtension":".glb"},{"bundleKey":"6013c27e421ab23c826713af3ebec891","fileSHA256":"dutQNnvzTCS4NgwvbeMbvhro5QnW0GJUeY_4rrvlcLY","storageKey":"85KB9Jr7eqz1Jwt1mYQGRzTy6ZXyO0j7ZcaLImCbzkU","contentType":"image/jpeg","fileExtension":".jpg"}],"launchAsset":{"bundleKey":"3d0ac172fe41ab941a8c68cd7e3a17ee","fileSHA256":"jjnar-ZPrb_g0Bx5tcFfjJrP3v8HdeAlXMk__xN7I1U","storageKey":"bimf-UXq5IFH5nNHCIHdcQ2MyJwTnwDVBb-P8hr2CZw","contentType":"application/javascript","fileExtension":".bundle"}}',
|
||||
isRollBackToEmbedded: false,
|
||||
manifestPermalink: 'https://u.expo.dev/update/bbe3e63a-f620-4603-8c4d-258f3e51590d',
|
||||
gitCommitHash: 'e91c5bb733eff675dd8a3722ed5d4f885a4b10ce',
|
||||
actor: {
|
||||
__typename: 'User',
|
||||
id: '026ace2b-c1f8-4a02-818d-6b52c100bdf2',
|
||||
username: 'quintest113',
|
||||
},
|
||||
branch: {
|
||||
id: '754bf17f-efc0-46ab-8a59-a03f20e53e9b',
|
||||
name: 'wrong-channel',
|
||||
__typename: 'UpdateBranch',
|
||||
},
|
||||
codeSigningInfo: null,
|
||||
__typename: 'Update',
|
||||
},
|
||||
{
|
||||
id: 'db22401b-d321-4629-a068-21d6d1f19c02',
|
||||
group: '16ca6dba-e63b-48b0-baa3-15a894ee9434',
|
||||
message: 'fix bug',
|
||||
createdAt: '2023-07-17T22:48:59.278Z',
|
||||
runtimeVersion: 'exposdk:48.0.0',
|
||||
platform: 'android',
|
||||
manifestFragment:
|
||||
'{"extra":{"expoClient":{"ios":{"supportsTablet":true,"bundleIdentifier":"com.quintest113.updatestest4"},"web":{"favicon":"./assets/favicon.png"},"icon":"./assets/icon.png","name":"updates-test-4","slug":"updates-test-4","extra":{"eas":{"projectId":"789a8784-0186-4f41-8bd2-7f219291a2b8"}},"splash":{"image":"./assets/splash.png","resizeMode":"contain","backgroundColor":"#ffffff"},"android":{"package":"com.quintest113.updatestest4","adaptiveIcon":{"backgroundColor":"#ffffff","foregroundImage":"./assets/adaptive-icon.png"}},"updates":{"url":"https://u.expo.dev/789a8784-0186-4f41-8bd2-7f219291a2b8"},"version":"1.0.0","platforms":["ios","android"],"sdkVersion":"48.0.0","orientation":"portrait","runtimeVersion":{"policy":"sdkVersion"},"currentFullName":"@quintest113/updates-test-4","originalFullName":"@quintest113/updates-test-4","userInterfaceStyle":"light","assetBundlePatterns":["**/*"]}},"assets":[{"bundleKey":"778ffc9fe8773a878e9c30a6304784de","fileSHA256":"i2Gkx-9w3JJ1PwSUl2SC9m_UFQ7CPfx3KrZeEDc6-lU","storageKey":"idnX8z03q4vGLzhYMPaQGBADjhqvWe67_afPqJ1vGzM","contentType":"image/png","fileExtension":".png"},{"bundleKey":"376d6a4c7f622917c39feb23671ef71d","fileSHA256":"QJsG0VpGM-5viirUgoKq0M4zQ_TYyPfLhAhN5mrM54I","storageKey":"46DQ95-UdSKiRkQgt5FyXkE1FrKOT9tBZP3nS7auKlg","contentType":"image/png","fileExtension":".png"},{"bundleKey":"c79c3606a1cf168006ad3979763c7e0c","fileSHA256":"kGZm3WiTRq2iMs42ia3vkHtCV_obWT8DY40rlJf2SIQ","storageKey":"k-3xHp3vP8mR36WdccUZVPTRJKpP-zlyboWdkXCIQpQ","contentType":"image/png","fileExtension":".png"},{"bundleKey":"02bc1fa7c0313217bde2d65ccbff40c9","fileSHA256":"_6fuRbdkBbpzkhSVAI99aMneY5X0tpQdsGNGX244IyA","storageKey":"lfqeUjiWFXwowhxVDBEzxsbTOEzYxMLebljy5Bh1mpU","contentType":"image/png","fileExtension":".png"},{"bundleKey":"35ba0eaec5a4f5ed12ca16fabeae451d","fileSHA256":"hM9es7ICUPaeDkczvrTaX0F_BoKJKlrRteYlcHU8IbE","storageKey":"fMoMrsUeB5xWw_Ugn8FqGJf-M2pld2hkXKR704z0MM8","contentType":"image/png","fileExtension":".png"},{"bundleKey":"5223c8d9b0d08b82a5670fb5f71faf78","fileSHA256":"LJXowmdeeZM1_QBGuwfb2gbLfs_1HtN5IsYwnWGcxNM","storageKey":"jeqOrdQBNY4hgl04B3wtGs8QNWMDmjvbjIMUXPtOBpw","contentType":"image/png","fileExtension":".png"},{"bundleKey":"02d5848f62ade9617cf96894727d32e7","fileSHA256":"u1-8zHOj9oxS8maH-7JbShJIq1-KEV7FXnrGpEUcR-4","storageKey":"LMUvUZZpdGFb7KqaB_l9N4TXTkFn1Pw92ws9fWDNZPw","contentType":"model/gltf-binary","fileExtension":".glb"},{"bundleKey":"6013c27e421ab23c826713af3ebec891","fileSHA256":"dutQNnvzTCS4NgwvbeMbvhro5QnW0GJUeY_4rrvlcLY","storageKey":"85KB9Jr7eqz1Jwt1mYQGRzTy6ZXyO0j7ZcaLImCbzkU","contentType":"image/jpeg","fileExtension":".jpg"}],"launchAsset":{"bundleKey":"4cc2ee49988e438ac066827b809721de","fileSHA256":"ctfK72f8EdS1IQmDvJLya38RMSma16beJcMoQToZIe0","storageKey":"QdEoKuCQPvVPA71gY52wqJ9vmtV4ObnCcPE2FP4SIdo","contentType":"application/javascript","fileExtension":".bundle"}}',
|
||||
isRollBackToEmbedded: false,
|
||||
manifestPermalink: 'https://u.expo.dev/update/db22401b-d321-4629-a068-21d6d1f19c02',
|
||||
gitCommitHash: 'e91c5bb733eff675dd8a3722ed5d4f885a4b10ce',
|
||||
actor: {
|
||||
__typename: 'User',
|
||||
id: '026ace2b-c1f8-4a02-818d-6b52c100bdf2',
|
||||
username: 'quintest113',
|
||||
},
|
||||
branch: {
|
||||
id: '754bf17f-efc0-46ab-8a59-a03f20e53e9b',
|
||||
name: 'wrong-channel',
|
||||
__typename: 'UpdateBranch',
|
||||
},
|
||||
codeSigningInfo: null,
|
||||
__typename: 'Update',
|
||||
},
|
||||
],
|
||||
],
|
||||
__typename: 'UpdateBranch',
|
||||
};
|
||||
|
||||
export const testUpdateBranch2: UpdateBranchObject = {
|
||||
id: '6941a8dd-5c0a-48bc-8876-f49c88ed419f',
|
||||
name: 'production',
|
||||
updateGroups: [
|
||||
[
|
||||
{
|
||||
id: 'f04aafc1-0bf5-4548-8c35-726b467e7c40',
|
||||
group: 'e40ad156-e9af-4cc2-8e9d-c7b5c328db48',
|
||||
message: 'fix bug',
|
||||
createdAt: '2023-06-23T23:37:10.004Z',
|
||||
runtimeVersion: 'exposdk:48.0.0',
|
||||
platform: 'ios',
|
||||
manifestFragment:
|
||||
'{"extra":{"expoClient":{"ios":{"supportsTablet":true,"bundleIdentifier":"com.quintest113.updatestest4"},"web":{"favicon":"./assets/favicon.png"},"icon":"./assets/icon.png","name":"updates-test-4","slug":"updates-test-4","extra":{"eas":{"projectId":"789a8784-0186-4f41-8bd2-7f219291a2b8"}},"splash":{"image":"./assets/splash.png","resizeMode":"contain","backgroundColor":"#ffffff"},"android":{"package":"com.quintest113.updatestest4","adaptiveIcon":{"backgroundColor":"#ffffff","foregroundImage":"./assets/adaptive-icon.png"}},"updates":{"url":"https://u.expo.dev/789a8784-0186-4f41-8bd2-7f219291a2b8"},"version":"1.0.0","platforms":["ios","android"],"sdkVersion":"48.0.0","orientation":"portrait","runtimeVersion":{"policy":"sdkVersion"},"currentFullName":"@quintest113/updates-test-4","originalFullName":"@quintest113/updates-test-4","userInterfaceStyle":"light","assetBundlePatterns":["**/*"]}},"assets":[{"bundleKey":"7d40544b395c5949f4646f5e150fe020","fileSHA256":"LN_rjlzN55dvcBL7jM5zryIpAp7iHU8VCf7RFIccbNg","storageKey":"Pi65ns2w9xLI5G_m9aGrJ5wK5FpWcGcInSSFwnA_0aA","contentType":"image/png","fileExtension":".png"},{"bundleKey":"cdd04e13d4ec83ff0cd13ec8dabdc341","fileSHA256":"irHji01LPI9giDYxtJo9bTPHaEHzuUcCMTlb4xn90Ks","storageKey":"oJZ9JX1m8IKNDgnIqcKOo5KUFOoa_NCaxruigZu65E0","contentType":"image/png","fileExtension":".png"},{"bundleKey":"a132ecc4ba5c1517ff83c0fb321bc7fc","fileSHA256":"K3RDqaWOksoRpXW9wRWYYVpX3eMFzYznDWAmBrMCzZg","storageKey":"X3OAkUu5P_LqyKFx1bNkA_qDQFLlSjCYNAlVEaOamHE","contentType":"image/png","fileExtension":".png"},{"bundleKey":"0ea69b5077e7c4696db85dbcba75b0e1","fileSHA256":"Pd0Hc-0n4j0leJ8wFzGzrEVa2L5aonoKG4OPicbSciU","storageKey":"FwJkAbrRNahBGX1KXnTAgiiwyzENu3D7DeEzCwmInyo","contentType":"image/png","fileExtension":".png"},{"bundleKey":"f5b790e2ac193b3d41015edb3551f9b8","fileSHA256":"vCfIss7SLYKt1AAi0hxjsgOMEDceLJB6ZWjQf6gTU4k","storageKey":"GQtYm7r0G9N3WJyrymjZ26p8eNMIb7rzqwSVgGco5EU","contentType":"image/png","fileExtension":".png"},{"bundleKey":"5223c8d9b0d08b82a5670fb5f71faf78","fileSHA256":"LJXowmdeeZM1_QBGuwfb2gbLfs_1HtN5IsYwnWGcxNM","storageKey":"jeqOrdQBNY4hgl04B3wtGs8QNWMDmjvbjIMUXPtOBpw","contentType":"image/png","fileExtension":".png"},{"bundleKey":"02d5848f62ade9617cf96894727d32e7","fileSHA256":"u1-8zHOj9oxS8maH-7JbShJIq1-KEV7FXnrGpEUcR-4","storageKey":"LMUvUZZpdGFb7KqaB_l9N4TXTkFn1Pw92ws9fWDNZPw","contentType":"model/gltf-binary","fileExtension":".glb"},{"bundleKey":"6013c27e421ab23c826713af3ebec891","fileSHA256":"dutQNnvzTCS4NgwvbeMbvhro5QnW0GJUeY_4rrvlcLY","storageKey":"85KB9Jr7eqz1Jwt1mYQGRzTy6ZXyO0j7ZcaLImCbzkU","contentType":"image/jpeg","fileExtension":".jpg"}],"launchAsset":{"bundleKey":"76ec49faddaef30ba78fe50edc5ab3d6","fileSHA256":"OLUdgP-fZI2shwGS5qlIk7nWnybL_13RJl5xR85CDBU","storageKey":"OCvI0-34zApbdo3eSGfIaQUbWdZeLeKH-nJSeedKTFs","contentType":"application/javascript","fileExtension":".bundle"}}',
|
||||
isRollBackToEmbedded: false,
|
||||
manifestPermalink: 'https://u.expo.dev/update/f04aafc1-0bf5-4548-8c35-726b467e7c40',
|
||||
gitCommitHash: 'e91c5bb733eff675dd8a3722ed5d4f885a4b10ce',
|
||||
actor: {
|
||||
__typename: 'User',
|
||||
id: '026ace2b-c1f8-4a02-818d-6b52c100bdf2',
|
||||
username: 'quintest113',
|
||||
},
|
||||
branch: {
|
||||
id: '6941a8dd-5c0a-48bc-8876-f49c88ed419f',
|
||||
name: 'production',
|
||||
__typename: 'UpdateBranch',
|
||||
},
|
||||
codeSigningInfo: null,
|
||||
__typename: 'Update',
|
||||
},
|
||||
{
|
||||
id: 'd8d15786-2727-407f-bf3e-f4bda7e397d9',
|
||||
group: 'e40ad156-e9af-4cc2-8e9d-c7b5c328db48',
|
||||
message: 'fix bug',
|
||||
createdAt: '2023-06-23T23:37:10.004Z',
|
||||
runtimeVersion: 'exposdk:48.0.0',
|
||||
platform: 'android',
|
||||
manifestFragment:
|
||||
'{"extra":{"expoClient":{"ios":{"supportsTablet":true,"bundleIdentifier":"com.quintest113.updatestest4"},"web":{"favicon":"./assets/favicon.png"},"icon":"./assets/icon.png","name":"updates-test-4","slug":"updates-test-4","extra":{"eas":{"projectId":"789a8784-0186-4f41-8bd2-7f219291a2b8"}},"splash":{"image":"./assets/splash.png","resizeMode":"contain","backgroundColor":"#ffffff"},"android":{"package":"com.quintest113.updatestest4","adaptiveIcon":{"backgroundColor":"#ffffff","foregroundImage":"./assets/adaptive-icon.png"}},"updates":{"url":"https://u.expo.dev/789a8784-0186-4f41-8bd2-7f219291a2b8"},"version":"1.0.0","platforms":["ios","android"],"sdkVersion":"48.0.0","orientation":"portrait","runtimeVersion":{"policy":"sdkVersion"},"currentFullName":"@quintest113/updates-test-4","originalFullName":"@quintest113/updates-test-4","userInterfaceStyle":"light","assetBundlePatterns":["**/*"]}},"assets":[{"bundleKey":"778ffc9fe8773a878e9c30a6304784de","fileSHA256":"i2Gkx-9w3JJ1PwSUl2SC9m_UFQ7CPfx3KrZeEDc6-lU","storageKey":"idnX8z03q4vGLzhYMPaQGBADjhqvWe67_afPqJ1vGzM","contentType":"image/png","fileExtension":".png"},{"bundleKey":"376d6a4c7f622917c39feb23671ef71d","fileSHA256":"QJsG0VpGM-5viirUgoKq0M4zQ_TYyPfLhAhN5mrM54I","storageKey":"46DQ95-UdSKiRkQgt5FyXkE1FrKOT9tBZP3nS7auKlg","contentType":"image/png","fileExtension":".png"},{"bundleKey":"c79c3606a1cf168006ad3979763c7e0c","fileSHA256":"kGZm3WiTRq2iMs42ia3vkHtCV_obWT8DY40rlJf2SIQ","storageKey":"k-3xHp3vP8mR36WdccUZVPTRJKpP-zlyboWdkXCIQpQ","contentType":"image/png","fileExtension":".png"},{"bundleKey":"02bc1fa7c0313217bde2d65ccbff40c9","fileSHA256":"_6fuRbdkBbpzkhSVAI99aMneY5X0tpQdsGNGX244IyA","storageKey":"lfqeUjiWFXwowhxVDBEzxsbTOEzYxMLebljy5Bh1mpU","contentType":"image/png","fileExtension":".png"},{"bundleKey":"35ba0eaec5a4f5ed12ca16fabeae451d","fileSHA256":"hM9es7ICUPaeDkczvrTaX0F_BoKJKlrRteYlcHU8IbE","storageKey":"fMoMrsUeB5xWw_Ugn8FqGJf-M2pld2hkXKR704z0MM8","contentType":"image/png","fileExtension":".png"},{"bundleKey":"5223c8d9b0d08b82a5670fb5f71faf78","fileSHA256":"LJXowmdeeZM1_QBGuwfb2gbLfs_1HtN5IsYwnWGcxNM","storageKey":"jeqOrdQBNY4hgl04B3wtGs8QNWMDmjvbjIMUXPtOBpw","contentType":"image/png","fileExtension":".png"},{"bundleKey":"02d5848f62ade9617cf96894727d32e7","fileSHA256":"u1-8zHOj9oxS8maH-7JbShJIq1-KEV7FXnrGpEUcR-4","storageKey":"LMUvUZZpdGFb7KqaB_l9N4TXTkFn1Pw92ws9fWDNZPw","contentType":"model/gltf-binary","fileExtension":".glb"},{"bundleKey":"6013c27e421ab23c826713af3ebec891","fileSHA256":"dutQNnvzTCS4NgwvbeMbvhro5QnW0GJUeY_4rrvlcLY","storageKey":"85KB9Jr7eqz1Jwt1mYQGRzTy6ZXyO0j7ZcaLImCbzkU","contentType":"image/jpeg","fileExtension":".jpg"}],"launchAsset":{"bundleKey":"f4c6981626dbabcf51078d07a1611f4e","fileSHA256":"q-k1hSWl49aXpSg4jOSaVVnYFSFL6xf7HVl_TQE-ObM","storageKey":"FwK1PaZqtpL0QZIgaHsxU1AV93JhMdu9K1wZizXROAc","contentType":"application/javascript","fileExtension":".bundle"}}',
|
||||
isRollBackToEmbedded: false,
|
||||
manifestPermalink: 'https://u.expo.dev/update/d8d15786-2727-407f-bf3e-f4bda7e397d9',
|
||||
gitCommitHash: 'e91c5bb733eff675dd8a3722ed5d4f885a4b10ce',
|
||||
actor: {
|
||||
__typename: 'User',
|
||||
id: '026ace2b-c1f8-4a02-818d-6b52c100bdf2',
|
||||
username: 'quintest113',
|
||||
},
|
||||
branch: {
|
||||
id: '6941a8dd-5c0a-48bc-8876-f49c88ed419f',
|
||||
name: 'production',
|
||||
__typename: 'UpdateBranch',
|
||||
},
|
||||
codeSigningInfo: null,
|
||||
__typename: 'Update',
|
||||
},
|
||||
],
|
||||
],
|
||||
__typename: 'UpdateBranch',
|
||||
};
|
||||
export const testChannelObject: UpdateChannelObject = {
|
||||
id: '9309afc2-9752-40db-8ef7-4abc10744c61',
|
||||
name: 'production',
|
||||
createdAt: '2023-05-16T21:26:16.946Z',
|
||||
updatedAt: '2023-05-16T21:26:16.946Z',
|
||||
branchMapping:
|
||||
'{"data":[{"branchId":"754bf17f-efc0-46ab-8a59-a03f20e53e9b","branchMappingLogic":{"operand":0.15,"clientKey":"rolloutToken","branchMappingOperator":"hash_lt"}},{"branchId":"6941a8dd-5c0a-48bc-8876-f49c88ed419f","branchMappingLogic":"true"}],"version":0}',
|
||||
updateBranches: [testUpdateBranch1, testUpdateBranch2],
|
||||
__typename: 'UpdateChannel',
|
||||
};
|
||||
|
||||
export const testBasicChannelInfo: UpdateChannelBasicInfoFragment = {
|
||||
id: '9309afc2-9752-40db-8ef7-4abc10744c61',
|
||||
name: 'production',
|
||||
branchMapping:
|
||||
'{"data":[{"branchId":"754bf17f-efc0-46ab-8a59-a03f20e53e9b","branchMappingLogic":{"operand":0.1,"clientKey":"rolloutToken","branchMappingOperator":"hash_lt"}},{"branchId":"6941a8dd-5c0a-48bc-8876-f49c88ed419f","branchMappingLogic":"true"}],"version":0}',
|
||||
__typename: 'UpdateChannel',
|
||||
};
|
||||
|
||||
export const testBasicChannelInfo2: UpdateChannelBasicInfoFragment = {
|
||||
id: '9d5c7bf0-d52d-474c-9140-c1bad7f0de9d',
|
||||
name: 'staging',
|
||||
branchMapping:
|
||||
'{"data":[{"branchId":"d7d68e32-d9c9-4a8d-8d1b-21e53100a5e8","branchMappingLogic":{"operand":0.1,"clientKey":"rolloutToken","branchMappingOperator":"hash_lt"}},{"branchId":"f9f708c2-0c91-4360-b2a4-0b61834aef4a","branchMappingLogic":"true"}],"version":0}',
|
||||
__typename: 'UpdateChannel',
|
||||
};
|
||||
@@ -0,0 +1,57 @@
|
||||
import { format } from '@expo/timeago.js';
|
||||
|
||||
import { getAlwaysTrueBranchMapping, getEmptyBranchMapping } from '../branch-mapping';
|
||||
import { getDescriptionByBranchId } from '../print-utils';
|
||||
import { testChannelObject, testUpdateBranch1, testUpdateBranch2 } from './fixtures';
|
||||
|
||||
describe(getDescriptionByBranchId, () => {
|
||||
it('should get descriptions for multiple branches with multiple update groups', () => {
|
||||
const descriptionByBranchId = getDescriptionByBranchId(testChannelObject);
|
||||
expect(Object.values(descriptionByBranchId)).toHaveLength(2);
|
||||
expect(descriptionByBranchId[testUpdateBranch1.id]).toEqual({
|
||||
branch: 'wrong-channel',
|
||||
branchRolloutPercentage: 15,
|
||||
update: {
|
||||
codeSigningKey: undefined,
|
||||
group: '16ca6dba-e63b-48b0-baa3-15a894ee9434',
|
||||
isRollBackToEmbedded: false,
|
||||
message: `"fix bug" (${format('2023-07-17T22:48:59.278Z', 'en_US')} by quintest113)`,
|
||||
platforms: 'android, ios',
|
||||
runtimeVersion: 'exposdk:48.0.0',
|
||||
},
|
||||
});
|
||||
|
||||
expect(descriptionByBranchId[testUpdateBranch2.id]).toEqual({
|
||||
branch: 'production',
|
||||
branchRolloutPercentage: 85,
|
||||
update: {
|
||||
codeSigningKey: undefined,
|
||||
group: 'e40ad156-e9af-4cc2-8e9d-c7b5c328db48',
|
||||
isRollBackToEmbedded: false,
|
||||
message: `"fix bug" (${format('2023-06-23T23:37:10.004Z', 'en_US')} by quintest113)`,
|
||||
platforms: 'android, ios',
|
||||
runtimeVersion: 'exposdk:48.0.0',
|
||||
},
|
||||
});
|
||||
});
|
||||
it('should get descriptions for branches with no updates', () => {
|
||||
const noUpdatesBranch = { ...testUpdateBranch1, updateGroups: [] };
|
||||
const oneBranchChannel = {
|
||||
...testChannelObject,
|
||||
updateBranches: [noUpdatesBranch],
|
||||
branchMapping: JSON.stringify(getAlwaysTrueBranchMapping(noUpdatesBranch.id)),
|
||||
};
|
||||
const descriptionByBranchId = getDescriptionByBranchId(oneBranchChannel);
|
||||
expect(Object.values(descriptionByBranchId)).toHaveLength(1);
|
||||
expect(descriptionByBranchId[testUpdateBranch1.id]).toEqual({ branch: 'wrong-channel' });
|
||||
});
|
||||
it('should get descriptions for no branches', () => {
|
||||
const noBranchChannel = {
|
||||
...testChannelObject,
|
||||
updateBranches: [],
|
||||
branchMapping: JSON.stringify(getEmptyBranchMapping()),
|
||||
};
|
||||
const descriptionByBranchId = getDescriptionByBranchId(noBranchChannel);
|
||||
expect(Object.values(descriptionByBranchId)).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
import { getUpdateBranch } from '../utils';
|
||||
import { channelInfoWithBranches, testBasicBranchInfo1 } from './branch-mapping-fixtures';
|
||||
|
||||
describe(getUpdateBranch, () => {
|
||||
it('gets the update branch', () => {
|
||||
expect(getUpdateBranch(channelInfoWithBranches, '754bf17f-efc0-46ab-8a59-a03f20e53e9b')).toBe(
|
||||
testBasicBranchInfo1
|
||||
);
|
||||
expect(() => getUpdateBranch(channelInfoWithBranches, 'not-a-branch-ID')).toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,107 @@
|
||||
import { EASUpdateAction, EASUpdateContext, NonInteractiveError } from '../../eas-update/utils';
|
||||
import { UpdateChannelBasicInfoFragment } from '../../graphql/generated';
|
||||
import { ChannelQuery } from '../../graphql/queries/ChannelQuery';
|
||||
import {
|
||||
Connection,
|
||||
Edge,
|
||||
FilterPagination,
|
||||
QueryParams,
|
||||
selectPaginatedAsync,
|
||||
} from '../../utils/relay';
|
||||
|
||||
/**
|
||||
* Select a channel for the project.
|
||||
*
|
||||
* @constructor
|
||||
* @param {function} options.filterPredicate - A predicate to filter the channels that are shown to the user. It takes a channelInfo object as a parameter and returns a boolean.
|
||||
* @param {string} options.printedType - The type of channel printed to the user. Defaults to 'channel'.
|
||||
* @param {number} options.pageSize - The number of channels to show per page. Defaults to 100.
|
||||
* @param {function} options.beforeEachFilterQuery Optional. If a filter predicate was specified, this callback function will be called before each query.
|
||||
* @args externalQueryParams The query params for the pagination.
|
||||
* @args totalNodesFetched The total number of nodes fetched so far.
|
||||
* @args dataset The dataset so far.
|
||||
* @param {function} options.afterEachFilterQuery Optional. If a filter predicate was specified, this callback function will be called after each query.
|
||||
* @args externalQueryParams The query params for the pagination.
|
||||
* @args totalNodesFetched The total number of nodes fetched so far.
|
||||
* @args dataset The dataset so far.
|
||||
* @args willFetchAgain If the query will fetch again to get a complete page.
|
||||
*/
|
||||
export class SelectChannel implements EASUpdateAction<UpdateChannelBasicInfoFragment | null> {
|
||||
constructor(
|
||||
private options: {
|
||||
filterPredicate?: (channelInfo: UpdateChannelBasicInfoFragment) => boolean;
|
||||
printedType?: string;
|
||||
pageSize?: number;
|
||||
beforeEachFilterQuery?: (
|
||||
externalQueryParams: QueryParams,
|
||||
totalNodesFetched: number,
|
||||
dataset: Edge<UpdateChannelBasicInfoFragment>[]
|
||||
) => void;
|
||||
afterEachFilterQuery?: (
|
||||
externalQueryParams: QueryParams,
|
||||
totalNodesFetched: number,
|
||||
dataset: Edge<UpdateChannelBasicInfoFragment>[],
|
||||
willFetchAgain: boolean
|
||||
) => void;
|
||||
} = {}
|
||||
) {}
|
||||
|
||||
async queryAsync(
|
||||
ctx: EASUpdateContext,
|
||||
queryParams: QueryParams
|
||||
): Promise<Connection<UpdateChannelBasicInfoFragment>> {
|
||||
const { graphqlClient, app } = ctx;
|
||||
const { projectId } = app;
|
||||
return await ChannelQuery.viewUpdateChannelsBasicInfoPaginatedOnAppAsync(graphqlClient, {
|
||||
appId: projectId,
|
||||
...queryParams,
|
||||
});
|
||||
}
|
||||
|
||||
async filterQueryAsync(
|
||||
ctx: EASUpdateContext,
|
||||
queryParams: QueryParams,
|
||||
filterPredicate: (channelInfo: UpdateChannelBasicInfoFragment) => boolean
|
||||
): Promise<Connection<UpdateChannelBasicInfoFragment>> {
|
||||
const queryAsync = async (
|
||||
queryParams: QueryParams
|
||||
): Promise<Connection<UpdateChannelBasicInfoFragment>> =>
|
||||
await this.queryAsync(ctx, queryParams);
|
||||
return await FilterPagination.getPageAsync({
|
||||
queryParams,
|
||||
queryAsync,
|
||||
filterPredicate,
|
||||
beforeEachQuery: this.options.beforeEachFilterQuery,
|
||||
afterEachQuery: this.options.afterEachFilterQuery,
|
||||
});
|
||||
}
|
||||
|
||||
public async runAsync(ctx: EASUpdateContext): Promise<UpdateChannelBasicInfoFragment | null> {
|
||||
const { nonInteractive } = ctx;
|
||||
const { filterPredicate } = this.options;
|
||||
const printedType = this.options.printedType ?? 'channel';
|
||||
const pageSize = this.options.pageSize ?? 100;
|
||||
if (nonInteractive) {
|
||||
throw new NonInteractiveError(
|
||||
`${printedType} selection cannot be run in non-interactive mode.`
|
||||
);
|
||||
}
|
||||
|
||||
const queryAsync = async (
|
||||
queryParams: QueryParams
|
||||
): Promise<Connection<UpdateChannelBasicInfoFragment>> =>
|
||||
filterPredicate
|
||||
? this.filterQueryAsync(ctx, queryParams, filterPredicate)
|
||||
: this.queryAsync(ctx, queryParams);
|
||||
|
||||
const getTitleAsync = async (channelInfo: UpdateChannelBasicInfoFragment): Promise<string> =>
|
||||
channelInfo.name;
|
||||
|
||||
return await selectPaginatedAsync({
|
||||
queryAsync,
|
||||
getTitleAsync,
|
||||
printedType,
|
||||
pageSize,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { createCtxMock } from '../../../eas-update/__tests__/fixtures';
|
||||
import { NonInteractiveError } from '../../../eas-update/utils';
|
||||
import { UpdateChannelBasicInfoFragment } from '../../../graphql/generated';
|
||||
import { ChannelQuery } from '../../../graphql/queries/ChannelQuery';
|
||||
import { promptAsync } from '../../../prompts';
|
||||
import { testBasicChannelInfo, testBasicChannelInfo2 } from '../../__tests__/fixtures';
|
||||
import { SelectChannel } from '../SelectChannel';
|
||||
|
||||
jest.mock('../../../prompts');
|
||||
jest.mock('../../../graphql/queries/ChannelQuery');
|
||||
describe(SelectChannel, () => {
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
it('returns null if project has no channels', async () => {
|
||||
const ctx = createCtxMock();
|
||||
jest.mocked(ChannelQuery.viewUpdateChannelsBasicInfoPaginatedOnAppAsync).mockResolvedValue({
|
||||
edges: [],
|
||||
pageInfo: {
|
||||
hasPreviousPage: false,
|
||||
hasNextPage: false,
|
||||
},
|
||||
});
|
||||
const selectChannel = new SelectChannel();
|
||||
const channel = await selectChannel.runAsync(ctx);
|
||||
expect(channel).toBeNull();
|
||||
});
|
||||
it('still prompts if project has only one channel', async () => {
|
||||
const ctx = createCtxMock();
|
||||
jest.mocked(ChannelQuery.viewUpdateChannelsBasicInfoPaginatedOnAppAsync).mockResolvedValue({
|
||||
edges: [{ node: testBasicChannelInfo, cursor: 'cursor' }],
|
||||
pageInfo: {
|
||||
hasPreviousPage: false,
|
||||
hasNextPage: false,
|
||||
},
|
||||
});
|
||||
jest.mocked(promptAsync).mockImplementation(async () => ({
|
||||
item: testBasicChannelInfo,
|
||||
}));
|
||||
const selectChannel = new SelectChannel();
|
||||
const channel = await selectChannel.runAsync(ctx);
|
||||
expect(channel).toBe(testBasicChannelInfo);
|
||||
expect(promptAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
it('returns the selected channel', async () => {
|
||||
jest.mocked(ChannelQuery.viewUpdateChannelsBasicInfoPaginatedOnAppAsync).mockResolvedValue({
|
||||
edges: [
|
||||
{ node: testBasicChannelInfo, cursor: 'cursor' },
|
||||
{ node: testBasicChannelInfo2, cursor: 'cursor' },
|
||||
],
|
||||
pageInfo: {
|
||||
hasPreviousPage: false,
|
||||
hasNextPage: false,
|
||||
},
|
||||
});
|
||||
jest.mocked(promptAsync).mockImplementation(async () => ({
|
||||
item: testBasicChannelInfo,
|
||||
}));
|
||||
const ctx = createCtxMock();
|
||||
const selectChannel = new SelectChannel();
|
||||
const channel = await selectChannel.runAsync(ctx);
|
||||
expect(channel).toBe(testBasicChannelInfo);
|
||||
});
|
||||
it('returns the only available channel with a filter', async () => {
|
||||
jest.mocked(ChannelQuery.viewUpdateChannelsBasicInfoPaginatedOnAppAsync).mockResolvedValue({
|
||||
edges: [
|
||||
{ node: testBasicChannelInfo, cursor: 'cursor' },
|
||||
{ node: testBasicChannelInfo2, cursor: 'cursor' },
|
||||
],
|
||||
pageInfo: {
|
||||
hasPreviousPage: false,
|
||||
hasNextPage: false,
|
||||
},
|
||||
});
|
||||
const ctx = createCtxMock();
|
||||
const selectChannel = new SelectChannel({
|
||||
filterPredicate: (channelInfo: UpdateChannelBasicInfoFragment) =>
|
||||
channelInfo === testBasicChannelInfo,
|
||||
});
|
||||
const channel = await selectChannel.runAsync(ctx);
|
||||
expect(channel).toBe(testBasicChannelInfo);
|
||||
});
|
||||
it('throws an error in Non-Interactive Mode', async () => {
|
||||
const ctx = createCtxMock({
|
||||
nonInteractive: true,
|
||||
});
|
||||
const selectChannel = new SelectChannel();
|
||||
|
||||
// dont fail if users are running in non-interactive mode
|
||||
await expect(selectChannel.runAsync(ctx)).rejects.toThrowError(NonInteractiveError);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,214 @@
|
||||
import { ChannelBasicInfo } from './utils';
|
||||
|
||||
// TODO(quin): move this into a common package with www
|
||||
export type BranchMappingOperator =
|
||||
| '=='
|
||||
| '!='
|
||||
| '<'
|
||||
| '>'
|
||||
| '<='
|
||||
| '>='
|
||||
| 'in'
|
||||
| 'regex'
|
||||
| 'hash_lt'
|
||||
| 'hash_lte'
|
||||
| 'hash_gt'
|
||||
| 'hash_gte';
|
||||
export type BranchMappingObject = {
|
||||
clientKey: string;
|
||||
branchMappingOperator: BranchMappingOperator;
|
||||
operand: number | string | string[];
|
||||
};
|
||||
export type BranchMappingAlwaysTrue = 'true';
|
||||
export type BranchMappingStatement =
|
||||
| ['and' | 'or', ...BranchMappingNode[]]
|
||||
| ['not', BranchMappingNode];
|
||||
export type BranchMappingNode =
|
||||
| BranchMappingAlwaysTrue
|
||||
| BranchMappingObject
|
||||
| BranchMappingStatement;
|
||||
export type BranchMapping = {
|
||||
version: number;
|
||||
data: {
|
||||
branchId: string;
|
||||
branchMappingLogic: BranchMappingNode;
|
||||
}[];
|
||||
};
|
||||
|
||||
export type AlwaysTrueBranchMapping = {
|
||||
version: number;
|
||||
data: [
|
||||
{
|
||||
branchId: string;
|
||||
branchMappingLogic: BranchMappingAlwaysTrue;
|
||||
}
|
||||
];
|
||||
};
|
||||
|
||||
export type EmptyBranchMapping = {
|
||||
version: number;
|
||||
data: [];
|
||||
};
|
||||
|
||||
export function getEmptyBranchMapping(): EmptyBranchMapping {
|
||||
return {
|
||||
version: 0,
|
||||
data: [],
|
||||
};
|
||||
}
|
||||
|
||||
export function getAlwaysTrueBranchMapping(branchId: string): AlwaysTrueBranchMapping {
|
||||
return {
|
||||
version: 0,
|
||||
data: [
|
||||
{
|
||||
branchId,
|
||||
branchMappingLogic: 'true',
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
export function hasEmptyBranchMap(channelInfo: ChannelBasicInfo): boolean {
|
||||
const branchMapping = getBranchMapping(channelInfo.branchMapping);
|
||||
return isEmptyBranchMapping(branchMapping);
|
||||
}
|
||||
|
||||
export function hasStandardBranchMap(channelInfo: ChannelBasicInfo): boolean {
|
||||
const branchMapping = getBranchMapping(channelInfo.branchMapping);
|
||||
return isAlwaysTrueBranchMapping(branchMapping);
|
||||
}
|
||||
|
||||
export function getStandardBranchId(channelInfo: ChannelBasicInfo): string {
|
||||
const branchMapping = getBranchMapping(channelInfo.branchMapping);
|
||||
assertAlwaysTrueBranchMapping(branchMapping);
|
||||
return getBranchIdFromStandardMapping(branchMapping);
|
||||
}
|
||||
|
||||
export function isEmptyBranchMapping(
|
||||
branchMapping: BranchMapping
|
||||
): branchMapping is EmptyBranchMapping {
|
||||
return branchMapping.data.length === 0;
|
||||
}
|
||||
|
||||
export function isAlwaysTrueBranchMapping(
|
||||
branchMapping: BranchMapping
|
||||
): branchMapping is AlwaysTrueBranchMapping {
|
||||
const numBranches = branchMapping.data.length;
|
||||
if (numBranches !== 1) {
|
||||
return false;
|
||||
}
|
||||
const branchMappingLogic = branchMapping.data[0].branchMappingLogic;
|
||||
return isAlwaysTrue(branchMappingLogic);
|
||||
}
|
||||
|
||||
function getBranchIdFromStandardMapping(branchMapping: AlwaysTrueBranchMapping): string {
|
||||
return branchMapping.data[0].branchId;
|
||||
}
|
||||
|
||||
export function getBranchIds(branchMapping: BranchMapping): string[] {
|
||||
return branchMapping.data.map(data => data.branchId);
|
||||
}
|
||||
|
||||
export function getBranchMapping(branchMappingString: string): BranchMapping {
|
||||
try {
|
||||
return JSON.parse(branchMappingString);
|
||||
} catch {
|
||||
throw new Error(`Could not parse branchMapping string into a JSON: "${branchMappingString}"`);
|
||||
}
|
||||
}
|
||||
|
||||
export function getNodesFromStatement(statement: BranchMappingStatement): BranchMappingNode[] {
|
||||
return statement.slice(1) as BranchMappingNode[];
|
||||
}
|
||||
|
||||
export function isAndStatement(
|
||||
statement: BranchMappingStatement
|
||||
): statement is BranchMappingStatement {
|
||||
return statement[0] === 'and';
|
||||
}
|
||||
|
||||
export function isStatement(node: BranchMappingNode): node is BranchMappingStatement {
|
||||
return Array.isArray(node);
|
||||
}
|
||||
|
||||
export function isNodeObject(node: BranchMappingNode): node is BranchMappingObject {
|
||||
return typeof node === 'object' && !isStatement(node);
|
||||
}
|
||||
|
||||
export function andStatement(nodes: BranchMappingNode[]): ['and', ...BranchMappingNode[]] {
|
||||
return ['and', ...nodes];
|
||||
}
|
||||
|
||||
export function isAlwaysTrue(node: BranchMappingNode): boolean {
|
||||
return node === 'true';
|
||||
}
|
||||
|
||||
export function alwaysTrue(): BranchMappingAlwaysTrue {
|
||||
return 'true';
|
||||
}
|
||||
|
||||
export function equalsOperator(): BranchMappingOperator {
|
||||
return '==';
|
||||
}
|
||||
|
||||
export function hashLtOperator(): BranchMappingOperator {
|
||||
return 'hash_lt';
|
||||
}
|
||||
|
||||
function isVersion(branchMapping: BranchMapping, version: number): boolean {
|
||||
return branchMapping.version === version;
|
||||
}
|
||||
|
||||
export function assertVersion(channelInfo: ChannelBasicInfo, version: number): void {
|
||||
const branchMapping = getBranchMapping(channelInfo.branchMapping);
|
||||
if (!isVersion(branchMapping, version)) {
|
||||
throw new BranchMappingValidationError(
|
||||
`Expected branch mapping version ${version}. Received: ${JSON.stringify(branchMapping)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function assertStatement(node: BranchMappingNode): asserts node is BranchMappingStatement {
|
||||
if (!isStatement(node)) {
|
||||
throw new BranchMappingValidationError(
|
||||
'Branch mapping node must be a statement. Received: ' + JSON.stringify(node)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function assertNodeObject(node: BranchMappingNode): asserts node is BranchMappingObject {
|
||||
if (!isNodeObject(node)) {
|
||||
throw new BranchMappingValidationError(
|
||||
'Branch mapping node must be an object. Received: ' + JSON.stringify(node)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function assertNumber(operand: string | number | string[]): asserts operand is number {
|
||||
if (typeof operand !== 'number') {
|
||||
throw new BranchMappingValidationError(
|
||||
'Expected a number. Received: ' + JSON.stringify(operand)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function assertString(operand: string | number | string[]): asserts operand is string {
|
||||
if (typeof operand !== 'string') {
|
||||
throw new BranchMappingValidationError(
|
||||
'Expected a string. Received: ' + JSON.stringify(operand)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function assertAlwaysTrueBranchMapping(
|
||||
branchMapping: BranchMapping
|
||||
): asserts branchMapping is AlwaysTrueBranchMapping {
|
||||
if (!isAlwaysTrueBranchMapping(branchMapping)) {
|
||||
throw new BranchMappingValidationError(
|
||||
'Expected standard branch mapping. Received: ' + JSON.stringify(branchMapping)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export class BranchMappingValidationError extends Error {}
|
||||
@@ -0,0 +1,68 @@
|
||||
import chalk from 'chalk';
|
||||
|
||||
import { UpdateChannelObject } from '../graphql/queries/ChannelQuery';
|
||||
import Log from '../log';
|
||||
import { getRollout, isRollout } from '../rollout/branch-mapping';
|
||||
import {
|
||||
FormattedBranchDescription,
|
||||
formatBranch,
|
||||
getUpdateGroupDescriptionsWithBranch,
|
||||
} from '../update/utils';
|
||||
import { assertVersion, hasEmptyBranchMap, hasStandardBranchMap } from './branch-mapping';
|
||||
|
||||
/**
|
||||
* Log all the branches associated with the channel and their most recent update group
|
||||
*/
|
||||
export function logChannelDetails(channel: UpdateChannelObject): void {
|
||||
assertVersion(channel, 0);
|
||||
const doesSupportMapping =
|
||||
hasEmptyBranchMap(channel) || hasStandardBranchMap(channel) || isRollout(channel);
|
||||
if (!doesSupportMapping) {
|
||||
Log.log(chalk.dim('Custom branch mapping detected.'));
|
||||
return;
|
||||
}
|
||||
|
||||
const branchDescriptionByBranchId = getDescriptionByBranchId(channel);
|
||||
const entries = Object.entries(branchDescriptionByBranchId);
|
||||
if (entries.length === 0) {
|
||||
Log.log(chalk.dim('No branches are pointed to this channel.'));
|
||||
} else {
|
||||
Log.log(
|
||||
entries
|
||||
.map(([_branchId, description]) => formatBranch(description))
|
||||
.join(`\n\n${chalk.dim('———')}\n\n`)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function getDescriptionByBranchId(
|
||||
channel: UpdateChannelObject
|
||||
): Record<string, FormattedBranchDescription> {
|
||||
return channel.updateBranches.reduce((acc, branch) => {
|
||||
const maybeRollout = isRollout(channel) ? getRollout(channel) : null;
|
||||
let maybePercentOnBranch: number | undefined = undefined;
|
||||
if (maybeRollout) {
|
||||
maybePercentOnBranch =
|
||||
maybeRollout.rolledOutBranchId === branch.id
|
||||
? maybeRollout.percentRolledOut
|
||||
: 100 - maybeRollout.percentRolledOut;
|
||||
}
|
||||
|
||||
if (branch.updateGroups.length === 0) {
|
||||
acc[branch.id] = { branch: branch.name, branchRolloutPercentage: maybePercentOnBranch };
|
||||
return acc;
|
||||
}
|
||||
const updateGroupsWithBranchDescriptions = getUpdateGroupDescriptionsWithBranch(
|
||||
branch.updateGroups
|
||||
);
|
||||
// display the most recent update group
|
||||
const updateGroupWithBranchDescription = updateGroupsWithBranchDescriptions[0];
|
||||
const { branch: branchName, ...updateGroup } = updateGroupWithBranchDescription;
|
||||
acc[branch.id] = {
|
||||
branch: branchName,
|
||||
branchRolloutPercentage: maybePercentOnBranch,
|
||||
update: updateGroup,
|
||||
};
|
||||
return acc;
|
||||
}, {} as Record<string, FormattedBranchDescription>);
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
import chalk from 'chalk';
|
||||
import { print } from 'graphql';
|
||||
import gql from 'graphql-tag';
|
||||
|
||||
import { createUpdateBranchOnAppAsync } from '../branch/queries';
|
||||
import { BranchNotFoundError } from '../branch/utils';
|
||||
import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { PaginatedQueryOptions } from '../commandUtils/pagination';
|
||||
import { withErrorHandlingAsync } from '../graphql/client';
|
||||
@@ -12,14 +15,18 @@ import {
|
||||
} from '../graphql/generated';
|
||||
import { BranchQuery, UpdateBranchOnChannelObject } from '../graphql/queries/BranchQuery';
|
||||
import { ChannelQuery, UpdateChannelObject } from '../graphql/queries/ChannelQuery';
|
||||
import { UpdateChannelBasicInfoFragmentNode } from '../graphql/types/UpdateChannelBasicInfo';
|
||||
import Log from '../log';
|
||||
import { getDisplayNameForProjectIdAsync } from '../project/projectUtils';
|
||||
import formatFields from '../utils/formatFields';
|
||||
import { printJsonOnlyOutput } from '../utils/json';
|
||||
import {
|
||||
paginatedQueryWithConfirmPromptAsync,
|
||||
paginatedQueryWithSelectPromptAsync,
|
||||
} from '../utils/queries';
|
||||
import { logChannelDetails } from './utils';
|
||||
import { ChannelNotFoundError } from './errors';
|
||||
import { logChannelDetails } from './print-utils';
|
||||
import { ChannelBasicInfo } from './utils';
|
||||
|
||||
export const CHANNELS_LIMIT = 25;
|
||||
|
||||
@@ -227,11 +234,11 @@ export async function createChannelOnAppAsync(
|
||||
updateChannel {
|
||||
createUpdateChannelForApp(appId: $appId, name: $name, branchMapping: $branchMapping) {
|
||||
id
|
||||
name
|
||||
branchMapping
|
||||
...UpdateChannelBasicInfoFragment
|
||||
}
|
||||
}
|
||||
}
|
||||
${print(UpdateChannelBasicInfoFragmentNode)}
|
||||
`,
|
||||
{
|
||||
appId,
|
||||
@@ -262,3 +269,101 @@ export async function ensureChannelExistsAsync(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function doesChannelExistAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
{ appId, channelName }: { appId: string; channelName: string }
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
await ChannelQuery.viewUpdateChannelAsync(graphqlClient, {
|
||||
appId,
|
||||
channelName,
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof ChannelNotFoundError) {
|
||||
return false;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* Creates a channel and links it to a branch with the same name.
|
||||
*
|
||||
* @param appId the app ID, also known as the project ID
|
||||
* @param channelName the name of the channel to create
|
||||
* @param shouldPrintJson print only the JSON output
|
||||
*/
|
||||
export async function createAndLinkChannelAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
{
|
||||
appId,
|
||||
channelName,
|
||||
shouldPrintJson,
|
||||
}: { appId: string; channelName: string; shouldPrintJson?: boolean }
|
||||
): Promise<ChannelBasicInfo> {
|
||||
let branchId: string;
|
||||
let branchMessage: string;
|
||||
|
||||
try {
|
||||
const branch = await BranchQuery.getBranchByNameAsync(graphqlClient, {
|
||||
appId,
|
||||
name: channelName,
|
||||
});
|
||||
branchId = branch.id;
|
||||
branchMessage = `We found a branch with the same name`;
|
||||
} catch (error) {
|
||||
if (error instanceof BranchNotFoundError) {
|
||||
const newBranch = await createUpdateBranchOnAppAsync(graphqlClient, {
|
||||
appId,
|
||||
name: channelName,
|
||||
});
|
||||
branchId = newBranch.id;
|
||||
branchMessage = `We also went ahead and made a branch with the same name`;
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
const {
|
||||
updateChannel: { createUpdateChannelForApp: newChannel },
|
||||
} = await createChannelOnAppAsync(graphqlClient, {
|
||||
appId,
|
||||
channelName,
|
||||
branchId,
|
||||
});
|
||||
|
||||
if (!newChannel) {
|
||||
throw new Error(
|
||||
`Could not create channel with name ${channelName} on project with id ${appId}`
|
||||
);
|
||||
}
|
||||
|
||||
if (shouldPrintJson) {
|
||||
printJsonOnlyOutput(newChannel);
|
||||
} else {
|
||||
Log.addNewLineIfNone();
|
||||
Log.withTick(
|
||||
`Created a new channel on project ${chalk.bold(
|
||||
await getDisplayNameForProjectIdAsync(graphqlClient, appId)
|
||||
)}`
|
||||
);
|
||||
Log.log(
|
||||
formatFields([
|
||||
{ label: 'Name', value: newChannel.name },
|
||||
{ label: 'ID', value: newChannel.id },
|
||||
])
|
||||
);
|
||||
Log.addNewLineIfNone();
|
||||
Log.withTick(`${branchMessage} and have pointed the channel at it.`);
|
||||
Log.log(
|
||||
formatFields([
|
||||
{ label: 'Name', value: newChannel.name },
|
||||
{ label: 'ID', value: branchId },
|
||||
])
|
||||
);
|
||||
}
|
||||
return newChannel;
|
||||
}
|
||||
@@ -1,117 +1,35 @@
|
||||
import assert from 'assert';
|
||||
import chalk from 'chalk';
|
||||
|
||||
import { UpdateChannelObject } from '../graphql/queries/ChannelQuery';
|
||||
import Log from '../log';
|
||||
import {
|
||||
FormattedBranchDescription,
|
||||
formatBranch,
|
||||
getUpdateGroupDescriptionsWithBranch,
|
||||
} from '../update/utils';
|
||||
|
||||
export type BranchMapping = {
|
||||
version: number;
|
||||
data: {
|
||||
branchId: string;
|
||||
branchMappingLogic: {
|
||||
operand: number;
|
||||
clientKey: string;
|
||||
branchMappingOperator: string;
|
||||
} & string;
|
||||
}[];
|
||||
export type ChannelBasicInfo = {
|
||||
id: string;
|
||||
name: string;
|
||||
branchMapping: string;
|
||||
};
|
||||
export type BranchBasicInfo = {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the branch mapping and determine whether it is a rollout.
|
||||
* Ensure that the branch mapping is properly formatted.
|
||||
*/
|
||||
export function getBranchMapping(branchMappingString?: string): {
|
||||
branchMapping: BranchMapping;
|
||||
isRollout: boolean;
|
||||
rolloutPercent?: number;
|
||||
} {
|
||||
if (!branchMappingString) {
|
||||
throw new Error('Missing branch mapping.');
|
||||
}
|
||||
let branchMapping: BranchMapping;
|
||||
try {
|
||||
branchMapping = JSON.parse(branchMappingString);
|
||||
} catch {
|
||||
throw new Error(`Could not parse branchMapping string into a JSON: "${branchMappingString}"`);
|
||||
}
|
||||
assert(branchMapping, 'Branch Mapping must be defined.');
|
||||
export type UpdateChannelInfoWithBranches<Branch extends BranchBasicInfo> = ChannelBasicInfo & {
|
||||
updateBranches: Branch[];
|
||||
};
|
||||
|
||||
if (branchMapping.version !== 0) {
|
||||
throw new Error('Branch mapping must be version 0.');
|
||||
}
|
||||
|
||||
const isRollout = branchMapping.data.length === 2;
|
||||
const rolloutPercent = branchMapping.data[0]?.branchMappingLogic.operand;
|
||||
|
||||
switch (branchMapping.data.length) {
|
||||
case 0:
|
||||
break;
|
||||
case 1:
|
||||
if (branchMapping.data[0].branchMappingLogic !== 'true') {
|
||||
throw new Error('Branch mapping logic for a single branch must be "true"');
|
||||
}
|
||||
break;
|
||||
case 2:
|
||||
if (branchMapping.data[0].branchMappingLogic.clientKey !== 'rolloutToken') {
|
||||
throw new Error('Client key of initial branch mapping must be "rolloutToken"');
|
||||
}
|
||||
if (branchMapping.data[0].branchMappingLogic.branchMappingOperator !== 'hash_lt') {
|
||||
throw new Error('Branch mapping operator of initial branch mapping must be "hash_lt"');
|
||||
}
|
||||
if (rolloutPercent == null) {
|
||||
throw new Error('Branch mapping is missing a "rolloutPercent"');
|
||||
}
|
||||
if (branchMapping.data[1].branchMappingLogic !== 'true') {
|
||||
throw new Error('Branch mapping logic for a the second branch of a rollout must be "true"');
|
||||
}
|
||||
break;
|
||||
default:
|
||||
throw new Error('Branch mapping data must have length less than or equal to 2.');
|
||||
}
|
||||
|
||||
return { branchMapping, isRollout, rolloutPercent };
|
||||
function getUpdateBranchNullable<Branch extends BranchBasicInfo>(
|
||||
channel: UpdateChannelInfoWithBranches<Branch>,
|
||||
branchId: string
|
||||
): Branch | null {
|
||||
const updateBranches = channel.updateBranches;
|
||||
const updateBranch = updateBranches.find(branch => branch.id === branchId);
|
||||
return updateBranch ?? null;
|
||||
}
|
||||
|
||||
export function logChannelDetails(channel: UpdateChannelObject): void {
|
||||
const { branchMapping, isRollout, rolloutPercent } = getBranchMapping(channel.branchMapping);
|
||||
if (branchMapping.data.length > 2) {
|
||||
throw new Error('Branch Mapping data must have length less than or equal to 2.');
|
||||
}
|
||||
|
||||
const rolloutBranchIds = branchMapping.data.map(data => data.branchId);
|
||||
const branchDescription = channel.updateBranches.flatMap(branch => {
|
||||
const updateGroupWithBranchDescriptions = getUpdateGroupDescriptionsWithBranch(
|
||||
branch.updateGroups
|
||||
);
|
||||
|
||||
const isRolloutBranch = isRollout && rolloutBranchIds.includes(branch.id);
|
||||
const isBaseBranch = rolloutBranchIds.length > 0 && rolloutBranchIds[0] === branch.id;
|
||||
let rolloutPercentNumber: number | undefined = undefined;
|
||||
if (isRolloutBranch) {
|
||||
rolloutPercentNumber = isBaseBranch ? rolloutPercent! * 100 : (1 - rolloutPercent!) * 100;
|
||||
}
|
||||
|
||||
return updateGroupWithBranchDescriptions.map(
|
||||
({ branch, ...updateGroup }): FormattedBranchDescription => ({
|
||||
branch,
|
||||
branchRolloutPercentage: rolloutPercentNumber,
|
||||
update: updateGroup,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
if (branchDescription.length === 0) {
|
||||
Log.log(chalk.dim('No branches are pointed to this channel.'));
|
||||
} else {
|
||||
Log.log(
|
||||
branchDescription
|
||||
.map(description => formatBranch(description))
|
||||
.join(`\n\n${chalk.dim('———')}\n\n`)
|
||||
export function getUpdateBranch<Branch extends BranchBasicInfo>(
|
||||
channel: UpdateChannelInfoWithBranches<Branch>,
|
||||
branchId: string
|
||||
): Branch {
|
||||
const updateBranch = getUpdateBranchNullable(channel, branchId);
|
||||
if (!updateBranch) {
|
||||
throw new Error(
|
||||
`Could not find branch with id "${branchId}" in branch-mapping of channel "${channel.name}"`
|
||||
);
|
||||
}
|
||||
return updateBranch;
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
import { Command } from '@oclif/core';
|
||||
import { CombinedError } from '@urql/core';
|
||||
import { GraphQLError } from 'graphql/error';
|
||||
import nullthrows from 'nullthrows';
|
||||
|
||||
import {
|
||||
@@ -6,18 +8,23 @@ import {
|
||||
CommandEvent,
|
||||
createAnalyticsAsync,
|
||||
} from '../analytics/AnalyticsManager';
|
||||
import Log from '../log';
|
||||
import SessionManager from '../user/SessionManager';
|
||||
import AnalyticsContextField from './context/AnalyticsContextField';
|
||||
import ContextField from './context/ContextField';
|
||||
import { DynamicProjectConfigContextField } from './context/DynamicProjectConfigContextField';
|
||||
import {
|
||||
DynamicPrivateProjectConfigContextField,
|
||||
DynamicPublicProjectConfigContextField,
|
||||
} from './context/DynamicProjectConfigContextField';
|
||||
import LoggedInContextField from './context/LoggedInContextField';
|
||||
import MaybeLoggedInContextField from './context/MaybeLoggedInContextField';
|
||||
import { OptionalProjectConfigContextField } from './context/OptionalProjectConfigContextField';
|
||||
import ProjectConfigContextField from './context/ProjectConfigContextField';
|
||||
import { OptionalPrivateProjectConfigContextField } from './context/OptionalPrivateProjectConfigContextField';
|
||||
import { PrivateProjectConfigContextField } from './context/PrivateProjectConfigContextField';
|
||||
import ProjectDirContextField from './context/ProjectDirContextField';
|
||||
import SessionManagementContextField from './context/SessionManagementContextField';
|
||||
import { EasCommandError } from './errors';
|
||||
|
||||
type ContextInput<
|
||||
export type ContextInput<
|
||||
T extends {
|
||||
[name: string]: any;
|
||||
} = object
|
||||
@@ -25,7 +32,7 @@ type ContextInput<
|
||||
[P in keyof T]: ContextField<T[P]>;
|
||||
};
|
||||
|
||||
type ContextOutput<
|
||||
export type ContextOutput<
|
||||
T extends {
|
||||
[name: string]: any;
|
||||
} = object
|
||||
@@ -33,6 +40,8 @@ type ContextOutput<
|
||||
[P in keyof T]: T[P];
|
||||
};
|
||||
|
||||
const BASE_GRAPHQL_ERROR_MESSAGE: string = 'GraphQL request failed.';
|
||||
|
||||
export default abstract class EasCommand extends Command {
|
||||
protected static readonly ContextOptions = {
|
||||
/**
|
||||
@@ -60,7 +69,7 @@ export default abstract class EasCommand extends Command {
|
||||
* run within a project directory, null otherwise.
|
||||
*/
|
||||
OptionalProjectConfig: {
|
||||
projectConfig: new OptionalProjectConfigContextField(),
|
||||
privateProjectConfig: new OptionalPrivateProjectConfigContextField(),
|
||||
},
|
||||
/**
|
||||
* Require this command to be run in a project directory. Return the project directory in the context.
|
||||
@@ -73,7 +82,9 @@ export default abstract class EasCommand extends Command {
|
||||
*/
|
||||
DynamicProjectConfig: {
|
||||
// eslint-disable-next-line async-protect/async-suffix
|
||||
getDynamicProjectConfigAsync: new DynamicProjectConfigContextField(),
|
||||
getDynamicPublicProjectConfigAsync: new DynamicPublicProjectConfigContextField(),
|
||||
// eslint-disable-next-line async-protect/async-suffix
|
||||
getDynamicPrivateProjectConfigAsync: new DynamicPrivateProjectConfigContextField(),
|
||||
},
|
||||
/**
|
||||
* Require the project to be identified and registered on server. Returns the project config in the context.
|
||||
@@ -82,7 +93,7 @@ export default abstract class EasCommand extends Command {
|
||||
*/
|
||||
ProjectConfig: {
|
||||
loggedIn: new LoggedInContextField(),
|
||||
projectConfig: new ProjectConfigContextField(),
|
||||
privateProjectConfig: new PrivateProjectConfigContextField(),
|
||||
},
|
||||
/**
|
||||
* Analytics manager. Returns the analytics manager in the context for use by the command.
|
||||
@@ -103,6 +114,18 @@ export default abstract class EasCommand extends Command {
|
||||
*/
|
||||
static contextDefinition: ContextInput = {};
|
||||
|
||||
/**
|
||||
* The user session manager. Responsible for coordinating all user session related state.
|
||||
* If needed in a subclass, use the SessionManager ContextOption.
|
||||
*/
|
||||
private sessionManagerInternal?: SessionManager;
|
||||
|
||||
/**
|
||||
* The analytics manager. Used for logging analytics.
|
||||
* It is set up here to ensure a consistent setup.
|
||||
*/
|
||||
private analyticsInternal?: AnalyticsWithOrchestration;
|
||||
|
||||
/**
|
||||
* Execute the context in the contextDefinition to satisfy command prerequisites.
|
||||
*/
|
||||
@@ -132,20 +155,10 @@ export default abstract class EasCommand extends Command {
|
||||
return Object.fromEntries(contextValuePairs);
|
||||
}
|
||||
|
||||
/**
|
||||
* The user session manager. Responsible for coordinating all user session related state.
|
||||
* If needed in a subclass, use the SessionManager ContextOption.
|
||||
*/
|
||||
private sessionManagerInternal?: SessionManager;
|
||||
private get sessionManager(): SessionManager {
|
||||
return nullthrows(this.sessionManagerInternal);
|
||||
}
|
||||
|
||||
/**
|
||||
* The analytics manager. Used for logging analytics.
|
||||
* It is set up here to ensure a consistent set up.
|
||||
*/
|
||||
private analyticsInternal?: AnalyticsWithOrchestration;
|
||||
private get analytics(): AnalyticsWithOrchestration {
|
||||
return nullthrows(this.analyticsInternal);
|
||||
}
|
||||
@@ -175,4 +188,36 @@ export default abstract class EasCommand extends Command {
|
||||
await this.analytics.flushAsync();
|
||||
return super.finally(err);
|
||||
}
|
||||
|
||||
protected override catch(err: Error): Promise<any> {
|
||||
let baseMessage = `${this.id} command failed.`;
|
||||
if (err instanceof EasCommandError) {
|
||||
Log.error(err.message);
|
||||
} else if (err instanceof CombinedError && err?.graphQLErrors) {
|
||||
const cleanMessage = err?.graphQLErrors
|
||||
.map((graphQLError: GraphQLError) => {
|
||||
const messageLine = graphQLError.message.replace('[GraphQL] ', '');
|
||||
const requestIdLine = graphQLError.extensions?.requestId
|
||||
? `\nRequest ID: ${err.graphQLErrors[0].extensions.requestId}`
|
||||
: '';
|
||||
|
||||
const defaultMsg = `${messageLine}${requestIdLine}`;
|
||||
|
||||
if (graphQLError.extensions?.errorCode === 'UNAUTHORIZED_ERROR') {
|
||||
return `You don't have the required permissions to perform this operation.\n\n${defaultMsg}`;
|
||||
}
|
||||
|
||||
return defaultMsg;
|
||||
})
|
||||
.join('\n');
|
||||
Log.error(cleanMessage);
|
||||
baseMessage = BASE_GRAPHQL_ERROR_MESSAGE;
|
||||
} else {
|
||||
Log.error(err.message);
|
||||
}
|
||||
Log.debug(err);
|
||||
const sanitizedError = new Error(baseMessage);
|
||||
sanitizedError.stack = err.stack;
|
||||
throw sanitizedError;
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,9 @@
|
||||
import { CombinedError } from '@urql/core';
|
||||
import { GraphQLError } from 'graphql/error';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
import { AnalyticsWithOrchestration, createAnalyticsAsync } from '../../analytics/AnalyticsManager';
|
||||
import Log from '../../log';
|
||||
import SessionManager from '../../user/SessionManager';
|
||||
import EasCommand from '../EasCommand';
|
||||
|
||||
@@ -11,8 +16,10 @@ jest.mock('../../analytics/AnalyticsManager', () => {
|
||||
createAnalyticsAsync: jest.fn(),
|
||||
};
|
||||
});
|
||||
jest.mock('../../log');
|
||||
|
||||
let originalProcessArgv: string[];
|
||||
const mockRequestId = uuidv4();
|
||||
|
||||
beforeAll(() => {
|
||||
originalProcessArgv = process.argv;
|
||||
@@ -40,7 +47,7 @@ const createTestEasCommand = (): typeof EasCommand => {
|
||||
async runAsync(): Promise<void> {}
|
||||
}
|
||||
|
||||
TestEasCommand.id = 'TestEasCommand'; // normally oclif will assign ids, but b/c this is located outside the commands folder it will not
|
||||
TestEasCommand.id = 'testEasCommand'; // normally oclif will assign ids, but b/c this is located outside the commands folder it will not
|
||||
return TestEasCommand;
|
||||
};
|
||||
|
||||
@@ -96,5 +103,169 @@ describe(EasCommand.name, () => {
|
||||
|
||||
expect(analytics.flushAsync).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe('catch', () => {
|
||||
it('logs the message', async () => {
|
||||
const TestEasCommand = createTestEasCommand();
|
||||
const logErrorSpy = jest.spyOn(Log, 'error');
|
||||
const logDebugSpy = jest.spyOn(Log, 'debug');
|
||||
const runAsyncMock = jest.spyOn(TestEasCommand.prototype as any, 'runAsync');
|
||||
const error = new Error('Unexpected, internal error message');
|
||||
runAsyncMock.mockImplementation(() => {
|
||||
throw error;
|
||||
});
|
||||
try {
|
||||
await TestEasCommand.run();
|
||||
} catch {}
|
||||
|
||||
expect(logErrorSpy).toBeCalledWith('Unexpected, internal error message');
|
||||
expect(logDebugSpy).toBeCalledWith(error);
|
||||
});
|
||||
|
||||
it('logs the cleaned message if needed', async () => {
|
||||
const TestEasCommand = createTestEasCommand();
|
||||
const logErrorSpy = jest.spyOn(Log, 'error');
|
||||
const logDebugSpy = jest.spyOn(Log, 'debug');
|
||||
const runAsyncMock = jest.spyOn(TestEasCommand.prototype as any, 'runAsync');
|
||||
const graphQLErrors = ['Unexpected GraphQL error message'];
|
||||
const error = new CombinedError({ graphQLErrors });
|
||||
runAsyncMock.mockImplementation(() => {
|
||||
throw error;
|
||||
});
|
||||
try {
|
||||
await TestEasCommand.run();
|
||||
} catch {}
|
||||
|
||||
expect(logErrorSpy).toBeCalledWith('Unexpected GraphQL error message');
|
||||
expect(logDebugSpy).toBeCalledWith(error);
|
||||
});
|
||||
|
||||
it('logs the cleaned message with request ID if present', async () => {
|
||||
const TestEasCommand = createTestEasCommand();
|
||||
const logErrorSpy = jest.spyOn(Log, 'error');
|
||||
const logDebugSpy = jest.spyOn(Log, 'debug');
|
||||
const runAsyncMock = jest.spyOn(TestEasCommand.prototype as any, 'runAsync');
|
||||
const graphQLError = new GraphQLError(
|
||||
'Unexpected GraphQL error message',
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
{
|
||||
errorCode: 'UNKNOWN_GRAPHQL_ERROR',
|
||||
requestId: mockRequestId,
|
||||
}
|
||||
);
|
||||
const graphQLErrors = [graphQLError];
|
||||
const error = new CombinedError({ graphQLErrors });
|
||||
runAsyncMock.mockImplementation(() => {
|
||||
throw error;
|
||||
});
|
||||
try {
|
||||
await TestEasCommand.run();
|
||||
} catch {}
|
||||
|
||||
expect(logErrorSpy).toBeCalledWith(
|
||||
`Unexpected GraphQL error message\nRequest ID: ${mockRequestId}`
|
||||
);
|
||||
expect(logDebugSpy).toBeCalledWith(error);
|
||||
});
|
||||
|
||||
it('logs the cleaned messages with request IDs if multiple GraphQL errors present', async () => {
|
||||
const TestEasCommand = createTestEasCommand();
|
||||
const logErrorSpy = jest.spyOn(Log, 'error');
|
||||
const logDebugSpy = jest.spyOn(Log, 'debug');
|
||||
const runAsyncMock = jest.spyOn(TestEasCommand.prototype as any, 'runAsync');
|
||||
const graphQLErrors = [
|
||||
new GraphQLError('Unexpected GraphQL error message', null, null, null, null, null, {
|
||||
errorCode: 'UNKNOWN_GRAPHQL_ERROR',
|
||||
requestId: mockRequestId,
|
||||
}),
|
||||
new GraphQLError('Other GraphQL error message', null, null, null, null, null, {
|
||||
errorCode: 'OTHER_GRAPHQL_ERROR',
|
||||
requestId: mockRequestId,
|
||||
}),
|
||||
new GraphQLError('Yet another GraphQL error message', null, null, null, null, null, {
|
||||
errorCode: 'YET_ANOTHER_GRAPHQL_ERROR',
|
||||
requestId: mockRequestId,
|
||||
}),
|
||||
];
|
||||
const error = new CombinedError({ graphQLErrors });
|
||||
runAsyncMock.mockImplementation(() => {
|
||||
throw error;
|
||||
});
|
||||
try {
|
||||
await TestEasCommand.run();
|
||||
} catch {}
|
||||
|
||||
expect(logErrorSpy).toBeCalledWith(
|
||||
'Unexpected GraphQL error message\n' +
|
||||
`Request ID: ${mockRequestId}\n` +
|
||||
'Other GraphQL error message\n' +
|
||||
`Request ID: ${mockRequestId}\n` +
|
||||
'Yet another GraphQL error message\n' +
|
||||
`Request ID: ${mockRequestId}`
|
||||
);
|
||||
expect(logDebugSpy).toBeCalledWith(error);
|
||||
});
|
||||
|
||||
it('re-throws the error with default base message', async () => {
|
||||
const TestEasCommand = createTestEasCommand();
|
||||
const runAsyncMock = jest.spyOn(TestEasCommand.prototype as any, 'runAsync');
|
||||
runAsyncMock.mockImplementation(() => {
|
||||
throw new Error('Error message');
|
||||
});
|
||||
try {
|
||||
await TestEasCommand.run();
|
||||
} catch (caughtError) {
|
||||
expect(caughtError).toBeInstanceOf(Error);
|
||||
expect((caughtError as Error).message).toEqual('testEasCommand command failed.');
|
||||
}
|
||||
});
|
||||
|
||||
it('re-throws the error with a different default base message in case of CombinedError', async () => {
|
||||
const TestEasCommand = createTestEasCommand();
|
||||
const runAsyncMock = jest.spyOn(TestEasCommand.prototype as any, 'runAsync');
|
||||
runAsyncMock.mockImplementation(() => {
|
||||
const graphQLErrors = ['Unexpected GraphQL error message'];
|
||||
throw new CombinedError({ graphQLErrors });
|
||||
});
|
||||
try {
|
||||
await TestEasCommand.run();
|
||||
} catch (caughtError) {
|
||||
expect(caughtError).toBeInstanceOf(Error);
|
||||
expect((caughtError as Error).message).toEqual('GraphQL request failed.');
|
||||
}
|
||||
});
|
||||
|
||||
it('re-throws the error with a different default base message in case of CombinedError with multiple GraphQLErrors', async () => {
|
||||
const TestEasCommand = createTestEasCommand();
|
||||
const runAsyncMock = jest.spyOn(TestEasCommand.prototype as any, 'runAsync');
|
||||
runAsyncMock.mockImplementation(() => {
|
||||
const graphQLErrors = [
|
||||
new GraphQLError('Unexpected GraphQL error message', null, null, null, null, null, {
|
||||
errorCode: 'UNEXPECTED_GRAPHQL_ERROR',
|
||||
requestId: mockRequestId,
|
||||
}),
|
||||
new GraphQLError('Other GraphQL error message', null, null, null, null, null, {
|
||||
errorCode: 'OTHER_GRAPHQL_ERROR',
|
||||
requestId: mockRequestId,
|
||||
}),
|
||||
new GraphQLError('Yet another GraphQL error message', null, null, null, null, null, {
|
||||
errorCode: 'YET_ANOTHER_GRAPHQL_ERROR',
|
||||
requestId: mockRequestId,
|
||||
}),
|
||||
];
|
||||
throw new CombinedError({ graphQLErrors });
|
||||
});
|
||||
try {
|
||||
await TestEasCommand.run();
|
||||
} catch (caughtError) {
|
||||
expect(caughtError).toBeInstanceOf(Error);
|
||||
expect((caughtError as Error).message).toEqual('GraphQL request failed.');
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,29 +1,55 @@
|
||||
import { ExpoConfig } from '@expo/config-types';
|
||||
|
||||
import { ExpoConfigOptions, getExpoConfig } from '../../project/expoConfig';
|
||||
import {
|
||||
ExpoConfigOptions,
|
||||
getPrivateExpoConfig,
|
||||
getPublicExpoConfig,
|
||||
} from '../../project/expoConfig';
|
||||
import ContextField, { ContextOptions } from './ContextField';
|
||||
import { findProjectDirAndVerifyProjectSetupAsync } from './contextUtils/findProjectDirAndVerifyProjectSetupAsync';
|
||||
import { getProjectIdAsync } from './contextUtils/getProjectIdAsync';
|
||||
|
||||
export type DynamicConfigContextFn = (options: ExpoConfigOptions) => Promise<{
|
||||
export type DynamicConfigContextFn = (options?: ExpoConfigOptions) => Promise<{
|
||||
projectId: string;
|
||||
exp: ExpoConfig;
|
||||
projectDir: string;
|
||||
}>;
|
||||
|
||||
export class DynamicProjectConfigContextField extends ContextField<DynamicConfigContextFn> {
|
||||
export class DynamicPublicProjectConfigContextField extends ContextField<DynamicConfigContextFn> {
|
||||
async getValueAsync({
|
||||
nonInteractive,
|
||||
sessionManager,
|
||||
}: ContextOptions): Promise<DynamicConfigContextFn> {
|
||||
const projectDir = await findProjectDirAndVerifyProjectSetupAsync();
|
||||
return async (options?: ExpoConfigOptions) => {
|
||||
const projectDir = await findProjectDirAndVerifyProjectSetupAsync();
|
||||
const expBefore = getExpoConfig(projectDir, options);
|
||||
const expBefore = getPublicExpoConfig(projectDir, options);
|
||||
const projectId = await getProjectIdAsync(sessionManager, expBefore, {
|
||||
nonInteractive,
|
||||
env: options?.env,
|
||||
});
|
||||
const exp = getExpoConfig(projectDir, options);
|
||||
const exp = getPublicExpoConfig(projectDir, options);
|
||||
return {
|
||||
exp,
|
||||
projectDir,
|
||||
projectId,
|
||||
};
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export class DynamicPrivateProjectConfigContextField extends ContextField<DynamicConfigContextFn> {
|
||||
async getValueAsync({
|
||||
nonInteractive,
|
||||
sessionManager,
|
||||
}: ContextOptions): Promise<DynamicConfigContextFn> {
|
||||
const projectDir = await findProjectDirAndVerifyProjectSetupAsync();
|
||||
return async (options?: ExpoConfigOptions) => {
|
||||
const expBefore = getPrivateExpoConfig(projectDir, options);
|
||||
const projectId = await getProjectIdAsync(sessionManager, expBefore, {
|
||||
nonInteractive,
|
||||
env: options?.env,
|
||||
});
|
||||
const exp = getPrivateExpoConfig(projectDir, options);
|
||||
return {
|
||||
exp,
|
||||
projectDir,
|
||||
|
||||
+4
-4
@@ -1,12 +1,12 @@
|
||||
import { ExpoConfig } from '@expo/config-types';
|
||||
import { InvalidEasJsonError } from '@expo/eas-json/build/errors';
|
||||
|
||||
import { getExpoConfig } from '../../project/expoConfig';
|
||||
import { getPrivateExpoConfig } from '../../project/expoConfig';
|
||||
import ContextField, { ContextOptions } from './ContextField';
|
||||
import { findProjectDirAndVerifyProjectSetupAsync } from './contextUtils/findProjectDirAndVerifyProjectSetupAsync';
|
||||
import { getProjectIdAsync } from './contextUtils/getProjectIdAsync';
|
||||
|
||||
export class OptionalProjectConfigContextField extends ContextField<
|
||||
export class OptionalPrivateProjectConfigContextField extends ContextField<
|
||||
| {
|
||||
projectId: string;
|
||||
exp: ExpoConfig;
|
||||
@@ -35,11 +35,11 @@ export class OptionalProjectConfigContextField extends ContextField<
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const expBefore = getExpoConfig(projectDir);
|
||||
const expBefore = getPrivateExpoConfig(projectDir);
|
||||
const projectId = await getProjectIdAsync(sessionManager, expBefore, {
|
||||
nonInteractive,
|
||||
});
|
||||
const exp = getExpoConfig(projectDir);
|
||||
const exp = getPrivateExpoConfig(projectDir);
|
||||
return {
|
||||
exp,
|
||||
projectDir,
|
||||
+4
-4
@@ -1,11 +1,11 @@
|
||||
import { ExpoConfig } from '@expo/config-types';
|
||||
|
||||
import { getExpoConfig } from '../../project/expoConfig';
|
||||
import { getPrivateExpoConfig } from '../../project/expoConfig';
|
||||
import ContextField, { ContextOptions } from './ContextField';
|
||||
import { findProjectDirAndVerifyProjectSetupAsync } from './contextUtils/findProjectDirAndVerifyProjectSetupAsync';
|
||||
import { getProjectIdAsync } from './contextUtils/getProjectIdAsync';
|
||||
|
||||
export default class ProjectConfigContextField extends ContextField<{
|
||||
export class PrivateProjectConfigContextField extends ContextField<{
|
||||
projectId: string;
|
||||
exp: ExpoConfig;
|
||||
projectDir: string;
|
||||
@@ -16,11 +16,11 @@ export default class ProjectConfigContextField extends ContextField<{
|
||||
projectDir: string;
|
||||
}> {
|
||||
const projectDir = await findProjectDirAndVerifyProjectSetupAsync();
|
||||
const expBefore = getExpoConfig(projectDir);
|
||||
const expBefore = getPrivateExpoConfig(projectDir);
|
||||
const projectId = await getProjectIdAsync(sessionManager, expBefore, {
|
||||
nonInteractive,
|
||||
});
|
||||
const exp = getExpoConfig(projectDir);
|
||||
const exp = getPrivateExpoConfig(projectDir);
|
||||
|
||||
return {
|
||||
projectId,
|
||||
+75
-1
@@ -1,4 +1,4 @@
|
||||
import { getConfig, modifyConfigAsync } from '@expo/config';
|
||||
import { getConfig, getConfigFilePaths, modifyConfigAsync } from '@expo/config';
|
||||
import { vol } from 'memfs';
|
||||
import { anything, instance, mock, when } from 'ts-mockito';
|
||||
|
||||
@@ -26,6 +26,10 @@ describe(getProjectIdAsync, () => {
|
||||
let sessionManager: SessionManager;
|
||||
|
||||
beforeEach(() => {
|
||||
jest
|
||||
.mocked(getConfigFilePaths)
|
||||
.mockReturnValue({ staticConfigPath: null, dynamicConfigPath: null });
|
||||
|
||||
const sessionManagerMock = mock<SessionManager>();
|
||||
when(sessionManagerMock.ensureLoggedInAsync(anything())).thenResolve({
|
||||
actor: {
|
||||
@@ -136,6 +140,54 @@ describe(getProjectIdAsync, () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('throws when the owner is not specified and is different than logged in user which is a robot', async () => {
|
||||
const sessionManagerMock = mock<SessionManager>();
|
||||
when(sessionManagerMock.ensureLoggedInAsync(anything())).thenResolve({
|
||||
actor: {
|
||||
__typename: 'Robot',
|
||||
id: 'robot_id',
|
||||
accounts: [
|
||||
{
|
||||
id: 'account_id_1',
|
||||
name: 'notnotbrent',
|
||||
users: [{ role: Role.Admin, actor: { id: 'robot_id' } }],
|
||||
},
|
||||
{
|
||||
id: 'account_id_2',
|
||||
name: 'dominik',
|
||||
users: [{ role: Role.ViewOnly, actor: { id: 'robot_id' } }],
|
||||
},
|
||||
],
|
||||
isExpoAdmin: false,
|
||||
featureGates: {},
|
||||
},
|
||||
authenticationInfo: { accessToken: 'fake', sessionSecret: null },
|
||||
});
|
||||
const sessionManagerRobot = instance(sessionManagerMock);
|
||||
|
||||
jest.mocked(getConfig).mockReturnValue({
|
||||
exp: { name: 'test', slug: 'test', extra: { eas: { projectId: '1234' } } },
|
||||
} as any);
|
||||
jest.mocked(AppQuery.byIdAsync).mockResolvedValue({
|
||||
id: '1234',
|
||||
fullName: '@totallybrent/test',
|
||||
slug: 'test',
|
||||
ownerAccount: { name: 'totallybrent' } as any,
|
||||
});
|
||||
|
||||
await expect(
|
||||
getProjectIdAsync(
|
||||
sessionManagerRobot,
|
||||
{ name: 'test', slug: 'test', extra: { eas: { projectId: '1234' } } },
|
||||
{ nonInteractive: false }
|
||||
)
|
||||
).rejects.toThrow(
|
||||
`Project config: Owner of project identified by "extra.eas.projectId" (totallybrent) must be specified in "owner" field when using a robot access token. To ensure all libraries work correctly, "owner": "totallybrent" should be added to the project config, which can be done automatically by re-running "eas init". ${learnMore(
|
||||
'https://expo.fyi/eas-project-id'
|
||||
)}`
|
||||
);
|
||||
});
|
||||
|
||||
it('throws when the slug is out of sync', async () => {
|
||||
jest.mocked(getConfig).mockReturnValue({
|
||||
exp: { name: 'test', slug: 'wat', extra: { eas: { projectId: '1234' } } },
|
||||
@@ -204,4 +256,26 @@ describe(getProjectIdAsync, () => {
|
||||
getProjectIdAsync(sessionManager, { name: 'test', slug: 'test' }, { nonInteractive: false })
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('throws if extra.eas.projectId is not a string', async () => {
|
||||
jest.mocked(getConfig).mockReturnValue({
|
||||
exp: { name: 'test', slug: 'test', extra: { eas: { projectId: 1234 } } },
|
||||
} as any);
|
||||
jest.mocked(AppQuery.byIdAsync).mockResolvedValue({
|
||||
id: '1234',
|
||||
fullName: '@notnotbrent/test',
|
||||
slug: 'test',
|
||||
ownerAccount: { name: 'notnotbrent' } as any,
|
||||
});
|
||||
|
||||
await expect(
|
||||
getProjectIdAsync(
|
||||
sessionManager,
|
||||
{ name: 'test', slug: 'wat', extra: { eas: { projectId: 1234 } } },
|
||||
{ nonInteractive: false }
|
||||
)
|
||||
).rejects.toThrow(
|
||||
`Project config: "extra.eas.projectId" must be a string, found number. If you're not sure how to set it up on your own, remove the property entirely and it will be automatically configured on the next EAS CLI run.`
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -3,11 +3,10 @@ import {
|
||||
Client,
|
||||
OperationContext,
|
||||
OperationResult,
|
||||
PromisifiedSource,
|
||||
OperationResultSource,
|
||||
TypedDocumentNode,
|
||||
cacheExchange,
|
||||
createClient as createUrqlClient,
|
||||
dedupExchange,
|
||||
fetchExchange,
|
||||
} from '@urql/core';
|
||||
import { retryExchange } from '@urql/exchange-retry';
|
||||
@@ -22,7 +21,7 @@ export interface ExpoGraphqlClient extends Client {
|
||||
query: DocumentNode | TypedDocumentNode<Data, Variables> | string,
|
||||
variables: Variables,
|
||||
context: Partial<OperationContext> & { additionalTypenames: string[] }
|
||||
): PromisifiedSource<OperationResult<Data, Variables>>;
|
||||
): OperationResultSource<OperationResult<Data, Variables>>;
|
||||
}
|
||||
|
||||
export function createGraphqlClient(authInfo: {
|
||||
@@ -32,7 +31,6 @@ export function createGraphqlClient(authInfo: {
|
||||
return createUrqlClient({
|
||||
url: getExpoApiBaseUrl() + '/graphql',
|
||||
exchanges: [
|
||||
dedupExchange,
|
||||
cacheExchange,
|
||||
retryExchange({
|
||||
maxDelayMs: 4000,
|
||||
|
||||
+1
-1
@@ -12,7 +12,7 @@ import { getVcsClient, setVcsClient } from '../../../vcs';
|
||||
import GitClient from '../../../vcs/clients/git';
|
||||
|
||||
async function applyCliConfigAsync(projectDir: string): Promise<void> {
|
||||
const easJsonAccessor = new EasJsonAccessor(projectDir);
|
||||
const easJsonAccessor = EasJsonAccessor.fromProjectPath(projectDir);
|
||||
const config = await EasJsonUtils.getCliConfigAsync(easJsonAccessor);
|
||||
if (config?.version && !semver.satisfies(easCliVersion, config.version)) {
|
||||
throw new Error(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { getProjectConfigDescription, modifyConfigAsync } from '@expo/config';
|
||||
import { getProjectConfigDescription } from '@expo/config';
|
||||
import { ExpoConfig } from '@expo/config-types';
|
||||
import { Env } from '@expo/eas-build-job';
|
||||
import chalk from 'chalk';
|
||||
@@ -6,7 +6,7 @@ import chalk from 'chalk';
|
||||
import { AppQuery } from '../../../graphql/queries/AppQuery';
|
||||
import Log, { learnMore } from '../../../log';
|
||||
import { ora } from '../../../ora';
|
||||
import { getExpoConfig } from '../../../project/expoConfig';
|
||||
import { createOrModifyExpoConfigAsync, getPrivateExpoConfig } from '../../../project/expoConfig';
|
||||
import { fetchOrCreateProjectIDForWriteToConfigWithConfirmationAsync } from '../../../project/fetchOrCreateProjectIDForWriteToConfigWithConfirmationAsync';
|
||||
import { toAppPrivacy } from '../../../project/projectUtils';
|
||||
import SessionManager from '../../../user/SessionManager';
|
||||
@@ -24,8 +24,8 @@ export async function saveProjectIdToAppConfigAsync(
|
||||
projectId: string,
|
||||
options: { env?: Env } = {}
|
||||
): Promise<void> {
|
||||
const exp = getExpoConfig(projectDir, options);
|
||||
const result = await modifyConfigAsync(
|
||||
const exp = getPrivateExpoConfig(projectDir, options);
|
||||
const result = await createOrModifyExpoConfigAsync(
|
||||
projectDir,
|
||||
{
|
||||
extra: { ...exp.extra, eas: { ...exp.extra?.eas, projectId } },
|
||||
@@ -89,6 +89,12 @@ export async function getProjectIdAsync(
|
||||
|
||||
const localProjectId = exp.extra?.eas?.projectId;
|
||||
if (localProjectId) {
|
||||
if (typeof localProjectId !== 'string') {
|
||||
throw new Error(
|
||||
`Project config: "extra.eas.projectId" must be a string, found ${typeof localProjectId}. If you're not sure how to set it up on your own, remove the property entirely and it will be automatically configured on the next EAS CLI run.`
|
||||
);
|
||||
}
|
||||
|
||||
// check that the local project ID matches account and slug
|
||||
const appForProjectId = await AppQuery.byIdAsync(graphqlClient, localProjectId);
|
||||
if (exp.owner && exp.owner !== appForProjectId.ownerAccount.name) {
|
||||
@@ -103,15 +109,28 @@ export async function getProjectIdAsync(
|
||||
|
||||
const actorUsername = getActorUsername(actor);
|
||||
if (!exp.owner && appForProjectId.ownerAccount.name !== actorUsername) {
|
||||
throw new Error(
|
||||
`Project config: Owner of project identified by "extra.eas.projectId" (${
|
||||
appForProjectId.ownerAccount.name
|
||||
}) does not match the logged in user (${actorUsername}) and the "owner" field is not specified. To ensure all libraries work correctly, "owner": "${
|
||||
appForProjectId.ownerAccount.name
|
||||
}" should be added to the project config, which can be done automatically by re-running "eas init". ${learnMore(
|
||||
'https://expo.fyi/eas-project-id'
|
||||
)}`
|
||||
);
|
||||
if (actorUsername) {
|
||||
throw new Error(
|
||||
`Project config: Owner of project identified by "extra.eas.projectId" (${
|
||||
appForProjectId.ownerAccount.name
|
||||
}) does not match the logged in user (${actorUsername}) and the "owner" field is not specified. To ensure all libraries work correctly, "owner": "${
|
||||
appForProjectId.ownerAccount.name
|
||||
}" should be added to the project config, which can be done automatically by re-running "eas init". ${learnMore(
|
||||
'https://expo.fyi/eas-project-id'
|
||||
)}`
|
||||
);
|
||||
} else {
|
||||
// robot caller
|
||||
throw new Error(
|
||||
`Project config: Owner of project identified by "extra.eas.projectId" (${
|
||||
appForProjectId.ownerAccount.name
|
||||
}) must be specified in "owner" field when using a robot access token. To ensure all libraries work correctly, "owner": "${
|
||||
appForProjectId.ownerAccount.name
|
||||
}" should be added to the project config, which can be done automatically by re-running "eas init". ${learnMore(
|
||||
'https://expo.fyi/eas-project-id'
|
||||
)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (exp.slug && exp.slug !== appForProjectId.slug) {
|
||||
@@ -141,12 +160,12 @@ export async function getProjectIdAsync(
|
||||
switch (user.__typename) {
|
||||
case 'User':
|
||||
return user.username;
|
||||
case 'SSOUser':
|
||||
return user.username;
|
||||
case 'Robot':
|
||||
throw new Error(
|
||||
'The "owner" manifest property is required when using robot users. See: https://docs.expo.dev/versions/latest/config/app/#owner'
|
||||
);
|
||||
case 'SSOUser':
|
||||
throw new Error('SSO users are not supported yet.');
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
export class EasCommandError extends Error {
|
||||
// constructor is not useless, since the constructor for Error allows for optional `message`
|
||||
// eslint-disable-next-line @typescript-eslint/no-useless-constructor
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import { Flags } from '@oclif/core';
|
||||
|
||||
import EasCommand from '../../commandUtils/EasCommand';
|
||||
import Log from '../../log';
|
||||
|
||||
@@ -5,13 +7,27 @@ export default class AccountLogin extends EasCommand {
|
||||
static override description = 'log in with your Expo account';
|
||||
static override aliases = ['login'];
|
||||
|
||||
static override flags = {
|
||||
// can pass either --sso or -s
|
||||
sso: Flags.boolean({
|
||||
description: 'Login with SSO',
|
||||
char: 's',
|
||||
default: false,
|
||||
hidden: true,
|
||||
}),
|
||||
};
|
||||
|
||||
static override contextDefinition = {
|
||||
...this.ContextOptions.SessionManagment,
|
||||
};
|
||||
|
||||
async runAsync(): Promise<void> {
|
||||
const {
|
||||
flags: { sso },
|
||||
} = await this.parse(AccountLogin);
|
||||
|
||||
const { sessionManager } = await this.getContextAsync(AccountLogin, { nonInteractive: false });
|
||||
await sessionManager.showLoginPromptAsync();
|
||||
await sessionManager.showLoginPromptAsync({ sso });
|
||||
Log.log('Logged in');
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
import chalk from 'chalk';
|
||||
import nullthrows from 'nullthrows';
|
||||
|
||||
import EasCommand from '../../commandUtils/EasCommand';
|
||||
import { Role } from '../../graphql/generated';
|
||||
import Log from '../../log';
|
||||
import { getActorDisplayName } from '../../user/User';
|
||||
import { Actor, getActorDisplayName } from '../../user/User';
|
||||
|
||||
export default class AccountView extends EasCommand {
|
||||
static override description = 'show the username you are logged in as';
|
||||
@@ -18,9 +20,48 @@ export default class AccountView extends EasCommand {
|
||||
} = await this.getContextAsync(AccountView, { nonInteractive: true });
|
||||
if (actor) {
|
||||
Log.log(chalk.green(getActorDisplayName(actor)));
|
||||
|
||||
// personal account is included, only show if more accounts that personal account
|
||||
// but do show personal account in list if there are more
|
||||
const accountExcludingPersonalAccount = actor.accounts.filter(
|
||||
account => !('username' in actor) || account.name !== actor.username
|
||||
);
|
||||
if (accountExcludingPersonalAccount.length > 0) {
|
||||
Log.newLine();
|
||||
Log.log('Accounts:');
|
||||
actor.accounts.forEach(account => {
|
||||
const roleOnAccount = AccountView.getRoleOnAccount(actor, account);
|
||||
Log.log(`• ${account.name} (Role: ${AccountView.getLabelForRole(roleOnAccount)})`);
|
||||
});
|
||||
}
|
||||
} else {
|
||||
Log.warn('Not logged in');
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
private static getRoleOnAccount(actor: Actor, account: Actor['accounts'][0]): Role {
|
||||
if ('username' in actor && account.name === actor.username) {
|
||||
return Role.Owner;
|
||||
}
|
||||
|
||||
return nullthrows(account.users.find(user => user.actor.id === actor.id)?.role);
|
||||
}
|
||||
|
||||
private static getLabelForRole(role: Role): string {
|
||||
switch (role) {
|
||||
case Role.Owner:
|
||||
return 'Owner';
|
||||
case Role.Admin:
|
||||
return 'Admin';
|
||||
case Role.Developer:
|
||||
return 'Developer';
|
||||
case Role.ViewOnly:
|
||||
return 'Viewer';
|
||||
case Role.Custom:
|
||||
case Role.HasAdmin:
|
||||
case Role.NotAdmin:
|
||||
return 'Custom';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -35,7 +35,7 @@ export default class BranchCreate extends EasCommand {
|
||||
flags: { json: jsonFlag, 'non-interactive': nonInteractive },
|
||||
} = await this.parse(BranchCreate);
|
||||
const {
|
||||
projectConfig: { projectId },
|
||||
privateProjectConfig: { projectId },
|
||||
loggedIn: { graphqlClient },
|
||||
} = await this.getContextAsync(BranchCreate, {
|
||||
nonInteractive,
|
||||
|
||||
@@ -108,7 +108,7 @@ export default class BranchDelete extends EasCommand {
|
||||
}
|
||||
|
||||
const {
|
||||
projectConfig: { projectId },
|
||||
privateProjectConfig: { projectId },
|
||||
loggedIn: { graphqlClient },
|
||||
} = await this.getContextAsync(BranchDelete, { nonInteractive });
|
||||
const projectDisplayName = await getDisplayNameForProjectIdAsync(graphqlClient, projectId);
|
||||
|
||||
@@ -20,7 +20,7 @@ export default class BranchList extends EasCommand {
|
||||
async runAsync(): Promise<void> {
|
||||
const { flags } = await this.parse(BranchList);
|
||||
const {
|
||||
projectConfig: { projectId },
|
||||
privateProjectConfig: { projectId },
|
||||
loggedIn: { graphqlClient },
|
||||
} = await this.getContextAsync(BranchList, {
|
||||
nonInteractive: flags['non-interactive'],
|
||||
|
||||
@@ -72,7 +72,7 @@ export default class BranchRename extends EasCommand {
|
||||
flags: { json: jsonFlag, from: currentName, to: newName, 'non-interactive': nonInteractive },
|
||||
} = await this.parse(BranchRename);
|
||||
const {
|
||||
projectConfig: { projectId },
|
||||
privateProjectConfig: { projectId },
|
||||
loggedIn: { graphqlClient },
|
||||
} = await this.getContextAsync(BranchRename, {
|
||||
nonInteractive,
|
||||
|
||||
@@ -38,7 +38,7 @@ export default class BranchView extends EasCommand {
|
||||
} = await this.parse(BranchView);
|
||||
const { 'non-interactive': nonInteractive } = flags;
|
||||
const {
|
||||
projectConfig: { projectId },
|
||||
privateProjectConfig: { projectId },
|
||||
loggedIn: { graphqlClient },
|
||||
} = await this.getContextAsync(BranchView, {
|
||||
nonInteractive,
|
||||
|
||||
@@ -147,7 +147,7 @@ export default class BuildCancel extends EasCommand {
|
||||
flags: { 'non-interactive': nonInteractive },
|
||||
} = await this.parse(BuildCancel);
|
||||
const {
|
||||
projectConfig: { projectId },
|
||||
privateProjectConfig: { projectId },
|
||||
loggedIn: { graphqlClient },
|
||||
} = await this.getContextAsync(BuildCancel, {
|
||||
nonInteractive,
|
||||
@@ -174,7 +174,7 @@ export default class BuildCancel extends EasCommand {
|
||||
const spinner = ora().start('Canceling the build…');
|
||||
try {
|
||||
const { status } = await cancelBuildAsync(graphqlClient, buildId);
|
||||
if (status === BuildStatus.Canceled) {
|
||||
if ([BuildStatus.Canceled, BuildStatus.PendingCancel].includes(status)) {
|
||||
spinner.succeed('Build canceled');
|
||||
} else {
|
||||
spinner.text = 'Build is already completed';
|
||||
|
||||
@@ -7,10 +7,14 @@ import { ensureProjectConfiguredAsync } from '../../build/configure';
|
||||
import EasCommand from '../../commandUtils/EasCommand';
|
||||
import Log, { learnMore } from '../../log';
|
||||
import { RequestedPlatform } from '../../platform';
|
||||
import { isExpoUpdatesInstalled } from '../../project/projectUtils';
|
||||
import { isExpoUpdatesInstalled, isUsingEASUpdate } from '../../project/projectUtils';
|
||||
import { resolveWorkflowAsync } from '../../project/workflow';
|
||||
import { promptAsync } from '../../prompts';
|
||||
import { syncUpdatesConfigurationAsync as syncAndroidUpdatesConfigurationAsync } from '../../update/android/UpdatesModule';
|
||||
import {
|
||||
ensureEASUpdateIsConfiguredInEasJsonAsync,
|
||||
ensureUseClassicUpdatesIsRemovedAsync,
|
||||
} from '../../update/configure';
|
||||
import { syncUpdatesConfigurationAsync as syncIosUpdatesConfigurationAsync } from '../../update/ios/UpdatesModule';
|
||||
import { getVcsClient } from '../../vcs';
|
||||
|
||||
@@ -33,7 +37,7 @@ export default class BuildConfigure extends EasCommand {
|
||||
async runAsync(): Promise<void> {
|
||||
const { flags } = await this.parse(BuildConfigure);
|
||||
const {
|
||||
projectConfig: { exp, projectId, projectDir },
|
||||
privateProjectConfig: { exp, projectId, projectDir },
|
||||
loggedIn: { graphqlClient },
|
||||
} = await this.getContextAsync(BuildConfigure, {
|
||||
nonInteractive: false,
|
||||
@@ -57,10 +61,19 @@ export default class BuildConfigure extends EasCommand {
|
||||
|
||||
// ensure eas.json exists
|
||||
Log.newLine();
|
||||
await ensureProjectConfiguredAsync({
|
||||
const didCreateEasJson = await ensureProjectConfiguredAsync({
|
||||
projectDir,
|
||||
nonInteractive: false,
|
||||
});
|
||||
if (didCreateEasJson && isUsingEASUpdate(exp, projectId)) {
|
||||
if (exp.updates?.useClassicUpdates) {
|
||||
// NOTE: this method modifies the Expo config; be sure to use this function's return value
|
||||
// if the config object is used later in the future
|
||||
await ensureUseClassicUpdatesIsRemovedAsync({ exp, projectDir });
|
||||
}
|
||||
|
||||
await ensureEASUpdateIsConfiguredInEasJsonAsync(projectDir);
|
||||
}
|
||||
|
||||
// configure expo-updates
|
||||
if (expoUpdatesIsInstalled) {
|
||||
|
||||
@@ -118,7 +118,7 @@ export default class Build extends EasCommand {
|
||||
|
||||
const {
|
||||
loggedIn: { actor, graphqlClient },
|
||||
getDynamicProjectConfigAsync,
|
||||
getDynamicPrivateProjectConfigAsync,
|
||||
projectDir,
|
||||
analytics,
|
||||
} = await this.getContextAsync(Build, {
|
||||
@@ -144,7 +144,7 @@ export default class Build extends EasCommand {
|
||||
projectDir,
|
||||
flagsWithPlatform,
|
||||
actor,
|
||||
getDynamicProjectConfigAsync
|
||||
getDynamicPrivateProjectConfigAsync
|
||||
);
|
||||
}
|
||||
|
||||
@@ -227,6 +227,13 @@ export default class Build extends EasCommand {
|
||||
});
|
||||
} else if (process.platform !== 'darwin' && requestedPlatform === RequestedPlatform.Ios) {
|
||||
Errors.error('Unsupported platform, macOS is required to build apps for iOS', { exit: 1 });
|
||||
} else if (
|
||||
!['linux', 'darwin'].includes(process.platform) &&
|
||||
requestedPlatform === RequestedPlatform.Android
|
||||
) {
|
||||
Errors.error('Unsupported platform, macOS or Linux is required to build apps for Android', {
|
||||
exit: 1,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -244,7 +251,7 @@ export async function handleDeprecatedEasJsonAsync(
|
||||
if (!(await fs.pathExists(EasJsonAccessor.formatEasJsonPath(projectDir)))) {
|
||||
return;
|
||||
}
|
||||
const easJsonAccessor = new EasJsonAccessor(projectDir);
|
||||
const easJsonAccessor = EasJsonAccessor.fromProjectPath(projectDir);
|
||||
const profileNames = await EasJsonUtils.getBuildProfileNamesAsync(easJsonAccessor);
|
||||
const platformAndProfileNames: [Platform, string][] = profileNames.flatMap(profileName => [
|
||||
[Platform.ANDROID, profileName],
|
||||
|
||||
@@ -73,7 +73,7 @@ export default class BuildInspect extends EasCommand {
|
||||
const { flags } = await this.parse(BuildInspect);
|
||||
const {
|
||||
loggedIn: { actor, graphqlClient },
|
||||
getDynamicProjectConfigAsync,
|
||||
getDynamicPrivateProjectConfigAsync,
|
||||
projectDir,
|
||||
analytics,
|
||||
} = await this.getContextAsync(BuildInspect, {
|
||||
@@ -120,7 +120,7 @@ export default class BuildInspect extends EasCommand {
|
||||
},
|
||||
},
|
||||
actor,
|
||||
getDynamicProjectConfigAsync
|
||||
getDynamicPrivateProjectConfigAsync
|
||||
);
|
||||
if (!flags.verbose) {
|
||||
Log.log(chalk.green('Build successful'));
|
||||
|
||||
@@ -6,6 +6,8 @@ import { runBuildAndSubmitAsync } from '../../build/runBuildAndSubmit';
|
||||
import EasCommand from '../../commandUtils/EasCommand';
|
||||
import { RequestedPlatform } from '../../platform';
|
||||
import { enableJsonOutput } from '../../utils/json';
|
||||
import { setVcsClient } from '../../vcs';
|
||||
import GitNoCommitClient from '../../vcs/clients/gitNoCommit';
|
||||
|
||||
/**
|
||||
* This command will be run on the EAS Build workers, when building
|
||||
@@ -44,13 +46,15 @@ export default class BuildInternal extends EasCommand {
|
||||
|
||||
const {
|
||||
loggedIn: { actor, graphqlClient },
|
||||
getDynamicProjectConfigAsync,
|
||||
getDynamicPrivateProjectConfigAsync,
|
||||
projectDir,
|
||||
analytics,
|
||||
} = await this.getContextAsync(BuildInternal, {
|
||||
nonInteractive: true,
|
||||
});
|
||||
|
||||
setVcsClient(new GitNoCommitClient());
|
||||
|
||||
await handleDeprecatedEasJsonAsync(projectDir, flags.nonInteractive);
|
||||
|
||||
await runBuildAndSubmitAsync(
|
||||
@@ -70,7 +74,7 @@ export default class BuildInternal extends EasCommand {
|
||||
},
|
||||
},
|
||||
actor,
|
||||
getDynamicProjectConfigAsync
|
||||
getDynamicPrivateProjectConfigAsync
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,7 @@ export default class BuildList extends EasCommand {
|
||||
BuildStatus.NEW,
|
||||
BuildStatus.IN_QUEUE,
|
||||
BuildStatus.IN_PROGRESS,
|
||||
BuildStatus.PENDING_CANCEL,
|
||||
BuildStatus.ERRORED,
|
||||
BuildStatus.FINISHED,
|
||||
BuildStatus.CANCELED,
|
||||
@@ -68,7 +69,7 @@ export default class BuildList extends EasCommand {
|
||||
'non-interactive': nonInteractive,
|
||||
} = flags;
|
||||
const {
|
||||
projectConfig: { projectId },
|
||||
privateProjectConfig: { projectId },
|
||||
loggedIn: { graphqlClient },
|
||||
} = await this.getContextAsync(BuildList, {
|
||||
nonInteractive,
|
||||
@@ -123,6 +124,8 @@ const toGraphQLBuildStatus = (buildStatus?: BuildStatus): GraphQLBuildStatus | u
|
||||
return GraphQLBuildStatus.InQueue;
|
||||
} else if (buildStatus === BuildStatus.IN_PROGRESS) {
|
||||
return GraphQLBuildStatus.InProgress;
|
||||
} else if (buildStatus === BuildStatus.PENDING_CANCEL) {
|
||||
return GraphQLBuildStatus.PendingCancel;
|
||||
} else if (buildStatus === BuildStatus.ERRORED) {
|
||||
return GraphQLBuildStatus.Errored;
|
||||
} else if (buildStatus === BuildStatus.FINISHED) {
|
||||
|
||||
@@ -101,7 +101,7 @@ export default class BuildResign extends EasCommand {
|
||||
|
||||
const {
|
||||
loggedIn: { actor, graphqlClient },
|
||||
getDynamicProjectConfigAsync,
|
||||
getDynamicPrivateProjectConfigAsync,
|
||||
projectDir,
|
||||
analytics,
|
||||
} = await this.getContextAsync(BuildResign, {
|
||||
@@ -121,7 +121,7 @@ export default class BuildResign extends EasCommand {
|
||||
await handleDeprecatedEasJsonAsync(projectDir, flags.nonInteractive);
|
||||
|
||||
await maybeWarnAboutEasOutagesAsync(graphqlClient, [StatuspageServiceName.EasBuild]);
|
||||
const easJsonAccessor = new EasJsonAccessor(projectDir);
|
||||
const easJsonAccessor = EasJsonAccessor.fromProjectPath(projectDir);
|
||||
const easJsonCliConfig: EasJson['cli'] =
|
||||
(await EasJsonUtils.getCliConfigAsync(easJsonAccessor)) ?? {};
|
||||
|
||||
@@ -130,7 +130,7 @@ export default class BuildResign extends EasCommand {
|
||||
platform,
|
||||
flags.profile ?? 'production'
|
||||
);
|
||||
const { exp, projectId } = await getDynamicProjectConfigAsync({ env: buildProfile.env });
|
||||
const { exp, projectId } = await getDynamicPrivateProjectConfigAsync({ env: buildProfile.env });
|
||||
const account = await getOwnerAccountForProjectIdAsync(graphqlClient, projectId);
|
||||
const build = await this.ensureBuildSelectedAsync(
|
||||
{ graphqlClient, projectId, platform, nonInteractive, limit, offset },
|
||||
|
||||
@@ -81,7 +81,7 @@ export default class Run extends EasCommand {
|
||||
const queryOptions = getPaginatedQueryOptions(flags);
|
||||
const {
|
||||
loggedIn: { graphqlClient },
|
||||
projectConfig: { projectId },
|
||||
privateProjectConfig: { projectId },
|
||||
} = await this.getContextAsync(Run, {
|
||||
nonInteractive: false,
|
||||
});
|
||||
@@ -210,8 +210,9 @@ async function maybeGetBuildAsync(
|
||||
},
|
||||
paginatedQueryOptions,
|
||||
selectPromptDisabledFunction: build => !isRunnableOnSimulatorOrEmulator(build),
|
||||
selectPromptWarningMessage:
|
||||
'Artifacts for this build have expired and are no longer available, or this is not a simulator/emulator build.',
|
||||
selectPromptWarningMessage: `Artifacts for this build have expired and are no longer available, or this is not ${
|
||||
flags.selectedPlatform === AppPlatform.Ios ? 'a simulator' : 'an emulator'
|
||||
} build.`,
|
||||
});
|
||||
return validateChosenBuild(build, flags.selectedPlatform);
|
||||
} else if (flags.runArchiveFlags.latest) {
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
import { Platform } from '@expo/eas-build-job';
|
||||
import { EasJsonAccessor } from '@expo/eas-json';
|
||||
import { Flags } from '@oclif/core';
|
||||
import chalk from 'chalk';
|
||||
|
||||
import EasCommand from '../../../commandUtils/EasCommand';
|
||||
import { EasNonInteractiveAndJsonFlags } from '../../../commandUtils/flags';
|
||||
import { AppVersionQuery } from '../../../graphql/queries/AppVersionQuery';
|
||||
import { toAppPlatform } from '../../../graphql/types/AppPlatform';
|
||||
import Log from '../../../log';
|
||||
import { selectRequestedPlatformAsync, toPlatforms } from '../../../platform';
|
||||
import { getApplicationIdentifierAsync } from '../../../project/applicationIdentifier';
|
||||
import {
|
||||
ensureVersionSourceIsRemoteAsync,
|
||||
validateAppConfigForRemoteVersionSource,
|
||||
} from '../../../project/remoteVersionSource';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json';
|
||||
import { getProfilesAsync } from '../../../utils/profiles';
|
||||
|
||||
export default class BuildVersionGetView extends EasCommand {
|
||||
public static override description = 'get the latest version from EAS servers';
|
||||
|
||||
public static override flags = {
|
||||
platform: Flags.enum({
|
||||
char: 'p',
|
||||
options: ['android', 'ios', 'all'],
|
||||
}),
|
||||
profile: Flags.string({
|
||||
char: 'e',
|
||||
description:
|
||||
'Name of the build profile from eas.json. Defaults to "production" if defined in eas.json.',
|
||||
helpValue: 'PROFILE_NAME',
|
||||
}),
|
||||
...EasNonInteractiveAndJsonFlags,
|
||||
};
|
||||
|
||||
static override contextDefinition = {
|
||||
...this.ContextOptions.LoggedIn,
|
||||
...this.ContextOptions.DynamicProjectConfig,
|
||||
...this.ContextOptions.ProjectDir,
|
||||
};
|
||||
|
||||
public async runAsync(): Promise<void> {
|
||||
const { flags } = await this.parse(BuildVersionGetView);
|
||||
if (flags.json) {
|
||||
enableJsonOutput();
|
||||
}
|
||||
const {
|
||||
loggedIn: { graphqlClient },
|
||||
getDynamicPrivateProjectConfigAsync,
|
||||
projectDir,
|
||||
} = await this.getContextAsync(BuildVersionGetView, {
|
||||
nonInteractive: true,
|
||||
});
|
||||
|
||||
if (!flags.platform && flags['non-interactive']) {
|
||||
throw new Error('"--platform" flag is required in non-interactive mode.');
|
||||
}
|
||||
const requestedPlatform = await selectRequestedPlatformAsync(flags.platform);
|
||||
const easJsonAccessor = EasJsonAccessor.fromProjectPath(projectDir);
|
||||
await ensureVersionSourceIsRemoteAsync(easJsonAccessor, {
|
||||
nonInteractive: flags['non-interactive'],
|
||||
});
|
||||
|
||||
const platforms = toPlatforms(requestedPlatform);
|
||||
const buildProfiles = await getProfilesAsync({
|
||||
type: 'build',
|
||||
easJsonAccessor,
|
||||
platforms,
|
||||
profileName: flags.profile ?? undefined,
|
||||
projectDir,
|
||||
});
|
||||
const results: { [key in Platform]?: string } = {};
|
||||
for (const { profile, platform } of buildProfiles) {
|
||||
const { exp, projectId } = await getDynamicPrivateProjectConfigAsync({
|
||||
env: profile.env,
|
||||
});
|
||||
|
||||
validateAppConfigForRemoteVersionSource(exp, platform);
|
||||
|
||||
const applicationIdentifier = await getApplicationIdentifierAsync({
|
||||
graphqlClient,
|
||||
projectDir,
|
||||
projectId,
|
||||
exp,
|
||||
buildProfile: profile,
|
||||
platform,
|
||||
});
|
||||
const remoteVersions = await AppVersionQuery.latestVersionAsync(
|
||||
graphqlClient,
|
||||
projectId,
|
||||
toAppPlatform(platform),
|
||||
applicationIdentifier
|
||||
);
|
||||
if (remoteVersions?.buildVersion) {
|
||||
results[platform] = remoteVersions?.buildVersion;
|
||||
}
|
||||
}
|
||||
if (flags.json) {
|
||||
const jsonResults: { versionCode?: string; buildNumber?: string } = {};
|
||||
if (results.android) {
|
||||
jsonResults.versionCode = results.android;
|
||||
}
|
||||
if (results.ios) {
|
||||
jsonResults.buildNumber = results.ios;
|
||||
}
|
||||
printJsonOnlyOutput(jsonResults);
|
||||
} else {
|
||||
if (Object.keys(results).length === 0) {
|
||||
Log.log('No remote versions are configured for this project.');
|
||||
return;
|
||||
}
|
||||
if (results.android) {
|
||||
Log.log(`Android versionCode - ${chalk.bold(results.android)}`);
|
||||
}
|
||||
if (results.ios) {
|
||||
Log.log(`iOS buildNumber - ${chalk.bold(results.ios)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
import { promptAsync } from '../../../prompts';
|
||||
|
||||
export default class BuildVersionSetView extends EasCommand {
|
||||
static override description = 'Update version of an app.';
|
||||
static override description = 'update version of an app';
|
||||
|
||||
static override flags = {
|
||||
platform: Flags.enum({
|
||||
@@ -47,22 +47,22 @@ export default class BuildVersionSetView extends EasCommand {
|
||||
const { flags } = await this.parse(BuildVersionSetView);
|
||||
const {
|
||||
loggedIn: { graphqlClient },
|
||||
getDynamicProjectConfigAsync,
|
||||
getDynamicPrivateProjectConfigAsync,
|
||||
projectDir,
|
||||
} = await this.getContextAsync(BuildVersionSetView, {
|
||||
nonInteractive: false,
|
||||
});
|
||||
|
||||
const platform = await selectPlatformAsync(flags.platform);
|
||||
const easJsonAccessor = new EasJsonAccessor(projectDir);
|
||||
await ensureVersionSourceIsRemoteAsync(easJsonAccessor);
|
||||
const easJsonAccessor = EasJsonAccessor.fromProjectPath(projectDir);
|
||||
await ensureVersionSourceIsRemoteAsync(easJsonAccessor, { nonInteractive: false });
|
||||
const profile = await EasJsonUtils.getBuildProfileAsync(
|
||||
easJsonAccessor,
|
||||
platform,
|
||||
flags.profile ?? undefined
|
||||
);
|
||||
|
||||
const { exp, projectId } = await getDynamicProjectConfigAsync({ env: profile.env });
|
||||
const { exp, projectId } = await getDynamicPrivateProjectConfigAsync({ env: profile.env });
|
||||
const displayName = await getDisplayNameForProjectIdAsync(graphqlClient, projectId);
|
||||
|
||||
validateAppConfigForRemoteVersionSource(exp, platform);
|
||||
|
||||
@@ -39,7 +39,7 @@ interface SyncContext<T extends Platform> {
|
||||
|
||||
export default class BuildVersionSyncView extends EasCommand {
|
||||
public static override description =
|
||||
'Update a version in native code with a value stored on EAS servers';
|
||||
'update a version in native code with a value stored on EAS servers';
|
||||
|
||||
public static override flags = {
|
||||
platform: Flags.enum({
|
||||
@@ -64,15 +64,15 @@ export default class BuildVersionSyncView extends EasCommand {
|
||||
const { flags } = await this.parse(BuildVersionSyncView);
|
||||
const {
|
||||
loggedIn: { graphqlClient },
|
||||
getDynamicProjectConfigAsync,
|
||||
getDynamicPrivateProjectConfigAsync,
|
||||
projectDir,
|
||||
} = await this.getContextAsync(BuildVersionSyncView, {
|
||||
nonInteractive: true,
|
||||
});
|
||||
|
||||
const requestedPlatform = await selectRequestedPlatformAsync(flags.platform);
|
||||
const easJsonAccessor = new EasJsonAccessor(projectDir);
|
||||
await ensureVersionSourceIsRemoteAsync(easJsonAccessor);
|
||||
const easJsonAccessor = EasJsonAccessor.fromProjectPath(projectDir);
|
||||
await ensureVersionSourceIsRemoteAsync(easJsonAccessor, { nonInteractive: false });
|
||||
|
||||
const platforms = toPlatforms(requestedPlatform);
|
||||
const buildProfiles = await getProfilesAsync({
|
||||
@@ -80,9 +80,10 @@ export default class BuildVersionSyncView extends EasCommand {
|
||||
easJsonAccessor,
|
||||
platforms,
|
||||
profileName: flags.profile ?? undefined,
|
||||
projectDir,
|
||||
});
|
||||
for (const profileInfo of buildProfiles) {
|
||||
const { exp, projectId } = await getDynamicProjectConfigAsync({
|
||||
const { exp, projectId } = await getDynamicPrivateProjectConfigAsync({
|
||||
env: profileInfo.profile.env,
|
||||
});
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ export default class BuildView extends EasCommand {
|
||||
flags,
|
||||
} = await this.parse(BuildView);
|
||||
const {
|
||||
projectConfig: { projectId },
|
||||
privateProjectConfig: { projectId },
|
||||
loggedIn: { graphqlClient },
|
||||
} = await this.getContextAsync(BuildView, {
|
||||
nonInteractive: true,
|
||||
|
||||
@@ -1,16 +1,11 @@
|
||||
import chalk from 'chalk';
|
||||
|
||||
import { createUpdateBranchOnAppAsync } from '../../branch/queries';
|
||||
import { BranchNotFoundError } from '../../branch/utils';
|
||||
import { createChannelOnAppAsync } from '../../channel/queries';
|
||||
import { createAndLinkChannelAsync } from '../../channel/queries';
|
||||
import EasCommand from '../../commandUtils/EasCommand';
|
||||
import { EasNonInteractiveAndJsonFlags } from '../../commandUtils/flags';
|
||||
import { BranchQuery } from '../../graphql/queries/BranchQuery';
|
||||
import Log from '../../log';
|
||||
import { getDisplayNameForProjectIdAsync } from '../../project/projectUtils';
|
||||
import { promptAsync } from '../../prompts';
|
||||
import formatFields from '../../utils/formatFields';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
|
||||
import { enableJsonOutput } from '../../utils/json';
|
||||
|
||||
export default class ChannelCreate extends EasCommand {
|
||||
static override description = 'create a channel';
|
||||
@@ -38,7 +33,7 @@ export default class ChannelCreate extends EasCommand {
|
||||
flags: { json: jsonFlag, 'non-interactive': nonInteractive },
|
||||
} = await this.parse(ChannelCreate);
|
||||
const {
|
||||
projectConfig: { projectId },
|
||||
privateProjectConfig: { projectId },
|
||||
loggedIn: { graphqlClient },
|
||||
} = await this.getContextAsync(ChannelCreate, {
|
||||
nonInteractive,
|
||||
@@ -60,69 +55,13 @@ export default class ChannelCreate extends EasCommand {
|
||||
}));
|
||||
}
|
||||
|
||||
let branchId: string;
|
||||
let branchMessage: string;
|
||||
|
||||
try {
|
||||
const branch = await BranchQuery.getBranchByNameAsync(graphqlClient, {
|
||||
appId: projectId,
|
||||
name: channelName,
|
||||
});
|
||||
branchId = branch.id;
|
||||
branchMessage = `We found a branch with the same name`;
|
||||
} catch (error) {
|
||||
if (error instanceof BranchNotFoundError) {
|
||||
const newBranch = await createUpdateBranchOnAppAsync(graphqlClient, {
|
||||
appId: projectId,
|
||||
name: channelName,
|
||||
});
|
||||
branchId = newBranch.id;
|
||||
branchMessage = `We also went ahead and made a branch with the same name`;
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
const {
|
||||
updateChannel: { createUpdateChannelForApp: newChannel },
|
||||
} = await createChannelOnAppAsync(graphqlClient, {
|
||||
await createAndLinkChannelAsync(graphqlClient, {
|
||||
appId: projectId,
|
||||
channelName,
|
||||
branchId,
|
||||
shouldPrintJson: jsonFlag,
|
||||
});
|
||||
|
||||
if (!newChannel) {
|
||||
throw new Error(
|
||||
`Could not create channel with name ${channelName} on project with id ${projectId}`
|
||||
);
|
||||
}
|
||||
|
||||
if (jsonFlag) {
|
||||
printJsonOnlyOutput(newChannel);
|
||||
} else {
|
||||
Log.addNewLineIfNone();
|
||||
Log.withTick(
|
||||
`Created a new channel on project ${chalk.bold(
|
||||
await getDisplayNameForProjectIdAsync(graphqlClient, projectId)
|
||||
)}`
|
||||
);
|
||||
Log.log(
|
||||
formatFields([
|
||||
{ label: 'Name', value: newChannel.name },
|
||||
{ label: 'ID', value: newChannel.id },
|
||||
])
|
||||
);
|
||||
Log.addNewLineIfNone();
|
||||
Log.withTick(`${branchMessage} and have pointed the channel at it.`);
|
||||
Log.log(
|
||||
formatFields([
|
||||
{ label: 'Name', value: newChannel.name },
|
||||
{ label: 'ID', value: branchId },
|
||||
])
|
||||
);
|
||||
|
||||
Log.addNewLineIfNone();
|
||||
Log.log(chalk.bold('You can now update your app by publishing!'));
|
||||
}
|
||||
Log.addNewLineIfNone();
|
||||
Log.log(chalk.bold('You can now update your app by publishing!'));
|
||||
}
|
||||
}
|
||||
@@ -41,7 +41,7 @@ export default class ChannelDelete extends EasCommand {
|
||||
flags: { json: jsonFlag, 'non-interactive': nonInteractive },
|
||||
} = await this.parse(ChannelDelete);
|
||||
const {
|
||||
projectConfig: { projectId },
|
||||
privateProjectConfig: { projectId },
|
||||
loggedIn: { graphqlClient },
|
||||
} = await this.getContextAsync(ChannelDelete, {
|
||||
nonInteractive,
|
||||
|
||||
@@ -1,26 +1,31 @@
|
||||
import { Flags } from '@oclif/core';
|
||||
import chalk from 'chalk';
|
||||
import { print } from 'graphql';
|
||||
import gql from 'graphql-tag';
|
||||
|
||||
import { selectBranchOnAppAsync } from '../../branch/queries';
|
||||
import { hasEmptyBranchMap, hasStandardBranchMap } from '../../channel/branch-mapping';
|
||||
import { selectChannelOnAppAsync } from '../../channel/queries';
|
||||
import EasCommand from '../../commandUtils/EasCommand';
|
||||
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { EasNonInteractiveAndJsonFlags } from '../../commandUtils/flags';
|
||||
import { withErrorHandlingAsync } from '../../graphql/client';
|
||||
import {
|
||||
UpdateChannelBasicInfoFragment,
|
||||
UpdateChannelBranchMappingMutation,
|
||||
UpdateChannelBranchMappingMutationVariables,
|
||||
} from '../../graphql/generated';
|
||||
import { BranchQuery } from '../../graphql/queries/BranchQuery';
|
||||
import { ChannelQuery } from '../../graphql/queries/ChannelQuery';
|
||||
import { UpdateChannelBasicInfoFragmentNode } from '../../graphql/types/UpdateChannelBasicInfo';
|
||||
import Log from '../../log';
|
||||
import { isRollout } from '../../rollout/branch-mapping';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
|
||||
|
||||
export async function updateChannelBranchMappingAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
{ channelId, branchMapping }: UpdateChannelBranchMappingMutationVariables
|
||||
): Promise<UpdateChannelBranchMappingMutation['updateChannel']['editUpdateChannel']> {
|
||||
): Promise<UpdateChannelBasicInfoFragment> {
|
||||
const data = await withErrorHandlingAsync(
|
||||
graphqlClient
|
||||
.mutation<UpdateChannelBranchMappingMutation, UpdateChannelBranchMappingMutationVariables>(
|
||||
@@ -29,11 +34,11 @@ export async function updateChannelBranchMappingAsync(
|
||||
updateChannel {
|
||||
editUpdateChannel(channelId: $channelId, branchMapping: $branchMapping) {
|
||||
id
|
||||
name
|
||||
branchMapping
|
||||
...UpdateChannelBasicInfoFragment
|
||||
}
|
||||
}
|
||||
}
|
||||
${print(UpdateChannelBasicInfoFragmentNode)}
|
||||
`,
|
||||
{ channelId, branchMapping }
|
||||
)
|
||||
@@ -75,7 +80,7 @@ export default class ChannelEdit extends EasCommand {
|
||||
flags: { branch: branchFlag, json, 'non-interactive': nonInteractive },
|
||||
} = await this.parse(ChannelEdit);
|
||||
const {
|
||||
projectConfig: { projectId },
|
||||
privateProjectConfig: { projectId },
|
||||
loggedIn: { graphqlClient },
|
||||
} = await this.getContextAsync(ChannelEdit, {
|
||||
nonInteractive,
|
||||
@@ -95,8 +100,10 @@ export default class ChannelEdit extends EasCommand {
|
||||
paginatedQueryOptions: { json, nonInteractive, offset: 0 },
|
||||
});
|
||||
|
||||
if (existingChannel.updateBranches.length > 1) {
|
||||
if (isRollout(existingChannel)) {
|
||||
throw new Error('There is a rollout in progress. Manage it with "channel:rollout" instead.');
|
||||
} else if (!hasStandardBranchMap(existingChannel) && !hasEmptyBranchMap(existingChannel)) {
|
||||
throw new Error('Only standard branch mappings can be edited with this command.');
|
||||
}
|
||||
|
||||
const branch = branchFlag
|
||||
|
||||
@@ -27,7 +27,7 @@ export default class ChannelList extends EasCommand {
|
||||
const paginatedQueryOptions = getPaginatedQueryOptions(flags);
|
||||
const { json: jsonFlag, 'non-interactive': nonInteractive } = flags;
|
||||
const {
|
||||
projectConfig: { projectId },
|
||||
privateProjectConfig: { projectId },
|
||||
loggedIn: { graphqlClient },
|
||||
} = await this.getContextAsync(ChannelList, {
|
||||
nonInteractive,
|
||||
|
||||
@@ -1,322 +1,125 @@
|
||||
import { Flags } from '@oclif/core';
|
||||
import chalk from 'chalk';
|
||||
|
||||
import { BranchMapping, getBranchMapping } from '../../channel/utils';
|
||||
import EasCommand from '../../commandUtils/EasCommand';
|
||||
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { EasNonInteractiveAndJsonFlags } from '../../commandUtils/flags';
|
||||
import { UpdateBranch } from '../../graphql/generated';
|
||||
import { BranchQuery } from '../../graphql/queries/BranchQuery';
|
||||
import { ChannelQuery, UpdateChannelByNameObject } from '../../graphql/queries/ChannelQuery';
|
||||
import Log from '../../log';
|
||||
import { getDisplayNameForProjectIdAsync } from '../../project/projectUtils';
|
||||
import { promptAsync, selectAsync } from '../../prompts';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
|
||||
import { updateChannelBranchMappingAsync } from './edit';
|
||||
import { NonInteractiveOptions as CreateRolloutNonInteractiveOptions } from '../../rollout/actions/CreateRollout';
|
||||
import { NonInteractiveOptions as EditRolloutNonInteractiveOptions } from '../../rollout/actions/EditRollout';
|
||||
import {
|
||||
EndOutcome,
|
||||
GeneralOptions as EndRolloutGeneralOptions,
|
||||
NonInteractiveOptions as EndRolloutNonInteractiveOptions,
|
||||
} from '../../rollout/actions/EndRollout';
|
||||
import { ManageRolloutActions } from '../../rollout/actions/ManageRollout';
|
||||
import { NonInteractiveRollout } from '../../rollout/actions/NonInteractiveRollout';
|
||||
import {
|
||||
MainMenuActions,
|
||||
RolloutActions,
|
||||
RolloutMainMenu,
|
||||
} from '../../rollout/actions/RolloutMainMenu';
|
||||
import { enableJsonOutput } from '../../utils/json';
|
||||
|
||||
async function promptForRolloutPercentAsync({
|
||||
promptMessage,
|
||||
}: {
|
||||
promptMessage: string;
|
||||
}): Promise<number> {
|
||||
const { name: rolloutPercent } = await promptAsync({
|
||||
type: 'text',
|
||||
name: 'name',
|
||||
format: value => {
|
||||
return parseInt(value, 10);
|
||||
},
|
||||
message: promptMessage,
|
||||
initial: 0,
|
||||
validate: (rolloutPercent: string): true | string => {
|
||||
const floatValue = parseFloat(rolloutPercent);
|
||||
return Number.isInteger(floatValue) && floatValue >= 0 && floatValue <= 100
|
||||
? true
|
||||
: 'The rollout percentage must be an integer between 0 and 100 inclusive.';
|
||||
},
|
||||
});
|
||||
return rolloutPercent;
|
||||
enum ActionRawFlagValue {
|
||||
CREATE = 'create',
|
||||
EDIT = 'edit',
|
||||
END = 'end',
|
||||
VIEW = 'view',
|
||||
}
|
||||
type ChannelRolloutRawArgsAndFlags = {
|
||||
channel?: string;
|
||||
action?: ActionRawFlagValue;
|
||||
percent?: number;
|
||||
outcome?: EndOutcome;
|
||||
'non-interactive': boolean;
|
||||
branch?: string;
|
||||
'runtime-version'?: string;
|
||||
'private-key-path'?: string;
|
||||
json?: boolean;
|
||||
};
|
||||
|
||||
function getRolloutInfo(channel: UpdateChannelByNameObject): {
|
||||
newBranch: Pick<UpdateBranch, 'name' | 'id'>;
|
||||
oldBranch: Pick<UpdateBranch, 'name' | 'id'>;
|
||||
currentPercent: number;
|
||||
} {
|
||||
const { branchMapping } = getBranchMapping(channel.branchMapping);
|
||||
const [newBranchId, oldBranchId] = branchMapping.data.map(d => d.branchId);
|
||||
const newBranch = channel.updateBranches.filter(branch => branch.id === newBranchId)[0];
|
||||
const oldBranch = channel.updateBranches.filter(branch => branch.id === oldBranchId)[0];
|
||||
|
||||
if (!newBranch || !oldBranch) {
|
||||
throw new Error(`Branch mapping rollout is missing a branch for channel "${channel.name}".`);
|
||||
}
|
||||
|
||||
const currentPercent = 100 * branchMapping.data[0].branchMappingLogic.operand;
|
||||
return { newBranch, oldBranch, currentPercent };
|
||||
}
|
||||
|
||||
async function startRolloutAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
{
|
||||
channelName,
|
||||
branchName,
|
||||
percent,
|
||||
projectId,
|
||||
displayName,
|
||||
currentBranchMapping,
|
||||
channel,
|
||||
nonInteractive,
|
||||
}: {
|
||||
channelName?: string;
|
||||
branchName: string;
|
||||
percent?: number;
|
||||
projectId: string;
|
||||
displayName: string;
|
||||
currentBranchMapping: BranchMapping;
|
||||
channel: UpdateChannelByNameObject;
|
||||
nonInteractive: boolean;
|
||||
}
|
||||
): Promise<{
|
||||
newChannelInfo: {
|
||||
id: string;
|
||||
name: string;
|
||||
branchMapping: string;
|
||||
};
|
||||
logMessage: string;
|
||||
}> {
|
||||
const branch = await BranchQuery.getBranchByNameAsync(graphqlClient, {
|
||||
appId: projectId,
|
||||
name: branchName,
|
||||
});
|
||||
|
||||
const oldBranchId = currentBranchMapping.data[0].branchId;
|
||||
if (branch.id === oldBranchId) {
|
||||
throw new Error(
|
||||
`channel "${channelName}" is already pointing at branch "${branchName}". Rollouts must be done with distinct branches.`
|
||||
);
|
||||
}
|
||||
|
||||
if (percent == null) {
|
||||
if (nonInteractive) {
|
||||
throw new Error(
|
||||
'You must specify a percent with the --percent flag when initiating a rollout with the --non-interactive flag.'
|
||||
);
|
||||
}
|
||||
const promptMessage = `What percent of users should be directed to the branch "${branchName}"?`;
|
||||
percent = await promptForRolloutPercentAsync({ promptMessage });
|
||||
}
|
||||
|
||||
const newBranchMapping = {
|
||||
version: 0,
|
||||
data: [
|
||||
{
|
||||
branchId: branch.id,
|
||||
branchMappingLogic: {
|
||||
operand: percent / 100,
|
||||
clientKey: 'rolloutToken',
|
||||
branchMappingOperator: 'hash_lt',
|
||||
},
|
||||
},
|
||||
currentBranchMapping.data[0],
|
||||
],
|
||||
};
|
||||
const newChannelInfo = await updateChannelBranchMappingAsync(graphqlClient, {
|
||||
channelId: channel.id,
|
||||
branchMapping: JSON.stringify(newBranchMapping),
|
||||
});
|
||||
|
||||
const oldBranch = channel.updateBranches.filter(branch => branch.id === oldBranchId)[0];
|
||||
if (!oldBranch) {
|
||||
throw new Error(
|
||||
`Branch mapping is missing its only branch for channel "${channelName}" on app "${displayName}"`
|
||||
);
|
||||
}
|
||||
|
||||
const logMessage = `️Started a rollout of branch ${chalk.bold(
|
||||
branchName
|
||||
)} onto channel ${chalk.bold(channelName!)}! ${chalk.bold(
|
||||
percent
|
||||
)}% of users will be directed to branch ${chalk.bold(branchName)}, ${chalk.bold(
|
||||
100 - percent
|
||||
)}% to branch ${chalk.bold(oldBranch.name)}.`;
|
||||
|
||||
return { newChannelInfo, logMessage };
|
||||
}
|
||||
|
||||
async function editRolloutAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
{
|
||||
channelName,
|
||||
percent,
|
||||
nonInteractive,
|
||||
currentBranchMapping,
|
||||
channel,
|
||||
}: {
|
||||
channelName?: string;
|
||||
percent?: number;
|
||||
nonInteractive: boolean;
|
||||
currentBranchMapping: BranchMapping;
|
||||
channel: UpdateChannelByNameObject;
|
||||
}
|
||||
): Promise<{
|
||||
newChannelInfo: {
|
||||
id: string;
|
||||
name: string;
|
||||
branchMapping: string;
|
||||
};
|
||||
logMessage: string;
|
||||
}> {
|
||||
const { newBranch, oldBranch, currentPercent } = getRolloutInfo(channel);
|
||||
|
||||
if (percent == null) {
|
||||
if (nonInteractive) {
|
||||
throw new Error(
|
||||
'A rollout is already in progress. If you wish to modify it you must use specify the new rollout percentage with the --percent flag.'
|
||||
);
|
||||
}
|
||||
const promptMessage = `Currently ${currentPercent}% of all users are routed to branch ${
|
||||
newBranch.name
|
||||
} and ${100 - currentPercent}% of all users are routed to branch ${
|
||||
oldBranch.name
|
||||
}. What percent of users should be directed to the branch ${newBranch.name}?`;
|
||||
percent = await promptForRolloutPercentAsync({ promptMessage });
|
||||
}
|
||||
|
||||
const newBranchMapping = { ...currentBranchMapping };
|
||||
newBranchMapping.data[0].branchMappingLogic.operand = percent / 100;
|
||||
|
||||
const newChannelInfo = await updateChannelBranchMappingAsync(graphqlClient, {
|
||||
channelId: channel.id,
|
||||
branchMapping: JSON.stringify(newBranchMapping),
|
||||
});
|
||||
|
||||
const logMessage = `️Rollout of branch ${chalk.bold(newBranch.name)} onto channel ${chalk.bold(
|
||||
channelName!
|
||||
)} updated from ${chalk.bold(currentPercent)}% to ${chalk.bold(percent)}%. ${chalk.bold(
|
||||
percent
|
||||
)}% of users will be directed to branch ${chalk.bold(newBranch.name)}, ${chalk.bold(
|
||||
100 - percent
|
||||
)}% to branch ${chalk.bold(oldBranch.name)}.`;
|
||||
|
||||
return { newChannelInfo, logMessage };
|
||||
}
|
||||
|
||||
async function endRolloutAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
{
|
||||
channelName,
|
||||
branchName,
|
||||
nonInteractive,
|
||||
projectId,
|
||||
channel,
|
||||
}: {
|
||||
channelName?: string;
|
||||
branchName?: string;
|
||||
nonInteractive: boolean;
|
||||
projectId: string;
|
||||
channel: UpdateChannelByNameObject;
|
||||
}
|
||||
): Promise<{
|
||||
newChannelInfo: {
|
||||
id: string;
|
||||
name: string;
|
||||
branchMapping: string;
|
||||
};
|
||||
logMessage: string;
|
||||
}> {
|
||||
// end rollout
|
||||
const { newBranch, oldBranch, currentPercent } = getRolloutInfo(channel);
|
||||
|
||||
let endOnNewBranch;
|
||||
if (branchName) {
|
||||
const branch = await BranchQuery.getBranchByNameAsync(graphqlClient, {
|
||||
appId: projectId,
|
||||
name: branchName,
|
||||
});
|
||||
|
||||
switch (branch.id) {
|
||||
case newBranch.id:
|
||||
endOnNewBranch = true;
|
||||
break;
|
||||
case oldBranch.id:
|
||||
endOnNewBranch = false;
|
||||
break;
|
||||
default:
|
||||
throw new Error(
|
||||
`The branch "${branchName}" specified by --branch must be one of the branches involved in the rollout: "${newBranch.name}" or "${oldBranch.name}".`
|
||||
);
|
||||
}
|
||||
} else {
|
||||
if (nonInteractive) {
|
||||
throw new Error(
|
||||
'Branch name must be specified with the --branch flag when both the --end and --non-interactive flag are true.'
|
||||
);
|
||||
}
|
||||
endOnNewBranch = await selectAsync<boolean>(
|
||||
'Ending the rollout will send all traffic to a single branch. Which one should that be?',
|
||||
[
|
||||
{
|
||||
title: `${newBranch.name} ${chalk.grey(`- current percent: ${100 - currentPercent}%`)}`,
|
||||
value: true,
|
||||
},
|
||||
{
|
||||
title: `${oldBranch.name} ${chalk.grey(`- current percent: ${currentPercent}%`)}`,
|
||||
value: false,
|
||||
},
|
||||
]
|
||||
);
|
||||
}
|
||||
if (endOnNewBranch == null) {
|
||||
throw new Error('Branch to end on is undefined.');
|
||||
}
|
||||
|
||||
const newBranchMapping = {
|
||||
version: 0,
|
||||
data: [
|
||||
{
|
||||
branchId: endOnNewBranch ? newBranch.id : oldBranch.id,
|
||||
branchMappingLogic: 'true',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const newChannelInfo = await updateChannelBranchMappingAsync(graphqlClient, {
|
||||
channelId: channel.id,
|
||||
branchMapping: JSON.stringify(newBranchMapping),
|
||||
});
|
||||
const logMessage = `️Rollout on channel ${chalk.bold(
|
||||
channelName
|
||||
)} ended. All traffic is now sent to branch ${chalk.bold(
|
||||
endOnNewBranch ? newBranch.name : oldBranch.name
|
||||
)}`;
|
||||
|
||||
return { newChannelInfo, logMessage };
|
||||
}
|
||||
type ChannelRolloutArgsAndFlags = {
|
||||
channelName?: string;
|
||||
action?: RolloutActions;
|
||||
nonInteractive: boolean;
|
||||
json?: boolean;
|
||||
} & Partial<EditRolloutNonInteractiveOptions> &
|
||||
Partial<EndRolloutNonInteractiveOptions> &
|
||||
EndRolloutGeneralOptions &
|
||||
Partial<CreateRolloutNonInteractiveOptions>;
|
||||
|
||||
export default class ChannelRollout extends EasCommand {
|
||||
static override hidden = true;
|
||||
static override description = 'Rollout a new branch out to a channel incrementally.';
|
||||
static override description = 'Roll a new branch out on a channel incrementally.';
|
||||
|
||||
static override args = [
|
||||
{
|
||||
name: 'channel',
|
||||
required: true,
|
||||
description: 'rollout that the channel is on',
|
||||
description: 'channel on which the rollout should be done',
|
||||
},
|
||||
];
|
||||
|
||||
static override flags = {
|
||||
branch: Flags.string({
|
||||
description: 'branch to rollout',
|
||||
action: Flags.enum({
|
||||
description: 'Rollout action to perform',
|
||||
options: Object.values(ActionRawFlagValue),
|
||||
required: false,
|
||||
relationships: [
|
||||
{
|
||||
type: 'all',
|
||||
flags: [
|
||||
{
|
||||
name: 'percent',
|
||||
// eslint-disable-next-line async-protect/async-suffix
|
||||
when: async flags => {
|
||||
return (
|
||||
!!flags['non-interactive'] &&
|
||||
(flags['action'] === ActionRawFlagValue.CREATE ||
|
||||
flags['action'] === ActionRawFlagValue.EDIT)
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'outcome',
|
||||
// eslint-disable-next-line async-protect/async-suffix
|
||||
when: async flags =>
|
||||
!!flags['non-interactive'] && flags['action'] === ActionRawFlagValue.END,
|
||||
},
|
||||
{
|
||||
name: 'branch',
|
||||
// eslint-disable-next-line async-protect/async-suffix
|
||||
when: async flags =>
|
||||
!!flags['non-interactive'] && flags['action'] === ActionRawFlagValue.CREATE,
|
||||
},
|
||||
{
|
||||
name: 'runtime-version',
|
||||
// eslint-disable-next-line async-protect/async-suffix
|
||||
when: async flags =>
|
||||
!!flags['non-interactive'] && flags['action'] === ActionRawFlagValue.CREATE,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
percent: Flags.integer({
|
||||
description: 'percent of traffic to redirect to the new branch',
|
||||
description:
|
||||
'Percent of users to send to the new branch. Use with --action=edit or --action=create',
|
||||
required: false,
|
||||
}),
|
||||
end: Flags.boolean({
|
||||
description: 'end the rollout',
|
||||
default: false,
|
||||
outcome: Flags.enum({
|
||||
description: 'End outcome of rollout. Use with --action=end',
|
||||
options: Object.values(EndOutcome),
|
||||
required: false,
|
||||
}),
|
||||
branch: Flags.string({
|
||||
description: 'Branch to roll out. Use with --action=create',
|
||||
required: false,
|
||||
}),
|
||||
'runtime-version': Flags.string({
|
||||
description: 'Runtime version to target. Use with --action=create',
|
||||
required: false,
|
||||
}),
|
||||
'private-key-path': Flags.string({
|
||||
description: `File containing the PEM-encoded private key corresponding to the certificate in expo-updates' configuration. Defaults to a file named "private-key.pem" in the certificate's directory.`,
|
||||
required: false,
|
||||
}),
|
||||
...EasNonInteractiveAndJsonFlags,
|
||||
};
|
||||
@@ -327,124 +130,64 @@ export default class ChannelRollout extends EasCommand {
|
||||
};
|
||||
|
||||
async runAsync(): Promise<void> {
|
||||
const { args, flags } = await this.parse(ChannelRollout);
|
||||
const argsAndFlags = this.sanitizeArgsAndFlags({ ...flags, ...args });
|
||||
const {
|
||||
args: { channel: channelName },
|
||||
flags: {
|
||||
json: jsonFlag,
|
||||
end: endFlag,
|
||||
branch: branchName,
|
||||
percent,
|
||||
'non-interactive': nonInteractive,
|
||||
},
|
||||
} = await this.parse(ChannelRollout);
|
||||
const {
|
||||
projectConfig: { projectId },
|
||||
privateProjectConfig: { exp, projectId },
|
||||
loggedIn: { graphqlClient },
|
||||
} = await this.getContextAsync(ChannelRollout, {
|
||||
nonInteractive,
|
||||
nonInteractive: argsAndFlags.nonInteractive,
|
||||
});
|
||||
if (jsonFlag) {
|
||||
if (argsAndFlags.json) {
|
||||
enableJsonOutput();
|
||||
}
|
||||
|
||||
const projectDisplayName = await getDisplayNameForProjectIdAsync(graphqlClient, projectId);
|
||||
|
||||
const channel = await ChannelQuery.viewUpdateChannelAsync(graphqlClient, {
|
||||
appId: projectId,
|
||||
channelName: channelName!,
|
||||
});
|
||||
if (!channel) {
|
||||
throw new Error(
|
||||
`Could not find a channel named "${channelName}". Check which channels exist on this project with ${chalk.bold(
|
||||
'eas channel:list'
|
||||
)}.`
|
||||
);
|
||||
}
|
||||
|
||||
const { branchMapping: currentBranchMapping, isRollout } = getBranchMapping(
|
||||
channel.branchMapping
|
||||
);
|
||||
|
||||
if (currentBranchMapping.data.length === 0) {
|
||||
throw new Error('The channel is not pointing at any branches.');
|
||||
}
|
||||
if (currentBranchMapping.data.length > 2) {
|
||||
throw new Error('"channel:rollout" cannot handle branch mappings with more than 2 branches.');
|
||||
}
|
||||
// This combination doesn't make sense. Throw an error explaining the options.
|
||||
if (isRollout && branchName && !endFlag) {
|
||||
throw new Error(
|
||||
`There is a rollout in progress. You can only either edit the rollout percent or 'end' it.`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* This if/else block has three branches:
|
||||
* 1. The branch mapping is not a rollout, i.e. it is pointing to a single branch.
|
||||
* 2. The branch mapping is a rollout.
|
||||
* a. increase/decrease the rollout percentage.
|
||||
* b. end the rollout.
|
||||
*/
|
||||
let rolloutMutationResult: {
|
||||
newChannelInfo: {
|
||||
id: string;
|
||||
name: string;
|
||||
branchMapping: string;
|
||||
};
|
||||
logMessage: string;
|
||||
const app = { projectId, exp };
|
||||
const ctx = {
|
||||
projectId,
|
||||
nonInteractive: argsAndFlags.nonInteractive,
|
||||
graphqlClient,
|
||||
app,
|
||||
};
|
||||
if (!isRollout) {
|
||||
rolloutMutationResult = await startRolloutAsync(graphqlClient, {
|
||||
channelName,
|
||||
branchName: branchName ?? (await promptForBranchNameAsync(channelName, nonInteractive)),
|
||||
percent,
|
||||
nonInteractive,
|
||||
projectId,
|
||||
displayName: projectDisplayName,
|
||||
currentBranchMapping,
|
||||
channel,
|
||||
});
|
||||
} else if (endFlag) {
|
||||
rolloutMutationResult = await endRolloutAsync(graphqlClient, {
|
||||
channelName,
|
||||
branchName,
|
||||
nonInteractive,
|
||||
projectId,
|
||||
channel,
|
||||
});
|
||||
if (argsAndFlags.nonInteractive) {
|
||||
await new NonInteractiveRollout(argsAndFlags).runAsync(ctx);
|
||||
} else {
|
||||
rolloutMutationResult = await editRolloutAsync(graphqlClient, {
|
||||
channelName,
|
||||
percent,
|
||||
nonInteractive,
|
||||
currentBranchMapping,
|
||||
channel,
|
||||
});
|
||||
}
|
||||
if (!rolloutMutationResult) {
|
||||
throw new Error('rollout result is empty');
|
||||
}
|
||||
const { newChannelInfo, logMessage } = rolloutMutationResult;
|
||||
if (jsonFlag) {
|
||||
printJsonOnlyOutput(newChannelInfo);
|
||||
} else {
|
||||
Log.withTick(logMessage);
|
||||
Log.addNewLineIfNone();
|
||||
Log.warn(
|
||||
`✨ This command is in Developer Preview and has not been released to production yet. Website support is coming soon.`
|
||||
);
|
||||
Log.addNewLineIfNone();
|
||||
await new RolloutMainMenu(argsAndFlags).runAsync(ctx);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function promptForBranchNameAsync(
|
||||
channelName: string,
|
||||
nonInteractive: boolean
|
||||
): Promise<string> {
|
||||
if (nonInteractive) {
|
||||
throw new Error('Must supply branch flag in non-interactive mode');
|
||||
private getAction(action: ActionRawFlagValue): RolloutActions {
|
||||
switch (action) {
|
||||
case ActionRawFlagValue.CREATE:
|
||||
return MainMenuActions.CREATE_NEW;
|
||||
case ActionRawFlagValue.EDIT:
|
||||
return ManageRolloutActions.EDIT;
|
||||
case ActionRawFlagValue.END:
|
||||
return ManageRolloutActions.END;
|
||||
case ActionRawFlagValue.VIEW:
|
||||
return ManageRolloutActions.VIEW;
|
||||
}
|
||||
}
|
||||
|
||||
private sanitizeArgsAndFlags(
|
||||
rawFlags: ChannelRolloutRawArgsAndFlags
|
||||
): ChannelRolloutArgsAndFlags {
|
||||
const action = rawFlags.action;
|
||||
return {
|
||||
channelName: rawFlags.channel,
|
||||
percent: rawFlags.percent,
|
||||
outcome: rawFlags.outcome,
|
||||
branchNameToRollout: rawFlags.branch,
|
||||
runtimeVersion: rawFlags['runtime-version'],
|
||||
privateKeyPath: rawFlags['private-key-path'] ?? null,
|
||||
action: action ? this.getAction(action) : undefined,
|
||||
nonInteractive: rawFlags['non-interactive'],
|
||||
json: rawFlags.json,
|
||||
};
|
||||
}
|
||||
const { name } = await promptAsync({
|
||||
type: 'text',
|
||||
name: 'name',
|
||||
message: `Select a branch to rollout onto ${channelName}`,
|
||||
validate: value => (value ? true : 'A branch must be specified.'),
|
||||
});
|
||||
return name;
|
||||
}
|
||||
@@ -38,7 +38,7 @@ export default class ChannelView extends EasCommand {
|
||||
const paginatedQueryOptions = getPaginatedQueryOptions(flags);
|
||||
const { json: jsonFlag, 'non-interactive': nonInteractive } = flags;
|
||||
const {
|
||||
projectConfig: { projectId },
|
||||
privateProjectConfig: { projectId },
|
||||
loggedIn: { graphqlClient },
|
||||
} = await this.getContextAsync(ChannelView, {
|
||||
nonInteractive,
|
||||
|
||||
@@ -41,11 +41,11 @@ export default class Config extends EasCommand {
|
||||
enableJsonOutput();
|
||||
}
|
||||
const { platform: maybePlatform, profile: maybeProfile } = flags;
|
||||
const { getDynamicProjectConfigAsync, projectDir } = await this.getContextAsync(Config, {
|
||||
const { getDynamicPublicProjectConfigAsync, projectDir } = await this.getContextAsync(Config, {
|
||||
nonInteractive: false,
|
||||
});
|
||||
|
||||
const accessor = new EasJsonAccessor(projectDir);
|
||||
const accessor = EasJsonAccessor.fromProjectPath(projectDir);
|
||||
const profileName =
|
||||
maybeProfile ??
|
||||
(await selectAsync(
|
||||
@@ -82,9 +82,8 @@ export default class Config extends EasCommand {
|
||||
Log.log(JSON.stringify(profile, null, 2));
|
||||
}
|
||||
} else {
|
||||
const { exp: appConfig } = await getDynamicProjectConfigAsync({
|
||||
const { exp: appConfig } = await getDynamicPublicProjectConfigAsync({
|
||||
env: profile.env,
|
||||
isPublicConfig: true,
|
||||
});
|
||||
|
||||
if (flags.json) {
|
||||
|
||||
Loaded 100 of 283 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user