mirror of
https://github.com/expo/eas-cli.git
synced 2026-11-13 20:28:08 -10:00
Compare commits
385
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f1ba8456a2 | ||
|
|
b60f0183e3 | ||
|
|
b48a015a63 | ||
|
|
cd72ffcd70 | ||
|
|
98ea269106 | ||
|
|
144e3ad0ba | ||
|
|
a40381508a | ||
|
|
7b58eb2390 | ||
|
|
4476c87962 | ||
|
|
18ecd0844a | ||
|
|
cc3e7cdbe4 | ||
|
|
aa7fecb9fe | ||
|
|
c73a3e3a1e | ||
|
|
37ca2ebd55 | ||
|
|
dead11964e | ||
|
|
b66d31d7d7 | ||
|
|
ed8170047b | ||
|
|
0e4056ba82 | ||
|
|
6182ea5a1d | ||
|
|
c9e7b649ad | ||
|
|
7a6b796242 | ||
|
|
804eb9a326 | ||
|
|
8da14f0101 | ||
|
|
dc62d22816 | ||
|
|
258c625611 | ||
|
|
e84340774d | ||
|
|
fbc23f7a7d | ||
|
|
97255ec90d | ||
|
|
cfccc87b82 | ||
|
|
09b4f8367e | ||
|
|
45481f9758 | ||
|
|
1119008317 | ||
|
|
123a0c1b7e | ||
|
|
2f21fa62b1 | ||
|
|
8ad449c6b7 | ||
|
|
fe8ec50806 | ||
|
|
86cefeb259 | ||
|
|
034e0a1e7f | ||
|
|
a6b776915f | ||
|
|
4ae52d0e3d | ||
|
|
f62720ef5a | ||
|
|
af09aaedb3 | ||
|
|
184616e874 | ||
|
|
6bb09c4e99 | ||
|
|
81add86c61 | ||
|
|
4bb1c784d8 | ||
|
|
322082c28b | ||
|
|
5db1d21aa5 | ||
|
|
0db5585b20 | ||
|
|
10ec54345a | ||
|
|
27b4710264 | ||
|
|
4132e92da5 | ||
|
|
264340a717 | ||
|
|
1d09c2c97d | ||
|
|
72cff1df1a | ||
|
|
6912e453ef | ||
|
|
839cbac47b | ||
|
|
5ba16dbc67 | ||
|
|
896b456dd3 | ||
|
|
f2c0202f06 | ||
|
|
497bac1cbf | ||
|
|
5b9734b60b | ||
|
|
3ac70d8977 | ||
|
|
32adc788d0 | ||
|
|
e465ed57cb | ||
|
|
df819fc232 | ||
|
|
539e201972 | ||
|
|
c14b3e179b | ||
|
|
f093197dea | ||
|
|
13834039a8 | ||
|
|
4d3023f7f1 | ||
|
|
eb7d3b5ecd | ||
|
|
8c721759bd | ||
|
|
c6a741bb8c | ||
|
|
af5b76fe9f | ||
|
|
aa428b7571 | ||
|
|
ba68f40922 | ||
|
|
1380b023ba | ||
|
|
adfc79abd4 | ||
|
|
92e6ecd03c | ||
|
|
05546f0bfd | ||
|
|
6c5c99b5d1 | ||
|
|
f27ea08839 | ||
|
|
15f47aea81 | ||
|
|
fd23f031b6 | ||
|
|
a74c434718 | ||
|
|
29950a7629 | ||
|
|
0fd33a95f5 | ||
|
|
843ed0d0aa | ||
|
|
bf2cde45c6 | ||
|
|
ecab803cfa | ||
|
|
570ff126a0 | ||
|
|
e24b21bd1e | ||
|
|
20da7f8778 | ||
|
|
14e002274f | ||
|
|
3b8dc6b7aa | ||
|
|
445a48a8dd | ||
|
|
f6436319ed | ||
|
|
bd1e32622d | ||
|
|
74dcb40caa | ||
|
|
f094146dcd | ||
|
|
3784745382 | ||
|
|
72be7e80bf | ||
|
|
cf1b8cb5eb | ||
|
|
b6df8d3a57 | ||
|
|
b96a262cd4 | ||
|
|
3df84f28aa | ||
|
|
554f411fae | ||
|
|
e6ce9837e6 | ||
|
|
30d8a0ce99 | ||
|
|
a9c5907230 | ||
|
|
ad0f94aca9 | ||
|
|
bf66b79126 | ||
|
|
dcc5d62800 | ||
|
|
878ed19f0c | ||
|
|
0e17e415b7 | ||
|
|
d4c5a4e1e4 | ||
|
|
3776c95f00 | ||
|
|
7804a00597 | ||
|
|
5c27574df2 | ||
|
|
f2ab223f0e | ||
|
|
38e64cbd9a | ||
|
|
2cd7194ef6 | ||
|
|
6c33c8a656 | ||
|
|
92643026f3 | ||
|
|
b8a056b43e | ||
|
|
1ba5cb07ce | ||
|
|
8e96143b59 | ||
|
|
52512c333d | ||
|
|
576c798666 | ||
|
|
356d6267ec | ||
|
|
a88897b40e | ||
|
|
8431167a13 | ||
|
|
d87f8d5750 | ||
|
|
1a261cf043 | ||
|
|
9daa933990 | ||
|
|
72f3ba817f | ||
|
|
e8ffdf25f7 | ||
|
|
196abc9a9e | ||
|
|
037529689a | ||
|
|
2502b843f8 | ||
|
|
012e75f8ee | ||
|
|
5ada92430b | ||
|
|
bf27ec5238 | ||
|
|
8f18b35fe8 | ||
|
|
e82b8966b0 | ||
|
|
fad19a1e03 | ||
|
|
553d648e86 | ||
|
|
5588a7343d | ||
|
|
8ec3c52be8 | ||
|
|
e29d7916b8 | ||
|
|
b9840556fc | ||
|
|
3cfb6725e7 | ||
|
|
de794c37f4 | ||
|
|
c9a0747762 | ||
|
|
80dfcdbe29 | ||
|
|
6b05dd6226 | ||
|
|
f1f189b3f4 | ||
|
|
e9f03f7941 | ||
|
|
683a778b15 | ||
|
|
43ccaa6129 | ||
|
|
d2c4608f3f | ||
|
|
d6b2caa4d6 | ||
|
|
78cd4bf86a | ||
|
|
ce8e098a86 | ||
|
|
94494489de | ||
|
|
50ad49cf96 | ||
|
|
6a378b369b | ||
|
|
3f41cdd58e | ||
|
|
ec38cdb154 | ||
|
|
2b89aed724 | ||
|
|
b9334ab7a3 | ||
|
|
0f717c0f22 | ||
|
|
537f95aa7b | ||
|
|
1dee08def7 | ||
|
|
15f6b04daa | ||
|
|
c0f27b8201 | ||
|
|
430181893f | ||
|
|
6636f76cff | ||
|
|
d7b47d080c | ||
|
|
046d8d75bb | ||
|
|
2d1f913648 | ||
|
|
b0522484af | ||
|
|
28ee6b95c9 | ||
|
|
67d85c2025 | ||
|
|
d213040dc4 | ||
|
|
dda4fe45be | ||
|
|
d5c0b1b3d7 | ||
|
|
f968654ffc | ||
|
|
a66753cde9 | ||
|
|
0c85c68948 | ||
|
|
78770912ab | ||
|
|
727dc105f8 | ||
|
|
e63de21702 | ||
|
|
e3bbdd519e | ||
|
|
25cc555189 | ||
|
|
be73d3a337 | ||
|
|
ecb6458a28 | ||
|
|
475d90ea23 | ||
|
|
59171230d4 | ||
|
|
b8b16139bf | ||
|
|
24b725438e | ||
|
|
3d2454ebf2 | ||
|
|
5caef3bc05 | ||
|
|
cc987ab71c | ||
|
|
32d3020659 | ||
|
|
17db11fa72 | ||
|
|
b5305a38cc | ||
|
|
3ec7bb7334 | ||
|
|
ceb07d4f54 | ||
|
|
92e12a57a7 | ||
|
|
373a4d40fa | ||
|
|
568faf7697 | ||
|
|
df6ff1bb7a | ||
|
|
a4a7b1c57f | ||
|
|
a9f8bfd1ff | ||
|
|
e7f687b45d | ||
|
|
666a885ba5 | ||
|
|
25c77d05f4 | ||
|
|
4b26cea3bc | ||
|
|
4aaf486e5e | ||
|
|
9a254ccf2d | ||
|
|
c8aef9dc3a | ||
|
|
f5669de55f | ||
|
|
f08965d9d2 | ||
|
|
817ed771f5 | ||
|
|
da4c41b9b7 | ||
|
|
6f47f5f691 | ||
|
|
204cd32979 | ||
|
|
f83c7d8455 | ||
|
|
5aedf13af1 | ||
|
|
39030e9a3e | ||
|
|
3ca333747f | ||
|
|
6997df3450 | ||
|
|
f939f3ceda | ||
|
|
716728a624 | ||
|
|
f8a01bade3 | ||
|
|
2cb426522d | ||
|
|
573373066d | ||
|
|
feba42f92b | ||
|
|
f373c3fc6c | ||
|
|
e1f86d779e | ||
|
|
c42a34092f | ||
|
|
ced7f924e7 | ||
|
|
93e16284ae | ||
|
|
379036a03d | ||
|
|
6035ce06ef | ||
|
|
0333fd3519 | ||
|
|
20b48326a4 | ||
|
|
199bb78b15 | ||
|
|
7e71b8eacd | ||
|
|
14bcf917a6 | ||
|
|
6ae19df305 | ||
|
|
eedc841ea6 | ||
|
|
98308bb3bd | ||
|
|
8b9de84855 | ||
|
|
496d6f5a6e | ||
|
|
89d1572f9d | ||
|
|
76f591583d | ||
|
|
5d40cc1bc0 | ||
|
|
60a349f2f5 | ||
|
|
439d45d6e1 | ||
|
|
cc48482595 | ||
|
|
a4fb2e20d3 | ||
|
|
ec16d92724 | ||
|
|
34e7720e6f | ||
|
|
938511d5fd | ||
|
|
b473e72c73 | ||
|
|
2816b432d8 | ||
|
|
56f0f7792d | ||
|
|
5236de797b | ||
|
|
fb33298024 | ||
|
|
66c6c8ab0b | ||
|
|
552ef1afe9 | ||
|
|
b35a4efdaa | ||
|
|
7244fc83dc | ||
|
|
1da9573040 | ||
|
|
4e8fc059a4 | ||
|
|
6bb3dd94f7 | ||
|
|
079328cf5a | ||
|
|
4ff8415f71 | ||
|
|
e8d14557d6 | ||
|
|
924f51ecb6 | ||
|
|
f297df7f67 | ||
|
|
ef8d6d08bc | ||
|
|
6ba59a321b | ||
|
|
6e87e94ab3 | ||
|
|
0dbb6890b2 | ||
|
|
2b228272c8 | ||
|
|
b5077bc4fd | ||
|
|
b07315e32d | ||
|
|
3c5bb3e815 | ||
|
|
5755dfdd3a | ||
|
|
d9e66437f4 | ||
|
|
faa75393a2 | ||
|
|
759d749a76 | ||
|
|
34c307c829 | ||
|
|
7b101601f2 | ||
|
|
668aeb2569 | ||
|
|
1f68e225d3 | ||
|
|
c530d06e22 | ||
|
|
9bfc44bdbc | ||
|
|
d11e43f8e0 | ||
|
|
c19a737287 | ||
|
|
2790fd4cd5 | ||
|
|
1b713af42f | ||
|
|
3ac6472682 | ||
|
|
4b8a9d195f | ||
|
|
30c5e9f4c1 | ||
|
|
d0c8f6bab7 | ||
|
|
faf6bffc62 | ||
|
|
ad55fdae37 | ||
|
|
b5f77404cb | ||
|
|
a1d4f26236 | ||
|
|
6c7bae2349 | ||
|
|
d7e48c2894 | ||
|
|
918a7f9501 | ||
|
|
852dac030f | ||
|
|
911cc90b93 | ||
|
|
b9234abff1 | ||
|
|
574bde9831 | ||
|
|
78837a413a | ||
|
|
6f63315f8e | ||
|
|
e8ac97c8cc | ||
|
|
a6fc31787d | ||
|
|
3056d8e23a | ||
|
|
1bf6757a72 | ||
|
|
b948dd5674 | ||
|
|
0e7a8bfe5e | ||
|
|
b86696fcdf | ||
|
|
f334bdbcc7 | ||
|
|
f16433048a | ||
|
|
e83061de36 | ||
|
|
73e0170836 | ||
|
|
078c7287d3 | ||
|
|
a65e691325 | ||
|
|
b306109676 | ||
|
|
035ca03b96 | ||
|
|
b3d092423b | ||
|
|
b768966ec4 | ||
|
|
1b40c1c20a | ||
|
|
98344b8fc2 | ||
|
|
f5480b4770 | ||
|
|
a2929ad364 | ||
|
|
6deee68a00 | ||
|
|
16d190ab84 | ||
|
|
6f2fc7c37b | ||
|
|
1b78407f15 | ||
|
|
05c8fde824 | ||
|
|
d9af6cb301 | ||
|
|
365e71f042 | ||
|
|
e74c606efa | ||
|
|
28f3a92c38 | ||
|
|
be9fb59e6d | ||
|
|
365a7465f4 | ||
|
|
85a66629c8 | ||
|
|
8a2d68fcb5 | ||
|
|
62819e0956 | ||
|
|
5c6880f4ce | ||
|
|
d5f562edf8 | ||
|
|
077d8b3d86 | ||
|
|
21c8131bcf | ||
|
|
18bf41782f | ||
|
|
0513fc51a6 | ||
|
|
528988dc0f | ||
|
|
7f46baeb21 | ||
|
|
608d61ed69 | ||
|
|
2b2b20a0f9 | ||
|
|
77423d1179 | ||
|
|
ebc277dfe4 | ||
|
|
427c8f152d | ||
|
|
1c245d23b4 | ||
|
|
29c4695351 | ||
|
|
7b968f6e0d | ||
|
|
8a38cc24e1 | ||
|
|
a85b9e2d80 | ||
|
|
e0f69327da | ||
|
|
76d4e65d92 | ||
|
|
58917ecbb4 | ||
|
|
e75a102361 | ||
|
|
061f626b2c | ||
|
|
3159550468 | ||
|
|
a20bd0051e | ||
|
|
0b64d1c8df | ||
|
|
fe37600323 |
No files matched your search
+11
-1
@@ -3,7 +3,7 @@ commentConfiguration:
|
||||
epilogue: 'Generated by [CodeMention](https://github.com/expo/expo/actions/workflows/codemention.yaml)'
|
||||
rules:
|
||||
- patterns: ['**/*']
|
||||
mentions: ['szdziedzic', 'khamilowicz', 'sjchmiela', 'radoslawkrzemien']
|
||||
mentions: ['douglowder']
|
||||
- patterns: ['packages/eas-cli/src/update/**']
|
||||
mentions: ['wschurman', 'quinlanj']
|
||||
- patterns: ['packages/eas-cli/src/commands/update/**']
|
||||
@@ -18,3 +18,13 @@ rules:
|
||||
mentions: ['bycedric']
|
||||
- patterns: ['packages/eas-cli/src/commands/metadata/**']
|
||||
mentions: ['bycedric']
|
||||
- patterns: ['packages/eas-cli/src/worker/**']
|
||||
mentions: ['kitten', 'kadikraman']
|
||||
- patterns: ['packages/eas-cli/src/commands/deploy/**']
|
||||
mentions: ['kitten', 'kadikraman']
|
||||
- patterns: ['packages/eas-cli/src/commands/build/**']
|
||||
mentions: ['sjchmiela']
|
||||
- patterns: ['packages/eas-cli/src/build/**']
|
||||
mentions: ['sjchmiela']
|
||||
- patterns: ['packages/eas-cli/src/commands/workflow/**']
|
||||
mentions: ['sjchmiela']
|
||||
@@ -0,0 +1,16 @@
|
||||
name: setup-gcloud
|
||||
|
||||
runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
- name: Auth gcloud
|
||||
uses: google-github-actions/auth@v2
|
||||
with:
|
||||
workload_identity_provider: 'projects/321830142373/locations/global/workloadIdentityPools/github/providers/expo'
|
||||
project_id: exponentjs
|
||||
|
||||
- name: Setup gcloud
|
||||
uses: google-github-actions/setup-gcloud@e427ad8a34f8676edf47cf7d7925499adf3eb74f # v2
|
||||
with:
|
||||
version: 413.0.0
|
||||
project_id: exponentjs
|
||||
@@ -13,28 +13,13 @@ concurrency:
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
pr-changelog-entry:
|
||||
name: Check CHANGELOG.md updated
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Check if CHANGELOG.md was updated
|
||||
id: changelog-updated
|
||||
uses: tj-actions/changed-files@v41
|
||||
with:
|
||||
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:
|
||||
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. Add it manually to CHANGELOG.md.
|
||||
⏩ 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.
|
||||
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4
|
||||
|
||||
- uses: ./.github/internal-actions/setup-gcloud
|
||||
|
||||
- name: Test
|
||||
run: |
|
||||
gsutil cp gs://eas-build-worker-tarballs/worker-android-staging.tar.gz gs://eas-build-worker-tarballs/worker-android-test.tar.gz
|
||||
@@ -0,0 +1,64 @@
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
staging_only:
|
||||
type: boolean
|
||||
required: true
|
||||
default: false
|
||||
description: "Only move the latest-eas-build-staging tag, not the latest-eas-build tag"
|
||||
version:
|
||||
type: string
|
||||
required: true
|
||||
description: "Version number to move the tag to"
|
||||
dry_run:
|
||||
type: boolean
|
||||
required: true
|
||||
default: true
|
||||
description: "Run the workflow in dry run mode, without making any NPM changes"
|
||||
|
||||
name: Move NPM tags used by EAS builds and workflows
|
||||
|
||||
jobs:
|
||||
move-tag:
|
||||
name: Move NPM tags used by EAS builds and workflows
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.EXPO_BOT_PAT }}
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_ACCESS_TOKEN }}
|
||||
INPUT_VERSION: ${{ github.event.inputs.version }}
|
||||
INPUT_DRY_RUN: ${{ github.event.inputs.dry_run }}
|
||||
INPUT_STAGING_ONLY: ${{ github.event.inputs.staging_only }}
|
||||
steps:
|
||||
- uses: actions/setup-node@v2
|
||||
with:
|
||||
registry-url: "https://registry.npmjs.org/"
|
||||
scope: "expo"
|
||||
node-version: 22
|
||||
- name: Resolve version (fails if the version does not exist)
|
||||
run: |
|
||||
set -o pipefail
|
||||
npm pack --dry-run eas-cli@$INPUT_VERSION
|
||||
- name: Add latest-eas-build-staging tag
|
||||
run: |
|
||||
cmd="npm dist-tag add eas-cli@$INPUT_VERSION latest-eas-build-staging"
|
||||
if [[ "$INPUT_DRY_RUN" == "true" ]]; then
|
||||
echo "Running in dry run mode, skipping version check"
|
||||
echo "cmd = $cmd"
|
||||
exit 0
|
||||
fi
|
||||
echo //registry.npmjs.org/:_authToken=$NODE_AUTH_TOKEN > ~/.npmrc
|
||||
$cmd
|
||||
- name: Add latest-eas-build tag
|
||||
run: |
|
||||
cmd="npm dist-tag add eas-cli@$INPUT_VERSION latest-eas-build"
|
||||
if [[ "$INPUT_DRY_RUN" == "true" ]]; then
|
||||
echo "Running in dry run mode, skipping version check"
|
||||
echo "cmd = $cmd"
|
||||
exit 0
|
||||
fi
|
||||
if [[ "$INPUT_STAGING_ONLY" == "true" ]]; then
|
||||
echo "Only moving the staging tag, skipping the production tag"
|
||||
exit 0
|
||||
fi
|
||||
echo //registry.npmjs.org/:_authToken=$NODE_AUTH_TOKEN > ~/.npmrc
|
||||
$cmd
|
||||
@@ -18,6 +18,7 @@ jobs:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
tag_name: ${{ github.ref }}
|
||||
release_name: ${{ github.ref }}
|
||||
draft: true
|
||||
build-linux:
|
||||
name: Build for Linux
|
||||
@@ -27,9 +28,13 @@ jobs:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/setup-node@v2
|
||||
with:
|
||||
node-version: 20
|
||||
node-version: 22
|
||||
- run: yarn install --frozen-lockfile --check-files
|
||||
- run: yarn build
|
||||
- name: Ensure GraphQL schema and generated code is up-to-date
|
||||
working-directory: ${{ github.workspace }}/packages/eas-cli
|
||||
run: |
|
||||
yarn verify-graphql-code
|
||||
- name: Build tarballs
|
||||
working-directory: ${{ github.workspace }}/packages/eas-cli
|
||||
run: |
|
||||
@@ -68,7 +73,7 @@ jobs:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/setup-node@v2
|
||||
with:
|
||||
node-version: 20
|
||||
node-version: 22
|
||||
- run: brew install python-setuptools
|
||||
env:
|
||||
HOMEBREW_NO_AUTO_UPDATE: 1
|
||||
@@ -111,7 +116,7 @@ jobs:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/setup-node@v2
|
||||
with:
|
||||
node-version: 20
|
||||
node-version: 22
|
||||
- run: yarn install --frozen-lockfile --check-files
|
||||
- run: sudo apt-get install nsis
|
||||
- run: yarn build
|
||||
@@ -153,7 +158,7 @@ jobs:
|
||||
with:
|
||||
registry-url: 'https://registry.npmjs.org/'
|
||||
scope: 'expo'
|
||||
node-version: 20
|
||||
node-version: 22
|
||||
- name: Install dependencies
|
||||
run: yarn install --frozen-lockfile --check-files
|
||||
- name: Build
|
||||
@@ -186,7 +191,7 @@ jobs:
|
||||
token: ${{ secrets.EXPO_BOT_PAT }}
|
||||
- uses: actions/setup-node@v2
|
||||
with:
|
||||
node-version: 20
|
||||
node-version: 22
|
||||
- name: Install dependencies
|
||||
working-directory: ./scripts
|
||||
run: yarn install --frozen-lockfile --check-files
|
||||
@@ -206,7 +211,7 @@ jobs:
|
||||
git config --global user.email "[email protected]"
|
||||
git config --global user.name "Expo CI"
|
||||
git add CHANGELOG.md
|
||||
git commit -m "update CHANGELOG.md"
|
||||
git commit --allow-empty -m "update CHANGELOG.md"
|
||||
git push
|
||||
- name: Prepare changelog for Slack
|
||||
run: |
|
||||
@@ -226,16 +231,3 @@ jobs:
|
||||
SLACK_USERNAME: 'EAS CLI CI'
|
||||
SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }}
|
||||
SLACK_FOOTER: ''
|
||||
open-pr-in-universe:
|
||||
name: Open a PR with `@expo/eas-json` bump in universe
|
||||
needs: publish-to-npm
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Open a PR with `@expo/eas-json` version bump in universe
|
||||
uses: benc-uk/workflow-dispatch@v1
|
||||
with:
|
||||
repo: expo/universe
|
||||
ref: main
|
||||
workflow: update-eas-json.yml
|
||||
token: ${{ secrets.EXPO_BOT_PAT }}
|
||||
@@ -0,0 +1,16 @@
|
||||
on:
|
||||
push: {}
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
id-token: write
|
||||
steps:
|
||||
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4
|
||||
|
||||
- uses: ./.github/internal-actions/setup-gcloud
|
||||
|
||||
- name: Test
|
||||
run: |
|
||||
gsutil cp gs://eas-build-worker-tarballs/worker-android-staging.tar.gz gs://eas-build-worker-tarballs/worker-android-test.tar.gz
|
||||
@@ -0,0 +1,27 @@
|
||||
name: Ensure GraphQL schema and generated code is up-to-date
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
schedule:
|
||||
- cron: '0 8 * * *' # 8:00 AM UTC time every day
|
||||
|
||||
concurrency:
|
||||
group: test-graphql-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: Ensure GraphQL schema and generated code is up-to-date
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Setup node
|
||||
uses: actions/setup-node@v2
|
||||
with:
|
||||
node-version: 22
|
||||
- run: yarn install --frozen-lockfile
|
||||
- run: yarn verify-graphql-code
|
||||
working-directory: packages/eas-cli
|
||||
@@ -15,7 +15,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
node: ['18', '20', '22']
|
||||
node: ['20', '22']
|
||||
include:
|
||||
- node: '20'
|
||||
coverage: true
|
||||
@@ -28,6 +28,9 @@ jobs:
|
||||
node-version: ${{ matrix.node }}
|
||||
- run: yarn install --frozen-lockfile --check-files
|
||||
- run: yarn typecheck
|
||||
- run: |
|
||||
git config --global user.email "[email protected]"
|
||||
git config --global user.name "Test"
|
||||
- run: yarn test
|
||||
if: ${{ !matrix.coverage }}
|
||||
env:
|
||||
@@ -49,7 +52,7 @@ jobs:
|
||||
|
||||
notify-slack:
|
||||
runs-on: ubuntu-latest
|
||||
needs: test
|
||||
needs: [test]
|
||||
name: Notify Slack
|
||||
if: ${{ github.ref == 'refs/heads/main' && always() }}
|
||||
steps:
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
type: string
|
||||
required: false
|
||||
description: 'Version number for the release'
|
||||
dry_run:
|
||||
type: boolean
|
||||
required: true
|
||||
default: true
|
||||
description: 'Run the release in dry run mode, without pushing any changes'
|
||||
|
||||
name: Manually trigger a release
|
||||
|
||||
jobs:
|
||||
release:
|
||||
name: Trigger a release
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.EXPO_BOT_PAT }}
|
||||
INPUT_VERSION: ${{ github.event.inputs.version }}
|
||||
INPUT_DRY_RUN: ${{ github.event.inputs.dry_run }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: main
|
||||
fetch-depth: 0
|
||||
token: ${{ secrets.EXPO_BOT_PAT }}
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
- run: |
|
||||
# Lerna will not detect changes correctly without having all tags
|
||||
git fetch --depth=1 origin +refs/tags/*:refs/tags/*
|
||||
# Checkout the main branch
|
||||
git checkout main
|
||||
git config --global user.email "[email protected]"
|
||||
git config --global user.name "Expo CI"
|
||||
yarn install --frozen-lockfile --check-files
|
||||
yarn build
|
||||
if [[ "$INPUT_VERSION" == "" ]]; then
|
||||
echo "Releasing with default version"
|
||||
yarn release
|
||||
else
|
||||
echo "Releasing with version $INPUT_VERSION"
|
||||
yarn release $INPUT_VERSION
|
||||
fi
|
||||
@@ -66,7 +66,7 @@ jobs:
|
||||
destination_branch: 'main'
|
||||
pr_title: 'Upgrade eas-build dependencies'
|
||||
pr_body: ':arrow_up: Upgrades [`eas-build`](https://github.com/expo/eas-build) dependencies.'
|
||||
pr_reviewer: 'szdziedzic,radoslawkrzemien,sjchmiela,khamilowicz'
|
||||
pr_reviewer: 'sjchmiela'
|
||||
github_token: ${{ secrets.EXPO_BOT_PAT }}
|
||||
|
||||
- uses: actions/github-script@v3
|
||||
|
||||
+625
-1
@@ -8,10 +8,634 @@ This is the log of notable changes to EAS CLI and related packages.
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- Sanitize and generate names for EAS Submit to prevent failures due to invalid characters or taken names. ([#2842](https://github.com/expo/eas-cli/pull/2842) by [@evanbacon](https://github.com/evanbacon))
|
||||
### 🐛 Bug fixes
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
## [16.28.0](https://github.com/expo/eas-cli/releases/tag/v16.28.0) - 2025-11-20
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- `eas:new` sorts organization accounts first in account selection. ([#3256](https://github.com/expo/eas-cli/pull/3256) by [@mackenco](https://github.com/mackenco))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- Fix `eas:build` bug where overages are checked before config is ready. ([#3260](https://github.com/expo/eas-cli/pull/3260) by [@mackenco](https://github.com/mackenco))
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
- Delete branch in background. ([#3258](https://github.com/expo/eas-cli/pull/3258) by [@quinlanj](https://github.com/quinlanj))
|
||||
|
||||
## [16.27.0](https://github.com/expo/eas-cli/releases/tag/v16.27.0) - 2025-11-13
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- Kick off update patch generation if enabled - this is feature is in preview, don't use it for prod yet please. ([#3250](https://github.com/expo/eas-cli/pull/3250) by [@quinlanj](https://github.com/quinlanj))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- Fix workflow:status when run ID passed in. ([#3253](https://github.com/expo/eas-cli/pull/3253) by [@douglowder](https://github.com/douglowder))
|
||||
- Fix updating file env vars with `env:update --non-interactive`. ([#3249](https://github.com/expo/eas-cli/pull/3249) by [@kadikraman](https://github.com/kadikraman))
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
- Fix yarn watch. ([#3251](https://github.com/expo/eas-cli/pull/3251) by [@quinlanj](https://github.com/quinlanj))
|
||||
- Remove the **node_modules** installation requirement for `workflow:run` command. ([#3254](https://github.com/expo/eas-cli/pull/3254) by [@kudo](https://github.com/kudo))
|
||||
|
||||
## [16.26.0](https://github.com/expo/eas-cli/releases/tag/v16.26.0) - 2025-10-29
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- New command `workflow:status`. ([#3242](https://github.com/expo/eas-cli/pull/3242) by [@douglowder](https://github.com/douglowder))
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
- `eas:new` cleanup (remove `hidden` tag, fix README error, output cleanup). ([#3243](https://github.com/expo/eas-cli/pull/3243) by [@mackenco](https://github.com/mackenco))
|
||||
|
||||
## [16.25.1](https://github.com/expo/eas-cli/releases/tag/v16.25.1) - 2025-10-28
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
- Remove preview label from EAS Hosting commands ([#3241](https://github.com/expo/eas-cli/pull/3241) by [@kitten](https://github.com/kitten))
|
||||
|
||||
## [16.25.0](https://github.com/expo/eas-cli/releases/tag/v16.25.0) - 2025-10-27
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- `eas:new` command changes (project name prompt, output cleanup, additional project files, additional prompts). ([#3235](https://github.com/expo/eas-cli/pull/3235) by [@mackenco](https://github.com/mackenco))
|
||||
- `eas:build` command displays a warning when account is near it's build limit. ([#3236](https://github.com/expo/eas-cli/pull/3236) by [@mackenco](https://github.com/mackenco))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- workflow:run can use --wait and --json together. ([#3239](https://github.com/expo/eas-cli/pull/3239) by [@douglowder](https://github.com/douglowder))
|
||||
- Add missing env for local builds with latest plugin. ([#3237](https://github.com/expo/eas-cli/pull/3237) by [@douglowder](https://github.com/douglowder))
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
- Document the default values for `track` and `releaseStatus`. ([#3229](https://github.com/expo/eas-cli/pull/3229) by [@kadikraman](https://github.com/kadikraman))
|
||||
|
||||
## [16.24.1](https://github.com/expo/eas-cli/releases/tag/v16.24.1) - 2025-10-19
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- Revert change in ora version. ([#3231](https://github.com/expo/eas-cli/pull/3231) by [@douglowder](https://github.com/douglowder))
|
||||
|
||||
## [16.24.0](https://github.com/expo/eas-cli/releases/tag/v16.24.0) - 2025-10-19
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- workflow:run --wait shows periodic job and step status updates. ([#3227](https://github.com/expo/eas-cli/pull/3227) by [@douglowder](https://github.com/douglowder))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- workflow:logs should show logs from build and repack jobs. ([#3226](https://github.com/expo/eas-cli/pull/3226) by [@douglowder](https://github.com/douglowder))
|
||||
|
||||
## [16.23.1](https://github.com/expo/eas-cli/releases/tag/v16.23.1) - 2025-10-16
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- Multiple bugfixes and small features for the `eas:new` command: handle project name and directory generation more smoothly; support `bun` as a package manager; add `--package-manager` flag with a default to `npm`; ensure workflow files are in the correct location after build; update the generated project's README with the provided package manager; ensure `app.json`'s `scheme` is valid; silence lots of noisy output. ([#3205](https://github.com/expo/eas-cli/pull/3205) by [@mackenco](https://github.com/mackenco))
|
||||
|
||||
## [16.23.0](https://github.com/expo/eas-cli/releases/tag/v16.23.0) - 2025-10-14
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- Support custom environment variable environments ([#3220](https://github.com/expo/eas-cli/pull/3220) by [@kadikraman](https://github.com/kadikraman))
|
||||
- Add --force flag to eas env:push command to bypass confirmation prompts. ([#3135](https://github.com/expo/eas-cli/pull/3135) by [@giorgiofellipe](https://github.com/giorgiofellipe))
|
||||
|
||||
## [16.22.0](https://github.com/expo/eas-cli/releases/tag/v16.22.0) - 2025-10-13
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- Improvements to workflow:create. ([#3194](https://github.com/expo/eas-cli/pull/3194) by [@douglowder](https://github.com/douglowder))
|
||||
|
||||
## [16.21.0](https://github.com/expo/eas-cli/releases/tag/v16.21.0) - 2025-10-13
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- Support `_expo/.routes.json` metadata file exported by `@expo/cli` for headers configuration on static deployments ([#3211](https://github.com/expo/eas-cli/pull/3211) by [@kitten](https://github.com/kitten))
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
- Add thorough tests for the env commands ([#3217](https://github.com/expo/eas-cli/pull/3217) by [@kadikraman](https://github.com/kadikraman))
|
||||
|
||||
## [16.20.4](https://github.com/expo/eas-cli/releases/tag/v16.20.4) - 2025-10-08
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- Allow workflow:run to run a workflow from a GitHub ref. ([#3203](https://github.com/expo/eas-cli/pull/3203) by [@douglowder](https://github.com/douglowder))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- Fix --environment flag in eas exec and pull ([#3216](https://github.com/expo/eas-cli/pull/3216) by [@kadikraman](https://github.com/kadikraman))
|
||||
|
||||
## [16.20.3](https://github.com/expo/eas-cli/releases/tag/v16.20.3) - 2025-10-07
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- Fix --environment flag in eas env commands ([#3213](https://github.com/expo/eas-cli/pull/3213) by [@kadikraman](https://github.com/kadikraman))
|
||||
|
||||
## [16.20.2](https://github.com/expo/eas-cli/releases/tag/v16.20.2) - 2025-10-07
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
- Create an `EnvironmentVariableEnvironment` enum and use it instead of the graphQL Enum ([#3208](https://github.com/expo/eas-cli/pull/3208) by [@kadikraman](https://github.com/kadikraman))
|
||||
|
||||
## [16.20.1](https://github.com/expo/eas-cli/releases/tag/v16.20.1) - 2025-10-03
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- skip syncing capabilities with settings if they don't contain the `enabled` attribute ([#3201](https://github.com/expo/eas-cli/pull/3201) by [@vonovak](https://github.com/vonovak))
|
||||
|
||||
## [16.20.0](https://github.com/expo/eas-cli/releases/tag/v16.20.0) - 2025-09-30
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- Improvements to workflow:validate. ([#3165](https://github.com/expo/eas-cli/pull/3165) by [@douglowder](https://github.com/douglowder))
|
||||
- Add `eas:new` command behind `hidden` flag. ([#3196](https://github.com/expo/eas-cli/pull/3196) by [@mackenco](https://github.com/mackenco))
|
||||
|
||||
## [16.19.3](https://github.com/expo/eas-cli/releases/tag/v16.19.3) - 2025-09-17
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
- Temporarily remove warnings when no compatible build found, need to refine the message. ([#3182](https://github.com/expo/eas-cli/pull/3182) by [@brentvatne](https://github.com/brentvatne))
|
||||
|
||||
## [16.19.2](https://github.com/expo/eas-cli/releases/tag/v16.19.2) - 2025-09-15
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- Add artifact URLs to workflow:view. ([#3175](https://github.com/expo/eas-cli/pull/3175) by [@douglowder](https://github.com/douglowder))
|
||||
|
||||
## [16.19.1](https://github.com/expo/eas-cli/releases/tag/v16.19.1) - 2025-09-12
|
||||
|
||||
## [16.19.0](https://github.com/expo/eas-cli/releases/tag/v16.19.0) - 2025-09-12
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- Allow any string as Android release `track`, not just the `production`, `internal`, `alpha`, `beta`. ([#3163](https://github.com/expo/eas-cli/pull/3163) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
|
||||
## [16.18.1](https://github.com/expo/eas-cli/releases/tag/v16.18.1) - 2025-09-10
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- CLI debug log should respect DEBUG=`eas:*`. ([#3162](https://github.com/expo/eas-cli/pull/3162) by [@douglowder](https://github.com/douglowder))
|
||||
- Better description for update:rollback. ([#3154](https://github.com/expo/eas-cli/pull/3154) by [@douglowder](https://github.com/douglowder))
|
||||
|
||||
## [16.18.0](https://github.com/expo/eas-cli/releases/tag/v16.18.0) - 2025-08-27
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- Show fingerprints in build:view and build:list commands. ([#3137](https://github.com/expo/eas-cli/pull/3137) by [@douglowder](https://github.com/douglowder))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- Make `EXPO_PUBLIC_` env vars plain text, rest sensitive ([#3121](https://github.com/expo/eas-cli/pull/3121) by [@kadikraman](https://github.com/kadikraman))
|
||||
|
||||
## [16.17.4](https://github.com/expo/eas-cli/releases/tag/v16.17.4) - 2025-07-31
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
- Introduce new release process and workflow. ([#3119](https://github.com/expo/eas-cli/pull/3119) by [@douglowder](https://github.com/douglowder))
|
||||
- SSO Login: print browser url ([#3113](https://github.com/expo/eas-cli/pull/3113) by [@BackSlasher](https://github.com/BackSlasher))
|
||||
|
||||
## [16.17.2](https://github.com/expo/eas-cli/releases/tag/v16.17.2) - 2025-07-23
|
||||
|
||||
## [16.17.1](https://github.com/expo/eas-cli/releases/tag/v16.17.1) - 2025-07-23
|
||||
|
||||
## [16.17.0](https://github.com/expo/eas-cli/releases/tag/v16.17.0) - 2025-07-18
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- Add `deploy:delete` for deleting worker deployments ([#3106](https://github.com/expo/eas-cli/pull/3106) by [@kadikraman](https://github.com/kadikraman))
|
||||
- Add `deploy:alias:delete` for deleting worker deployment aliases ([#3107](https://github.com/expo/eas-cli/pull/3107) by [@kadikraman](https://github.com/kadikraman))
|
||||
|
||||
## [16.16.0](https://github.com/expo/eas-cli/releases/tag/v16.16.0) - 2025-07-16
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- Add workflow:view and workflow:logs commands. ([#3090](https://github.com/expo/eas-cli/pull/3090) by [@douglowder](https://github.com/douglowder))
|
||||
- Add support for providing inputs to new workflow runs ([#3095](https://github.com/expo/eas-cli/pull/3095) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- Refactor capabilities syncing to avoid provisioning profiles becoming invalid. ([#3088](https://github.com/expo/eas-cli/pull/3088) by [@vonovak](https://github.com/vonovak))
|
||||
- Avoid corrupted EAS Hosting batch upload bodies and double-check length and checksums while uploading. ([#3103](https://github.com/expo/eas-cli/pull/3103) by [@kitten](https://github.com/kitten))
|
||||
|
||||
## [16.15.0](https://github.com/expo/eas-cli/releases/tag/v16.15.0) - 2025-07-10
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- Implement new EAS Hosting asset upload protocol, which improves performance by grouping files into batched upload API calls ([#3085](https://github.com/expo/eas-cli/pull/3085) by [@kitten](https://github.com/kitten))
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
- Update the EAS Hosting worker deployment progress indicator to use a progress bar for asset uploads instead ([#3099](https://github.com/expo/eas-cli/pull/3099) by [@kitten](https://github.com/kitten))
|
||||
|
||||
## [16.14.1](https://github.com/expo/eas-cli/releases/tag/v16.14.1) - 2025-07-08
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
- Update apple team info as needed. ([#3096](https://github.com/expo/eas-cli/pull/3096) by [@quinlanj](https://github.com/quinlanj))
|
||||
|
||||
## [16.14.0](https://github.com/expo/eas-cli/releases/tag/v16.14.0) - 2025-07-07
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- Add update:revert-update-rollout command. ([#3068](https://github.com/expo/eas-cli/pull/3068) by [@wschurman](https://github.com/wschurman))
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
- Remove unreleased `link` and `unlink` commands from `eas env`. ([#3092](https://github.com/expo/eas-cli/pull/3092) by [@kadikraman](https://github.com/kadikraman))
|
||||
- Add new CI to verify GraphQL generation. ([#3093](https://github.com/expo/eas-cli/pull/3093) by [@douglowder](https://github.com/douglowder))
|
||||
- Update GraphQL generation to look in all src directories. ([#3097](https://github.com/expo/eas-cli/pull/3097) by [@wschurman](https://github.com/wschurman))
|
||||
|
||||
## [16.13.4](https://github.com/expo/eas-cli/releases/tag/v16.13.4) - 2025-07-04
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
- Remove hidden flag from `eas upload`. ([#3014](https://github.com/expo/eas-cli/pull/3014) by [@gabrieldonadel](https://github.com/gabrieldonadel))
|
||||
|
||||
## [16.13.3](https://github.com/expo/eas-cli/releases/tag/v16.13.3) - 2025-07-01
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- Fix suggested command to switch Xcode Developer Tools. ([#3079](https://github.com/expo/eas-cli/pull/3079) by [@rolfb](https://github.com/rolfb))
|
||||
- Skip configuring build profiles when running `eas update:configure` if no build profiles exist. ([#3084](https://github.com/expo/eas-cli/pull/3084) by [@brentvatne](https://github.com/brentvatne))
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
- Reword how paid plans are described ([#3082](https://github.com/expo/eas-cli/pull/3082) by [@ide](https://github.com/ide))
|
||||
|
||||
## [16.13.2](https://github.com/expo/eas-cli/releases/tag/v16.13.2) - 2025-06-27
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
- Amend update override fields. ([#3080](https://github.com/expo/eas-cli/pull/3080) by [@quinlanj](https://github.com/quinlanj))
|
||||
|
||||
## [16.13.1](https://github.com/expo/eas-cli/releases/tag/v16.13.1) - 2025-06-25
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- Fix bug in log during update:republish. ([#3067](https://github.com/expo/eas-cli/pull/3067) by [@wschurman](https://github.com/wschurman))
|
||||
- Fix update group deletion. ([#3069](https://github.com/expo/eas-cli/pull/3069) by [@wschurman](https://github.com/wschurman))
|
||||
|
||||
## [16.13.0](https://github.com/expo/eas-cli/releases/tag/v16.13.0) - 2025-06-24
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- Add `--what-to-test` option to `build` command. ([#3065](https://github.com/expo/eas-cli/pull/3065) by [@vonovak](https://github.com/vonovak))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- Hide `workflow:list`. ([#3060](https://github.com/expo/eas-cli/pull/3060) by [@douglowder](https://github.com/douglowder))
|
||||
- Align text of messages from `build` and `workflow:run`. ([#3061](https://github.com/expo/eas-cli/pull/3061) by [@douglowder](https://github.com/douglowder))
|
||||
|
||||
## [16.12.0](https://github.com/expo/eas-cli/releases/tag/v16.12.0) - 2025-06-20
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- Add `--what-to-test` option to the `submit` command. ([#3023](https://github.com/expo/eas-cli/pull/3023) by [@vonovak](https://github.com/vonovak))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- Fixed monorepo support in `workflow:run` if a project is not connected to a GitHub repository. ([#3058](https://github.com/expo/eas-cli/pull/3058) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
|
||||
## [16.10.1](https://github.com/expo/eas-cli/releases/tag/v16.10.1) - 2025-06-13
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- Fixed `eas build:dev` command to correctly use passed build profile. ([#3053](https://github.com/expo/eas-cli/pull/3053) by [@sebryu](https://github.com/sebryu))
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
- Allow rolling out update on empty branch. ([#3050](https://github.com/expo/eas-cli/pull/3050) by [@wschurman](https://github.com/wschurman))
|
||||
|
||||
## [16.10.0](https://github.com/expo/eas-cli/releases/tag/v16.10.0) - 2025-06-12
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- [eas-cli] Add workflow:cancel. ([#3048](https://github.com/expo/eas-cli/pull/3048) by [@douglowder](https://github.com/douglowder))
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
- Removed repack support. ([#3044](https://github.com/expo/eas-cli/pull/3044) by [@kudo](https://github.com/kudo))
|
||||
|
||||
## [16.9.0](https://github.com/expo/eas-cli/releases/tag/v16.9.0) - 2025-06-06
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- Add `groups` option to the `submit` command. ([#2891](https://github.com/expo/eas-cli/pull/2891) by [@khamilowicz](https://github.com/khamilowicz))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- [eas-cli] Ensure workflow:runs returns most recent runs. ([#3045](https://github.com/expo/eas-cli/pull/3045) by [@douglowder](https://github.com/douglowder))
|
||||
|
||||
## [16.8.0](https://github.com/expo/eas-cli/releases/tag/v16.8.0) - 2025-06-04
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- [eas-cli] Add workflow:list and workflow:runs. ([#3030](https://github.com/expo/eas-cli/pull/3030) by [@douglowder](https://github.com/douglowder))
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
- Change location of EAS Update host override config. ([#3042](https://github.com/expo/eas-cli/pull/3042) by [@wschurman](https://github.com/wschurman))
|
||||
|
||||
## [16.7.2](https://github.com/expo/eas-cli/releases/tag/v16.7.2) - 2025-06-03
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- Add support for array `flavorDimensions` (`flavorDimensions = ['version']`). ([#3043](https://github.com/expo/eas-cli/pull/3043) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
|
||||
## [16.7.1](https://github.com/expo/eas-cli/releases/tag/v16.7.1) - 2025-06-03
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- Fix some unit tests by updating mocks. ([#3035](https://github.com/expo/eas-cli/pull/3035) by [@douglowder](https://github.com/douglowder))
|
||||
- Fix update republishing not including environment or git info. ([#3036](https://github.com/expo/eas-cli/pull/3036) by [@wschurman](https://github.com/wschurman))
|
||||
- Do not require `eas.json` or `package.json` files when creating new workflow runs. ([#3028](https://github.com/expo/eas-cli/pull/3028) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
- Add `EAS_SKIP_CLI_VERSION_CHECK` allowing us to skip `eas-cli` version check (against `eas.json#version`). ([#3041](https://github.com/expo/eas-cli/pull/3041) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
|
||||
## [16.7.0](https://github.com/expo/eas-cli/releases/tag/v16.7.0) - 2025-05-29
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- Add support for manifest host and asset host overriding for EAS Update. ([#3021](https://github.com/expo/eas-cli/pull/3021) by [@wschurman](https://github.com/wschurman))
|
||||
- Add rollout flag to update:republish. ([#3029](https://github.com/expo/eas-cli/pull/3029) by [@quinlanj](https://github.com/quinlanj))
|
||||
|
||||
## [16.6.2](https://github.com/expo/eas-cli/releases/tag/v16.6.2) - 2025-05-23
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
- Further no longer require owner field for SDK >= 53 or canary. ([#3017](https://github.com/expo/eas-cli/pull/3017) by [@wschurman](https://github.com/wschurman))
|
||||
|
||||
## [16.6.1](https://github.com/expo/eas-cli/releases/tag/v16.6.1) - 2025-05-14
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- Onboarding: add cwd to spawned git process. ([#3015](https://github.com/expo/eas-cli/pull/3015) by [@quinlanj](https://github.com/quinlanj))
|
||||
|
||||
## [16.6.0](https://github.com/expo/eas-cli/releases/tag/v16.6.0) - 2025-05-14
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- Add support for uploading iOS internal distribution local builds. ([#3014](https://github.com/expo/eas-cli/pull/3014) by [@gabrieldonadel](https://github.com/gabrieldonadel))
|
||||
|
||||
## [16.5.0](https://github.com/expo/eas-cli/releases/tag/v16.5.0) - 2025-05-13
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- Add `--wait`/`--no-wait` flag to `eas workflow:run`. ([#3012](https://github.com/expo/eas-cli/pull/3012) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
- Add `--json` flag to `eas workflow:run`. ([#3013](https://github.com/expo/eas-cli/pull/3013) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
|
||||
## [16.4.2](https://github.com/expo/eas-cli/releases/tag/v16.4.2) - 2025-05-10
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
- Update `eas-build-cache-provider` function names to use `buildCacheProvider` key. ([#3002](https://github.com/expo/eas-cli/pull/3002) by [@gabrieldonadel](https://github.com/gabrieldonadel))
|
||||
|
||||
## [16.4.1](https://github.com/expo/eas-cli/releases/tag/v16.4.1) - 2025-05-07
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
- Allow non-standard build types in custom gradle command. ([#3003](https://github.com/expo/eas-cli/pull/3003) by [@khamilowicz](https://github.com/khamilowicz))
|
||||
|
||||
## [16.4.0](https://github.com/expo/eas-cli/releases/tag/v16.4.0) - 2025-05-02
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- Add `--verbose-logs` flag for `build` command ([#3000](https://github.com/expo/eas-cli/pull/3000) by [@khamilowicz](https://github.com/khamilowicz))
|
||||
- Add `eas-build-cache-provider` package. ([#3002](https://github.com/expo/eas-cli/pull/3002) by [@gabrieldonadel](https://github.com/gabrieldonadel))
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
- Read remote build cache provider types from `@expo/config`. ([#3005](https://github.com/expo/eas-cli/pull/3005) by [@gabrieldonadel](https://github.com/gabrieldonadel))
|
||||
|
||||
## [16.3.3](https://github.com/expo/eas-cli/releases/tag/v16.3.3) - 2025-04-24
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- Add `sdk-52` alias to the list or supported android images. ([#2989](https://github.com/expo/eas-cli/pull/2989) by [@kadikraman](https://github.com/kadikraman))
|
||||
- Detect when expo metro config is used. ([#2996](https://github.com/expo/eas-cli/pull/2996) by [@kadikraman](https://github.com/kadikraman))
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
- upload assetmap.json when publishing update. ([#2994](https://github.com/expo/eas-cli/pull/2994) by [@quinlanj](https://github.com/quinlanj))
|
||||
- Add error messages for CDN-level upload errors. ([#2998](https://github.com/expo/eas-cli/pull/2998) by [@kitten](https://github.com/kitten))
|
||||
|
||||
## [16.3.2](https://github.com/expo/eas-cli/releases/tag/v16.3.2) - 2025-04-17
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
- Update upload command to display build info. ([#2990](https://github.com/expo/eas-cli/pull/2990) by [@gabrieldonadel](https://github.com/gabrieldonadel))
|
||||
- change launch asset file extension. ([#2991](https://github.com/expo/eas-cli/pull/2991) by [@quinlanj](https://github.com/quinlanj))
|
||||
|
||||
## [16.3.1](https://github.com/expo/eas-cli/releases/tag/v16.3.1) - 2025-04-11
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
- Add `--no-dev-client` flag for `build:download` command. ([#2985](https://github.com/expo/eas-cli/pull/2985) by [@gabrieldonadel](https://github.com/gabrieldonadel))
|
||||
|
||||
## [16.3.0](https://github.com/expo/eas-cli/releases/tag/v16.3.0) - 2025-04-09
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- Add `eas upload` command. ([#2932](https://github.com/expo/eas-cli/pull/2932), [#2981](https://github.com/expo/eas-cli/pull/2981), [#2983](https://github.com/expo/eas-cli/pull/2983) by [@gabrieldonadel](https://github.com/gabrieldonadel))
|
||||
- Add `eas build:download` command. ([#2982](https://github.com/expo/eas-cli/pull/2982) by [@gabrieldonadel](https://github.com/gabrieldonadel))
|
||||
|
||||
## [16.2.2](https://github.com/expo/eas-cli/releases/tag/v16.2.2) - 2025-04-08
|
||||
|
||||
## [16.2.1](https://github.com/expo/eas-cli/releases/tag/v16.2.1) - 2025-04-04
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
- Bump `@expo/apple-utils` to use async JWT API. ([#2973](https://github.com/expo/eas-cli/pull/2973) by [@EvanBacon](https://github.com/EvanBacon))
|
||||
|
||||
## [16.2.0](https://github.com/expo/eas-cli/releases/tag/v16.2.0) - 2025-04-03
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- Add environment flag to `eas fingerprint:compare`. ([#2954](https://github.com/expo/eas-cli/pull/2954) by [@quinlanj](https://github.com/quinlanj))
|
||||
- Add build-profile flag to `eas fingerprint:generate`. ([#2966](https://github.com/expo/eas-cli/pull/2966) by [@quinlanj](https://github.com/quinlanj))
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
- Remove hidden flag from `eas fingerprint:generate`. ([#2965](https://github.com/expo/eas-cli/pull/2965) by [@quinlanj](https://github.com/quinlanj))
|
||||
- Refactor `eas update` command to improve code readability. ([#2976](https://github.com/expo/eas-cli/pull/2976) by [@quinlanj](https://github.com/quinlanj))
|
||||
- `eas update`: add warning if no build exists with fingerprint. ([#2977](https://github.com/expo/eas-cli/pull/2977) by [@quinlanj](https://github.com/quinlanj))
|
||||
|
||||
## [16.1.0](https://github.com/expo/eas-cli/releases/tag/v16.1.0) - 2025-03-26
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- Add environment flag to `eas fingerprint:generate`. ([#2951](https://github.com/expo/eas-cli/pull/2951) by [@quinlanj](https://github.com/quinlanj))
|
||||
- Add `corepack` field to `eas.json`. ([#2964](https://github.com/expo/eas-cli/pull/2964) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
|
||||
## [16.0.1](https://github.com/expo/eas-cli/releases/tag/v16.0.1) - 2025-03-20
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- Make `eas update:configure` re-apply configuration from app.json /app.config.js when run multiple times. ([#2957](https://github.com/expo/eas-cli/pull/2957) by [@brentvatne](https://github.com/brentvatne))
|
||||
|
||||
## [16.0.0](https://github.com/expo/eas-cli/releases/tag/v16.0.0) - 2025-03-19
|
||||
|
||||
### 🛠 Breaking changes
|
||||
|
||||
- Add support for `.easignore` when `requireCommit` is set to `true`. ([#2942](https://github.com/expo/eas-cli/pull/2942) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
- Up to 15.0.0, if `requireCommit` was `true`, `.easignore` was silently ignored.
|
||||
- Versions 15.0.0-15.0.13 started using `.easignore` to skip files from being bundled into a tarball when `requireCommit` was `true`. This was an unexpected change in behavior.
|
||||
- To clear this up, versions 15.0.13-15.0.15 were erroring if `.easignore` was present when `requireCommit` was `true`.
|
||||
- `[email protected]` formalizes the 15.0.0-15.0.13 behavior by adhering to `.easignore` even when `requireCommit` is set to `true`.
|
||||
- If you know what you're doing and you want to suppress a warning printed, you can do so by setting `EAS_SUPPRESS_REQUIRE_COMMIT_EASIGNORE_WARNING` environment variable to `true`.
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- Add requestId to ApiV2Error. ([#2941](https://github.com/expo/eas-cli/pull/2941) by [@wschurman](https://github.com/wschurman))
|
||||
- Release `eas fingerprint:generate` in hidden mode ([#2937](https://github.com/expo/eas-cli/pull/2937) by [@quinlanj](https://github.com/quinlanj))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- Use correct logic to determine whether artifacts have expired in `eas build:run` command. ([#2931](https://github.com/expo/eas-cli/pull/2931) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
|
||||
## [15.0.15](https://github.com/expo/eas-cli/releases/tag/v15.0.15) - 2025-03-12
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- Pass through the updates version to `setUpdatesConfigAsync`, which expects it in order to determine which field values to use. ([#2934](https://github.com/expo/eas-cli/pull/2934) by [@brentvatne](https://github.com/brentvatne)).
|
||||
|
||||
## [15.0.14](https://github.com/expo/eas-cli/releases/tag/v15.0.14) - 2025-03-06
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- In `EAS_NO_VCS=1`, use Git for repository root when `EAS_PROJECT_ROOT` is not set. ([#2901](https://github.com/expo/eas-cli/pull/2901) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
- Suggest using `eas build:dev` for matching configurations. ([#2929](https://github.com/expo/eas-cli/pull/2929) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
|
||||
## [15.0.13](https://github.com/expo/eas-cli/releases/tag/v15.0.13) - 2025-03-04
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- Fixed `.git` being always unexpectedly removed if you had `requireCommit: true` and `.easignore` present. ([#2925](https://github.com/expo/eas-cli/pull/2925) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
- Fix `eas fingerprint:compare` URL generation and pretty prints. ([#2909](https://github.com/expo/eas-cli/pull/2909) by [@quinlanj](https://github.com/quinlanj))
|
||||
- fix formatFields to handle empty array. ([#2914](https://github.com/expo/eas-cli/pull/2914) by [@quinlanj](https://github.com/quinlanj))
|
||||
- fix git diff header in `fingerprint:compare`. ([#2915](https://github.com/expo/eas-cli/pull/2915) by [@quinlanj](https://github.com/quinlanj))
|
||||
- amend fingerprint compare url. ([#2923](https://github.com/expo/eas-cli/pull/2923) by [@quinlanj](https://github.com/quinlanj))
|
||||
- improve error message for `applicationIdSuffix`. ([#2924](https://github.com/expo/eas-cli/pull/2924) by [@kadikraman](https://github.com/kadikraman))
|
||||
|
||||
## [15.0.12](https://github.com/expo/eas-cli/releases/tag/v15.0.12) - 2025-02-22
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- Fix APNS key creation. ([#2916](https://github.com/expo/eas-cli/pull/2916) by [@EvanBacon](https://github.com/EvanBacon))
|
||||
|
||||
## [15.0.11](https://github.com/expo/eas-cli/releases/tag/v15.0.11) - 2025-02-21
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- Fix internal TestFlight group creation. ([#2906](https://github.com/expo/eas-cli/pull/2906) by [@EvanBacon](https://github.com/EvanBacon))
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
- Narrow amount of data queried for basic update channel operations. ([#2901](https://github.com/expo/eas-cli/pull/2901) by [@wschurman](https://github.com/wschurman))
|
||||
- Fix `eas fingerprint:compare` description. ([#2908](https://github.com/expo/eas-cli/pull/2908) by [@quinlanj](https://github.com/quinlanj))
|
||||
- Skip auto-creation of TestFlight group when there are already exisitng TestFlight groups and allow to opt out of the behavior by setting `EAS_NO_AUTO_TESTFLIGHT_SETUP` env var. ([#2856](https://github.com/expo/eas-cli/pull/2856) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
|
||||
## [15.0.10](https://github.com/expo/eas-cli/releases/tag/v15.0.10) - 2025-02-11
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- Fix files deleted in working directory not being removed from the project archive when `requireCommit` is false. ([#2900](https://github.com/expo/eas-cli/pull/2900) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
|
||||
## [15.0.9](https://github.com/expo/eas-cli/releases/tag/v15.0.9) - 2025-02-09
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- Fix files not being ignored when creating a tarball on Windows in Git repository in no `requireCommit` mode. ([#2894](https://github.com/expo/eas-cli/pull/2894) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
|
||||
## [15.0.8](https://github.com/expo/eas-cli/releases/tag/v15.0.8) - 2025-02-09
|
||||
|
||||
## [15.0.7](https://github.com/expo/eas-cli/releases/tag/v15.0.7) - 2025-02-09
|
||||
|
||||
## [15.0.6](https://github.com/expo/eas-cli/releases/tag/v15.0.6) - 2025-02-07
|
||||
|
||||
## [15.0.5](https://github.com/expo/eas-cli/releases/tag/v15.0.5) - 2025-02-06
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- Do not copy files over onto a cloned Git repository when packing the project archive if `requireCommit` is true. ([#2885](https://github.com/expo/eas-cli/pull/2885) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
- Fix `EISDIR` error when archiving project with submodules ignored. ([#2884](https://github.com/expo/eas-cli/pull/2884) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
|
||||
## [15.0.4](https://github.com/expo/eas-cli/releases/tag/v15.0.4) - 2025-02-05
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- Fixed `GitClient` not respecting `.easignore` file. ([#2873](https://github.com/expo/eas-cli/pull/2873) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
- Fix symlink support in `makeShallowCopyAsync`. ([#2874](https://github.com/expo/eas-cli/pull/2874) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
- Allow excluding `.git` directory from project archive by adding it to `.easignore`. ([#2879](https://github.com/expo/eas-cli/pull/2879) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
- Popup website in fingerprint:compare. ([#2859](https://github.com/expo/eas-cli/pull/2859) by [@quinlanj](https://github.com/quinlanj))
|
||||
- Fix fingerprint:compare URL. ([#2861](https://github.com/expo/eas-cli/pull/2861) by [@quinlanj](https://github.com/quinlanj))
|
||||
- Make less gql calls in fingerprint:compare. ([#2860](https://github.com/expo/eas-cli/pull/2860) by [@quinlanj](https://github.com/quinlanj))
|
||||
- No longer require owner field for SDK >= 53 or canary. ([#2835](https://github.com/expo/eas-cli/pull/2835) by [@wschurman](https://github.com/wschurman))
|
||||
- Add --open flag to fingerprint:compare. ([#2872](https://github.com/expo/eas-cli/pull/2872) by [@quinlanj](https://github.com/quinlanj))
|
||||
|
||||
## [15.0.3](https://github.com/expo/eas-cli/releases/tag/v15.0.3) - 2025-02-04
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- Fixed EAS server environment variables does not pass to `npx expo-updates runtimeversion:resolve` call. ([#2867](https://github.com/expo/eas-cli/pull/2867) by [@kudo](https://github.com/kudo))
|
||||
|
||||
## [15.0.2](https://github.com/expo/eas-cli/releases/tag/v15.0.2) - 2025-02-04
|
||||
|
||||
## [15.0.1](https://github.com/expo/eas-cli/releases/tag/v15.0.1) - 2025-02-04
|
||||
|
||||
## [15.0.0](https://github.com/expo/eas-cli/releases/tag/v15.0.0) - 2025-02-04
|
||||
|
||||
### 🛠 Breaking changes
|
||||
|
||||
- Use Git to archive projects containing a Git repository. (Previously, Git would only be used if `requireCommit` flag in `eas.json` was set to `true`.) ([#2841](https://github.com/expo/eas-cli/pull/2841) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- Print warning for `NoVcsClient` only once. ([#2863](https://github.com/expo/eas-cli/pull/2863) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
- Add update support for fingerprint:compare. ([#2850](https://github.com/expo/eas-cli/pull/2850) by [@quinlanj](https://github.com/quinlanj))
|
||||
- Add update group id support for fingerprint:compare. ([#2851](https://github.com/expo/eas-cli/pull/2851) by [@quinlanj](https://github.com/quinlanj))
|
||||
- Add better interactive support for fingerprint:compare. ([#2854](https://github.com/expo/eas-cli/pull/2854) by [@quinlanj](https://github.com/quinlanj))
|
||||
|
||||
## [14.7.1](https://github.com/expo/eas-cli/releases/tag/v14.7.1) - 2025-01-31
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- Account for `ios.config.usesNonExemptEncryption` in non-exempt encryption status prompt. ([#2852](https://github.com/expo/eas-cli/pull/2852) by [@EvanBacon](https://github.com/EvanBacon))
|
||||
|
||||
## [14.7.0](https://github.com/expo/eas-cli/releases/tag/v14.7.0) - 2025-01-30
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- Add `--submit` and `-s` as aliases for `--auto-submit` flag. ([#2846](https://github.com/expo/eas-cli/pull/2846) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- Skip non-exempt check in non-interactive mode. ([#2849](https://github.com/expo/eas-cli/pull/2849) by [@EvanBacon](https://github.com/EvanBacon))
|
||||
|
||||
## [14.6.0](https://github.com/expo/eas-cli/releases/tag/v14.6.0) - 2025-01-30
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- Prompt to set non-exempt encryption status for the iOS app to support faster store submissions. ([#2843](https://github.com/expo/eas-cli/pull/2843) by [@EvanBacon](https://github.com/EvanBacon))
|
||||
- Automatically create internal TestFlight group in EAS Submit command. ([#2839](https://github.com/expo/eas-cli/pull/2839) by [@evanbacon](https://github.com/evanbacon))
|
||||
- Sanitize and generate names for EAS Submit to prevent failures due to invalid characters or taken names. ([#2842](https://github.com/expo/eas-cli/pull/2842) by [@evanbacon](https://github.com/evanbacon))
|
||||
- Release `eas fingerprint:compare`. ([#2821](https://github.com/expo/eas-cli/pull/2821) by [@quinlanj](https://github.com/quinlanj))
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
- Make new autoIncremented builds start at nr 1 by default ([#2828](https://github.com/expo/eas-cli/pull/2828) by [@radoslawkrzemien](https://github.com/radoslawkrzemien))
|
||||
|
||||
+14
-5
@@ -1,12 +1,21 @@
|
||||
# Development
|
||||
|
||||
Set up an alias for the EAS CLI so you can try it in projects all around your computer. The project is compiled on the fly so you don't need to run a build (watch) command.
|
||||
## Getting Started
|
||||
|
||||
> The only exception is when you just cloned the repository or there have been changes in `packages/eas-json`. In that case, you'll have to run `yarn build` in the root.
|
||||
If this is your first time running `eas-cli` locally, make sure you install and build the packages. In the project root, run:
|
||||
|
||||
```sh
|
||||
yarn install
|
||||
yarn build
|
||||
```
|
||||
|
||||
From there, set up an alias for the EAS CLI so you can try it in projects all around your computer. The project is compiled on the fly so you don't need to run a build (watch) command.
|
||||
|
||||
> The only exception is if there have been changes in `packages/eas-json`. In that case, you'll have to run `yarn build` in the root.
|
||||
|
||||
Open your `.zshrc` or other config file and add:
|
||||
|
||||
```
|
||||
```sh
|
||||
alias easd="/PATH/TO/eas-cli/bin/run"
|
||||
```
|
||||
|
||||
@@ -14,7 +23,7 @@ Then use it with `easd` like `easd build`.
|
||||
|
||||
If you're making changes to `packages/eas-json` or prefer/need to work with production code, start the build command in watch mode with:
|
||||
|
||||
```
|
||||
```sh
|
||||
yarn start
|
||||
```
|
||||
|
||||
@@ -33,7 +42,7 @@ If it is easier for you to work with the code locally with compiler options set
|
||||
|
||||
you can consider using:
|
||||
|
||||
```
|
||||
```sh
|
||||
yarn start-allow-unused
|
||||
```
|
||||
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
# Releasing EAS CLI
|
||||
|
||||
1. Run `yarn release` in the repository root folder. The next version is chosen automatically based on the changelog entries. If you want to use different version, pass the version string as a positional argument to the command, e.g. `yarn release 1.2.3`.
|
||||
1. Invoke the GitHub workflow ["Manually trigger a release"](https://github.com/expo/eas-cli/actions/workflows/trigger-release.yml). The next version is chosen automatically based on the changelog entries. If you want to use different version, pass the version string as an input to the workflow, in semver format, e.g. "1.2.3"
|
||||
2. That's it! GitHub Actions is going to take care of the rest. Watch the #eas-cli Slack channel for a successful release notification.
|
||||
|
||||
## Choosing the next version
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"npmClient": "yarn",
|
||||
"version": "14.5.0",
|
||||
"version": "16.28.0",
|
||||
"$schema": "node_modules/lerna/schemas/lerna-schema.json"
|
||||
}
|
||||
+1
-1
@@ -28,7 +28,7 @@
|
||||
"eslint-plugin-node": "11.1.0",
|
||||
"jest": "29.7.0",
|
||||
"jest-watch-typeahead": "2.2.2",
|
||||
"lerna": "7.4.2",
|
||||
"lerna": "8.2.3",
|
||||
"oclif": "^3.9.0",
|
||||
"prettier": "3.1.1",
|
||||
"ts-jest": "29.1.1",
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
# eas-build-cache-provider
|
||||
|
||||
A build cache provider plugin for the Expo CLI
|
||||
|
||||
To use the EAS remote build provider plugin, install the `eas-build-cache-provider` package as a developer dependency:
|
||||
|
||||
```bash
|
||||
npm install --save-dev eas-build-cache-provider
|
||||
```
|
||||
|
||||
Then, update your **app.json** to include the `buildCacheProvider` property and its provider under `experiments`:
|
||||
|
||||
```json
|
||||
{
|
||||
"expo": {
|
||||
...
|
||||
"experiments": {
|
||||
"buildCacheProvider": "eas"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,58 @@
|
||||
{
|
||||
"name": "eas-build-cache-provider",
|
||||
"description": "A build cache provider plugin for the Expo CLI",
|
||||
"version": "16.4.2",
|
||||
"author": "Expo <[email protected]>",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "7.23.5",
|
||||
"@expo/config": "11.0.10",
|
||||
"@expo/spawn-async": "^1.7.2",
|
||||
"chalk": "4.1.2",
|
||||
"figures": "3.2.0",
|
||||
"fs-extra": "11.2.0",
|
||||
"getenv": "1.0.0",
|
||||
"log-symbols": "4.1.0",
|
||||
"semver": "7.5.2",
|
||||
"terminal-link": "2.1.1",
|
||||
"tslib": "2.4.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tsconfig/node18": "18.2.4",
|
||||
"@types/babel__code-frame": "7.0.3",
|
||||
"@types/fs-extra": "11.0.4",
|
||||
"@types/getenv": "^1.0.0",
|
||||
"memfs": "3.4.13",
|
||||
"rimraf": "3.0.2",
|
||||
"typescript": "5.3.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
"homepage": "https://github.com/expo/eas-cli",
|
||||
"license": "MIT",
|
||||
"main": "build/index.js",
|
||||
"types": "build/index.d.ts",
|
||||
"repository": "expo/eas-cli",
|
||||
"scripts": {
|
||||
"build": "tsc --project tsconfig.build.json",
|
||||
"build-allow-unused": "tsc --project tsconfig.allowUnused.json",
|
||||
"watch": "yarn build --watch --preserveWatchOutput",
|
||||
"watch-allow-unused": "yarn build-allow-unused --watch --preserveWatchOutput",
|
||||
"typecheck": "tsc",
|
||||
"prepack": "yarn rebuild",
|
||||
"rebuild": "rimraf build && yarn build",
|
||||
"test": "jest",
|
||||
"clean": "rimraf build node_modules yarn-error.log"
|
||||
},
|
||||
"files": [
|
||||
"/build"
|
||||
],
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"volta": {
|
||||
"node": "20.11.0",
|
||||
"yarn": "1.22.21"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
module.exports = {
|
||||
extends: ['universe/shared/typescript-analysis'],
|
||||
overrides: [
|
||||
{
|
||||
files: ['*.ts', '*.d.ts'],
|
||||
parserOptions: {
|
||||
project: './packages/eas-build-cache-provider/tsconfig.json',
|
||||
},
|
||||
rules: {
|
||||
'@typescript-eslint/explicit-function-return-type': [
|
||||
'warn',
|
||||
{
|
||||
allowExpressions: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,38 @@
|
||||
import { RunOptions, getPackageJson } from '@expo/config';
|
||||
import { SpawnResult } from '@expo/spawn-async';
|
||||
|
||||
export function isSpawnResultError(obj: any): obj is Error & SpawnResult {
|
||||
return (
|
||||
obj &&
|
||||
'message' in obj &&
|
||||
obj.status !== undefined &&
|
||||
obj.stdout !== undefined &&
|
||||
obj.stderr !== undefined
|
||||
);
|
||||
}
|
||||
|
||||
export function isDevClientBuild({
|
||||
runOptions,
|
||||
projectRoot,
|
||||
}: {
|
||||
runOptions: RunOptions;
|
||||
projectRoot: string;
|
||||
}): boolean {
|
||||
if (!hasDirectDevClientDependency(projectRoot)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ('variant' in runOptions && runOptions.variant !== undefined) {
|
||||
return runOptions.variant === 'debug';
|
||||
}
|
||||
if ('configuration' in runOptions && runOptions.configuration !== undefined) {
|
||||
return runOptions.configuration === 'Debug';
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export function hasDirectDevClientDependency(projectRoot: string): boolean {
|
||||
const { dependencies = {}, devDependencies = {} } = getPackageJson(projectRoot);
|
||||
return !!dependencies['expo-dev-client'] || !!devDependencies['expo-dev-client'];
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import {
|
||||
BuildCacheProviderPlugin,
|
||||
CalculateFingerprintHashProps,
|
||||
ResolveBuildCacheProps,
|
||||
UploadBuildCacheProps,
|
||||
} from '@expo/config';
|
||||
import spawnAsync from '@expo/spawn-async';
|
||||
import chalk from 'chalk';
|
||||
import fs from 'fs-extra';
|
||||
import path from 'path';
|
||||
|
||||
import { isDevClientBuild, isSpawnResultError } from './helpers';
|
||||
import Log from './log';
|
||||
|
||||
async function resolveBuildCacheAsync({
|
||||
projectRoot,
|
||||
platform,
|
||||
fingerprintHash,
|
||||
runOptions,
|
||||
}: ResolveBuildCacheProps): Promise<string | null> {
|
||||
const easJsonPath = path.join(projectRoot, 'eas.json');
|
||||
if (!(await fs.exists(easJsonPath))) {
|
||||
Log.debug('eas.json not found, skip checking for remote builds');
|
||||
return null;
|
||||
}
|
||||
|
||||
Log.log(
|
||||
chalk`{whiteBright \u203A} {bold Searching builds with matching fingerprint on EAS servers}`
|
||||
);
|
||||
try {
|
||||
const results = await spawnAsync(
|
||||
'npx',
|
||||
[
|
||||
'eas-cli',
|
||||
'build:download',
|
||||
`--platform=${platform}`,
|
||||
`--fingerprint=${fingerprintHash}`,
|
||||
'--non-interactive',
|
||||
isDevClientBuild({ runOptions, projectRoot }) ? '--dev-client' : '--no-dev-client',
|
||||
'--json',
|
||||
],
|
||||
{
|
||||
cwd: projectRoot,
|
||||
}
|
||||
);
|
||||
|
||||
Log.log(chalk`{whiteBright \u203A} {bold Successfully downloaded cached build}`);
|
||||
// {
|
||||
// "path": "/var/folders/03/lppcpcnn61q3mz5ckzmzd8w80000gn/T/eas-cli-nodejs/eas-build-run-cache/c0f9ba9c-0cf1-4c5c-8566-b28b7971050f_22f1bbfa-1c09-4b67-9e4a-721906546b58.app"
|
||||
// }
|
||||
const json = JSON.parse(results.stdout.trim());
|
||||
return json?.path;
|
||||
} catch (error) {
|
||||
Log.debug('eas-cli error:', error);
|
||||
// @TODO(2025-04-11): remove this in a future release
|
||||
if (isSpawnResultError(error) && error.stderr.includes('command build:download not found')) {
|
||||
Log.warn(
|
||||
`To take advantage of EAS build cache provider, upgrade your eas-cli installation to latest.`
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadBuildCacheAsync({
|
||||
projectRoot,
|
||||
platform,
|
||||
fingerprintHash,
|
||||
buildPath,
|
||||
}: UploadBuildCacheProps): Promise<string | null> {
|
||||
const easJsonPath = path.join(projectRoot, 'eas.json');
|
||||
if (!(await fs.exists(easJsonPath))) {
|
||||
Log.debug('eas.json not found, skip uploading builds');
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
Log.log(chalk`{whiteBright \u203A} {bold Uploading build to EAS}`);
|
||||
const results = await spawnAsync(
|
||||
'npx',
|
||||
[
|
||||
'eas-cli',
|
||||
'upload',
|
||||
`--platform=${platform}`,
|
||||
`--fingerprint=${fingerprintHash}`,
|
||||
buildPath ? `--build-path=${buildPath}` : '',
|
||||
'--non-interactive',
|
||||
'--json',
|
||||
],
|
||||
{
|
||||
cwd: projectRoot,
|
||||
}
|
||||
);
|
||||
// {
|
||||
// "url": "/var/folders/03/lppcpcnn61q3mz5ckzmzd8w80000gn/T/eas-cli-nodejs/eas-build-run-cache/c0f9ba9c-0cf1-4c5c-8566-b28b7971050f_22f1bbfa-1c09-4b67-9e4a-721906546b58.app"
|
||||
// }
|
||||
const json = JSON.parse(results.stdout.trim());
|
||||
Log.log(chalk`{whiteBright \u203A} {bold Build successfully uploaded: ${json?.url}}`);
|
||||
return json?.url;
|
||||
} catch (error) {
|
||||
Log.debug('eas-cli error:', error);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function calculateEASFingerprintHashAsync({
|
||||
projectRoot,
|
||||
platform,
|
||||
}: CalculateFingerprintHashProps): Promise<string | null> {
|
||||
// prefer using `eas fingerprint:generate` because it automatically upload sources
|
||||
try {
|
||||
const results = await spawnAsync(
|
||||
'npx',
|
||||
['eas-cli', 'fingerprint:generate', `--platform=${platform}`, '--json', '--non-interactive'],
|
||||
{
|
||||
cwd: projectRoot,
|
||||
}
|
||||
);
|
||||
// {
|
||||
// "hash": "203f960b965e154b77dc31c6c42e5582e8d77196"
|
||||
// }
|
||||
const json = JSON.parse(results.stdout.trim());
|
||||
return json?.hash;
|
||||
} catch (error) {
|
||||
Log.debug('eas-cli error:', error);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const EASBuildCacheProvider: BuildCacheProviderPlugin = {
|
||||
resolveBuildCache: resolveBuildCacheAsync,
|
||||
uploadBuildCache: uploadBuildCacheAsync,
|
||||
calculateFingerprintHash: calculateEASFingerprintHashAsync,
|
||||
};
|
||||
|
||||
export default EASBuildCacheProvider;
|
||||
@@ -0,0 +1,121 @@
|
||||
import chalk from 'chalk';
|
||||
import figures from 'figures';
|
||||
import { boolish } from 'getenv';
|
||||
import logSymbols from 'log-symbols';
|
||||
import terminalLink from 'terminal-link';
|
||||
|
||||
type Color = (...text: string[]) => string;
|
||||
|
||||
export default class Log {
|
||||
public static readonly isDebug = boolish('EXPO_DEBUG', false);
|
||||
|
||||
public static log(...args: any[]): void {
|
||||
Log.consoleLog(...args);
|
||||
}
|
||||
|
||||
public static newLine(): void {
|
||||
Log.consoleLog();
|
||||
}
|
||||
|
||||
public static addNewLineIfNone(): void {
|
||||
if (!Log.isLastLineNewLine) {
|
||||
Log.newLine();
|
||||
}
|
||||
}
|
||||
|
||||
public static error(...args: any[]): void {
|
||||
Log.consoleLog(...Log.withTextColor(args, chalk.red));
|
||||
}
|
||||
|
||||
public static warn(...args: any[]): void {
|
||||
Log.consoleLog(...Log.withTextColor(args, chalk.yellow));
|
||||
}
|
||||
|
||||
public static debug(...args: any[]): void {
|
||||
if (Log.isDebug) {
|
||||
Log.consoleLog(...args);
|
||||
}
|
||||
}
|
||||
|
||||
public static gray(...args: any[]): void {
|
||||
Log.consoleLog(...Log.withTextColor(args, chalk.gray));
|
||||
}
|
||||
|
||||
public static warnDeprecatedFlag(flag: string, message: string): void {
|
||||
Log.warn(`› ${chalk.bold('--' + flag)} flag is deprecated. ${message}`);
|
||||
}
|
||||
|
||||
public static fail(message: string): void {
|
||||
Log.log(`${chalk.red(logSymbols.error)} ${message}`);
|
||||
}
|
||||
|
||||
public static succeed(message: string): void {
|
||||
Log.log(`${chalk.green(logSymbols.success)} ${message}`);
|
||||
}
|
||||
|
||||
public static withTick(...args: any[]): void {
|
||||
Log.consoleLog(chalk.green(figures.tick), ...args);
|
||||
}
|
||||
|
||||
public static withInfo(...args: any[]): void {
|
||||
Log.consoleLog(chalk.green(figures.info), ...args);
|
||||
}
|
||||
|
||||
private static consoleLog(...args: any[]): void {
|
||||
Log.updateIsLastLineNewLine(args);
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(...args);
|
||||
}
|
||||
|
||||
private static withTextColor(args: any[], chalkColor: Color): string[] {
|
||||
return args.map(arg => chalkColor(arg));
|
||||
}
|
||||
|
||||
private static isLastLineNewLine = false;
|
||||
private static updateIsLastLineNewLine(args: any[]): void {
|
||||
if (args.length === 0) {
|
||||
Log.isLastLineNewLine = true;
|
||||
} else {
|
||||
const lastArg = args[args.length - 1];
|
||||
if (typeof lastArg === 'string' && (lastArg === '' || lastArg.match(/[\r\n]$/))) {
|
||||
Log.isLastLineNewLine = true;
|
||||
} else {
|
||||
Log.isLastLineNewLine = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Prints a link for given URL, using text if provided, otherwise text is just the URL.
|
||||
* Format links as dim (unless disabled) and with an underline.
|
||||
*
|
||||
* @example https://expo.dev
|
||||
*/
|
||||
export function link(
|
||||
url: string,
|
||||
{ text = url, fallback, dim = true }: { text?: string; dim?: boolean; fallback?: string } = {}
|
||||
): string {
|
||||
// Links can be disabled via env variables https://github.com/jamestalmage/supports-hyperlinks/blob/master/index.js
|
||||
const output = terminalLink(text, url, {
|
||||
fallback: () =>
|
||||
fallback ?? (text === url ? chalk.underline(url) : `${text}: ${chalk.underline(url)}`),
|
||||
});
|
||||
return dim ? chalk.dim(output) : output;
|
||||
}
|
||||
|
||||
/**
|
||||
* Provide a consistent "Learn more" link experience.
|
||||
* Format links as dim (unless disabled) with an underline.
|
||||
*
|
||||
* @example Learn more: https://expo.dev
|
||||
*/
|
||||
export function learnMore(
|
||||
url: string,
|
||||
{
|
||||
learnMoreMessage: maybeLearnMoreMessage,
|
||||
dim = true,
|
||||
}: { learnMoreMessage?: string; dim?: boolean } = {}
|
||||
): string {
|
||||
return link(url, { text: maybeLearnMoreMessage ?? 'Learn more', dim });
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": "./tsconfig",
|
||||
"exclude": ["**/__mocks__/*", "**/__tests__/*"]
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"extends": "@tsconfig/node18/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"declaration": true,
|
||||
"importHelpers": true,
|
||||
"module": "commonjs",
|
||||
"moduleResolution": "node",
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noImplicitOverride": true,
|
||||
"noImplicitReturns": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"outDir": "build",
|
||||
"rootDir": "src",
|
||||
"typeRoots": ["../../node_modules/@types", "../../ts-declarations", "./node_modules/@types"]
|
||||
},
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
+685
-202
File diff suppressed because it is too large.
Load diff
@@ -12,3 +12,6 @@ if (process.env.TMPDIR) {
|
||||
(fs.realpath as any).native = jest.fn();
|
||||
|
||||
module.exports = fs;
|
||||
|
||||
// Ensure requiring node:fs returns the mock too. This is needed for expo/config and expo/json-file.
|
||||
jest.mock('node:fs', () => fs);
|
||||
@@ -1,13 +1,7 @@
|
||||
overwrite: true
|
||||
schema: 'https://staging-api.expo.dev/graphql'
|
||||
documents:
|
||||
- 'src/graphql/**/!(*.d).{ts,tsx}'
|
||||
- 'src/credentials/ios/api/graphql/**/!(*.d).{ts,tsx}'
|
||||
- 'src/credentials/android/api/graphql/**/!(*.d).{ts,tsx}'
|
||||
- 'src/commands/**/*.ts'
|
||||
- 'src/branch/**/*.ts'
|
||||
- 'src/channel/**/*.ts'
|
||||
- 'src/worker/**/*.ts'
|
||||
- 'src/**/!(*.d).ts'
|
||||
generates:
|
||||
src/graphql/generated.ts:
|
||||
plugins:
|
||||
|
||||
Generated
+8408
-909
File diff suppressed because it is too large.
Load diff
@@ -1,22 +1,22 @@
|
||||
{
|
||||
"name": "eas-cli",
|
||||
"description": "EAS command line tool",
|
||||
"version": "14.5.0",
|
||||
"version": "16.28.0",
|
||||
"author": "Expo <[email protected]>",
|
||||
"bin": {
|
||||
"eas": "./bin/run"
|
||||
},
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"dependencies": {
|
||||
"@expo/apple-utils": "2.1.5",
|
||||
"@expo/apple-utils": "2.1.12",
|
||||
"@expo/code-signing-certificates": "0.0.5",
|
||||
"@expo/config": "10.0.6",
|
||||
"@expo/config-plugins": "9.0.12",
|
||||
"@expo/eas-build-job": "1.0.165",
|
||||
"@expo/eas-json": "14.5.0",
|
||||
"@expo/eas-build-job": "1.0.243",
|
||||
"@expo/eas-json": "16.25.0",
|
||||
"@expo/env": "^1.0.0",
|
||||
"@expo/json-file": "8.3.3",
|
||||
"@expo/logger": "1.0.117",
|
||||
"@expo/logger": "1.0.221",
|
||||
"@expo/multipart-body-parser": "2.0.0",
|
||||
"@expo/osascript": "2.1.4",
|
||||
"@expo/package-manager": "1.7.0",
|
||||
@@ -28,7 +28,7 @@
|
||||
"@expo/results": "1.0.0",
|
||||
"@expo/rudder-sdk-node": "1.1.1",
|
||||
"@expo/spawn-async": "1.7.2",
|
||||
"@expo/steps": "1.0.166",
|
||||
"@expo/steps": "1.0.231",
|
||||
"@expo/timeago.js": "1.0.0",
|
||||
"@oclif/core": "^1.26.2",
|
||||
"@oclif/plugin-autocomplete": "^2.3.10",
|
||||
@@ -38,6 +38,7 @@
|
||||
"ajv": "8.11.0",
|
||||
"ajv-formats": "2.1.1",
|
||||
"better-opn": "3.0.2",
|
||||
"bplist-parser": "^0.3.0",
|
||||
"chalk": "4.1.2",
|
||||
"cli-progress": "3.12.0",
|
||||
"dateformat": "4.6.3",
|
||||
@@ -48,7 +49,7 @@
|
||||
"fast-deep-equal": "3.1.3",
|
||||
"fast-glob": "3.3.2",
|
||||
"figures": "3.2.0",
|
||||
"form-data": "4.0.0",
|
||||
"form-data": "^4.0.4",
|
||||
"fs-extra": "11.2.0",
|
||||
"getenv": "1.0.0",
|
||||
"gradle-to-js": "2.0.1",
|
||||
@@ -68,6 +69,7 @@
|
||||
"nanoid": "3.3.8",
|
||||
"node-fetch": "2.6.7",
|
||||
"node-forge": "1.3.1",
|
||||
"node-stream-zip": "1.15.0",
|
||||
"nullthrows": "1.1.1",
|
||||
"ora": "5.1.0",
|
||||
"pkg-dir": "4.2.0",
|
||||
@@ -78,17 +80,19 @@
|
||||
"qrcode-terminal": "0.12.0",
|
||||
"resolve-from": "5.0.0",
|
||||
"semver": "7.5.4",
|
||||
"set-interval-async": "3.0.3",
|
||||
"slash": "3.0.0",
|
||||
"tar": "6.2.1",
|
||||
"tar-stream": "3.1.7",
|
||||
"terminal-link": "2.1.1",
|
||||
"ts-deepmerge": "6.2.0",
|
||||
"tslib": "2.6.2",
|
||||
"turndown": "7.1.2",
|
||||
"untildify": "4.0.0",
|
||||
"uuid": "9.0.1",
|
||||
"wrap-ansi": "7.0.0",
|
||||
"yaml": "2.6.0",
|
||||
"zod": "^3.23.8"
|
||||
"zod": "^4.1.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@graphql-codegen/cli": "5.0.0",
|
||||
@@ -105,7 +109,7 @@
|
||||
"@types/getenv": "^1.0.0",
|
||||
"@types/jsonwebtoken": "8.5.1",
|
||||
"@types/mime": "3.0.1",
|
||||
"@types/node-fetch": "2.6.2",
|
||||
"@types/node-fetch": "2.6.12",
|
||||
"@types/node-forge": "1.3.1",
|
||||
"@types/pngjs": "6.0.4",
|
||||
"@types/promise-retry": "1.1.3",
|
||||
@@ -116,14 +120,13 @@
|
||||
"@types/tough-cookie": "4.0.2",
|
||||
"@types/uuid": "9.0.7",
|
||||
"@types/wrap-ansi": "3.0.0",
|
||||
"axios": "0.28.1",
|
||||
"axios": "1.11.0",
|
||||
"eslint-plugin-graphql": "4.0.0",
|
||||
"express": "4.20.0",
|
||||
"memfs": "3.4.13",
|
||||
"mockdate": "3.0.5",
|
||||
"nock": "13.4.0",
|
||||
"rimraf": "3.0.2",
|
||||
"ts-deepmerge": "6.2.0",
|
||||
"ts-mockito": "2.6.1",
|
||||
"ts-node": "10.9.2",
|
||||
"typescript": "5.3.3"
|
||||
@@ -223,15 +226,18 @@
|
||||
"prepack": "yarn rebuild && node ./scripts/prepack.js",
|
||||
"rebuild": "rimraf build && yarn build",
|
||||
"pretarball-ci": "./scripts/pretarball-ci.sh",
|
||||
"build": "tsc --project tsconfig.build.json",
|
||||
"build": "yarn typecheck-for-build && yarn copy-new-templates",
|
||||
"build-allow-unused": "tsc --project tsconfig.allowUnused.json",
|
||||
"watch": "yarn build --watch --preserveWatchOutput",
|
||||
"watch": "yarn typecheck-for-build --watch --preserveWatchOutput",
|
||||
"watch-allow-unused": "yarn build-allow-unused --watch --preserveWatchOutput",
|
||||
"typecheck": "tsc",
|
||||
"typecheck-for-build": "tsc --project tsconfig.build.json",
|
||||
"test": "jest",
|
||||
"version": "yarn oclif readme && node scripts/patch-readme && git add README.md",
|
||||
"generate-graphql-code": "graphql-codegen --config graphql-codegen.yml",
|
||||
"clean": "rimraf dist build tmp node_modules yarn-error.log"
|
||||
"verify-graphql-code": "./scripts/verify-graphql.sh",
|
||||
"clean": "rimraf dist build tmp node_modules yarn-error.log",
|
||||
"copy-new-templates": "rimraf build/commandUtils/new/templates && mkdir -p build/commandUtils/new && cp -r src/commandUtils/new/templates build/commandUtils/new"
|
||||
},
|
||||
"volta": {
|
||||
"node": "20.11.0",
|
||||
|
||||
Executable
+37
@@ -0,0 +1,37 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
function cleanup()
|
||||
{
|
||||
echo 'Cleaning up...'
|
||||
rm -f graphql.schema.json.bak src/graphql/generated.ts.bak
|
||||
}
|
||||
|
||||
# Fail if anything errors
|
||||
set -eox pipefail
|
||||
# If this script exits, trap it first and clean up
|
||||
trap cleanup EXIT
|
||||
|
||||
cp graphql.schema.json graphql.schema.json.bak
|
||||
cp src/graphql/generated.ts src/graphql/generated.ts.bak
|
||||
|
||||
yarn generate-graphql-code
|
||||
if cmp -s graphql.schema.json graphql.schema.json.bak; then
|
||||
echo "GraphQL schema is up-to-date"
|
||||
if cmp -s src/graphql/generated.ts src/graphql/generated.ts.bak; then
|
||||
echo "GraphQL generated code is up-to-date"
|
||||
exit 0
|
||||
else
|
||||
echo "GraphQL code has changed but has not been regenerated. Run `yarn generate-graphql-code` and commit the changes."
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
echo "GraphQL schema has changed on the server."
|
||||
echo "Building eas-json"
|
||||
cd ../eas-json
|
||||
yarn build
|
||||
cd ../eas-cli
|
||||
echo "Running `yarn tsc` to check for breaking changes..."
|
||||
yarn tsc
|
||||
# If there are no errors, then the schema is backwards compatible
|
||||
exit 0
|
||||
fi
|
||||
@@ -6,6 +6,7 @@ export class ApiV2Error extends Error {
|
||||
readonly expoApiV2ErrorDetails?: JSONValue;
|
||||
readonly expoApiV2ErrorServerStack?: string;
|
||||
readonly expoApiV2ErrorMetadata?: object;
|
||||
readonly expoApiV2ErrorRequestId?: string;
|
||||
|
||||
constructor(response: {
|
||||
message: string;
|
||||
@@ -13,11 +14,13 @@ export class ApiV2Error extends Error {
|
||||
stack?: string;
|
||||
details?: JSONValue;
|
||||
metadata?: object;
|
||||
requestId?: string;
|
||||
}) {
|
||||
super(response.message);
|
||||
this.expoApiV2ErrorCode = response.code;
|
||||
this.expoApiV2ErrorDetails = response.details;
|
||||
this.expoApiV2ErrorServerStack = response.stack;
|
||||
this.expoApiV2ErrorMetadata = response.metadata;
|
||||
this.expoApiV2ErrorRequestId = response.requestId;
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ describe(ApiV2Client, () => {
|
||||
stack: 'line 1: hello',
|
||||
details: { who: 'world' },
|
||||
metadata: { an: 'object' },
|
||||
requestId: 'request-id',
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -37,6 +38,7 @@ describe(ApiV2Client, () => {
|
||||
expect(error.expoApiV2ErrorDetails).toEqual({ who: 'world' });
|
||||
expect(error.expoApiV2ErrorMetadata).toEqual({ an: 'object' });
|
||||
expect(error.expoApiV2ErrorServerStack).toEqual('line 1: hello');
|
||||
expect(error.expoApiV2ErrorRequestId).toEqual('request-id');
|
||||
});
|
||||
|
||||
it('does not convert non-APIv2 error to ApiV2Error', async () => {
|
||||
|
||||
@@ -7,7 +7,16 @@ import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
import { ContextInput, ContextOutput } from '../../commandUtils/EasCommand';
|
||||
import { DynamicConfigContextFn } from '../../commandUtils/context/DynamicProjectConfigContextField';
|
||||
import { AppFragment, Role } from '../../graphql/generated';
|
||||
import {
|
||||
AppFragment,
|
||||
Role,
|
||||
WorkflowJobStatus,
|
||||
WorkflowJobType,
|
||||
WorkflowRunByIdWithJobsQuery,
|
||||
WorkflowRunFragment,
|
||||
WorkflowRunStatus,
|
||||
WorkflowRunTriggerEventType,
|
||||
} from '../../graphql/generated';
|
||||
|
||||
export function getMockEasJson(): EasJson {
|
||||
return {
|
||||
@@ -81,7 +90,9 @@ export function mockCommandContext<
|
||||
if (contextKey === 'loggedIn') {
|
||||
result.loggedIn = {};
|
||||
}
|
||||
|
||||
if (contextKey === 'projectId') {
|
||||
result.projectId = overrides.projectId ?? mockProjectId;
|
||||
}
|
||||
if (contextKey === 'projectDir') {
|
||||
result.projectDir = projectDir;
|
||||
}
|
||||
@@ -168,3 +179,184 @@ export function withLocalVersionSource(easJson: EasJson): EasJson {
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function getMockEmptyWorkflowRunsFragment(): WorkflowRunFragment[] {
|
||||
return getMockWorkflowRunsFragment();
|
||||
}
|
||||
|
||||
export function getMockWorkflowRunsFragment(
|
||||
params?:
|
||||
| undefined
|
||||
| {
|
||||
successes?: number;
|
||||
failures?: number;
|
||||
pending?: number;
|
||||
withJobs?: number;
|
||||
withBuildJobs?: number;
|
||||
}
|
||||
): WorkflowRunFragment[] {
|
||||
const {
|
||||
successes = 0,
|
||||
failures = 0,
|
||||
pending = 0,
|
||||
withJobs = 0,
|
||||
withBuildJobs = 0,
|
||||
} = params ?? {};
|
||||
const runs: any[] = [];
|
||||
for (let i = 0; i < successes; i++) {
|
||||
runs.push({
|
||||
id: `success-${i}`,
|
||||
status: WorkflowRunStatus.Success,
|
||||
errors: [],
|
||||
createdAt: '2022-01-01T00:00:00.000Z',
|
||||
updatedAt: '2022-01-01T00:00:00.000Z',
|
||||
gitCommitHash: '1234567890',
|
||||
gitCommitMessage: 'commit message',
|
||||
workflow: {
|
||||
name: 'build',
|
||||
id: 'build',
|
||||
fileName: 'build.yml',
|
||||
},
|
||||
});
|
||||
}
|
||||
for (let i = 0; i < failures; i++) {
|
||||
runs.push({
|
||||
id: `failure-${i}`,
|
||||
errors: ['Failed'],
|
||||
status: WorkflowRunStatus.Failure,
|
||||
createdAt: '2022-01-01T00:00:00.000Z',
|
||||
updatedAt: '2022-01-01T00:00:00.000Z',
|
||||
gitCommitHash: '1234567890',
|
||||
gitCommitMessage: 'commit message',
|
||||
workflow: {
|
||||
name: 'build',
|
||||
id: 'build',
|
||||
fileName: 'build.yml',
|
||||
},
|
||||
});
|
||||
}
|
||||
for (let i = 0; i < pending; i++) {
|
||||
runs.push({
|
||||
id: `pending-${i}`,
|
||||
errors: [],
|
||||
status: WorkflowRunStatus.InProgress,
|
||||
createdAt: '2022-01-01T00:00:00.000Z',
|
||||
updatedAt: '2022-01-01T00:00:00.000Z',
|
||||
gitCommitHash: '1234567890',
|
||||
gitCommitMessage: 'commit message',
|
||||
workflow: {
|
||||
name: 'build',
|
||||
id: 'build',
|
||||
fileName: 'build.yml',
|
||||
},
|
||||
});
|
||||
}
|
||||
for (let i = 0; i < withJobs; i++) {
|
||||
runs.push(getMockWorkflowRunWithJobsFragment());
|
||||
}
|
||||
for (let i = 0; i < withBuildJobs; i++) {
|
||||
runs.push(getMockWorkflowRunWithBuildJobsFragment());
|
||||
}
|
||||
return runs;
|
||||
}
|
||||
|
||||
export function getMockWorkflowCustomJobFragment(
|
||||
runId?: string
|
||||
): WorkflowRunByIdWithJobsQuery['workflowRuns']['byId']['jobs'][number] {
|
||||
return {
|
||||
id: 'job1',
|
||||
key: 'job1',
|
||||
name: 'job1',
|
||||
status: WorkflowJobStatus.Success,
|
||||
type: WorkflowJobType.Custom,
|
||||
createdAt: '2022-01-01T00:00:00.000Z',
|
||||
updatedAt: '2022-01-01T00:00:00.000Z',
|
||||
outputs: {},
|
||||
errors: [],
|
||||
workflowRun: {
|
||||
id: runId ?? 'build1',
|
||||
},
|
||||
turtleJobRun: {
|
||||
id: 'job1',
|
||||
logFileUrls: ['https://example.com/log1'],
|
||||
errors: [],
|
||||
artifacts: [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function getMockWorkflowBuildJobFragment(
|
||||
runId?: string
|
||||
): WorkflowRunByIdWithJobsQuery['workflowRuns']['byId']['jobs'][number] {
|
||||
return {
|
||||
...getMockWorkflowCustomJobFragment(runId),
|
||||
type: WorkflowJobType.Build,
|
||||
outputs: {
|
||||
build_id: 'build1',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function getMockWorkflowRunWithJobsFragment(
|
||||
runID?: string
|
||||
): WorkflowRunByIdWithJobsQuery['workflowRuns']['byId'] {
|
||||
const id = runID ?? 'build1';
|
||||
return {
|
||||
id,
|
||||
status: WorkflowRunStatus.Failure,
|
||||
triggerEventType: WorkflowRunTriggerEventType.Manual,
|
||||
createdAt: '2022-01-01T00:00:00.000Z',
|
||||
updatedAt: '2022-01-01T00:00:00.000Z',
|
||||
gitCommitHash: '1234567890',
|
||||
gitCommitMessage: 'commit message',
|
||||
errors: [],
|
||||
workflow: {
|
||||
id: 'build',
|
||||
name: 'build',
|
||||
fileName: 'build.yml',
|
||||
app: {
|
||||
id: mockProjectId,
|
||||
__typename: 'App',
|
||||
name: 'App',
|
||||
ownerAccount: {
|
||||
id: 'account-id',
|
||||
name: 'account-name',
|
||||
__typename: 'Account',
|
||||
},
|
||||
},
|
||||
},
|
||||
jobs: [getMockWorkflowCustomJobFragment(id)],
|
||||
};
|
||||
}
|
||||
|
||||
export function getMockWorkflowRunWithBuildJobsFragment(
|
||||
runID?: string
|
||||
): WorkflowRunByIdWithJobsQuery['workflowRuns']['byId'] {
|
||||
const id = runID ?? 'build1';
|
||||
return {
|
||||
id,
|
||||
status: WorkflowRunStatus.Failure,
|
||||
triggerEventType: WorkflowRunTriggerEventType.Manual,
|
||||
createdAt: '2022-01-01T00:00:00.000Z',
|
||||
updatedAt: '2022-01-01T00:00:00.000Z',
|
||||
gitCommitHash: '1234567890',
|
||||
gitCommitMessage: 'commit message',
|
||||
errors: [],
|
||||
workflow: {
|
||||
id: 'build',
|
||||
name: 'build',
|
||||
fileName: 'build.yml',
|
||||
app: {
|
||||
id: mockProjectId,
|
||||
__typename: 'App',
|
||||
name: 'App',
|
||||
ownerAccount: {
|
||||
id: 'account-id',
|
||||
name: 'account-name',
|
||||
__typename: 'Account',
|
||||
},
|
||||
},
|
||||
},
|
||||
jobs: [getMockWorkflowBuildJobFragment(id)],
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
import {
|
||||
getMockEmptyWorkflowRunsFragment,
|
||||
getMockWorkflowCustomJobFragment,
|
||||
getMockWorkflowRunWithJobsFragment,
|
||||
getMockWorkflowRunsFragment,
|
||||
mockCommandContext,
|
||||
mockProjectId,
|
||||
mockTestCommand,
|
||||
} from './utils';
|
||||
import {
|
||||
fetchRawLogsForBuildJobAsync,
|
||||
fetchRawLogsForCustomJobAsync,
|
||||
} from '../../commandUtils/workflow/fetchLogs';
|
||||
import WorkflowLogView from '../../commands/workflow/logs';
|
||||
import { AppPlatform, BuildPriority, BuildStatus } from '../../graphql/generated';
|
||||
import { AppQuery } from '../../graphql/queries/AppQuery';
|
||||
import { BuildQuery } from '../../graphql/queries/BuildQuery';
|
||||
import { WorkflowJobQuery } from '../../graphql/queries/WorkflowJobQuery';
|
||||
import { WorkflowRunQuery } from '../../graphql/queries/WorkflowRunQuery';
|
||||
import Log from '../../log';
|
||||
import { promptAsync } from '../../prompts';
|
||||
|
||||
jest.mock('../../build/android/version');
|
||||
jest.mock('../../build/ios/version');
|
||||
jest.mock('../../project/applicationIdentifier');
|
||||
jest.mock('../../graphql/queries/AppVersionQuery');
|
||||
jest.mock('../../graphql/queries/AppQuery');
|
||||
jest.mock('../../graphql/queries/BuildQuery');
|
||||
jest.mock('../../graphql/queries/WorkflowJobQuery');
|
||||
jest.mock('../../graphql/queries/WorkflowRunQuery');
|
||||
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');
|
||||
jest.mock('../../commandUtils/workflow/fetchLogs');
|
||||
|
||||
describe(WorkflowLogView, () => {
|
||||
beforeEach(() => {
|
||||
jest.mocked(WorkflowJobQuery.byIdAsync).mockResolvedValue(getMockWorkflowCustomJobFragment());
|
||||
jest
|
||||
.mocked(WorkflowRunQuery.withJobsByIdAsync)
|
||||
.mockResolvedValue(getMockWorkflowRunWithJobsFragment());
|
||||
});
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
test('view logs, passing in no parameters, no runs found', async () => {
|
||||
const ctx = mockCommandContext(WorkflowLogView, {
|
||||
projectId: mockProjectId,
|
||||
});
|
||||
const cmd = mockTestCommand(WorkflowLogView, [], ctx);
|
||||
jest
|
||||
.mocked(AppQuery.byIdWorkflowRunsFilteredByStatusAsync)
|
||||
.mockResolvedValue(getMockEmptyWorkflowRunsFragment());
|
||||
await cmd.run();
|
||||
expect(AppQuery.byIdWorkflowRunsFilteredByStatusAsync).toHaveBeenCalledWith(
|
||||
ctx.loggedIn.graphqlClient,
|
||||
mockProjectId,
|
||||
undefined,
|
||||
20
|
||||
);
|
||||
expect(Log.error).toHaveBeenCalledWith('No workflow runs found');
|
||||
});
|
||||
test('view logs, passing in no parameters, runs found, no jobs', async () => {
|
||||
const ctx = mockCommandContext(WorkflowLogView, {
|
||||
projectId: mockProjectId,
|
||||
});
|
||||
const cmd = mockTestCommand(WorkflowLogView, [], ctx);
|
||||
jest
|
||||
.mocked(AppQuery.byIdWorkflowRunsFilteredByStatusAsync)
|
||||
.mockResolvedValue(getMockWorkflowRunsFragment({ successes: 1 }));
|
||||
jest.mocked(promptAsync).mockResolvedValue({ selectedRun: 'build1' });
|
||||
await cmd.run();
|
||||
expect(AppQuery.byIdWorkflowRunsFilteredByStatusAsync).toHaveBeenCalledWith(
|
||||
ctx.loggedIn.graphqlClient,
|
||||
mockProjectId,
|
||||
undefined,
|
||||
20
|
||||
);
|
||||
expect(Log.error).toHaveBeenCalledWith('No job found');
|
||||
});
|
||||
test('view logs, passing in no parameters, runs found, jobs found, step selected, no logs', async () => {
|
||||
const ctx = mockCommandContext(WorkflowLogView, {
|
||||
projectId: mockProjectId,
|
||||
});
|
||||
const cmd = mockTestCommand(WorkflowLogView, [], ctx);
|
||||
jest
|
||||
.mocked(AppQuery.byIdWorkflowRunsFilteredByStatusAsync)
|
||||
.mockResolvedValue(getMockWorkflowRunsFragment({ withJobs: 1 }));
|
||||
let promptCalls = 0;
|
||||
jest.mocked(promptAsync).mockImplementation(async () => {
|
||||
if (promptCalls === 0) {
|
||||
promptCalls++;
|
||||
return { selectedRun: 'build1' };
|
||||
} else if (promptCalls === 1) {
|
||||
promptCalls++;
|
||||
return { selectedJob: 0 };
|
||||
} else {
|
||||
return { selectedStep: 'step1' };
|
||||
}
|
||||
});
|
||||
jest.mocked(fetchRawLogsForCustomJobAsync).mockResolvedValue(null);
|
||||
await cmd.run();
|
||||
expect(AppQuery.byIdWorkflowRunsFilteredByStatusAsync).toHaveBeenCalledWith(
|
||||
ctx.loggedIn.graphqlClient,
|
||||
mockProjectId,
|
||||
undefined,
|
||||
20
|
||||
);
|
||||
expect(promptAsync).toHaveBeenCalledTimes(2);
|
||||
expect(Log.error).toHaveBeenCalledWith('No logs found');
|
||||
});
|
||||
test('view logs, passing in no parameters, runs found, jobs found, step selected, logs found', async () => {
|
||||
const ctx = mockCommandContext(WorkflowLogView, {
|
||||
projectId: mockProjectId,
|
||||
});
|
||||
const cmd = mockTestCommand(WorkflowLogView, [], ctx);
|
||||
jest
|
||||
.mocked(AppQuery.byIdWorkflowRunsFilteredByStatusAsync)
|
||||
.mockResolvedValue(getMockWorkflowRunsFragment({ withJobs: 1 }));
|
||||
let promptCalls = 0;
|
||||
jest.mocked(promptAsync).mockImplementation(async () => {
|
||||
if (promptCalls === 0) {
|
||||
promptCalls++;
|
||||
return { selectedRun: 'build1' };
|
||||
} else if (promptCalls === 1) {
|
||||
promptCalls++;
|
||||
return { selectedJob: 0 };
|
||||
} else {
|
||||
return { selectedStep: 'step1' };
|
||||
}
|
||||
});
|
||||
jest
|
||||
.mocked(fetchRawLogsForCustomJobAsync)
|
||||
.mockResolvedValue(
|
||||
'{"result":"test","marker":"test","buildStepDisplayName":"step1","buildStepInternalId":"step1","time":"2022-01-01T00:00:00.000Z","msg":"test"}'
|
||||
);
|
||||
await cmd.run();
|
||||
expect(AppQuery.byIdWorkflowRunsFilteredByStatusAsync).toHaveBeenCalledWith(
|
||||
ctx.loggedIn.graphqlClient,
|
||||
mockProjectId,
|
||||
undefined,
|
||||
20
|
||||
);
|
||||
expect(promptAsync).toHaveBeenCalledTimes(3);
|
||||
expect(Log.error).not.toHaveBeenCalled();
|
||||
expect(Log.log).toHaveBeenCalledWith(' 2022-01-01T00:00:00.000Z test');
|
||||
});
|
||||
test('view build logs, passing in no parameters, runs found, jobs found, step selected, logs found', async () => {
|
||||
const ctx = mockCommandContext(WorkflowLogView, {
|
||||
projectId: mockProjectId,
|
||||
});
|
||||
const cmd = mockTestCommand(WorkflowLogView, [], ctx);
|
||||
jest
|
||||
.mocked(AppQuery.byIdWorkflowRunsFilteredByStatusAsync)
|
||||
.mockResolvedValue(getMockWorkflowRunsFragment({ withBuildJobs: 1 }));
|
||||
jest.mocked(BuildQuery.byIdAsync).mockResolvedValue({
|
||||
id: 'build1',
|
||||
status: BuildStatus.Finished,
|
||||
priority: BuildPriority.Normal,
|
||||
createdAt: '2022-01-01T00:00:00.000Z',
|
||||
updatedAt: '2022-01-01T00:00:00.000Z',
|
||||
isForIosSimulator: false,
|
||||
project: {
|
||||
id: mockProjectId,
|
||||
__typename: 'App',
|
||||
name: 'App',
|
||||
slug: 'app',
|
||||
ownerAccount: {
|
||||
id: 'account-id',
|
||||
name: 'account-name',
|
||||
__typename: 'Account',
|
||||
},
|
||||
},
|
||||
platform: AppPlatform.Android,
|
||||
logFiles: ['https://example.com/log1'],
|
||||
});
|
||||
let promptCalls = 0;
|
||||
jest.mocked(promptAsync).mockImplementation(async () => {
|
||||
if (promptCalls === 0) {
|
||||
promptCalls++;
|
||||
return { selectedRun: 'build1' };
|
||||
} else if (promptCalls === 1) {
|
||||
promptCalls++;
|
||||
return { selectedJob: 0 };
|
||||
} else {
|
||||
return { selectedStep: 'step1' };
|
||||
}
|
||||
});
|
||||
jest
|
||||
.mocked(fetchRawLogsForBuildJobAsync)
|
||||
.mockResolvedValue(
|
||||
'{"result":"test","marker":"test","buildStepDisplayName":"step1","buildStepInternalId":"step1","time":"2022-01-01T00:00:00.000Z","msg":"test"}'
|
||||
);
|
||||
await cmd.run();
|
||||
expect(AppQuery.byIdWorkflowRunsFilteredByStatusAsync).toHaveBeenCalledWith(
|
||||
ctx.loggedIn.graphqlClient,
|
||||
mockProjectId,
|
||||
undefined,
|
||||
20
|
||||
);
|
||||
expect(promptAsync).toHaveBeenCalledTimes(3);
|
||||
expect(Log.error).not.toHaveBeenCalled();
|
||||
expect(Log.log).toHaveBeenCalledWith(' 2022-01-01T00:00:00.000Z test');
|
||||
});
|
||||
test('view logs for a workflow job, passing in a job ID, all steps', async () => {
|
||||
const ctx = mockCommandContext(WorkflowLogView, {
|
||||
projectId: mockProjectId,
|
||||
});
|
||||
const cmd = mockTestCommand(WorkflowLogView, ['job1', '--all-steps'], ctx);
|
||||
await cmd.run();
|
||||
expect(WorkflowJobQuery.byIdAsync).toHaveBeenCalledWith(ctx.loggedIn.graphqlClient, 'job1', {
|
||||
useCache: false,
|
||||
});
|
||||
});
|
||||
test('view logs for a workflow job, passing in a run ID', async () => {
|
||||
const ctx = mockCommandContext(WorkflowLogView, {
|
||||
projectId: mockProjectId,
|
||||
});
|
||||
jest.mocked(promptAsync).mockResolvedValue({ selectedJob: 'job1' });
|
||||
jest.mocked(WorkflowJobQuery.byIdAsync).mockRejectedValue(new Error('Job not found'));
|
||||
const cmd = mockTestCommand(WorkflowLogView, ['build1', '--all-steps'], ctx);
|
||||
await cmd.run();
|
||||
expect(WorkflowRunQuery.withJobsByIdAsync).toHaveBeenCalledWith(
|
||||
ctx.loggedIn.graphqlClient,
|
||||
'build1',
|
||||
{
|
||||
useCache: false,
|
||||
}
|
||||
);
|
||||
});
|
||||
test('view logs for a workflow job, passing in a run ID, non-interactive', async () => {
|
||||
const ctx = mockCommandContext(WorkflowLogView, {
|
||||
projectId: mockProjectId,
|
||||
});
|
||||
jest.mocked(promptAsync).mockResolvedValue({ selectedJob: 'job1' });
|
||||
jest.mocked(WorkflowJobQuery.byIdAsync).mockRejectedValue(new Error('Job not found'));
|
||||
const cmd = mockTestCommand(WorkflowLogView, ['build1', '--non-interactive'], ctx);
|
||||
try {
|
||||
await cmd.run();
|
||||
} catch (e) {
|
||||
expect(e).toBeInstanceOf(Error);
|
||||
expect((e as Error).message).toEqual('No workflow job found that matched the provided ID');
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,184 @@
|
||||
import {
|
||||
getMockEmptyWorkflowRunsFragment,
|
||||
getMockWorkflowRunsFragment,
|
||||
mockCommandContext,
|
||||
mockProjectId,
|
||||
mockTestCommand,
|
||||
} from './utils';
|
||||
import WorkflowRunList from '../../commands/workflow/runs';
|
||||
import { WorkflowRunStatus, WorkflowRunTriggerEventType } from '../../graphql/generated';
|
||||
import { AppQuery } from '../../graphql/queries/AppQuery';
|
||||
import { WorkflowRunQuery } from '../../graphql/queries/WorkflowRunQuery';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
|
||||
|
||||
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/queries/WorkflowRunQuery');
|
||||
jest.mock('../../graphql/mutations/AppVersionMutation');
|
||||
jest.mock('../../project/workflow');
|
||||
jest.mock('../../project/android/gradleUtils');
|
||||
jest.mock('../../project/ios/target');
|
||||
jest.mock('../../project/ios/scheme');
|
||||
jest.mock('fs');
|
||||
jest.mock('../../log');
|
||||
jest.mock('../../prompts');
|
||||
jest.mock('../../utils/json');
|
||||
|
||||
describe(WorkflowRunList, () => {
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
test('list workflow runs with default params', async () => {
|
||||
const ctx = mockCommandContext(WorkflowRunList, {
|
||||
projectId: mockProjectId,
|
||||
});
|
||||
jest
|
||||
.mocked(AppQuery.byIdWorkflowRunsFilteredByStatusAsync)
|
||||
.mockResolvedValue(getMockEmptyWorkflowRunsFragment());
|
||||
const cmd = mockTestCommand(WorkflowRunList, [], ctx);
|
||||
await cmd.run();
|
||||
expect(AppQuery.byIdWorkflowRunsFilteredByStatusAsync).toHaveBeenCalledWith(
|
||||
ctx.loggedIn.graphqlClient,
|
||||
mockProjectId,
|
||||
undefined,
|
||||
10
|
||||
);
|
||||
expect(enableJsonOutput).not.toHaveBeenCalled();
|
||||
expect(printJsonOnlyOutput).not.toHaveBeenCalled();
|
||||
});
|
||||
test('list workflow runs with custom limit', async () => {
|
||||
const ctx = mockCommandContext(WorkflowRunList, {
|
||||
projectId: mockProjectId,
|
||||
});
|
||||
jest
|
||||
.mocked(AppQuery.byIdWorkflowRunsFilteredByStatusAsync)
|
||||
.mockResolvedValue(getMockEmptyWorkflowRunsFragment());
|
||||
const cmd = mockTestCommand(WorkflowRunList, ['--limit', '100'], ctx);
|
||||
await cmd.run();
|
||||
expect(AppQuery.byIdWorkflowRunsFilteredByStatusAsync).toHaveBeenCalledWith(
|
||||
ctx.loggedIn.graphqlClient,
|
||||
mockProjectId,
|
||||
undefined,
|
||||
100
|
||||
);
|
||||
expect(enableJsonOutput).not.toHaveBeenCalled();
|
||||
expect(printJsonOnlyOutput).not.toHaveBeenCalled();
|
||||
});
|
||||
test('list workflow runs with specific filename', async () => {
|
||||
const ctx = mockCommandContext(WorkflowRunList, {
|
||||
projectId: mockProjectId,
|
||||
});
|
||||
jest.mocked(WorkflowRunQuery.byAppIdFileNameAndStatusAsync).mockResolvedValue([
|
||||
{
|
||||
id: 'build',
|
||||
status: WorkflowRunStatus.Success,
|
||||
triggerEventType: WorkflowRunTriggerEventType.Manual,
|
||||
createdAt: '2022-01-01T00:00:00.000Z',
|
||||
updatedAt: '2022-01-01T00:00:00.000Z',
|
||||
errors: [],
|
||||
gitCommitHash: '1234567890',
|
||||
gitCommitMessage: 'commit message',
|
||||
workflow: {
|
||||
id: 'build',
|
||||
name: 'build',
|
||||
fileName: 'build.yml',
|
||||
},
|
||||
},
|
||||
]);
|
||||
const cmd = mockTestCommand(WorkflowRunList, ['--workflow', 'build.yml'], ctx);
|
||||
await cmd.run();
|
||||
expect(WorkflowRunQuery.byAppIdFileNameAndStatusAsync).toHaveBeenCalledWith(
|
||||
ctx.loggedIn.graphqlClient,
|
||||
mockProjectId,
|
||||
'build.yml',
|
||||
undefined,
|
||||
10
|
||||
);
|
||||
expect(enableJsonOutput).not.toHaveBeenCalled();
|
||||
expect(printJsonOnlyOutput).not.toHaveBeenCalled();
|
||||
});
|
||||
test('list only workflow runs with status FAILURE, get json output', async () => {
|
||||
const ctx = mockCommandContext(WorkflowRunList, {
|
||||
projectId: mockProjectId,
|
||||
});
|
||||
jest
|
||||
.mocked(AppQuery.byIdWorkflowRunsFilteredByStatusAsync)
|
||||
.mockResolvedValue(getMockWorkflowRunsFragment({ failures: 1 }));
|
||||
const cmd = mockTestCommand(WorkflowRunList, ['--status', 'FAILURE', '--json'], ctx);
|
||||
await cmd.run();
|
||||
expect(AppQuery.byIdWorkflowRunsFilteredByStatusAsync).toHaveBeenCalledWith(
|
||||
ctx.loggedIn.graphqlClient,
|
||||
mockProjectId,
|
||||
WorkflowRunStatus.Failure,
|
||||
10
|
||||
);
|
||||
expect(printJsonOnlyOutput).toHaveBeenCalledWith([
|
||||
{
|
||||
id: 'failure-0',
|
||||
status: 'FAILURE',
|
||||
gitCommitHash: '1234567890',
|
||||
gitCommitMessage: 'commit message',
|
||||
trigger: '',
|
||||
triggerType: 'Other',
|
||||
startedAt: '2022-01-01T00:00:00.000Z',
|
||||
finishedAt: '2022-01-01T00:00:00.000Z',
|
||||
workflowId: 'build',
|
||||
workflowName: 'build',
|
||||
workflowFileName: 'build.yml',
|
||||
},
|
||||
]);
|
||||
});
|
||||
test('list only workflow runs with specific filename and status FAILURE, get json output', async () => {
|
||||
const ctx = mockCommandContext(WorkflowRunList, {
|
||||
projectId: mockProjectId,
|
||||
});
|
||||
jest.mocked(WorkflowRunQuery.byAppIdFileNameAndStatusAsync).mockResolvedValue([
|
||||
{
|
||||
id: 'build1',
|
||||
status: WorkflowRunStatus.Failure,
|
||||
triggerEventType: WorkflowRunTriggerEventType.Manual,
|
||||
createdAt: '2022-01-01T00:00:00.000Z',
|
||||
updatedAt: '2022-01-01T00:00:00.000Z',
|
||||
gitCommitHash: '1234567890',
|
||||
gitCommitMessage: 'commit message',
|
||||
errors: [],
|
||||
workflow: {
|
||||
id: 'build',
|
||||
name: 'build',
|
||||
fileName: 'build.yml',
|
||||
},
|
||||
},
|
||||
]);
|
||||
const cmd = mockTestCommand(
|
||||
WorkflowRunList,
|
||||
['--workflow', 'build.yml', '--status', 'FAILURE', '--json'],
|
||||
ctx
|
||||
);
|
||||
await cmd.run();
|
||||
expect(WorkflowRunQuery.byAppIdFileNameAndStatusAsync).toHaveBeenCalledWith(
|
||||
ctx.loggedIn.graphqlClient,
|
||||
mockProjectId,
|
||||
'build.yml',
|
||||
WorkflowRunStatus.Failure,
|
||||
10
|
||||
);
|
||||
expect(printJsonOnlyOutput).toHaveBeenCalledWith([
|
||||
{
|
||||
id: 'build1',
|
||||
status: 'FAILURE',
|
||||
gitCommitHash: '1234567890',
|
||||
gitCommitMessage: 'commit message',
|
||||
trigger: '',
|
||||
triggerType: 'Manual',
|
||||
startedAt: '2022-01-01T00:00:00.000Z',
|
||||
finishedAt: '2022-01-01T00:00:00.000Z',
|
||||
workflowId: 'build',
|
||||
workflowName: 'build',
|
||||
workflowFileName: 'build.yml',
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -95,7 +95,11 @@ export function getExpoWebsiteBaseUrl(): string {
|
||||
}
|
||||
}
|
||||
|
||||
export function getEASUpdateURL(projectId: string): string {
|
||||
export function getEASUpdateURL(projectId: string, manifestHostOverride: string | null): string {
|
||||
if (manifestHostOverride) {
|
||||
return new URL(projectId, `https://${manifestHostOverride}`).href;
|
||||
}
|
||||
|
||||
if (process.env.EXPO_STAGING) {
|
||||
return new URL(projectId, `https://staging-u.expo.dev`).href;
|
||||
} else if (process.env.EXPO_LOCAL) {
|
||||
@@ -104,3 +108,7 @@ export function getEASUpdateURL(projectId: string): string {
|
||||
return new URL(projectId, `https://u.expo.dev`).href;
|
||||
}
|
||||
}
|
||||
|
||||
export function getExpoApiWorkflowSchemaURL(): string {
|
||||
return getExpoApiBaseUrl() + '/v2/workflows/schema';
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import gql from 'graphql-tag';
|
||||
|
||||
import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { withErrorHandlingAsync } from '../graphql/client';
|
||||
import {
|
||||
BackgroundJobReceiptDataFragment,
|
||||
ScheduleBranchDeletionMutation,
|
||||
ScheduleBranchDeletionMutationVariables,
|
||||
} from '../graphql/generated';
|
||||
import { BackgroundJobReceiptNode } from '../graphql/types/BackgroundJobReceipt';
|
||||
|
||||
export async function scheduleBranchDeletionAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
{
|
||||
branchId,
|
||||
}: {
|
||||
branchId: string;
|
||||
}
|
||||
): Promise<BackgroundJobReceiptDataFragment> {
|
||||
const result = await withErrorHandlingAsync(
|
||||
graphqlClient
|
||||
.mutation<ScheduleBranchDeletionMutation, ScheduleBranchDeletionMutationVariables>(
|
||||
gql`
|
||||
mutation ScheduleBranchDeletion($branchId: ID!) {
|
||||
updateBranch {
|
||||
scheduleUpdateBranchDeletion(branchId: $branchId) {
|
||||
id
|
||||
...BackgroundJobReceiptData
|
||||
}
|
||||
}
|
||||
}
|
||||
${BackgroundJobReceiptNode}
|
||||
`,
|
||||
{ branchId }
|
||||
)
|
||||
.toPromise()
|
||||
);
|
||||
return result.updateBranch.scheduleUpdateBranchDeletion;
|
||||
}
|
||||
@@ -72,6 +72,7 @@ function createMockBuildFragment({
|
||||
updatedAt: new Date().toISOString(),
|
||||
platform: AppPlatform.Android,
|
||||
id: buildId ?? uuid(),
|
||||
logFiles: [],
|
||||
priority: BuildPriority.Normal,
|
||||
project: {
|
||||
__typename: 'App',
|
||||
|
||||
@@ -38,7 +38,7 @@ describe(ensureProjectConfiguredAsync, () => {
|
||||
}),
|
||||
});
|
||||
await expect(fs.pathExists(EasJsonAccessor.formatEasJsonPath('.'))).resolves.toBeTruthy();
|
||||
const vcsClientMock = jest.mocked(new GitClient());
|
||||
const vcsClientMock = jest.mocked(new GitClient({ requireCommit: false }));
|
||||
vcsClientMock.showChangedFilesAsync.mockImplementation(async () => {});
|
||||
vcsClientMock.isCommitRequiredAsync.mockImplementation(async () => false);
|
||||
vcsClientMock.trackFileAsync.mockImplementation(async () => {});
|
||||
@@ -60,7 +60,7 @@ describe(ensureProjectConfiguredAsync, () => {
|
||||
});
|
||||
});
|
||||
await expect(fs.pathExists(EasJsonAccessor.formatEasJsonPath('.'))).resolves.toBeFalsy();
|
||||
const vcsClientMock = jest.mocked(new GitClient());
|
||||
const vcsClientMock = jest.mocked(new GitClient({ requireCommit: false }));
|
||||
vcsClientMock.showChangedFilesAsync.mockImplementation(async () => {});
|
||||
vcsClientMock.isCommitRequiredAsync.mockImplementation(async () => false);
|
||||
vcsClientMock.trackFileAsync.mockImplementation(async () => {});
|
||||
|
||||
@@ -70,6 +70,7 @@ function createMockBuildFragment({
|
||||
return {
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
logFiles: [],
|
||||
platform: AppPlatform.Android,
|
||||
id: buildId ?? uuid(),
|
||||
priority: BuildPriority.Normal,
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import Log from '../../../log';
|
||||
import { maybeWarnAboutNonStandardBuildType } from '../build';
|
||||
|
||||
const warn = jest.spyOn(Log, 'warn');
|
||||
|
||||
describe('maybeWarnAboutNonStandardBuildType', () => {
|
||||
it('should not warn about non-standard build type', () => {
|
||||
const buildProfile = {
|
||||
gradleCommand: ':app:buildRelease',
|
||||
};
|
||||
|
||||
maybeWarnAboutNonStandardBuildType({ buildProfile, buildType: 'release' });
|
||||
|
||||
expect(warn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should warn about non-standard build type', () => {
|
||||
const buildProfile = {
|
||||
gradleCommand: ':app:buildStage',
|
||||
};
|
||||
|
||||
maybeWarnAboutNonStandardBuildType({ buildProfile, buildType: 'stage' });
|
||||
|
||||
expect(warn).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Android, Metadata, Platform, Workflow } from '@expo/eas-build-job';
|
||||
import { Android, Platform, Workflow } from '@expo/eas-build-job';
|
||||
import { AppVersionSource } from '@expo/eas-json';
|
||||
import chalk from 'chalk';
|
||||
import getenv from 'getenv';
|
||||
import nullthrows from 'nullthrows';
|
||||
|
||||
import { transformJob } from './graphql';
|
||||
@@ -10,9 +11,9 @@ import { resolveRemoteVersionCodeAsync } from './version';
|
||||
import AndroidCredentialsProvider, {
|
||||
AndroidCredentials,
|
||||
} from '../../credentials/android/AndroidCredentialsProvider';
|
||||
import { BuildParamsInput } from '../../graphql/generated';
|
||||
import { BuildMetadataInput, BuildParamsInput } from '../../graphql/generated';
|
||||
import { BuildMutation, BuildResult } from '../../graphql/mutations/BuildMutation';
|
||||
import Log from '../../log';
|
||||
import Log, { learnMore } from '../../log';
|
||||
import {
|
||||
ensureApplicationIdIsDefinedForManagedProjectAsync,
|
||||
getApplicationIdAsync,
|
||||
@@ -26,7 +27,6 @@ import {
|
||||
prepareBuildRequestForPlatformAsync,
|
||||
} from '../build';
|
||||
import { AndroidBuildContext, BuildContext, CommonContext } from '../context';
|
||||
import { transformMetadata } from '../graphql';
|
||||
import { logCredentialsSource } from '../utils/credentials';
|
||||
import {
|
||||
checkGoogleServicesFileAsync,
|
||||
@@ -65,6 +65,10 @@ This means that it will most likely produce an AAB and you will not be able to i
|
||||
ctx.vcsClient
|
||||
);
|
||||
|
||||
if (gradleContext?.buildType) {
|
||||
maybeWarnAboutNonStandardBuildType({ buildProfile, buildType: gradleContext.buildType });
|
||||
}
|
||||
|
||||
if (ctx.workflow === Workflow.MANAGED) {
|
||||
await ensureApplicationIdIsDefinedForManagedProjectAsync(ctx);
|
||||
}
|
||||
@@ -119,10 +123,9 @@ export async function prepareAndroidBuildAsync(
|
||||
sendBuildRequestAsync: async (
|
||||
appId: string,
|
||||
job: Android.Job,
|
||||
metadata: Metadata,
|
||||
graphqlMetadata: BuildMetadataInput,
|
||||
buildParams: BuildParamsInput
|
||||
): Promise<BuildResult> => {
|
||||
const graphqlMetadata = transformMetadata(metadata);
|
||||
const graphqlJob = transformJob(job);
|
||||
return await BuildMutation.createAndroidBuildAsync(ctx.graphqlClient, {
|
||||
appId,
|
||||
@@ -168,3 +171,38 @@ async function ensureAndroidCredentialsAsync(
|
||||
source: credentialsSource,
|
||||
};
|
||||
}
|
||||
|
||||
export function maybeWarnAboutNonStandardBuildType({
|
||||
buildProfile,
|
||||
buildType,
|
||||
}: {
|
||||
buildProfile: Pick<CommonContext<Platform.ANDROID>['buildProfile'], 'gradleCommand'>;
|
||||
buildType: string;
|
||||
}): void {
|
||||
const shouldSuppressWarning = getenv.boolish('EAS_BUILD_NO_GRADLE_BUILD_TYPE_WARNING', false);
|
||||
|
||||
if (shouldSuppressWarning) {
|
||||
return;
|
||||
}
|
||||
|
||||
const knownValidBuildTypes = ['debug', 'release'];
|
||||
if (!knownValidBuildTypes.includes(buildType)) {
|
||||
Log.warn(
|
||||
`Custom gradle command "${chalk.bold(
|
||||
buildProfile.gradleCommand
|
||||
)}" has non-standard build type: "${chalk.bold(
|
||||
buildType
|
||||
)}". Expected the command to end with "${chalk.bold('...Debug')}" or "${chalk.bold(
|
||||
'...Release'
|
||||
)}"`
|
||||
);
|
||||
Log.warn(
|
||||
`Ensure the command is spelled correctly and ${chalk.bold(
|
||||
'build.gradle'
|
||||
)} is configured correctly. To suppress this warning, set ${chalk.bold(
|
||||
'EAS_BUILD_NO_GRADLE_BUILD_TYPE_WARNING=true'
|
||||
)}.`
|
||||
);
|
||||
Log.warn(learnMore('https://developer.android.com/build/build-variants#build-types'));
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,7 @@ export function transformJob(job: Android.Job): AndroidJobInput {
|
||||
experimental: job.experimental,
|
||||
mode: transformBuildMode(job.mode),
|
||||
customBuildConfig: job.customBuildConfig,
|
||||
environment: job.environment,
|
||||
loggerLevel: job.loggerLevel
|
||||
? loggerLevelToGraphQLWorkerLoggerLevel[job.loggerLevel]
|
||||
: undefined,
|
||||
|
||||
@@ -12,7 +12,7 @@ import slash from 'slash';
|
||||
|
||||
import { AndroidCredentials } from '../../credentials/android/AndroidCredentialsProvider';
|
||||
import { getCustomBuildConfigPathForJob } from '../../project/customBuildConfig';
|
||||
import { getUsername } from '../../project/projectUtils';
|
||||
import { getUsernameForBuildMetadataAndBuildJob } from '../../project/projectUtils';
|
||||
import { BuildContext } from '../context';
|
||||
|
||||
interface JobData {
|
||||
@@ -29,7 +29,7 @@ export async function prepareJobAsync(
|
||||
ctx: BuildContext<Platform.ANDROID>,
|
||||
jobData: JobData
|
||||
): Promise<Android.Job> {
|
||||
const username = getUsername(ctx.exp, ctx.user);
|
||||
const username = getUsernameForBuildMetadataAndBuildJob(ctx.user);
|
||||
const buildProfile: BuildProfile<Platform.ANDROID> = ctx.buildProfile;
|
||||
const projectRootDirectory =
|
||||
slash(path.relative(await ctx.vcsClient.getRootPathAsync(), ctx.projectDir)) || '.';
|
||||
@@ -57,9 +57,7 @@ export async function prepareJobAsync(
|
||||
: undefined;
|
||||
|
||||
let buildMode;
|
||||
if (ctx.repack) {
|
||||
buildMode = BuildMode.REPACK;
|
||||
} else if (buildProfile.config) {
|
||||
if (buildProfile.config) {
|
||||
buildMode = BuildMode.CUSTOM;
|
||||
} else {
|
||||
buildMode = BuildMode.BUILD;
|
||||
@@ -73,11 +71,15 @@ export async function prepareJobAsync(
|
||||
builderEnvironment: {
|
||||
image: buildProfile.image,
|
||||
node: buildProfile.node,
|
||||
corepack: buildProfile.corepack,
|
||||
pnpm: buildProfile.pnpm,
|
||||
bun: buildProfile.bun,
|
||||
yarn: buildProfile.yarn,
|
||||
ndk: buildProfile.ndk,
|
||||
env: buildProfile.env,
|
||||
env: {
|
||||
...buildProfile.env,
|
||||
...(ctx.isVerboseLoggingEnabled ? { EAS_VERBOSE: '1' } : {}),
|
||||
},
|
||||
},
|
||||
cache: {
|
||||
...cacheDefaults,
|
||||
@@ -92,6 +94,7 @@ export async function prepareJobAsync(
|
||||
gradleCommand: buildProfile.gradleCommand,
|
||||
applicationArchivePath: buildProfile.applicationArchivePath ?? buildProfile.artifactPath,
|
||||
buildArtifactPaths: buildProfile.buildArtifactPaths,
|
||||
environment: ctx.buildProfile.environment,
|
||||
buildType,
|
||||
username,
|
||||
...(ctx.android.versionCodeOverride && {
|
||||
@@ -109,11 +112,6 @@ export async function prepareJobAsync(
|
||||
path: maybeCustomBuildConfigPath,
|
||||
},
|
||||
}),
|
||||
...(ctx.repack && {
|
||||
customBuildConfig: {
|
||||
path: '__eas/repack.yml',
|
||||
},
|
||||
}),
|
||||
loggerLevel: ctx.loggerLevel,
|
||||
// Technically, these are unused, but let's include them here for type consistency.
|
||||
// See: https://github.com/expo/eas-build/pull/454
|
||||
|
||||
@@ -2,7 +2,6 @@ import {
|
||||
ArchiveSource,
|
||||
ArchiveSourceType,
|
||||
BuildJob,
|
||||
FingerprintSource,
|
||||
Metadata,
|
||||
Platform,
|
||||
} from '@expo/eas-build-job';
|
||||
@@ -35,8 +34,10 @@ import { collectMetadataAsync } from './metadata';
|
||||
import { printDeprecationWarnings } from './utils/printBuildInfo';
|
||||
import {
|
||||
LocalFile,
|
||||
assertProjectTarballSizeDoesNotExceedLimit,
|
||||
makeProjectMetadataFileAsync,
|
||||
makeProjectTarballAsync,
|
||||
maybeWarnAboutProjectTarballSize,
|
||||
reviewAndCommitChangesAsync,
|
||||
} from './utils/repository';
|
||||
import { BuildEvent } from '../analytics/AnalyticsManager';
|
||||
@@ -44,12 +45,15 @@ import { withAnalyticsAsync } from '../analytics/common';
|
||||
import { getExpoWebsiteBaseUrl } from '../api';
|
||||
import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { EasCommandError } from '../commandUtils/errors';
|
||||
import { createFingerprintAsync } from '../fingerprint/cli';
|
||||
import {
|
||||
AppPlatform,
|
||||
BuildFragment,
|
||||
BuildMetadataInput,
|
||||
BuildParamsInput,
|
||||
BuildPriority,
|
||||
BuildStatus,
|
||||
FingerprintSourceInput,
|
||||
UploadSessionType,
|
||||
} from '../graphql/generated';
|
||||
import { BuildMutation, BuildResult } from '../graphql/mutations/BuildMutation';
|
||||
@@ -66,7 +70,6 @@ import { maybeUploadFingerprintAsync } from '../project/maybeUploadFingerprintAs
|
||||
import { resolveRuntimeVersionAsync } from '../project/resolveRuntimeVersionAsync';
|
||||
import { uploadFileAtPathToGCSAsync } from '../uploads';
|
||||
import { formatBytes } from '../utils/files';
|
||||
import { createFingerprintAsync } from '../utils/fingerprintCli';
|
||||
import { printJsonOnlyOutput } from '../utils/json';
|
||||
import { createProgressTracker } from '../utils/progress';
|
||||
import { sleepAsync } from '../utils/promise';
|
||||
@@ -92,7 +95,7 @@ interface Builder<TPlatform extends Platform, Credentials, TJob extends BuildJob
|
||||
sendBuildRequestAsync(
|
||||
appId: string,
|
||||
job: TJob,
|
||||
metadata: Metadata,
|
||||
metadata: BuildMetadataInput,
|
||||
buildParams: BuildParamsInput
|
||||
): Promise<BuildResult>;
|
||||
}
|
||||
@@ -191,13 +194,20 @@ export async function prepareBuildRequestForPlatformAsync<
|
||||
} else if (ctx.localBuildOptions.localBuildMode === LocalBuildMode.INTERNAL) {
|
||||
await BuildMutation.updateBuildMetadataAsync(ctx.graphqlClient, {
|
||||
buildId: nullthrows(process.env.EAS_BUILD_ID),
|
||||
metadata: transformMetadata(metadata),
|
||||
metadata: transformMetadata({
|
||||
metadata,
|
||||
fingerprintSource: runtimeAndFingerprintMetadata.fingerprintSource ?? undefined,
|
||||
}),
|
||||
});
|
||||
printJsonOnlyOutput({ job, metadata });
|
||||
return undefined;
|
||||
} else if (!ctx.localBuildOptions.localBuildMode) {
|
||||
try {
|
||||
return await sendBuildRequestAsync(builder, job, metadata, buildParams);
|
||||
const graphqlMetadata = transformMetadata({
|
||||
metadata,
|
||||
fingerprintSource: runtimeAndFingerprintMetadata.fingerprintSource ?? undefined,
|
||||
});
|
||||
return await sendBuildRequestAsync(builder, job, graphqlMetadata, buildParams);
|
||||
} catch (error: any) {
|
||||
handleBuildRequestError(error, job.platform);
|
||||
}
|
||||
@@ -275,19 +285,8 @@ async function uploadProjectAsync<TPlatform extends Platform>(
|
||||
);
|
||||
const projectTarball = await makeProjectTarballAsync(ctx.vcsClient);
|
||||
|
||||
if (projectTarball.size > 1024 * 1024 * 100) {
|
||||
Log.warn(
|
||||
`Your project archive is ${formatBytes(
|
||||
projectTarball.size
|
||||
)}. You can reduce its size and the time it takes to upload by excluding files that are unnecessary for the build process in ${chalk.bold(
|
||||
'.easignore'
|
||||
)} file. ${learnMore('https://expo.fyi/eas-build-archive')}`
|
||||
);
|
||||
}
|
||||
|
||||
if (projectTarball.size > 2 * 1024 * 1024 * 1024) {
|
||||
throw new Error('Project archive is too big. Maximum allowed size is 2GB.');
|
||||
}
|
||||
maybeWarnAboutProjectTarballSize(projectTarball.size);
|
||||
assertProjectTarballSizeDoesNotExceedLimit(projectTarball.size);
|
||||
|
||||
projectTarballPath = projectTarball.path;
|
||||
const [bucketKey, { metadataLocation }] = await Promise.all([
|
||||
@@ -371,7 +370,7 @@ async function sendBuildRequestAsync<
|
||||
>(
|
||||
builder: Builder<TPlatform, Credentials, TJob>,
|
||||
job: TJob,
|
||||
metadata: Metadata,
|
||||
graphqlMetadata: BuildMetadataInput,
|
||||
buildParams: BuildParamsInput
|
||||
): Promise<BuildFragment> {
|
||||
const { ctx } = builder;
|
||||
@@ -385,7 +384,7 @@ async function sendBuildRequestAsync<
|
||||
const { build, deprecationInfo } = await builder.sendBuildRequestAsync(
|
||||
ctx.projectId,
|
||||
job,
|
||||
metadata,
|
||||
graphqlMetadata,
|
||||
buildParams
|
||||
);
|
||||
|
||||
@@ -509,11 +508,7 @@ async function handleSingleBuildProgressAsync(
|
||||
if (build.priority !== BuildPriority.High) {
|
||||
Log.newLine();
|
||||
Log.log('Start builds sooner in the priority queue.');
|
||||
Log.log(
|
||||
`Sign up for EAS Production or Enterprise at ${link(
|
||||
formatAccountBillingUrl(accountName)
|
||||
)}`
|
||||
);
|
||||
Log.log(`Sign up for a paid plan at ${link(formatAccountBillingUrl(accountName))}`);
|
||||
}
|
||||
|
||||
Log.newLine();
|
||||
@@ -667,7 +662,7 @@ async function computeAndMaybeUploadRuntimeAndFingerprintMetadataAsync<T extends
|
||||
): Promise<{
|
||||
runtimeVersion?: string | undefined;
|
||||
fingerprintHash?: string | undefined;
|
||||
fingerprintSource?: FingerprintSource | undefined;
|
||||
fingerprintSource?: FingerprintSourceInput | undefined;
|
||||
}> {
|
||||
const runtimeAndFingerprintMetadata =
|
||||
await computeAndMaybeUploadFingerprintFromExpoUpdatesAsync(ctx);
|
||||
@@ -689,7 +684,7 @@ async function computeAndMaybeUploadFingerprintFromExpoUpdatesAsync<T extends Pl
|
||||
ctx: BuildContext<T>
|
||||
): Promise<{
|
||||
runtimeVersion?: string;
|
||||
fingerprintSource?: FingerprintSource;
|
||||
fingerprintSource?: FingerprintSourceInput;
|
||||
fingerprintHash?: string;
|
||||
}> {
|
||||
const resolvedRuntimeVersion = await resolveRuntimeVersionAsync({
|
||||
@@ -736,7 +731,7 @@ async function computeAndMaybeUploadFingerprintWithoutExpoUpdatesAsync<T extends
|
||||
{ debug }: { debug?: boolean } = {}
|
||||
): Promise<{
|
||||
fingerprintHash?: string;
|
||||
fingerprintSource?: FingerprintSource;
|
||||
fingerprintSource?: FingerprintSourceInput;
|
||||
}> {
|
||||
const fingerprint = await createFingerprintAsync(ctx.projectDir, {
|
||||
workflow: ctx.workflow,
|
||||
|
||||
@@ -4,6 +4,7 @@ import fs from 'fs-extra';
|
||||
|
||||
import { maybeBailOnRepoStatusAsync, reviewAndCommitChangesAsync } from './utils/repository';
|
||||
import Log, { learnMore } from '../log';
|
||||
import { ora } from '../ora';
|
||||
import { easCliVersion } from '../utils/easCli';
|
||||
import { Client } from '../vcs/vcs';
|
||||
|
||||
@@ -52,6 +53,78 @@ async function configureAsync({
|
||||
}
|
||||
}
|
||||
|
||||
export async function doesBuildProfileExistAsync({
|
||||
projectDir,
|
||||
profileName,
|
||||
}: {
|
||||
projectDir: string;
|
||||
profileName: string;
|
||||
}): Promise<boolean> {
|
||||
try {
|
||||
const easJsonAccessor = EasJsonAccessor.fromProjectPath(projectDir);
|
||||
const easJson = await easJsonAccessor.readRawJsonAsync();
|
||||
if (!easJson.build?.[profileName]) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
} catch (error) {
|
||||
Log.error(`We were unable to read ${chalk.bold('eas.json')} contents. Error: ${error}.`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function createBuildProfileAsync({
|
||||
projectDir,
|
||||
profileName,
|
||||
profileContents,
|
||||
vcsClient,
|
||||
nonInteractive,
|
||||
}: {
|
||||
projectDir: string;
|
||||
profileName: string;
|
||||
profileContents: Record<string, any>;
|
||||
vcsClient: Client;
|
||||
nonInteractive: boolean;
|
||||
}): Promise<void> {
|
||||
const spinner = ora(`Adding "${profileName}" build profile to ${chalk.bold('eas.json')}`).start();
|
||||
try {
|
||||
const easJsonAccessor = EasJsonAccessor.fromProjectPath(projectDir);
|
||||
await easJsonAccessor.readRawJsonAsync();
|
||||
|
||||
easJsonAccessor.patch(easJsonRawObject => {
|
||||
return {
|
||||
...easJsonRawObject,
|
||||
build: {
|
||||
...easJsonRawObject.build,
|
||||
[profileName]: profileContents,
|
||||
},
|
||||
};
|
||||
});
|
||||
await easJsonAccessor.writeAsync();
|
||||
spinner.succeed(
|
||||
`Successfully added "${profileName}" build profile to ${chalk.bold('eas.json')}.`
|
||||
);
|
||||
|
||||
if (await vcsClient.isCommitRequiredAsync()) {
|
||||
Log.newLine();
|
||||
await reviewAndCommitChangesAsync(
|
||||
vcsClient,
|
||||
`Add "${profileName}" build profile to eas.json`,
|
||||
{
|
||||
nonInteractive,
|
||||
}
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
spinner.fail(
|
||||
`We were not able to configure "${profileName}" build profile inside of ${chalk.bold(
|
||||
'eas.json'
|
||||
)}. Error: ${error}.`
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
const EAS_JSON_DEFAULT: EasJson = {
|
||||
cli: {
|
||||
version: `>= ${easCliVersion}`,
|
||||
|
||||
@@ -33,6 +33,7 @@ export interface IosBuildContext {
|
||||
}
|
||||
|
||||
export interface BuildContext<T extends Platform> {
|
||||
accountId: string;
|
||||
accountName: string;
|
||||
easJsonCliConfig: EasJson['cli'];
|
||||
buildProfile: BuildProfile<T>;
|
||||
@@ -62,6 +63,7 @@ export interface BuildContext<T extends Platform> {
|
||||
requiredPackageManager: NodePackageManager['name'] | null;
|
||||
vcsClient: Client;
|
||||
loggerLevel?: LoggerLevel;
|
||||
repack: boolean;
|
||||
isVerboseLoggingEnabled: boolean;
|
||||
whatToTest?: string;
|
||||
env: Record<string, string>;
|
||||
}
|
||||
@@ -42,7 +42,8 @@ export async function createBuildContextAsync<T extends Platform>({
|
||||
customBuildConfigMetadata,
|
||||
buildLoggerLevel,
|
||||
freezeCredentials,
|
||||
repack,
|
||||
isVerboseLoggingEnabled,
|
||||
whatToTest,
|
||||
env,
|
||||
}: {
|
||||
buildProfileName: string;
|
||||
@@ -64,7 +65,8 @@ export async function createBuildContextAsync<T extends Platform>({
|
||||
customBuildConfigMetadata?: CustomBuildConfigMetadata;
|
||||
buildLoggerLevel?: LoggerLevel;
|
||||
freezeCredentials: boolean;
|
||||
repack: boolean;
|
||||
isVerboseLoggingEnabled: boolean;
|
||||
whatToTest?: string;
|
||||
env: Record<string, string>;
|
||||
}): Promise<BuildContext<T>> {
|
||||
const { exp, projectId } = await getDynamicPrivateProjectConfigAsync({
|
||||
@@ -123,6 +125,7 @@ export async function createBuildContextAsync<T extends Platform>({
|
||||
);
|
||||
|
||||
const commonContext: CommonContext<T> = {
|
||||
accountId,
|
||||
accountName: account.name,
|
||||
buildProfile,
|
||||
buildProfileName,
|
||||
@@ -150,8 +153,9 @@ export async function createBuildContextAsync<T extends Platform>({
|
||||
developmentClient,
|
||||
requiredPackageManager,
|
||||
loggerLevel: buildLoggerLevel,
|
||||
repack,
|
||||
isVerboseLoggingEnabled,
|
||||
env,
|
||||
whatToTest,
|
||||
};
|
||||
if (platform === Platform.ANDROID) {
|
||||
const common = commonContext as CommonContext<Platform.ANDROID>;
|
||||
|
||||
@@ -1,17 +1,11 @@
|
||||
import { Env } from '@expo/eas-build-job';
|
||||
import { BuildProfile } from '@expo/eas-json';
|
||||
|
||||
import { DefaultEnvironment } from './utils/environment';
|
||||
import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { EnvironmentVariableEnvironment } from '../graphql/generated';
|
||||
import { EnvironmentVariablesQuery } from '../graphql/queries/EnvironmentVariablesQuery';
|
||||
import Log, { learnMore } from '../log';
|
||||
|
||||
function isEnvironment(env: string): env is EnvironmentVariableEnvironment {
|
||||
return Object.values(EnvironmentVariableEnvironment).includes(
|
||||
env as EnvironmentVariableEnvironment
|
||||
);
|
||||
}
|
||||
|
||||
export async function evaluateConfigWithEnvVarsAsync<Config extends { projectId: string }, Opts>({
|
||||
buildProfile,
|
||||
buildProfileName,
|
||||
@@ -54,20 +48,9 @@ async function resolveEnvVarsAsync({
|
||||
projectId: string;
|
||||
}): Promise<Env> {
|
||||
const environment =
|
||||
buildProfile.environment?.toUpperCase() ??
|
||||
buildProfile.environment ??
|
||||
resolveSuggestedEnvironmentForBuildProfileConfiguration(buildProfile);
|
||||
|
||||
if (!isEnvironment(environment)) {
|
||||
Log.log(
|
||||
`Loaded "env" configuration for the "${buildProfileName}" profile: ${
|
||||
buildProfile.env && Object.keys(buildProfile.env).length > 0
|
||||
? Object.keys(buildProfile.env).join(', ')
|
||||
: 'no environment variables specified'
|
||||
}. ${learnMore('https://docs.expo.dev/build-reference/variables/')}`
|
||||
);
|
||||
return { ...buildProfile.env };
|
||||
}
|
||||
|
||||
try {
|
||||
const environmentVariables = await EnvironmentVariablesQuery.byAppIdWithSensitiveAsync(
|
||||
graphqlClient,
|
||||
@@ -134,16 +117,16 @@ async function resolveEnvVarsAsync({
|
||||
|
||||
function resolveSuggestedEnvironmentForBuildProfileConfiguration(
|
||||
buildProfile: BuildProfile
|
||||
): EnvironmentVariableEnvironment {
|
||||
): DefaultEnvironment {
|
||||
const environment =
|
||||
buildProfile.distribution === 'store'
|
||||
? EnvironmentVariableEnvironment.Production
|
||||
? DefaultEnvironment.Production
|
||||
: buildProfile.developmentClient
|
||||
? EnvironmentVariableEnvironment.Development
|
||||
: EnvironmentVariableEnvironment.Preview;
|
||||
? DefaultEnvironment.Development
|
||||
: DefaultEnvironment.Preview;
|
||||
|
||||
Log.log(
|
||||
`Resolved "${environment.toLowerCase()}" environment for the build. ${learnMore(
|
||||
`Resolved "${environment}" environment for the build. ${learnMore(
|
||||
'https://docs.expo.dev/eas/environment-variables/#setting-the-environment-for-your-builds'
|
||||
)}`
|
||||
);
|
||||
|
||||
@@ -3,7 +3,6 @@ import {
|
||||
ArchiveSourceType,
|
||||
BuildMode,
|
||||
BuildTrigger,
|
||||
FingerprintSourceType,
|
||||
Metadata,
|
||||
Workflow,
|
||||
} from '@expo/eas-build-job';
|
||||
@@ -19,7 +18,6 @@ import {
|
||||
FingerprintSourceInput,
|
||||
BuildMode as GraphQLBuildMode,
|
||||
BuildTrigger as GraphQLBuildTrigger,
|
||||
FingerprintSourceType as GraphQLFingeprintSourceType,
|
||||
ProjectArchiveSourceInput,
|
||||
ProjectArchiveSourceType,
|
||||
WorkerLoggerLevel,
|
||||
@@ -42,11 +40,16 @@ export function transformProjectArchive(archiveSource: ArchiveSource): ProjectAr
|
||||
}
|
||||
}
|
||||
|
||||
export function transformMetadata(metadata: Metadata): BuildMetadataInput {
|
||||
export function transformMetadata({
|
||||
metadata,
|
||||
fingerprintSource,
|
||||
}: {
|
||||
metadata: Metadata;
|
||||
fingerprintSource: FingerprintSourceInput | undefined;
|
||||
}): BuildMetadataInput {
|
||||
return {
|
||||
...metadata,
|
||||
fingerprintSource:
|
||||
metadata.fingerprintSource && transformFingerprintSource(metadata.fingerprintSource),
|
||||
fingerprintSource,
|
||||
credentialsSource:
|
||||
metadata.credentialsSource && transformCredentialsSource(metadata.credentialsSource),
|
||||
distribution: metadata.distribution && transformDistribution(metadata.distribution),
|
||||
@@ -57,20 +60,6 @@ export function transformMetadata(metadata: Metadata): BuildMetadataInput {
|
||||
};
|
||||
}
|
||||
|
||||
export function transformFingerprintSource(
|
||||
fingerprintSource: NonNullable<Metadata['fingerprintSource']>
|
||||
): FingerprintSourceInput | null {
|
||||
if (fingerprintSource.type !== FingerprintSourceType.GCS) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
type: GraphQLFingeprintSourceType.Gcs,
|
||||
bucketKey: fingerprintSource.bucketKey,
|
||||
isDebugFingerprint: fingerprintSource.isDebugFingerprint,
|
||||
};
|
||||
}
|
||||
|
||||
function transformCredentialsSource(
|
||||
credentialsSource: Metadata['credentialsSource']
|
||||
): BuildCredentialsSource {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Ios, Metadata, Platform, Workflow } from '@expo/eas-build-job';
|
||||
import { Ios, Platform, Workflow } from '@expo/eas-build-job';
|
||||
import { AppVersionSource } from '@expo/eas-json';
|
||||
|
||||
import { ensureIosCredentialsAsync } from './credentials';
|
||||
@@ -7,14 +7,14 @@ import { prepareJobAsync } from './prepareJob';
|
||||
import { syncProjectConfigurationAsync } from './syncProjectConfiguration';
|
||||
import { resolveRemoteBuildNumberAsync } from './version';
|
||||
import { IosCredentials } from '../../credentials/ios/types';
|
||||
import { BuildParamsInput } from '../../graphql/generated';
|
||||
import { BuildMetadataInput, BuildParamsInput } from '../../graphql/generated';
|
||||
import { BuildMutation, BuildResult } from '../../graphql/mutations/BuildMutation';
|
||||
import { ensureBundleIdentifierIsDefinedForManagedProjectAsync } from '../../project/ios/bundleIdentifier';
|
||||
import { ensureNonExemptEncryptionIsDefinedForManagedProjectAsync } from '../../project/ios/exemptEncryption';
|
||||
import { resolveXcodeBuildContextAsync } from '../../project/ios/scheme';
|
||||
import { findApplicationTarget, resolveTargetsAsync } from '../../project/ios/target';
|
||||
import { BuildRequestSender, JobData, prepareBuildRequestForPlatformAsync } from '../build';
|
||||
import { BuildContext, CommonContext, IosBuildContext } from '../context';
|
||||
import { transformMetadata } from '../graphql';
|
||||
import {
|
||||
checkGoogleServicesFileAsync,
|
||||
checkNodeEnvVariable,
|
||||
@@ -28,6 +28,7 @@ export async function createIosContextAsync(
|
||||
|
||||
if (ctx.workflow === Workflow.MANAGED) {
|
||||
await ensureBundleIdentifierIsDefinedForManagedProjectAsync(ctx);
|
||||
await ensureNonExemptEncryptionIsDefinedForManagedProjectAsync(ctx);
|
||||
}
|
||||
|
||||
checkNodeEnvVariable(ctx);
|
||||
@@ -104,10 +105,9 @@ export async function prepareIosBuildAsync(
|
||||
sendBuildRequestAsync: async (
|
||||
appId: string,
|
||||
job: Ios.Job,
|
||||
metadata: Metadata,
|
||||
graphqlMetadata: BuildMetadataInput,
|
||||
buildParams: BuildParamsInput
|
||||
): Promise<BuildResult> => {
|
||||
const graphqlMetadata = transformMetadata(metadata);
|
||||
const graphqlJob = transformJob(job);
|
||||
return await BuildMutation.createIosBuildAsync(ctx.graphqlClient, {
|
||||
appId,
|
||||
|
||||
@@ -31,6 +31,7 @@ export function transformJob(job: Ios.Job): IosJobInput {
|
||||
experimental: job.experimental,
|
||||
mode: transformBuildMode(job.mode),
|
||||
customBuildConfig: job.customBuildConfig,
|
||||
environment: job.environment,
|
||||
loggerLevel: job.loggerLevel
|
||||
? loggerLevelToGraphQLWorkerLoggerLevel[job.loggerLevel]
|
||||
: undefined,
|
||||
|
||||
@@ -14,7 +14,7 @@ import slash from 'slash';
|
||||
import { IosCredentials, TargetCredentials } from '../../credentials/ios/types';
|
||||
import { IosJobSecretsInput } from '../../graphql/generated';
|
||||
import { getCustomBuildConfigPathForJob } from '../../project/customBuildConfig';
|
||||
import { getUsername } from '../../project/projectUtils';
|
||||
import { getUsernameForBuildMetadataAndBuildJob } from '../../project/projectUtils';
|
||||
import { BuildContext } from '../context';
|
||||
|
||||
interface JobData {
|
||||
@@ -32,7 +32,7 @@ export async function prepareJobAsync(
|
||||
ctx: BuildContext<Platform.IOS>,
|
||||
jobData: JobData
|
||||
): Promise<Ios.Job> {
|
||||
const username = getUsername(ctx.exp, ctx.user);
|
||||
const username = getUsernameForBuildMetadataAndBuildJob(ctx.user);
|
||||
const buildProfile: BuildProfile<Platform.IOS> = ctx.buildProfile;
|
||||
const projectRootDirectory =
|
||||
slash(path.relative(await ctx.vcsClient.getRootPathAsync(), ctx.projectDir)) || '.';
|
||||
@@ -49,9 +49,7 @@ export async function prepareJobAsync(
|
||||
: undefined;
|
||||
|
||||
let buildMode;
|
||||
if (ctx.repack) {
|
||||
buildMode = BuildMode.REPACK;
|
||||
} else if (buildProfile.config) {
|
||||
if (buildProfile.config) {
|
||||
buildMode = BuildMode.CUSTOM;
|
||||
} else {
|
||||
buildMode = BuildMode.BUILD;
|
||||
@@ -65,13 +63,17 @@ export async function prepareJobAsync(
|
||||
builderEnvironment: {
|
||||
image: buildProfile.image,
|
||||
node: buildProfile.node,
|
||||
corepack: buildProfile.corepack,
|
||||
pnpm: buildProfile.pnpm,
|
||||
bun: buildProfile.bun,
|
||||
yarn: buildProfile.yarn,
|
||||
bundler: buildProfile.bundler,
|
||||
cocoapods: buildProfile.cocoapods,
|
||||
fastlane: buildProfile.fastlane,
|
||||
env: buildProfile.env,
|
||||
env: {
|
||||
...buildProfile.env,
|
||||
...(ctx.isVerboseLoggingEnabled ? { EAS_VERBOSE: '1' } : {}),
|
||||
},
|
||||
},
|
||||
cache: {
|
||||
...cacheDefaults,
|
||||
@@ -88,6 +90,7 @@ export async function prepareJobAsync(
|
||||
buildConfiguration: buildProfile.buildConfiguration,
|
||||
applicationArchivePath: buildProfile.applicationArchivePath ?? buildProfile.artifactPath,
|
||||
buildArtifactPaths: buildProfile.buildArtifactPaths,
|
||||
environment: ctx.buildProfile.environment,
|
||||
username,
|
||||
...(ctx.ios.buildNumberOverride && {
|
||||
version: {
|
||||
@@ -104,11 +107,6 @@ export async function prepareJobAsync(
|
||||
path: maybeCustomBuildConfigPath,
|
||||
},
|
||||
}),
|
||||
...(ctx.repack && {
|
||||
customBuildConfig: {
|
||||
path: '__eas/repack.yml',
|
||||
},
|
||||
}),
|
||||
loggerLevel: ctx.loggerLevel,
|
||||
// Technically, these are unused, but let's include them here for type consistency.
|
||||
// See: https://github.com/expo/eas-build/pull/454
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import { Job, Metadata } from '@expo/eas-build-job';
|
||||
import { Job, Metadata, version } from '@expo/eas-build-job';
|
||||
import spawnAsync from '@expo/spawn-async';
|
||||
import { ChildProcess } from 'child_process';
|
||||
import semver from 'semver';
|
||||
|
||||
import { getExpoApiBaseUrl } from '../api';
|
||||
import Log from '../log';
|
||||
import { ora } from '../ora';
|
||||
|
||||
const PLUGIN_PACKAGE_NAME = 'eas-cli-local-build-plugin';
|
||||
const PLUGIN_PACKAGE_VERSION = '1.0.163';
|
||||
const PLUGIN_PACKAGE_VERSION = version; // should match version of @expo/eas-build-job
|
||||
|
||||
export enum LocalBuildMode {
|
||||
/**
|
||||
@@ -60,6 +61,7 @@ export async function runLocalBuildAsync(
|
||||
...env,
|
||||
...process.env,
|
||||
EAS_LOCAL_BUILD_WORKINGDIR: options.workingdir ?? process.env.EAS_LOCAL_BUILD_WORKINGDIR,
|
||||
__API_SERVER_URL: getExpoApiBaseUrl(),
|
||||
...(options.skipCleanup || options.skipNativeBuild
|
||||
? { EAS_LOCAL_BUILD_SKIP_CLEANUP: '1' }
|
||||
: {}),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { FingerprintSource, Metadata, Platform, sanitizeMetadata } from '@expo/eas-build-job';
|
||||
import { Metadata, Platform, sanitizeMetadata } from '@expo/eas-build-job';
|
||||
import { IosEnterpriseProvisioning } from '@expo/eas-json';
|
||||
import fs from 'fs-extra';
|
||||
import resolveFrom from 'resolve-from';
|
||||
@@ -9,7 +9,10 @@ import { maybeResolveVersionsAsync as maybeResolveIosVersionsAsync } from './ios
|
||||
import { LocalBuildMode } from './local';
|
||||
import { BuildDistributionType } from './types';
|
||||
import Log from '../log';
|
||||
import { getUsername, isExpoUpdatesInstalled } from '../project/projectUtils';
|
||||
import {
|
||||
getUsernameForBuildMetadataAndBuildJob,
|
||||
isExpoUpdatesInstalled,
|
||||
} from '../project/projectUtils';
|
||||
import { readChannelSafelyAsync as readAndroidChannelSafelyAsync } from '../update/android/UpdatesModule';
|
||||
import { readChannelSafelyAsync as readIosChannelSafelyAsync } from '../update/ios/UpdatesModule';
|
||||
import { easCliVersion } from '../utils/easCli';
|
||||
@@ -19,7 +22,6 @@ export async function collectMetadataAsync<T extends Platform>(
|
||||
runtimeAndFingerprintMetadata: {
|
||||
runtimeVersion?: string | undefined;
|
||||
fingerprintHash?: string | undefined;
|
||||
fingerprintSource?: FingerprintSource | undefined;
|
||||
}
|
||||
): Promise<Metadata> {
|
||||
const channelObject = await resolveChannelAsync(ctx);
|
||||
@@ -33,7 +35,6 @@ export async function collectMetadataAsync<T extends Platform>(
|
||||
sdkVersion: ctx.exp.sdkVersion,
|
||||
runtimeVersion: runtimeAndFingerprintMetadata?.runtimeVersion,
|
||||
fingerprintHash: runtimeAndFingerprintMetadata?.fingerprintHash,
|
||||
fingerprintSource: runtimeAndFingerprintMetadata?.fingerprintSource,
|
||||
reactNativeVersion: await getReactNativeVersionAsync(ctx.projectDir),
|
||||
...channelObject,
|
||||
distribution,
|
||||
@@ -48,7 +49,7 @@ export async function collectMetadataAsync<T extends Platform>(
|
||||
ctx.localBuildOptions.localBuildMode === LocalBuildMode.INTERNAL
|
||||
? false
|
||||
: await ctx.vcsClient.hasUncommittedChangesAsync(),
|
||||
username: getUsername(ctx.exp, ctx.user),
|
||||
username: getUsernameForBuildMetadataAndBuildJob(ctx.user),
|
||||
message: ctx.message,
|
||||
...(ctx.platform === Platform.IOS && {
|
||||
iosEnterpriseProvisioning: resolveIosEnterpriseProvisioning(
|
||||
@@ -62,7 +63,6 @@ export async function collectMetadataAsync<T extends Platform>(
|
||||
requiredPackageManager: ctx.requiredPackageManager ?? undefined,
|
||||
selectedImage: ctx.buildProfile.image,
|
||||
customNodeVersion: ctx.buildProfile.node,
|
||||
environment: ctx.buildProfile.environment,
|
||||
simulator: 'simulator' in ctx.buildProfile && ctx.buildProfile.simulator,
|
||||
};
|
||||
return sanitizeMetadata(metadata);
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
import { LoggerLevel } from '@expo/logger';
|
||||
import assert from 'assert';
|
||||
import chalk from 'chalk';
|
||||
import { pathExists } from 'fs-extra';
|
||||
import nullthrows from 'nullthrows';
|
||||
|
||||
import { prepareAndroidBuildAsync } from './android/build';
|
||||
@@ -62,7 +63,7 @@ import {
|
||||
validateBuildProfileVersionSettingsAsync,
|
||||
} from '../project/remoteVersionSource';
|
||||
import { confirmAsync } from '../prompts';
|
||||
import { runAsync } from '../run/run';
|
||||
import { getEasBuildRunCachedAppPath, runAsync } from '../run/run';
|
||||
import { isRunnableOnSimulatorOrEmulator } from '../run/utils';
|
||||
import { createSubmissionContextAsync } from '../submit/context';
|
||||
import {
|
||||
@@ -77,10 +78,12 @@ import { downloadAndMaybeExtractAppAsync } from '../utils/download';
|
||||
import { truthy } from '../utils/expodash/filter';
|
||||
import { printJsonOnlyOutput } from '../utils/json';
|
||||
import { ProfileData, getProfilesAsync } from '../utils/profiles';
|
||||
import { maybeWarnAboutUsageOveragesAsync } from '../utils/usage/checkForOverages';
|
||||
import { Client } from '../vcs/vcs';
|
||||
|
||||
let metroConfigValidated = false;
|
||||
let sdkVersionChecked = false;
|
||||
let hasWarnedAboutUsageOverages = false;
|
||||
|
||||
export interface BuildFlags {
|
||||
requestedPlatform: RequestedPlatform;
|
||||
@@ -96,19 +99,33 @@ export interface BuildFlags {
|
||||
message?: string;
|
||||
buildLoggerLevel?: LoggerLevel;
|
||||
freezeCredentials: boolean;
|
||||
repack: boolean;
|
||||
isVerboseLoggingEnabled?: boolean;
|
||||
whatToTest?: string;
|
||||
}
|
||||
|
||||
export async function runBuildAndSubmitAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
analytics: Analytics,
|
||||
vcsClient: Client,
|
||||
projectDir: string,
|
||||
flags: BuildFlags,
|
||||
actor: Actor,
|
||||
getDynamicPrivateProjectConfigAsync: DynamicConfigContextFn
|
||||
): Promise<{
|
||||
export async function runBuildAndSubmitAsync({
|
||||
graphqlClient,
|
||||
analytics,
|
||||
vcsClient,
|
||||
projectDir,
|
||||
flags,
|
||||
actor,
|
||||
getDynamicPrivateProjectConfigAsync,
|
||||
downloadSimBuildAutoConfirm,
|
||||
envOverride,
|
||||
}: {
|
||||
graphqlClient: ExpoGraphqlClient;
|
||||
analytics: Analytics;
|
||||
vcsClient: Client;
|
||||
projectDir: string;
|
||||
flags: BuildFlags;
|
||||
actor: Actor;
|
||||
getDynamicPrivateProjectConfigAsync: DynamicConfigContextFn;
|
||||
downloadSimBuildAutoConfirm?: boolean;
|
||||
envOverride?: Env;
|
||||
}): Promise<{
|
||||
buildIds: string[];
|
||||
buildProfiles?: ProfileData<'build'>[];
|
||||
}> {
|
||||
await vcsClient.ensureRepoExistsAsync();
|
||||
await ensureRepoIsCleanAsync(vcsClient, flags.nonInteractive);
|
||||
@@ -192,13 +209,15 @@ export async function runBuildAndSubmitAsync(
|
||||
for (const buildProfile of buildProfiles) {
|
||||
const platform = toAppPlatform(buildProfile.platform);
|
||||
|
||||
const { env } = await evaluateConfigWithEnvVarsAsync({
|
||||
buildProfile: buildProfile.profile,
|
||||
buildProfileName: buildProfile.profileName,
|
||||
graphqlClient,
|
||||
getProjectConfig: getDynamicPrivateProjectConfigAsync,
|
||||
opts: { env: buildProfile.profile.env },
|
||||
});
|
||||
const { env } = !envOverride
|
||||
? await evaluateConfigWithEnvVarsAsync({
|
||||
buildProfile: buildProfile.profile,
|
||||
buildProfileName: buildProfile.profileName,
|
||||
graphqlClient,
|
||||
getProjectConfig: getDynamicPrivateProjectConfigAsync,
|
||||
opts: { env: buildProfile.profile.env },
|
||||
})
|
||||
: { env: envOverride };
|
||||
|
||||
const { build: maybeBuild, buildCtx } = await prepareAndStartBuildAsync({
|
||||
projectDir,
|
||||
@@ -213,6 +232,7 @@ export async function runBuildAndSubmitAsync(
|
||||
getDynamicPrivateProjectConfigAsync,
|
||||
customBuildConfigMetadata: customBuildConfigMetadataByPlatform[platform],
|
||||
env,
|
||||
easJsonAccessor,
|
||||
});
|
||||
if (maybeBuild) {
|
||||
startedBuilds.push({ build: maybeBuild, buildProfile });
|
||||
@@ -255,7 +275,7 @@ export async function runBuildAndSubmitAsync(
|
||||
({ platform }) => toAppPlatform(platform) === startedBuild.build.platform
|
||||
)
|
||||
).profile;
|
||||
const submission = await prepareAndStartSubmissionAsync({
|
||||
const submission = await prepareAndStartAutoSubmissionAsync({
|
||||
build: startedBuild.build,
|
||||
buildCtx: nullthrows(buildCtxByPlatform[startedBuild.build.platform]),
|
||||
moreBuilds: startedBuilds.length > 1,
|
||||
@@ -308,7 +328,7 @@ export async function runBuildAndSubmitAsync(
|
||||
[BuildStatus.Errored, BuildStatus.Canceled, BuildStatus.PendingCancel].includes(build?.status)
|
||||
);
|
||||
|
||||
await maybeDownloadAndRunSimulatorBuildsAsync(builds, flags);
|
||||
await maybeDownloadAndRunSimulatorBuildsAsync(builds, flags, downloadSimBuildAutoConfirm);
|
||||
|
||||
if (haveAllBuildsFailedOrCanceled || !flags.autoSubmit) {
|
||||
if (flags.json) {
|
||||
@@ -333,6 +353,7 @@ export async function runBuildAndSubmitAsync(
|
||||
}
|
||||
return {
|
||||
buildIds: startedBuilds.map(({ build }) => build.id),
|
||||
buildProfiles,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -349,6 +370,7 @@ async function prepareAndStartBuildAsync({
|
||||
getDynamicPrivateProjectConfigAsync,
|
||||
customBuildConfigMetadata,
|
||||
env,
|
||||
easJsonAccessor,
|
||||
}: {
|
||||
projectDir: string;
|
||||
flags: BuildFlags;
|
||||
@@ -361,6 +383,7 @@ async function prepareAndStartBuildAsync({
|
||||
vcsClient: Client;
|
||||
getDynamicPrivateProjectConfigAsync: DynamicConfigContextFn;
|
||||
customBuildConfigMetadata?: CustomBuildConfigMetadata;
|
||||
easJsonAccessor: EasJsonAccessor;
|
||||
env: Env;
|
||||
}): Promise<{ build: BuildFragment | undefined; buildCtx: BuildContext<Platform> }> {
|
||||
const buildCtx = await createBuildContextAsync({
|
||||
@@ -383,10 +406,20 @@ async function prepareAndStartBuildAsync({
|
||||
customBuildConfigMetadata,
|
||||
buildLoggerLevel: flags.buildLoggerLevel ?? (Log.isDebug ? LoggerLevel.DEBUG : undefined),
|
||||
freezeCredentials: flags.freezeCredentials,
|
||||
repack: flags.repack,
|
||||
isVerboseLoggingEnabled: flags.isVerboseLoggingEnabled ?? false,
|
||||
whatToTest: flags.whatToTest,
|
||||
env,
|
||||
});
|
||||
|
||||
if (!hasWarnedAboutUsageOverages && !flags.localBuildOptions.localBuildMode) {
|
||||
hasWarnedAboutUsageOverages = true;
|
||||
Log.newLine();
|
||||
await maybeWarnAboutUsageOveragesAsync({ graphqlClient, accountId: buildCtx.accountId });
|
||||
if (!moreBuilds) {
|
||||
Log.newLine();
|
||||
}
|
||||
}
|
||||
|
||||
if (moreBuilds) {
|
||||
Log.newLine();
|
||||
const appPlatform = toAppPlatform(buildProfile.platform);
|
||||
@@ -407,8 +440,18 @@ async function prepareAndStartBuildAsync({
|
||||
nonInteractive: flags.nonInteractive,
|
||||
buildProfile,
|
||||
env: buildProfile.profile.env,
|
||||
easJsonAccessor,
|
||||
});
|
||||
if (isUsingEASUpdate(buildCtx.exp, buildCtx.projectId)) {
|
||||
const easJsonCliConfig: EasJson['cli'] =
|
||||
(await EasJsonUtils.getCliConfigAsync(easJsonAccessor)) ?? {};
|
||||
|
||||
if (
|
||||
isUsingEASUpdate(
|
||||
buildCtx.exp,
|
||||
buildCtx.projectId,
|
||||
easJsonCliConfig.updateManifestHostOverride ?? null
|
||||
)
|
||||
) {
|
||||
const doesChannelExist = await doesChannelExistAsync(graphqlClient, {
|
||||
appId: buildCtx.projectId,
|
||||
channelName: buildProfile.profile.channel,
|
||||
@@ -473,7 +516,7 @@ async function startBuildAsync(ctx: BuildContext<Platform>): Promise<BuildFragme
|
||||
return await sendBuildRequestAsync();
|
||||
}
|
||||
|
||||
async function prepareAndStartSubmissionAsync({
|
||||
async function prepareAndStartAutoSubmissionAsync({
|
||||
build,
|
||||
buildCtx,
|
||||
moreBuilds,
|
||||
@@ -508,6 +551,8 @@ async function prepareAndStartSubmissionAsync({
|
||||
vcsClient: buildCtx.vcsClient,
|
||||
isVerboseFastlaneEnabled: false,
|
||||
specifiedProfile: selectedSubmitProfileName,
|
||||
groups: undefined, // use groups from submit profile
|
||||
whatToTest: buildCtx.whatToTest,
|
||||
});
|
||||
|
||||
if (moreBuilds) {
|
||||
@@ -531,18 +576,29 @@ function exitWithNonZeroCodeIfSomeBuildsFailed(maybeBuilds: (BuildFragment | nul
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadAndRunAsync(build: BuildFragment): Promise<void> {
|
||||
export async function downloadAndRunAsync(build: BuildFragment): Promise<void> {
|
||||
assert(build.artifacts?.applicationArchiveUrl);
|
||||
const cachedAppPath = getEasBuildRunCachedAppPath(build.project.id, build.id, build.platform);
|
||||
|
||||
if (await pathExists(cachedAppPath)) {
|
||||
Log.newLine();
|
||||
Log.log(`Using cached app...`);
|
||||
await runAsync(cachedAppPath, build.platform);
|
||||
return;
|
||||
}
|
||||
|
||||
const buildPath = await downloadAndMaybeExtractAppAsync(
|
||||
build.artifacts.applicationArchiveUrl,
|
||||
build.platform
|
||||
build.platform,
|
||||
cachedAppPath
|
||||
);
|
||||
await runAsync(buildPath, build.platform);
|
||||
}
|
||||
|
||||
async function maybeDownloadAndRunSimulatorBuildsAsync(
|
||||
builds: MaybeBuildFragment[],
|
||||
flags: BuildFlags
|
||||
flags: BuildFlags,
|
||||
autoConfirm?: boolean
|
||||
): Promise<void> {
|
||||
const simBuilds = builds.filter(truthy).filter(isRunnableOnSimulatorOrEmulator);
|
||||
|
||||
@@ -550,11 +606,15 @@ async function maybeDownloadAndRunSimulatorBuildsAsync(
|
||||
for (const simBuild of simBuilds) {
|
||||
if (simBuild.platform === AppPlatform.Android || process.platform === 'darwin') {
|
||||
Log.newLine();
|
||||
const confirm = await confirmAsync({
|
||||
message: `Install and run the ${
|
||||
simBuild.platform === AppPlatform.Android ? 'Android' : 'iOS'
|
||||
} build on ${simBuild.platform === AppPlatform.Android ? 'an emulator' : 'a simulator'}?`,
|
||||
});
|
||||
const confirm =
|
||||
autoConfirm ??
|
||||
(await confirmAsync({
|
||||
message: `Install and run the ${
|
||||
simBuild.platform === AppPlatform.Android ? 'Android' : 'iOS'
|
||||
} build on ${
|
||||
simBuild.platform === AppPlatform.Android ? 'an emulator' : 'a simulator'
|
||||
}?`,
|
||||
}));
|
||||
if (confirm) {
|
||||
await downloadAndRunAsync(simBuild);
|
||||
}
|
||||
@@ -572,6 +632,7 @@ async function validateExpoUpdatesInstalledAsProjectDependencyAsync({
|
||||
nonInteractive,
|
||||
sdkVersion,
|
||||
env,
|
||||
easJsonAccessor,
|
||||
}: {
|
||||
exp: ExpoConfig;
|
||||
projectId: string;
|
||||
@@ -581,6 +642,7 @@ async function validateExpoUpdatesInstalledAsProjectDependencyAsync({
|
||||
nonInteractive: boolean;
|
||||
sdkVersion?: string;
|
||||
env: Env | undefined;
|
||||
easJsonAccessor: EasJsonAccessor;
|
||||
}): Promise<void> {
|
||||
if (isExpoUpdatesInstalledOrAvailable(projectDir, sdkVersion)) {
|
||||
return;
|
||||
@@ -602,6 +664,9 @@ async function validateExpoUpdatesInstalledAsProjectDependencyAsync({
|
||||
message: `Would you like to install the "expo-updates" package and configure EAS Update now?`,
|
||||
});
|
||||
if (installExpoUpdates) {
|
||||
const easJsonCliConfig: EasJson['cli'] =
|
||||
(await EasJsonUtils.getCliConfigAsync(easJsonAccessor)) ?? {};
|
||||
|
||||
await ensureEASUpdateIsConfiguredAsync({
|
||||
exp,
|
||||
projectId,
|
||||
@@ -609,6 +674,7 @@ async function validateExpoUpdatesInstalledAsProjectDependencyAsync({
|
||||
platform: RequestedPlatform.All,
|
||||
vcsClient,
|
||||
env,
|
||||
manifestHostOverride: easJsonCliConfig.updateManifestHostOverride ?? null,
|
||||
});
|
||||
Log.withTick('Installed expo-updates and configured EAS Update.');
|
||||
throw new Error('Command must be re-run to pick up new updates configuration.');
|
||||
|
||||
@@ -35,5 +35,4 @@ export interface BuildFlags {
|
||||
message?: string;
|
||||
buildLoggerLevel?: LoggerLevel;
|
||||
freezeCredentials: boolean;
|
||||
repack: boolean;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export enum DefaultEnvironment {
|
||||
Development = 'development',
|
||||
Preview = 'preview',
|
||||
Production = 'production',
|
||||
}
|
||||
@@ -6,8 +6,44 @@ import {
|
||||
BuildFragment,
|
||||
BuildStatus as GraphQLBuildStatus,
|
||||
} from '../../graphql/generated';
|
||||
import { link } from '../../log';
|
||||
import { appPlatformDisplayNames } from '../../platform';
|
||||
import formatFields from '../../utils/formatFields';
|
||||
import formatFields, { FormatFieldsItem } from '../../utils/formatFields';
|
||||
|
||||
export function formatGraphQLBuildArtifacts(build: BuildFragment): FormatFieldsItem[] {
|
||||
const fields = [
|
||||
{
|
||||
label: 'Application Archive URL',
|
||||
get value() {
|
||||
switch (build.status) {
|
||||
case GraphQLBuildStatus.InProgress:
|
||||
return '<in progress>';
|
||||
default: {
|
||||
const url = build.artifacts?.buildUrl;
|
||||
return url ? link(url) : 'null';
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Build Artifacts URL',
|
||||
get value() {
|
||||
switch (build.status) {
|
||||
case GraphQLBuildStatus.InProgress:
|
||||
return '<in progress>';
|
||||
default: {
|
||||
const url = build.artifacts?.buildArtifactsUrl;
|
||||
return url ? link(url) : 'null';
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
];
|
||||
return fields.filter(({ value }) => value !== undefined && value !== null) as {
|
||||
label: string;
|
||||
value: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
export function formatGraphQLBuild(build: BuildFragment): string {
|
||||
const actor = getActorName(build);
|
||||
@@ -81,10 +117,11 @@ export function formatGraphQLBuild(build: BuildFragment): string {
|
||||
},
|
||||
{
|
||||
label: 'Logs',
|
||||
value: getBuildLogsUrl(build),
|
||||
value: link(getBuildLogsUrl(build)),
|
||||
},
|
||||
...formatGraphQLBuildArtifacts(build),
|
||||
{
|
||||
label: 'Artifact',
|
||||
label: 'Fingerprint',
|
||||
get value() {
|
||||
switch (build.status) {
|
||||
case GraphQLBuildStatus.New:
|
||||
@@ -96,8 +133,8 @@ export function formatGraphQLBuild(build: BuildFragment): string {
|
||||
case GraphQLBuildStatus.Errored:
|
||||
return null;
|
||||
case GraphQLBuildStatus.Finished: {
|
||||
const url = build.artifacts?.buildUrl;
|
||||
return url ? url : chalk.red('not found');
|
||||
const hash = build.fingerprint?.hash;
|
||||
return hash ? chalk.green(hash) : chalk.red('not found');
|
||||
}
|
||||
default:
|
||||
return null;
|
||||
|
||||
@@ -43,14 +43,14 @@ const errorCodeToErrorMessageOverride: Record<string, (build: BuildFragment) =>
|
||||
|
||||
export function printLogsUrls(builds: BuildFragment[]): void {
|
||||
if (builds.length === 1) {
|
||||
Log.log(`Build details: ${link(getBuildLogsUrl(builds[0]))}`);
|
||||
Log.log(`See logs: ${link(getBuildLogsUrl(builds[0]))}`);
|
||||
} else {
|
||||
builds.forEach(build => {
|
||||
const logsUrl = getBuildLogsUrl(build);
|
||||
Log.log(
|
||||
`${appPlatformEmojis[build.platform]} ${
|
||||
`See ${appPlatformEmojis[build.platform]} ${
|
||||
appPlatformDisplayNames[build.platform]
|
||||
} build details: ${link(logsUrl)}`
|
||||
} logs: ${link(logsUrl)}`
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import path from 'path';
|
||||
import tar from 'tar';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
import Log from '../../log';
|
||||
import Log, { learnMore } from '../../log';
|
||||
import { ora } from '../../ora';
|
||||
import { confirmAsync, promptAsync } from '../../prompts';
|
||||
import { formatBytes } from '../../utils/files';
|
||||
@@ -169,6 +169,34 @@ export async function makeProjectTarballAsync(vcsClient: Client): Promise<LocalF
|
||||
return { size, path: tarPath };
|
||||
}
|
||||
|
||||
export function maybeWarnAboutProjectTarballSize(size: number): void {
|
||||
if (size <= 150 /* MiB */ * 1024 /* KiB */ * 1024 /* B */) {
|
||||
return;
|
||||
}
|
||||
|
||||
Log.warn(
|
||||
`Your project archive is ${formatBytes(
|
||||
size
|
||||
)}. You can reduce its size and the time it takes to upload by excluding files that are unnecessary for the build process in ${chalk.bold(
|
||||
'.easignore'
|
||||
)} file. ${learnMore('https://expo.fyi/eas-build-archive')}`
|
||||
);
|
||||
}
|
||||
|
||||
const MAX_ALLOWED_PROJECT_TARBALL_SIZE =
|
||||
2 /* GiB */ * 1024 /* MiB */ * 1024 /* KiB */ * 1024; /* B */
|
||||
export function assertProjectTarballSizeDoesNotExceedLimit(size: number): void {
|
||||
if (size <= MAX_ALLOWED_PROJECT_TARBALL_SIZE) {
|
||||
return;
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Project archive is too big. Maximum allowed size is ${formatBytes(
|
||||
MAX_ALLOWED_PROJECT_TARBALL_SIZE
|
||||
)}.`
|
||||
);
|
||||
}
|
||||
|
||||
enum ShouldCommitChanges {
|
||||
Yes,
|
||||
ShowDiffFirst,
|
||||
|
||||
@@ -72,3 +72,12 @@ export function getProjectGitHubSettingsUrl(accountName: string, projectName: st
|
||||
getExpoWebsiteBaseUrl()
|
||||
).toString();
|
||||
}
|
||||
|
||||
export function getHostingDeploymentsUrl(accountName: string, projectName: string): string {
|
||||
return new URL(
|
||||
`/accounts/${encodeURIComponent(accountName)}/projects/${encodeURIComponent(
|
||||
projectName
|
||||
)}/hosting/deployments`,
|
||||
getExpoWebsiteBaseUrl()
|
||||
).toString();
|
||||
}
|
||||
@@ -18,6 +18,7 @@ export const testUpdateBranch1: UpdateBranchObject = {
|
||||
isRollBackToEmbedded: false,
|
||||
manifestPermalink: 'https://u.expo.dev/update/bbe3e63a-f620-4603-8c4d-258f3e51590d',
|
||||
gitCommitHash: 'e91c5bb733eff675dd8a3722ed5d4f885a4b10ce',
|
||||
isGitWorkingTreeDirty: false,
|
||||
actor: {
|
||||
__typename: 'User',
|
||||
id: '026ace2b-c1f8-4a02-818d-6b52c100bdf2',
|
||||
@@ -43,6 +44,7 @@ export const testUpdateBranch1: UpdateBranchObject = {
|
||||
isRollBackToEmbedded: false,
|
||||
manifestPermalink: 'https://u.expo.dev/update/db22401b-d321-4629-a068-21d6d1f19c02',
|
||||
gitCommitHash: 'e91c5bb733eff675dd8a3722ed5d4f885a4b10ce',
|
||||
isGitWorkingTreeDirty: false,
|
||||
actor: {
|
||||
__typename: 'User',
|
||||
id: '026ace2b-c1f8-4a02-818d-6b52c100bdf2',
|
||||
@@ -78,6 +80,7 @@ export const testUpdateBranch2: UpdateBranchObject = {
|
||||
isRollBackToEmbedded: false,
|
||||
manifestPermalink: 'https://u.expo.dev/update/f04aafc1-0bf5-4548-8c35-726b467e7c40',
|
||||
gitCommitHash: 'e91c5bb733eff675dd8a3722ed5d4f885a4b10ce',
|
||||
isGitWorkingTreeDirty: false,
|
||||
actor: {
|
||||
__typename: 'User',
|
||||
id: '026ace2b-c1f8-4a02-818d-6b52c100bdf2',
|
||||
@@ -103,6 +106,7 @@ export const testUpdateBranch2: UpdateBranchObject = {
|
||||
isRollBackToEmbedded: false,
|
||||
manifestPermalink: 'https://u.expo.dev/update/d8d15786-2727-407f-bf3e-f4bda7e397d9',
|
||||
gitCommitHash: 'e91c5bb733eff675dd8a3722ed5d4f885a4b10ce',
|
||||
isGitWorkingTreeDirty: false,
|
||||
actor: {
|
||||
__typename: 'User',
|
||||
id: '026ace2b-c1f8-4a02-818d-6b52c100bdf2',
|
||||
|
||||
@@ -289,7 +289,7 @@ export async function doesChannelExistAsync(
|
||||
{ appId, channelName }: { appId: string; channelName: string }
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
await ChannelQuery.viewUpdateChannelAsync(graphqlClient, {
|
||||
await ChannelQuery.viewUpdateChannelBasicInfoAsync(graphqlClient, {
|
||||
appId,
|
||||
channelName,
|
||||
});
|
||||
|
||||
@@ -26,8 +26,7 @@ import {
|
||||
CommandEvent,
|
||||
createAnalyticsAsync,
|
||||
} from '../analytics/AnalyticsManager';
|
||||
import { EnvironmentVariableEnvironment } from '../graphql/generated';
|
||||
import Log from '../log';
|
||||
import Log, { link } from '../log';
|
||||
import SessionManager from '../user/SessionManager';
|
||||
import { Client } from '../vcs/vcs';
|
||||
|
||||
@@ -60,7 +59,7 @@ interface BaseGetContextAsyncArgs {
|
||||
|
||||
interface GetContextAsyncArgsWithRequiredServerSideEnvironmentArgument
|
||||
extends BaseGetContextAsyncArgs {
|
||||
withServerSideEnvironment: EnvironmentVariableEnvironment | null;
|
||||
withServerSideEnvironment: string | null;
|
||||
}
|
||||
|
||||
interface GetContextAsyncArgsWithoutServerSideEnvironmentArgument extends BaseGetContextAsyncArgs {
|
||||
@@ -264,6 +263,16 @@ export default abstract class EasCommand extends Command {
|
||||
const defaultMsg = `${messageLine}${requestIdLine}`;
|
||||
|
||||
if (graphQLError.extensions?.errorCode === 'UNAUTHORIZED_ERROR') {
|
||||
if (defaultMsg.includes('ScopedAccountActorViewerContext') && process.env.EAS_BUILD) {
|
||||
// We're in EAS, authenticated with a scoped account actor access token.
|
||||
// We may have not added the scoped actor privacy rule to the right place yet.
|
||||
return `${chalk.bold(
|
||||
`You don't have the required permissions to perform this operation.`
|
||||
)}\n\nWe are in the process of migrating EAS to a more granular permissioning system. If you believe what you're doing is a legitimate operation you should be able to perform, report this to us at ${link(
|
||||
'https://expo.dev/contact'
|
||||
)}\n\nOriginal error message: ${defaultMsg}`;
|
||||
}
|
||||
|
||||
return `${chalk.bold(
|
||||
`You don't have the required permissions to perform this operation.`
|
||||
)}\n\nThis can sometimes happen if you are logged in as incorrect user.\nRun ${chalk.bold(
|
||||
|
||||
@@ -43,6 +43,7 @@ export async function fetchBuildsAsync({
|
||||
platform?: RequestedPlatform;
|
||||
profile?: string;
|
||||
hasFingerprint?: boolean;
|
||||
fingerprintHash?: string;
|
||||
};
|
||||
}): Promise<BuildFragment[]> {
|
||||
let builds: BuildFragment[];
|
||||
@@ -56,6 +57,9 @@ export async function fetchBuildsAsync({
|
||||
if (filters?.hasFingerprint) {
|
||||
queryFilters['hasFingerprint'] = filters.hasFingerprint;
|
||||
}
|
||||
if (filters?.fingerprintHash) {
|
||||
queryFilters['fingerprintHash'] = filters.fingerprintHash;
|
||||
}
|
||||
if (!filters?.statuses) {
|
||||
builds = await BuildQuery.viewBuildsOnAppAsync(graphqlClient, {
|
||||
appId: projectId,
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Analytics } from '../../analytics/AnalyticsManager';
|
||||
import { EnvironmentVariableEnvironment } from '../../graphql/generated';
|
||||
import SessionManager from '../../user/SessionManager';
|
||||
import { Client } from '../../vcs/vcs';
|
||||
|
||||
@@ -11,7 +10,7 @@ export interface ContextOptions {
|
||||
/**
|
||||
* If specified, env variables from the selected environment will be fetched from the server and used to evaluate the dynamic config.
|
||||
*/
|
||||
withServerSideEnvironment?: EnvironmentVariableEnvironment | null;
|
||||
withServerSideEnvironment?: string | null;
|
||||
}
|
||||
|
||||
export default abstract class ContextField<T> {
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@ import { getProjectIdAsync } from './contextUtils/getProjectIdAsync';
|
||||
import { loadServerSideEnvironmentVariablesAsync } from './contextUtils/loadServerSideEnvironmentVariablesAsync';
|
||||
import { getPublicExpoConfigAsync } from '../../project/expoConfig';
|
||||
|
||||
type GetServerSideEnvironmentVariablesFn = (
|
||||
export type GetServerSideEnvironmentVariablesFn = (
|
||||
maybeEnv?: Record<string, string>
|
||||
) => Promise<Record<string, string>>;
|
||||
|
||||
|
||||
+56
-1
@@ -1,6 +1,10 @@
|
||||
import { vol } from 'memfs';
|
||||
|
||||
import { findProjectRootAsync } from '../findProjectDirAndVerifyProjectSetupAsync';
|
||||
import { easCliVersion } from '../../../../utils/easCli';
|
||||
import {
|
||||
findProjectDirAndVerifyProjectSetupAsync,
|
||||
findProjectRootAsync,
|
||||
} from '../findProjectDirAndVerifyProjectSetupAsync';
|
||||
|
||||
jest.mock('@expo/config');
|
||||
jest.mock('fs');
|
||||
@@ -60,3 +64,54 @@ describe(findProjectRootAsync, () => {
|
||||
expect(projectRoot).toBe('/app');
|
||||
});
|
||||
});
|
||||
|
||||
describe(findProjectDirAndVerifyProjectSetupAsync, () => {
|
||||
it('throws if the CLI version is not satisfied', async () => {
|
||||
vol.fromJSON(
|
||||
{
|
||||
'./eas.json': JSON.stringify({ cli: { version: '1.0.0' } }),
|
||||
'./package.json': JSON.stringify({}),
|
||||
},
|
||||
'/app'
|
||||
);
|
||||
await expect(findProjectDirAndVerifyProjectSetupAsync({ cwd: '/app' })).rejects.toThrow(
|
||||
new RegExp(
|
||||
`^You are on eas-cli@${easCliVersion} which does not satisfy the CLI version constraint defined in eas.json \\(1.0.0\\)`,
|
||||
's'
|
||||
)
|
||||
);
|
||||
|
||||
process.env.EAS_SKIP_CLI_VERSION_CHECK = 'true';
|
||||
await expect(findProjectDirAndVerifyProjectSetupAsync({ cwd: '/app' })).resolves.not.toThrow();
|
||||
delete process.env.EAS_SKIP_CLI_VERSION_CHECK;
|
||||
|
||||
await expect(findProjectDirAndVerifyProjectSetupAsync({ cwd: '/app' })).rejects.toThrow(
|
||||
new RegExp(
|
||||
`^You are on eas-cli@${easCliVersion} which does not satisfy the CLI version constraint defined in eas.json \\(1.0.0\\)`,
|
||||
's'
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
it('does not throw if the CLI version is satisfied', async () => {
|
||||
vol.fromJSON(
|
||||
{
|
||||
'./eas.json': JSON.stringify({ cli: { version: '>= 1.0.0' } }),
|
||||
'./package.json': JSON.stringify({}),
|
||||
},
|
||||
'/app'
|
||||
);
|
||||
await expect(findProjectDirAndVerifyProjectSetupAsync({ cwd: '/app' })).resolves.not.toThrow();
|
||||
});
|
||||
|
||||
it('does not throw if there is no version', async () => {
|
||||
vol.fromJSON(
|
||||
{
|
||||
'./eas.json': JSON.stringify({}),
|
||||
'./package.json': JSON.stringify({}),
|
||||
},
|
||||
'/app'
|
||||
);
|
||||
await expect(findProjectDirAndVerifyProjectSetupAsync({ cwd: '/app' })).resolves.not.toThrow();
|
||||
});
|
||||
});
|
||||
+174
-79
@@ -78,7 +78,12 @@ describe(getProjectIdAsync, () => {
|
||||
|
||||
it('gets the project ID from app config if exists', async () => {
|
||||
jest.mocked(getConfig).mockReturnValue({
|
||||
exp: { name: 'test', slug: 'test', extra: { eas: { projectId: '1234' } } },
|
||||
exp: {
|
||||
sdkVersion: '52.0.0',
|
||||
name: 'test',
|
||||
slug: 'test',
|
||||
extra: { eas: { projectId: '1234' } },
|
||||
},
|
||||
} as any);
|
||||
jest.mocked(AppQuery.byIdAsync).mockResolvedValue({
|
||||
id: '1234',
|
||||
@@ -91,7 +96,7 @@ describe(getProjectIdAsync, () => {
|
||||
await expect(
|
||||
getProjectIdAsync(
|
||||
sessionManager,
|
||||
{ name: 'test', slug: 'test', extra: { eas: { projectId: '1234' } } },
|
||||
{ sdkVersion: '52.0.0', name: 'test', slug: 'test', extra: { eas: { projectId: '1234' } } },
|
||||
{ nonInteractive: false }
|
||||
)
|
||||
).resolves.toEqual('1234');
|
||||
@@ -99,7 +104,13 @@ describe(getProjectIdAsync, () => {
|
||||
|
||||
it('throws when the owner is out of sync', async () => {
|
||||
jest.mocked(getConfig).mockReturnValue({
|
||||
exp: { name: 'test', slug: 'test', owner: 'wat', extra: { eas: { projectId: '1234' } } },
|
||||
exp: {
|
||||
sdkVersion: '54.0.0',
|
||||
name: 'test',
|
||||
slug: 'test',
|
||||
owner: 'wat',
|
||||
extra: { eas: { projectId: '1234' } },
|
||||
},
|
||||
} as any);
|
||||
jest.mocked(AppQuery.byIdAsync).mockResolvedValue({
|
||||
id: '1234',
|
||||
@@ -112,7 +123,13 @@ describe(getProjectIdAsync, () => {
|
||||
await expect(
|
||||
getProjectIdAsync(
|
||||
sessionManager,
|
||||
{ name: 'test', slug: 'test', owner: 'wat', extra: { eas: { projectId: '1234' } } },
|
||||
{
|
||||
sdkVersion: '52.0.0',
|
||||
name: 'test',
|
||||
slug: 'test',
|
||||
owner: 'wat',
|
||||
extra: { eas: { projectId: '1234' } },
|
||||
},
|
||||
{ nonInteractive: false }
|
||||
)
|
||||
).rejects.toThrow(
|
||||
@@ -122,83 +139,143 @@ describe(getProjectIdAsync, () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('throws when the owner is not specified and is different than logged in user', async () => {
|
||||
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',
|
||||
name: 'test',
|
||||
slug: 'test',
|
||||
ownerAccount: { name: 'totallybrent' } as any,
|
||||
describe('when sdkVersion is less than 53', () => {
|
||||
it('throws when the owner is not specified and is different than logged in user', async () => {
|
||||
jest.mocked(getConfig).mockReturnValue({
|
||||
exp: {
|
||||
sdkVersion: '52.0.0',
|
||||
name: 'test',
|
||||
slug: 'test',
|
||||
extra: { eas: { projectId: '1234' } },
|
||||
},
|
||||
} as any);
|
||||
jest.mocked(AppQuery.byIdAsync).mockResolvedValue({
|
||||
id: '1234',
|
||||
fullName: '@totallybrent/test',
|
||||
name: 'test',
|
||||
slug: 'test',
|
||||
ownerAccount: { name: 'totallybrent' } as any,
|
||||
});
|
||||
|
||||
await expect(
|
||||
getProjectIdAsync(
|
||||
sessionManager,
|
||||
{
|
||||
sdkVersion: '52.0.0',
|
||||
name: 'test',
|
||||
slug: 'test',
|
||||
extra: { eas: { projectId: '1234' } },
|
||||
},
|
||||
{ nonInteractive: false }
|
||||
)
|
||||
).rejects.toThrow(
|
||||
`Project config: Owner of project identified by "extra.eas.projectId" (totallybrent) does not match the logged in user (notnotbrent) and the "owner" field is not specified. 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'
|
||||
)}`
|
||||
);
|
||||
});
|
||||
|
||||
await expect(
|
||||
getProjectIdAsync(
|
||||
sessionManager,
|
||||
{ name: 'test', slug: 'test', extra: { eas: { projectId: '1234' } } },
|
||||
{ nonInteractive: false }
|
||||
)
|
||||
).rejects.toThrow(
|
||||
`Project config: Owner of project identified by "extra.eas.projectId" (totallybrent) does not match the logged in user (notnotbrent) and the "owner" field is not specified. 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 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: {
|
||||
sdkVersion: '52.0.0',
|
||||
name: 'test',
|
||||
slug: 'test',
|
||||
extra: { eas: { projectId: '1234' } },
|
||||
},
|
||||
} as any);
|
||||
jest.mocked(AppQuery.byIdAsync).mockResolvedValue({
|
||||
id: '1234',
|
||||
fullName: '@totallybrent/test',
|
||||
name: 'test',
|
||||
slug: 'test',
|
||||
ownerAccount: { name: 'totallybrent' } as any,
|
||||
});
|
||||
|
||||
await expect(
|
||||
getProjectIdAsync(
|
||||
sessionManagerRobot,
|
||||
{
|
||||
sdkVersion: '52.0.0',
|
||||
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 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);
|
||||
describe('when sdkVersion is 53 or higher', () => {
|
||||
it('does not throw when the owner is not specified', async () => {
|
||||
jest.mocked(getConfig).mockReturnValue({
|
||||
exp: {
|
||||
sdkVersion: '53.0.0',
|
||||
name: 'test',
|
||||
slug: 'test',
|
||||
extra: { eas: { projectId: '1234' } },
|
||||
},
|
||||
} as any);
|
||||
jest.mocked(AppQuery.byIdAsync).mockResolvedValue({
|
||||
id: '1234',
|
||||
fullName: '@totallybrent/test',
|
||||
name: 'test',
|
||||
slug: 'test',
|
||||
ownerAccount: { name: 'totallybrent' } as any,
|
||||
});
|
||||
|
||||
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',
|
||||
name: 'test',
|
||||
slug: 'test',
|
||||
ownerAccount: { name: 'totallybrent' } as any,
|
||||
await expect(
|
||||
getProjectIdAsync(
|
||||
sessionManager,
|
||||
{
|
||||
sdkVersion: '53.0.0',
|
||||
name: 'test',
|
||||
slug: 'test',
|
||||
extra: { eas: { projectId: '1234' } },
|
||||
},
|
||||
{ nonInteractive: false }
|
||||
)
|
||||
).resolves.not.toThrow();
|
||||
});
|
||||
|
||||
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' } } },
|
||||
exp: {
|
||||
sdkVersion: '52.0.0',
|
||||
name: 'test',
|
||||
slug: 'wat',
|
||||
extra: { eas: { projectId: '1234' } },
|
||||
},
|
||||
} as any);
|
||||
jest.mocked(AppQuery.byIdAsync).mockResolvedValue({
|
||||
id: '1234',
|
||||
@@ -211,7 +288,7 @@ describe(getProjectIdAsync, () => {
|
||||
await expect(
|
||||
getProjectIdAsync(
|
||||
sessionManager,
|
||||
{ name: 'test', slug: 'wat', extra: { eas: { projectId: '1234' } } },
|
||||
{ sdkVersion: '52.0.0', name: 'test', slug: 'wat', extra: { eas: { projectId: '1234' } } },
|
||||
{ nonInteractive: false }
|
||||
)
|
||||
).rejects.toThrow(
|
||||
@@ -222,10 +299,17 @@ describe(getProjectIdAsync, () => {
|
||||
});
|
||||
|
||||
it('fetches the project ID when not in app config, and sets it in the config', async () => {
|
||||
jest.mocked(getConfig).mockReturnValue({ exp: { name: 'test', slug: 'test' } } as any);
|
||||
jest
|
||||
.mocked(getConfig)
|
||||
.mockReturnValue({ exp: { sdkVersion: '52.0.0', name: 'test', slug: 'test' } } as any);
|
||||
jest.mocked(modifyConfigAsync).mockResolvedValue({
|
||||
type: 'success',
|
||||
config: { name: 'test', slug: 'test', extra: { eas: { projectId: '2345' } } },
|
||||
config: {
|
||||
sdkVersion: '52.0.0',
|
||||
name: 'test',
|
||||
slug: 'test',
|
||||
extra: { eas: { projectId: '2345' } },
|
||||
},
|
||||
});
|
||||
jest
|
||||
.mocked(fetchOrCreateProjectIDForWriteToConfigWithConfirmationAsync)
|
||||
@@ -233,7 +317,7 @@ describe(getProjectIdAsync, () => {
|
||||
|
||||
const projectId = await getProjectIdAsync(
|
||||
sessionManager,
|
||||
{ name: 'test', slug: 'test' },
|
||||
{ sdkVersion: '52.0.0', name: 'test', slug: 'test' },
|
||||
{
|
||||
nonInteractive: false,
|
||||
}
|
||||
@@ -252,7 +336,9 @@ describe(getProjectIdAsync, () => {
|
||||
});
|
||||
|
||||
it('throws if writing the ID back to the config fails', async () => {
|
||||
jest.mocked(getConfig).mockReturnValue({ exp: { name: 'test', slug: 'test' } } as any);
|
||||
jest
|
||||
.mocked(getConfig)
|
||||
.mockReturnValue({ exp: { sdkVersion: '52.0.0', name: 'test', slug: 'test' } } as any);
|
||||
jest.mocked(modifyConfigAsync).mockResolvedValue({
|
||||
type: 'fail',
|
||||
config: null,
|
||||
@@ -262,13 +348,22 @@ describe(getProjectIdAsync, () => {
|
||||
.mockImplementation(async () => '4567');
|
||||
|
||||
await expect(
|
||||
getProjectIdAsync(sessionManager, { name: 'test', slug: 'test' }, { nonInteractive: false })
|
||||
getProjectIdAsync(
|
||||
sessionManager,
|
||||
{ sdkVersion: '52.0.0', 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 } } },
|
||||
exp: {
|
||||
sdkVersion: '52.0.0',
|
||||
name: 'test',
|
||||
slug: 'test',
|
||||
extra: { eas: { projectId: 1234 } },
|
||||
},
|
||||
} as any);
|
||||
jest.mocked(AppQuery.byIdAsync).mockResolvedValue({
|
||||
id: '1234',
|
||||
@@ -281,7 +376,7 @@ describe(getProjectIdAsync, () => {
|
||||
await expect(
|
||||
getProjectIdAsync(
|
||||
sessionManager,
|
||||
{ name: 'test', slug: 'wat', extra: { eas: { projectId: 1234 } } },
|
||||
{ sdkVersion: '52.0.0', name: 'test', slug: 'wat', extra: { eas: { projectId: 1234 } } },
|
||||
{ nonInteractive: false }
|
||||
)
|
||||
).rejects.toThrow(
|
||||
|
||||
+15
-3
@@ -2,6 +2,7 @@ import { EasJsonAccessor, EasJsonUtils } from '@expo/eas-json';
|
||||
import * as PackageManagerUtils from '@expo/package-manager';
|
||||
import chalk from 'chalk';
|
||||
import fs from 'fs-extra';
|
||||
import getenv from 'getenv';
|
||||
import path from 'path';
|
||||
import pkgDir from 'pkg-dir';
|
||||
import semver from 'semver';
|
||||
@@ -13,7 +14,14 @@ import { resolveVcsClient } from '../../../vcs';
|
||||
async function applyCliConfigAsync(projectDir: string): Promise<void> {
|
||||
const easJsonAccessor = EasJsonAccessor.fromProjectPath(projectDir);
|
||||
const config = await EasJsonUtils.getCliConfigAsync(easJsonAccessor);
|
||||
if (config?.version && !semver.satisfies(easCliVersion, config.version)) {
|
||||
|
||||
const shouldSkipVersionCheck = getenv.boolish('EAS_SKIP_CLI_VERSION_CHECK', false);
|
||||
|
||||
if (
|
||||
!shouldSkipVersionCheck &&
|
||||
config?.version &&
|
||||
!semver.satisfies(easCliVersion, config.version)
|
||||
) {
|
||||
throw new Error(
|
||||
`You are on eas-cli@${easCliVersion} which does not satisfy the CLI version constraint defined in eas.json (${
|
||||
config.version
|
||||
@@ -112,8 +120,12 @@ let ranEnsureEasCliIsNotInDependencies = false;
|
||||
*
|
||||
* @deprecated Should not be used outside of context functions.
|
||||
*/
|
||||
export async function findProjectDirAndVerifyProjectSetupAsync(): Promise<string> {
|
||||
const projectDir = await findProjectRootAsync();
|
||||
export async function findProjectDirAndVerifyProjectSetupAsync({
|
||||
cwd,
|
||||
}: {
|
||||
cwd?: string;
|
||||
} = {}): Promise<string> {
|
||||
const projectDir = await findProjectRootAsync({ cwd });
|
||||
await applyCliConfigAsync(projectDir);
|
||||
if (!ranEnsureEasCliIsNotInDependencies) {
|
||||
ranEnsureEasCliIsNotInDependencies = true;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ExpoConfig, getProjectConfigDescription } from '@expo/config';
|
||||
import { Env } from '@expo/eas-build-job';
|
||||
import chalk from 'chalk';
|
||||
import semver from 'semver';
|
||||
|
||||
import { ExpoGraphqlClient, createGraphqlClient } from './createGraphqlClient';
|
||||
import { findProjectRootAsync } from './findProjectDirAndVerifyProjectSetupAsync';
|
||||
@@ -127,29 +128,33 @@ export async function validateOrSetProjectIdAsync({
|
||||
);
|
||||
}
|
||||
|
||||
const actorUsername = getActorUsername(actor);
|
||||
if (!exp.owner && appForProjectId.ownerAccount.name !== actorUsername) {
|
||||
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'
|
||||
)}`
|
||||
);
|
||||
const sdkVersion = exp.sdkVersion;
|
||||
// SDK 53 and above no longer require owner field in app config
|
||||
if (sdkVersion && semver.satisfies(sdkVersion, '< 53.0.0')) {
|
||||
const actorUsername = getActorUsername(actor);
|
||||
if (!exp.owner && appForProjectId.ownerAccount.name !== actorUsername) {
|
||||
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'
|
||||
)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -175,7 +180,7 @@ export async function validateOrSetProjectIdAsync({
|
||||
|
||||
Log.warn('EAS project not configured.');
|
||||
|
||||
const getAccountNameForEASProjectSync = (exp: ExpoConfig, user: Actor): string => {
|
||||
const getDefaultAccountNameForEASProject = (exp: ExpoConfig, user: Actor): string => {
|
||||
if (exp.owner) {
|
||||
return exp.owner;
|
||||
}
|
||||
@@ -186,7 +191,7 @@ export async function validateOrSetProjectIdAsync({
|
||||
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'
|
||||
'Must configure EAS project by running "eas init" before using a robot user to manage the project.'
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -194,7 +199,7 @@ export async function validateOrSetProjectIdAsync({
|
||||
const projectId = await fetchOrCreateProjectIDForWriteToConfigWithConfirmationAsync(
|
||||
graphqlClient,
|
||||
{
|
||||
accountName: getAccountNameForEASProjectSync(exp, actor),
|
||||
accountName: getDefaultAccountNameForEASProject(exp, actor),
|
||||
projectName: exp.slug,
|
||||
},
|
||||
{
|
||||
|
||||
+4
-12
@@ -1,23 +1,15 @@
|
||||
import { ExpoGraphqlClient } from './createGraphqlClient';
|
||||
import { EnvironmentVariableEnvironment } from '../../../graphql/generated';
|
||||
import { EnvironmentVariablesQuery } from '../../../graphql/queries/EnvironmentVariablesQuery';
|
||||
import Log from '../../../log';
|
||||
|
||||
const cachedServerSideEnvironmentVariables: Record<
|
||||
EnvironmentVariableEnvironment,
|
||||
Record<string, string> | null
|
||||
> = {
|
||||
[EnvironmentVariableEnvironment.Development]: null,
|
||||
[EnvironmentVariableEnvironment.Preview]: null,
|
||||
[EnvironmentVariableEnvironment.Production]: null,
|
||||
};
|
||||
const cachedServerSideEnvironmentVariables: Record<string, Record<string, string> | undefined> = {};
|
||||
|
||||
export async function loadServerSideEnvironmentVariablesAsync({
|
||||
environment,
|
||||
projectId,
|
||||
graphqlClient,
|
||||
}: {
|
||||
environment: EnvironmentVariableEnvironment;
|
||||
environment: string;
|
||||
projectId: string;
|
||||
graphqlClient: ExpoGraphqlClient;
|
||||
}): Promise<Record<string, string>> {
|
||||
@@ -42,13 +34,13 @@ export async function loadServerSideEnvironmentVariablesAsync({
|
||||
|
||||
if (Object.keys(serverEnvVars).length > 0) {
|
||||
Log.log(
|
||||
`Environment variables with visibility "Plain text" and "Sensitive" loaded from the "${environment.toLowerCase()}" environment on EAS: ${Object.keys(
|
||||
`Environment variables with visibility "Plain text" and "Sensitive" loaded from the "${environment}" environment on EAS: ${Object.keys(
|
||||
serverEnvVars
|
||||
).join(', ')}.`
|
||||
);
|
||||
} else {
|
||||
Log.log(
|
||||
`No environment variables with visibility "Plain text" and "Sensitive" found for the "${environment.toLowerCase()}" environment on EAS.`
|
||||
`No environment variables with visibility "Plain text" and "Sensitive" found for the "${environment}" environment on EAS.`
|
||||
);
|
||||
}
|
||||
Log.newLine();
|
||||
|
||||
@@ -1,17 +1,5 @@
|
||||
import { Flags } from '@oclif/core';
|
||||
|
||||
import { EnvironmentVariableEnvironment } from '../graphql/generated';
|
||||
|
||||
// NOTE: not exactly true, but, provided mapToLowercase and upperCaseAsync
|
||||
// are used in tandem, it saves on unnecessary typying in commands
|
||||
async function upperCaseAsync<T>(input: string): Promise<T> {
|
||||
return input.toUpperCase() as T;
|
||||
}
|
||||
|
||||
function mapToLowercase<T extends string>(options: T[]): T[] {
|
||||
return options.map(option => option.toLowerCase()) as T[];
|
||||
}
|
||||
|
||||
export const EasNonInteractiveAndJsonFlags = {
|
||||
json: Flags.boolean({
|
||||
description: 'Enable JSON output, non-JSON messages will be printed to stderr.',
|
||||
@@ -23,21 +11,17 @@ export const EasNonInteractiveAndJsonFlags = {
|
||||
};
|
||||
|
||||
export const EasEnvironmentFlagParameters = {
|
||||
description: "Environment variable's environment",
|
||||
parse: upperCaseAsync,
|
||||
options: mapToLowercase([
|
||||
EnvironmentVariableEnvironment.Development,
|
||||
EnvironmentVariableEnvironment.Preview,
|
||||
EnvironmentVariableEnvironment.Production,
|
||||
]),
|
||||
description: "Environment variable's environment, e.g. 'production', 'preview', 'development'",
|
||||
};
|
||||
|
||||
export const EASEnvironmentFlag = {
|
||||
environment: Flags.enum<EnvironmentVariableEnvironment>(EasEnvironmentFlagParameters),
|
||||
environment: Flags.string({
|
||||
description: "Environment variable's environment, e.g. 'production', 'preview', 'development'",
|
||||
}),
|
||||
};
|
||||
|
||||
export const EASMultiEnvironmentFlag = {
|
||||
environment: Flags.enum<EnvironmentVariableEnvironment>({
|
||||
environment: Flags.string({
|
||||
...EasEnvironmentFlagParameters,
|
||||
multiple: true,
|
||||
}),
|
||||
@@ -81,12 +65,10 @@ export const EasJsonOnlyFlag = {
|
||||
};
|
||||
|
||||
export const EasUpdateEnvironmentFlag = {
|
||||
environment: Flags.enum<EnvironmentVariableEnvironment | null>({
|
||||
environment: Flags.string({
|
||||
description:
|
||||
'Environment to use for the server-side defined EAS environment variables during command execution.',
|
||||
options: mapToLowercase(Object.values(EnvironmentVariableEnvironment)),
|
||||
parse: upperCaseAsync,
|
||||
'Environment to use for the server-side defined EAS environment variables during command execution, e.g. "production", "preview", "development"',
|
||||
required: false,
|
||||
default: null,
|
||||
default: undefined,
|
||||
}),
|
||||
};
|
||||
@@ -0,0 +1,131 @@
|
||||
import { canAccessRepositoryUsingSshAsync, runGitCloneAsync } from '../../../onboarding/git';
|
||||
import { installDependenciesAsync } from '../../../onboarding/installDependencies';
|
||||
import { runCommandAsync } from '../../../onboarding/runCommand';
|
||||
import {
|
||||
cloneTemplateAsync,
|
||||
initializeGitRepositoryAsync,
|
||||
installProjectDependenciesAsync,
|
||||
} from '../commands';
|
||||
|
||||
jest.mock('../../../onboarding/git');
|
||||
jest.mock('../../../onboarding/runCommand');
|
||||
jest.mock('../../../onboarding/installDependencies');
|
||||
jest.mock('../../../ora');
|
||||
jest.mock('fs-extra');
|
||||
|
||||
describe('commands', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('cloneTemplateAsync', () => {
|
||||
it('should clone the template project with ssh', async () => {
|
||||
const targetProjectDir = '/test/target-project';
|
||||
const finalTargetProjectDir = '/test/final-target-project';
|
||||
|
||||
jest.mocked(canAccessRepositoryUsingSshAsync).mockResolvedValue(true);
|
||||
jest.mocked(runGitCloneAsync).mockResolvedValue({
|
||||
targetProjectDir: finalTargetProjectDir,
|
||||
});
|
||||
|
||||
const result = await cloneTemplateAsync(targetProjectDir);
|
||||
|
||||
expect(runGitCloneAsync).toHaveBeenCalledWith({
|
||||
githubUsername: 'expo',
|
||||
githubRepositoryName: 'expo-template-default',
|
||||
targetProjectDir,
|
||||
cloneMethod: 'ssh',
|
||||
showOutput: false,
|
||||
});
|
||||
|
||||
expect(result).toBe(finalTargetProjectDir);
|
||||
});
|
||||
|
||||
it('should clone the template project with https', async () => {
|
||||
const targetProjectDir = '/test/target-project';
|
||||
const finalTargetProjectDir = '/test/final-target-project';
|
||||
|
||||
jest.mocked(canAccessRepositoryUsingSshAsync).mockResolvedValue(false);
|
||||
jest.mocked(runGitCloneAsync).mockResolvedValue({
|
||||
targetProjectDir: finalTargetProjectDir,
|
||||
});
|
||||
|
||||
const result = await cloneTemplateAsync(targetProjectDir);
|
||||
|
||||
expect(runGitCloneAsync).toHaveBeenCalledWith({
|
||||
githubUsername: 'expo',
|
||||
githubRepositoryName: 'expo-template-default',
|
||||
targetProjectDir,
|
||||
cloneMethod: 'https',
|
||||
showOutput: false,
|
||||
});
|
||||
|
||||
expect(result).toBe(finalTargetProjectDir);
|
||||
});
|
||||
});
|
||||
|
||||
describe('installProjectDependenciesAsync', () => {
|
||||
it('should install the project dependencies', async () => {
|
||||
const projectDir = '/test/project-directory';
|
||||
|
||||
jest.mocked(installDependenciesAsync).mockResolvedValue();
|
||||
jest.mocked(runCommandAsync).mockResolvedValue();
|
||||
|
||||
await installProjectDependenciesAsync(projectDir, 'npm');
|
||||
|
||||
expect(installDependenciesAsync).toHaveBeenCalledWith({
|
||||
outputLevel: 'none',
|
||||
projectDir,
|
||||
packageManager: 'npm',
|
||||
});
|
||||
|
||||
expect(runCommandAsync).toHaveBeenCalledWith({
|
||||
cwd: projectDir,
|
||||
command: 'npx',
|
||||
args: ['expo', 'install', 'expo-updates'],
|
||||
showOutput: false,
|
||||
showSpinner: false,
|
||||
});
|
||||
|
||||
expect(runCommandAsync).toHaveBeenCalledWith({
|
||||
cwd: projectDir,
|
||||
command: 'npx',
|
||||
args: ['expo', 'install', '@expo/metro-runtime'],
|
||||
showOutput: false,
|
||||
showSpinner: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('initializeGitRepositoryAsync', () => {
|
||||
it('should initialize git repository', async () => {
|
||||
const projectDir = '/test/project-dir';
|
||||
|
||||
await initializeGitRepositoryAsync(projectDir);
|
||||
|
||||
expect(runCommandAsync).toHaveBeenCalledWith({
|
||||
command: 'git',
|
||||
args: ['init'],
|
||||
cwd: projectDir,
|
||||
showOutput: false,
|
||||
showSpinner: false,
|
||||
});
|
||||
|
||||
expect(runCommandAsync).toHaveBeenCalledWith({
|
||||
command: 'git',
|
||||
args: ['add', '.'],
|
||||
cwd: projectDir,
|
||||
showOutput: false,
|
||||
showSpinner: false,
|
||||
});
|
||||
|
||||
expect(runCommandAsync).toHaveBeenCalledWith({
|
||||
command: 'git',
|
||||
args: ['commit', '-m', 'Initial commit'],
|
||||
cwd: projectDir,
|
||||
showOutput: false,
|
||||
showSpinner: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,245 @@
|
||||
import fs from 'fs-extra';
|
||||
|
||||
import { LogSpy } from './testUtils';
|
||||
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { jester } from '../../../credentials/__tests__/fixtures-constants';
|
||||
import { findProjectIdByAccountNameAndSlugNullableAsync } from '../../../project/fetchOrCreateProjectIDForWriteToConfigWithConfirmationAsync';
|
||||
import { promptAsync } from '../../../prompts';
|
||||
import {
|
||||
findAvailableProjectNameAsync,
|
||||
generateProjectConfigAsync,
|
||||
getAccountChoices,
|
||||
promptForProjectAccountAsync,
|
||||
} from '../configs';
|
||||
|
||||
jest.mock('../../../prompts');
|
||||
jest.mock('../../../log');
|
||||
jest.mock('fs-extra');
|
||||
jest.mock('../../../project/fetchOrCreateProjectIDForWriteToConfigWithConfirmationAsync');
|
||||
jest.mock('../utils', () => ({
|
||||
printDirectory: jest.fn((dir: string) => dir),
|
||||
}));
|
||||
|
||||
describe('configs', () => {
|
||||
let logSpy: LogSpy;
|
||||
|
||||
beforeAll(() => {
|
||||
logSpy = new LogSpy('withInfo');
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
logSpy.restore();
|
||||
});
|
||||
|
||||
// Helper function to verify prompt was called with expected message
|
||||
const expectPromptToHaveMessage = (expectedMessage: string): void => {
|
||||
expect(promptAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: expectedMessage,
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const mockUserInput = (inputValue: any): void => {
|
||||
jest.mocked(promptAsync).mockResolvedValue(inputValue);
|
||||
};
|
||||
|
||||
describe('generateProjectConfigAsync', () => {
|
||||
const mockOptions = {
|
||||
graphqlClient: {} as ExpoGraphqlClient,
|
||||
projectAccount: 'test-account',
|
||||
};
|
||||
|
||||
describe('when no path is provided', () => {
|
||||
it('should prompt for project name with default value', async () => {
|
||||
(fs.pathExists as jest.Mock).mockResolvedValue(false);
|
||||
jest.mocked(findProjectIdByAccountNameAndSlugNullableAsync).mockResolvedValue(null);
|
||||
mockUserInput({ name: 'expo-project' });
|
||||
|
||||
const result = await generateProjectConfigAsync(undefined, mockOptions);
|
||||
|
||||
expect(result).toEqual({
|
||||
projectName: 'expo-project',
|
||||
projectDirectory: expect.stringContaining('/expo-project'),
|
||||
});
|
||||
expectPromptToHaveMessage('What would you like to name your project?');
|
||||
expect(promptAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
initial: 'expo-project',
|
||||
})
|
||||
);
|
||||
logSpy.expectLogToContain('Using project name: expo-project');
|
||||
});
|
||||
|
||||
it('should prompt and use custom name entered by user', async () => {
|
||||
(fs.pathExists as jest.Mock).mockResolvedValue(false);
|
||||
jest.mocked(findProjectIdByAccountNameAndSlugNullableAsync).mockResolvedValue(null);
|
||||
mockUserInput({ name: 'user-custom-name' });
|
||||
|
||||
const result = await generateProjectConfigAsync(undefined, mockOptions);
|
||||
|
||||
expect(result).toEqual({
|
||||
projectName: 'user-custom-name',
|
||||
projectDirectory: expect.stringContaining('/user-custom-name'),
|
||||
});
|
||||
expectPromptToHaveMessage('What would you like to name your project?');
|
||||
logSpy.expectLogToContain('Using project name: user-custom-name');
|
||||
});
|
||||
|
||||
it('should use shortid when prompted name exists', async () => {
|
||||
(fs.pathExists as jest.Mock).mockResolvedValue(true); // base name exists
|
||||
jest.mocked(findProjectIdByAccountNameAndSlugNullableAsync).mockResolvedValue(null);
|
||||
mockUserInput({ name: 'existing-project' });
|
||||
|
||||
const result = await generateProjectConfigAsync(undefined, mockOptions);
|
||||
|
||||
expect(result).toEqual({
|
||||
projectName: expect.stringMatching(/^existing-project-[a-zA-Z0-9_-]{6}$/),
|
||||
projectDirectory: expect.stringContaining(result.projectName),
|
||||
});
|
||||
logSpy.expectLogToContain('Using project name:');
|
||||
});
|
||||
});
|
||||
|
||||
describe('when path is provided', () => {
|
||||
it('should use absolute path and extract name from basename', async () => {
|
||||
(fs.pathExists as jest.Mock).mockResolvedValue(false);
|
||||
jest.mocked(findProjectIdByAccountNameAndSlugNullableAsync).mockResolvedValue(null);
|
||||
const absolutePath = '/absolute/path/to/my-project';
|
||||
const result = await generateProjectConfigAsync(absolutePath, mockOptions);
|
||||
|
||||
expect(result).toEqual({
|
||||
projectName: 'my-project',
|
||||
projectDirectory: absolutePath,
|
||||
});
|
||||
expect(promptAsync).not.toHaveBeenCalled();
|
||||
logSpy.expectLogToContain('Using project directory:');
|
||||
});
|
||||
|
||||
it('should resolve relative path and extract name from basename', async () => {
|
||||
(fs.pathExists as jest.Mock).mockResolvedValue(false);
|
||||
jest.mocked(findProjectIdByAccountNameAndSlugNullableAsync).mockResolvedValue(null);
|
||||
const relativePath = 'some/relative/my-app';
|
||||
const result = await generateProjectConfigAsync(relativePath, mockOptions);
|
||||
|
||||
expect(result).toEqual({
|
||||
projectName: 'my-app',
|
||||
projectDirectory: expect.stringContaining('some/relative/my-app'),
|
||||
});
|
||||
expect(promptAsync).not.toHaveBeenCalled();
|
||||
logSpy.expectLogToContain('Using project directory:');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('promptForProjectAccountAsync', () => {
|
||||
it('should prompt for a project account when there are multiple accounts', async () => {
|
||||
const selectedAccount = 'jester';
|
||||
mockUserInput({ account: { name: selectedAccount } });
|
||||
|
||||
const result = await promptForProjectAccountAsync(jester);
|
||||
|
||||
expect(result).toBe(selectedAccount);
|
||||
expectPromptToHaveMessage('Which account should own this project?');
|
||||
});
|
||||
|
||||
it('should automatically select single account with permissions', async () => {
|
||||
const result = await promptForProjectAccountAsync(jester);
|
||||
expect(result).toBe(jester.accounts[0].name);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAccountChoices', () => {
|
||||
it('should return mapped account choices', () => {
|
||||
const result = getAccountChoices(jester);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
title: 'other',
|
||||
value: { name: 'other' },
|
||||
disabled: true,
|
||||
description:
|
||||
'You do not have the required permissions to create projects on this account.',
|
||||
},
|
||||
{
|
||||
title: 'jester',
|
||||
value: { name: 'jester' },
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findAvailableProjectNameAsync', () => {
|
||||
const mockGraphqlClient = {} as ExpoGraphqlClient;
|
||||
const mockOptions = {
|
||||
graphqlClient: mockGraphqlClient,
|
||||
projectAccount: 'jester',
|
||||
};
|
||||
|
||||
it('should find first available name when checking both local and remote', async () => {
|
||||
(fs.pathExists as jest.Mock).mockResolvedValue(false);
|
||||
jest.mocked(findProjectIdByAccountNameAndSlugNullableAsync).mockResolvedValue(null);
|
||||
|
||||
const result = await findAvailableProjectNameAsync('test-project', '/base/path', mockOptions);
|
||||
|
||||
expect(result).toEqual({
|
||||
projectName: 'test-project',
|
||||
projectDirectory: '/base/path/test-project',
|
||||
});
|
||||
expect(fs.pathExists).toHaveBeenCalledTimes(1);
|
||||
expect(findProjectIdByAccountNameAndSlugNullableAsync).toHaveBeenCalledWith(
|
||||
mockGraphqlClient,
|
||||
'jester',
|
||||
'test-project'
|
||||
);
|
||||
});
|
||||
|
||||
it('should use shortid variant when base name exists locally', async () => {
|
||||
(fs.pathExists as jest.Mock).mockResolvedValue(true); // Base name exists locally
|
||||
jest.mocked(findProjectIdByAccountNameAndSlugNullableAsync).mockResolvedValue(null);
|
||||
|
||||
const result = await findAvailableProjectNameAsync('test-project', '/base/path', mockOptions);
|
||||
|
||||
expect(result).toEqual({
|
||||
projectName: expect.stringMatching(/^test-project-[a-zA-Z0-9_-]{6}$/),
|
||||
projectDirectory: expect.stringContaining('/base/path/test-project-'),
|
||||
});
|
||||
expect(fs.pathExists).toHaveBeenCalledTimes(1);
|
||||
expect(fs.pathExists).toHaveBeenCalledWith('/base/path/test-project');
|
||||
});
|
||||
|
||||
it('should use shortid variant when base name exists remotely', async () => {
|
||||
(fs.pathExists as jest.Mock).mockResolvedValue(false);
|
||||
jest.mocked(findProjectIdByAccountNameAndSlugNullableAsync).mockResolvedValue('project-id'); // Base name exists remotely
|
||||
|
||||
const result = await findAvailableProjectNameAsync('test-project', '/base/path', mockOptions);
|
||||
|
||||
expect(result).toEqual({
|
||||
projectName: expect.stringMatching(/^test-project-[a-zA-Z0-9_-]{6}$/),
|
||||
projectDirectory: expect.stringContaining('/base/path/test-project-'),
|
||||
});
|
||||
expect(findProjectIdByAccountNameAndSlugNullableAsync).toHaveBeenCalledTimes(1);
|
||||
expect(findProjectIdByAccountNameAndSlugNullableAsync).toHaveBeenCalledWith(
|
||||
mockGraphqlClient,
|
||||
'jester',
|
||||
'test-project'
|
||||
);
|
||||
});
|
||||
|
||||
it('should use shortid variant when base name is taken both locally and remotely', async () => {
|
||||
(fs.pathExists as jest.Mock).mockResolvedValue(true);
|
||||
jest.mocked(findProjectIdByAccountNameAndSlugNullableAsync).mockResolvedValue('project-id');
|
||||
|
||||
const result = await findAvailableProjectNameAsync('test-project', '/base/path', mockOptions);
|
||||
|
||||
expect(result).toEqual({
|
||||
projectName: expect.stringMatching(/^test-project-[a-zA-Z0-9_-]{6}$/),
|
||||
projectDirectory: expect.stringContaining('/base/path/test-project-'),
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,369 @@
|
||||
import { ExpoConfig } from '@expo/config';
|
||||
import fs from 'fs-extra';
|
||||
|
||||
import { getEASUpdateURL } from '../../../api';
|
||||
import { AppFragment } from '../../../graphql/generated';
|
||||
import { easCliVersion } from '../../../utils/easCli';
|
||||
import {
|
||||
cleanAndPrefix,
|
||||
copyProjectTemplatesAsync,
|
||||
generateAppConfigAsync,
|
||||
generateEasConfigAsync,
|
||||
updatePackageJsonAsync,
|
||||
updateReadmeAsync,
|
||||
} from '../projectFiles';
|
||||
|
||||
jest.mock('../../../api');
|
||||
jest.mock('fs-extra');
|
||||
|
||||
describe('projectFiles', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
jest.mocked(fs.writeFile).mockImplementation(() => Promise.resolve());
|
||||
jest.mocked(fs.readJson).mockImplementation(() => Promise.resolve({}));
|
||||
jest.mocked(fs.writeJson).mockImplementation(() => Promise.resolve());
|
||||
jest.mocked(fs.copy).mockImplementation(() => Promise.resolve());
|
||||
jest.mocked(fs.remove).mockImplementation(() => Promise.resolve());
|
||||
jest.mocked(fs.readFile).mockImplementation(() => Promise.resolve(''));
|
||||
jest.mocked(fs.symlink).mockImplementation(() => Promise.resolve());
|
||||
});
|
||||
|
||||
describe('cleanAndPrefix', () => {
|
||||
it('should clean and prefix the string', () => {
|
||||
expect(cleanAndPrefix('test-app-123', 'app')).toBe('testapp123');
|
||||
expect(cleanAndPrefix('test@app#with$symbols', 'app')).toBe('testappwithsymbols');
|
||||
expect(cleanAndPrefix('test-app-with-dashes', 'app')).toBe('testappwithdashes');
|
||||
expect(cleanAndPrefix('TestApp', 'app')).toBe('appTestApp');
|
||||
expect(cleanAndPrefix('100 App', 'app')).toBe('app100App');
|
||||
});
|
||||
|
||||
it('should handle schemes', () => {
|
||||
expect(cleanAndPrefix('test-app-123', 'scheme')).toBe('test-app-123');
|
||||
expect(cleanAndPrefix('test@app#with$symbols', 'scheme')).toBe('testappwithsymbols');
|
||||
expect(cleanAndPrefix('test+app.with-allowed.symbols', 'scheme')).toBe(
|
||||
'test+app.with-allowed.symbols'
|
||||
);
|
||||
expect(cleanAndPrefix('100 App', 'scheme')).toBe('scheme100app');
|
||||
expect(cleanAndPrefix('TestApp', 'scheme')).toBe('testapp');
|
||||
expect(cleanAndPrefix('testApp2', 'scheme')).toBe('testapp2');
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateAppConfigAsync', () => {
|
||||
const projectDir = '/test/project-dir';
|
||||
const mockUpdateUrl = 'https://u.expo.dev/test-project-id';
|
||||
|
||||
const createMockApp = (overrides: Partial<AppFragment> = {}): AppFragment => ({
|
||||
id: 'test-project-id',
|
||||
name: 'Test App',
|
||||
fullName: '@testowner/test-app',
|
||||
slug: 'test-app',
|
||||
ownerAccount: {
|
||||
id: 'testowner-account-id',
|
||||
name: 'testowner',
|
||||
users: [],
|
||||
},
|
||||
...overrides,
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
jest.mocked(getEASUpdateURL).mockReturnValue(mockUpdateUrl);
|
||||
jest.mocked(fs.readJson).mockResolvedValue({
|
||||
expo: {} as ExpoConfig,
|
||||
});
|
||||
});
|
||||
|
||||
it('should generate the app config', async () => {
|
||||
const mockApp = createMockApp();
|
||||
jest.mocked(fs.readJson).mockResolvedValue({
|
||||
expo: {
|
||||
name: 'value-to-override',
|
||||
slug: 'value-to-override',
|
||||
icon: 'value-to-keep',
|
||||
} as ExpoConfig,
|
||||
});
|
||||
|
||||
await generateAppConfigAsync(projectDir, mockApp);
|
||||
|
||||
expect(getEASUpdateURL).toHaveBeenCalledWith('test-project-id', null);
|
||||
|
||||
const expectedConfig = {
|
||||
expo: {
|
||||
name: 'Test App',
|
||||
slug: 'test-app',
|
||||
scheme: 'testapp',
|
||||
icon: 'value-to-keep',
|
||||
extra: {
|
||||
eas: {
|
||||
projectId: 'test-project-id',
|
||||
},
|
||||
},
|
||||
owner: 'testowner',
|
||||
updates: {
|
||||
url: mockUpdateUrl,
|
||||
},
|
||||
runtimeVersion: {
|
||||
policy: 'appVersion',
|
||||
},
|
||||
ios: {
|
||||
bundleIdentifier: 'com.testowner.testapp',
|
||||
},
|
||||
android: {
|
||||
package: 'com.testowner.testapp',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(fs.writeJson).toHaveBeenCalledWith(`${projectDir}/app.json`, expectedConfig, {
|
||||
spaces: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle invalid characters in the bundle identifier', async () => {
|
||||
const mockApp = createMockApp({
|
||||
slug: 'test-app-with-dashes',
|
||||
ownerAccount: {
|
||||
id: 'test-owner-account-id',
|
||||
name: 'test-owner',
|
||||
users: [],
|
||||
},
|
||||
});
|
||||
|
||||
await generateAppConfigAsync(projectDir, mockApp);
|
||||
|
||||
expect(fs.writeJson).toHaveBeenCalledWith(
|
||||
`${projectDir}/app.json`,
|
||||
expect.objectContaining({
|
||||
expo: expect.objectContaining({
|
||||
ios: expect.objectContaining({
|
||||
bundleIdentifier: 'com.testowner.testappwithdashes',
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
{ spaces: 2 }
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle invalid characters in the owner account name', async () => {
|
||||
const mockApp = createMockApp({
|
||||
slug: 'testapp',
|
||||
ownerAccount: {
|
||||
id: 'test-owner-123-account-id',
|
||||
name: 'test-owner-123',
|
||||
users: [],
|
||||
},
|
||||
});
|
||||
|
||||
await generateAppConfigAsync(projectDir, mockApp);
|
||||
|
||||
expect(fs.writeJson).toHaveBeenCalledWith(
|
||||
`${projectDir}/app.json`,
|
||||
expect.objectContaining({
|
||||
expo: expect.objectContaining({
|
||||
ios: expect.objectContaining({
|
||||
bundleIdentifier: 'com.testowner123.testapp',
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
{ spaces: 2 }
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle invalid characters in the slug', async () => {
|
||||
const mockApp = createMockApp({
|
||||
slug: 'test@app#with$symbols',
|
||||
fullName: '@testowner/test@app#with$symbols',
|
||||
});
|
||||
|
||||
await generateAppConfigAsync(projectDir, mockApp);
|
||||
|
||||
expect(fs.writeJson).toHaveBeenCalledWith(
|
||||
`${projectDir}/app.json`,
|
||||
expect.objectContaining({
|
||||
expo: expect.objectContaining({
|
||||
ios: expect.objectContaining({
|
||||
bundleIdentifier: 'com.testowner.testappwithsymbols',
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
{ spaces: 2 }
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateEasConfigAsync', () => {
|
||||
const projectDir = '/test/project-dir';
|
||||
|
||||
it('should generate the eas config', async () => {
|
||||
const expectedEasConfig = {
|
||||
cli: {
|
||||
version: `>= ${easCliVersion}`,
|
||||
appVersionSource: 'remote',
|
||||
},
|
||||
build: {
|
||||
development: {
|
||||
developmentClient: true,
|
||||
distribution: 'internal',
|
||||
android: { image: 'latest' },
|
||||
ios: { image: 'latest' },
|
||||
},
|
||||
'development-simulator': {
|
||||
extends: 'development',
|
||||
ios: { simulator: true },
|
||||
},
|
||||
preview: {
|
||||
distribution: 'internal',
|
||||
channel: 'main',
|
||||
android: { image: 'latest' },
|
||||
ios: { image: 'latest' },
|
||||
},
|
||||
production: {
|
||||
channel: 'production',
|
||||
autoIncrement: true,
|
||||
android: { image: 'latest' },
|
||||
ios: { image: 'latest' },
|
||||
},
|
||||
},
|
||||
submit: {
|
||||
production: {},
|
||||
},
|
||||
};
|
||||
|
||||
await generateEasConfigAsync(projectDir);
|
||||
|
||||
expect(fs.writeJson).toHaveBeenCalledWith(`${projectDir}/eas.json`, expectedEasConfig, {
|
||||
spaces: 2,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('updatePackageJsonAsync', () => {
|
||||
it('should update the package.json', async () => {
|
||||
jest.mocked(fs.readJson).mockResolvedValue({
|
||||
name: 'test-app',
|
||||
version: '1.0.0',
|
||||
});
|
||||
const projectDir = '/test/project-dir';
|
||||
await updatePackageJsonAsync(projectDir);
|
||||
|
||||
const expectedPackageJson = {
|
||||
name: 'test-app',
|
||||
version: '1.0.0',
|
||||
scripts: {
|
||||
draft: 'npx eas-cli@latest workflow:run create-draft.yml',
|
||||
'development-builds': 'npx eas-cli@latest workflow:run create-development-builds.yml',
|
||||
deploy: 'npx eas-cli@latest workflow:run deploy-to-production.yml',
|
||||
},
|
||||
};
|
||||
|
||||
expect(fs.writeJson).toHaveBeenCalledWith(`${projectDir}/package.json`, expectedPackageJson, {
|
||||
spaces: 2,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('copyProjectTemplatesAsync', () => {
|
||||
it('should copy the project templates', async () => {
|
||||
const projectDir = '/test/project-dir';
|
||||
|
||||
await copyProjectTemplatesAsync(projectDir);
|
||||
|
||||
expect(fs.copy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('templates'),
|
||||
projectDir,
|
||||
expect.objectContaining({
|
||||
errorOnExist: false,
|
||||
overwrite: true,
|
||||
filter: expect.any(Function),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateReadmeAsync', () => {
|
||||
const projectDir = '/test/project-dir';
|
||||
const readmeTemplatePath = expect.stringContaining('templates/readme-additions.md');
|
||||
const projectReadmePath = `${projectDir}/README.md`;
|
||||
|
||||
const mockReadmeTemplate = `## Get started
|
||||
|
||||
To start the app, in your terminal run:
|
||||
|
||||
\`\`\`bash
|
||||
npm run start
|
||||
\`\`\`
|
||||
|
||||
## Workflows
|
||||
`;
|
||||
const mockExistingReadme = `# My App
|
||||
|
||||
## Get started
|
||||
|
||||
Follow these steps to get started with the app.
|
||||
|
||||
## Installation
|
||||
|
||||
Install the dependencies first.`;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.mocked(fs.readFile).mockImplementation((filePath: any) => {
|
||||
const content = filePath.includes('readme-additions.md')
|
||||
? mockReadmeTemplate
|
||||
: filePath === projectReadmePath
|
||||
? mockExistingReadme
|
||||
: '';
|
||||
return Promise.resolve(content);
|
||||
});
|
||||
});
|
||||
|
||||
it('should read the existing readme and merge the template content', async () => {
|
||||
await updateReadmeAsync(projectDir, 'npm');
|
||||
|
||||
expect(fs.readFile).toHaveBeenCalledWith(readmeTemplatePath, 'utf8');
|
||||
expect(fs.readFile).toHaveBeenCalledWith(projectReadmePath, 'utf8');
|
||||
|
||||
const headings = ['# My App', '## Get started', '## Workflows'];
|
||||
expect(fs.writeFile).toHaveBeenCalledWith(
|
||||
projectReadmePath,
|
||||
expect.stringMatching(new RegExp(headings.join('[\\s\\S]*')))
|
||||
);
|
||||
});
|
||||
|
||||
it('should replace npm run with package manager specific commands', async () => {
|
||||
await updateReadmeAsync(projectDir, 'yarn');
|
||||
|
||||
expect(fs.writeFile).toHaveBeenCalledWith(
|
||||
projectReadmePath,
|
||||
expect.stringContaining('yarn run start')
|
||||
);
|
||||
});
|
||||
|
||||
it("should handle the case when 'Get started' section does not exist", async () => {
|
||||
const readmeWithoutGetStarted = `# My App
|
||||
|
||||
This is my awesome app.
|
||||
|
||||
## Installation
|
||||
|
||||
Install the dependencies first.`;
|
||||
|
||||
jest.mocked(fs.readFile).mockImplementation((filePath: any) => {
|
||||
const content = filePath.includes('readme-additions.md')
|
||||
? mockReadmeTemplate
|
||||
: filePath === projectReadmePath
|
||||
? readmeWithoutGetStarted
|
||||
: '';
|
||||
return Promise.resolve(content);
|
||||
});
|
||||
|
||||
await updateReadmeAsync(projectDir, 'npm');
|
||||
|
||||
const headings = ['# My App', '## Installation', '## Get started', '## Workflows'];
|
||||
expect(fs.writeFile).toHaveBeenCalledWith(
|
||||
projectReadmePath,
|
||||
expect.stringMatching(new RegExp(headings.join('[\\s\\S]*')))
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
import Log from '../../../log';
|
||||
|
||||
export class LogSpy {
|
||||
private readonly logSpy: jest.SpyInstance;
|
||||
|
||||
constructor(logMethod: 'warn' | 'log' | 'withTick' | 'withInfo') {
|
||||
this.logSpy = jest.spyOn(Log, logMethod);
|
||||
}
|
||||
|
||||
getLogOutput(): string[] {
|
||||
return this.logSpy.mock.calls.map(call => (call.length === 0 ? '' : call.join(' ')));
|
||||
}
|
||||
|
||||
expectLogToContain(message: string): void {
|
||||
const output = this.getLogOutput();
|
||||
// strip out ANSI codes and special characters like the tick
|
||||
const outputWithoutAnsi = output.map(line =>
|
||||
line.replace(/\x1b\[[0-9;]*m/g, '').replace(/✔\s*/, '')
|
||||
);
|
||||
expect(outputWithoutAnsi.some(line => line.includes(message))).toBeTruthy();
|
||||
}
|
||||
|
||||
restore(): void {
|
||||
this.logSpy.mockRestore();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import path from 'path';
|
||||
|
||||
import { printDirectory } from '../utils';
|
||||
|
||||
describe('utils', () => {
|
||||
describe('printDirectory', () => {
|
||||
let cwdSpy: jest.SpyInstance;
|
||||
|
||||
beforeEach(() => {
|
||||
// Mock process.cwd() to return a predictable value
|
||||
cwdSpy = jest.spyOn(process, 'cwd').mockReturnValue('/Users/test/projects');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should return "." for the current directory', () => {
|
||||
const result = printDirectory('/Users/test/projects');
|
||||
expect(result).toBe('.');
|
||||
});
|
||||
|
||||
it('should return relative path with "./" prefix for subdirectory', () => {
|
||||
const result = printDirectory('/Users/test/projects/my-app');
|
||||
expect(result).toBe('./my-app');
|
||||
});
|
||||
|
||||
it('should return relative path with "./" prefix for nested subdirectory', () => {
|
||||
const result = printDirectory('/Users/test/projects/nested/my-app');
|
||||
expect(result).toBe('./nested/my-app');
|
||||
});
|
||||
|
||||
it('should return absolute path for directory outside cwd', () => {
|
||||
const result = printDirectory('/Users/other/projects/my-app');
|
||||
expect(result).toBe('/Users/other/projects/my-app');
|
||||
});
|
||||
|
||||
it('should return absolute path for parent directory', () => {
|
||||
const result = printDirectory('/Users/test');
|
||||
expect(result).toBe('/Users/test');
|
||||
});
|
||||
|
||||
it('should handle relative paths and convert them correctly', () => {
|
||||
// Relative path resolves to absolute first, then converts back to relative
|
||||
const result = printDirectory('my-app');
|
||||
expect(result).toBe('./my-app');
|
||||
});
|
||||
|
||||
it('should handle relative paths with "./" already', () => {
|
||||
const result = printDirectory('./my-app');
|
||||
expect(result).toBe('./my-app');
|
||||
});
|
||||
|
||||
it('should handle relative paths with "../" (goes outside cwd)', () => {
|
||||
const result = printDirectory('../other-project');
|
||||
// This goes outside cwd, so should return absolute path
|
||||
expect(result).toBe('/Users/test/other-project');
|
||||
});
|
||||
|
||||
it('should handle complex relative paths', () => {
|
||||
const result = printDirectory('./nested/deep/project');
|
||||
expect(result).toBe('./nested/deep/project');
|
||||
});
|
||||
|
||||
it('should normalize paths correctly', () => {
|
||||
const result = printDirectory('/Users/test/projects/./my-app/../my-app');
|
||||
expect(result).toBe('./my-app');
|
||||
});
|
||||
|
||||
it('should handle paths in real cwd', () => {
|
||||
// Restore mock to test with real cwd
|
||||
cwdSpy.mockRestore();
|
||||
const realCwd = process.cwd();
|
||||
|
||||
// Test with current directory
|
||||
const result1 = printDirectory(realCwd);
|
||||
expect(result1).toBe('.');
|
||||
|
||||
// Test with subdirectory
|
||||
const subDir = path.join(realCwd, 'test-dir');
|
||||
const result2 = printDirectory(subDir);
|
||||
expect(result2).toBe('./test-dir');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
import chalk from 'chalk';
|
||||
import fs from 'fs-extra';
|
||||
import path from 'path';
|
||||
|
||||
import { printDirectory } from './utils';
|
||||
import { canAccessRepositoryUsingSshAsync, runGitCloneAsync } from '../../onboarding/git';
|
||||
import { PackageManager, installDependenciesAsync } from '../../onboarding/installDependencies';
|
||||
import { runCommandAsync } from '../../onboarding/runCommand';
|
||||
import { ora } from '../../ora';
|
||||
|
||||
export async function cloneTemplateAsync(targetProjectDir: string): Promise<string> {
|
||||
const githubUsername = 'expo';
|
||||
const githubRepositoryName = 'expo-template-default';
|
||||
|
||||
const spinner = ora(
|
||||
`${chalk.bold(`Cloning the project to ${printDirectory(targetProjectDir)}`)}`
|
||||
).start();
|
||||
|
||||
const cloneMethod = (await canAccessRepositoryUsingSshAsync({
|
||||
githubUsername,
|
||||
githubRepositoryName,
|
||||
}))
|
||||
? 'ssh'
|
||||
: 'https';
|
||||
|
||||
const { targetProjectDir: finalTargetProjectDirectory } = await runGitCloneAsync({
|
||||
githubUsername,
|
||||
githubRepositoryName,
|
||||
targetProjectDir,
|
||||
cloneMethod,
|
||||
showOutput: false,
|
||||
});
|
||||
|
||||
spinner.succeed(`Cloned the project to ${printDirectory(finalTargetProjectDirectory)}`);
|
||||
|
||||
return finalTargetProjectDirectory;
|
||||
}
|
||||
|
||||
export async function installProjectDependenciesAsync(
|
||||
projectDir: string,
|
||||
packageManager: PackageManager
|
||||
): Promise<void> {
|
||||
const spinner = ora(`${chalk.bold('Installing project dependencies')}`).start();
|
||||
await installDependenciesAsync({
|
||||
outputLevel: 'none',
|
||||
projectDir,
|
||||
packageManager,
|
||||
});
|
||||
|
||||
const dependencies = ['expo-updates', '@expo/metro-runtime'];
|
||||
for (const dependency of dependencies) {
|
||||
spinner.text = `Installing ${chalk.bold(dependency)}`;
|
||||
await runCommandAsync({
|
||||
cwd: projectDir,
|
||||
command: 'npx',
|
||||
args: ['expo', 'install', dependency],
|
||||
showOutput: false,
|
||||
showSpinner: false,
|
||||
});
|
||||
}
|
||||
spinner.succeed(`Installed project dependencies`);
|
||||
}
|
||||
|
||||
export async function initializeGitRepositoryAsync(projectDir: string): Promise<void> {
|
||||
const spinner = ora(`${chalk.bold('Initializing Git repository')}`).start();
|
||||
await fs.remove(path.join(projectDir, '.git'));
|
||||
|
||||
const commands = [['init'], ['add', '.'], ['commit', '-m', 'Initial commit']];
|
||||
|
||||
for (const args of commands) {
|
||||
await runCommandAsync({
|
||||
cwd: projectDir,
|
||||
command: 'git',
|
||||
args,
|
||||
showOutput: false,
|
||||
showSpinner: false,
|
||||
});
|
||||
}
|
||||
spinner.succeed(`Initialized Git repository`);
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
import fs from 'fs-extra';
|
||||
import { nanoid } from 'nanoid';
|
||||
import path from 'path';
|
||||
|
||||
import { printDirectory } from './utils';
|
||||
import { Role } from '../../graphql/generated';
|
||||
import Log from '../../log';
|
||||
import { findProjectIdByAccountNameAndSlugNullableAsync } from '../../project/fetchOrCreateProjectIDForWriteToConfigWithConfirmationAsync';
|
||||
import { Choice, promptAsync } from '../../prompts';
|
||||
import { Actor } from '../../user/User';
|
||||
import { ExpoGraphqlClient } from '../context/contextUtils/createGraphqlClient';
|
||||
|
||||
function validateProjectPath(resolvedPath: string): void {
|
||||
const normalizedPath = path.normalize(resolvedPath);
|
||||
|
||||
// Check for path traversal attempts
|
||||
if (normalizedPath.includes('..')) {
|
||||
throw new Error(`Invalid project path: "${resolvedPath}". Path traversal is not allowed.`);
|
||||
}
|
||||
|
||||
// Ensure we're not trying to create a project in system directories
|
||||
const systemDirs = ['/bin', '/sbin', '/etc', '/usr', '/var', '/sys', '/proc', '/dev'];
|
||||
const isSystemDir = systemDirs.some(
|
||||
dir => normalizedPath === dir || normalizedPath.startsWith(dir + path.sep)
|
||||
);
|
||||
|
||||
if (isSystemDir) {
|
||||
throw new Error(
|
||||
`Invalid project path: "${resolvedPath}". Cannot create projects in system directories.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function generateProjectConfigAsync(
|
||||
pathArg: string | undefined,
|
||||
options: {
|
||||
graphqlClient: ExpoGraphqlClient;
|
||||
projectAccount: string;
|
||||
}
|
||||
): Promise<{
|
||||
projectName: string;
|
||||
projectDirectory: string;
|
||||
}> {
|
||||
let baseName = 'expo-project';
|
||||
let parentDirectory = process.cwd();
|
||||
|
||||
if (pathArg) {
|
||||
const resolvedPath = path.isAbsolute(pathArg) ? pathArg : path.resolve(process.cwd(), pathArg);
|
||||
validateProjectPath(resolvedPath);
|
||||
baseName = path.basename(resolvedPath);
|
||||
parentDirectory = path.dirname(resolvedPath);
|
||||
} else {
|
||||
baseName = (
|
||||
await promptAsync({
|
||||
type: 'text',
|
||||
name: 'name',
|
||||
message: 'What would you like to name your project?',
|
||||
initial: 'expo-project',
|
||||
})
|
||||
).name;
|
||||
}
|
||||
|
||||
// Find an available name checking both local filesystem and remote server
|
||||
const { projectName, projectDirectory } = await findAvailableProjectNameAsync(
|
||||
baseName,
|
||||
parentDirectory,
|
||||
options
|
||||
);
|
||||
|
||||
Log.withInfo(`Using project name: ${projectName}`);
|
||||
Log.withInfo(`Using project directory: ${printDirectory(projectDirectory)}`);
|
||||
|
||||
return {
|
||||
projectName,
|
||||
projectDirectory,
|
||||
};
|
||||
}
|
||||
|
||||
function getAccountPermissionsMap(actor: Actor): Map<string, boolean> {
|
||||
const permissionsMap = new Map<string, boolean>();
|
||||
|
||||
for (const account of actor.accounts) {
|
||||
const hasPermission =
|
||||
account.users.find(it => it.actor.id === actor.id)?.role !== Role.ViewOnly;
|
||||
permissionsMap.set(account.name, hasPermission);
|
||||
}
|
||||
|
||||
return permissionsMap;
|
||||
}
|
||||
|
||||
export async function promptForProjectAccountAsync(actor: Actor): Promise<string> {
|
||||
const permissionsMap = getAccountPermissionsMap(actor);
|
||||
|
||||
// If only one account, use it (if has permissions)
|
||||
if (actor.accounts.length === 1) {
|
||||
const account = actor.accounts[0];
|
||||
|
||||
if (permissionsMap.get(account.name)) {
|
||||
return account.name;
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`You don't have permission to create projects on your only available account (${account.name}).`
|
||||
);
|
||||
}
|
||||
|
||||
// Multiple accounts - prompt user to select one with permissions
|
||||
return (
|
||||
await promptAsync({
|
||||
type: 'select',
|
||||
name: 'account',
|
||||
message: 'Which account should own this project?',
|
||||
choices: getAccountChoices(actor, permissionsMap),
|
||||
})
|
||||
).account.name;
|
||||
}
|
||||
|
||||
export function getAccountChoices(actor: Actor, permissionsMap?: Map<string, boolean>): Choice[] {
|
||||
const permissions = permissionsMap ?? getAccountPermissionsMap(actor);
|
||||
const sortedAccounts = [...actor.accounts].sort((a, _b) => (a.ownerUserActor ? 1 : -1));
|
||||
|
||||
return sortedAccounts.map(account => {
|
||||
const isPersonalAccount = !!account.ownerUserActor && account.ownerUserActor.id === actor.id;
|
||||
const isTeamAccount = !!account.ownerUserActor && account.ownerUserActor.id !== actor.id;
|
||||
|
||||
const accountDisplayName = isPersonalAccount
|
||||
? `${account.name} (Limited - Personal Account)`
|
||||
: isTeamAccount
|
||||
? `${account.name} (Limited - Team Account)`
|
||||
: account.name;
|
||||
|
||||
const disabled = !permissions.get(account.name);
|
||||
|
||||
return {
|
||||
title: accountDisplayName,
|
||||
value: { name: account.name },
|
||||
...(disabled && {
|
||||
disabled: true,
|
||||
description: 'You do not have the required permissions to create projects on this account.',
|
||||
}),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function verifyProjectDoesNotExistAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
accountName: string,
|
||||
projectName: string,
|
||||
{ silent = false }: { silent?: boolean } = {}
|
||||
): Promise<boolean> {
|
||||
const existingProjectId = await findProjectIdByAccountNameAndSlugNullableAsync(
|
||||
graphqlClient,
|
||||
accountName,
|
||||
projectName
|
||||
);
|
||||
|
||||
const doesNotExist = existingProjectId === null;
|
||||
if (!doesNotExist && !silent) {
|
||||
Log.warn(`Project @${accountName}/${projectName} already exists on the server.`);
|
||||
}
|
||||
|
||||
return doesNotExist;
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds an available project name that doesn't conflict with either:
|
||||
* Local filesystem (directory already exists)
|
||||
* Remote server (project already exists on Expo)
|
||||
*/
|
||||
export async function findAvailableProjectNameAsync(
|
||||
baseName: string,
|
||||
parentDirectory: string,
|
||||
{
|
||||
graphqlClient,
|
||||
projectAccount,
|
||||
}: {
|
||||
graphqlClient: ExpoGraphqlClient;
|
||||
projectAccount: string;
|
||||
}
|
||||
): Promise<{ projectName: string; projectDirectory: string }> {
|
||||
let projectName = baseName;
|
||||
let projectDirectory = path.join(parentDirectory, projectName);
|
||||
|
||||
const localExists = await fs.pathExists(projectDirectory);
|
||||
|
||||
const remoteAvailable = await verifyProjectDoesNotExistAsync(
|
||||
graphqlClient,
|
||||
projectAccount,
|
||||
projectName
|
||||
);
|
||||
|
||||
if (localExists || !remoteAvailable) {
|
||||
projectName = `${baseName}-${nanoid(6)}`;
|
||||
projectDirectory = path.join(parentDirectory, projectName);
|
||||
}
|
||||
|
||||
return {
|
||||
projectName,
|
||||
projectDirectory,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
import { ExpoConfig } from '@expo/config';
|
||||
import { AppVersionSource, EasJson } from '@expo/eas-json';
|
||||
import fs from 'fs-extra';
|
||||
import path from 'path';
|
||||
import merge from 'ts-deepmerge';
|
||||
|
||||
import { getEASUpdateURL } from '../../api';
|
||||
import { AppFragment } from '../../graphql/generated';
|
||||
import { PackageManager } from '../../onboarding/installDependencies';
|
||||
import { easCliVersion } from '../../utils/easCli';
|
||||
|
||||
// Android package names must start with a lowercase letter
|
||||
// schemes must start with a lowercase letter and can only contain lowercase letters, digits, "+", "." or "-"
|
||||
export function cleanAndPrefix(_string: string, type: 'user' | 'app' | 'scheme'): string {
|
||||
let string = _string;
|
||||
let pattern = /[^A-Za-z0-9]/g;
|
||||
if (type === 'scheme') {
|
||||
string = _string.toLowerCase();
|
||||
pattern = /[^a-z0-9+.-]/g;
|
||||
}
|
||||
|
||||
const prefix = /^[^a-z]/.test(string) ? type : '';
|
||||
const cleaned = string.replaceAll(pattern, '');
|
||||
|
||||
return prefix + cleaned;
|
||||
}
|
||||
|
||||
export async function generateAppConfigAsync(projectDir: string, app: AppFragment): Promise<void> {
|
||||
const user = cleanAndPrefix(app.ownerAccount.name, 'user');
|
||||
const slug = cleanAndPrefix(app.slug, 'app');
|
||||
const scheme = cleanAndPrefix(app.name ?? app.slug, 'scheme');
|
||||
|
||||
const bundleIdentifier = `com.${user}.${slug}`;
|
||||
const updateUrl = getEASUpdateURL(app.id, /* manifestHostOverride */ null);
|
||||
|
||||
const { expo: baseExpoConfig } = await fs.readJson(path.join(projectDir, 'app.json'));
|
||||
const expoConfig: ExpoConfig = {
|
||||
name: app.name ?? app.slug,
|
||||
slug: app.slug,
|
||||
scheme,
|
||||
extra: {
|
||||
eas: {
|
||||
projectId: app.id,
|
||||
},
|
||||
},
|
||||
owner: app.ownerAccount.name,
|
||||
updates: {
|
||||
url: updateUrl,
|
||||
},
|
||||
runtimeVersion: {
|
||||
policy: 'appVersion',
|
||||
},
|
||||
ios: {
|
||||
bundleIdentifier,
|
||||
},
|
||||
android: {
|
||||
package: bundleIdentifier,
|
||||
},
|
||||
};
|
||||
const mergedConfig = merge(baseExpoConfig, expoConfig);
|
||||
|
||||
const appJsonPath = path.join(projectDir, 'app.json');
|
||||
await fs.writeJson(appJsonPath, { expo: mergedConfig }, { spaces: 2 });
|
||||
}
|
||||
|
||||
export async function generateEasConfigAsync(projectDir: string): Promise<void> {
|
||||
const easBuildGitHubConfig = {
|
||||
android: {
|
||||
image: 'latest',
|
||||
},
|
||||
ios: {
|
||||
image: 'latest',
|
||||
},
|
||||
};
|
||||
|
||||
const easJson: EasJson = {
|
||||
cli: {
|
||||
version: `>= ${easCliVersion}`,
|
||||
appVersionSource: AppVersionSource.REMOTE,
|
||||
},
|
||||
build: {
|
||||
development: {
|
||||
developmentClient: true,
|
||||
distribution: 'internal',
|
||||
...easBuildGitHubConfig,
|
||||
},
|
||||
'development-simulator': {
|
||||
extends: 'development',
|
||||
ios: {
|
||||
simulator: true,
|
||||
},
|
||||
},
|
||||
preview: {
|
||||
distribution: 'internal',
|
||||
channel: 'main',
|
||||
...easBuildGitHubConfig,
|
||||
},
|
||||
production: {
|
||||
channel: 'production',
|
||||
autoIncrement: true,
|
||||
...easBuildGitHubConfig,
|
||||
},
|
||||
},
|
||||
submit: {
|
||||
production: {},
|
||||
},
|
||||
};
|
||||
|
||||
const easJsonPath = path.join(projectDir, 'eas.json');
|
||||
await fs.writeJson(easJsonPath, easJson, { spaces: 2 });
|
||||
}
|
||||
|
||||
export async function updatePackageJsonAsync(projectDir: string): Promise<void> {
|
||||
const packageJsonPath = path.join(projectDir, 'package.json');
|
||||
const packageJson = await fs.readJson(packageJsonPath);
|
||||
|
||||
if (!packageJson.scripts) {
|
||||
packageJson.scripts = {};
|
||||
}
|
||||
packageJson.scripts.draft = 'npx eas-cli@latest workflow:run create-draft.yml';
|
||||
packageJson.scripts['development-builds'] =
|
||||
'npx eas-cli@latest workflow:run create-development-builds.yml';
|
||||
packageJson.scripts.deploy = 'npx eas-cli@latest workflow:run deploy-to-production.yml';
|
||||
|
||||
await fs.writeJson(packageJsonPath, packageJson, { spaces: 2 });
|
||||
}
|
||||
|
||||
export async function copyProjectTemplatesAsync(projectDir: string): Promise<void> {
|
||||
const templatesSourceDir = path.join(__dirname, 'templates');
|
||||
|
||||
// Copy everything from templates to projectDir, skipping readme-additions.md
|
||||
await fs.copy(templatesSourceDir, projectDir, {
|
||||
overwrite: true,
|
||||
errorOnExist: false,
|
||||
filter: (src: string) => {
|
||||
return !src.endsWith('readme-additions.md');
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateReadmeAsync(
|
||||
projectDir: string,
|
||||
packageManager: PackageManager
|
||||
): Promise<void> {
|
||||
const readmeTemplatePath = path.join(__dirname, 'templates', 'readme-additions.md');
|
||||
const projectReadmePath = path.join(projectDir, 'README.md');
|
||||
|
||||
const readmeAdditions = await fs.readFile(readmeTemplatePath, 'utf8');
|
||||
const existingReadme = await fs.readFile(projectReadmePath, 'utf8');
|
||||
|
||||
const targetSection = '## Get started';
|
||||
const sectionIndex = existingReadme.indexOf(targetSection);
|
||||
|
||||
let mergedReadme: string;
|
||||
if (sectionIndex !== -1) {
|
||||
// Find the next ## section after "## Get started"
|
||||
const afterTargetSection = existingReadme.substring(sectionIndex);
|
||||
const nextSectionMatch = afterTargetSection.match(/\n## /);
|
||||
let endIndex = existingReadme.length;
|
||||
if (nextSectionMatch?.index !== undefined) {
|
||||
// Replace from "## Get started" to the next "##" section
|
||||
endIndex = sectionIndex + nextSectionMatch.index;
|
||||
}
|
||||
|
||||
const beforeSection = existingReadme.substring(0, sectionIndex).trim();
|
||||
const afterSection = existingReadme.substring(endIndex);
|
||||
mergedReadme = beforeSection + '\n\n' + readmeAdditions.trim() + '\n\n' + afterSection;
|
||||
} else {
|
||||
// No "Get started" section found, append the template to the existing README
|
||||
mergedReadme = existingReadme.trim() + '\n\n' + readmeAdditions.trim();
|
||||
}
|
||||
|
||||
mergedReadme = mergedReadme.replaceAll('npm run', `${packageManager} run`);
|
||||
|
||||
await fs.writeFile(projectReadmePath, mergedReadme);
|
||||
}
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
name: Create development builds
|
||||
|
||||
jobs:
|
||||
android_development_build:
|
||||
name: Build Android
|
||||
type: build
|
||||
params:
|
||||
platform: android
|
||||
profile: development
|
||||
ios_device_development_build:
|
||||
name: Build iOS device
|
||||
type: build
|
||||
params:
|
||||
platform: ios
|
||||
profile: development
|
||||
ios_simulator_development_build:
|
||||
name: Build iOS simulator
|
||||
type: build
|
||||
params:
|
||||
platform: ios
|
||||
profile: development-simulator
|
||||
@@ -0,0 +1,15 @@
|
||||
name: Create draft
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ['*']
|
||||
|
||||
jobs:
|
||||
publish_preview_update:
|
||||
name: Publish preview update
|
||||
type: update
|
||||
params:
|
||||
branch: ${{ github.ref_name || 'test' }}
|
||||
deploy_website:
|
||||
name: Deploy website
|
||||
type: deploy
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
name: Deploy to production
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ['main']
|
||||
|
||||
jobs:
|
||||
fingerprint:
|
||||
name: Fingerprint
|
||||
type: fingerprint
|
||||
get_android_build:
|
||||
name: Check for existing android build
|
||||
needs: [fingerprint]
|
||||
type: get-build
|
||||
params:
|
||||
fingerprint_hash: ${{ needs.fingerprint.outputs.android_fingerprint_hash }}
|
||||
profile: production
|
||||
get_ios_build:
|
||||
name: Check for existing ios build
|
||||
needs: [fingerprint]
|
||||
type: get-build
|
||||
params:
|
||||
fingerprint_hash: ${{ needs.fingerprint.outputs.ios_fingerprint_hash }}
|
||||
profile: production
|
||||
build_android:
|
||||
name: Build Android
|
||||
needs: [get_android_build]
|
||||
if: ${{ !needs.get_android_build.outputs.build_id }}
|
||||
type: build
|
||||
params:
|
||||
platform: android
|
||||
profile: production
|
||||
build_ios:
|
||||
name: Build iOS
|
||||
needs: [get_ios_build]
|
||||
if: ${{ !needs.get_ios_build.outputs.build_id }}
|
||||
type: build
|
||||
params:
|
||||
platform: ios
|
||||
profile: production
|
||||
submit_android_build:
|
||||
name: Submit Android Build
|
||||
needs: [build_android]
|
||||
type: submit
|
||||
params:
|
||||
build_id: ${{ needs.build_android.outputs.build_id }}
|
||||
submit_ios_build:
|
||||
name: Submit iOS Build
|
||||
needs: [build_ios]
|
||||
type: submit
|
||||
params:
|
||||
build_id: ${{ needs.build_ios.outputs.build_id }}
|
||||
publish_android_update:
|
||||
name: Publish Android update
|
||||
needs: [get_android_build]
|
||||
if: ${{ needs.get_android_build.outputs.build_id }}
|
||||
type: update
|
||||
params:
|
||||
branch: production
|
||||
platform: android
|
||||
publish_ios_update:
|
||||
name: Publish iOS update
|
||||
needs: [get_ios_build]
|
||||
if: ${{ needs.get_ios_build.outputs.build_id }}
|
||||
type: update
|
||||
params:
|
||||
branch: production
|
||||
platform: ios
|
||||
@@ -0,0 +1,163 @@
|
||||
# AGENTS.md
|
||||
|
||||
## Project Overview
|
||||
|
||||
This is an Expo/React Native mobile application. Prioritize mobile-first patterns, performance, and cross-platform compatibility.
|
||||
|
||||
## Documentation Resources
|
||||
|
||||
When working on this project, **always consult the official Expo documentation** available at:
|
||||
|
||||
- **https://docs.expo.dev/llms.txt** - Index of all available documentation files
|
||||
- **https://docs.expo.dev/llms-full.txt** - Complete Expo documentation including Expo Router, Expo Modules API, development process
|
||||
- **https://docs.expo.dev/llms-eas.txt** - Complete EAS (Expo Application Services) documentation
|
||||
- **https://docs.expo.dev/llms-sdk.txt** - Complete Expo SDK documentation
|
||||
- **https://reactnative.dev/docs/getting-started** - Complete React Native documentation
|
||||
|
||||
These documentation files are specifically formatted for AI agents and should be your **primary reference** for:
|
||||
|
||||
- Expo APIs and best practices
|
||||
- Expo Router navigation patterns
|
||||
- EAS Build, Submit, and Update workflows
|
||||
- Expo SDK modules and their usage
|
||||
- Development and deployment processes
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
/
|
||||
├── app/ # Expo Router file-based routing
|
||||
│ ├── (tabs)/ # Tab-based navigation screens
|
||||
│ │ ├── index.tsx # Home screen
|
||||
│ │ ├── explore.tsx # Explore screen
|
||||
│ │ └── _layout.tsx # Tabs layout
|
||||
│ ├── _layout.tsx # Root layout with theme provider
|
||||
│ └── modal.tsx # Modal screen example
|
||||
├── components/ # Reusable React components
|
||||
│ ├── ui/ # UI primitives (IconSymbol, Collapsible)
|
||||
│ └── ... # Feature components (themed, haptic, parallax)
|
||||
├── constants/ # App-wide constants (theme, colors)
|
||||
├── hooks/ # Custom React hooks (color scheme, theme)
|
||||
├── assets/ # Static assets (images, fonts)
|
||||
├── scripts/ # Utility scripts (reset-project)
|
||||
├── .eas/workflows/ # EAS Workflows (CI/CD automation)
|
||||
├── app.json # Expo configuration
|
||||
├── eas.json # EAS Build/Submit configuration
|
||||
└── package.json # Dependencies and scripts
|
||||
```
|
||||
|
||||
## Essential Commands
|
||||
|
||||
### Development
|
||||
|
||||
```bash
|
||||
npx expo start # Start dev server
|
||||
npx expo start --clear # Clear cache and start dev server
|
||||
npx expo install <package> # Install packages with compatible versions
|
||||
npx expo install --check # Check which installed packages need to be updated
|
||||
npx expo install --fix # Automatically update any invalid package versions
|
||||
npm run development-builds # Create development builds (workflow)
|
||||
npm run reset-project # Reset to blank template
|
||||
```
|
||||
|
||||
### Building & Testing
|
||||
|
||||
```bash
|
||||
npx expo doctor # Check project health and dependencies
|
||||
npx expo lint # Run ESLint
|
||||
npm run draft # Publish preview update and website (workflow)
|
||||
```
|
||||
|
||||
### Production
|
||||
|
||||
```bash
|
||||
npx eas-cli@latest build --platform ios -s # Use EAS to build for iOS platform and submit to App Store
|
||||
npx eas-cli@latest build --platform android -s # Use EAS to build for Android platform and submit to Google Play Store
|
||||
npm run deploy # Deploy to production (workflow)
|
||||
```
|
||||
|
||||
## Development Guidelines
|
||||
|
||||
### Code Style & Standards
|
||||
|
||||
- **TypeScript First**: Use TypeScript for all new code with strict type checking
|
||||
- **Naming Conventions**: Use meaningful, descriptive names for variables, functions, and components
|
||||
- **Self-Documenting Code**: Write clear, readable code that explains itself; only add comments for complex business logic or design decisions
|
||||
- **React 19 Patterns**: Follow modern React patterns including:
|
||||
- Function components with hooks
|
||||
- Enable React Compiler
|
||||
- Proper dependency arrays in useEffect
|
||||
- Memoization when appropriate (useMemo, useCallback)
|
||||
- Error boundaries for better error handling
|
||||
|
||||
### Navigation & Routing
|
||||
|
||||
- Use **Expo Router** for all navigation
|
||||
- Import `Link`, `router`, and `useLocalSearchParams` from `expo-router`
|
||||
- Docs: https://docs.expo.dev/router/introduction/
|
||||
|
||||
### Recommended Libraries
|
||||
|
||||
- **Navigation**: `expo-router` for navigation
|
||||
- **Images**: `expo-image` for optimized image handling and caching
|
||||
- **Animations**: `react-native-reanimated` for performant animations on native thread
|
||||
- **Gestures**: `react-native-gesture-handler` for native gesture recognition
|
||||
- **Storage**: Use `expo-sqlite` for persistent storage, `expo-sqlite/kv-store` for simple key-value storage
|
||||
|
||||
## Debugging & Development Tools
|
||||
|
||||
### DevTools Integration
|
||||
|
||||
- **React Native DevTools**: Use MCP `open_devtools` command to launch debugging tools
|
||||
- **Network Inspection**: Monitor API calls and network requests in DevTools
|
||||
- **Element Inspector**: Debug component hierarchy and styles
|
||||
- **Performance Profiler**: Identify performance bottlenecks
|
||||
- **Logging**: Use `console.log` for debugging (remove before production), `console.warn` for deprecation notices, `console.error` for actual errors, and implement error boundaries for production error handling
|
||||
|
||||
### Testing & Quality Assurance
|
||||
|
||||
#### Automated Testing with MCP Tools
|
||||
|
||||
Developers can configure the Expo MCP server with the following doc: https://docs.expo.dev/eas/ai/mcp/
|
||||
|
||||
- **Component Testing**: Add `testID` props to components for automation
|
||||
- **Visual Testing**: Use MCP `automation_take_screenshot` to verify UI appearance
|
||||
- **Interaction Testing**: Use MCP `automation_tap_by_testid` to simulate user interactions
|
||||
- **View Verification**: Use MCP `automation_find_view_by_testid` to validate component rendering
|
||||
|
||||
## EAS Workflows CI/CD
|
||||
|
||||
This project is pre-configured with **EAS Workflows** for automating development and release processes. Workflows are defined in `.eas/workflows/` directory.
|
||||
|
||||
When working with EAS Workflows, **always refer to**:
|
||||
|
||||
- https://docs.expo.dev/eas/workflows/ for workflow examples
|
||||
- The `.eas/workflows/` directory for existing workflow configurations
|
||||
- You can check that a workflow YAML is valid using the workflows schema: https://exp.host/--/api/v2/workflows/schema
|
||||
|
||||
### Build Profiles (eas.json)
|
||||
|
||||
- **development**: Development builds with dev client
|
||||
- **development-simulator**: Development builds for iOS simulator
|
||||
- **preview**: Internal distribution preview builds
|
||||
- **production**: Production builds with auto-increment
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Expo Go Errors & Development Builds
|
||||
|
||||
If there are errors in **Expo Go** or the project is not running, create a **development build**. **Expo Go** is a sandbox environment with a limited set of native modules. To create development builds, run `eas build:dev`. Additionally, after installing new packages or adding config plugins, new development builds are often required.
|
||||
|
||||
## AI Agent Instructions
|
||||
|
||||
When working on this project:
|
||||
|
||||
1. **Always start by consulting the appropriate documentation**:
|
||||
|
||||
- For general Expo questions: https://docs.expo.dev/llms-full.txt
|
||||
- For EAS/deployment questions: https://docs.expo.dev/llms-eas.txt
|
||||
- For SDK/API questions: https://docs.expo.dev/llms-sdk.txt
|
||||
|
||||
2. **Understand before implementing**: Read the relevant docs section before writing code
|
||||
|
||||
3. **Follow existing patterns**: Look at existing components and screens for patterns to follow
|
||||
@@ -0,0 +1,9 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
**Note**: This project uses `AGENTS.md` files for detailed guidance.
|
||||
|
||||
## Primary Reference
|
||||
|
||||
Please see `AGENTS.md` in this same directory for the main project documentation and guidance.
|
||||
@@ -0,0 +1,36 @@
|
||||
## Get started
|
||||
|
||||
To start the app, in your terminal run:
|
||||
|
||||
```bash
|
||||
npm run start
|
||||
```
|
||||
|
||||
In the output, you'll find options to open the app in:
|
||||
|
||||
- [a development build](https://docs.expo.dev/develop/development-builds/introduction/)
|
||||
- [an Android emulator](https://docs.expo.dev/workflow/android-studio-emulator/)
|
||||
- [an iOS simulator](https://docs.expo.dev/workflow/ios-simulator/)
|
||||
- [Expo Go](https://expo.dev/go), a limited sandbox for trying out app development with Expo
|
||||
|
||||
You can start developing by editing the files inside the **app** directory. This project uses [file-based routing](https://docs.expo.dev/router/introduction).
|
||||
|
||||
## Workflows
|
||||
|
||||
This project is configured to use [EAS Workflows](https://docs.expo.dev/eas/workflows/get-started/) to automate some development and release processes. These commands are set up in [`package.json`](./package.json) and can be run using NPM scripts in your terminal.
|
||||
|
||||
### Previews
|
||||
|
||||
Run `npm run draft` to [publish a preview update](https://docs.expo.dev/eas/workflows/examples/publish-preview-update/) of your project, which can be viewed in Expo Go or in a development build.
|
||||
|
||||
### Development Builds
|
||||
|
||||
Run `npm run development-builds` to [create a development build](https://docs.expo.dev/eas/workflows/examples/create-development-builds/). Note - you'll need to follow the [Prerequisites](https://docs.expo.dev/eas/workflows/examples/create-development-builds/#prerequisites) to ensure you have the correct emulator setup on your machine.
|
||||
|
||||
### Production Deployments
|
||||
|
||||
Run `npm run deploy` to [deploy to production](https://docs.expo.dev/eas/workflows/examples/deploy-to-production/). Note - you'll need to follow the [Prerequisites](https://docs.expo.dev/eas/workflows/examples/deploy-to-production/#prerequisites) to ensure you're set up to submit to the Apple and Google stores.
|
||||
|
||||
## Hosting
|
||||
|
||||
Expo offers hosting for websites and API functions via EAS Hosting. See the [Getting Started](https://docs.expo.dev/eas/hosting/get-started/) guide to learn more.
|
||||
@@ -0,0 +1,20 @@
|
||||
import path from 'path';
|
||||
|
||||
/**
|
||||
* Formats a directory path for display.
|
||||
* If the directory is within the current working directory, returns a relative path.
|
||||
* Otherwise, returns the absolute path.
|
||||
*/
|
||||
export function printDirectory(directory: string): string {
|
||||
const cwd = process.cwd();
|
||||
const absoluteDir = path.isAbsolute(directory) ? directory : path.resolve(cwd, directory);
|
||||
|
||||
const relativePath = path.relative(cwd, absoluteDir);
|
||||
|
||||
// If the relative path doesn't start with '..' it means it's within or at the cwd
|
||||
if (!relativePath.startsWith('..') && !path.isAbsolute(relativePath)) {
|
||||
return relativePath !== '' ? `./${relativePath}` : '.';
|
||||
}
|
||||
|
||||
return absoluteDir;
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
import { ExpoConfig } from '@expo/config';
|
||||
import { BuildProfile, EasJsonAccessor, EasJsonUtils, Platform } from '@expo/eas-json';
|
||||
import { AndroidBuildProfile, IosBuildProfile } from '@expo/eas-json/build/build/types';
|
||||
|
||||
import BuildConfigure from '../../commands/build/configure';
|
||||
import UpdateConfigure from '../../commands/update/configure';
|
||||
import Log from '../../log';
|
||||
import { promptAsync } from '../../prompts';
|
||||
|
||||
export async function buildProfileNamesFromProjectAsync(projectDir: string): Promise<Set<string>> {
|
||||
const easJsonAccessor = EasJsonAccessor.fromProjectPath(projectDir);
|
||||
|
||||
const buildProfileNames = new Set(
|
||||
easJsonAccessor && (await EasJsonUtils.getBuildProfileNamesAsync(easJsonAccessor))
|
||||
);
|
||||
return buildProfileNames;
|
||||
}
|
||||
export async function getBuildProfileAsync(
|
||||
projectDir: string,
|
||||
platform: Platform,
|
||||
profileName: string
|
||||
): Promise<BuildProfile<Platform>> {
|
||||
const easJsonAccessor = EasJsonAccessor.fromProjectPath(projectDir);
|
||||
const buildProfile = await EasJsonUtils.getBuildProfileAsync(
|
||||
easJsonAccessor,
|
||||
platform,
|
||||
profileName
|
||||
);
|
||||
return buildProfile;
|
||||
}
|
||||
export async function buildProfilesFromProjectAsync(
|
||||
projectDir: string
|
||||
): Promise<Map<string, { android: AndroidBuildProfile; ios: IosBuildProfile }>> {
|
||||
const buildProfileNames = await buildProfileNamesFromProjectAsync(projectDir);
|
||||
const buildProfiles: Map<string, { android: AndroidBuildProfile; ios: IosBuildProfile }> =
|
||||
new Map();
|
||||
for (const profileName of buildProfileNames) {
|
||||
const iosBuildProfile = (await getBuildProfileAsync(
|
||||
projectDir,
|
||||
Platform.IOS,
|
||||
profileName
|
||||
)) as IosBuildProfile;
|
||||
const androidBuildProfile = (await getBuildProfileAsync(
|
||||
projectDir,
|
||||
Platform.ANDROID,
|
||||
profileName
|
||||
)) as AndroidBuildProfile;
|
||||
buildProfiles.set(profileName, { android: androidBuildProfile, ios: iosBuildProfile });
|
||||
}
|
||||
return buildProfiles;
|
||||
}
|
||||
export function isBuildProfileForDevelopment(
|
||||
buildProfile: BuildProfile<Platform>,
|
||||
platform: Platform
|
||||
): boolean {
|
||||
if (buildProfile.developmentClient) {
|
||||
return true;
|
||||
}
|
||||
if (platform === Platform.ANDROID) {
|
||||
return (buildProfile as BuildProfile<Platform.ANDROID>).gradleCommand === ':app:assembleDebug';
|
||||
}
|
||||
if (platform === Platform.IOS) {
|
||||
return (buildProfile as BuildProfile<Platform.IOS>).buildConfiguration === 'Debug';
|
||||
}
|
||||
return false;
|
||||
}
|
||||
export function isIosBuildProfileForSimulator(buildProfile: BuildProfile<Platform.IOS>): boolean {
|
||||
return buildProfile.simulator ?? false;
|
||||
}
|
||||
export async function addAndroidDevelopmentBuildProfileToEasJsonAsync(
|
||||
projectDir: string,
|
||||
buildProfileName: string
|
||||
): Promise<void> {
|
||||
const easJsonAccessor = EasJsonAccessor.fromProjectPath(projectDir);
|
||||
await easJsonAccessor.readRawJsonAsync();
|
||||
easJsonAccessor.patch(easJsonRawObject => {
|
||||
easJsonRawObject.build = {
|
||||
...easJsonRawObject.build,
|
||||
[buildProfileName]: {
|
||||
developmentClient: true,
|
||||
distribution: 'internal',
|
||||
android: {
|
||||
gradleCommand: ':app:assembleDebug',
|
||||
},
|
||||
},
|
||||
};
|
||||
return easJsonRawObject;
|
||||
});
|
||||
await easJsonAccessor.writeAsync();
|
||||
}
|
||||
export async function addIosDevelopmentBuildProfileToEasJsonAsync(
|
||||
projectDir: string,
|
||||
buildProfileName: string,
|
||||
simulator: boolean
|
||||
): Promise<void> {
|
||||
const easJsonAccessor = EasJsonAccessor.fromProjectPath(projectDir);
|
||||
await easJsonAccessor.readRawJsonAsync();
|
||||
easJsonAccessor.patch(easJsonRawObject => {
|
||||
easJsonRawObject.build = {
|
||||
...easJsonRawObject.build,
|
||||
[buildProfileName]: {
|
||||
developmentClient: true,
|
||||
distribution: 'internal',
|
||||
ios: {
|
||||
buildConfiguration: 'Debug',
|
||||
simulator,
|
||||
},
|
||||
},
|
||||
};
|
||||
return easJsonRawObject;
|
||||
});
|
||||
await easJsonAccessor.writeAsync();
|
||||
}
|
||||
|
||||
export async function addProductionBuildProfileToEasJsonIfNeededAsync(
|
||||
projectDir: string
|
||||
): Promise<boolean> {
|
||||
const easJsonAccessor = EasJsonAccessor.fromProjectPath(projectDir);
|
||||
await easJsonAccessor.readRawJsonAsync();
|
||||
let profileAdded = false;
|
||||
easJsonAccessor.patch(easJsonRawObject => {
|
||||
if (!easJsonRawObject.build?.production) {
|
||||
profileAdded = true;
|
||||
easJsonRawObject.build = {
|
||||
...(easJsonRawObject.build ?? {}),
|
||||
production: {},
|
||||
};
|
||||
// Also add the profile to submit
|
||||
easJsonRawObject.submit = {
|
||||
...(easJsonRawObject.submit ?? {}),
|
||||
production: {},
|
||||
};
|
||||
}
|
||||
return easJsonRawObject;
|
||||
});
|
||||
if (profileAdded) {
|
||||
Log.log('Added missing production build profile to eas.json');
|
||||
}
|
||||
await easJsonAccessor.writeAsync();
|
||||
return profileAdded;
|
||||
}
|
||||
|
||||
export async function hasBuildConfigureBeenRunAsync({
|
||||
projectDir,
|
||||
expoConfig,
|
||||
}: {
|
||||
projectDir: string;
|
||||
expoConfig: ExpoConfig;
|
||||
}): Promise<boolean> {
|
||||
// Is there a project ID in the Expo config?
|
||||
if (!expoConfig.extra?.eas?.projectId) {
|
||||
return false;
|
||||
}
|
||||
// Is there an eas.json?
|
||||
const easJsonAccessor = EasJsonAccessor.fromProjectPath(projectDir);
|
||||
try {
|
||||
await easJsonAccessor.readAsync();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function hasUpdateConfigureBeenRunAsync({
|
||||
projectDir,
|
||||
expoConfig,
|
||||
}: {
|
||||
projectDir: string;
|
||||
expoConfig: ExpoConfig;
|
||||
}): Promise<boolean> {
|
||||
// Does the Expo config have an updates URL?
|
||||
if (!expoConfig.updates?.url) {
|
||||
return false;
|
||||
}
|
||||
// Does at least one build profile have a channel?
|
||||
const easJsonAccessor = EasJsonAccessor.fromProjectPath(projectDir);
|
||||
try {
|
||||
const easJson = await easJsonAccessor.readAsync();
|
||||
return Object.values(easJson.build ?? {}).some(buildProfile => !!buildProfile.channel);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs update:configure if needed. Returns a boolean (proceed with workflow creation, or not)
|
||||
*/
|
||||
|
||||
export async function runUpdateConfigureIfNeededAsync({
|
||||
projectDir,
|
||||
expoConfig,
|
||||
}: {
|
||||
projectDir: string;
|
||||
expoConfig: ExpoConfig;
|
||||
}): Promise<boolean> {
|
||||
if (
|
||||
await hasUpdateConfigureBeenRunAsync({
|
||||
projectDir,
|
||||
expoConfig,
|
||||
})
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
const nextStep = (
|
||||
await promptAsync({
|
||||
type: 'select',
|
||||
name: 'nextStep',
|
||||
message:
|
||||
'You have chosen to create a workflow that requires EAS Update configuration. What would you like to do?',
|
||||
choices: [
|
||||
{ title: 'Configure EAS Update and then proceed', value: 'configure' },
|
||||
{ title: 'EAS Update is already configured, proceed', value: 'proceed' },
|
||||
{ title: 'Choose a different workflow template', value: 'repeat' },
|
||||
],
|
||||
})
|
||||
).nextStep;
|
||||
switch (nextStep) {
|
||||
case 'configure':
|
||||
Log.newLine();
|
||||
await UpdateConfigure.run([]);
|
||||
return true;
|
||||
case 'proceed':
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Runs build:configure if needed. Returns a boolean (proceed with workflow creation, or not)
|
||||
*/
|
||||
export async function runBuildConfigureIfNeededAsync({
|
||||
projectDir,
|
||||
expoConfig,
|
||||
}: {
|
||||
projectDir: string;
|
||||
expoConfig: ExpoConfig;
|
||||
}): Promise<boolean> {
|
||||
if (
|
||||
await hasBuildConfigureBeenRunAsync({
|
||||
projectDir,
|
||||
expoConfig,
|
||||
})
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
const nextStep = (
|
||||
await promptAsync({
|
||||
type: 'select',
|
||||
name: 'nextStep',
|
||||
message:
|
||||
'You have chosen to create a workflow that requires EAS Build configuration. What would you like to do?',
|
||||
choices: [
|
||||
{ title: 'Configure EAS Build and then proceed', value: 'configure' },
|
||||
{ title: 'EAS Build is already configured, proceed', value: 'proceed' },
|
||||
{ title: 'Choose a different workflow template', value: 'repeat' },
|
||||
],
|
||||
})
|
||||
).nextStep;
|
||||
switch (nextStep) {
|
||||
case 'configure':
|
||||
Log.newLine();
|
||||
await BuildConfigure.run(['-p', 'all']);
|
||||
return true;
|
||||
case 'proceed':
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,536 @@
|
||||
import { ExpoConfig } from '@expo/config';
|
||||
import { Platform } from '@expo/eas-build-job';
|
||||
|
||||
import {
|
||||
addAndroidDevelopmentBuildProfileToEasJsonAsync,
|
||||
addIosDevelopmentBuildProfileToEasJsonAsync,
|
||||
addProductionBuildProfileToEasJsonIfNeededAsync,
|
||||
buildProfilesFromProjectAsync,
|
||||
hasBuildConfigureBeenRunAsync,
|
||||
hasUpdateConfigureBeenRunAsync,
|
||||
isBuildProfileForDevelopment,
|
||||
isIosBuildProfileForSimulator,
|
||||
} from './buildProfileUtils';
|
||||
import Log, { link } from '../../log';
|
||||
import { confirmAsync } from '../../prompts';
|
||||
import { easCliVersion } from '../../utils/easCli';
|
||||
|
||||
export enum WorkflowStarterName {
|
||||
BUILD = 'build',
|
||||
UPDATE = 'update',
|
||||
CUSTOM = 'custom',
|
||||
DEPLOY = 'deploy',
|
||||
}
|
||||
|
||||
export type WorkflowStarter = {
|
||||
name: WorkflowStarterName;
|
||||
displayName: string;
|
||||
defaultFileName: string;
|
||||
template: any;
|
||||
header: string;
|
||||
nextSteps?: string[];
|
||||
};
|
||||
|
||||
const createdByEASCLI = `# Created by EAS CLI v${easCliVersion}`;
|
||||
|
||||
const CUSTOM_TEMPLATE = {
|
||||
name: 'Custom workflow',
|
||||
on: {
|
||||
push: {
|
||||
branches: ['main'],
|
||||
},
|
||||
},
|
||||
jobs: {
|
||||
custom_build: {
|
||||
name: 'Custom job',
|
||||
steps: [
|
||||
{
|
||||
uses: 'eas/checkout',
|
||||
},
|
||||
{
|
||||
name: 'Hello World',
|
||||
id: 'hello_world',
|
||||
run: '# Custom script\necho "Hello, World"\n',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const CUSTOM_TEMPLATE_HEADER = `
|
||||
# Custom job
|
||||
#
|
||||
# This workflow shows how to write custom jobs.
|
||||
# It contains a predefined workflow step and a shell command to print "Hello, World!".
|
||||
#
|
||||
# Key features:
|
||||
# - Triggers on pushes to the main branch.
|
||||
# (Requires linked GitHub account: https://expo.dev/accounts/[account]/settings/github)
|
||||
# - Can be triggered manually with eas workflow:run custom.yml
|
||||
# - Runs eas/checkout then a custom "echo" command
|
||||
#
|
||||
# For detailed documentation on workflow syntax and available step types, visit:
|
||||
# https://docs.expo.dev/eas/workflows/syntax/#jobsjob_idstepsstepuses
|
||||
#
|
||||
${createdByEASCLI}
|
||||
#
|
||||
`;
|
||||
|
||||
const BUILD_TEMPLATE = {
|
||||
name: 'Create development builds',
|
||||
on: {
|
||||
push: {
|
||||
branches: ['main'],
|
||||
},
|
||||
},
|
||||
jobs: {},
|
||||
};
|
||||
|
||||
const BUILD_TEMPLATE_HEADER = `
|
||||
# Create development builds
|
||||
#
|
||||
# This workflow shows how to create development builds.
|
||||
#
|
||||
# Key features:
|
||||
# - Can be triggered manually with eas workflow:run create-development-builds.yml
|
||||
# - Runs the pre-packaged build job to create Android and iOS development builds
|
||||
# for Android emulators, Android and iOS devices, and iOS simulators
|
||||
#
|
||||
# For a detailed guide on using this workflow, visit:
|
||||
# https://docs.expo.dev/develop/development-builds/introduction/
|
||||
#
|
||||
${createdByEASCLI}
|
||||
#
|
||||
`;
|
||||
|
||||
const PUBLISH_UPDATE_TEMPLATE = {
|
||||
name: 'Publish preview update',
|
||||
on: {
|
||||
push: {
|
||||
branches: ['*'],
|
||||
},
|
||||
},
|
||||
jobs: {
|
||||
publish_preview_update: {
|
||||
name: 'Publish preview update',
|
||||
type: 'update',
|
||||
params: {
|
||||
branch: '${{ github.ref_name || "test" }}',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const PUBLISH_UPDATE_TEMPLATE_HEADER = `
|
||||
# Publish preview update
|
||||
#
|
||||
# This workflow shows how to publish preview updates.
|
||||
# Learn more: https://docs.expo.dev/review/share-previews-with-your-team/
|
||||
#
|
||||
# Key features:
|
||||
# - Triggers on pushes to all branches
|
||||
# (Requires linked GitHub account: https://expo.dev/accounts/[account]/settings/github)
|
||||
# - Can be triggered manually with eas workflow:run publish-preview-update.yml
|
||||
# - Runs the pre-packaged update job
|
||||
#
|
||||
# For a detailed guide on using this workflow, visit:
|
||||
# https://docs.expo.dev/eas/workflows/examples/publish-preview-update/
|
||||
#
|
||||
${createdByEASCLI}
|
||||
#
|
||||
`;
|
||||
|
||||
const DEPLOY_TEMPLATE = {
|
||||
name: 'Deploy to production',
|
||||
on: {
|
||||
push: {
|
||||
branches: ['main'],
|
||||
},
|
||||
},
|
||||
jobs: {
|
||||
fingerprint: {
|
||||
name: 'Fingerprint',
|
||||
type: 'fingerprint',
|
||||
},
|
||||
get_android_build: {
|
||||
name: 'Check for existing android build',
|
||||
needs: ['fingerprint'],
|
||||
type: 'get-build',
|
||||
params: {
|
||||
fingerprint_hash: '${{ needs.fingerprint.outputs.android_fingerprint_hash }}',
|
||||
profile: 'production',
|
||||
},
|
||||
},
|
||||
get_ios_build: {
|
||||
name: 'Check for existing ios build',
|
||||
needs: ['fingerprint'],
|
||||
type: 'get-build',
|
||||
params: {
|
||||
fingerprint_hash: '${{ needs.fingerprint.outputs.ios_fingerprint_hash }}',
|
||||
profile: 'production',
|
||||
},
|
||||
},
|
||||
build_android: {
|
||||
name: 'Build Android',
|
||||
needs: ['get_android_build'],
|
||||
if: '${{ !needs.get_android_build.outputs.build_id }}',
|
||||
type: 'build',
|
||||
params: {
|
||||
platform: 'android',
|
||||
profile: 'production',
|
||||
},
|
||||
},
|
||||
build_ios: {
|
||||
name: 'Build iOS',
|
||||
needs: ['get_ios_build'],
|
||||
if: '${{ !needs.get_ios_build.outputs.build_id }}',
|
||||
type: 'build',
|
||||
params: {
|
||||
platform: 'ios',
|
||||
profile: 'production',
|
||||
},
|
||||
},
|
||||
submit_android_build: {
|
||||
name: 'Submit Android Build',
|
||||
needs: ['build_android'],
|
||||
type: 'submit',
|
||||
params: {
|
||||
build_id: '${{ needs.build_android.outputs.build_id }}',
|
||||
},
|
||||
},
|
||||
submit_ios_build: {
|
||||
name: 'Submit iOS Build',
|
||||
needs: ['build_ios'],
|
||||
type: 'submit',
|
||||
params: {
|
||||
build_id: '${{ needs.build_ios.outputs.build_id }}',
|
||||
},
|
||||
},
|
||||
publish_android_update: {
|
||||
name: 'Publish Android update',
|
||||
needs: ['get_android_build'],
|
||||
if: '${{ needs.get_android_build.outputs.build_id }}',
|
||||
type: 'update',
|
||||
params: {
|
||||
branch: 'production',
|
||||
platform: 'android',
|
||||
},
|
||||
},
|
||||
publish_ios_update: {
|
||||
name: 'Publish iOS update',
|
||||
needs: ['get_ios_build'],
|
||||
if: '${{ needs.get_ios_build.outputs.build_id }}',
|
||||
type: 'update',
|
||||
params: {
|
||||
branch: 'production',
|
||||
platform: 'ios',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const DEPLOY_TEMPLATE_HEADER = `
|
||||
# Deploy to production
|
||||
#
|
||||
# This workflow shows how to build and submit to app stores.
|
||||
#
|
||||
# Key features:
|
||||
# - Triggers on pushes to "main" (Requires linked GitHub account: https://expo.dev/accounts/[account]/settings/github)
|
||||
# - Can be triggered manually with eas workflow:run deploy-to-production.yml
|
||||
# - Creates builds and submits them to app stores when native changes are detected, otherwise sends an over-the-air update.
|
||||
#
|
||||
# For a detailed guide on using this workflow, visit:
|
||||
# https://docs.expo.dev/eas/workflows/examples/deploy-to-production/
|
||||
`;
|
||||
|
||||
function nextStepForDeviceBuildProfile(buildProfileName: string): string {
|
||||
return `A build job in this workflow uses the build profile "${buildProfileName}"
|
||||
to build your app for real devices, and may require credentials.
|
||||
You can configure your credentials with the command "eas credentials".
|
||||
For more information, please see ${link('https://docs.expo.dev/app-signing/app-credentials/')}.
|
||||
`
|
||||
.trim()
|
||||
.trimStart();
|
||||
}
|
||||
|
||||
function nextStepForAppSubmission(): string {
|
||||
return `This workflow includes a job to submit your app to app stores.
|
||||
You will need to configure your App Store/Play Store credentials
|
||||
before you can run this workflow.
|
||||
Please see ${link('https://docs.expo.dev/deploy/submit-to-app-stores/')}.
|
||||
`
|
||||
.trim()
|
||||
.trimStart();
|
||||
}
|
||||
|
||||
export function howToRunWorkflow(
|
||||
workflowFileName: string,
|
||||
workflowStarter: WorkflowStarter
|
||||
): string {
|
||||
const lines = [
|
||||
`To run this workflow manually, use the command "eas workflow:run .eas/workflows/${workflowFileName}".`,
|
||||
];
|
||||
if (workflowStarter.template?.on?.push?.branches?.length > 0) {
|
||||
const branches = workflowStarter.template.on.push.branches;
|
||||
if (branches.length === 1 && branches[0] === '*') {
|
||||
lines.push(
|
||||
'This workflow is also configured to run automatically when code is pushed to any branch.'
|
||||
);
|
||||
} else if (branches.length === 1) {
|
||||
lines.push(
|
||||
`This workflow is also configured to run automatically when code is pushed to the "${branches[0]}" branch.`
|
||||
);
|
||||
} else {
|
||||
lines.push(
|
||||
`This workflow is also configured to run automatically when code is pushed to the following branches: ${branches.join(
|
||||
', '
|
||||
)}.`
|
||||
);
|
||||
}
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
export const workflowStarters: WorkflowStarter[] = [
|
||||
{
|
||||
displayName: 'Custom',
|
||||
name: WorkflowStarterName.CUSTOM,
|
||||
defaultFileName: 'custom.yml',
|
||||
template: CUSTOM_TEMPLATE,
|
||||
header: CUSTOM_TEMPLATE_HEADER,
|
||||
},
|
||||
{
|
||||
displayName: 'Create development builds',
|
||||
name: WorkflowStarterName.BUILD,
|
||||
defaultFileName: 'create-development-builds.yml',
|
||||
template: BUILD_TEMPLATE,
|
||||
header: BUILD_TEMPLATE_HEADER,
|
||||
},
|
||||
{
|
||||
displayName: 'Publish updates',
|
||||
name: WorkflowStarterName.UPDATE,
|
||||
defaultFileName: 'publish-updates.yml',
|
||||
template: PUBLISH_UPDATE_TEMPLATE,
|
||||
header: PUBLISH_UPDATE_TEMPLATE_HEADER,
|
||||
},
|
||||
{
|
||||
displayName: 'Deploy to production',
|
||||
name: WorkflowStarterName.DEPLOY,
|
||||
defaultFileName: 'deploy-to-production.yml',
|
||||
template: DEPLOY_TEMPLATE,
|
||||
header: DEPLOY_TEMPLATE_HEADER,
|
||||
},
|
||||
];
|
||||
|
||||
export async function addBuildJobsToDevelopmentBuildTemplateAsync(
|
||||
projectDir: string,
|
||||
workflowStarter: WorkflowStarter
|
||||
): Promise<WorkflowStarter> {
|
||||
const buildProfiles = await buildProfilesFromProjectAsync(projectDir);
|
||||
// android_development_build
|
||||
|
||||
const nextSteps: Set<string> = new Set();
|
||||
|
||||
let androidDevelopmentBuildProfileName: string | null = null;
|
||||
for (const profileName of buildProfiles.keys()) {
|
||||
const profile = buildProfiles.get(profileName)?.android;
|
||||
if (!profile) {
|
||||
continue;
|
||||
}
|
||||
if (isBuildProfileForDevelopment(profile, Platform.ANDROID)) {
|
||||
androidDevelopmentBuildProfileName = profileName;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!androidDevelopmentBuildProfileName) {
|
||||
Log.warn(
|
||||
'This workflow requires an Android development build profile in your eas.json, but none were found.'
|
||||
);
|
||||
const add = await confirmAsync({
|
||||
message: 'Do you want to add an Android development build profile?',
|
||||
initial: false,
|
||||
});
|
||||
if (add) {
|
||||
let androidDevelopmentBuildProfileName = 'android_development';
|
||||
while (buildProfiles.has(androidDevelopmentBuildProfileName)) {
|
||||
androidDevelopmentBuildProfileName = `${androidDevelopmentBuildProfileName}_1`;
|
||||
}
|
||||
await addAndroidDevelopmentBuildProfileToEasJsonAsync(
|
||||
projectDir,
|
||||
androidDevelopmentBuildProfileName
|
||||
);
|
||||
} else {
|
||||
Log.log('Skipping Android development build job...');
|
||||
}
|
||||
}
|
||||
if (androidDevelopmentBuildProfileName) {
|
||||
Log.log(`Using Android development build profile: ${androidDevelopmentBuildProfileName}`);
|
||||
workflowStarter.template.jobs.android_development_build = {
|
||||
name: `Build ${androidDevelopmentBuildProfileName} for android`,
|
||||
type: 'build',
|
||||
params: {
|
||||
profile: androidDevelopmentBuildProfileName,
|
||||
platform: 'android',
|
||||
},
|
||||
};
|
||||
nextSteps.add(nextStepForDeviceBuildProfile(androidDevelopmentBuildProfileName));
|
||||
}
|
||||
|
||||
// ios_simulator_development_build
|
||||
let iosSimulatorDevelopmentBuildProfileName: string | null = null;
|
||||
for (const profileName of buildProfiles.keys()) {
|
||||
const profile = buildProfiles.get(profileName)?.ios;
|
||||
if (!profile) {
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
isBuildProfileForDevelopment(profile, Platform.IOS) &&
|
||||
isIosBuildProfileForSimulator(profile)
|
||||
) {
|
||||
iosSimulatorDevelopmentBuildProfileName = profileName;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!iosSimulatorDevelopmentBuildProfileName) {
|
||||
Log.warn(
|
||||
'This workflow requires an iOS simulator development build profile in your eas.json, but none were found.'
|
||||
);
|
||||
const add = await confirmAsync({
|
||||
message: 'Do you want to add an iOS simulator development build profile?',
|
||||
initial: false,
|
||||
});
|
||||
if (add) {
|
||||
let iosSimulatorDevelopmentBuildProfileName = 'ios_simulator_development';
|
||||
while (buildProfiles.has(iosSimulatorDevelopmentBuildProfileName)) {
|
||||
iosSimulatorDevelopmentBuildProfileName = `${iosSimulatorDevelopmentBuildProfileName}_1`;
|
||||
}
|
||||
await addIosDevelopmentBuildProfileToEasJsonAsync(
|
||||
projectDir,
|
||||
iosSimulatorDevelopmentBuildProfileName,
|
||||
true
|
||||
);
|
||||
} else {
|
||||
Log.log('Skipping iOS simulator development build job...');
|
||||
}
|
||||
}
|
||||
if (iosSimulatorDevelopmentBuildProfileName) {
|
||||
Log.log(
|
||||
`Using iOS simulator development build profile: ${iosSimulatorDevelopmentBuildProfileName}`
|
||||
);
|
||||
workflowStarter.template.jobs.ios_simulator_development_build = {
|
||||
name: `Build ${iosSimulatorDevelopmentBuildProfileName} for iOS simulator`,
|
||||
type: 'build',
|
||||
params: {
|
||||
profile: iosSimulatorDevelopmentBuildProfileName,
|
||||
platform: 'ios',
|
||||
},
|
||||
};
|
||||
}
|
||||
// ios_device_development_build
|
||||
let iosDeviceDevelopmentBuildProfileName: string | null = null;
|
||||
for (const profileName of buildProfiles.keys()) {
|
||||
const profile = buildProfiles.get(profileName)?.ios;
|
||||
if (!profile) {
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
isBuildProfileForDevelopment(profile, Platform.IOS) &&
|
||||
!isIosBuildProfileForSimulator(profile)
|
||||
) {
|
||||
iosDeviceDevelopmentBuildProfileName = profileName;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!iosDeviceDevelopmentBuildProfileName) {
|
||||
Log.warn(
|
||||
'This workflow requires an iOS device development build profile in your eas.json, but none were found.'
|
||||
);
|
||||
const add = await confirmAsync({
|
||||
message: 'Do you want to add an iOS device development build profile?',
|
||||
initial: false,
|
||||
});
|
||||
if (add) {
|
||||
let iosDeviceDevelopmentBuildProfileName = 'ios_device_development';
|
||||
while (buildProfiles.has(iosDeviceDevelopmentBuildProfileName)) {
|
||||
iosDeviceDevelopmentBuildProfileName = `${iosDeviceDevelopmentBuildProfileName}_1`;
|
||||
}
|
||||
await addIosDevelopmentBuildProfileToEasJsonAsync(
|
||||
projectDir,
|
||||
iosDeviceDevelopmentBuildProfileName,
|
||||
false
|
||||
);
|
||||
} else {
|
||||
Log.log('Skipping iOS device development build job...');
|
||||
}
|
||||
}
|
||||
if (iosDeviceDevelopmentBuildProfileName) {
|
||||
Log.log(`Using iOS device development build profile: ${iosDeviceDevelopmentBuildProfileName}`);
|
||||
workflowStarter.template.jobs.ios_development_build = {
|
||||
name: `Build ${iosDeviceDevelopmentBuildProfileName} for iOS simulator`,
|
||||
type: 'build',
|
||||
params: {
|
||||
profile: iosDeviceDevelopmentBuildProfileName,
|
||||
platform: 'ios',
|
||||
},
|
||||
};
|
||||
nextSteps.add(nextStepForDeviceBuildProfile(iosDeviceDevelopmentBuildProfileName));
|
||||
}
|
||||
workflowStarter.nextSteps = [...nextSteps];
|
||||
return workflowStarter;
|
||||
}
|
||||
|
||||
export async function ensureProductionBuildProfileExistsAsync(
|
||||
projectDir: string,
|
||||
workflowStarter: WorkflowStarter
|
||||
): Promise<WorkflowStarter> {
|
||||
await addProductionBuildProfileToEasJsonIfNeededAsync(projectDir);
|
||||
workflowStarter.nextSteps = [
|
||||
nextStepForDeviceBuildProfile('production'),
|
||||
nextStepForAppSubmission(),
|
||||
];
|
||||
return workflowStarter;
|
||||
}
|
||||
|
||||
export async function customizeTemplateIfNeededAsync(
|
||||
workflowStarter: WorkflowStarter,
|
||||
projectDir: string,
|
||||
expoConfig: ExpoConfig
|
||||
): Promise<any> {
|
||||
// Ensure EAS Build is configured
|
||||
switch (workflowStarter.name) {
|
||||
case WorkflowStarterName.BUILD:
|
||||
case WorkflowStarterName.DEPLOY:
|
||||
case WorkflowStarterName.UPDATE:
|
||||
if (!(await hasBuildConfigureBeenRunAsync({ projectDir, expoConfig }))) {
|
||||
throw new Error(
|
||||
'EAS Build is not configured for this project. Please run "eas build:configure" to configure it.'
|
||||
);
|
||||
}
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
// Ensure EAS Update is configured
|
||||
switch (workflowStarter.name) {
|
||||
case WorkflowStarterName.DEPLOY:
|
||||
case WorkflowStarterName.UPDATE:
|
||||
if (!(await hasUpdateConfigureBeenRunAsync({ projectDir, expoConfig }))) {
|
||||
throw new Error(
|
||||
'EAS Update is not configured for this project. Please run "eas update:configure" to configure it.'
|
||||
);
|
||||
}
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
// Customize template
|
||||
switch (workflowStarter.name) {
|
||||
case WorkflowStarterName.BUILD:
|
||||
Log.debug('Adding build jobs to template...');
|
||||
return await addBuildJobsToDevelopmentBuildTemplateAsync(projectDir, workflowStarter);
|
||||
case WorkflowStarterName.DEPLOY:
|
||||
return await ensureProductionBuildProfileExistsAsync(projectDir, workflowStarter);
|
||||
default:
|
||||
return workflowStarter;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { WorkflowJobResult } from './types';
|
||||
import { BuildQuery } from '../../graphql/queries/BuildQuery';
|
||||
import { ExpoGraphqlClient } from '../context/contextUtils/createGraphqlClient';
|
||||
|
||||
// This function is in a separate module for testing purposes
|
||||
|
||||
export async function fetchRawLogsForCustomJobAsync(
|
||||
job: WorkflowJobResult
|
||||
): Promise<string | null> {
|
||||
const firstLogFileUrl = job.turtleJobRun?.logFileUrls?.[0];
|
||||
if (!firstLogFileUrl) {
|
||||
return null;
|
||||
}
|
||||
const response = await fetch(firstLogFileUrl, {
|
||||
method: 'GET',
|
||||
});
|
||||
const rawLogs = await response.text();
|
||||
return rawLogs;
|
||||
}
|
||||
|
||||
export async function fetchRawLogsForBuildJobAsync(
|
||||
state: { graphqlClient: ExpoGraphqlClient },
|
||||
job: WorkflowJobResult
|
||||
): Promise<string | null> {
|
||||
const buildId = job.outputs?.build_id;
|
||||
if (!buildId) {
|
||||
return null;
|
||||
}
|
||||
const buildFragment = await BuildQuery.byIdAsync(state.graphqlClient, buildId, {
|
||||
useCache: false,
|
||||
});
|
||||
const firstLogFileUrl = buildFragment.logFiles?.[0];
|
||||
if (!firstLogFileUrl) {
|
||||
return null;
|
||||
}
|
||||
const response = await fetch(firstLogFileUrl, {
|
||||
method: 'GET',
|
||||
});
|
||||
const rawLogs = await response.text();
|
||||
return rawLogs;
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
import * as YAML from 'yaml';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { DefaultEnvironment } from '../../build/utils/environment';
|
||||
import { booleanLike, stringLike } from '../../credentials/ios/types';
|
||||
import Log from '../../log';
|
||||
import { promptAsync } from '../../prompts';
|
||||
|
||||
const inputExtraProperties = {
|
||||
description: stringLike.optional().describe('Description of the input'),
|
||||
required: booleanLike.default(false).optional().describe('Whether the input is required.'),
|
||||
};
|
||||
|
||||
// Adapted from the input definition on the server (https://github.com/expo/universe/pull/21950)
|
||||
export const WorkflowDispatchInputZ = z.discriminatedUnion('type', [
|
||||
z.object({
|
||||
...inputExtraProperties,
|
||||
type: z.literal('string').default('string').optional(),
|
||||
default: stringLike.optional().describe('Default value for the input'),
|
||||
}),
|
||||
z.object({
|
||||
...inputExtraProperties,
|
||||
type: z.literal('boolean'),
|
||||
default: booleanLike.optional().describe('Default value for the input'),
|
||||
}),
|
||||
z.object({
|
||||
...inputExtraProperties,
|
||||
type: z.literal('number'),
|
||||
default: z.number().optional().describe('Default value for the input'),
|
||||
}),
|
||||
z.object({
|
||||
...inputExtraProperties,
|
||||
type: z.literal('choice'),
|
||||
default: stringLike.optional().describe('Default value for the input'),
|
||||
options: z.array(stringLike).min(1).describe('Options for choice type inputs'),
|
||||
}),
|
||||
z.object({
|
||||
...inputExtraProperties,
|
||||
type: z.literal('environment'),
|
||||
default: z
|
||||
.enum(Object.values(DefaultEnvironment))
|
||||
.optional()
|
||||
.describe('Default value for the input'),
|
||||
}),
|
||||
]);
|
||||
export function parseInputs(inputFlags: string[]): Record<string, string> {
|
||||
const inputs: Record<string, string> = {};
|
||||
|
||||
for (const inputFlag of inputFlags) {
|
||||
const equalIndex = inputFlag.indexOf('=');
|
||||
if (equalIndex === -1) {
|
||||
throw new Error(`Invalid input format: ${inputFlag}. Expected key=value format.`);
|
||||
}
|
||||
|
||||
const key = inputFlag.substring(0, equalIndex);
|
||||
const value = inputFlag.substring(equalIndex + 1);
|
||||
|
||||
if (!key) {
|
||||
throw new Error(`Invalid input format: ${inputFlag}. Key cannot be empty.`);
|
||||
}
|
||||
|
||||
inputs[key] = value;
|
||||
}
|
||||
|
||||
return inputs;
|
||||
}
|
||||
|
||||
export function parseJsonInputs(jsonString: string): Record<string, string> {
|
||||
try {
|
||||
const parsed = JSON.parse(jsonString);
|
||||
|
||||
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
||||
throw new Error('JSON input must be an object.');
|
||||
}
|
||||
|
||||
return parsed;
|
||||
} catch (error) {
|
||||
throw new Error(`Invalid JSON input.`, { cause: error });
|
||||
}
|
||||
}
|
||||
|
||||
export function parseWorkflowInputsFromYaml(
|
||||
yamlConfig: string
|
||||
): Record<string, z.infer<typeof WorkflowDispatchInputZ>> {
|
||||
try {
|
||||
const parsed = YAML.parse(yamlConfig);
|
||||
return z
|
||||
.record(z.string(), WorkflowDispatchInputZ)
|
||||
.default({})
|
||||
.parse(parsed?.on?.workflow_dispatch?.inputs);
|
||||
} catch (error) {
|
||||
Log.warn('Failed to parse workflow inputs from YAML:', error);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
export async function maybePromptForMissingInputsAsync({
|
||||
inputSpecs,
|
||||
inputs,
|
||||
}: {
|
||||
inputSpecs: Record<string, z.infer<typeof WorkflowDispatchInputZ>>;
|
||||
inputs: Record<string, unknown>;
|
||||
}): Promise<Record<string, unknown>> {
|
||||
const requiredInputs = Object.entries(inputSpecs).filter(([_, spec]) => spec.required);
|
||||
|
||||
const missingRequiredInputs = requiredInputs.filter(([key]) => inputs[key] === undefined);
|
||||
|
||||
if (missingRequiredInputs.length === 0) {
|
||||
return inputs;
|
||||
}
|
||||
|
||||
Log.addNewLineIfNone();
|
||||
Log.log('Some required inputs are missing. Please provide them:');
|
||||
|
||||
const nextInputs = { ...inputs };
|
||||
|
||||
for (const [key, spec] of missingRequiredInputs) {
|
||||
const value = await promptForMissingInputAsync({ key, spec });
|
||||
nextInputs[key] = value;
|
||||
}
|
||||
|
||||
return nextInputs;
|
||||
}
|
||||
|
||||
async function promptForMissingInputAsync({
|
||||
key,
|
||||
spec,
|
||||
}: {
|
||||
key: string;
|
||||
spec: z.infer<typeof WorkflowDispatchInputZ>;
|
||||
}): Promise<unknown> {
|
||||
const message = spec.description ? `${key} (${spec.description})` : key;
|
||||
|
||||
switch (spec.type) {
|
||||
case 'boolean': {
|
||||
const { value } = await promptAsync({
|
||||
type: 'confirm',
|
||||
name: 'value',
|
||||
message,
|
||||
initial: spec.default,
|
||||
});
|
||||
return value;
|
||||
}
|
||||
|
||||
case 'number': {
|
||||
const { value } = await promptAsync({
|
||||
type: 'number',
|
||||
name: 'value',
|
||||
message,
|
||||
initial: spec.default,
|
||||
validate: (val: number) => {
|
||||
if (isNaN(val)) {
|
||||
return 'Please enter a valid number';
|
||||
}
|
||||
return true;
|
||||
},
|
||||
});
|
||||
return value;
|
||||
}
|
||||
|
||||
case 'choice': {
|
||||
const { value } = await promptAsync({
|
||||
type: 'select',
|
||||
name: 'value',
|
||||
message,
|
||||
choices: spec.options.map(option => ({
|
||||
title: option,
|
||||
value: option,
|
||||
})),
|
||||
initial: spec.default,
|
||||
});
|
||||
return value;
|
||||
}
|
||||
|
||||
case 'string':
|
||||
case 'environment':
|
||||
default: {
|
||||
const { value } = await promptAsync({
|
||||
type: 'text',
|
||||
name: 'value',
|
||||
message,
|
||||
initial: spec.default,
|
||||
validate: (val: string) => {
|
||||
if (spec.required && (!val || val.trim() === '')) {
|
||||
return 'This field is required';
|
||||
}
|
||||
return true;
|
||||
},
|
||||
});
|
||||
return value;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,324 @@
|
||||
import { Choice } from 'prompts';
|
||||
|
||||
import { WorkflowJobResult, WorkflowLogs } from './types';
|
||||
import {
|
||||
choiceFromWorkflowJob,
|
||||
choiceFromWorkflowRun,
|
||||
choicesFromWorkflowLogs,
|
||||
fetchAndProcessLogsFromJobAsync,
|
||||
processWorkflowRuns,
|
||||
} from './utils';
|
||||
import { AppQuery } from '../../graphql/queries/AppQuery';
|
||||
import { WorkflowJobQuery } from '../../graphql/queries/WorkflowJobQuery';
|
||||
import { WorkflowRunQuery } from '../../graphql/queries/WorkflowRunQuery';
|
||||
import Log from '../../log';
|
||||
import { promptAsync } from '../../prompts';
|
||||
import { ExpoGraphqlClient } from '../context/contextUtils/createGraphqlClient';
|
||||
|
||||
/*
|
||||
* State machine types and functions for moving between different workflow command states
|
||||
*/
|
||||
|
||||
export enum WorkflowCommandSelectionStateValue {
|
||||
WORKFLOW_RUN_SELECTION = 'WORKFLOW_RUN_SELECTION',
|
||||
WORKFLOW_JOB_SELECTION = 'WORKFLOW_JOB_SELECTION',
|
||||
WORKFLOW_STEP_SELECTION = 'WORKFLOW_STEP_SELECTION',
|
||||
FINISH = 'FINISH',
|
||||
ERROR = 'ERROR',
|
||||
}
|
||||
const workflowCommandSelectionAllowedStateTransitions: {
|
||||
[key in WorkflowCommandSelectionStateValue]: WorkflowCommandSelectionStateValue[];
|
||||
} = {
|
||||
WORKFLOW_JOB_SELECTION: [
|
||||
WorkflowCommandSelectionStateValue.WORKFLOW_STEP_SELECTION,
|
||||
WorkflowCommandSelectionStateValue.WORKFLOW_RUN_SELECTION,
|
||||
WorkflowCommandSelectionStateValue.ERROR,
|
||||
WorkflowCommandSelectionStateValue.FINISH,
|
||||
],
|
||||
WORKFLOW_RUN_SELECTION: [
|
||||
WorkflowCommandSelectionStateValue.WORKFLOW_JOB_SELECTION,
|
||||
WorkflowCommandSelectionStateValue.ERROR,
|
||||
],
|
||||
WORKFLOW_STEP_SELECTION: [
|
||||
WorkflowCommandSelectionStateValue.WORKFLOW_JOB_SELECTION,
|
||||
WorkflowCommandSelectionStateValue.ERROR,
|
||||
WorkflowCommandSelectionStateValue.FINISH,
|
||||
],
|
||||
ERROR: [WorkflowCommandSelectionStateValue.WORKFLOW_RUN_SELECTION],
|
||||
FINISH: [],
|
||||
};
|
||||
|
||||
export type WorkflowCommandSelectionState = {
|
||||
graphqlClient: ExpoGraphqlClient;
|
||||
projectId: string;
|
||||
nonInteractive: boolean;
|
||||
allSteps: boolean;
|
||||
state: WorkflowCommandSelectionStateValue;
|
||||
runId?: string;
|
||||
jobId?: string;
|
||||
step?: string;
|
||||
job?: WorkflowJobResult;
|
||||
logs?: WorkflowLogs | null;
|
||||
message?: string;
|
||||
};
|
||||
|
||||
export type WorkflowCommandSelectionStateParameters = Omit<
|
||||
WorkflowCommandSelectionState,
|
||||
'graphqlClient' | 'projectId' | 'state' | 'nonInteractive' | 'allSteps'
|
||||
>;
|
||||
|
||||
export type WorkflowCommandSelectionAction = (
|
||||
prevState: WorkflowCommandSelectionState
|
||||
) => Promise<WorkflowCommandSelectionState>;
|
||||
|
||||
export function moveToNewWorkflowCommandSelectionState(
|
||||
previousState: WorkflowCommandSelectionState,
|
||||
newStateValue: WorkflowCommandSelectionStateValue,
|
||||
parameters: WorkflowCommandSelectionStateParameters
|
||||
): WorkflowCommandSelectionState {
|
||||
if (
|
||||
!workflowCommandSelectionAllowedStateTransitions[previousState.state].includes(newStateValue)
|
||||
) {
|
||||
const errorMessage = `Invalid state transition from ${
|
||||
previousState.state
|
||||
} to ${newStateValue}. Allowed transitions from ${
|
||||
previousState.state
|
||||
}: ${workflowCommandSelectionAllowedStateTransitions[previousState.state].join(', ')}`;
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
return {
|
||||
...previousState,
|
||||
state: newStateValue,
|
||||
...parameters,
|
||||
};
|
||||
}
|
||||
|
||||
export function moveToWorkflowRunSelectionState(
|
||||
previousState: WorkflowCommandSelectionState,
|
||||
params?: {
|
||||
runId?: string | undefined;
|
||||
}
|
||||
): WorkflowCommandSelectionState {
|
||||
return moveToNewWorkflowCommandSelectionState(
|
||||
previousState,
|
||||
WorkflowCommandSelectionStateValue.WORKFLOW_RUN_SELECTION,
|
||||
{
|
||||
runId: params?.runId,
|
||||
jobId: undefined,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export function moveToWorkflowJobSelectionState(
|
||||
previousState: WorkflowCommandSelectionState,
|
||||
params: {
|
||||
jobId?: string | undefined;
|
||||
runId?: string | undefined;
|
||||
}
|
||||
): WorkflowCommandSelectionState {
|
||||
return moveToNewWorkflowCommandSelectionState(
|
||||
previousState,
|
||||
WorkflowCommandSelectionStateValue.WORKFLOW_JOB_SELECTION,
|
||||
params
|
||||
);
|
||||
}
|
||||
|
||||
export function moveToWorkflowStepSelectionState(
|
||||
previousState: WorkflowCommandSelectionState,
|
||||
params: {
|
||||
job: WorkflowJobResult;
|
||||
}
|
||||
): WorkflowCommandSelectionState {
|
||||
return moveToNewWorkflowCommandSelectionState(
|
||||
previousState,
|
||||
WorkflowCommandSelectionStateValue.WORKFLOW_STEP_SELECTION,
|
||||
params
|
||||
);
|
||||
}
|
||||
|
||||
export function moveToWorkflowSelectionFinishedState(
|
||||
previousState: WorkflowCommandSelectionState,
|
||||
params: {
|
||||
step: string;
|
||||
logs: WorkflowLogs;
|
||||
}
|
||||
): WorkflowCommandSelectionState {
|
||||
return moveToNewWorkflowCommandSelectionState(
|
||||
previousState,
|
||||
WorkflowCommandSelectionStateValue.FINISH,
|
||||
params
|
||||
);
|
||||
}
|
||||
|
||||
export function moveToWorkflowSelectionErrorState(
|
||||
previousState: WorkflowCommandSelectionState,
|
||||
message: string
|
||||
): WorkflowCommandSelectionState {
|
||||
return moveToNewWorkflowCommandSelectionState(
|
||||
previousState,
|
||||
WorkflowCommandSelectionStateValue.ERROR,
|
||||
{
|
||||
message,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line async-protect/async-suffix
|
||||
export const workflowRunSelectionAction: WorkflowCommandSelectionAction = async prevState => {
|
||||
const { graphqlClient, projectId, runId, jobId, allSteps } = prevState;
|
||||
Log.debug(
|
||||
`workflowRunSelectionAction: runId = ${runId}, jobId = ${jobId}, allSteps = ${allSteps}`
|
||||
);
|
||||
|
||||
if (runId) {
|
||||
return moveToWorkflowJobSelectionState(prevState, { runId });
|
||||
}
|
||||
const runs = await AppQuery.byIdWorkflowRunsFilteredByStatusAsync(
|
||||
graphqlClient,
|
||||
projectId,
|
||||
undefined,
|
||||
20
|
||||
);
|
||||
if (runs.length === 0) {
|
||||
return moveToWorkflowSelectionErrorState(prevState, 'No workflow runs found');
|
||||
}
|
||||
const processedRuns = processWorkflowRuns(runs);
|
||||
const choices = processedRuns.map(run => choiceFromWorkflowRun(run));
|
||||
const selectedId = (
|
||||
await promptAsync({
|
||||
type: 'select',
|
||||
name: 'selectedRun',
|
||||
message: 'Select a workflow run:',
|
||||
choices,
|
||||
})
|
||||
).selectedRun;
|
||||
return moveToWorkflowJobSelectionState(prevState, { runId: selectedId });
|
||||
};
|
||||
|
||||
// eslint-disable-next-line async-protect/async-suffix
|
||||
export const workflowJobSelectionAction: WorkflowCommandSelectionAction = async prevState => {
|
||||
const { graphqlClient, runId, jobId, nonInteractive, allSteps } = prevState;
|
||||
Log.debug(
|
||||
`workflowJobSelectionAction: runId = ${runId}, jobId = ${jobId}, allSteps = ${allSteps}`
|
||||
);
|
||||
if (jobId) {
|
||||
let workflowJobResult = undefined;
|
||||
try {
|
||||
workflowJobResult = await WorkflowJobQuery.byIdAsync(graphqlClient, jobId, {
|
||||
useCache: false,
|
||||
});
|
||||
return moveToWorkflowStepSelectionState(prevState, { job: workflowJobResult });
|
||||
} catch {}
|
||||
if (nonInteractive && !workflowJobResult) {
|
||||
return moveToWorkflowSelectionErrorState(
|
||||
prevState,
|
||||
'No workflow job found that matched the provided ID'
|
||||
);
|
||||
} else {
|
||||
// The passed in ID may be a run ID, pass it back to run selection action
|
||||
return moveToWorkflowRunSelectionState(prevState, { runId: jobId });
|
||||
}
|
||||
} else {
|
||||
// No job ID was passed in
|
||||
if (nonInteractive) {
|
||||
return moveToWorkflowSelectionErrorState(
|
||||
prevState,
|
||||
'No workflow job ID provided in non-interactive mode'
|
||||
);
|
||||
} else if (!runId) {
|
||||
// If no jobId or runId, we should go back to run selection
|
||||
return moveToWorkflowRunSelectionState(prevState);
|
||||
}
|
||||
const workflowRunResult = await WorkflowRunQuery.withJobsByIdAsync(graphqlClient, runId, {
|
||||
useCache: false,
|
||||
});
|
||||
if (!workflowRunResult) {
|
||||
return moveToWorkflowSelectionErrorState(
|
||||
prevState,
|
||||
'No workflow run found that matched the provided ID'
|
||||
);
|
||||
}
|
||||
const choices: Choice[] = [
|
||||
...workflowRunResult.jobs.map((job, i) => choiceFromWorkflowJob(job, i)),
|
||||
{
|
||||
title: 'Go back and select a different workflow run',
|
||||
value: -1,
|
||||
},
|
||||
];
|
||||
const selectedJobIndex = (
|
||||
await promptAsync({
|
||||
type: 'select',
|
||||
name: 'selectedJob',
|
||||
message: 'Select a job:',
|
||||
choices,
|
||||
})
|
||||
).selectedJob;
|
||||
if (selectedJobIndex === -1) {
|
||||
return moveToWorkflowRunSelectionState(prevState);
|
||||
}
|
||||
const selectedJob = workflowRunResult.jobs[selectedJobIndex] as WorkflowJobResult;
|
||||
return moveToWorkflowStepSelectionState(prevState, { job: selectedJob });
|
||||
}
|
||||
};
|
||||
|
||||
// eslint-disable-next-line async-protect/async-suffix
|
||||
export const workflowStepSelectionAction: WorkflowCommandSelectionAction = async prevState => {
|
||||
const { job, allSteps } = prevState;
|
||||
Log.debug(`workflowStepSelectionAction: job = ${job?.id}, allSteps = ${allSteps}`);
|
||||
if (!job) {
|
||||
return moveToWorkflowSelectionErrorState(prevState, 'No job found');
|
||||
}
|
||||
const logs = await fetchAndProcessLogsFromJobAsync(prevState, job);
|
||||
if (!logs) {
|
||||
return moveToWorkflowSelectionErrorState(prevState, 'No logs found');
|
||||
}
|
||||
if (allSteps) {
|
||||
return moveToWorkflowSelectionFinishedState(prevState, { step: '', logs });
|
||||
}
|
||||
const choices: Choice[] = [
|
||||
...choicesFromWorkflowLogs(logs),
|
||||
{
|
||||
title: 'Go back and select a different workflow job',
|
||||
value: 'go-back',
|
||||
},
|
||||
];
|
||||
const selectedStep: string =
|
||||
(
|
||||
await promptAsync({
|
||||
type: 'select',
|
||||
name: 'selectedStep',
|
||||
message: 'Select a step:',
|
||||
choices,
|
||||
})
|
||||
).selectedStep ?? '';
|
||||
if (selectedStep === 'go-back') {
|
||||
return moveToWorkflowJobSelectionState(prevState, {
|
||||
runId: job.workflowRun.id,
|
||||
jobId: undefined,
|
||||
});
|
||||
}
|
||||
return moveToWorkflowSelectionFinishedState(prevState, { step: selectedStep, logs });
|
||||
};
|
||||
|
||||
export const executeWorkflowSelectionActionsAsync: WorkflowCommandSelectionAction =
|
||||
async prevState => {
|
||||
let currentState = prevState;
|
||||
while (
|
||||
currentState.state !== WorkflowCommandSelectionStateValue.FINISH &&
|
||||
currentState.state !== WorkflowCommandSelectionStateValue.ERROR
|
||||
) {
|
||||
Log.debug(`${currentState.state}`);
|
||||
switch (currentState.state) {
|
||||
case WorkflowCommandSelectionStateValue.WORKFLOW_JOB_SELECTION:
|
||||
currentState = await workflowJobSelectionAction(currentState);
|
||||
break;
|
||||
case WorkflowCommandSelectionStateValue.WORKFLOW_RUN_SELECTION:
|
||||
currentState = await workflowRunSelectionAction(currentState);
|
||||
break;
|
||||
case WorkflowCommandSelectionStateValue.WORKFLOW_STEP_SELECTION:
|
||||
currentState = await workflowStepSelectionAction(currentState);
|
||||
break;
|
||||
}
|
||||
}
|
||||
return currentState;
|
||||
};
|
||||
@@ -0,0 +1,53 @@
|
||||
import { WorkflowJobByIdQuery, WorkflowRunByIdWithJobsQuery } from '../../graphql/generated';
|
||||
|
||||
/*
|
||||
* Utility types and Zod definitions for workflow commands
|
||||
*/
|
||||
|
||||
export enum WorkflowTriggerType {
|
||||
MANUAL = 'Manual',
|
||||
GITHUB = 'GitHub',
|
||||
SCHEDULED = 'Scheduled',
|
||||
OTHER = 'Other',
|
||||
}
|
||||
|
||||
export type WorkflowRunResult = {
|
||||
id: string;
|
||||
status: string;
|
||||
gitCommitMessage: string | null;
|
||||
gitCommitHash: string | null;
|
||||
triggerType: WorkflowTriggerType;
|
||||
trigger: string | null;
|
||||
startedAt: string;
|
||||
finishedAt: string;
|
||||
workflowId: string;
|
||||
workflowName: string | null;
|
||||
workflowFileName: string;
|
||||
};
|
||||
|
||||
export type WorkflowResult = {
|
||||
id: string;
|
||||
name?: string | null | undefined;
|
||||
fileName: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type WorkflowJobResult =
|
||||
| WorkflowRunByIdWithJobsQuery['workflowRuns']['byId']['jobs'][number]
|
||||
| WorkflowJobByIdQuery['workflowJobs']['byId'];
|
||||
|
||||
export type WorkflowRunWithJobsResult = WorkflowRunResult & {
|
||||
jobs: WorkflowJobResult[];
|
||||
logs?: string;
|
||||
};
|
||||
|
||||
export type WorkflowLogLine = {
|
||||
time: string;
|
||||
msg: string;
|
||||
result?: string;
|
||||
marker?: string;
|
||||
err?: any;
|
||||
};
|
||||
|
||||
export type WorkflowLogs = Map<string, WorkflowLogLine[]>;
|
||||
@@ -0,0 +1,373 @@
|
||||
import chalk from 'chalk';
|
||||
import * as fs from 'node:fs';
|
||||
|
||||
import { fetchRawLogsForBuildJobAsync, fetchRawLogsForCustomJobAsync } from './fetchLogs';
|
||||
import {
|
||||
WorkflowJobResult,
|
||||
WorkflowLogLine,
|
||||
WorkflowLogs,
|
||||
WorkflowRunResult,
|
||||
WorkflowTriggerType,
|
||||
} from './types';
|
||||
import {
|
||||
WorkflowJobStatus,
|
||||
WorkflowJobType,
|
||||
WorkflowRunByIdQuery,
|
||||
WorkflowRunByIdWithJobsQuery,
|
||||
WorkflowRunFragment,
|
||||
WorkflowRunStatus,
|
||||
WorkflowRunTriggerEventType,
|
||||
} from '../../graphql/generated';
|
||||
import { WorkflowRunQuery } from '../../graphql/queries/WorkflowRunQuery';
|
||||
import Log from '../../log';
|
||||
import { ora } from '../../ora';
|
||||
import { Choice } from '../../prompts';
|
||||
import formatFields from '../../utils/formatFields';
|
||||
import { sleepAsync } from '../../utils/promise';
|
||||
import { ExpoGraphqlClient } from '../context/contextUtils/createGraphqlClient';
|
||||
|
||||
export function computeTriggerInfoForWorkflowRun(run: WorkflowRunFragment): {
|
||||
triggerType: WorkflowTriggerType;
|
||||
trigger: string | null;
|
||||
} {
|
||||
let triggerType = WorkflowTriggerType.OTHER;
|
||||
let trigger = '';
|
||||
if (run.actor?.__typename === 'Robot') {
|
||||
if (run.actor.firstName?.startsWith('GitHub App · ')) {
|
||||
trigger = `${run.requestedGitRef ?? ''}@${run.gitCommitHash?.substring(0, 12) ?? ''}`;
|
||||
}
|
||||
} else if (run.actor?.__typename === 'User') {
|
||||
trigger = run.actor.username;
|
||||
}
|
||||
switch (run.triggerEventType) {
|
||||
case WorkflowRunTriggerEventType.Manual:
|
||||
triggerType = WorkflowTriggerType.MANUAL;
|
||||
break;
|
||||
case WorkflowRunTriggerEventType.GithubPullRequestLabeled:
|
||||
case WorkflowRunTriggerEventType.GithubPullRequestOpened:
|
||||
case WorkflowRunTriggerEventType.GithubPullRequestReopened:
|
||||
case WorkflowRunTriggerEventType.GithubPullRequestSynchronize:
|
||||
case WorkflowRunTriggerEventType.GithubPush:
|
||||
triggerType = WorkflowTriggerType.GITHUB;
|
||||
break;
|
||||
case WorkflowRunTriggerEventType.Schedule:
|
||||
triggerType = WorkflowTriggerType.SCHEDULED;
|
||||
trigger = run.triggeringSchedule ?? '';
|
||||
break;
|
||||
}
|
||||
return { triggerType, trigger };
|
||||
}
|
||||
|
||||
export function choiceFromWorkflowRun(run: WorkflowRunResult): Choice {
|
||||
const titleArray = [
|
||||
run.workflowFileName,
|
||||
run.status,
|
||||
run.startedAt,
|
||||
run.triggerType,
|
||||
run.trigger,
|
||||
];
|
||||
return {
|
||||
title: titleArray.join(' - '),
|
||||
value: run.id,
|
||||
description: `ID: ${run.id}, Message: ${run.gitCommitMessage?.split('\n')[0] ?? ''}`,
|
||||
};
|
||||
}
|
||||
|
||||
export function choiceFromWorkflowJob(job: WorkflowJobResult, index: number): Choice {
|
||||
return {
|
||||
title: `${job.name} - ${job.status}`,
|
||||
value: index,
|
||||
description: `ID: ${job.id}`,
|
||||
};
|
||||
}
|
||||
|
||||
export function choicesFromWorkflowLogs(
|
||||
logs: WorkflowLogs
|
||||
): (Choice & { name: string; status: string; logLines: WorkflowLogLine[] | undefined })[] {
|
||||
return Array.from(logs.keys())
|
||||
.map(step => {
|
||||
const logLines = logs.get(step);
|
||||
const stepStatus =
|
||||
logLines?.filter((line: WorkflowLogLine) => line.marker === 'end-step')[0]?.result ?? '';
|
||||
return {
|
||||
title: `${step} - ${stepStatus}`,
|
||||
name: step,
|
||||
status: stepStatus,
|
||||
value: step,
|
||||
logLines,
|
||||
};
|
||||
})
|
||||
.filter(step => step.status !== 'skipped');
|
||||
}
|
||||
|
||||
export function processWorkflowRuns(runs: WorkflowRunFragment[]): WorkflowRunResult[] {
|
||||
return runs.map(run => {
|
||||
const finishedAt = run.status === WorkflowRunStatus.InProgress ? null : run.updatedAt;
|
||||
const { triggerType, trigger } = computeTriggerInfoForWorkflowRun(run);
|
||||
return {
|
||||
id: run.id,
|
||||
status: run.status,
|
||||
gitCommitMessage: run.gitCommitMessage?.split('\n')[0] ?? null,
|
||||
gitCommitHash: run.gitCommitHash ?? null,
|
||||
startedAt: run.createdAt,
|
||||
finishedAt,
|
||||
triggerType,
|
||||
trigger,
|
||||
workflowId: run.workflow.id,
|
||||
workflowName: run.workflow.name ?? null,
|
||||
workflowFileName: run.workflow.fileName,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchAndProcessLogsFromJobAsync(
|
||||
state: { graphqlClient: ExpoGraphqlClient },
|
||||
job: WorkflowJobResult
|
||||
): Promise<WorkflowLogs | null> {
|
||||
let rawLogs: string | null;
|
||||
switch (job.type) {
|
||||
case WorkflowJobType.Build:
|
||||
case WorkflowJobType.Repack:
|
||||
rawLogs = await fetchRawLogsForBuildJobAsync(state, job);
|
||||
break;
|
||||
default:
|
||||
rawLogs = await fetchRawLogsForCustomJobAsync(job);
|
||||
break;
|
||||
}
|
||||
if (!rawLogs) {
|
||||
return null;
|
||||
}
|
||||
Log.debug(`rawLogs = ${JSON.stringify(rawLogs, null, 2)}`);
|
||||
const logs: WorkflowLogs = new Map();
|
||||
const logKeys = new Set<string>();
|
||||
rawLogs.split('\n').forEach((line, index) => {
|
||||
Log.debug(`line ${index} = ${JSON.stringify(line, null, 2)}`);
|
||||
try {
|
||||
const parsedLine = JSON.parse(line);
|
||||
const { buildStepDisplayName, buildStepInternalId, time, msg, result, marker, err } =
|
||||
parsedLine;
|
||||
const stepId = buildStepDisplayName ?? buildStepInternalId;
|
||||
if (stepId) {
|
||||
if (!logKeys.has(stepId)) {
|
||||
logKeys.add(stepId);
|
||||
logs.set(stepId, []);
|
||||
}
|
||||
logs.get(stepId)?.push({ time, msg, result, marker, err });
|
||||
}
|
||||
} catch {}
|
||||
});
|
||||
return logs;
|
||||
}
|
||||
|
||||
function descriptionForJobStatus(status: WorkflowJobStatus): string {
|
||||
switch (status) {
|
||||
case WorkflowJobStatus.New:
|
||||
return 'Waiting for worker';
|
||||
case WorkflowJobStatus.InProgress:
|
||||
return 'In progress';
|
||||
case WorkflowJobStatus.Success:
|
||||
return 'Completed successfully';
|
||||
case WorkflowJobStatus.Failure:
|
||||
return 'Failed';
|
||||
case WorkflowJobStatus.Canceled:
|
||||
return 'Canceled';
|
||||
case WorkflowJobStatus.Skipped:
|
||||
return 'Skipped';
|
||||
case WorkflowJobStatus.ActionRequired:
|
||||
return 'Waiting for action';
|
||||
case WorkflowJobStatus.PendingCancel:
|
||||
return 'Pending cancel';
|
||||
}
|
||||
}
|
||||
|
||||
export async function infoForActiveWorkflowRunAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
workflowRun: WorkflowRunByIdWithJobsQuery['workflowRuns']['byId'],
|
||||
maxLogLines: number = 5 // -1 means no limit
|
||||
): Promise<string> {
|
||||
const statusLines = [];
|
||||
const statusValues = [];
|
||||
for (const job of workflowRun.jobs) {
|
||||
statusValues.push({ label: '', value: '' });
|
||||
statusValues.push({ label: ' Job', value: job.name });
|
||||
statusValues.push({ label: ' Status', value: descriptionForJobStatus(job.status) });
|
||||
if (job.status !== WorkflowJobStatus.InProgress) {
|
||||
continue;
|
||||
}
|
||||
const logs = await fetchAndProcessLogsFromJobAsync({ graphqlClient }, job);
|
||||
const steps = logs ? choicesFromWorkflowLogs(logs) : [];
|
||||
if (steps.length > 0) {
|
||||
const currentStep = steps[steps.length - 1];
|
||||
statusValues.push({ label: ' Current step', value: currentStep.name });
|
||||
if (currentStep?.logLines?.length) {
|
||||
statusValues.push({ label: ' Current logs', value: '' });
|
||||
const currentLogs =
|
||||
currentStep.logLines
|
||||
?.map(line => line.msg)
|
||||
.filter((_, index) => {
|
||||
if (maxLogLines === -1) {
|
||||
return true;
|
||||
}
|
||||
return index > (currentStep.logLines?.length ?? 0) - maxLogLines;
|
||||
}) ?? [];
|
||||
for (const log of currentLogs) {
|
||||
statusValues.push({ label: '', value: log });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
statusValues.push({ label: '', value: '' });
|
||||
statusLines.push(formatFields(statusValues));
|
||||
return statusLines.join('\n');
|
||||
}
|
||||
|
||||
export async function infoForFailedWorkflowRunAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
workflowRun: WorkflowRunByIdWithJobsQuery['workflowRuns']['byId'],
|
||||
maxLogLines: number = -1 // -1 means no limit
|
||||
): Promise<string> {
|
||||
const statusLines = [];
|
||||
const statusValues = [];
|
||||
const logLinesToKeep = maxLogLines === -1 ? Infinity : maxLogLines;
|
||||
for (const job of workflowRun.jobs) {
|
||||
if (job.status !== WorkflowJobStatus.Failure) {
|
||||
continue;
|
||||
}
|
||||
const logs = await fetchAndProcessLogsFromJobAsync({ graphqlClient }, job);
|
||||
const steps = logs ? choicesFromWorkflowLogs(logs) : [];
|
||||
statusValues.push({ label: '', value: '' });
|
||||
statusValues.push({ label: ' Failed job', value: job.name });
|
||||
if (steps.length > 0) {
|
||||
const failedStep = steps.find(step => step.status === 'fail');
|
||||
if (failedStep) {
|
||||
const logs = failedStep.logLines?.map(line => line.msg).slice(-logLinesToKeep) ?? [];
|
||||
statusValues.push({ label: ' Failed step', value: failedStep.name });
|
||||
statusValues.push({
|
||||
label: ' Logs for failed step',
|
||||
value: '',
|
||||
});
|
||||
for (const log of logs) {
|
||||
statusValues.push({ label: '', value: log });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
statusValues.push({ label: '', value: '' });
|
||||
statusLines.push(formatFields(statusValues));
|
||||
return statusLines.join('\n');
|
||||
}
|
||||
export async function fileExistsAsync(filePath: string): Promise<boolean> {
|
||||
return await fs.promises
|
||||
.access(filePath, fs.constants.F_OK)
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
}
|
||||
export async function maybeReadStdinAsync(): Promise<string | null> {
|
||||
// Check if there's data on stdin
|
||||
if (process.stdin.isTTY) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return await new Promise((resolve, reject) => {
|
||||
let data = '';
|
||||
|
||||
process.stdin.setEncoding('utf8');
|
||||
|
||||
process.stdin.on('readable', () => {
|
||||
let chunk;
|
||||
while ((chunk = process.stdin.read()) !== null) {
|
||||
data += chunk;
|
||||
}
|
||||
});
|
||||
|
||||
process.stdin.on('end', () => {
|
||||
const trimmedData = data.trim();
|
||||
resolve(trimmedData || null);
|
||||
});
|
||||
|
||||
process.stdin.on('error', err => {
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
}
|
||||
export async function showWorkflowStatusAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
{
|
||||
workflowRunId,
|
||||
spinnerUsesStdErr,
|
||||
waitForCompletion = true,
|
||||
}: { workflowRunId: string; spinnerUsesStdErr: boolean; waitForCompletion?: boolean }
|
||||
): Promise<WorkflowRunByIdQuery['workflowRuns']['byId']> {
|
||||
Log.log('Waiting for workflow run to complete. You can press Ctrl+C to exit.');
|
||||
|
||||
const spinner = ora({
|
||||
stream: spinnerUsesStdErr ? process.stderr : process.stdout,
|
||||
text: '',
|
||||
}).start();
|
||||
spinner.prefixText = chalk`{bold.yellow Workflow run is waiting to start:}`;
|
||||
|
||||
let failedFetchesCount = 0;
|
||||
|
||||
while (true) {
|
||||
try {
|
||||
const workflowRun = await WorkflowRunQuery.withJobsByIdAsync(graphqlClient, workflowRunId, {
|
||||
useCache: false,
|
||||
});
|
||||
|
||||
failedFetchesCount = 0;
|
||||
|
||||
switch (workflowRun.status) {
|
||||
case WorkflowRunStatus.New:
|
||||
break;
|
||||
case WorkflowRunStatus.InProgress: {
|
||||
spinner.prefixText = chalk`{bold.green Workflow run is in progress:}`;
|
||||
spinner.text = await infoForActiveWorkflowRunAsync(graphqlClient, workflowRun, 5);
|
||||
break;
|
||||
}
|
||||
case WorkflowRunStatus.ActionRequired:
|
||||
spinner.prefixText = chalk`{bold.yellow Workflow run is waiting for action:}`;
|
||||
break;
|
||||
|
||||
case WorkflowRunStatus.Canceled:
|
||||
spinner.prefixText = chalk`{bold.yellow Workflow has been canceled.}`;
|
||||
spinner.stopAndPersist();
|
||||
return workflowRun;
|
||||
|
||||
case WorkflowRunStatus.Failure: {
|
||||
spinner.prefixText = chalk`{bold.red Workflow has failed.}`;
|
||||
const failedInfo = await infoForFailedWorkflowRunAsync(graphqlClient, workflowRun, 30);
|
||||
spinner.fail(failedInfo);
|
||||
return workflowRun;
|
||||
}
|
||||
case WorkflowRunStatus.Success:
|
||||
spinner.prefixText = chalk`{bold.green Workflow has completed successfully.}`;
|
||||
spinner.text = '';
|
||||
spinner.succeed('');
|
||||
return workflowRun;
|
||||
}
|
||||
if (!waitForCompletion) {
|
||||
if (spinner.isSpinning) {
|
||||
spinner.stopAndPersist();
|
||||
}
|
||||
return workflowRun;
|
||||
}
|
||||
} catch {
|
||||
spinner.text = '⚠ Failed to fetch the workflow run status. Check your network connection.';
|
||||
|
||||
failedFetchesCount += 1;
|
||||
|
||||
if (failedFetchesCount > 6) {
|
||||
spinner.fail('Failed to fetch the workflow run status 6 times in a row. Aborting wait.');
|
||||
process.exit(workflowRunExitCodes.WAIT_ABORTED);
|
||||
}
|
||||
}
|
||||
|
||||
await sleepAsync(10 /* seconds */ * 1000 /* milliseconds */);
|
||||
}
|
||||
}
|
||||
|
||||
export const workflowRunExitCodes = {
|
||||
WORKFLOW_FAILED: 11,
|
||||
WORKFLOW_CANCELED: 12,
|
||||
WAIT_ABORTED: 13,
|
||||
};
|
||||
@@ -0,0 +1,208 @@
|
||||
import { InvalidEasJsonError, MissingEasJsonError } from '@expo/eas-json/build/errors';
|
||||
import { CombinedError } from '@urql/core';
|
||||
import { promises as fs } from 'fs';
|
||||
import path from 'path';
|
||||
import * as YAML from 'yaml';
|
||||
|
||||
import { buildProfileNamesFromProjectAsync } from './buildProfileUtils';
|
||||
import { getExpoApiWorkflowSchemaURL } from '../../api';
|
||||
import { WorkflowRevisionMutation } from '../../graphql/mutations/WorkflowRevisionMutation';
|
||||
import Log from '../../log';
|
||||
import { createValidator, getReadableErrors } from '../../metadata/utils/ajv';
|
||||
import { WorkflowFile } from '../../utils/workflowFile';
|
||||
import { ExpoGraphqlClient } from '../context/contextUtils/createGraphqlClient';
|
||||
|
||||
const jobTypesWithBuildProfile = new Set(['build', 'repack']);
|
||||
|
||||
const buildProfileIsInterpolated = (profileName: string): boolean => {
|
||||
return profileName.includes('${{') && profileName.includes('}}');
|
||||
};
|
||||
|
||||
export async function validateWorkflowFileAsync(
|
||||
workflowFileContents: { yamlConfig: string; filePath: string },
|
||||
projectDir: string,
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
projectId: string
|
||||
): Promise<void> {
|
||||
const parsedYaml = parsedYamlFromWorkflowContents(workflowFileContents);
|
||||
Log.debug(`Parsed workflow: ${JSON.stringify(parsedYaml, null, 2)}`);
|
||||
|
||||
// Check if the parsed result is empty or null
|
||||
Log.debug(`Validating workflow is not empty...`);
|
||||
validateWorkflowIsNotEmpty(parsedYaml);
|
||||
|
||||
const workflowSchema = await fetchWorkflowSchemaAsync();
|
||||
|
||||
// Check that all job types are valid
|
||||
Log.debug(`Validating workflow job types...`);
|
||||
validateWorkflowJobTypes(parsedYaml, workflowSchema);
|
||||
|
||||
// Check for build jobs that do not match any EAS build profiles
|
||||
Log.debug(`Validating workflow build jobs...`);
|
||||
await validateWorkflowBuildJobsAsync(parsedYaml, projectDir);
|
||||
|
||||
// Check that result passes validation against workflow schema
|
||||
Log.debug(`Validating workflow structure...`);
|
||||
validateWorkflowStructure(parsedYaml, workflowSchema);
|
||||
|
||||
// Check for other errors using the server-side validation
|
||||
Log.debug(`Validating workflow on server...`);
|
||||
await validateWorkflowOnServerAsync(graphqlClient, projectId, workflowFileContents);
|
||||
}
|
||||
|
||||
export function logWorkflowValidationErrors(error: unknown): void {
|
||||
if (error instanceof MissingEasJsonError) {
|
||||
throw new Error(
|
||||
'Workflows require a valid eas.json. Please run "eas build:configure" to create it.'
|
||||
);
|
||||
} else if (error instanceof InvalidEasJsonError) {
|
||||
throw new Error(
|
||||
'Workflows require a valid eas.json. Please fix the errors in your eas.json and try again.\n\n' +
|
||||
error.message
|
||||
);
|
||||
} else if (error instanceof YAML.YAMLParseError) {
|
||||
Log.error(`YAML syntax error: ${error.message}`);
|
||||
} else if (error instanceof CombinedError) {
|
||||
WorkflowFile.maybePrintWorkflowFileValidationErrors({
|
||||
error,
|
||||
});
|
||||
|
||||
throw error;
|
||||
} else if (error instanceof Error) {
|
||||
Log.error(`Error: ${error.message}`);
|
||||
} else {
|
||||
Log.error(`Unexpected error: ${String(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
function validateWorkflowIsNotEmpty(parsedYaml: any): void {
|
||||
if (
|
||||
parsedYaml === null ||
|
||||
parsedYaml === undefined ||
|
||||
(typeof parsedYaml === 'object' && Object.keys(parsedYaml).length === 0)
|
||||
) {
|
||||
throw new Error('YAML file is empty or contains only comments.');
|
||||
}
|
||||
}
|
||||
|
||||
async function validateWorkflowOnServerAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
projectId: string,
|
||||
workflowFileContents: { yamlConfig: string }
|
||||
): Promise<void> {
|
||||
await WorkflowRevisionMutation.validateWorkflowYamlConfigAsync(graphqlClient, {
|
||||
appId: projectId,
|
||||
yamlConfig: workflowFileContents.yamlConfig,
|
||||
});
|
||||
}
|
||||
|
||||
async function validateWorkflowBuildJobsAsync(parsedYaml: any, projectDir: string): Promise<void> {
|
||||
const jobs = jobsFromWorkflow(parsedYaml);
|
||||
const buildJobs = jobs.filter(job => jobTypesWithBuildProfile.has(job.value.type));
|
||||
if (buildJobs.length === 0) {
|
||||
return;
|
||||
}
|
||||
const buildProfileNames = await buildProfileNamesFromProjectAsync(projectDir);
|
||||
|
||||
const invalidBuildJobs = buildJobs.filter(
|
||||
job =>
|
||||
!buildProfileNames.has(job.value.params.profile) &&
|
||||
// If a profile name is interpolated, we can't check if it's valid until the workflow actually runs
|
||||
!buildProfileIsInterpolated(job.value.params.profile)
|
||||
);
|
||||
|
||||
if (invalidBuildJobs.length > 0) {
|
||||
const invalidBuildProfiles = new Set(invalidBuildJobs.map(job => job.value.params.profile));
|
||||
throw new Error(
|
||||
`The build jobs in this workflow refer to the following build profiles that are not present in your eas.json file: ${[
|
||||
...invalidBuildProfiles,
|
||||
].join(', ')}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function validateWorkflowJobTypes(parsedYaml: any, workflowJsonSchema: any): void {
|
||||
const jobs = jobsFromWorkflow(parsedYaml);
|
||||
const jobTypes = jobTypesFromWorkflowSchema(workflowJsonSchema);
|
||||
const invalidJobs = jobs.filter(job => job.value.type && !jobTypes.includes(job.value.type));
|
||||
if (invalidJobs.length > 0) {
|
||||
throw new Error(
|
||||
`The following jobs have invalid types: ${invalidJobs
|
||||
.map(job => job.key)
|
||||
.join(', ')}. Valid types are: ${jobTypes.join(', ')}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function validateWorkflowStructure(parsedYaml: any, workflowJsonSchema: any): void {
|
||||
delete workflowJsonSchema['$schema'];
|
||||
|
||||
const ajv = createValidator();
|
||||
const validate = ajv.compile(workflowJsonSchema);
|
||||
const result = validate(parsedYaml);
|
||||
|
||||
if (!result) {
|
||||
Log.debug(
|
||||
JSON.stringify(
|
||||
{
|
||||
errors: validate.errors,
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
);
|
||||
const readableErrors = getReadableErrors(validate.errors ?? []);
|
||||
const processedErrors = new Set<string>();
|
||||
for (const err of readableErrors) {
|
||||
if (err.message) {
|
||||
processedErrors.add(err.message);
|
||||
}
|
||||
}
|
||||
throw new Error([...processedErrors].join('\n'));
|
||||
}
|
||||
}
|
||||
|
||||
export function parsedYamlFromWorkflowContents(workflowFileContents: { yamlConfig: string }): any {
|
||||
const parsedYaml = YAML.parse(workflowFileContents.yamlConfig);
|
||||
return parsedYaml;
|
||||
}
|
||||
|
||||
export function workflowContentsFromParsedYaml(parsedYaml: any): string {
|
||||
return YAML.stringify(parsedYaml);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type
|
||||
async function fetchWorkflowSchemaAsync(): Promise<any> {
|
||||
// EXPO_TESTING_WORKFLOW_SCHEMA_PATH is used only for testing against a different schema
|
||||
if (process.env.EXPO_TESTING_WORKFLOW_SCHEMA_PATH) {
|
||||
const schemaPath = path.resolve(process.env.EXPO_TESTING_WORKFLOW_SCHEMA_PATH);
|
||||
Log.debug(`Loading workflow schema from ${schemaPath}`);
|
||||
const jsonString = await fs.readFile(schemaPath, 'utf-8');
|
||||
const jsonFromFile = JSON.parse(jsonString);
|
||||
return jsonFromFile.data;
|
||||
}
|
||||
// Otherwise, we fetch from <ApiBaseUrl>/v2/workflows/schema
|
||||
const schemaUrl = getExpoApiWorkflowSchemaURL();
|
||||
Log.debug(`Fetching workflow schema from ${schemaUrl}`);
|
||||
const response = await fetch(schemaUrl);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Unable to fetch EAS Workflow schema, received status: ${response.status}`);
|
||||
}
|
||||
const jsonResponse: any = (await response.json()) as any;
|
||||
return jsonResponse.data;
|
||||
}
|
||||
|
||||
function jobsFromWorkflow(parsedYaml: any): any[] {
|
||||
return Object.entries(parsedYaml?.jobs).flatMap(([key, value]: [string, any]) => {
|
||||
return {
|
||||
key,
|
||||
value,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function jobTypesFromWorkflowSchema(workflowJsonSchema: any): string[] {
|
||||
return workflowJsonSchema?.properties?.jobs?.additionalProperties?.anyOf.map(
|
||||
(props: any) => props.properties.type.const
|
||||
);
|
||||
}
|
||||
@@ -1,23 +1,19 @@
|
||||
import chalk from 'chalk';
|
||||
import gql from 'graphql-tag';
|
||||
|
||||
import { scheduleBranchDeletionAsync } from '../../branch/delete';
|
||||
import { selectBranchOnAppAsync } from '../../branch/queries';
|
||||
import EasCommand from '../../commandUtils/EasCommand';
|
||||
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { EasNonInteractiveAndJsonFlags } from '../../commandUtils/flags';
|
||||
import { getPaginatedQueryOptions } from '../../commandUtils/pagination';
|
||||
import { withErrorHandlingAsync } from '../../graphql/client';
|
||||
import {
|
||||
DeleteUpdateBranchMutation,
|
||||
DeleteUpdateBranchMutationVariables,
|
||||
DeleteUpdateBranchResult,
|
||||
GetBranchInfoQuery,
|
||||
GetBranchInfoQueryVariables,
|
||||
} from '../../graphql/generated';
|
||||
import { GetBranchInfoQuery, GetBranchInfoQueryVariables } from '../../graphql/generated';
|
||||
import Log from '../../log';
|
||||
import { getDisplayNameForProjectIdAsync } from '../../project/projectUtils';
|
||||
import { toggleConfirmAsync } from '../../prompts';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
|
||||
import { pollForBackgroundJobReceiptAsync } from '../../utils/pollForBackgroundJobReceiptAsync';
|
||||
|
||||
async function getBranchInfoAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
@@ -50,31 +46,6 @@ async function getBranchInfoAsync(
|
||||
return data;
|
||||
}
|
||||
|
||||
async function deleteBranchOnAppAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
{ branchId }: DeleteUpdateBranchMutationVariables
|
||||
): Promise<DeleteUpdateBranchResult> {
|
||||
const data = await withErrorHandlingAsync(
|
||||
graphqlClient
|
||||
.mutation<DeleteUpdateBranchMutation, DeleteUpdateBranchMutationVariables>(
|
||||
gql`
|
||||
mutation DeleteUpdateBranch($branchId: ID!) {
|
||||
updateBranch {
|
||||
deleteUpdateBranch(branchId: $branchId) {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
{
|
||||
branchId,
|
||||
}
|
||||
)
|
||||
.toPromise()
|
||||
);
|
||||
return data.updateBranch.deleteUpdateBranch;
|
||||
}
|
||||
|
||||
export default class BranchDelete extends EasCommand {
|
||||
static override description = 'delete a branch';
|
||||
|
||||
@@ -146,12 +117,12 @@ export default class BranchDelete extends EasCommand {
|
||||
}
|
||||
}
|
||||
|
||||
const deletionResult = await deleteBranchOnAppAsync(graphqlClient, {
|
||||
branchId,
|
||||
});
|
||||
const receipt = await scheduleBranchDeletionAsync(graphqlClient, { branchId });
|
||||
const successfulReceipt = await pollForBackgroundJobReceiptAsync(graphqlClient, receipt);
|
||||
Log.debug('Deletion result', { successfulReceipt });
|
||||
|
||||
if (jsonFlag) {
|
||||
printJsonOnlyOutput(deletionResult);
|
||||
printJsonOnlyOutput({ id: branchId });
|
||||
} else {
|
||||
Log.withTick(
|
||||
`️Deleted branch "${branchName}" and all of its updates on project ${chalk.bold(
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Platform, Workflow } from '@expo/eas-build-job';
|
||||
import { EasJson, EasJsonAccessor, EasJsonUtils } from '@expo/eas-json';
|
||||
import { Flags } from '@oclif/core';
|
||||
import chalk from 'chalk';
|
||||
|
||||
@@ -9,10 +10,10 @@ import Log, { learnMore } from '../../log';
|
||||
import { RequestedPlatform } from '../../platform';
|
||||
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 } from '../../update/configure';
|
||||
import { syncUpdatesConfigurationAsync as syncIosUpdatesConfigurationAsync } from '../../update/ios/UpdatesModule';
|
||||
import { promptPlatformAsync } from '../../utils/prompts';
|
||||
|
||||
export default class BuildConfigure extends EasCommand {
|
||||
static override description = 'configure the project to support EAS Build';
|
||||
@@ -66,8 +67,13 @@ export default class BuildConfigure extends EasCommand {
|
||||
nonInteractive: false,
|
||||
vcsClient,
|
||||
});
|
||||
if (didCreateEasJson && isUsingEASUpdate(exp, projectId)) {
|
||||
await ensureEASUpdateIsConfiguredInEasJsonAsync(projectDir);
|
||||
if (didCreateEasJson) {
|
||||
const easJsonAccessor = EasJsonAccessor.fromProjectPath(projectDir);
|
||||
const easJsonCliConfig: EasJson['cli'] =
|
||||
(await EasJsonUtils.getCliConfigAsync(easJsonAccessor)) ?? {};
|
||||
if (isUsingEASUpdate(exp, projectId, easJsonCliConfig.updateManifestHostOverride ?? null)) {
|
||||
await ensureEASUpdateIsConfiguredInEasJsonAsync(projectDir);
|
||||
}
|
||||
}
|
||||
|
||||
// configure expo-updates
|
||||
@@ -107,24 +113,7 @@ export default class BuildConfigure extends EasCommand {
|
||||
|
||||
async function promptForPlatformAsync(): Promise<RequestedPlatform> {
|
||||
Log.addNewLineIfNone();
|
||||
const { platform } = await promptAsync({
|
||||
type: 'select',
|
||||
return await promptPlatformAsync({
|
||||
message: 'Which platforms would you like to configure for EAS Build?',
|
||||
name: 'platform',
|
||||
choices: [
|
||||
{
|
||||
title: 'All',
|
||||
value: RequestedPlatform.All,
|
||||
},
|
||||
{
|
||||
title: 'iOS',
|
||||
value: RequestedPlatform.Ios,
|
||||
},
|
||||
{
|
||||
title: 'Android',
|
||||
value: RequestedPlatform.Android,
|
||||
},
|
||||
],
|
||||
});
|
||||
return platform;
|
||||
}
|
||||
@@ -0,0 +1,355 @@
|
||||
import { Platform } from '@expo/eas-build-job';
|
||||
import { BuildProfile, EasJsonAccessor } from '@expo/eas-json';
|
||||
import { Errors, Flags } from '@oclif/core';
|
||||
import chalk from 'chalk';
|
||||
|
||||
import {
|
||||
createBuildProfileAsync,
|
||||
doesBuildProfileExistAsync,
|
||||
ensureProjectConfiguredAsync,
|
||||
} from '../../build/configure';
|
||||
import { evaluateConfigWithEnvVarsAsync } from '../../build/evaluateConfigWithEnvVarsAsync';
|
||||
import { downloadAndRunAsync, runBuildAndSubmitAsync } from '../../build/runBuildAndSubmit';
|
||||
import { ensureRepoIsCleanAsync } from '../../build/utils/repository';
|
||||
import EasCommand from '../../commandUtils/EasCommand';
|
||||
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { createFingerprintAsync } from '../../fingerprint/cli';
|
||||
import { BuildFragment, BuildStatus, DistributionType } from '../../graphql/generated';
|
||||
import { BuildQuery } from '../../graphql/queries/BuildQuery';
|
||||
import { toAppPlatform } from '../../graphql/types/AppPlatform';
|
||||
import Log from '../../log';
|
||||
import { RequestedPlatform } from '../../platform';
|
||||
import { resolveWorkflowAsync } from '../../project/workflow';
|
||||
import { confirmAsync, promptAsync } from '../../prompts';
|
||||
import { expoCommandAsync } from '../../utils/expoCli';
|
||||
import { ProfileData, getProfilesAsync } from '../../utils/profiles';
|
||||
import { Client } from '../../vcs/vcs';
|
||||
|
||||
const DEFAULT_EAS_BUILD_RUN_PROFILE_NAME = 'development-simulator';
|
||||
|
||||
export default class BuildDev extends EasCommand {
|
||||
static override hidden: true;
|
||||
|
||||
static override description =
|
||||
'run dev client simulator/emulator build with matching fingerprint or create a new one';
|
||||
|
||||
static override flags = {
|
||||
platform: Flags.enum<Platform.IOS | Platform.ANDROID>({
|
||||
char: 'p',
|
||||
options: [Platform.IOS, Platform.ANDROID],
|
||||
}),
|
||||
profile: Flags.string({
|
||||
char: 'e',
|
||||
description: `Name of the build profile from eas.json. It must be a profile allowing to create emulator/simulator internal distribution dev client builds. The "${DEFAULT_EAS_BUILD_RUN_PROFILE_NAME}" build profile will be selected by default.`,
|
||||
helpValue: 'PROFILE_NAME',
|
||||
}),
|
||||
};
|
||||
|
||||
static override contextDefinition = {
|
||||
...this.ContextOptions.LoggedIn,
|
||||
...this.ContextOptions.DynamicProjectConfig,
|
||||
...this.ContextOptions.ProjectDir,
|
||||
...this.ContextOptions.Vcs,
|
||||
...this.ContextOptions.Analytics,
|
||||
...this.ContextOptions.ProjectId,
|
||||
};
|
||||
|
||||
protected override async runAsync(): Promise<any> {
|
||||
const { flags } = await this.parse(BuildDev);
|
||||
|
||||
const {
|
||||
loggedIn: { actor, graphqlClient },
|
||||
getDynamicPrivateProjectConfigAsync,
|
||||
projectDir,
|
||||
analytics,
|
||||
vcsClient,
|
||||
projectId,
|
||||
} = await this.getContextAsync(BuildDev, {
|
||||
nonInteractive: false,
|
||||
withServerSideEnvironment: null,
|
||||
});
|
||||
|
||||
const platform = await this.selectPlatformAsync(flags.platform);
|
||||
if (process.platform !== 'darwin' && platform === Platform.IOS) {
|
||||
Errors.error('Running iOS builds in simulator is only supported on macOS.', { exit: 1 });
|
||||
}
|
||||
|
||||
await vcsClient.ensureRepoExistsAsync();
|
||||
await ensureRepoIsCleanAsync(vcsClient, flags.nonInteractive);
|
||||
await ensureProjectConfiguredAsync({
|
||||
projectDir,
|
||||
nonInteractive: false,
|
||||
vcsClient,
|
||||
});
|
||||
|
||||
const buildProfile = await this.ensureValidBuildRunProfileExistsAsync({
|
||||
projectDir,
|
||||
platform,
|
||||
selectedBuildProfileName: flags.profile,
|
||||
vcsClient,
|
||||
});
|
||||
|
||||
const workflow = await resolveWorkflowAsync(projectDir, platform, vcsClient);
|
||||
|
||||
const { env } = await evaluateConfigWithEnvVarsAsync({
|
||||
buildProfile: buildProfile.profile,
|
||||
buildProfileName: buildProfile.profileName,
|
||||
graphqlClient,
|
||||
getProjectConfig: getDynamicPrivateProjectConfigAsync,
|
||||
opts: { env: buildProfile.profile.env },
|
||||
});
|
||||
|
||||
const fingerprint = await createFingerprintAsync(projectDir, {
|
||||
env,
|
||||
workflow,
|
||||
platforms: [platform],
|
||||
});
|
||||
if (!fingerprint) {
|
||||
Errors.error('Failed to calculate fingerprint', { exit: 1 });
|
||||
}
|
||||
Log.log(`✨ Calculated fingerprint hash: ${fingerprint.hash}`);
|
||||
Log.newLine();
|
||||
|
||||
const builds = await this.getBuildsAsync({
|
||||
graphqlClient,
|
||||
projectId,
|
||||
platform,
|
||||
fingerprint,
|
||||
profile: buildProfile.profileName,
|
||||
});
|
||||
if (builds.length !== 0) {
|
||||
const build = builds[0];
|
||||
Log.succeed(
|
||||
`🎯 Found successful build with matching fingerprint on EAS servers. Running it...`
|
||||
);
|
||||
|
||||
if (build.artifacts?.applicationArchiveUrl) {
|
||||
await downloadAndRunAsync(build);
|
||||
await this.startDevServerAsync({ projectDir, platform });
|
||||
return;
|
||||
} else {
|
||||
Log.warn('Artifacts for this build expired. New build will be started.');
|
||||
}
|
||||
}
|
||||
|
||||
Log.log('🚀 No successful build with matching fingerprint found. Starting a new build...');
|
||||
|
||||
const previousBuildsForSelectedProfile = await this.getBuildsAsync({
|
||||
graphqlClient,
|
||||
projectId,
|
||||
platform,
|
||||
profile: buildProfile.profileName,
|
||||
});
|
||||
if (
|
||||
previousBuildsForSelectedProfile.length > 0 &&
|
||||
previousBuildsForSelectedProfile[0].metrics?.buildDuration
|
||||
) {
|
||||
Log.log(
|
||||
`🕒 Previous build for "${buildProfile.profileName}" profile completed in ${Math.floor(
|
||||
previousBuildsForSelectedProfile[0].metrics.buildDuration / 60000
|
||||
)} minutes.`
|
||||
);
|
||||
}
|
||||
|
||||
await runBuildAndSubmitAsync({
|
||||
graphqlClient,
|
||||
analytics,
|
||||
vcsClient,
|
||||
projectDir,
|
||||
flags: {
|
||||
requestedPlatform:
|
||||
platform === Platform.ANDROID ? RequestedPlatform.Android : RequestedPlatform.Ios,
|
||||
nonInteractive: false,
|
||||
freezeCredentials: false,
|
||||
wait: true,
|
||||
clearCache: false,
|
||||
json: false,
|
||||
autoSubmit: false,
|
||||
localBuildOptions: {},
|
||||
profile: flags.profile ?? DEFAULT_EAS_BUILD_RUN_PROFILE_NAME,
|
||||
},
|
||||
actor,
|
||||
getDynamicPrivateProjectConfigAsync,
|
||||
downloadSimBuildAutoConfirm: true,
|
||||
envOverride: env,
|
||||
});
|
||||
await this.startDevServerAsync({ projectDir, platform });
|
||||
}
|
||||
|
||||
private async selectPlatformAsync(platform?: Platform): Promise<Platform> {
|
||||
if (platform) {
|
||||
return platform;
|
||||
}
|
||||
const { resolvedPlatform } = await promptAsync({
|
||||
type: 'select',
|
||||
message: 'Select platform',
|
||||
name: 'resolvedPlatform',
|
||||
choices: [
|
||||
{ title: 'Android', value: Platform.ANDROID },
|
||||
{ title: 'iOS', value: Platform.IOS },
|
||||
],
|
||||
});
|
||||
return resolvedPlatform;
|
||||
}
|
||||
|
||||
private async validateBuildRunProfileAsync({
|
||||
platform,
|
||||
buildProfile,
|
||||
buildProfileName,
|
||||
}: {
|
||||
platform: Platform;
|
||||
buildProfile: BuildProfile;
|
||||
buildProfileName: string;
|
||||
}): Promise<void> {
|
||||
if (buildProfile.developmentClient !== true) {
|
||||
Errors.error(
|
||||
`Profile "${buildProfileName}" must specify "developmentClient: true" to create a dev client build. Select a different profile or update the profile in eas.json.`,
|
||||
{ exit: 1 }
|
||||
);
|
||||
}
|
||||
if (buildProfile.distribution !== 'internal') {
|
||||
Errors.error(
|
||||
`Profile "${buildProfileName}" must specify "distribution: internal" in order to work with eas build:dev command. Select a different profile or update the profile in eas.json.`,
|
||||
{ exit: 1 }
|
||||
);
|
||||
}
|
||||
|
||||
if (platform === Platform.IOS) {
|
||||
const iosProfile = buildProfile as BuildProfile<Platform.IOS>;
|
||||
if (iosProfile.simulator !== true && iosProfile.withoutCredentials !== true) {
|
||||
Errors.error(
|
||||
`Profile "${buildProfileName}" must specify "ios.simulator: true" or "withoutCredentials: true" to create an iOS simulator build. Select a different profile or update the profile in eas.json.`,
|
||||
{ exit: 1 }
|
||||
);
|
||||
}
|
||||
} else {
|
||||
const androidProfile = buildProfile as BuildProfile<Platform.ANDROID>;
|
||||
if (
|
||||
androidProfile.distribution !== 'internal' &&
|
||||
androidProfile.withoutCredentials !== true
|
||||
) {
|
||||
Errors.error(
|
||||
`Profile "${buildProfileName}" must specify "distribution: internal" or "withoutCredentials: true" to create an Android emulator build. Select a different profile or update the profile in eas.json.`,
|
||||
{ exit: 1 }
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async ensureValidBuildRunProfileExistsAsync({
|
||||
projectDir,
|
||||
platform,
|
||||
selectedBuildProfileName,
|
||||
vcsClient,
|
||||
}: {
|
||||
projectDir: string;
|
||||
platform: Platform;
|
||||
selectedBuildProfileName?: string;
|
||||
vcsClient: Client;
|
||||
}): Promise<ProfileData<'build'>> {
|
||||
if (
|
||||
!!selectedBuildProfileName ||
|
||||
(await doesBuildProfileExistAsync({
|
||||
projectDir,
|
||||
profileName: DEFAULT_EAS_BUILD_RUN_PROFILE_NAME,
|
||||
}))
|
||||
) {
|
||||
const easJsonAccessor = EasJsonAccessor.fromProjectPath(projectDir);
|
||||
const [buildProfile] = await getProfilesAsync({
|
||||
type: 'build',
|
||||
easJsonAccessor,
|
||||
platforms: [platform],
|
||||
profileName: selectedBuildProfileName ?? DEFAULT_EAS_BUILD_RUN_PROFILE_NAME,
|
||||
projectDir,
|
||||
});
|
||||
await this.validateBuildRunProfileAsync({
|
||||
buildProfileName: selectedBuildProfileName ?? DEFAULT_EAS_BUILD_RUN_PROFILE_NAME,
|
||||
platform,
|
||||
buildProfile: buildProfile.profile,
|
||||
});
|
||||
} else {
|
||||
const createBuildProfile = await confirmAsync({
|
||||
message: `We want to go ahead and generate "${DEFAULT_EAS_BUILD_RUN_PROFILE_NAME}" build profile for you, that matches eas build:dev criteria. Do you want to proceed?`,
|
||||
});
|
||||
if (!createBuildProfile) {
|
||||
Errors.error(
|
||||
'Come back later or specify different build compliant with eas build:dev requirements by using "--profile" flag.',
|
||||
{ exit: 1 }
|
||||
);
|
||||
}
|
||||
await createBuildProfileAsync({
|
||||
projectDir,
|
||||
profileName: DEFAULT_EAS_BUILD_RUN_PROFILE_NAME,
|
||||
profileContents: {
|
||||
developmentClient: true,
|
||||
distribution: 'internal',
|
||||
ios: {
|
||||
simulator: true,
|
||||
},
|
||||
environment: 'development',
|
||||
},
|
||||
nonInteractive: false,
|
||||
vcsClient,
|
||||
});
|
||||
}
|
||||
|
||||
const easJsonAccessor = EasJsonAccessor.fromProjectPath(projectDir);
|
||||
const [buildProfile] = await getProfilesAsync({
|
||||
type: 'build',
|
||||
easJsonAccessor,
|
||||
platforms: [platform],
|
||||
profileName: selectedBuildProfileName ?? DEFAULT_EAS_BUILD_RUN_PROFILE_NAME,
|
||||
projectDir,
|
||||
});
|
||||
return buildProfile;
|
||||
}
|
||||
|
||||
private async getBuildsAsync({
|
||||
graphqlClient,
|
||||
projectId,
|
||||
platform,
|
||||
fingerprint,
|
||||
profile,
|
||||
}: {
|
||||
graphqlClient: ExpoGraphqlClient;
|
||||
projectId: string;
|
||||
platform: Platform;
|
||||
fingerprint?: { hash: string };
|
||||
profile?: string;
|
||||
}): Promise<BuildFragment[]> {
|
||||
return await BuildQuery.viewBuildsOnAppAsync(graphqlClient, {
|
||||
appId: projectId,
|
||||
filter: {
|
||||
platform: toAppPlatform(platform),
|
||||
fingerprintHash: fingerprint?.hash,
|
||||
status: BuildStatus.Finished,
|
||||
simulator: platform === Platform.IOS ? true : undefined,
|
||||
distribution: platform === Platform.ANDROID ? DistributionType.Internal : undefined,
|
||||
developmentClient: true,
|
||||
buildProfile: profile,
|
||||
},
|
||||
offset: 0,
|
||||
limit: 1,
|
||||
});
|
||||
}
|
||||
|
||||
private async startDevServerAsync({
|
||||
projectDir,
|
||||
platform,
|
||||
}: {
|
||||
projectDir: string;
|
||||
platform: Platform;
|
||||
}): Promise<void> {
|
||||
Log.newLine();
|
||||
Log.log(
|
||||
`Starting development server: ${chalk.dim(
|
||||
`npx expo start --dev-client ${platform === Platform.IOS ? '--ios' : '--android'}`
|
||||
)}`
|
||||
);
|
||||
await expoCommandAsync(projectDir, [
|
||||
'start',
|
||||
'--dev-client',
|
||||
platform === Platform.IOS ? '--ios' : '--android',
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
import { Platform } from '@expo/eas-build-job';
|
||||
import { Errors, Flags } from '@oclif/core';
|
||||
import chalk from 'chalk';
|
||||
import { pathExists } from 'fs-extra';
|
||||
|
||||
import EasCommand from '../../commandUtils/EasCommand';
|
||||
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { EasNonInteractiveAndJsonFlags } from '../../commandUtils/flags';
|
||||
import { AppPlatform, BuildFragment, BuildStatus, DistributionType } from '../../graphql/generated';
|
||||
import { BuildQuery } from '../../graphql/queries/BuildQuery';
|
||||
import { toAppPlatform } from '../../graphql/types/AppPlatform';
|
||||
import Log from '../../log';
|
||||
import { promptAsync } from '../../prompts';
|
||||
import { getEasBuildRunCachedAppPath } from '../../run/run';
|
||||
import { downloadAndMaybeExtractAppAsync } from '../../utils/download';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
|
||||
|
||||
export default class Download extends EasCommand {
|
||||
static override description = 'download simulator/emulator builds for a given fingerprint hash';
|
||||
|
||||
static override flags = {
|
||||
fingerprint: Flags.string({
|
||||
description: 'Fingerprint hash of the build to download',
|
||||
required: true,
|
||||
}),
|
||||
platform: Flags.enum<Platform.IOS | Platform.ANDROID>({
|
||||
char: 'p',
|
||||
options: [Platform.IOS, Platform.ANDROID],
|
||||
}),
|
||||
'dev-client': Flags.boolean({
|
||||
description: 'Filter only dev-client builds.',
|
||||
allowNo: true,
|
||||
}),
|
||||
...EasNonInteractiveAndJsonFlags,
|
||||
};
|
||||
|
||||
static override contextDefinition = {
|
||||
...this.ContextOptions.LoggedIn,
|
||||
...this.ContextOptions.ProjectId,
|
||||
};
|
||||
|
||||
async runAsync(): Promise<void> {
|
||||
const {
|
||||
flags: {
|
||||
json: jsonFlag,
|
||||
platform,
|
||||
fingerprint,
|
||||
'dev-client': developmentClient,
|
||||
'non-interactive': nonInteractive,
|
||||
},
|
||||
} = await this.parse(Download);
|
||||
|
||||
const {
|
||||
loggedIn: { graphqlClient },
|
||||
projectId,
|
||||
} = await this.getContextAsync(Download, {
|
||||
nonInteractive,
|
||||
});
|
||||
|
||||
if (jsonFlag) {
|
||||
enableJsonOutput();
|
||||
}
|
||||
|
||||
const selectedPlatform = await resolvePlatformAsync({ nonInteractive, platform });
|
||||
const build = await this.getBuildAsync({
|
||||
graphqlClient,
|
||||
projectId,
|
||||
platform: selectedPlatform,
|
||||
fingerprintHash: fingerprint,
|
||||
developmentClient,
|
||||
});
|
||||
const buildArtifactPath = await this.getPathToBuildArtifactAsync(build, selectedPlatform);
|
||||
if (jsonFlag) {
|
||||
const jsonResults = { path: buildArtifactPath };
|
||||
printJsonOnlyOutput(jsonResults);
|
||||
} else {
|
||||
Log.log(`Build downloaded to ${chalk.bold(buildArtifactPath)}`);
|
||||
}
|
||||
}
|
||||
|
||||
private async getBuildAsync({
|
||||
graphqlClient,
|
||||
projectId,
|
||||
platform,
|
||||
fingerprintHash,
|
||||
developmentClient,
|
||||
}: {
|
||||
graphqlClient: ExpoGraphqlClient;
|
||||
projectId: string;
|
||||
platform: AppPlatform;
|
||||
fingerprintHash: string;
|
||||
developmentClient: boolean;
|
||||
}): Promise<BuildFragment> {
|
||||
const builds = await BuildQuery.viewBuildsOnAppAsync(graphqlClient, {
|
||||
appId: projectId,
|
||||
filter: {
|
||||
platform,
|
||||
fingerprintHash,
|
||||
status: BuildStatus.Finished,
|
||||
simulator: platform === AppPlatform.Ios ? true : undefined,
|
||||
distribution: platform === AppPlatform.Android ? DistributionType.Internal : undefined,
|
||||
developmentClient,
|
||||
},
|
||||
offset: 0,
|
||||
limit: 1,
|
||||
});
|
||||
if (builds.length === 0) {
|
||||
throw new Errors.CLIError(
|
||||
`No builds available for ${platform} with fingerprint hash ${fingerprintHash}`
|
||||
);
|
||||
}
|
||||
|
||||
Log.succeed(`🎯 Found successful build with matching fingerprint on EAS servers.`);
|
||||
return builds[0];
|
||||
}
|
||||
|
||||
async getPathToBuildArtifactAsync(build: BuildFragment, platform: AppPlatform): Promise<string> {
|
||||
const cachedBuildArtifactPath = getEasBuildRunCachedAppPath(
|
||||
build.project.id,
|
||||
build.id,
|
||||
platform
|
||||
);
|
||||
if (await pathExists(cachedBuildArtifactPath)) {
|
||||
Log.newLine();
|
||||
Log.log(`Using cached build...`);
|
||||
return cachedBuildArtifactPath;
|
||||
}
|
||||
|
||||
if (!build.artifacts?.applicationArchiveUrl) {
|
||||
throw new Error('Build does not have an application archive url');
|
||||
}
|
||||
|
||||
return await downloadAndMaybeExtractAppAsync(
|
||||
build.artifacts.applicationArchiveUrl,
|
||||
platform,
|
||||
cachedBuildArtifactPath
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function resolvePlatformAsync({
|
||||
nonInteractive,
|
||||
platform,
|
||||
}: {
|
||||
nonInteractive: boolean;
|
||||
platform?: Platform;
|
||||
}): Promise<AppPlatform> {
|
||||
if (nonInteractive && !platform) {
|
||||
throw new Error('Platform must be provided in non-interactive mode');
|
||||
}
|
||||
|
||||
if (platform) {
|
||||
return toAppPlatform(platform);
|
||||
}
|
||||
|
||||
const { selectedPlatform } = await promptAsync({
|
||||
type: 'select',
|
||||
message: 'Select platform',
|
||||
name: 'selectedPlatform',
|
||||
choices: [
|
||||
{ title: 'Android', value: AppPlatform.Android },
|
||||
{ title: 'iOS', value: AppPlatform.Ios },
|
||||
],
|
||||
});
|
||||
return selectedPlatform;
|
||||
}
|
||||
Loaded 100 of 306 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user