mirror of
https://github.com/expo/eas-cli.git
synced 2026-11-13 20:28:08 -10:00
Compare commits
581
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
819f0b4c6f | ||
|
|
9ffd3a096b | ||
|
|
e3ff26e1ca | ||
|
|
bbd26a8030 | ||
|
|
57dd372957 | ||
|
|
5232641217 | ||
|
|
3c3d3150c9 | ||
|
|
fd322d4189 | ||
|
|
77254f6be1 | ||
|
|
44fcbbaf17 | ||
|
|
db841b45ee | ||
|
|
568669d384 | ||
|
|
aa50bf11ce | ||
|
|
119133360b | ||
|
|
3f0f25dd46 | ||
|
|
e821ae95e1 | ||
|
|
f4e51b4fbc | ||
|
|
963bea9b03 | ||
|
|
9bf2020b2b | ||
|
|
50e695b5a3 | ||
|
|
f3577deac4 | ||
|
|
f11c0b4d34 | ||
|
|
88411e8fc0 | ||
|
|
c199173704 | ||
|
|
800098b958 | ||
|
|
146468914f | ||
|
|
0e48417983 | ||
|
|
cf61f585e6 | ||
|
|
cc700ae263 | ||
|
|
4e94c60c7f | ||
|
|
37f8e117e1 | ||
|
|
46095d2da4 | ||
|
|
6c01856617 | ||
|
|
f8cd50e0ed | ||
|
|
fd0a129a4f | ||
|
|
c44e07bfeb | ||
|
|
951583e6ea | ||
|
|
3f9247ca84 | ||
|
|
c38638e94e | ||
|
|
3b46466a9d | ||
|
|
806085cbf1 | ||
|
|
398b41cc81 | ||
|
|
d23a97238b | ||
|
|
5b6f312ccb | ||
|
|
2bcab0f2b4 | ||
|
|
b90ce63067 | ||
|
|
be02aced9d | ||
|
|
f788b081c6 | ||
|
|
654f973d0e | ||
|
|
2e614cbde9 | ||
|
|
b7150163d9 | ||
|
|
0922121e84 | ||
|
|
4ace7d20fb | ||
|
|
4ae9aed8f6 | ||
|
|
54d6079406 | ||
|
|
f08adad1e7 | ||
|
|
346e47448a | ||
|
|
b0fde7b465 | ||
|
|
d42efadf51 | ||
|
|
e302390324 | ||
|
|
3a239f8cf9 | ||
|
|
b5bc8a8f6e | ||
|
|
6cbff89b75 | ||
|
|
118c008917 | ||
|
|
3b24d37f3c | ||
|
|
df425201b3 | ||
|
|
9636507130 | ||
|
|
2dd50ad21a | ||
|
|
30441b3db4 | ||
|
|
789c314a8d | ||
|
|
4760314ddf | ||
|
|
c8e57f000c | ||
|
|
3c82a2f8a8 | ||
|
|
0e7e926cec | ||
|
|
30364fd679 | ||
|
|
267fa0e43b | ||
|
|
e154f19751 | ||
|
|
4bca79f828 | ||
|
|
7e01661f70 | ||
|
|
ab18e3e27e | ||
|
|
3e7a112097 | ||
|
|
4cd6b8d228 | ||
|
|
400244194b | ||
|
|
dcf3a2d4fd | ||
|
|
0464ac27cd | ||
|
|
e0d2126f0a | ||
|
|
bd46da748d | ||
|
|
d8c68561c5 | ||
|
|
dfc314d5be | ||
|
|
2285d5771a | ||
|
|
aa22163688 | ||
|
|
5e5395b0c6 | ||
|
|
c94a1e7d9c | ||
|
|
aefadf9376 | ||
|
|
7acea869ff | ||
|
|
02af3f587f | ||
|
|
0d0da1a47a | ||
|
|
eec0b88258 | ||
|
|
98be401942 | ||
|
|
8c2689a27b | ||
|
|
1b34118d85 | ||
|
|
0f75fe407e | ||
|
|
efb7e534f4 | ||
|
|
53f89c0df1 | ||
|
|
1bbe7dfb72 | ||
|
|
da4b56a58d | ||
|
|
1080600af6 | ||
|
|
dcb6713f12 | ||
|
|
24a1d2d581 | ||
|
|
8af88c2d29 | ||
|
|
d416ac2162 | ||
|
|
7bf0493a4d | ||
|
|
91f32cc377 | ||
|
|
cf54f62bac | ||
|
|
3ef3e8447c | ||
|
|
ad64e1afad | ||
|
|
a0800cf7ef | ||
|
|
34436ee674 | ||
|
|
1072b75927 | ||
|
|
c0c9d2e7df | ||
|
|
19b831667f | ||
|
|
09db7da23b | ||
|
|
f08dde2469 | ||
|
|
213b5d240c | ||
|
|
16ebdce913 | ||
|
|
121ff7a718 | ||
|
|
360393f647 | ||
|
|
82d66054dc | ||
|
|
d8849dbf8c | ||
|
|
4d6b5f435b | ||
|
|
8fe7dfea29 | ||
|
|
8750155c25 | ||
|
|
a0fce1c773 | ||
|
|
cad3dca5df | ||
|
|
2ef6e79395 | ||
|
|
44270814a7 | ||
|
|
95155681db | ||
|
|
53fd599d0a | ||
|
|
b51201d010 | ||
|
|
f86c079733 | ||
|
|
843ec7407b | ||
|
|
537cd7872a | ||
|
|
007c40bed4 | ||
|
|
ba9f8d8ddd | ||
|
|
e4bd933e81 | ||
|
|
a6434bf6b8 | ||
|
|
19bad84109 | ||
|
|
5d9f8f9d76 | ||
|
|
d9d884d211 | ||
|
|
f902bb845b | ||
|
|
03c76c1e0e | ||
|
|
1d0d329cdb | ||
|
|
7ed1efb7d6 | ||
|
|
b7bfec0366 | ||
|
|
a5308f0d37 | ||
|
|
3ad644bb9d | ||
|
|
233e5451a8 | ||
|
|
ce954cdecc | ||
|
|
59bd1caf98 | ||
|
|
86877fdda3 | ||
|
|
9299c9b7cc | ||
|
|
64fb2e5b3a | ||
|
|
91fdbac773 | ||
|
|
507eadb914 | ||
|
|
f1cde7a291 | ||
|
|
8b9ad47d16 | ||
|
|
192984b47d | ||
|
|
2c36810ce7 | ||
|
|
bfa63f95fe | ||
|
|
c414b9f975 | ||
|
|
6946a085cb | ||
|
|
317d2fcbb6 | ||
|
|
dd61b4bb70 | ||
|
|
80fbf7cf62 | ||
|
|
26b6a17160 | ||
|
|
7f4e2eef95 | ||
|
|
88b683655c | ||
|
|
f3aa3d98d6 | ||
|
|
f852b9329d | ||
|
|
a2340531b0 | ||
|
|
39df3a1364 | ||
|
|
2f1d454582 | ||
|
|
e2560ea867 | ||
|
|
346d136b8b | ||
|
|
dd77312510 | ||
|
|
120a965368 | ||
|
|
050336b6fd | ||
|
|
7fc3e3fcdb | ||
|
|
2d117e5b4a | ||
|
|
131377bd2c | ||
|
|
ed61b0c41b | ||
|
|
9f4d3bfc56 | ||
|
|
306e7b1d71 | ||
|
|
a2b73844f0 | ||
|
|
ebbe786cd0 | ||
|
|
190172f06c | ||
|
|
2232a5ed59 | ||
|
|
ef3e9079e5 | ||
|
|
ec10eff1e6 | ||
|
|
3e3ec87622 | ||
|
|
f1cf92d63a | ||
|
|
7096daa116 | ||
|
|
6429e70a9a | ||
|
|
083381da82 | ||
|
|
1c1af2da86 | ||
|
|
72ce75e1ee | ||
|
|
ea553a1025 | ||
|
|
3a6bcb3f2e | ||
|
|
8b27255ce2 | ||
|
|
1f0ae27ea0 | ||
|
|
62d26ca32b | ||
|
|
3aebb54d11 | ||
|
|
0bf42e4cd4 | ||
|
|
ec4ef42f3c | ||
|
|
a70222d35d | ||
|
|
d20db0fff4 | ||
|
|
00ef6087f8 | ||
|
|
9c74c5c6b8 | ||
|
|
719febc519 | ||
|
|
9d9c99c0f6 | ||
|
|
e42031eaef | ||
|
|
9ec43bce84 | ||
|
|
488c3b3cb8 | ||
|
|
f5ac3d26a0 | ||
|
|
6be86d5119 | ||
|
|
04025802ac | ||
|
|
0a4c8785e6 | ||
|
|
1de239b6ba | ||
|
|
4515d29907 | ||
|
|
7591cac883 | ||
|
|
9765f75a7d | ||
|
|
1f1b58db82 | ||
|
|
b0c88a37a9 | ||
|
|
5694bb9fdc | ||
|
|
9245657e23 | ||
|
|
9ef8483882 | ||
|
|
b859e11126 | ||
|
|
cee499e0e6 | ||
|
|
8702f165f3 | ||
|
|
a6bea7e234 | ||
|
|
37343dd586 | ||
|
|
058183f202 | ||
|
|
7ee50de78e | ||
|
|
ca11aa9e41 | ||
|
|
64d985fcda | ||
|
|
bd2bb4dcf6 | ||
|
|
17db6c4cf1 | ||
|
|
5203096aca | ||
|
|
a53e1f916f | ||
|
|
09a4b3d387 | ||
|
|
24e7de5bfb | ||
|
|
613fb5b6ea | ||
|
|
4e117075fa | ||
|
|
33f4ae7a05 | ||
|
|
caba28901d | ||
|
|
77e02f5759 | ||
|
|
fa2717aada | ||
|
|
322f797cbe | ||
|
|
2a64b099ed | ||
|
|
81465733e8 | ||
|
|
9b22056205 | ||
|
|
24841f82e0 | ||
|
|
a8fae470d0 | ||
|
|
72283cdda2 | ||
|
|
9874ff26b3 | ||
|
|
85d64b7ad3 | ||
|
|
2dba2705b7 | ||
|
|
d1bec6e00a | ||
|
|
597e012ca3 | ||
|
|
8632b8ada7 | ||
|
|
b288879838 | ||
|
|
8beb8f46da | ||
|
|
9120bf5777 | ||
|
|
01619d4182 | ||
|
|
aa4ea4ae2a | ||
|
|
c236c2ffb5 | ||
|
|
b5a3a513de | ||
|
|
6058183c45 | ||
|
|
a042d27573 | ||
|
|
88b21e476d | ||
|
|
3d497d4f05 | ||
|
|
9b668e3629 | ||
|
|
a670a2a3fc | ||
|
|
53a407fa78 | ||
|
|
5ab1f637a5 | ||
|
|
f59c945347 | ||
|
|
83e50ab546 | ||
|
|
9a415fe743 | ||
|
|
e722b6601c | ||
|
|
170e5145f0 | ||
|
|
405c532f13 | ||
|
|
5bed1c3a01 | ||
|
|
8d0d34c497 | ||
|
|
6270bc122b | ||
|
|
57f1128450 | ||
|
|
cb09906b2e | ||
|
|
fa704c43b3 | ||
|
|
eede0c3dd7 | ||
|
|
1758a4e5e5 | ||
|
|
20ebf24a20 | ||
|
|
427ececb0b | ||
|
|
f65670e18f | ||
|
|
41a0a4554d | ||
|
|
c1da053f8c | ||
|
|
57da02af9d | ||
|
|
b9c505bbdc | ||
|
|
633e6a6721 | ||
|
|
f7ddb4ba8f | ||
|
|
91eb2c467b | ||
|
|
f237a7e074 | ||
|
|
242c3cffb6 | ||
|
|
eee54af6ee | ||
|
|
86f86dbe86 | ||
|
|
cc99bb3326 | ||
|
|
08dc52c631 | ||
|
|
51db45a861 | ||
|
|
790853b3b2 | ||
|
|
23479acabc | ||
|
|
de965e3693 | ||
|
|
5c3afc5141 | ||
|
|
50814723c5 | ||
|
|
c6919e9fd6 | ||
|
|
6b220f12ae | ||
|
|
2aade4c0c8 | ||
|
|
f16c40a496 | ||
|
|
aed6725d17 | ||
|
|
ac6c240217 | ||
|
|
34ae7f5e0b | ||
|
|
40aa1c7d35 | ||
|
|
b128af464c | ||
|
|
1236152120 | ||
|
|
9201af566d | ||
|
|
cf4eb7a4e4 | ||
|
|
3ca02ab740 | ||
|
|
c05ef9ccca | ||
|
|
dccd249cb0 | ||
|
|
9e3840a987 | ||
|
|
a32e98a72a | ||
|
|
3496c30a2c | ||
|
|
38e4b65a8a | ||
|
|
5e5273e6a4 | ||
|
|
e1be834d62 | ||
|
|
55e5ab465a | ||
|
|
5be66f3963 | ||
|
|
024c699637 | ||
|
|
52a4e9b78d | ||
|
|
b3b0350556 | ||
|
|
afd445721c | ||
|
|
b36ac56851 | ||
|
|
2ff5dbe5fb | ||
|
|
d5fe7890cd | ||
|
|
0c183b447c | ||
|
|
b3c2ebb9e5 | ||
|
|
5bc7bcbf87 | ||
|
|
a1d361db1d | ||
|
|
4a36f98abe | ||
|
|
a518728bf0 | ||
|
|
482b7f8f64 | ||
|
|
639d5881f5 | ||
|
|
0fc6cf8658 | ||
|
|
c7e13c25ab | ||
|
|
77b5d63282 | ||
|
|
daded8d7d2 | ||
|
|
5a21417c8d | ||
|
|
9c3aa4c96d | ||
|
|
14481716d1 | ||
|
|
5e8b109746 | ||
|
|
996ef329eb | ||
|
|
0e93eb884f | ||
|
|
958fce59e6 | ||
|
|
595a841f21 | ||
|
|
fd01b9393b | ||
|
|
61c16674d5 | ||
|
|
838701f0d1 | ||
|
|
3ca26392b7 | ||
|
|
40c0a0a5a1 | ||
|
|
f412451aaf | ||
|
|
e2d747aadb | ||
|
|
5df5bd0aa5 | ||
|
|
583fd24e33 | ||
|
|
12c67e525c | ||
|
|
fd4a64cc30 | ||
|
|
a77cb45692 | ||
|
|
863ef1eeec | ||
|
|
bc04241e0f | ||
|
|
9f94ddb606 | ||
|
|
6b101614ba | ||
|
|
811568737b | ||
|
|
36b5e0cee8 | ||
|
|
8681437081 | ||
|
|
3e79916462 | ||
|
|
0613b6c2cd | ||
|
|
63ed6f2b4e | ||
|
|
9985d618ef | ||
|
|
46c0ee6423 | ||
|
|
5585874c21 | ||
|
|
b288c31216 | ||
|
|
738c8a21c8 | ||
|
|
ff5669ff30 | ||
|
|
fdf64a8673 | ||
|
|
2c7b442f06 | ||
|
|
9c09cb9034 | ||
|
|
e52d14586f | ||
|
|
0f7646868f | ||
|
|
6fe4511280 | ||
|
|
efc1551d62 | ||
|
|
0cc5b74081 | ||
|
|
6c6f9b079d | ||
|
|
7da0db0237 | ||
|
|
66f916ac02 | ||
|
|
0e500ec827 | ||
|
|
00f1d24aa5 | ||
|
|
b909409c54 | ||
|
|
d18d7a6e21 | ||
|
|
0131cdbf4b | ||
|
|
114190c52e | ||
|
|
a06352d150 | ||
|
|
1969eea8a7 | ||
|
|
8c758fa413 | ||
|
|
4b2fb24d1d | ||
|
|
1637573d72 | ||
|
|
367477f75b | ||
|
|
f5d44a08ec | ||
|
|
d4ffb07402 | ||
|
|
0b2b6ddc14 | ||
|
|
0cff5ef83f | ||
|
|
396c45269e | ||
|
|
98ad9798c5 | ||
|
|
b4741293ba | ||
|
|
43a6aa826b | ||
|
|
e0a9384e56 | ||
|
|
b5ca8c63d9 | ||
|
|
c98401131a | ||
|
|
c1cd0f584f | ||
|
|
f82aae7fc9 | ||
|
|
c349ad7a40 | ||
|
|
3500a4d5ec | ||
|
|
149d07b28e | ||
|
|
e248bf3fc3 | ||
|
|
f07b692ead | ||
|
|
ebfe69535f | ||
|
|
58dbee8dee | ||
|
|
feca0dc721 | ||
|
|
81831d156d | ||
|
|
55c859cea7 | ||
|
|
613748b2f0 | ||
|
|
bbd4bca83d | ||
|
|
007866f278 | ||
|
|
1d2e9818ed | ||
|
|
34deafe440 | ||
|
|
55d8b650d3 | ||
|
|
3ef6802d55 | ||
|
|
e9484ffa5a | ||
|
|
c2b50a3a08 | ||
|
|
06bb202bdf | ||
|
|
64b67e9ea7 | ||
|
|
faf1e90bed | ||
|
|
c8935e199b | ||
|
|
af76a9ce48 | ||
|
|
3986e2fbb7 | ||
|
|
1f97e8cc1c | ||
|
|
be1e2d7189 | ||
|
|
8c4d7a6d0e | ||
|
|
0849bf80b0 | ||
|
|
57ea4cd8c2 | ||
|
|
4c95900299 | ||
|
|
94238f3274 | ||
|
|
361230ffd2 | ||
|
|
88065c2837 | ||
|
|
7f7b59ccfc | ||
|
|
2a9f9d4695 | ||
|
|
8281a68e2e | ||
|
|
cf47843712 | ||
|
|
7901fad195 | ||
|
|
df346764c8 | ||
|
|
8024d33b25 | ||
|
|
e41d9f6430 | ||
|
|
822313e90e | ||
|
|
6c4f4c0049 | ||
|
|
bb249345a7 | ||
|
|
c1716e3b5a | ||
|
|
40a6f3dda3 | ||
|
|
8cadda2099 | ||
|
|
8837214db2 | ||
|
|
1812b85e39 | ||
|
|
5024164408 | ||
|
|
b7983f4998 | ||
|
|
7eea81ef83 | ||
|
|
5b9b4f9a2b | ||
|
|
f862b7744b | ||
|
|
e0b38f79dc | ||
|
|
a709b9fd23 | ||
|
|
ad2109103a | ||
|
|
f797ae1c0a | ||
|
|
f0287fab8a | ||
|
|
68a2b14e8b | ||
|
|
04e55c33c1 | ||
|
|
95a7137caa | ||
|
|
f9978baba2 | ||
|
|
2023f6161d | ||
|
|
a4f35466c3 | ||
|
|
5f73b0a2d7 | ||
|
|
d194f18b8e | ||
|
|
17e1a145e8 | ||
|
|
7ebb547fd9 | ||
|
|
6eca9ad564 | ||
|
|
229f00a8ed | ||
|
|
2bbf7f2b6d | ||
|
|
d578abc69a | ||
|
|
89253f5767 | ||
|
|
dd5afd354a | ||
|
|
bdcc768354 | ||
|
|
9cd2bafac5 | ||
|
|
69515ab47b | ||
|
|
ac5f7ab186 | ||
|
|
265c7eacc5 | ||
|
|
98229581ca | ||
|
|
a718fd10d3 | ||
|
|
78a63b8ca1 | ||
|
|
7487f413de | ||
|
|
da5f4e644a | ||
|
|
be38117012 | ||
|
|
8f3314f188 | ||
|
|
31b6cc9ab6 | ||
|
|
04a8b08f59 | ||
|
|
8bce9b19b2 | ||
|
|
d3d6d41f9f | ||
|
|
0534328dfc | ||
|
|
d0b9af2553 | ||
|
|
80674a3451 | ||
|
|
41b48a80be | ||
|
|
3d5b77a263 | ||
|
|
fa25d259d9 | ||
|
|
26596f32e9 | ||
|
|
031c610d58 | ||
|
|
fcb5beccb4 | ||
|
|
3003a712f6 | ||
|
|
e89f030c29 | ||
|
|
3e224f9583 | ||
|
|
db338b2b78 | ||
|
|
971c8e4010 | ||
|
|
df64a59354 | ||
|
|
e5d80fcb6d | ||
|
|
4088ea3e43 | ||
|
|
edd42186d9 | ||
|
|
e6ba63cfd0 | ||
|
|
7b60901bb1 | ||
|
|
a551e9010a | ||
|
|
95441798ed | ||
|
|
814f863a63 | ||
|
|
283f086b25 | ||
|
|
7da01333c6 | ||
|
|
c057eaa098 | ||
|
|
968fdd221c | ||
|
|
6d28e0eecc | ||
|
|
a2b4d2dbd2 | ||
|
|
660c996602 | ||
|
|
35ede265ed | ||
|
|
cf02f075d7 | ||
|
|
a2027eabd2 | ||
|
|
4ef239c59c | ||
|
|
3610eee2b5 | ||
|
|
54ae94f532 | ||
|
|
c68bf3e26f | ||
|
|
940e3f6d40 | ||
|
|
4c6226b90b | ||
|
|
1341bb18d1 | ||
|
|
beac65ce53 | ||
|
|
a8cc54d409 | ||
|
|
6a639bd556 | ||
|
|
dde9685b38 | ||
|
|
5b3a76a5b5 | ||
|
|
513e09157b | ||
|
|
69a939c635 | ||
|
|
58156545d0 | ||
|
|
8a52b5c401 | ||
|
|
14d4c7b234 | ||
|
|
fd17c96b3b | ||
|
|
833a5465c5 | ||
|
|
f0c958e58b | ||
|
|
607838c8e1 |
No files matched your search
@@ -1 +1,5 @@
|
||||
* text eol=lf
|
||||
*.png binary
|
||||
*.jpg binary
|
||||
|
||||
**/yarn.lock linguist-generated
|
||||
@@ -37,7 +37,7 @@ body:
|
||||
required: true
|
||||
attributes:
|
||||
label: Environment
|
||||
description: Run `npx expo-env-info` and paste the output here.
|
||||
description: Run `npx expo-env-info` and `npx expo-doctor`, and paste the output here.
|
||||
- type: textarea
|
||||
validations:
|
||||
required: false
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
<!-- Thanks for contributing to _EAS CLI_! Before you submit your pull request, please make sure to check the following boxes by putting an x in the [ ] (don't: [x ], [ x], do: [x]) -->
|
||||
|
||||
# Checklist
|
||||
|
||||
- [ ] I've added an entry to [CHANGELOG.md](https://github.com/expo/eas-cli/blob/main/CHANGELOG.md#main) if necessary. You can comment this pull request with `/changelog-entry [breaking-change|new-feature|bug-fix|chore] [message]` and CHANGELOG.md will be updated automatically.
|
||||
<!-- If this PR requires a changelog entry, add it by commenting the PR with the command `/changelog-entry [breaking-change|new-feature|bug-fix|chore] [message]`. -->
|
||||
<!-- You can skip the changelog check by labeling the PR with "no changelog". -->
|
||||
|
||||
# Why
|
||||
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"printWidth": 100,
|
||||
"singleQuote": true
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
name: notify-slack-on-fail-or-recover
|
||||
|
||||
inputs:
|
||||
channel:
|
||||
required: true
|
||||
type: string
|
||||
slack_webhook:
|
||||
required: true
|
||||
type: string
|
||||
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- name: Get previous workflow run status
|
||||
uses: actions/github-script@v6
|
||||
id: run-status
|
||||
with:
|
||||
script: |
|
||||
const script = require('.github/internal-actions/notify-slack-on-fail-or-recover/main.js');
|
||||
return await script({github, context, core});
|
||||
|
||||
- name: Commit subject
|
||||
shell: bash
|
||||
run: |
|
||||
subject="$(git show --format=%s --quiet | sed 's/"/\\"/g')"
|
||||
echo "${{ fromJSON(steps.run-status.outputs.result).previous }}"
|
||||
echo "SUBJECT=$subject" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Send Slack Success Notification
|
||||
if: fromJSON(steps.run-status.outputs.result).previous == 'failure' && fromJSON(steps.run-status.outputs.result).current == 'success'
|
||||
uses: rtCamp/action-slack-notify@v2
|
||||
env:
|
||||
SLACK_CHANNEL: ${{ inputs.channel }}
|
||||
SLACK_COLOR: good
|
||||
SLACK_ICON: https://github.com/expo-bot.png?size=48
|
||||
SLACK_TITLE: 'Fixed, was failing'
|
||||
SLACK_MESSAGE: ${{ env.SUBJECT }}
|
||||
SLACK_USERNAME: 'EAS CLI CI'
|
||||
SLACK_WEBHOOK: ${{ inputs.slack_webhook }}
|
||||
SLACK_FOOTER: ''
|
||||
|
||||
- name: Send Slack Failure Notification
|
||||
if: fromJSON(steps.run-status.outputs.result).current == 'failure'
|
||||
uses: rtCamp/action-slack-notify@v2
|
||||
env:
|
||||
SLACK_CHANNEL: ${{ inputs.channel }}
|
||||
SLACK_COLOR: danger
|
||||
SLACK_ICON: https://github.com/expo-bot.png?size=48
|
||||
SLACK_TITLE: 'Failed'
|
||||
SLACK_MESSAGE: ${{ env.SUBJECT }}
|
||||
SLACK_USERNAME: 'EAS CLI CI'
|
||||
SLACK_WEBHOOK: ${{ inputs.slack_webhook }}
|
||||
SLACK_FOOTER: ''
|
||||
@@ -0,0 +1,155 @@
|
||||
function updateOctokitInstance(github) {
|
||||
updateGithubRestActions(github);
|
||||
addConvenienceMethods(github);
|
||||
}
|
||||
|
||||
function addConvenienceMethods(github) {
|
||||
Object.assign(github, {
|
||||
// checks if a run is a failure from the perspective of sending Slack
|
||||
// notifications. Works for both runs and run attempts, which should be the
|
||||
// same object shapes.
|
||||
// When a run has not been completed, it fetches the jobs for the workflow
|
||||
// to check if the job is "failing" instead of "failed".
|
||||
async isFailedRun(run) {
|
||||
console.log(`checking run: ${run.id}`);
|
||||
console.log(`status: ${run.status}`);
|
||||
console.log(`conclusion: ${run.conclusion}`);
|
||||
console.log(`run_attempt: ${run.run_attempt}`);
|
||||
|
||||
if (run.status === 'completed') {
|
||||
console.log('completed -- checking conclusion');
|
||||
return run.conclusion === 'failure' || run.conclusion === 'timed_out';
|
||||
}
|
||||
|
||||
const owner = run.repository.owner.login;
|
||||
const repo = run.repository.name;
|
||||
const jobsResp = await github.rest.actions.listJobsForWorkflowRun({
|
||||
owner,
|
||||
repo,
|
||||
run_id: run.id,
|
||||
});
|
||||
|
||||
console.log('checking jobs');
|
||||
const jobs = jobsResp.data;
|
||||
for (const job of jobs.jobs) {
|
||||
if (job.conclusion === 'failure' || job.conclusion === 'timed_out') {
|
||||
console.log('job failed');
|
||||
return true;
|
||||
} else if (job.status === 'completed') {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const step of job.steps) {
|
||||
if (step.conclusion === 'failure') {
|
||||
console.log('job step failed');
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// github-script's Octokit/REST plugins are out of date, so we have to
|
||||
// add our own custom endpoint method
|
||||
// see https://octokit.github.io/rest.js/v18#custom-endpoint-methods for
|
||||
// documentation
|
||||
function updateGithubRestActions(github) {
|
||||
Object.assign(github.rest.actions, {
|
||||
getWorkflowRunAttempt: github.request.defaults({
|
||||
method: 'GET',
|
||||
url: '/repos/{owner}/{repo}/actions/runs/{run_id}/attempts/{attempt_number}',
|
||||
params: {
|
||||
owner: {
|
||||
required: true,
|
||||
type: 'string',
|
||||
},
|
||||
repo: {
|
||||
required: true,
|
||||
type: 'string',
|
||||
},
|
||||
run_id: {
|
||||
required: true,
|
||||
type: 'number',
|
||||
},
|
||||
attempt_number: {
|
||||
required: true,
|
||||
type: 'number',
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
// Returns { current: 'failure' } | { current: 'success', previous: 'success' | 'failure' } to indicate the failure status
|
||||
// of the current and previous runs
|
||||
module.exports = async ({ github }) => {
|
||||
const { GITHUB_REPOSITORY, GITHUB_RUN_ID, GITHUB_REF_NAME } = process.env;
|
||||
const [owner, repo] = GITHUB_REPOSITORY.split('/');
|
||||
updateOctokitInstance(github);
|
||||
|
||||
const workflowRunResp = await github.rest.actions.getWorkflowRun({
|
||||
owner,
|
||||
repo,
|
||||
run_id: GITHUB_RUN_ID,
|
||||
});
|
||||
|
||||
const currentRun = workflowRunResp.data;
|
||||
const listWorkflowRunsResp = await github.rest.actions.listWorkflowRuns({
|
||||
owner,
|
||||
repo,
|
||||
workflow_id: currentRun.workflow_id,
|
||||
branch: GITHUB_REF_NAME,
|
||||
});
|
||||
const { workflow_runs } = listWorkflowRunsResp.data;
|
||||
|
||||
if (workflow_runs.length === 1 && workflow_runs[0].run_attempt === 1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const previousRuns = workflow_runs.filter(
|
||||
(run) => run.status === 'completed' && run.conclusion !== 'cancelled'
|
||||
);
|
||||
|
||||
if (await github.isFailedRun(currentRun)) {
|
||||
return { current: 'failure', previous: null };
|
||||
}
|
||||
|
||||
if (currentRun.run_attempt === 1 && previousRuns.length > 0) {
|
||||
return {
|
||||
current: 'success',
|
||||
previous: (await github.isFailedRun(previousRuns[0])) ? 'failure' : 'success',
|
||||
};
|
||||
}
|
||||
|
||||
for (let i = currentRun.run_attempt - 1; i > 0; i--) {
|
||||
const previousAttemptResp = await github.rest.actions.getWorkflowRunAttempt({
|
||||
owner,
|
||||
repo,
|
||||
run_id: currentRun.id,
|
||||
attempt_number: i,
|
||||
});
|
||||
const previousAttempt = previousAttemptResp.data;
|
||||
if (
|
||||
previousAttempt.conclusion === 'success' ||
|
||||
previousAttempt.conclusion === 'failure' ||
|
||||
previousAttempt.conclusion === 'timed_out'
|
||||
) {
|
||||
return {
|
||||
current: 'success',
|
||||
previous: (await github.isFailedRun(previousAttempt)) ? 'failure' : 'success',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (previousRuns.length > 0) {
|
||||
return {
|
||||
current: 'success',
|
||||
previous: (await github.isFailedRun(previousRuns[0])) ? 'failure' : 'success',
|
||||
};
|
||||
}
|
||||
|
||||
return { current: 'success', previous: 'success' };
|
||||
};
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "notify-slack-on-fail-or-recover",
|
||||
"version": "1.0.0",
|
||||
"description": "A GitHub Action to notify Expo Slack of build failures and recovery.",
|
||||
"main": "main.js",
|
||||
"author": "Expo",
|
||||
"license": "UNLICENSED",
|
||||
"private": true,
|
||||
"devDependencies": {
|
||||
"prettier": "^2.5.1"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
|
||||
# yarn lockfile v1
|
||||
|
||||
|
||||
[email protected]:
|
||||
version "2.5.1"
|
||||
resolved "https://registry.yarnpkg.com/prettier/-/prettier-2.5.1.tgz#fff75fa9d519c54cf0fce328c1017d94546bc56a"
|
||||
integrity sha512-vBZcPRUR5MZJwoyi3ZoyQlc1rXeEck8KgeC9AwwOn+exuxLxq5toTRDTSaVrXHxelDMHy9zlicw8u66yxoSUFg==
|
||||
@@ -0,0 +1,48 @@
|
||||
name: Changelog bot
|
||||
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
|
||||
jobs:
|
||||
pr-commented:
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ github.event.issue.pull_request && github.event.issue.user.login == github.event.comment.user.login && startsWith(github.event.comment.body, '/changelog-entry ') }}
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
with:
|
||||
token: ${{ secrets.EXPO_BOT_PAT }}
|
||||
- uses: actions/setup-node@v2
|
||||
with:
|
||||
node-version: 18
|
||||
- name: Checkout Pull Request
|
||||
run: hub pr checkout ${{ github.event.issue.number }}
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Install dependencies
|
||||
working-directory: ./scripts
|
||||
run: yarn install --frozen-lockfile --check-files
|
||||
- name: Add changelog entry
|
||||
working-directory: ./scripts
|
||||
run: |
|
||||
PARAMS=${PR_COMMENT_BODY#/changelog-entry }
|
||||
if [[ ! "$PARAMS" =~ " " ]]; then
|
||||
echo "Invalid /changelog-entry command"
|
||||
exit 1
|
||||
fi
|
||||
CATEGORY=$( cut -d ' ' -f 1 <<< "$PARAMS" | sed -e 's/^[[:space:]]*//' )
|
||||
MESSAGE=$( cut -d ' ' -f 2- <<< "$PARAMS" | sed -e 's/^[[:space:]]*//' )
|
||||
yarn changelog-entry "$CATEGORY" "$MESSAGE"
|
||||
env:
|
||||
GITHUB_PR_AUTHOR: ${{ github.event.issue.user.login }}
|
||||
GITHUB_PR_NUMBER: ${{ github.event.issue.number }}
|
||||
PR_COMMENT_BODY: ${{ github.event.comment.body }}
|
||||
- name: Commit and push
|
||||
run: |
|
||||
# use the last commit author name and email
|
||||
git config --global user.name "$(git --no-pager log --format=format:'%an' -n 1)"
|
||||
git config --global user.email "$(git --no-pager log --format=format:'%ae' -n 1)"
|
||||
|
||||
git add CHANGELOG.md
|
||||
git commit -m "update CHANGELOG.md"
|
||||
git push
|
||||
@@ -1,46 +1,37 @@
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
name: CHANGELOG.md PR check
|
||||
|
||||
name: Changelog
|
||||
on:
|
||||
pull_request:
|
||||
types:
|
||||
- opened
|
||||
- synchronize
|
||||
- labeled
|
||||
- unlabeled
|
||||
|
||||
jobs:
|
||||
pr-commented:
|
||||
pr-changelog-entry:
|
||||
name: Check CHANGELOG.md updated
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ github.event.issue.pull_request && github.event.issue.user.login == github.event.comment.user.login && startsWith(github.event.comment.body, '/changelog-entry ') }}
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/setup-node@v2
|
||||
- name: Check if CHANGELOG.md was updated
|
||||
id: changelog-updated
|
||||
uses: tj-actions/changed-files@v35
|
||||
with:
|
||||
node-version: '16'
|
||||
- name: Checkout Pull Request
|
||||
run: hub pr checkout ${{ github.event.issue.number }}
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Install dependencies
|
||||
working-directory: ./scripts
|
||||
run: yarn install --frozen-lockfile --check-files
|
||||
- name: Add changelog entry
|
||||
working-directory: ./scripts
|
||||
run: |
|
||||
PARAMS=${PR_COMMENT_BODY#/changelog-entry }
|
||||
if [[ ! "$PARAMS" =~ " " ]]; then
|
||||
echo "Invalid /changelog-entry command"
|
||||
exit 1
|
||||
fi
|
||||
CATEGORY=$( cut -d ' ' -f 1 <<< "$PARAMS" | sed -e 's/^[[:space:]]*//' )
|
||||
MESSAGE=$( cut -d ' ' -f 2- <<< "$PARAMS" | sed -e 's/^[[:space:]]*//' )
|
||||
yarn changelog-entry "$CATEGORY" "$MESSAGE"
|
||||
env:
|
||||
GITHUB_PR_AUTHOR: ${{ github.event.issue.user.login }}
|
||||
GITHUB_PR_NUMBER: ${{ github.event.issue.number }}
|
||||
PR_COMMENT_BODY: ${{ github.event.comment.body }}
|
||||
- name: Commit and push
|
||||
run: |
|
||||
# use the last commit author name and email
|
||||
git config --global user.name "$(git --no-pager log --format=format:'%an' -n 1)"
|
||||
git config --global user.email "$(git --no-pager log --format=format:'%ae' -n 1)"
|
||||
|
||||
git add CHANGELOG.md
|
||||
git commit -m "update CHANGELOG.md"
|
||||
git push
|
||||
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. You have two options: you can add it manually, or you can use the changelog bot to do it for you.
|
||||
🤖 To use the bot, simply comment on this PR with the command `/changelog-entry [breaking-change|new-feature|bug-fix|chore] [message]`.
|
||||
⏩ If this PR doesn't require a changelog entry, such as if it's an internal change that doesn't affect the user experience, you can add the "no changelog" label to the PR.
|
||||
@@ -1,20 +0,0 @@
|
||||
name: Installer tests
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
types: [opened, synchronize]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: Run the installer
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
matrix:
|
||||
os: [ubuntu-latest, macos-latest]
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- run: bash bin/install.sh
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
- run: eas --version
|
||||
@@ -0,0 +1,146 @@
|
||||
name: Issue Triage
|
||||
on:
|
||||
issues:
|
||||
types: [labeled]
|
||||
|
||||
jobs:
|
||||
needs-repro:
|
||||
runs-on: ubuntu-20.04
|
||||
if: "${{ contains(github.event.label.name, 'incomplete issue: missing or invalid repro') }}"
|
||||
steps:
|
||||
- uses: actions/github-script@v3
|
||||
with:
|
||||
github-token: ${{ secrets.EXPO_BOT_GITHUB_TOKEN }}
|
||||
script: |
|
||||
github.issues.createComment({
|
||||
issue_number: context.issue.number,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
body: `Hi there! It looks like your issue requires a minimal reproducible example, but it is invalid or absent. Please prepare such an example and share it in a new issue.
|
||||
|
||||
**The best way to get attention to your issue is to provide a clean and easy way for a developer to reproduce the issue on their own machine.** Please do not provide your entire project, or a project with more code than is necessary to reproduce the issue.
|
||||
|
||||
A side benefit of going through the process of narrowing down the minimal amount of code needed to reproduce the issue is that you may get lucky and discover that the bug is due to a mistake in your application code that you can quickly fix on your own.
|
||||
|
||||
### Resources
|
||||
|
||||
- ["How to create a Minimal, Reproducible Example"](https://stackoverflow.com/help/minimal-reproducible-example)
|
||||
- ["How to narrow down the source of an error"](https://expo.fyi/manual-debugging)
|
||||
|
||||
### Common concerns
|
||||
|
||||
#### "I've only been able to reproduce it in private, proprietary code"
|
||||
|
||||
You may not have spent enough time narrowing down the root cause of the issue. Try out the techniques discussed in this [manual debugging guide](https://expo.fyi/manual-debugging) to learn how to isolate the problem from the rest of your codebase.
|
||||
|
||||
#### "I didn't have time to create one"
|
||||
|
||||
That's understandable, it can take some time to prepare. We ask that you hold off on filing an issue until you are able to fully complete the required fields in the issue template.
|
||||
|
||||
#### "You can reproduce it by yourself by creating a project and following these steps"
|
||||
|
||||
This is useful knowledge, but it's still valuable to have the resulting project that is produced from running the steps, where you have verified you can reproduce the issue.
|
||||
`})
|
||||
github.issues.update({
|
||||
issue_number: context.issue.number,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
state: 'closed'
|
||||
})
|
||||
|
||||
needs-info:
|
||||
runs-on: ubuntu-20.04
|
||||
if: "${{ contains(github.event.label.name, 'incomplete issue: missing info') }}"
|
||||
steps:
|
||||
- uses: actions/github-script@v3
|
||||
with:
|
||||
github-token: ${{ secrets.EXPO_BOT_GITHUB_TOKEN }}
|
||||
script: |
|
||||
github.issues.createComment({
|
||||
issue_number: context.issue.number,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
body: `Hello! There isn't enough information provided in your issue description for us to be able to help you. The best way to get help is to provide information to enable other developers to understand the issue and reproduce it on their own machines.
|
||||
|
||||
Please create a new issue and fill out the entire issue template to the best of your ability. Refer to the following resources for more information on how to create a good issue report.
|
||||
|
||||
### Resources
|
||||
|
||||
- ["How do I ask a good question?"](https://stackoverflow.com/help/how-to-ask)
|
||||
- ["How to create a Minimal, Reproducible Example"](https://stackoverflow.com/help/minimal-reproducible-example)
|
||||
- ["How to narrow down the source of an error"](https://expo.fyi/manual-debugging)
|
||||
`})
|
||||
github.issues.update({
|
||||
issue_number: context.issue.number,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
state: 'closed'
|
||||
})
|
||||
|
||||
issue-accepted:
|
||||
runs-on: ubuntu-20.04
|
||||
if: github.event.label.name == 'issue accepted'
|
||||
steps:
|
||||
- uses: actions/github-script@v3
|
||||
with:
|
||||
github-token: ${{ secrets.EXPO_BOT_GITHUB_TOKEN }}
|
||||
script: |
|
||||
github.issues.createComment({
|
||||
issue_number: context.issue.number,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
body: `Thank you for filing this issue!
|
||||
This comment acknowledges we believe this may be a bug and there’s enough information to investigate it.
|
||||
However, we can’t promise any sort of timeline for resolution. We prioritize issues based on severity, breadth of impact, and alignment with our roadmap. If you’d like to help move it more quickly, you can continue to investigate it more deeply and/or you can open a pull request that fixes the cause.
|
||||
`})
|
||||
|
||||
question:
|
||||
runs-on: ubuntu-20.04
|
||||
if: "${{ contains(github.event.label.name, 'invalid issue: question') }}"
|
||||
steps:
|
||||
- uses: actions/github-script@v3
|
||||
with:
|
||||
github-token: ${{ secrets.EXPO_BOT_GITHUB_TOKEN }}
|
||||
script: |
|
||||
github.issues.createComment({
|
||||
issue_number: context.issue.number,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
body: `Hello! Our GitHub issues are reserved for bug reports.
|
||||
|
||||
If you have a question about Expo or related tools, please post on our forums at https://forums.expo.dev/ or join our Discord at https://chat.expo.dev/.
|
||||
|
||||
### Resources
|
||||
|
||||
- ["How do I ask a good question?"](https://stackoverflow.com/help/how-to-ask)
|
||||
- ["Join the community"](https://docs.expo.dev/next-steps/community/)
|
||||
`})
|
||||
github.issues.update({
|
||||
issue_number: context.issue.number,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
state: 'closed'
|
||||
})
|
||||
|
||||
feature-request:
|
||||
runs-on: ubuntu-20.04
|
||||
if: "${{ contains(github.event.label.name, 'invalid issue: feature request') }}"
|
||||
steps:
|
||||
- uses: actions/github-script@v3
|
||||
with:
|
||||
github-token: ${{ secrets.EXPO_BOT_GITHUB_TOKEN }}
|
||||
script: |
|
||||
github.issues.createComment({
|
||||
issue_number: context.issue.number,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
body: `Hello! Our GitHub issues are reserved for bug reports.
|
||||
|
||||
If you would like to request a feature, please post to https://expo.canny.io/feature-requests.
|
||||
`})
|
||||
github.issues.update({
|
||||
issue_number: context.issue.number,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
state: 'closed'
|
||||
})
|
||||
@@ -27,7 +27,7 @@ jobs:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/setup-node@v2
|
||||
with:
|
||||
node-version: '16'
|
||||
node-version: 18
|
||||
- run: yarn install --frozen-lockfile --check-files
|
||||
- run: yarn build
|
||||
- name: Build tarballs
|
||||
@@ -36,10 +36,10 @@ jobs:
|
||||
yarn pretarball-ci
|
||||
yarn oclif pack:tarballs --no-xz --targets linux-x64,linux-arm
|
||||
- id: x64
|
||||
run: echo "::set-output name=filename::$(ls eas-*-x64.tar.gz)"
|
||||
run: echo "filename=$(ls eas-*-x64.tar.gz)" >> $GITHUB_OUTPUT
|
||||
working-directory: ${{ github.workspace }}/packages/eas-cli/dist
|
||||
- id: arm
|
||||
run: echo "::set-output name=filename::$(ls eas-*-arm.tar.gz)"
|
||||
run: echo "filename=$(ls eas-*-arm.tar.gz)" >> $GITHUB_OUTPUT
|
||||
working-directory: ${{ github.workspace }}/packages/eas-cli/dist
|
||||
- name: Upload linux-x64 tarball
|
||||
uses: actions/upload-release-asset@v1
|
||||
@@ -68,7 +68,7 @@ jobs:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/setup-node@v2
|
||||
with:
|
||||
node-version: '16'
|
||||
node-version: 18
|
||||
- run: yarn install --frozen-lockfile --check-files
|
||||
- run: yarn build
|
||||
- name: Build tarballs
|
||||
@@ -77,7 +77,7 @@ jobs:
|
||||
yarn pretarball-ci
|
||||
yarn oclif pack:tarballs --no-xz --targets darwin-x64
|
||||
- id: x64
|
||||
run: echo "::set-output name=filename::$(ls eas-*-x64.tar.gz)"
|
||||
run: echo "filename=$(ls eas-*-x64.tar.gz)" >> $GITHUB_OUTPUT
|
||||
working-directory: ${{ github.workspace }}/packages/eas-cli/dist
|
||||
- name: Upload darwin-x64 tarball
|
||||
uses: actions/upload-release-asset@v1
|
||||
@@ -96,7 +96,7 @@ jobs:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/setup-node@v2
|
||||
with:
|
||||
node-version: '16'
|
||||
node-version: 18
|
||||
- run: yarn install --frozen-lockfile --check-files
|
||||
- run: sudo apt-get install nsis
|
||||
- run: yarn build
|
||||
@@ -105,10 +105,10 @@ jobs:
|
||||
yarn oclif pack:win
|
||||
working-directory: ${{ github.workspace }}/packages/eas-cli
|
||||
- id: x64
|
||||
run: echo "::set-output name=filename::$(ls eas-*-x64.exe)"
|
||||
run: echo "filename=$(ls eas-*-x64.exe)" >> $GITHUB_OUTPUT
|
||||
working-directory: ${{ github.workspace }}/packages/eas-cli/dist/win32
|
||||
- id: x86
|
||||
run: echo "::set-output name=filename::$(ls eas-*-x86.exe)"
|
||||
run: echo "filename=$(ls eas-*-x86.exe)" >> $GITHUB_OUTPUT
|
||||
working-directory: ${{ github.workspace }}/packages/eas-cli/dist/win32
|
||||
- name: Upload x64 installer
|
||||
uses: actions/upload-release-asset@v1
|
||||
@@ -138,7 +138,7 @@ jobs:
|
||||
with:
|
||||
registry-url: 'https://registry.npmjs.org/'
|
||||
scope: 'expo'
|
||||
node-version: '16'
|
||||
node-version: 18
|
||||
- name: Install dependencies
|
||||
run: yarn install --frozen-lockfile --check-files
|
||||
- name: Build
|
||||
@@ -147,15 +147,25 @@ jobs:
|
||||
run: yarn lerna publish from-package --yes --no-verify-access
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_ACCESS_TOKEN }}
|
||||
- name: Add latest-eas-build-staging tag
|
||||
run: |
|
||||
VERSION=$(cat lerna.json | jq -r .version)
|
||||
echo //registry.npmjs.org/:_authToken=$NODE_AUTH_TOKEN > ~/.npmrc
|
||||
npm dist-tag add eas-cli@$VERSION latest-eas-build-staging
|
||||
echo "Run \"npm dist-tag add eas-cli@$VERSION latest-eas-build\" to promote it to production."
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_ACCESS_TOKEN }}
|
||||
release-changelog:
|
||||
name: Update changelog and publish release
|
||||
runs-on: ubuntu-latest
|
||||
needs: publish-to-npm
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
with:
|
||||
token: ${{ secrets.EXPO_BOT_PAT }}
|
||||
- uses: actions/setup-node@v2
|
||||
with:
|
||||
node-version: '16'
|
||||
node-version: 18
|
||||
- name: Install dependencies
|
||||
working-directory: ./scripts
|
||||
run: yarn install --frozen-lockfile --check-files
|
||||
@@ -189,9 +199,9 @@ jobs:
|
||||
env:
|
||||
SLACK_CHANNEL: eas-cli
|
||||
SLACK_COLOR: ${{ job.status }}
|
||||
SLACK_ICON: https://github.com/expo-ci.png?size=48
|
||||
SLACK_ICON: https://github.com/expo-bot.png?size=48
|
||||
SLACK_TITLE: 'Released eas-cli@${{ env.EAS_CLI_VERSION }}'
|
||||
SLACK_MESSAGE: ${{ env.SLACK_CHANGELOG }}
|
||||
SLACK_USERNAME: 'Expo CI'
|
||||
SLACK_USERNAME: 'EAS CLI CI'
|
||||
SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }}
|
||||
SLACK_FOOTER: ''
|
||||
@@ -9,7 +9,7 @@ jobs:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/setup-node@v2
|
||||
with:
|
||||
node-version: 16
|
||||
node-version: 18
|
||||
- uses: preactjs/compressed-size-action@v2
|
||||
with:
|
||||
build-script: 'build:tarballs:linux'
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
name: Test scripts
|
||||
|
||||
defaults:
|
||||
run:
|
||||
working-directory: scripts
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'scripts/**'
|
||||
pull_request:
|
||||
paths:
|
||||
- 'scripts/**'
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Setup node
|
||||
uses: actions/setup-node@v2
|
||||
with:
|
||||
node-version: 18
|
||||
- run: yarn install --frozen-lockfile --check-files
|
||||
- run: yarn typecheck
|
||||
@@ -1,4 +1,5 @@
|
||||
name: Run tests
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
@@ -10,9 +11,9 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
node: ['12', '14', '16']
|
||||
node: ['14', '16', '18']
|
||||
include:
|
||||
- node: '16'
|
||||
- node: '18'
|
||||
coverage: true
|
||||
name: Test with Node ${{ matrix.node }}
|
||||
steps:
|
||||
@@ -25,11 +26,32 @@ jobs:
|
||||
- run: yarn typecheck
|
||||
- run: yarn test
|
||||
if: ${{ !matrix.coverage }}
|
||||
env:
|
||||
NODE_OPTIONS: --max-old-space-size=8192
|
||||
- run: yarn test --coverage
|
||||
if: ${{ matrix.coverage }}
|
||||
env:
|
||||
# Oclif seems to cause OOM errors when testing with coverage enabled.
|
||||
# Remove this whenever we solved those OOM errors.
|
||||
NODE_OPTIONS: --max-old-space-size=8192
|
||||
- run: yarn lint --max-warnings=0
|
||||
- run: shellcheck bin/*.sh
|
||||
- run: yarn install --frozen-lockfile --check-files
|
||||
working-directory: ./scripts
|
||||
- uses: codecov/codecov-action@v1
|
||||
if: ${{ matrix.coverage }}
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
- run: yarn lint-changelog
|
||||
|
||||
notify-slack:
|
||||
runs-on: ubuntu-latest
|
||||
needs: test
|
||||
name: Notify Slack
|
||||
if: ${{ github.ref == 'refs/heads/main' && always() }}
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Notify Slack
|
||||
uses: ./.github/internal-actions/notify-slack-on-fail-or-recover
|
||||
with:
|
||||
channel: eas-cli
|
||||
slack_webhook: ${{ secrets.SLACK_WEBHOOK }}
|
||||
@@ -2,10 +2,12 @@
|
||||
packages/*/build
|
||||
packages/eas-cli/dist
|
||||
packages/eas-cli/tmp
|
||||
scripts/build
|
||||
|
||||
# Code editors
|
||||
.idea
|
||||
.history
|
||||
.vscode
|
||||
|
||||
# macOS
|
||||
.DS_Store
|
||||
|
||||
+2
-1
@@ -4,5 +4,6 @@
|
||||
"singleQuote": true,
|
||||
"bracketSameLine": true,
|
||||
"trailingComma": "es5",
|
||||
"arrowParens": "avoid"
|
||||
"arrowParens": "avoid",
|
||||
"endOfLine": "auto"
|
||||
}
|
||||
+840
-24
File diff suppressed because it is too large.
Load diff
+14
-5
@@ -1,7 +1,16 @@
|
||||
packages/eas-cli/src/build @dsokal @wkozyra95
|
||||
packages/eas-cli/src/commands/build @dsokal @wkozyra95
|
||||
* @szdziedzic @khamilowicz
|
||||
|
||||
packages/eas-cli/src/submit @barthap @dsokal
|
||||
packages/eas-cli/src/commands/submit.ts @barthap @dsokal
|
||||
packages/eas-cli/src/build @szdziedzic @khamilowicz
|
||||
packages/eas-cli/src/commands/build @szdziedzic @khamilowicz
|
||||
|
||||
packages/eas-json @dsokal @wkozyra95
|
||||
packages/eas-cli/src/submit @szdziedzic @khamilowicz
|
||||
packages/eas-cli/src/commands/submit.ts @szdziedzic @khamilowicz
|
||||
|
||||
packages/eas-cli/src/update @EvanBacon @byCedric @wschurman
|
||||
packages/eas-cli/src/commands/update @EvanBacon @byCedric @wschurman
|
||||
|
||||
packages/eas-cli/schema @bycedric
|
||||
packages/eas-cli/src/metadata @bycedric
|
||||
packages/eas-cli/src/commands/metadata @bycedric
|
||||
|
||||
packages/eas-json @szdziedzic @khamilowicz
|
||||
+47
-3
@@ -1,4 +1,48 @@
|
||||
## Releasing a new version
|
||||
# Development
|
||||
|
||||
1. Run `yarn release` in the repository root folder.
|
||||
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.
|
||||
Set up an alias for the EAS CLI so you can try it in projects all around your computer. The project is compiled on the fly so you don't need to run a build (watch) command.
|
||||
|
||||
> The only exception is when you just cloned the repository or there have been changes in `packages/eas-json`. In that case, you'll have to run `yarn build` in the root.
|
||||
|
||||
Open your `.zshrc` or other config file and add:
|
||||
|
||||
```
|
||||
alias easd="/PATH/TO/eas-cli/bin/run"
|
||||
```
|
||||
|
||||
Then use it with `easd` like `easd build`.
|
||||
|
||||
If you're making changes to `packages/eas-json` or prefer/need to work with production code, start the build command in watch mode with:
|
||||
|
||||
```
|
||||
yarn start
|
||||
```
|
||||
|
||||
## Format
|
||||
|
||||
- Be sure to update the [`CHANGELOG.md`](./CHANGELOG.md) with changes for every PR. There is a changelog bot that can generate the proper entry for you. The instructions on how to use it are in the PR description placeholder.
|
||||
- End `async` functions with `Async` like `runAsync`. This is just how we format functions at Expo.
|
||||
- Utilize the unified `Log` module instead of `console.log`.
|
||||
|
||||
## Using local and staging API
|
||||
|
||||
For development against local API:
|
||||
|
||||
- Set up the API server as described at https://github.com/expo/universe/tree/main/server/www.
|
||||
- Set the `EXPO_LOCAL=1` env variable either on every command run (e.g. `EXPO_LOCAL=1 easd ...`) or more permamently with `export EXPO_LOCAL=1` (works in a current shell session).
|
||||
|
||||
For development against staging API:
|
||||
|
||||
- Set the `EXPO_STAGING=1` env variable either on every command run (e.g. `EXPO_STAGING=1 easd ...`) or more permamently with `export EXPO_STAGING=1` (works in a current shell session).
|
||||
|
||||
## Working on local builds (`eas build --local`)
|
||||
|
||||
See https://github.com/expo/eas-build/blob/main/DEVELOPMENT.md for how to set up your environment when making changes to [`eas-cli-local-build-plugin`](https://github.com/expo/eas-build/tree/main/packages/local-build-plugin) and/or [`build-tools`](https://github.com/expo/eas-build/tree/main/packages/build-tools).
|
||||
|
||||
## Testing
|
||||
|
||||
Run `yarn test` either in the repository root or in a package directory that you're working on.
|
||||
|
||||
## Releasing
|
||||
|
||||
See [RELEASING.md](./RELEASING.md).
|
||||
@@ -0,0 +1,18 @@
|
||||
# Releasing EAS CLI
|
||||
|
||||
1. Run `yarn release` in the repository root folder. The next version is chosen automatically based on the changelog entries. If you want to use different version, pass the version string as a positional argument to the command, e.g. `yarn release 1.2.3`.
|
||||
2. That's it! GitHub Actions is going to take care of the rest. Watch the #eas-cli Slack channel for a successful release notification.
|
||||
|
||||
## Choosing the next version
|
||||
|
||||
As stated above, the next EAS CLI version is chosen automatically based on the existing changelog entries under the unreleased section in **CHANGELOG.md**.
|
||||
|
||||
The algorithm works as follows:
|
||||
|
||||
- If there are any entries in the "🛠 Breaking changes" section, bump the MAJOR version.
|
||||
- Otherwise, if there are any entries in the "🎉 New features", bump the MINOR version.
|
||||
- Otherwise, bump the PATCH version.
|
||||
|
||||
## Updating EAS CLI version on EAS Build
|
||||
|
||||
The release process promotes the new version to staging. After verifying it works there, you can promote it to production with `npm dist-tag add eas-cli@{version} latest-eas-build`.
|
||||
-130
@@ -1,130 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# This script downloads the latest release version of EAS CLI from GitHub and
|
||||
# installs it in /usr/local/bin
|
||||
|
||||
{
|
||||
# All code is inside a block to ensure the script executes only when
|
||||
# downloaded completely.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
prefix="${EAS_PREFIX:-/usr/local}"
|
||||
temp_dir="$(mktemp -d)"
|
||||
|
||||
if [ -t 1 ] && [ -z "${NO_COLOR:-}" ]
|
||||
then
|
||||
# Use colors when stdout is a terminal and NO_COLOR isn't set.
|
||||
text_bold=$'\033[1m'
|
||||
text_normal=$'\033[22m'
|
||||
text_red=$'\033[31m'
|
||||
text_default_color=$'\033[39m'
|
||||
else
|
||||
# Disable colors
|
||||
text_bold=""
|
||||
text_normal=""
|
||||
text_red=""
|
||||
text_default_color=""
|
||||
fi
|
||||
|
||||
abort() {
|
||||
echo -e "$text_bold$text_red$1$text_normal$text_default_color"
|
||||
exit 1
|
||||
}
|
||||
|
||||
install() {
|
||||
needs_sudo=""
|
||||
# Make sure path exists and can be written to by the current user.
|
||||
if ! mkdir -p "$prefix/lib" || [[ ! -w "$prefix/lib" ]]
|
||||
then
|
||||
needs_sudo=true
|
||||
fi
|
||||
if ! mkdir -p "$prefix/bin" || [[ ! -w "$prefix/bin" ]]
|
||||
then
|
||||
needs_sudo=true
|
||||
fi
|
||||
if ! rm -rf "$prefix/lib/eas"
|
||||
then
|
||||
needs_sudo=true
|
||||
fi
|
||||
# Run commands with sudo if necessary.
|
||||
if [ -n "$needs_sudo" ]
|
||||
then
|
||||
if [ "$EUID" -ne 0 ]
|
||||
then
|
||||
echo "Installing requires superuser access."
|
||||
echo "The sudo command will prompt for your password."
|
||||
fi
|
||||
sudo rm -rf "$prefix/lib/eas"
|
||||
sudo mv "$temp_dir/eas" "$prefix/lib/eas"
|
||||
sudo ln -fs "$prefix/lib/eas/bin/eas" "$prefix/bin/eas"
|
||||
else
|
||||
mv "$temp_dir/eas" "$prefix/lib/eas"
|
||||
ln -fs "$prefix/lib/eas/bin/eas" "$prefix/bin/eas"
|
||||
fi
|
||||
}
|
||||
|
||||
if [[ ! ":$PATH:" == *":$prefix/bin:"* ]]; then
|
||||
abort "Your path is missing $prefix/bin, you need to add this to \$PATH use this installer."
|
||||
fi
|
||||
|
||||
hardware="$(uname -m)"
|
||||
case "$hardware" in
|
||||
x86_64)
|
||||
arch="x64"
|
||||
;;
|
||||
arm*)
|
||||
arch="arm"
|
||||
;;
|
||||
*)
|
||||
abort "Unsupported CPU architecture $hardware."
|
||||
;;
|
||||
esac
|
||||
|
||||
case "$(uname -s)" in
|
||||
Darwin*)
|
||||
platform="darwin"
|
||||
if [[ "$arch" != "x64" ]]; then
|
||||
abort "Unsupported CPU architecture $hardware on macOS."
|
||||
fi
|
||||
;;
|
||||
Linux*)
|
||||
platform="linux"
|
||||
;;
|
||||
*)
|
||||
abort "This installer is supported only on macOS, Linux, and Windows Subsystem for Linux."
|
||||
;;
|
||||
esac
|
||||
url=$(hub release -L 1 | xargs hub release show --show-downloads | grep "${platform}-${arch}.tar.gz")
|
||||
echo "Installing EAS CLI from $url"
|
||||
|
||||
mkdir -p "$temp_dir"
|
||||
cd "$temp_dir"
|
||||
|
||||
echo
|
||||
echo "Downloading..."
|
||||
if [ "$(command -v curl)" ]
|
||||
then
|
||||
curl --location --progress-bar "$url" | tar xz
|
||||
else
|
||||
wget -O- "$url" | tar xz
|
||||
fi
|
||||
|
||||
# Move the installation over and link the executable (may require sudo)
|
||||
install
|
||||
|
||||
# Test the bundled node binary and if it doesn't work remove it (fall back to
|
||||
# node installed in path).
|
||||
"$prefix/lib/eas/bin/node" -v > /dev/null || {
|
||||
rm "$prefix/lib/eas/bin/node"
|
||||
}
|
||||
|
||||
# Test the CLI
|
||||
executable=$(command -v eas) || abort "Could not run eas after installation."
|
||||
echo
|
||||
echo -e "EAS CLI is installed to ${executable}"
|
||||
eas --version
|
||||
|
||||
echo
|
||||
echo -e "Type ${text_bold}eas --help${text_normal} to get started."
|
||||
}
|
||||
+2
-1
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"npmClient": "yarn",
|
||||
"useWorkspaces": true,
|
||||
"version": "0.52.0"
|
||||
"version": "3.15.1",
|
||||
"$schema": "node_modules/lerna/schemas/lerna-schema.json"
|
||||
}
|
||||
+18
-16
@@ -1,4 +1,5 @@
|
||||
{
|
||||
"name": "eas-cli-root",
|
||||
"private": true,
|
||||
"workspaces": [
|
||||
"packages/*"
|
||||
@@ -8,31 +9,32 @@
|
||||
"build:tarballs:linux": "yarn build-for-size-check",
|
||||
"build-for-size-check": "yarn build && yarn workspace eas-cli pretarball-ci && CLI_SIZE_CHECK=1 yarn workspace eas-cli oclif pack:tarballs --no-xz --targets linux-x64",
|
||||
"typecheck": "lerna run typecheck",
|
||||
"start": "lerna run watch --parallel",
|
||||
"start": "lerna run watch --stream --parallel",
|
||||
"watch": "yarn start",
|
||||
"eas": "packages/eas-cli/bin/run",
|
||||
"lint": "eslint .",
|
||||
"release": "./scripts/bin/run update-local-plugin && lerna version --exact",
|
||||
"lint-changelog": "./scripts/bin/run lint-changelog",
|
||||
"release": "lerna run rebuild && ./scripts/bin/run release",
|
||||
"test": "jest",
|
||||
"clean": "lerna run clean && rm -rf node_modules coverage"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/jest": "27.5.0",
|
||||
"@types/node": "17.0.32",
|
||||
"eslint": "8.15.0",
|
||||
"eslint-config-universe": "11.0.0",
|
||||
"eslint-plugin-async-protect": "1.1.0",
|
||||
"@types/jest": "29.2.5",
|
||||
"@types/node": "18.11.18",
|
||||
"eslint": "8.31.0",
|
||||
"eslint-config-universe": "11.1.1",
|
||||
"eslint-plugin-async-protect": "3.0.0",
|
||||
"eslint-plugin-node": "11.1.0",
|
||||
"jest": "28.1.0",
|
||||
"jest-watch-typeahead": "1.1.0",
|
||||
"lerna": "4.0.0",
|
||||
"oclif": "3.0.1",
|
||||
"prettier": "2.6.2",
|
||||
"ts-jest": "28.0.2",
|
||||
"typescript": "4.6.4"
|
||||
"jest": "29.3.1",
|
||||
"jest-watch-typeahead": "2.2.1",
|
||||
"lerna": "6.4.1",
|
||||
"oclif": "3.4.3",
|
||||
"prettier": "2.8.2",
|
||||
"ts-jest": "29.0.3",
|
||||
"typescript": "4.9.4"
|
||||
},
|
||||
"volta": {
|
||||
"node": "16.10.0",
|
||||
"yarn": "1.22.15"
|
||||
"node": "18.6.0",
|
||||
"yarn": "1.22.19"
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,24 @@ const rootEslintrc = require('../../.eslintrc.js');
|
||||
|
||||
module.exports = {
|
||||
rules: {
|
||||
'no-restricted-properties': [
|
||||
'warn',
|
||||
{
|
||||
object: 'it',
|
||||
property: 'only',
|
||||
message: 'it.only should not be committed to main.',
|
||||
},
|
||||
{
|
||||
object: 'test',
|
||||
property: 'only',
|
||||
message: 'test.only should not be committed to main.',
|
||||
},
|
||||
{
|
||||
object: 'describe',
|
||||
property: 'only',
|
||||
message: 'describe.only should not be committed to main.',
|
||||
},
|
||||
],
|
||||
'no-restricted-imports': [
|
||||
'error',
|
||||
{
|
||||
|
||||
+496
-131
File diff suppressed because it is too large.
Load diff
@@ -5,6 +5,8 @@ documents:
|
||||
- 'src/credentials/ios/api/graphql/**/!(*.d).{ts,tsx}'
|
||||
- 'src/credentials/android/api/graphql/**/!(*.d).{ts,tsx}'
|
||||
- 'src/commands/**/*.ts'
|
||||
- 'src/branch/**/*.ts'
|
||||
- 'src/channel/**/*.ts'
|
||||
generates:
|
||||
src/graphql/generated.ts:
|
||||
plugins:
|
||||
@@ -14,7 +16,7 @@ generates:
|
||||
dedupeOperationSuffix: true
|
||||
hooks:
|
||||
afterOneFileWrite:
|
||||
- ./annotate-graphql-codegen.sh
|
||||
- 'node ./scripts/annotate-graphql-codegen.js'
|
||||
./graphql.schema.json:
|
||||
plugins:
|
||||
- 'introspection'
|
||||
Generated
+12409
-749
File diff suppressed because it is too large.
Load diff
@@ -2,4 +2,5 @@ module.exports = async () => {
|
||||
process.env.TZ = 'UTC';
|
||||
process.env.EAS_NO_VCS = '1';
|
||||
process.env.EAS_PROJECT_ROOT = '/app';
|
||||
process.env.FORCE_COLOR = '1';
|
||||
};
|
||||
@@ -1,115 +1,131 @@
|
||||
{
|
||||
"name": "eas-cli",
|
||||
"description": "EAS command line tool",
|
||||
"version": "0.52.0",
|
||||
"version": "3.15.1",
|
||||
"author": "Expo <[email protected]>",
|
||||
"bin": {
|
||||
"eas": "./bin/run"
|
||||
},
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"dependencies": {
|
||||
"@expo/apple-utils": "0.0.0-alpha.31",
|
||||
"@expo/code-signing-certificates": "0.0.2",
|
||||
"@expo/config": "6.0.23",
|
||||
"@expo/config-plugins": "4.1.4",
|
||||
"@expo/config-types": "45.0.0",
|
||||
"@expo/eas-build-job": "0.2.72",
|
||||
"@expo/eas-json": "0.52.0",
|
||||
"@expo/json-file": "8.2.36",
|
||||
"@expo/apple-utils": "1.3.1",
|
||||
"@expo/code-signing-certificates": "0.0.5",
|
||||
"@expo/config": "8.1.2",
|
||||
"@expo/config-plugins": "7.2.4",
|
||||
"@expo/config-types": "49.0.0",
|
||||
"@expo/eas-build-job": "1.0.13",
|
||||
"@expo/eas-json": "3.15.1",
|
||||
"@expo/json-file": "8.2.37",
|
||||
"@expo/multipart-body-parser": "1.1.0",
|
||||
"@expo/osascript": "2.0.33",
|
||||
"@expo/package-manager": "0.0.57",
|
||||
"@expo/pkcs12": "0.0.8",
|
||||
"@expo/plist": "0.0.18",
|
||||
"@expo/plugin-warn-if-update-available": "2.2.0",
|
||||
"@expo/prebuild-config": "3.1.6",
|
||||
"@expo/plist": "0.0.20",
|
||||
"@expo/plugin-help": "5.1.22",
|
||||
"@expo/plugin-warn-if-update-available": "2.5.0",
|
||||
"@expo/prebuild-config": "6.2.5",
|
||||
"@expo/results": "1.0.0",
|
||||
"@expo/rudder-sdk-node": "1.1.1",
|
||||
"@expo/spawn-async": "1.6.0",
|
||||
"@expo/spawn-async": "1.7.0",
|
||||
"@expo/steps": "1.0.25",
|
||||
"@expo/timeago.js": "1.0.0",
|
||||
"@oclif/core": "1.8.1",
|
||||
"@oclif/plugin-autocomplete": "1.3.0",
|
||||
"@oclif/plugin-help": "5.1.12",
|
||||
"@urql/core": "2.4.4",
|
||||
"@urql/exchange-retry": "0.3.3",
|
||||
"@oclif/core": "1.23.2",
|
||||
"@oclif/plugin-autocomplete": "1.3.10",
|
||||
"@segment/ajv-human-errors": "^2.1.2",
|
||||
"@urql/core": "4.0.11",
|
||||
"@urql/exchange-graphcache": "6.1.4",
|
||||
"@urql/exchange-retry": "1.2.0",
|
||||
"ajv": "8.11.0",
|
||||
"ajv-formats": "2.1.1",
|
||||
"better-opn": "3.0.2",
|
||||
"chalk": "4.1.2",
|
||||
"cli-progress": "3.11.0",
|
||||
"cli-table3": "0.6.2",
|
||||
"cli-progress": "3.11.2",
|
||||
"dateformat": "4.6.3",
|
||||
"dotenv": "16.0.3",
|
||||
"env-paths": "2.2.0",
|
||||
"envinfo": "7.8.1",
|
||||
"fast-deep-equal": "3.1.3",
|
||||
"fast-glob": "3.2.11",
|
||||
"fast-glob": "3.2.12",
|
||||
"figures": "3.2.0",
|
||||
"form-data": "4.0.0",
|
||||
"fs-extra": "10.1.0",
|
||||
"getenv": "1.0.0",
|
||||
"gradle-to-js": "2.0.1",
|
||||
"graphql": "16.5.0",
|
||||
"graphql": "16.6.0",
|
||||
"graphql-tag": "2.12.6",
|
||||
"https-proxy-agent": "5.0.1",
|
||||
"ignore": "5.2.0",
|
||||
"ignore": "5.2.4",
|
||||
"indent-string": "4.0.0",
|
||||
"jks-js": "1.0.2",
|
||||
"joi": "17.6.0",
|
||||
"jks-js": "1.1.0",
|
||||
"joi": "17.7.0",
|
||||
"keychain": "1.3.0",
|
||||
"log-symbols": "4.1.0",
|
||||
"mime": "3.0.0",
|
||||
"minimatch": "5.0.1",
|
||||
"minimatch": "5.1.2",
|
||||
"nanoid": "3.3.4",
|
||||
"node-fetch": "2.6.7",
|
||||
"node-forge": "1.3.1",
|
||||
"nullthrows": "1.1.1",
|
||||
"ora": "5.1.0",
|
||||
"pkg-dir": "4.2.0",
|
||||
"pngjs": "6.0.0",
|
||||
"promise-limit": "2.7.0",
|
||||
"promise-retry": "2.0.1",
|
||||
"prompts": "2.4.2",
|
||||
"qrcode-terminal": "0.12.0",
|
||||
"resolve-from": "5.0.0",
|
||||
"semver": "7.3.7",
|
||||
"semver": "7.3.8",
|
||||
"slash": "3.0.0",
|
||||
"strip-ansi": "6.0.0",
|
||||
"tar": "6.1.11",
|
||||
"tar": "6.1.13",
|
||||
"terminal-link": "2.1.1",
|
||||
"tslib": "2.4.0",
|
||||
"tslib": "2.4.1",
|
||||
"turndown": "7.1.1",
|
||||
"untildify": "4.0.0",
|
||||
"uuid": "8.3.2",
|
||||
"uuid": "9.0.0",
|
||||
"wrap-ansi": "7.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@graphql-codegen/cli": "2.6.2",
|
||||
"@graphql-codegen/introspection": "2.1.1",
|
||||
"@graphql-codegen/typescript": "2.4.11",
|
||||
"@graphql-codegen/typescript-operations": "2.4.0",
|
||||
"@types/cli-progress": "3.9.2",
|
||||
"@graphql-codegen/cli": "2.16.3",
|
||||
"@graphql-codegen/introspection": "2.2.3",
|
||||
"@graphql-codegen/typescript": "2.8.7",
|
||||
"@graphql-codegen/typescript-operations": "2.5.12",
|
||||
"@types/cli-progress": "3.11.0",
|
||||
"@types/dateformat": "3.0.1",
|
||||
"@types/envinfo": "7.8.1",
|
||||
"@types/express": "4.17.15",
|
||||
"@types/fs-extra": "9.0.13",
|
||||
"@types/getenv": "^1.0.0",
|
||||
"@types/mime": "2.0.3",
|
||||
"@types/node-fetch": "^2.5.7",
|
||||
"@types/node-forge": "1.0.2",
|
||||
"@types/prompts": "2.0.14",
|
||||
"@types/semver": "7.3.9",
|
||||
"@types/tar": "6.1.1",
|
||||
"@types/mime": "3.0.1",
|
||||
"@types/node-fetch": "2.6.2",
|
||||
"@types/node-forge": "1.3.1",
|
||||
"@types/pngjs": "6.0.1",
|
||||
"@types/promise-retry": "1.1.3",
|
||||
"@types/prompts": "2.4.2",
|
||||
"@types/semver": "7.3.13",
|
||||
"@types/tar": "6.1.3",
|
||||
"@types/tough-cookie": "4.0.2",
|
||||
"@types/uuid": "8.3.4",
|
||||
"@types/uuid": "9.0.0",
|
||||
"@types/wrap-ansi": "3.0.0",
|
||||
"axios": "0.27.2",
|
||||
"eslint-plugin-graphql": "4.0.0",
|
||||
"form-data": "4.0.0",
|
||||
"memfs": "3.4.1",
|
||||
"express": "4.18.2",
|
||||
"memfs": "3.4.13",
|
||||
"mockdate": "3.0.5",
|
||||
"nock": "13.2.4",
|
||||
"tempy": "0.7.0",
|
||||
"ts-deepmerge": "2.0.1",
|
||||
"typescript": "4.6.4"
|
||||
"nock": "13.2.9",
|
||||
"rimraf": "3.0.2",
|
||||
"ts-deepmerge": "4.0.0",
|
||||
"ts-mockito": "2.6.1",
|
||||
"ts-node": "10.9.1",
|
||||
"typescript": "4.9.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
"node": ">=14.0.0"
|
||||
},
|
||||
"files": [
|
||||
"/bin",
|
||||
"/build",
|
||||
"/oclif.manifest.json"
|
||||
"/oclif.manifest.json",
|
||||
"/schema"
|
||||
],
|
||||
"homepage": "https://github.com/expo/eas-cli",
|
||||
"keywords": [
|
||||
@@ -123,7 +139,7 @@
|
||||
"commands": "./build/commands",
|
||||
"plugins": [
|
||||
"@oclif/plugin-autocomplete",
|
||||
"@oclif/plugin-help",
|
||||
"@expo/plugin-help",
|
||||
"@expo/plugin-warn-if-update-available"
|
||||
],
|
||||
"topics": {
|
||||
@@ -142,6 +158,9 @@
|
||||
"device": {
|
||||
"description": "manage Apple devices for Internal Distribution"
|
||||
},
|
||||
"metadata": {
|
||||
"description": "manage store configuration"
|
||||
},
|
||||
"project": {
|
||||
"description": "manage project"
|
||||
},
|
||||
@@ -160,11 +179,11 @@
|
||||
},
|
||||
"warn-if-update-available": {
|
||||
"timeoutInDays": 0.5,
|
||||
"message": "<%= chalk.bold('★') %> <%= chalk.bold(config.name + '@' + latest) %> <%= chalk.bold('is now available, please upgrade.') %>\n<%= chalk.dim('Proceeding with outdated version') %>\n"
|
||||
"message": "<%= chalk('★') %> <%= chalk.bold(config.name + '@' + latest) %> <%= chalk.bold('is now available.') %>\n<%= chalk.dim('To upgrade, run ') %><%= chalk.dim(chalk.bold('npm install -g eas-cli')) %><%= chalk.dim('.') %>\n<%= chalk.dim('Proceeding with outdated version.') %>\n"
|
||||
},
|
||||
"update": {
|
||||
"node": {
|
||||
"version": "12.13.0"
|
||||
"version": "18.6.0"
|
||||
},
|
||||
"s3": {
|
||||
"templates": {
|
||||
@@ -184,8 +203,9 @@
|
||||
},
|
||||
"repository": "expo/eas-cli",
|
||||
"scripts": {
|
||||
"postpack": "rm -f oclif.manifest.json",
|
||||
"prepack": "./scripts/prepack.sh",
|
||||
"postpack": "rimraf oclif.manifest.json",
|
||||
"prepack": "yarn rebuild && node ./scripts/prepack.js",
|
||||
"rebuild": "rimraf build && yarn build",
|
||||
"pretarball-ci": "./scripts/pretarball-ci.sh",
|
||||
"build": "tsc --project tsconfig.build.json",
|
||||
"watch": "yarn build --watch --preserveWatchOutput",
|
||||
@@ -193,6 +213,10 @@
|
||||
"test": "jest",
|
||||
"version": "yarn oclif readme && node scripts/patch-readme && git add README.md",
|
||||
"generate-graphql-code": "graphql-codegen --config graphql-codegen.yml",
|
||||
"clean": "rm -rf dist build tmp node_modules yarn-error.log"
|
||||
"clean": "rimraf dist build tmp node_modules yarn-error.log"
|
||||
},
|
||||
"volta": {
|
||||
"node": "18.6.0",
|
||||
"yarn": "1.22.19"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,793 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-06/schema",
|
||||
"type": "object",
|
||||
"definitions": {
|
||||
"apple": {
|
||||
"AppleKidsAge": {
|
||||
"enum": [
|
||||
"FIVE_AND_UNDER",
|
||||
"SIX_TO_EIGHT",
|
||||
"NINE_TO_ELEVEN"
|
||||
]
|
||||
},
|
||||
"AppleRating": {
|
||||
"enum": [
|
||||
"NONE",
|
||||
"INFREQUENT_OR_MILD",
|
||||
"FREQUENT_OR_INTENSE"
|
||||
]
|
||||
},
|
||||
"AppleCategory": {
|
||||
"type": "array",
|
||||
"description": "App Store categories for the app, can add up to two categories. The first item in this list is the primary category, the second is the secondary and optional category. `STICKERS` and `GAMES` categories can add up to two additional subcategories, written as nested array.",
|
||||
"markdownDescription": "App Store categories for the app, can add up to two categories. The first item in this list is the primary category, the second is the secondary and optional category. `STICKERS` and `GAMES` categories can add up to two additional subcategories, written as nested array. E.g.\n\n- `[\"SPORTS\"]`\n- `[\"SPORTS\", \"LIFESTYLE\"]`\n- `[[\"GAMES\", \"GAMES_CARD\"], \"ENTERTAINMENT\"]`\n- `[\"GAMES\", \"WEATHER\"]`",
|
||||
"minItems": 1,
|
||||
"maxItems": 2,
|
||||
"uniqueItems": true,
|
||||
"defaultSnippets": [
|
||||
{
|
||||
"label": "Basic categories",
|
||||
"description": "Add two simple categories",
|
||||
"body": [
|
||||
"$1",
|
||||
"$2"
|
||||
]
|
||||
},
|
||||
{
|
||||
"label": "Category with subcategories",
|
||||
"description": "Add a category containing subcategories",
|
||||
"body": [
|
||||
[
|
||||
"${1|GAMES,STICKERS|}",
|
||||
"$3",
|
||||
"$4"
|
||||
]
|
||||
]
|
||||
}
|
||||
],
|
||||
"items": {
|
||||
"oneOf": [
|
||||
{
|
||||
"$ref": "#/definitions/apple/AppleCategory/categories/root"
|
||||
},
|
||||
{
|
||||
"$ref": "#/definitions/apple/AppleCategory/categories/parent"
|
||||
},
|
||||
{
|
||||
"type": "array",
|
||||
"description": "The GAMES category can have additional subcategories",
|
||||
"minItems": 1,
|
||||
"maxItems": 3,
|
||||
"uniqueItems": true,
|
||||
"items": [
|
||||
{
|
||||
"const": "GAMES"
|
||||
},
|
||||
{
|
||||
"$ref": "#/definitions/apple/AppleCategory/categories/games"
|
||||
},
|
||||
{
|
||||
"$ref": "#/definitions/apple/AppleCategory/categories/games"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "array",
|
||||
"description": "The STICKERS category can have additional subcategories",
|
||||
"minItems": 1,
|
||||
"maxItems": 3,
|
||||
"uniqueItems": true,
|
||||
"items": [
|
||||
{
|
||||
"const": "STICKERS"
|
||||
},
|
||||
{
|
||||
"$ref": "#/definitions/apple/AppleCategory/categories/stickers"
|
||||
},
|
||||
{
|
||||
"$ref": "#/definitions/apple/AppleCategory/categories/stickers"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"categories": {
|
||||
"root": {
|
||||
"enum": [
|
||||
"BUSINESS",
|
||||
"SOCIAL_NETWORKING",
|
||||
"MEDICAL",
|
||||
"FOOD_AND_DRINK",
|
||||
"EDUCATION",
|
||||
"BOOKS",
|
||||
"SPORTS",
|
||||
"FINANCE",
|
||||
"REFERENCE",
|
||||
"GRAPHICS_AND_DESIGN",
|
||||
"DEVELOPER_TOOLS",
|
||||
"HEALTH_AND_FITNESS",
|
||||
"MUSIC",
|
||||
"WEATHER",
|
||||
"TRAVEL",
|
||||
"ENTERTAINMENT",
|
||||
"LIFESTYLE",
|
||||
"MAGAZINES_AND_NEWSPAPERS",
|
||||
"UTILITIES",
|
||||
"SHOPPING",
|
||||
"PRODUCTIVITY",
|
||||
"NEWS",
|
||||
"PHOTO_AND_VIDEO",
|
||||
"NAVIGATION"
|
||||
]
|
||||
},
|
||||
"parent": {
|
||||
"enum": [
|
||||
"GAMES",
|
||||
"STICKERS"
|
||||
]
|
||||
},
|
||||
"games": {
|
||||
"enum": [
|
||||
"GAMES_SPORTS",
|
||||
"GAMES_WORD",
|
||||
"GAMES_MUSIC",
|
||||
"GAMES_ADVENTURE",
|
||||
"GAMES_ACTION",
|
||||
"GAMES_ROLE_PLAYING",
|
||||
"GAMES_CASUAL",
|
||||
"GAMES_BOARD",
|
||||
"GAMES_TRIVIA",
|
||||
"GAMES_CARD",
|
||||
"GAMES_PUZZLE",
|
||||
"GAMES_CASINO",
|
||||
"GAMES_STRATEGY",
|
||||
"GAMES_SIMULATION",
|
||||
"GAMES_RACING",
|
||||
"GAMES_FAMILY"
|
||||
]
|
||||
},
|
||||
"stickers": {
|
||||
"enum": [
|
||||
"STICKERS_PLACES_AND_OBJECTS",
|
||||
"STICKERS_EMOJI_AND_EXPRESSIONS",
|
||||
"STICKERS_CELEBRATIONS",
|
||||
"STICKERS_CELEBRITIES",
|
||||
"STICKERS_MOVIES_AND_TV",
|
||||
"STICKERS_SPORTS_AND_ACTIVITIES",
|
||||
"STICKERS_EATING_AND_DRINKING",
|
||||
"STICKERS_CHARACTERS",
|
||||
"STICKERS_ANIMALS",
|
||||
"STICKERS_FASHION",
|
||||
"STICKERS_ART",
|
||||
"STICKERS_GAMING",
|
||||
"STICKERS_KIDS_AND_FAMILY",
|
||||
"STICKERS_PEOPLE",
|
||||
"STICKERS_MUSIC"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"AppleInfo": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"title",
|
||||
"privacyPolicyUrl"
|
||||
],
|
||||
"defaultSnippets": [
|
||||
{
|
||||
"label": "Basic",
|
||||
"description": "Create the minimum required locale properties",
|
||||
"body": {
|
||||
"title": "${1:App title}",
|
||||
"privacyPolicyUrl": "https://${3:www.}"
|
||||
}
|
||||
},
|
||||
{
|
||||
"label": "Full",
|
||||
"description": "Create all available locale properties",
|
||||
"body": {
|
||||
"title": "${1:App title}",
|
||||
"subtitle": "${2:Subtitle for your app}",
|
||||
"description": "${3:A longer description of what your app does}",
|
||||
"keywords": [
|
||||
"${4:keyword}"
|
||||
],
|
||||
"releaseNotes": "${5:Bug fixes and improved stability}",
|
||||
"promoText": "${6:Short tagline for your app}",
|
||||
"marketingUrl": "https://${7:www.}",
|
||||
"supportUrl": "https://${8:www.}",
|
||||
"privacyPolicyUrl": "https://${9:www.}",
|
||||
"privacyChoicesUrl": "https://${10:www.}"
|
||||
}
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"title": {
|
||||
"type": "string",
|
||||
"description": "Name of the app in the store. This should be similar to the installed app name.",
|
||||
"minLength": 2,
|
||||
"maxLength": 30,
|
||||
"meta": {
|
||||
"storeInfo": "The name will be reviewed before it is made available on the App Store."
|
||||
}
|
||||
},
|
||||
"subtitle": {
|
||||
"type": "string",
|
||||
"description": "Subtext for the app. Example: 'A Fun Game For Friends'",
|
||||
"maxLength": 30,
|
||||
"meta": {
|
||||
"storeInfo": "The subtitle will be reviewed before it is made available on the App Store."
|
||||
}
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"description": "Main description of what the app does",
|
||||
"minLength": 10,
|
||||
"maxLength": 4000,
|
||||
"meta": {
|
||||
"versioned": true,
|
||||
"liveEdits": true
|
||||
}
|
||||
},
|
||||
"keywords": {
|
||||
"type": "array",
|
||||
"description": "List of keywords to help users find the app in the App Store",
|
||||
"uniqueItems": true,
|
||||
"items": {
|
||||
"maxLength": 100,
|
||||
"type": "string"
|
||||
},
|
||||
"meta": {
|
||||
"versioned": true
|
||||
}
|
||||
},
|
||||
"releaseNotes": {
|
||||
"type": "string",
|
||||
"description": "Changes since the last public version",
|
||||
"maxLength": 4000,
|
||||
"meta": {
|
||||
"versioned": true,
|
||||
"liveEdits": true
|
||||
}
|
||||
},
|
||||
"promoText": {
|
||||
"type": "string",
|
||||
"description": "Short tagline for the app",
|
||||
"maxLength": 170,
|
||||
"meta": {
|
||||
"versioned": true,
|
||||
"liveEdits": true,
|
||||
"aso": false
|
||||
}
|
||||
},
|
||||
"marketingUrl": {
|
||||
"type": "string",
|
||||
"description": "URL to the app marketing page",
|
||||
"maxLength": 255,
|
||||
"meta": {
|
||||
"versioned": true,
|
||||
"liveEdits": true
|
||||
}
|
||||
},
|
||||
"supportUrl": {
|
||||
"type": "string",
|
||||
"description": "URL to the app support page",
|
||||
"maxLength": 255,
|
||||
"meta": {
|
||||
"versioned": true,
|
||||
"liveEdits": true
|
||||
}
|
||||
},
|
||||
"privacyPolicyText": {
|
||||
"type": "string",
|
||||
"description": "Privacy policy for Apple TV"
|
||||
},
|
||||
"privacyPolicyUrl": {
|
||||
"type": "string",
|
||||
"description": "A URL that links to your privacy policy. A privacy policy is required for all apps.",
|
||||
"maxLength": 255,
|
||||
"meta": {
|
||||
"versioned": true,
|
||||
"liveEdits": true
|
||||
}
|
||||
},
|
||||
"privacyChoicesUrl": {
|
||||
"type": "string",
|
||||
"description": "A URL where users can modify and delete the data collected from the app, or decide how their data is used and shared.",
|
||||
"maxLength": 255,
|
||||
"meta": {
|
||||
"versioned": true,
|
||||
"liveEdits": true,
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"AppleAdvisory": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"alcoholTobaccoOrDrugUseOrReferences",
|
||||
"contests",
|
||||
"gamblingSimulated",
|
||||
"horrorOrFearThemes",
|
||||
"matureOrSuggestiveThemes",
|
||||
"medicalOrTreatmentInformation",
|
||||
"profanityOrCrudeHumor",
|
||||
"sexualContentGraphicAndNudity",
|
||||
"sexualContentOrNudity",
|
||||
"violenceCartoonOrFantasy",
|
||||
"violenceRealistic",
|
||||
"violenceRealisticProlongedGraphicOrSadistic",
|
||||
"gambling",
|
||||
"unrestrictedWebAccess",
|
||||
"kidsAgeBand",
|
||||
"seventeenPlus"
|
||||
],
|
||||
"defaultSnippets": [
|
||||
{
|
||||
"label": "Basic",
|
||||
"description": "Create an advisory that disables all age-rated properties, for when your app does nothing",
|
||||
"body": {
|
||||
"alcoholTobaccoOrDrugUseOrReferences": "NONE",
|
||||
"contests": "NONE",
|
||||
"gamblingSimulated": "NONE",
|
||||
"horrorOrFearThemes": "NONE",
|
||||
"matureOrSuggestiveThemes": "NONE",
|
||||
"medicalOrTreatmentInformation": "NONE",
|
||||
"profanityOrCrudeHumor": "NONE",
|
||||
"sexualContentGraphicAndNudity": "NONE",
|
||||
"sexualContentOrNudity": "NONE",
|
||||
"violenceCartoonOrFantasy": "NONE",
|
||||
"violenceRealistic": "NONE",
|
||||
"violenceRealisticProlongedGraphicOrSadistic": "NONE",
|
||||
"gambling": false,
|
||||
"unrestrictedWebAccess": false,
|
||||
"kidsAgeBand": null,
|
||||
"seventeenPlus": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"label": "Full",
|
||||
"description": "Create an advisory that enables some of the age-rated properties",
|
||||
"body": {
|
||||
"alcoholTobaccoOrDrugUseOrReferences": "${1|NONE,INFREQUENT_OR_MILD,FREQUENT_OR_INTENSE|}",
|
||||
"contests": "${2|NONE,INFREQUENT_OR_MILD,FREQUENT_OR_INTENSE|}",
|
||||
"gamblingSimulated": "${3|NONE,INFREQUENT_OR_MILD,FREQUENT_OR_INTENSE|}",
|
||||
"horrorOrFearThemes": "${4|NONE,INFREQUENT_OR_MILD,FREQUENT_OR_INTENSE|}",
|
||||
"matureOrSuggestiveThemes": "${5|NONE,INFREQUENT_OR_MILD,FREQUENT_OR_INTENSE|}",
|
||||
"medicalOrTreatmentInformation": "${6|NONE,INFREQUENT_OR_MILD,FREQUENT_OR_INTENSE|}",
|
||||
"profanityOrCrudeHumor": "${7|NONE,INFREQUENT_OR_MILD,FREQUENT_OR_INTENSE|}",
|
||||
"sexualContentGraphicAndNudity": "${8|NONE,INFREQUENT_OR_MILD,FREQUENT_OR_INTENSE|}",
|
||||
"sexualContentOrNudity": "${9|NONE,INFREQUENT_OR_MILD,FREQUENT_OR_INTENSE|}",
|
||||
"violenceCartoonOrFantasy": "${10|NONE,INFREQUENT_OR_MILD,FREQUENT_OR_INTENSE|}",
|
||||
"violenceRealistic": "${11|NONE,INFREQUENT_OR_MILD,FREQUENT_OR_INTENSE|}",
|
||||
"violenceRealisticProlongedGraphicOrSadistic": "${12|NONE,INFREQUENT_OR_MILD,FREQUENT_OR_INTENSE|}",
|
||||
"unrestrictedWebAccess": false,
|
||||
"gambling": false,
|
||||
"kidsAgeBand": null,
|
||||
"seventeenPlus": false
|
||||
}
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"alcoholTobaccoOrDrugUseOrReferences": {
|
||||
"$ref": "#/definitions/apple/AppleRating",
|
||||
"description": "Does the app contain alcohol, tobacco, or drug use or references?"
|
||||
},
|
||||
"contests": {
|
||||
"$ref": "#/definitions/apple/AppleRating",
|
||||
"description": "Does the app contain contests?"
|
||||
},
|
||||
"gamblingSimulated": {
|
||||
"$ref": "#/definitions/apple/AppleRating",
|
||||
"description": "Does the app contain simulated gambling?"
|
||||
},
|
||||
"medicalOrTreatmentInformation": {
|
||||
"$ref": "#/definitions/apple/AppleRating",
|
||||
"description": "Does the app contain medical or treatment information?"
|
||||
},
|
||||
"profanityOrCrudeHumor": {
|
||||
"$ref": "#/definitions/apple/AppleRating",
|
||||
"description": "Does the app contain profanity or crude humor?"
|
||||
},
|
||||
"sexualContentGraphicAndNudity": {
|
||||
"$ref": "#/definitions/apple/AppleRating",
|
||||
"description": "Does the app contain graphic sexual content and nudity?"
|
||||
},
|
||||
"sexualContentOrNudity": {
|
||||
"$ref": "#/definitions/apple/AppleRating",
|
||||
"description": "Does the app contain sexual content or nudity?"
|
||||
},
|
||||
"horrorOrFearThemes": {
|
||||
"$ref": "#/definitions/apple/AppleRating",
|
||||
"description": "Does the app contain horror or fear themes?"
|
||||
},
|
||||
"matureOrSuggestiveThemes": {
|
||||
"$ref": "#/definitions/apple/AppleRating",
|
||||
"description": "Does the app contain mature or suggestive themes?"
|
||||
},
|
||||
"violenceCartoonOrFantasy": {
|
||||
"$ref": "#/definitions/apple/AppleRating",
|
||||
"description": "Does the app contain cartoon or fantasy violence?"
|
||||
},
|
||||
"violenceRealisticProlongedGraphicOrSadistic": {
|
||||
"$ref": "#/definitions/apple/AppleRating",
|
||||
"description": "Does the app contain prolonged graphic or sadistic realistic violence?"
|
||||
},
|
||||
"violenceRealistic": {
|
||||
"$ref": "#/definitions/apple/AppleRating",
|
||||
"description": "Does the app contain realistic violence?"
|
||||
},
|
||||
"unrestrictedWebAccess": {
|
||||
"type": "boolean",
|
||||
"description": "Does your app contain unrestricted web access, such as with an embedded browser?"
|
||||
},
|
||||
"gambling": {
|
||||
"type": "boolean",
|
||||
"description": "Does your app contain gambling?"
|
||||
},
|
||||
"kidsAgeBand": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "null"
|
||||
},
|
||||
{
|
||||
"$ref": "#/definitions/apple/AppleKidsAge"
|
||||
}
|
||||
],
|
||||
"description": "When parents visit the Kids category on the App Store, they expect the apps they find will protect their children’s data, provide only age-appropriate content, and require a parental gate in order to link out of the app, request permissions, or present purchasing opportunities. It’s critical that no personally identifiable information or device information be transmitted to third parties, and that advertisements are human-reviewed for age appropriateness in order to be displayed.\n@see https://developer-mdn.apple.com/news/?id=091202019a",
|
||||
"markdownDescription": "When parents visit the Kids category on the App Store, they expect the apps they find will protect their children’s data, provide only age-appropriate content, and require a parental gate in order to link out of the app, request permissions, or present purchasing opportunities. It’s critical that no personally identifiable information or device information be transmitted to third parties, and that advertisements are human-reviewed for age appropriateness in order to be displayed.\n\n[Learn more](https://developer-mdn.apple.com/news/?id=091202019a)"
|
||||
},
|
||||
"seventeenPlus": {
|
||||
"type": "boolean",
|
||||
"description": "If your app rates 12+ or lower, and you believe its content may not be suitable for children under 17, you can manually set the age rating to 17+.\n@see https://help.apple.com/app-store-connect/#/dev599d50efb",
|
||||
"markdownDescription": "If your app rates 12+ or lower, and you believe its content may not be suitable for children under 17, you can manually set the age rating to 17+.\n\n[Learn more](https://help.apple.com/app-store-connect/#/dev599d50efb)"
|
||||
}
|
||||
}
|
||||
},
|
||||
"AppleRelease": {
|
||||
"type": "object",
|
||||
"description": "Configure how the app is released in the app store.",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"automaticRelease": {
|
||||
"description": "If and how the app should automatically be released when approved by App Store review.",
|
||||
"markdownDescription": "If and how the app should automatically be released when approved by App Store review.\n- `false` - Manually release the app after store approval. (default behavior)\n- `true` - Automatically release after store approval.\n- `Date` - Automatically schedule release on this date, after store approval.\n\nApple does not guarantee that your app is available at the chosen scheduled release date.",
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
{
|
||||
"type": "boolean"
|
||||
}
|
||||
],
|
||||
"defaultSnippets": [
|
||||
{
|
||||
"label": "Manual release",
|
||||
"description": "Manually release the app after store approval",
|
||||
"body": false
|
||||
},
|
||||
{
|
||||
"label": "Automatic release",
|
||||
"description": "Automatically release the app after store approval",
|
||||
"body": true
|
||||
},
|
||||
{
|
||||
"label": "Scheduled release",
|
||||
"description": "Automatically release the app on this date, and store approval",
|
||||
"body": "$CURRENT_YEAR-${1:$CURRENT_MONTH}-${2:$CURRENT_DATE}T${3:$CURRENT_HOUR}:00:00+00:00"
|
||||
}
|
||||
]
|
||||
},
|
||||
"phasedRelease": {
|
||||
"type": "boolean",
|
||||
"description": "Phased release for automatic updates lets you gradually release this update over a 7-day period to users who have turned on automatic updates. Keep in mind that this version will still be available to all users as a manual update from the App Store. You can pause the phased release for up to 30 days or release this update to all users at any time.\n@see https://help.apple.com/app-store-connect/#/dev3d65fcee1",
|
||||
"markdownDescription": "Phased release for automatic updates lets you gradually release this update over a 7-day period to users who have turned on automatic updates.\n\nKeep in mind that this version will still be available to all users as a manual update from the App Store.\n\nYou can pause the phased release for up to 30 days or release this update to all users at any time.\n\n[Learn More](https://help.apple.com/app-store-connect/#/dev3d65fcee1)"
|
||||
}
|
||||
}
|
||||
},
|
||||
"AppleReview": {
|
||||
"type": "object",
|
||||
"description": "Info provided to the App Store review team to help them understand how to use your app.",
|
||||
"required": [
|
||||
"firstName",
|
||||
"lastName",
|
||||
"email",
|
||||
"phone"
|
||||
],
|
||||
"defaultSnippets": [
|
||||
{
|
||||
"label": "Basic",
|
||||
"description": "Only define the contact details in case communcation is needed with the App Store review team.",
|
||||
"body": {
|
||||
"firstName": "$1",
|
||||
"lastName": "$2",
|
||||
"email": "$3",
|
||||
"phone": "+$4"
|
||||
}
|
||||
},
|
||||
{
|
||||
"label": "Full",
|
||||
"description": "Define the demo account credentials, additional info, and contact details for the App Store review team.",
|
||||
"body": {
|
||||
"firstName": "$1",
|
||||
"lastName": "$2",
|
||||
"email": "$3",
|
||||
"phone": "+$4",
|
||||
"demoUsername": "$5",
|
||||
"demoPassword": "$6",
|
||||
"demoRequired": true,
|
||||
"notes": "$7"
|
||||
}
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"firstName": {
|
||||
"type": "string",
|
||||
"description": "First name of contact in case communication is needed with the App Store review team.",
|
||||
"minLength": 1
|
||||
},
|
||||
"lastName": {
|
||||
"type": "string",
|
||||
"description": "Last name of contact in case communication is needed with the App Store review team.",
|
||||
"minLength": 1
|
||||
},
|
||||
"email": {
|
||||
"type": "string",
|
||||
"description": "Email address of contact in case communication is needed with the App Store review team.",
|
||||
"format": "email"
|
||||
},
|
||||
"phone": {
|
||||
"type": "string",
|
||||
"description": "Phone number of contact in case communication is needed with the App Store review team. Preface the phone number with ‘+’ followed by the country code (for example, +44 844 209 0611)"
|
||||
},
|
||||
"demoUsername": {
|
||||
"type": "string",
|
||||
"description": "The user name to sign in to your app to review its features."
|
||||
},
|
||||
"demoPassword": {
|
||||
"type": "string",
|
||||
"description": "The password to sign in to your app to review its features."
|
||||
},
|
||||
"demoRequired": {
|
||||
"type": "boolean",
|
||||
"description": "A Boolean value that indicates if sign-in information is required to review all the features of your app. If users sign in using social media, provide information for an account for review. Credentials must be valid and active for duration of review.",
|
||||
"markdownDescription": "A Boolean value that indicates if sign-in information is required to review all the features of your app. If users sign in using social media, provide information for an account for review.\n\n**Credentials must be valid and active for duration of review.**"
|
||||
},
|
||||
"notes": {
|
||||
"type": "string",
|
||||
"description": "Additional information about your app that can help during the review process. Do not include demo account details.",
|
||||
"markdownDescription": "Additional information about your app that can help during the review process.\n\n**Do not include demo account details.**",
|
||||
"minLength": 2,
|
||||
"maxLength": 4000
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"AppleConfig": {
|
||||
"type": "object",
|
||||
"description": "Configuration that is specific to the Apple App Store.",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"version": {
|
||||
"type": "string",
|
||||
"description": "The version string to edit, or create, if it doesn't exist. All the metadata will be stored on this app version.",
|
||||
"minLength": 1,
|
||||
"defaultSnippets": [
|
||||
{
|
||||
"body": "${1:1}.${2:0}"
|
||||
}
|
||||
]
|
||||
},
|
||||
"copyright": {
|
||||
"type": "string",
|
||||
"description": "The updated company copyright. Example: 2021 Evan Bacon",
|
||||
"defaultSnippets": [
|
||||
{
|
||||
"body": "$CURRENT_YEAR ${1:name}"
|
||||
}
|
||||
],
|
||||
"meta": {
|
||||
"versioned": true,
|
||||
"liveEdits": true,
|
||||
"storeInfo": "The name of the person or entity that owns the exclusive rights to your app, preceded by the year the rights were obtained (for example, \"2008 Acme Inc.\"). Do not provide a URL."
|
||||
}
|
||||
},
|
||||
"advisory": {
|
||||
"$ref": "#/definitions/apple/AppleAdvisory"
|
||||
},
|
||||
"categories": {
|
||||
"$ref": "#/definitions/apple/AppleCategory"
|
||||
},
|
||||
"info": {
|
||||
"description": "Localized core app info",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"ar-SA": {
|
||||
"$ref": "#/definitions/apple/AppleInfo",
|
||||
"description": "Arabic"
|
||||
},
|
||||
"ca": {
|
||||
"$ref": "#/definitions/apple/AppleInfo",
|
||||
"description": "Catalan"
|
||||
},
|
||||
"zh-Hans": {
|
||||
"$ref": "#/definitions/apple/AppleInfo",
|
||||
"description": "Chinese (Simplified)"
|
||||
},
|
||||
"zh-Hant": {
|
||||
"$ref": "#/definitions/apple/AppleInfo",
|
||||
"description": "Chinese (Traditional)"
|
||||
},
|
||||
"hr": {
|
||||
"$ref": "#/definitions/apple/AppleInfo",
|
||||
"description": "Croatian"
|
||||
},
|
||||
"cs": {
|
||||
"$ref": "#/definitions/apple/AppleInfo",
|
||||
"description": "Czech"
|
||||
},
|
||||
"da": {
|
||||
"$ref": "#/definitions/apple/AppleInfo",
|
||||
"description": "Danish"
|
||||
},
|
||||
"nl-NL": {
|
||||
"$ref": "#/definitions/apple/AppleInfo",
|
||||
"description": "Dutch"
|
||||
},
|
||||
"en-AU": {
|
||||
"$ref": "#/definitions/apple/AppleInfo",
|
||||
"description": "English (Australia)"
|
||||
},
|
||||
"en-CA": {
|
||||
"$ref": "#/definitions/apple/AppleInfo",
|
||||
"description": "English (Canada)"
|
||||
},
|
||||
"en-GB": {
|
||||
"$ref": "#/definitions/apple/AppleInfo",
|
||||
"description": "English (U.K.)"
|
||||
},
|
||||
"en-US": {
|
||||
"$ref": "#/definitions/apple/AppleInfo",
|
||||
"description": "English (U.S.)"
|
||||
},
|
||||
"fi": {
|
||||
"$ref": "#/definitions/apple/AppleInfo",
|
||||
"description": "Finnish"
|
||||
},
|
||||
"fr-FR": {
|
||||
"$ref": "#/definitions/apple/AppleInfo",
|
||||
"description": "French"
|
||||
},
|
||||
"fr-CA": {
|
||||
"$ref": "#/definitions/apple/AppleInfo",
|
||||
"description": "French (Canada)"
|
||||
},
|
||||
"de-DE": {
|
||||
"$ref": "#/definitions/apple/AppleInfo",
|
||||
"description": "German"
|
||||
},
|
||||
"el": {
|
||||
"$ref": "#/definitions/apple/AppleInfo",
|
||||
"description": "Greek"
|
||||
},
|
||||
"he": {
|
||||
"$ref": "#/definitions/apple/AppleInfo",
|
||||
"description": "Hebrew"
|
||||
},
|
||||
"hi": {
|
||||
"$ref": "#/definitions/apple/AppleInfo",
|
||||
"description": "Hindi"
|
||||
},
|
||||
"hu": {
|
||||
"$ref": "#/definitions/apple/AppleInfo",
|
||||
"description": "Hungarian"
|
||||
},
|
||||
"id": {
|
||||
"$ref": "#/definitions/apple/AppleInfo",
|
||||
"description": "Indonesian"
|
||||
},
|
||||
"it": {
|
||||
"$ref": "#/definitions/apple/AppleInfo",
|
||||
"description": "Italian"
|
||||
},
|
||||
"ja": {
|
||||
"$ref": "#/definitions/apple/AppleInfo",
|
||||
"description": "Japanese"
|
||||
},
|
||||
"ko": {
|
||||
"$ref": "#/definitions/apple/AppleInfo",
|
||||
"description": "Korean"
|
||||
},
|
||||
"ms": {
|
||||
"$ref": "#/definitions/apple/AppleInfo",
|
||||
"description": "Malay"
|
||||
},
|
||||
"no": {
|
||||
"$ref": "#/definitions/apple/AppleInfo",
|
||||
"description": "Norwegian"
|
||||
},
|
||||
"pl": {
|
||||
"$ref": "#/definitions/apple/AppleInfo",
|
||||
"description": "Polish"
|
||||
},
|
||||
"pt-BR": {
|
||||
"$ref": "#/definitions/apple/AppleInfo",
|
||||
"description": "Portuguese (Brazil)"
|
||||
},
|
||||
"pt-PT": {
|
||||
"$ref": "#/definitions/apple/AppleInfo",
|
||||
"description": "Portuguese (Portugal)"
|
||||
},
|
||||
"ro": {
|
||||
"$ref": "#/definitions/apple/AppleInfo",
|
||||
"description": "Romanian"
|
||||
},
|
||||
"ru": {
|
||||
"$ref": "#/definitions/apple/AppleInfo",
|
||||
"description": "Russian"
|
||||
},
|
||||
"sk": {
|
||||
"$ref": "#/definitions/apple/AppleInfo",
|
||||
"description": "Slovak"
|
||||
},
|
||||
"es-MX": {
|
||||
"$ref": "#/definitions/apple/AppleInfo",
|
||||
"description": "Spanish (Mexico)"
|
||||
},
|
||||
"es-ES": {
|
||||
"$ref": "#/definitions/apple/AppleInfo",
|
||||
"description": "Spanish (Spain)"
|
||||
},
|
||||
"sv": {
|
||||
"$ref": "#/definitions/apple/AppleInfo",
|
||||
"description": "Swedish"
|
||||
},
|
||||
"th": {
|
||||
"$ref": "#/definitions/apple/AppleInfo",
|
||||
"description": "Thai"
|
||||
},
|
||||
"tr": {
|
||||
"$ref": "#/definitions/apple/AppleInfo",
|
||||
"description": "Turkish"
|
||||
},
|
||||
"uk": {
|
||||
"$ref": "#/definitions/apple/AppleInfo",
|
||||
"description": "Ukrainian"
|
||||
},
|
||||
"vi": {
|
||||
"$ref": "#/definitions/apple/AppleInfo",
|
||||
"description": "Vietnamese"
|
||||
}
|
||||
}
|
||||
},
|
||||
"release": {
|
||||
"$ref": "#/definitions/apple/AppleRelease",
|
||||
"description": "Release strategy"
|
||||
},
|
||||
"review": {
|
||||
"$ref": "#/definitions/apple/AppleReview",
|
||||
"description": "Info for the App Store reviewer"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"properties": {
|
||||
"configVersion": {
|
||||
"enum": [0],
|
||||
"description": "The EAS metadata store configuration version"
|
||||
},
|
||||
"apple": {
|
||||
"$ref": "#/definitions/AppleConfig"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"configVersion",
|
||||
"apple"
|
||||
]
|
||||
}
|
||||
Executable → Regular
+8
-6
@@ -1,13 +1,15 @@
|
||||
#!/usr/bin/env sh
|
||||
const fs = require('fs/promises');
|
||||
|
||||
OUTFILE="src/graphql/generated.ts"
|
||||
const path = './src/graphql/generated.ts';
|
||||
|
||||
content="/**
|
||||
(async () => {
|
||||
const generatedCode = await fs.readFile(path, 'utf8');
|
||||
const comment = `/**
|
||||
* This file was generated using GraphQL Codegen
|
||||
* Command: yarn generate-graphql-code
|
||||
* Run this during development for automatic type generation when editing GraphQL documents
|
||||
* For more info and docs, visit https://graphql-code-generator.com/
|
||||
*/
|
||||
"
|
||||
*/\n\n`;
|
||||
|
||||
printf '%s\n%s\n' "$content" "$(cat $OUTFILE)" > $OUTFILE
|
||||
await fs.writeFile(path, comment + generatedCode);
|
||||
})();
|
||||
@@ -1,4 +1,4 @@
|
||||
const { promises: fs } = require('fs');
|
||||
const fs = require('fs/promises');
|
||||
|
||||
(async () => {
|
||||
// Patch `oclif readme` path and link generation
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
const spawn = require('@expo/spawn-async');
|
||||
|
||||
(async () => {
|
||||
if (!process.env.CLI_SIZE_CHECK) {
|
||||
await spawn('yarn', ['oclif', 'manifest'], { stdio: 'inherit' });
|
||||
}
|
||||
})();
|
||||
@@ -1,10 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -eo pipefail
|
||||
|
||||
rm -rf build
|
||||
yarn build
|
||||
|
||||
if [ -z "$CLI_SIZE_CHECK" ]; then
|
||||
yarn oclif manifest
|
||||
fi
|
||||
@@ -0,0 +1,23 @@
|
||||
import { JSONValue } from '@expo/json-file';
|
||||
|
||||
export class ApiV2Error extends Error {
|
||||
override readonly name = 'ApiV2Error';
|
||||
readonly expoApiV2ErrorCode: string;
|
||||
readonly expoApiV2ErrorDetails?: JSONValue;
|
||||
readonly expoApiV2ErrorServerStack?: string;
|
||||
readonly expoApiV2ErrorMetadata?: object;
|
||||
|
||||
constructor(response: {
|
||||
message: string;
|
||||
code: string;
|
||||
stack?: string;
|
||||
details?: JSONValue;
|
||||
metadata?: object;
|
||||
}) {
|
||||
super(response.message);
|
||||
this.expoApiV2ErrorCode = response.code;
|
||||
this.expoApiV2ErrorDetails = response.details;
|
||||
this.expoApiV2ErrorServerStack = response.stack;
|
||||
this.expoApiV2ErrorMetadata = response.metadata;
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
import assert from 'assert';
|
||||
import nock from 'nock';
|
||||
|
||||
import { ApiV2Error, api, getExpoApiBaseUrl } from '../api';
|
||||
import { ApiV2Error } from '../ApiV2Error';
|
||||
import { ApiV2Client, getExpoApiBaseUrl } from '../api';
|
||||
|
||||
describe('apiClient', () => {
|
||||
describe(ApiV2Client, () => {
|
||||
it('converts Expo APIv2 error to ApiV2Error', async () => {
|
||||
nock(getExpoApiBaseUrl())
|
||||
.post('/v2/test')
|
||||
@@ -21,7 +22,9 @@ describe('apiClient', () => {
|
||||
|
||||
let error: Error | null = null;
|
||||
try {
|
||||
await api.postAsync('test', { body: {} });
|
||||
await new ApiV2Client({ accessToken: null, sessionSecret: null }).postAsync('test', {
|
||||
body: {},
|
||||
});
|
||||
} catch (e: any) {
|
||||
error = e;
|
||||
}
|
||||
@@ -41,7 +44,9 @@ describe('apiClient', () => {
|
||||
|
||||
let error: Error | null = null;
|
||||
try {
|
||||
await api.postAsync('test', { body: {} });
|
||||
await new ApiV2Client({ accessToken: null, sessionSecret: null }).postAsync('test', {
|
||||
body: {},
|
||||
});
|
||||
} catch (e: any) {
|
||||
error = e;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`BuildVersionGetView reading version when appVersionSource is set to local 1`] = `"This project is not configured for using remote version source. Add [1m{"cli": { "appVersionSource": "remote" }}[22m in eas.json or re-run this command without "--non-interactive" flag."`;
|
||||
@@ -0,0 +1,161 @@
|
||||
import { AppVersionSource, EasJson } from '@expo/eas-json';
|
||||
import chalk from 'chalk';
|
||||
|
||||
import BuildVersionGetView from '../../commands/build/version/get';
|
||||
import { AppVersionQuery } from '../../graphql/queries/AppVersionQuery';
|
||||
import Log from '../../log';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
|
||||
import { getMockEasJson, mockCommandContext, mockProjectId, mockTestCommand } from './utils';
|
||||
|
||||
jest.mock('../../project/applicationIdentifier');
|
||||
jest.mock('fs');
|
||||
jest.mock('../../log');
|
||||
jest.mock('../../utils/json');
|
||||
jest.mock('../../graphql/queries/AppVersionQuery');
|
||||
|
||||
function withRemoteVersionSource(easJson: EasJson): EasJson {
|
||||
return {
|
||||
...easJson,
|
||||
cli: {
|
||||
...easJson.cli,
|
||||
appVersionSource: AppVersionSource.REMOTE,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe(BuildVersionGetView, () => {
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
test('reading version for platform android', async () => {
|
||||
const ctx = mockCommandContext(BuildVersionGetView, {
|
||||
easJson: withRemoteVersionSource(getMockEasJson()),
|
||||
});
|
||||
jest.mocked(AppVersionQuery.latestVersionAsync).mockImplementation(async () => ({
|
||||
buildVersion: '100',
|
||||
storeVersion: '1.0.0',
|
||||
}));
|
||||
|
||||
const cmd = mockTestCommand(BuildVersionGetView, ['--platform=android'], ctx);
|
||||
await cmd.run();
|
||||
expect(AppVersionQuery.latestVersionAsync).toHaveBeenCalledWith(
|
||||
ctx.loggedIn.graphqlClient,
|
||||
mockProjectId,
|
||||
'ANDROID',
|
||||
'eas.test.com'
|
||||
);
|
||||
expect(Log.log).toHaveBeenCalledWith(`Android versionCode - ${chalk.bold('100')}`);
|
||||
expect(enableJsonOutput).not.toHaveBeenCalled();
|
||||
expect(printJsonOnlyOutput).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('reading version for platform android when no remote version is set', async () => {
|
||||
const ctx = mockCommandContext(BuildVersionGetView, {
|
||||
easJson: withRemoteVersionSource(getMockEasJson()),
|
||||
});
|
||||
jest.mocked(AppVersionQuery.latestVersionAsync).mockImplementation(async () => null);
|
||||
|
||||
const cmd = mockTestCommand(BuildVersionGetView, ['--platform=android'], ctx);
|
||||
await cmd.run();
|
||||
expect(AppVersionQuery.latestVersionAsync).toHaveBeenCalledWith(
|
||||
ctx.loggedIn.graphqlClient,
|
||||
mockProjectId,
|
||||
'ANDROID',
|
||||
'eas.test.com'
|
||||
);
|
||||
expect(Log.log).toHaveBeenCalledWith(`No remote versions are configured for this project.`);
|
||||
expect(enableJsonOutput).not.toHaveBeenCalled();
|
||||
expect(printJsonOnlyOutput).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('reading version with --json flag for platform android', async () => {
|
||||
const ctx = mockCommandContext(BuildVersionGetView, {
|
||||
easJson: withRemoteVersionSource(getMockEasJson()),
|
||||
});
|
||||
jest.mocked(AppVersionQuery.latestVersionAsync).mockImplementation(async () => ({
|
||||
buildVersion: '100',
|
||||
storeVersion: '1.0.0',
|
||||
}));
|
||||
|
||||
const cmd = mockTestCommand(
|
||||
BuildVersionGetView,
|
||||
['--non-interactive', '--json', '--platform=android'],
|
||||
ctx
|
||||
);
|
||||
await cmd.run();
|
||||
expect(AppVersionQuery.latestVersionAsync).toHaveBeenCalledWith(
|
||||
ctx.loggedIn.graphqlClient,
|
||||
mockProjectId,
|
||||
'ANDROID',
|
||||
'eas.test.com'
|
||||
);
|
||||
expect(enableJsonOutput).toHaveBeenCalled();
|
||||
expect(printJsonOnlyOutput).toHaveBeenCalledWith({
|
||||
versionCode: '100',
|
||||
});
|
||||
});
|
||||
|
||||
test('reading version with --json flag for platform ios', async () => {
|
||||
const ctx = mockCommandContext(BuildVersionGetView, {
|
||||
easJson: withRemoteVersionSource(getMockEasJson()),
|
||||
});
|
||||
jest.mocked(AppVersionQuery.latestVersionAsync).mockImplementation(async () => ({
|
||||
buildVersion: '100',
|
||||
storeVersion: '1.0.0',
|
||||
}));
|
||||
|
||||
const cmd = mockTestCommand(
|
||||
BuildVersionGetView,
|
||||
['--non-interactive', '--json', '--platform=ios'],
|
||||
ctx
|
||||
);
|
||||
await cmd.run();
|
||||
expect(AppVersionQuery.latestVersionAsync).toHaveBeenCalledWith(
|
||||
ctx.loggedIn.graphqlClient,
|
||||
mockProjectId,
|
||||
'IOS',
|
||||
'eas.test.com'
|
||||
);
|
||||
expect(enableJsonOutput).toHaveBeenCalled();
|
||||
expect(printJsonOnlyOutput).toHaveBeenCalledWith({
|
||||
buildNumber: '100',
|
||||
});
|
||||
});
|
||||
|
||||
test('reading version with --json flag when no remote version is set', async () => {
|
||||
const ctx = mockCommandContext(BuildVersionGetView, {
|
||||
easJson: withRemoteVersionSource(getMockEasJson()),
|
||||
});
|
||||
jest.mocked(AppVersionQuery.latestVersionAsync).mockImplementation(async () => null);
|
||||
|
||||
const cmd = mockTestCommand(
|
||||
BuildVersionGetView,
|
||||
['--non-interactive', '--json', '--platform=android'],
|
||||
ctx
|
||||
);
|
||||
await cmd.run();
|
||||
expect(AppVersionQuery.latestVersionAsync).toHaveBeenCalledWith(
|
||||
ctx.loggedIn.graphqlClient,
|
||||
mockProjectId,
|
||||
'ANDROID',
|
||||
'eas.test.com'
|
||||
);
|
||||
expect(enableJsonOutput).toHaveBeenCalled();
|
||||
expect(printJsonOnlyOutput).toHaveBeenCalledWith({});
|
||||
});
|
||||
|
||||
test('reading version when appVersionSource is set to local ', async () => {
|
||||
const ctx = mockCommandContext(BuildVersionGetView, {});
|
||||
jest.mocked(AppVersionQuery.latestVersionAsync).mockImplementation(async () => ({
|
||||
buildVersion: '100',
|
||||
storeVersion: '1.0.0',
|
||||
}));
|
||||
|
||||
const cmd = mockTestCommand(
|
||||
BuildVersionGetView,
|
||||
['--non-interactive', '--json', '--platform=android'],
|
||||
ctx
|
||||
);
|
||||
await expect(cmd.run()).rejects.toThrowErrorMatchingSnapshot();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,140 @@
|
||||
import { AppVersionSource, EasJson } from '@expo/eas-json';
|
||||
import fs from 'fs-extra';
|
||||
import path from 'path';
|
||||
|
||||
import BuildVersionSetView from '../../commands/build/version/set';
|
||||
import { AppVersionMutation } from '../../graphql/mutations/AppVersionMutation';
|
||||
import { AppQuery } from '../../graphql/queries/AppQuery';
|
||||
import { AppVersionQuery } from '../../graphql/queries/AppVersionQuery';
|
||||
import Log from '../../log';
|
||||
import * as prompts from '../../prompts';
|
||||
import {
|
||||
getMockAppFragment,
|
||||
getMockEasJson,
|
||||
mockCommandContext,
|
||||
mockProjectId,
|
||||
mockTestCommand,
|
||||
} from './utils';
|
||||
|
||||
jest.mock('../../project/applicationIdentifier');
|
||||
jest.mock('../../graphql/queries/AppVersionQuery');
|
||||
jest.mock('../../graphql/queries/AppQuery');
|
||||
jest.mock('../../graphql/mutations/AppVersionMutation');
|
||||
jest.mock('fs');
|
||||
jest.mock('../../log');
|
||||
jest.mock('../../prompts');
|
||||
jest.mock('../../utils/json');
|
||||
|
||||
function withRemoteVersionSource(easJson: EasJson): EasJson {
|
||||
return {
|
||||
...easJson,
|
||||
cli: {
|
||||
...easJson.cli,
|
||||
appVersionSource: AppVersionSource.REMOTE,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe(BuildVersionSetView, () => {
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
test('setting version for platform android', async () => {
|
||||
const ctx = mockCommandContext(BuildVersionSetView, {
|
||||
easJson: withRemoteVersionSource(getMockEasJson()),
|
||||
});
|
||||
jest.mocked(AppQuery.byIdAsync).mockImplementation(async () => getMockAppFragment());
|
||||
jest.mocked(AppVersionQuery.latestVersionAsync).mockImplementation(async () => null);
|
||||
jest.mocked(prompts.promptAsync).mockImplementation(async () => ({
|
||||
version: '1000',
|
||||
}));
|
||||
|
||||
const cmd = mockTestCommand(BuildVersionSetView, ['--platform=android'], ctx);
|
||||
await cmd.run();
|
||||
expect(AppVersionQuery.latestVersionAsync).toHaveBeenCalledWith(
|
||||
ctx.loggedIn.graphqlClient,
|
||||
mockProjectId,
|
||||
'ANDROID',
|
||||
'eas.test.com'
|
||||
);
|
||||
expect(AppVersionMutation.createAppVersionAsync).toHaveBeenCalledWith(
|
||||
ctx.loggedIn.graphqlClient,
|
||||
expect.objectContaining({
|
||||
buildVersion: '1000',
|
||||
storeVersion: '1.0.0',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test('printing current remote version before prompting for a new one', async () => {
|
||||
const ctx = mockCommandContext(BuildVersionSetView, {
|
||||
easJson: withRemoteVersionSource(getMockEasJson()),
|
||||
});
|
||||
jest.mocked(AppQuery.byIdAsync).mockImplementation(async () => getMockAppFragment());
|
||||
jest.mocked(AppVersionQuery.latestVersionAsync).mockImplementation(async () => ({
|
||||
buildVersion: '100',
|
||||
storeVersion: '1.0.0',
|
||||
}));
|
||||
jest.mocked(prompts.promptAsync).mockImplementation(async () => ({
|
||||
version: '1000',
|
||||
}));
|
||||
|
||||
const cmd = mockTestCommand(BuildVersionSetView, ['--platform=android'], ctx);
|
||||
await cmd.run();
|
||||
expect(Log.log).toHaveBeenCalledWith(expect.stringMatching('configured with versionCode 100'));
|
||||
expect(AppVersionQuery.latestVersionAsync).toHaveBeenCalledWith(
|
||||
ctx.loggedIn.graphqlClient,
|
||||
mockProjectId,
|
||||
'ANDROID',
|
||||
'eas.test.com'
|
||||
);
|
||||
});
|
||||
|
||||
test('printing info that there is no remote version configured', async () => {
|
||||
const ctx = mockCommandContext(BuildVersionSetView, {
|
||||
easJson: withRemoteVersionSource(getMockEasJson()),
|
||||
});
|
||||
jest.mocked(AppQuery.byIdAsync).mockImplementation(async () => getMockAppFragment());
|
||||
jest.mocked(AppVersionQuery.latestVersionAsync).mockImplementation(async () => null);
|
||||
jest.mocked(prompts.promptAsync).mockImplementation(async () => ({
|
||||
version: '1000',
|
||||
}));
|
||||
|
||||
const cmd = mockTestCommand(BuildVersionSetView, ['--platform=android'], ctx);
|
||||
await cmd.run();
|
||||
expect(Log.log).toHaveBeenCalledWith(
|
||||
expect.stringMatching('does not have any versionCode configured')
|
||||
);
|
||||
expect(AppVersionQuery.latestVersionAsync).toHaveBeenCalledWith(
|
||||
ctx.loggedIn.graphqlClient,
|
||||
mockProjectId,
|
||||
'ANDROID',
|
||||
'eas.test.com'
|
||||
);
|
||||
});
|
||||
|
||||
test('setting version aborts when appVersionSource is set to local and users refuse auto configuration', async () => {
|
||||
const ctx = mockCommandContext(BuildVersionSetView, {});
|
||||
jest.mocked(AppVersionQuery.latestVersionAsync).mockImplementation(async () => null);
|
||||
jest.mocked(AppQuery.byIdAsync).mockImplementation(async () => getMockAppFragment());
|
||||
jest.mocked(prompts.confirmAsync).mockImplementation(async () => false);
|
||||
|
||||
const cmd = mockTestCommand(BuildVersionSetView, ['--platform=android'], ctx);
|
||||
await expect(cmd.run()).rejects.toThrowError('Aborting...');
|
||||
expect(AppVersionMutation.createAppVersionAsync).not.toHaveBeenCalledWith();
|
||||
});
|
||||
|
||||
test('setting version when appVersionSource is set to local and user allows auto configuration', async () => {
|
||||
const ctx = mockCommandContext(BuildVersionSetView, {});
|
||||
jest.mocked(AppVersionQuery.latestVersionAsync).mockImplementation(async () => null);
|
||||
jest.mocked(AppQuery.byIdAsync).mockImplementation(async () => getMockAppFragment());
|
||||
jest.mocked(prompts.confirmAsync).mockImplementation(async () => true);
|
||||
|
||||
const cmd = mockTestCommand(BuildVersionSetView, ['--platform=android'], ctx);
|
||||
await cmd.run();
|
||||
|
||||
const easJsonAfterCmd = await fs.readJson(path.join(ctx.projectDir, 'eas.json'));
|
||||
expect(easJsonAfterCmd.cli.appVersionSource).toBe('remote');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,204 @@
|
||||
import { Workflow } from '@expo/eas-build-job';
|
||||
import { AppVersionSource, EasJson } from '@expo/eas-json';
|
||||
import fs from 'fs-extra';
|
||||
import path from 'path';
|
||||
|
||||
import { updateNativeVersionsAsync as updateAndroidNativeVersionsAsync } from '../../build/android/version';
|
||||
import { updateNativeVersionsAsync as updateIosNativeVersionsAsync } from '../../build/ios/version';
|
||||
import BuildVersionSyncView from '../../commands/build/version/sync';
|
||||
import { Target } from '../../credentials/ios/types';
|
||||
import { AppVersionMutation } from '../../graphql/mutations/AppVersionMutation';
|
||||
import { AppQuery } from '../../graphql/queries/AppQuery';
|
||||
import { AppVersionQuery } from '../../graphql/queries/AppVersionQuery';
|
||||
import { getAppBuildGradleAsync } from '../../project/android/gradleUtils';
|
||||
import { resolveTargetsAsync } from '../../project/ios/target';
|
||||
import { resolveWorkflowAsync } from '../../project/workflow';
|
||||
import * as prompts from '../../prompts';
|
||||
import {
|
||||
getMockAppFragment,
|
||||
getMockEasJson,
|
||||
mockCommandContext,
|
||||
mockProjectId,
|
||||
mockTestCommand,
|
||||
} from './utils';
|
||||
|
||||
jest.mock('../../build/android/version');
|
||||
jest.mock('../../build/ios/version');
|
||||
jest.mock('../../project/applicationIdentifier');
|
||||
jest.mock('../../graphql/queries/AppVersionQuery');
|
||||
jest.mock('../../graphql/queries/AppQuery');
|
||||
jest.mock('../../graphql/mutations/AppVersionMutation');
|
||||
jest.mock('../../project/workflow');
|
||||
jest.mock('../../project/android/gradleUtils');
|
||||
jest.mock('../../project/ios/target');
|
||||
jest.mock('../../project/ios/scheme');
|
||||
jest.mock('fs');
|
||||
jest.mock('../../log');
|
||||
jest.mock('../../prompts');
|
||||
jest.mock('../../utils/json');
|
||||
|
||||
function withRemoteVersionSource(easJson: EasJson): EasJson {
|
||||
return {
|
||||
...easJson,
|
||||
cli: {
|
||||
...easJson.cli,
|
||||
appVersionSource: AppVersionSource.REMOTE,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe(BuildVersionSyncView, () => {
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
test('syncing version for managed project on platform android', async () => {
|
||||
const ctx = mockCommandContext(BuildVersionSyncView, {
|
||||
easJson: withRemoteVersionSource(getMockEasJson()),
|
||||
});
|
||||
jest.mocked(AppQuery.byIdAsync).mockImplementation(async () => getMockAppFragment());
|
||||
jest.mocked(AppVersionQuery.latestVersionAsync).mockImplementation(async () => ({
|
||||
buildVersion: '1000',
|
||||
storeVersion: '0.0.1',
|
||||
}));
|
||||
jest.mocked(prompts.promptAsync).mockImplementation(async () => ({
|
||||
version: '1000',
|
||||
}));
|
||||
jest.mocked(resolveWorkflowAsync).mockImplementation(async () => Workflow.MANAGED);
|
||||
|
||||
const cmd = mockTestCommand(BuildVersionSyncView, ['--platform=android'], ctx);
|
||||
const syncAndroidAsync = jest.spyOn(cmd, 'syncAndroidAsync' as any);
|
||||
|
||||
await cmd.run();
|
||||
expect(AppVersionQuery.latestVersionAsync).toHaveBeenCalledWith(
|
||||
ctx.loggedIn.graphqlClient,
|
||||
mockProjectId,
|
||||
'ANDROID',
|
||||
'eas.test.com'
|
||||
);
|
||||
expect(syncAndroidAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('syncing version for bare project on platform android', async () => {
|
||||
const ctx = mockCommandContext(BuildVersionSyncView, {
|
||||
easJson: withRemoteVersionSource(getMockEasJson()),
|
||||
});
|
||||
jest.mocked(AppQuery.byIdAsync).mockImplementation(async () => getMockAppFragment());
|
||||
jest.mocked(AppVersionQuery.latestVersionAsync).mockImplementation(async () => ({
|
||||
buildVersion: '1000',
|
||||
storeVersion: '0.0.1',
|
||||
}));
|
||||
jest.mocked(prompts.promptAsync).mockImplementation(async () => ({
|
||||
version: '1000',
|
||||
}));
|
||||
jest.mocked(resolveWorkflowAsync).mockImplementation(async () => Workflow.GENERIC);
|
||||
jest.mocked(getAppBuildGradleAsync).mockImplementation(async () => ({
|
||||
android: {},
|
||||
}));
|
||||
|
||||
const cmd = mockTestCommand(BuildVersionSyncView, ['--platform=android'], ctx);
|
||||
const syncAndroidAsync = jest.spyOn(cmd, 'syncAndroidAsync' as any);
|
||||
|
||||
await cmd.run();
|
||||
expect(AppVersionQuery.latestVersionAsync).toHaveBeenCalledWith(
|
||||
ctx.loggedIn.graphqlClient,
|
||||
mockProjectId,
|
||||
'ANDROID',
|
||||
'eas.test.com'
|
||||
);
|
||||
expect(syncAndroidAsync).toHaveBeenCalled();
|
||||
expect(updateAndroidNativeVersionsAsync).toHaveBeenCalledWith({
|
||||
projectDir: ctx.projectDir,
|
||||
versionCode: 1000,
|
||||
});
|
||||
});
|
||||
|
||||
test('syncing version for managed project on platform ios', async () => {
|
||||
const ctx = mockCommandContext(BuildVersionSyncView, {
|
||||
easJson: withRemoteVersionSource(getMockEasJson()),
|
||||
});
|
||||
jest.mocked(AppQuery.byIdAsync).mockImplementation(async () => getMockAppFragment());
|
||||
jest.mocked(AppVersionQuery.latestVersionAsync).mockImplementation(async () => ({
|
||||
buildVersion: '1000',
|
||||
storeVersion: '0.0.1',
|
||||
}));
|
||||
jest.mocked(prompts.promptAsync).mockImplementation(async () => ({
|
||||
version: '1000',
|
||||
}));
|
||||
jest.mocked(resolveWorkflowAsync).mockImplementation(async () => Workflow.MANAGED);
|
||||
|
||||
const cmd = mockTestCommand(BuildVersionSyncView, ['--platform=ios'], ctx);
|
||||
const syncIosAsync = jest.spyOn(cmd, 'syncIosAsync' as any);
|
||||
|
||||
await cmd.run();
|
||||
expect(AppVersionQuery.latestVersionAsync).toHaveBeenCalledWith(
|
||||
ctx.loggedIn.graphqlClient,
|
||||
mockProjectId,
|
||||
'IOS',
|
||||
'eas.test.com'
|
||||
);
|
||||
expect(syncIosAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('syncing version for bare project on platform ios', async () => {
|
||||
const ctx = mockCommandContext(BuildVersionSyncView, {
|
||||
easJson: withRemoteVersionSource(getMockEasJson()),
|
||||
});
|
||||
const fakeTarget: Target = {
|
||||
targetName: 'testapp',
|
||||
bundleIdentifier: 'eas.test.com',
|
||||
entitlements: {},
|
||||
};
|
||||
jest.mocked(AppQuery.byIdAsync).mockImplementation(async () => getMockAppFragment());
|
||||
jest.mocked(AppVersionQuery.latestVersionAsync).mockImplementation(async () => ({
|
||||
buildVersion: '1000',
|
||||
storeVersion: '0.0.1',
|
||||
}));
|
||||
jest.mocked(prompts.promptAsync).mockImplementation(async () => ({
|
||||
version: '1000',
|
||||
}));
|
||||
jest.mocked(resolveWorkflowAsync).mockImplementation(async () => Workflow.GENERIC);
|
||||
jest.mocked(resolveTargetsAsync).mockImplementation(async () => [fakeTarget]);
|
||||
|
||||
const cmd = mockTestCommand(BuildVersionSyncView, ['--platform=ios'], ctx);
|
||||
const syncIosAsync = jest.spyOn(cmd, 'syncIosAsync' as any);
|
||||
|
||||
await cmd.run();
|
||||
expect(AppVersionQuery.latestVersionAsync).toHaveBeenCalledWith(
|
||||
ctx.loggedIn.graphqlClient,
|
||||
mockProjectId,
|
||||
'IOS',
|
||||
'eas.test.com'
|
||||
);
|
||||
expect(syncIosAsync).toHaveBeenCalled();
|
||||
expect(updateIosNativeVersionsAsync).toHaveBeenCalledWith({
|
||||
buildNumber: '1000',
|
||||
projectDir: ctx.projectDir,
|
||||
targets: [fakeTarget],
|
||||
});
|
||||
});
|
||||
|
||||
test('syncing version aborts when appVersionSource is set to local and users refuse auto configuration', async () => {
|
||||
const ctx = mockCommandContext(BuildVersionSyncView, {});
|
||||
jest.mocked(AppVersionQuery.latestVersionAsync).mockImplementation(async () => null);
|
||||
jest.mocked(AppQuery.byIdAsync).mockImplementation(async () => getMockAppFragment());
|
||||
jest.mocked(prompts.confirmAsync).mockImplementation(async () => false);
|
||||
|
||||
const cmd = mockTestCommand(BuildVersionSyncView, ['--platform=android'], ctx);
|
||||
await expect(cmd.run()).rejects.toThrowError('Aborting...');
|
||||
expect(AppVersionMutation.createAppVersionAsync).not.toHaveBeenCalledWith();
|
||||
});
|
||||
|
||||
test('syncing version when appVersionSource is set to local and user allows auto configuration', async () => {
|
||||
const ctx = mockCommandContext(BuildVersionSyncView, {});
|
||||
jest.mocked(AppVersionQuery.latestVersionAsync).mockImplementation(async () => null);
|
||||
jest.mocked(AppQuery.byIdAsync).mockImplementation(async () => getMockAppFragment());
|
||||
jest.mocked(prompts.confirmAsync).mockImplementation(async () => true);
|
||||
|
||||
const cmd = mockTestCommand(BuildVersionSyncView, ['--platform=android'], ctx);
|
||||
await cmd.run();
|
||||
|
||||
const easJsonAfterCmd = await fs.readJson(path.join(ctx.projectDir, 'eas.json'));
|
||||
expect(easJsonAfterCmd.cli.appVersionSource).toBe('remote');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,149 @@
|
||||
import { ExpoConfig } from '@expo/config-types';
|
||||
import { EasJson } from '@expo/eas-json';
|
||||
import { Command, Config } from '@oclif/core';
|
||||
import { vol } from 'memfs';
|
||||
import path from 'path';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
import { ContextInput, ContextOutput } from '../../commandUtils/EasCommand';
|
||||
import { DynamicConfigContextFn } from '../../commandUtils/context/DynamicProjectConfigContextField';
|
||||
import { AppFragment, Role } from '../../graphql/generated';
|
||||
|
||||
export function getMockEasJson(): EasJson {
|
||||
return {
|
||||
cli: {},
|
||||
build: {
|
||||
production: {},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const mockProjectId = 'fake-project-id';
|
||||
|
||||
export function getMockAppFragment(): AppFragment {
|
||||
return {
|
||||
id: mockProjectId,
|
||||
slug: 'testapp',
|
||||
fullName: '@testuser/testpp',
|
||||
ownerAccount: {
|
||||
id: 'test-account-id',
|
||||
name: 'testuser',
|
||||
users: [
|
||||
{
|
||||
role: Role.Owner,
|
||||
actor: {
|
||||
id: 'test-user-id',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function getMockExpoConfig(): ExpoConfig {
|
||||
return {
|
||||
name: 'testapp',
|
||||
slug: 'tetsapp',
|
||||
extra: {
|
||||
projectId: mockProjectId,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export interface LocalProjectContext {
|
||||
projectDir: string;
|
||||
exp?: ExpoConfig;
|
||||
appId?: string;
|
||||
}
|
||||
|
||||
export function mockCommandContext<
|
||||
C extends {
|
||||
[name: string]: any;
|
||||
} = object
|
||||
>(
|
||||
commandClass: { contextDefinition: ContextInput<C> },
|
||||
overrides: {
|
||||
easJson?: EasJson;
|
||||
exp?: ExpoConfig;
|
||||
projectId?: string;
|
||||
getDynamicPrivateProjectConfigAsync?: DynamicConfigContextFn;
|
||||
getDynamicPublicProjectConfigAsync?: DynamicConfigContextFn;
|
||||
}
|
||||
): ContextOutput<C> {
|
||||
const projectDir = path.join('/test', uuidv4());
|
||||
vol.reset();
|
||||
vol.fromJSON({ 'eas.json': JSON.stringify(overrides.easJson ?? getMockEasJson()) }, projectDir);
|
||||
const contextDefinition = commandClass.contextDefinition;
|
||||
|
||||
const result: any = {};
|
||||
for (const [contextKey] of Object.entries(contextDefinition)) {
|
||||
if (contextKey === 'loggedIn') {
|
||||
result.loggedIn = {};
|
||||
}
|
||||
|
||||
if (contextKey === 'projectDir') {
|
||||
result.projectDir = projectDir;
|
||||
}
|
||||
if (contextKey === 'maybeLoggedIn') {
|
||||
result.maybeLoggedIn = {};
|
||||
}
|
||||
if (contextKey === 'sessionManager') {
|
||||
result.sessionManager = {};
|
||||
}
|
||||
if (contextKey === 'projectConfig') {
|
||||
result.projectConfig = {
|
||||
exp: overrides.exp ?? getMockExpoConfig(),
|
||||
projectId: overrides.projectId ?? mockProjectId,
|
||||
projectDir,
|
||||
};
|
||||
}
|
||||
if (contextKey === 'getDynamicPrivateProjectConfigAsync') {
|
||||
result.getDynamicPrivateProjectConfigAsync = () => ({
|
||||
exp: overrides.exp ?? getMockExpoConfig(),
|
||||
projectId: overrides.projectId ?? mockProjectId,
|
||||
projectDir,
|
||||
});
|
||||
}
|
||||
|
||||
if (contextKey === 'getDynamicPublicProjectConfigAsync') {
|
||||
result.getDynamicPublicProjectConfigAsync = () => ({
|
||||
exp: overrides.exp ?? getMockExpoConfig(),
|
||||
projectId: overrides.projectId ?? mockProjectId,
|
||||
projectDir,
|
||||
});
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function mockTestCommand<T extends Command>(
|
||||
commandConstructor: new (argv: string[], config: Config) => T,
|
||||
argv: string[],
|
||||
ctx: any
|
||||
): T {
|
||||
const cmd = new commandConstructor(argv, new Config({ root: __dirname }));
|
||||
(cmd as any).getContextAsync = jest.fn(() => ctx);
|
||||
return cmd;
|
||||
}
|
||||
|
||||
export class NoErrorThrownError extends Error {}
|
||||
|
||||
export const getErrorAsync = async <TError = any>(
|
||||
call: () => unknown
|
||||
): Promise<TError | NoErrorThrownError> => {
|
||||
try {
|
||||
await call();
|
||||
throw new NoErrorThrownError();
|
||||
} catch (error: unknown) {
|
||||
return error as TError;
|
||||
}
|
||||
};
|
||||
|
||||
export const getError = <TError = any>(call: () => unknown): TError | NoErrorThrownError => {
|
||||
try {
|
||||
call();
|
||||
throw new NoErrorThrownError();
|
||||
} catch (error: unknown) {
|
||||
return error as TError;
|
||||
}
|
||||
};
|
||||
@@ -1,60 +0,0 @@
|
||||
import { Actor } from '../user/User';
|
||||
|
||||
interface MockProject {
|
||||
projectRoot: string;
|
||||
projectTree: Record<string, string>;
|
||||
appJSON: AppJSON;
|
||||
packageJSON: PackageJSON;
|
||||
}
|
||||
|
||||
interface PackageJSON {
|
||||
name: string;
|
||||
version: string;
|
||||
description: string;
|
||||
main: string;
|
||||
}
|
||||
|
||||
interface AppJSON {
|
||||
expo: {
|
||||
name: string;
|
||||
version: string;
|
||||
slug: string;
|
||||
sdkVersion: string;
|
||||
owner?: string;
|
||||
[key: string]: any;
|
||||
};
|
||||
}
|
||||
|
||||
export function createTestProject(
|
||||
user: Actor,
|
||||
appJSONExtraData?: Record<string, object | string>
|
||||
): MockProject {
|
||||
const projectRoot = '/test-project';
|
||||
const packageJSON: PackageJSON = {
|
||||
name: 'testing123',
|
||||
version: '0.1.0',
|
||||
description: 'fake description',
|
||||
main: 'index.js',
|
||||
};
|
||||
|
||||
const owner = appJSONExtraData?.owner as string | undefined;
|
||||
const appJSON: AppJSON = {
|
||||
expo: {
|
||||
name: 'testing 123',
|
||||
version: '0.1.0',
|
||||
slug: 'testing-123',
|
||||
sdkVersion: '33.0.0',
|
||||
owner: owner ?? (user.__typename === 'User' ? user.username : undefined),
|
||||
...appJSONExtraData,
|
||||
},
|
||||
};
|
||||
return {
|
||||
appJSON,
|
||||
packageJSON,
|
||||
projectRoot,
|
||||
projectTree: {
|
||||
[projectRoot + '/package.json']: JSON.stringify(packageJSON, null, 2),
|
||||
[projectRoot + '/app.json']: JSON.stringify(appJSON, null, 2),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
import RudderAnalytics from '@expo/rudder-sdk-node';
|
||||
import os from 'os';
|
||||
import { URL } from 'url';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
import { Actor, getActorDisplayName } from '../user/User';
|
||||
import UserSettings from '../user/UserSettings';
|
||||
import { easCliVersion } from '../utils/easCli';
|
||||
|
||||
const PLATFORM_TO_ANALYTICS_PLATFORM: Partial<Record<NodeJS.Platform, string>> = {
|
||||
darwin: 'Mac',
|
||||
win32: 'Windows',
|
||||
linux: 'Linux',
|
||||
};
|
||||
|
||||
export type AnalyticsEvent = CommandEvent | BuildEvent | SubmissionEvent | MetadataEvent;
|
||||
|
||||
export enum CommandEvent {
|
||||
ACTION = 'action', // generic event type which is used to determine the 'daily active user' stat, include an `action: eas ${subcommand}` property inside of the event properties object
|
||||
}
|
||||
|
||||
export enum SubmissionEvent {
|
||||
SUBMIT_COMMAND = 'submit cli submit command',
|
||||
SUBMIT_COMMAND_ATTEMPT = 'submit cli attempt',
|
||||
SUBMIT_COMMAND_SUCCESS = 'submit cli success',
|
||||
SUBMIT_COMMAND_FAIL = 'submit cli fail',
|
||||
GATHER_CREDENTIALS_ATTEMPT = 'submit cli gather credentials attempt',
|
||||
GATHER_CREDENTIALS_SUCCESS = 'submit cli gather credentials success',
|
||||
GATHER_CREDENTIALS_FAIL = 'submit cli gather credentials fail',
|
||||
GATHER_ARCHIVE_ATTEMPT = 'submit cli gather archive attempt',
|
||||
GATHER_ARCHIVE_SUCCESS = 'submit cli gather archive success',
|
||||
GATHER_ARCHIVE_FAIL = 'submit cli gather archive fail',
|
||||
SUBMIT_REQUEST_ATTEMPT = 'submit cli request attempt',
|
||||
SUBMIT_REQUEST_SUCCESS = 'submit cli request success',
|
||||
SUBMIT_REQUEST_FAIL = 'submit cli request fail',
|
||||
API_KEY_DOWNLOAD_FAIL = 'submit cli credentials api key download fail',
|
||||
API_KEY_DOWNLOAD_RETRY = 'submit cli credentials api key download fail temporary',
|
||||
API_KEY_DOWNLOAD_SUCCESS = 'submit cli credentials api key download succeed',
|
||||
}
|
||||
|
||||
export enum BuildEvent {
|
||||
BUILD_COMMAND = 'build cli build command',
|
||||
PROJECT_UPLOAD_ATTEMPT = 'build cli project upload attempt',
|
||||
PROJECT_UPLOAD_SUCCESS = 'build cli project upload success',
|
||||
PROJECT_UPLOAD_FAIL = 'build cli project upload fail',
|
||||
GATHER_CREDENTIALS_ATTEMPT = 'build cli gather credentials attempt',
|
||||
GATHER_CREDENTIALS_SUCCESS = 'build cli gather credentials success',
|
||||
GATHER_CREDENTIALS_FAIL = 'build cli gather credentials fail',
|
||||
CONFIGURE_PROJECT_ATTEMPT = 'build cli configure project attempt',
|
||||
CONFIGURE_PROJECT_SUCCESS = 'build cli configure project success',
|
||||
CONFIGURE_PROJECT_FAIL = 'build cli configure project fail',
|
||||
BUILD_REQUEST_ATTEMPT = 'build cli build request attempt',
|
||||
BUILD_REQUEST_SUCCESS = 'build cli build request success',
|
||||
BUILD_REQUEST_FAIL = 'build cli build request fail',
|
||||
|
||||
BUILD_STATUS_COMMAND = 'build cli build status',
|
||||
|
||||
CREDENTIALS_SYNC_COMMAND = 'build cli credentials sync command',
|
||||
CREDENTIALS_SYNC_UPDATE_LOCAL_ATTEMPT = 'build cli credentials sync update local attempt',
|
||||
CREDENTIALS_SYNC_UPDATE_LOCAL_SUCCESS = 'build cli credentials sync update local success',
|
||||
CREDENTIALS_SYNC_UPDATE_LOCAL_FAIL = 'build cli credentials sync update local fail',
|
||||
CREDENTIALS_SYNC_UPDATE_REMOTE_ATTEMPT = 'build cli credentials sync update remote attempt',
|
||||
CREDENTIALS_SYNC_UPDATE_REMOTE_SUCCESS = 'build cli credentials sync update remote success',
|
||||
CREDENTIALS_SYNC_UPDATE_REMOTE_FAIL = 'build cli credentials sync update remote fail',
|
||||
|
||||
ANDROID_KEYSTORE_CREATE = 'build cli credentials keystore create',
|
||||
}
|
||||
|
||||
export enum MetadataEvent {
|
||||
APPLE_METADATA_DOWNLOAD = 'metadata cli download apple response',
|
||||
APPLE_METADATA_UPLOAD = 'metadata cli upload apple response',
|
||||
}
|
||||
|
||||
export type AnalyticsEventProperties = Record<string, string | number | boolean>;
|
||||
|
||||
/**
|
||||
* The interface for commands to use to log events to analytics.
|
||||
*/
|
||||
export interface Analytics {
|
||||
logEvent(name: AnalyticsEvent, properties: AnalyticsEventProperties): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The interface for commands to use to orchestrate the analytics system. Should only be necessary to use
|
||||
* this within EASCommand.
|
||||
*/
|
||||
export interface AnalyticsWithOrchestration extends Analytics {
|
||||
setActor(actor: Actor): void;
|
||||
flushAsync(): Promise<void>;
|
||||
}
|
||||
|
||||
const USER_SETTINGS_KEY_AMPLITUDE_ENABLED = 'amplitudeEnabled';
|
||||
const USER_SETTINGS_KEY_AMPLITUDE_DEVICE_ID = 'amplitudeDeviceId';
|
||||
const USER_SETTINGS_KEY_ANALYTICS_ENABLED = 'analyticsEnabled';
|
||||
const USER_SETTINGS_KEY_ANALYTICS_DEVICE_ID = 'analyticsDeviceId';
|
||||
|
||||
/**
|
||||
* Sets the user's analytics enabled preference. Note that this will only take effect
|
||||
* upon the next run of the CLI.
|
||||
*/
|
||||
export async function setAnalyticsEnabledAsync(enabled: boolean): Promise<void> {
|
||||
await UserSettings.setAsync(USER_SETTINGS_KEY_ANALYTICS_ENABLED, enabled);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the user's analytics enabled preference.
|
||||
*/
|
||||
export async function getAnalyticsEnabledAsync(): Promise<boolean> {
|
||||
const analyticsEnabled = await UserSettings.getAsync(USER_SETTINGS_KEY_ANALYTICS_ENABLED, null);
|
||||
return !!analyticsEnabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an instance of Analytics based on the user's analytics enabled preferences.
|
||||
*/
|
||||
export async function createAnalyticsAsync(): Promise<AnalyticsWithOrchestration> {
|
||||
// TODO: remove after some time
|
||||
const amplitudeEnabled = await UserSettings.getAsync(USER_SETTINGS_KEY_AMPLITUDE_ENABLED, null);
|
||||
if (amplitudeEnabled !== null) {
|
||||
await UserSettings.setAsync(USER_SETTINGS_KEY_ANALYTICS_ENABLED, amplitudeEnabled);
|
||||
await UserSettings.deleteKeyAsync(USER_SETTINGS_KEY_AMPLITUDE_ENABLED);
|
||||
}
|
||||
const amplitudeDeviceId = await UserSettings.getAsync(
|
||||
USER_SETTINGS_KEY_AMPLITUDE_DEVICE_ID,
|
||||
null
|
||||
);
|
||||
if (amplitudeDeviceId !== null) {
|
||||
await UserSettings.setAsync(USER_SETTINGS_KEY_ANALYTICS_DEVICE_ID, amplitudeDeviceId);
|
||||
await UserSettings.deleteKeyAsync(USER_SETTINGS_KEY_AMPLITUDE_DEVICE_ID);
|
||||
}
|
||||
|
||||
if (process.env.DISABLE_EAS_ANALYTICS) {
|
||||
await UserSettings.setAsync(USER_SETTINGS_KEY_ANALYTICS_ENABLED, false);
|
||||
}
|
||||
|
||||
const analyticsEnabled =
|
||||
!process.env.https_proxy && // disable analytics if running behind proxy
|
||||
(await UserSettings.getAsync(USER_SETTINGS_KEY_ANALYTICS_ENABLED, true));
|
||||
if (!analyticsEnabled) {
|
||||
return new NoOpAnalytics();
|
||||
}
|
||||
|
||||
const persistedDeviceId = await UserSettings.getAsync(
|
||||
USER_SETTINGS_KEY_ANALYTICS_DEVICE_ID,
|
||||
null
|
||||
);
|
||||
const deviceId = persistedDeviceId ?? uuidv4();
|
||||
if (!persistedDeviceId) {
|
||||
await UserSettings.setAsync(USER_SETTINGS_KEY_ANALYTICS_DEVICE_ID, deviceId);
|
||||
}
|
||||
return new RudderstackAnalytics(deviceId);
|
||||
}
|
||||
|
||||
class NoOpAnalytics implements AnalyticsWithOrchestration {
|
||||
logEvent(): void {}
|
||||
setActor(): void {}
|
||||
async flushAsync(): Promise<void> {}
|
||||
}
|
||||
|
||||
const RudderstackAnalyticsConfig =
|
||||
process.env.EXPO_STAGING || process.env.EXPO_LOCAL
|
||||
? {
|
||||
// staging environment
|
||||
rudderstackWriteKey: '1wpX20Da4ltFGSXbPFYUL00Chb7',
|
||||
rudderstackDataPlaneURL: 'https://cdp.expo.dev',
|
||||
}
|
||||
: {
|
||||
// prod environment
|
||||
rudderstackWriteKey: '1wpXLFxmujq86etH6G6cc90hPcC',
|
||||
rudderstackDataPlaneURL: 'https://cdp.expo.dev',
|
||||
};
|
||||
|
||||
class RudderstackAnalytics implements AnalyticsWithOrchestration {
|
||||
private readonly rudderstackClient = new RudderAnalytics(
|
||||
RudderstackAnalyticsConfig.rudderstackWriteKey,
|
||||
new URL('/v1/batch', RudderstackAnalyticsConfig.rudderstackDataPlaneURL).toString(),
|
||||
{
|
||||
flushInterval: 300,
|
||||
}
|
||||
);
|
||||
|
||||
private identifiedActor: Actor | null = null;
|
||||
|
||||
constructor(private readonly persistentDeviceId: string) {
|
||||
// identify once with just anonymous ID. Once the actor is fetched, re-indentify with
|
||||
// both so that they can be associated
|
||||
this.rudderstackClient.identify({
|
||||
anonymousId: persistentDeviceId,
|
||||
});
|
||||
}
|
||||
|
||||
public setActor(actor: Actor): void {
|
||||
if (this.identifiedActor) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.rudderstackClient.identify({
|
||||
userId: actor.id,
|
||||
anonymousId: this.persistentDeviceId,
|
||||
traits: {
|
||||
username: getActorDisplayName(actor),
|
||||
user_id: actor.id,
|
||||
user_type: actor.__typename,
|
||||
},
|
||||
});
|
||||
this.identifiedActor = actor;
|
||||
}
|
||||
|
||||
public logEvent(name: AnalyticsEvent, properties: AnalyticsEventProperties): void {
|
||||
const userId = this.identifiedActor?.id;
|
||||
const deviceId = this.persistentDeviceId;
|
||||
const commonEventProperties = { source_version: easCliVersion, source: 'eas cli' };
|
||||
const identity = { userId: userId ?? undefined, anonymousId: deviceId ?? uuidv4() };
|
||||
this.rudderstackClient.track({
|
||||
event: name,
|
||||
properties: { ...properties, ...commonEventProperties },
|
||||
...identity,
|
||||
context: this.getRudderStackContext(),
|
||||
});
|
||||
}
|
||||
|
||||
public async flushAsync(): Promise<void> {
|
||||
await this.rudderstackClient.flush();
|
||||
}
|
||||
|
||||
private getRudderStackContext(): Record<string, any> {
|
||||
const platform = PLATFORM_TO_ANALYTICS_PLATFORM[os.platform()] || os.platform();
|
||||
return {
|
||||
os: { name: platform, version: os.release() },
|
||||
device: { type: platform, model: platform },
|
||||
app: { name: 'eas cli', version: easCliVersion ?? undefined },
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,23 +1,28 @@
|
||||
import { Analytics, Event } from './events';
|
||||
|
||||
export type TrackingContext = Record<string, string | number | boolean>;
|
||||
import { Analytics, AnalyticsEvent, AnalyticsEventProperties } from './AnalyticsManager';
|
||||
|
||||
export async function withAnalyticsAsync<Result>(
|
||||
analytics: Analytics,
|
||||
fn: () => Promise<Result>,
|
||||
analytics: {
|
||||
attemptEvent: Event;
|
||||
successEvent: Event;
|
||||
failureEvent: Event;
|
||||
trackingCtx: TrackingContext;
|
||||
{
|
||||
attemptEvent,
|
||||
successEvent,
|
||||
failureEvent,
|
||||
properties,
|
||||
}: {
|
||||
attemptEvent: AnalyticsEvent;
|
||||
successEvent: AnalyticsEvent;
|
||||
failureEvent: AnalyticsEvent;
|
||||
properties: AnalyticsEventProperties;
|
||||
}
|
||||
): Promise<Result> {
|
||||
try {
|
||||
analytics.logEvent(attemptEvent, properties);
|
||||
const result = await fn();
|
||||
Analytics.logEvent(analytics.successEvent, analytics.trackingCtx);
|
||||
analytics.logEvent(successEvent, properties);
|
||||
return result;
|
||||
} catch (error: any) {
|
||||
Analytics.logEvent(analytics.failureEvent, {
|
||||
...analytics.trackingCtx,
|
||||
analytics.logEvent(failureEvent, {
|
||||
...properties,
|
||||
reason: error.message,
|
||||
});
|
||||
throw error;
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
import { logEvent } from './rudderstackClient';
|
||||
export type Event = BuildEvent | SubmissionEvent;
|
||||
|
||||
export enum SubmissionEvent {
|
||||
SUBMIT_COMMAND = 'submit cli submit command',
|
||||
SUBMIT_COMMAND_ATTEMPT = 'submit cli attempt',
|
||||
SUBMIT_COMMAND_SUCCESS = 'submit cli success',
|
||||
SUBMIT_COMMAND_FAIL = 'submit cli fail',
|
||||
GATHER_CREDENTIALS_ATTEMPT = 'submit cli gather credentials attempt',
|
||||
GATHER_CREDENTIALS_SUCCESS = 'submit cli gather credentials success',
|
||||
GATHER_CREDENTIALS_FAIL = 'submit cli gather credentials fail',
|
||||
GATHER_ARCHIVE_ATTEMPT = 'submit cli gather archive attempt',
|
||||
GATHER_ARCHIVE_SUCCESS = 'submit cli gather archive success',
|
||||
GATHER_ARCHIVE_FAIL = 'submit cli gather archive fail',
|
||||
SUBMIT_REQUEST_ATTEMPT = 'submit cli request attempt',
|
||||
SUBMIT_REQUEST_SUCCESS = 'submit cli request success',
|
||||
SUBMIT_REQUEST_FAIL = 'submit cli request fail',
|
||||
}
|
||||
|
||||
export enum BuildEvent {
|
||||
BUILD_COMMAND = 'build cli build command',
|
||||
PROJECT_UPLOAD_ATTEMPT = 'build cli project upload attempt',
|
||||
PROJECT_UPLOAD_SUCCESS = 'build cli project upload success',
|
||||
PROJECT_UPLOAD_FAIL = 'build cli project upload fail',
|
||||
GATHER_CREDENTIALS_ATTEMPT = 'build cli gather credentials attempt',
|
||||
GATHER_CREDENTIALS_SUCCESS = 'build cli gather credentials success',
|
||||
GATHER_CREDENTIALS_FAIL = 'build cli gather credentials fail',
|
||||
CONFIGURE_PROJECT_ATTEMPT = 'build cli configure project attempt',
|
||||
CONFIGURE_PROJECT_SUCCESS = 'build cli configure project success',
|
||||
CONFIGURE_PROJECT_FAIL = 'build cli configure project fail',
|
||||
BUILD_REQUEST_ATTEMPT = 'build cli build request attempt',
|
||||
BUILD_REQUEST_SUCCESS = 'build cli build request success',
|
||||
BUILD_REQUEST_FAIL = 'build cli build request fail',
|
||||
|
||||
BUILD_STATUS_COMMAND = 'build cli build status',
|
||||
|
||||
CREDENTIALS_SYNC_COMMAND = 'build cli credentials sync command',
|
||||
CREDENTIALS_SYNC_UPDATE_LOCAL_ATTEMPT = 'build cli credentials sync update local attempt',
|
||||
CREDENTIALS_SYNC_UPDATE_LOCAL_SUCCESS = 'build cli credentials sync update local success',
|
||||
CREDENTIALS_SYNC_UPDATE_LOCAL_FAIL = 'build cli credentials sync update local fail',
|
||||
CREDENTIALS_SYNC_UPDATE_REMOTE_ATTEMPT = 'build cli credentials sync update remote attempt',
|
||||
CREDENTIALS_SYNC_UPDATE_REMOTE_SUCCESS = 'build cli credentials sync update remote success',
|
||||
CREDENTIALS_SYNC_UPDATE_REMOTE_FAIL = 'build cli credentials sync update remote fail',
|
||||
|
||||
ANDROID_KEYSTORE_CREATE = 'build cli credentials keystore create',
|
||||
}
|
||||
|
||||
export class Analytics {
|
||||
static logEvent(name: Event, properties: Record<string, any>): void {
|
||||
logEvent(name, properties);
|
||||
}
|
||||
}
|
||||
@@ -1,129 +0,0 @@
|
||||
import RudderAnalytics from '@expo/rudder-sdk-node';
|
||||
import os from 'os';
|
||||
import { URL } from 'url';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
import UserSettings from '../user/UserSettings';
|
||||
import { easCliVersion } from '../utils/easCli';
|
||||
|
||||
const PLATFORM_TO_ANALYTICS_PLATFORM: { [platform: string]: string } = {
|
||||
darwin: 'Mac',
|
||||
win32: 'Windows',
|
||||
linux: 'Linux',
|
||||
};
|
||||
|
||||
let rudderstackClient: RudderAnalytics | null = null;
|
||||
let userIdentified = false;
|
||||
let identifyData: {
|
||||
userId: string;
|
||||
deviceId: string;
|
||||
traits: Record<string, any>;
|
||||
} | null = null;
|
||||
|
||||
export async function initAsync(): Promise<void> {
|
||||
// TODO: remove after some time
|
||||
const amplitudeEnabled = await UserSettings.getAsync('amplitudeEnabled', null);
|
||||
if (amplitudeEnabled !== null) {
|
||||
await UserSettings.setAsync('analyticsEnabled', amplitudeEnabled);
|
||||
await UserSettings.deleteKeyAsync('amplitudeEnabled');
|
||||
}
|
||||
const amplitudeDeviceId = await UserSettings.getAsync('amplitudeDeviceId', null);
|
||||
if (amplitudeDeviceId !== null) {
|
||||
await UserSettings.setAsync('analyticsDeviceId', amplitudeDeviceId);
|
||||
await UserSettings.deleteKeyAsync('amplitudeDeviceId');
|
||||
}
|
||||
// TODO: cut here
|
||||
if (process.env.DISABLE_EAS_ANALYTICS) {
|
||||
await UserSettings.setAsync('analyticsEnabled', false);
|
||||
}
|
||||
|
||||
const analyticsEnabled = await UserSettings.getAsync('analyticsEnabled', true);
|
||||
if (analyticsEnabled) {
|
||||
const config =
|
||||
process.env.EXPO_STAGING || process.env.EXPO_LOCAL
|
||||
? {
|
||||
// staging environment
|
||||
rudderstackWriteKey: '1wpX20Da4ltFGSXbPFYUL00Chb7',
|
||||
rudderstackDataPlaneURL: 'https://cdp.expo.dev',
|
||||
}
|
||||
: {
|
||||
// prod environment
|
||||
rudderstackWriteKey: '1wpXLFxmujq86etH6G6cc90hPcC',
|
||||
rudderstackDataPlaneURL: 'https://cdp.expo.dev',
|
||||
};
|
||||
|
||||
rudderstackClient = new RudderAnalytics(
|
||||
config.rudderstackWriteKey,
|
||||
new URL('/v1/batch', config.rudderstackDataPlaneURL).toString(),
|
||||
{
|
||||
flushInterval: 300,
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function setUserDataAsync(userId: string, traits: Record<string, any>): Promise<void> {
|
||||
const savedDeviceId = await UserSettings.getAsync('analyticsDeviceId', null);
|
||||
const deviceId = savedDeviceId ?? uuidv4();
|
||||
if (!savedDeviceId) {
|
||||
await UserSettings.setAsync('analyticsDeviceId', deviceId);
|
||||
}
|
||||
|
||||
identifyData = {
|
||||
userId,
|
||||
deviceId,
|
||||
traits,
|
||||
};
|
||||
|
||||
ensureUserIdentified();
|
||||
}
|
||||
|
||||
export async function flushAsync(): Promise<void> {
|
||||
if (rudderstackClient) {
|
||||
await rudderstackClient.flush();
|
||||
}
|
||||
}
|
||||
|
||||
export function logEvent(name: string, properties: Record<string, any> = {}): void {
|
||||
if (!rudderstackClient) {
|
||||
return;
|
||||
}
|
||||
ensureUserIdentified();
|
||||
|
||||
const { userId, deviceId } = identifyData ?? {};
|
||||
const commonEventProperties = { source_version: easCliVersion, source: 'eas cli' };
|
||||
|
||||
const identity = { userId: userId ?? undefined, anonymousId: deviceId ?? uuidv4() };
|
||||
rudderstackClient.track({
|
||||
event: name,
|
||||
properties: { ...properties, ...commonEventProperties },
|
||||
...identity,
|
||||
context: getRudderStackContext(),
|
||||
});
|
||||
}
|
||||
|
||||
function ensureUserIdentified(): void {
|
||||
if (!rudderstackClient || userIdentified || !identifyData) {
|
||||
return;
|
||||
}
|
||||
|
||||
rudderstackClient.identify({
|
||||
userId: identifyData.userId,
|
||||
anonymousId: identifyData.deviceId,
|
||||
traits: identifyData.traits,
|
||||
});
|
||||
userIdentified = true;
|
||||
}
|
||||
|
||||
function getRudderStackContext(): Record<string, any> {
|
||||
const platform = PLATFORM_TO_ANALYTICS_PLATFORM[os.platform()] || os.platform();
|
||||
return {
|
||||
os: { name: platform, version: os.release() },
|
||||
device: { type: platform, model: platform },
|
||||
app: { name: 'eas cli', version: easCliVersion ?? undefined },
|
||||
};
|
||||
}
|
||||
|
||||
export enum AnalyticsEvent {
|
||||
ACTION = 'action', // generic event type which is used to determine the 'daily active user' stat, include an `action: eas ${subcommand}` property inside of the event properties object
|
||||
}
|
||||
+59
-74
@@ -1,93 +1,78 @@
|
||||
import { JSONValue } from '@expo/json-file';
|
||||
|
||||
import { ApiV2Error } from './ApiV2Error';
|
||||
import fetch, { RequestError, RequestInit } from './fetch';
|
||||
import { getAccessToken, getSessionSecret } from './user/sessionStorage';
|
||||
|
||||
export class ApiV2Error extends Error {
|
||||
readonly name = 'ApiV2Error';
|
||||
readonly expoApiV2ErrorCode: string;
|
||||
readonly expoApiV2ErrorDetails?: JSONValue;
|
||||
readonly expoApiV2ErrorServerStack?: string;
|
||||
readonly expoApiV2ErrorMetadata?: object;
|
||||
|
||||
constructor(response: {
|
||||
message: string;
|
||||
code: string;
|
||||
stack?: string;
|
||||
details?: JSONValue;
|
||||
metadata?: object;
|
||||
}) {
|
||||
super(response.message);
|
||||
this.expoApiV2ErrorCode = response.code;
|
||||
this.expoApiV2ErrorDetails = response.details;
|
||||
this.expoApiV2ErrorServerStack = response.stack;
|
||||
this.expoApiV2ErrorMetadata = response.metadata;
|
||||
}
|
||||
}
|
||||
|
||||
interface RequestOptions {
|
||||
body: JSONValue;
|
||||
}
|
||||
|
||||
export const api = {
|
||||
async putAsync(path: string, options: RequestOptions): Promise<any> {
|
||||
return await requestAsync(path, { method: 'PUT', body: JSON.stringify(options.body) });
|
||||
},
|
||||
export class ApiV2Client {
|
||||
constructor(
|
||||
private readonly authInfo: {
|
||||
accessToken: string | null;
|
||||
sessionSecret: string | null;
|
||||
}
|
||||
) {}
|
||||
|
||||
async postAsync(path: string, options: RequestOptions): Promise<any> {
|
||||
return await requestAsync(path, { method: 'POST', body: JSON.stringify(options.body) });
|
||||
},
|
||||
|
||||
async deleteAsync(path: string): Promise<any> {
|
||||
return await requestAsync(path, { method: 'DELETE' });
|
||||
},
|
||||
|
||||
async getAsync(path: string): Promise<any> {
|
||||
return await requestAsync(path, { method: 'GET' });
|
||||
},
|
||||
};
|
||||
|
||||
async function requestAsync(path: string, options: RequestInit): Promise<any> {
|
||||
try {
|
||||
const response = await fetch(`${getExpoApiBaseUrl()}/v2/${path}`, {
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...getAuthHeaders(),
|
||||
},
|
||||
});
|
||||
return await response.json();
|
||||
} catch (err) {
|
||||
await handleApiErrorAsync(err);
|
||||
public async putAsync(path: string, options: RequestOptions): Promise<any> {
|
||||
return await this.requestAsync(path, { method: 'PUT', body: JSON.stringify(options.body) });
|
||||
}
|
||||
}
|
||||
|
||||
async function handleApiErrorAsync(err: any): Promise<void> {
|
||||
if (err instanceof RequestError) {
|
||||
let result: { [key: string]: any };
|
||||
public async postAsync(path: string, options: RequestOptions): Promise<any> {
|
||||
return await this.requestAsync(path, { method: 'POST', body: JSON.stringify(options.body) });
|
||||
}
|
||||
|
||||
public async deleteAsync(path: string): Promise<any> {
|
||||
return await this.requestAsync(path, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
public async getAsync(path: string): Promise<any> {
|
||||
return await this.requestAsync(path, { method: 'GET' });
|
||||
}
|
||||
|
||||
private getAuthHeaders(): Record<string, string> {
|
||||
const token = this.authInfo.accessToken;
|
||||
if (token) {
|
||||
return { authorization: `Bearer ${token}` };
|
||||
}
|
||||
const sessionSecret = this.authInfo.sessionSecret;
|
||||
if (sessionSecret) {
|
||||
return { 'expo-session': sessionSecret };
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
private async requestAsync(path: string, options: RequestInit): Promise<any> {
|
||||
try {
|
||||
result = (await err.response.json()) as { [key: string]: any };
|
||||
} catch {
|
||||
throw new Error(`Malformed api response: ${await err.response.text()}`);
|
||||
const response = await fetch(`${getExpoApiBaseUrl()}/v2/${path}`, {
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...this.getAuthHeaders(),
|
||||
},
|
||||
});
|
||||
return await response.json();
|
||||
} catch (err) {
|
||||
await ApiV2Client.handleApiErrorAsync(err);
|
||||
}
|
||||
if (result.errors?.length) {
|
||||
throw new ApiV2Error(result.errors[0]);
|
||||
}
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
function getAuthHeaders(): Record<string, string> {
|
||||
const token = getAccessToken();
|
||||
if (token) {
|
||||
return { authorization: `Bearer ${token}` };
|
||||
private static async handleApiErrorAsync(err: any): Promise<void> {
|
||||
if (err instanceof RequestError) {
|
||||
let result: { [key: string]: any };
|
||||
try {
|
||||
result = (await err.response.json()) as { [key: string]: any };
|
||||
} catch {
|
||||
throw new Error(`Malformed api response: ${await err.response.text()}`);
|
||||
}
|
||||
if (result.errors?.length) {
|
||||
throw new ApiV2Error(result.errors[0]);
|
||||
}
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
const sessionSecret = getSessionSecret();
|
||||
if (sessionSecret) {
|
||||
return { 'expo-session': sessionSecret };
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
export function getExpoApiBaseUrl(): string {
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
import chalk from 'chalk';
|
||||
import gql from 'graphql-tag';
|
||||
|
||||
import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { PaginatedQueryOptions } from '../commandUtils/pagination';
|
||||
import { withErrorHandlingAsync } from '../graphql/client';
|
||||
import {
|
||||
CreateUpdateBranchForAppMutation,
|
||||
CreateUpdateBranchForAppMutationVariables,
|
||||
UpdateBranch,
|
||||
UpdateBranchFragment,
|
||||
} from '../graphql/generated';
|
||||
import { BranchQuery } from '../graphql/queries/BranchQuery';
|
||||
import Log from '../log';
|
||||
import { formatBranch, getBranchDescription } from '../update/utils';
|
||||
import { printJsonOnlyOutput } from '../utils/json';
|
||||
import {
|
||||
SelectPromptEntry,
|
||||
paginatedQueryWithConfirmPromptAsync,
|
||||
paginatedQueryWithSelectPromptAsync,
|
||||
} from '../utils/queries';
|
||||
import { BranchNotFoundError } from './utils';
|
||||
|
||||
export const BRANCHES_LIMIT = 50;
|
||||
|
||||
export async function selectBranchOnAppAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
{
|
||||
projectId,
|
||||
promptTitle,
|
||||
displayTextForListItem,
|
||||
paginatedQueryOptions,
|
||||
}: {
|
||||
projectId: string;
|
||||
displayTextForListItem: (queryItem: UpdateBranchFragment) => SelectPromptEntry;
|
||||
promptTitle: string;
|
||||
paginatedQueryOptions: PaginatedQueryOptions;
|
||||
}
|
||||
): Promise<UpdateBranchFragment> {
|
||||
if (paginatedQueryOptions.nonInteractive) {
|
||||
throw new Error('Unable to select a branch in non-interactive mode.');
|
||||
}
|
||||
|
||||
const selectedBranch = await paginatedQueryWithSelectPromptAsync({
|
||||
limit: paginatedQueryOptions.limit ?? BRANCHES_LIMIT,
|
||||
offset: paginatedQueryOptions.offset,
|
||||
queryToPerform: (limit, offset) =>
|
||||
queryBranchesOnProjectAsync(graphqlClient, limit, offset, projectId),
|
||||
promptOptions: {
|
||||
title: promptTitle,
|
||||
getIdentifierForQueryItem: updateBranchFragment => updateBranchFragment.id,
|
||||
makePartialChoiceObject: displayTextForListItem,
|
||||
},
|
||||
});
|
||||
if (!selectedBranch) {
|
||||
throw new Error(`Could not find any branches for project "${projectId}"`);
|
||||
}
|
||||
return selectedBranch;
|
||||
}
|
||||
|
||||
export async function listAndRenderBranchesOnAppAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
{
|
||||
projectId,
|
||||
paginatedQueryOptions,
|
||||
}: {
|
||||
projectId: string;
|
||||
paginatedQueryOptions: PaginatedQueryOptions;
|
||||
}
|
||||
): Promise<void> {
|
||||
if (paginatedQueryOptions.nonInteractive) {
|
||||
const branches = await queryBranchesOnProjectAsync(
|
||||
graphqlClient,
|
||||
paginatedQueryOptions.limit ?? BRANCHES_LIMIT,
|
||||
paginatedQueryOptions.offset,
|
||||
projectId
|
||||
);
|
||||
renderPageOfBranches(branches, paginatedQueryOptions);
|
||||
} else {
|
||||
await paginatedQueryWithConfirmPromptAsync({
|
||||
limit: paginatedQueryOptions.limit ?? BRANCHES_LIMIT,
|
||||
offset: paginatedQueryOptions.offset,
|
||||
queryToPerform: (limit, offset) =>
|
||||
queryBranchesOnProjectAsync(graphqlClient, limit, offset, projectId),
|
||||
promptOptions: {
|
||||
title: 'Load more branches?',
|
||||
renderListItems: branches => renderPageOfBranches(branches, paginatedQueryOptions),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function queryBranchesOnProjectAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
limit: number,
|
||||
offset: number,
|
||||
projectId: string
|
||||
): Promise<UpdateBranchFragment[]> {
|
||||
return await BranchQuery.listBranchesOnAppAsync(graphqlClient, {
|
||||
appId: projectId,
|
||||
limit,
|
||||
offset,
|
||||
});
|
||||
}
|
||||
|
||||
function renderPageOfBranches(
|
||||
currentPage: UpdateBranchFragment[],
|
||||
{ json }: PaginatedQueryOptions
|
||||
): void {
|
||||
if (json) {
|
||||
printJsonOnlyOutput(currentPage);
|
||||
} else {
|
||||
Log.addNewLineIfNone();
|
||||
Log.log(chalk.bold('Branches:'));
|
||||
Log.addNewLineIfNone();
|
||||
Log.log(
|
||||
currentPage
|
||||
.map(branch => formatBranch(getBranchDescription(branch)))
|
||||
.join(`\n\n${chalk.dim('———')}\n\n`)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function createUpdateBranchOnAppAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
{ appId, name }: CreateUpdateBranchForAppMutationVariables
|
||||
): Promise<Pick<UpdateBranch, 'id' | 'name'>> {
|
||||
const result = await withErrorHandlingAsync(
|
||||
graphqlClient
|
||||
.mutation<CreateUpdateBranchForAppMutation, CreateUpdateBranchForAppMutationVariables>(
|
||||
gql`
|
||||
mutation CreateUpdateBranchForApp($appId: ID!, $name: String!) {
|
||||
updateBranch {
|
||||
createUpdateBranchForApp(appId: $appId, name: $name) {
|
||||
id
|
||||
name
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
{
|
||||
appId,
|
||||
name,
|
||||
}
|
||||
)
|
||||
.toPromise()
|
||||
);
|
||||
const newBranch = result.updateBranch.createUpdateBranchForApp;
|
||||
if (!newBranch) {
|
||||
throw new Error(`Could not create branch ${name}.`);
|
||||
}
|
||||
return newBranch;
|
||||
}
|
||||
|
||||
export async function ensureBranchExistsAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
{
|
||||
appId,
|
||||
branchName,
|
||||
}: {
|
||||
appId: string;
|
||||
branchName: string;
|
||||
}
|
||||
): Promise<{ branchId: string; createdBranch: boolean }> {
|
||||
try {
|
||||
const updateBranch = await BranchQuery.getBranchByNameAsync(graphqlClient, {
|
||||
appId,
|
||||
name: branchName,
|
||||
});
|
||||
|
||||
const { id } = updateBranch;
|
||||
return { branchId: id, createdBranch: false };
|
||||
} catch (error) {
|
||||
if (error instanceof BranchNotFoundError) {
|
||||
const newUpdateBranch = await createUpdateBranchOnAppAsync(graphqlClient, {
|
||||
appId,
|
||||
name: branchName,
|
||||
});
|
||||
return { branchId: newUpdateBranch.id, createdBranch: true };
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { getVcsClient } from '../vcs';
|
||||
|
||||
export async function getDefaultBranchNameAsync(): Promise<string> {
|
||||
return (
|
||||
(await getVcsClient().getBranchNameAsync()) ||
|
||||
`branch-${Math.random().toString(36).substring(2, 4)}`
|
||||
);
|
||||
}
|
||||
|
||||
export class BranchNotFoundError extends Error {
|
||||
constructor(message?: string) {
|
||||
super(message ?? 'Branch not found.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { instance, mock } from 'ts-mockito';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { selectBuildToCancelAsync } from '../../commands/build/cancel';
|
||||
import {
|
||||
AppPlatform,
|
||||
BuildFragment,
|
||||
BuildPriority,
|
||||
BuildResourceClass,
|
||||
BuildStatus,
|
||||
} from '../../graphql/generated';
|
||||
import { BuildQuery } from '../../graphql/queries/BuildQuery';
|
||||
import { confirmAsync, selectAsync } from '../../prompts';
|
||||
|
||||
jest.mock('../../ora', () => ({
|
||||
ora: jest.fn().mockImplementation(() => ({
|
||||
start: jest.fn().mockImplementation(() => ({
|
||||
stop: jest.fn(),
|
||||
fail: jest.fn(),
|
||||
})),
|
||||
})),
|
||||
}));
|
||||
jest.mock('../../prompts', () => {
|
||||
return {
|
||||
selectAsync: jest.fn(),
|
||||
confirmAsync: jest.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock('../../graphql/queries/BuildQuery', () => {
|
||||
const actual = jest.requireActual('../../graphql/queries/BuildQuery');
|
||||
return {
|
||||
BuildQuery: {
|
||||
...actual.BuildQuery,
|
||||
viewBuildsOnAppAsync: jest.fn(),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
describe(selectBuildToCancelAsync.name, () => {
|
||||
const selectedBuildId = uuid();
|
||||
const projectId = uuid();
|
||||
|
||||
beforeEach(() => {
|
||||
jest
|
||||
.mocked(BuildQuery.viewBuildsOnAppAsync)
|
||||
.mockImplementation(async () => [
|
||||
createMockBuildFragment({ projectId, buildId: selectedBuildId }),
|
||||
]);
|
||||
});
|
||||
|
||||
it('does not return build id when confirmation is rejected', async () => {
|
||||
const graphqlClient = instance(mock<ExpoGraphqlClient>());
|
||||
jest.mocked(confirmAsync).mockResolvedValueOnce(false);
|
||||
expect(selectBuildToCancelAsync(graphqlClient, projectId, 'blah')).resolves.toEqual(null);
|
||||
});
|
||||
|
||||
it('returns build id when confirmation is confirmed', async () => {
|
||||
const graphqlClient = instance(mock<ExpoGraphqlClient>());
|
||||
jest.mocked(selectAsync).mockResolvedValueOnce(selectedBuildId);
|
||||
jest.mocked(confirmAsync).mockResolvedValueOnce(true);
|
||||
expect(selectBuildToCancelAsync(graphqlClient, projectId, 'blah')).resolves.toEqual(
|
||||
selectedBuildId
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
function createMockBuildFragment({
|
||||
projectId,
|
||||
buildId,
|
||||
}: {
|
||||
projectId: string;
|
||||
buildId?: string;
|
||||
}): BuildFragment {
|
||||
return {
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
platform: AppPlatform.Android,
|
||||
id: buildId ?? uuid(),
|
||||
priority: BuildPriority.Normal,
|
||||
project: {
|
||||
__typename: 'App',
|
||||
slug: 'test-project',
|
||||
id: projectId,
|
||||
name: 'test-project',
|
||||
ownerAccount: {
|
||||
__typename: 'Account',
|
||||
id: uuid(),
|
||||
name: 'test-account',
|
||||
},
|
||||
},
|
||||
status: BuildStatus.InQueue,
|
||||
resourceClass: BuildResourceClass.AndroidMedium,
|
||||
};
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 2.1 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.8 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.0 KiB |
@@ -0,0 +1,597 @@
|
||||
import { Platform } from '@expo/eas-build-job';
|
||||
import { CombinedError } from '@urql/core';
|
||||
import { GraphQLError } from 'graphql/error';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
import { getError } from '../../__tests__/commands/utils';
|
||||
import { EasCommandError } from '../../commandUtils/errors';
|
||||
import Build from '../../commands/build';
|
||||
import Log, { link } from '../../log';
|
||||
import { handleBuildRequestError } from '../build';
|
||||
import {
|
||||
EasBuildDownForMaintenanceError,
|
||||
EasBuildFreeTierDisabledAndroidError,
|
||||
EasBuildFreeTierDisabledError,
|
||||
EasBuildFreeTierDisabledIOSError,
|
||||
EasBuildFreeTierIosLimitExceededError,
|
||||
EasBuildFreeTierLimitExceededError,
|
||||
EasBuildResourceClassNotAvailableInFreeTierError,
|
||||
EasBuildTooManyPendingBuildsError,
|
||||
RequestValidationError,
|
||||
TurtleDeprecatedJobFormatError,
|
||||
} from '../errors';
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
const EXPECTED_STRINGIFIED_GRAPHQL_ERROR_JSON = `[
|
||||
{
|
||||
"name": "GraphQLError",
|
||||
"extensions": {},
|
||||
"message": "Error 1"
|
||||
},
|
||||
{
|
||||
"name": "GraphQLError",
|
||||
"extensions": {},
|
||||
"message": "Error 2"
|
||||
},
|
||||
{
|
||||
"name": "GraphQLError",
|
||||
"extensions": {},
|
||||
"message": "Error 3"
|
||||
}
|
||||
]`;
|
||||
|
||||
const EXPECTED_EAS_BUILD_DOWN_MESSAGE = `EAS Build is down for maintenance. Try again later. Check ${link(
|
||||
'https://status.expo.dev/'
|
||||
)} for updates.`;
|
||||
|
||||
const EXPECTED_MAX_BUILD_COUNT_MESSAGE_ANDROID = `You have already reached the maximum number of pending Android builds for your account. Try again later.`;
|
||||
const EXPECTED_MAX_BUILD_COUNT_MESSAGE_IOS = `You have already reached the maximum number of pending iOS builds for your account. Try again later.`;
|
||||
|
||||
const EXPECTED_GENERIC_MESSAGE =
|
||||
'Build request failed. Make sure you are using the latest eas-cli version. If the problem persists, report the issue.';
|
||||
|
||||
const mockRequestId = uuidv4();
|
||||
const getGraphQLError = (message: string, errorCode: string): GraphQLError => {
|
||||
return new GraphQLError(message, null, null, null, null, null, {
|
||||
errorCode,
|
||||
requestId: mockRequestId,
|
||||
});
|
||||
};
|
||||
|
||||
const assertReThrownError = (
|
||||
caughtError: CombinedError | Error,
|
||||
expectedType: typeof EasCommandError,
|
||||
expectedMessage: string = 'Error 1'
|
||||
): void => {
|
||||
expect(caughtError).toBeInstanceOf(expectedType);
|
||||
expect((caughtError as Error).message).toEqual(expectedMessage);
|
||||
};
|
||||
|
||||
describe(Build.name, () => {
|
||||
describe('handle build request errors', () => {
|
||||
describe('logs graphQL errors to debug if present', () => {
|
||||
it('does it with Android', async () => {
|
||||
const platform = Platform.ANDROID;
|
||||
const logDebugSpy = jest.spyOn(Log, 'debug');
|
||||
const graphQLErrors = ['Error 1', 'Error 2', 'Error 3'];
|
||||
const error = new CombinedError({ graphQLErrors });
|
||||
|
||||
try {
|
||||
handleBuildRequestError(error, platform);
|
||||
} catch {}
|
||||
|
||||
expect(logDebugSpy).toBeCalledWith(EXPECTED_STRINGIFIED_GRAPHQL_ERROR_JSON);
|
||||
});
|
||||
|
||||
it('does it with iOS', async () => {
|
||||
const platform = Platform.IOS;
|
||||
const logDebugSpy = jest.spyOn(Log, 'debug');
|
||||
const graphQLErrors = ['Error 1', 'Error 2', 'Error 3'];
|
||||
const error = new CombinedError({ graphQLErrors });
|
||||
|
||||
try {
|
||||
handleBuildRequestError(error, platform);
|
||||
} catch {}
|
||||
|
||||
expect(logDebugSpy).toBeCalledWith(EXPECTED_STRINGIFIED_GRAPHQL_ERROR_JSON);
|
||||
});
|
||||
});
|
||||
|
||||
describe('logs nothing to debug if graphQLErrors not present', () => {
|
||||
it('does it with Android', async () => {
|
||||
const platform = Platform.ANDROID;
|
||||
const logDebugSpy = jest.spyOn(Log, 'debug');
|
||||
const error = new Error('Generic error');
|
||||
|
||||
try {
|
||||
handleBuildRequestError(error, platform);
|
||||
} catch {}
|
||||
|
||||
expect(logDebugSpy).toBeCalledWith(undefined);
|
||||
});
|
||||
|
||||
it('does it with iOS', async () => {
|
||||
const platform = Platform.IOS;
|
||||
const logDebugSpy = jest.spyOn(Log, 'debug');
|
||||
const error = new Error('Generic error');
|
||||
|
||||
try {
|
||||
handleBuildRequestError(error, platform);
|
||||
} catch {}
|
||||
|
||||
expect(logDebugSpy).toBeCalledWith(undefined);
|
||||
});
|
||||
});
|
||||
|
||||
describe('error is server-side defined error', () => {
|
||||
describe('for TURTLE_DEPRECATED_JOB_FORMAT', () => {
|
||||
describe('with Android', () => {
|
||||
it('throws correct EasCommandError subclass', async () => {
|
||||
const platform = Platform.ANDROID;
|
||||
const graphQLError = getGraphQLError('Error 1', 'TURTLE_DEPRECATED_JOB_FORMAT');
|
||||
const graphQLErrors = [graphQLError];
|
||||
const error = new CombinedError({ graphQLErrors });
|
||||
|
||||
const handleBuildRequestErrorThrownError = getError<TurtleDeprecatedJobFormatError>(
|
||||
() => {
|
||||
handleBuildRequestError(error, platform);
|
||||
}
|
||||
);
|
||||
|
||||
assertReThrownError(
|
||||
handleBuildRequestErrorThrownError,
|
||||
TurtleDeprecatedJobFormatError,
|
||||
'Error 1'
|
||||
);
|
||||
});
|
||||
|
||||
it('throws correct EasBuildResourceClassNotAvailableInFreeTierError', async () => {
|
||||
const platform = Platform.ANDROID;
|
||||
const graphQLError = getGraphQLError(
|
||||
'Error 1',
|
||||
'EAS_BUILD_RESOURCE_CLASS_NOT_AVAILABLE_IN_FREE_TIER'
|
||||
);
|
||||
const graphQLErrors = [graphQLError];
|
||||
const error = new CombinedError({ graphQLErrors });
|
||||
|
||||
const handleBuildRequestErrorThrownError =
|
||||
getError<EasBuildResourceClassNotAvailableInFreeTierError>(() => {
|
||||
handleBuildRequestError(error, platform);
|
||||
});
|
||||
|
||||
assertReThrownError(
|
||||
handleBuildRequestErrorThrownError,
|
||||
EasBuildResourceClassNotAvailableInFreeTierError,
|
||||
'Error 1'
|
||||
);
|
||||
});
|
||||
});
|
||||
describe('with iOS', () => {
|
||||
it('throws correct EasCommandError subclass', async () => {
|
||||
const platform = Platform.IOS;
|
||||
const graphQLError = getGraphQLError('Error 1', 'TURTLE_DEPRECATED_JOB_FORMAT');
|
||||
const graphQLErrors = [graphQLError];
|
||||
const error = new CombinedError({ graphQLErrors });
|
||||
|
||||
const handleBuildRequestErrorThrownError = getError<TurtleDeprecatedJobFormatError>(
|
||||
() => {
|
||||
handleBuildRequestError(error, platform);
|
||||
}
|
||||
);
|
||||
|
||||
assertReThrownError(
|
||||
handleBuildRequestErrorThrownError,
|
||||
TurtleDeprecatedJobFormatError,
|
||||
'Error 1'
|
||||
);
|
||||
});
|
||||
|
||||
it('throws correct EasBuildResourceClassNotAvailableInFreeTierError', async () => {
|
||||
const platform = Platform.IOS;
|
||||
const graphQLError = getGraphQLError(
|
||||
'Error 1',
|
||||
'EAS_BUILD_RESOURCE_CLASS_NOT_AVAILABLE_IN_FREE_TIER'
|
||||
);
|
||||
const graphQLErrors = [graphQLError];
|
||||
const error = new CombinedError({ graphQLErrors });
|
||||
|
||||
const handleBuildRequestErrorThrownError =
|
||||
getError<EasBuildResourceClassNotAvailableInFreeTierError>(() => {
|
||||
handleBuildRequestError(error, platform);
|
||||
});
|
||||
|
||||
assertReThrownError(
|
||||
handleBuildRequestErrorThrownError,
|
||||
EasBuildResourceClassNotAvailableInFreeTierError,
|
||||
'Error 1'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('for EAS_BUILD_FREE_TIER_DISABLED', () => {
|
||||
describe('with Android', () => {
|
||||
it('throws correct EasCommandError subclass', async () => {
|
||||
const platform = Platform.ANDROID;
|
||||
const graphQLError = getGraphQLError('Error 1', 'EAS_BUILD_FREE_TIER_DISABLED');
|
||||
const graphQLErrors = [graphQLError];
|
||||
const error = new CombinedError({ graphQLErrors });
|
||||
|
||||
const handleBuildRequestErrorThrownError = getError<EasBuildFreeTierDisabledError>(
|
||||
() => {
|
||||
handleBuildRequestError(error, platform);
|
||||
}
|
||||
);
|
||||
|
||||
assertReThrownError(
|
||||
handleBuildRequestErrorThrownError,
|
||||
EasBuildFreeTierDisabledError,
|
||||
'Error 1'
|
||||
);
|
||||
});
|
||||
});
|
||||
describe('with iOS', () => {
|
||||
it('throws correct EasCommandError subclass', async () => {
|
||||
const platform = Platform.IOS;
|
||||
const graphQLError = getGraphQLError('Error 1', 'EAS_BUILD_FREE_TIER_DISABLED');
|
||||
const graphQLErrors = [graphQLError];
|
||||
const error = new CombinedError({ graphQLErrors });
|
||||
|
||||
const handleBuildRequestErrorThrownError = getError<EasBuildFreeTierDisabledError>(
|
||||
() => {
|
||||
handleBuildRequestError(error, platform);
|
||||
}
|
||||
);
|
||||
|
||||
assertReThrownError(
|
||||
handleBuildRequestErrorThrownError,
|
||||
EasBuildFreeTierDisabledError,
|
||||
'Error 1'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
describe('for EAS_BUILD_FREE_TIER_DISABLED_IOS', () => {
|
||||
it('throws correct EasCommandError subclass', async () => {
|
||||
const platform = Platform.IOS;
|
||||
const graphQLError = getGraphQLError('Error 1', 'EAS_BUILD_FREE_TIER_DISABLED_IOS');
|
||||
const graphQLErrors = [graphQLError];
|
||||
const error = new CombinedError({ graphQLErrors });
|
||||
|
||||
try {
|
||||
handleBuildRequestError(error, platform);
|
||||
} catch (caughtError) {
|
||||
assertReThrownError(caughtError as Error, EasBuildFreeTierDisabledIOSError, 'Error 1');
|
||||
}
|
||||
});
|
||||
});
|
||||
describe('for EAS_BUILD_FREE_TIER_DISABLED_ANDROID', () => {
|
||||
it('throws correct EasCommandError subclass', async () => {
|
||||
const platform = Platform.ANDROID;
|
||||
const graphQLError = getGraphQLError('Error 1', 'EAS_BUILD_FREE_TIER_DISABLED_ANDROID');
|
||||
const graphQLErrors = [graphQLError];
|
||||
const error = new CombinedError({ graphQLErrors });
|
||||
|
||||
const handleBuildRequestErrorThrownError = getError<EasBuildFreeTierDisabledAndroidError>(
|
||||
() => {
|
||||
handleBuildRequestError(error, platform);
|
||||
}
|
||||
);
|
||||
|
||||
assertReThrownError(
|
||||
handleBuildRequestErrorThrownError,
|
||||
EasBuildFreeTierDisabledAndroidError,
|
||||
'Error 1'
|
||||
);
|
||||
});
|
||||
});
|
||||
describe('for EAS_BUILD_FREE_TIER_LIMIT_EXCEEDED', () => {
|
||||
it('throws correct EasCommandError subclass', async () => {
|
||||
const platform = Platform.ANDROID;
|
||||
const graphQLError = getGraphQLError('Error 1', 'EAS_BUILD_FREE_TIER_LIMIT_EXCEEDED');
|
||||
const graphQLErrors = [graphQLError];
|
||||
const error = new CombinedError({ graphQLErrors });
|
||||
|
||||
const handleBuildRequestErrorThrownError = getError<EasBuildFreeTierLimitExceededError>(
|
||||
() => {
|
||||
handleBuildRequestError(error, platform);
|
||||
}
|
||||
);
|
||||
|
||||
assertReThrownError(
|
||||
handleBuildRequestErrorThrownError,
|
||||
EasBuildFreeTierLimitExceededError,
|
||||
'Error 1'
|
||||
);
|
||||
});
|
||||
});
|
||||
describe('for EAS_BUILD_FREE_TIER_IOS_LIMIT_EXCEEDED', () => {
|
||||
it('throws correct EasCommandError subclass', async () => {
|
||||
const platform = Platform.ANDROID;
|
||||
const graphQLError = getGraphQLError('Error 1', 'EAS_BUILD_FREE_TIER_IOS_LIMIT_EXCEEDED');
|
||||
const graphQLErrors = [graphQLError];
|
||||
const error = new CombinedError({ graphQLErrors });
|
||||
|
||||
const handleBuildRequestErrorThrownError =
|
||||
getError<EasBuildFreeTierIosLimitExceededError>(() => {
|
||||
handleBuildRequestError(error, platform);
|
||||
});
|
||||
|
||||
assertReThrownError(
|
||||
handleBuildRequestErrorThrownError,
|
||||
EasBuildFreeTierIosLimitExceededError,
|
||||
'Error 1'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('for VALIDATION_ERROR', () => {
|
||||
describe('with Android', () => {
|
||||
it('throws correct EasCommandError subclass', async () => {
|
||||
const platform = Platform.ANDROID;
|
||||
const graphQLError = getGraphQLError('Error 1', 'VALIDATION_ERROR');
|
||||
const graphQLErrors = [graphQLError];
|
||||
const error = new CombinedError({ graphQLErrors });
|
||||
|
||||
const handleBuildRequestErrorThrownError = getError<RequestValidationError>(() => {
|
||||
handleBuildRequestError(error, platform);
|
||||
});
|
||||
|
||||
assertReThrownError(
|
||||
handleBuildRequestErrorThrownError,
|
||||
RequestValidationError,
|
||||
'Error 1'
|
||||
);
|
||||
});
|
||||
});
|
||||
describe('with iOS', () => {
|
||||
it('throws correct EasCommandError subclass', async () => {
|
||||
const platform = Platform.IOS;
|
||||
const graphQLError = getGraphQLError('Error 1', 'VALIDATION_ERROR');
|
||||
const graphQLErrors = [graphQLError];
|
||||
const error = new CombinedError({ graphQLErrors });
|
||||
|
||||
const handleBuildRequestErrorThrownError = getError<RequestValidationError>(() => {
|
||||
handleBuildRequestError(error, platform);
|
||||
});
|
||||
|
||||
assertReThrownError(
|
||||
handleBuildRequestErrorThrownError,
|
||||
RequestValidationError,
|
||||
'Error 1'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('error is other expected graphQL-related error', () => {
|
||||
describe('for EAS_BUILD_DOWN_FOR_MAINTENANCE', () => {
|
||||
describe('with Android', () => {
|
||||
it('throws correct EasCommandError subclass', async () => {
|
||||
const platform = Platform.ANDROID;
|
||||
const graphQLError = getGraphQLError('Error 1', 'EAS_BUILD_DOWN_FOR_MAINTENANCE');
|
||||
const graphQLErrors = [graphQLError];
|
||||
const error = new CombinedError({ graphQLErrors });
|
||||
|
||||
const handleBuildRequestErrorThrownError = getError<EasBuildDownForMaintenanceError>(
|
||||
() => {
|
||||
handleBuildRequestError(error, platform);
|
||||
}
|
||||
);
|
||||
|
||||
assertReThrownError(
|
||||
handleBuildRequestErrorThrownError,
|
||||
EasBuildDownForMaintenanceError,
|
||||
EXPECTED_EAS_BUILD_DOWN_MESSAGE
|
||||
);
|
||||
});
|
||||
});
|
||||
describe('with iOS', () => {
|
||||
it('throws correct EasCommandError subclass', async () => {
|
||||
const platform = Platform.IOS;
|
||||
const graphQLError = getGraphQLError('Error 1', 'EAS_BUILD_DOWN_FOR_MAINTENANCE');
|
||||
const graphQLErrors = [graphQLError];
|
||||
const error = new CombinedError({ graphQLErrors });
|
||||
|
||||
const handleBuildRequestErrorThrownError = getError<EasBuildDownForMaintenanceError>(
|
||||
() => {
|
||||
handleBuildRequestError(error, platform);
|
||||
}
|
||||
);
|
||||
|
||||
assertReThrownError(
|
||||
handleBuildRequestErrorThrownError,
|
||||
EasBuildDownForMaintenanceError,
|
||||
EXPECTED_EAS_BUILD_DOWN_MESSAGE
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('for EAS_BUILD_TOO_MANY_PENDING_BUILDS', () => {
|
||||
describe('with Android', () => {
|
||||
it('throws correct EasCommandError subclass', async () => {
|
||||
const platform = Platform.ANDROID;
|
||||
const graphQLError = getGraphQLError('Error 1', 'EAS_BUILD_TOO_MANY_PENDING_BUILDS');
|
||||
const graphQLErrors = [graphQLError];
|
||||
const error = new CombinedError({ graphQLErrors });
|
||||
|
||||
const handleBuildRequestErrorThrownError = getError<EasBuildTooManyPendingBuildsError>(
|
||||
() => {
|
||||
handleBuildRequestError(error, platform);
|
||||
}
|
||||
);
|
||||
|
||||
assertReThrownError(
|
||||
handleBuildRequestErrorThrownError,
|
||||
EasBuildTooManyPendingBuildsError,
|
||||
EXPECTED_MAX_BUILD_COUNT_MESSAGE_ANDROID
|
||||
);
|
||||
});
|
||||
});
|
||||
describe('with iOS', () => {
|
||||
it('throws correct EasCommandError subclass', async () => {
|
||||
const platform = Platform.IOS;
|
||||
const graphQLError = getGraphQLError('Error 1', 'EAS_BUILD_TOO_MANY_PENDING_BUILDS');
|
||||
const graphQLErrors = [graphQLError];
|
||||
const error = new CombinedError({ graphQLErrors });
|
||||
|
||||
const handleBuildRequestErrorThrownError = getError<EasBuildTooManyPendingBuildsError>(
|
||||
() => {
|
||||
handleBuildRequestError(error, platform);
|
||||
}
|
||||
);
|
||||
|
||||
assertReThrownError(
|
||||
handleBuildRequestErrorThrownError,
|
||||
EasBuildTooManyPendingBuildsError,
|
||||
EXPECTED_MAX_BUILD_COUNT_MESSAGE_IOS
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('error is unexpected graphQL-related error', () => {
|
||||
describe('with Android', () => {
|
||||
it('throws base Error class with custom message', async () => {
|
||||
const platform = Platform.ANDROID;
|
||||
const graphQLError = getGraphQLError('Error 1', 'UNKNOWN_GRAPHQL_ERROR');
|
||||
const graphQLErrors = [graphQLError];
|
||||
const error = new CombinedError({ graphQLErrors });
|
||||
const expectedMessage = `${EXPECTED_GENERIC_MESSAGE}\nRequest ID: ${mockRequestId}\nError message: Error 1`;
|
||||
|
||||
const handleBuildRequestErrorThrownError = getError<Error>(() => {
|
||||
handleBuildRequestError(error, platform);
|
||||
});
|
||||
|
||||
assertReThrownError(handleBuildRequestErrorThrownError, Error, expectedMessage);
|
||||
});
|
||||
it('throws base Error class with message including all error messages if combined error has multiple GraphQL errors', async () => {
|
||||
const platform = Platform.ANDROID;
|
||||
const graphQLErrors = [
|
||||
getGraphQLError('Error 1', 'UNKNOWN_GRAPHQL_ERROR'),
|
||||
getGraphQLError('Error 2', 'OTHER_GRAPHQL_ERROR'),
|
||||
getGraphQLError('Error 3', 'YET_ANOTHER_GRAPHQL_ERROR'),
|
||||
];
|
||||
const error = new CombinedError({ graphQLErrors });
|
||||
const expectedMessage =
|
||||
`${EXPECTED_GENERIC_MESSAGE}\n` +
|
||||
`Request ID: ${mockRequestId}\n` +
|
||||
'Error message: Error 1\n' +
|
||||
`Request ID: ${mockRequestId}\n` +
|
||||
'Error message: Error 2\n' +
|
||||
`Request ID: ${mockRequestId}\n` +
|
||||
'Error message: Error 3';
|
||||
|
||||
const handleBuildRequestErrorThrownError = getError<Error>(() => {
|
||||
handleBuildRequestError(error, platform);
|
||||
});
|
||||
|
||||
assertReThrownError(handleBuildRequestErrorThrownError, Error, expectedMessage);
|
||||
});
|
||||
describe('without request ID', () => {
|
||||
it('throws base Error class with custom message without request ID line', async () => {
|
||||
const platform = Platform.ANDROID;
|
||||
const graphQLError = getGraphQLError('Error 1', 'UNKNOWN_GRAPHQL_ERROR');
|
||||
delete graphQLError.extensions.requestId;
|
||||
const graphQLErrors = [graphQLError];
|
||||
const error = new CombinedError({ graphQLErrors });
|
||||
const expectedMessage = `${EXPECTED_GENERIC_MESSAGE}\nError message: Error 1`;
|
||||
|
||||
const handleBuildRequestErrorThrownError = getError<Error>(() => {
|
||||
handleBuildRequestError(error, platform);
|
||||
});
|
||||
|
||||
assertReThrownError(handleBuildRequestErrorThrownError, Error, expectedMessage);
|
||||
});
|
||||
});
|
||||
});
|
||||
describe('with iOS', () => {
|
||||
it('throws base Error class with custom message', async () => {
|
||||
const platform = Platform.IOS;
|
||||
const graphQLError = getGraphQLError('Error 1', 'UNKNOWN_GRAPHQL_ERROR');
|
||||
const graphQLErrors = [graphQLError];
|
||||
const error = new CombinedError({ graphQLErrors });
|
||||
const expectedMessage = `${EXPECTED_GENERIC_MESSAGE}\nRequest ID: ${mockRequestId}\nError message: Error 1`;
|
||||
|
||||
const handleBuildRequestErrorThrownError = getError<Error>(() => {
|
||||
handleBuildRequestError(error, platform);
|
||||
});
|
||||
|
||||
assertReThrownError(handleBuildRequestErrorThrownError, Error, expectedMessage);
|
||||
});
|
||||
it('throws base Error class with message including all error messages if combined error has multiple GraphQL errors', async () => {
|
||||
const platform = Platform.IOS;
|
||||
const graphQLErrors = [
|
||||
getGraphQLError('Error 1', 'UNKNOWN_GRAPHQL_ERROR'),
|
||||
getGraphQLError('Error 2', 'OTHER_GRAPHQL_ERROR'),
|
||||
getGraphQLError('Error 3', 'YET_ANOTHER_GRAPHQL_ERROR'),
|
||||
];
|
||||
const error = new CombinedError({ graphQLErrors });
|
||||
const expectedMessage =
|
||||
`${EXPECTED_GENERIC_MESSAGE}\n` +
|
||||
`Request ID: ${mockRequestId}\n` +
|
||||
'Error message: Error 1\n' +
|
||||
`Request ID: ${mockRequestId}\n` +
|
||||
'Error message: Error 2\n' +
|
||||
`Request ID: ${mockRequestId}\n` +
|
||||
'Error message: Error 3';
|
||||
|
||||
const handleBuildRequestErrorThrownError = getError<Error>(() => {
|
||||
handleBuildRequestError(error, platform);
|
||||
});
|
||||
|
||||
assertReThrownError(handleBuildRequestErrorThrownError, Error, expectedMessage);
|
||||
});
|
||||
describe('without request ID', () => {
|
||||
it('throws base Error class with custom message without request ID line', async () => {
|
||||
const platform = Platform.IOS;
|
||||
const graphQLError = getGraphQLError('Error 1', 'UNKNOWN_GRAPHQL_ERROR');
|
||||
delete graphQLError.extensions.requestId;
|
||||
const graphQLErrors = [graphQLError];
|
||||
const error = new CombinedError({ graphQLErrors });
|
||||
const expectedMessage = `${EXPECTED_GENERIC_MESSAGE}\nError message: Error 1`;
|
||||
|
||||
const handleBuildRequestErrorThrownError = getError<Error>(() => {
|
||||
handleBuildRequestError(error, platform);
|
||||
});
|
||||
|
||||
assertReThrownError(handleBuildRequestErrorThrownError, Error, expectedMessage);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('error is unexpected non-graphQL-related error', () => {
|
||||
describe('with Android', () => {
|
||||
it('throws base Error class with propagated message', async () => {
|
||||
const platform = Platform.ANDROID;
|
||||
const error = new Error('Non-graphQL-related error');
|
||||
|
||||
const handleBuildRequestErrorThrownError = getError<Error>(() => {
|
||||
handleBuildRequestError(error, platform);
|
||||
});
|
||||
|
||||
expect(handleBuildRequestErrorThrownError).toStrictEqual(error);
|
||||
});
|
||||
});
|
||||
describe('with iOS', () => {
|
||||
it('throws base Error class with propagated message', async () => {
|
||||
const platform = Platform.IOS;
|
||||
const error = new Error('Non-graphQL-related error');
|
||||
|
||||
const handleBuildRequestErrorThrownError = getError<Error>(() => {
|
||||
handleBuildRequestError(error, platform);
|
||||
});
|
||||
|
||||
expect(handleBuildRequestErrorThrownError).toStrictEqual(error);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
import { truncateGitCommitMessage } from '../metadata';
|
||||
|
||||
describe(truncateGitCommitMessage, () => {
|
||||
it('returns undefined if no message was passed', () => {
|
||||
expect(truncateGitCommitMessage(undefined)).toBeUndefined();
|
||||
});
|
||||
it('returns commit message', () => {
|
||||
expect(truncateGitCommitMessage('a'.repeat(10))).toBe('a'.repeat(10));
|
||||
});
|
||||
|
||||
it('truncates long commit messages', () => {
|
||||
expect(truncateGitCommitMessage('a'.repeat(5000))).toBe(`${'a'.repeat(4093)}...`);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,142 @@
|
||||
import { Platform, Workflow } from '@expo/eas-build-job';
|
||||
import { ExitError } from '@oclif/core/lib/errors';
|
||||
import path from 'path';
|
||||
import { instance, mock, when } from 'ts-mockito';
|
||||
|
||||
import { CommonContext } from '../context';
|
||||
import { validatePNGsForManagedProjectAsync } from '../validate';
|
||||
|
||||
const fixturesPath = path.join(__dirname, 'fixtures');
|
||||
|
||||
describe(validatePNGsForManagedProjectAsync, () => {
|
||||
it('does not validate PNGs for generic projects', async () => {
|
||||
const ctxMock = mock<CommonContext<Platform.ANDROID>>();
|
||||
when(ctxMock.workflow).thenReturn(Workflow.GENERIC);
|
||||
const ctx = instance(ctxMock);
|
||||
await expect(validatePNGsForManagedProjectAsync(ctx)).resolves.not.toThrow();
|
||||
});
|
||||
|
||||
it('does not validate PNGs for projects with SDK >= 47', async () => {
|
||||
const ctxMock = mock<CommonContext<Platform.ANDROID>>();
|
||||
when(ctxMock.workflow).thenReturn(Workflow.GENERIC);
|
||||
when(ctxMock.exp).thenReturn({
|
||||
name: 'blah',
|
||||
slug: 'blah',
|
||||
android: { adaptiveIcon: { foregroundImage: 'assets/icon.jpg' } },
|
||||
sdkVersion: '47.0.0',
|
||||
});
|
||||
const ctx = instance(ctxMock);
|
||||
await expect(validatePNGsForManagedProjectAsync(ctx)).resolves.not.toThrow();
|
||||
});
|
||||
|
||||
describe(Platform.ANDROID, () => {
|
||||
it('exits if foregroundImage is not a file with .png extension', async () => {
|
||||
const ctxMock = mock<CommonContext<Platform.ANDROID>>();
|
||||
when(ctxMock.workflow).thenReturn(Workflow.MANAGED);
|
||||
when(ctxMock.platform).thenReturn(Platform.ANDROID);
|
||||
when(ctxMock.exp).thenReturn({
|
||||
name: 'blah',
|
||||
slug: 'blah',
|
||||
android: { adaptiveIcon: { foregroundImage: 'assets/icon.jpg' } },
|
||||
sdkVersion: '46.0.0',
|
||||
});
|
||||
const ctx = instance(ctxMock);
|
||||
|
||||
await expect(validatePNGsForManagedProjectAsync(ctx)).rejects.toThrow(ExitError);
|
||||
});
|
||||
|
||||
it('exits if foregroundImage is not a png file', async () => {
|
||||
const ctxMock = mock<CommonContext<Platform.ANDROID>>();
|
||||
when(ctxMock.workflow).thenReturn(Workflow.MANAGED);
|
||||
when(ctxMock.platform).thenReturn(Platform.ANDROID);
|
||||
when(ctxMock.exp).thenReturn({
|
||||
name: 'blah',
|
||||
slug: 'blah',
|
||||
android: { adaptiveIcon: { foregroundImage: path.join(fixturesPath, 'icon-jpg.png') } },
|
||||
sdkVersion: '46.0.0',
|
||||
});
|
||||
const ctx = instance(ctxMock);
|
||||
|
||||
await expect(validatePNGsForManagedProjectAsync(ctx)).rejects.toThrow(ExitError);
|
||||
});
|
||||
|
||||
it('does not throw if foregroundImage is a png file', async () => {
|
||||
const ctxMock = mock<CommonContext<Platform.ANDROID>>();
|
||||
when(ctxMock.workflow).thenReturn(Workflow.MANAGED);
|
||||
when(ctxMock.platform).thenReturn(Platform.ANDROID);
|
||||
when(ctxMock.exp).thenReturn({
|
||||
name: 'blah',
|
||||
slug: 'blah',
|
||||
android: { adaptiveIcon: { foregroundImage: path.join(fixturesPath, 'icon-alpha.png') } },
|
||||
sdkVersion: '46.0.0',
|
||||
});
|
||||
const ctx = instance(ctxMock);
|
||||
|
||||
await expect(validatePNGsForManagedProjectAsync(ctx)).resolves.not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
// Validating iOS PNGs is currently disabled
|
||||
// See https://github.com/expo/eas-cli/pull/1477 for context
|
||||
xdescribe(Platform.IOS, () => {
|
||||
it('exits if icon is not a file with .png extension', async () => {
|
||||
const ctxMock = mock<CommonContext<Platform.IOS>>();
|
||||
when(ctxMock.workflow).thenReturn(Workflow.MANAGED);
|
||||
when(ctxMock.platform).thenReturn(Platform.IOS);
|
||||
when(ctxMock.exp).thenReturn({
|
||||
name: 'blah',
|
||||
slug: 'blah',
|
||||
ios: { icon: path.join(fixturesPath, 'assets/icon.jpg') },
|
||||
sdkVersion: '46.0.0',
|
||||
});
|
||||
const ctx = instance(ctxMock);
|
||||
|
||||
await expect(validatePNGsForManagedProjectAsync(ctx)).rejects.toThrow(ExitError);
|
||||
});
|
||||
|
||||
it('exits if icon is not a png file', async () => {
|
||||
const ctxMock = mock<CommonContext<Platform.IOS>>();
|
||||
when(ctxMock.workflow).thenReturn(Workflow.MANAGED);
|
||||
when(ctxMock.platform).thenReturn(Platform.IOS);
|
||||
when(ctxMock.exp).thenReturn({
|
||||
name: 'blah',
|
||||
slug: 'blah',
|
||||
ios: { icon: path.join(fixturesPath, 'icon-jpg.png') },
|
||||
sdkVersion: '46.0.0',
|
||||
});
|
||||
const ctx = instance(ctxMock);
|
||||
|
||||
await expect(validatePNGsForManagedProjectAsync(ctx)).rejects.toThrow(ExitError);
|
||||
});
|
||||
|
||||
it('exits if icon has alpha channel (transparency)', async () => {
|
||||
const ctxMock = mock<CommonContext<Platform.IOS>>();
|
||||
when(ctxMock.workflow).thenReturn(Workflow.MANAGED);
|
||||
when(ctxMock.platform).thenReturn(Platform.IOS);
|
||||
when(ctxMock.exp).thenReturn({
|
||||
name: 'blah',
|
||||
slug: 'blah',
|
||||
ios: { icon: path.join(fixturesPath, 'icon-alpha.png') },
|
||||
sdkVersion: '46.0.0',
|
||||
});
|
||||
const ctx = instance(ctxMock);
|
||||
|
||||
await expect(validatePNGsForManagedProjectAsync(ctx)).rejects.toThrow(ExitError);
|
||||
});
|
||||
|
||||
it('does not throw if icon is a png file and does not have alpha channel', async () => {
|
||||
const ctxMock = mock<CommonContext<Platform.IOS>>();
|
||||
when(ctxMock.workflow).thenReturn(Workflow.MANAGED);
|
||||
when(ctxMock.platform).thenReturn(Platform.IOS);
|
||||
when(ctxMock.exp).thenReturn({
|
||||
name: 'blah',
|
||||
slug: 'blah',
|
||||
ios: { icon: path.join(fixturesPath, 'icon-no-alpha.png') },
|
||||
sdkVersion: '46.0.0',
|
||||
});
|
||||
const ctx = instance(ctxMock);
|
||||
|
||||
await expect(validatePNGsForManagedProjectAsync(ctx)).resolves.not.toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -73,39 +73,39 @@ describe(bumpVersionAsync, () => {
|
||||
|
||||
await bumpVersionAsync({
|
||||
bumpStrategy: BumpStrategy.VERSION_CODE,
|
||||
projectDir: '/repo',
|
||||
projectDir: '/app',
|
||||
exp: fakeExp,
|
||||
});
|
||||
|
||||
const appJSON = await fs.readJSON('/repo/app.json');
|
||||
const appJSON = await fs.readJSON('/app/app.json');
|
||||
expect(fakeExp.version).toBe('3.0.0');
|
||||
expect(fakeExp.android?.versionCode).toBe(124);
|
||||
expect(appJSON.expo.version).toBe('3.0.0');
|
||||
expect(appJSON.expo.android.versionCode).toBe(124);
|
||||
|
||||
const buildGradle = await getAppBuildGradleAsync('/repo');
|
||||
const buildGradle = await getAppBuildGradleAsync('/app');
|
||||
assert(buildGradle);
|
||||
expect(resolveConfigValue(buildGradle, 'versionCode')).toBe('124');
|
||||
expect(resolveConfigValue(buildGradle, 'versionName')).toBe('3.0.0');
|
||||
});
|
||||
|
||||
it('bumps expo.version and gradle versionCode when strategy = BumpStrategy.SHORT_VERSION', async () => {
|
||||
it('bumps expo.version and gradle versionCode when strategy = BumpStrategy.APP_VERSION', async () => {
|
||||
const nativeVersionName = '1.0.0'; // this should be overwritten by bumpVersionAsync
|
||||
const fakeExp = initBareWorkflowProject({ versionName: nativeVersionName });
|
||||
|
||||
await bumpVersionAsync({
|
||||
bumpStrategy: BumpStrategy.APP_VERSION,
|
||||
projectDir: '/repo',
|
||||
projectDir: '/app',
|
||||
exp: fakeExp,
|
||||
});
|
||||
|
||||
const appJSON = await fs.readJSON('/repo/app.json');
|
||||
const appJSON = await fs.readJSON('/app/app.json');
|
||||
expect(fakeExp.version).toBe('3.0.1');
|
||||
expect(fakeExp.android?.versionCode).toBe(123);
|
||||
expect(appJSON.expo.version).toBe('3.0.1');
|
||||
expect(appJSON.expo.android.versionCode).toBe(123);
|
||||
|
||||
const buildGradle = await getAppBuildGradleAsync('/repo');
|
||||
const buildGradle = await getAppBuildGradleAsync('/app');
|
||||
assert(buildGradle);
|
||||
expect(resolveConfigValue(buildGradle, 'versionCode')).toBe('123');
|
||||
expect(resolveConfigValue(buildGradle, 'versionName')).toBe('3.0.1');
|
||||
@@ -116,17 +116,17 @@ describe(bumpVersionAsync, () => {
|
||||
|
||||
await bumpVersionAsync({
|
||||
bumpStrategy: BumpStrategy.NOOP,
|
||||
projectDir: '/repo',
|
||||
projectDir: '/app',
|
||||
exp: fakeExp,
|
||||
});
|
||||
|
||||
const appJSON = await fs.readJSON('/repo/app.json');
|
||||
const appJSON = await fs.readJSON('/app/app.json');
|
||||
expect(fakeExp.version).toBe('3.0.0');
|
||||
expect(fakeExp.android?.versionCode).toBe(123);
|
||||
expect(appJSON.expo.version).toBe('3.0.0');
|
||||
expect(appJSON.expo.android.versionCode).toBe(123);
|
||||
|
||||
const buildGradle = await getAppBuildGradleAsync('/repo');
|
||||
const buildGradle = await getAppBuildGradleAsync('/app');
|
||||
assert(buildGradle);
|
||||
expect(resolveConfigValue(buildGradle, 'versionCode')).toBe('123');
|
||||
expect(resolveConfigValue(buildGradle, 'versionName')).toBe('3.0.0');
|
||||
@@ -140,11 +140,11 @@ describe(bumpVersionInAppJsonAsync, () => {
|
||||
|
||||
await bumpVersionInAppJsonAsync({
|
||||
bumpStrategy: BumpStrategy.VERSION_CODE,
|
||||
projectDir: '/repo',
|
||||
projectDir: '/app',
|
||||
exp: fakeExp,
|
||||
});
|
||||
|
||||
const appJSON = await fs.readJSON('/repo/app.json');
|
||||
const appJSON = await fs.readJSON('/app/app.json');
|
||||
expect(fakeExp.version).toBe('5.0.0');
|
||||
expect(fakeExp.android?.versionCode).toBe(127);
|
||||
expect(appJSON.expo.version).toBe('5.0.0');
|
||||
@@ -156,11 +156,11 @@ describe(bumpVersionInAppJsonAsync, () => {
|
||||
|
||||
await bumpVersionInAppJsonAsync({
|
||||
bumpStrategy: BumpStrategy.APP_VERSION,
|
||||
projectDir: '/repo',
|
||||
projectDir: '/app',
|
||||
exp: fakeExp,
|
||||
});
|
||||
|
||||
const appJSON = await fs.readJSON('/repo/app.json');
|
||||
const appJSON = await fs.readJSON('/app/app.json');
|
||||
expect(fakeExp.version).toBe('5.0.1');
|
||||
expect(fakeExp.android?.versionCode).toBe(126);
|
||||
expect(appJSON.expo.version).toBe('5.0.1');
|
||||
@@ -172,11 +172,11 @@ describe(bumpVersionInAppJsonAsync, () => {
|
||||
|
||||
await bumpVersionInAppJsonAsync({
|
||||
bumpStrategy: BumpStrategy.NOOP,
|
||||
projectDir: '/repo',
|
||||
projectDir: '/app',
|
||||
exp: fakeExp,
|
||||
});
|
||||
|
||||
const appJSON = await fs.readJSON('/repo/app.json');
|
||||
const appJSON = await fs.readJSON('/app/app.json');
|
||||
expect(fakeExp.version).toBe('5.0.0');
|
||||
expect(fakeExp.android?.versionCode).toBe(126);
|
||||
expect(appJSON.expo.version).toBe('5.0.0');
|
||||
@@ -189,7 +189,7 @@ describe(maybeResolveVersionsAsync, () => {
|
||||
it('reads the versions from native code', async () => {
|
||||
const exp = initBareWorkflowProject();
|
||||
const { appVersion, appBuildVersion } = await maybeResolveVersionsAsync(
|
||||
'/repo',
|
||||
'/app',
|
||||
exp,
|
||||
{} as BuildProfile<Platform.ANDROID>
|
||||
);
|
||||
@@ -201,7 +201,7 @@ describe(maybeResolveVersionsAsync, () => {
|
||||
it('reads the versions from expo config', async () => {
|
||||
const exp = initManagedProject();
|
||||
const { appVersion, appBuildVersion } = await maybeResolveVersionsAsync(
|
||||
'/repo',
|
||||
'/app',
|
||||
exp,
|
||||
{} as BuildProfile<Platform.ANDROID>
|
||||
);
|
||||
@@ -240,7 +240,7 @@ function initBareWorkflowProject({
|
||||
}`,
|
||||
'./android/app/src/main/AndroidManifest.xml': 'fake',
|
||||
},
|
||||
'/repo'
|
||||
'/app'
|
||||
);
|
||||
|
||||
return fakeExp;
|
||||
@@ -261,7 +261,7 @@ function initManagedProject(): ExpoConfig {
|
||||
expo: fakeExp,
|
||||
}),
|
||||
},
|
||||
'/repo'
|
||||
'/app'
|
||||
);
|
||||
|
||||
return fakeExp;
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { Android, Job, Metadata, Platform, Workflow } from '@expo/eas-build-job';
|
||||
import { AppVersionSource } from '@expo/eas-json';
|
||||
import chalk from 'chalk';
|
||||
import nullthrows from 'nullthrows';
|
||||
|
||||
import AndroidCredentialsProvider, {
|
||||
AndroidCredentials,
|
||||
} from '../../credentials/android/AndroidCredentialsProvider';
|
||||
import { BuildParamsInput } from '../../graphql/generated';
|
||||
import { BuildMutation, BuildResult } from '../../graphql/mutations/BuildMutation';
|
||||
import Log from '../../log';
|
||||
import {
|
||||
@@ -13,7 +15,6 @@ import {
|
||||
} from '../../project/android/applicationId';
|
||||
import { resolveGradleBuildContextAsync } from '../../project/android/gradle';
|
||||
import { toggleConfirmAsync } from '../../prompts';
|
||||
import { findAccountByName } from '../../user/Account';
|
||||
import {
|
||||
BuildRequestSender,
|
||||
CredentialsResult,
|
||||
@@ -23,10 +24,15 @@ import {
|
||||
import { AndroidBuildContext, BuildContext, CommonContext } from '../context';
|
||||
import { transformMetadata } from '../graphql';
|
||||
import { logCredentialsSource } from '../utils/credentials';
|
||||
import { checkGoogleServicesFileAsync, checkNodeEnvVariable } from '../validate';
|
||||
import {
|
||||
checkGoogleServicesFileAsync,
|
||||
checkNodeEnvVariable,
|
||||
validatePNGsForManagedProjectAsync,
|
||||
} from '../validate';
|
||||
import { transformJob } from './graphql';
|
||||
import { prepareJobAsync } from './prepareJob';
|
||||
import { syncProjectConfigurationAsync } from './syncProjectConfiguration';
|
||||
import { resolveRemoteVersionCodeAsync } from './version';
|
||||
|
||||
export async function createAndroidContextAsync(
|
||||
ctx: CommonContext<Platform.ANDROID>
|
||||
@@ -44,23 +50,34 @@ This means that it will most likely produce an AAB and you will not be able to i
|
||||
Log.newLine();
|
||||
const confirmed = await toggleConfirmAsync({ message: 'Would you like to proceed?' });
|
||||
if (!confirmed) {
|
||||
Log.error('Please update eas.json and come back again.');
|
||||
Log.error('Update eas.json and come back again.');
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
checkNodeEnvVariable(ctx);
|
||||
await checkGoogleServicesFileAsync(ctx);
|
||||
await validatePNGsForManagedProjectAsync(ctx);
|
||||
|
||||
const gradleContext = await resolveGradleBuildContextAsync(ctx.projectDir, buildProfile);
|
||||
|
||||
if (ctx.workflow === Workflow.MANAGED) {
|
||||
await ensureApplicationIdIsDefinedForManagedProjectAsync(ctx.projectDir, ctx.exp);
|
||||
await ensureApplicationIdIsDefinedForManagedProjectAsync(ctx);
|
||||
}
|
||||
|
||||
const applicationId = await getApplicationIdAsync(ctx.projectDir, ctx.exp, gradleContext);
|
||||
const versionCodeOverride =
|
||||
ctx.easJsonCliConfig?.appVersionSource === AppVersionSource.REMOTE
|
||||
? await resolveRemoteVersionCodeAsync(ctx.graphqlClient, {
|
||||
projectDir: ctx.projectDir,
|
||||
projectId: ctx.projectId,
|
||||
exp: ctx.exp,
|
||||
applicationId,
|
||||
buildProfile,
|
||||
})
|
||||
: undefined;
|
||||
|
||||
return { applicationId, gradleContext };
|
||||
return { applicationId, gradleContext, versionCodeOverride };
|
||||
}
|
||||
|
||||
export async function prepareAndroidBuildAsync(
|
||||
@@ -72,10 +89,14 @@ export async function prepareAndroidBuildAsync(
|
||||
return await ensureAndroidCredentialsAsync(ctx);
|
||||
},
|
||||
syncProjectConfigurationAsync: async () => {
|
||||
await syncProjectConfigurationAsync({
|
||||
await syncProjectConfigurationAsync(ctx.graphqlClient, {
|
||||
projectDir: ctx.projectDir,
|
||||
exp: ctx.exp,
|
||||
buildProfile: ctx.buildProfile,
|
||||
localAutoIncrement:
|
||||
ctx.easJsonCliConfig?.appVersionSource === AppVersionSource.REMOTE
|
||||
? false
|
||||
: ctx.buildProfile.autoIncrement,
|
||||
projectId: ctx.projectId,
|
||||
});
|
||||
},
|
||||
prepareJobAsync: async (
|
||||
@@ -87,21 +108,24 @@ export async function prepareAndroidBuildAsync(
|
||||
sendBuildRequestAsync: async (
|
||||
appId: string,
|
||||
job: Android.Job,
|
||||
metadata: Metadata
|
||||
metadata: Metadata,
|
||||
buildParams: BuildParamsInput
|
||||
): Promise<BuildResult> => {
|
||||
const graphqlMetadata = transformMetadata(metadata);
|
||||
const graphqlJob = transformJob(job);
|
||||
return await BuildMutation.createAndroidBuildAsync({
|
||||
return await BuildMutation.createAndroidBuildAsync(ctx.graphqlClient, {
|
||||
appId,
|
||||
job: graphqlJob,
|
||||
metadata: graphqlMetadata,
|
||||
buildParams,
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function shouldProvideCredentials(ctx: BuildContext<Platform.ANDROID>): boolean {
|
||||
return !ctx.buildProfile.withoutCredentials;
|
||||
const isCustomBuild = ctx.buildProfile.config !== undefined;
|
||||
return !ctx.buildProfile.withoutCredentials && !isCustomBuild;
|
||||
}
|
||||
|
||||
async function ensureAndroidCredentialsAsync(
|
||||
@@ -118,7 +142,7 @@ async function ensureAndroidCredentialsAsync(
|
||||
const provider = new AndroidCredentialsProvider(ctx.credentialsCtx, {
|
||||
app: {
|
||||
account: nullthrows(
|
||||
findAccountByName(ctx.user.accounts, ctx.accountName),
|
||||
ctx.user.accounts.find(a => a.name === ctx.accountName),
|
||||
`You do not have access to account: ${ctx.accountName}`
|
||||
),
|
||||
projectName: ctx.projectName,
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
import { Android } from '@expo/eas-build-job';
|
||||
|
||||
import { AndroidBuildType, AndroidJobInput } from '../../graphql/generated';
|
||||
import { transformProjectArchive, transformWorkflow } from '../graphql';
|
||||
import {
|
||||
transformBuildMode,
|
||||
transformBuildTrigger,
|
||||
transformProjectArchive,
|
||||
transformWorkflow,
|
||||
} from '../graphql';
|
||||
|
||||
export function transformJob(job: Android.Job): AndroidJobInput {
|
||||
return {
|
||||
type: transformWorkflow(job.type),
|
||||
triggeredBy: transformBuildTrigger(job.triggeredBy),
|
||||
projectArchive: transformProjectArchive(job.projectArchive),
|
||||
projectRootDirectory: job.projectRootDirectory,
|
||||
releaseChannel: job.releaseChannel,
|
||||
@@ -13,12 +19,16 @@ export function transformJob(job: Android.Job): AndroidJobInput {
|
||||
secrets: job.secrets,
|
||||
builderEnvironment: job.builderEnvironment,
|
||||
cache: job.cache,
|
||||
version: job.version?.versionCode ? { versionCode: job.version.versionCode } : undefined,
|
||||
gradleCommand: job.gradleCommand,
|
||||
artifactPath: job.artifactPath,
|
||||
applicationArchivePath: job.applicationArchivePath,
|
||||
buildArtifactPaths: job.buildArtifactPaths,
|
||||
username: job.username,
|
||||
buildType: job.buildType && transformBuildType(job.buildType),
|
||||
developmentClient: job.developmentClient,
|
||||
experimental: job.experimental,
|
||||
mode: transformBuildMode(job.mode),
|
||||
customBuildConfig: job.customBuildConfig,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,19 @@
|
||||
import { Android, ArchiveSource, Job, Platform, sanitizeJob } from '@expo/eas-build-job';
|
||||
import {
|
||||
Android,
|
||||
ArchiveSource,
|
||||
BuildMode,
|
||||
BuildTrigger,
|
||||
Job,
|
||||
Platform,
|
||||
sanitizeJob,
|
||||
} from '@expo/eas-build-job';
|
||||
import { BuildProfile } from '@expo/eas-json';
|
||||
import path from 'path';
|
||||
import slash from 'slash';
|
||||
|
||||
import { AndroidCredentials } from '../../credentials/android/AndroidCredentialsProvider';
|
||||
import { getCustomBuildConfigPath } from '../../project/customBuildConfig';
|
||||
import { getUsername } from '../../project/projectUtils';
|
||||
import { ensureLoggedInAsync } from '../../user/actions';
|
||||
import { getVcsClient } from '../../vcs';
|
||||
import { BuildContext } from '../context';
|
||||
|
||||
@@ -16,15 +24,14 @@ interface JobData {
|
||||
|
||||
const cacheDefaults = {
|
||||
disabled: false,
|
||||
customPaths: [],
|
||||
cacheDefaultPaths: true,
|
||||
paths: [],
|
||||
};
|
||||
|
||||
export async function prepareJobAsync(
|
||||
ctx: BuildContext<Platform.ANDROID>,
|
||||
jobData: JobData
|
||||
): Promise<Job> {
|
||||
const username = getUsername(ctx.exp, await ensureLoggedInAsync());
|
||||
const username = getUsername(ctx.exp, ctx.user);
|
||||
const buildProfile: BuildProfile<Platform.ANDROID> = ctx.buildProfile;
|
||||
const projectRootDirectory =
|
||||
slash(path.relative(await getVcsClient().getRootPathAsync(), ctx.projectDir)) || '.';
|
||||
@@ -42,11 +49,15 @@ export async function prepareJobAsync(
|
||||
}
|
||||
: {};
|
||||
|
||||
let buildType = ctx.buildProfile.buildType;
|
||||
if (!buildType && !buildProfile.gradleCommand && ctx.buildProfile.distribution === 'internal') {
|
||||
let buildType = buildProfile.buildType;
|
||||
if (!buildType && !buildProfile.gradleCommand && buildProfile.distribution === 'internal') {
|
||||
buildType = Android.BuildType.APK;
|
||||
}
|
||||
|
||||
const maybeCustomBuildConfigPath = buildProfile.config
|
||||
? getCustomBuildConfigPath(buildProfile.config)
|
||||
: undefined;
|
||||
|
||||
const job: Android.Job = {
|
||||
type: ctx.workflow,
|
||||
platform: Platform.ANDROID,
|
||||
@@ -68,16 +79,29 @@ export async function prepareJobAsync(
|
||||
secrets: {
|
||||
...buildCredentials,
|
||||
},
|
||||
releaseChannel: ctx.buildProfile.releaseChannel,
|
||||
updates: { channel: ctx.buildProfile.channel },
|
||||
releaseChannel: buildProfile.releaseChannel,
|
||||
updates: { channel: buildProfile.channel },
|
||||
developmentClient: buildProfile.developmentClient,
|
||||
gradleCommand: buildProfile.gradleCommand,
|
||||
artifactPath: buildProfile.artifactPath,
|
||||
applicationArchivePath: buildProfile.applicationArchivePath ?? buildProfile.artifactPath,
|
||||
buildArtifactPaths: buildProfile.buildArtifactPaths,
|
||||
buildType,
|
||||
username,
|
||||
...(ctx.android.versionCodeOverride && {
|
||||
version: {
|
||||
versionCode: ctx.android.versionCodeOverride,
|
||||
},
|
||||
}),
|
||||
experimental: {
|
||||
prebuildCommand: ctx.buildProfile.prebuildCommand,
|
||||
prebuildCommand: buildProfile.prebuildCommand,
|
||||
},
|
||||
mode: buildProfile.config ? BuildMode.CUSTOM : BuildMode.BUILD,
|
||||
triggeredBy: BuildTrigger.EAS_CLI,
|
||||
...(maybeCustomBuildConfigPath && {
|
||||
customBuildConfig: {
|
||||
path: maybeCustomBuildConfigPath,
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
return sanitizeJob(job);
|
||||
|
||||
@@ -1,34 +1,39 @@
|
||||
import { ExpoConfig } from '@expo/config';
|
||||
import { AndroidConfig } from '@expo/config-plugins';
|
||||
import { Platform, Workflow } from '@expo/eas-build-job';
|
||||
import { AndroidVersionAutoIncrement, BuildProfile } from '@expo/eas-json';
|
||||
import { AndroidVersionAutoIncrement } from '@expo/eas-json';
|
||||
import chalk from 'chalk';
|
||||
import fs from 'fs-extra';
|
||||
import path from 'path';
|
||||
|
||||
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import Log from '../../log';
|
||||
import { isExpoUpdatesInstalled } from '../../project/projectUtils';
|
||||
import { resolveWorkflowAsync } from '../../project/workflow';
|
||||
import { syncUpdatesConfigurationAsync } from '../../update/android/UpdatesModule';
|
||||
import { BumpStrategy, bumpVersionAsync, bumpVersionInAppJsonAsync } from './version';
|
||||
|
||||
export async function syncProjectConfigurationAsync({
|
||||
projectDir,
|
||||
exp,
|
||||
buildProfile,
|
||||
}: {
|
||||
projectDir: string;
|
||||
exp: ExpoConfig;
|
||||
buildProfile: BuildProfile<Platform.ANDROID>;
|
||||
}): Promise<void> {
|
||||
export async function syncProjectConfigurationAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
{
|
||||
projectDir,
|
||||
exp,
|
||||
localAutoIncrement,
|
||||
projectId,
|
||||
}: {
|
||||
projectDir: string;
|
||||
exp: ExpoConfig;
|
||||
localAutoIncrement?: AndroidVersionAutoIncrement;
|
||||
projectId: string;
|
||||
}
|
||||
): Promise<void> {
|
||||
const workflow = await resolveWorkflowAsync(projectDir, Platform.ANDROID);
|
||||
const { autoIncrement } = buildProfile;
|
||||
const versionBumpStrategy = resolveVersionBumpStrategy(autoIncrement ?? false);
|
||||
const versionBumpStrategy = resolveVersionBumpStrategy(localAutoIncrement ?? false);
|
||||
|
||||
if (workflow === Workflow.GENERIC) {
|
||||
await cleanUpOldEasBuildGradleScriptAsync(projectDir);
|
||||
if (isExpoUpdatesInstalled(projectDir)) {
|
||||
await syncUpdatesConfigurationAsync(projectDir, exp);
|
||||
await syncUpdatesConfigurationAsync(graphqlClient, projectDir, exp, projectId);
|
||||
}
|
||||
await bumpVersionAsync({ projectDir, exp, bumpStrategy: versionBumpStrategy });
|
||||
} else {
|
||||
|
||||
@@ -1,16 +1,22 @@
|
||||
import { ExpoConfig } from '@expo/config';
|
||||
import { AndroidConfig } from '@expo/config-plugins';
|
||||
import { AndroidConfig, Updates } from '@expo/config-plugins';
|
||||
import { Platform, Workflow } from '@expo/eas-build-job';
|
||||
import { BuildProfile } from '@expo/eas-json';
|
||||
import chalk from 'chalk';
|
||||
import fs from 'fs-extra';
|
||||
|
||||
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { AppPlatform } from '../../graphql/generated';
|
||||
import { AppVersionMutation } from '../../graphql/mutations/AppVersionMutation';
|
||||
import { AppVersionQuery } from '../../graphql/queries/AppVersionQuery';
|
||||
import Log from '../../log';
|
||||
import { ora } from '../../ora';
|
||||
import {
|
||||
getAppBuildGradleAsync,
|
||||
parseGradleCommand,
|
||||
resolveConfigValue,
|
||||
} from '../../project/android/gradleUtils';
|
||||
import { getNextVersionCode } from '../../project/android/versions';
|
||||
import { resolveWorkflowAsync } from '../../project/workflow';
|
||||
import { updateAppJsonConfigAsync } from '../utils/appJson';
|
||||
import { bumpAppVersionAsync, ensureStaticConfigExists } from '../utils/version';
|
||||
@@ -46,9 +52,10 @@ export async function bumpVersionAsync({
|
||||
|
||||
await bumpVersionInAppJsonAsync({ bumpStrategy, projectDir, exp });
|
||||
Log.log('Updated versions in app.json');
|
||||
await writeVersionsToBuildGradleAsync({
|
||||
await updateNativeVersionsAsync({
|
||||
projectDir,
|
||||
exp,
|
||||
version: exp.version,
|
||||
versionCode: exp.android?.versionCode,
|
||||
});
|
||||
Log.log('Synchronized versions with build gradle');
|
||||
}
|
||||
@@ -74,7 +81,7 @@ export async function bumpVersionInAppJsonAsync({
|
||||
await bumpAppVersionAsync({ appVersion, projectDir, exp });
|
||||
} else {
|
||||
const versionCode = AndroidConfig.Version.getVersionCode(exp);
|
||||
const bumpedVersionCode = versionCode + 1;
|
||||
const bumpedVersionCode = getNextVersionCode(versionCode);
|
||||
Log.log(
|
||||
`Bumping ${chalk.bold('expo.android.versionCode')} from ${chalk.bold(
|
||||
versionCode
|
||||
@@ -116,21 +123,33 @@ export async function maybeResolveVersionsAsync(
|
||||
}
|
||||
}
|
||||
|
||||
async function writeVersionsToBuildGradleAsync({
|
||||
export async function updateNativeVersionsAsync({
|
||||
projectDir,
|
||||
exp,
|
||||
version,
|
||||
versionCode,
|
||||
}: {
|
||||
projectDir: string;
|
||||
exp: ExpoConfig;
|
||||
}): Promise<string> {
|
||||
version?: string;
|
||||
versionCode?: number;
|
||||
}): Promise<void> {
|
||||
const buildGradle = await readBuildGradleAsync(projectDir);
|
||||
if (!buildGradle) {
|
||||
throw new Error('This project is missing a build.gradle file.');
|
||||
}
|
||||
let updatedBuildGradle = AndroidConfig.Version.setVersionName(exp, buildGradle);
|
||||
updatedBuildGradle = AndroidConfig.Version.setVersionCode(exp, updatedBuildGradle);
|
||||
let updatedBuildGradle = buildGradle;
|
||||
if (version !== undefined) {
|
||||
updatedBuildGradle = updatedBuildGradle.replace(
|
||||
new RegExp(`versionName ".*"`),
|
||||
`versionName "${version}"`
|
||||
);
|
||||
}
|
||||
if (versionCode !== undefined) {
|
||||
updatedBuildGradle = updatedBuildGradle.replace(
|
||||
new RegExp(`versionCode.*`),
|
||||
`versionCode ${versionCode}`
|
||||
);
|
||||
}
|
||||
await writeBuildGradleAsync({ projectDir, buildGradle: updatedBuildGradle });
|
||||
return updatedBuildGradle;
|
||||
}
|
||||
|
||||
async function readBuildGradleAsync(projectDir: string): Promise<string | undefined> {
|
||||
@@ -151,3 +170,101 @@ async function writeBuildGradleAsync({
|
||||
const buildGradlePath = AndroidConfig.Paths.getAppBuildGradleFilePath(projectDir);
|
||||
await fs.writeFile(buildGradlePath, buildGradle);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns buildNumber that will be used for the next build. If current build profile
|
||||
* has an 'autoIncrement' option set, it increments the version on server.
|
||||
*/
|
||||
export async function resolveRemoteVersionCodeAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
{
|
||||
projectDir,
|
||||
projectId,
|
||||
exp,
|
||||
applicationId,
|
||||
buildProfile,
|
||||
}: {
|
||||
projectDir: string;
|
||||
projectId: string;
|
||||
exp: ExpoConfig;
|
||||
applicationId: string;
|
||||
buildProfile: BuildProfile<Platform.ANDROID>;
|
||||
}
|
||||
): Promise<string> {
|
||||
const remoteVersions = await AppVersionQuery.latestVersionAsync(
|
||||
graphqlClient,
|
||||
projectId,
|
||||
AppPlatform.Android,
|
||||
applicationId
|
||||
);
|
||||
|
||||
const localVersions = await maybeResolveVersionsAsync(projectDir, exp, buildProfile);
|
||||
let currentBuildVersion: string;
|
||||
if (remoteVersions?.buildVersion) {
|
||||
currentBuildVersion = remoteVersions.buildVersion;
|
||||
} else {
|
||||
if (localVersions.appBuildVersion) {
|
||||
Log.warn(
|
||||
'No remote versions are configured for this project, versionCode will be initialized based on the value from the local project.'
|
||||
);
|
||||
currentBuildVersion = localVersions.appBuildVersion;
|
||||
} else {
|
||||
Log.error(
|
||||
`Remote versions are not configured and EAS CLI was not able to read the current version from your project. Use "eas build:version:set" to initialize remote versions.`
|
||||
);
|
||||
throw new Error('Remote versions are not configured.');
|
||||
}
|
||||
}
|
||||
if (!buildProfile.autoIncrement && remoteVersions?.buildVersion) {
|
||||
return currentBuildVersion;
|
||||
} else if (!buildProfile.autoIncrement && !remoteVersions?.buildVersion) {
|
||||
const spinner = ora(
|
||||
`Initializing versionCode with ${chalk.bold(currentBuildVersion)}.`
|
||||
).start();
|
||||
try {
|
||||
await AppVersionMutation.createAppVersionAsync(graphqlClient, {
|
||||
appId: projectId,
|
||||
platform: AppPlatform.Android,
|
||||
applicationIdentifier: applicationId,
|
||||
storeVersion: localVersions.appVersion ?? exp.version ?? '1.0.0',
|
||||
buildVersion: currentBuildVersion,
|
||||
runtimeVersion: Updates.getRuntimeVersionNullable(exp, Platform.ANDROID) ?? undefined,
|
||||
});
|
||||
spinner.succeed(`Initialized versionCode with ${chalk.bold(currentBuildVersion)}.`);
|
||||
} catch (err) {
|
||||
spinner.fail(`Failed to initialize versionCode with ${chalk.bold(currentBuildVersion)}.`);
|
||||
throw err;
|
||||
}
|
||||
return currentBuildVersion;
|
||||
} else {
|
||||
const nextBuildVersion = getNextVersionCode(currentBuildVersion);
|
||||
const spinner = ora(
|
||||
`Incrementing versionCode from ${chalk.bold(currentBuildVersion)} to ${chalk.bold(
|
||||
nextBuildVersion
|
||||
)}.`
|
||||
).start();
|
||||
try {
|
||||
await AppVersionMutation.createAppVersionAsync(graphqlClient, {
|
||||
appId: projectId,
|
||||
platform: AppPlatform.Android,
|
||||
applicationIdentifier: applicationId,
|
||||
storeVersion: localVersions.appVersion ?? exp.version ?? '1.0.0',
|
||||
buildVersion: String(nextBuildVersion),
|
||||
runtimeVersion: Updates.getRuntimeVersionNullable(exp, Platform.ANDROID) ?? undefined,
|
||||
});
|
||||
spinner.succeed(
|
||||
`Incremented versionCode from ${chalk.bold(currentBuildVersion)} to ${chalk.bold(
|
||||
nextBuildVersion
|
||||
)}.`
|
||||
);
|
||||
} catch (err) {
|
||||
spinner.fail(
|
||||
`Failed to increment versionCode from ${chalk.bold(currentBuildVersion)} to ${chalk.bold(
|
||||
nextBuildVersion
|
||||
)}.`
|
||||
);
|
||||
throw err;
|
||||
}
|
||||
return String(nextBuildVersion);
|
||||
}
|
||||
}
|
||||
@@ -1,36 +1,58 @@
|
||||
import { ArchiveSource, ArchiveSourceType, Job, Metadata, Platform } from '@expo/eas-build-job';
|
||||
import { CredentialsSource } from '@expo/eas-json';
|
||||
import { CredentialsSource, EasJsonAccessor, EasJsonUtils, ResourceClass } from '@expo/eas-json';
|
||||
import assert from 'assert';
|
||||
import chalk from 'chalk';
|
||||
import cliProgress from 'cli-progress';
|
||||
import fs from 'fs-extra';
|
||||
import { GraphQLError } from 'graphql/error';
|
||||
import nullthrows from 'nullthrows';
|
||||
|
||||
import { BuildEvent } from '../analytics/AnalyticsManager';
|
||||
import { withAnalyticsAsync } from '../analytics/common';
|
||||
import { BuildEvent } from '../analytics/events';
|
||||
import { getExpoWebsiteBaseUrl } from '../api';
|
||||
import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { EasCommandError } from '../commandUtils/errors';
|
||||
import {
|
||||
AppPlatform,
|
||||
BuildFragment,
|
||||
BuildParamsInput,
|
||||
BuildPriority,
|
||||
BuildResourceClass,
|
||||
BuildStatus,
|
||||
UploadSessionType,
|
||||
} from '../graphql/generated';
|
||||
import { BuildResult } from '../graphql/mutations/BuildMutation';
|
||||
import { BuildMutation, BuildResult } from '../graphql/mutations/BuildMutation';
|
||||
import { BuildQuery } from '../graphql/queries/BuildQuery';
|
||||
import Log, { learnMore } from '../log';
|
||||
import Log, { learnMore, link } from '../log';
|
||||
import { Ora, ora } from '../ora';
|
||||
import {
|
||||
appPlatformDisplayNames,
|
||||
appPlatformEmojis,
|
||||
requestedPlatformDisplayNames,
|
||||
} from '../platform';
|
||||
import { uploadAsync } from '../uploads';
|
||||
import { confirmAsync } from '../prompts';
|
||||
import { uploadFileAtPathToGCSAsync } from '../uploads';
|
||||
import { formatBytes } from '../utils/files';
|
||||
import { printJsonOnlyOutput } from '../utils/json';
|
||||
import { createProgressTracker } from '../utils/progress';
|
||||
import { sleepAsync } from '../utils/promise';
|
||||
import { getVcsClient } from '../vcs';
|
||||
import { BuildContext } from './context';
|
||||
import { runLocalBuildAsync } from './local';
|
||||
import {
|
||||
EasBuildDownForMaintenanceError,
|
||||
EasBuildFreeTierDisabledAndroidError,
|
||||
EasBuildFreeTierDisabledError,
|
||||
EasBuildFreeTierDisabledIOSError,
|
||||
EasBuildFreeTierIosLimitExceededError,
|
||||
EasBuildFreeTierLimitExceededError,
|
||||
EasBuildProjectArchiveUploadError,
|
||||
EasBuildResourceClassNotAvailableInFreeTierError,
|
||||
EasBuildTooManyPendingBuildsError,
|
||||
RequestValidationError,
|
||||
TurtleDeprecatedJobFormatError,
|
||||
} from './errors';
|
||||
import { transformMetadata } from './graphql';
|
||||
import { LocalBuildMode, runLocalBuildAsync } from './local';
|
||||
import { collectMetadataAsync } from './metadata';
|
||||
import { printDeprecationWarnings } from './utils/printBuildInfo';
|
||||
import { makeProjectTarballAsync, reviewAndCommitChangesAsync } from './utils/repository';
|
||||
@@ -53,11 +75,27 @@ interface Builder<TPlatform extends Platform, Credentials, TJob extends Job> {
|
||||
): Promise<CredentialsResult<Credentials> | undefined>;
|
||||
syncProjectConfigurationAsync(ctx: BuildContext<TPlatform>): Promise<void>;
|
||||
prepareJobAsync(ctx: BuildContext<TPlatform>, jobData: JobData<Credentials>): Promise<Job>;
|
||||
sendBuildRequestAsync(appId: string, job: TJob, metadata: Metadata): Promise<BuildResult>;
|
||||
sendBuildRequestAsync(
|
||||
appId: string,
|
||||
job: TJob,
|
||||
metadata: Metadata,
|
||||
buildParams: BuildParamsInput
|
||||
): Promise<BuildResult>;
|
||||
}
|
||||
|
||||
export type BuildRequestSender = () => Promise<BuildFragment | undefined>;
|
||||
|
||||
function resolveBuildParamsInput<T extends Platform>(
|
||||
ctx: BuildContext<T>,
|
||||
metadata: Metadata
|
||||
): BuildParamsInput {
|
||||
return {
|
||||
resourceClass: ctx.resourceClass,
|
||||
sdkVersion: metadata.sdkVersion,
|
||||
reactNativeVersion: metadata.reactNativeVersion,
|
||||
};
|
||||
}
|
||||
|
||||
export async function prepareBuildRequestForPlatformAsync<
|
||||
TPlatform extends Platform,
|
||||
Credentials,
|
||||
@@ -65,21 +103,26 @@ export async function prepareBuildRequestForPlatformAsync<
|
||||
>(builder: Builder<TPlatform, Credentials, TJob>): Promise<BuildRequestSender> {
|
||||
const { ctx } = builder;
|
||||
const credentialsResult = await withAnalyticsAsync(
|
||||
ctx.analytics,
|
||||
async () => await builder.ensureCredentialsAsync(ctx),
|
||||
{
|
||||
attemptEvent: BuildEvent.GATHER_CREDENTIALS_ATTEMPT,
|
||||
successEvent: BuildEvent.GATHER_CREDENTIALS_SUCCESS,
|
||||
failureEvent: BuildEvent.GATHER_CREDENTIALS_FAIL,
|
||||
trackingCtx: ctx.trackingCtx,
|
||||
properties: ctx.analyticsEventProperties,
|
||||
}
|
||||
);
|
||||
|
||||
await withAnalyticsAsync(async () => await builder.syncProjectConfigurationAsync(ctx), {
|
||||
attemptEvent: BuildEvent.CONFIGURE_PROJECT_ATTEMPT,
|
||||
successEvent: BuildEvent.CONFIGURE_PROJECT_SUCCESS,
|
||||
failureEvent: BuildEvent.CONFIGURE_PROJECT_FAIL,
|
||||
trackingCtx: ctx.trackingCtx,
|
||||
});
|
||||
await withAnalyticsAsync(
|
||||
ctx.analytics,
|
||||
async () => await builder.syncProjectConfigurationAsync(ctx),
|
||||
{
|
||||
attemptEvent: BuildEvent.CONFIGURE_PROJECT_ATTEMPT,
|
||||
successEvent: BuildEvent.CONFIGURE_PROJECT_SUCCESS,
|
||||
failureEvent: BuildEvent.CONFIGURE_PROJECT_FAIL,
|
||||
properties: ctx.analyticsEventProperties,
|
||||
}
|
||||
);
|
||||
|
||||
if (await getVcsClient().isCommitRequiredAsync()) {
|
||||
Log.addNewLineIfNone();
|
||||
@@ -89,62 +132,98 @@ export async function prepareBuildRequestForPlatformAsync<
|
||||
);
|
||||
}
|
||||
|
||||
const projectArchive = ctx.localBuildOptions.enable
|
||||
? ({
|
||||
type: ArchiveSourceType.PATH,
|
||||
path: (await makeProjectTarballAsync()).path,
|
||||
} as const)
|
||||
: ({
|
||||
type: ArchiveSourceType.S3,
|
||||
bucketKey: await uploadProjectAsync(ctx),
|
||||
} as const);
|
||||
let projectArchive: ArchiveSource | undefined;
|
||||
if (ctx.localBuildOptions.localBuildMode === LocalBuildMode.LOCAL_BUILD_PLUGIN) {
|
||||
projectArchive = {
|
||||
type: ArchiveSourceType.PATH,
|
||||
path: (await makeProjectTarballAsync()).path,
|
||||
};
|
||||
} else if (ctx.localBuildOptions.localBuildMode === LocalBuildMode.INTERNAL) {
|
||||
projectArchive = {
|
||||
type: ArchiveSourceType.PATH,
|
||||
path: process.cwd(),
|
||||
};
|
||||
} else if (!ctx.localBuildOptions.localBuildMode) {
|
||||
projectArchive = {
|
||||
type: ArchiveSourceType.GCS,
|
||||
bucketKey: await uploadProjectAsync(ctx),
|
||||
};
|
||||
}
|
||||
assert(projectArchive);
|
||||
|
||||
const metadata = await collectMetadataAsync(ctx);
|
||||
const buildParams = resolveBuildParamsInput(ctx, metadata);
|
||||
const job = await builder.prepareJobAsync(ctx, {
|
||||
projectArchive,
|
||||
credentials: credentialsResult?.credentials,
|
||||
});
|
||||
|
||||
return async () => {
|
||||
if (ctx.localBuildOptions.enable) {
|
||||
await runLocalBuildAsync(job, ctx.localBuildOptions);
|
||||
if (ctx.localBuildOptions.localBuildMode === LocalBuildMode.LOCAL_BUILD_PLUGIN) {
|
||||
await runLocalBuildAsync(job, metadata, ctx.localBuildOptions);
|
||||
return undefined;
|
||||
} else {
|
||||
} else if (ctx.localBuildOptions.localBuildMode === LocalBuildMode.INTERNAL) {
|
||||
await BuildMutation.updateBuildMetadataAsync(ctx.graphqlClient, {
|
||||
buildId: nullthrows(process.env.EAS_BUILD_ID),
|
||||
metadata: transformMetadata(metadata),
|
||||
});
|
||||
printJsonOnlyOutput({ job, metadata });
|
||||
return undefined;
|
||||
} else if (!ctx.localBuildOptions.localBuildMode) {
|
||||
try {
|
||||
return await sendBuildRequestAsync(builder, job, metadata);
|
||||
return await sendBuildRequestAsync(builder, job, metadata, buildParams);
|
||||
} catch (error: any) {
|
||||
handleBuildRequestError(error, job.platform);
|
||||
}
|
||||
} else {
|
||||
throw new Error('Unknown localBuildMode.');
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function handleBuildRequestError(error: any, platform: Platform): never {
|
||||
if (error?.graphQLErrors?.[0]?.extensions?.errorCode === 'TURTLE_DEPRECATED_JOB_FORMAT') {
|
||||
Log.error('EAS Build API has changed, please upgrade to the latest eas-cli version.');
|
||||
throw new Error('Build request failed.');
|
||||
} else if (
|
||||
error?.graphQLErrors?.[0]?.extensions?.errorCode === 'EAS_BUILD_DOWN_FOR_MAINTENANCE'
|
||||
) {
|
||||
Log.error(
|
||||
'EAS Build is down for maintenance, please try again later. Check https://status.expo.dev/ for updates.'
|
||||
const SERVER_SIDE_DEFINED_ERRORS: Record<string, typeof EasCommandError> = {
|
||||
TURTLE_DEPRECATED_JOB_FORMAT: TurtleDeprecatedJobFormatError,
|
||||
EAS_BUILD_FREE_TIER_DISABLED: EasBuildFreeTierDisabledError,
|
||||
EAS_BUILD_FREE_TIER_DISABLED_IOS: EasBuildFreeTierDisabledIOSError,
|
||||
EAS_BUILD_FREE_TIER_DISABLED_ANDROID: EasBuildFreeTierDisabledAndroidError,
|
||||
EAS_BUILD_FREE_TIER_LIMIT_EXCEEDED: EasBuildFreeTierLimitExceededError,
|
||||
EAS_BUILD_FREE_TIER_IOS_LIMIT_EXCEEDED: EasBuildFreeTierIosLimitExceededError,
|
||||
EAS_BUILD_RESOURCE_CLASS_NOT_AVAILABLE_IN_FREE_TIER:
|
||||
EasBuildResourceClassNotAvailableInFreeTierError,
|
||||
VALIDATION_ERROR: RequestValidationError,
|
||||
};
|
||||
|
||||
export function handleBuildRequestError(error: any, platform: Platform): never {
|
||||
Log.debug(JSON.stringify(error.graphQLErrors, null, 2));
|
||||
|
||||
const graphQLErrorCode: string = error?.graphQLErrors?.[0]?.extensions?.errorCode;
|
||||
if (graphQLErrorCode in SERVER_SIDE_DEFINED_ERRORS) {
|
||||
const ErrorClass: typeof EasCommandError = SERVER_SIDE_DEFINED_ERRORS[graphQLErrorCode];
|
||||
throw new ErrorClass(error?.graphQLErrors?.[0]?.message);
|
||||
} else if (graphQLErrorCode === 'EAS_BUILD_DOWN_FOR_MAINTENANCE') {
|
||||
throw new EasBuildDownForMaintenanceError(
|
||||
`EAS Build is down for maintenance. Try again later. Check ${link(
|
||||
'https://status.expo.dev/'
|
||||
)} for updates.`
|
||||
);
|
||||
throw new Error('Build request failed.');
|
||||
} else if (error?.graphQLErrors?.[0]?.extensions?.errorCode === 'EAS_BUILD_FREE_TIER_DISABLED') {
|
||||
Log.error(
|
||||
'EAS Build free tier is temporarily disabled, please try again later. Check https://status.expo.dev/ for updates.'
|
||||
);
|
||||
throw new Error('Build request failed.');
|
||||
} else if (
|
||||
error?.graphQLErrors?.[0]?.extensions?.errorCode === 'EAS_BUILD_TOO_MANY_PENDING_BUILDS'
|
||||
) {
|
||||
Log.error(
|
||||
} else if (graphQLErrorCode === 'EAS_BUILD_TOO_MANY_PENDING_BUILDS') {
|
||||
throw new EasBuildTooManyPendingBuildsError(
|
||||
`You have already reached the maximum number of pending ${requestedPlatformDisplayNames[platform]} builds for your account. Try again later.`
|
||||
);
|
||||
throw new Error('Build request failed.');
|
||||
} else if (error?.graphQLErrors) {
|
||||
Log.error(
|
||||
'Build request failed. Make sure you are using the latest eas-cli version. If the problem persists, please report the issue.'
|
||||
const errorMessage = error.graphQLErrors
|
||||
.map((graphQLError: GraphQLError) => {
|
||||
const requestIdLine = graphQLError?.extensions?.requestId
|
||||
? `\nRequest ID: ${graphQLError.extensions.requestId}`
|
||||
: '';
|
||||
const errorMessageLine = graphQLError?.message
|
||||
? `\nError message: ${graphQLError.message}`
|
||||
: '';
|
||||
return `${requestIdLine}${errorMessageLine}`;
|
||||
})
|
||||
.join('');
|
||||
throw new Error(
|
||||
`Build request failed. Make sure you are using the latest eas-cli version. If the problem persists, report the issue.${errorMessage}`
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
@@ -156,12 +235,35 @@ async function uploadProjectAsync<TPlatform extends Platform>(
|
||||
let projectTarballPath;
|
||||
try {
|
||||
return await withAnalyticsAsync(
|
||||
ctx.analytics,
|
||||
async () => {
|
||||
Log.newLine();
|
||||
Log.log(
|
||||
`Compressing project files and uploading to EAS Build. ${learnMore(
|
||||
'https://expo.fyi/eas-build-archive'
|
||||
)}`
|
||||
);
|
||||
const projectTarball = await makeProjectTarballAsync();
|
||||
|
||||
if (projectTarball.size > 1024 * 1024 * 100) {
|
||||
Log.warn(
|
||||
`Your project archive is ${formatBytes(
|
||||
projectTarball.size
|
||||
)}. You can reduce its size and the time it takes to upload by excluding files that are unnecessary for the build process in ${chalk.bold(
|
||||
'.easignore'
|
||||
)} file. ${learnMore('https://expo.fyi/eas-build-archive')}`
|
||||
);
|
||||
}
|
||||
|
||||
if (projectTarball.size > 2 * 1024 * 1024 * 1024) {
|
||||
throw new Error('Project archive is too big. Maximum allowed size is 2GB.');
|
||||
}
|
||||
|
||||
projectTarballPath = projectTarball.path;
|
||||
|
||||
const { bucketKey } = await uploadAsync(
|
||||
UploadSessionType.EasBuildProjectSources,
|
||||
const bucketKey = await uploadFileAtPathToGCSAsync(
|
||||
ctx.graphqlClient,
|
||||
UploadSessionType.EasBuildGcsProjectSources,
|
||||
projectTarball.path,
|
||||
createProgressTracker({
|
||||
total: projectTarball.size,
|
||||
@@ -169,10 +271,7 @@ async function uploadProjectAsync<TPlatform extends Platform>(
|
||||
`Uploading to EAS Build (${formatBytes(projectTarball.size * ratio)} / ${formatBytes(
|
||||
projectTarball.size
|
||||
)})`,
|
||||
completedMessage: (duration: string) =>
|
||||
`Uploaded to EAS ${chalk.dim(duration)} ${learnMore(
|
||||
'https://expo.fyi/eas-build-archive'
|
||||
)}`,
|
||||
completedMessage: (duration: string) => `Uploaded to EAS ${chalk.dim(duration)}`,
|
||||
})
|
||||
);
|
||||
return bucketKey;
|
||||
@@ -181,9 +280,15 @@ async function uploadProjectAsync<TPlatform extends Platform>(
|
||||
attemptEvent: BuildEvent.PROJECT_UPLOAD_ATTEMPT,
|
||||
successEvent: BuildEvent.PROJECT_UPLOAD_SUCCESS,
|
||||
failureEvent: BuildEvent.PROJECT_UPLOAD_FAIL,
|
||||
trackingCtx: ctx.trackingCtx,
|
||||
properties: ctx.analyticsEventProperties,
|
||||
}
|
||||
);
|
||||
} catch (err: any) {
|
||||
let errMessage = 'Failed to upload the project tarball to EAS Build';
|
||||
if (err.message) {
|
||||
errMessage += `\n\nReason: ${err.message}`;
|
||||
}
|
||||
throw new EasBuildProjectArchiveUploadError(errMessage);
|
||||
} finally {
|
||||
if (projectTarballPath) {
|
||||
await fs.remove(projectTarballPath);
|
||||
@@ -194,10 +299,12 @@ async function uploadProjectAsync<TPlatform extends Platform>(
|
||||
async function sendBuildRequestAsync<TPlatform extends Platform, Credentials, TJob extends Job>(
|
||||
builder: Builder<TPlatform, Credentials, TJob>,
|
||||
job: TJob,
|
||||
metadata: Metadata
|
||||
metadata: Metadata,
|
||||
buildParams: BuildParamsInput
|
||||
): Promise<BuildFragment> {
|
||||
const { ctx } = builder;
|
||||
return await withAnalyticsAsync(
|
||||
ctx.analytics,
|
||||
async () => {
|
||||
if (Log.isDebug) {
|
||||
Log.log(`Starting ${requestedPlatformDisplayNames[job.platform]} build`);
|
||||
@@ -206,7 +313,8 @@ async function sendBuildRequestAsync<TPlatform extends Platform, Credentials, TJ
|
||||
const { build, deprecationInfo } = await builder.sendBuildRequestAsync(
|
||||
ctx.projectId,
|
||||
job,
|
||||
metadata
|
||||
metadata,
|
||||
buildParams
|
||||
);
|
||||
|
||||
printDeprecationWarnings(deprecationInfo);
|
||||
@@ -216,46 +324,56 @@ async function sendBuildRequestAsync<TPlatform extends Platform, Credentials, TJ
|
||||
attemptEvent: BuildEvent.BUILD_REQUEST_ATTEMPT,
|
||||
successEvent: BuildEvent.BUILD_REQUEST_SUCCESS,
|
||||
failureEvent: BuildEvent.BUILD_REQUEST_FAIL,
|
||||
trackingCtx: ctx.trackingCtx,
|
||||
properties: ctx.analyticsEventProperties,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
type MaybeBuildFragment = BuildFragment | null;
|
||||
export type MaybeBuildFragment = BuildFragment | null;
|
||||
|
||||
export async function waitForBuildEndAsync(
|
||||
{ buildIds, accountName }: { buildIds: string[]; accountName: string },
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
{
|
||||
// 2 hours (max build time limit) + 10 minutes (possible queue time)
|
||||
timeoutSec = 2 * 60 * 60 + 10 * 60,
|
||||
intervalSec = 10,
|
||||
} = {}
|
||||
buildIds,
|
||||
accountName,
|
||||
projectDir,
|
||||
nonInteractive,
|
||||
}: { buildIds: string[]; accountName: string; projectDir: string; nonInteractive: boolean },
|
||||
{ intervalSec = 10 } = {}
|
||||
): Promise<MaybeBuildFragment[]> {
|
||||
const b = `build${buildIds.length > 1 ? 's' : ''}`;
|
||||
Log.log(`Waiting for ${b} to complete. You can press Ctrl+C to exit.`);
|
||||
const spinner = ora(`Waiting for ${b} to complete.`).start();
|
||||
const endTime = new Date().getTime() + timeoutSec * 1000;
|
||||
while (new Date().getTime() <= endTime) {
|
||||
const builds = await getBuildsSafelyAsync(buildIds);
|
||||
let spinner;
|
||||
let originalSpinnerText;
|
||||
if (buildIds.length === 1) {
|
||||
Log.log('Waiting for build to complete. You can press Ctrl+C to exit.');
|
||||
originalSpinnerText = 'Waiting for build to complete.';
|
||||
spinner = ora(originalSpinnerText).start();
|
||||
} else {
|
||||
originalSpinnerText = 'Waiting for builds to complete. You can press Ctrl+C to exit.';
|
||||
spinner = ora('Waiting for builds to complete. You can press Ctrl+C to exit.').start();
|
||||
}
|
||||
while (true) {
|
||||
const builds = await getBuildsSafelyAsync(graphqlClient, buildIds);
|
||||
const { refetch } =
|
||||
builds.length === 1
|
||||
? await handleSingleBuildProgressAsync({ build: builds[0], accountName }, { spinner })
|
||||
: await handleMultipleBuildsProgressAsync({ builds }, { spinner });
|
||||
? await handleSingleBuildProgressAsync(
|
||||
{ build: builds[0], accountName, projectDir, nonInteractive },
|
||||
{ spinner }
|
||||
)
|
||||
: await handleMultipleBuildsProgressAsync({ builds }, { spinner, originalSpinnerText });
|
||||
if (!refetch) {
|
||||
return builds;
|
||||
}
|
||||
await sleepAsync(intervalSec * 1000);
|
||||
}
|
||||
spinner.fail('Timed out');
|
||||
throw new Error(
|
||||
'Timeout reached! It is taking longer than expected to finish the build, aborting...'
|
||||
);
|
||||
}
|
||||
|
||||
async function getBuildsSafelyAsync(buildIds: string[]): Promise<MaybeBuildFragment[]> {
|
||||
async function getBuildsSafelyAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
buildIds: string[]
|
||||
): Promise<MaybeBuildFragment[]> {
|
||||
const promises = buildIds.map(async buildId => {
|
||||
try {
|
||||
return await BuildQuery.byIdAsync(buildId, { useCache: false });
|
||||
return await BuildQuery.byIdAsync(graphqlClient, buildId, { useCache: false });
|
||||
} catch (err) {
|
||||
Log.debug('Failed to fetch the build status', err);
|
||||
return null;
|
||||
@@ -275,7 +393,17 @@ const queueProgressBar = new cliProgress.SingleBar(
|
||||
);
|
||||
|
||||
async function handleSingleBuildProgressAsync(
|
||||
{ build, accountName }: { build: MaybeBuildFragment; accountName: string },
|
||||
{
|
||||
build,
|
||||
accountName,
|
||||
projectDir,
|
||||
nonInteractive,
|
||||
}: {
|
||||
build: MaybeBuildFragment;
|
||||
accountName: string;
|
||||
projectDir: string;
|
||||
nonInteractive: boolean;
|
||||
},
|
||||
{ spinner }: { spinner: Ora }
|
||||
): Promise<BuildProgressResult> {
|
||||
if (build === null) {
|
||||
@@ -300,7 +428,7 @@ async function handleSingleBuildProgressAsync(
|
||||
spinner.succeed('Build finished');
|
||||
return { refetch: false };
|
||||
case BuildStatus.New:
|
||||
spinner.text = `Build is waiting to enter the queue. Check your concurrency limit at ${chalk.underline(
|
||||
spinner.text = `Build is waiting to enter the queue. Check your concurrency limit at ${link(
|
||||
formatAccountSubscriptionsUrl(accountName)
|
||||
)}.`;
|
||||
break;
|
||||
@@ -314,20 +442,70 @@ async function handleSingleBuildProgressAsync(
|
||||
if (
|
||||
!queueProgressBarStarted &&
|
||||
typeof build.initialQueuePosition === 'number' &&
|
||||
typeof build.queuePosition === 'number'
|
||||
typeof build.queuePosition === 'number' &&
|
||||
typeof build.estimatedWaitTimeLeftSeconds === 'number'
|
||||
) {
|
||||
spinner.stopAndPersist();
|
||||
if (build.priority !== BuildPriority.High) {
|
||||
Log.newLine();
|
||||
Log.log('Start builds sooner in the priority queue.');
|
||||
Log.log(
|
||||
`Sign up for EAS Production or Enterprise at ${chalk.underline(
|
||||
`Sign up for EAS Production or Enterprise at ${link(
|
||||
formatAccountSubscriptionsUrl(accountName)
|
||||
)}`
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
build.platform === AppPlatform.Ios &&
|
||||
[BuildResourceClass.IosIntelLarge, BuildResourceClass.IosIntelMedium].includes(
|
||||
build.resourceClass
|
||||
)
|
||||
) {
|
||||
let askToSwitchToM1 = false;
|
||||
if (
|
||||
build.priority === BuildPriority.High &&
|
||||
build.estimatedWaitTimeLeftSeconds >= /* 10 minutes */ 10 * 60
|
||||
) {
|
||||
Log.newLine();
|
||||
Log.warn(
|
||||
`Warning: Priority queue wait time for legacy iOS Intel workers is longer than usual (more than 10 minutes).`
|
||||
);
|
||||
Log.warn(`We recommend switching to the new, faster M1 workers for iOS builds.`);
|
||||
Log.warn(
|
||||
learnMore('https://blog.expo.dev/m1-workers-on-eas-build-dcaa2c1333ad', {
|
||||
learnMoreMessage: 'Learn more on switching to M1 workers.',
|
||||
})
|
||||
);
|
||||
askToSwitchToM1 = true;
|
||||
} else if (
|
||||
build.priority !== BuildPriority.High &&
|
||||
build.estimatedWaitTimeLeftSeconds >= /* 120 minutes */ 120 * 60
|
||||
) {
|
||||
Log.newLine();
|
||||
Log.warn(
|
||||
`Warning: Free tier queue wait time for legacy iOS Intel workers is longer than usual.`
|
||||
);
|
||||
Log.warn(`We recommend switching to the new, faster M1 workers for iOS builds.`);
|
||||
Log.warn(
|
||||
learnMore('https://blog.expo.dev/m1-workers-on-eas-build-dcaa2c1333ad', {
|
||||
learnMoreMessage: 'Learn more on switching to M1 workers.',
|
||||
})
|
||||
);
|
||||
askToSwitchToM1 = true;
|
||||
}
|
||||
if (!nonInteractive && askToSwitchToM1) {
|
||||
const shouldSwitchToM1 = await confirmAsync({
|
||||
message: `Switch iOS builds to M1 workers (modifies build profiles in eas.json)?`,
|
||||
});
|
||||
if (shouldSwitchToM1) {
|
||||
await updateIosBuildProfilesToUseM1WorkersAsync(projectDir);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Log.newLine();
|
||||
Log.log('Waiting in queue');
|
||||
Log.log(`Waiting in ${priorityToQueueDisplayName[build.priority]}`);
|
||||
queueProgressBar.start(
|
||||
build.initialQueuePosition + 1,
|
||||
build.initialQueuePosition - build.queuePosition + 1,
|
||||
@@ -340,6 +518,7 @@ async function handleSingleBuildProgressAsync(
|
||||
}
|
||||
break;
|
||||
}
|
||||
case BuildStatus.PendingCancel:
|
||||
case BuildStatus.Canceled:
|
||||
spinner.fail('Build canceled');
|
||||
return { refetch: false };
|
||||
@@ -360,10 +539,17 @@ async function handleSingleBuildProgressAsync(
|
||||
return { refetch: true };
|
||||
}
|
||||
|
||||
const priorityToQueueDisplayName: Record<BuildPriority, string> = {
|
||||
[BuildPriority.Normal]: 'Free tier queue',
|
||||
[BuildPriority.NormalPlus]: 'Free tier queue',
|
||||
[BuildPriority.High]: 'priority queue',
|
||||
};
|
||||
|
||||
const statusToDisplayName: Record<BuildStatus, string> = {
|
||||
[BuildStatus.New]: 'waiting to enter the queue (concurrency limit reached)',
|
||||
[BuildStatus.InQueue]: 'in queue',
|
||||
[BuildStatus.InProgress]: 'in progress',
|
||||
[BuildStatus.PendingCancel]: 'canceled',
|
||||
[BuildStatus.Canceled]: 'canceled',
|
||||
[BuildStatus.Finished]: 'finished',
|
||||
[BuildStatus.Errored]: 'failed',
|
||||
@@ -373,7 +559,7 @@ const platforms = [AppPlatform.Android, AppPlatform.Ios];
|
||||
|
||||
async function handleMultipleBuildsProgressAsync(
|
||||
{ builds: maybeBuilds }: { builds: MaybeBuildFragment[] },
|
||||
{ spinner }: { spinner: Ora }
|
||||
{ spinner, originalSpinnerText }: { spinner: Ora; originalSpinnerText: string }
|
||||
): Promise<BuildProgressResult> {
|
||||
const buildCount = maybeBuilds.length;
|
||||
const builds = maybeBuilds.filter<BuildFragment>(isBuildFragment);
|
||||
@@ -382,7 +568,12 @@ async function handleMultipleBuildsProgressAsync(
|
||||
builds.filter(build => build.status === BuildStatus.Finished).length === buildCount;
|
||||
const allSettled =
|
||||
builds.filter(build =>
|
||||
[BuildStatus.Finished, BuildStatus.Errored, BuildStatus.Canceled].includes(build.status)
|
||||
[
|
||||
BuildStatus.Finished,
|
||||
BuildStatus.Errored,
|
||||
BuildStatus.Canceled,
|
||||
BuildStatus.PendingCancel,
|
||||
].includes(build.status)
|
||||
).length === buildCount;
|
||||
|
||||
if (allSettled) {
|
||||
@@ -393,7 +584,7 @@ async function handleMultipleBuildsProgressAsync(
|
||||
}
|
||||
return { refetch: false };
|
||||
} else {
|
||||
spinner.text = formatPendingBuildsText(builds);
|
||||
spinner.text = formatPendingBuildsText(originalSpinnerText, builds);
|
||||
return { refetch: true };
|
||||
}
|
||||
}
|
||||
@@ -412,9 +603,10 @@ function formatSettledBuildsText(builds: BuildFragment[]): string {
|
||||
.join('\n ');
|
||||
}
|
||||
|
||||
function formatPendingBuildsText(builds: BuildFragment[]): string {
|
||||
return platforms
|
||||
.map(platform => {
|
||||
function formatPendingBuildsText(originalSpinnerText: string, builds: BuildFragment[]): string {
|
||||
return [
|
||||
originalSpinnerText,
|
||||
...platforms.map(platform => {
|
||||
const build = builds.find(build => build.platform === platform);
|
||||
const status = build ? statusToDisplayName[build.status] : 'unknown';
|
||||
let extraInfo = '';
|
||||
@@ -437,8 +629,8 @@ function formatPendingBuildsText(builds: BuildFragment[]): string {
|
||||
return `${appPlatformEmojis[platform]} ${
|
||||
appPlatformDisplayNames[platform]
|
||||
} build - status: ${chalk.bold(status)}${extraInfo}`;
|
||||
})
|
||||
.join('\n ');
|
||||
}),
|
||||
].join('\n ');
|
||||
}
|
||||
|
||||
function isBuildFragment(maybeBuild: MaybeBuildFragment): maybeBuild is BuildFragment {
|
||||
@@ -460,3 +652,21 @@ function formatAccountSubscriptionsUrl(accountName: string): string {
|
||||
getExpoWebsiteBaseUrl()
|
||||
).toString();
|
||||
}
|
||||
|
||||
async function updateIosBuildProfilesToUseM1WorkersAsync(projectDir: string): Promise<void> {
|
||||
const easJsonAccessor = EasJsonAccessor.fromProjectPath(projectDir);
|
||||
await easJsonAccessor.readRawJsonAsync();
|
||||
|
||||
const profileNames = await EasJsonUtils.getBuildProfileNamesAsync(easJsonAccessor);
|
||||
easJsonAccessor.patch(easJsonRawObject => {
|
||||
for (const profileName of profileNames) {
|
||||
easJsonRawObject.build[profileName].ios = {
|
||||
...easJsonRawObject.build[profileName].ios,
|
||||
resourceClass: ResourceClass.M_MEDIUM,
|
||||
};
|
||||
}
|
||||
return easJsonRawObject;
|
||||
});
|
||||
await easJsonAccessor.writeAsync();
|
||||
Log.withTick('Updated eas.json. Your next builds will run on M1 workers.');
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
import { Platform, Workflow } from '@expo/eas-build-job';
|
||||
import { EasJson, EasJsonReader } from '@expo/eas-json';
|
||||
import { EasJson, EasJsonAccessor } from '@expo/eas-json';
|
||||
import chalk from 'chalk';
|
||||
import fs from 'fs-extra';
|
||||
|
||||
import Log from '../log';
|
||||
import Log, { learnMore } from '../log';
|
||||
import { resolveWorkflowAsync } from '../project/workflow';
|
||||
import { easCliVersion } from '../utils/easCli';
|
||||
import { getVcsClient } from '../vcs';
|
||||
@@ -14,6 +14,10 @@ interface ConfigureParams {
|
||||
nonInteractive: boolean;
|
||||
}
|
||||
|
||||
export async function easJsonExistsAsync(projectDir: string): Promise<boolean> {
|
||||
return await fs.pathExists(EasJsonAccessor.formatEasJsonPath(projectDir));
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates eas.json if it does not exist.
|
||||
*
|
||||
@@ -24,7 +28,7 @@ interface ConfigureParams {
|
||||
export async function ensureProjectConfiguredAsync(
|
||||
configureParams: ConfigureParams
|
||||
): Promise<boolean> {
|
||||
if (await fs.pathExists(EasJsonReader.formatEasJsonPath(configureParams.projectDir))) {
|
||||
if (await easJsonExistsAsync(configureParams.projectDir)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -89,7 +93,7 @@ const EAS_JSON_BARE_DEFAULT: EasJson = {
|
||||
};
|
||||
|
||||
async function createEasJsonAsync(projectDir: string): Promise<void> {
|
||||
const easJsonPath = EasJsonReader.formatEasJsonPath(projectDir);
|
||||
const easJsonPath = EasJsonAccessor.formatEasJsonPath(projectDir);
|
||||
|
||||
const hasAndroidNativeProject =
|
||||
(await resolveWorkflowAsync(projectDir, Platform.ANDROID)) === Workflow.GENERIC;
|
||||
@@ -102,5 +106,9 @@ async function createEasJsonAsync(projectDir: string): Promise<void> {
|
||||
|
||||
await fs.writeFile(easJsonPath, `${JSON.stringify(easJson, null, 2)}\n`);
|
||||
await getVcsClient().trackFileAsync(easJsonPath);
|
||||
Log.withTick(`Generated ${chalk.bold('eas.json')}`);
|
||||
Log.withTick(
|
||||
`Generated ${chalk.bold('eas.json')}. ${learnMore(
|
||||
'https://docs.expo.dev/build-reference/eas-json/'
|
||||
)}`
|
||||
);
|
||||
}
|
||||
@@ -1,12 +1,14 @@
|
||||
import { ExpoConfig } from '@expo/config';
|
||||
import { Platform, Workflow } from '@expo/eas-build-job';
|
||||
import { BuildProfile } from '@expo/eas-json';
|
||||
import type { XCBuildConfiguration } from 'xcode';
|
||||
import { BuildProfile, EasJson } from '@expo/eas-json';
|
||||
|
||||
import { TrackingContext } from '../analytics/common';
|
||||
import { Analytics, AnalyticsEventProperties } from '../analytics/AnalyticsManager';
|
||||
import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { CredentialsContext } from '../credentials/context';
|
||||
import { Target } from '../credentials/ios/types';
|
||||
import { BuildResourceClass } from '../graphql/generated';
|
||||
import { GradleBuildContext } from '../project/android/gradle';
|
||||
import { CustomBuildConfigMetadata } from '../project/customBuildConfig';
|
||||
import { XcodeBuildContext } from '../project/ios/scheme';
|
||||
import { Actor } from '../user/User';
|
||||
import { LocalBuildOptions } from './local';
|
||||
@@ -16,32 +18,41 @@ export type CommonContext<T extends Platform> = Omit<BuildContext<T>, 'android'
|
||||
export interface AndroidBuildContext {
|
||||
applicationId: string;
|
||||
gradleContext?: GradleBuildContext;
|
||||
versionCodeOverride?: string;
|
||||
}
|
||||
|
||||
export interface IosBuildContext {
|
||||
bundleIdentifier: string;
|
||||
applicationTargetBuildSettings: XCBuildConfiguration['buildSettings'];
|
||||
applicationTarget: Target;
|
||||
targets: Target[];
|
||||
xcodeBuildContext: XcodeBuildContext;
|
||||
buildNumberOverride?: string;
|
||||
}
|
||||
|
||||
export interface BuildContext<T extends Platform> {
|
||||
accountName: string;
|
||||
easJsonCliConfig: EasJson['cli'];
|
||||
buildProfile: BuildProfile<T>;
|
||||
buildProfileName: string;
|
||||
resourceClass: BuildResourceClass;
|
||||
clearCache: boolean;
|
||||
credentialsCtx: CredentialsContext;
|
||||
exp: ExpoConfig;
|
||||
localBuildOptions: LocalBuildOptions;
|
||||
nonInteractive: boolean;
|
||||
noWait: boolean;
|
||||
runFromCI: boolean;
|
||||
platform: T;
|
||||
projectDir: string;
|
||||
projectId: string;
|
||||
projectName: string;
|
||||
trackingCtx: TrackingContext;
|
||||
message?: string;
|
||||
analyticsEventProperties: AnalyticsEventProperties;
|
||||
user: Actor;
|
||||
graphqlClient: ExpoGraphqlClient;
|
||||
analytics: Analytics;
|
||||
workflow: Workflow;
|
||||
customBuildConfigMetadata?: CustomBuildConfigMetadata;
|
||||
android: T extends Platform.ANDROID ? AndroidBuildContext : undefined;
|
||||
ios: T extends Platform.IOS ? IosBuildContext : undefined;
|
||||
}
|
||||
@@ -1,54 +1,75 @@
|
||||
import { Platform } from '@expo/eas-build-job';
|
||||
import { BuildProfile } from '@expo/eas-json';
|
||||
import { BuildProfile, EasJson, ResourceClass } from '@expo/eas-json';
|
||||
import JsonFile from '@expo/json-file';
|
||||
import getenv from 'getenv';
|
||||
import resolveFrom from 'resolve-from';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
import { TrackingContext } from '../analytics/common';
|
||||
import { Analytics, BuildEvent } from '../analytics/events';
|
||||
import { Analytics, AnalyticsEventProperties, BuildEvent } from '../analytics/AnalyticsManager';
|
||||
import { DynamicConfigContextFn } from '../commandUtils/context/DynamicProjectConfigContextField';
|
||||
import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { CredentialsContext } from '../credentials/context';
|
||||
import { getExpoConfig } from '../project/expoConfig';
|
||||
import { getProjectAccountName, getProjectIdAsync } from '../project/projectUtils';
|
||||
import { CustomBuildConfigMetadata } from '../project/customBuildConfig';
|
||||
import { getOwnerAccountForProjectIdAsync } from '../project/projectUtils';
|
||||
import { resolveWorkflowAsync } from '../project/workflow';
|
||||
import { findAccountByName } from '../user/Account';
|
||||
import { ensureLoggedInAsync } from '../user/actions';
|
||||
import { Actor } from '../user/User';
|
||||
import { createAndroidContextAsync } from './android/build';
|
||||
import { BuildContext, CommonContext } from './context';
|
||||
import { createIosContextAsync } from './ios/build';
|
||||
import { LocalBuildOptions } from './local';
|
||||
import { resolveBuildResourceClassAsync } from './utils/resourceClass';
|
||||
|
||||
export async function createBuildContextAsync<T extends Platform>({
|
||||
buildProfileName,
|
||||
buildProfile,
|
||||
easJsonCliConfig,
|
||||
clearCache = false,
|
||||
localBuildOptions,
|
||||
nonInteractive = false,
|
||||
nonInteractive,
|
||||
noWait,
|
||||
platform,
|
||||
projectDir,
|
||||
resourceClassFlag,
|
||||
message,
|
||||
actor,
|
||||
graphqlClient,
|
||||
analytics,
|
||||
getDynamicPrivateProjectConfigAsync,
|
||||
customBuildConfigMetadata,
|
||||
}: {
|
||||
buildProfileName: string;
|
||||
buildProfile: BuildProfile<T>;
|
||||
easJsonCliConfig: EasJson['cli'];
|
||||
clearCache: boolean;
|
||||
localBuildOptions: LocalBuildOptions;
|
||||
nonInteractive: boolean;
|
||||
noWait: boolean;
|
||||
platform: T;
|
||||
projectDir: string;
|
||||
resourceClassFlag?: ResourceClass;
|
||||
message?: string;
|
||||
actor: Actor;
|
||||
graphqlClient: ExpoGraphqlClient;
|
||||
analytics: Analytics;
|
||||
getDynamicPrivateProjectConfigAsync: DynamicConfigContextFn;
|
||||
customBuildConfigMetadata?: CustomBuildConfigMetadata;
|
||||
}): Promise<BuildContext<T>> {
|
||||
const exp = getExpoConfig(projectDir, { env: buildProfile.env });
|
||||
|
||||
const user = await ensureLoggedInAsync();
|
||||
const accountName = getProjectAccountName(exp, user);
|
||||
const { exp, projectId } = await getDynamicPrivateProjectConfigAsync({ env: buildProfile.env });
|
||||
const projectName = exp.slug;
|
||||
const projectId = await getProjectIdAsync(exp, { env: buildProfile.env });
|
||||
const account = await getOwnerAccountForProjectIdAsync(graphqlClient, projectId);
|
||||
const workflow = await resolveWorkflowAsync(projectDir, platform);
|
||||
const accountId = findAccountByName(user.accounts, accountName)?.id;
|
||||
const accountId = account.id;
|
||||
const runFromCI = getenv.boolish('CI', false);
|
||||
|
||||
const credentialsCtx = new CredentialsContext({
|
||||
exp,
|
||||
projectInfo: { exp, projectId },
|
||||
nonInteractive,
|
||||
projectDir,
|
||||
user,
|
||||
user: actor,
|
||||
graphqlClient,
|
||||
analytics,
|
||||
env: buildProfile.env,
|
||||
easJsonCliConfig,
|
||||
});
|
||||
|
||||
const devClientProperties = getDevClientEventProperties({
|
||||
@@ -56,32 +77,49 @@ export async function createBuildContextAsync<T extends Platform>({
|
||||
projectDir,
|
||||
buildProfile,
|
||||
});
|
||||
const trackingCtx = {
|
||||
const analyticsEventProperties = {
|
||||
tracking_id: uuidv4(),
|
||||
platform,
|
||||
...(exp.sdkVersion && { sdk_version: exp.sdkVersion }),
|
||||
...(accountId && { account_id: accountId }),
|
||||
project_id: projectId,
|
||||
project_type: workflow,
|
||||
...devClientProperties,
|
||||
no_wait: noWait,
|
||||
run_from_ci: runFromCI,
|
||||
};
|
||||
Analytics.logEvent(BuildEvent.BUILD_COMMAND, trackingCtx);
|
||||
analytics.logEvent(BuildEvent.BUILD_COMMAND, analyticsEventProperties);
|
||||
|
||||
const resourceClass = await resolveBuildResourceClassAsync(
|
||||
buildProfile,
|
||||
platform,
|
||||
resourceClassFlag
|
||||
);
|
||||
|
||||
const commonContext: CommonContext<T> = {
|
||||
accountName,
|
||||
accountName: account.name,
|
||||
buildProfile,
|
||||
buildProfileName,
|
||||
resourceClass,
|
||||
easJsonCliConfig,
|
||||
clearCache,
|
||||
credentialsCtx,
|
||||
exp,
|
||||
localBuildOptions,
|
||||
nonInteractive,
|
||||
noWait,
|
||||
platform,
|
||||
projectDir,
|
||||
projectId,
|
||||
projectName,
|
||||
trackingCtx,
|
||||
user,
|
||||
analyticsEventProperties,
|
||||
user: actor,
|
||||
graphqlClient,
|
||||
analytics,
|
||||
workflow,
|
||||
message,
|
||||
runFromCI,
|
||||
customBuildConfigMetadata,
|
||||
};
|
||||
if (platform === Platform.ANDROID) {
|
||||
const common = commonContext as CommonContext<Platform.ANDROID>;
|
||||
@@ -106,7 +144,7 @@ function getDevClientEventProperties({
|
||||
platform: Platform;
|
||||
projectDir: string;
|
||||
buildProfile: BuildProfile;
|
||||
}): Partial<TrackingContext> {
|
||||
}): Partial<AnalyticsEventProperties> {
|
||||
let includesDevClient;
|
||||
const version = tryGetDevClientVersion(projectDir);
|
||||
if (platform === Platform.ANDROID && 'gradleCommand' in buildProfile) {
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { EasCommandError } from '../commandUtils/errors';
|
||||
|
||||
export class TurtleDeprecatedJobFormatError extends EasCommandError {}
|
||||
|
||||
export class EasBuildFreeTierDisabledError extends EasCommandError {}
|
||||
|
||||
export class EasBuildFreeTierDisabledIOSError extends EasCommandError {}
|
||||
|
||||
export class EasBuildFreeTierDisabledAndroidError extends EasCommandError {}
|
||||
|
||||
export class EasBuildFreeTierLimitExceededError extends EasCommandError {}
|
||||
|
||||
export class EasBuildFreeTierIosLimitExceededError extends EasCommandError {}
|
||||
|
||||
export class EasBuildResourceClassNotAvailableInFreeTierError extends EasCommandError {}
|
||||
|
||||
export class RequestValidationError extends EasCommandError {}
|
||||
|
||||
export class EasBuildDownForMaintenanceError extends EasCommandError {}
|
||||
|
||||
export class EasBuildTooManyPendingBuildsError extends EasCommandError {}
|
||||
|
||||
export class EasBuildProjectArchiveUploadError extends EasCommandError {}
|
||||
@@ -1,11 +1,19 @@
|
||||
import { ArchiveSource, ArchiveSourceType, Metadata, Workflow } from '@expo/eas-build-job';
|
||||
import {
|
||||
ArchiveSource,
|
||||
ArchiveSourceType,
|
||||
BuildTrigger,
|
||||
Metadata,
|
||||
Workflow,
|
||||
} from '@expo/eas-build-job';
|
||||
|
||||
import {
|
||||
BuildCredentialsSource,
|
||||
BuildIosEnterpriseProvisioning,
|
||||
BuildMetadataInput,
|
||||
BuildMode,
|
||||
BuildWorkflow,
|
||||
DistributionType,
|
||||
BuildTrigger as GraphQLBuildTrigger,
|
||||
ProjectArchiveSourceInput,
|
||||
ProjectArchiveSourceType,
|
||||
} from '../graphql/generated';
|
||||
@@ -16,6 +24,11 @@ export function transformProjectArchive(archiveSource: ArchiveSource): ProjectAr
|
||||
type: ProjectArchiveSourceType.S3,
|
||||
bucketKey: archiveSource.bucketKey,
|
||||
};
|
||||
} else if (archiveSource.type === ArchiveSourceType.GCS) {
|
||||
return {
|
||||
type: ProjectArchiveSourceType.Gcs,
|
||||
bucketKey: archiveSource.bucketKey,
|
||||
};
|
||||
} else if (archiveSource.type === ArchiveSourceType.URL) {
|
||||
return {
|
||||
type: ProjectArchiveSourceType.Url,
|
||||
@@ -29,6 +42,7 @@ export function transformProjectArchive(archiveSource: ArchiveSource): ProjectAr
|
||||
export function transformMetadata(metadata: Metadata): BuildMetadataInput {
|
||||
return {
|
||||
...metadata,
|
||||
buildMode: metadata.buildMode && transformBuildMode(metadata.buildMode),
|
||||
credentialsSource:
|
||||
metadata.credentialsSource && transformCredentialsSource(metadata.credentialsSource),
|
||||
distribution: metadata.distribution && transformDistribution(metadata.distribution),
|
||||
@@ -76,3 +90,25 @@ export function transformIosEnterpriseProvisioning(
|
||||
return BuildIosEnterpriseProvisioning.Universal;
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: check what in metadata
|
||||
export function transformBuildMode(buildMode: string): BuildMode {
|
||||
if (buildMode === 'build') {
|
||||
return BuildMode.Build;
|
||||
} else if (buildMode === 'resign') {
|
||||
return BuildMode.Resign;
|
||||
} else if (buildMode === 'custom') {
|
||||
return BuildMode.Custom;
|
||||
} else {
|
||||
throw new Error(`Unsupported build mode: ${buildMode}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function transformBuildTrigger(buildTrigger: BuildTrigger): GraphQLBuildTrigger {
|
||||
if (buildTrigger === BuildTrigger.EAS_CLI) {
|
||||
return GraphQLBuildTrigger.EasCli;
|
||||
} else if (buildTrigger === BuildTrigger.GIT_BASED_INTEGRATION) {
|
||||
return GraphQLBuildTrigger.GitBasedIntegration;
|
||||
}
|
||||
throw new Error('Unknown build trigger');
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { IOSConfig } from '@expo/config-plugins';
|
||||
import fs from 'fs-extra';
|
||||
import { vol } from 'memfs';
|
||||
import os from 'os';
|
||||
import type { XCBuildConfiguration } from 'xcode';
|
||||
|
||||
import { readPlistAsync } from '../../../utils/plist';
|
||||
import {
|
||||
@@ -17,6 +18,12 @@ import {
|
||||
|
||||
jest.mock('fs');
|
||||
|
||||
const getXCBuildConfigurationFromPbxproj = jest.spyOn(
|
||||
IOSConfig.Target,
|
||||
'getXCBuildConfigurationFromPbxproj'
|
||||
);
|
||||
const getPbxproj = jest.spyOn(IOSConfig.XcodeUtils, 'getPbxproj');
|
||||
|
||||
afterAll(async () => {
|
||||
// do not remove the following line
|
||||
// this fixes a weird error with tempy in @expo/image-utils
|
||||
@@ -51,19 +58,39 @@ describe(evaluateTemplateString, () => {
|
||||
|
||||
// bare workflow
|
||||
describe(bumpVersionAsync, () => {
|
||||
beforeEach(() => {
|
||||
getPbxproj.mockImplementation(() => ({} as any));
|
||||
});
|
||||
afterEach(() => {
|
||||
getXCBuildConfigurationFromPbxproj.mockReset();
|
||||
getPbxproj.mockReset();
|
||||
});
|
||||
it('bumps expo.ios.buildNumber and CFBundleVersion when strategy = BumpStrategy.BUILD_NUMBER', async () => {
|
||||
const fakeExp = initBareWorkflowProject();
|
||||
|
||||
getXCBuildConfigurationFromPbxproj.mockImplementation(
|
||||
() =>
|
||||
({
|
||||
buildSettings: {
|
||||
INFOPLIST_FILE: 'myproject/Info.plist',
|
||||
},
|
||||
} as XCBuildConfiguration)
|
||||
);
|
||||
await bumpVersionAsync({
|
||||
bumpStrategy: BumpStrategy.BUILD_NUMBER,
|
||||
projectDir: '/repo',
|
||||
projectDir: '/app',
|
||||
exp: fakeExp,
|
||||
buildSettings: {},
|
||||
targets: [
|
||||
{
|
||||
targetName: 'example',
|
||||
bundleIdentifier: 'example.com',
|
||||
entitlements: {},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const appJSON = await fs.readJSON('/repo/app.json');
|
||||
const appJSON = await fs.readJSON('/app/app.json');
|
||||
const infoPlist = (await readPlistAsync(
|
||||
'/repo/ios/myproject/Info.plist'
|
||||
'/app/ios/myproject/Info.plist'
|
||||
)) as IOSConfig.InfoPlist;
|
||||
expect(fakeExp.version).toBe('1.0.0');
|
||||
expect(fakeExp.ios?.buildNumber).toBe('2');
|
||||
@@ -76,18 +103,30 @@ describe(bumpVersionAsync, () => {
|
||||
it('bumps expo.ios.buildNumber and CFBundleVersion for non default Info.plist location', async () => {
|
||||
const fakeExp = initBareWorkflowProject({ infoPlistName: 'Info2.plist' });
|
||||
|
||||
getXCBuildConfigurationFromPbxproj.mockImplementation(
|
||||
() =>
|
||||
({
|
||||
buildSettings: {
|
||||
INFOPLIST_FILE: '$(SRCROOT)/myproject/Info2.plist',
|
||||
},
|
||||
} as XCBuildConfiguration)
|
||||
);
|
||||
await bumpVersionAsync({
|
||||
bumpStrategy: BumpStrategy.BUILD_NUMBER,
|
||||
projectDir: '/repo',
|
||||
projectDir: '/app',
|
||||
exp: fakeExp,
|
||||
buildSettings: {
|
||||
INFOPLIST_FILE: '$(SRCROOT)/myproject/Info2.plist',
|
||||
},
|
||||
targets: [
|
||||
{
|
||||
targetName: 'example',
|
||||
bundleIdentifier: 'example.com',
|
||||
entitlements: {},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const appJSON = await fs.readJSON('/repo/app.json');
|
||||
const appJSON = await fs.readJSON('/app/app.json');
|
||||
const infoPlist = (await readPlistAsync(
|
||||
'/repo/ios/myproject/Info2.plist'
|
||||
'/app/ios/myproject/Info2.plist'
|
||||
)) as IOSConfig.InfoPlist;
|
||||
expect(fakeExp.version).toBe('1.0.0');
|
||||
expect(fakeExp.ios?.buildNumber).toBe('2');
|
||||
@@ -97,19 +136,32 @@ describe(bumpVersionAsync, () => {
|
||||
expect(infoPlist['CFBundleVersion']).toBe('2');
|
||||
});
|
||||
|
||||
it('bumps expo.version and CFBundleShortVersionString when strategy = BumpStrategy.SHORT_VERSION', async () => {
|
||||
it('bumps expo.version and CFBundleShortVersionString when strategy = BumpStrategy.APP_VERSION', async () => {
|
||||
const fakeExp = initBareWorkflowProject();
|
||||
|
||||
getXCBuildConfigurationFromPbxproj.mockImplementation(
|
||||
() =>
|
||||
({
|
||||
buildSettings: {
|
||||
INFOPLIST_FILE: 'myproject/Info.plist',
|
||||
},
|
||||
} as XCBuildConfiguration)
|
||||
);
|
||||
await bumpVersionAsync({
|
||||
bumpStrategy: BumpStrategy.APP_VERSION,
|
||||
projectDir: '/repo',
|
||||
projectDir: '/app',
|
||||
exp: fakeExp,
|
||||
buildSettings: {},
|
||||
targets: [
|
||||
{
|
||||
targetName: 'example',
|
||||
bundleIdentifier: 'example.com',
|
||||
entitlements: {},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const appJSON = await fs.readJSON('/repo/app.json');
|
||||
const appJSON = await fs.readJSON('/app/app.json');
|
||||
const infoPlist = (await readPlistAsync(
|
||||
'/repo/ios/myproject/Info.plist'
|
||||
'/app/ios/myproject/Info.plist'
|
||||
)) as IOSConfig.InfoPlist;
|
||||
expect(fakeExp.version).toBe('1.0.1');
|
||||
expect(fakeExp.ios?.buildNumber).toBe('1');
|
||||
@@ -121,17 +173,30 @@ describe(bumpVersionAsync, () => {
|
||||
|
||||
it('does not bump any version when strategy = BumpStrategy.NOOP', async () => {
|
||||
const fakeExp = initBareWorkflowProject();
|
||||
|
||||
getXCBuildConfigurationFromPbxproj.mockImplementation(
|
||||
() =>
|
||||
({
|
||||
buildSettings: {
|
||||
INFOPLIST_FILE: 'myproject/Info.plist',
|
||||
},
|
||||
} as XCBuildConfiguration)
|
||||
);
|
||||
await bumpVersionAsync({
|
||||
bumpStrategy: BumpStrategy.NOOP,
|
||||
projectDir: '/repo',
|
||||
projectDir: '/app',
|
||||
exp: fakeExp,
|
||||
buildSettings: {},
|
||||
targets: [
|
||||
{
|
||||
targetName: 'example',
|
||||
bundleIdentifier: 'example.com',
|
||||
entitlements: {},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const appJSON = await fs.readJSON('/repo/app.json');
|
||||
const appJSON = await fs.readJSON('/app/app.json');
|
||||
const infoPlist = (await readPlistAsync(
|
||||
'/repo/ios/myproject/Info.plist'
|
||||
'/app/ios/myproject/Info.plist'
|
||||
)) as IOSConfig.InfoPlist;
|
||||
expect(fakeExp.version).toBe('1.0.0');
|
||||
expect(fakeExp.ios?.buildNumber).toBe('1');
|
||||
@@ -149,11 +214,11 @@ describe(bumpVersionInAppJsonAsync, () => {
|
||||
|
||||
await bumpVersionInAppJsonAsync({
|
||||
bumpStrategy: BumpStrategy.BUILD_NUMBER,
|
||||
projectDir: '/repo',
|
||||
projectDir: '/app',
|
||||
exp: fakeExp,
|
||||
});
|
||||
|
||||
const appJSON = await fs.readJSON('/repo/app.json');
|
||||
const appJSON = await fs.readJSON('/app/app.json');
|
||||
expect(fakeExp.version).toBe('1.0.0');
|
||||
expect(fakeExp.ios?.buildNumber).toBe('2');
|
||||
expect(appJSON.expo.version).toBe('1.0.0');
|
||||
@@ -165,11 +230,11 @@ describe(bumpVersionInAppJsonAsync, () => {
|
||||
|
||||
await bumpVersionInAppJsonAsync({
|
||||
bumpStrategy: BumpStrategy.APP_VERSION,
|
||||
projectDir: '/repo',
|
||||
projectDir: '/app',
|
||||
exp: fakeExp,
|
||||
});
|
||||
|
||||
const appJSON = await fs.readJSON('/repo/app.json');
|
||||
const appJSON = await fs.readJSON('/app/app.json');
|
||||
expect(fakeExp.version).toBe('1.0.1');
|
||||
expect(fakeExp.ios?.buildNumber).toBe('1');
|
||||
expect(appJSON.expo.version).toBe('1.0.1');
|
||||
@@ -181,11 +246,11 @@ describe(bumpVersionInAppJsonAsync, () => {
|
||||
|
||||
await bumpVersionInAppJsonAsync({
|
||||
bumpStrategy: BumpStrategy.NOOP,
|
||||
projectDir: '/repo',
|
||||
projectDir: '/app',
|
||||
exp: fakeExp,
|
||||
});
|
||||
|
||||
const appJSON = await fs.readJSON('/repo/app.json');
|
||||
const appJSON = await fs.readJSON('/app/app.json');
|
||||
expect(fakeExp.version).toBe('1.0.0');
|
||||
expect(fakeExp.ios?.buildNumber).toBe('1');
|
||||
expect(appJSON.expo.version).toBe('1.0.0');
|
||||
@@ -197,7 +262,7 @@ describe(readBuildNumberAsync, () => {
|
||||
describe('bare project', () => {
|
||||
it('reads the build number from native code', async () => {
|
||||
const exp = initBareWorkflowProject();
|
||||
const buildNumber = await readBuildNumberAsync('/repo', exp, {});
|
||||
const buildNumber = await readBuildNumberAsync('/app', exp, {});
|
||||
expect(buildNumber).toBe('1');
|
||||
});
|
||||
});
|
||||
@@ -205,7 +270,7 @@ describe(readBuildNumberAsync, () => {
|
||||
describe('managed project', () => {
|
||||
it('reads the build number from expo config', async () => {
|
||||
const exp = initManagedProject();
|
||||
const buildNumber = await readBuildNumberAsync('/repo', exp, {});
|
||||
const buildNumber = await readBuildNumberAsync('/app', exp, {});
|
||||
expect(buildNumber).toBe('1');
|
||||
});
|
||||
});
|
||||
@@ -215,24 +280,38 @@ describe(readShortVersionAsync, () => {
|
||||
describe('bare project', () => {
|
||||
it('reads the short version from native code', async () => {
|
||||
const exp = initBareWorkflowProject();
|
||||
const appVersion = await readShortVersionAsync('/repo', exp, {});
|
||||
const appVersion = await readShortVersionAsync('/app', exp, {});
|
||||
expect(appVersion).toBe('1.0.0');
|
||||
});
|
||||
it('evaluates interpolated build number', async () => {
|
||||
const exp = initBareWorkflowProject({
|
||||
appVersion: '$(CURRENT_PROJECT_VERSION)',
|
||||
});
|
||||
const buildNumber = await readShortVersionAsync('/repo', exp, {
|
||||
const buildNumber = await readShortVersionAsync('/app', exp, {
|
||||
CURRENT_PROJECT_VERSION: '1.0.0',
|
||||
});
|
||||
expect(buildNumber).toBe('1.0.0');
|
||||
});
|
||||
|
||||
it('fails when build number is invalid', async () => {
|
||||
const exp = initBareWorkflowProject({
|
||||
appVersion: '$(CURRENT_PROJECT_VERSION)',
|
||||
});
|
||||
|
||||
await expect(
|
||||
readShortVersionAsync('/app', exp, {
|
||||
CURRENT_PROJECT_VERSION: '0.0.7.1.028',
|
||||
})
|
||||
).rejects.toThrowError(
|
||||
'CFBundleShortVersionString (version field in app.json/app.config.js) must be a period-separated list of three non-negative integers. Current value: 0.0.7.1.028'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('managed project', () => {
|
||||
it('reads the version from app config', async () => {
|
||||
const exp = initBareWorkflowProject();
|
||||
const appVersion = await readShortVersionAsync('/repo', exp, {});
|
||||
const appVersion = await readShortVersionAsync('/app', exp, {});
|
||||
expect(appVersion).toBe('1.0.0');
|
||||
});
|
||||
});
|
||||
@@ -244,66 +323,75 @@ describe(getInfoPlistPath, () => {
|
||||
{
|
||||
'./ios/testapp/Info.plist': '',
|
||||
},
|
||||
'/repo'
|
||||
'/app'
|
||||
);
|
||||
const plistPath = getInfoPlistPath('/repo', {});
|
||||
expect(plistPath).toBe('/repo/ios/testapp/Info.plist');
|
||||
const plistPath = getInfoPlistPath('/app', {});
|
||||
expect(plistPath).toBe('/app/ios/testapp/Info.plist');
|
||||
});
|
||||
it('returns INFOPLIST_FILE if specified', () => {
|
||||
vol.fromJSON(
|
||||
{
|
||||
'./ios/testapp/Info.plist': '',
|
||||
},
|
||||
'/repo'
|
||||
'/app'
|
||||
);
|
||||
const plistPath = getInfoPlistPath('/repo', { INFOPLIST_FILE: './qwert/NotInfo.plist' });
|
||||
expect(plistPath).toBe('/repo/ios/qwert/NotInfo.plist');
|
||||
const plistPath = getInfoPlistPath('/app', { INFOPLIST_FILE: './qwert/NotInfo.plist' });
|
||||
expect(plistPath).toBe('/app/ios/qwert/NotInfo.plist');
|
||||
});
|
||||
it('evaluates SRCROOT in Info.plist', () => {
|
||||
vol.fromJSON(
|
||||
{
|
||||
'./ios/testapp/Info.plist': '',
|
||||
},
|
||||
'/repo'
|
||||
'/app'
|
||||
);
|
||||
const plistPath = getInfoPlistPath('/repo', {
|
||||
const plistPath = getInfoPlistPath('/app', {
|
||||
INFOPLIST_FILE: '$(SRCROOT)/qwert/NotInfo.plist',
|
||||
});
|
||||
expect(plistPath).toBe('/repo/ios/qwert/NotInfo.plist');
|
||||
expect(plistPath).toBe('/app/ios/qwert/NotInfo.plist');
|
||||
});
|
||||
it('evaluates BuildSettings in Info.plist', () => {
|
||||
vol.fromJSON(
|
||||
{
|
||||
'./ios/testapp/Info.plist': '',
|
||||
},
|
||||
'/repo'
|
||||
'/app'
|
||||
);
|
||||
const plistPath = getInfoPlistPath('/repo', {
|
||||
const plistPath = getInfoPlistPath('/app', {
|
||||
INFOPLIST_FILE: '$(SRCROOT)/qwert/$(TARGET_NAME).plist',
|
||||
TARGET_NAME: 'NotInfo',
|
||||
});
|
||||
expect(plistPath).toBe('/repo/ios/qwert/NotInfo.plist');
|
||||
expect(plistPath).toBe('/app/ios/qwert/NotInfo.plist');
|
||||
});
|
||||
});
|
||||
|
||||
function initBareWorkflowProject({
|
||||
appVersion = '1.0.0',
|
||||
version = '1',
|
||||
buildNumber = '1',
|
||||
expoConfig = {},
|
||||
infoPlistName = 'Info.plist',
|
||||
}: { appVersion?: string; version?: string; infoPlistName?: string } = {}): ExpoConfig {
|
||||
}: {
|
||||
appVersion?: string;
|
||||
buildNumber?: string;
|
||||
infoPlistName?: string;
|
||||
expoConfig?: { version?: string; ios?: object };
|
||||
} = {}): ExpoConfig {
|
||||
const fakeExp: ExpoConfig = {
|
||||
name: 'myproject',
|
||||
slug: 'myproject',
|
||||
version: '1.0.0',
|
||||
ios: {
|
||||
buildNumber: '1',
|
||||
...expoConfig.ios,
|
||||
},
|
||||
...expoConfig,
|
||||
};
|
||||
vol.fromJSON(
|
||||
{
|
||||
'./app.json': JSON.stringify({
|
||||
expo: fakeExp,
|
||||
}),
|
||||
'./ios/myproject.xcodeproj/project.pbxproj': '',
|
||||
[`./ios/myproject/${infoPlistName}`]: `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
@@ -311,11 +399,11 @@ function initBareWorkflowProject({
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>${appVersion}</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>${version}</string>
|
||||
<string>${buildNumber}</string>
|
||||
</dict>
|
||||
</plist>`,
|
||||
},
|
||||
'/repo'
|
||||
'/app'
|
||||
);
|
||||
|
||||
return fakeExp;
|
||||
@@ -337,7 +425,7 @@ function initManagedProject(): ExpoConfig {
|
||||
expo: fakeExp,
|
||||
}),
|
||||
},
|
||||
'/repo'
|
||||
'/app'
|
||||
);
|
||||
|
||||
return fakeExp;
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { IOSConfig } from '@expo/config-plugins';
|
||||
import { Ios, Job, Metadata, Platform, Workflow } from '@expo/eas-build-job';
|
||||
import type { XCBuildConfiguration } from 'xcode';
|
||||
import { AppVersionSource } from '@expo/eas-json';
|
||||
|
||||
import { IosCredentials, Target } from '../../credentials/ios/types';
|
||||
import { IosCredentials } from '../../credentials/ios/types';
|
||||
import { BuildParamsInput } from '../../graphql/generated';
|
||||
import { BuildMutation, BuildResult } from '../../graphql/mutations/BuildMutation';
|
||||
import { ensureBundleIdentifierIsDefinedForManagedProjectAsync } from '../../project/ios/bundleIdentifier';
|
||||
import { resolveXcodeBuildContextAsync } from '../../project/ios/scheme';
|
||||
@@ -10,11 +10,16 @@ import { findApplicationTarget, resolveTargetsAsync } from '../../project/ios/ta
|
||||
import { BuildRequestSender, JobData, prepareBuildRequestForPlatformAsync } from '../build';
|
||||
import { BuildContext, CommonContext, IosBuildContext } from '../context';
|
||||
import { transformMetadata } from '../graphql';
|
||||
import { checkGoogleServicesFileAsync, checkNodeEnvVariable } from '../validate';
|
||||
import {
|
||||
checkGoogleServicesFileAsync,
|
||||
checkNodeEnvVariable,
|
||||
validatePNGsForManagedProjectAsync,
|
||||
} from '../validate';
|
||||
import { ensureIosCredentialsAsync } from './credentials';
|
||||
import { transformJob } from './graphql';
|
||||
import { prepareJobAsync } from './prepareJob';
|
||||
import { syncProjectConfigurationAsync } from './syncProjectConfiguration';
|
||||
import { resolveRemoteBuildNumberAsync } from './version';
|
||||
|
||||
export async function createIosContextAsync(
|
||||
ctx: CommonContext<Platform.IOS>
|
||||
@@ -22,11 +27,12 @@ export async function createIosContextAsync(
|
||||
const { buildProfile } = ctx;
|
||||
|
||||
if (ctx.workflow === Workflow.MANAGED) {
|
||||
await ensureBundleIdentifierIsDefinedForManagedProjectAsync(ctx.projectDir, ctx.exp);
|
||||
await ensureBundleIdentifierIsDefinedForManagedProjectAsync(ctx);
|
||||
}
|
||||
|
||||
checkNodeEnvVariable(ctx);
|
||||
await checkGoogleServicesFileAsync(ctx);
|
||||
await validatePNGsForManagedProjectAsync(ctx);
|
||||
|
||||
const xcodeBuildContext = await resolveXcodeBuildContextAsync(
|
||||
{
|
||||
@@ -43,14 +49,22 @@ export async function createIosContextAsync(
|
||||
env: buildProfile.env,
|
||||
});
|
||||
const applicationTarget = findApplicationTarget(targets);
|
||||
const applicationTargetBuildSettings = resolveBuildSettings(ctx, applicationTarget);
|
||||
|
||||
const buildNumberOverride =
|
||||
ctx.easJsonCliConfig?.appVersionSource === AppVersionSource.REMOTE
|
||||
? await resolveRemoteBuildNumberAsync(ctx.graphqlClient, {
|
||||
projectDir: ctx.projectDir,
|
||||
projectId: ctx.projectId,
|
||||
exp: ctx.exp,
|
||||
applicationTarget,
|
||||
buildProfile,
|
||||
})
|
||||
: undefined;
|
||||
return {
|
||||
bundleIdentifier: applicationTarget.bundleIdentifier,
|
||||
applicationTarget,
|
||||
applicationTargetBuildSettings,
|
||||
targets,
|
||||
xcodeBuildContext,
|
||||
buildNumberOverride,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -63,11 +77,15 @@ export async function prepareIosBuildAsync(
|
||||
return ensureIosCredentialsAsync(ctx, ctx.ios.targets);
|
||||
},
|
||||
syncProjectConfigurationAsync: async () => {
|
||||
await syncProjectConfigurationAsync({
|
||||
await syncProjectConfigurationAsync(ctx.graphqlClient, {
|
||||
projectDir: ctx.projectDir,
|
||||
exp: ctx.exp,
|
||||
buildProfile: ctx.buildProfile,
|
||||
buildSettings: ctx.ios.applicationTargetBuildSettings,
|
||||
targets: ctx.ios.targets,
|
||||
localAutoIncrement:
|
||||
ctx.easJsonCliConfig?.appVersionSource === AppVersionSource.REMOTE
|
||||
? false
|
||||
: ctx.buildProfile.autoIncrement,
|
||||
projectId: ctx.projectId,
|
||||
});
|
||||
},
|
||||
prepareJobAsync: async (
|
||||
@@ -82,30 +100,17 @@ export async function prepareIosBuildAsync(
|
||||
sendBuildRequestAsync: async (
|
||||
appId: string,
|
||||
job: Ios.Job,
|
||||
metadata: Metadata
|
||||
metadata: Metadata,
|
||||
buildParams: BuildParamsInput
|
||||
): Promise<BuildResult> => {
|
||||
const graphqlMetadata = transformMetadata(metadata);
|
||||
const graphqlJob = transformJob(job);
|
||||
return await BuildMutation.createIosBuildAsync({
|
||||
return await BuildMutation.createIosBuildAsync(ctx.graphqlClient, {
|
||||
appId,
|
||||
job: graphqlJob,
|
||||
metadata: graphqlMetadata,
|
||||
buildParams,
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function resolveBuildSettings(
|
||||
ctx: CommonContext<Platform.IOS>,
|
||||
applicationTarget: Target
|
||||
): XCBuildConfiguration['buildSettings'] {
|
||||
if (ctx.workflow === Workflow.MANAGED) {
|
||||
return {};
|
||||
}
|
||||
const project = IOSConfig.XcodeUtils.getPbxproj(ctx.projectDir);
|
||||
const xcBuildConfiguration = IOSConfig.Target.getXCBuildConfigurationFromPbxproj(project, {
|
||||
targetName: applicationTarget.targetName,
|
||||
buildConfiguration: applicationTarget.buildConfiguration,
|
||||
});
|
||||
return xcBuildConfiguration?.buildSettings ?? {};
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
import { Platform } from '@expo/eas-build-job';
|
||||
import { BuildProfile } from '@expo/eas-json';
|
||||
|
||||
import { CredentialsContext } from '../../credentials/context';
|
||||
import IosCredentialsProvider from '../../credentials/ios/IosCredentialsProvider';
|
||||
import { getAppFromContext } from '../../credentials/ios/actions/BuildCredentialsUtils';
|
||||
import { getAppFromContextAsync } from '../../credentials/ios/actions/BuildCredentialsUtils';
|
||||
import { IosCredentials, Target } from '../../credentials/ios/types';
|
||||
import { CredentialsResult } from '../build';
|
||||
import { BuildContext } from '../context';
|
||||
@@ -16,7 +18,7 @@ export async function ensureIosCredentialsAsync(
|
||||
}
|
||||
|
||||
const provider = new IosCredentialsProvider(buildCtx.credentialsCtx, {
|
||||
app: getAppFromContext(buildCtx.credentialsCtx),
|
||||
app: await getAppFromContextAsync(buildCtx.credentialsCtx),
|
||||
targets,
|
||||
distribution: buildCtx.buildProfile.distribution ?? 'store',
|
||||
enterpriseProvisioning: buildCtx.buildProfile.enterpriseProvisioning,
|
||||
@@ -31,6 +33,28 @@ export async function ensureIosCredentialsAsync(
|
||||
};
|
||||
}
|
||||
|
||||
export async function ensureIosCredentialsForBuildResignAsync(
|
||||
credentialsCtx: CredentialsContext,
|
||||
targets: Target[],
|
||||
buildProfile: BuildProfile<Platform.IOS>
|
||||
): Promise<CredentialsResult<IosCredentials>> {
|
||||
const provider = new IosCredentialsProvider(credentialsCtx, {
|
||||
app: await getAppFromContextAsync(credentialsCtx),
|
||||
targets,
|
||||
distribution: 'internal',
|
||||
enterpriseProvisioning: buildProfile.enterpriseProvisioning,
|
||||
});
|
||||
|
||||
const { credentialsSource } = buildProfile;
|
||||
|
||||
logCredentialsSource(credentialsSource, Platform.IOS);
|
||||
return {
|
||||
credentials: await provider.getCredentialsAsync(credentialsSource),
|
||||
source: credentialsSource,
|
||||
};
|
||||
}
|
||||
|
||||
function shouldProvideCredentials(buildCtx: BuildContext<Platform.IOS>): boolean {
|
||||
return !buildCtx.buildProfile.simulator;
|
||||
const isCustomBuild = buildCtx.buildProfile.config !== undefined;
|
||||
return !buildCtx.buildProfile.simulator && !isCustomBuild;
|
||||
}
|
||||
@@ -1,32 +1,41 @@
|
||||
import { Env, Ios } from '@expo/eas-build-job';
|
||||
import { Ios } from '@expo/eas-build-job';
|
||||
import nullthrows from 'nullthrows';
|
||||
|
||||
import { IosJobInput, IosJobSecretsInput } from '../../graphql/generated';
|
||||
import { transformProjectArchive, transformWorkflow } from '../graphql';
|
||||
import {
|
||||
transformBuildMode,
|
||||
transformBuildTrigger,
|
||||
transformProjectArchive,
|
||||
transformWorkflow,
|
||||
} from '../graphql';
|
||||
|
||||
export function transformJob(job: Ios.Job): IosJobInput {
|
||||
return {
|
||||
type: transformWorkflow(job.type),
|
||||
triggeredBy: transformBuildTrigger(job.triggeredBy),
|
||||
projectArchive: transformProjectArchive(job.projectArchive),
|
||||
projectRootDirectory: job.projectRootDirectory,
|
||||
projectRootDirectory: nullthrows(job.projectRootDirectory),
|
||||
releaseChannel: job.releaseChannel,
|
||||
updates: job.updates,
|
||||
secrets: transformIosSecrets(job.secrets),
|
||||
secrets: job.secrets ? transformIosSecrets(job.secrets) : undefined,
|
||||
builderEnvironment: job.builderEnvironment,
|
||||
cache: job.cache,
|
||||
version: job.version?.buildNumber ? { buildNumber: job.version.buildNumber } : undefined,
|
||||
scheme: job.scheme,
|
||||
buildConfiguration: job.buildConfiguration,
|
||||
artifactPath: job.artifactPath,
|
||||
applicationArchivePath: job.applicationArchivePath,
|
||||
buildArtifactPaths: job.buildArtifactPaths,
|
||||
username: job.username,
|
||||
developmentClient: job.developmentClient,
|
||||
simulator: job.simulator,
|
||||
experimental: job.experimental,
|
||||
mode: transformBuildMode(job.mode),
|
||||
customBuildConfig: job.customBuildConfig,
|
||||
};
|
||||
}
|
||||
|
||||
function transformIosSecrets(secrets: {
|
||||
export function transformIosSecrets(secrets: {
|
||||
buildCredentials?: Ios.BuildCredentials;
|
||||
env?: Env;
|
||||
}): IosJobSecretsInput {
|
||||
const buildCredentials: IosJobSecretsInput['buildCredentials'] = [];
|
||||
|
||||
@@ -42,6 +51,5 @@ function transformIosSecrets(secrets: {
|
||||
|
||||
return {
|
||||
buildCredentials,
|
||||
environmentSecrets: secrets.env,
|
||||
};
|
||||
}
|
||||
@@ -1,10 +1,21 @@
|
||||
import { ArchiveSource, Ios, Job, Platform, sanitizeJob } from '@expo/eas-build-job';
|
||||
import {
|
||||
ArchiveSource,
|
||||
BuildMode,
|
||||
BuildTrigger,
|
||||
Ios,
|
||||
Job,
|
||||
Platform,
|
||||
sanitizeJob,
|
||||
} from '@expo/eas-build-job';
|
||||
import { BuildProfile } from '@expo/eas-json';
|
||||
import nullthrows from 'nullthrows';
|
||||
import path from 'path';
|
||||
import slash from 'slash';
|
||||
|
||||
import { IosCredentials, TargetCredentials } from '../../credentials/ios/types';
|
||||
import { IosJobSecretsInput } from '../../graphql/generated';
|
||||
import { getCustomBuildConfigPath } from '../../project/customBuildConfig';
|
||||
import { getUsername } from '../../project/projectUtils';
|
||||
import { ensureLoggedInAsync } from '../../user/actions';
|
||||
import { getVcsClient } from '../../vcs';
|
||||
import { BuildContext } from '../context';
|
||||
|
||||
@@ -16,18 +27,18 @@ interface JobData {
|
||||
|
||||
const cacheDefaults = {
|
||||
disabled: false,
|
||||
customPaths: [],
|
||||
cacheDefaultPaths: true,
|
||||
paths: [],
|
||||
};
|
||||
|
||||
export async function prepareJobAsync(
|
||||
ctx: BuildContext<Platform.IOS>,
|
||||
jobData: JobData
|
||||
): Promise<Job> {
|
||||
const username = getUsername(ctx.exp, ctx.user);
|
||||
const buildProfile: BuildProfile<Platform.IOS> = ctx.buildProfile;
|
||||
const projectRootDirectory =
|
||||
slash(path.relative(await getVcsClient().getRootPathAsync(), ctx.projectDir)) || '.';
|
||||
const username = getUsername(ctx.exp, await ensureLoggedInAsync());
|
||||
const buildCredentials: Ios.Job['secrets']['buildCredentials'] = {};
|
||||
const buildCredentials: Ios.BuildSecrets['buildCredentials'] = {};
|
||||
if (jobData.credentials) {
|
||||
const targetNames = Object.keys(jobData.credentials);
|
||||
for (const targetName of targetNames) {
|
||||
@@ -35,44 +46,77 @@ export async function prepareJobAsync(
|
||||
}
|
||||
}
|
||||
|
||||
const maybeCustomBuildConfigPath = buildProfile.config
|
||||
? getCustomBuildConfigPath(buildProfile.config)
|
||||
: undefined;
|
||||
|
||||
const job: Ios.Job = {
|
||||
type: ctx.workflow,
|
||||
platform: Platform.IOS,
|
||||
projectArchive: jobData.projectArchive,
|
||||
projectRootDirectory,
|
||||
builderEnvironment: {
|
||||
image: ctx.buildProfile.image,
|
||||
node: ctx.buildProfile.node,
|
||||
yarn: ctx.buildProfile.yarn,
|
||||
bundler: ctx.buildProfile.bundler,
|
||||
cocoapods: ctx.buildProfile.cocoapods,
|
||||
fastlane: ctx.buildProfile.fastlane,
|
||||
expoCli: ctx.buildProfile.expoCli,
|
||||
env: ctx.buildProfile.env,
|
||||
image: buildProfile.image,
|
||||
node: buildProfile.node,
|
||||
yarn: buildProfile.yarn,
|
||||
bundler: buildProfile.bundler,
|
||||
cocoapods: buildProfile.cocoapods,
|
||||
fastlane: buildProfile.fastlane,
|
||||
expoCli: buildProfile.expoCli,
|
||||
env: buildProfile.env,
|
||||
},
|
||||
cache: {
|
||||
...cacheDefaults,
|
||||
...ctx.buildProfile.cache,
|
||||
...buildProfile.cache,
|
||||
clear: ctx.clearCache,
|
||||
},
|
||||
secrets: {
|
||||
buildCredentials,
|
||||
},
|
||||
releaseChannel: ctx.buildProfile.releaseChannel,
|
||||
updates: { channel: ctx.buildProfile.channel },
|
||||
developmentClient: ctx.buildProfile.developmentClient,
|
||||
simulator: ctx.buildProfile.simulator,
|
||||
releaseChannel: buildProfile.releaseChannel,
|
||||
updates: { channel: buildProfile.channel },
|
||||
developmentClient: buildProfile.developmentClient,
|
||||
simulator: buildProfile.simulator,
|
||||
scheme: jobData.buildScheme,
|
||||
buildConfiguration: ctx.buildProfile.buildConfiguration,
|
||||
artifactPath: ctx.buildProfile.artifactPath,
|
||||
buildConfiguration: buildProfile.buildConfiguration,
|
||||
applicationArchivePath: buildProfile.applicationArchivePath ?? buildProfile.artifactPath,
|
||||
buildArtifactPaths: buildProfile.buildArtifactPaths,
|
||||
username,
|
||||
...(ctx.ios.buildNumberOverride && {
|
||||
version: {
|
||||
buildNumber: ctx.ios.buildNumberOverride,
|
||||
},
|
||||
}),
|
||||
experimental: {
|
||||
prebuildCommand: ctx.buildProfile.prebuildCommand,
|
||||
prebuildCommand: buildProfile.prebuildCommand,
|
||||
},
|
||||
mode: buildProfile.config ? BuildMode.CUSTOM : BuildMode.BUILD,
|
||||
triggeredBy: BuildTrigger.EAS_CLI,
|
||||
...(maybeCustomBuildConfigPath && {
|
||||
customBuildConfig: {
|
||||
path: maybeCustomBuildConfigPath,
|
||||
},
|
||||
}),
|
||||
};
|
||||
return sanitizeJob(job);
|
||||
}
|
||||
|
||||
export function prepareCredentialsToResign(credentials: IosCredentials): IosJobSecretsInput {
|
||||
const buildCredentials: IosJobSecretsInput['buildCredentials'] = [];
|
||||
for (const targetName of Object.keys(credentials ?? {})) {
|
||||
buildCredentials.push({
|
||||
targetName,
|
||||
provisioningProfileBase64: nullthrows(credentials?.[targetName].provisioningProfile),
|
||||
distributionCertificate: {
|
||||
dataBase64: nullthrows(credentials?.[targetName].distributionCertificate.certificateP12),
|
||||
password: nullthrows(credentials?.[targetName].distributionCertificate.certificatePassword),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return { buildCredentials };
|
||||
}
|
||||
|
||||
function prepareTargetCredentials(targetCredentials: TargetCredentials): Ios.TargetCredentials {
|
||||
return {
|
||||
provisioningProfileBase64: targetCredentials.provisioningProfile,
|
||||
|
||||
@@ -1,33 +1,38 @@
|
||||
import { ExpoConfig } from '@expo/config';
|
||||
import { Platform, Workflow } from '@expo/eas-build-job';
|
||||
import { BuildProfile, IosVersionAutoIncrement } from '@expo/eas-json';
|
||||
import type { XCBuildConfiguration } from 'xcode';
|
||||
import { IosVersionAutoIncrement } from '@expo/eas-json';
|
||||
|
||||
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { Target } from '../../credentials/ios/types';
|
||||
import { isExpoUpdatesInstalled } from '../../project/projectUtils';
|
||||
import { resolveWorkflowAsync } from '../../project/workflow';
|
||||
import { syncUpdatesConfigurationAsync } from '../../update/ios/UpdatesModule';
|
||||
import { BumpStrategy, bumpVersionAsync, bumpVersionInAppJsonAsync } from './version';
|
||||
|
||||
export async function syncProjectConfigurationAsync({
|
||||
projectDir,
|
||||
exp,
|
||||
buildProfile,
|
||||
buildSettings,
|
||||
}: {
|
||||
projectDir: string;
|
||||
exp: ExpoConfig;
|
||||
buildProfile: BuildProfile<Platform.IOS>;
|
||||
buildSettings: XCBuildConfiguration['buildSettings'];
|
||||
}): Promise<void> {
|
||||
export async function syncProjectConfigurationAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
{
|
||||
projectDir,
|
||||
exp,
|
||||
targets,
|
||||
localAutoIncrement,
|
||||
projectId,
|
||||
}: {
|
||||
projectDir: string;
|
||||
exp: ExpoConfig;
|
||||
targets: Target[];
|
||||
localAutoIncrement?: IosVersionAutoIncrement;
|
||||
projectId: string;
|
||||
}
|
||||
): Promise<void> {
|
||||
const workflow = await resolveWorkflowAsync(projectDir, Platform.IOS);
|
||||
const { autoIncrement } = buildProfile;
|
||||
const versionBumpStrategy = resolveVersionBumpStrategy(autoIncrement ?? false);
|
||||
const versionBumpStrategy = resolveVersionBumpStrategy(localAutoIncrement ?? false);
|
||||
|
||||
if (workflow === Workflow.GENERIC) {
|
||||
if (isExpoUpdatesInstalled(projectDir)) {
|
||||
await syncUpdatesConfigurationAsync(projectDir, exp);
|
||||
await syncUpdatesConfigurationAsync(graphqlClient, projectDir, exp, projectId);
|
||||
}
|
||||
await bumpVersionAsync({ projectDir, exp, bumpStrategy: versionBumpStrategy, buildSettings });
|
||||
await bumpVersionAsync({ projectDir, exp, bumpStrategy: versionBumpStrategy, targets });
|
||||
} else {
|
||||
await bumpVersionInAppJsonAsync({ projectDir, exp, bumpStrategy: versionBumpStrategy });
|
||||
}
|
||||
|
||||
@@ -1,16 +1,28 @@
|
||||
import { ExpoConfig, getConfigFilePaths } from '@expo/config';
|
||||
import { IOSConfig } from '@expo/config-plugins';
|
||||
import { ExpoConfig } from '@expo/config';
|
||||
import { IOSConfig, Updates } from '@expo/config-plugins';
|
||||
import { Platform, Workflow } from '@expo/eas-build-job';
|
||||
import { BuildProfile } from '@expo/eas-json';
|
||||
import chalk from 'chalk';
|
||||
import path from 'path';
|
||||
import type { XCBuildConfiguration } from 'xcode';
|
||||
|
||||
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { Target } from '../../credentials/ios/types';
|
||||
import { AppPlatform } from '../../graphql/generated';
|
||||
import { AppVersionMutation } from '../../graphql/mutations/AppVersionMutation';
|
||||
import { AppVersionQuery } from '../../graphql/queries/AppVersionQuery';
|
||||
import Log from '../../log';
|
||||
import { ora } from '../../ora';
|
||||
import { findApplicationTarget } from '../../project/ios/target';
|
||||
import { getNextBuildNumber, isValidBuildNumber } from '../../project/ios/versions';
|
||||
import { resolveWorkflowAsync } from '../../project/workflow';
|
||||
import { promptAsync } from '../../prompts';
|
||||
import uniqBy from '../../utils/expodash/uniqBy';
|
||||
import { readPlistAsync, writePlistAsync } from '../../utils/plist';
|
||||
import { updateAppJsonConfigAsync } from '../utils/appJson';
|
||||
import { bumpAppVersionAsync } from '../utils/version';
|
||||
import { bumpAppVersionAsync, ensureStaticConfigExists } from '../utils/version';
|
||||
|
||||
const SHORT_VERSION_REGEX = /^\d+.\d+.\d+$/;
|
||||
|
||||
export enum BumpStrategy {
|
||||
APP_VERSION,
|
||||
@@ -22,21 +34,25 @@ export async function bumpVersionAsync({
|
||||
bumpStrategy,
|
||||
projectDir,
|
||||
exp,
|
||||
buildSettings,
|
||||
targets,
|
||||
}: {
|
||||
projectDir: string;
|
||||
exp: ExpoConfig;
|
||||
bumpStrategy: BumpStrategy;
|
||||
buildSettings: XCBuildConfiguration['buildSettings'];
|
||||
targets: Target[];
|
||||
}): Promise<void> {
|
||||
if (bumpStrategy === BumpStrategy.NOOP) {
|
||||
return;
|
||||
}
|
||||
ensureStaticConfigExists(projectDir);
|
||||
const infoPlist = await readInfoPlistAsync(projectDir, buildSettings);
|
||||
await bumpVersionInAppJsonAsync({ bumpStrategy, projectDir, exp });
|
||||
Log.log('Updated versions in app.json');
|
||||
await writeVersionsToInfoPlistAsync({ projectDir, exp, infoPlist, buildSettings });
|
||||
await updateNativeVersionsAsync({
|
||||
projectDir,
|
||||
version: exp.version,
|
||||
buildNumber: exp.ios?.buildNumber,
|
||||
targets,
|
||||
});
|
||||
Log.log('Synchronized versions with Info.plist');
|
||||
}
|
||||
|
||||
@@ -59,10 +75,8 @@ export async function bumpVersionInAppJsonAsync({
|
||||
await bumpAppVersionAsync({ appVersion, projectDir, exp });
|
||||
} else {
|
||||
const buildNumber = IOSConfig.Version.getBuildNumber(exp);
|
||||
if (buildNumber.match(/^\d+(\.\d+)*$/)) {
|
||||
const comps = buildNumber.split('.');
|
||||
comps[comps.length - 1] = String(Number(comps[comps.length - 1]) + 1);
|
||||
const bumpedBuildNumber = comps.join('.');
|
||||
if (isValidBuildNumber(buildNumber)) {
|
||||
const bumpedBuildNumber = getNextBuildNumber(buildNumber);
|
||||
Log.log(
|
||||
`Bumping ${chalk.bold('expo.ios.buildNumber')} from ${chalk.bold(
|
||||
buildNumber
|
||||
@@ -85,6 +99,14 @@ export async function bumpVersionInAppJsonAsync({
|
||||
}
|
||||
}
|
||||
|
||||
function validateShortVersion(shortVersion: string | undefined): void {
|
||||
if (shortVersion && !SHORT_VERSION_REGEX.test(shortVersion)) {
|
||||
throw new Error(
|
||||
`CFBundleShortVersionString (version field in app.json/app.config.js) must be a period-separated list of three non-negative integers. Current value: ${shortVersion}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function readShortVersionAsync(
|
||||
projectDir: string,
|
||||
exp: ExpoConfig,
|
||||
@@ -93,11 +115,15 @@ export async function readShortVersionAsync(
|
||||
const workflow = await resolveWorkflowAsync(projectDir, Platform.IOS);
|
||||
if (workflow === Workflow.GENERIC) {
|
||||
const infoPlist = await readInfoPlistAsync(projectDir, buildSettings);
|
||||
return (
|
||||
|
||||
const shortVersion =
|
||||
infoPlist.CFBundleShortVersionString &&
|
||||
evaluateTemplateString(infoPlist.CFBundleShortVersionString, buildSettings)
|
||||
);
|
||||
evaluateTemplateString(infoPlist.CFBundleShortVersionString, buildSettings);
|
||||
|
||||
validateShortVersion(shortVersion);
|
||||
return shortVersion;
|
||||
} else {
|
||||
validateShortVersion(exp.version);
|
||||
return exp.version;
|
||||
}
|
||||
}
|
||||
@@ -121,12 +147,21 @@ export async function readBuildNumberAsync(
|
||||
export async function maybeResolveVersionsAsync(
|
||||
projectDir: string,
|
||||
exp: ExpoConfig,
|
||||
buildSettings: XCBuildConfiguration['buildSettings']
|
||||
targets: Target[]
|
||||
): Promise<{ appVersion?: string; appBuildVersion?: string }> {
|
||||
const applicationTarget = findApplicationTarget(targets);
|
||||
try {
|
||||
return {
|
||||
appBuildVersion: await readBuildNumberAsync(projectDir, exp, buildSettings),
|
||||
appVersion: await readShortVersionAsync(projectDir, exp, buildSettings),
|
||||
appBuildVersion: await readBuildNumberAsync(
|
||||
projectDir,
|
||||
exp,
|
||||
applicationTarget.buildSettings ?? {}
|
||||
),
|
||||
appVersion: await readShortVersionAsync(
|
||||
projectDir,
|
||||
exp,
|
||||
applicationTarget.buildSettings ?? {}
|
||||
),
|
||||
};
|
||||
} catch (err: any) {
|
||||
Log.warn('Failed to read app versions.');
|
||||
@@ -137,23 +172,6 @@ export async function maybeResolveVersionsAsync(
|
||||
}
|
||||
}
|
||||
|
||||
async function writeVersionsToInfoPlistAsync({
|
||||
projectDir,
|
||||
exp,
|
||||
infoPlist,
|
||||
buildSettings,
|
||||
}: {
|
||||
projectDir: string;
|
||||
exp: ExpoConfig;
|
||||
infoPlist: IOSConfig.InfoPlist;
|
||||
buildSettings: XCBuildConfiguration['buildSettings'];
|
||||
}): Promise<IOSConfig.InfoPlist> {
|
||||
let updatedInfoPlist = IOSConfig.Version.setVersion(exp, infoPlist);
|
||||
updatedInfoPlist = IOSConfig.Version.setBuildNumber(exp, updatedInfoPlist);
|
||||
await writeInfoPlistAsync({ projectDir, infoPlist: updatedInfoPlist, buildSettings });
|
||||
return updatedInfoPlist;
|
||||
}
|
||||
|
||||
export function getInfoPlistPath(
|
||||
projectDir: string,
|
||||
buildSettings: XCBuildConfiguration['buildSettings']
|
||||
@@ -181,24 +199,55 @@ async function readInfoPlistAsync(
|
||||
return ((await readPlistAsync(infoPlistPath)) ?? {}) as IOSConfig.InfoPlist;
|
||||
}
|
||||
|
||||
async function writeInfoPlistAsync({
|
||||
export async function updateNativeVersionsAsync({
|
||||
projectDir,
|
||||
infoPlist,
|
||||
buildSettings,
|
||||
version,
|
||||
buildNumber,
|
||||
targets,
|
||||
}: {
|
||||
projectDir: string;
|
||||
infoPlist: IOSConfig.InfoPlist;
|
||||
buildSettings: XCBuildConfiguration['buildSettings'];
|
||||
version?: string;
|
||||
buildNumber?: string;
|
||||
targets: Target[];
|
||||
}): Promise<void> {
|
||||
const infoPlistPath = getInfoPlistPath(projectDir, buildSettings);
|
||||
await writePlistAsync(infoPlistPath, infoPlist);
|
||||
const project = IOSConfig.XcodeUtils.getPbxproj(projectDir);
|
||||
const iosDir = path.join(projectDir, 'ios');
|
||||
|
||||
const infoPlistFiles: string[] = [];
|
||||
for (const target of targets) {
|
||||
const { targetName, buildConfiguration } = target;
|
||||
const xcBuildConfiguration = IOSConfig.Target.getXCBuildConfigurationFromPbxproj(project, {
|
||||
targetName,
|
||||
buildConfiguration,
|
||||
});
|
||||
const infoPlist = xcBuildConfiguration?.buildSettings?.INFOPLIST_FILE;
|
||||
if (infoPlist) {
|
||||
const evaluatedInfoPlistPath = trimQuotes(
|
||||
evaluateTemplateString(infoPlist, {
|
||||
SRCROOT: iosDir,
|
||||
})
|
||||
);
|
||||
const absolutePath = path.isAbsolute(evaluatedInfoPlistPath)
|
||||
? evaluatedInfoPlistPath
|
||||
: path.join(iosDir, evaluatedInfoPlistPath);
|
||||
infoPlistFiles.push(path.normalize(absolutePath));
|
||||
}
|
||||
}
|
||||
const uniqueInfoPlistPaths = uniqBy(infoPlistFiles, i => i);
|
||||
for (const infoPlistPath of uniqueInfoPlistPaths) {
|
||||
const infoPlist = (await readPlistAsync(infoPlistPath)) as IOSConfig.InfoPlist;
|
||||
if (buildNumber) {
|
||||
infoPlist.CFBundleVersion = buildNumber;
|
||||
}
|
||||
if (version) {
|
||||
infoPlist.CFBundleShortVersionString = version;
|
||||
}
|
||||
await writePlistAsync(infoPlistPath, infoPlist);
|
||||
}
|
||||
}
|
||||
|
||||
function ensureStaticConfigExists(projectDir: string): void {
|
||||
const paths = getConfigFilePaths(projectDir);
|
||||
if (!paths.staticConfigPath) {
|
||||
throw new Error('autoIncrement option is not supported when using app.config.js');
|
||||
}
|
||||
function trimQuotes(s: string): string {
|
||||
return s?.startsWith('"') && s.endsWith('"') ? s.slice(1, -1) : s;
|
||||
}
|
||||
|
||||
export function evaluateTemplateString(
|
||||
@@ -216,3 +265,110 @@ export function evaluateTemplateString(
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns buildNumber that will be used for the next build. If current build profile
|
||||
* has an 'autoIncrement' option set, it increments the version on server.
|
||||
*/
|
||||
export async function resolveRemoteBuildNumberAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
{
|
||||
projectDir,
|
||||
projectId,
|
||||
exp,
|
||||
applicationTarget,
|
||||
buildProfile,
|
||||
}: {
|
||||
projectDir: string;
|
||||
projectId: string;
|
||||
exp: ExpoConfig;
|
||||
applicationTarget: Target;
|
||||
buildProfile: BuildProfile<Platform.IOS>;
|
||||
}
|
||||
): Promise<string> {
|
||||
const remoteVersions = await AppVersionQuery.latestVersionAsync(
|
||||
graphqlClient,
|
||||
projectId,
|
||||
AppPlatform.Ios,
|
||||
applicationTarget.bundleIdentifier
|
||||
);
|
||||
|
||||
const localBuildNumber = await readBuildNumberAsync(
|
||||
projectDir,
|
||||
exp,
|
||||
applicationTarget.buildSettings ?? {}
|
||||
);
|
||||
const localShortVersion = await readShortVersionAsync(
|
||||
projectDir,
|
||||
exp,
|
||||
applicationTarget.buildSettings ?? {}
|
||||
);
|
||||
let currentBuildVersion: string;
|
||||
if (remoteVersions?.buildVersion) {
|
||||
currentBuildVersion = remoteVersions.buildVersion;
|
||||
} else {
|
||||
if (localBuildNumber) {
|
||||
Log.warn(
|
||||
'No remote versions are configured for this project, buildNumber will be initialized based on the value from the local project.'
|
||||
);
|
||||
currentBuildVersion = localBuildNumber;
|
||||
} else {
|
||||
Log.error(
|
||||
`Remote versions are not configured and EAS CLI was not able to read the current version from your project. Use "eas build:version:set" to initialize remote versions.`
|
||||
);
|
||||
throw new Error('Remote versions are not configured.');
|
||||
}
|
||||
}
|
||||
if (!buildProfile.autoIncrement && remoteVersions?.buildVersion) {
|
||||
return currentBuildVersion;
|
||||
} else if (!buildProfile.autoIncrement && !remoteVersions?.buildVersion) {
|
||||
const spinner = ora(
|
||||
`Initializing buildNumber with ${chalk.bold(currentBuildVersion)}.`
|
||||
).start();
|
||||
try {
|
||||
await AppVersionMutation.createAppVersionAsync(graphqlClient, {
|
||||
appId: projectId,
|
||||
platform: AppPlatform.Ios,
|
||||
applicationIdentifier: applicationTarget.bundleIdentifier,
|
||||
storeVersion: localShortVersion ?? '1.0.0',
|
||||
buildVersion: currentBuildVersion,
|
||||
runtimeVersion: Updates.getRuntimeVersionNullable(exp, Platform.IOS) ?? undefined,
|
||||
});
|
||||
spinner.succeed(`Initialized buildNumber with ${chalk.bold(currentBuildVersion)}.`);
|
||||
} catch (err) {
|
||||
spinner.fail(`Failed to initialize buildNumber with ${chalk.bold(currentBuildVersion)}.`);
|
||||
throw err;
|
||||
}
|
||||
return currentBuildVersion;
|
||||
} else {
|
||||
const nextBuildVersion = getNextBuildNumber(currentBuildVersion);
|
||||
const spinner = ora(
|
||||
`Incrementing buildNumber from ${chalk.bold(currentBuildVersion)} to ${chalk.bold(
|
||||
nextBuildVersion
|
||||
)}.`
|
||||
).start();
|
||||
try {
|
||||
await AppVersionMutation.createAppVersionAsync(graphqlClient, {
|
||||
appId: projectId,
|
||||
platform: AppPlatform.Ios,
|
||||
applicationIdentifier: applicationTarget.bundleIdentifier,
|
||||
storeVersion: localShortVersion ?? '1.0.0',
|
||||
buildVersion: nextBuildVersion,
|
||||
runtimeVersion: Updates.getRuntimeVersionNullable(exp, Platform.IOS) ?? undefined,
|
||||
});
|
||||
spinner.succeed(
|
||||
`Incremented buildNumber from ${chalk.bold(currentBuildVersion)} to ${chalk.bold(
|
||||
nextBuildVersion
|
||||
)}.`
|
||||
);
|
||||
} catch (err) {
|
||||
spinner.fail(
|
||||
`Failed to increment buildNumber from ${chalk.bold(currentBuildVersion)} to ${chalk.bold(
|
||||
nextBuildVersion
|
||||
)}.`
|
||||
);
|
||||
throw err;
|
||||
}
|
||||
return nextBuildVersion;
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Job } from '@expo/eas-build-job';
|
||||
import { Job, Metadata } from '@expo/eas-build-job';
|
||||
import spawnAsync from '@expo/spawn-async';
|
||||
import { ChildProcess } from 'child_process';
|
||||
import semver from 'semver';
|
||||
@@ -6,10 +6,28 @@ import semver from 'semver';
|
||||
import { ora } from '../ora';
|
||||
|
||||
const PLUGIN_PACKAGE_NAME = 'eas-cli-local-build-plugin';
|
||||
const PLUGIN_PACKAGE_VERSION = '0.0.78';
|
||||
const PLUGIN_PACKAGE_VERSION = '1.0.27';
|
||||
|
||||
export enum LocalBuildMode {
|
||||
/**
|
||||
* Local build that users can run on their own machines. Instead
|
||||
* of sending build request to EAS Servers it's passing it as an argument
|
||||
* to local-build-plugin, that will run the build locally.
|
||||
*
|
||||
* Triggered when running `eas build --local`.
|
||||
*/
|
||||
LOCAL_BUILD_PLUGIN = 'local-build-plugin',
|
||||
/**
|
||||
* Type of local build that is not accessible to users directly. When
|
||||
* cloud build is triggered by git based integration, we are running
|
||||
* in this mode. Instead of sending build request to EAS Servers it's
|
||||
* printing it to the stdout as JSON, so EAS Build worker can read it.
|
||||
*/
|
||||
INTERNAL = 'internal',
|
||||
}
|
||||
|
||||
export interface LocalBuildOptions {
|
||||
enable: boolean;
|
||||
localBuildMode?: LocalBuildMode;
|
||||
skipCleanup?: boolean;
|
||||
skipNativeBuild?: boolean;
|
||||
artifactsDir?: string;
|
||||
@@ -18,8 +36,12 @@ export interface LocalBuildOptions {
|
||||
verbose?: boolean;
|
||||
}
|
||||
|
||||
export async function runLocalBuildAsync(job: Job, options: LocalBuildOptions): Promise<void> {
|
||||
const { command, args } = await getCommandAndArgsAsync(job);
|
||||
export async function runLocalBuildAsync(
|
||||
job: Job,
|
||||
metadata: Metadata,
|
||||
options: LocalBuildOptions
|
||||
): Promise<void> {
|
||||
const { command, args } = await getCommandAndArgsAsync(job, metadata);
|
||||
let spinner;
|
||||
if (!options.verbose) {
|
||||
spinner = ora().start(options.skipNativeBuild ? 'Preparing project' : 'Building project');
|
||||
@@ -53,15 +75,18 @@ export async function runLocalBuildAsync(job: Job, options: LocalBuildOptions):
|
||||
}
|
||||
}
|
||||
|
||||
async function getCommandAndArgsAsync(job: Job): Promise<{ command: string; args: string[] }> {
|
||||
const jobBase64 = Buffer.from(JSON.stringify({ job })).toString('base64');
|
||||
async function getCommandAndArgsAsync(
|
||||
job: Job,
|
||||
metadata: Metadata
|
||||
): Promise<{ command: string; args: string[] }> {
|
||||
const jobAndMetadataBase64 = Buffer.from(JSON.stringify({ job, metadata })).toString('base64');
|
||||
if (process.env.EAS_LOCAL_BUILD_PLUGIN_PATH) {
|
||||
return {
|
||||
command: process.env.EAS_LOCAL_BUILD_PLUGIN_PATH,
|
||||
args: [jobBase64],
|
||||
args: [jobAndMetadataBase64],
|
||||
};
|
||||
} else {
|
||||
const args = [`${PLUGIN_PACKAGE_NAME}@${PLUGIN_PACKAGE_VERSION}`, jobBase64];
|
||||
const args = [`${PLUGIN_PACKAGE_NAME}@${PLUGIN_PACKAGE_VERSION}`, jobAndMetadataBase64];
|
||||
if (await isAtLeastNpm7Async()) {
|
||||
// npx shipped with npm >= 7.0.0 requires the "-y" flag to run commands without
|
||||
// prompting the user to install a package that is used for the first time
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Updates } from '@expo/config-plugins';
|
||||
import { Metadata, Platform, sanitizeMetadata } from '@expo/eas-build-job';
|
||||
import { BuildMode, Metadata, Platform, sanitizeMetadata } from '@expo/eas-build-job';
|
||||
import { IosEnterpriseProvisioning } from '@expo/eas-json';
|
||||
import fs from 'fs-extra';
|
||||
import resolveFrom from 'resolve-from';
|
||||
@@ -14,43 +14,55 @@ import {
|
||||
readChannelSafelyAsync as readIosChannelSafelyAsync,
|
||||
readReleaseChannelSafelyAsync as readIosReleaseChannelSafelyAsync,
|
||||
} from '../update/ios/UpdatesModule';
|
||||
import { ensureLoggedInAsync } from '../user/actions';
|
||||
import { easCliVersion } from '../utils/easCli';
|
||||
import { getVcsClient } from '../vcs';
|
||||
import { maybeResolveVersionsAsync as maybeResolveAndroidVersionsAsync } from './android/version';
|
||||
import { BuildContext } from './context';
|
||||
import { maybeResolveVersionsAsync as maybeResolveIosVersionsAsync } from './ios/version';
|
||||
import { LocalBuildMode } from './local';
|
||||
|
||||
export async function collectMetadataAsync<T extends Platform>(
|
||||
ctx: BuildContext<T>
|
||||
): Promise<Metadata> {
|
||||
const vcsClient = getVcsClient();
|
||||
const channelOrReleaseChannel = await resolveChannelOrReleaseChannelAsync(ctx);
|
||||
const distribution =
|
||||
('simulator' in ctx.buildProfile && ctx.buildProfile.simulator
|
||||
? 'simulator'
|
||||
: ctx.buildProfile.distribution) ?? 'store';
|
||||
const metadata = {
|
||||
trackingContext: ctx.trackingCtx,
|
||||
const metadata: Metadata = {
|
||||
trackingContext: ctx.analyticsEventProperties,
|
||||
...(await maybeResolveVersionsAsync(ctx)),
|
||||
cliVersion: easCliVersion,
|
||||
workflow: ctx.workflow,
|
||||
credentialsSource: ctx.buildProfile.credentialsSource,
|
||||
sdkVersion: ctx.exp.sdkVersion,
|
||||
runtimeVersion: Updates.getRuntimeVersionNullable(ctx.exp, ctx.platform) ?? undefined,
|
||||
reactNativeVersion: await getReactNativeVersionAsync(ctx),
|
||||
reactNativeVersion: await getReactNativeVersionAsync(ctx.projectDir),
|
||||
...channelOrReleaseChannel,
|
||||
distribution,
|
||||
appName: ctx.exp.name,
|
||||
appIdentifier: resolveAppIdentifier(ctx),
|
||||
buildProfile: ctx.buildProfileName,
|
||||
gitCommitHash: await getVcsClient().getCommitHashAsync(),
|
||||
isGitWorkingTreeDirty: await getVcsClient().hasUncommittedChangesAsync(),
|
||||
username: getUsername(ctx.exp, await ensureLoggedInAsync()),
|
||||
gitCommitHash: await vcsClient.getCommitHashAsync(),
|
||||
gitCommitMessage: truncateGitCommitMessage(
|
||||
(await vcsClient.getLastCommitMessageAsync()) ?? undefined
|
||||
),
|
||||
isGitWorkingTreeDirty:
|
||||
ctx.localBuildOptions.localBuildMode === LocalBuildMode.INTERNAL
|
||||
? false
|
||||
: await vcsClient.hasUncommittedChangesAsync(),
|
||||
username: getUsername(ctx.exp, ctx.user),
|
||||
message: ctx.message,
|
||||
...(ctx.platform === Platform.IOS && {
|
||||
iosEnterpriseProvisioning: resolveIosEnterpriseProvisioning(
|
||||
ctx as BuildContext<Platform.IOS>
|
||||
),
|
||||
}),
|
||||
runWithNoWaitFlag: ctx.noWait,
|
||||
runFromCI: ctx.runFromCI,
|
||||
buildMode: ctx.buildProfile.config ? BuildMode.CUSTOM : BuildMode.BUILD,
|
||||
customWorkflowName: ctx.customBuildConfigMetadata?.workflowName,
|
||||
};
|
||||
return sanitizeMetadata(metadata);
|
||||
}
|
||||
@@ -60,14 +72,32 @@ async function maybeResolveVersionsAsync<T extends Platform>(
|
||||
): Promise<{ appBuildVersion?: string; appVersion?: string }> {
|
||||
if (ctx.platform === Platform.IOS) {
|
||||
const iosContext = ctx as BuildContext<Platform.IOS>;
|
||||
return await maybeResolveIosVersionsAsync(
|
||||
const resolvedVersion = await maybeResolveIosVersionsAsync(
|
||||
ctx.projectDir,
|
||||
ctx.exp,
|
||||
iosContext.ios.applicationTargetBuildSettings
|
||||
iosContext.ios.targets
|
||||
);
|
||||
if (iosContext.ios.buildNumberOverride) {
|
||||
return {
|
||||
...resolvedVersion,
|
||||
appBuildVersion: iosContext.ios.buildNumberOverride,
|
||||
};
|
||||
}
|
||||
return resolvedVersion;
|
||||
} else if (ctx.platform === Platform.ANDROID) {
|
||||
const androidCtx = ctx as BuildContext<Platform.ANDROID>;
|
||||
return await maybeResolveAndroidVersionsAsync(ctx.projectDir, ctx.exp, androidCtx.buildProfile);
|
||||
const resolvedVersion = await maybeResolveAndroidVersionsAsync(
|
||||
ctx.projectDir,
|
||||
ctx.exp,
|
||||
androidCtx.buildProfile
|
||||
);
|
||||
if (androidCtx.android.versionCodeOverride) {
|
||||
return {
|
||||
...resolvedVersion,
|
||||
appBuildVersion: androidCtx.android.versionCodeOverride,
|
||||
};
|
||||
}
|
||||
return resolvedVersion;
|
||||
} else {
|
||||
throw new Error(`Unsupported platform ${ctx.platform}`);
|
||||
}
|
||||
@@ -131,11 +161,9 @@ async function getNativeChannelAsync<T extends Platform>(
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async function getReactNativeVersionAsync(
|
||||
ctx: BuildContext<Platform>
|
||||
): Promise<string | undefined> {
|
||||
export async function getReactNativeVersionAsync(projectDir: string): Promise<string | undefined> {
|
||||
try {
|
||||
const reactNativePackageJsonPath = resolveFrom(ctx.projectDir, 'react-native/package.json');
|
||||
const reactNativePackageJsonPath = resolveFrom(projectDir, 'react-native/package.json');
|
||||
return (await fs.readJson(reactNativePackageJsonPath)).version;
|
||||
} catch (err) {
|
||||
Log.debug('Failed to resolve react-native version:');
|
||||
@@ -149,3 +177,13 @@ function resolveIosEnterpriseProvisioning(
|
||||
): IosEnterpriseProvisioning | undefined {
|
||||
return ctx.buildProfile.enterpriseProvisioning;
|
||||
}
|
||||
|
||||
export function truncateGitCommitMessage(
|
||||
msg: string | undefined,
|
||||
maxLength: number = 4096
|
||||
): string | undefined {
|
||||
if (msg === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
return msg.length > maxLength ? `${msg.substring(0, maxLength - 3)}...` : msg;
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
import chalk from 'chalk';
|
||||
|
||||
import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { PaginatedQueryOptions } from '../commandUtils/pagination';
|
||||
import { AppPlatform, BuildFilter, BuildFragment } from '../graphql/generated';
|
||||
import { BuildQuery } from '../graphql/queries/BuildQuery';
|
||||
import Log from '../log';
|
||||
import { fromNow } from '../utils/date';
|
||||
import { printJsonOnlyOutput } from '../utils/json';
|
||||
import {
|
||||
paginatedQueryWithConfirmPromptAsync,
|
||||
paginatedQueryWithSelectPromptAsync,
|
||||
} from '../utils/queries';
|
||||
import { formatGraphQLBuild } from './utils/formatBuild';
|
||||
|
||||
export const BUILDS_LIMIT = 50;
|
||||
|
||||
export async function listAndRenderBuildsOnAppAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
{
|
||||
projectId,
|
||||
projectDisplayName,
|
||||
filter,
|
||||
paginatedQueryOptions,
|
||||
}: {
|
||||
projectId: string;
|
||||
projectDisplayName: string;
|
||||
filter?: BuildFilter;
|
||||
paginatedQueryOptions: PaginatedQueryOptions;
|
||||
}
|
||||
): Promise<void> {
|
||||
if (paginatedQueryOptions.nonInteractive) {
|
||||
const builds = await BuildQuery.viewBuildsOnAppAsync(graphqlClient, {
|
||||
appId: projectId,
|
||||
limit: paginatedQueryOptions.limit ?? BUILDS_LIMIT,
|
||||
offset: paginatedQueryOptions.offset,
|
||||
filter,
|
||||
});
|
||||
renderPageOfBuilds({ builds, projectDisplayName, paginatedQueryOptions });
|
||||
} else {
|
||||
await paginatedQueryWithConfirmPromptAsync({
|
||||
limit: paginatedQueryOptions.limit ?? BUILDS_LIMIT,
|
||||
offset: paginatedQueryOptions.offset,
|
||||
queryToPerform: (limit, offset) =>
|
||||
BuildQuery.viewBuildsOnAppAsync(graphqlClient, {
|
||||
appId: projectId,
|
||||
limit,
|
||||
offset,
|
||||
filter,
|
||||
}),
|
||||
promptOptions: {
|
||||
title: 'Load more builds?',
|
||||
renderListItems: builds =>
|
||||
renderPageOfBuilds({ builds, projectDisplayName, paginatedQueryOptions }),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function listAndSelectBuildOnAppAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
{
|
||||
projectId,
|
||||
title,
|
||||
filter,
|
||||
paginatedQueryOptions,
|
||||
selectPromptDisabledFunction,
|
||||
selectPromptWarningMessage,
|
||||
}: {
|
||||
projectId: string;
|
||||
title: string;
|
||||
filter?: BuildFilter;
|
||||
paginatedQueryOptions: PaginatedQueryOptions;
|
||||
selectPromptDisabledFunction?: (build: BuildFragment) => boolean;
|
||||
selectPromptWarningMessage?: string;
|
||||
}
|
||||
): Promise<BuildFragment | null> {
|
||||
if (paginatedQueryOptions.nonInteractive) {
|
||||
throw new Error('Unable to select a build in non-interactive mode.');
|
||||
} else {
|
||||
const selectedBuild = await paginatedQueryWithSelectPromptAsync({
|
||||
limit: paginatedQueryOptions.limit ?? BUILDS_LIMIT,
|
||||
offset: paginatedQueryOptions.offset,
|
||||
queryToPerform: (limit, offset) =>
|
||||
BuildQuery.viewBuildsOnAppAsync(graphqlClient, {
|
||||
appId: projectId,
|
||||
limit,
|
||||
offset,
|
||||
filter,
|
||||
}),
|
||||
promptOptions: {
|
||||
title,
|
||||
getIdentifierForQueryItem: build => build.id,
|
||||
makePartialChoiceObject: createBuildToPartialChoiceMaker(selectPromptDisabledFunction),
|
||||
selectPromptWarningMessage,
|
||||
},
|
||||
});
|
||||
return selectedBuild ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
function createBuildToPartialChoiceMaker(
|
||||
selectPromptDisabledFunction?: (build: BuildFragment) => boolean
|
||||
) {
|
||||
return (
|
||||
build: BuildFragment
|
||||
): {
|
||||
title: string;
|
||||
description: string;
|
||||
disabled?: boolean;
|
||||
} => {
|
||||
const splitCommitMessage = build.gitCommitMessage?.split('\n');
|
||||
const formattedCommitData =
|
||||
build.gitCommitHash && splitCommitMessage && splitCommitMessage.length > 0
|
||||
? `${build.gitCommitHash.slice(0, 7)} "${chalk.bold(
|
||||
splitCommitMessage[0] + (splitCommitMessage.length > 1 ? '…' : '')
|
||||
)}"`
|
||||
: null;
|
||||
|
||||
const descriptionItems: { name: string; value: string | null }[] = [
|
||||
{ name: 'Version', value: build.appVersion ? chalk.bold(build.appVersion) : null },
|
||||
{ name: 'Commit', value: formattedCommitData },
|
||||
{
|
||||
name: 'Message',
|
||||
value: build.message
|
||||
? chalk.bold(
|
||||
build.message.length > 200 ? `${build.message.slice(0, 200)}...` : build.message
|
||||
)
|
||||
: null,
|
||||
},
|
||||
{
|
||||
name: build.platform === AppPlatform.Ios ? 'Build number' : 'Version code',
|
||||
value: build.appBuildVersion ? chalk.bold(build.appBuildVersion) : null,
|
||||
},
|
||||
{ name: 'Profile', value: build.buildProfile ? chalk.bold(build.buildProfile) : null },
|
||||
];
|
||||
|
||||
const filteredDescriptionArray: string[] = descriptionItems
|
||||
.filter(item => item.value)
|
||||
.map(item => `${chalk.bold(item.name)}: ${item.value}`);
|
||||
|
||||
return {
|
||||
title: `${chalk.bold(`ID:`)} ${build.id} (${chalk.bold(
|
||||
`${fromNow(new Date(build.completedAt))} ago`
|
||||
)})`,
|
||||
description: filteredDescriptionArray.length > 0 ? filteredDescriptionArray.join('\n') : '',
|
||||
disabled: selectPromptDisabledFunction?.(build),
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export async function getLatestBuildAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
{
|
||||
projectId,
|
||||
filter,
|
||||
}: {
|
||||
projectId: string;
|
||||
filter?: BuildFilter;
|
||||
}
|
||||
): Promise<BuildFragment | null> {
|
||||
const builds = await BuildQuery.viewBuildsOnAppAsync(graphqlClient, {
|
||||
appId: projectId,
|
||||
limit: 1,
|
||||
offset: 0,
|
||||
filter,
|
||||
});
|
||||
|
||||
if (builds.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return builds[0];
|
||||
}
|
||||
|
||||
function renderPageOfBuilds({
|
||||
builds,
|
||||
projectDisplayName,
|
||||
paginatedQueryOptions,
|
||||
}: {
|
||||
builds: BuildFragment[];
|
||||
projectDisplayName: string;
|
||||
paginatedQueryOptions: PaginatedQueryOptions;
|
||||
}): void {
|
||||
if (paginatedQueryOptions.json) {
|
||||
printJsonOnlyOutput(builds);
|
||||
} else {
|
||||
const list = builds.map(build => formatGraphQLBuild(build)).join(`\n\n${chalk.dim('———')}\n\n`);
|
||||
|
||||
Log.addNewLineIfNone();
|
||||
Log.log(chalk.bold(`Builds for ${projectDisplayName}:`));
|
||||
Log.log(`\n${list}`);
|
||||
}
|
||||
}
|
||||
@@ -1,30 +1,71 @@
|
||||
import { Platform, Workflow } from '@expo/eas-build-job';
|
||||
import { BuildProfile, SubmitProfile } from '@expo/eas-json';
|
||||
import {
|
||||
AppVersionSource,
|
||||
BuildProfile,
|
||||
EasJson,
|
||||
EasJsonAccessor,
|
||||
EasJsonUtils,
|
||||
ResourceClass,
|
||||
SubmitProfile,
|
||||
} from '@expo/eas-json';
|
||||
import assert from 'assert';
|
||||
import chalk from 'chalk';
|
||||
import nullthrows from 'nullthrows';
|
||||
|
||||
import { AppPlatform, BuildFragment, BuildStatus, SubmissionFragment } from '../graphql/generated';
|
||||
import { Analytics } from '../analytics/AnalyticsManager';
|
||||
import { DynamicConfigContextFn } from '../commandUtils/context/DynamicProjectConfigContextField';
|
||||
import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import {
|
||||
AppPlatform,
|
||||
BuildFragment,
|
||||
BuildStatus,
|
||||
BuildWithSubmissionsFragment,
|
||||
SubmissionFragment,
|
||||
} from '../graphql/generated';
|
||||
import { BuildQuery } from '../graphql/queries/BuildQuery';
|
||||
import { toAppPlatform, toPlatform } from '../graphql/types/AppPlatform';
|
||||
import Log from '../log';
|
||||
import Log, { learnMore } from '../log';
|
||||
import {
|
||||
RequestedPlatform,
|
||||
appPlatformDisplayNames,
|
||||
appPlatformEmojis,
|
||||
toPlatforms,
|
||||
} from '../platform';
|
||||
import {
|
||||
CustomBuildConfigMetadata,
|
||||
validateCustomBuildConfigAsync,
|
||||
} from '../project/customBuildConfig';
|
||||
import { checkExpoSdkIsSupportedAsync } from '../project/expoSdk';
|
||||
import { validateMetroConfigForManagedWorkflowAsync } from '../project/metroConfig';
|
||||
import {
|
||||
installExpoUpdatesAsync,
|
||||
isExpoUpdatesInstalledAsDevDependency,
|
||||
isExpoUpdatesInstalledOrAvailable,
|
||||
validateAppVersionRuntimePolicySupportAsync,
|
||||
} from '../project/projectUtils';
|
||||
import {
|
||||
validateAppConfigForRemoteVersionSource,
|
||||
validateBuildProfileVersionSettings,
|
||||
} from '../project/remoteVersionSource';
|
||||
import { confirmAsync } from '../prompts';
|
||||
import { runAsync } from '../run/run';
|
||||
import { isRunnableOnSimulatorOrEmulator } from '../run/utils';
|
||||
import { createSubmissionContextAsync } from '../submit/context';
|
||||
import {
|
||||
exitWithNonZeroCodeIfSomeSubmissionsDidntFinish,
|
||||
submitAsync,
|
||||
waitToCompleteAsync as waitForSubmissionsToCompleteAsync,
|
||||
} from '../submit/submit';
|
||||
import { printSubmissionDetailsUrls } from '../submit/utils/urls';
|
||||
import { validateBuildProfileConfigMatchesProjectConfigAsync } from '../update/utils';
|
||||
import { Actor } from '../user/User';
|
||||
import { downloadAndMaybeExtractAppAsync } from '../utils/download';
|
||||
import { truthy } from '../utils/expodash/filter';
|
||||
import { printJsonOnlyOutput } from '../utils/json';
|
||||
import { ProfileData, getProfilesAsync } from '../utils/profiles';
|
||||
import { getVcsClient } from '../vcs';
|
||||
import { prepareAndroidBuildAsync } from './android/build';
|
||||
import { BuildRequestSender, waitForBuildEndAsync } from './build';
|
||||
import { BuildRequestSender, MaybeBuildFragment, waitForBuildEndAsync } from './build';
|
||||
import { ensureProjectConfiguredAsync } from './configure';
|
||||
import { BuildContext } from './context';
|
||||
import { createBuildContextAsync } from './createContext';
|
||||
@@ -47,9 +88,18 @@ export interface BuildFlags {
|
||||
autoSubmit: boolean;
|
||||
submitProfile?: string;
|
||||
localBuildOptions: LocalBuildOptions;
|
||||
resourceClass?: ResourceClass;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export async function runBuildAndSubmitAsync(projectDir: string, flags: BuildFlags): Promise<void> {
|
||||
export async function runBuildAndSubmitAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
analytics: Analytics,
|
||||
projectDir: string,
|
||||
flags: BuildFlags,
|
||||
actor: Actor,
|
||||
getDynamicPrivateProjectConfigAsync: DynamicConfigContextFn
|
||||
): Promise<void> {
|
||||
await getVcsClient().ensureRepoExistsAsync();
|
||||
await ensureRepoIsCleanAsync(flags.nonInteractive);
|
||||
|
||||
@@ -57,14 +107,24 @@ export async function runBuildAndSubmitAsync(projectDir: string, flags: BuildFla
|
||||
projectDir,
|
||||
nonInteractive: flags.nonInteractive,
|
||||
});
|
||||
const easJsonAccessor = EasJsonAccessor.fromProjectPath(projectDir);
|
||||
const easJsonCliConfig: EasJson['cli'] =
|
||||
(await EasJsonUtils.getCliConfigAsync(easJsonAccessor)) ?? {};
|
||||
|
||||
const platforms = toPlatforms(flags.requestedPlatform);
|
||||
const buildProfiles = await getProfilesAsync({
|
||||
type: 'build',
|
||||
projectDir,
|
||||
easJsonAccessor,
|
||||
platforms,
|
||||
profileName: flags.profile ?? undefined,
|
||||
});
|
||||
Log.log(
|
||||
`Loaded "env" configuration for the "${buildProfiles[0].profileName}" profile: ${
|
||||
buildProfiles[0].profile.env
|
||||
? Object.keys(buildProfiles[0].profile.env).join(', ')
|
||||
: 'no environment variables specified'
|
||||
}. ${learnMore('https://docs.expo.dev/build-reference/variables/')}`
|
||||
);
|
||||
|
||||
await ensureExpoDevClientInstalledForDevClientBuildsAsync({
|
||||
projectDir,
|
||||
@@ -72,26 +132,43 @@ export async function runBuildAndSubmitAsync(projectDir: string, flags: BuildFla
|
||||
buildProfiles,
|
||||
});
|
||||
|
||||
const customBuildConfigMetadataByPlatform: { [p in AppPlatform]?: CustomBuildConfigMetadata } =
|
||||
{};
|
||||
for (const buildProfile of buildProfiles) {
|
||||
validateBuildProfileVersionSettings(buildProfile, easJsonCliConfig);
|
||||
const maybeMetadata = await validateCustomBuildConfigAsync(projectDir, buildProfile.profile);
|
||||
if (maybeMetadata) {
|
||||
customBuildConfigMetadataByPlatform[toAppPlatform(buildProfile.platform)] = maybeMetadata;
|
||||
}
|
||||
}
|
||||
|
||||
const startedBuilds: {
|
||||
build: BuildFragment;
|
||||
build: BuildWithSubmissionsFragment | BuildFragment;
|
||||
buildProfile: ProfileData<'build'>;
|
||||
}[] = [];
|
||||
const buildCtxByPlatform: { [p in AppPlatform]?: BuildContext<Platform> } = {};
|
||||
|
||||
for (const buildProfile of buildProfiles) {
|
||||
const platform = toAppPlatform(buildProfile.platform);
|
||||
const { build: maybeBuild, buildCtx } = await prepareAndStartBuildAsync({
|
||||
projectDir,
|
||||
flags,
|
||||
moreBuilds: platforms.length > 1,
|
||||
buildProfile,
|
||||
easJsonCliConfig,
|
||||
actor,
|
||||
graphqlClient,
|
||||
analytics,
|
||||
getDynamicPrivateProjectConfigAsync,
|
||||
customBuildConfigMetadata: customBuildConfigMetadataByPlatform[platform],
|
||||
});
|
||||
if (maybeBuild) {
|
||||
startedBuilds.push({ build: maybeBuild, buildProfile });
|
||||
}
|
||||
buildCtxByPlatform[toAppPlatform(buildProfile.platform)] = buildCtx;
|
||||
buildCtxByPlatform[platform] = buildCtx;
|
||||
}
|
||||
|
||||
if (flags.localBuildOptions.enable) {
|
||||
if (flags.localBuildOptions.localBuildMode) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -102,7 +179,7 @@ export async function runBuildAndSubmitAsync(projectDir: string, flags: BuildFla
|
||||
const submissions: SubmissionFragment[] = [];
|
||||
if (flags.autoSubmit) {
|
||||
const submitProfiles = await getProfilesAsync({
|
||||
projectDir,
|
||||
easJsonAccessor,
|
||||
platforms,
|
||||
profileName: flags.submitProfile,
|
||||
type: 'submit',
|
||||
@@ -122,6 +199,10 @@ export async function runBuildAndSubmitAsync(projectDir: string, flags: BuildFla
|
||||
submitProfile,
|
||||
nonInteractive: flags.nonInteractive,
|
||||
});
|
||||
startedBuild.build = await BuildQuery.withSubmissionsByIdAsync(
|
||||
graphqlClient,
|
||||
startedBuild.build.id
|
||||
);
|
||||
submissions.push(submission);
|
||||
}
|
||||
|
||||
@@ -138,20 +219,44 @@ export async function runBuildAndSubmitAsync(projectDir: string, flags: BuildFla
|
||||
}
|
||||
|
||||
const { accountName } = Object.values(buildCtxByPlatform)[0];
|
||||
const builds = await waitForBuildEndAsync({
|
||||
const builds = await waitForBuildEndAsync(graphqlClient, {
|
||||
buildIds: startedBuilds.map(({ build }) => build.id),
|
||||
accountName,
|
||||
projectDir,
|
||||
nonInteractive: flags.nonInteractive,
|
||||
});
|
||||
printBuildResults(builds, flags.json);
|
||||
if (!flags.json) {
|
||||
printBuildResults(builds);
|
||||
}
|
||||
|
||||
const haveAllBuildsFailedOrCanceled = builds.every(
|
||||
build => build?.status && [BuildStatus.Errored, BuildStatus.Canceled].includes(build?.status)
|
||||
build =>
|
||||
build?.status &&
|
||||
[BuildStatus.Errored, BuildStatus.Canceled, BuildStatus.PendingCancel].includes(build?.status)
|
||||
);
|
||||
|
||||
await maybeDownloadAndRunSimulatorBuildsAsync(builds, flags);
|
||||
|
||||
if (haveAllBuildsFailedOrCanceled || !flags.autoSubmit) {
|
||||
if (flags.json) {
|
||||
printJsonOnlyOutput(builds);
|
||||
}
|
||||
exitWithNonZeroCodeIfSomeBuildsFailed(builds);
|
||||
} else {
|
||||
// the following function also exits with non zero code if any of the submissions failed
|
||||
await waitForSubmissionsToCompleteAsync(submissions);
|
||||
const completedSubmissions = await waitForSubmissionsToCompleteAsync(
|
||||
graphqlClient,
|
||||
submissions
|
||||
);
|
||||
if (flags.json) {
|
||||
printJsonOnlyOutput(
|
||||
await Promise.all(
|
||||
builds
|
||||
.filter((i): i is BuildWithSubmissionsFragment => !!i)
|
||||
.map(build => BuildQuery.withSubmissionsByIdAsync(graphqlClient, build.id))
|
||||
)
|
||||
);
|
||||
}
|
||||
exitWithNonZeroCodeIfSomeSubmissionsDidntFinish(completedSubmissions);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -160,20 +265,41 @@ async function prepareAndStartBuildAsync({
|
||||
flags,
|
||||
moreBuilds,
|
||||
buildProfile,
|
||||
easJsonCliConfig,
|
||||
actor,
|
||||
graphqlClient,
|
||||
analytics,
|
||||
getDynamicPrivateProjectConfigAsync,
|
||||
customBuildConfigMetadata,
|
||||
}: {
|
||||
projectDir: string;
|
||||
flags: BuildFlags;
|
||||
moreBuilds: boolean;
|
||||
buildProfile: ProfileData<'build'>;
|
||||
easJsonCliConfig: EasJson['cli'];
|
||||
actor: Actor;
|
||||
graphqlClient: ExpoGraphqlClient;
|
||||
analytics: Analytics;
|
||||
getDynamicPrivateProjectConfigAsync: DynamicConfigContextFn;
|
||||
customBuildConfigMetadata?: CustomBuildConfigMetadata;
|
||||
}): Promise<{ build: BuildFragment | undefined; buildCtx: BuildContext<Platform> }> {
|
||||
const buildCtx = await createBuildContextAsync({
|
||||
buildProfileName: buildProfile.profileName,
|
||||
resourceClassFlag: flags.resourceClass,
|
||||
clearCache: flags.clearCache,
|
||||
buildProfile: buildProfile.profile,
|
||||
nonInteractive: flags.nonInteractive,
|
||||
noWait: !flags.wait,
|
||||
platform: buildProfile.platform,
|
||||
projectDir,
|
||||
localBuildOptions: flags.localBuildOptions,
|
||||
easJsonCliConfig,
|
||||
message: flags.message,
|
||||
actor,
|
||||
graphqlClient,
|
||||
analytics,
|
||||
getDynamicPrivateProjectConfigAsync,
|
||||
customBuildConfigMetadata,
|
||||
});
|
||||
|
||||
if (moreBuilds) {
|
||||
@@ -186,6 +312,25 @@ async function prepareAndStartBuildAsync({
|
||||
);
|
||||
}
|
||||
|
||||
await validateBuildProfileConfigMatchesProjectConfigAsync(
|
||||
buildCtx.exp,
|
||||
buildProfile,
|
||||
buildCtx.projectId,
|
||||
flags.nonInteractive
|
||||
);
|
||||
if (buildProfile.profile.channel) {
|
||||
await validateExpoUpdatesInstalledAsProjectDependencyAsync({
|
||||
projectDir,
|
||||
sdkVersion: buildCtx.exp.sdkVersion,
|
||||
nonInteractive: flags.nonInteractive,
|
||||
buildProfile,
|
||||
});
|
||||
}
|
||||
|
||||
await validateAppVersionRuntimePolicySupportAsync(buildCtx.projectDir, buildCtx.exp);
|
||||
if (easJsonCliConfig?.appVersionSource === AppVersionSource.REMOTE) {
|
||||
validateAppConfigForRemoteVersionSource(buildCtx.exp, buildProfile.platform);
|
||||
}
|
||||
if (buildCtx.workflow === Workflow.MANAGED) {
|
||||
if (!sdkVersionChecked) {
|
||||
await checkExpoSdkIsSupportedAsync(buildCtx);
|
||||
@@ -235,13 +380,17 @@ async function prepareAndStartSubmissionAsync({
|
||||
const submissionCtx = await createSubmissionContextAsync({
|
||||
platform,
|
||||
projectDir,
|
||||
projectId: build.project.id,
|
||||
profile: submitProfile,
|
||||
archiveFlags: { id: build.id },
|
||||
nonInteractive,
|
||||
env: buildProfile.env,
|
||||
credentialsCtx: buildCtx.credentialsCtx,
|
||||
applicationIdentifier: buildCtx.android?.applicationId ?? buildCtx.ios?.bundleIdentifier,
|
||||
actor: buildCtx.user,
|
||||
graphqlClient: buildCtx.graphqlClient,
|
||||
analytics: buildCtx.analytics,
|
||||
projectId: buildCtx.projectId,
|
||||
exp: buildCtx.exp,
|
||||
});
|
||||
|
||||
if (moreBuilds) {
|
||||
@@ -264,3 +413,72 @@ function exitWithNonZeroCodeIfSomeBuildsFailed(maybeBuilds: (BuildFragment | nul
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadAndRunAsync(build: BuildFragment): Promise<void> {
|
||||
assert(build.artifacts?.applicationArchiveUrl);
|
||||
const buildPath = await downloadAndMaybeExtractAppAsync(
|
||||
build.artifacts.applicationArchiveUrl,
|
||||
build.platform
|
||||
);
|
||||
await runAsync(buildPath, build.platform);
|
||||
}
|
||||
|
||||
async function maybeDownloadAndRunSimulatorBuildsAsync(
|
||||
builds: MaybeBuildFragment[],
|
||||
flags: BuildFlags
|
||||
): Promise<void> {
|
||||
const simBuilds = builds.filter(truthy).filter(isRunnableOnSimulatorOrEmulator);
|
||||
|
||||
if (simBuilds.length > 0 && !flags.autoSubmit && !flags.nonInteractive) {
|
||||
for (const simBuild of simBuilds) {
|
||||
if (simBuild.platform === AppPlatform.Android || process.platform === 'darwin') {
|
||||
Log.newLine();
|
||||
const confirm = await confirmAsync({
|
||||
message: `Install and run the ${
|
||||
simBuild.platform === AppPlatform.Android ? 'Android' : 'iOS'
|
||||
} build on ${simBuild.platform === AppPlatform.Android ? 'an emulator' : 'a simulator'}?`,
|
||||
});
|
||||
if (confirm) {
|
||||
await downloadAndRunAsync(simBuild);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function validateExpoUpdatesInstalledAsProjectDependencyAsync({
|
||||
projectDir,
|
||||
buildProfile,
|
||||
nonInteractive,
|
||||
sdkVersion,
|
||||
}: {
|
||||
projectDir: string;
|
||||
buildProfile: ProfileData<'build'>;
|
||||
nonInteractive: boolean;
|
||||
sdkVersion?: string;
|
||||
}): Promise<void> {
|
||||
if (isExpoUpdatesInstalledOrAvailable(projectDir, sdkVersion)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isExpoUpdatesInstalledAsDevDependency(projectDir)) {
|
||||
Log.warn(
|
||||
`The build profile "${buildProfile.profileName}" uses the channel "${buildProfile.profile.channel}", but you've added "expo-updates" as a dev dependency. To make channels work for your builds, move "expo-updates" from dev dependencies to the main dependencies in your project.`
|
||||
);
|
||||
} else if (nonInteractive) {
|
||||
Log.warn(
|
||||
`The build profile "${buildProfile.profileName}" has specified the channel "${buildProfile.profile.channel}", but the "expo-updates" package hasn't been installed. To use channels for your builds, install the "expo-updates" package by running "npx expo install expo-updates".`
|
||||
);
|
||||
} else {
|
||||
Log.warn(
|
||||
`The build profile "${buildProfile.profileName}" specifies the channel "${buildProfile.profile.channel}", but the "expo-updates" package is missing. To use channels in your builds, install the "expo-updates" package.`
|
||||
);
|
||||
const installExpoUpdates = await confirmAsync({
|
||||
message: `Would you like to install the "expo-updates" package?`,
|
||||
});
|
||||
if (installExpoUpdates) {
|
||||
await installExpoUpdatesAsync(projectDir, { silent: false });
|
||||
Log.withTick('Installed expo-updates');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ export enum BuildStatus {
|
||||
NEW = 'new',
|
||||
IN_QUEUE = 'in-queue',
|
||||
IN_PROGRESS = 'in-progress',
|
||||
PENDING_CANCEL = 'pending-cancel',
|
||||
ERRORED = 'errored',
|
||||
FINISHED = 'finished',
|
||||
CANCELED = 'canceled',
|
||||
|
||||
@@ -27,6 +27,7 @@ export function formatGraphQLBuild(build: BuildFragment): string {
|
||||
return chalk.blue('in queue');
|
||||
case GraphQLBuildStatus.InProgress:
|
||||
return chalk.blue('in progress');
|
||||
case GraphQLBuildStatus.PendingCancel:
|
||||
case GraphQLBuildStatus.Canceled:
|
||||
return chalk.gray('canceled');
|
||||
case GraphQLBuildStatus.Finished:
|
||||
@@ -38,6 +39,10 @@ export function formatGraphQLBuild(build: BuildFragment): string {
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Message',
|
||||
value: build.message,
|
||||
},
|
||||
{
|
||||
label: 'Distribution',
|
||||
value: build.distribution?.toLowerCase(),
|
||||
@@ -86,6 +91,7 @@ export function formatGraphQLBuild(build: BuildFragment): string {
|
||||
case GraphQLBuildStatus.InQueue:
|
||||
case GraphQLBuildStatus.InProgress:
|
||||
return '<in progress>';
|
||||
case GraphQLBuildStatus.PendingCancel:
|
||||
case GraphQLBuildStatus.Canceled:
|
||||
case GraphQLBuildStatus.Errored:
|
||||
return null;
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import assert from 'assert';
|
||||
import chalk from 'chalk';
|
||||
import indentString from 'indent-string';
|
||||
import qrcodeTerminal from 'qrcode-terminal';
|
||||
|
||||
@@ -12,44 +11,64 @@ import {
|
||||
EasBuildDeprecationInfo,
|
||||
EasBuildDeprecationInfoType,
|
||||
} from '../../graphql/generated';
|
||||
import Log, { learnMore } from '../../log';
|
||||
import Log, { learnMore, link } from '../../log';
|
||||
import { appPlatformDisplayNames, appPlatformEmojis } from '../../platform';
|
||||
import { printJsonOnlyOutput } from '../../utils/json';
|
||||
import { getBuildLogsUrl, getInternalDistributionInstallUrl } from './url';
|
||||
|
||||
function terminalLinkFallback(url: string, text: string): string {
|
||||
return `${text} (${url})`;
|
||||
}
|
||||
|
||||
const errorCodeToErrorMessageOverride: Record<string, (build: BuildFragment) => string> = {
|
||||
EAS_BUILD_UNKNOWN_FASTLANE_ERROR: build =>
|
||||
`The ${link(getBuildLogsUrl(build, 'run-fastlane'), {
|
||||
text: '"Run fastlane"',
|
||||
fallback: terminalLinkFallback(getBuildLogsUrl(build, 'run-fastlane'), '"Run fastlane"'),
|
||||
})} step failed with an unknown error. Refer to the ${link(
|
||||
getBuildLogsUrl(build, 'xcode-logs'),
|
||||
{
|
||||
text: '"Xcode logs"',
|
||||
fallback: terminalLinkFallback(getBuildLogsUrl(build, 'xcode-logs'), '"Xcode logs"'),
|
||||
}
|
||||
)} phase for additional, more detailed logs`,
|
||||
EAS_BUILD_UNKNOWN_GRADLE_ERROR: build =>
|
||||
`Gradle build failed with unknown error. See logs for the ${link(
|
||||
getBuildLogsUrl(build, 'run-gradlew'),
|
||||
{
|
||||
text: '"Run gradlew"',
|
||||
fallback: terminalLinkFallback(getBuildLogsUrl(build, 'run-gradlew'), '"Run gradlew"'),
|
||||
}
|
||||
)} phase for more information.`,
|
||||
};
|
||||
|
||||
export function printLogsUrls(builds: BuildFragment[]): void {
|
||||
if (builds.length === 1) {
|
||||
Log.log(`Build details: ${chalk.underline(getBuildLogsUrl(builds[0]))}`);
|
||||
Log.log(`Build details: ${link(getBuildLogsUrl(builds[0]))}`);
|
||||
} else {
|
||||
builds.forEach(build => {
|
||||
const logsUrl = getBuildLogsUrl(build);
|
||||
Log.log(
|
||||
`${appPlatformEmojis[build.platform]} ${
|
||||
appPlatformDisplayNames[build.platform]
|
||||
} build details: ${chalk.underline(logsUrl)}`
|
||||
} build details: ${link(logsUrl)}`
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function printBuildResults(builds: (BuildFragment | null)[], json: boolean): void {
|
||||
if (json) {
|
||||
printJsonOnlyOutput(builds);
|
||||
export function printBuildResults(builds: (BuildFragment | null)[]): void {
|
||||
if (builds.length === 1) {
|
||||
const [build] = builds;
|
||||
assert(build, 'Build should be defined');
|
||||
printBuildResult(build);
|
||||
} else {
|
||||
Log.newLine();
|
||||
if (builds.length === 1) {
|
||||
const [build] = builds;
|
||||
assert(build, 'Build should be defined');
|
||||
printBuildResult(build);
|
||||
} else {
|
||||
(builds.filter(i => i) as BuildFragment[]).forEach(build => printBuildResult(build));
|
||||
}
|
||||
(builds.filter(i => i) as BuildFragment[]).forEach(build => printBuildResult(build));
|
||||
}
|
||||
}
|
||||
|
||||
function printBuildResult(build: BuildFragment): void {
|
||||
Log.addNewLineIfNone();
|
||||
if (build.status === BuildStatus.Errored) {
|
||||
Log.addNewLineIfNone();
|
||||
const userError = build.error;
|
||||
Log.error(
|
||||
`${appPlatformEmojis[build.platform]} ${
|
||||
@@ -57,11 +76,12 @@ function printBuildResult(build: BuildFragment): void {
|
||||
} build failed${userError ? ':' : ''}`
|
||||
);
|
||||
if (userError) {
|
||||
printUserError(userError);
|
||||
printUserError(userError, build);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (build.status === BuildStatus.Canceled) {
|
||||
if ([BuildStatus.Canceled, BuildStatus.PendingCancel].includes(build.status)) {
|
||||
Log.addNewLineIfNone();
|
||||
Log.error(
|
||||
`${appPlatformEmojis[build.platform]} ${
|
||||
appPlatformDisplayNames[build.platform]
|
||||
@@ -71,6 +91,7 @@ function printBuildResult(build: BuildFragment): void {
|
||||
}
|
||||
|
||||
if (build.distribution === DistributionType.Internal) {
|
||||
Log.addNewLineIfNone();
|
||||
const logsUrl = getBuildLogsUrl(build);
|
||||
// It's tricky to install the .apk file directly on Android so let's fallback
|
||||
// to the build details page and let people press the button to download there
|
||||
@@ -84,13 +105,18 @@ function printBuildResult(build: BuildFragment): void {
|
||||
appPlatformDisplayNames[build.platform]
|
||||
} devices (or scan the QR code) to install the app:`
|
||||
);
|
||||
Log.log(`${chalk.underline(logsUrl)}`);
|
||||
Log.log(`${link(logsUrl)}`);
|
||||
} else {
|
||||
// TODO: it looks like buildUrl could possibly be undefined, based on the code below.
|
||||
// we should account for this case better if it is possible
|
||||
const url = build.artifacts?.buildUrl ?? '';
|
||||
Log.log(`${appPlatformEmojis[build.platform]} ${appPlatformDisplayNames[build.platform]} app:`);
|
||||
Log.log(`${chalk.underline(url)}`);
|
||||
const url = build.artifacts?.buildUrl;
|
||||
if (url) {
|
||||
Log.addNewLineIfNone();
|
||||
Log.log(
|
||||
`${appPlatformEmojis[build.platform]} ${appPlatformDisplayNames[build.platform]} app:`
|
||||
);
|
||||
Log.log(link(url));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,21 +125,34 @@ export function printDeprecationWarnings(deprecationInfo?: EasBuildDeprecationIn
|
||||
return;
|
||||
}
|
||||
if (deprecationInfo.type === EasBuildDeprecationInfoType.Internal) {
|
||||
Log.warn('This command is using API that soon will be deprecated, please update eas-cli.');
|
||||
Log.warn('This command is using API that soon will be deprecated. Upgrade EAS CLI.');
|
||||
Log.warn("Changes won't affect your project config.");
|
||||
Log.warn(deprecationInfo.message);
|
||||
} else if (deprecationInfo.type === EasBuildDeprecationInfoType.UserFacing) {
|
||||
Log.warn('This command is using API that soon will be deprecated.');
|
||||
Log.warn(deprecationInfo.message);
|
||||
} else {
|
||||
Log.warn('An unexpected warning was encountered. Please report it as a bug:');
|
||||
Log.warn('An unexpected warning was encountered. Report it as a bug:');
|
||||
Log.warn(deprecationInfo);
|
||||
}
|
||||
}
|
||||
|
||||
export function printUserError(error: BuildError): void {
|
||||
Log.error(error.message);
|
||||
if (error.docsUrl) {
|
||||
Log.error(learnMore(error.docsUrl, { dim: false }));
|
||||
export function printUserError(error: BuildError, build: BuildFragment): void {
|
||||
const maybeErrorMessageOverride = maybeGetErrorMessageOverride(error, build);
|
||||
if (maybeErrorMessageOverride) {
|
||||
Log.error(maybeErrorMessageOverride);
|
||||
} else {
|
||||
Log.error(error.message);
|
||||
if (error.docsUrl) {
|
||||
Log.error(learnMore(error.docsUrl, { dim: false }));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function maybeGetErrorMessageOverride(error: BuildError, build: BuildFragment): string | null {
|
||||
if (!(error.errorCode in errorCodeToErrorMessageOverride)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return errorCodeToErrorMessageOverride[error.errorCode](build);
|
||||
}
|
||||
@@ -28,7 +28,7 @@ export async function maybeBailOnRepoStatusAsync(): Promise<void> {
|
||||
});
|
||||
|
||||
if (!answer) {
|
||||
throw new Error('Please commit all changes. Aborting...');
|
||||
throw new Error('Commit all changes. Aborting...');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,12 +39,12 @@ export async function ensureRepoIsCleanAsync(nonInteractive = false): Promise<vo
|
||||
Log.addNewLineIfNone();
|
||||
Log.warn(`${chalk.bold('Warning!')} Your repository working tree is dirty.`);
|
||||
Log.log(
|
||||
`This operation needs to be run on a clean working tree, please ${chalk.bold(
|
||||
'commit all your changes before proceeding'
|
||||
`This operation needs to be run on a clean working tree. ${chalk.bold(
|
||||
'Commit all your changes before proceeding'
|
||||
)}.`
|
||||
);
|
||||
if (nonInteractive) {
|
||||
throw new Error('Please commit all changes. Aborting...');
|
||||
throw new Error('Commit all changes. Aborting...');
|
||||
}
|
||||
const answer = await confirmAsync({
|
||||
message: `Commit changes to git?`,
|
||||
@@ -52,7 +52,7 @@ export async function ensureRepoIsCleanAsync(nonInteractive = false): Promise<vo
|
||||
if (answer) {
|
||||
await commitPromptAsync({ commitAllFiles: true });
|
||||
} else {
|
||||
throw new Error('Please commit all changes. Aborting...');
|
||||
throw new Error('Commit all changes. Aborting...');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,7 +70,11 @@ export async function commitPromptAsync({
|
||||
initial: initialCommitMessage,
|
||||
validate: (input: string) => input !== '',
|
||||
});
|
||||
await getVcsClient().commitAsync({ commitAllFiles, commitMessage: message });
|
||||
await getVcsClient().commitAsync({
|
||||
commitAllFiles,
|
||||
commitMessage: message,
|
||||
nonInteractive: false,
|
||||
});
|
||||
}
|
||||
|
||||
export async function makeProjectTarballAsync(): Promise<{ path: string; size: number }> {
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import { Platform } from '@expo/eas-build-job';
|
||||
import { BuildProfile, ResourceClass } from '@expo/eas-json';
|
||||
import chalk from 'chalk';
|
||||
|
||||
import { BuildResourceClass } from '../../graphql/generated';
|
||||
import Log, { link } from '../../log';
|
||||
|
||||
type AndroidResourceClass = Exclude<
|
||||
ResourceClass,
|
||||
| ResourceClass.M1_EXPERIMENTAL
|
||||
| ResourceClass.M1_MEDIUM
|
||||
| ResourceClass.M1_LARGE
|
||||
| ResourceClass.INTEL_MEDIUM
|
||||
| ResourceClass.M_MEDIUM
|
||||
| ResourceClass.M_LARGE
|
||||
>;
|
||||
|
||||
const iosResourceClassToBuildResourceClassMapping: Record<ResourceClass, BuildResourceClass> = {
|
||||
[ResourceClass.DEFAULT]: BuildResourceClass.IosDefault,
|
||||
[ResourceClass.LARGE]: BuildResourceClass.IosLarge,
|
||||
[ResourceClass.M1_EXPERIMENTAL]: BuildResourceClass.IosMMedium,
|
||||
[ResourceClass.M1_MEDIUM]: BuildResourceClass.IosMMedium,
|
||||
[ResourceClass.M1_LARGE]: BuildResourceClass.IosMLarge,
|
||||
[ResourceClass.INTEL_MEDIUM]: BuildResourceClass.IosIntelMedium,
|
||||
[ResourceClass.MEDIUM]: BuildResourceClass.IosMedium,
|
||||
[ResourceClass.M_MEDIUM]: BuildResourceClass.IosMMedium,
|
||||
[ResourceClass.M_LARGE]: BuildResourceClass.IosMLarge,
|
||||
};
|
||||
|
||||
const androidResourceClassToBuildResourceClassMapping: Record<
|
||||
AndroidResourceClass,
|
||||
BuildResourceClass
|
||||
> = {
|
||||
[ResourceClass.DEFAULT]: BuildResourceClass.AndroidDefault,
|
||||
[ResourceClass.LARGE]: BuildResourceClass.AndroidLarge,
|
||||
[ResourceClass.MEDIUM]: BuildResourceClass.AndroidMedium,
|
||||
};
|
||||
|
||||
export async function resolveBuildResourceClassAsync<T extends Platform>(
|
||||
profile: BuildProfile<T>,
|
||||
platform: Platform,
|
||||
resourceClassFlag?: ResourceClass
|
||||
): Promise<BuildResourceClass> {
|
||||
const profileResourceClass = profile.resourceClass;
|
||||
|
||||
if (profileResourceClass && resourceClassFlag && resourceClassFlag !== profileResourceClass) {
|
||||
Log.warn(
|
||||
`Build profile specifies the "${profileResourceClass}" resource class but you passed "${resourceClassFlag}" to --resource-class.\nUsing "${resourceClassFlag}" as the override.`
|
||||
);
|
||||
}
|
||||
|
||||
const selectedResourceClass = resourceClassFlag ?? profileResourceClass;
|
||||
|
||||
return platform === Platform.IOS
|
||||
? resolveIosResourceClass(resourceClassFlag, profileResourceClass)
|
||||
: resolveAndroidResourceClass(selectedResourceClass);
|
||||
}
|
||||
|
||||
function resolveAndroidResourceClass(selectedResourceClass?: ResourceClass): BuildResourceClass {
|
||||
if (
|
||||
selectedResourceClass &&
|
||||
[
|
||||
ResourceClass.M1_EXPERIMENTAL,
|
||||
ResourceClass.M1_MEDIUM,
|
||||
ResourceClass.M1_LARGE,
|
||||
ResourceClass.INTEL_MEDIUM,
|
||||
].includes(selectedResourceClass)
|
||||
) {
|
||||
throw new Error(`Resource class ${selectedResourceClass} is only available for iOS builds`);
|
||||
}
|
||||
|
||||
const resourceClass = selectedResourceClass ?? ResourceClass.DEFAULT;
|
||||
|
||||
return androidResourceClassToBuildResourceClassMapping[resourceClass as AndroidResourceClass];
|
||||
}
|
||||
|
||||
function resolveIosResourceClass(
|
||||
resourceClassFlag?: ResourceClass,
|
||||
profileResourceClass?: ResourceClass
|
||||
): BuildResourceClass {
|
||||
const resourceClass = resourceClassFlag ?? profileResourceClass ?? ResourceClass.DEFAULT;
|
||||
|
||||
if (resourceClassFlag === ResourceClass.LARGE) {
|
||||
throw new Error(
|
||||
`Experimental "large" resource class for Intel iOS workers is no longer available. Remove the specified resource class to use the default, or learn more about all available resource classes: ${link(
|
||||
'https://docs.expo.dev/build-reference/eas-json/'
|
||||
)}`
|
||||
);
|
||||
}
|
||||
|
||||
if ([ResourceClass.M1_EXPERIMENTAL, ResourceClass.M1_MEDIUM].includes(resourceClass)) {
|
||||
Log.warn(
|
||||
`Resource class ${chalk.bold(resourceClass)} is deprecated. Use ${chalk.bold(
|
||||
'm-medium'
|
||||
)} instead.`
|
||||
);
|
||||
}
|
||||
|
||||
if ([ResourceClass.M_LARGE, ResourceClass.M1_LARGE].includes(resourceClass)) {
|
||||
Log.warn(
|
||||
`Resource class ${chalk.bold(resourceClass)} is deprecated. Use ${chalk.bold(
|
||||
'large'
|
||||
)} instead.`
|
||||
);
|
||||
}
|
||||
|
||||
return iosResourceClassToBuildResourceClassMapping[resourceClass];
|
||||
}
|
||||
@@ -10,12 +10,14 @@ export function getProjectDashboardUrl(accountName: string, projectName: string)
|
||||
).toString();
|
||||
}
|
||||
|
||||
export function getBuildLogsUrl(build: BuildFragment): string {
|
||||
export function getBuildLogsUrl(build: BuildFragment, hash?: string): string {
|
||||
const { project } = build;
|
||||
const url =
|
||||
project.__typename !== 'App'
|
||||
? `/builds/${build.id}`
|
||||
: `/accounts/${project.ownerAccount.name}/projects/${project.slug}/builds/${build.id}`;
|
||||
: `/accounts/${project.ownerAccount.name}/projects/${project.slug}/builds/${build.id}${
|
||||
hash ? `#${hash}` : ''
|
||||
}`;
|
||||
|
||||
return new URL(url, getExpoWebsiteBaseUrl()).toString();
|
||||
}
|
||||
@@ -35,3 +37,16 @@ export function getInternalDistributionInstallUrl(build: BuildFragment): string
|
||||
|
||||
return build.artifacts.buildUrl;
|
||||
}
|
||||
|
||||
export function getUpdateGroupUrl(
|
||||
accountName: string,
|
||||
projectName: string,
|
||||
updateGroupId: string
|
||||
): string {
|
||||
return new URL(
|
||||
`/accounts/${encodeURIComponent(accountName)}/projects/${encodeURIComponent(
|
||||
projectName
|
||||
)}/updates/${encodeURIComponent(updateGroupId)}`,
|
||||
getExpoWebsiteBaseUrl()
|
||||
).toString();
|
||||
}
|
||||
@@ -1,8 +1,11 @@
|
||||
import { Platform, Workflow } from '@expo/eas-build-job';
|
||||
import { Errors } from '@oclif/core';
|
||||
import fs from 'fs-extra';
|
||||
import path from 'path';
|
||||
import semver from 'semver';
|
||||
|
||||
import Log, { learnMore } from '../log';
|
||||
import { isPNGAsync } from '../utils/image';
|
||||
import { getVcsClient } from '../vcs';
|
||||
import { CommonContext } from './context';
|
||||
|
||||
@@ -50,3 +53,137 @@ export async function checkGoogleServicesFileAsync<T extends Platform>(
|
||||
function isInsideDirectory(file: string, directory: string): boolean {
|
||||
return file.startsWith(directory);
|
||||
}
|
||||
|
||||
export async function validatePNGsForManagedProjectAsync<T extends Platform>(
|
||||
ctx: CommonContext<T>
|
||||
): Promise<void> {
|
||||
if (ctx.workflow !== Workflow.MANAGED) {
|
||||
return;
|
||||
}
|
||||
|
||||
// don't run PNG checks on SDK 47 and newer
|
||||
// see https://github.com/expo/eas-cli/pull/1477#issuecomment-1293914917
|
||||
if (!ctx.exp.sdkVersion || semver.satisfies(ctx.exp.sdkVersion, '>= 47.0.0')) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (ctx.platform === Platform.ANDROID) {
|
||||
await validateAndroidPNGsAsync(ctx as CommonContext<Platform.ANDROID>);
|
||||
}
|
||||
// Validating iOS PNGs is currently disabled
|
||||
// See https://github.com/expo/eas-cli/pull/1477 for context
|
||||
//
|
||||
// else {
|
||||
// await validateIosPNGsAsync(ctx as CommonContext<Platform.IOS>);
|
||||
// }
|
||||
}
|
||||
|
||||
type ConfigPng = { configPath: string; pngPath: string | undefined };
|
||||
|
||||
async function validateAndroidPNGsAsync(ctx: CommonContext<Platform.ANDROID>): Promise<void> {
|
||||
const pngs: ConfigPng[] = [
|
||||
{
|
||||
configPath: 'exp.icon',
|
||||
pngPath: ctx.exp.icon,
|
||||
},
|
||||
{
|
||||
configPath: 'exp.android.icon',
|
||||
pngPath: ctx.exp.android?.icon,
|
||||
},
|
||||
{
|
||||
configPath: 'exp.android.adaptiveIcon.foregroundImage',
|
||||
pngPath: ctx.exp.android?.adaptiveIcon?.foregroundImage,
|
||||
},
|
||||
{
|
||||
configPath: 'exp.android.adaptiveIcon.backgroundImage',
|
||||
pngPath: ctx.exp.android?.adaptiveIcon?.backgroundImage,
|
||||
},
|
||||
{
|
||||
configPath: 'exp.splash.image',
|
||||
pngPath: ctx.exp.splash?.image,
|
||||
},
|
||||
{
|
||||
configPath: 'exp.notification.icon',
|
||||
pngPath: ctx.exp.notification?.icon,
|
||||
},
|
||||
];
|
||||
await validatePNGsAsync(pngs);
|
||||
}
|
||||
|
||||
// Validating iOS PNGs is currently disabled
|
||||
// See https://github.com/expo/eas-cli/pull/1477 for context
|
||||
//
|
||||
// async function validateIosPNGsAsync(ctx: CommonContext<Platform.IOS>): Promise<void> {
|
||||
// const pngs: ConfigPng[] = [
|
||||
// {
|
||||
// configPath: 'exp.icon',
|
||||
// pngPath: ctx.exp.icon,
|
||||
// },
|
||||
// {
|
||||
// configPath: 'exp.ios.icon',
|
||||
// pngPath: ctx.exp.ios?.icon,
|
||||
// },
|
||||
// {
|
||||
// configPath: 'exp.splash.image',
|
||||
// pngPath: ctx.exp.splash?.image,
|
||||
// },
|
||||
// {
|
||||
// configPath: 'exp.notification.icon',
|
||||
// pngPath: ctx.exp.notification?.icon,
|
||||
// },
|
||||
// ];
|
||||
// await validatePNGsAsync(pngs);
|
||||
|
||||
// const icon = ctx.exp.ios?.icon ?? ctx.exp.icon;
|
||||
// if (!icon) {
|
||||
// return;
|
||||
// }
|
||||
// const iconConfigPath = `expo${ctx.exp.ios?.icon ? '.ios' : ''}.icon`;
|
||||
|
||||
// try {
|
||||
// await ensurePNGIsNotTransparentAsync(icon);
|
||||
// } catch (err: any) {
|
||||
// if (err instanceof ImageTransparencyError) {
|
||||
// Log.error(
|
||||
// `Your iOS app icon (${iconConfigPath}) can't have transparency if you wish to upload your app to the Apple App Store.`
|
||||
// );
|
||||
// Log.error(learnMore('https://expo.fyi/remove-alpha-channel', { dim: false }));
|
||||
// Errors.exit(1);
|
||||
// } else {
|
||||
// throw err;
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
|
||||
async function validatePNGsAsync(configPngs: ConfigPng[]): Promise<void> {
|
||||
const validationPromises = configPngs.map(configPng => validatePNGAsync(configPng));
|
||||
const validationResults = await Promise.allSettled(validationPromises);
|
||||
const failedValidations = validationResults.filter(
|
||||
(result): result is PromiseRejectedResult => result.status === 'rejected'
|
||||
);
|
||||
|
||||
if (failedValidations.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
Log.error('PNG images validation failed:');
|
||||
for (const { reason } of failedValidations) {
|
||||
const error: Error = reason;
|
||||
Log.error(`- ${error.message}`);
|
||||
}
|
||||
Errors.exit(1);
|
||||
}
|
||||
|
||||
async function validatePNGAsync({ configPath, pngPath }: ConfigPng): Promise<void> {
|
||||
if (!pngPath) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!pngPath.endsWith('.png')) {
|
||||
throw new Error(`"${configPath}" is not a PNG file`);
|
||||
}
|
||||
|
||||
if (!(await isPNGAsync(pngPath))) {
|
||||
throw new Error(`"${configPath}" is not valid PNG`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export class ListChannels {
|
||||
constructor() {}
|
||||
|
||||
async runAsync(): Promise<void> {}
|
||||
|
||||
// TODO return channels fragment
|
||||
async listChannelsAsync(): Promise<void> {}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export class ChannelNotFoundError extends Error {
|
||||
constructor(message?: string) {
|
||||
super(message ?? 'Channel not found.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
import chalk from 'chalk';
|
||||
import gql from 'graphql-tag';
|
||||
|
||||
import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { PaginatedQueryOptions } from '../commandUtils/pagination';
|
||||
import { withErrorHandlingAsync } from '../graphql/client';
|
||||
import {
|
||||
CreateUpdateChannelOnAppMutation,
|
||||
CreateUpdateChannelOnAppMutationVariables,
|
||||
ViewBranchesOnUpdateChannelQueryVariables,
|
||||
ViewUpdateChannelsOnAppQueryVariables,
|
||||
} from '../graphql/generated';
|
||||
import { BranchQuery, UpdateBranchOnChannelObject } from '../graphql/queries/BranchQuery';
|
||||
import { ChannelQuery, UpdateChannelObject } from '../graphql/queries/ChannelQuery';
|
||||
import Log from '../log';
|
||||
import formatFields from '../utils/formatFields';
|
||||
import { printJsonOnlyOutput } from '../utils/json';
|
||||
import {
|
||||
paginatedQueryWithConfirmPromptAsync,
|
||||
paginatedQueryWithSelectPromptAsync,
|
||||
} from '../utils/queries';
|
||||
import { logChannelDetails } from './utils';
|
||||
|
||||
export const CHANNELS_LIMIT = 25;
|
||||
|
||||
export async function selectChannelOnAppAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
{
|
||||
projectId,
|
||||
selectionPromptTitle,
|
||||
paginatedQueryOptions,
|
||||
}: {
|
||||
projectId: string;
|
||||
selectionPromptTitle: string;
|
||||
paginatedQueryOptions: PaginatedQueryOptions;
|
||||
}
|
||||
): Promise<UpdateChannelObject> {
|
||||
if (paginatedQueryOptions.nonInteractive) {
|
||||
throw new Error('Unable to select a channel in non-interactive mode.');
|
||||
}
|
||||
|
||||
const updateChannel = await paginatedQueryWithSelectPromptAsync({
|
||||
limit: paginatedQueryOptions.limit ?? CHANNELS_LIMIT,
|
||||
offset: paginatedQueryOptions.offset,
|
||||
queryToPerform: (limit, offset) =>
|
||||
queryChannelsOnAppAsync(graphqlClient, { appId: projectId, limit, offset }),
|
||||
promptOptions: {
|
||||
title: selectionPromptTitle,
|
||||
makePartialChoiceObject: updateChannel => ({ title: updateChannel.name }),
|
||||
getIdentifierForQueryItem: updateChannel => updateChannel.id,
|
||||
},
|
||||
});
|
||||
|
||||
if (!updateChannel) {
|
||||
throw new Error(`Could not find any channels for app "${projectId}"`);
|
||||
}
|
||||
return updateChannel;
|
||||
}
|
||||
|
||||
export async function listAndRenderChannelsOnAppAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
{
|
||||
projectId,
|
||||
paginatedQueryOptions,
|
||||
}: {
|
||||
projectId: string;
|
||||
paginatedQueryOptions: PaginatedQueryOptions;
|
||||
}
|
||||
): Promise<void> {
|
||||
if (paginatedQueryOptions.nonInteractive) {
|
||||
const channels = await queryChannelsOnAppAsync(graphqlClient, {
|
||||
appId: projectId,
|
||||
limit: paginatedQueryOptions.limit ?? CHANNELS_LIMIT,
|
||||
offset: paginatedQueryOptions.offset,
|
||||
});
|
||||
renderPageOfChannels(channels, paginatedQueryOptions);
|
||||
} else {
|
||||
await paginatedQueryWithConfirmPromptAsync({
|
||||
limit: paginatedQueryOptions.limit ?? CHANNELS_LIMIT,
|
||||
offset: paginatedQueryOptions.offset,
|
||||
queryToPerform: (limit, offset) =>
|
||||
queryChannelsOnAppAsync(graphqlClient, { limit, offset, appId: projectId }),
|
||||
promptOptions: {
|
||||
title: 'Load more channels?',
|
||||
renderListItems: channels => renderPageOfChannels(channels, paginatedQueryOptions),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function listAndRenderBranchesAndUpdatesOnChannelAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
{
|
||||
projectId: appId,
|
||||
channelName,
|
||||
paginatedQueryOptions,
|
||||
}: {
|
||||
projectId: string;
|
||||
channelName: string;
|
||||
paginatedQueryOptions: PaginatedQueryOptions;
|
||||
}
|
||||
): Promise<void> {
|
||||
const channel = await ChannelQuery.viewUpdateChannelAsync(graphqlClient, { appId, channelName });
|
||||
renderChannelHeaderContent({ channelName: channel.name, channelId: channel.id });
|
||||
|
||||
if (paginatedQueryOptions.nonInteractive) {
|
||||
const branches = await queryBranchesAndUpdateGroupsOnChannelAsync(graphqlClient, {
|
||||
appId,
|
||||
channelName,
|
||||
offset: paginatedQueryOptions.offset,
|
||||
limit: paginatedQueryOptions.limit ?? CHANNELS_LIMIT,
|
||||
});
|
||||
renderPageOfBranchesOnChannel(channel, branches, paginatedQueryOptions);
|
||||
} else {
|
||||
await paginatedQueryWithConfirmPromptAsync({
|
||||
limit: paginatedQueryOptions.limit ?? CHANNELS_LIMIT,
|
||||
offset: paginatedQueryOptions.offset,
|
||||
queryToPerform: (limit, offset) =>
|
||||
queryBranchesAndUpdateGroupsOnChannelAsync(graphqlClient, {
|
||||
channelName,
|
||||
appId,
|
||||
offset,
|
||||
limit,
|
||||
}),
|
||||
promptOptions: {
|
||||
title: 'Load more channels?',
|
||||
renderListItems: branches =>
|
||||
renderPageOfBranchesOnChannel(channel, branches, paginatedQueryOptions),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function queryChannelsOnAppAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
{ appId, offset, limit }: ViewUpdateChannelsOnAppQueryVariables
|
||||
): Promise<UpdateChannelObject[]> {
|
||||
return await ChannelQuery.viewUpdateChannelsOnAppAsync(graphqlClient, {
|
||||
appId,
|
||||
offset,
|
||||
limit,
|
||||
});
|
||||
}
|
||||
|
||||
async function queryBranchesAndUpdateGroupsOnChannelAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
args: ViewBranchesOnUpdateChannelQueryVariables
|
||||
): Promise<UpdateBranchOnChannelObject[]> {
|
||||
return await BranchQuery.listBranchesOnChannelAsync(graphqlClient, args);
|
||||
}
|
||||
|
||||
function renderPageOfChannels(
|
||||
currentPage: UpdateChannelObject[],
|
||||
{ json }: PaginatedQueryOptions
|
||||
): void {
|
||||
if (json) {
|
||||
printJsonOnlyOutput({ currentPage });
|
||||
} else {
|
||||
for (const channel of currentPage) {
|
||||
renderChannelHeaderContent({ channelName: channel.name, channelId: channel.id });
|
||||
Log.addNewLineIfNone();
|
||||
logChannelDetails(channel);
|
||||
|
||||
if (currentPage.indexOf(channel) < currentPage.length - 1) {
|
||||
Log.log(`\n${chalk.dim('———')}\n`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function renderPageOfBranchesOnChannel(
|
||||
channel: UpdateChannelObject,
|
||||
currentPage: UpdateBranchOnChannelObject[],
|
||||
{ json }: PaginatedQueryOptions
|
||||
): void {
|
||||
const channelWithNewBranches = { ...channel, updateBranches: currentPage };
|
||||
if (json) {
|
||||
printJsonOnlyOutput({ currentPage: channelWithNewBranches });
|
||||
} else {
|
||||
// The channel details contain both the branch and latest update group
|
||||
Log.addNewLineIfNone();
|
||||
logChannelDetails(channelWithNewBranches);
|
||||
}
|
||||
}
|
||||
|
||||
function renderChannelHeaderContent({
|
||||
channelName,
|
||||
channelId,
|
||||
}: {
|
||||
channelName: string;
|
||||
channelId: string;
|
||||
}): void {
|
||||
Log.addNewLineIfNone();
|
||||
Log.log(chalk.bold('Channel:'));
|
||||
Log.log(
|
||||
formatFields([
|
||||
{ label: 'Name', value: channelName },
|
||||
{ label: 'ID', value: channelId },
|
||||
])
|
||||
);
|
||||
Log.addNewLineIfNone();
|
||||
Log.log(chalk`{bold Branches pointed at this channel and their most recent update group:}`);
|
||||
}
|
||||
|
||||
export async function createChannelOnAppAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
{
|
||||
appId,
|
||||
branchId,
|
||||
channelName,
|
||||
}: {
|
||||
appId: string;
|
||||
branchId: string;
|
||||
channelName: string;
|
||||
}
|
||||
): Promise<CreateUpdateChannelOnAppMutation> {
|
||||
// Point the new channel at a branch with its same name.
|
||||
const branchMapping = JSON.stringify({
|
||||
data: [{ branchId, branchMappingLogic: 'true' }],
|
||||
version: 0,
|
||||
});
|
||||
return await withErrorHandlingAsync(
|
||||
graphqlClient
|
||||
.mutation<CreateUpdateChannelOnAppMutation, CreateUpdateChannelOnAppMutationVariables>(
|
||||
gql`
|
||||
mutation CreateUpdateChannelOnApp($appId: ID!, $name: String!, $branchMapping: String!) {
|
||||
updateChannel {
|
||||
createUpdateChannelForApp(appId: $appId, name: $name, branchMapping: $branchMapping) {
|
||||
id
|
||||
name
|
||||
branchMapping
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
{
|
||||
appId,
|
||||
name: channelName,
|
||||
branchMapping,
|
||||
}
|
||||
)
|
||||
.toPromise()
|
||||
);
|
||||
}
|
||||
|
||||
export async function ensureChannelExistsAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
{ appId, branchId, channelName }: { appId: string; branchId: string; channelName: string }
|
||||
): Promise<void> {
|
||||
try {
|
||||
await createChannelOnAppAsync(graphqlClient, {
|
||||
appId,
|
||||
channelName,
|
||||
branchId,
|
||||
});
|
||||
} catch (e: any) {
|
||||
const isIgnorableError =
|
||||
e.graphQLErrors?.length === 1 &&
|
||||
e.graphQLErrors[0].extensions.errorCode === 'CHANNEL_ALREADY_EXISTS';
|
||||
if (!isIgnorableError) {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import assert from 'assert';
|
||||
import chalk from 'chalk';
|
||||
|
||||
import { UpdateChannelObject } from '../graphql/queries/ChannelQuery';
|
||||
import Log from '../log';
|
||||
import {
|
||||
FormattedBranchDescription,
|
||||
formatBranch,
|
||||
getUpdateGroupDescriptionsWithBranch,
|
||||
} from '../update/utils';
|
||||
|
||||
export type BranchMapping = {
|
||||
version: number;
|
||||
data: {
|
||||
branchId: string;
|
||||
branchMappingLogic: {
|
||||
operand: number;
|
||||
clientKey: string;
|
||||
branchMappingOperator: string;
|
||||
} & string;
|
||||
}[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the branch mapping and determine whether it is a rollout.
|
||||
* Ensure that the branch mapping is properly formatted.
|
||||
*/
|
||||
export function getBranchMapping(branchMappingString?: string): {
|
||||
branchMapping: BranchMapping;
|
||||
isRollout: boolean;
|
||||
rolloutPercent?: number;
|
||||
} {
|
||||
if (!branchMappingString) {
|
||||
throw new Error('Missing branch mapping.');
|
||||
}
|
||||
let branchMapping: BranchMapping;
|
||||
try {
|
||||
branchMapping = JSON.parse(branchMappingString);
|
||||
} catch {
|
||||
throw new Error(`Could not parse branchMapping string into a JSON: "${branchMappingString}"`);
|
||||
}
|
||||
assert(branchMapping, 'Branch Mapping must be defined.');
|
||||
|
||||
if (branchMapping.version !== 0) {
|
||||
throw new Error('Branch mapping must be version 0.');
|
||||
}
|
||||
|
||||
const isRollout = branchMapping.data.length === 2;
|
||||
const rolloutPercent = branchMapping.data[0]?.branchMappingLogic.operand;
|
||||
|
||||
switch (branchMapping.data.length) {
|
||||
case 0:
|
||||
break;
|
||||
case 1:
|
||||
if (branchMapping.data[0].branchMappingLogic !== 'true') {
|
||||
throw new Error('Branch mapping logic for a single branch must be "true"');
|
||||
}
|
||||
break;
|
||||
case 2:
|
||||
if (branchMapping.data[0].branchMappingLogic.clientKey !== 'rolloutToken') {
|
||||
throw new Error('Client key of initial branch mapping must be "rolloutToken"');
|
||||
}
|
||||
if (branchMapping.data[0].branchMappingLogic.branchMappingOperator !== 'hash_lt') {
|
||||
throw new Error('Branch mapping operator of initial branch mapping must be "hash_lt"');
|
||||
}
|
||||
if (rolloutPercent == null) {
|
||||
throw new Error('Branch mapping is missing a "rolloutPercent"');
|
||||
}
|
||||
if (branchMapping.data[1].branchMappingLogic !== 'true') {
|
||||
throw new Error('Branch mapping logic for a the second branch of a rollout must be "true"');
|
||||
}
|
||||
break;
|
||||
default:
|
||||
throw new Error('Branch mapping data must have length less than or equal to 2.');
|
||||
}
|
||||
|
||||
return { branchMapping, isRollout, rolloutPercent };
|
||||
}
|
||||
|
||||
export function logChannelDetails(channel: UpdateChannelObject): void {
|
||||
const { branchMapping, isRollout, rolloutPercent } = getBranchMapping(channel.branchMapping);
|
||||
if (branchMapping.data.length > 2) {
|
||||
throw new Error('Branch Mapping data must have length less than or equal to 2.');
|
||||
}
|
||||
|
||||
const rolloutBranchIds = branchMapping.data.map(data => data.branchId);
|
||||
const branchDescription = channel.updateBranches.flatMap(branch => {
|
||||
const updateGroupWithBranchDescriptions = getUpdateGroupDescriptionsWithBranch(
|
||||
branch.updateGroups
|
||||
);
|
||||
|
||||
const isRolloutBranch = isRollout && rolloutBranchIds.includes(branch.id);
|
||||
const isBaseBranch = rolloutBranchIds.length > 0 && rolloutBranchIds[0] === branch.id;
|
||||
let rolloutPercentNumber: number | undefined = undefined;
|
||||
if (isRolloutBranch) {
|
||||
rolloutPercentNumber = isBaseBranch ? rolloutPercent! * 100 : (1 - rolloutPercent!) * 100;
|
||||
}
|
||||
|
||||
return updateGroupWithBranchDescriptions.map(
|
||||
({ branch, ...updateGroup }): FormattedBranchDescription => ({
|
||||
branch,
|
||||
branchRolloutPercentage: rolloutPercentNumber,
|
||||
update: updateGroup,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
if (branchDescription.length === 0) {
|
||||
Log.log(chalk.dim('No branches are pointed to this channel.'));
|
||||
} else {
|
||||
Log.log(
|
||||
branchDescription
|
||||
.map(description => formatBranch(description))
|
||||
.join(`\n\n${chalk.dim('———')}\n\n`)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,69 +1,216 @@
|
||||
import { EasJsonReader } from '@expo/eas-json';
|
||||
import { Command } from '@oclif/core';
|
||||
import semver from 'semver';
|
||||
import { CombinedError } from '@urql/core';
|
||||
import { GraphQLError } from 'graphql/error';
|
||||
import nullthrows from 'nullthrows';
|
||||
|
||||
import {
|
||||
AnalyticsEvent,
|
||||
flushAsync as flushAnalyticsAsync,
|
||||
initAsync as initAnalyticsAsync,
|
||||
logEvent,
|
||||
} from '../analytics/rudderstackClient';
|
||||
import { findProjectRootAsync } from '../project/projectUtils';
|
||||
import { getUserAsync } from '../user/User';
|
||||
import { ensureLoggedInAsync } from '../user/actions';
|
||||
import { easCliVersion } from '../utils/easCli';
|
||||
import { setVcsClient } from '../vcs';
|
||||
import GitClient from '../vcs/clients/git';
|
||||
AnalyticsWithOrchestration,
|
||||
CommandEvent,
|
||||
createAnalyticsAsync,
|
||||
} from '../analytics/AnalyticsManager';
|
||||
import Log from '../log';
|
||||
import SessionManager from '../user/SessionManager';
|
||||
import AnalyticsContextField from './context/AnalyticsContextField';
|
||||
import ContextField from './context/ContextField';
|
||||
import {
|
||||
DynamicPrivateProjectConfigContextField,
|
||||
DynamicPublicProjectConfigContextField,
|
||||
} from './context/DynamicProjectConfigContextField';
|
||||
import LoggedInContextField from './context/LoggedInContextField';
|
||||
import MaybeLoggedInContextField from './context/MaybeLoggedInContextField';
|
||||
import { OptionalPrivateProjectConfigContextField } from './context/OptionalPrivateProjectConfigContextField';
|
||||
import { PrivateProjectConfigContextField } from './context/PrivateProjectConfigContextField';
|
||||
import ProjectDirContextField from './context/ProjectDirContextField';
|
||||
import SessionManagementContextField from './context/SessionManagementContextField';
|
||||
import { EasCommandError } from './errors';
|
||||
|
||||
export type ContextInput<
|
||||
T extends {
|
||||
[name: string]: any;
|
||||
} = object
|
||||
> = {
|
||||
[P in keyof T]: ContextField<T[P]>;
|
||||
};
|
||||
|
||||
export type ContextOutput<
|
||||
T extends {
|
||||
[name: string]: any;
|
||||
} = object
|
||||
> = {
|
||||
[P in keyof T]: T[P];
|
||||
};
|
||||
|
||||
const BASE_GRAPHQL_ERROR_MESSAGE: string = 'GraphQL request failed.';
|
||||
|
||||
export default abstract class EasCommand extends Command {
|
||||
protected static readonly ContextOptions = {
|
||||
/**
|
||||
* Require this command to be run when logged-in. Returns the logged-in actor and a logged-in
|
||||
* graphql client in the context.
|
||||
*/
|
||||
LoggedIn: {
|
||||
loggedIn: new LoggedInContextField(),
|
||||
},
|
||||
/**
|
||||
* Do not require this command to be run when logged-in, but if it is get the logged-in actor and a
|
||||
* maybe-logged-in graphql client.
|
||||
*/
|
||||
MaybeLoggedIn: {
|
||||
maybeLoggedIn: new MaybeLoggedInContextField(),
|
||||
},
|
||||
/**
|
||||
* Specify this context requirement if the command needs to mutate the user session.
|
||||
*/
|
||||
SessionManagment: {
|
||||
sessionManager: new SessionManagementContextField(),
|
||||
},
|
||||
/**
|
||||
* Require the project to be identified and registered on server if this command is being
|
||||
* run within a project directory, null otherwise.
|
||||
*/
|
||||
OptionalProjectConfig: {
|
||||
privateProjectConfig: new OptionalPrivateProjectConfigContextField(),
|
||||
},
|
||||
/**
|
||||
* Require this command to be run in a project directory. Return the project directory in the context.
|
||||
*/
|
||||
ProjectDir: {
|
||||
projectDir: new ProjectDirContextField(),
|
||||
},
|
||||
/**
|
||||
* Provides functions to load the project config when dynamic config options are needed (custom Env for example).
|
||||
*/
|
||||
DynamicProjectConfig: {
|
||||
// eslint-disable-next-line async-protect/async-suffix
|
||||
getDynamicPublicProjectConfigAsync: new DynamicPublicProjectConfigContextField(),
|
||||
// eslint-disable-next-line async-protect/async-suffix
|
||||
getDynamicPrivateProjectConfigAsync: new DynamicPrivateProjectConfigContextField(),
|
||||
},
|
||||
/**
|
||||
* Require the project to be identified and registered on server. Returns the project config in the context.
|
||||
* This also requires the user to be logged in (getProjectIdAsync requires logged in), so also expose that context.
|
||||
* Exposing the loggedIn context here helps us guarantee user identification for logging purposes.
|
||||
*/
|
||||
ProjectConfig: {
|
||||
loggedIn: new LoggedInContextField(),
|
||||
privateProjectConfig: new PrivateProjectConfigContextField(),
|
||||
},
|
||||
/**
|
||||
* Analytics manager. Returns the analytics manager in the context for use by the command.
|
||||
*/
|
||||
Analytics: {
|
||||
analytics: new AnalyticsContextField(),
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* When user data is unavailable locally, determines if the command will
|
||||
* force the user to log in
|
||||
* Context allows for subclasses (commands) to declare their prerequisites in a type-safe manner.
|
||||
* These declarative definitions each output a context property that is the result of the prerequisite being
|
||||
* satisfied. These allow a unified common interface to be shared amongst commands in order to provide a more
|
||||
* consistent CLI experience.
|
||||
*
|
||||
* For example, let's say a command needs the EAS project ID to make a GraphQL mutation. It should declare that
|
||||
* it requires the `ProjectConfig` context, and then call `getContextAsync` to get the project ID.
|
||||
*/
|
||||
protected requiresAuthentication = true;
|
||||
protected mustBeRunInsideProject = true;
|
||||
static contextDefinition: ContextInput = {};
|
||||
|
||||
/**
|
||||
* The user session manager. Responsible for coordinating all user session related state.
|
||||
* If needed in a subclass, use the SessionManager ContextOption.
|
||||
*/
|
||||
private sessionManagerInternal?: SessionManager;
|
||||
|
||||
/**
|
||||
* The analytics manager. Used for logging analytics.
|
||||
* It is set up here to ensure a consistent setup.
|
||||
*/
|
||||
private analyticsInternal?: AnalyticsWithOrchestration;
|
||||
|
||||
/**
|
||||
* Execute the context in the contextDefinition to satisfy command prerequisites.
|
||||
*/
|
||||
protected async getContextAsync<
|
||||
C extends {
|
||||
[name: string]: any;
|
||||
} = object
|
||||
>(
|
||||
commandClass: { contextDefinition: ContextInput<C> },
|
||||
{ nonInteractive }: { nonInteractive: boolean }
|
||||
): Promise<ContextOutput<C>> {
|
||||
const contextDefinition = commandClass.contextDefinition;
|
||||
|
||||
// do these serially so that they don't do things like ask for login twice in parallel
|
||||
const contextValuePairs = [];
|
||||
for (const [contextKey, contextField] of Object.entries(contextDefinition)) {
|
||||
contextValuePairs.push([
|
||||
contextKey,
|
||||
await contextField.getValueAsync({
|
||||
nonInteractive,
|
||||
sessionManager: this.sessionManager,
|
||||
analytics: this.analytics,
|
||||
}),
|
||||
]);
|
||||
}
|
||||
|
||||
return Object.fromEntries(contextValuePairs);
|
||||
}
|
||||
|
||||
private get sessionManager(): SessionManager {
|
||||
return nullthrows(this.sessionManagerInternal);
|
||||
}
|
||||
|
||||
private get analytics(): AnalyticsWithOrchestration {
|
||||
return nullthrows(this.analyticsInternal);
|
||||
}
|
||||
|
||||
protected abstract runAsync(): Promise<any>;
|
||||
|
||||
// eslint-disable-next-line async-protect/async-suffix
|
||||
async run(): Promise<any> {
|
||||
await initAnalyticsAsync();
|
||||
if (this.mustBeRunInsideProject) {
|
||||
await this.applyCliConfigAsync();
|
||||
}
|
||||
this.analyticsInternal = await createAnalyticsAsync();
|
||||
this.sessionManagerInternal = new SessionManager(this.analytics);
|
||||
|
||||
if (this.requiresAuthentication) {
|
||||
const { flags } = await this.parse();
|
||||
const nonInteractive = (flags as any)['non-interactive'] ?? false;
|
||||
await ensureLoggedInAsync({ nonInteractive });
|
||||
} else {
|
||||
await getUserAsync();
|
||||
}
|
||||
logEvent(AnalyticsEvent.ACTION, {
|
||||
// this is needed for logEvent call below as it identifies the user in the analytics system
|
||||
// if possible
|
||||
await this.sessionManager.getUserAsync();
|
||||
|
||||
this.analytics.logEvent(CommandEvent.ACTION, {
|
||||
// id is assigned by oclif in constructor based on the filepath:
|
||||
// commands/submit === submit, commands/build/list === build:list
|
||||
action: `eas ${this.id}`,
|
||||
});
|
||||
|
||||
return this.runAsync();
|
||||
}
|
||||
|
||||
// eslint-disable-next-line async-protect/async-suffix
|
||||
async finally(err: Error): Promise<any> {
|
||||
await flushAnalyticsAsync();
|
||||
override async finally(err: Error): Promise<any> {
|
||||
await this.analytics.flushAsync();
|
||||
return super.finally(err);
|
||||
}
|
||||
|
||||
private async applyCliConfigAsync(): Promise<void> {
|
||||
const projectDir = await findProjectRootAsync();
|
||||
const easJsonReader = new EasJsonReader(projectDir);
|
||||
const config = await easJsonReader.getCliConfigAsync();
|
||||
if (config?.version && !semver.satisfies(easCliVersion, config.version)) {
|
||||
throw new Error(
|
||||
`You are on eas-cli@${easCliVersion} which does not satisfy the CLI version constraint in eas.json (${config.version})`
|
||||
);
|
||||
}
|
||||
if (config?.requireCommit) {
|
||||
setVcsClient(new GitClient());
|
||||
protected override catch(err: Error): Promise<any> {
|
||||
let baseMessage = `${this.id} command failed.`;
|
||||
if (err instanceof EasCommandError) {
|
||||
Log.error(err.message);
|
||||
} else if (err instanceof CombinedError && err?.graphQLErrors) {
|
||||
const cleanMessage = err?.graphQLErrors
|
||||
.map((graphQLError: GraphQLError) => {
|
||||
const messageLine = graphQLError.message.replace('[GraphQL] ', '');
|
||||
const requestIdLine = graphQLError.extensions?.requestId
|
||||
? `\nRequest ID: ${err.graphQLErrors[0].extensions.requestId}`
|
||||
: '';
|
||||
return `${messageLine}${requestIdLine}`;
|
||||
})
|
||||
.join('\n');
|
||||
Log.error(cleanMessage);
|
||||
baseMessage = BASE_GRAPHQL_ERROR_MESSAGE;
|
||||
} else {
|
||||
Log.error(err.message);
|
||||
}
|
||||
Log.debug(err);
|
||||
const sanitizedError = new Error(baseMessage);
|
||||
sanitizedError.stack = err.stack;
|
||||
throw sanitizedError;
|
||||
}
|
||||
}
|
||||
@@ -1,22 +1,25 @@
|
||||
import { flushAsync, initAsync, logEvent } from '../../analytics/rudderstackClient';
|
||||
import { jester as mockJester } from '../../credentials/__tests__/fixtures-constants';
|
||||
import { getUserAsync } from '../../user/User';
|
||||
import { ensureLoggedInAsync } from '../../user/actions';
|
||||
import { CombinedError } from '@urql/core';
|
||||
import { GraphQLError } from 'graphql/error';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
import { AnalyticsWithOrchestration, createAnalyticsAsync } from '../../analytics/AnalyticsManager';
|
||||
import Log from '../../log';
|
||||
import SessionManager from '../../user/SessionManager';
|
||||
import EasCommand from '../EasCommand';
|
||||
|
||||
jest.mock('../../user/User');
|
||||
jest.mock('../../user/actions', () => ({ ensureLoggedInAsync: jest.fn() }));
|
||||
jest.mock('../../analytics/rudderstackClient', () => {
|
||||
const { AnalyticsEvent } = jest.requireActual('../../analytics/rudderstackClient');
|
||||
jest.mock('../../user/SessionManager');
|
||||
jest.mock('../../analytics/AnalyticsManager', () => {
|
||||
const { CommandEvent } = jest.requireActual('../../analytics/AnalyticsManager');
|
||||
return {
|
||||
AnalyticsEvent,
|
||||
logEvent: jest.fn(),
|
||||
initAsync: jest.fn(),
|
||||
flushAsync: jest.fn(),
|
||||
CommandEvent,
|
||||
createAnalyticsAsync: jest.fn(),
|
||||
};
|
||||
});
|
||||
jest.mock('../../log');
|
||||
|
||||
let originalProcessArgv: string[];
|
||||
const mockRequestId = uuidv4();
|
||||
|
||||
beforeAll(() => {
|
||||
originalProcessArgv = process.argv;
|
||||
@@ -27,28 +30,24 @@ afterAll(() => {
|
||||
process.argv = originalProcessArgv;
|
||||
});
|
||||
|
||||
const analytics: AnalyticsWithOrchestration = {
|
||||
logEvent: jest.fn((): void => {}),
|
||||
setActor: jest.fn((): void => {}),
|
||||
flushAsync: jest.fn(async (): Promise<void> => {}),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest
|
||||
.mocked(getUserAsync)
|
||||
.mockReset()
|
||||
.mockImplementation(async () => mockJester);
|
||||
jest
|
||||
.mocked(ensureLoggedInAsync)
|
||||
.mockReset()
|
||||
.mockImplementation(async () => mockJester);
|
||||
jest.mocked(initAsync).mockReset();
|
||||
jest.mocked(flushAsync).mockReset();
|
||||
jest.mocked(logEvent).mockReset();
|
||||
jest.resetAllMocks();
|
||||
|
||||
jest.mocked(createAnalyticsAsync).mockResolvedValue(analytics);
|
||||
});
|
||||
|
||||
const createTestEasCommand = (authValue: boolean): typeof EasCommand => {
|
||||
const createTestEasCommand = (): typeof EasCommand => {
|
||||
class TestEasCommand extends EasCommand {
|
||||
requiresAuthentication = authValue;
|
||||
|
||||
async runAsync(): Promise<void> {}
|
||||
}
|
||||
|
||||
TestEasCommand.id = 'TestEasCommand'; // normally oclif will assign ids, but b/c this is located outside the commands folder it will not
|
||||
TestEasCommand.id = 'testEasCommand'; // normally oclif will assign ids, but b/c this is located outside the commands folder it will not
|
||||
return TestEasCommand;
|
||||
};
|
||||
|
||||
@@ -61,40 +60,33 @@ describe(EasCommand.name, () => {
|
||||
//
|
||||
// See https://github.com/oclif/command/blob/master/src/command.ts#L80
|
||||
// and look for "Config.load"
|
||||
it('ensures the user is logged in', async () => {
|
||||
const TestEasCommand = createTestEasCommand(true);
|
||||
it('ensures the user data is read', async () => {
|
||||
const TestEasCommand = createTestEasCommand();
|
||||
await TestEasCommand.run();
|
||||
|
||||
expect(ensureLoggedInAsync).toHaveBeenCalled();
|
||||
}, 15_000);
|
||||
|
||||
it('ensures the user data is read from cache', async () => {
|
||||
const TestEasCommand = createTestEasCommand(false);
|
||||
await TestEasCommand.run();
|
||||
|
||||
expect(jest.mocked(getUserAsync)).toBeCalledTimes(1);
|
||||
expect(jest.mocked(ensureLoggedInAsync)).not.toBeCalled();
|
||||
});
|
||||
const sessionManagerSpy = jest.spyOn(SessionManager.prototype, 'getUserAsync');
|
||||
expect(sessionManagerSpy).toBeCalledTimes(1);
|
||||
}, 30_000);
|
||||
|
||||
it('initializes analytics', async () => {
|
||||
const TestEasCommand = createTestEasCommand(true);
|
||||
const TestEasCommand = createTestEasCommand();
|
||||
await TestEasCommand.run();
|
||||
|
||||
expect(initAsync).toHaveBeenCalled();
|
||||
expect(createAnalyticsAsync).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('flushes analytics', async () => {
|
||||
const TestEasCommand = createTestEasCommand(true);
|
||||
const TestEasCommand = createTestEasCommand();
|
||||
await TestEasCommand.run();
|
||||
|
||||
expect(flushAsync).toHaveBeenCalled();
|
||||
expect(analytics.flushAsync).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('logs events', async () => {
|
||||
const TestEasCommand = createTestEasCommand(true);
|
||||
const TestEasCommand = createTestEasCommand();
|
||||
await TestEasCommand.run();
|
||||
|
||||
expect(logEvent).toHaveBeenCalledWith('action', {
|
||||
expect(analytics.logEvent).toHaveBeenCalledWith('action', {
|
||||
action: `eas ${TestEasCommand.id}`,
|
||||
});
|
||||
});
|
||||
@@ -102,14 +94,178 @@ describe(EasCommand.name, () => {
|
||||
|
||||
describe('after exceptions', () => {
|
||||
it('flushes analytics', async () => {
|
||||
const TestEasCommand = createTestEasCommand(true);
|
||||
const TestEasCommand = createTestEasCommand();
|
||||
try {
|
||||
await TestEasCommand.run().then(() => {
|
||||
throw new Error('foo');
|
||||
});
|
||||
} catch {}
|
||||
|
||||
expect(flushAsync).toHaveBeenCalled();
|
||||
expect(analytics.flushAsync).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe('catch', () => {
|
||||
it('logs the message', async () => {
|
||||
const TestEasCommand = createTestEasCommand();
|
||||
const logErrorSpy = jest.spyOn(Log, 'error');
|
||||
const logDebugSpy = jest.spyOn(Log, 'debug');
|
||||
const runAsyncMock = jest.spyOn(TestEasCommand.prototype as any, 'runAsync');
|
||||
const error = new Error('Unexpected, internal error message');
|
||||
runAsyncMock.mockImplementation(() => {
|
||||
throw error;
|
||||
});
|
||||
try {
|
||||
await TestEasCommand.run();
|
||||
} catch {}
|
||||
|
||||
expect(logErrorSpy).toBeCalledWith('Unexpected, internal error message');
|
||||
expect(logDebugSpy).toBeCalledWith(error);
|
||||
});
|
||||
|
||||
it('logs the cleaned message if needed', async () => {
|
||||
const TestEasCommand = createTestEasCommand();
|
||||
const logErrorSpy = jest.spyOn(Log, 'error');
|
||||
const logDebugSpy = jest.spyOn(Log, 'debug');
|
||||
const runAsyncMock = jest.spyOn(TestEasCommand.prototype as any, 'runAsync');
|
||||
const graphQLErrors = ['Unexpected GraphQL error message'];
|
||||
const error = new CombinedError({ graphQLErrors });
|
||||
runAsyncMock.mockImplementation(() => {
|
||||
throw error;
|
||||
});
|
||||
try {
|
||||
await TestEasCommand.run();
|
||||
} catch {}
|
||||
|
||||
expect(logErrorSpy).toBeCalledWith('Unexpected GraphQL error message');
|
||||
expect(logDebugSpy).toBeCalledWith(error);
|
||||
});
|
||||
|
||||
it('logs the cleaned message with request ID if present', async () => {
|
||||
const TestEasCommand = createTestEasCommand();
|
||||
const logErrorSpy = jest.spyOn(Log, 'error');
|
||||
const logDebugSpy = jest.spyOn(Log, 'debug');
|
||||
const runAsyncMock = jest.spyOn(TestEasCommand.prototype as any, 'runAsync');
|
||||
const graphQLError = new GraphQLError(
|
||||
'Unexpected GraphQL error message',
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
{
|
||||
errorCode: 'UNKNOWN_GRAPHQL_ERROR',
|
||||
requestId: mockRequestId,
|
||||
}
|
||||
);
|
||||
const graphQLErrors = [graphQLError];
|
||||
const error = new CombinedError({ graphQLErrors });
|
||||
runAsyncMock.mockImplementation(() => {
|
||||
throw error;
|
||||
});
|
||||
try {
|
||||
await TestEasCommand.run();
|
||||
} catch {}
|
||||
|
||||
expect(logErrorSpy).toBeCalledWith(
|
||||
`Unexpected GraphQL error message\nRequest ID: ${mockRequestId}`
|
||||
);
|
||||
expect(logDebugSpy).toBeCalledWith(error);
|
||||
});
|
||||
|
||||
it('logs the cleaned messages with request IDs if multiple GraphQL errors present', async () => {
|
||||
const TestEasCommand = createTestEasCommand();
|
||||
const logErrorSpy = jest.spyOn(Log, 'error');
|
||||
const logDebugSpy = jest.spyOn(Log, 'debug');
|
||||
const runAsyncMock = jest.spyOn(TestEasCommand.prototype as any, 'runAsync');
|
||||
const graphQLErrors = [
|
||||
new GraphQLError('Unexpected GraphQL error message', null, null, null, null, null, {
|
||||
errorCode: 'UNKNOWN_GRAPHQL_ERROR',
|
||||
requestId: mockRequestId,
|
||||
}),
|
||||
new GraphQLError('Other GraphQL error message', null, null, null, null, null, {
|
||||
errorCode: 'OTHER_GRAPHQL_ERROR',
|
||||
requestId: mockRequestId,
|
||||
}),
|
||||
new GraphQLError('Yet another GraphQL error message', null, null, null, null, null, {
|
||||
errorCode: 'YET_ANOTHER_GRAPHQL_ERROR',
|
||||
requestId: mockRequestId,
|
||||
}),
|
||||
];
|
||||
const error = new CombinedError({ graphQLErrors });
|
||||
runAsyncMock.mockImplementation(() => {
|
||||
throw error;
|
||||
});
|
||||
try {
|
||||
await TestEasCommand.run();
|
||||
} catch {}
|
||||
|
||||
expect(logErrorSpy).toBeCalledWith(
|
||||
'Unexpected GraphQL error message\n' +
|
||||
`Request ID: ${mockRequestId}\n` +
|
||||
'Other GraphQL error message\n' +
|
||||
`Request ID: ${mockRequestId}\n` +
|
||||
'Yet another GraphQL error message\n' +
|
||||
`Request ID: ${mockRequestId}`
|
||||
);
|
||||
expect(logDebugSpy).toBeCalledWith(error);
|
||||
});
|
||||
|
||||
it('re-throws the error with default base message', async () => {
|
||||
const TestEasCommand = createTestEasCommand();
|
||||
const runAsyncMock = jest.spyOn(TestEasCommand.prototype as any, 'runAsync');
|
||||
runAsyncMock.mockImplementation(() => {
|
||||
throw new Error('Error message');
|
||||
});
|
||||
try {
|
||||
await TestEasCommand.run();
|
||||
} catch (caughtError) {
|
||||
expect(caughtError).toBeInstanceOf(Error);
|
||||
expect((caughtError as Error).message).toEqual('testEasCommand command failed.');
|
||||
}
|
||||
});
|
||||
|
||||
it('re-throws the error with a different default base message in case of CombinedError', async () => {
|
||||
const TestEasCommand = createTestEasCommand();
|
||||
const runAsyncMock = jest.spyOn(TestEasCommand.prototype as any, 'runAsync');
|
||||
runAsyncMock.mockImplementation(() => {
|
||||
const graphQLErrors = ['Unexpected GraphQL error message'];
|
||||
throw new CombinedError({ graphQLErrors });
|
||||
});
|
||||
try {
|
||||
await TestEasCommand.run();
|
||||
} catch (caughtError) {
|
||||
expect(caughtError).toBeInstanceOf(Error);
|
||||
expect((caughtError as Error).message).toEqual('GraphQL request failed.');
|
||||
}
|
||||
});
|
||||
|
||||
it('re-throws the error with a different default base message in case of CombinedError with multiple GraphQLErrors', async () => {
|
||||
const TestEasCommand = createTestEasCommand();
|
||||
const runAsyncMock = jest.spyOn(TestEasCommand.prototype as any, 'runAsync');
|
||||
runAsyncMock.mockImplementation(() => {
|
||||
const graphQLErrors = [
|
||||
new GraphQLError('Unexpected GraphQL error message', null, null, null, null, null, {
|
||||
errorCode: 'UNEXPECTED_GRAPHQL_ERROR',
|
||||
requestId: mockRequestId,
|
||||
}),
|
||||
new GraphQLError('Other GraphQL error message', null, null, null, null, null, {
|
||||
errorCode: 'OTHER_GRAPHQL_ERROR',
|
||||
requestId: mockRequestId,
|
||||
}),
|
||||
new GraphQLError('Yet another GraphQL error message', null, null, null, null, null, {
|
||||
errorCode: 'YET_ANOTHER_GRAPHQL_ERROR',
|
||||
requestId: mockRequestId,
|
||||
}),
|
||||
];
|
||||
throw new CombinedError({ graphQLErrors });
|
||||
});
|
||||
try {
|
||||
await TestEasCommand.run();
|
||||
} catch (caughtError) {
|
||||
expect(caughtError).toBeInstanceOf(Error);
|
||||
expect((caughtError as Error).message).toEqual('GraphQL request failed.');
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
import { Analytics } from '../../analytics/AnalyticsManager';
|
||||
import ContextField, { ContextOptions } from './ContextField';
|
||||
|
||||
export default class AnalyticsContextField extends ContextField<Analytics> {
|
||||
async getValueAsync({ analytics }: ContextOptions): Promise<Analytics> {
|
||||
return analytics;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Analytics } from '../../analytics/AnalyticsManager';
|
||||
import SessionManager from '../../user/SessionManager';
|
||||
|
||||
export interface ContextOptions {
|
||||
sessionManager: SessionManager;
|
||||
analytics: Analytics;
|
||||
nonInteractive: boolean;
|
||||
}
|
||||
|
||||
export default abstract class ContextField<T> {
|
||||
abstract getValueAsync(options: ContextOptions): Promise<T>;
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { ExpoConfig } from '@expo/config-types';
|
||||
|
||||
import {
|
||||
ExpoConfigOptions,
|
||||
getPrivateExpoConfig,
|
||||
getPublicExpoConfig,
|
||||
} from '../../project/expoConfig';
|
||||
import ContextField, { ContextOptions } from './ContextField';
|
||||
import { findProjectDirAndVerifyProjectSetupAsync } from './contextUtils/findProjectDirAndVerifyProjectSetupAsync';
|
||||
import { getProjectIdAsync } from './contextUtils/getProjectIdAsync';
|
||||
|
||||
export type DynamicConfigContextFn = (options?: ExpoConfigOptions) => Promise<{
|
||||
projectId: string;
|
||||
exp: ExpoConfig;
|
||||
projectDir: string;
|
||||
}>;
|
||||
|
||||
export class DynamicPublicProjectConfigContextField extends ContextField<DynamicConfigContextFn> {
|
||||
async getValueAsync({
|
||||
nonInteractive,
|
||||
sessionManager,
|
||||
}: ContextOptions): Promise<DynamicConfigContextFn> {
|
||||
const projectDir = await findProjectDirAndVerifyProjectSetupAsync();
|
||||
return async (options?: ExpoConfigOptions) => {
|
||||
const expBefore = getPublicExpoConfig(projectDir, options);
|
||||
const projectId = await getProjectIdAsync(sessionManager, expBefore, {
|
||||
nonInteractive,
|
||||
env: options?.env,
|
||||
});
|
||||
const exp = getPublicExpoConfig(projectDir, options);
|
||||
return {
|
||||
exp,
|
||||
projectDir,
|
||||
projectId,
|
||||
};
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export class DynamicPrivateProjectConfigContextField extends ContextField<DynamicConfigContextFn> {
|
||||
async getValueAsync({
|
||||
nonInteractive,
|
||||
sessionManager,
|
||||
}: ContextOptions): Promise<DynamicConfigContextFn> {
|
||||
const projectDir = await findProjectDirAndVerifyProjectSetupAsync();
|
||||
return async (options?: ExpoConfigOptions) => {
|
||||
const expBefore = getPrivateExpoConfig(projectDir, options);
|
||||
const projectId = await getProjectIdAsync(sessionManager, expBefore, {
|
||||
nonInteractive,
|
||||
env: options?.env,
|
||||
});
|
||||
const exp = getPrivateExpoConfig(projectDir, options);
|
||||
return {
|
||||
exp,
|
||||
projectDir,
|
||||
projectId,
|
||||
};
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { Actor } from '../../user/User';
|
||||
import FeatureGateEnvOverrides from '../gating/FeatureGateEnvOverrides';
|
||||
import FeatureGating from '../gating/FeatureGating';
|
||||
import ContextField, { ContextOptions } from './ContextField';
|
||||
import { ExpoGraphqlClient, createGraphqlClient } from './contextUtils/createGraphqlClient';
|
||||
|
||||
export default class LoggedInContextField extends ContextField<{
|
||||
actor: Actor;
|
||||
featureGating: FeatureGating;
|
||||
graphqlClient: ExpoGraphqlClient;
|
||||
}> {
|
||||
async getValueAsync({ nonInteractive, sessionManager }: ContextOptions): Promise<{
|
||||
actor: Actor;
|
||||
featureGating: FeatureGating;
|
||||
graphqlClient: ExpoGraphqlClient;
|
||||
}> {
|
||||
const { actor, authenticationInfo } = await sessionManager.ensureLoggedInAsync({
|
||||
nonInteractive,
|
||||
});
|
||||
const featureGateServerValues: { [key: string]: boolean } = actor?.featureGates ?? {};
|
||||
|
||||
const graphqlClient = createGraphqlClient(authenticationInfo);
|
||||
|
||||
return {
|
||||
actor,
|
||||
featureGating: new FeatureGating(featureGateServerValues, new FeatureGateEnvOverrides()),
|
||||
graphqlClient,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { Actor } from '../../user/User';
|
||||
import FeatureGateEnvOverrides from '../gating/FeatureGateEnvOverrides';
|
||||
import FeatureGating from '../gating/FeatureGating';
|
||||
import ContextField, { ContextOptions } from './ContextField';
|
||||
import { ExpoGraphqlClient, createGraphqlClient } from './contextUtils/createGraphqlClient';
|
||||
|
||||
export default class MaybeLoggedInContextField extends ContextField<{
|
||||
actor: Actor | null;
|
||||
featureGating: FeatureGating;
|
||||
graphqlClient: ExpoGraphqlClient;
|
||||
}> {
|
||||
async getValueAsync({ sessionManager }: ContextOptions): Promise<{
|
||||
actor: Actor | null;
|
||||
featureGating: FeatureGating;
|
||||
graphqlClient: ExpoGraphqlClient;
|
||||
}> {
|
||||
const authenticationInfo = {
|
||||
accessToken: sessionManager.getAccessToken(),
|
||||
sessionSecret: sessionManager.getSessionSecret(),
|
||||
};
|
||||
|
||||
const graphqlClient = createGraphqlClient(authenticationInfo);
|
||||
const actor = await sessionManager.getUserAsync();
|
||||
const featureGateServerValues: { [key: string]: boolean } = actor?.featureGates ?? {};
|
||||
|
||||
return {
|
||||
actor: actor ?? null,
|
||||
featureGating: new FeatureGating(featureGateServerValues, new FeatureGateEnvOverrides()),
|
||||
graphqlClient,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { ExpoConfig } from '@expo/config-types';
|
||||
import { InvalidEasJsonError } from '@expo/eas-json/build/errors';
|
||||
|
||||
import { getPrivateExpoConfig } from '../../project/expoConfig';
|
||||
import ContextField, { ContextOptions } from './ContextField';
|
||||
import { findProjectDirAndVerifyProjectSetupAsync } from './contextUtils/findProjectDirAndVerifyProjectSetupAsync';
|
||||
import { getProjectIdAsync } from './contextUtils/getProjectIdAsync';
|
||||
|
||||
export class OptionalPrivateProjectConfigContextField extends ContextField<
|
||||
| {
|
||||
projectId: string;
|
||||
exp: ExpoConfig;
|
||||
projectDir: string;
|
||||
}
|
||||
| undefined
|
||||
> {
|
||||
async getValueAsync({ nonInteractive, sessionManager }: ContextOptions): Promise<
|
||||
| {
|
||||
projectId: string;
|
||||
exp: ExpoConfig;
|
||||
projectDir: string;
|
||||
}
|
||||
| undefined
|
||||
> {
|
||||
let projectDir: string;
|
||||
try {
|
||||
projectDir = await findProjectDirAndVerifyProjectSetupAsync();
|
||||
if (!projectDir) {
|
||||
return undefined;
|
||||
}
|
||||
} catch (e) {
|
||||
if (e instanceof InvalidEasJsonError) {
|
||||
throw e;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const expBefore = getPrivateExpoConfig(projectDir);
|
||||
const projectId = await getProjectIdAsync(sessionManager, expBefore, {
|
||||
nonInteractive,
|
||||
});
|
||||
const exp = getPrivateExpoConfig(projectDir);
|
||||
return {
|
||||
exp,
|
||||
projectDir,
|
||||
projectId,
|
||||
};
|
||||
}
|
||||
}
|
||||
Loaded 100 of 635 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user