Compare commits

...
Author SHA1 Message Date
Dominik Sokal dd7a3912c6 address PR feedback 2022-07-29 14:22:56 +02:00
Dominik Sokal 77ac0550a9 use commit message as default build message 2022-07-29 11:07:02 +02:00
Dominik Sokal f9978baba2 [ENG-5836] add --message param to eas build (#1237)
* generate graphql code

* [eas-cli] allow specifying build message

* upgrade @expo/eas-build-job

* update CHANGELOG.md
2022-07-29 10:41:55 +02:00
Dominik Sokal 2023f6161d [ENG-5622] allow importing devices from Apple Developer Portal (#1236)
* allow importing devices from Apple Developer Portal

* update CHANGELOG.md
2022-07-29 10:30:18 +02:00
Expo CI a4f35466c3 update CHANGELOG.md 2022-07-28 09:53:15 +00:00
Dominik Sokal 5f73b0a2d7 v0.56.0 2022-07-28 11:43:00 +02:00
James Ide d194f18b8e Set Kirby and Will as codeowners for EAS Update CLI (#1233) 2022-07-26 09:22:45 -07:00
Wojciech Kozyra 17e1a145e8 [eas-cli] Fix newlines in cli deprecation message (#1231)
* [eas-cli] Fix newlines in cli deprecation message

* Update CHANGELOG
2022-07-26 10:46:09 +02:00
Kim Brandwijk 7ebb547fd9 [eas-cli] Invoke export with sourcemaps on update (#1228) 2022-07-22 12:47:37 +02:00
Wojciech Kozyra 6eca9ad564 [eas-cli] Add eas build:version:sync command (#1219) 2022-07-21 16:04:15 +02:00
Dominik Sokal 229f00a8ed Update CHANGELOG.md 2022-07-21 10:25:32 +02:00
Jérémy Barbet 2bbf7f2b6d chore: add better message on how to self-upgrade eas-cli (#1222)
* fix: make sure we add a line when needed while runing commands

* chore: add instruction on how to upgrade + remove ending new line

* chore: don't make the * bold
2022-07-21 10:24:10 +02:00
Dominik SokalandBrent Vatne d578abc69a [ENG-5017] display queue type when waiting (#1217)
* display queue type when waiting

* Update packages/eas-cli/src/build/build.ts

Co-authored-by: Brent Vatne <[email protected]>

* update CHANGELOG.md

Co-authored-by: Brent Vatne <[email protected]>
2022-07-20 09:36:22 +02:00
Wojciech Kozyra 89253f5767 [eas-cli] Add eas build:version:set command (#1209) 2022-07-18 13:26:46 +02:00
Kim Brandwijk dd5afd354a [cli] Change wording on build warning for bundle id/package name (#1211) 2022-07-16 20:43:36 +02:00
Dominik Sokal bdcc768354 rename uploadAsync -> uploadFileAtPathToS3Async (#1216) 2022-07-15 12:13:18 +02:00
Dominik Sokal 9cd2bafac5 fix CHANGELOG bot (#1215)
* upgrade node to 18.6.0 and yarn to 1.22.19

* upgrade dependencies
2022-07-15 12:13:09 +02:00
Dominik Sokal 69515ab47b fix S3 upload URLs (#1207) 2022-07-13 13:00:30 +02:00
Will Schurman ac5f7ab186 [eas-cli] Show spinner instead of silently timing out during asset upload (#1206) 2022-07-12 16:41:51 -07:00
Wojciech Kozyra 265c7eacc5 [eas-json] Increase max number of chained "extends" in build profiles (#1208) 2022-07-12 13:46:40 +02:00
Expo CI 98229581ca update CHANGELOG.md 2022-07-12 00:36:36 +00:00
Will Schurman a718fd10d3 v0.55.1 2022-07-11 17:25:37 -07:00
Will Schurman 78a63b8ca1 [eas-cli] Fix asset upload crash (#1205) 2022-07-11 16:50:09 -07:00
Cedric van Putten 7487f413de [eas-cli] Temporarily increase coverage memory to stabilize CI (#1196) 2022-07-11 19:40:32 +02:00
Expo CI da5f4e644a update CHANGELOG.md 2022-07-11 10:28:38 +00:00
Dominik Sokal be38117012 v0.55.0 2022-07-11 11:49:04 +02:00
Wojciech Kozyra 8f3314f188 [eas-cli] Keep using AndroidManifest.xml as a workflow marker (#1204) 2022-07-11 11:05:36 +02:00
Wojciech KozyraandDominik Sokal 31b6cc9ab6 [eas-cli] allow applicationId override (#1203)
* allow applicationId override

* Apply suggestions from code review

Co-authored-by: Dominik Sokal <[email protected]>

* update CHANGELOG.md

* update CHANGELOG.md

Co-authored-by: Dominik Sokal <[email protected]>
2022-07-11 10:19:06 +02:00
Dominik Sokal 04a8b08f59 [ENG-4579][scripts] convert to ESM (#1192)
* upgrade typescript to 4.7.4

* [scripts] add extensions to imports

* [scripts] configure as ESM

* [scripts] fix after converting to ESM

* [scripts] fix lint

* [scripts] require node >= 14.16

* [scripts] add node: protocol

* add scripts/build to .gitignore

* [scripts] add typecheck script

* add workflow for testing scripts

* address PR feedback
2022-07-04 11:50:57 +02:00
Cedric van Putten 8bce9b19b2 [eas-cli] Remove deprecated idfa and price tier from store config (#1193)
* [eas-cli] Drop deprecated idfa from store config

* [eas-cli] Drop deprecated price tier from store config

* update CHANGELOG.md
2022-07-02 02:52:35 +02:00
Cedric van Putten d3d6d41f9f [eas-cli] Improve store config categories configuration (#1187)
* [eas-cli] Improve EAS metadata category configuration

* [eas-cli] Update category types for apple store configuration

* [eas-cli] Add tests for the behavior of the categories from store configuration

* [eas-cli] Update apple store config with new categories

* [eas-cli] Simplify parent vs normal categories in schema

* [eas-cli] Fix serialization issue with empty secondary sub category

* [eas-cli] Relax the apple category types to allow strings

* [eas-cli] Fix an issue in reader related to the category types

* [eas-cli] Add comments about string vs category enum

* update CHANGELOG.md
2022-07-02 02:02:37 +02:00
Brent Vatne 0534328dfc [github] Add issue triage workflows 2022-06-30 17:45:44 -07:00
Cedric van Putten d0b9af2553 [eas-cli] Add review details to metadata store configuration (#1184)
* [eas-cli] Tweak the store configuration validationf or review

* [eas-cli] Implement review details in metadata

* [eas-cli] Use valid fixtures in config tests

* [eas-cli] Add markdown description for review notes

* [eas-cli] Add app review detail task description

* update CHANGELOG.md
2022-06-29 06:17:10 -07:00
Cedric van Putten 80674a3451 [eas-cli] Improve store configuration defaults and schema configuration (#1183)
* [eas-cli] Avoid setting automaticRelease for manual releases in config

* [eas-cli] Use same default app name as schema

* [eas-cli] Prefer releaseNotes over whatsNew in apple info

* [eas-cli] Drop deprecated gamblingAndContest from schema

* [eas-cli] Use better defaults for advisory

* [eas-cli] Remove type from enum as its not required by spec

* [eas-cli] Remove $ref and description workaround for draft 6

* [eas-cli] Update kids age rating description

* [eas-cli] Remove default app title

* update CHANGELOG.md
2022-06-28 05:24:32 -07:00
kirb 41b48a80be [eas-cli] truncates long messages for eas update command (#1178) 2022-06-24 15:27:52 -07:00
Dominik Sokal 3d5b77a263 [ENG-5262] fix URLs returned from AWS S3 (#1179)
* fix URLs returned from AWS S3

* update CHANGELOG.md
2022-06-24 11:17:49 +02:00
Chris WalterandDominik Sokal fa25d259d9 [1/1] Support resource-class flag in eas-cli (#1138)
* [1/1] Support build-resource-class flag in eas-cli

# Why
We want to add a flag to allow users to specify resource-class when they
run `eas build`. The flag is experimental for now, and we haven't
decided exactly how we'll incorporate the resource classes into plan
pricing. But this PR doesn't deal with that - it just makes the
necessary changes to eas-cli to support specifying a resource class when
running `eas build`.

# How
Add a new enum for BuildResourceClass that matches the enum created
for GraphQL in universe/server/www. Then create a new flag
`build-resource-class` whose options are the values of the
BuildResourceClass enum. Then add some validation to prevent resource
classes being used for incompatible platforms (e.g. prevent `-p ios
--build-resource-class=ANDROID_LARGE`). Finally, pass along the flag
value to the call to the WWW graphql mutations for BuildAndroid and
BuildiOS.

# Test Plan
```
yarn test
```
See test plan on this PR for more thorough e2e testing summary:
https://github.com/expo/universe/pull/9800

* Update packages/eas-cli/src/commands/build/index.ts

Co-authored-by: Dominik Sokal <[email protected]>

* Apply suggestions from code review

Co-authored-by: Dominik Sokal <[email protected]>

* Address PR comments

* Regenerate graphql from staging

* update CHANGELOG.md

Co-authored-by: Dominik Sokal <[email protected]>
2022-06-23 20:11:20 -07:00
Cedric van PuttenandEvan Bacon 26596f32e9 [eas-cli] Allow users to skip metadata validation (#1175)
* [eas-cli] Allow users to skip validation when schema has an issue

* [eas-cli] Make sure instructions are visible

* update CHANGELOG.md

* [eas-cli] Remove instructions from confirm async

* [eas-cli] Fix typo in confirm question

* [eas-cli] Reword warning about ignoring store config issues

Co-authored-by: Evan Bacon <[email protected]>

* [eas-cli] Reword confirmation question of ignoring issues

Co-authored-by: Evan Bacon <[email protected]>

Co-authored-by: Evan Bacon <[email protected]>
2022-06-23 05:13:39 -07:00
Cedric van Putten 031c610d58 [eas-cli] Update validation rules for metadata info locales (#1174)
* [eas-cli] Update validation rules for metadata info locales

* Add missing codeowner for metadata schema

* update CHANGELOG.md

* Fix path in codeowners
2022-06-23 04:43:28 -07:00
Dominik Sokal fcb5beccb4 update CHANGELOG.md 2022-06-23 09:16:57 +02:00
kirb 3003a712f6 [eas-cli] include bundler output in terminal for eas update (#1171) 2022-06-22 10:46:31 -07:00
Cedric van Putten e89f030c29 fix CHANGELOG 2022-06-17 16:02:19 +02:00
Expo CI 3e224f9583 update CHANGELOG.md 2022-06-15 21:56:43 +00:00
Cedric van Putten db338b2b78 v0.54.1 2022-06-15 23:42:44 +02:00
Cedric van Putten 971c8e4010 [eas-cli] Add link to ASC after successful metadata sync (#1168)
* [eas-cli] Add link to ASC after successful metadata sync

* update CHANGELOG.md
2022-06-15 23:26:36 +02:00
Cedric van Putten df64a59354 [eas-cli] Surface metadata cause errors in logging output (#1167)
* [eas-cli] Surface metadata cause errors in logging output

* update CHANGELOG.md
2022-06-15 23:26:16 +02:00
Cedric van Putten e5d80fcb6d [eas-cli] Add schemas to the bundled files (#1166)
* [eas-cli] Add schemas to the bundled files

* update CHANGELOG.md
2022-06-15 22:05:05 +02:00
Expo CI 4088ea3e43 update CHANGELOG.md 2022-06-15 17:01:30 +00:00
Kirby Cofino edd42186d9 v0.54.0 2022-06-15 09:53:45 -07:00
Wojciech Kozyra e6ba63cfd0 [eas-cli] Fix regression for default submit profile (#1163)
* [eas-cli] Fix regression for default submit profile

* Update CHANGELOG
2022-06-15 13:02:24 +02:00
Wojciech Kozyra 7b60901bb1 [eas-cli] update @expo/eas-build-job (#1162)
* update eas-build-job

* update CHANGELOG.md
2022-06-15 12:01:15 +02:00
Wojciech Kozyra a551e9010a add default codeowners (#1161) 2022-06-15 10:54:02 +02:00
Wojciech KozyraandDominik Sokal 95441798ed Update issue template (expo doctor output) (#1160)
* Update issue template (expo doctor output)

* Update .github/ISSUE_TEMPLATE/bug_report.yml

Co-authored-by: Dominik Sokal <[email protected]>

Co-authored-by: Dominik Sokal <[email protected]>
2022-06-15 10:49:08 +02:00
Wojciech Kozyra 814f863a63 fix build error 2022-06-15 10:44:22 +02:00
Wojciech Kozyra 283f086b25 [eas-cli] Remove fallback for legacy format in eas.json (#1158)
* remove support for deprecated eas.json formats

* update CHANGELOG.md
2022-06-15 10:39:48 +02:00
Dominik Sokal 7da01333c6 Update CHANGELOG.md 2022-06-15 09:30:07 +02:00
Cedric van PuttenandWojciech Kozyra c057eaa098 [eas-cli] Add metadata commands (#1136)
* [eas-cli] Add metadata commands

* Add myself as codeowner to the metadata

* [eas-cli] Hide the metadata commands for now

* Add metadata changelog entry

* [eas-cli] Update metadata configure command description

* [eas-cli] Drop non-interactive due to limitations in AppStoreApi

* [eas-cli] Reimplement credentials context for metadata

* [eas-cli] Drop non-interactive flag in metadata command

* [eas-cli] Drop args from metadata:configure

Co-authored-by: Wojciech Kozyra <[email protected]>

* [eas-cli] Add retroactive code review changes (#1147)

* [eas-cli] Add public access modifiers to classes

* [eas-cli] Add specific types to date precision remover

* [eas-cli] Add try catch around writing json to always unsubscribe

* [eas-cli] Add async return await to uploadAsync log methods

* [eas-cli] Fix date precision type and test

* [eas-cli] Use submission profile bundle id if defined

* [eas-cli] Drop catch from IO try catch block

* [eas-cli] Move unique helper to global utils

* [eas-cli] Prefer nullish coalescing

* [eas-cli] Move metadata commands to pull and push

* [eas-cli] Drop the platform option from the push command

* [eas-cli] Remove alias and reword descriptions

* [eas-cli] Auto-configure eas.json with metadata when missing (#1156)

* [eas-cli] Polish the command output for release (#1159)

* [eas-cli] Finalize metadata logging output

* [eas-cli] Fix logging validation errors when store.config.json is not found

* [eas-cli] Make all localized entites bold

* [eas-cli] Make version string visible in logs

* [eas-cli] Add note about early beta

* [eas-cli] Remove hidden from meta commands

* [eas-cli] Add metadata topic

* [eas-cli] Rename eas metadata to eas metadata:push in logging

* [eas-cli] Remove unused arg flag from push

Co-authored-by: Wojciech Kozyra <[email protected]>
2022-06-14 17:46:59 +02:00
Will Schurman 968fdd221c [eas-cli] Limit concurrent asset uploads (#1153) 2022-06-13 13:27:51 -07:00
Dominik Sokal 6d28e0eecc Update CHANGELOG.md 2022-06-13 08:50:03 +02:00
kirb a2b4d2dbd2 [eas-cli] add fetching additional pages for update --republish (#1148)
- add update/getUpdatesToRepublishInteractiveAsync and tests
2022-06-10 19:05:26 -07:00
kirb 660c996602 [eas-cli] improve update terminal output (#1152)
- add additional information to publish command output such as update ids and website links
- https://linear.app/expo/issue/ENG-4589/improve-publish-command
- add additional context to uploadAssetsAsync return type
- add real-time text to upload spinner showing upload progress
- remove ) from website link on unsupported terminals
2022-06-10 18:06:50 -07:00
kirb 35ede265ed [eas-cli] make viewBranchAsync updates limit configurable (#1144)
* make viewBranchAsync limit configurable
* add tests for updates/ensureChannelExistsAsync
* rename PAGE_LIMIT to viewBranchUpdatesQueryUpdateLimit
* generate graphql
2022-06-08 14:06:45 -07:00
Dominik Sokal cf02f075d7 Update CHANGELOG.md 2022-06-08 12:05:02 +02:00
Dominik Sokal a2027eabd2 fix CHANGELOG 2022-06-08 12:02:29 +02:00
Expo CI 4ef239c59c update CHANGELOG.md 2022-06-07 19:11:58 +00:00
119 changed files with 5939 additions and 1209 deletions

No files matched your search

+1 -1
View File
@@ -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 `expo doctor`, and paste the output here.
- type: textarea
validations:
required: false
+146
View File
@@ -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'
})
+26
View File
@@ -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
+4
View File
@@ -27,6 +27,10 @@ jobs:
if: ${{ !matrix.coverage }}
- run: yarn test --coverage
if: ${{ matrix.coverage }}
env:
# Oclif seems to cause OOM errors when testing with coverage enabled.
# Remove this whenever we solved those OOM errors.
NODE_OPTIONS: --max-old-space-size=4096
- run: yarn lint --max-warnings=0
- run: shellcheck bin/*.sh
- uses: codecov/codecov-action@v1
+2
View File
@@ -2,10 +2,12 @@
packages/*/build
packages/eas-cli/dist
packages/eas-cli/tmp
scripts/build
# Code editors
.idea
.history
.vscode
# macOS
.DS_Store
+90 -1
View File
@@ -8,10 +8,99 @@ This is the log of notable changes to EAS CLI and related packages.
### 🎉 New features
- Add support for attaching messages to builds. ([#1237](https://github.com/expo/eas-cli/pull/1237) by [@dsokal](https://github.com/dsokal))
### 🐛 Bug fixes
### 🧹 Chores
## [0.56.0](https://github.com/expo/eas-cli/releases/tag/v0.56.0) - 2022-07-28
### 🎉 New features
- Display build queue type when waiting. ([#1217](https://github.com/expo/eas-cli/pull/1217) by [@dsokal](https://github.com/dsokal))
- Add better message on how to upgrade EAS CLI. ([#1222](https://github.com/expo/eas-cli/pull/1222) by [@jeremybarbet](https://github.com/jeremybarbet), [#1231](https://github.com/expo/eas-cli/pull/1231) by [@wkozyra95](https://github.com/wkozyra95))
- Invoke export with sourcemaps on update. ([#1228](https://github.com/expo/eas-cli/pull/1228) by [@kbrandwijk](https://github.com/kbrandwijk))
- Add new option to `eas device:create` - allow importing devices from Apple Developer Portal. ([#1236](https://github.com/expo/eas-cli/pull/1236) by [@dsokal](https://github.com/dsokal))
### 🐛 Bug fixes
- Show spinner instead of silently timing out during asset upload. ([#1206](https://github.com/expo/eas-cli/pull/1206) by [@wschurman](https://github.com/wschurman))
- Fix build archive S3 URLs. ([#1207](https://github.com/expo/eas-cli/pull/1207) by [@dsokal](https://github.com/dsokal))
- `autoIncrement` option on iOS will update versions of all targets that depend on an application target. ([#1219](https://github.com/expo/eas-cli/pull/1219) by [@wkozyra95](https://github.com/wkozyra95))
### 🧹 Chores
- Increase max number of chained "extends" in build profiles. ([#1208](https://github.com/expo/eas-cli/pull/1208) by [@wkozyra95](https://github.com/wkozyra95))
- Change wording on build warning for bundle id/package name. ([#1211](https://github.com/expo/eas-cli/pull/1211) by [@kbrandwijk](https://github.com/kbrandwijk))
## [0.55.1](https://github.com/expo/eas-cli/releases/tag/v0.55.1) - 2022-07-12
### 🐛 Bug fixes
- Fix asset upload crash. ([#1205](https://github.com/expo/eas-cli/pull/1205) by [@wschurman](https://github.com/wschurman))
## [0.55.0](https://github.com/expo/eas-cli/releases/tag/v0.55.0) - 2022-07-11
### 🎉 New features
- Show bundler output by default for eas update command. ([#1171](https://github.com/expo/eas-cli/pull/1171) by [@kgc00](https://github.com/kgc00/))
- Allow users to skip metadata validation. ([#1175](https://github.com/expo/eas-cli/pull/1175) by [@byCedric](https://github.com/byCedric))
- Add experimental `--resource-class` flag for Build commands. ([#1138](https://github.com/expo/eas-cli/pull/1138) by [@christopherwalter](https://github.com/christopherwalter))
- Truncate long messages for `eas update` command, rather than failing. ([#1178](https://github.com/expo/eas-cli/pull/1178) by [@kgc00](https://github.com/kgc00))
- Add review details to metadata store configuration. ([#1184](https://github.com/expo/eas-cli/pull/1184) by [@byCedric](https://github.com/byCedric))
- Override applicationId via env. ([#1203](https://github.com/expo/eas-cli/pull/1203) by [@wkozyra95](https://github.com/wkozyra95))
### 🐛 Bug fixes
- Fix submission archive URLs. ([#1179](https://github.com/expo/eas-cli/pull/1179) by [@dsokal](https://github.com/dsokal))
### 🧹 Chores
- Update validation rules for metadata info locales. ([#1174](https://github.com/expo/eas-cli/pull/1174) by [@byCedric](https://github.com/byCedric))
- Improve store configuration defaults and schema documentation. ([#1183](https://github.com/expo/eas-cli/pull/1183) by [@byCedric](https://github.com/byCedric))
- Improve store config categories configuration. ([#1187](https://github.com/expo/eas-cli/pull/1187) by [@byCedric](https://github.com/byCedric))
- Remove deprecated idfa and price tier from store config. ([#1193](https://github.com/expo/eas-cli/pull/1193) by [@byCedric](https://github.com/byCedric))
## [0.54.1](https://github.com/expo/eas-cli/releases/tag/v0.54.1) - 2022-06-15
### 🎉 New features
- Add App Store Connect link after `eas metadata:push`. ([#1168](https://github.com/expo/eas-cli/pull/1168) by [@byCedric](https://github.com/byCedric))
### 🐛 Bug fixes
- Surface ASC errors in metadata for normal API rejections. ([#1167](https://github.com/expo/eas-cli/pull/1167) by [@byCedric](https://github.com/byCedric))
- Add metadata schema to bundled eas-cli package. ([#1166](https://github.com/expo/eas-cli/pull/1166) by [@byCedric](https://github.com/byCedric))
## [0.54.0](https://github.com/expo/eas-cli/releases/tag/v0.54.0) - 2022-06-15
### 🛠 Breaking changes
- Remove fallback for legacy format in `eas.json`. ([#1158](https://github.com/expo/eas-cli/pull/1158), [#1163](https://github.com/expo/eas-cli/pull/1163) by [@wkozyra95](https://github.com/wkozyra95))
### 🎉 New features
- `eas update` now provides more information about the publish process including real-time feedback on asset uploads, update ids, and website links. ([#1152](https://github.com/expo/eas-cli/pull/1152) by [@kgc00](https://github.com/kgc00/))
- Added first beta of `eas metadata` to sync store information using store configuration files ([#1136](https://github.com/expo/eas-cli/pull/1136) by [@bycedric](https://github.com/bycedric))
### 🐛 Bug fixes
- Improved support for working on branches with many updates. ([#1144](https://github.com/expo/eas-cli/pull/1144), [#1148](https://github.com/expo/eas-cli/pull/1148) by [@kgc00](https://github.com/kgc00/))
- `eas update` project links no longer contain a `)` on unsupported terminals. ([#1152](https://github.com/expo/eas-cli/pull/1152) by [@kgc00](https://github.com/kgc00/))
### 🧹 Chores
- Update `eas-build-job`. ([#1162](https://github.com/expo/eas-cli/pull/1162) by [@wkozyra95](https://github.com/wkozyra95))
## [0.53.1](https://github.com/expo/eas-cli/releases/tag/v0.53.1) - 2022-06-07
### 🐛 Bug fixes
- No longer timeout on publishing updates for branches with many updates. ([#1119](https://github.com/expo/eas-cli/pull/1119) by [@kgc00](https://github.com/kgc00/))
### 🧹 Chores
- Display project archive docs before compressing/uploading to EAS Build. ([#1127](https://github.com/expo/eas-cli/pull/1127) by [@dsokal](https://github.com/dsokal))
- Add new common error code for submits. ([#1129](https://github.com/expo/eas-cli/pull/1129) by [@dsokal](https://github.com/dsokal))
@@ -24,7 +113,7 @@ This is the log of notable changes to EAS CLI and related packages.
### 🐛 Bug fixes
- Match bundle identifier capabilities more accurately. ([#1112](https://github.com/expo/eas-cli/pull/1112) by [@EvanBacon](https://github.com/EvanBacon))
- No longer timeout on publishing updates for branches with many updates. ([#1119](https://github.com/expo/eas-cli/pull/1119)) by [@kgc00](https://github.com/kgc00/)
- Limit concurrent asset uploads. ([#1153](https://github.com/expo/eas-cli/pull/1153) by [@wschurman](https://github.com/wschurman))
### 🧹 Chores
+9
View File
@@ -1,7 +1,16 @@
* @dsokal @wkozyra95
packages/eas-cli/src/build @dsokal @wkozyra95
packages/eas-cli/src/commands/build @dsokal @wkozyra95
packages/eas-cli/src/submit @barthap @dsokal
packages/eas-cli/src/commands/submit.ts @barthap @dsokal
packages/eas-cli/src/update @kgc00 @wschurman
packages/eas-cli/src/commands/update @kgc00 @wschurman
packages/eas-cli/schema @bycedric
packages/eas-cli/src/metadata @bycedric
packages/eas-cli/src/commands/metadata @bycedric
packages/eas-json @dsokal @wkozyra95
+1 -1
View File
@@ -1,5 +1,5 @@
{
"npmClient": "yarn",
"useWorkspaces": true,
"version": "0.53.1"
"version": "0.56.0"
}
+3 -3
View File
@@ -29,10 +29,10 @@
"oclif": "3.0.1",
"prettier": "2.6.2",
"ts-jest": "28.0.2",
"typescript": "4.6.4"
"typescript": "4.7.4"
},
"volta": {
"node": "18.2.0",
"yarn": "1.22.18"
"node": "18.6.0",
"yarn": "1.22.19"
}
}
+78 -42
View File
@@ -65,6 +65,8 @@ eas --help COMMAND
* [`eas init`](#eas-init)
* [`eas login`](#eas-login)
* [`eas logout`](#eas-logout)
* [`eas metadata:pull`](#eas-metadatapull)
* [`eas metadata:push`](#eas-metadatapush)
* [`eas project:info`](#eas-projectinfo)
* [`eas project:init`](#eas-projectinit)
* [`eas secret:create`](#eas-secretcreate)
@@ -98,7 +100,7 @@ ALIASES
$ eas login
```
_See code: [src/commands/account/login.ts](https://github.com/expo/eas-cli/blob/v0.53.1/packages/eas-cli/src/commands/account/login.ts)_
_See code: [src/commands/account/login.ts](https://github.com/expo/eas-cli/blob/v0.56.0/packages/eas-cli/src/commands/account/login.ts)_
## `eas account:logout`
@@ -115,7 +117,7 @@ ALIASES
$ eas logout
```
_See code: [src/commands/account/logout.ts](https://github.com/expo/eas-cli/blob/v0.53.1/packages/eas-cli/src/commands/account/logout.ts)_
_See code: [src/commands/account/logout.ts](https://github.com/expo/eas-cli/blob/v0.56.0/packages/eas-cli/src/commands/account/logout.ts)_
## `eas account:view`
@@ -132,7 +134,7 @@ ALIASES
$ eas whoami
```
_See code: [src/commands/account/view.ts](https://github.com/expo/eas-cli/blob/v0.53.1/packages/eas-cli/src/commands/account/view.ts)_
_See code: [src/commands/account/view.ts](https://github.com/expo/eas-cli/blob/v0.56.0/packages/eas-cli/src/commands/account/view.ts)_
## `eas analytics [STATUS]`
@@ -146,7 +148,7 @@ DESCRIPTION
display or change analytics settings
```
_See code: [src/commands/analytics.ts](https://github.com/expo/eas-cli/blob/v0.53.1/packages/eas-cli/src/commands/analytics.ts)_
_See code: [src/commands/analytics.ts](https://github.com/expo/eas-cli/blob/v0.56.0/packages/eas-cli/src/commands/analytics.ts)_
## `eas autocomplete [SHELL]`
@@ -195,7 +197,7 @@ DESCRIPTION
create a branch
```
_See code: [src/commands/branch/create.ts](https://github.com/expo/eas-cli/blob/v0.53.1/packages/eas-cli/src/commands/branch/create.ts)_
_See code: [src/commands/branch/create.ts](https://github.com/expo/eas-cli/blob/v0.56.0/packages/eas-cli/src/commands/branch/create.ts)_
## `eas branch:delete [NAME]`
@@ -215,7 +217,7 @@ DESCRIPTION
delete a branch
```
_See code: [src/commands/branch/delete.ts](https://github.com/expo/eas-cli/blob/v0.53.1/packages/eas-cli/src/commands/branch/delete.ts)_
_See code: [src/commands/branch/delete.ts](https://github.com/expo/eas-cli/blob/v0.56.0/packages/eas-cli/src/commands/branch/delete.ts)_
## `eas branch:list`
@@ -232,7 +234,7 @@ DESCRIPTION
list all branches
```
_See code: [src/commands/branch/list.ts](https://github.com/expo/eas-cli/blob/v0.53.1/packages/eas-cli/src/commands/branch/list.ts)_
_See code: [src/commands/branch/list.ts](https://github.com/expo/eas-cli/blob/v0.56.0/packages/eas-cli/src/commands/branch/list.ts)_
## `eas branch:rename`
@@ -251,7 +253,7 @@ DESCRIPTION
rename a branch
```
_See code: [src/commands/branch/rename.ts](https://github.com/expo/eas-cli/blob/v0.53.1/packages/eas-cli/src/commands/branch/rename.ts)_
_See code: [src/commands/branch/rename.ts](https://github.com/expo/eas-cli/blob/v0.56.0/packages/eas-cli/src/commands/branch/rename.ts)_
## `eas branch:view [NAME]`
@@ -271,7 +273,7 @@ DESCRIPTION
view a branch
```
_See code: [src/commands/branch/view.ts](https://github.com/expo/eas-cli/blob/v0.53.1/packages/eas-cli/src/commands/branch/view.ts)_
_See code: [src/commands/branch/view.ts](https://github.com/expo/eas-cli/blob/v0.56.0/packages/eas-cli/src/commands/branch/view.ts)_
## `eas build`
@@ -300,7 +302,7 @@ DESCRIPTION
start a build
```
_See code: [src/commands/build/index.ts](https://github.com/expo/eas-cli/blob/v0.53.1/packages/eas-cli/src/commands/build/index.ts)_
_See code: [src/commands/build/index.ts](https://github.com/expo/eas-cli/blob/v0.56.0/packages/eas-cli/src/commands/build/index.ts)_
## `eas build:cancel [BUILD_ID]`
@@ -314,7 +316,7 @@ DESCRIPTION
cancel a build
```
_See code: [src/commands/build/cancel.ts](https://github.com/expo/eas-cli/blob/v0.53.1/packages/eas-cli/src/commands/build/cancel.ts)_
_See code: [src/commands/build/cancel.ts](https://github.com/expo/eas-cli/blob/v0.56.0/packages/eas-cli/src/commands/build/cancel.ts)_
## `eas build:configure`
@@ -331,7 +333,7 @@ DESCRIPTION
configure the project to support EAS Build
```
_See code: [src/commands/build/configure.ts](https://github.com/expo/eas-cli/blob/v0.53.1/packages/eas-cli/src/commands/build/configure.ts)_
_See code: [src/commands/build/configure.ts](https://github.com/expo/eas-cli/blob/v0.56.0/packages/eas-cli/src/commands/build/configure.ts)_
## `eas build:inspect`
@@ -366,7 +368,7 @@ DESCRIPTION
inspect the state of the project at specific build stages, useful for troubleshooting
```
_See code: [src/commands/build/inspect.ts](https://github.com/expo/eas-cli/blob/v0.53.1/packages/eas-cli/src/commands/build/inspect.ts)_
_See code: [src/commands/build/inspect.ts](https://github.com/expo/eas-cli/blob/v0.56.0/packages/eas-cli/src/commands/build/inspect.ts)_
## `eas build:list`
@@ -399,7 +401,7 @@ DESCRIPTION
list all builds for your project
```
_See code: [src/commands/build/list.ts](https://github.com/expo/eas-cli/blob/v0.53.1/packages/eas-cli/src/commands/build/list.ts)_
_See code: [src/commands/build/list.ts](https://github.com/expo/eas-cli/blob/v0.56.0/packages/eas-cli/src/commands/build/list.ts)_
## `eas build:submit`
@@ -444,7 +446,7 @@ DESCRIPTION
view a build for your project
```
_See code: [src/commands/build/view.ts](https://github.com/expo/eas-cli/blob/v0.53.1/packages/eas-cli/src/commands/build/view.ts)_
_See code: [src/commands/build/view.ts](https://github.com/expo/eas-cli/blob/v0.56.0/packages/eas-cli/src/commands/build/view.ts)_
## `eas channel:create [NAME]`
@@ -464,7 +466,7 @@ DESCRIPTION
create a channel
```
_See code: [src/commands/channel/create.ts](https://github.com/expo/eas-cli/blob/v0.53.1/packages/eas-cli/src/commands/channel/create.ts)_
_See code: [src/commands/channel/create.ts](https://github.com/expo/eas-cli/blob/v0.56.0/packages/eas-cli/src/commands/channel/create.ts)_
## `eas channel:edit [NAME]`
@@ -485,7 +487,7 @@ DESCRIPTION
point a channel at a new branch
```
_See code: [src/commands/channel/edit.ts](https://github.com/expo/eas-cli/blob/v0.53.1/packages/eas-cli/src/commands/channel/edit.ts)_
_See code: [src/commands/channel/edit.ts](https://github.com/expo/eas-cli/blob/v0.56.0/packages/eas-cli/src/commands/channel/edit.ts)_
## `eas channel:list`
@@ -502,7 +504,7 @@ DESCRIPTION
list all channels
```
_See code: [src/commands/channel/list.ts](https://github.com/expo/eas-cli/blob/v0.53.1/packages/eas-cli/src/commands/channel/list.ts)_
_See code: [src/commands/channel/list.ts](https://github.com/expo/eas-cli/blob/v0.56.0/packages/eas-cli/src/commands/channel/list.ts)_
## `eas channel:view [NAME]`
@@ -522,7 +524,7 @@ DESCRIPTION
view a channel
```
_See code: [src/commands/channel/view.ts](https://github.com/expo/eas-cli/blob/v0.53.1/packages/eas-cli/src/commands/channel/view.ts)_
_See code: [src/commands/channel/view.ts](https://github.com/expo/eas-cli/blob/v0.56.0/packages/eas-cli/src/commands/channel/view.ts)_
## `eas config`
@@ -540,7 +542,7 @@ DESCRIPTION
display project configuration (app.json + eas.json)
```
_See code: [src/commands/config.ts](https://github.com/expo/eas-cli/blob/v0.53.1/packages/eas-cli/src/commands/config.ts)_
_See code: [src/commands/config.ts](https://github.com/expo/eas-cli/blob/v0.56.0/packages/eas-cli/src/commands/config.ts)_
## `eas credentials`
@@ -554,7 +556,7 @@ DESCRIPTION
manage credentials
```
_See code: [src/commands/credentials.ts](https://github.com/expo/eas-cli/blob/v0.53.1/packages/eas-cli/src/commands/credentials.ts)_
_See code: [src/commands/credentials.ts](https://github.com/expo/eas-cli/blob/v0.56.0/packages/eas-cli/src/commands/credentials.ts)_
## `eas device:create`
@@ -568,7 +570,7 @@ DESCRIPTION
register new Apple Devices to use for internal distribution
```
_See code: [src/commands/device/create.ts](https://github.com/expo/eas-cli/blob/v0.53.1/packages/eas-cli/src/commands/device/create.ts)_
_See code: [src/commands/device/create.ts](https://github.com/expo/eas-cli/blob/v0.56.0/packages/eas-cli/src/commands/device/create.ts)_
## `eas device:delete`
@@ -586,7 +588,7 @@ DESCRIPTION
remove a registered device from your account
```
_See code: [src/commands/device/delete.ts](https://github.com/expo/eas-cli/blob/v0.53.1/packages/eas-cli/src/commands/device/delete.ts)_
_See code: [src/commands/device/delete.ts](https://github.com/expo/eas-cli/blob/v0.56.0/packages/eas-cli/src/commands/device/delete.ts)_
## `eas device:list`
@@ -603,7 +605,7 @@ DESCRIPTION
list all registered devices for your account
```
_See code: [src/commands/device/list.ts](https://github.com/expo/eas-cli/blob/v0.53.1/packages/eas-cli/src/commands/device/list.ts)_
_See code: [src/commands/device/list.ts](https://github.com/expo/eas-cli/blob/v0.56.0/packages/eas-cli/src/commands/device/list.ts)_
## `eas device:view [UDID]`
@@ -617,7 +619,7 @@ DESCRIPTION
view a device for your project
```
_See code: [src/commands/device/view.ts](https://github.com/expo/eas-cli/blob/v0.53.1/packages/eas-cli/src/commands/device/view.ts)_
_See code: [src/commands/device/view.ts](https://github.com/expo/eas-cli/blob/v0.56.0/packages/eas-cli/src/commands/device/view.ts)_
## `eas diagnostics`
@@ -631,7 +633,7 @@ DESCRIPTION
display environment info
```
_See code: [src/commands/diagnostics.ts](https://github.com/expo/eas-cli/blob/v0.53.1/packages/eas-cli/src/commands/diagnostics.ts)_
_See code: [src/commands/diagnostics.ts](https://github.com/expo/eas-cli/blob/v0.56.0/packages/eas-cli/src/commands/diagnostics.ts)_
## `eas help [COMMAND]`
@@ -698,6 +700,40 @@ ALIASES
$ eas logout
```
## `eas metadata:pull`
generate the local store configuration from the app stores
```
USAGE
$ eas metadata:pull [--profile <value>]
FLAGS
--profile=<value> Name of the submit profile from eas.json. Defaults to "production" if defined in eas.json.
DESCRIPTION
generate the local store configuration from the app stores
```
_See code: [src/commands/metadata/pull.ts](https://github.com/expo/eas-cli/blob/v0.56.0/packages/eas-cli/src/commands/metadata/pull.ts)_
## `eas metadata:push`
sync the local store configuration to the app stores
```
USAGE
$ eas metadata:push [--profile <value>]
FLAGS
--profile=<value> Name of the submit profile from eas.json. Defaults to "production" if defined in eas.json.
DESCRIPTION
sync the local store configuration to the app stores
```
_See code: [src/commands/metadata/push.ts](https://github.com/expo/eas-cli/blob/v0.56.0/packages/eas-cli/src/commands/metadata/push.ts)_
## `eas project:info`
information about the current project
@@ -710,7 +746,7 @@ DESCRIPTION
information about the current project
```
_See code: [src/commands/project/info.ts](https://github.com/expo/eas-cli/blob/v0.53.1/packages/eas-cli/src/commands/project/info.ts)_
_See code: [src/commands/project/info.ts](https://github.com/expo/eas-cli/blob/v0.56.0/packages/eas-cli/src/commands/project/info.ts)_
## `eas project:init`
@@ -727,7 +763,7 @@ ALIASES
$ eas init
```
_See code: [src/commands/project/init.ts](https://github.com/expo/eas-cli/blob/v0.53.1/packages/eas-cli/src/commands/project/init.ts)_
_See code: [src/commands/project/init.ts](https://github.com/expo/eas-cli/blob/v0.56.0/packages/eas-cli/src/commands/project/init.ts)_
## `eas secret:create`
@@ -747,7 +783,7 @@ DESCRIPTION
create an environment secret on the current project or owner account
```
_See code: [src/commands/secret/create.ts](https://github.com/expo/eas-cli/blob/v0.53.1/packages/eas-cli/src/commands/secret/create.ts)_
_See code: [src/commands/secret/create.ts](https://github.com/expo/eas-cli/blob/v0.56.0/packages/eas-cli/src/commands/secret/create.ts)_
## `eas secret:delete`
@@ -764,7 +800,7 @@ DESCRIPTION
delete an environment secret by ID
```
_See code: [src/commands/secret/delete.ts](https://github.com/expo/eas-cli/blob/v0.53.1/packages/eas-cli/src/commands/secret/delete.ts)_
_See code: [src/commands/secret/delete.ts](https://github.com/expo/eas-cli/blob/v0.56.0/packages/eas-cli/src/commands/secret/delete.ts)_
## `eas secret:list`
@@ -778,7 +814,7 @@ DESCRIPTION
list environment secrets available for your current app
```
_See code: [src/commands/secret/list.ts](https://github.com/expo/eas-cli/blob/v0.53.1/packages/eas-cli/src/commands/secret/list.ts)_
_See code: [src/commands/secret/list.ts](https://github.com/expo/eas-cli/blob/v0.56.0/packages/eas-cli/src/commands/secret/list.ts)_
## `eas submit`
@@ -808,7 +844,7 @@ ALIASES
$ eas build:submit
```
_See code: [src/commands/submit.ts](https://github.com/expo/eas-cli/blob/v0.53.1/packages/eas-cli/src/commands/submit.ts)_
_See code: [src/commands/submit.ts](https://github.com/expo/eas-cli/blob/v0.56.0/packages/eas-cli/src/commands/submit.ts)_
## `eas update`
@@ -838,7 +874,7 @@ DESCRIPTION
publish an update group
```
_See code: [src/commands/update/index.ts](https://github.com/expo/eas-cli/blob/v0.53.1/packages/eas-cli/src/commands/update/index.ts)_
_See code: [src/commands/update/index.ts](https://github.com/expo/eas-cli/blob/v0.56.0/packages/eas-cli/src/commands/update/index.ts)_
## `eas update:configure`
@@ -855,7 +891,7 @@ DESCRIPTION
configure the project to support EAS Update
```
_See code: [src/commands/update/configure.ts](https://github.com/expo/eas-cli/blob/v0.53.1/packages/eas-cli/src/commands/update/configure.ts)_
_See code: [src/commands/update/configure.ts](https://github.com/expo/eas-cli/blob/v0.56.0/packages/eas-cli/src/commands/update/configure.ts)_
## `eas update:delete GROUPID`
@@ -875,7 +911,7 @@ DESCRIPTION
delete all the updates in an update group
```
_See code: [src/commands/update/delete.ts](https://github.com/expo/eas-cli/blob/v0.53.1/packages/eas-cli/src/commands/update/delete.ts)_
_See code: [src/commands/update/delete.ts](https://github.com/expo/eas-cli/blob/v0.56.0/packages/eas-cli/src/commands/update/delete.ts)_
## `eas update:list`
@@ -894,7 +930,7 @@ DESCRIPTION
view the recent updates for a branch
```
_See code: [src/commands/update/list.ts](https://github.com/expo/eas-cli/blob/v0.53.1/packages/eas-cli/src/commands/update/list.ts)_
_See code: [src/commands/update/list.ts](https://github.com/expo/eas-cli/blob/v0.56.0/packages/eas-cli/src/commands/update/list.ts)_
## `eas update:view GROUPID`
@@ -914,7 +950,7 @@ DESCRIPTION
update group details
```
_See code: [src/commands/update/view.ts](https://github.com/expo/eas-cli/blob/v0.53.1/packages/eas-cli/src/commands/update/view.ts)_
_See code: [src/commands/update/view.ts](https://github.com/expo/eas-cli/blob/v0.56.0/packages/eas-cli/src/commands/update/view.ts)_
## `eas webhook:create`
@@ -934,7 +970,7 @@ DESCRIPTION
create a webhook
```
_See code: [src/commands/webhook/create.ts](https://github.com/expo/eas-cli/blob/v0.53.1/packages/eas-cli/src/commands/webhook/create.ts)_
_See code: [src/commands/webhook/create.ts](https://github.com/expo/eas-cli/blob/v0.56.0/packages/eas-cli/src/commands/webhook/create.ts)_
## `eas webhook:delete [ID]`
@@ -951,7 +987,7 @@ DESCRIPTION
delete a webhook
```
_See code: [src/commands/webhook/delete.ts](https://github.com/expo/eas-cli/blob/v0.53.1/packages/eas-cli/src/commands/webhook/delete.ts)_
_See code: [src/commands/webhook/delete.ts](https://github.com/expo/eas-cli/blob/v0.56.0/packages/eas-cli/src/commands/webhook/delete.ts)_
## `eas webhook:list`
@@ -968,7 +1004,7 @@ DESCRIPTION
list webhooks
```
_See code: [src/commands/webhook/list.ts](https://github.com/expo/eas-cli/blob/v0.53.1/packages/eas-cli/src/commands/webhook/list.ts)_
_See code: [src/commands/webhook/list.ts](https://github.com/expo/eas-cli/blob/v0.56.0/packages/eas-cli/src/commands/webhook/list.ts)_
## `eas webhook:update`
@@ -989,7 +1025,7 @@ DESCRIPTION
update a webhook
```
_See code: [src/commands/webhook/update.ts](https://github.com/expo/eas-cli/blob/v0.53.1/packages/eas-cli/src/commands/webhook/update.ts)_
_See code: [src/commands/webhook/update.ts](https://github.com/expo/eas-cli/blob/v0.56.0/packages/eas-cli/src/commands/webhook/update.ts)_
## `eas webhook:view ID`
@@ -1006,7 +1042,7 @@ DESCRIPTION
view a webhook
```
_See code: [src/commands/webhook/view.ts](https://github.com/expo/eas-cli/blob/v0.53.1/packages/eas-cli/src/commands/webhook/view.ts)_
_See code: [src/commands/webhook/view.ts](https://github.com/expo/eas-cli/blob/v0.56.0/packages/eas-cli/src/commands/webhook/view.ts)_
## `eas whoami`
+1678 -5
View File
File diff suppressed because it is too large. Load diff
+16 -6
View File
@@ -1,7 +1,7 @@
{
"name": "eas-cli",
"description": "EAS command line tool",
"version": "0.53.1",
"version": "0.56.0",
"author": "Expo <[email protected]>",
"bin": {
"eas": "./bin/run"
@@ -13,8 +13,8 @@
"@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.53.1",
"@expo/eas-build-job": "0.2.82",
"@expo/eas-json": "0.56.0",
"@expo/json-file": "8.2.36",
"@expo/multipart-body-parser": "1.1.0",
"@expo/pkcs12": "0.0.8",
@@ -61,6 +61,7 @@
"nullthrows": "1.1.1",
"ora": "5.1.0",
"pkg-dir": "4.2.0",
"promise-limit": "2.7.0",
"prompts": "2.4.2",
"qrcode-terminal": "0.12.0",
"resolve-from": "5.0.0",
@@ -102,7 +103,8 @@
"nock": "13.2.4",
"tempy": "0.7.0",
"ts-deepmerge": "2.0.1",
"typescript": "4.6.4"
"ts-mockito": "2.6.1",
"typescript": "4.7.4"
},
"engines": {
"node": ">=14.0.0"
@@ -110,7 +112,8 @@
"files": [
"/bin",
"/build",
"/oclif.manifest.json"
"/oclif.manifest.json",
"/schema"
],
"homepage": "https://github.com/expo/eas-cli",
"keywords": [
@@ -143,6 +146,9 @@
"device": {
"description": "manage Apple devices for Internal Distribution"
},
"metadata": {
"description": "manage store configuration"
},
"project": {
"description": "manage project"
},
@@ -161,7 +167,7 @@
},
"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": {
@@ -195,5 +201,9 @@
"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"
},
"volta": {
"node": "18.6.0",
"yarn": "1.22.19"
}
}
+409 -349
View File
@@ -19,102 +19,166 @@
]
},
"AppleKidsAge": {
"enum": ["FIVE_AND_UNDER", "SIX_TO_EIGHT", "NINE_TO_ELEVEN"]
"enum": [
"FIVE_AND_UNDER",
"SIX_TO_EIGHT",
"NINE_TO_ELEVEN"
]
},
"AppleRating": {
"enum": ["NONE", "INFREQUENT_OR_MILD", "FREQUENT_OR_INTENSE"]
"enum": [
"NONE",
"INFREQUENT_OR_MILD",
"FREQUENT_OR_INTENSE"
]
},
"AppleCategory": {
"enum": [
"FOOD_AND_DRINK",
"BUSINESS",
"EDUCATION",
"SOCIAL_NETWORKING",
"BOOKS",
"SPORTS",
"FINANCE",
"REFERENCE",
"GRAPHICS_AND_DESIGN",
"DEVELOPER_TOOLS",
"HEALTH_AND_FITNESS",
"MUSIC",
"WEATHER",
"TRAVEL",
"ENTERTAINMENT",
"STICKERS",
"GAMES",
"LIFESTYLE",
"MEDICAL",
"MAGAZINES_AND_NEWSPAPERS",
"UTILITIES",
"SHOPPING",
"PRODUCTIVITY",
"NEWS",
"PHOTO_AND_VIDEO",
"NAVIGATION"
]
},
"AppleSubcategory": {
"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",
"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"
]
},
"AppleIdfa": {
"description": "Identifier for Advertisers",
"type": "object",
"additionalProperties": false,
"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": [
{
"body": {
"servesAds": false,
"attributesAppInstallationToPreviousAd": false,
"attributesActionWithPreviousAd": false,
"honorsLimitedAdTracking": false
}
"$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"
}
]
}
],
"properties": {
"servesAds": {
"type": "boolean"
]
},
"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"
]
},
"attributesAppInstallationToPreviousAd": {
"type": "boolean"
"parent": {
"enum": [
"GAMES",
"STICKERS"
]
},
"attributesActionWithPreviousAd": {
"type": "boolean"
"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"
]
},
"honorsLimitedAdTracking": {
"type": "boolean"
"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"
]
}
}
},
@@ -123,240 +187,278 @@
"additionalProperties": false,
"required": [
"title",
"subtitle",
"description",
"keywords",
"marketingUrl",
"supportUrl",
"privacyPolicyUrl"
],
"defaultSnippets": [
{
"label": "Basic",
"description": "Create the minimum required locale properties",
"body": {
"title": "${1:App title}",
"privacyPolicyUrl": "https://${3:www.}"
}
},
{
"label": "Interactive",
"description": "Creates localized app info",
"label": "Full",
"description": "Create all available locale properties",
"body": {
"title": "$1",
"subtitle": "$2",
"description": "$3",
"keywords": [],
"marketingUrl": "",
"supportUrl": "",
"privacyPolicyUrl": ""
"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": {
"description": "Name of the app in the store. This should be similar to the installed app name.",
"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": {
"description": "Subtext for the app. Example: 'A Fun Game For Friends'",
"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
},
"maxLength": 4000,
"description": "Main description of what the app does",
"type": "string"
},
"promoText": {
"meta": {
"versioned": true,
"liveEdits": true,
"aso": false
},
"maxLength": 170,
"description": "Short tagline for the app",
"type": "string"
}
},
"keywords": {
"meta": {
"versioned": true
},
"description": "List of keywords to help users find the app in the App Store",
"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"
},
"whatsNew": {
"meta": {
"versioned": true,
"liveEdits": true
},
"maxLength": 4000,
"description": "Changes since the last public version",
"type": "string"
},
"marketingUrl": {
"meta": {
"versioned": true,
"liveEdits": true
},
"description": "URL to the app marketing page",
"type": "string"
},
"supportUrl": {
"meta": {
"versioned": true,
"liveEdits": true
},
"description": "URL to the app support page",
"type": "string"
},
"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
},
"maxLength": 255,
"description": "A URL that links to your privacy policy. A privacy policy is required for all apps.",
"type": "string"
}
},
"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
},
"maxLength": 255,
"description": "A URL where users can modify and delete the data collected from the app, or decide how their data is used and shared.",
"type": "string"
}
}
}
},
"AppleAdvisory": {
"type": "object",
"additionalProperties": false,
"required": [
"alcoholTobaccoOrDrugUseOrReferences",
"contests",
"gamblingSimulated",
"horrorOrFearThemes",
"matureOrSuggestiveThemes",
"medicalOrTreatmentInformation",
"profanityOrCrudeHumor",
"sexualContentGraphicAndNudity",
"sexualContentOrNudity",
"violenceCartoonOrFantasy",
"violenceRealistic",
"violenceRealisticProlongedGraphicOrSadistic",
"gambling",
"unrestrictedWebAccess",
"kidsAgeBand",
"seventeenPlus"
],
"defaultSnippets": [
{
"label": "Minimum",
"description": "The bare minimum rating, your app does nothing",
"body": {
"alcoholTobaccoOrDrugUseOrReferences": "NONE",
"contests": "NONE",
"gamblingSimulated": "NONE",
"medicalOrTreatmentInformation": "NONE",
"profanityOrCrudeHumor": "NONE",
"sexualContentGraphicAndNudity": "NONE",
"sexualContentOrNudity": "NONE",
"horrorOrFearThemes": "NONE",
"matureOrSuggestiveThemes": "NONE",
"violenceCartoonOrFantasy": "NONE",
"violenceRealisticProlongedGraphicOrSadistic": "NONE",
"violenceRealistic": "NONE",
"gamblingAndContests":false,
"gambling": false,
"kidsAgeBand": null,
"seventeenPlus": false,
"unrestrictedWebAccess": false
}
},
{
"label": "Interactive",
"description": "Step through options",
"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|}",
"medicalOrTreatmentInformation": "${4|NONE,INFREQUENT_OR_MILD,FREQUENT_OR_INTENSE|}",
"profanityOrCrudeHumor": "${5|NONE,INFREQUENT_OR_MILD,FREQUENT_OR_INTENSE|}",
"sexualContentGraphicAndNudity": "${6|NONE,INFREQUENT_OR_MILD,FREQUENT_OR_INTENSE|}",
"sexualContentOrNudity": "${7|NONE,INFREQUENT_OR_MILD,FREQUENT_OR_INTENSE|}",
"horrorOrFearThemes": "${8|NONE,INFREQUENT_OR_MILD,FREQUENT_OR_INTENSE|}",
"matureOrSuggestiveThemes": "${9|NONE,INFREQUENT_OR_MILD,FREQUENT_OR_INTENSE|}",
"violenceCartoonOrFantasy": "${10|NONE,INFREQUENT_OR_MILD,FREQUENT_OR_INTENSE|}",
"violenceRealisticProlongedGraphicOrSadistic": "${11|NONE,INFREQUENT_OR_MILD,FREQUENT_OR_INTENSE|}",
"violenceRealistic": "${12|NONE,INFREQUENT_OR_MILD,FREQUENT_OR_INTENSE|}",
"gamblingAndContests":false,
"gambling": false,
"kidsAgeBand": null,
"seventeenPlus": false,
"unrestrictedWebAccess": false
}
{
"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"
"$ref": "#/definitions/apple/AppleRating",
"description": "Does the app contain alcohol, tobacco, or drug use or references?"
},
"contests": {
"$ref": "#/definitions/apple/AppleRating"
},
"gamblingAndContests": {
"type": "boolean"
},
"gambling": {
"type": "boolean"
"$ref": "#/definitions/apple/AppleRating",
"description": "Does the app contain contests?"
},
"gamblingSimulated": {
"$ref": "#/definitions/apple/AppleRating"
},
"kidsAgeBand": {
"anyOf": [
{
"$ref": "#/definitions/apple/AppleKidsAge"
},
{
"type": "null"
}
]
"$ref": "#/definitions/apple/AppleRating",
"description": "Does the app contain simulated gambling?"
},
"medicalOrTreatmentInformation": {
"$ref": "#/definitions/apple/AppleRating"
"$ref": "#/definitions/apple/AppleRating",
"description": "Does the app contain medical or treatment information?"
},
"profanityOrCrudeHumor": {
"$ref": "#/definitions/apple/AppleRating"
"$ref": "#/definitions/apple/AppleRating",
"description": "Does the app contain profanity or crude humor?"
},
"sexualContentGraphicAndNudity": {
"$ref": "#/definitions/apple/AppleRating"
"$ref": "#/definitions/apple/AppleRating",
"description": "Does the app contain graphic sexual content and nudity?"
},
"sexualContentOrNudity": {
"$ref": "#/definitions/apple/AppleRating"
},
"seventeenPlus": {
"type": "boolean"
"$ref": "#/definitions/apple/AppleRating",
"description": "Does the app contain sexual content or nudity?"
},
"horrorOrFearThemes": {
"$ref": "#/definitions/apple/AppleRating"
"$ref": "#/definitions/apple/AppleRating",
"description": "Does the app contain horror or fear themes?"
},
"matureOrSuggestiveThemes": {
"$ref": "#/definitions/apple/AppleRating"
},
"unrestrictedWebAccess": {
"type": "boolean"
"$ref": "#/definitions/apple/AppleRating",
"description": "Does the app contain mature or suggestive themes?"
},
"violenceCartoonOrFantasy": {
"$ref": "#/definitions/apple/AppleRating"
"$ref": "#/definitions/apple/AppleRating",
"description": "Does the app contain cartoon or fantasy violence?"
},
"violenceRealisticProlongedGraphicOrSadistic": {
"$ref": "#/definitions/apple/AppleRating"
"$ref": "#/definitions/apple/AppleRating",
"description": "Does the app contain prolonged graphic or sadistic realistic violence?"
},
"violenceRealistic": {
"$ref": "#/definitions/apple/AppleRating"
"$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.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.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)"
}
}
},
@@ -402,8 +504,8 @@
}
},
"shouldResetRatings": {
"description": "Should App Store ratings be reset for this version of the app.",
"type": "boolean",
"description": "Should App Store ratings be reset for this version of the app.",
"meta": {
"storeInfo": "You can reset your app’s summary rating for all countries or regions when you release this version. Keep in mind that once this version is released, you won’t be able to restore the rating. Your app’s existing customer reviews will still appear on the App Store."
}
@@ -412,144 +514,107 @@
"type": "boolean"
},
"automaticRelease": {
"description": "Should the app automatically be released when approved by App Store review.",
"type": "boolean"
"type": "boolean",
"description": "Should the app automatically be released when approved by App Store review."
},
"usesNonExemptEncryption": {
"description": "Alternative to setting `ITSAppUsesNonExemptEncryption` in the binary's `Info.plist`.",
"type": "boolean"
"type": "boolean",
"description": "Alternative to setting `ITSAppUsesNonExemptEncryption` in the binary's `Info.plist`."
}
}
},
"AppleReview": {
"type": "object",
"description": "Info provided to the App Store review team to help them understand how to use your app.",
"required": ["firstName", "lastName"],
"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"
"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": {
"demoUsername": {
"description": "Optional username for testing the app.",
"type": "string"
},
"demoPassword": {
"description": "Optional password for testing the app.",
"type": "string"
},
"attachment": {
"description": "Local file path to upload to the review team.",
"type": "string"
},
"firstName": {
"description": "Developer first name for the reviewer to contact.",
"type": "string"
"type": "string",
"description": "First name of contact in case communication is needed with the App Store review team.",
"minLength": 1
},
"lastName": {
"description": "Developer last name (surname) for the reviewer to contact.",
"type": "string"
},
"phone": {
"description": "Developer phone number for the reviewer to contact.",
"type": "string"
"type": "string",
"description": "Last name of contact in case communication is needed with the App Store review team.",
"minLength": 1
},
"email": {
"description": "Developer email address for the reviewer to contact.",
"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": {
"description": "Any additional notes for the App Store reviewer.",
"type": "string"
"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": {
"description": "Configuration that is specific to the Apple App Store.",
"type": "object",
"description": "Configuration that is specific to the Apple App Store.",
"additionalProperties": false,
"properties": {
"advisory": {
"$ref": "#/definitions/apple/AppleAdvisory"
},
"categories": {
"type": "array",
"maxItems": 2,
"description": "App Store categories for the app. Can add up to two categories. STICKERS and GAMES categories can add an additional subcategory.",
"items": {
"anyOf": [
{
"$ref": "#/definitions/apple/AppleCategory"
},
{
"type": "array",
"maxItems": 2,
"items": [
{
"enum": ["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"
]
}
],
"additionalItems": false
},
{
"type": "array",
"maxItems": 2,
"items": [
{
"enum": ["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"
]
}
],
"additionalItems": false
}
]
}
"$ref": "#/definitions/apple/AppleCategory"
},
"copyright": {
"type": "string",
"description": "The updated company copyright. Example: 2021 Evan Bacon",
"defaultSnippets": [
{
"body": "$CURRENT_YEAR ${1:name}"
@@ -559,12 +624,7 @@
"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."
},
"description": "The updated company copyright. Example: 2021 Evan Bacon",
"type": "string"
},
"idfa": {
"$ref": "#/definitions/apple/AppleIdfa"
}
},
"info": {
"description": "Localized core app info",
@@ -901,28 +961,28 @@
]
},
"release": {
"description": "Release strategy",
"$ref": "#/definitions/apple/AppleRelease"
"$ref": "#/definitions/apple/AppleRelease",
"description": "Release strategy"
},
"review": {
"description": "Info for the App Store reviewer",
"$ref": "#/definitions/apple/AppleReview"
},
"priceTier": {
"description": "App price tier. 0 is Free.",
"type": "number"
"$ref": "#/definitions/apple/AppleReview",
"description": "Info for the App Store reviewer"
}
}
}
},
"properties": {
"configVersion": {
"const": 0
"const": 0,
"description": "The EAS metadata store configuration version"
},
"apple": {
"$ref": "#/definitions/AppleConfig"
}
},
"additionalProperties": false,
"required": ["configVersion", "apple"]
"required": [
"configVersion",
"apple"
]
}
@@ -0,0 +1,17 @@
import { fixS3Url } from '../uploads';
describe(fixS3Url, () => {
it('fixes broken links', () => {
const input =
'https://submission-service-archives.s3.amazonaws.com/production%2Fdc98ca84-1473-4cb3-ae81-8c7b291cb27e%2F4424aa95-b985-4e2f-8755-9507b1037c1c';
const expectedOutput =
'https://submission-service-archives.s3.amazonaws.com/production/dc98ca84-1473-4cb3-ae81-8c7b291cb27e/4424aa95-b985-4e2f-8755-9507b1037c1c';
expect(fixS3Url(input)).toBe(expectedOutput);
});
it('does not change correct urls', () => {
const correctUrl =
'https://submission-service-archives.s3.amazonaws.com/production/dc98ca84-1473-4cb3-ae81-8c7b291cb27e/4424aa95-b985-4e2f-8755-9507b1037c1c';
expect(fixS3Url(correctUrl)).toBe(correctUrl);
});
});
@@ -0,0 +1,40 @@
import { instance, mock, when } from 'ts-mockito';
import { Client } from '../../vcs/vcs';
import { getFormattedCommitMessageAsync } from '../metadata';
describe(getFormattedCommitMessageAsync, () => {
it(`returns undefined if it's not a git project`, async () => {
const clientMock = mock<Client>();
when(clientMock.getLastCommitMessageAsync()).thenResolve(null);
const client = instance(clientMock);
await expect(getFormattedCommitMessageAsync(client)).resolves.toBe(undefined);
});
it('returns commit message', async () => {
const clientMock = mock<Client>();
when(clientMock.getLastCommitMessageAsync()).thenResolve('lorem ipsum');
const client = instance(clientMock);
await expect(getFormattedCommitMessageAsync(client)).resolves.toBe('lorem ipsum');
});
it('trims long commit messages', async () => {
const clientMock = mock<Client>();
when(clientMock.getLastCommitMessageAsync()).thenResolve('a'.repeat(1500));
const client = instance(clientMock);
await expect(getFormattedCommitMessageAsync(client)).resolves.toBe(`${'a'.repeat(1021)}...`);
});
it('replaces new lines with spaces', async () => {
const clientMock = mock<Client>();
when(clientMock.getLastCommitMessageAsync()).thenResolve(`Lorem\nipsum\ndolor\nsit\namet`);
const client = instance(clientMock);
await expect(getFormattedCommitMessageAsync(client)).resolves.toBe(
`Lorem ipsum dolor sit amet`
);
});
});
@@ -89,7 +89,7 @@ describe(bumpVersionAsync, () => {
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 });
+4 -1
View File
@@ -5,6 +5,7 @@ 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 {
@@ -87,7 +88,8 @@ 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);
@@ -95,6 +97,7 @@ export async function prepareAndroidBuildAsync(
appId,
job: graphqlJob,
metadata: graphqlMetadata,
buildParams,
});
},
});
+24 -10
View File
@@ -11,6 +11,7 @@ import {
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 +47,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 +76,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 +118,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> {
+28 -7
View File
@@ -11,6 +11,7 @@ import { getExpoWebsiteBaseUrl } from '../api';
import {
AppPlatform,
BuildFragment,
BuildParamsInput,
BuildPriority,
BuildStatus,
UploadSessionType,
@@ -24,7 +25,7 @@ import {
appPlatformEmojis,
requestedPlatformDisplayNames,
} from '../platform';
import { uploadAsync } from '../uploads';
import { uploadFileAtPathToS3Async } from '../uploads';
import { formatBytes } from '../utils/files';
import { createProgressTracker } from '../utils/progress';
import { sleepAsync } from '../utils/promise';
@@ -53,11 +54,22 @@ 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>): BuildParamsInput {
return {
resourceClass: ctx.resourceClass,
};
}
export async function prepareBuildRequestForPlatformAsync<
TPlatform extends Platform,
Credentials,
@@ -100,6 +112,7 @@ export async function prepareBuildRequestForPlatformAsync<
} as const);
const metadata = await collectMetadataAsync(ctx);
const buildParams = resolveBuildParamsInput(ctx);
const job = await builder.prepareJobAsync(ctx, {
projectArchive,
credentials: credentialsResult?.credentials,
@@ -111,7 +124,7 @@ export async function prepareBuildRequestForPlatformAsync<
return undefined;
} else {
try {
return await sendBuildRequestAsync(builder, job, metadata);
return await sendBuildRequestAsync(builder, job, metadata, buildParams);
} catch (error: any) {
handleBuildRequestError(error, job.platform);
}
@@ -166,7 +179,7 @@ async function uploadProjectAsync<TPlatform extends Platform>(
const projectTarball = await makeProjectTarballAsync();
projectTarballPath = projectTarball.path;
const { bucketKey } = await uploadAsync(
const { bucketKey } = await uploadFileAtPathToS3Async(
UploadSessionType.EasBuildProjectSources,
projectTarball.path,
createProgressTracker({
@@ -197,7 +210,8 @@ 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(
@@ -209,7 +223,8 @@ async function sendBuildRequestAsync<TPlatform extends Platform, Credentials, TJ
const { build, deprecationInfo } = await builder.sendBuildRequestAsync(
ctx.projectId,
job,
metadata
metadata,
buildParams
);
printDeprecationWarnings(deprecationInfo);
@@ -337,7 +352,7 @@ async function handleSingleBuildProgressAsync(
);
}
Log.newLine();
Log.log('Waiting in queue');
Log.log(`Waiting in ${priorityToQueueDisplayName[build.priority]}`);
queueProgressBar.start(
build.initialQueuePosition + 1,
build.initialQueuePosition - build.queuePosition + 1,
@@ -370,6 +385,12 @@ async function handleSingleBuildProgressAsync(
return { refetch: true };
}
const priorityToQueueDisplayName: Record<BuildPriority, string> = {
[BuildPriority.Normal]: 'queue',
[BuildPriority.NormalPlus]: 'queue',
[BuildPriority.High]: 'priority queue',
};
const statusToDisplayName: Record<BuildStatus, string> = {
[BuildStatus.New]: 'waiting to enter the queue (concurrency limit reached)',
[BuildStatus.InQueue]: 'in queue',
+5 -3
View File
@@ -1,11 +1,11 @@
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 { CredentialsContext } from '../credentials/context';
import { Target } from '../credentials/ios/types';
import { BuildResourceClass } from '../graphql/generated';
import { GradleBuildContext } from '../project/android/gradle';
import { XcodeBuildContext } from '../project/ios/scheme';
import { Actor } from '../user/User';
@@ -20,7 +20,6 @@ export interface AndroidBuildContext {
export interface IosBuildContext {
bundleIdentifier: string;
applicationTargetBuildSettings: XCBuildConfiguration['buildSettings'];
applicationTarget: Target;
targets: Target[];
xcodeBuildContext: XcodeBuildContext;
@@ -28,8 +27,10 @@ export interface IosBuildContext {
export interface BuildContext<T extends Platform> {
accountName: string;
easJsonCliConfig: EasJson['cli'];
buildProfile: BuildProfile<T>;
buildProfileName: string;
resourceClass: BuildResourceClass;
clearCache: boolean;
credentialsCtx: CredentialsContext;
exp: ExpoConfig;
@@ -39,6 +40,7 @@ export interface BuildContext<T extends Platform> {
projectDir: string;
projectId: string;
projectName: string;
message?: string;
trackingCtx: TrackingContext;
user: Actor;
workflow: Workflow;
+11 -1
View File
@@ -1,5 +1,5 @@
import { Platform } from '@expo/eas-build-job';
import { BuildProfile } from '@expo/eas-json';
import { BuildProfile, EasJson } from '@expo/eas-json';
import JsonFile from '@expo/json-file';
import resolveFrom from 'resolve-from';
import { v4 as uuidv4 } from 'uuid';
@@ -7,6 +7,7 @@ import { v4 as uuidv4 } from 'uuid';
import { TrackingContext } from '../analytics/common';
import { Analytics, BuildEvent } from '../analytics/events';
import { CredentialsContext } from '../credentials/context';
import { BuildResourceClass } from '../graphql/generated';
import { getExpoConfig } from '../project/expoConfig';
import { getProjectAccountName, getProjectIdAsync } from '../project/projectUtils';
import { resolveWorkflowAsync } from '../project/workflow';
@@ -20,19 +21,25 @@ import { LocalBuildOptions } from './local';
export async function createBuildContextAsync<T extends Platform>({
buildProfileName,
buildProfile,
easJsonCliConfig,
clearCache = false,
localBuildOptions,
nonInteractive = false,
platform,
projectDir,
resourceClass,
message,
}: {
buildProfileName: string;
buildProfile: BuildProfile<T>;
easJsonCliConfig: EasJson['cli'];
clearCache: boolean;
localBuildOptions: LocalBuildOptions;
nonInteractive: boolean;
platform: T;
projectDir: string;
resourceClass: BuildResourceClass;
message?: string;
}): Promise<BuildContext<T>> {
const exp = getExpoConfig(projectDir, { env: buildProfile.env });
@@ -70,6 +77,8 @@ export async function createBuildContextAsync<T extends Platform>({
accountName,
buildProfile,
buildProfileName,
resourceClass,
easJsonCliConfig,
clearCache,
credentialsCtx,
exp,
@@ -82,6 +91,7 @@ export async function createBuildContextAsync<T extends Platform>({
trackingCtx,
user,
workflow,
message,
};
if (platform === Platform.ANDROID) {
const common = commonContext as CommonContext<Platform.ANDROID>;
@@ -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,14 +58,34 @@ 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',
exp: fakeExp,
buildSettings: {},
targets: [
{
targetName: 'example',
bundleIdentifier: 'example.com',
entitlements: {},
},
],
});
const appJSON = await fs.readJSON('/repo/app.json');
@@ -76,13 +103,25 @@ 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',
exp: fakeExp,
buildSettings: {
INFOPLIST_FILE: '$(SRCROOT)/myproject/Info2.plist',
},
targets: [
{
targetName: 'example',
bundleIdentifier: 'example.com',
entitlements: {},
},
],
});
const appJSON = await fs.readJSON('/repo/app.json');
@@ -97,14 +136,27 @@ 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',
exp: fakeExp,
buildSettings: {},
targets: [
{
targetName: 'example',
bundleIdentifier: 'example.com',
entitlements: {},
},
],
});
const appJSON = await fs.readJSON('/repo/app.json');
@@ -121,12 +173,25 @@ 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',
exp: fakeExp,
buildSettings: {},
targets: [
{
targetName: 'example',
bundleIdentifier: 'example.com',
entitlements: {},
},
],
});
const appJSON = await fs.readJSON('/repo/app.json');
@@ -288,9 +353,9 @@ describe(getInfoPlistPath, () => {
function initBareWorkflowProject({
appVersion = '1.0.0',
version = '1',
buildNumber = '1',
infoPlistName = 'Info.plist',
}: { appVersion?: string; version?: string; infoPlistName?: string } = {}): ExpoConfig {
}: { appVersion?: string; buildNumber?: string; infoPlistName?: string } = {}): ExpoConfig {
const fakeExp: ExpoConfig = {
name: 'myproject',
slug: 'myproject',
@@ -311,7 +376,7 @@ function initBareWorkflowProject({
<key>CFBundleShortVersionString</key>
<string>${appVersion}</string>
<key>CFBundleVersion</key>
<string>${version}</string>
<string>${buildNumber}</string>
</dict>
</plist>`,
},
+6 -22
View File
@@ -1,8 +1,7 @@
import { IOSConfig } from '@expo/config-plugins';
import { Ios, Job, Metadata, Platform, Workflow } from '@expo/eas-build-job';
import type { XCBuildConfiguration } from 'xcode';
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';
@@ -43,12 +42,10 @@ export async function createIosContextAsync(
env: buildProfile.env,
});
const applicationTarget = findApplicationTarget(targets);
const applicationTargetBuildSettings = resolveBuildSettings(ctx, applicationTarget);
return {
bundleIdentifier: applicationTarget.bundleIdentifier,
applicationTarget,
applicationTargetBuildSettings,
targets,
xcodeBuildContext,
};
@@ -67,7 +64,7 @@ export async function prepareIosBuildAsync(
projectDir: ctx.projectDir,
exp: ctx.exp,
buildProfile: ctx.buildProfile,
buildSettings: ctx.ios.applicationTargetBuildSettings,
targets: ctx.ios.targets,
});
},
prepareJobAsync: async (
@@ -82,7 +79,8 @@ 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);
@@ -90,22 +88,8 @@ export async function prepareIosBuildAsync(
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,8 +1,8 @@
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 { Target } from '../../credentials/ios/types';
import { isExpoUpdatesInstalled } from '../../project/projectUtils';
import { resolveWorkflowAsync } from '../../project/workflow';
import { syncUpdatesConfigurationAsync } from '../../update/ios/UpdatesModule';
@@ -12,12 +12,12 @@ export async function syncProjectConfigurationAsync({
projectDir,
exp,
buildProfile,
buildSettings,
targets,
}: {
projectDir: string;
exp: ExpoConfig;
buildProfile: BuildProfile<Platform.IOS>;
buildSettings: XCBuildConfiguration['buildSettings'];
targets: Target[];
}): Promise<void> {
const workflow = await resolveWorkflowAsync(projectDir, Platform.IOS);
const { autoIncrement } = buildProfile;
@@ -27,7 +27,7 @@ export async function syncProjectConfigurationAsync({
if (isExpoUpdatesInstalled(projectDir)) {
await syncUpdatesConfigurationAsync(projectDir, exp);
}
await bumpVersionAsync({ projectDir, exp, bumpStrategy: versionBumpStrategy, buildSettings });
await bumpVersionAsync({ projectDir, exp, bumpStrategy: versionBumpStrategy, targets });
} else {
await bumpVersionInAppJsonAsync({ projectDir, exp, bumpStrategy: versionBumpStrategy });
}
+71 -42
View File
@@ -1,16 +1,20 @@
import { ExpoConfig, getConfigFilePaths } from '@expo/config';
import { ExpoConfig } from '@expo/config';
import { IOSConfig } from '@expo/config-plugins';
import { Platform, Workflow } from '@expo/eas-build-job';
import chalk from 'chalk';
import path from 'path';
import type { XCBuildConfiguration } from 'xcode';
import { Target } from '../../credentials/ios/types';
import Log from '../../log';
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';
export enum BumpStrategy {
APP_VERSION,
@@ -22,21 +26,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 +67,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
@@ -121,12 +127,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 +152,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 +179,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(
+21 -8
View File
@@ -17,6 +17,7 @@ import {
import { ensureLoggedInAsync } from '../user/actions';
import { easCliVersion } from '../utils/easCli';
import { getVcsClient } from '../vcs';
import { Client } from '../vcs/vcs';
import { maybeResolveVersionsAsync as maybeResolveAndroidVersionsAsync } from './android/version';
import { BuildContext } from './context';
import { maybeResolveVersionsAsync as maybeResolveIosVersionsAsync } from './ios/version';
@@ -29,7 +30,9 @@ export async function collectMetadataAsync<T extends Platform>(
('simulator' in ctx.buildProfile && ctx.buildProfile.simulator
? 'simulator'
: ctx.buildProfile.distribution) ?? 'store';
const metadata = {
const vcsClient = getVcsClient();
const metadata: Metadata = {
trackingContext: ctx.trackingCtx,
...(await maybeResolveVersionsAsync(ctx)),
cliVersion: easCliVersion,
@@ -43,9 +46,10 @@ export async function collectMetadataAsync<T extends Platform>(
appName: ctx.exp.name,
appIdentifier: resolveAppIdentifier(ctx),
buildProfile: ctx.buildProfileName,
gitCommitHash: await getVcsClient().getCommitHashAsync(),
isGitWorkingTreeDirty: await getVcsClient().hasUncommittedChangesAsync(),
gitCommitHash: await vcsClient.getCommitHashAsync(),
isGitWorkingTreeDirty: await vcsClient.hasUncommittedChangesAsync(),
username: getUsername(ctx.exp, await ensureLoggedInAsync()),
message: ctx.message ?? (await getFormattedCommitMessageAsync(vcsClient)),
...(ctx.platform === Platform.IOS && {
iosEnterpriseProvisioning: resolveIosEnterpriseProvisioning(
ctx as BuildContext<Platform.IOS>
@@ -60,11 +64,7 @@ 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(
ctx.projectDir,
ctx.exp,
iosContext.ios.applicationTargetBuildSettings
);
return await maybeResolveIosVersionsAsync(ctx.projectDir, ctx.exp, iosContext.ios.targets);
} else if (ctx.platform === Platform.ANDROID) {
const androidCtx = ctx as BuildContext<Platform.ANDROID>;
return await maybeResolveAndroidVersionsAsync(ctx.projectDir, ctx.exp, androidCtx.buildProfile);
@@ -149,3 +149,16 @@ function resolveIosEnterpriseProvisioning(
): IosEnterpriseProvisioning | undefined {
return ctx.buildProfile.enterpriseProvisioning;
}
export async function getFormattedCommitMessageAsync(
vcsClient: Client
): Promise<string | undefined> {
const maybeCommitMessage = (await vcsClient.getLastCommitMessageAsync())?.trim();
if (!maybeCommitMessage) {
return undefined;
}
const lines = maybeCommitMessage.split('\n');
const withoutEmptyLines = lines.filter(i => i);
const message = withoutEmptyLines.join(' ');
return message.length > 1024 ? message.substring(0, 1021) + '...' : message;
}
@@ -1,9 +1,15 @@
import { Platform, Workflow } from '@expo/eas-build-job';
import { BuildProfile, SubmitProfile } from '@expo/eas-json';
import { BuildProfile, EasJson, EasJsonReader, SubmitProfile } from '@expo/eas-json';
import chalk from 'chalk';
import nullthrows from 'nullthrows';
import { AppPlatform, BuildFragment, BuildStatus, SubmissionFragment } from '../graphql/generated';
import {
AppPlatform,
BuildFragment,
BuildResourceClass,
BuildStatus,
SubmissionFragment,
} from '../graphql/generated';
import { toAppPlatform, toPlatform } from '../graphql/types/AppPlatform';
import Log from '../log';
import {
@@ -30,6 +36,7 @@ import { BuildContext } from './context';
import { createBuildContextAsync } from './createContext';
import { prepareIosBuildAsync } from './ios/build';
import { LocalBuildOptions } from './local';
import { UserInputResourceClass } from './types';
import { ensureExpoDevClientInstalledForDevClientBuildsAsync } from './utils/devClient';
import { printBuildResults, printLogsUrls } from './utils/printBuildInfo';
import { ensureRepoIsCleanAsync } from './utils/repository';
@@ -47,8 +54,24 @@ export interface BuildFlags {
autoSubmit: boolean;
submitProfile?: string;
localBuildOptions: LocalBuildOptions;
userInputResourceClass?: UserInputResourceClass;
message?: string;
}
const platformToGraphQLResourceClassMapping: Record<
Platform,
Record<UserInputResourceClass, BuildResourceClass>
> = {
[Platform.ANDROID]: {
[UserInputResourceClass.DEFAULT]: BuildResourceClass.AndroidDefault,
[UserInputResourceClass.LARGE]: BuildResourceClass.AndroidLarge,
},
[Platform.IOS]: {
[UserInputResourceClass.DEFAULT]: BuildResourceClass.IosDefault,
[UserInputResourceClass.LARGE]: BuildResourceClass.IosLarge,
},
};
export async function runBuildAndSubmitAsync(projectDir: string, flags: BuildFlags): Promise<void> {
await getVcsClient().ensureRepoExistsAsync();
await ensureRepoIsCleanAsync(flags.nonInteractive);
@@ -57,11 +80,13 @@ export async function runBuildAndSubmitAsync(projectDir: string, flags: BuildFla
projectDir,
nonInteractive: flags.nonInteractive,
});
const easJsonReader = new EasJsonReader(projectDir);
const easJsonCliConfig: EasJson['cli'] = (await easJsonReader.getCliConfigAsync()) ?? {};
const platforms = toPlatforms(flags.requestedPlatform);
const buildProfiles = await getProfilesAsync({
type: 'build',
projectDir,
easJsonReader,
platforms,
profileName: flags.profile ?? undefined,
});
@@ -84,6 +109,11 @@ export async function runBuildAndSubmitAsync(projectDir: string, flags: BuildFla
flags,
moreBuilds: platforms.length > 1,
buildProfile,
resourceClass:
platformToGraphQLResourceClassMapping[buildProfile.platform][
flags.userInputResourceClass ?? UserInputResourceClass.DEFAULT
],
easJsonCliConfig,
});
if (maybeBuild) {
startedBuilds.push({ build: maybeBuild, buildProfile });
@@ -102,7 +132,7 @@ export async function runBuildAndSubmitAsync(projectDir: string, flags: BuildFla
const submissions: SubmissionFragment[] = [];
if (flags.autoSubmit) {
const submitProfiles = await getProfilesAsync({
projectDir,
easJsonReader,
platforms,
profileName: flags.submitProfile,
type: 'submit',
@@ -160,20 +190,27 @@ async function prepareAndStartBuildAsync({
flags,
moreBuilds,
buildProfile,
resourceClass,
easJsonCliConfig,
}: {
projectDir: string;
flags: BuildFlags;
moreBuilds: boolean;
buildProfile: ProfileData<'build'>;
resourceClass: BuildResourceClass;
easJsonCliConfig: EasJson['cli'];
}): Promise<{ build: BuildFragment | undefined; buildCtx: BuildContext<Platform> }> {
const buildCtx = await createBuildContextAsync({
buildProfileName: buildProfile.profileName,
resourceClass,
clearCache: flags.clearCache,
buildProfile: buildProfile.profile,
nonInteractive: flags.nonInteractive,
platform: buildProfile.platform,
projectDir,
localBuildOptions: flags.localBuildOptions,
easJsonCliConfig,
message: flags.message,
});
if (moreBuilds) {
+5
View File
@@ -12,3 +12,8 @@ export enum BuildDistributionType {
INTERNAL = 'internal',
SIMULATOR = 'simulator',
}
export enum UserInputResourceClass {
DEFAULT = 'default',
LARGE = 'large',
}
+13
View File
@@ -35,3 +35,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();
}
+20 -75
View File
@@ -1,21 +1,13 @@
import { EasJsonReader } from '@expo/eas-json';
import { Errors, Flags } from '@oclif/core';
import chalk from 'chalk';
import figures from 'figures';
import fs from 'fs-extra';
import path from 'path';
import { BuildFlags, runBuildAndSubmitAsync } from '../../build/runBuildAndSubmit';
import { ensureRepoIsCleanAsync, reviewAndCommitChangesAsync } from '../../build/utils/repository';
import { UserInputResourceClass } from '../../build/types';
import EasCommand from '../../commandUtils/EasCommand';
import Log, { learnMore, link } from '../../log';
import Log from '../../log';
import { RequestedPlatform, selectRequestedPlatformAsync } from '../../platform';
import { findProjectRootAsync } from '../../project/projectUtils';
import { selectAsync } from '../../prompts';
import { easCliVersion } from '../../utils/easCli';
import { enableJsonOutput } from '../../utils/json';
import { getVcsClient, setVcsClient } from '../../vcs';
import GitClient from '../../vcs/clients/git';
interface RawBuildFlags {
platform?: string;
@@ -30,6 +22,8 @@ interface RawBuildFlags {
json: boolean;
'auto-submit': boolean;
'auto-submit-with-profile'?: string;
'resource-class'?: UserInputResourceClass;
message?: string;
}
export default class Build extends EasCommand {
@@ -88,6 +82,15 @@ export default class Build extends EasCommand {
helpValue: 'PROFILE_NAME',
exclusive: ['auto-submit'],
}),
'resource-class': Flags.enum({
options: Object.values(UserInputResourceClass),
hidden: true,
description: 'The instance type that will be used to run this build [experimental]',
}),
message: Flags.string({
char: 'm',
description: 'A short message describing the build',
}),
};
async runAsync(): Promise<void> {
@@ -98,7 +101,6 @@ export default class Build extends EasCommand {
const flags = await this.sanitizeFlagsAsync(rawFlags);
const projectDir = await findProjectRootAsync();
await handleDeprecatedEasJsonAsync(projectDir, flags.nonInteractive);
await runBuildAndSubmitAsync(projectDir, flags);
}
@@ -146,6 +148,11 @@ export default class Build extends EasCommand {
Log.newLine();
}
const message = flags['message'];
if (message && message.length > 1024) {
Errors.error('Message cannot be longer than 1024 characters.', { exit: 1 });
}
const profile = flags['profile'];
return {
requestedPlatform,
@@ -165,70 +172,8 @@ export default class Build extends EasCommand {
json: flags['json'],
autoSubmit: flags['auto-submit'] || flags['auto-submit-with-profile'] !== undefined,
submitProfile: flags['auto-submit-with-profile'] ?? profile,
userInputResourceClass: flags['resource-class'] ?? UserInputResourceClass.DEFAULT,
message,
};
}
}
export async function handleDeprecatedEasJsonAsync(
projectDir: string,
nonInteractive: boolean
): Promise<void> {
const easJsonPath = EasJsonReader.formatEasJsonPath(projectDir);
if (!(await fs.pathExists(easJsonPath))) {
return;
}
const easJsonReader = new EasJsonReader(projectDir);
const rawEasJson = await easJsonReader.readAsync();
if (rawEasJson?.cli) {
return;
}
if (nonInteractive) {
Log.warn(
`${
figures.warning
} Action required: the default behavior of EAS CLI has changed and your eas.json must be updated to remove ambiguity around which Git integration workflow to use. Refer to ${link(
'https://expo.fyi/eas-vcs-workflow'
)} for more information.`
);
Log.warn(
'This warning will become an error in an upcoming EAS CLI release. For now, we will proceed with the old default behavior to avoid disruption of your builds.'
);
setVcsClient(new GitClient());
return;
}
Log.log(
`${chalk.bold(
'eas-cli@>=0.34.0 no longer requires that you commit changes to Git before starting a build.'
)} ${learnMore('https://expo.fyi/eas-vcs-workflow')}`
);
Log.log(
`If you want to continue using the Git integration, you can opt in with ${chalk.bold(
'cli.requireCommit'
)} in ${chalk.bold('eas.json')} or with the following prompt.`
);
Log.newLine();
const mode = await selectAsync('Select your preferred Git integration', [
{ title: 'Require changes to be committed in Git (old default)', value: 'requireCommit' },
{ title: 'Allow builds with dirty Git working tree (new default)', value: 'noCommit' },
]);
if (mode === 'requireCommit') {
setVcsClient(new GitClient());
await ensureRepoIsCleanAsync(nonInteractive);
}
rawEasJson.cli =
mode === 'requireCommit'
? { version: `>= ${easCliVersion}`, requireCommit: true }
: { version: `>= ${easCliVersion}` };
await fs.writeJSON(easJsonPath, rawEasJson, { spaces: 2 });
Log.withTick('Updated eas.json');
if (mode === 'requireCommit') {
await getVcsClient().trackFileAsync(easJsonPath);
await reviewAndCommitChangesAsync('Set cli.requireCommit to true in eas.json', {
nonInteractive,
});
}
}
@@ -0,0 +1,114 @@
import { getRuntimeVersionNullable } from '@expo/config-plugins/build/utils/Updates';
import { Platform } from '@expo/eas-build-job';
import { EasJsonReader } from '@expo/eas-json';
import { Flags } from '@oclif/core';
import chalk from 'chalk';
import EasCommand from '../../../commandUtils/EasCommand';
import { AppVersionMutation } from '../../../graphql/mutations/AppVersionMutation';
import { AppVersionQuery } from '../../../graphql/queries/AppVersionQuery';
import { toAppPlatform } from '../../../graphql/types/AppPlatform';
import Log from '../../../log';
import { selectPlatformAsync } from '../../../platform';
import { VERSION_CODE_REQUIREMENTS, isValidVersionCode } from '../../../project/android/versions';
import { getApplicationIdentifierAsync } from '../../../project/applicationIdentifier';
import { getExpoConfig } from '../../../project/expoConfig';
import { BUILD_NUMBER_REQUIREMENTS, isValidBuildNumber } from '../../../project/ios/versions';
import {
findProjectRootAsync,
getProjectFullNameAsync,
getProjectIdAsync,
} from '../../../project/projectUtils';
import {
ensureRemoteVersionPolicyAsync,
getBuildVersionName,
validateAppConfigForRemoteVersionPolicyAsync,
} from '../../../project/remoteVersionSource';
import { promptAsync } from '../../../prompts';
export default class BuildVersionSetView extends EasCommand {
static description = 'Update version of an app.';
static hidden = true;
static flags = {
platform: Flags.enum({
char: 'p',
options: ['android', 'ios'],
}),
profile: Flags.string({
description:
'Name of the build profile from eas.json. Defaults to "production" if defined in eas.json.',
helpValue: 'PROFILE_NAME',
}),
};
public async runAsync(): Promise<void> {
const { flags } = await this.parse(BuildVersionSetView);
const projectDir = await findProjectRootAsync();
const platform = await selectPlatformAsync(flags.platform);
const easJsonReader = new EasJsonReader(projectDir);
await ensureRemoteVersionPolicyAsync(projectDir, easJsonReader);
const profile = await easJsonReader.getBuildProfileAsync(platform, flags.profile ?? undefined);
const exp = getExpoConfig(projectDir, { env: profile.env });
const projectId = await getProjectIdAsync(exp);
const projectFullName = await getProjectFullNameAsync(exp);
await validateAppConfigForRemoteVersionPolicyAsync(exp);
const applicationIdentifier = await getApplicationIdentifierAsync(
projectDir,
exp,
profile,
platform
);
const remoteVersions = await AppVersionQuery.latestVersionAsync(
projectId,
toAppPlatform(platform),
applicationIdentifier
);
const currentStateMessage = remoteVersions?.buildVersion
? `Project ${chalk.bold(projectFullName)} with ${getApplicationIdentifierName(
platform
)} "${applicationIdentifier}" is configured with ${getBuildVersionName(platform)} ${
remoteVersions.buildVersion
}.`
: `Project ${chalk.bold(projectFullName)} with ${getApplicationIdentifierName(
platform
)} "${applicationIdentifier}" does not have any ${getBuildVersionName(
platform
)} configured.`;
const versionPromptMessage = remoteVersions?.buildVersion
? `What version would you like to set?`
: `What version would you like to initialize it with?`;
Log.log(currentStateMessage);
const { version } = await promptAsync({
type: platform === Platform.ANDROID ? 'number' : 'text',
name: 'version',
message: versionPromptMessage,
validate:
platform === Platform.ANDROID
? value => isValidVersionCode(value) || `Invalid value: ${VERSION_CODE_REQUIREMENTS}.`
: value => isValidBuildNumber(value) || `Invalid value: ${BUILD_NUMBER_REQUIREMENTS}.`,
});
await AppVersionMutation.createAppVersionAsync({
appId: projectId,
platform: toAppPlatform(platform),
applicationIdentifier,
storeVersion: exp.version ?? '1.0.0',
buildVersion: String(version),
runtimeVersion: getRuntimeVersionNullable(exp, platform) ?? undefined,
});
}
}
function getApplicationIdentifierName(platform: Platform): string {
if (platform === Platform.ANDROID) {
return 'application ID';
} else {
return 'bundle identifier';
}
}
@@ -0,0 +1,184 @@
import { ExpoConfig } from '@expo/config';
import { Platform, Workflow } from '@expo/eas-build-job';
import { BuildProfile, EasJsonReader } from '@expo/eas-json';
import { Flags } from '@oclif/core';
import chalk from 'chalk';
import { updateNativeVersionsAsync as updateAndroidNativeVersionsAsync } from '../../../build/android/version';
import { updateNativeVersionsAsync as updateIosNativeVersionsAsync } from '../../../build/ios/version';
import EasCommand from '../../../commandUtils/EasCommand';
import { AppVersionQuery } from '../../../graphql/queries/AppVersionQuery';
import { toAppPlatform } from '../../../graphql/types/AppPlatform';
import Log from '../../../log';
import {
appPlatformDisplayNames,
selectRequestedPlatformAsync,
toPlatforms,
} from '../../../platform';
import { getAppBuildGradleAsync } from '../../../project/android/gradleUtils';
import { VERSION_CODE_REQUIREMENTS, isValidVersionCode } from '../../../project/android/versions';
import { getApplicationIdentifierAsync } from '../../../project/applicationIdentifier';
import { getExpoConfig } from '../../../project/expoConfig';
import { resolveXcodeBuildContextAsync } from '../../../project/ios/scheme';
import { resolveTargetsAsync } from '../../../project/ios/target';
import { BUILD_NUMBER_REQUIREMENTS, isValidBuildNumber } from '../../../project/ios/versions';
import { findProjectRootAsync, getProjectIdAsync } from '../../../project/projectUtils';
import {
ensureRemoteVersionPolicyAsync,
getBuildVersionName,
validateAppConfigForRemoteVersionPolicyAsync,
} from '../../../project/remoteVersionSource';
import { resolveWorkflowAsync } from '../../../project/workflow';
import { getProfilesAsync } from '../../../utils/profiles';
interface SyncContext<T extends Platform> {
projectDir: string;
exp: ExpoConfig;
workflow: Workflow;
profile: BuildProfile<T>;
buildVersion: string;
}
export default class BuildVersionSyncView extends EasCommand {
public static description = 'Update a version in native code with a value stored on EAS servers';
static hidden = true;
public static flags = {
platform: Flags.enum({
char: 'p',
options: ['android', 'ios', 'all'],
}),
profile: Flags.string({
description:
'Name of the build profile from eas.json. Defaults to "production" if defined in eas.json.',
helpValue: 'PROFILE_NAME',
}),
};
public async runAsync(): Promise<void> {
const { flags } = await this.parse(BuildVersionSyncView);
const projectDir = await findProjectRootAsync();
const requestedPlatform = await selectRequestedPlatformAsync(flags.platform);
const easJsonReader = new EasJsonReader(projectDir);
await ensureRemoteVersionPolicyAsync(projectDir, easJsonReader);
const platforms = toPlatforms(requestedPlatform);
const buildProfiles = await getProfilesAsync({
type: 'build',
easJsonReader,
platforms,
profileName: flags.profile ?? undefined,
});
for (const profileInfo of buildProfiles) {
const exp = getExpoConfig(projectDir, { env: profileInfo.profile.env });
const projectId = await getProjectIdAsync(exp);
await validateAppConfigForRemoteVersionPolicyAsync(exp);
const platformDisplayName = appPlatformDisplayNames[toAppPlatform(profileInfo.platform)];
const applicationIdentifier = await getApplicationIdentifierAsync(
projectDir,
exp,
profileInfo.profile,
profileInfo.platform
);
const remoteVersions = await AppVersionQuery.latestVersionAsync(
projectId,
toAppPlatform(profileInfo.platform),
applicationIdentifier
);
const workflow = await resolveWorkflowAsync(projectDir, profileInfo.platform);
if (!remoteVersions?.buildVersion) {
Log.warn(
`Skipping versions sync for ${platformDisplayName}. There are no versions configured on Expo servers, use "eas build:version:set" or run a build to initialize it.`
);
continue;
}
if (workflow === Workflow.MANAGED) {
Log.warn(
`The remote value for the ${platformDisplayName} ${getBuildVersionName(
profileInfo.platform
)} is ${chalk.bold(
remoteVersions?.buildVersion
)}, but it was not synced to the local project. This command has no effect on projects using managed workflow.`
);
continue;
}
if (profileInfo.platform === Platform.ANDROID) {
this.syncAndroidAsync({
projectDir,
exp,
profile: profileInfo.profile as BuildProfile<Platform.ANDROID>,
workflow,
buildVersion: remoteVersions.buildVersion,
});
} else {
this.syncIosAsync({
projectDir,
exp,
profile: profileInfo.profile as BuildProfile<Platform.IOS>,
workflow,
buildVersion: remoteVersions.buildVersion,
});
}
Log.withTick(
`Successfully updated the ${platformDisplayName} ${getBuildVersionName(
profileInfo.platform
)} in native code to ${chalk.bold(remoteVersions?.buildVersion)}.`
);
}
}
private async syncIosAsync({
workflow,
projectDir,
exp,
profile,
buildVersion,
}: SyncContext<Platform.IOS>): Promise<void> {
const xcodeBuildContext = await resolveXcodeBuildContextAsync(
{ exp, projectDir, nonInteractive: false },
profile
);
const targets = await resolveTargetsAsync({
projectDir,
exp,
xcodeBuildContext,
env: profile.env,
});
if (!isValidBuildNumber(buildVersion)) {
throw new Error(`Invalid buildNumber (${buildVersion}), ${BUILD_NUMBER_REQUIREMENTS}.`);
}
if (workflow === Workflow.GENERIC) {
await updateIosNativeVersionsAsync({
projectDir,
buildNumber: buildVersion,
targets,
});
}
}
private async syncAndroidAsync({
projectDir,
workflow,
buildVersion,
}: SyncContext<Platform.ANDROID>): Promise<void> {
if (!isValidVersionCode(buildVersion)) {
throw new Error(`Invalid versionCode (${buildVersion}), ${VERSION_CODE_REQUIREMENTS}.`);
}
if (workflow === Workflow.GENERIC) {
const buildGradle = await getAppBuildGradleAsync(projectDir);
const isMultiFlavor =
buildGradle.android?.productFlavors || buildGradle.android?.flavorDimensions;
if (isMultiFlavor) {
throw new Error(
"Versions in native code can't be automatically synced in multi-flavor Android projects. If you are using EAS Build with app version source set to remote, the correct values will be injected at the build time."
);
}
await updateAndroidNativeVersionsAsync({ projectDir, versionCode: Number(buildVersion) });
}
}
}
-2
View File
@@ -11,7 +11,6 @@ import { appPlatformEmojis } from '../platform';
import { getExpoConfig } from '../project/expoConfig';
import { findProjectRootAsync } from '../project/projectUtils';
import { selectAsync } from '../prompts';
import { handleDeprecatedEasJsonAsync } from './build';
export default class Config extends EasCommand {
static description = 'display project configuration (app.json + eas.json)';
@@ -31,7 +30,6 @@ export default class Config extends EasCommand {
};
const projectDir = await findProjectRootAsync();
await handleDeprecatedEasJsonAsync(projectDir, false);
const reader = new EasJsonReader(projectDir);
const profileName =
@@ -0,0 +1,64 @@
import { getConfig } from '@expo/config';
import { Flags } from '@oclif/core';
import chalk from 'chalk';
import path from 'path';
import { ensureProjectConfiguredAsync } from '../../build/configure';
import EasCommand from '../../commandUtils/EasCommand';
import { CredentialsContext } from '../../credentials/context';
import Log, { learnMore } from '../../log';
import { createMetadataContextAsync } from '../../metadata/context';
import { downloadMetadataAsync } from '../../metadata/download';
import { handleMetadataError } from '../../metadata/errors';
import { findProjectRootAsync, getProjectIdAsync } from '../../project/projectUtils';
import { ensureLoggedInAsync } from '../../user/actions';
export default class MetadataPull extends EasCommand {
static description = 'generate the local store configuration from the app stores';
static flags = {
profile: Flags.string({
description:
'Name of the submit profile from eas.json. Defaults to "production" if defined in eas.json.',
}),
};
async runAsync(): Promise<void> {
Log.warn('EAS Metadata is in beta and subject to breaking changes.');
const { flags } = await this.parse(MetadataPull);
const projectDir = await findProjectRootAsync();
const { exp } = getConfig(projectDir, { skipSDKVersionRequirement: true });
await getProjectIdAsync(exp);
await ensureProjectConfiguredAsync({ projectDir, nonInteractive: false });
const credentialsCtx = new CredentialsContext({
exp,
projectDir,
user: await ensureLoggedInAsync(),
nonInteractive: false,
});
const metadataCtx = await createMetadataContextAsync({
credentialsCtx,
projectDir,
exp,
profileName: flags.profile,
});
try {
const filePath = await downloadMetadataAsync(metadataCtx);
const relativePath = path.relative(process.cwd(), filePath);
Log.addNewLineIfNone();
Log.log(`🎉 Your store configuration is ready.
- Update the ${chalk.bold(relativePath)} file to prepare the app information.
- Run ${chalk.bold('eas submit')} or manually upload a new app version to the app stores.
- Once the app is uploaded, run ${chalk.bold('eas metadata:push')} to sync the store configuration.
- ${learnMore('https://docs.expo.dev/eas-metadata/introduction/')}`);
} catch (error: any) {
handleMetadataError(error);
}
}
}
@@ -0,0 +1,57 @@
import { getConfig } from '@expo/config';
import { Flags } from '@oclif/core';
import { ensureProjectConfiguredAsync } from '../../build/configure';
import EasCommand from '../../commandUtils/EasCommand';
import { CredentialsContext } from '../../credentials/context';
import Log, { learnMore } from '../../log';
import { createMetadataContextAsync } from '../../metadata/context';
import { handleMetadataError } from '../../metadata/errors';
import { uploadMetadataAsync } from '../../metadata/upload';
import { findProjectRootAsync, getProjectIdAsync } from '../../project/projectUtils';
import { ensureLoggedInAsync } from '../../user/actions';
export default class MetadataPush extends EasCommand {
static description = 'sync the local store configuration to the app stores';
static flags = {
profile: Flags.string({
description:
'Name of the submit profile from eas.json. Defaults to "production" if defined in eas.json.',
}),
};
async runAsync(): Promise<void> {
Log.warn('EAS Metadata is in beta and subject to breaking changes.');
const { flags } = await this.parse(MetadataPush);
const projectDir = await findProjectRootAsync();
const { exp } = getConfig(projectDir, { skipSDKVersionRequirement: true });
await getProjectIdAsync(exp);
await ensureProjectConfiguredAsync({ projectDir, nonInteractive: false });
const credentialsCtx = new CredentialsContext({
exp,
projectDir,
user: await ensureLoggedInAsync(),
nonInteractive: false,
});
const metadataCtx = await createMetadataContextAsync({
credentialsCtx,
projectDir,
exp,
profileName: flags.profile,
});
try {
const { appleLink } = await uploadMetadataAsync(metadataCtx);
Log.addNewLineIfNone();
Log.log(`🎉 Store configuration is synced with the app stores.
${learnMore(appleLink, { learnMoreMessage: 'See the changes in App Store Connect' })}`);
} catch (error: any) {
handleMetadataError(error);
}
}
}
+2 -1
View File
@@ -1,3 +1,4 @@
import { EasJsonReader } from '@expo/eas-json';
import { Errors, Flags } from '@oclif/core';
import chalk from 'chalk';
@@ -95,7 +96,7 @@ export default class Submit extends EasCommand {
const platforms = toPlatforms(flags.requestedPlatform);
const submissionProfiles = await getProfilesAsync({
type: 'submit',
projectDir,
easJsonReader: new EasJsonReader(projectDir),
platforms,
profileName: flags.profile,
});
+145 -43
View File
@@ -9,6 +9,7 @@ import gql from 'graphql-tag';
import nullthrows from 'nullthrows';
import { getEASUpdateURL } from '../../api';
import { getUpdateGroupUrl } from '../../build/utils/url';
import EasCommand from '../../commandUtils/EasCommand';
import fetch from '../../fetch';
import { graphqlClient, withErrorHandlingAsync } from '../../graphql/client';
@@ -25,11 +26,12 @@ import {
} from '../../graphql/generated';
import { PublishMutation } from '../../graphql/mutations/PublishMutation';
import { UpdateQuery } from '../../graphql/queries/UpdateQuery';
import Log from '../../log';
import Log, { link } from '../../log';
import { ora } from '../../ora';
import { getExpoConfig } from '../../project/expoConfig';
import {
findProjectRootAsync,
getProjectAccountName,
getProjectIdAsync,
installExpoUpdatesAsync,
isExpoUpdatesInstalledOrAvailable,
@@ -44,6 +46,7 @@ import {
import { resolveWorkflowAsync } from '../../project/workflow';
import { confirmAsync, promptAsync, selectAsync } from '../../prompts';
import { formatUpdate } from '../../update/utils';
import { ensureLoggedInAsync } from '../../user/actions';
import {
checkManifestBodyAgainstUpdateInfoGroup,
getCodeSigningInfoAsync,
@@ -59,7 +62,7 @@ import { listBranchesAsync } from '../branch/list';
import { createUpdateChannelOnAppAsync } from '../channel/create';
export const defaultPublishPlatforms: PublishPlatform[] = ['android', 'ios'];
type PlatformFlag = PublishPlatform | 'all';
export type PublishPlatformFlag = PublishPlatform | 'all';
async function getUpdateGroupAsync({
group,
@@ -117,12 +120,16 @@ async function ensureChannelExistsAsync({
}
}
async function ensureBranchExistsAsync({
export async function ensureBranchExistsAsync({
appId,
name: branchName,
limit,
offset,
}: {
appId: string;
name: string;
limit?: number;
offset?: number;
}): Promise<{
id: string;
updates: Exclude<
@@ -133,6 +140,8 @@ async function ensureBranchExistsAsync({
const { app } = await UpdateQuery.viewBranchAsync({
appId,
name: branchName,
limit,
offset,
});
const updateBranch = app?.byId.updateBranchByName;
if (updateBranch) {
@@ -221,7 +230,7 @@ export default class UpdatePublish extends EasCommand {
enableJsonOutput();
}
const platformFlag = platform as PlatformFlag;
const platformFlag = platform as PublishPlatformFlag;
// If a group was specified, that means we are republishing it.
republish = group ? true : republish;
@@ -309,13 +318,10 @@ export default class UpdatePublish extends EasCommand {
assert(branchName, 'Branch name must be specified.');
}
const { id: branchId, updates } = await ensureBranchExistsAsync({
appId: projectId,
name: branchName,
});
let unsortedUpdateInfoGroups: UpdateInfoGroup = {};
let oldMessage: string, oldRuntimeVersion: string;
let uploadedAssetCount = 0;
if (republish) {
// If we are republishing, we don't need to worry about building the bundle or uploading the assets.
// Instead we get the `updateInfoGroup` from the update we wish to republish.
@@ -335,26 +341,12 @@ export default class UpdatePublish extends EasCommand {
throw new Error('You must specify the update group to republish.');
}
const updateGroups = uniqBy(updates, u => u.group)
.filter(update => {
// Only show groups that have updates on the specified platform(s).
return platformFlag === 'all' || update.platform === platformFlag;
})
.map(update => ({
title: formatUpdateTitle(update),
value: update.group,
}));
if (updateGroups.length === 0) {
throw new Error(
`There are no updates on branch "${branchName}" published on the platform(s) ${platformFlag}. Did you mean to publish a new update instead?`
);
}
const selectedUpdateGroup = await selectAsync<string>(
'which update would you like to republish?',
updateGroups
updatesToRepublish = await getUpdatesToRepublishInteractiveAsync(
projectId,
branchName,
platformFlag,
50
);
updatesToRepublish = updates.filter(update => update.group === selectedUpdateGroup);
}
const updatesToRepublishFilteredByPlatform = updatesToRepublish.filter(
// Only republish to the specified platforms
@@ -362,7 +354,7 @@ export default class UpdatePublish extends EasCommand {
);
if (updatesToRepublishFilteredByPlatform.length === 0) {
throw new Error(
`There are no updates on branch "${branchName}" published on the platform(s) "${platformFlag}" with group ID "${
`There are no updates on branch "${branchName}" published for the platform(s) "${platformFlag}" with group ID "${
group ? group : updatesToRepublish[0].group
}". Did you mean to publish a new update instead?`
);
@@ -449,15 +441,28 @@ export default class UpdatePublish extends EasCommand {
try {
const platforms = platformFlag === 'all' ? defaultPublishPlatforms : [platformFlag];
const assets = await collectAssetsAsync({ inputDir: inputDir!, platforms });
await uploadAssetsAsync(assets);
const { uniqueUploadedAssetCount } = await uploadAssetsAsync(
assets,
(totalAssets, missingAssets) => {
assetSpinner.text = `Uploading assets. Finished (${
totalAssets - missingAssets
}/${totalAssets})`;
}
);
uploadedAssetCount = uniqueUploadedAssetCount;
unsortedUpdateInfoGroups = await buildUnsortedUpdateInfoGroupAsync(assets, exp);
assetSpinner.succeed('Uploaded assets!');
const uploadAssetSuccessMessage = uploadedAssetCount
? `Uploaded ${uploadedAssetCount} ${uploadedAssetCount === 1 ? 'asset' : 'assets'}!`
: `Uploading assets skipped -- no new assets found!`;
assetSpinner.succeed(uploadAssetSuccessMessage);
} catch (e) {
assetSpinner.fail('Failed to upload assets');
throw e;
}
}
const truncatedMessage = truncatePublishUpdateMessage(message!);
const runtimeToPlatformMapping: Record<string, string[]> = {};
for (const runtime of new Set(Object.values(runtimeVersions))) {
runtimeToPlatformMapping[runtime] = Object.entries(runtimeVersions)
@@ -465,6 +470,12 @@ export default class UpdatePublish extends EasCommand {
.map(pair => pair[0]);
}
const { id: branchId } = await ensureBranchExistsAsync({
appId: projectId,
name: branchName,
limit: 0,
});
// Sort the updates into different groups based on their platform specific runtime versions
const updateGroups: PublishUpdateGroupInput[] = Object.entries(runtimeToPlatformMapping).map(
([runtime, platforms]) => {
@@ -484,7 +495,7 @@ export default class UpdatePublish extends EasCommand {
branchId,
updateInfoGroup: localUpdateInfoGroup,
runtimeVersion: republish ? oldRuntimeVersion : runtime,
message,
message: truncatedMessage,
awaitingCodeSigningInfo: !!codeSigningInfo,
};
}
@@ -554,20 +565,36 @@ export default class UpdatePublish extends EasCommand {
Log.addNewLineIfNone();
for (const runtime of new Set(Object.values(runtimeVersions))) {
const platforms = newUpdates
.filter(update => update.runtimeVersion === runtime)
.map(update => update.platform);
const newUpdate = newUpdates.find(update => update.runtimeVersion === runtime);
if (!newUpdate) {
const newUpdatesForRuntimeVersion = newUpdates.filter(
update => update.runtimeVersion === runtime
);
if (!newUpdatesForRuntimeVersion.length) {
throw new Error(`Publish response is missing updates with runtime ${runtime}.`);
}
const platforms = newUpdatesForRuntimeVersion.map(update => update.platform);
const newAndroidUpdate = newUpdatesForRuntimeVersion.find(
update => update.platform === 'android'
);
const newIosUpdate = newUpdatesForRuntimeVersion.find(update => update.platform === 'ios');
const updateGroupId = newUpdatesForRuntimeVersion[0].group;
const projectName = exp.slug;
const accountName = getProjectAccountName(exp, await ensureLoggedInAsync());
const updateGroupUrl = getUpdateGroupUrl(accountName, projectName, updateGroupId);
const updateGroupLink = link(updateGroupUrl, { dim: false });
Log.log(
formatFields([
{ label: 'branch', value: branchName },
{ label: 'runtime version', value: runtime },
{ label: 'platform', value: platforms.join(', ') },
{ label: 'update group ID', value: newUpdate.group },
{ label: 'message', value: message! },
{ label: 'Branch', value: branchName },
{ label: 'Runtime version', value: runtime },
{ label: 'Platform', value: platforms.join(', ') },
{ label: 'Update group ID', value: updateGroupId },
...(newAndroidUpdate
? [{ label: 'Android update ID', value: newAndroidUpdate.id }]
: []),
...(newIosUpdate ? [{ label: 'iOS update ID', value: newIosUpdate.id }] : []),
{ label: 'Message', value: truncatedMessage },
{ label: 'Website link', value: updateGroupLink },
])
);
Log.addNewLineIfNone();
@@ -576,9 +603,76 @@ export default class UpdatePublish extends EasCommand {
}
}
export async function getUpdatesToRepublishInteractiveAsync(
projectId: string,
branchName: string,
platformFlag: string,
pageSize: number,
offset: number = 0,
cumulativeUpdates: Exclude<
Exclude<ViewBranchUpdatesQuery['app'], null | undefined>['byId']['updateBranchByName'],
null | undefined
>['updates'] = []
): Promise<
Exclude<
Exclude<ViewBranchUpdatesQuery['app'], null | undefined>['byId']['updateBranchByName'],
null | undefined
>['updates']
> {
const fetchMoreValue = '_fetchMore';
const { updates } = await ensureBranchExistsAsync({
appId: projectId,
name: branchName,
limit: pageSize + 1, // fetch an extra item so we know if there are additional pages
offset,
});
cumulativeUpdates = [
...cumulativeUpdates,
// drop that extra item used for pagination from our render logic
...updates.slice(0, updates.length - 1),
];
const cumulativeUpdatesForTargetPlatforms =
platformFlag === 'all'
? cumulativeUpdates
: cumulativeUpdates.filter(update => {
// Only show groups that have updates on the specified platform(s).
return update.platform === platformFlag;
});
const updateGroups = uniqBy(cumulativeUpdatesForTargetPlatforms, u => u.group).map(update => ({
title: formatUpdateTitle(update),
value: update.group,
}));
if (!updateGroups.length) {
throw new Error(
`There are no updates on branch "${branchName}" published for the platform(s) ${platformFlag}. Did you mean to publish a new update instead?`
);
}
if (updates.length > pageSize) {
updateGroups.push({ title: 'Next page...', value: fetchMoreValue });
}
const selectedUpdateGroup = await selectAsync<string>(
'Which update would you like to republish?',
updateGroups
);
if (selectedUpdateGroup === fetchMoreValue) {
return await getUpdatesToRepublishInteractiveAsync(
projectId,
branchName,
platformFlag,
pageSize,
offset + pageSize,
cumulativeUpdates
);
}
return cumulativeUpdates.filter(update => update.group === selectedUpdateGroup);
}
async function getRuntimeVersionObjectAsync(
exp: ExpoConfig,
platformFlag: PlatformFlag,
platformFlag: PublishPlatformFlag,
projectDir: string
): Promise<Record<string, string>> {
const platforms = (platformFlag === 'all' ? ['android', 'ios'] : [platformFlag]) as Platform[];
@@ -645,3 +739,11 @@ async function checkEASUpdateURLIsSetAsync(exp: ExpoConfig): Promise<void> {
);
}
}
export const truncatePublishUpdateMessage = (originalMessage: string): string => {
if (originalMessage.length > 1024) {
Log.warn('Update message exceeds the allowed 1024 character limit. Truncating message...');
return originalMessage.substring(0, 1021) + '...';
}
return originalMessage;
};
@@ -254,11 +254,11 @@ export class SetUpAdhocProvisioningProfile {
ctx: CredentialsContext,
appleTeam: AppleTeamFragment
): Promise<AppleDeviceFragment[]> {
const action = new DeviceCreateAction(this.app.account, appleTeam);
const action = new DeviceCreateAction(ctx.appStore, this.app.account, appleTeam);
const method = await action.runAsync();
while (true) {
if (method !== RegistrationMethod.INPUT) {
if (method === RegistrationMethod.WEBSITE) {
Log.newLine();
Log.log(chalk.bold("Press any key if you've already finished device registration."));
await pressAnyKeyToContinueAsync();
@@ -1,4 +1,5 @@
import { JSONObject } from '@expo/json-file';
import type { XCBuildConfiguration } from 'xcode';
import {
CommonIosAppCredentialsFragment,
@@ -17,6 +18,7 @@ export interface Target {
bundleIdentifier: string;
parentBundleIdentifier?: string;
entitlements: JSONObject;
buildSettings?: XCBuildConfiguration['buildSettings'];
}
export interface TargetCredentials {
@@ -0,0 +1 @@
export const runDeveloperPortalMethodAsync = jest.fn();
@@ -1,13 +1,17 @@
import prompts from 'prompts';
import { instance, mock } from 'ts-mockito';
import AppStoreApi from '../../../../credentials/ios/appstore/AppStoreApi';
import { Account } from '../../../../user/Account';
import DeviceCreateAction, { RegistrationMethod } from '../action';
import { runDeveloperPortalMethodAsync } from '../developerPortalMethod';
import { runInputMethodAsync } from '../inputMethod';
import { runRegistrationUrlMethodAsync } from '../registrationUrlMethod';
jest.mock('prompts');
jest.mock('../registrationUrlMethod');
jest.mock('../developerPortalMethod');
jest.mock('../inputMethod');
jest.mock('../registrationUrlMethod');
beforeEach(() => {
const promptsMock = jest.mocked(prompts);
@@ -25,6 +29,8 @@ describe(DeviceCreateAction, () => {
jest.mocked(prompts).mockImplementationOnce(async () => ({
method: RegistrationMethod.WEBSITE,
}));
const appStoreApiMock = mock<AppStoreApi>();
const appStoreApi = instance(appStoreApiMock);
const account: Account = {
id: 'account_id',
@@ -35,7 +41,7 @@ describe(DeviceCreateAction, () => {
appleTeamIdentifier: 'ABC123Y',
appleTeamName: 'John Doe (Individual)',
};
const action = new DeviceCreateAction(account, appleTeam);
const action = new DeviceCreateAction(appStoreApi, account, appleTeam);
await action.runAsync();
expect(runRegistrationUrlMethodAsync).toBeCalled();
@@ -45,6 +51,8 @@ describe(DeviceCreateAction, () => {
jest.mocked(prompts).mockImplementationOnce(async () => ({
method: RegistrationMethod.INPUT,
}));
const appStoreApiMock = mock<AppStoreApi>();
const appStoreApi = instance(appStoreApiMock);
const account: Account = {
id: 'account_id',
@@ -55,10 +63,32 @@ describe(DeviceCreateAction, () => {
appleTeamIdentifier: 'ABC123Y',
appleTeamName: 'John Doe (Individual)',
};
const action = new DeviceCreateAction(account, appleTeam);
const action = new DeviceCreateAction(appStoreApi, account, appleTeam);
await action.runAsync();
expect(runInputMethodAsync).toBeCalled();
});
it('calls runDeveloperPortalMethodAsync if user chooses the developer portal method', async () => {
jest.mocked(prompts).mockImplementationOnce(async () => ({
method: RegistrationMethod.DEVELOPER_PORTAL,
}));
const appStoreApiMock = mock<AppStoreApi>();
const appStoreApi = instance(appStoreApiMock);
const account: Account = {
id: 'account_id',
name: 'foobar',
};
const appleTeam = {
id: 'apple-team-id',
appleTeamIdentifier: 'ABC123Y',
appleTeamName: 'John Doe (Individual)',
};
const action = new DeviceCreateAction(appStoreApi, account, appleTeam);
await action.runAsync();
expect(runDeveloperPortalMethodAsync).toBeCalled();
});
});
});
@@ -1,20 +1,24 @@
import chalk from 'chalk';
import AppStoreApi from '../../../credentials/ios/appstore/AppStoreApi';
import { AppleTeam } from '../../../graphql/generated';
import Log from '../../../log';
import { promptAsync } from '../../../prompts';
import { Account } from '../../../user/Account';
import { runDeveloperPortalMethodAsync } from './developerPortalMethod';
import { runInputMethodAsync } from './inputMethod';
import { runRegistrationUrlMethodAsync } from './registrationUrlMethod';
export enum RegistrationMethod {
WEBSITE,
INPUT,
DEVELOPER_PORTAL,
EXIT,
}
export default class DeviceCreateAction {
constructor(
private appStoreApi: AppStoreApi,
private account: Account,
private appleTeam: Pick<AppleTeam, 'appleTeamIdentifier' | 'appleTeamName' | 'id'>
) {}
@@ -23,6 +27,8 @@ export default class DeviceCreateAction {
const method = await this.askForRegistrationMethodAsync();
if (method === RegistrationMethod.WEBSITE) {
await runRegistrationUrlMethodAsync(this.account.id, this.appleTeam);
} else if (method === RegistrationMethod.DEVELOPER_PORTAL) {
await runDeveloperPortalMethodAsync(this.appStoreApi, this.account.id, this.appleTeam);
} else if (method === RegistrationMethod.INPUT) {
await runInputMethodAsync(this.account.id, this.appleTeam);
} else if (method === RegistrationMethod.EXIT) {
@@ -44,6 +50,12 @@ export default class DeviceCreateAction {
)} - generates a registration URL to be opened on your devices`,
value: RegistrationMethod.WEBSITE,
},
{
title: `${chalk.bold(
'Developer Portal'
)} - import devices already registered on Apple Developer Portal`,
value: RegistrationMethod.DEVELOPER_PORTAL,
},
{
title: `${chalk.bold('Input')} - allows you to type in UDIDs (advanced option)`,
value: RegistrationMethod.INPUT,
@@ -0,0 +1,131 @@
import { Device, DeviceClass } from '@expo/apple-utils';
import { AppleDeviceMutation } from '../../../credentials/ios/api/graphql/mutations/AppleDeviceMutation';
import {
AppleDeviceFragmentWithAppleTeam,
AppleDeviceQuery,
} from '../../../credentials/ios/api/graphql/queries/AppleDeviceQuery';
import AppStoreApi from '../../../credentials/ios/appstore/AppStoreApi';
import { getRequestContext } from '../../../credentials/ios/appstore/authenticate';
import { AuthCtx } from '../../../credentials/ios/appstore/authenticateTypes';
import { AppleDeviceClass, AppleTeam } from '../../../graphql/generated';
import Log from '../../../log';
import { ora } from '../../../ora';
import { promptAsync } from '../../../prompts';
import chunk from '../../../utils/expodash/chunk';
const DEVICE_IMPORT_CHUNK_SIZE = 10;
const DEVICE_CLASS_TO_GRAPHQL_TYPE: Partial<Record<DeviceClass, AppleDeviceClass>> = {
[DeviceClass.IPAD]: AppleDeviceClass.Ipad,
[DeviceClass.IPHONE]: AppleDeviceClass.Iphone,
};
export async function runDeveloperPortalMethodAsync(
appStoreApi: AppStoreApi,
accountId: string,
appleTeam: Pick<AppleTeam, 'appleTeamIdentifier' | 'appleTeamName' | 'id'>
): Promise<void> {
const appleAuthCtx = await appStoreApi.ensureAuthenticatedAsync();
const unregisteredPortalDevices = await findUnregisteredPortalDevicesAsync(
appleAuthCtx,
accountId,
appleTeam
);
if (unregisteredPortalDevices.length === 0) {
Log.log('All your devices registered on Apple Developer Portal are already imported to EAS.');
return;
}
const devicesToImport = await chooseDevicesToImportAsync(unregisteredPortalDevices);
await importDevicesAsync(accountId, appleTeam, devicesToImport);
}
async function importDevicesAsync(
accountId: string,
appleTeam: Pick<AppleTeam, 'appleTeamIdentifier' | 'appleTeamName' | 'id'>,
devices: Device[]
): Promise<void> {
const spinner = ora(
`Importing ${devices.length} Apple device${devices.length === 1 ? '' : 's'} to EAS`
).start();
const deviceChunks = chunk(devices, DEVICE_IMPORT_CHUNK_SIZE);
try {
for (const deviceChunk of deviceChunks) {
await importDeviceChunkAsync(accountId, appleTeam, deviceChunk);
}
} catch (err) {
spinner.fail();
throw err;
}
spinner.succeed();
}
async function importDeviceChunkAsync(
accountId: string,
appleTeam: Pick<AppleTeam, 'appleTeamIdentifier' | 'appleTeamName' | 'id'>,
devices: Device[]
): Promise<void> {
const promises = devices.map(device => {
return AppleDeviceMutation.createAppleDeviceAsync(
{
appleTeamId: appleTeam.id,
identifier: device.attributes.udid,
name: device.attributes.name,
deviceClass: DEVICE_CLASS_TO_GRAPHQL_TYPE[device.attributes.deviceClass] ?? undefined,
},
accountId
);
});
await Promise.all(promises);
}
async function findUnregisteredPortalDevicesAsync(
appleAuthCtx: AuthCtx,
accountId: string,
appleTeam: Pick<AppleTeam, 'appleTeamIdentifier' | 'appleTeamName' | 'id'>
): Promise<Device[]> {
const expoRegisteredDevices = await AppleDeviceQuery.getAllByAppleTeamIdentifierAsync(
accountId,
appleTeam.appleTeamIdentifier,
{ useCache: false }
);
const expoRegisteredDevicesByUdid = expoRegisteredDevices.reduce((acc, device) => {
acc[device.identifier] = device;
return acc;
}, {} as Record<string, AppleDeviceFragmentWithAppleTeam>);
const portalDevices = await Device.getAllIOSProfileDevicesAsync(getRequestContext(appleAuthCtx));
return portalDevices.filter(
portalDevice =>
!(portalDevice.attributes.udid in expoRegisteredDevicesByUdid) &&
[DeviceClass.IPAD, DeviceClass.IPHONE].includes(portalDevice.attributes.deviceClass)
);
}
async function chooseDevicesToImportAsync(devices: Device[]): Promise<Device[]> {
const { chosenDevices } = await promptAsync({
type: 'multiselect',
name: 'chosenDevices',
message: 'Which devices do you want to import to EAS?',
choices: devices.map(device => ({
value: device,
title: formatDeviceLabel(device),
selected: true,
})),
});
return chosenDevices;
}
export function formatDeviceLabel(device: Device): string {
const deviceDetails = formatDeviceDetails(device);
return `${device.attributes.name} - ${device.attributes.udid})${
deviceDetails !== '' ? ` - ${deviceDetails}` : ''
}`;
}
function formatDeviceDetails(device: Device): string {
let details: string = device.attributes.deviceClass;
if (device.attributes.model) {
details = device.attributes.model;
}
return details === '' ? details : `(${details})`;
}
+4 -4
View File
@@ -29,12 +29,12 @@ export default class DeviceManager {
Log.addNewLineIfNone();
const account = await this.resolveAccountAsync();
const { team } = await this.ctx.appStore.ensureAuthenticatedAsync();
const appleAuthCtx = await this.ctx.appStore.ensureAuthenticatedAsync();
const appleTeam = await ensureAppleTeamExistsAsync(account.id, {
appleTeamIdentifier: team.id,
appleTeamName: team.name,
appleTeamIdentifier: appleAuthCtx.team.id,
appleTeamName: appleAuthCtx.team.name,
});
const action = new DeviceCreateAction(account, appleTeam);
const action = new DeviceCreateAction(this.ctx.appStore, account, appleTeam);
await action.runAsync();
}
+11
View File
@@ -0,0 +1,11 @@
export default {
/**
* Dangerous overrides, use only if you know what you are doing
*/
/**
* Overrides applicationId from Android project, setting this option will also
* ignore failures when parsing build.gradle.
*/
overrideAndroidApplicationId: process.env.EAS_DANGEROUS_OVERRIDE_ANDROID_APPLICATION_ID,
};
+251 -9
View File
@@ -123,6 +123,8 @@ export type Account = {
requiresAccessTokenForPushSecurity: Scalars['Boolean'];
/** Snacks associated with this account */
snacks: Array<Snack>;
/** SSO configuration for this account */
ssoConfiguration?: Maybe<AccountSsoConfiguration>;
/** Subscription info visible to members that have VIEWER role */
subscription?: Maybe<SubscriptionDetails>;
/** @deprecated No longer needed */
@@ -393,6 +395,90 @@ export type AccountQueryByNameArgs = {
accountName: Scalars['String'];
};
/** Auth configuration data for an SSO account. */
export type AccountSsoConfiguration = {
__typename?: 'AccountSSOConfiguration';
authEndpoint?: Maybe<Scalars['String']>;
authProtocol: AuthProtocolType;
authProviderIdentifier: Scalars['String'];
clientIdentifier: Scalars['String'];
clientSecret: Scalars['String'];
createdAt: Scalars['DateTime'];
id: Scalars['ID'];
issuer?: Maybe<Scalars['String']>;
jwksEndpoint?: Maybe<Scalars['String']>;
redirectUri: Scalars['String'];
tokenEndpoint?: Maybe<Scalars['String']>;
updatedAt: Scalars['DateTime'];
userInfoEndpoint?: Maybe<Scalars['String']>;
};
export type AccountSsoConfigurationData = {
authEndpoint?: InputMaybe<Scalars['String']>;
authProtocol: AuthProtocolType;
authProviderIdentifier: Scalars['String'];
clientIdentifier: Scalars['String'];
clientSecret: Scalars['String'];
issuer?: InputMaybe<Scalars['String']>;
jwksEndpoint?: InputMaybe<Scalars['String']>;
redirectUri: Scalars['String'];
tokenEndpoint?: InputMaybe<Scalars['String']>;
userInfoEndpoint?: InputMaybe<Scalars['String']>;
};
export type AccountSsoConfigurationMutation = {
__typename?: 'AccountSSOConfigurationMutation';
/** Create an AccountSSOConfiguration for an Account */
createAccountSSOConfiguration: AccountSsoConfiguration;
/** Delete an AccountSSOConfiguration */
deleteAccountSSOConfiguration: DeleteAccountSsoConfigurationResult;
/** Update an AccountSSOConfiguration */
updateAccountSSOConfiguration: AccountSsoConfiguration;
};
export type AccountSsoConfigurationMutationCreateAccountSsoConfigurationArgs = {
accountId: Scalars['ID'];
accountSSOConfigurationData: AccountSsoConfigurationData;
};
export type AccountSsoConfigurationMutationDeleteAccountSsoConfigurationArgs = {
id: Scalars['ID'];
};
export type AccountSsoConfigurationMutationUpdateAccountSsoConfigurationArgs = {
accountSSOConfigurationData: AccountSsoConfigurationData;
id: Scalars['ID'];
};
/** Public auth configuration data for an SSO account. */
export type AccountSsoConfigurationPublicData = {
__typename?: 'AccountSSOConfigurationPublicData';
authEndpoint?: Maybe<Scalars['String']>;
authProtocol: AuthProtocolType;
authProviderIdentifier: Scalars['String'];
clientIdentifier: Scalars['String'];
id: Scalars['ID'];
issuer?: Maybe<Scalars['String']>;
jwksEndpoint?: Maybe<Scalars['String']>;
redirectUri: Scalars['String'];
tokenEndpoint?: Maybe<Scalars['String']>;
userInfoEndpoint?: Maybe<Scalars['String']>;
};
export type AccountSsoConfigurationPublicDataQuery = {
__typename?: 'AccountSSOConfigurationPublicDataQuery';
/** Get AccountSSOConfiguration public data by account name */
publicDataByAccountName: AccountSsoConfigurationPublicData;
};
export type AccountSsoConfigurationPublicDataQueryPublicDataByAccountNameArgs = {
accountName: Scalars['String'];
};
export type AccountUsageMetric = {
__typename?: 'AccountUsageMetric';
id: Scalars['ID'];
@@ -427,6 +513,7 @@ export type AccountUsageMetricsByBillingPeriodArgs = {
export type AccountUsageMetricsMetricsForServiceMetricArgs = {
filterParams?: InputMaybe<Scalars['JSONObject']>;
granularity: UsageMetricsGranularity;
serviceMetric: EasServiceMetric;
timespan: UsageMetricsTimespan;
@@ -708,6 +795,7 @@ export type AndroidJobInput = {
type: BuildWorkflow;
updates?: InputMaybe<BuildUpdatesInput>;
username?: InputMaybe<Scalars['String']>;
version?: InputMaybe<AndroidJobVersionInput>;
};
export type AndroidJobKeystoreInput = {
@@ -722,6 +810,10 @@ export type AndroidJobSecretsInput = {
environmentSecrets?: InputMaybe<Scalars['JSONObject']>;
};
export type AndroidJobVersionInput = {
versionCode: Scalars['String'];
};
export type AndroidKeystore = {
__typename?: 'AndroidKeystore';
account: Account;
@@ -834,6 +926,7 @@ export type App = Project & {
isLikedByMe: Scalars['Boolean'];
/** @deprecated No longer supported */
lastPublishedTime: Scalars['DateTime'];
latestAppVersionByPlatformAndApplicationIdentifier?: Maybe<AppVersion>;
latestReleaseForReleaseChannel?: Maybe<AppRelease>;
/** ID of latest classic update release */
latestReleaseId: Scalars['ID'];
@@ -956,6 +1049,13 @@ export type AppIosAppCredentialsArgs = {
};
/** Represents an Exponent App (or Experience in legacy terms) */
export type AppLatestAppVersionByPlatformAndApplicationIdentifierArgs = {
applicationIdentifier: Scalars['String'];
platform: AppPlatform;
};
/** Represents an Exponent App (or Experience in legacy terms) */
export type AppLatestReleaseForReleaseChannelArgs = {
platform: AppPlatform;
@@ -1198,6 +1298,52 @@ export enum AppStoreConnectUserRole {
Unknown = 'UNKNOWN'
}
/** Represents Play Store/App Store version of an application */
export type AppVersion = {
__typename?: 'AppVersion';
/**
* Store identifier for an application
* - Android - applicationId
* - iOS - bundle identifier
*/
applicationIdentifier: Scalars['String'];
/**
* Value that identifies build in a store (it's visible to developers, but not to end users)
* - Android - versionCode in build.gradle ("android.versionCode" field in app.json)
* - iOS - CFBundleVersion in Info.plist ("ios.buildNumber" field in app.json)
*/
buildVersion: Scalars['String'];
id: Scalars['ID'];
platform: AppPlatform;
runtimeVersion?: Maybe<Scalars['String']>;
/**
* User-facing version in a store
* - Android - versionName in build.gradle ("version" field in app.json)
* - iOS - CFBundleShortVersionString in Info.plist ("version" field in app.json)
*/
storeVersion: Scalars['String'];
};
export type AppVersionInput = {
appId: Scalars['ID'];
applicationIdentifier: Scalars['String'];
buildVersion: Scalars['String'];
platform: AppPlatform;
runtimeVersion?: InputMaybe<Scalars['String']>;
storeVersion: Scalars['String'];
};
export type AppVersionMutation = {
__typename?: 'AppVersionMutation';
/** Create an app version */
createAppVersion: AppVersion;
};
export type AppVersionMutationCreateAppVersionArgs = {
appVersionInput: AppVersionInput;
};
export type AppleAppIdentifier = {
__typename?: 'AppleAppIdentifier';
account: Account;
@@ -1414,7 +1560,7 @@ export type ApplePushKey = {
};
export type ApplePushKeyInput = {
appleTeamId?: InputMaybe<Scalars['ID']>;
appleTeamId: Scalars['ID'];
keyIdentifier: Scalars['String'];
keyP8: Scalars['String'];
};
@@ -1539,6 +1685,10 @@ export type AssetQueryMetadataArgs = {
storageKeys: Array<Scalars['String']>;
};
export enum AuthProtocolType {
Oidc = 'OIDC'
}
export type Billing = {
__typename?: 'Billing';
/** History of invoices */
@@ -1564,10 +1714,14 @@ export type Build = ActivityTimelineProjectActivity & BuildOrBuildJob & {
appVersion?: Maybe<Scalars['String']>;
artifacts?: Maybe<BuildArtifacts>;
buildProfile?: Maybe<Scalars['String']>;
canRetry: Scalars['Boolean'];
cancelingActor?: Maybe<Actor>;
channel?: Maybe<Scalars['String']>;
createdAt?: Maybe<Scalars['DateTime']>;
childBuild?: Maybe<Build>;
completedAt?: Maybe<Scalars['DateTime']>;
createdAt: Scalars['DateTime'];
distribution?: Maybe<DistributionType>;
enqueuedAt?: Maybe<Scalars['DateTime']>;
error?: Maybe<BuildError>;
estimatedWaitTimeLeftSeconds?: Maybe<Scalars['Int']>;
expirationDate?: Maybe<Scalars['DateTime']>;
@@ -1581,19 +1735,27 @@ export type Build = ActivityTimelineProjectActivity & BuildOrBuildJob & {
iosEnterpriseProvisioning?: Maybe<BuildIosEnterpriseProvisioning>;
isGitWorkingTreeDirty?: Maybe<Scalars['Boolean']>;
logFiles: Array<Scalars['String']>;
maxBuildTimeSeconds: Scalars['Int'];
/** Retry time starts after completedAt */
maxRetryTimeMinutes: Scalars['Int'];
message?: Maybe<Scalars['String']>;
metrics?: Maybe<BuildMetrics>;
parentBuild?: Maybe<Build>;
platform: AppPlatform;
priority: BuildPriority;
project: Project;
provisioningStartedAt?: Maybe<Scalars['DateTime']>;
/** Queue position is 1-indexed */
queuePosition?: Maybe<Scalars['Int']>;
reactNativeVersion?: Maybe<Scalars['String']>;
releaseChannel?: Maybe<Scalars['String']>;
resourceClass?: Maybe<BuildResourceClass>;
runtimeVersion?: Maybe<Scalars['String']>;
sdkVersion?: Maybe<Scalars['String']>;
status: BuildStatus;
submissions: Array<Submission>;
updatedAt?: Maybe<Scalars['DateTime']>;
updatedAt: Scalars['DateTime'];
workerStartedAt?: Maybe<Scalars['DateTime']>;
};
export type BuildArtifact = {
@@ -1734,6 +1896,7 @@ export type BuildMetadataInput = {
gitCommitHash?: InputMaybe<Scalars['String']>;
iosEnterpriseProvisioning?: InputMaybe<BuildIosEnterpriseProvisioning>;
isGitWorkingTreeDirty?: InputMaybe<Scalars['Boolean']>;
message?: InputMaybe<Scalars['String']>;
reactNativeVersion?: InputMaybe<Scalars['String']>;
releaseChannel?: InputMaybe<Scalars['String']>;
runtimeVersion?: InputMaybe<Scalars['String']>;
@@ -1765,6 +1928,8 @@ export type BuildMutation = {
createIosBuild: CreateBuildResult;
/** Delete an EAS Build build */
deleteBuild: Build;
/** Retry an EAS Build build */
retryBuild: Build;
};
@@ -1775,6 +1940,7 @@ export type BuildMutationCancelBuildArgs = {
export type BuildMutationCreateAndroidBuildArgs = {
appId: Scalars['ID'];
buildParams?: InputMaybe<BuildParamsInput>;
job: AndroidJobInput;
metadata?: InputMaybe<BuildMetadataInput>;
};
@@ -1782,6 +1948,7 @@ export type BuildMutationCreateAndroidBuildArgs = {
export type BuildMutationCreateIosBuildArgs = {
appId: Scalars['ID'];
buildParams?: InputMaybe<BuildParamsInput>;
job: IosJobInput;
metadata?: InputMaybe<BuildMetadataInput>;
};
@@ -1791,13 +1958,23 @@ export type BuildMutationDeleteBuildArgs = {
buildId: Scalars['ID'];
};
export type BuildMutationRetryBuildArgs = {
buildId: Scalars['ID'];
};
export type BuildOrBuildJob = {
id: Scalars['ID'];
};
export type BuildParamsInput = {
resourceClass: BuildResourceClass;
};
export enum BuildPriority {
High = 'HIGH',
Normal = 'NORMAL'
Normal = 'NORMAL',
NormalPlus = 'NORMAL_PLUS'
}
/** Publicly visible data for a Build. */
@@ -1862,6 +2039,14 @@ export type BuildQueryByIdArgs = {
buildId: Scalars['ID'];
};
export enum BuildResourceClass {
AndroidDefault = 'ANDROID_DEFAULT',
AndroidLarge = 'ANDROID_LARGE',
IosDefault = 'IOS_DEFAULT',
IosLarge = 'IOS_LARGE',
Legacy = 'LEGACY'
}
export enum BuildStatus {
Canceled = 'CANCELED',
Errored = 'ERRORED',
@@ -2000,6 +2185,11 @@ export type DeleteAccountResult = {
id: Scalars['ID'];
};
export type DeleteAccountSsoConfigurationResult = {
__typename?: 'DeleteAccountSSOConfigurationResult';
id: Scalars['ID'];
};
export type DeleteAndroidKeystoreResult = {
__typename?: 'DeleteAndroidKeystoreResult';
id: Scalars['ID'];
@@ -2095,6 +2285,7 @@ export enum EasBuildDeprecationInfoType {
export enum EasServiceMetric {
AssetsRequests = 'ASSETS_REQUESTS',
BandwidthUsage = 'BANDWIDTH_USAGE',
Builds = 'BUILDS',
ManifestRequests = 'MANIFEST_REQUESTS',
UniqueUsers = 'UNIQUE_USERS'
}
@@ -2188,6 +2379,15 @@ export enum Feature {
Teams = 'TEAMS'
}
export type FutureSubscription = {
__typename?: 'FutureSubscription';
createdAt: Scalars['DateTime'];
id: Scalars['ID'];
meteredBillingStatus: MeteredBillingStatus;
planId: Scalars['String'];
startDate: Scalars['DateTime'];
};
export type GetSignedAssetUploadSpecificationsResult = {
__typename?: 'GetSignedAssetUploadSpecificationsResult';
specifications: Array<Scalars['String']>;
@@ -2460,6 +2660,7 @@ export type IosJobInput = {
type: BuildWorkflow;
updates?: InputMaybe<BuildUpdatesInput>;
username?: InputMaybe<Scalars['String']>;
version?: InputMaybe<IosJobVersionInput>;
};
export type IosJobSecretsInput = {
@@ -2473,6 +2674,10 @@ export type IosJobTargetCredentialsInput = {
targetName: Scalars['String'];
};
export type IosJobVersionInput = {
buildNumber: Scalars['String'];
};
/** @deprecated Use developmentClient option instead. */
export enum IosManagedBuildType {
DevelopmentClient = 'DEVELOPMENT_CLIENT',
@@ -2654,6 +2859,11 @@ export type MeMutationUpdateProfileArgs = {
userData: UserDataInput;
};
export type MeteredBillingStatus = {
__typename?: 'MeteredBillingStatus';
EAS_UPDATE: Scalars['Boolean'];
};
export type Offer = {
__typename?: 'Offer';
features?: Maybe<Array<Maybe<Feature>>>;
@@ -2870,6 +3080,8 @@ export type RootMutation = {
accessToken: AccessTokenMutation;
/** Mutations that modify an Account */
account: AccountMutation;
/** Mutations that create, update, and delete an AccountSSOConfiguration */
accountSSOConfiguration: AccountSsoConfigurationMutation;
/** Mutations that modify the build credentials for an Android app */
androidAppBuildCredentials: AndroidAppBuildCredentialsMutation;
/** Mutations that modify the credentials for an Android app */
@@ -2882,6 +3094,8 @@ export type RootMutation = {
app?: Maybe<AppMutation>;
/** Mutations that modify an App Store Connect Api Key */
appStoreConnectApiKey: AppStoreConnectApiKeyMutation;
/** Mutations that modify an AppVersion */
appVersion: AppVersionMutation;
/** Mutations that modify an Identifier for an iOS App */
appleAppIdentifier: AppleAppIdentifierMutation;
/** Mutations that modify an Apple Device */
@@ -2957,6 +3171,8 @@ export type RootQuery = {
_doNotUse?: Maybe<Scalars['String']>;
/** Top-level query object for querying Accounts. */
account: AccountQuery;
/** Top-level query object for querying AccountSSOConfigurationPublicData */
accountSSOConfigurationPublicData: AccountSsoConfigurationPublicDataQuery;
/** Top-level query object for querying Actors. */
actor: ActorQuery;
/**
@@ -3150,12 +3366,16 @@ export type Submission = ActivityTimelineProjectActivity & {
archiveUrl?: Maybe<Scalars['String']>;
canRetry: Scalars['Boolean'];
cancelingActor?: Maybe<Actor>;
childSubmission?: Maybe<Submission>;
completedAt?: Maybe<Scalars['DateTime']>;
createdAt: Scalars['DateTime'];
error?: Maybe<SubmissionError>;
id: Scalars['ID'];
initiatingActor?: Maybe<Actor>;
iosConfig?: Maybe<IosSubmissionConfig>;
logsUrl?: Maybe<Scalars['String']>;
/** Retry time starts after completedAt */
maxRetryTimeMinutes: Scalars['Int'];
parentSubmission?: Maybe<Submission>;
platform: AppPlatform;
status: SubmissionStatus;
@@ -3251,8 +3471,10 @@ export type SubscriptionDetails = {
cancelledAt?: Maybe<Scalars['DateTime']>;
concurrencies?: Maybe<Concurrencies>;
endedAt?: Maybe<Scalars['DateTime']>;
futureSubscription?: Maybe<FutureSubscription>;
id: Scalars['ID'];
isDowngrading?: Maybe<Scalars['Boolean']>;
meteredBillingStatus: MeteredBillingStatus;
name?: Maybe<Scalars['String']>;
nextInvoice?: Maybe<Scalars['DateTime']>;
planId?: Maybe<Scalars['String']>;
@@ -3447,6 +3669,7 @@ export type UsageMetricTotal = {
export enum UsageMetricType {
Bandwidth = 'BANDWIDTH',
Build = 'BUILD',
Request = 'REQUEST',
User = 'USER'
}
@@ -4302,23 +4525,32 @@ export type CreateAppMutationVariables = Exact<{
export type CreateAppMutation = { __typename?: 'RootMutation', app?: { __typename?: 'AppMutation', createApp: { __typename?: 'App', id: string } } | null };
export type CreateAppVersionMutationVariables = Exact<{
appVersionInput: AppVersionInput;
}>;
export type CreateAppVersionMutation = { __typename?: 'RootMutation', appVersion: { __typename?: 'AppVersionMutation', createAppVersion: { __typename?: 'AppVersion', id: string } } };
export type CreateAndroidBuildMutationVariables = Exact<{
appId: Scalars['ID'];
job: AndroidJobInput;
metadata?: InputMaybe<BuildMetadataInput>;
buildParams?: InputMaybe<BuildParamsInput>;
}>;
export type CreateAndroidBuildMutation = { __typename?: 'RootMutation', build: { __typename?: 'BuildMutation', createAndroidBuild: { __typename?: 'CreateBuildResult', build: { __typename?: 'Build', id: string, status: BuildStatus, platform: AppPlatform, channel?: string | null, releaseChannel?: string | null, distribution?: DistributionType | null, iosEnterpriseProvisioning?: BuildIosEnterpriseProvisioning | null, buildProfile?: string | null, sdkVersion?: string | null, appVersion?: string | null, appBuildVersion?: string | null, runtimeVersion?: string | null, gitCommitHash?: string | null, initialQueuePosition?: number | null, queuePosition?: number | null, estimatedWaitTimeLeftSeconds?: number | null, priority: BuildPriority, createdAt?: any | null, updatedAt?: any | null, error?: { __typename?: 'BuildError', errorCode: string, message: string, docsUrl?: string | null } | null, artifacts?: { __typename?: 'BuildArtifacts', buildUrl?: string | null, xcodeBuildLogsUrl?: string | null } | null, initiatingActor?: { __typename: 'Robot', id: string, displayName: string } | { __typename: 'User', id: string, displayName: string } | null, project: { __typename: 'App', id: string, name: string, slug: string, ownerAccount: { __typename?: 'Account', id: string, name: string } } | { __typename: 'Snack', id: string, name: string, slug: string } }, deprecationInfo?: { __typename?: 'EASBuildDeprecationInfo', type: EasBuildDeprecationInfoType, message: string } | null } } };
export type CreateAndroidBuildMutation = { __typename?: 'RootMutation', build: { __typename?: 'BuildMutation', createAndroidBuild: { __typename?: 'CreateBuildResult', build: { __typename?: 'Build', id: string, status: BuildStatus, platform: AppPlatform, channel?: string | null, releaseChannel?: string | null, distribution?: DistributionType | null, iosEnterpriseProvisioning?: BuildIosEnterpriseProvisioning | null, buildProfile?: string | null, sdkVersion?: string | null, appVersion?: string | null, appBuildVersion?: string | null, runtimeVersion?: string | null, gitCommitHash?: string | null, initialQueuePosition?: number | null, queuePosition?: number | null, estimatedWaitTimeLeftSeconds?: number | null, priority: BuildPriority, createdAt: any, updatedAt: any, error?: { __typename?: 'BuildError', errorCode: string, message: string, docsUrl?: string | null } | null, artifacts?: { __typename?: 'BuildArtifacts', buildUrl?: string | null, xcodeBuildLogsUrl?: string | null } | null, initiatingActor?: { __typename: 'Robot', id: string, displayName: string } | { __typename: 'User', id: string, displayName: string } | null, project: { __typename: 'App', id: string, name: string, slug: string, ownerAccount: { __typename?: 'Account', id: string, name: string } } | { __typename: 'Snack', id: string, name: string, slug: string } }, deprecationInfo?: { __typename?: 'EASBuildDeprecationInfo', type: EasBuildDeprecationInfoType, message: string } | null } } };
export type CreateIosBuildMutationVariables = Exact<{
appId: Scalars['ID'];
job: IosJobInput;
metadata?: InputMaybe<BuildMetadataInput>;
buildParams?: InputMaybe<BuildParamsInput>;
}>;
export type CreateIosBuildMutation = { __typename?: 'RootMutation', build: { __typename?: 'BuildMutation', createIosBuild: { __typename?: 'CreateBuildResult', build: { __typename?: 'Build', id: string, status: BuildStatus, platform: AppPlatform, channel?: string | null, releaseChannel?: string | null, distribution?: DistributionType | null, iosEnterpriseProvisioning?: BuildIosEnterpriseProvisioning | null, buildProfile?: string | null, sdkVersion?: string | null, appVersion?: string | null, appBuildVersion?: string | null, runtimeVersion?: string | null, gitCommitHash?: string | null, initialQueuePosition?: number | null, queuePosition?: number | null, estimatedWaitTimeLeftSeconds?: number | null, priority: BuildPriority, createdAt?: any | null, updatedAt?: any | null, error?: { __typename?: 'BuildError', errorCode: string, message: string, docsUrl?: string | null } | null, artifacts?: { __typename?: 'BuildArtifacts', buildUrl?: string | null, xcodeBuildLogsUrl?: string | null } | null, initiatingActor?: { __typename: 'Robot', id: string, displayName: string } | { __typename: 'User', id: string, displayName: string } | null, project: { __typename: 'App', id: string, name: string, slug: string, ownerAccount: { __typename?: 'Account', id: string, name: string } } | { __typename: 'Snack', id: string, name: string, slug: string } }, deprecationInfo?: { __typename?: 'EASBuildDeprecationInfo', type: EasBuildDeprecationInfoType, message: string } | null } } };
export type CreateIosBuildMutation = { __typename?: 'RootMutation', build: { __typename?: 'BuildMutation', createIosBuild: { __typename?: 'CreateBuildResult', build: { __typename?: 'Build', id: string, status: BuildStatus, platform: AppPlatform, channel?: string | null, releaseChannel?: string | null, distribution?: DistributionType | null, iosEnterpriseProvisioning?: BuildIosEnterpriseProvisioning | null, buildProfile?: string | null, sdkVersion?: string | null, appVersion?: string | null, appBuildVersion?: string | null, runtimeVersion?: string | null, gitCommitHash?: string | null, initialQueuePosition?: number | null, queuePosition?: number | null, estimatedWaitTimeLeftSeconds?: number | null, priority: BuildPriority, createdAt: any, updatedAt: any, error?: { __typename?: 'BuildError', errorCode: string, message: string, docsUrl?: string | null } | null, artifacts?: { __typename?: 'BuildArtifacts', buildUrl?: string | null, xcodeBuildLogsUrl?: string | null } | null, initiatingActor?: { __typename: 'Robot', id: string, displayName: string } | { __typename: 'User', id: string, displayName: string } | null, project: { __typename: 'App', id: string, name: string, slug: string, ownerAccount: { __typename?: 'Account', id: string, name: string } } | { __typename: 'Snack', id: string, name: string, slug: string } }, deprecationInfo?: { __typename?: 'EASBuildDeprecationInfo', type: EasBuildDeprecationInfoType, message: string } | null } } };
export type CreateEnvironmentSecretForAccountMutationVariables = Exact<{
input: CreateEnvironmentSecretInput;
@@ -4418,6 +4650,15 @@ export type DeleteWebhookMutationVariables = Exact<{
export type DeleteWebhookMutation = { __typename?: 'RootMutation', webhook: { __typename?: 'WebhookMutation', deleteWebhook: { __typename?: 'DeleteWebhookResult', id: string } } };
export type LatestAppVersionQueryVariables = Exact<{
appId: Scalars['String'];
platform: AppPlatform;
applicationIdentifier: Scalars['String'];
}>;
export type LatestAppVersionQuery = { __typename?: 'RootQuery', app: { __typename?: 'AppQuery', byId: { __typename?: 'App', id: string, latestAppVersionByPlatformAndApplicationIdentifier?: { __typename?: 'AppVersion', id: string, storeVersion: string, buildVersion: string } | null } } };
export type ViewBranchQueryVariables = Exact<{
appId: Scalars['String'];
name: Scalars['String'];
@@ -4431,7 +4672,7 @@ export type BuildsByIdQueryVariables = Exact<{
}>;
export type BuildsByIdQuery = { __typename?: 'RootQuery', builds: { __typename?: 'BuildQuery', byId: { __typename?: 'Build', id: string, status: BuildStatus, platform: AppPlatform, channel?: string | null, releaseChannel?: string | null, distribution?: DistributionType | null, iosEnterpriseProvisioning?: BuildIosEnterpriseProvisioning | null, buildProfile?: string | null, sdkVersion?: string | null, appVersion?: string | null, appBuildVersion?: string | null, runtimeVersion?: string | null, gitCommitHash?: string | null, initialQueuePosition?: number | null, queuePosition?: number | null, estimatedWaitTimeLeftSeconds?: number | null, priority: BuildPriority, createdAt?: any | null, updatedAt?: any | null, error?: { __typename?: 'BuildError', errorCode: string, message: string, docsUrl?: string | null } | null, artifacts?: { __typename?: 'BuildArtifacts', buildUrl?: string | null, xcodeBuildLogsUrl?: string | null } | null, initiatingActor?: { __typename: 'Robot', id: string, displayName: string } | { __typename: 'User', id: string, displayName: string } | null, project: { __typename: 'App', id: string, name: string, slug: string, ownerAccount: { __typename?: 'Account', id: string, name: string } } | { __typename: 'Snack', id: string, name: string, slug: string } } } };
export type BuildsByIdQuery = { __typename?: 'RootQuery', builds: { __typename?: 'BuildQuery', byId: { __typename?: 'Build', id: string, status: BuildStatus, platform: AppPlatform, channel?: string | null, releaseChannel?: string | null, distribution?: DistributionType | null, iosEnterpriseProvisioning?: BuildIosEnterpriseProvisioning | null, buildProfile?: string | null, sdkVersion?: string | null, appVersion?: string | null, appBuildVersion?: string | null, runtimeVersion?: string | null, gitCommitHash?: string | null, initialQueuePosition?: number | null, queuePosition?: number | null, estimatedWaitTimeLeftSeconds?: number | null, priority: BuildPriority, createdAt: any, updatedAt: any, error?: { __typename?: 'BuildError', errorCode: string, message: string, docsUrl?: string | null } | null, artifacts?: { __typename?: 'BuildArtifacts', buildUrl?: string | null, xcodeBuildLogsUrl?: string | null } | null, initiatingActor?: { __typename: 'Robot', id: string, displayName: string } | { __typename: 'User', id: string, displayName: string } | null, project: { __typename: 'App', id: string, name: string, slug: string, ownerAccount: { __typename?: 'Account', id: string, name: string } } | { __typename: 'Snack', id: string, name: string, slug: string } } } };
export type GetAllBuildsForAppQueryVariables = Exact<{
appId: Scalars['String'];
@@ -4441,7 +4682,7 @@ export type GetAllBuildsForAppQueryVariables = Exact<{
}>;
export type GetAllBuildsForAppQuery = { __typename?: 'RootQuery', app: { __typename?: 'AppQuery', byId: { __typename?: 'App', id: string, builds: Array<{ __typename?: 'Build', id: string, status: BuildStatus, platform: AppPlatform, channel?: string | null, releaseChannel?: string | null, distribution?: DistributionType | null, iosEnterpriseProvisioning?: BuildIosEnterpriseProvisioning | null, buildProfile?: string | null, sdkVersion?: string | null, appVersion?: string | null, appBuildVersion?: string | null, runtimeVersion?: string | null, gitCommitHash?: string | null, initialQueuePosition?: number | null, queuePosition?: number | null, estimatedWaitTimeLeftSeconds?: number | null, priority: BuildPriority, createdAt?: any | null, updatedAt?: any | null, error?: { __typename?: 'BuildError', errorCode: string, message: string, docsUrl?: string | null } | null, artifacts?: { __typename?: 'BuildArtifacts', buildUrl?: string | null, xcodeBuildLogsUrl?: string | null } | null, initiatingActor?: { __typename: 'Robot', id: string, displayName: string } | { __typename: 'User', id: string, displayName: string } | null, project: { __typename: 'App', id: string, name: string, slug: string, ownerAccount: { __typename?: 'Account', id: string, name: string } } | { __typename: 'Snack', id: string, name: string, slug: string } }> } } };
export type GetAllBuildsForAppQuery = { __typename?: 'RootQuery', app: { __typename?: 'AppQuery', byId: { __typename?: 'App', id: string, builds: Array<{ __typename?: 'Build', id: string, status: BuildStatus, platform: AppPlatform, channel?: string | null, releaseChannel?: string | null, distribution?: DistributionType | null, iosEnterpriseProvisioning?: BuildIosEnterpriseProvisioning | null, buildProfile?: string | null, sdkVersion?: string | null, appVersion?: string | null, appBuildVersion?: string | null, runtimeVersion?: string | null, gitCommitHash?: string | null, initialQueuePosition?: number | null, queuePosition?: number | null, estimatedWaitTimeLeftSeconds?: number | null, priority: BuildPriority, createdAt: any, updatedAt: any, error?: { __typename?: 'BuildError', errorCode: string, message: string, docsUrl?: string | null } | null, artifacts?: { __typename?: 'BuildArtifacts', buildUrl?: string | null, xcodeBuildLogsUrl?: string | null } | null, initiatingActor?: { __typename: 'Robot', id: string, displayName: string } | { __typename: 'User', id: string, displayName: string } | null, project: { __typename: 'App', id: string, name: string, slug: string, ownerAccount: { __typename?: 'Account', id: string, name: string } } | { __typename: 'Snack', id: string, name: string, slug: string } }> } } };
export type GetChannelByNameForAppQueryVariables = Exact<{
appId: Scalars['String'];
@@ -4510,6 +4751,7 @@ export type ViewBranchUpdatesQueryVariables = Exact<{
appId: Scalars['String'];
name: Scalars['String'];
limit: Scalars['Int'];
offset: Scalars['Int'];
}>;
@@ -4537,7 +4779,7 @@ export type WebhookByIdQuery = { __typename?: 'RootQuery', webhook: { __typename
export type AppFragment = { __typename?: 'App', id: string, fullName: string, slug: string };
export type BuildFragment = { __typename?: 'Build', id: string, status: BuildStatus, platform: AppPlatform, channel?: string | null, releaseChannel?: string | null, distribution?: DistributionType | null, iosEnterpriseProvisioning?: BuildIosEnterpriseProvisioning | null, buildProfile?: string | null, sdkVersion?: string | null, appVersion?: string | null, appBuildVersion?: string | null, runtimeVersion?: string | null, gitCommitHash?: string | null, initialQueuePosition?: number | null, queuePosition?: number | null, estimatedWaitTimeLeftSeconds?: number | null, priority: BuildPriority, createdAt?: any | null, updatedAt?: any | null, error?: { __typename?: 'BuildError', errorCode: string, message: string, docsUrl?: string | null } | null, artifacts?: { __typename?: 'BuildArtifacts', buildUrl?: string | null, xcodeBuildLogsUrl?: string | null } | null, initiatingActor?: { __typename: 'Robot', id: string, displayName: string } | { __typename: 'User', id: string, displayName: string } | null, project: { __typename: 'App', id: string, name: string, slug: string, ownerAccount: { __typename?: 'Account', id: string, name: string } } | { __typename: 'Snack', id: string, name: string, slug: string } };
export type BuildFragment = { __typename?: 'Build', id: string, status: BuildStatus, platform: AppPlatform, channel?: string | null, releaseChannel?: string | null, distribution?: DistributionType | null, iosEnterpriseProvisioning?: BuildIosEnterpriseProvisioning | null, buildProfile?: string | null, sdkVersion?: string | null, appVersion?: string | null, appBuildVersion?: string | null, runtimeVersion?: string | null, gitCommitHash?: string | null, initialQueuePosition?: number | null, queuePosition?: number | null, estimatedWaitTimeLeftSeconds?: number | null, priority: BuildPriority, createdAt: any, updatedAt: any, error?: { __typename?: 'BuildError', errorCode: string, message: string, docsUrl?: string | null } | null, artifacts?: { __typename?: 'BuildArtifacts', buildUrl?: string | null, xcodeBuildLogsUrl?: string | null } | null, initiatingActor?: { __typename: 'Robot', id: string, displayName: string } | { __typename: 'User', id: string, displayName: string } | null, project: { __typename: 'App', id: string, name: string, slug: string, ownerAccount: { __typename?: 'Account', id: string, name: string } } | { __typename: 'Snack', id: string, name: string, slug: string } };
export type EnvironmentSecretFragment = { __typename?: 'EnvironmentSecret', id: string, name: string, createdAt: any };
@@ -0,0 +1,42 @@
import assert from 'assert';
import gql from 'graphql-tag';
import { graphqlClient, withErrorHandlingAsync } from '../client';
import {
AppPlatform,
CreateAppVersionMutation,
CreateAppVersionMutationVariables,
} from '../generated';
export const AppVersionMutation = {
async createAppVersionAsync(appVersionInput: {
appId: string;
platform: AppPlatform;
applicationIdentifier: string;
storeVersion: string;
buildVersion: string;
runtimeVersion?: string;
}): Promise<string> {
const data = await withErrorHandlingAsync(
graphqlClient
.mutation<CreateAppVersionMutation, CreateAppVersionMutationVariables>(
gql`
mutation CreateAppVersionMutation($appVersionInput: AppVersionInput!) {
appVersion {
createAppVersion(appVersionInput: $appVersionInput) {
id
}
}
}
`,
{
appVersionInput,
}
)
.toPromise()
);
const appVersionId = data.appVersion?.createAppVersion.id;
assert(appVersionId, 'AppVersion ID must be defined');
return appVersionId;
},
};
@@ -7,6 +7,7 @@ import {
AndroidJobInput,
BuildFragment,
BuildMetadataInput,
BuildParamsInput,
CreateAndroidBuildMutation,
CreateAndroidBuildMutationVariables,
CreateIosBuildMutation,
@@ -26,6 +27,7 @@ export const BuildMutation = {
appId: string;
job: AndroidJobInput;
metadata: BuildMetadataInput;
buildParams: BuildParamsInput;
}): Promise<BuildResult> {
const data = await withErrorHandlingAsync(
graphqlClient
@@ -35,9 +37,15 @@ export const BuildMutation = {
$appId: ID!
$job: AndroidJobInput!
$metadata: BuildMetadataInput
$buildParams: BuildParamsInput
) {
build {
createAndroidBuild(appId: $appId, job: $job, metadata: $metadata) {
createAndroidBuild(
appId: $appId
job: $job
metadata: $metadata
buildParams: $buildParams
) {
build {
id
...BuildFragment
@@ -62,6 +70,7 @@ export const BuildMutation = {
appId: string;
job: IosJobInput;
metadata: BuildMetadataInput;
buildParams: BuildParamsInput;
}): Promise<BuildResult> {
const data = await withErrorHandlingAsync(
graphqlClient
@@ -71,9 +80,15 @@ export const BuildMutation = {
$appId: ID!
$job: IosJobInput!
$metadata: BuildMetadataInput
$buildParams: BuildParamsInput
) {
build {
createIosBuild(appId: $appId, job: $job, metadata: $metadata) {
createIosBuild(
appId: $appId
job: $job
metadata: $metadata
buildParams: $buildParams
) {
build {
id
...BuildFragment
@@ -0,0 +1,51 @@
import gql from 'graphql-tag';
import { graphqlClient, withErrorHandlingAsync } from '../client';
import {
AppPlatform,
AppVersion,
LatestAppVersionQuery,
LatestAppVersionQueryVariables,
} from '../generated';
export const AppVersionQuery = {
async latestVersionAsync(
appId: string,
platform: AppPlatform,
applicationIdentifier: string
): Promise<Pick<AppVersion, 'storeVersion' | 'buildVersion'> | null> {
const data = await withErrorHandlingAsync(
graphqlClient
.query<LatestAppVersionQuery, LatestAppVersionQueryVariables>(
gql`
query LatestAppVersion(
$appId: String!
$platform: AppPlatform!
$applicationIdentifier: String!
) {
app {
byId(appId: $appId) {
id
latestAppVersionByPlatformAndApplicationIdentifier(
platform: $platform
applicationIdentifier: $applicationIdentifier
) {
id
storeVersion
buildVersion
}
}
}
}
`,
{ appId, applicationIdentifier, platform },
{
additionalTypenames: ['App', 'AppVersion'],
}
)
.toPromise()
);
return data.app.byId.latestAppVersionByPlatformAndApplicationIdentifier ?? null;
},
};
@@ -8,7 +8,11 @@ import {
ViewBranchUpdatesQueryVariables,
} from '../generated';
const PAGE_LIMIT = 300;
export const viewBranchUpdatesQueryUpdateLimit = 300;
type ViewBranchUpdatesQueryVariablesWithOptionalLimitAndOffset =
Partial<ViewBranchUpdatesQueryVariables> &
Pick<ViewBranchUpdatesQueryVariables, 'appId' | 'name'>;
export const UpdateQuery = {
async viewAllAsync({ appId }: { appId: string }): Promise<ViewAllUpdatesQuery> {
@@ -47,26 +51,31 @@ export const UpdateQuery = {
`,
{
appId,
limit: PAGE_LIMIT,
limit: viewBranchUpdatesQueryUpdateLimit,
},
{ additionalTypenames: ['UpdateBranch', 'Update'] }
)
.toPromise()
);
},
async viewBranchAsync({ appId, name }: Pick<ViewBranchUpdatesQueryVariables, 'appId' | 'name'>) {
async viewBranchAsync({
appId,
name,
limit = viewBranchUpdatesQueryUpdateLimit,
offset = 0,
}: ViewBranchUpdatesQueryVariablesWithOptionalLimitAndOffset) {
return withErrorHandlingAsync<ViewBranchUpdatesQuery>(
graphqlClient
.query<ViewBranchUpdatesQuery, ViewBranchUpdatesQueryVariables>(
gql`
query ViewBranchUpdates($appId: String!, $name: String!, $limit: Int!) {
query ViewBranchUpdates($appId: String!, $name: String!, $limit: Int!, $offset: Int!) {
app {
byId(appId: $appId) {
id
updateBranchByName(name: $name) {
id
name
updates(offset: 0, limit: $limit) {
updates(offset: $offset, limit: $limit) {
id
group
message
@@ -92,7 +101,8 @@ export const UpdateQuery = {
{
appId,
name,
limit: PAGE_LIMIT,
limit,
offset,
},
{ additionalTypenames: ['UpdateBranch', 'Update'] }
)
@@ -1,23 +1,84 @@
import { AgeRatingDeclaration, KidsAge } from '@expo/apple-utils';
import { AgeRatingDeclaration, KidsAge, Rating } from '@expo/apple-utils';
import { AttributesOf } from '../../../../utils/asc';
export const kidsSixToEightAdvisory: AttributesOf<AgeRatingDeclaration> = {
// @ts-expect-error: Deprecated property `gamblingAndContests` needs to be deleted from the types
// These attributes is what we get from the API when no questions are answered
export const emptyAdvisory: AttributesOf<AgeRatingDeclaration> = {
alcoholTobaccoOrDrugUseOrReferences: null,
gamblingSimulated: null,
gambling: null,
contests: null,
gamblingSimulated: null,
horrorOrFearThemes: null,
matureOrSuggestiveThemes: null,
medicalOrTreatmentInformation: null,
profanityOrCrudeHumor: null,
sexualContentGraphicAndNudity: null,
sexualContentOrNudity: null,
horrorOrFearThemes: null,
matureOrSuggestiveThemes: null,
violenceCartoonOrFantasy: null,
violenceRealisticProlongedGraphicOrSadistic: null,
violenceRealistic: null,
violenceRealisticProlongedGraphicOrSadistic: null,
gambling: false,
unrestrictedWebAccess: false,
kidsAgeBand: null,
seventeenPlus: false,
};
// @ts-expect-error: Deprecated property `gamblingAndContests` needs to be deleted from the types
export const leastRestrictiveAdvisory: AttributesOf<AgeRatingDeclaration> = {
alcoholTobaccoOrDrugUseOrReferences: Rating.NONE,
contests: Rating.NONE,
gamblingSimulated: Rating.NONE,
horrorOrFearThemes: Rating.NONE,
matureOrSuggestiveThemes: Rating.NONE,
medicalOrTreatmentInformation: Rating.NONE,
profanityOrCrudeHumor: Rating.NONE,
sexualContentGraphicAndNudity: Rating.NONE,
sexualContentOrNudity: Rating.NONE,
violenceCartoonOrFantasy: Rating.NONE,
violenceRealistic: Rating.NONE,
violenceRealisticProlongedGraphicOrSadistic: Rating.NONE,
gambling: false,
unrestrictedWebAccess: false,
kidsAgeBand: null,
seventeenPlus: false,
};
// @ts-expect-error: Deprecated property `gamblingAndContests` needs to be deleted from the types
export const mostRestrictiveAdvisory: AttributesOf<AgeRatingDeclaration> = {
alcoholTobaccoOrDrugUseOrReferences: Rating.FREQUENT_OR_INTENSE,
contests: Rating.FREQUENT_OR_INTENSE,
gamblingSimulated: Rating.FREQUENT_OR_INTENSE,
horrorOrFearThemes: Rating.FREQUENT_OR_INTENSE,
matureOrSuggestiveThemes: Rating.FREQUENT_OR_INTENSE,
medicalOrTreatmentInformation: Rating.FREQUENT_OR_INTENSE,
profanityOrCrudeHumor: Rating.FREQUENT_OR_INTENSE,
sexualContentGraphicAndNudity: Rating.FREQUENT_OR_INTENSE,
sexualContentOrNudity: Rating.FREQUENT_OR_INTENSE,
violenceCartoonOrFantasy: Rating.FREQUENT_OR_INTENSE,
violenceRealistic: Rating.FREQUENT_OR_INTENSE,
violenceRealisticProlongedGraphicOrSadistic: Rating.FREQUENT_OR_INTENSE,
gambling: true,
unrestrictedWebAccess: true,
kidsAgeBand: null,
seventeenPlus: true,
};
// @ts-expect-error: Deprecated property `gamblingAndContests` needs to be deleted from the types
export const kidsSixToEightAdvisory: AttributesOf<AgeRatingDeclaration> = {
alcoholTobaccoOrDrugUseOrReferences: Rating.NONE,
contests: Rating.NONE,
gamblingSimulated: Rating.NONE,
horrorOrFearThemes: Rating.NONE,
matureOrSuggestiveThemes: Rating.NONE,
medicalOrTreatmentInformation: Rating.NONE,
profanityOrCrudeHumor: Rating.NONE,
sexualContentGraphicAndNudity: Rating.NONE,
sexualContentOrNudity: Rating.NONE,
violenceCartoonOrFantasy: Rating.NONE,
violenceRealistic: Rating.NONE,
violenceRealisticProlongedGraphicOrSadistic: Rating.NONE,
gambling: false,
unrestrictedWebAccess: false,
kidsAgeBand: KidsAge.SIX_TO_EIGHT,
seventeenPlus: false,
kidsAgeBand: KidsAge.SIX_TO_EIGHT,
gamblingAndContests: false,
};
@@ -1,23 +1,37 @@
import { AppCategory, AppInfo, BundleIdPlatform } from '@expo/apple-utils';
import { AppCategory, AppCategoryId, AppSubcategoryId, BundleIdPlatform } from '@expo/apple-utils';
import { AttributesOf } from '../../../../utils/asc';
import { AppleConfigWriter } from '../../writer';
export const primaryOnlyCategory: Pick<AttributesOf<AppInfo>, 'primaryCategory'> = {
primaryCategory: new AppCategory({} as any, 'ENTERTAINMENT', {
/** Infered type of the AppleConfigWriter.setCategories attributes */
type CategoryInfoProps = Parameters<typeof AppleConfigWriter.prototype.setCategories>[0];
/**
* Export a factory method instead of fixed categories.
* In the tests, we need to create a couple of different categories,
* adding them here as fixed objects won't be readable.
*/
export function makeCategory(id?: AppCategoryId | AppSubcategoryId): AppCategory | undefined {
if (!id) {
return undefined;
}
return new AppCategory({} as any, id, {
platforms: [BundleIdPlatform.IOS, BundleIdPlatform.MAC_OS],
}),
};
});
}
export const secondaryOnlyCategory: Pick<AttributesOf<AppInfo>, 'secondaryCategory'> = {
secondaryCategory: new AppCategory({} as any, 'GAMES', {
platforms: [BundleIdPlatform.IOS, BundleIdPlatform.MAC_OS],
}),
};
export const primaryAndSecondaryCategory: Pick<
AttributesOf<AppInfo>,
'primaryCategory' | 'secondaryCategory'
> = {
...primaryOnlyCategory,
...secondaryOnlyCategory,
};
/**
* Export a helper method to create the AppInfo object, using only AppCategoryIds or AppSubcategoryId.
*/
export function makeCategoryInfo(
attributes: Partial<Record<keyof CategoryInfoProps, AppCategoryId | AppSubcategoryId>>
): CategoryInfoProps {
return {
primaryCategory: makeCategory(attributes.primaryCategory),
primarySubcategoryOne: makeCategory(attributes.primarySubcategoryOne),
primarySubcategoryTwo: makeCategory(attributes.primarySubcategoryTwo),
secondaryCategory: makeCategory(attributes.secondaryCategory),
secondarySubcategoryOne: makeCategory(attributes.secondarySubcategoryOne),
secondarySubcategoryTwo: makeCategory(attributes.secondarySubcategoryTwo),
};
}
@@ -0,0 +1,25 @@
import { AppStoreReviewDetail } from '@expo/apple-utils';
import { AttributesOf } from '../../../../utils/asc';
export const nameOnlyReviewDetails: AttributesOf<AppStoreReviewDetail> = {
contactFirstName: 'Evan',
contactLastName: 'Bacon',
contactEmail: '[email protected]',
contactPhone: '+1 (555) 555-5555',
demoAccountName: null,
demoAccountPassword: null,
demoAccountRequired: null,
notes: null,
};
export const nameAndDemoReviewDetails: AttributesOf<AppStoreReviewDetail> = {
contactFirstName: 'Evan',
contactLastName: 'Bacon',
contactEmail: '[email protected]',
contactPhone: '+1 (555) 555-5555',
demoAccountName: '[email protected]',
demoAccountPassword: 'secretpassword',
demoAccountRequired: true,
notes: null,
};
@@ -0,0 +1,85 @@
import { AppCategoryId, AppSubcategoryId } from '@expo/apple-utils';
import { AppleConfigReader } from '../reader';
import { leastRestrictiveAdvisory, mostRestrictiveAdvisory } from './fixtures/ageRatingDeclaration';
describe('getCategories', () => {
it('ignores categories when not set', () => {
const reader = new AppleConfigReader({ categories: undefined });
expect(reader.getCategories()).toBeNull();
});
it('returns primary category only', () => {
const reader = new AppleConfigReader({ categories: [AppCategoryId.ENTERTAINMENT] });
expect(reader.getCategories()).toMatchObject({
primaryCategory: AppCategoryId.ENTERTAINMENT,
secondaryCategory: undefined,
});
});
it('returns primary and secondary categories', () => {
const reader = new AppleConfigReader({
categories: [AppCategoryId.ENTERTAINMENT, AppCategoryId.FOOD_AND_DRINK],
});
expect(reader.getCategories()).toMatchObject({
primaryCategory: AppCategoryId.ENTERTAINMENT,
secondaryCategory: AppCategoryId.FOOD_AND_DRINK,
});
});
it('returns primary category without subcategory', () => {
const reader = new AppleConfigReader({ categories: [AppCategoryId.GAMES] });
expect(reader.getCategories()).toMatchObject({
primaryCategory: AppCategoryId.GAMES,
secondaryCategory: undefined,
});
});
it('returns primary category with two subcategories', () => {
const reader = new AppleConfigReader({
categories: [
[AppCategoryId.GAMES, AppSubcategoryId.GAMES_CARD, AppSubcategoryId.GAMES_ADVENTURE],
],
});
expect(reader.getCategories()).toMatchObject({
primaryCategory: AppCategoryId.GAMES,
primarySubcategoryOne: AppSubcategoryId.GAMES_CARD,
primarySubcategoryTwo: AppSubcategoryId.GAMES_ADVENTURE,
secondaryCategory: undefined,
});
});
it('returns primary and secondary categories with two subcategories', () => {
const reader = new AppleConfigReader({
categories: [
AppCategoryId.ENTERTAINMENT,
[AppCategoryId.GAMES, AppSubcategoryId.GAMES_CARD, AppSubcategoryId.GAMES_ADVENTURE],
],
});
expect(reader.getCategories()).toMatchObject({
primaryCategory: AppCategoryId.ENTERTAINMENT,
secondaryCategory: AppCategoryId.GAMES,
secondarySubcategoryOne: AppSubcategoryId.GAMES_CARD,
secondarySubcategoryTwo: AppSubcategoryId.GAMES_ADVENTURE,
});
expect(reader.getCategories()).not.toHaveProperty('primarySubcategoryOne');
expect(reader.getCategories()).not.toHaveProperty('primarySubcategoryTwo');
});
});
describe('getAgeRating', () => {
it('ignores advisory when not set', () => {
const reader = new AppleConfigReader({ advisory: undefined });
expect(reader.getAgeRating()).toBeNull();
});
it('auto-fills least restrictive advisory', () => {
const reader = new AppleConfigReader({ advisory: {} });
expect(reader.getAgeRating()).toMatchObject(leastRestrictiveAdvisory);
});
it('returns most restrictive advisory', () => {
const reader = new AppleConfigReader({ advisory: mostRestrictiveAdvisory });
expect(reader.getAgeRating()).toMatchObject(mostRestrictiveAdvisory);
});
});
@@ -1,7 +1,15 @@
import { AppCategoryId, AppSubcategoryId } from '@expo/apple-utils';
import { AppleConfigWriter } from '../writer';
import { kidsSixToEightAdvisory } from './fixtures/ageRatingDeclaration';
import { primaryAndSecondaryCategory, secondaryOnlyCategory } from './fixtures/appInfo';
import {
emptyAdvisory,
kidsSixToEightAdvisory,
leastRestrictiveAdvisory,
mostRestrictiveAdvisory,
} from './fixtures/ageRatingDeclaration';
import { makeCategoryInfo } from './fixtures/appInfo';
import { dutchInfo, englishInfo } from './fixtures/appInfoLocalization';
import { nameAndDemoReviewDetails, nameOnlyReviewDetails } from './fixtures/appStoreReviewDetail';
import { automaticRelease, manualRelease, scheduledRelease } from './fixtures/appStoreVersion';
import { dutchVersion, englishVersion } from './fixtures/appStoreVersionLocalization';
@@ -16,11 +24,23 @@ describe('toSchema', () => {
});
describe('setAgeRating', () => {
it('modifies the advisory', () => {
it('auto-fills least restrictive advisory', () => {
const writer = new AppleConfigWriter();
writer.setAgeRating(emptyAdvisory);
expect(writer.schema.advisory).toMatchObject(leastRestrictiveAdvisory);
});
it('modifies kids band rating', () => {
const writer = new AppleConfigWriter();
writer.setAgeRating(kidsSixToEightAdvisory);
expect(writer.schema.advisory).toMatchObject(kidsSixToEightAdvisory);
});
it('modifies most restrictive advisory', () => {
const writer = new AppleConfigWriter();
writer.setAgeRating(mostRestrictiveAdvisory);
expect(writer.schema.advisory).toMatchObject(mostRestrictiveAdvisory);
});
});
describe('setInfoLocale', () => {
@@ -55,22 +75,109 @@ describe('setInfoLocale', () => {
});
describe('setCategories', () => {
it('modifies the categories', () => {
const writer = new AppleConfigWriter();
writer.setCategories(primaryAndSecondaryCategory as any);
expect(writer.schema.categories).toHaveLength(2);
expect((writer.schema.categories as any)[0]).toBe(
primaryAndSecondaryCategory.primaryCategory?.id
);
expect((writer.schema.categories as any)[1]).toBe(
primaryAndSecondaryCategory.secondaryCategory?.id
);
});
it('skips secondary category without primary category', () => {
const writer = new AppleConfigWriter();
writer.setCategories(secondaryOnlyCategory as any);
expect(writer.schema.categories).toHaveLength(0);
writer.setCategories(makeCategoryInfo({}));
expect(writer.schema.categories).toBeUndefined();
});
it('removes existing values when undefined', () => {
const writer = new AppleConfigWriter();
writer.setCategories(makeCategoryInfo({ primaryCategory: AppCategoryId.BUSINESS }));
writer.setCategories(makeCategoryInfo({}));
expect(writer.schema.categories).toBeUndefined();
});
it('modifies primary category only', () => {
const writer = new AppleConfigWriter();
writer.setCategories(makeCategoryInfo({ primaryCategory: AppCategoryId.ENTERTAINMENT }));
expect(writer.schema.categories).toEqual([AppCategoryId.ENTERTAINMENT]);
});
it('modifies primary and secondary categories', () => {
const writer = new AppleConfigWriter();
writer.setCategories(
makeCategoryInfo({
primaryCategory: AppCategoryId.ENTERTAINMENT,
secondaryCategory: AppCategoryId.FOOD_AND_DRINK,
})
);
expect(writer.schema.categories).toEqual([
AppCategoryId.ENTERTAINMENT,
AppCategoryId.FOOD_AND_DRINK,
]);
});
it('modifies primary category without subcategories', () => {
const writer = new AppleConfigWriter();
writer.setCategories(makeCategoryInfo({ primaryCategory: AppCategoryId.GAMES }));
expect(writer.schema.categories).toEqual([AppCategoryId.GAMES]);
});
it('modifies primary category with one subcategories', () => {
const writer = new AppleConfigWriter();
writer.setCategories(
makeCategoryInfo({
primaryCategory: AppCategoryId.GAMES,
primarySubcategoryOne: AppSubcategoryId.GAMES_CARD,
})
);
expect(writer.schema.categories).toEqual([[AppCategoryId.GAMES, AppSubcategoryId.GAMES_CARD]]);
// We need to filter the lists to avoid writing `[GAMES, GAMES_CARD, null]`
expect(writer.schema.categories![0]).toHaveLength(2);
});
it('modifies primary category with two subcategories', () => {
const writer = new AppleConfigWriter();
writer.setCategories(
makeCategoryInfo({
primaryCategory: AppCategoryId.GAMES,
primarySubcategoryOne: AppSubcategoryId.GAMES_CARD,
primarySubcategoryTwo: AppSubcategoryId.GAMES_ADVENTURE,
})
);
expect(writer.schema.categories).toEqual([
[AppCategoryId.GAMES, AppSubcategoryId.GAMES_CARD, AppSubcategoryId.GAMES_ADVENTURE],
]);
});
it('modifies primary and secondary categories with one subcategories', () => {
const writer = new AppleConfigWriter();
writer.setCategories(
makeCategoryInfo({
primaryCategory: AppCategoryId.ENTERTAINMENT,
secondaryCategory: AppCategoryId.GAMES,
secondarySubcategoryOne: AppSubcategoryId.GAMES_CARD,
})
);
expect(writer.schema.categories).toEqual([
AppCategoryId.ENTERTAINMENT,
[AppCategoryId.GAMES, AppSubcategoryId.GAMES_CARD],
]);
// We need to filter the lists to avoid writing `[GAMES, GAMES_CARD, null]`
expect(writer.schema.categories![1]).toHaveLength(2);
});
it('modifies primary and secondary categories with two subcategories', () => {
const writer = new AppleConfigWriter();
writer.setCategories(
makeCategoryInfo({
primaryCategory: AppCategoryId.ENTERTAINMENT,
secondaryCategory: AppCategoryId.GAMES,
secondarySubcategoryOne: AppSubcategoryId.GAMES_CARD,
secondarySubcategoryTwo: AppSubcategoryId.GAMES_ADVENTURE,
})
);
expect(writer.schema.categories).toEqual([
AppCategoryId.ENTERTAINMENT,
[AppCategoryId.GAMES, AppSubcategoryId.GAMES_CARD, AppSubcategoryId.GAMES_ADVENTURE],
]);
});
it('modifies secondary category without primary', () => {
const writer = new AppleConfigWriter();
writer.setCategories(makeCategoryInfo({ secondaryCategory: AppCategoryId.GAMES }));
expect(writer.schema.categories).toEqual(['', AppCategoryId.GAMES]);
});
});
@@ -99,18 +206,16 @@ describe('setVersionRelease', () => {
});
});
it('modifies manual release', () => {
it('does not modify manual release', () => {
const writer = new AppleConfigWriter();
writer.setVersionRelease(manualRelease);
expect(writer.schema.release).toMatchObject({
automaticRelease: false,
});
expect(writer.schema.release).toBeUndefined();
});
it('overwrites all release fields', () => {
const writer = new AppleConfigWriter();
writer.setVersionRelease(scheduledRelease);
writer.setVersionRelease(manualRelease);
writer.setVersionRelease(automaticRelease);
expect(writer.schema.release).not.toHaveProperty('autoReleaseDate');
});
});
@@ -149,3 +254,46 @@ describe('setVersionLocale', () => {
});
});
});
describe('setReviewDetails', () => {
it('modifies name only review details', () => {
const writer = new AppleConfigWriter();
writer.setReviewDetails(nameOnlyReviewDetails);
expect(writer.schema.review).toMatchObject({
firstName: nameOnlyReviewDetails.contactFirstName,
lastName: nameOnlyReviewDetails.contactLastName,
email: nameOnlyReviewDetails.contactEmail,
phone: nameOnlyReviewDetails.contactPhone,
demoUsername: undefined,
demoPassword: undefined,
demoRequired: undefined,
notes: undefined,
});
});
it('modifies name and demo review details', () => {
const writer = new AppleConfigWriter();
writer.setReviewDetails(nameAndDemoReviewDetails);
expect(writer.schema.review).toMatchObject({
firstName: nameAndDemoReviewDetails.contactFirstName,
lastName: nameAndDemoReviewDetails.contactLastName,
email: nameAndDemoReviewDetails.contactEmail,
phone: nameAndDemoReviewDetails.contactPhone,
demoUsername: nameAndDemoReviewDetails.demoAccountName,
demoPassword: nameAndDemoReviewDetails.demoAccountPassword,
demoRequired: nameAndDemoReviewDetails.demoAccountRequired,
notes: undefined,
});
});
it('replaces existing review details', () => {
const writer = new AppleConfigWriter();
writer.setReviewDetails(nameAndDemoReviewDetails);
writer.setReviewDetails(nameOnlyReviewDetails);
expect(writer.schema.review).toMatchObject({
demoUsername: undefined,
demoPassword: undefined,
demoRequired: undefined,
});
});
});
@@ -1,13 +1,15 @@
import {
AgeRatingDeclaration,
AppInfoLocalization,
AppStoreReviewDetail,
AppStoreVersion,
AppStoreVersionLocalization,
CategoryIds,
Rating,
ReleaseType,
} from '@expo/apple-utils';
import { unique } from '../../utils/array';
import uniq from '../../../utils/expodash/uniq';
import { AttributesOf } from '../../utils/asc';
import { removeDatePrecision } from '../../utils/date';
import { AppleMetadata } from '../types';
@@ -22,18 +24,42 @@ export const DEFAULT_WHATSNEW = 'Bug fixes and improved stability';
* This uses version 0 of the config schema.
*/
export class AppleConfigReader {
constructor(public readonly schema: AppleMetadata) {}
public constructor(public readonly schema: AppleMetadata) {}
getAgeRating(): Partial<AttributesOf<AgeRatingDeclaration>> | null {
return this.schema.advisory || null;
public getAgeRating(): Partial<AttributesOf<AgeRatingDeclaration>> | null {
const attributes = this.schema.advisory;
if (!attributes) {
return null;
}
return {
alcoholTobaccoOrDrugUseOrReferences:
attributes.alcoholTobaccoOrDrugUseOrReferences ?? Rating.NONE,
contests: attributes.contests ?? Rating.NONE,
gamblingSimulated: attributes.gamblingSimulated ?? Rating.NONE,
horrorOrFearThemes: attributes.horrorOrFearThemes ?? Rating.NONE,
matureOrSuggestiveThemes: attributes.matureOrSuggestiveThemes ?? Rating.NONE,
medicalOrTreatmentInformation: attributes.medicalOrTreatmentInformation ?? Rating.NONE,
profanityOrCrudeHumor: attributes.profanityOrCrudeHumor ?? Rating.NONE,
sexualContentGraphicAndNudity: attributes.sexualContentGraphicAndNudity ?? Rating.NONE,
sexualContentOrNudity: attributes.sexualContentOrNudity ?? Rating.NONE,
violenceCartoonOrFantasy: attributes.violenceCartoonOrFantasy ?? Rating.NONE,
violenceRealistic: attributes.violenceRealistic ?? Rating.NONE,
violenceRealisticProlongedGraphicOrSadistic:
attributes.violenceRealisticProlongedGraphicOrSadistic ?? Rating.NONE,
gambling: attributes.gambling ?? false,
unrestrictedWebAccess: attributes.unrestrictedWebAccess ?? false,
kidsAgeBand: attributes.kidsAgeBand ?? null,
seventeenPlus: attributes.seventeenPlus ?? false,
};
}
getLocales(): string[] {
public getLocales(): string[] {
// TODO: filter "default" locales, add option to add non-localized info to the config
return unique(Object.keys(this.schema.info || {}));
return uniq(Object.keys(this.schema.info || {}));
}
getInfoLocale(
public getInfoLocale(
locale: string
): PartialExcept<AttributesOf<AppInfoLocalization>, 'locale' | 'name'> | null {
const info = this.schema.info?.[locale];
@@ -43,7 +69,7 @@ export class AppleConfigReader {
return {
locale,
name: info.title || 'no name provided',
name: info.title,
subtitle: info.subtitle,
privacyChoicesUrl: info.privacyChoicesUrl,
privacyPolicyText: info.privacyPolicyText,
@@ -51,25 +77,44 @@ export class AppleConfigReader {
};
}
getCategories(): CategoryIds | null {
if (Array.isArray(this.schema.categories) && this.schema.categories.length > 0) {
return {
primaryCategory: this.schema.categories[0],
secondaryCategory: this.schema.categories[1],
};
public getCategories(): CategoryIds | null {
const { categories } = this.schema;
if (!categories || categories.length <= 0) {
return null;
}
return null;
// We validate the categories based on enums, but they will still be strings here.
const categoryIds: Partial<Record<keyof CategoryIds, string>> = {};
if (Array.isArray(categories[0])) {
categoryIds.primaryCategory = categories[0][0];
categoryIds.primarySubcategoryOne = categories[0][1];
categoryIds.primarySubcategoryTwo = categories[0][2];
} else {
categoryIds.primaryCategory = categories[0];
}
if (Array.isArray(categories[1])) {
categoryIds.secondaryCategory = categories[1][0];
categoryIds.secondarySubcategoryOne = categories[1][1];
categoryIds.secondarySubcategoryTwo = categories[1][2];
} else {
categoryIds.secondaryCategory = categories[1];
}
// Because we handle categories as normal strings,
// the type doesn't match with the actual CategoryIds types.
return categoryIds as CategoryIds;
}
/** Get the `AppStoreVersion` object. */
getVersion(): Partial<
public getVersion(): Partial<
Omit<AttributesOf<AppStoreVersion>, 'releaseType' | 'earliestReleaseDate'>
> | null {
return this.schema.copyright ? { copyright: this.schema.copyright } : null;
}
getVersionRelease(): Partial<
public getVersionRelease(): Partial<
Pick<AttributesOf<AppStoreVersion>, 'releaseType' | 'earliestReleaseDate'>
> | null {
const { release } = this.schema;
@@ -99,7 +144,7 @@ export class AppleConfigReader {
return null;
}
getVersionLocale(
public getVersionLocale(
locale: string,
context: { versionIsFirst: boolean }
): Partial<AttributesOf<AppStoreVersionLocalization>> | null {
@@ -119,4 +164,23 @@ export class AppleConfigReader {
supportUrl: info.supportUrl,
};
}
public getReviewDetails(): Partial<AttributesOf<AppStoreReviewDetail>> | null {
const review = this.schema.review;
if (!review) {
return null;
}
return {
contactFirstName: review.firstName,
contactLastName: review.lastName,
contactEmail: review.email,
contactPhone: review.phone,
demoAccountName: review.demoUsername,
demoAccountPassword: review.demoPassword,
demoAccountRequired: review.demoRequired,
notes: review.notes,
// TODO: add attachment
};
}
}
@@ -2,8 +2,10 @@ import {
AgeRatingDeclaration,
AppInfo,
AppInfoLocalization,
AppStoreReviewDetail,
AppStoreVersion,
AppStoreVersionLocalization,
Rating,
ReleaseType,
} from '@expo/apple-utils';
@@ -18,24 +20,43 @@ export class AppleConfigWriter {
constructor(public readonly schema: Partial<AppleMetadata> = {}) {}
/** Get the schema result to write it to the config file */
toSchema(): { configVersion: number; apple: Partial<AppleMetadata> } {
public toSchema(): { configVersion: number; apple: Partial<AppleMetadata> } {
return {
configVersion: 0,
apple: this.schema,
};
}
setAgeRating(attributes: AttributesOf<AgeRatingDeclaration>): void {
this.schema.advisory = attributes;
public setAgeRating(attributes: AttributesOf<AgeRatingDeclaration>): void {
this.schema.advisory = {
alcoholTobaccoOrDrugUseOrReferences:
attributes.alcoholTobaccoOrDrugUseOrReferences ?? Rating.NONE,
contests: attributes.contests ?? Rating.NONE,
gamblingSimulated: attributes.gamblingSimulated ?? Rating.NONE,
horrorOrFearThemes: attributes.horrorOrFearThemes ?? Rating.NONE,
matureOrSuggestiveThemes: attributes.matureOrSuggestiveThemes ?? Rating.NONE,
medicalOrTreatmentInformation: attributes.medicalOrTreatmentInformation ?? Rating.NONE,
profanityOrCrudeHumor: attributes.profanityOrCrudeHumor ?? Rating.NONE,
sexualContentGraphicAndNudity: attributes.sexualContentGraphicAndNudity ?? Rating.NONE,
sexualContentOrNudity: attributes.sexualContentOrNudity ?? Rating.NONE,
violenceCartoonOrFantasy: attributes.violenceCartoonOrFantasy ?? Rating.NONE,
violenceRealistic: attributes.violenceRealistic ?? Rating.NONE,
violenceRealisticProlongedGraphicOrSadistic:
attributes.violenceRealisticProlongedGraphicOrSadistic ?? Rating.NONE,
gambling: attributes.gambling ?? false,
unrestrictedWebAccess: attributes.unrestrictedWebAccess ?? false,
kidsAgeBand: attributes.kidsAgeBand ?? null,
seventeenPlus: attributes.seventeenPlus ?? false,
};
}
setInfoLocale(attributes: AttributesOf<AppInfoLocalization>): void {
public setInfoLocale(attributes: AttributesOf<AppInfoLocalization>): void {
this.schema.info = this.schema.info ?? {};
const existing = this.schema.info[attributes.locale] ?? {};
this.schema.info[attributes.locale] = {
...existing,
title: attributes.name ?? 'no name provided',
title: attributes.name ?? '',
subtitle: optional(attributes.subtitle),
privacyPolicyUrl: optional(attributes.privacyPolicyUrl),
privacyPolicyText: optional(attributes.privacyPolicyText),
@@ -43,25 +64,54 @@ export class AppleConfigWriter {
};
}
setCategories({ primaryCategory, secondaryCategory }: AttributesOf<AppInfo>): void {
public setCategories(
attributes: Pick<
AttributesOf<AppInfo>,
| 'primaryCategory'
| 'primarySubcategoryOne'
| 'primarySubcategoryTwo'
| 'secondaryCategory'
| 'secondarySubcategoryOne'
| 'secondarySubcategoryTwo'
>
): void {
this.schema.categories = undefined;
if (!attributes.primaryCategory && !attributes.secondaryCategory) {
return;
}
this.schema.categories = [];
// TODO: see why these types are conflicting
if (primaryCategory) {
this.schema.categories.push(primaryCategory.id as any);
if (secondaryCategory) {
this.schema.categories.push(secondaryCategory.id as any);
}
if (attributes.primaryCategory && attributes.primarySubcategoryOne) {
this.schema.categories[0] = [
attributes.primaryCategory.id,
attributes.primarySubcategoryOne?.id,
attributes.primarySubcategoryTwo?.id,
].filter(Boolean) as string[];
} else {
// If only the secondaryCategory was provided,
// autofill with an empty string and cause a store config error.
this.schema.categories[0] = attributes.primaryCategory?.id ?? '';
}
if (attributes.secondaryCategory && attributes.secondarySubcategoryOne) {
this.schema.categories[1] = [
attributes.secondaryCategory.id,
attributes.secondarySubcategoryOne?.id,
attributes.secondarySubcategoryTwo?.id,
].filter(Boolean) as string[];
} else if (attributes.secondaryCategory) {
this.schema.categories[1] = attributes.secondaryCategory.id;
}
}
setVersion(
public setVersion(
attributes: Omit<AttributesOf<AppStoreVersion>, 'releaseType' | 'earliestReleaseDate'>
): void {
this.schema.copyright = optional(attributes.copyright);
}
setVersionRelease(
public setVersionRelease(
attributes: Pick<AttributesOf<AppStoreVersion>, 'releaseType' | 'earliestReleaseDate'>
): void {
if (attributes.releaseType === ReleaseType.SCHEDULED) {
@@ -77,13 +127,13 @@ export class AppleConfigWriter {
}
if (attributes.releaseType === ReleaseType.MANUAL) {
this.schema.release = {
automaticRelease: false,
};
// ReleaseType.MANUAL is the default behavior, so we don't need to configure it.
// Setting `"automaticRelease": false` is a bit confusing for people who don't know what automaticRelease does.
this.schema.release = undefined;
}
}
setVersionLocale(attributes: AttributesOf<AppStoreVersionLocalization>): void {
public setVersionLocale(attributes: AttributesOf<AppStoreVersionLocalization>): void {
this.schema.info = this.schema.info ?? {};
const existing = this.schema.info[attributes.locale] ?? {};
@@ -99,6 +149,20 @@ export class AppleConfigWriter {
supportUrl: optional(attributes.supportUrl),
};
}
public setReviewDetails(attributes: AttributesOf<AppStoreReviewDetail>): void {
this.schema.review = {
firstName: attributes.contactFirstName ?? '',
lastName: attributes.contactLastName ?? '',
email: attributes.contactEmail ?? '',
phone: attributes.contactPhone ?? '',
demoUsername: optional(attributes.demoAccountName),
demoPassword: optional(attributes.demoAccountPassword),
demoRequired: optional(attributes.demoAccountRequired),
notes: optional(attributes.notes),
// TODO: add attachment
};
}
}
/** Helper function to convert `T | null` to `T | undefined`, required for the entity properties */
+2 -1
View File
@@ -2,13 +2,14 @@ import type { App } from '@expo/apple-utils';
import type { AgeRatingData } from './tasks/age-rating';
import type { AppInfoData } from './tasks/app-info';
import type { AppReviewData } from './tasks/app-review-detail';
import type { AppVersionData } from './tasks/app-version';
/**
* The fully prepared apple data, used within the `downloadAsync` or `uploadAsync` tasks.
* It contains references to each individual models, to either upload or download App Store data.
*/
export type AppleData = { app: App } & AppInfoData & AppVersionData & AgeRatingData;
export type AppleData = { app: App } & AppInfoData & AppVersionData & AgeRatingData & AppReviewData;
/**
* The unprepared partial apple data, used within the `prepareAsync` tasks.
+4 -4
View File
@@ -4,16 +4,16 @@ import { AppleData, PartialAppleData } from './data';
export abstract class AppleTask {
/** Get a description from the task to use as section headings in the log */
abstract name(): string;
public abstract name(): string;
/** Prepare the data from the App Store to start syncing with the store configuration */
abstract prepareAsync(options: TaskPrepareOptions): Promise<void>;
public abstract prepareAsync(options: TaskPrepareOptions): Promise<void>;
/** Download all information from the App Store to generate the store configuration */
abstract downloadAsync(options: TaskDownloadOptions): Promise<void>;
public abstract downloadAsync(options: TaskDownloadOptions): Promise<void>;
/** Upload all information from the store configuration to the App Store */
abstract uploadAsync(options: TaskUploadOptions): Promise<void>;
public abstract uploadAsync(options: TaskUploadOptions): Promise<void>;
}
export type TaskPrepareOptions = {
@@ -0,0 +1,151 @@
import { AppStoreReviewAttachment, AppStoreReviewDetail, AppStoreVersion } from '@expo/apple-utils';
import nock from 'nock';
import { AppleConfigReader } from '../../config/reader';
import { AppleConfigWriter } from '../../config/writer';
import { AppReviewDetailTask } from '../app-review-detail';
import { requestContext } from './fixtures/requestContext';
jest.mock('../../../../ora');
jest.mock('../../config/writer');
describe(AppReviewDetailTask, () => {
describe('prepareAsync', () => {
it('aborts when version is not loaded', async () => {
const promise = new AppReviewDetailTask().prepareAsync({ context: {} as any });
await expect(promise).rejects.toThrow('version not init');
});
it('loads review details from app version', async () => {
const scope = nock('https://api.appstoreconnect.apple.com')
.get(`/v1/${AppStoreVersion.type}/stub-id/appStoreReviewDetail`)
.query((params: any) => params['include'] === AppStoreReviewAttachment.type)
.reply(200, require('./fixtures/appStoreVersions/get-appStoreReviewDetail-200.json'));
const context: any = {
version: new AppStoreVersion(requestContext, 'stub-id', {} as any),
};
await new AppReviewDetailTask().prepareAsync({ context });
expect(context.reviewDetail).toBeInstanceOf(AppStoreReviewDetail);
expect(scope.isDone()).toBeTruthy();
});
});
describe('downloadAsync', () => {
it('sets review details when loaded', async () => {
const writer = jest.mocked(new AppleConfigWriter());
const reviewDetail = new AppStoreReviewDetail(requestContext, 'stub-id', {} as any);
await new AppReviewDetailTask().downloadAsync({
config: writer,
context: { reviewDetail } as any,
});
expect(writer.setReviewDetails).toBeCalledWith(reviewDetail.attributes);
});
it('skips when no review details are loaded', async () => {
const writer = jest.mocked(new AppleConfigWriter());
await new AppReviewDetailTask().downloadAsync({
config: writer,
context: {} as any,
});
expect(writer.setReviewDetails).not.toBeCalled();
});
});
describe('uploadAsync', () => {
it('skips when review details are not configured', async () => {
const scope = nock('https://api.appstoreconnect.apple.com')
.post(`/v1/${AppStoreReviewDetail.type}`)
.reply(201, require('./fixtures/appStoreReviewDetails/post-201.json'));
await new AppReviewDetailTask().uploadAsync({
config: new AppleConfigReader({
review: undefined,
}),
context: {
version: new AppStoreVersion(requestContext, 'stub-id', {} as any),
} as any,
});
expect(scope.isDone()).toBeFalsy();
nock.cleanAll();
});
it('aborts when version is not loaded', async () => {
const promise = new AppReviewDetailTask().uploadAsync({
config: new AppleConfigReader({
review: {
firstName: 'Evan',
lastName: 'Bacon',
email: '[email protected]',
phone: '+1 555 555 5555',
},
}),
context: {} as any,
});
await expect(promise).rejects.toThrow('version not init');
});
it('updates review details when loaded', async () => {
const scope = nock('https://api.appstoreconnect.apple.com')
.patch(`/v1/${AppStoreReviewDetail.type}/APP_STORE_REVIEW_DETAILS_1`)
.reply(200, require('./fixtures/appStoreReviewDetails/patch-200.json'));
await new AppReviewDetailTask().uploadAsync({
config: new AppleConfigReader({
review: {
firstName: 'Evan',
lastName: 'Bacon',
email: '[email protected]',
phone: '+1 555 555 5555',
},
}),
context: {
version: new AppStoreVersion(requestContext, 'stub-id', {} as any),
reviewDetail: new AppStoreReviewDetail(
requestContext,
'APP_STORE_REVIEW_DETAILS_1',
{} as any
),
} as any,
});
expect(scope.isDone()).toBeTruthy();
});
it('creates and updates review details when not loaded', async () => {
const createReviewDetailScope = nock('https://api.appstoreconnect.apple.com')
.post(`/v1/${AppStoreReviewDetail.type}`)
.reply(201, require('./fixtures/appStoreReviewDetails/post-201.json'));
const updateReviewDetailScope = nock('https://api.appstoreconnect.apple.com')
.patch(`/v1/${AppStoreReviewDetail.type}/APP_STORE_REVIEW_DETAILS_1`)
.reply(200, require('./fixtures/appStoreReviewDetails/patch-200.json'));
await new AppReviewDetailTask().uploadAsync({
config: new AppleConfigReader({
review: {
firstName: 'Evan',
lastName: 'Bacon',
email: '[email protected]',
phone: '+1 555 555 5555',
},
}),
context: {
version: new AppStoreVersion(requestContext, 'stub-id', {} as any),
} as any,
});
expect(createReviewDetailScope.isDone()).toBeTruthy();
expect(updateReviewDetailScope.isDone()).toBeTruthy();
});
});
});
@@ -0,0 +1,36 @@
{
"data": {
"type": "appStoreReviewDetails",
"id": "APP_STORE_REVIEW_DETAILS_1",
"attributes": {
"contactFirstName": "Evan",
"contactLastName": "Bacon",
"contactPhone": "+1-555-555-5555",
"contactEmail": "[email protected]",
"demoAccountName": null,
"demoAccountPassword": null,
"demoAccountRequired": false,
"notes": ""
},
"relationships": {
"appStoreReviewAttachments": {
"links": {
"self": "https://appstoreconnect.apple.com/iris/v1/appStoreReviewDetails/APP_STORE_REVIEW_DETAILS_1/relationships/appStoreReviewAttachments",
"related": "https://appstoreconnect.apple.com/iris/v1/appStoreReviewDetails/APP_STORE_REVIEW_DETAILS_1/appStoreReviewAttachments"
}
},
"appSandboxEntitlementUsages": {
"links": {
"self": "https://appstoreconnect.apple.com/iris/v1/appStoreReviewDetails/APP_STORE_REVIEW_DETAILS_1/relationships/appSandboxEntitlementUsages",
"related": "https://appstoreconnect.apple.com/iris/v1/appStoreReviewDetails/APP_STORE_REVIEW_DETAILS_1/appSandboxEntitlementUsages"
}
}
},
"links": {
"self": "https://appstoreconnect.apple.com/iris/v1/appStoreReviewDetails/APP_STORE_REVIEW_DETAILS_1"
}
},
"links": {
"self": "https://appstoreconnect.apple.com/iris/v1/appStoreReviewDetails/APP_STORE_REVIEW_DETAILS_1"
}
}
@@ -0,0 +1,36 @@
{
"data": {
"type": "appStoreReviewDetails",
"id": "APP_STORE_REVIEW_DETAILS_1",
"attributes": {
"contactFirstName": "Evan",
"contactLastName": "Bacon",
"contactPhone": "+1-555-555-5555",
"contactEmail": "[email protected]",
"demoAccountName": null,
"demoAccountPassword": null,
"demoAccountRequired": false,
"notes": ""
},
"relationships": {
"appStoreReviewAttachments": {
"links": {
"self": "https://appstoreconnect.apple.com/iris/v1/appStoreReviewDetails/APP_STORE_REVIEW_DETAILS_1/relationships/appStoreReviewAttachments",
"related": "https://appstoreconnect.apple.com/iris/v1/appStoreReviewDetails/APP_STORE_REVIEW_DETAILS_1/appStoreReviewAttachments"
}
},
"appSandboxEntitlementUsages": {
"links": {
"self": "https://appstoreconnect.apple.com/iris/v1/appStoreReviewDetails/APP_STORE_REVIEW_DETAILS_1/relationships/appSandboxEntitlementUsages",
"related": "https://appstoreconnect.apple.com/iris/v1/appStoreReviewDetails/APP_STORE_REVIEW_DETAILS_1/appSandboxEntitlementUsages"
}
}
},
"links": {
"self": "https://appstoreconnect.apple.com/iris/v1/appStoreReviewDetails/APP_STORE_REVIEW_DETAILS_1"
}
},
"links": {
"self": "https://appstoreconnect.apple.com/iris/v1/appStoreReviewDetails"
}
}
@@ -0,0 +1,6 @@
{
"data": null,
"links": {
"self": "https://appstoreconnect.apple.com/iris/v1/appStoreVersions/APP_STORE_VERSION_1/appStoreReviewDetail?include=appStoreReviewAttachments"
}
}
@@ -0,0 +1,36 @@
{
"data": {
"type": "appStoreReviewDetails",
"id": "APP_STORE_REVIEW_DETAILS_1",
"attributes": {
"contactFirstName": "Evan",
"contactLastName": "Bacon",
"contactPhone": "+1-555-555-5555",
"contactEmail": "[email protected]",
"demoAccountName": null,
"demoAccountPassword": null,
"demoAccountRequired": false,
"notes": ""
},
"relationships": {
"appStoreReviewAttachments": {
"links": {
"self": "https://appstoreconnect.apple.com/iris/v1/appStoreReviewDetails/APP_STORE_REVIEW_DETAILS_1/relationships/appStoreReviewAttachments",
"related": "https://appstoreconnect.apple.com/iris/v1/appStoreReviewDetails/APP_STORE_REVIEW_DETAILS_1/appStoreReviewAttachments"
}
},
"appSandboxEntitlementUsages": {
"links": {
"self": "https://appstoreconnect.apple.com/iris/v1/appStoreReviewDetails/APP_STORE_REVIEW_DETAILS_1/relationships/appSandboxEntitlementUsages",
"related": "https://appstoreconnect.apple.com/iris/v1/appStoreReviewDetails/APP_STORE_REVIEW_DETAILS_1/appSandboxEntitlementUsages"
}
}
},
"links": {
"self": "https://appstoreconnect.apple.com/iris/v1/appStoreReviewDetails/APP_STORE_REVIEW_DETAILS_1"
}
},
"links": {
"self": "https://appstoreconnect.apple.com/iris/v1/appStoreReviewDetails/APP_STORE_REVIEW_DETAILS_1"
}
}
@@ -12,20 +12,20 @@ export type AgeRatingData = {
};
export class AgeRatingTask extends AppleTask {
name = (): string => 'age rating declarations';
public name = (): string => 'age rating declarations';
async prepareAsync({ context }: TaskPrepareOptions): Promise<void> {
public async prepareAsync({ context }: TaskPrepareOptions): Promise<void> {
assert(context.version, `App version information is not prepared, can't update age rating`);
context.ageRating = (await context.version.getAgeRatingDeclarationAsync()) || undefined;
}
async downloadAsync({ config, context }: TaskDownloadOptions): Promise<void> {
public async downloadAsync({ config, context }: TaskDownloadOptions): Promise<void> {
if (context.ageRating) {
config.setAgeRating(context.ageRating.attributes);
}
}
async uploadAsync({ config, context }: TaskUploadOptions): Promise<void> {
public async uploadAsync({ config, context }: TaskUploadOptions): Promise<void> {
assert(context.ageRating, `Age rating not initialized, can't update age rating`);
const ageRating = config.getAgeRating();
@@ -15,9 +15,9 @@ export type AppInfoData = {
};
export class AppInfoTask extends AppleTask {
name = (): string => 'app information';
public name = (): string => 'app information';
async prepareAsync({ context }: TaskPrepareOptions): Promise<void> {
public async prepareAsync({ context }: TaskPrepareOptions): Promise<void> {
const info = await retryIfNullAsync(() => context.app.getEditAppInfoAsync());
assert(info, 'Could not resolve the editable app info to update');
@@ -25,7 +25,7 @@ export class AppInfoTask extends AppleTask {
context.infoLocales = await info.getLocalizationsAsync();
}
async downloadAsync({ config, context }: TaskDownloadOptions): Promise<void> {
public async downloadAsync({ config, context }: TaskDownloadOptions): Promise<void> {
assert(context.info, `App info not initialized, can't download info`);
config.setCategories(context.info.attributes);
@@ -35,7 +35,7 @@ export class AppInfoTask extends AppleTask {
}
}
async uploadAsync({ config, context }: TaskUploadOptions): Promise<void> {
public async uploadAsync({ config, context }: TaskUploadOptions): Promise<void> {
assert(context.info, `App info not initialized, can't update info`);
const categories = config.getCategories();
@@ -68,14 +68,19 @@ export class AppInfoTask extends AppleTask {
const model = context.infoLocales.find(model => model.attributes.locale === locale);
await logAsync(
() =>
model
? model.updateAsync(attributes)
: context.info.createLocalizationAsync({ ...attributes, locale }),
async () => {
return model
? await model.updateAsync(attributes)
: await context.info.createLocalizationAsync({ ...attributes, locale });
},
{
pending: `${model ? 'Updating' : 'Creating'} localized info for ${locale}...`,
success: `${model ? 'Updated' : 'Created'} localized info for ${locale}`,
failure: `Failed ${model ? 'updating' : 'creating'} localized info for ${locale}`,
pending: `${model ? 'Updating' : 'Creating'} localized info for ${chalk.bold(
locale
)}...`,
success: `${model ? 'Updated' : 'Created'} localized info for ${chalk.bold(locale)}`,
failure: `Failed ${model ? 'updating' : 'creating'} localized info for ${chalk.bold(
locale
)}`,
}
);
}
@@ -0,0 +1,58 @@
import { AppStoreReviewDetail } from '@expo/apple-utils';
import assert from 'assert';
import chalk from 'chalk';
import Log from '../../../log';
import { logAsync } from '../../utils/log';
import { AppleTask, TaskDownloadOptions, TaskPrepareOptions, TaskUploadOptions } from '../task';
export type AppReviewData = {
/** The current app info that should be edited */
reviewDetail: AppStoreReviewDetail;
};
/** Handle all contact, demo account, or instruction info that are required for the App Store review team. */
export class AppReviewDetailTask extends AppleTask {
public name = (): string => 'app review detail';
public async prepareAsync({ context }: TaskPrepareOptions): Promise<void> {
assert(context.version, `App version not initialized, can't download store review details`);
context.reviewDetail = (await context.version.getAppStoreReviewDetailAsync()) ?? undefined;
}
public async downloadAsync({ config, context }: TaskDownloadOptions): Promise<void> {
if (context.reviewDetail) {
config.setReviewDetails(context.reviewDetail.attributes);
}
}
public async uploadAsync({ config, context }: TaskUploadOptions): Promise<void> {
const reviewDetail = config.getReviewDetails();
if (!reviewDetail) {
return Log.log(chalk`{dim - Skipped store review details, not configured}`);
}
assert(context.version, `App version not initialized, can't upload store review details`);
const { versionString } = context.version.attributes;
if (!context.reviewDetail) {
// We can't set the demo required property when creating, omit it from the request
const { demoAccountRequired, ...reviewDetailsToCreate } = reviewDetail;
context.reviewDetail = await logAsync(
() => context.version.createReviewDetailAsync(reviewDetailsToCreate),
{
pending: `Creating store review details for ${chalk.bold(versionString)}...`,
success: `Created store review details for ${chalk.bold(versionString)}`,
failure: `Failed creating store review details for ${chalk.bold(versionString)}`,
}
);
}
context.reviewDetail = await logAsync(() => context.reviewDetail.updateAsync(reviewDetail), {
pending: `Updating store review details for ${chalk.bold(versionString)}...`,
success: `Updated store review details for ${chalk.bold(versionString)}`,
failure: `Failed updating store review details for ${chalk.bold(versionString)}`,
});
}
}
@@ -28,17 +28,17 @@ export type AppVersionData = {
export class AppVersionTask extends AppleTask {
private options: AppVersionOptions;
constructor(options: Partial<AppVersionOptions> = {}) {
public constructor(options: Partial<AppVersionOptions> = {}) {
super();
this.options = {
platform: options.platform || Platform.IOS,
editLive: options.editLive || false,
platform: options.platform ?? Platform.IOS,
editLive: options.editLive ?? false,
};
}
name = (): string => (this.options.editLive ? 'live app version' : 'editable app version');
public name = (): string => (this.options.editLive ? 'live app version' : 'editable app version');
async prepareAsync({ context }: TaskPrepareOptions): Promise<void> {
public async prepareAsync({ context }: TaskPrepareOptions): Promise<void> {
const { version, versionIsFirst, versionIsLive } = await resolveVersionAsync(
context.app,
this.options
@@ -52,7 +52,7 @@ export class AppVersionTask extends AppleTask {
context.versionLocales = await version.getLocalizationsAsync();
}
async downloadAsync({ config, context }: TaskDownloadOptions): Promise<void> {
public async downloadAsync({ config, context }: TaskDownloadOptions): Promise<void> {
assert(context.version, `App version not initialized, can't download version`);
config.setVersion(context.version.attributes);
@@ -63,7 +63,7 @@ export class AppVersionTask extends AppleTask {
}
}
async uploadAsync({ config, context }: TaskUploadOptions): Promise<void> {
public async uploadAsync({ config, context }: TaskUploadOptions): Promise<void> {
assert(context.version, `App version not initialized, can't update version`);
const version = config.getVersion();
@@ -71,16 +71,17 @@ export class AppVersionTask extends AppleTask {
if (!version && !release) {
Log.log(chalk`{dim - Skipped version and release update, not configured}`);
} else {
const description = [version && 'version info', release && 'release info']
const { versionString } = context.version.attributes;
const description = [version && 'version', release && 'release']
.filter(Boolean)
.join(' and ');
context.version = await logAsync(
() => context.version.updateAsync({ ...version, ...release }),
{
pending: `Updating ${description}...`,
success: `Updated ${description}`,
failure: `Failed updating ${description}`,
pending: `Updating ${description} info for ${chalk.bold(versionString)}...`,
success: `Updated ${description} info for ${chalk.bold(versionString)}`,
failure: `Failed updating ${description} info for ${chalk.bold(versionString)}`,
}
);
}
@@ -97,14 +98,21 @@ export class AppVersionTask extends AppleTask {
const oldModel = context.versionLocales.find(model => model.attributes.locale === locale);
await logAsync(
() =>
oldModel
? oldModel.updateAsync(attributes)
: context.version.createLocalizationAsync({ ...attributes, locale }),
async () => {
return oldModel
? await oldModel.updateAsync(attributes)
: await context.version.createLocalizationAsync({ ...attributes, locale });
},
{
pending: `${oldModel ? 'Updating' : 'Creating'} localized version for ${locale}...`,
success: `${oldModel ? 'Updated' : 'Created'} localized version for ${locale}`,
failure: `Failed ${oldModel ? 'updating' : 'creating'} localized version for ${locale}`,
pending: `${oldModel ? 'Updating' : 'Creating'} localized version for ${chalk.bold(
locale
)}...`,
success: `${oldModel ? 'Updated' : 'Created'} localized version for ${chalk.bold(
locale
)}`,
failure: `Failed ${
oldModel ? 'updating' : 'creating'
} localized version for ${chalk.bold(locale)}`,
}
);
}
@@ -2,11 +2,12 @@ import { MetadataContext } from '../../context';
import { AppleTask } from '../task';
import { AgeRatingTask } from './age-rating';
import { AppInfoTask } from './app-info';
import { AppReviewDetailTask } from './app-review-detail';
import { AppVersionTask } from './app-version';
/**
* List of all eligible tasks to sync local store configuration to the App store.
*/
export function createAppleTasks(_ctx: MetadataContext): AppleTask[] {
return [new AppVersionTask(), new AppInfoTask(), new AgeRatingTask()];
return [new AppVersionTask(), new AppInfoTask(), new AgeRatingTask(), new AppReviewDetailTask()];
}
+10 -11
View File
@@ -1,11 +1,11 @@
import type { AgeRatingDeclarationProps, AppCategoryId, AppSubcategoryId } from '@expo/apple-utils';
import type { AgeRatingDeclarationProps } from '@expo/apple-utils';
export type AppleLocale = string;
export interface AppleMetadata {
copyright?: string;
info?: Record<AppleLocale, AppleInfo>;
categories?: AppCategoryId[] | AppleCategory;
categories?: AppleCategory;
release?: AppleRelease;
advisory?: AppleAdvisory;
preview?: Record<string, string[]>;
@@ -14,10 +14,8 @@ export interface AppleMetadata {
export type AppleAdvisory = Partial<AgeRatingDeclarationProps>;
export interface AppleCategory {
category: AppCategoryId;
subcategory?: AppSubcategoryId[];
}
/** Apps can define up to two categories, or categories with up to two subcategories */
export type AppleCategory = (string | string[])[];
export interface AppleRelease {
isPhasedReleaseEnabled?: boolean;
@@ -45,12 +43,13 @@ export interface AppleInfo {
}
export interface AppleReview {
firstName?: string;
lastName?: string;
phone?: string;
email?: string;
firstName: string;
lastName: string;
phone: string;
email: string;
demoUsername?: string;
demoPassword?: string;
demoRequired?: boolean;
notes?: string;
attachment?: string;
// attachment?: string;
}
+7 -18
View File
@@ -1,7 +1,7 @@
import { App, Session } from '@expo/apple-utils';
import { ExpoConfig } from '@expo/config';
import { Platform } from '@expo/eas-build-job';
import { SubmitProfile } from '@expo/eas-json';
import { EasJsonReader, SubmitProfile } from '@expo/eas-json';
import assert from 'assert';
import { CredentialsContext } from '../credentials/context';
@@ -10,7 +10,6 @@ import { getExpoConfig } from '../project/expoConfig';
import { getBundleIdentifierAsync } from '../project/ios/bundleIdentifier';
import { Actor } from '../user/User';
import { ensureLoggedInAsync } from '../user/actions';
import { getProfilesAsync } from '../utils/profiles';
export type MetadataContext = {
/** Submission profile platform to use */
@@ -48,28 +47,18 @@ export async function createMetadataContextAsync(params: {
exp?: ExpoConfig;
profileName?: string;
}): Promise<MetadataContext> {
const submissionProfiles = await getProfilesAsync({
type: 'submit',
platforms: [Platform.IOS],
projectDir: params.projectDir,
profileName: params.profileName,
});
const submissionProfile = submissionProfiles.find(profile => profile.platform === Platform.IOS);
assert(
submissionProfile,
'Could not resolve the iOS submission profile, only iOS is supported for metadata'
);
const iosSubmissionProfile = submissionProfile.profile as SubmitProfile<Platform.IOS>;
const easJsonReader = new EasJsonReader(params.projectDir);
const submitProfile = await easJsonReader.getSubmitProfileAsync(Platform.IOS, params.profileName);
const exp = params.exp ?? getExpoConfig(params.projectDir);
const user = await ensureLoggedInAsync();
const bundleIdentifier = await getBundleIdentifierAsync(params.projectDir, exp);
const bundleIdentifier =
submitProfile.bundleIdentifier ?? (await getBundleIdentifierAsync(params.projectDir, exp));
return {
platform: Platform.IOS,
profile: iosSubmissionProfile,
metadataPath: iosSubmissionProfile.metadataPath ?? 'store.config.json',
profile: submitProfile,
metadataPath: submitProfile.metadataPath ?? 'store.config.json',
user,
credentialsCtx: params.credentialsCtx,
bundleIdentifier,
+9 -2
View File
@@ -2,6 +2,7 @@ import fs from 'fs-extra';
import path from 'path';
import { MetadataEvent } from '../analytics/events';
import Log from '../log';
import { confirmAsync } from '../prompts';
import { AppleData } from './apple/data';
import { createAppleTasks } from './apple/tasks';
@@ -33,6 +34,9 @@ export async function downloadMetadataAsync(metadataCtx: MetadataContext): Promi
{ app, auth }
);
Log.addNewLineIfNone();
Log.log('Downloading App Store configuration...');
const errors: Error[] = [];
const config = createAppleWriter();
const tasks = createAppleTasks(metadataCtx);
@@ -54,8 +58,11 @@ export async function downloadMetadataAsync(metadataCtx: MetadataContext): Promi
}
}
await fs.writeJson(filePath, config.toSchema(), { spaces: 2 });
unsubscribeTelemetry();
try {
await fs.writeJson(filePath, config.toSchema(), { spaces: 2 });
} finally {
unsubscribeTelemetry();
}
if (errors.length > 0) {
throw new MetadataDownloadError(errors, executionId);
+25 -7
View File
@@ -1,4 +1,5 @@
import type { ErrorObject } from 'ajv';
import chalk from 'chalk';
import Log, { link } from '../log';
@@ -8,7 +9,7 @@ import Log, { link } from '../log';
* and should contain useful information for the user to solve before trying again.
*/
export class MetadataValidationError extends Error {
constructor(message?: string, public readonly errors?: ErrorObject[]) {
public constructor(message?: string, public readonly errors: ErrorObject[] = []) {
super(message ?? 'Store configuration validation failed');
}
}
@@ -20,7 +21,7 @@ export class MetadataValidationError extends Error {
* It contains that list of encountered errors to present to the user.
*/
export class MetadataUploadError extends Error {
constructor(public readonly errors: Error[], public readonly executionId: string) {
public constructor(public readonly errors: Error[], public readonly executionId: string) {
super(
`Store configuration upload encountered ${
errors.length === 1 ? 'an error' : `${errors.length} errors`
@@ -36,7 +37,7 @@ export class MetadataUploadError extends Error {
* It contains that list of encountered errors to present to the user.
*/
export class MetadataDownloadError extends Error {
constructor(public readonly errors: Error[], public readonly executionId: string) {
public constructor(public readonly errors: Error[], public readonly executionId: string) {
super(
`Store configuration download encountered ${
errors.length === 1 ? 'an error' : `${errors.length} errors`
@@ -45,19 +46,36 @@ export class MetadataDownloadError extends Error {
}
}
/**
* Log the encountered metadata validation error in detail for the user.
* This should help communicate any possible configuration error and help the user resolve it.
*/
export function logMetadataValidationError(error: MetadataValidationError): void {
Log.newLine();
Log.error(chalk.bold(error.message));
if (error.errors?.length > 0) {
// TODO(cedric): group errors by property to make multiple errors for same property more readable
Log.log(error.errors.map(err => ` - ${chalk.bold(err.dataPath)} ${err.message}`).join('\n'));
}
}
/**
* Handle a thrown metadata error by informing the user what went wrong.
* If a normal error is thrown, this method will re-throw that error to avoid consuming it.
*/
export function handleMetadataError(error: Error): void {
if (error instanceof MetadataValidationError) {
Log.error(error.message);
Log.log(error.errors?.map(err => ` - ${err.dataPath} ${err.message}`).join('\n'));
return;
return logMetadataValidationError(error);
}
if (error instanceof MetadataDownloadError || error instanceof MetadataUploadError) {
Log.error(error.message);
Log.newLine();
Log.error(chalk.bold(error.message));
if (error.errors?.length > 0) {
Log.newLine();
Log.error(error.errors.map(err => err.message).join('\n\n'));
}
Log.newLine();
Log.log('Please check the logs for any configuration issues.');
Log.log('If this issue persists, please open a new issue at:');
// TODO: add execution ID to the issue template link
+27 -7
View File
@@ -2,34 +2,52 @@ import fs from 'fs-extra';
import path from 'path';
import { MetadataEvent } from '../analytics/events';
import Log from '../log';
import { confirmAsync } from '../prompts';
import { AppleData } from './apple/data';
import { createAppleTasks } from './apple/tasks';
import { createAppleReader, validateConfig } from './config';
import { MetadataContext, ensureMetadataAppStoreAuthenticatedAsync } from './context';
import { MetadataUploadError, MetadataValidationError } from './errors';
import { MetadataUploadError, MetadataValidationError, logMetadataValidationError } from './errors';
import { subscribeTelemetry } from './utils/telemetry';
/**
* Sync a local store configuration with the stores.
* Note, only App Store is supported at this time.
*/
export async function uploadMetadataAsync(metadataCtx: MetadataContext): Promise<void> {
export async function uploadMetadataAsync(
metadataCtx: MetadataContext
): Promise<{ appleLink: string }> {
const filePath = path.resolve(metadataCtx.projectDir, metadataCtx.metadataPath);
if (!(await fs.pathExists(filePath))) {
throw new MetadataValidationError(`Store configuration file not found "${filePath}"`);
}
const fileData = await fs.readJson(filePath);
const { valid, errors: validationErrors } = validateConfig(fileData);
if (!valid) {
const error = new MetadataValidationError(`Store configuration errors found`, validationErrors);
logMetadataValidationError(error);
Log.newLine();
Log.warn(
'Without further updates, the current store configuration may fail to be synchronized with the App Store or pass App Store review.'
);
const attempt = await confirmAsync({
message: 'Do you still want to push the store configuration?',
});
if (!attempt) {
throw error;
}
}
const { app, auth } = await ensureMetadataAppStoreAuthenticatedAsync(metadataCtx);
const { unsubscribeTelemetry, executionId } = subscribeTelemetry(
MetadataEvent.APPLE_METADATA_UPLOAD,
{ app, auth }
);
const fileData = await fs.readJson(filePath);
const { valid, errors: validationErrors } = validateConfig(fileData);
if (!valid) {
throw new MetadataValidationError(`Store configuration errors found`, validationErrors);
}
Log.addNewLineIfNone();
Log.log('Uploading App Store configuration...');
const errors: Error[] = [];
const config = createAppleReader(fileData);
@@ -57,4 +75,6 @@ export async function uploadMetadataAsync(metadataCtx: MetadataContext): Promise
if (errors.length > 0) {
throw new MetadataUploadError(errors, executionId);
}
return { appleLink: `https://appstoreconnect.apple.com/apps/${app.id}/appstore` };
}
@@ -4,7 +4,6 @@ describe(removeDatePrecision, () => {
it('returns null for falsy values', () => {
expect(removeDatePrecision(null)).toBeNull();
expect(removeDatePrecision(undefined)).toBeNull();
expect(removeDatePrecision(false)).toBeNull();
});
it('returns null for invalid dates', () => {
@@ -1,4 +0,0 @@
export function unique<T = any>(items: T[]): T[] {
const set = new Set(items);
return [...set];
}
+1 -1
View File
@@ -9,7 +9,7 @@
* "pointer": "/data/attributes/earliestReleaseDate"
* }
*/
export function removeDatePrecision(date: any): null | Date {
export function removeDatePrecision(date: null | undefined | string | number | Date): null | Date {
if (date) {
try {
const result = new Date(date);
+17
View File
@@ -47,6 +47,23 @@ export async function selectRequestedPlatformAsync(platform?: string): Promise<R
return requestedPlatform;
}
export async function selectPlatformAsync(platform?: string): Promise<Platform> {
if (platform && Object.values(Platform).includes(platform.toLowerCase() as Platform)) {
return platform.toLowerCase() as Platform;
}
const { resolvedPlatform } = await promptAsync({
type: 'select',
message: 'Select platform',
name: 'resolvedPlatform',
choices: [
{ title: 'Android', value: Platform.ANDROID },
{ title: 'iOS', value: Platform.IOS },
],
});
return resolvedPlatform;
}
export function toPlatforms(requestedPlatform: RequestedPlatform): Platform[] {
if (requestedPlatform === RequestedPlatform.All) {
return [Platform.ANDROID, Platform.IOS];
@@ -9,7 +9,6 @@ import { PublishMutation } from '../../graphql/mutations/PublishMutation';
import { PublishQuery } from '../../graphql/queries/PublishQuery';
import {
MetadataJoi,
TIMEOUT_LIMIT,
buildUnsortedUpdateInfoGroupAsync,
collectAssetsAsync,
convertAssetToUpdateInfoGroupFormatAsync,
@@ -412,38 +411,6 @@ describe(uploadAssetsAsync, () => {
return { specifications: ['{}', '{}', '{}'] };
});
// beforeEach(() => {
// jest.useFakeTimers();
// });
it('throws an error if the upload exceeds TIMEOUT_LIMIT', async () => {
jest.spyOn(PublishQuery, 'getAssetMetadataAsync').mockImplementation(async () => {
const status = AssetMetadataStatus.DoesNotExist;
mockdate.set(Date.now() + TIMEOUT_LIMIT + 1);
jest.runAllTimers();
return [
{
storageKey: 'qbgckgkgfdjnNuf9dQd7FDTWUmlEEzg7l1m1sKzQaq0',
status,
__typename: 'AssetMetadataResult',
}, // userDefinedAsset
{
storageKey: 'bbjgXFSIXtjviGwkaPFY0HG4dVVIGiXHAboRFQEqVa4',
status,
__typename: 'AssetMetadataResult',
}, // android.code
{
storageKey: 'dP-nC8EJXKz42XKh_Rc9tYxiGAT-ilpkRltEi6HIKeQ',
status,
__typename: 'AssetMetadataResult',
}, // ios.code
];
});
mockdate.set(0);
await expect(uploadAssetsAsync(assetsForUpdateInfoGroup)).rejects.toThrow(
'Asset upload timed out. Please try again.'
);
});
it('resolves if the assets are already uploaded', async () => {
jest.spyOn(PublishQuery, 'getAssetMetadataAsync').mockImplementation(async () => {
const status = AssetMetadataStatus.Exists;
@@ -468,7 +435,11 @@ describe(uploadAssetsAsync, () => {
});
mockdate.set(0);
await expect(uploadAssetsAsync(assetsForUpdateInfoGroup)).resolves.toBe(undefined);
await expect(uploadAssetsAsync(assetsForUpdateInfoGroup)).resolves.toEqual({
assetCount: 6,
uniqueAssetCount: 3,
uniqueUploadedAssetCount: 0,
});
});
it('resolves if the assets are eventually uploaded', async () => {
jest.spyOn(PublishQuery, 'getAssetMetadataAsync').mockImplementation(async () => {
@@ -496,6 +467,45 @@ describe(uploadAssetsAsync, () => {
});
mockdate.set(0);
await expect(uploadAssetsAsync(assetsForUpdateInfoGroup)).resolves.toBe(undefined);
await expect(uploadAssetsAsync(assetsForUpdateInfoGroup)).resolves.toEqual({
assetCount: 6,
uniqueAssetCount: 3,
uniqueUploadedAssetCount: 2,
});
});
it('updates spinner text throughout execution', async () => {
jest.spyOn(PublishQuery, 'getAssetMetadataAsync').mockImplementation(async () => {
const status =
Date.now() === 0 ? AssetMetadataStatus.DoesNotExist : AssetMetadataStatus.Exists;
mockdate.set(Date.now() + 1);
jest.runAllTimers();
return [
{
storageKey: 'qbgckgkgfdjnNuf9dQd7FDTWUmlEEzg7l1m1sKzQaq0',
status,
__typename: 'AssetMetadataResult',
}, // userDefinedAsset
{
storageKey: 'bbjgXFSIXtjviGwkaPFY0HG4dVVIGiXHAboRFQEqVa4',
status,
__typename: 'AssetMetadataResult',
}, // android.code
{
storageKey: 'dP-nC8EJXKz42XKh_Rc9tYxiGAT-ilpkRltEi6HIKeQ',
status,
__typename: 'AssetMetadataResult',
}, // ios.code
];
});
const updateSpinnerFn = jest.fn((_totalAssets, _missingAssets) => {});
mockdate.set(0);
await uploadAssetsAsync(assetsForUpdateInfoGroup, updateSpinnerFn);
const calls = updateSpinnerFn.mock.calls;
expect(calls).toEqual([
[3, 3],
[3, 2],
[3, 0],
]);
});
});
@@ -0,0 +1,27 @@
import { getNextVersionCode, isValidVersionCode } from '../versions';
describe(isValidVersionCode, () => {
test('isValidVersionCode()', () => {
expect(isValidVersionCode(123)).toBe(true);
expect(isValidVersionCode(12.3)).toBe(false);
expect(isValidVersionCode(-123)).toBe(false);
expect(isValidVersionCode(2000000000)).toBe(true);
expect(isValidVersionCode(2000000001)).toBe(false);
expect(isValidVersionCode('123')).toBe(true);
expect(isValidVersionCode('12.3')).toBe(false);
expect(isValidVersionCode('-123')).toBe(false);
expect(isValidVersionCode('2000000000')).toBe(true);
expect(isValidVersionCode('2000000001')).toBe(false);
});
});
describe(getNextVersionCode, () => {
test('getNextVersionCode()', () => {
expect(getNextVersionCode(123)).toBe(124);
expect(() => getNextVersionCode(12.3)).toThrowError();
expect(getNextVersionCode(1999999999)).toBe(2000000000);
expect(() => getNextVersionCode(2000000000)).toThrowError();
expect(getNextVersionCode('123')).toBe(124);
expect(() => getNextVersionCode('12.3')).toThrowError();
});
});
@@ -7,6 +7,7 @@ import fs from 'fs-extra';
import nullthrows from 'nullthrows';
import { readAppJson } from '../../build/utils/appJson';
import env from '../../env';
import Log, { learnMore } from '../../log';
import { getProjectConfigDescription, getUsername } from '../../project/projectUtils';
import { promptAsync } from '../../prompts';
@@ -43,6 +44,9 @@ export async function getApplicationIdFromBareAsync(
projectDir: string,
gradleContext?: GradleBuildContext
): Promise<string> {
if (env.overrideAndroidApplicationId) {
return env.overrideAndroidApplicationId;
}
const errorMessage = 'Could not read applicationId from Android project.';
if (gradleContext) {
@@ -155,10 +159,10 @@ export function warnIfAndroidPackageDefinedInAppConfigForBareWorkflowProject(
): void {
if (AndroidConfig.Package.getPackage(exp) && !warnPrinted) {
Log.warn(
`Specifying "android.package" in ${getProjectConfigDescription(
`Specified value for "android.package" in ${getProjectConfigDescription(
projectDir
)} is deprecated for bare workflow projects.\n` +
'EAS Build depends only on the value in the native code. Please remove the deprecated configuration.'
)} is ignored because an ${chalk.bold('android')} directory was detected in the project.\n` +
'EAS Build will use the value found in the native code.'
);
warnPrinted = true;
}
@@ -0,0 +1,22 @@
import assert from 'assert';
const MAX_VERSION_CODE = 2000000000;
export const VERSION_CODE_REQUIREMENTS = `versionCode needs to be a positive integer smaller or equal to ${MAX_VERSION_CODE}`;
export function isValidVersionCode(versionCode: string | number): boolean {
const numericVersionCode = typeof versionCode === 'string' ? Number(versionCode) : versionCode;
return (
Number.isInteger(numericVersionCode) &&
numericVersionCode <= MAX_VERSION_CODE &&
numericVersionCode > 0
);
}
export function getNextVersionCode(versionCode: string | number): number {
assert(isValidVersionCode(versionCode), `Invalid versionCode ${versionCode}`);
const numericVersionCode = typeof versionCode === 'string' ? Number(versionCode) : versionCode;
if (numericVersionCode >= MAX_VERSION_CODE) {
throw new Error(`Invalid value: ${VERSION_CODE_REQUIREMENTS}.`);
}
return numericVersionCode + 1;
}
@@ -0,0 +1,49 @@
import { ExpoConfig } from '@expo/config';
import { Platform, Workflow } from '@expo/eas-build-job';
import { BuildProfile } from '@expo/eas-json';
import {
ensureApplicationIdIsDefinedForManagedProjectAsync,
getApplicationIdAsync,
} from './android/applicationId';
import { resolveGradleBuildContextAsync } from './android/gradle';
import { getBundleIdentifierAsync } from './ios/bundleIdentifier';
import { resolveXcodeBuildContextAsync } from './ios/scheme';
import { findApplicationTarget, resolveTargetsAsync } from './ios/target';
import { resolveWorkflowAsync } from './workflow';
export async function getApplicationIdentifierAsync(
projectDir: string,
exp: ExpoConfig,
buildProfile: BuildProfile,
platform: Platform
): Promise<string> {
if (platform === Platform.ANDROID) {
const profile = buildProfile as BuildProfile<Platform.ANDROID>;
const workflow = await resolveWorkflowAsync(projectDir, Platform.ANDROID);
const gradleContext = await resolveGradleBuildContextAsync(projectDir, profile);
if (workflow === Workflow.MANAGED) {
await ensureApplicationIdIsDefinedForManagedProjectAsync(projectDir, exp);
}
return await getApplicationIdAsync(projectDir, exp, gradleContext);
} else {
const profile = buildProfile as BuildProfile<Platform.IOS>;
const xcodeBuildContext = await resolveXcodeBuildContextAsync(
{ exp, projectDir, nonInteractive: false },
profile
);
const targets = await resolveTargetsAsync({
projectDir,
exp,
xcodeBuildContext,
env: profile.env,
});
const applicationTarget = findApplicationTarget(targets);
return await getBundleIdentifierAsync(projectDir, exp, {
targetName: applicationTarget.targetName,
buildConfiguration: applicationTarget.buildConfiguration,
});
}
}
@@ -35,11 +35,14 @@ export async function ensureProjectExistsAsync(projectInfo: ProjectInfo): Promis
assert(account, `You must have access to the ${accountName} account to run this command`);
const projectDashboardUrl = getProjectDashboardUrl(accountName, projectName);
const projectLink = terminalLink(projectFullName, projectDashboardUrl);
const projectLink = terminalLink(projectFullName, projectDashboardUrl, {
// https://github.com/sindresorhus/terminal-link/issues/18#issuecomment-1068020361
fallback: () => `${projectFullName} (${projectDashboardUrl})`,
});
const spinner = ora(`Linking to project ${chalk.bold(projectFullName)}`).start();
const maybeId = await findProjectIdByAccountNameAndSlugNullableAsync(accountName, projectName);
if (maybeId) {
spinner.succeed(`Linked to project ${chalk.bold(projectLink)}`);
projectCache[projectFullName] = maybeId;
@@ -0,0 +1,21 @@
import { getNextBuildNumber, isValidBuildNumber } from '../versions';
describe(isValidBuildNumber, () => {
test('isValidBuildNumber()', () => {
expect(isValidBuildNumber('1.2.3')).toBe(true);
expect(isValidBuildNumber('1.2')).toBe(true);
expect(isValidBuildNumber('1')).toBe(true);
expect(isValidBuildNumber('1.2.3.4')).toBe(false);
expect(isValidBuildNumber('1.2.-3')).toBe(false);
expect(isValidBuildNumber('1.a.3')).toBe(false);
});
});
describe(getNextBuildNumber, () => {
test('getNextBuildNumber', () => {
expect(getNextBuildNumber('1.2.3')).toBe('1.2.4');
expect(getNextBuildNumber('1.2')).toBe('1.3');
expect(getNextBuildNumber('1')).toBe('2');
expect(() => getNextBuildNumber('1.2.3.4')).toThrowError();
});
});
@@ -152,10 +152,10 @@ export function warnIfBundleIdentifierDefinedInAppConfigForBareWorkflowProject(
): void {
if (IOSConfig.BundleIdentifier.getBundleIdentifier(exp) && !warnPrinted) {
Log.warn(
`Specifying "ios.bundleIdentifier" in ${getProjectConfigDescription(
`Specified value for "ios.bundleIdentifier" in ${getProjectConfigDescription(
projectDir
)} is deprecated for bare workflow projects.\n` +
'EAS Build depends only on the value in the native code. Please remove the deprecated configuration.'
)} is ignored because an ${chalk.bold('ios')} directory was detected in the project.\n` +
'EAS Build will use the value found in the native code.'
);
warnPrinted = true;
}
+29 -1
View File
@@ -1,8 +1,9 @@
import { ExpoConfig } from '@expo/config';
import { IOSConfig } from '@expo/config-plugins';
import { IOSConfig, XcodeProject } from '@expo/config-plugins';
import { Platform, Workflow } from '@expo/eas-build-job';
import { JSONObject } from '@expo/json-file';
import Joi from 'joi';
import type { XCBuildConfiguration } from 'xcode';
import { Target } from '../../credentials/ios/types';
import { resolveWorkflowAsync } from '../workflow';
@@ -91,6 +92,7 @@ export async function resolveBareProjectTargetsAsync({
const { buildScheme, buildConfiguration } = xcodeBuildContext;
const result: Target[] = [];
const pbxProject = IOSConfig.XcodeUtils.getPbxproj(projectDir);
const applicationTarget = await IOSConfig.Target.findApplicationTargetWithDependenciesAsync(
projectDir,
buildScheme
@@ -108,6 +110,11 @@ export async function resolveBareProjectTargetsAsync({
bundleIdentifier,
buildConfiguration,
entitlements: entitlements ?? {},
buildSettings: resolveBareProjectBuildSettings(
pbxProject,
applicationTarget.name,
buildConfiguration
),
});
const dependencies = await resolveBareProjectDependenciesAsync({
@@ -116,6 +123,7 @@ export async function resolveBareProjectTargetsAsync({
buildConfiguration,
target: applicationTarget,
bundleIdentifier,
pbxProject,
});
if (dependencies.length > 0) {
result.push(...dependencies);
@@ -141,12 +149,14 @@ async function resolveBareProjectDependenciesAsync({
buildConfiguration,
target,
bundleIdentifier,
pbxProject,
}: {
exp: ExpoConfig;
projectDir: string;
buildConfiguration?: string;
target: IOSConfig.Target.Target;
bundleIdentifier: string;
pbxProject: XcodeProject;
}): Promise<Target[]> {
const result: Target[] = [];
@@ -166,6 +176,11 @@ async function resolveBareProjectDependenciesAsync({
bundleIdentifier: dependencyBundleIdentifier,
parentBundleIdentifier: bundleIdentifier,
entitlements: entitlements ?? {},
buildSettings: resolveBareProjectBuildSettings(
pbxProject,
dependency.name,
buildConfiguration
),
});
const dependencyDependencies = await resolveBareProjectDependenciesAsync({
exp,
@@ -173,6 +188,7 @@ async function resolveBareProjectDependenciesAsync({
buildConfiguration,
target: dependency,
bundleIdentifier: dependencyBundleIdentifier,
pbxProject,
});
if (dependencyDependencies.length > 0) {
result.push(...dependencyDependencies);
@@ -198,3 +214,15 @@ export function findTargetByName(targets: Target[], name: string): Target {
}
return target;
}
function resolveBareProjectBuildSettings(
project: XcodeProject,
targetName: string,
buildConfiguration?: string
): XCBuildConfiguration['buildSettings'] {
const xcBuildConfiguration = IOSConfig.Target.getXCBuildConfigurationFromPbxproj(project, {
targetName,
buildConfiguration,
});
return xcBuildConfiguration?.buildSettings ?? {};
}
@@ -0,0 +1,14 @@
import assert from 'assert';
export const BUILD_NUMBER_REQUIREMENTS = `buildNumber needs to consist only of up to 3 dot-separated positive integers`;
export function isValidBuildNumber(buildNumber: string): boolean {
return !!buildNumber.match(/^\d+(\.\d+)?(\.\d+)?$/);
}
export function getNextBuildNumber(buildNumber: string): string {
assert(isValidBuildNumber(buildNumber), `Invalid buildNumber ${buildNumber}`);
const comps = buildNumber.split('.');
comps[comps.length - 1] = String(Number(comps[comps.length - 1]) + 1);
return comps.join('.');
}
+41 -18
View File
@@ -5,18 +5,16 @@ import fs from 'fs-extra';
import Joi from 'joi';
import mime from 'mime';
import path from 'path';
import promiseLimit from 'promise-limit';
import { AssetMetadataStatus, PartialManifestAsset } from '../graphql/generated';
import { PublishMutation } from '../graphql/mutations/PublishMutation';
import { PresignedPost } from '../graphql/mutations/UploadSessionMutation';
import { PublishQuery } from '../graphql/queries/PublishQuery';
import Log from '../log';
import { uploadWithPresignedPostAsync } from '../uploads';
import { expoCommandAsync } from '../utils/expoCli';
import uniqBy from '../utils/expodash/uniqBy';
export const TIMEOUT_LIMIT = 60_000; // 1 minute
export type PublishPlatform = Extract<'android' | 'ios', Platform>;
type Metadata = {
version: number;
@@ -157,11 +155,14 @@ export async function buildBundlesAsync({
throw new Error('Could not locate package.json');
}
await expoCommandAsync(
projectDir,
['export', '--output-dir', inputDir, '--experimental-bundle'],
{ silent: !Log.isDebug }
);
await expoCommandAsync(projectDir, [
'export',
'--output-dir',
inputDir,
'--experimental-bundle',
'--non-interactive',
'--dump-sourcemap',
]);
}
export async function resolveInputDirectoryAsync(customInputDirectory: string): Promise<string> {
@@ -245,7 +246,16 @@ export async function filterOutAssetsThatAlreadyExistAsync(
return missingAssets;
}
export async function uploadAssetsAsync(assetsForUpdateInfoGroup: CollectedAssets): Promise<void> {
type AssetUploadResult = {
assetCount: number;
uniqueAssetCount: number;
uniqueUploadedAssetCount: number;
};
export async function uploadAssetsAsync(
assetsForUpdateInfoGroup: CollectedAssets,
updateSpinnerText?: (totalAssets: number, missingAssets: number) => void
): Promise<AssetUploadResult> {
let assets: RawAsset[] = [];
let platform: keyof CollectedAssets;
for (platform in assetsForUpdateInfoGroup) {
@@ -270,29 +280,42 @@ export async function uploadAssetsAsync(assetsForUpdateInfoGroup: CollectedAsset
}
>(assetsWithStorageKey, asset => asset.storageKey);
const totalAssets = uniqueAssets.length;
updateSpinnerText?.(totalAssets, totalAssets);
let missingAssets = await filterOutAssetsThatAlreadyExistAsync(uniqueAssets);
const uniqueUploadedAssetCount = missingAssets.length;
const { specifications } = await PublishMutation.getUploadURLsAsync(
missingAssets.map(ma => ma.contentType)
);
updateSpinnerText?.(totalAssets, missingAssets.length);
const assetUploadPromiseLimit = promiseLimit(15);
await Promise.all(
missingAssets.map((missingAsset, i) => {
const presignedPost: PresignedPost = JSON.parse(specifications[i]);
return uploadWithPresignedPostAsync(missingAsset.path, presignedPost);
assetUploadPromiseLimit(async () => {
const presignedPost: PresignedPost = JSON.parse(specifications[i]);
await uploadWithPresignedPostAsync(missingAsset.path, presignedPost);
});
})
);
// Wait up to TIMEOUT_LIMIT for assets to be uploaded and processed
const start = Date.now();
let timeout = 1;
while (missingAssets.length > 0) {
const timeoutPromise = new Promise(resolve => setTimeout(resolve, timeout * 1000)); // linear backoff
const timeoutPromise = new Promise(resolve =>
setTimeout(resolve, Math.min(timeout * 1000, 5000))
); // linear backoff
missingAssets = await filterOutAssetsThatAlreadyExistAsync(missingAssets);
await timeoutPromise; // await after filterOutAssetsThatAlreadyExistAsync for easy mocking with jest.runAllTimers
timeout += 1;
if (Date.now() - start > TIMEOUT_LIMIT) {
throw new Error('Asset upload timed out. Please try again.');
}
updateSpinnerText?.(totalAssets, missingAssets.length);
}
return {
assetCount: assets.length,
uniqueAssetCount: uniqueAssets.length,
uniqueUploadedAssetCount,
};
}
@@ -0,0 +1,71 @@
import { ExpoConfig } from '@expo/config';
import { Platform } from '@expo/eas-build-job';
import { AppVersionSource, EasJsonReader } from '@expo/eas-json';
import chalk from 'chalk';
import fs from 'fs-extra';
import Log from '../log';
import { confirmAsync } from '../prompts';
export async function ensureRemoteVersionPolicyAsync(
projectDir: string,
easJsonReader: EasJsonReader
): Promise<void> {
const easJsonCliConfig = await easJsonReader.getCliConfigAsync();
if (easJsonCliConfig?.appVersionSource === AppVersionSource.REMOTE) {
return;
}
Log.log(
`The app version source defines whether the app version is stored locally in your project source (e.g. in app.json, Info.plist, or build.gradle) or remotely on EAS servers and only applied to the project at build time. To use this command, you will need to enable the remote version policy by adding ${chalk.bold(
'{"cli": { "appVersionSource": "remote" }}'
)} in eas.json.`
);
// TODO: add link to docs
const confirm = await confirmAsync({
message: 'Do you want to set app version source to remote now?',
});
if (!confirm) {
throw new Error('Aborting...');
}
const easJsonPath = EasJsonReader.formatEasJsonPath(projectDir);
const easJson = await fs.readJSON(easJsonPath);
easJson.cli = { ...easJson?.cli, appVersionSource: AppVersionSource.REMOTE };
await fs.writeFile(easJsonPath, `${JSON.stringify(easJson, null, 2)}\n`);
Log.withTick('Updated eas.json');
}
export async function validateAppConfigForRemoteVersionPolicyAsync(exp: ExpoConfig): Promise<void> {
if (typeof exp.runtimeVersion === 'object' && exp.runtimeVersion?.policy === 'nativeVersion') {
throw new Error(
`${chalk.bold('nativeVersion')} policy for ${chalk.bold(
'runtimeVersion'
)} is currently not supported when version source is set to remote. Switch policy e.g. to ${chalk.bold(
'appVersion'
)} or define version explicitly.`
);
}
if (exp.ios?.buildNumber !== undefined) {
throw new Error(
`${chalk.bold(
'ios.buildNumber'
)} field in app config is not supported when version source is set to remote, remove it and re-run the command.`
);
}
if (exp.android?.versionCode !== undefined) {
throw new Error(
`${chalk.bold(
'android.versionCode'
)} field in app config is not supported when version source is set to remote, remove it and re-run the command.`
);
}
}
export function getBuildVersionName(platform: Platform): string {
if (platform === Platform.ANDROID) {
return 'versionCode';
} else {
return 'buildNumber';
}
}
+15 -12
View File
@@ -9,23 +9,26 @@ export async function resolveWorkflowAsync(
projectDir: string,
platform: Platform
): Promise<Workflow> {
let platformWorkflowMarker;
let platformWorkflowMarkers: string[];
try {
platformWorkflowMarker =
platformWorkflowMarkers =
platform === Platform.ANDROID
? await AndroidConfig.Paths.getAndroidManifestAsync(projectDir)
: IOSConfig.Paths.getPBXProjectPath(projectDir);
? [
path.join(projectDir, 'android/app/build.gradle'),
await AndroidConfig.Paths.getAndroidManifestAsync(projectDir),
]
: [IOSConfig.Paths.getPBXProjectPath(projectDir)];
} catch {
return Workflow.MANAGED;
}
if (await fs.pathExists(platformWorkflowMarker)) {
return (await getVcsClient().isFileIgnoredAsync(
path.relative(projectDir, platformWorkflowMarker)
))
? Workflow.MANAGED
: Workflow.GENERIC;
} else {
return Workflow.MANAGED;
for (const marker of platformWorkflowMarkers) {
if (
(await fs.pathExists(marker)) &&
!(await getVcsClient().isFileIgnoredAsync(path.relative(projectDir, marker)))
) {
return Workflow.GENERIC;
}
}
return Workflow.MANAGED;
}
@@ -7,7 +7,7 @@ import { AppPlatform, BuildFragment, UploadSessionType } from '../../graphql/gen
import { BuildQuery } from '../../graphql/queries/BuildQuery';
import { toAppPlatform } from '../../graphql/types/AppPlatform';
import { confirmAsync, promptAsync } from '../../prompts';
import { uploadAsync } from '../../uploads';
import { uploadFileAtPathToS3Async } from '../../uploads';
import {
Archive,
ArchiveSourceType,
@@ -242,7 +242,10 @@ describe(getArchiveAsync, () => {
it('handles path source', async () => {
const path = '/archive.apk';
await fs.writeFile(path, 'some content');
jest.mocked(uploadAsync).mockResolvedValueOnce({ url: ARCHIVE_URL, bucketKey: 'wat' });
jest
.mocked(uploadFileAtPathToS3Async)
.mockResolvedValueOnce({ url: ARCHIVE_URL, bucketKey: 'wat' });
const archive = await getArchiveAsync({
...SOURCE_STUB_INPUT,
@@ -250,7 +253,7 @@ describe(getArchiveAsync, () => {
path,
});
expect(uploadAsync).toBeCalledWith(
expect(uploadFileAtPathToS3Async).toBeCalledWith(
UploadSessionType.EasSubmitAppArchive,
path,
expect.anything()
+2 -2
View File
@@ -1,7 +1,7 @@
import fs from 'fs-extra';
import { UploadSessionType } from '../../graphql/generated';
import { uploadAsync } from '../../uploads';
import { uploadFileAtPathToS3Async } from '../../uploads';
import { createProgressTracker } from '../../utils/progress';
export async function isExistingFileAsync(filePath: string): Promise<boolean> {
@@ -15,7 +15,7 @@ export async function isExistingFileAsync(filePath: string): Promise<boolean> {
export async function uploadAppArchiveAsync(path: string): Promise<string> {
const fileSize = (await fs.stat(path)).size;
const { url } = await uploadAsync(
const { url } = await uploadFileAtPathToS3Async(
UploadSessionType.EasSubmitAppArchive,
path,
createProgressTracker({
@@ -0,0 +1,329 @@
import { v4 as uuidv4 } from 'uuid';
import {
PublishPlatformFlag,
ensureBranchExistsAsync,
getUpdatesToRepublishInteractiveAsync,
truncatePublishUpdateMessage,
} from '../../commands/update';
import { graphqlClient } from '../../graphql/client';
import { ViewBranchUpdatesQuery } from '../../graphql/generated';
import { viewBranchUpdatesQueryUpdateLimit } from '../../graphql/queries/UpdateQuery';
import Log from '../../log';
import { selectAsync } from '../../prompts';
const appId = '6c94sxe6-37d2-4700-52fa-1b813204dad2';
const branchId = '5e84e3cb-563e-4022-a65e-6e7a42fe4ed3';
const appName = '@tester/test';
const branchName = 'test-branch';
const projectName = 'test-project';
jest.mock('../../graphql/queries/UpdateQuery', () => {
const actual = jest.requireActual('../../graphql/queries/UpdateQuery');
return {
...actual,
getViewBranchUpdatesQueryUpdateLimit: jest.fn(actual.getViewBranchUpdatesQueryUpdateLimit),
};
});
jest.mock('../../prompts', () => ({
selectAsync: jest.fn(),
}));
jest.mock('../../commands/update', () => {
const actual = jest.requireActual('../../commands/update');
return {
...actual,
ensureBranchExistsAsync: jest.fn(actual.ensureBranchExistsAsync),
getUpdatesToRepublishInteractiveAsync: jest.fn(actual.getUpdatesToRepublishInteractiveAsync),
};
});
const updatesToResolve = jest.fn(
(): Exclude<
Exclude<ViewBranchUpdatesQuery['app'], null | undefined>['byId']['updateBranchByName'],
null | undefined
>['updates'] => []
);
jest.mock('../../graphql/client', () => ({
...jest.requireActual('../../graphql/client'),
graphqlClient: {
query: jest.fn(() => ({
toPromise: () =>
Promise.resolve({
data: {
__typename: 'RootQuery',
app: {
__typename: 'AppQuery',
byId: {
__typename: 'App',
id: appId,
updateBranchByName: {
__typename: 'UpdateBranch',
id: branchId,
name: branchName,
updates: updatesToResolve(),
},
},
},
},
}),
})),
mutation: jest.fn(() => ({
toPromise: () => ({
error: {
graphQLErrors: [
{
GraphQLError: `A channel already exists with (app_id, name) = (${appId}, ${appName})`,
extensions: {
code: 'INTERNAL_SERVER_ERROR',
errorCode: 'CHANNEL_ALREADY_EXISTS',
},
},
],
},
}),
})),
},
}));
function createMockUpdates(
{
updateCount = 1,
platformFlag = 'all',
groupId,
}: Partial<{
updateCount: number;
platformFlag: PublishPlatformFlag;
groupId: string;
}> = {
updateCount: 1,
platformFlag: 'all',
}
): Exclude<
Exclude<ViewBranchUpdatesQuery['app'], null | undefined>['byId']['updateBranchByName'],
null | undefined
>['updates'] {
let updates: Exclude<
Exclude<ViewBranchUpdatesQuery['app'], null | undefined>['byId']['updateBranchByName'],
null | undefined
>['updates'] = [];
if (platformFlag === 'all') {
updateCount = Math.ceil(updateCount / 2);
}
for (let i = 0; i < updateCount; i++) {
const androidUpdate = {
id: uuidv4(),
group: uuidv4(),
message: 'default test message',
createdAt: new Date(),
runtimeVersion: 'exposdk:44.0.0',
platform: 'android',
manifestFragment: '',
};
const iosUpdate = {
...androidUpdate,
id: uuidv4(),
platform: 'ios',
};
if (!i && groupId) {
androidUpdate.group = groupId;
iosUpdate.group = groupId;
}
const isOddNumberedUpdateCount = !(updateCount % 2);
const isFinalLoop = i === updateCount - 1;
switch (platformFlag) {
case 'all':
updates = [
...updates,
androidUpdate,
...(isFinalLoop && isOddNumberedUpdateCount ? [] : [iosUpdate]),
];
break;
case 'android':
updates = [...updates, androidUpdate];
break;
case 'ios':
updates = [...updates, iosUpdate];
break;
}
}
return updates;
}
describe('UpdatePublish', () => {
describe('ensureBranchExistsAsync', () => {
beforeEach(() => {
jest.mocked(graphqlClient.query).mockClear();
jest.mocked(graphqlClient.mutation).mockClear();
});
it('defaults limit to viewBranchUpdatesQueryUpdateLimit and offset to 0 when none are provided', async () => {
await ensureBranchExistsAsync({ appId, name: appName });
const [[, bindings]] = jest.mocked(graphqlClient.query).mock.calls;
expect(bindings).toEqual({
appId,
name: appName,
limit: viewBranchUpdatesQueryUpdateLimit,
offset: 0,
});
});
it.each([
[0, 0],
[10, 50],
[100, 3],
[1000, 77],
])('sets the limit to %s and offset to %s when provided', async (limit, offset) => {
await ensureBranchExistsAsync({ appId, name: appName, limit, offset });
const [[, bindings]] = jest.mocked(graphqlClient.query).mock.calls;
expect(bindings).toEqual({
appId,
name: appName,
limit,
offset,
});
});
});
describe('getUpdatesToRepublishInteractiveAsync', () => {
beforeEach(() => {
jest.mocked(graphqlClient.query).mockClear();
jest.mocked(graphqlClient.mutation).mockClear();
jest.mocked(getUpdatesToRepublishInteractiveAsync).mockClear();
jest.mocked(selectAsync).mockClear();
});
it('throws when there are no updates', async () => {
const platformFlag = 'all';
await expect(
async () =>
await getUpdatesToRepublishInteractiveAsync(projectName, branchName, platformFlag, 50)
).rejects.toThrow(
`There are no updates on branch "${branchName}" published for the platform(s) ${platformFlag}. Did you mean to publish a new update instead?`
);
});
it('fetches multiple pages of updates and can select an update from the most recent page', async () => {
const mockSelectedGroupId = uuidv4();
const pageSize = 2;
updatesToResolve
.mockReturnValueOnce(createMockUpdates({ updateCount: pageSize + 1 }))
.mockReturnValueOnce(
createMockUpdates({ updateCount: pageSize + 1, groupId: mockSelectedGroupId })
);
jest
.mocked(selectAsync)
.mockResolvedValueOnce('_fetchMore')
.mockResolvedValueOnce(mockSelectedGroupId);
const selectedUpdates = await getUpdatesToRepublishInteractiveAsync(
projectName,
branchName,
'all',
pageSize
);
expect(selectedUpdates.every(update => update.group === mockSelectedGroupId)).toBeTruthy();
});
it('fetches multiple pages of updates and can return an update from a previous page', async () => {
const mockSelectedGroupId = uuidv4();
const pageSize = 5;
updatesToResolve
.mockReturnValueOnce(
createMockUpdates({ updateCount: pageSize + 1, groupId: mockSelectedGroupId })
)
.mockReturnValueOnce(createMockUpdates({ updateCount: pageSize + 1 }));
jest
.mocked(selectAsync)
.mockResolvedValueOnce('_fetchMore')
.mockResolvedValueOnce(mockSelectedGroupId);
const selectedUpdates = await getUpdatesToRepublishInteractiveAsync(
projectName,
branchName,
'all',
pageSize
);
expect(selectedUpdates.every(update => update.group === mockSelectedGroupId)).toBeTruthy();
});
it('displays the option to fetch more pages as long as there are unfetched updates left', async () => {
const mockSelectedGroupId = uuidv4();
const pageSize = 10;
updatesToResolve
.mockReturnValueOnce(
createMockUpdates({ updateCount: pageSize + 1, groupId: mockSelectedGroupId })
)
.mockReturnValueOnce(createMockUpdates({ updateCount: pageSize + 1 }))
.mockReturnValueOnce(createMockUpdates({ updateCount: pageSize }));
jest
.mocked(selectAsync)
.mockResolvedValueOnce('_fetchMore')
.mockResolvedValueOnce('_fetchMore')
.mockResolvedValueOnce(mockSelectedGroupId);
await getUpdatesToRepublishInteractiveAsync(projectName, branchName, 'all', pageSize);
const { calls } = jest.mocked(selectAsync).mock;
expect(calls.length).toEqual(3);
const [[, firstOptions], [, secondOptions], [, thirdOptions]] = calls;
const fetchMoreValue = '_fetchMore';
expect(firstOptions[firstOptions.length - 1].value).toEqual(fetchMoreValue);
expect(firstOptions.length).toEqual(pageSize / 2 + 1); // + 1 === fetch more object
expect(secondOptions[secondOptions.length - 1].value).toEqual(fetchMoreValue);
expect(secondOptions.length).toEqual((pageSize / 2) * 2 + 1);
expect(thirdOptions.every(update => update.value !== fetchMoreValue)).toBeTruthy();
expect(thirdOptions.length).toEqual((pageSize / 2) * 3);
});
it('paginates update requests as expected', async () => {
const mockSelectedGroupId = uuidv4();
const pageSize = 50;
updatesToResolve
.mockReturnValueOnce(
createMockUpdates({ updateCount: pageSize + 1, groupId: mockSelectedGroupId })
)
.mockReturnValueOnce(createMockUpdates({ updateCount: pageSize + 1 }))
.mockReturnValueOnce(createMockUpdates({ updateCount: pageSize + 1 }))
.mockReturnValueOnce(createMockUpdates({ updateCount: pageSize }));
jest
.mocked(selectAsync)
.mockResolvedValueOnce('_fetchMore')
.mockResolvedValueOnce('_fetchMore')
.mockResolvedValueOnce('_fetchMore')
.mockResolvedValueOnce(mockSelectedGroupId);
await getUpdatesToRepublishInteractiveAsync(projectName, branchName, 'all', pageSize);
const gqlBindings = jest.mocked(graphqlClient.query).mock.calls.map(call => call[1]);
expect(gqlBindings).toEqual([
expect.objectContaining({ limit: pageSize + 1, offset: pageSize * 0 }),
expect.objectContaining({ limit: pageSize + 1, offset: pageSize * 1 }),
expect.objectContaining({ limit: pageSize + 1, offset: pageSize * 2 }),
expect.objectContaining({ limit: pageSize + 1, offset: pageSize * 3 }),
]);
});
});
describe(truncatePublishUpdateMessage.name, () => {
const warnSpy = jest.spyOn(Log, 'warn');
beforeEach(() => {
warnSpy.mockClear();
});
it('does not alter messages with less than 1024 characters', () => {
const message = 'Small message =)';
const truncatedMessage = truncatePublishUpdateMessage(message);
expect(truncatedMessage).toEqual(message);
expect(warnSpy).not.toBeCalled();
});
it('truncates messages to a length of 1024, including ellipses', () => {
const longMessage = Array.from({ length: 2024 }, () => 'a').join('');
const truncatedMessage = truncatePublishUpdateMessage(longMessage);
expect(truncatedMessage.length).toEqual(1024);
expect(truncatedMessage.slice(-3)).toEqual('...');
expect(warnSpy).toBeCalledWith(
'Update message exceeds the allowed 1024 character limit. Truncating message...'
);
});
});
});
+25 -6
View File
@@ -1,20 +1,29 @@
import assert from 'assert';
import FormData from 'form-data';
import fs from 'fs-extra';
import { Response } from 'node-fetch';
import nullthrows from 'nullthrows';
import { URL } from 'url';
import fetch from './fetch';
import { UploadSessionType } from './graphql/generated';
import { PresignedPost, UploadSessionMutation } from './graphql/mutations/UploadSessionMutation';
import { ProgressHandler } from './utils/progress';
export async function uploadAsync(
export async function uploadFileAtPathToS3Async(
type: UploadSessionType,
path: string,
handleProgressEvent: ProgressHandler
): Promise<{ url: string; bucketKey: string }> {
const presignedPost = await UploadSessionMutation.createUploadSessionAsync(type);
const url = await uploadWithPresignedPostAsync(path, presignedPost, handleProgressEvent);
assert(presignedPost.fields.key, 'key is not specified in in presigned post');
const response = await uploadWithPresignedPostAsync(path, presignedPost, handleProgressEvent);
const location = nullthrows(
response.headers.get('location'),
`location does not exist in response headers (make sure you're uploading to AWS S3)`
);
const url = fixS3Url(location);
return { url, bucketKey: presignedPost.fields.key };
}
@@ -22,7 +31,7 @@ export async function uploadWithPresignedPostAsync(
file: string,
presignedPost: PresignedPost,
handleProgressEvent?: ProgressHandler
): Promise<string> {
): Promise<Response> {
const fileStat = await fs.stat(file);
const fileSize = fileStat.size;
const form = new FormData();
@@ -54,13 +63,23 @@ export async function uploadWithPresignedPostAsync(
try {
const response = await uploadPromise;
handleProgressEvent({ isComplete: true });
return String(response.headers.get('location'));
return response;
} catch (error: any) {
handleProgressEvent({ isComplete: true, error });
throw error;
}
} else {
const response = await uploadPromise;
return String(response.headers.get('location'));
return await uploadPromise;
}
}
/**
* S3 returns broken URLs, sth like:
* https://submission-service-archives.s3.amazonaws.com/production%2Fdc98ca84-1473-4cb3-ae81-8c7b291cb27e%2F4424aa95-b985-4e2f-8755-9507b1037c1c
* This function replaces %2F with /.
*/
export function fixS3Url(archiveUrl: string): string {
const parsed = new URL(archiveUrl);
parsed.pathname = decodeURIComponent(parsed.pathname);
return parsed.toString();
}
@@ -1,5 +1,5 @@
import { Platform } from '@expo/eas-build-job';
import { BuildProfile, EasJsonReader, errors } from '@expo/eas-json';
import { EasJsonReader, errors } from '@expo/eas-json';
import { selectAsync } from '../../prompts';
import { getProfilesAsync } from '../profiles';
@@ -31,7 +31,7 @@ describe(getProfilesAsync, () => {
it('defaults to production profile', async () => {
const result = await getProfilesAsync({
projectDir: '/fake',
easJsonReader: new EasJsonReader('/fake'),
platforms: [Platform.ANDROID, Platform.IOS],
profileName: undefined,
type: 'build',
@@ -39,53 +39,8 @@ describe(getProfilesAsync, () => {
expect(result[0].profileName).toBe('production');
expect(result[1].profileName).toBe('production');
expect(getBuildProfileAsync).toBeCalledWith(Platform.ANDROID, 'production');
expect(getBuildProfileAsync).toBeCalledWith(Platform.IOS, 'production');
});
it('defaults to release profile when production profile is non-existent', async () => {
getBuildProfileAsync
.mockImplementationOnce(() => {
throw new errors.MissingProfileError();
})
.mockImplementationOnce(() => {
throw new errors.MissingProfileError();
});
const result = await getProfilesAsync({
projectDir: '/fake',
platforms: [Platform.ANDROID, Platform.IOS],
profileName: undefined,
type: 'build',
});
expect(result[0].profileName).toBe('release');
expect(result[1].profileName).toBe('release');
expect(getBuildProfileAsync).toBeCalledWith(Platform.ANDROID, 'production');
expect(getBuildProfileAsync).toBeCalledWith(Platform.IOS, 'production');
expect(getBuildProfileAsync).toBeCalledWith(Platform.ANDROID, 'release');
expect(getBuildProfileAsync).toBeCalledWith(Platform.IOS, 'release');
});
it('asks the user to pick profile if "release" does not exist', async () => {
getBuildProfileAsync.mockImplementation(async (_, profileName) => {
if (profileName === 'foo') {
return {} as unknown as BuildProfile<Platform>;
}
throw new errors.MissingProfileError();
});
getBuildProfileNamesAsync.mockImplementation(() => Promise.resolve(['foo', 'bar']));
jest.mocked(selectAsync).mockImplementation(async () => 'foo');
const result = await getProfilesAsync({
projectDir: '/fake',
platforms: [Platform.ANDROID],
profileName: undefined,
type: 'build',
});
expect(selectAsync).toHaveBeenCalled();
expect(getBuildProfileAsync).toBeCalledWith(Platform.ANDROID, 'foo');
expect(result[0].profileName).toBe('foo');
expect(getBuildProfileAsync).toBeCalledWith(Platform.ANDROID, undefined);
expect(getBuildProfileAsync).toBeCalledWith(Platform.IOS, undefined);
});
it('throws an error if there are no profiles in eas.json', async () => {
@@ -96,7 +51,7 @@ describe(getProfilesAsync, () => {
await expect(
getProfilesAsync({
projectDir: '/fake',
easJsonReader: new EasJsonReader('/fake'),
platforms: [Platform.ANDROID],
profileName: undefined,
type: 'build',
@@ -106,7 +61,7 @@ describe(getProfilesAsync, () => {
it('gets a specific profile', async () => {
const result = await getProfilesAsync({
projectDir: '/fake',
easJsonReader: new EasJsonReader('/fake'),
platforms: [Platform.ANDROID, Platform.IOS],
profileName: 'custom-profile',
type: 'build',
@@ -125,7 +80,7 @@ describe(getProfilesAsync, () => {
await expect(
getProfilesAsync({
projectDir: '/fake',
easJsonReader: new EasJsonReader('/fake'),
platforms: [Platform.ANDROID, Platform.IOS],
profileName: undefined,
type: 'build',
@@ -0,0 +1,19 @@
import chunk from '../chunk';
describe(chunk, () => {
it('works with an empty list', () => {
expect(chunk([])).toEqual([]);
});
it('works with list length undividable by chunk size', () => {
expect(chunk([1, 2, 3, 4, 5], 2)).toEqual([[1, 2], [3, 4], [5]]);
});
it('works with list length dividable by chunk size', () => {
expect(chunk([1, 2, 3, 4, 5, 6], 2)).toEqual([
[1, 2],
[3, 4],
[5, 6],
]);
});
});
@@ -0,0 +1,15 @@
import uniq from '../uniq';
describe(uniq, () => {
it('returns unique numbers from a list', () => {
expect(uniq([1, 2, 2, 3, 4, 2, 3])).toEqual([1, 2, 3, 4]);
});
it('returns unique strings from a list', () => {
expect(uniq(['hi', 'hello', 'hello', 'ola'])).toEqual(['hi', 'hello', 'ola']);
});
it('returns unique mixed types from a list', () => {
expect(uniq([1, 2, 2, 'hi', 'hi', 'hello', 3])).toEqual([1, 2, 'hi', 'hello', 3]);
});
});
@@ -0,0 +1,12 @@
export default function chunk<T>(list: T[], size = 1): T[][] {
const result: T[][] = [];
for (let i = 0; i < list.length; i++) {
const isFirstElementInChunk = i % size === 0;
if (isFirstElementInChunk) {
result.push([list[i]]);
} else {
result[result.length - 1].push(list[i]);
}
}
return result;
}
@@ -0,0 +1,4 @@
export default function uniq<T = any>(items: T[]): T[] {
const set = new Set(items);
return [...set];
}
Loaded 100 of 119 files, more files were not shown because too many files have changed in this diff. Show more