Compare commits

...
249 Commits
Author SHA1 Message Date
Jon Samp 432b59a77d SAVEPOINT 2025-09-12 10:41:17 -05:00
Jon Samp f8399b5ddf SAVEPOINT 2025-09-12 10:38:01 -05:00
Expo CI e24b21bd1e update CHANGELOG.md 2025-09-10 11:09:48 +00:00
Expo CI 20da7f8778 v16.18.1 2025-09-10 10:54:43 +00:00
Stanisław Chmiela 14e002274f [eas-cli] Support unknown Android release tracks in submit:internal (#3170)
<!-- If this PR requires a changelog entry, add it by commenting the PR with the command `/changelog-entry [breaking-change|new-feature|bug-fix|chore] [message]`. -->
<!-- You can skip the changelog check by labeling the PR with "no changelog". -->

# Why

Preparation for merging the rest of `track`-related pull requests.

Currently, the only place fetching `track` from `www` for display is `submit:internal` command. There, we fetch the submission config to know what parameters to supply to `fastlane supply` in the Submit workflow job.

Currently, we're using a mapping of `{ PRODUCTION: production, INTERNAL: internal, ... }` to transform the GraphQL value (enum key) to config value ("enum value").

# How

Since we're planning to make `androidConfig.track` be a plain string (e.g. `internal`), we should fall back to the actual value if the mapping does not produce a match.

This currently should not change anything (`track` is guaranteed to be a GraphQL enum value). This is a preparation for landing of https://github.com/expo/universe/pull/21827 which (somewhat breakily, but we expect to be the only consumers of our GraphQL API) changes `track` to be any string.

This `track` value is being used [here](https://github.com/expo/universe/blob/2f143aa8d51821c7e1c306768787c46b18bdd544/server/www/src/data/entities/workflow/job/WorkflowSubmitJob.ts#L399-L403).

# Test Plan

This is currently a no-op. Tested along with all the pull requests in the mix.

<img width="372" height="90" alt="Zrzut ekranu 2025-09-9 o 17 03 38" src="https://github.com/user-attachments/assets/0b260ad2-2379-4597-aa1c-dae8bf86446d" />
<img width="243" height="72" alt="Zrzut ekranu 2025-09-9 o 16 59 37" src="https://github.com/user-attachments/assets/4eef353d-ac7e-4b16-9413-a304a574b685" />
<img width="449" height="110" alt="Zrzut ekranu 2025-09-9 o 17 00 40" src="https://github.com/user-attachments/assets/58a44b7a-16bb-46b4-8d98-f233c196406a" />
<img width="366" height="85" alt="Zrzut ekranu 2025-09-9 o 17 00 35" src="https://github.com/user-attachments/assets/71013cd6-5f5e-4e38-951c-008642fbee8c" />
2025-09-10 12:37:37 +02:00
Douglas Lowder 3b8dc6b7aa CLI debug log should respect DEBUG=eas:* (#3162)
# Why

It would be great if we could use the environment setting `DEBUG=eas:*` to enable debug logs when running the EAS CLI, similarly to the way `DEBUG=expo:*` is used in the Expo CLI.

This is a first step towards implementing that. Ideally, different commands and utility modules would set different tags for the debug log messages, but this will at least allow the debug logs to appear for existing `Log.debug()` calls.

# How

Modify `src/log.ts` to write debug logs if DEBUG is set to a regex that matches `eas:log:debug`.

# Test Plan

CI should pass.
2025-09-05 11:02:56 -07:00
Douglas Lowder 445a48a8dd Sync GraphQL schema and generated code with server schema (#3161)
# Why

Sync up the schema to reflect recent additions to the server GraphQL schema

# How

`yarn --cwd packages/eas-cli verify-graphql-code`, then inspect changes in schema and generated code.

# Test Plan

CI should pass
2025-09-05 11:02:45 -07:00
Douglas Lowder f6436319ed Add better description to update:rollback (#3154)
# Why

Make it easier for customers to use `eas update:rollback` in CI.

# How

Add description text on the underlying commands that do support the non-interactive flag.
2025-08-28 12:52:15 -07:00
Expo CI bd1e32622d update CHANGELOG.md 2025-08-27 18:02:36 +00:00
Expo CI 74dcb40caa v16.18.0 2025-08-27 17:44:02 +00:00
Jon Samp f094146dcd exposes channel:delete as command (#3150) 2025-08-25 09:33:49 -05:00
Vojtech Novak 3784745382 chore: include available profiles in 'Missing build profile' error (#3141)
* chore: include available profiles in 'Missing build profile' error

* address review
2025-08-13 13:44:21 +02:00
Ash Wu 72be7e80bf [eas-cli] fix broken docs links (#3140)
* [eas-cli] fix broken docs links

Signed-off-by: Ash Wu <[email protected]>

* fix eslint

Signed-off-by: Ash Wu <[email protected]>

---------

Signed-off-by: Ash Wu <[email protected]>
2025-08-13 12:05:36 +02:00
Douglas Lowder cf1b8cb5eb Show fingerprints in build:view and build:list commands (#3137) 2025-08-07 09:12:02 -07:00
Stanisław Chmiela b6df8d3a57 [chore] Update codemention.yml (#3136) 2025-08-07 17:36:18 +02:00
Kadi Kraman b96a262cd4 [eas-cli] Only make EXPO_PUBLIC_ env vars plain text (#3121) 2025-07-31 12:40:40 +01:00
Douglas Lowder 3df84f28aa Use correct token in release trigger workflow (#3125) 2025-07-30 18:19:29 -07:00
Expo CI 554f411fae update CHANGELOG.md 2025-07-31 00:44:50 +00:00
Expo CI e6ce9837e6 v16.17.4 2025-07-31 00:39:29 +00:00
Douglas Lowder 30d8a0ce99 Adjust release workflow so Lerna detects version changes (#3124)
# Why

The existing release trigger workflow does not obtain the GItHub history and tags, so that Lerna cannot correctly determine which package versions to bump.

# How

Set `fetch-depth: 0` in the checkout action, and do an explicit fetch of all tags.

# Test Plan

- After merge, a dry run of the release trigger should correctly bump only changed packages.
2025-07-30 16:45:53 -07:00
Douglas Lowder a9c5907230 Fix release script (#3123)
# Why

The new release GitHub workflow has an error because the release script needs to be run without prompts:

```
Checking if eas-cli-local-build-plugin upgrade is available
eas-cli-local-build-plugin is already on the latest version - 1.0.171
lerna notice cli v8.2.3
lerna info ci enabled
lerna info current version 16.17.3
lerna info Assuming all packages changed
Changes:
 - eas-build-cache-provider: 16.4.2 => 16.17.4
 - eas-cli: 16.17.3 => 16.17.4
 - @expo/eas-json: 16.13.2 => 16.17.4
error Command failed with exit code 130.
? Are you sure you want to create these versions? (ynH) info Visit https://yarnpkg.com/en/docs/cli/run for documentation about this command.
Error: Process completed with exit code 130.
```
# How

- Added the `--yes` flag to the script commands
- Instead of failing when run locally, the script now executes the release flow in "dry run" mode, without committing, tagging, or pushing changes.
- Add workflow input for dry run mode (true by default, must be manually set to false to actually release)
- Get full Git history before running release script, as required for correct Lerna version check

# Test Plan

- Test locally
- Run the workflow in dry run mode after merging
- Attempt a release after this PR is merged
2025-07-30 16:07:20 -07:00
Kadi Kraman ad0f94aca9 [ci] Build eas-json before typecheck (#3122)
* Build eas-json

* Ensure check gets run when the script changes
2025-07-30 12:38:07 -07:00
Douglas Lowder bf66b79126 New workflow to trigger releases (#3119)
# Why

The current release script requires that the user executing the script have permission to push changes to main without any PR requirement or checks.

# How

To allow us to add PR requirements and checks, I have added a manual GitHub workflow so that the existing script is executed by the expo-bot user, which is exempted from PR requirements.

I have also made a few adjustments to the `release.yml` workflow (remove incorrect release name, update Node version to 22).

# Test Plan

- CI should pass
- Try a release after this PR is merged
2025-07-30 09:27:58 -07:00
Will Schurman 🦦 dcc5d62800 [eas-cli] Fix lint (#3116) 2025-07-28 10:40:53 -07:00
Will Schurman 🦦 878ed19f0c Update expoSsoLauncher.ts 2025-07-28 10:35:15 -07:00
Nitzan Raz 0e17e415b7 [TINY] SSO Login: print browser url
Summary:
I'm working in a weird setup where the browser won't launch from cli.
This required me to visit the url manually from my browser.
To get the url, I had to add a `console.log` to the cli's code directly.
Figured this change will make it easier for similar users to login, without exposing sensitive data

Test Plan:
Tested manually on the compiled code, not the best
2025-07-27 17:47:39 +03:00
Expo CI d4c5a4e1e4 update CHANGELOG.md 2025-07-23 22:12:02 +00:00
Douglas Lowder 3776c95f00 v16.17.3 2025-07-23 15:08:48 -07:00
Expo CI 7804a00597 update CHANGELOG.md 2025-07-23 20:55:31 +00:00
Douglas Lowder 5c27574df2 Bump form-data, fix dependencies and GraphQL (#3111) 2025-07-23 13:43:50 -07:00
Expo CI f2ab223f0e update CHANGELOG.md 2025-07-18 17:37:57 +00:00
Kadi Kraman 38e64cbd9a v16.17.0 2025-07-18 18:14:57 +01:00
Kadi Kraman 2cd7194ef6 Fix formatting in changelog 2025-07-18 17:58:55 +01:00
Kadi Kraman 6c33c8a656 Fix changelog 2025-07-18 17:56:27 +01:00
Kadi Kraman 92643026f3 [eas-cli] Add command for deleting worker aliases (#3107) 2025-07-18 16:28:53 +01:00
Kadi Kraman b8a056b43e [eas-cli] Add command for deleting worker deployments (#3106) 2025-07-18 12:56:09 +01:00
Vojtech Novak 1ba5cb07ce [eas-cli] change push notification confirmation message (#3077) 2025-07-18 10:56:28 +02:00
Phil Pluckthun 8e96143b59 v16.16.0 2025-07-16 23:35:52 +01:00
Phil Pluckthun 52512c333d [eas-cli] Fix batch uploads being subject to early-termination issues corrupting multipart upload parts (#3103)
* Workaround early stream termination using filehandle.read

* Add length check to createReadStreamAsync

* Align asset and multipart entry file types

* Recalculate and check hash during upload

* Update error messages and add comment

* Emit errors earlier and refactor loop

* Use same reader function and disable objectMode

* Add CHANGELOG entry

* Apply lints
2025-07-16 14:34:10 +01:00
Douglas Lowder 576c798666 New commands workflow:view and workflow:logs (#3090)
# Why

Adding the new `workflow:logs` command (see the [workflow CLI design document](https://www.notion.so/expo/EAS-CLI-workflow-commands-8bc5ccc4f9f949c8adfec1347817113c)) and the related command `workflow:view`, to provide more details on workflow runs, jobs, and their associated logs.

```bash
$ eas workflow:view --help
view details for a workflow run, including jobs. If no run ID is provided, you will be prompted to select
from recent workflow runs for the current project.

USAGE
  $ eas workflow:view [ID] [--json] [--non-interactive]

ARGUMENTS
  ID  ID of the workflow run to view

FLAGS
  --json             Enable JSON output, non-JSON messages will be printed to stderr.
  --non-interactive  Run the command in non-interactive mode.

DESCRIPTION
  view details for a workflow run, including jobs. If no run ID is provided, you will be prompted to select
from recent
  workflow runs for the current project.
```
```bash
$ eas workflow:logs --help
view logs for a workflow run, selecting a job and step to view. You can pass in either a workflow run ID or a job ID. 
If no ID is passed in, you will be prompted to select from recent workflow runs for the current project.

USAGE
  $ eas workflow:logs [ID] [--json] [--non-interactive] [--allSteps]

ARGUMENTS
  ID  ID of the workflow run or workflow job to view logs for

FLAGS
  --allSteps         Print all logs, rather than prompting for a specific step. This will be automatically set when in
                     non-interactive mode.
  --json             Enable JSON output, non-JSON messages will be printed to stderr.
  --non-interactive  Run the command in non-interactive mode.

DESCRIPTION
  view logs for a workflow run, selecting a job and step to view. You can pass in either a workflow run ID or a job ID.
If no ID is passed in, you will be prompted to select from recent workflow runs for the current project.
```
# How

Implemented the new commands. New queries and and a new fragment for workflow jobs have been added.

A state machine has been implemented to ensure correct flow for the `workflow:logs` command, allowing the user to drill down from workflow runs, to jobs, to viewing logs for an individual step. Where it makes sense, the selection prompts allow backtracking to search for a different run, job, etc.

# Test Plan

- CI should pass
- Tested locally with different inputs, and with different flag values
- New unit tests added for `workflow:logs`
2025-07-15 21:19:01 -07:00
Vojtech Novak 356d6267ec [eas-cli] fix CapabilityModel creation and syncing (#3088)
# Why

The current implementation of capabilities syncing can lead to provisioning profiles becoming invalid. This PR refactors the syncing process to avoid this issue by making the following improvements:

1. Preventing unnecessary creation of capability identifiers that already exist
2. Only updating bundle capabilities when necessary

closes https://github.com/expo/eas-cli/issues/1069
closes https://github.com/expo/eas-cli/issues/1445

# How

I refactored the capability syncing logic to:

- Add test coverage for faulty behaviors. More tests are [up the stack](https://github.com/expo/eas-cli/pull/3086/files#diff-09c5aad6e72a7a336e9783710d3899ff2ea432146929bc07ba8bfec7c7bbd2a8).
- Only create capability identifiers that don't already exist on the server
- Skip updating bundle capabilities when no changes are needed


# Test Plan

`easd` command below is from https://github.com/expo/eas-cli/blob/main/CONTRIBUTING.md#development.

To best test this locally, have an iOS app with 

(1) no capabilities enabled. Enable some capabilities in `app.json` and run prebuild. [iCloud](https://docs.expo.dev/versions/latest/sdk/document-picker/#example-appjson-with-config-plugin), Apple Sign In, app groups, or `"com.apple.developer.default-data-protection": "NSFileProtectionComplete / ..."` are of special interest, but any will do.

OR

(2) a buch of capabilities already enabled

using this app, run `easd build -p ios` TWO times. Confirm that the first build creates new capabilities on your app bundle (https://developer.apple.com/account/resources/identifiers/list) and provisioning profiles (if any). Confirm the second build passes and doesn't require any provisioning profile changes.

Finally, change the capabilities, and again run `easd build -p ios` twice to confirm no capabilities / provisioning profiles are changed on the second run (that's what should happen on the first run).

- green CI
- local tests
2025-07-14 22:34:47 +02:00
Vojtech Novak a88897b40e [eas-cli] refactor capability syncing (#3087)
# Why

This PR refactors the iOS capability synchronization logic to make it more "linear" and easier to maintain

# How

- Encapsulating capability-specific logic with the capability definition
- Making the operation decision process more explicit and easier to follow

# Test Plan

- green CI
- local tests
2025-07-14 22:29:17 +02:00
Vojtech Novak 8431167a13 [eas-cli] fix capability syncing (#3086)
# Why

This PR refactors the iOS bundle ID capabilities management to improve handling of capabilities with complex settings. The current implementation doesn't properly handle capabilities that have settings - Sign In with Apple, iCloud, and Data Protection.


# How

- Added test cases that would previously fail
- Extracted capability list to a separate file (`capabilityList.ts`) to improve code organization
- Fixed synchronization logic to make tests pass (in a dirty way, refactored later)
- Fixed handling of MDM Managed Associated Domains to prevent disabling when Associated Domains is enabled
- Improved test assertions to use non-deprecated matchers (`toHaveBeenCalledWith` instead of `toBeCalled`)

# Test Plan

- green CI
- local testing
2025-07-14 22:09:24 +02:00
Stanisław Chmiela d87f8d5750 [eas-cli] Add support for workflow:run inputs (#3095)
<!-- If this PR requires a changelog entry, add it by commenting the PR with the command `/changelog-entry [breaking-change|new-feature|bug-fix|chore] [message]`. -->
<!-- You can skip the changelog check by labeling the PR with "no changelog". -->

# Why

We want to allow users to provide inputs to workflow runs.

# How

Added support for providing inputs with `-F` flag. It follows [`gh-cli`'s example](https://docs.github.com/en/actions/how-tos/managing-workflow-runs-and-deployments/managing-workflow-runs/manually-running-a-workflow#running-a-workflow).

<img width="748" alt="Zrzut ekranu 2025-07-7 o 14 44 09" src="https://github.com/user-attachments/assets/d2ae7fda-5ad8-44c2-9873-d396b02e4f17" />

I am explicitly not supporting `-f` because the backend does not allow "raw field with no type coercion". It always tries to resolve from whatever is the input to the right type.

# Test Plan

Ran `easd workflow:run upload-download-test.yml -F name=test -F test=test2` and the workflow run defined two inputs: `name` and `test`.

- [workflow run](https://expo.dev/accounts/sjchmiela/projects/test-production/workflows/0197f5ea-a397-734f-81e0-8a5e78e3aa90) from `easd workflow:run inputs-test.yml`
- [workflow run](https://expo.dev/accounts/sjchmiela/projects/test-production/workflows/0197f5f0-7206-73e2-b741-409decc6e9ca) from `easd workflow:run inputs-test.yml -F name=Doug`
2025-07-14 20:41:04 +02:00
Douglas Lowder 1a261cf043 Add GraphQL check to release workflow (#3101)
# Why

Noticed that a recent release failed the GraphQL check. This ensures that the release will fail if the check does not pass.
2025-07-11 12:35:16 -07:00
Expo CI 9daa933990 update CHANGELOG.md 2025-07-10 17:37:51 +00:00
Phil Pluckthun 72f3ba817f v16.15.0 2025-07-10 18:30:29 +01:00
Phil Pluckthun e8ffdf25f7 feat(eas-cli): Implement new batched EAS Hosting asset upload protocol (#3085)
* Add custom multipart format

* Reuse list of files for upload params

* Remove unused gzip compression

* Clean up UploadParams

* Update file crawling to include size and mime type

* Move file-size check to assets.ts

* Refactor upload functions

* Update upload API function to support multipart uploads

* Update uploadAsync params usage in deploy command

* Upload as multipart batches, if instructed

* Count batch length against total

* Add CHANGELOG

* Fix signal not being used

* Add new assets.json format

* Skip empty assets

* Fix lint error

* Adjust concurrency

Beyond 10 we do risk some CPU contention in rare cases for small files
(especially on Windows)
However, we can now run more requests concurrently, since they're slower
each and hence are less likely to run into network issues due to lower
rate.

* Revert "Skip empty assets"

This reverts commit 18434cdb48.

* feat(eas-cli): Implement new asset upload progress bar for `deploy` command (#3099)

* Update comment

* Add CHANGELOG entry for progress bar change
2025-07-10 18:25:45 +01:00
Quinlan Jung 196abc9a9e fix changelog 2025-07-08 12:59:11 -07:00
Expo CI 037529689a update CHANGELOG.md 2025-07-08 19:15:28 +00:00
Quinlan Jung 2502b843f8 v16.14.1 2025-07-08 12:09:44 -07:00
Quinlan Jung 🦥 012e75f8ee [eas-cli] update apple team info as needed (#3096)
* [eas-cli] update apple team info as needed

* Temporary Commit at 7/7/2025, 2:01:08 PM

* Temporary Commit at 7/7/2025, 2:12:28 PM

* Temporary Commit at 7/8/2025, 11:28:08 AM
2025-07-08 12:08:34 -07:00
Expo CI 5ada92430b update CHANGELOG.md 2025-07-07 22:52:34 +00:00
Will Schurman bf27ec5238 v16.14.0 2025-07-07 15:47:08 -07:00
Will Schurman 🦦 8f18b35fe8 [eas-cli] Fix graphql codegen to look in all directories (#3097)
<!-- If this PR requires a changelog entry, add it by commenting the PR with the command `/changelog-entry [breaking-change|new-feature|bug-fix|chore] [message]`. -->
<!-- You can skip the changelog check by labeling the PR with "no changelog". -->

# Why

While creating a previous PR, I moved a graphql query into a directory not explicitly specified in `graphql-codegen.yml`. It took me a while to figure out what was going on (only error was `tsc`).

To avoid other devs running into this, just generate it for all files in src.

# How

1. Update paths
2. Fix `fetchUserAsync` to be typed and have a unique query name.

# Test Plan

```
neas logout
neas login
neas whoami
```
2025-07-07 14:24:25 -07:00
Will Schurman 🦦 e82b8966b0 [eas-cli] Add update:revert-update-rollout command (#3068)
<!-- If this PR requires a changelog entry, add it by commenting the PR with the command `/changelog-entry [breaking-change|new-feature|bug-fix|chore] [message]`. -->
<!-- You can skip the changelog check by labeling the PR with "no changelog". -->

# Why

The docs for ending an update-based rollout are incorrect: https://docs.expo.dev/eas-update/rollouts/#ending-a-rollout

In order to revert a update-based rollout, it currently says:
> To accomplish this end state, republish the previous update by using the `eas update:republish` command.

But this is incorrect since one is not allowed to publish a new update until the control update is finished (can't have multiple roll-outs).

What actually needs to happen under the hood is one of two options:

- When on a branch with a rollout with a control update
    1. Delete the rollout update
    2. Republish the control update
- When on a branch with a rollout with only a rollout update
    1. Delete the rollout update
    2. Publish a roll-back-to-embedded (after warning since this may not be intended behavior).

This PR adds a new command, `eas update:revert-update-rollout` that does this.

# How

Add command, refactoring shared code where necessary.

This handles a ton of cases. Essentially the cross product of:
- Rollout published on empty branch or branch with existing updates. (rollout has control)
- Rollout published per-platform or single group with both platforms,
- Control published per-platform or single group with both platforms.

So, 8 total cases.

# Test Plan

Tested most of the 8 cases.

Normal update group rollout (both platforms), normal control on same update group:

```
(branch with an existing notmal update group)
$ neas update --rollout-percentage=50
$ neas update:revert-update-rollout
✔ Find update by branch or channel? › Branch
✔ Select branch from which to choose update › main
✔ Select a target runtime › 1.0.0
✔ Are you sure you want to revert the rollout update group with ID "b0db8e15-d3d0-41db-985a-d74f0bd8fb00"? This will delete the rollout update group and republish the control update group (ID: "7df732c7-ccad-4755-8b80-acd1c03c5a81"). … yes
✔ Provide an update message. … watwatrep
✔ Republished update group
```

Normal update group rollout (both platforms), controls in different update groups (latest updates for each platform at time of rollout publish were in different groups):
```
$ neas update --platform android
$ neas update --platform ios
$ neas update --rollout-percentage=50
$ neas update:revert-update-rollout
✔ Find update by branch or channel? › Branch
✔ Select branch from which to choose update › main
✔ Select a target runtime › 1.0.0
✔ Are you sure you want to revert the rollout update group with ID "47ef8fe7-6f9b-4c59-bdd5-cdcc12eef0f2"? This will delete the rollout update group and republish the control update groups (IDs: "a821e0be-04af-455b-8b75-ceabea3afc09", "5a07a994-7a71-4d4e-a188-83df9e456e2c"). … yes
✔ Provide an update message. … Republish "ioswat" - group: a821e0be-04af-455b-8b75-ceabea3afc09
✔ Provide an update message. … Republish "watandroid" - group: 5a07a994-7a71-4d4e-a188-83df9e456e2c
✔ Republished update group

Branch           main
Runtime version  1.0.0
Platform         ios
Update group ID  c1d05402-4391-4cd8-9eb0-ab0c7958bce5
iOS update ID    06b57ea9-4016-40b7-962b-756d463f2ba9
Message          Republish "ioswat" - group: a821e0be-04af-455b-8b75-ceabea3afc09
EAS Dashboard    https://staging.expo.dev/accounts/wschurman/projects/test-ojdasksajajaja/updates/c1d05402-4391-4cd8-9eb0-ab0c7958bce5
✔ Republished update group

Branch             main
Runtime version    1.0.0
Platform           android
Update group ID    56d10192-9bbd-4557-ab25-44250b3ccf6b
Android update ID  eb5b1a25-cbba-4f2a-afe9-d698068369ed
Message            Republish "watandroid" - group: 5a07a994-7a71-4d4e-a188-83df9e456e2c
EAS Dashboard      https://staging.expo.dev/accounts/wschurman/projects/test-ojdasksajajaja/updates/56d10192-9bbd-4557-ab25-44250b3ccf6b
```

Two single platform rollouts:
```
$ neas update --platform android
$ neas update --platform ios
$ neas update --rollout-percentage=50 --platform android
$ neas update --rollout-percentage=50 --platform ios
$ neas update:revert-update-rollout
✔ Find update by branch or channel? › Branch
✔ Select branch from which to choose update › main
✔ Select a target runtime › 1.0.0
✔ Which rollout update group would you like to revert? › Rollout update group ID: 38369dcd-fe24-40c5-a85d-c7d4c03c9230, Platform: android, Rollout Percentage: 50
✔ Are you sure you want to revert the rollout update group with ID "38369dcd-fe24-40c5-a85d-c7d4c03c9230"? This will delete the rollout update group and republish the control update group (ID: "5a07a994-7a71-4d4e-a188-83df9e456e2c"). … yes
✔ Provide an update message. … Republish "watandroid" - group: 5a07a994-7a71-4d4e-a188-83df9e456e2c
✔ Republished update group

$ neas update:revert-update-rollout
(node:96817) [DEP0040] DeprecationWarning: The `punycode` module is deprecated. Please use a userland alternative instead.
(Use `node --trace-deprecation ...` to show where the warning was created)
✔ Find update by branch or channel? › Branch
✔ Select branch from which to choose update › main
✔ Select a target runtime › 1.0.0
✔ Are you sure you want to revert the rollout update group with ID "ae7aaa74-06cc-43b1-98dd-63e019cb4a64"? This will delete the rollout update group and republish the control update group (ID: "a821e0be-04af-455b-8b75-ceabea3afc09"). … yes
✔ Provide an update message. … Republish "ioswat" - group: a821e0be-04af-455b-8b75-ceabea3afc09
✔ Republished update group
```

Empty branch rollout (both platforms), no control:

```
(empty branch)
$ neas update --rollout-percentage=50
$ neas update:revert-update-rollout
✔ Find update by branch or channel? › Branch
✔ Select branch from which to choose update › main
✔ Select a target runtime › 1.0.0
✔ Are you sure you want to revert the rollout update group with ID "1797d1fe-cf58-4134-abe1-b0333250da55"? This will delete the rollout update group and publish a new roll-back-to-embedded update (no control update to roll back to), whose behavior may not be a true revert depending on the previous state of the branch. … yes
✔ Provide an update message: … geageawga
✔ Published!
```

Empty branch rollout (just one platform), no control:
```
(empty branch)
$ neas update --rollout-percentage=50 --platform android
$ neas update:revert-update-rollout
(node:87664) [DEP0040] DeprecationWarning: The `punycode` module is deprecated. Please use a userland alternative instead.
(Use `node --trace-deprecation ...` to show where the warning was created)
✔ Find update by branch or channel? › Branch
✔ Select branch from which to choose update › main
✔ Select a target runtime › 1.0.0
✔ Are you sure you want to revert the rollout update group with ID "b2115695-ba9f-4f2d-a901-9f147d09e113"? This will delete the rollout update group and publish a new roll-back-to-embedded update (no control update to roll back to), whose behavior may not be a true revert depending on the previous state of the branch. … yes
✔ Provide an update message: … dwada
✔ Published!

Branch             main
Runtime version    1.0.0
Platform           android
```
2025-07-07 13:58:09 -07:00
Douglas Lowder fad19a1e03 Workflow to test and verify GraphQL (#3093)
# Why

Now that #3091 is resolved, we need to add some CI to guard against similar future issues with GraphQL breakage.

# How

Add a new workflow that

- Executes GraphQL generation, and fails if there is an error.
- Verifies that for a given PR, any changes in GraphQL generated code or schema files have been checked in as part of the PR. If not, the workflow fails.

The new workflow will execute on pushes, and on a daily schedule to guard against breakage due to changes on the server side.

# Test Plan

- CI should pass
- Push a source change in the GraphQL code, but do not regenerate GraphQL, and verify that the new workflow fails
2025-07-07 12:27:32 -07:00
Kadi Kraman 553d648e86 [eas-cli] Remove link and unlink commands (#3092) 2025-07-06 10:30:56 +01:00
Expo CI 5588a7343d update CHANGELOG.md 2025-07-04 20:32:59 +00:00
Gabriel Donadel 8ec3c52be8 v16.13.4 2025-07-04 17:27:19 -03:00
Gabriel Donadel Dall'Agnol e29d7916b8 [eas-cli] remove hidden flag from upload (#3083)
* [eas-cli] remove hidden flag from upload

* Add changelog entry
2025-07-04 21:39:17 +02:00
Expo CI b9840556fc update CHANGELOG.md 2025-07-01 02:39:04 +00:00
Brent Vatne 3cfb6725e7 v16.13.3 2025-06-30 19:33:34 -07:00
Brent Vatne de794c37f4 [eas-cli] no warning in update:configure when no build profiles (#3084) 2025-06-30 19:09:35 -07:00
James Ide c9a0747762 [build] Replace mention of specific plans (#3082)
Why
===
The message about queuing builds faster mentions the Production and Enterprise plans. The Starter plan is also a good choice.

How
===
This PR changes the message just to say "paid plan" which is simple and understandable.

Test Plan
===
Just automated tests since this is a copy change.
2025-06-30 15:55:32 -07:00
Rolf Bjaanes 80dfcdbe29 [eas-cli] Update systemRequirements.ts with correct fix for missing Simulator (#3079)
<!-- If this PR requires a changelog entry, add it by commenting the PR with the command `/changelog-entry [breaking-change|new-feature|bug-fix|chore] [message]`. -->
<!-- You can skip the changelog check by labeling the PR with "no changelog". -->

# Why

The previously recommended xcode-select command which was `sudo xcode-select -s /Applications/Xcode.app` did not work. Updated to suggest the correct fix which is running `sudo xcode-select -s /Applications/Xcode.app/Contents/Developer`


# How

Attempted to use `eas build:dev` after installing Xcode beta to use iOS 26, got error message which suggested the `sudo xcode-select -s /Applications/Xcode.app` command. It didn't work, but `sudo xcode-select -s /Applications/Xcode.app/Contents/Developer` did.


# Test Plan

Not quite sure how to reproduce the scenario in order to test this, but I can verify that this is what the correct suggestion should be as it fixed the problem which triggered the error message, and that's the location where it should look for the Simulator.
2025-06-30 22:25:28 +02:00
Expo CI 6b05dd6226 update CHANGELOG.md 2025-06-27 23:27:36 +00:00
Quinlan Jung f1f189b3f4 v16.13.2 2025-06-27 16:22:34 -07:00
Quinlan Jung 🦥 e9f03f7941 [eas-json] amend update override fields (#3080)
* [eas-json] amend update override fields

* Temporary Commit at 6/27/2025, 4:17:10 PM
2025-06-27 16:21:15 -07:00
Expo CI 683a778b15 update CHANGELOG.md 2025-06-25 20:35:04 +00:00
Stanisław Chmiela 43ccaa6129 v16.13.1 2025-06-25 22:29:32 +02:00
Stanisław Chmiela d2c4608f3f [eas-cli] Add groups output to submit:internal (#3075)
<!-- If this PR requires a changelog entry, add it by commenting the PR with the command `/changelog-entry [breaking-change|new-feature|bug-fix|chore] [message]`. -->
<!-- You can skip the changelog check by labeling the PR with "no changelog". -->

# Why

Since https://linear.app/expo/issue/ENG-14634/add-support-for-groups-option-in-fastlane-pilot-call we support passing `--groups` parameter to `eas submit` and configuring submission profile with `groups` parameter ("undocumentedly").

I need to add support for this property to `submit:internal`. We're going to source the `groups` to pass to `fastlane pilot` from either:
- submit profile (this pull request adds this)
- or workflow config (in which case we'll don't use this output).

# How

Added `groups` output to the `submit:internal` JSON object. We're going to parse it in https://github.com/expo/universe/blob/5dcea35fb50bf9fc8e3ae4ffe445f958bb5789e6/server/www/src/data/entities/workflow/job/WorkflowSubmitJob.ts#L192-L200.

# Test Plan

Ran `easd submit:internal --id 60a2d86a-b6d8-4ba2-b7dd-bf05bdd78791 --platform ios` in a test project of mine after adding submit profile like

```
  "submit": {
    "production": {
      "ios": {
        "ascAppId": "6769090000",
        "groups": ["family", "friends"]
      }
    }
  }
```

<img width="253" alt="Zrzut ekranu 2025-06-25 o 17 39 22" src="https://github.com/user-attachments/assets/584e1bea-a905-4ba3-a68e-5df4a4d3477e" />
2025-06-25 22:10:10 +02:00
Will Schurman 🦦 d6b2caa4d6 [eas-cli] Fix update group deletion (#3069)
<!-- If this PR requires a changelog entry, add it by commenting the PR with the command `/changelog-entry [breaking-change|new-feature|bug-fix|chore] [message]`. -->
<!-- You can skip the changelog check by labeling the PR with "no changelog". -->

# Why

Update group background job polling (and all background job polling in the CLI) is broken due to query caching in the client. Not sure why this worked when I added it as the caching has been present for a while.

# How

Disable cache for the poll request.

This also inverts the confirmation on `update:delete` to be affirmative as most of our other confirm questions are (default to yes). The reason for this change is that I found it non-intuitive while testing this to have "yes" cancel the command. I think the reason we use to have this inverted is it was dangerous, but IMO it's already enough intent by running the command to just have the default be yes. Happy to revert this back if reviewers disagree.

# Test Plan

```
eas update
neas update:delete <update_group>
```

See it now works.
2025-06-24 11:33:43 -07:00
Will Schurman 🦦 78cd4bf86a [eas-cli] Fix bug in log during update:republish (#3067)
<!-- If this PR requires a changelog entry, add it by commenting the PR with the command `/changelog-entry [breaking-change|new-feature|bug-fix|chore] [message]`. -->
<!-- You can skip the changelog check by labeling the PR with "no changelog". -->

# Why

Noticed this condition was inverted during creation of a new related command.

# How

Correct the if statement. It should warn when platform is not `all`.

# Test Plan

Run `neas update:republish --platform ios`, see logged message:

```
$ eas update:republish --platform ios
✔ Find update by branch or channel? › Branch
✔ Select branch from which to choose update › main
✔ Load more update groups? › [Jun 23 11:14 by wschurman, runtimeVersion: 1.0.0] Initial commit

Generated by create-expo-app 3.4.2.
You are republishing an update group that wasn't published for all platforms.
✔ The republished update group will appear on the same platforms it was originally published on: ios
✔ Provide an update message. … Republish "Initial commit

Generated by create-expo-app 3.4.2." - group: 8e1b11be-ef82-4ccd-8294-a4e7075849d1
✔ Republished update group
```
2025-06-24 11:29:53 -07:00
Expo CI ce8e098a86 update CHANGELOG.md 2025-06-24 15:12:42 +00:00
Vojtech Novak 94494489de v16.13.0 2025-06-24 16:52:08 +02:00
Vojtech Novak 50ad49cf96 [eas-cli] add --what-to-test option to build (#3065)
* [eas-cli] add --what-to-test param to build

* improve command description
2025-06-24 13:51:10 +02:00
Douglas LowderandStanisław Chmiela 6a378b369b [eas-cli] hide workflow:list (#3060)
* [eas-cli] hide workflow:list

* Apply suggestions from code review

Co-authored-by: Stanisław Chmiela <[email protected]>

---------

Co-authored-by: Stanisław Chmiela <[email protected]>
2025-06-20 12:18:32 -07:00
Douglas LowderandStanisław Chmiela 3f41cdd58e [eas-cli] align text of messages from build and workflow:run (#3061)
* [eas-cli] align text of messages from build and workflow:run

* Apply suggestions from code review

Co-authored-by: Stanisław Chmiela <[email protected]>

---------

Co-authored-by: Stanisław Chmiela <[email protected]>
2025-06-20 12:15:38 -07:00
Expo CI ec38cdb154 update CHANGELOG.md 2025-06-20 12:39:17 +00:00
Stanisław Chmiela 2b89aed724 v16.12.0 2025-06-20 14:15:13 +02:00
Stanisław Chmiela b9334ab7a3 [eas-cli] Add projectRootDirectory to workflow:run payload (#3058)
<!-- If this PR requires a changelog entry, add it by commenting the PR with the command `/changelog-entry [breaking-change|new-feature|bug-fix|chore] [message]`. -->
<!-- You can skip the changelog check by labeling the PR with "no changelog". -->

# Why

[exponent-internal.slack.com/archives/C06EFBQK3B7/p1750180529429209](https://exponent-internal.slack.com/archives/C06EFBQK3B7/p1750180529429209)

# How

Copied what we do for builds — https://github.com/expo/eas-cli/blob/b0522484afdbc7c80c7da84c724480ade8f16f55/packages/eas-cli/src/build/ios/prepareJob.ts#L37-L38 — and added `projectRootDirectory` to workflow runs.

I hoped there's a `node:path` method that would do what `slash` does, but I found none and Cursor did not know of one either.

# Test Plan

Tested end to end with https://github.com/expo/universe/pull/20644.
2025-06-20 14:14:23 +02:00
Vojtech Novak 0f717c0f22 v16.11.0 (#3059) 2025-06-18 18:09:51 +02:00
Vojtech Novak 537f95aa7b [eas-cli] add --what-to-test param to submit (#3023) 2025-06-18 16:14:09 +02:00
Expo CI 1dee08def7 update CHANGELOG.md 2025-06-13 12:45:51 +00:00
Stanisław Chmiela 15f6b04daa v16.10.1 2025-06-13 14:21:54 +02:00
Sebastian Szewczyk c0f27b8201 [eas-cli] eas build:dev command will correctly use passed build profile (#3053)
# Why

I tried to use eas dev:build with --build-profile parameter, but I got eas build with different profile

# How

I tested locally that changes in this branch fixes the problem for my case

# Test Plan

I have run following command (easd linked to locally build eas-cli, as described in CONTRIBUTING.md)
```
EXPO_DEBUG=1 easd build:dev -e preview -p android
```
2025-06-13 14:21:07 +02:00
Will Schurman 430181893f [eas-cli] Fix changelog merge conflict (#3052)
<!-- If this PR requires a changelog entry, add it by commenting the PR with the command `/changelog-entry [breaking-change|new-feature|bug-fix|chore] [message]`. -->
<!-- You can skip the changelog check by labeling the PR with "no changelog". -->

# Why

6636f76cff had a logical merge conflict in the changelog since a new version was released.

# How

Move the entry to the correct release.

# Test Plan

Inspect.
2025-06-12 09:44:55 -07:00
Will Schurman 6636f76cff [eas-cli] Allow rolling out update on empty branch (#3050)
<!-- If this PR requires a changelog entry, add it by commenting the PR with the command `/changelog-entry [breaking-change|new-feature|bug-fix|chore] [message]`. -->
<!-- You can skip the changelog check by labeling the PR with "no changelog". -->

# Why

This is the eas-cli component of https://github.com/expo/universe/pull/20493, which allows creating a rollout update on a branch with no existing updates.

# How

Remove error when no existing update exists.

# Test Plan

```
yarn create expo-app test-kjhsadjhkkjhafskjhjkhhhh
cd test-kjhsadjhkkjhafskjhjkhhhh
neas init
neas update:configure
neas update --rollout-percentage=60
```

View published update in db, see it has 60% rollout percentage but no control
2025-06-12 09:41:13 -07:00
Expo CI d7b47d080c update CHANGELOG.md 2025-06-12 09:21:13 +00:00
Douglas Lowder 046d8d75bb v16.10.0 2025-06-12 02:15:20 -07:00
Douglas Lowder 2d1f913648 [eas-cli] Implement workflow:cancel (#3048) 2025-06-11 13:03:02 -07:00
Kudo Chien b0522484af [eas-cli] drop repack support (#3044)
# Why

since the onboarding repack is not available now, let's remove the code for onboarding repack. we can later use `BuildMode.Repack` for workflow repack job.

# How

remove repack relevant code

# Test Plan

ci passed
2025-06-09 20:50:11 +08:00
Expo CI 28ee6b95c9 update CHANGELOG.md 2025-06-06 16:11:08 +00:00
Stanisław Chmiela 67d85c2025 v16.9.0 2025-06-06 17:44:31 +02:00
Piotr SzeremetaandStanisław Chmiela d213040dc4 Allow specifying groups option in submit command (#2891)
# Why

[ENG-14634: Add support for `groups` option in `fastlane pilot` call](https://linear.app/expo/issue/ENG-14634/add-support-for-groups-option-in-fastlane-pilot-call)

User should be able to pass the `groups` option to `fastlane pilot` when submitting an iOS build.

# How

* Accept the `groups` flag in the `eas submit` command
* Pass this parameter to the `submission-config`

# Test Plan

1. Build the project
2. Run the `eas submit -p ios --groups "Test 1"` command
3. Check the submission logs

# Deployment plan

1. https://github.com/expo/turtle-v2/pull/2082
2. https://github.com/expo/universe/pull/18600
3. https://github.com/expo/eas-cli/pull/2891

Co-authored-by: Stanisław Chmiela <[email protected]>
2025-06-06 17:33:37 +02:00
Douglas Lowder dda4fe45be [eas-cli] Fix workflow:runs query sort order (#3045) 2025-06-04 13:10:56 -07:00
Expo CI d5c0b1b3d7 update CHANGELOG.md 2025-06-04 11:33:53 +00:00
Stanisław Chmiela f968654ffc v16.8.0 2025-06-04 13:27:33 +02:00
Stanisław Chmiela a66753cde9 [eas-cli] Allow skipping version check (#3041)
<!-- If this PR requires a changelog entry, add it by commenting the PR with the command `/changelog-entry [breaking-change|new-feature|bug-fix|chore] [message]`. -->
<!-- You can skip the changelog check by labeling the PR with "no changelog". -->

# Why

https://exponent-internal.slack.com/archives/C089ED313PV/p1748545207578949?thread_ts=1748543276.564549&cid=C089ED313PV

# How

Added support for `EAS_SKIP_CLI_VERSION_CHECK` environment variable.

# Test Plan

Added tests.
2025-06-04 13:26:57 +02:00
Douglas LowderandWill Schurman 0c85c68948 [eas-cli] workflow:list and workflow:runs commands (#3030)
Co-authored-by: Will Schurman <[email protected]>
2025-06-03 19:41:31 -07:00
Will Schurman 78770912ab [eas-cli] Change location of EAS Update host override config (#3042)
<!-- If this PR requires a changelog entry, add it by commenting the PR with the command `/changelog-entry [breaking-change|new-feature|bug-fix|chore] [message]`. -->
<!-- You can skip the changelog check by labeling the PR with "no changelog". -->

# Why

As requested during feature review, these should live in the top-level "cli" key of eas.json.

Closes ENG-15925.

# How

Change location.

# Test Plan

`yarn tsc`
2025-06-03 08:53:19 -07:00
Expo CI 727dc105f8 update CHANGELOG.md 2025-06-03 14:05:18 +00:00
Stanisław Chmiela e63de21702 v16.7.2 2025-06-03 15:58:23 +02:00
Stanisław Chmiela e3bbdd519e [eas-cli] Add support for array flavorDimensions (#3043)
# Why

https://exponent-internal.slack.com/archives/C06EFBQK3B7/p1748956922999689

# How

Added support for `flavorDimensions` specified as array of strings.

# Test Plan

Added tests.
2025-06-03 15:56:54 +02:00
Expo CI 25cc555189 update CHANGELOG.md 2025-06-03 07:25:43 +00:00
Stanisław Chmiela be73d3a337 v16.7.1 2025-06-03 09:19:10 +02:00
Stanisław Chmiela ecb6458a28 [eas-cli] Do not require eas.json or package.json to create a workflow run (#3028)
<!-- If this PR requires a changelog entry, add it by commenting the PR with the command `/changelog-entry [breaking-change|new-feature|bug-fix|chore] [message]`. -->
<!-- You can skip the changelog check by labeling the PR with "no changelog". -->

# Why

`eas.json` is not _required_ to run workflows. Builds will fail, but we can warn about it and also the workflow error makes sense.

# How

Check before uploading the non-required files.

# Test Plan

<img width="946" alt="Zrzut ekranu 2025-06-2 o 21 45 31" src="https://github.com/user-attachments/assets/b6b4aad1-608f-471c-b541-302850744062" />
2025-06-03 09:18:17 +02:00
Will Schurman 475d90ea23 [eas-cli] Fix update republishing not including environment or git info (#3036)
<!-- If this PR requires a changelog entry, add it by commenting the PR with the command `/changelog-entry [breaking-change|new-feature|bug-fix|chore] [message]`. -->
<!-- You can skip the changelog check by labeling the PR with "no changelog". -->

# Why

These fields were missing in republished updates.

Closes ENG-15846.

# How

Copy these fields over as well when republishing.

# Test Plan

`neas update` with dirty working tree.
`neas update:republish`

Verify republished update has same dirty working tree status.
2025-06-02 08:28:21 -07:00
Douglas Lowder 59171230d4 [eas-cli] Fix failing unit tests (#3035) 2025-05-29 14:10:58 -07:00
Expo CI b8b16139bf update CHANGELOG.md 2025-05-29 10:30:14 +00:00
Quinlan Jung 24b725438e v16.7.0 2025-05-29 12:24:48 +02:00
Quinlan Jung 3d2454ebf2 [eas-cli] add rollout flag to update:republish (#3029)
* [eas-cli] add rollout flag to update:republish

* Temporary Commit at 5/29/2025, 12:17:09 PM
2025-05-29 12:21:56 +02:00
Will Schurman 5caef3bc05 [eas-json][eas-cli] Add support for manifest host and asset host overriding (#3021)
<!-- If this PR requires a changelog entry, add it by commenting the PR with the command `/changelog-entry [breaking-change|new-feature|bug-fix|chore] [message]`. -->
<!-- You can skip the changelog check by labeling the PR with "no changelog". -->

# Why

CLI change corresponding to https://github.com/expo/universe/pull/20131.

This adds the ability to override the manifest and asset hosts for EAS Update such that a set of proxy hosts can be used.

Closes ENG-15804.

# How

Considered putting these in flags, but it turned out that many many commands need to detect EAS Update configuration so they need access to these as well. Therefore, I added the new fields to `eas-json`.

The fields are:
- `manifestHostOverride` - a host to replace `u.expo.dev` in the URL.
- `assetHostOverride` - a host to replace `assets.eascdn.net` in asset URLs in the generated update manifests.

Then, in every place in EAS CLI when we generate a manifest URL, instead use the overridden URL.

Then, supply the manifest URL and asset URL overrides when creating updates (or republishes).

# Test Plan

Lengthy, but here it goes:
1. Create two proxies, one for assets and one for manifests. All these proxies do is proxy the requests.
    - https://github.com/wschurman/updates-test-proxy is a proxy for the manifest request built using expo router API routes and EAS Hosting. It is hosted at https://updates-test-proxy.expo.app and just fetches the URL input but at `u.expo.dev` and returns the response.
    - https://github.com/wschurman/updates-test-asset-proxy is the same but for assets. https://updates-test-asset-proxy.expo.app
2. Create an app on staging
3. Add the following to eas.json:
    ```
    "update": {
        "assetHostOverride": "updates-test-asset-proxy.expo.app",
        "manifestHostOverride": "updates-test-proxy.expo.app"
      }
    ```
4. run `neas update:configure`, see overridden URL in app.json
5. run `neas update`, see update is published, visit overridden manifest URL, see an update!
6. Visit one of the asset URLs (supplying authorization header) and see it correctly proxies.
7. configure code signing
8. publish again, see code signing works (downloads from `manifestPermalink`)
9. republish the last update, see it works and sets the overrides correctly.
2025-05-23 10:50:58 -07:00
Expo CI cc987ab71c update CHANGELOG.md 2025-05-23 17:45:39 +00:00
Will Schurman 32d3020659 v16.6.2 2025-05-21 09:05:18 -07:00
Will Schurman 17db11fa72 [eas-cli] Further no longer require owner field for SDK >= 53 or canary (#3017) 2025-05-16 17:19:51 -07:00
Expo CI b5305a38cc update CHANGELOG.md 2025-05-14 19:54:34 +00:00
Quinlan Jung 3ec7bb7334 v16.6.1 2025-05-14 12:47:29 -07:00
Quinlan Jung ceb07d4f54 [eas-cli] onboarding: add cwd to spawned git process (#3015)
* [eas-cli] onboarding: add cwd to spawned git process

* Temporary Commit at 5/14/2025, 12:39:27 PM
2025-05-14 12:44:22 -07:00
Expo CI 92e12a57a7 update CHANGELOG.md 2025-05-14 03:56:06 +00:00
Gabriel Donadel 373a4d40fa v16.6.0 2025-05-14 00:50:57 -03:00
Gabriel Donadel Dall'Agnol 568faf7697 [eas-cli] Add support for uploading for ipa files (#3014)
* [eas-cli] Add support uploading for ipa files

* Add changelog entry

* Add appIdentifier and appName to metadata
2025-05-14 05:49:05 +02:00
Expo CI df6ff1bb7a update CHANGELOG.md 2025-05-13 09:10:26 +00:00
Stanisław Chmiela a4a7b1c57f v16.5.0 2025-05-13 11:01:39 +02:00
Stanisław Chmiela a9f8bfd1ff [eas-cli] Add --json flag to workflow:run (#3013)
# Why

Affirm asked for this.

# How

Added `--json` flag which is going to print out all useful job information.

# Test Plan

`--wait --json`:
```
{
  "id": "0196c564-5553-70ed-8835-adff6484e8cf",
  "name": "easd.yml",
  "status": "SUCCESS",
  "createdAt": "2025-05-12T16:46:49.171Z",
  "workflow": {
    "id": "48ee38cd-44d3-4359-8b35-d71297545ea5",
    "fileName": "easd.yml"
  },
  "jobs": [
    {
      "id": "0196c564-58c9-75da-a3ef-931c64b756a2",
      "key": "test",
      "name": "test",
      "type": "CUSTOM",
      "status": "SUCCESS",
      "outputs": {},
      "createdAt": "2025-05-12T16:46:50.057Z"
    }
  ],
  "url": "http://expo.test/accounts/stanley/projects/test-local/workflows/0196c564-5553-70ed-8835-adff6484e8cf"
}
```


`--json`:
```
{
  "id": "0196c563-5a25-75e1-9447-e35d82f8fef0",
  "url": "http://expo.test/accounts/stanley/projects/test-local/workflows/0196c563-5a25-75e1-9447-e35d82f8fef0"
}
```
2025-05-13 11:00:40 +02:00
Stanisław Chmiela e7f687b45d [eas-cli] Add --wait flag to workflow:run (#3012)
<!-- If this PR requires a changelog entry, add it by commenting the PR with the command `/changelog-entry [breaking-change|new-feature|bug-fix|chore] [message]`. -->
<!-- You can skip the changelog check by labeling the PR with "no changelog". -->

# Why

https://exponent-internal.slack.com/archives/C083Q88U4M8/p1747060067200209

# How

Added `--wait`/`--no-wait` flag to `workflow:run`. It's off by default (keeping current behavior).

# Test Plan

```
Compressing project files and uploading to EAS. Learn more: https://expo.fyi/eas-build-archive
✔ Uploaded project archive to EAS 1s
✔ Uploaded eas.json to EAS 
✔ Uploaded package.json to EAS 

See logs: http://expo.test/accounts/stanley/projects/test-local/workflows/0196c530-e558-7357-85ab-139314b21171
✔ Workflow run started successfully.
```

```
See logs: http://expo.test/accounts/stanley/projects/test-local/workflows/0196c52f-0536-774a-8639-1e9de629d88e

Waiting for workflow run to complete. You can press Ctrl+C to exit.
✖ Workflow run has failed.
```

```
See logs: http://expo.test/accounts/stanley/projects/test-local/workflows/0196c52d-2065-7bb3-85d0-571d8d166d82

Waiting for workflow run to complete. You can press Ctrl+C to exit.
⚠ Workflow run has been canceled.
```

```
See logs: http://expo.test/accounts/stanley/projects/test-local/workflows/0196c53e-8150-7c52-91d4-7610b38eee11

Waiting for workflow run to complete. You can press Ctrl+C to exit.
⠏ ⚠ Failed to fetch the workflow run status. Check your network connection.
```

```
See logs: http://expo.test/accounts/stanley/projects/test-local/workflows/0196c53e-8150-7c52-91d4-7610b38eee11

Waiting for workflow run to complete. You can press Ctrl+C to exit.
✖ Failed to fetch the workflow run status 6 times in a row. Aborting wait.
```

```
USAGE
  $ eas workflow:run [FILE] [--non-interactive] [--wait]

ARGUMENTS
  FILE  Path to the workflow file to run

FLAGS
  --non-interactive  Run the command in non-interactive mode.
  --[no-]wait        Wait for workflow run to complete

DESCRIPTION
  Run an EAS workflow

FLAG DESCRIPTIONS
  --[no-]wait  Wait for workflow run to complete

    Exit codes: 0 = success, 11 = failure, 12 = canceled, 13 = wait aborted.
```
2025-05-12 23:41:34 +02:00
Gabriel Donadel 666a885ba5 update CHANGELOG.md 2025-05-10 19:52:22 -03:00
Gabriel Donadel 25c77d05f4 v16.4.2 2025-05-09 18:12:21 -03:00
Gabriel Donadel Dall'Agnol 4b26cea3bc [build-cache-provider] Use new buildCacheProvider key (#3011)
* [build-cache-provider] Use new buildCacheProvider key

* Add changelog entry
2025-05-09 21:40:42 +02:00
Stanisław Chmiela 4aaf486e5e Update CHANGELOG.md 2025-05-07 15:48:13 +02:00
Expo CI 9a254ccf2d update CHANGELOG.md 2025-05-07 13:41:19 +00:00
Stanisław Chmiela c8aef9dc3a v16.4.1 2025-05-07 15:35:39 +02:00
Stanisław Chmiela f5669de55f Fix changelog 2025-05-07 15:35:13 +02:00
Piotr Szeremeta f08965d9d2 [eas-cli] Allow any suffix for gradle command build type (#3003)
# Why

[ENG-15618: Support different suffixes of custom gradle commands](https://linear.app/expo/issue/ENG-15618/support-different-suffixes-of-custom-gradle-commands)

Currently when user specifies a custom gradle command that does not end with `*Release` or `*Debug` the command is not parsed correctly. Build type and flavour are not parsed, which causes problems with setting code version and app version down the line.

# How

- Changed the regexp that parses gradle command to be more flexible, allowing any suffix that starts with a capital letter.

# Test Plan

- add a custom gradle command to `eas.json`, for example 
```
 "development": {
      "android": {
        "gradleCommand": ":app:assembleAbcStaging"
      }
    },
```
- add a `buildType` and packageFlavor to `build.gradle` file:
```
productFlavors {  
        abc { // New flavor
            versionCode 3
            versionName "abc"
        }
    }
buildTypes {
        staging {}
        debug {... }
        release {...}
}
```

- start an android build from cli.
- started build should not warn about invalid configuration
- on website, build version should be set to the value assigned in flavour.
2025-05-07 15:33:53 +02:00
Expo CI 817ed771f5 update CHANGELOG.md 2025-05-02 12:49:52 +00:00
Gabriel Donadel da4c41b9b7 v16.4.0 2025-05-02 09:43:54 -03:00
Gabriel Donadel 6f47f5f691 Fix changelog 2025-05-02 09:43:27 -03:00
Gabriel Donadel Dall'Agnol 204cd32979 [build-cache-provider] Read types from @expo/config (#3005)
* [build-cache-provider] Read types from @expo/config

* Add changelog entry

* Fix lint
2025-05-02 13:33:28 +02:00
Gabriel Donadel f83c7d8455 Revert "Add changelog entry"
This reverts commit 5aedf13af1.
2025-04-30 16:32:51 -03:00
Gabriel Donadel 5aedf13af1 Add changelog entry 2025-04-30 16:16:46 -03:00
Gabriel Donadel 39030e9a3e Update readme 2025-04-30 16:15:30 -03:00
Gabriel Donadel Dall'Agnol 3ca333747f Add eas-build-cache-provider package (#3002)
* Add eas-build-cache-provider package

* Add comments about types

* Add dependencies

* Add changelog entry
2025-04-27 23:05:13 +02:00
Piotr Szeremeta 6997df3450 [eas-cli] Add --verbose-logs option to eas build command (#3000) 2025-04-25 12:00:32 +02:00
Expo CI f939f3ceda update CHANGELOG.md 2025-04-24 22:19:42 +00:00
Quinlan Jung 716728a624 v16.3.3 2025-04-24 15:11:13 -07:00
Kadi Kraman f8a01bade3 [eas-json] Add android sdk-52 image to schema (#2989)
* Add android sdk-52 image to schema

* Add changelog
2025-04-24 13:40:27 +01:00
Kadi Kraman 2cb426522d Update codementions (#3001)
* Update codementions

* Add codementions for hosting
2025-04-24 12:25:07 +01:00
Phil Pluckthun 573373066d [eas-cli] Add specific error for WAF block events (EAS Hosting) (#2998)
* Add specific error for WAF block events

* Add CHANGELOG entry

* Remove duplicate line (oops)

* Apply lints
2025-04-24 09:42:28 +01:00
Kadi Kraman feba42f92b [eas-cli] Detect when expo metro config is used (#2996)
* Detect when expo metro config is used

* Add changelog

* Ensure backwards compatibility for SDK 50 and below
2025-04-23 11:13:32 +01:00
Quinlan Jung f373c3fc6c [eas-cli] upload assetmap.json when publishing update (#2994)
* [eas-cli] upload assetmap.json when publishing update

* Temporary Commit at 4/21/2025, 11:53:48 AM
2025-04-21 11:59:58 -07:00
Expo CI e1f86d779e update CHANGELOG.md 2025-04-17 23:40:49 +00:00
Quinlan Jung c42a34092f v16.3.2 2025-04-17 16:35:40 -07:00
Quinlan Jung ced7f924e7 [eas-cli] change launch asset file extension (#2991)
* [eas-cli] change launch asset file extension

* Temporary Commit at 4/16/2025, 2:58:03 PM

* Temporary Commit at 4/16/2025, 3:23:17 PM
2025-04-16 15:34:34 -07:00
Gabriel Donadel Dall'Agnol 93e16284ae [eas-cli] Update upload command to display build info (#2990)
* [eas-cli] Update upload command to display build info

* Add changelog entry
2025-04-16 14:36:10 +02:00
Expo CI 379036a03d update CHANGELOG.md 2025-04-11 15:26:53 +00:00
Gabriel Donadel 6035ce06ef v16.3.1 2025-04-11 12:20:40 -03:00
Gabriel Donadel Dall'Agnol 0333fd3519 [eas-cli] Allow no in dev-client flag for build:download (#2985)
* [eas-cli] Allow no in dev-client flag for build:download

* Add changelog entry
2025-04-11 15:40:47 +02:00
Expo CI 20b48326a4 update CHANGELOG.md 2025-04-09 23:17:20 +00:00
Gabriel Donadel 199bb78b15 v16.3.0 2025-04-09 20:12:04 -03:00
Gabriel Donadel 7e71b8eacd [eas-cli] Fix upload command lint 2025-04-09 20:03:14 -03:00
Gabriel Donadel Dall'AgnolandQuinlan Jung 14bcf917a6 [eas-cli] Add non-interactive and json flags to upload command (#2983)
* [eas-cli] Add non-interactive and json flags to upload command

* Add changelog entry

* Add update command to readme

* Update packages/eas-cli/src/commands/upload.ts

Co-authored-by: Quinlan Jung <[email protected]>

---------

Co-authored-by: Quinlan Jung <[email protected]>
2025-04-10 00:55:14 +02:00
Gabriel Donadel Dall'Agnol 6ae19df305 [eas-cli] Add build:download command (#2982)
* [eas-cli] Add build:download command

* Fix json flag

* Address PR comments

* Add dev-client flag
2025-04-08 22:25:03 +02:00
Expo CI eedc841ea6 update CHANGELOG.md 2025-04-08 13:09:33 +00:00
Gabriel Donadel 98308bb3bd v16.2.2 2025-04-08 10:03:33 -03:00
Gabriel Donadel Dall'Agnol 8b9de84855 [eas-cli][upload] Filter out duplicates and compress apps (#2981)
* [eas-cli][upload] Filter out duplicates and compress apps

* Fix leftover code
2025-04-08 15:00:30 +02:00
Expo CI 496d6f5a6e update CHANGELOG.md 2025-04-04 11:33:50 +00:00
Gabriel Donadel 89d1572f9d v16.2.1 2025-04-04 08:28:37 -03:00
Gabriel Donadel Dall'AgnolandSzymon Dziedzic 76f591583d Add upload command (#2932)
* Add share command

* Use EasShareGcsAppArchive upload type

* Add share build mutation

* Fix app file extension not persisting

* Add function to extract App Metadata

* Add tar support

* Use shareLocalBuild

* Add fingerprint flag

* Rename shareLocalBuildAsync to createLocalBuildAsync

* Fix fingerprint value

* Update packages/eas-cli/src/commands/share.ts

Co-authored-by: Szymon Dziedzic <[email protected]>

* Rename command to upload

* Remove ipa support

* Fix detecting ios build

---------

Co-authored-by: Szymon Dziedzic <[email protected]>
2025-04-04 13:07:07 +02:00
Evan Bacon 5d40cc1bc0 chore: bump expo/apple-utils (#2973)
* bump apple utils

* update async types

* Update telemetry.ts

* Update CHANGELOG.md
2025-04-03 17:31:43 -05:00
Expo CI 60a349f2f5 update CHANGELOG.md 2025-04-03 16:47:03 +00:00
Will Schurman 439d45d6e1 v16.2.0 2025-04-03 09:37:07 -07:00
Quinlan Jung cc48482595 [eas-cli] update: add warning if no build exists with fingerprint (#2977)
# Why

When publishing updates, users need to know if there are compatible builds for their fingerprints. This PR prints this information at the end of `eas update` to warn users, if they have updates being published that don't have corresponding builds, causing potential compatibility issues.

<img width="1536" alt="Screenshot 2025-04-01 at 5 09 12 PM" src="https://github.com/user-attachments/assets/400ac0d9-c39e-4aee-9793-465c6acb634e" />


# How

Added functionality to check for compatible builds when publishing updates:

1. Enhanced `fetchBuildsAsync` to support filtering by `fingerprintHash`
2. Implemented `findCompatibleBuildsAsync` to find builds matching fingerprints
3. Added warnings in the update publish command when no compatible builds are found for fingerprints
4. Included links to the fingerprint details on the Expo website for easier debugging

# Test Plan

Run `eas update` with a fingerprint that doesn't have a compatible build
2025-04-02 15:41:18 -07:00
Quinlan Jung a4fb2e20d3 [eas-cli] remove giant else stmt in update routin (#2976)
# Why

This PR refactors the `UpdatePublish` command to improve code readability and flow by removing a nested `else` block and using an early return pattern.

before
```
 if (jsonFlag) {
      printJsonOnlyOutput(getUpdateJsonInfosForUpdates(newUpdates));
 } else {
      // all the logic
 }
```

after
```
 if (jsonFlag) {
      printJsonOnlyOutput(getUpdateJsonInfosForUpdates(newUpdates));
      return;
 } 

// all the logic
```

# How

- Removed the large `else` block that contained most of the code in the `UpdatePublish` command
- Added an early return after handling the JSON flag case
- Kept all the existing functionality intact while making the code more maintainable
- Improved the logical flow by flattening the code structure

# Test Plan

Manually tested the `eas update` command to ensure the code path works correctly
2025-04-02 15:40:06 -07:00
Will Schurman ec16d92724 [eas-cli] Switch update group deletion to be asynchronous (#2975) 2025-04-01 14:07:18 -07:00
Stanisław Chmiela 34e7720e6f [eas-cli] Add helpful info for ScopedAccountActorViewerContext errors in EAS (#2971)
<!-- If this PR requires a changelog entry, add it by commenting the PR with the command `/changelog-entry [breaking-change|new-feature|bug-fix|chore] [message]`. -->
<!-- You can skip the changelog check by labeling the PR with "no changelog". -->

# Why

I'm going to move Workflow and GitHub builds to scoped token soon. If users depend on the automatically provided `EXPO_TOKEN` _and_ the action they're going to try to do has not been whitelisted with scoped rules yet, they're going to get an `UnauthorizedError` mentioning `ScopedAccountActorViewerContext`.

On top of logs annotations (working on them [here](https://github.com/expo/universe/pull/19494)) I think we can make the error message a bit better.

# How

Special-case `ScopedAccountActorViewerContext` error in `process.env.EAS_BUILD` and print message asking the user to contact us.

Alongside this, I think https://github.com/expo/universe/pull/19500 may be a good idea.

# Test Plan

With `readRules` in `UserPrivacyPolicy` cleared out:

<img width="1440" alt="Zrzut ekranu 2025-03-31 o 23 43 00" src="https://github.com/user-attachments/assets/89245ccc-21e7-4041-98ce-21a517f932d4" />
2025-04-01 17:32:08 +02:00
Szymon Dziedzic 938511d5fd [gha] remove Szymon from codemention (#2970) 2025-03-31 16:09:37 +02:00
Stanisław Chmiela b473e72c73 [eas-cli] Improve unauthorized error for eas deploy --prod (#2968)
<!-- If this PR requires a changelog entry, add it by commenting the PR with the command `/changelog-entry [breaking-change|new-feature|bug-fix|chore] [message]`. -->
<!-- You can skip the changelog check by labeling the PR with "no changelog". -->

# Why

When we move Workflows to scoped token which has at most DEVELOPER permission, `type: deploy, prod: true` jobs will fail with an ominous

```
You don't have the required permissions to perform this operation.

This can sometimes happen if you are logged in as incorrect user.
Run eas whoami to check the username you are logged in as.
Run eas login to change the account.

Original error message: Entity not authorized: AppDevDomainNameEntity[00000000-0000-0000-0000-000000000000] (viewer = ScopedAccountActorViewerContext[16d62ef3-a219-47ca-936b-89958f6cb1f8], action = CREATE, ruleIndex = -1)
Request ID: 863a1317-882b-4cab-b8f2-c5f5d63b6f29

```

# How

Special-cased the `--prod` && `AppDevDomainEntity` && `CREATE` && `UNAUTHORIZED_ERROR` error.

Not sure if we should call out "as an app admin" in the error message. Open to suggestions.

# Test Plan

Ran `eas deploy --prod` as a developer-permission-only user. I've updated message in code since taking the screenshot.

<img width="836" alt="Zrzut ekranu 2025-03-28 o 21 21 02" src="https://github.com/user-attachments/assets/fc3a8b08-ae2f-4630-9e88-ccb48151d3e2" />
2025-03-31 13:52:46 +02:00
Quinlan Jung 2816b432d8 [eas-cli] add build-profile flag (#2966)
* [eas-cli] add build-profile flag

* Temporary Commit at 3/26/2025, 4:21:14 PM

* Temporary Commit at 3/27/2025, 4:23:26 PM
2025-03-27 16:30:47 -07:00
Quinlan Jung 56f0f7792d [eas-cli] remove hidden flag from fingerprint:generate (#2965)
* [eas-cli] remove hidden flag from  fingerprint:generate

* Temporary Commit at 3/26/2025, 1:16:34 PM
2025-03-27 09:47:10 -07:00
Quinlan Jung 5236de797b Temporary Commit at 3/21/2025, 12:56:52 PM (#2954) 2025-03-26 12:34:32 -07:00
Expo CI fb33298024 update CHANGELOG.md 2025-03-26 12:51:26 +00:00
Szymon Dziedzic 66c6c8ab0b v16.1.0 2025-03-26 13:46:22 +01:00
Szymon Dziedzic 552ef1afe9 [eas-cli] add corepack field to eas.json (#2964)
<!-- If this PR requires a changelog entry, add it by commenting the PR with the command `/changelog-entry [breaking-change|new-feature|bug-fix|chore] [message]`. -->
<!-- You can skip the changelog check by labeling the PR with "no changelog". -->

# Why

Closes https://github.com/expo/eas-cli/issues/2401

# How

Add `corepack` field to `eas.json`

# Test Plan

Tests, tested manually
2025-03-26 13:45:29 +01:00
Expo Bot b35a4efdaa Upgrade eas-build dependencies (#2961) 2025-03-25 11:42:17 +01:00
Expo Bot 7244fc83dc Upgrade eas-build dependencies (#2956) 2025-03-24 11:16:08 +01:00
Quinlan Jung 1da9573040 [eas-cli] add support for build profiles in fingerprint generation (#2951)
* [eas-cli] add build profile support for fingerprint

Temporary Commit at 3/16/2025, 6:31:01 PM

Temporary Commit at 3/16/2025, 6:37:24 PM

Temporary Commit at 3/16/2025, 7:59:29 PM

Temporary Commit at 3/16/2025, 8:01:21 PM

Temporary Commit at 3/18/2025, 2:38:20 PM

Temporary Commit at 3/18/2025, 3:21:52 PM

* Temporary Commit at 3/20/2025, 3:35:30 PM

* Temporary Commit at 3/20/2025, 3:55:17 PM

* Temporary Commit at 3/21/2025, 7:42:01 AM

* Temporary Commit at 3/21/2025, 7:42:34 AM

* Temporary Commit at 3/21/2025, 7:46:59 AM

* Temporary Commit at 3/21/2025, 12:16:23 PM
2025-03-21 12:21:14 -07:00
Expo CI 4e8fc059a4 update CHANGELOG.md 2025-03-20 21:48:51 +00:00
Brent Vatne 6bb3dd94f7 v16.0.1 2025-03-20 14:43:51 -07:00
Brent Vatne 079328cf5a [eas-cli] Always sync update configuration to native with eas update:configure (#2957) 2025-03-20 14:43:12 -07:00
dependabot[bot] 4ff8415f71 Bump @babel/runtime from 7.12.5 to 7.26.10 (#2949)
Bumps [@babel/runtime](https://github.com/babel/babel/tree/HEAD/packages/babel-runtime) from 7.12.5 to 7.26.10.
- [Release notes](https://github.com/babel/babel/releases)
- [Changelog](https://github.com/babel/babel/blob/main/CHANGELOG.md)
- [Commits](https://github.com/babel/babel/commits/v7.26.10/packages/babel-runtime)

---
updated-dependencies:
- dependency-name: "@babel/runtime"
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-03-20 11:11:19 +01:00
Expo CI e8d14557d6 update CHANGELOG.md 2025-03-19 09:36:29 +00:00
Stanisław Chmiela 924f51ecb6 v16.0.0 2025-03-19 10:31:01 +01:00
Quinlan Jung f297df7f67 [ENG-15002][eas-cli] fingerprint:generate (#2937)
* [eas-cli] fingerprint:generate

* Temporary Commit at 3/16/2025, 6:31:01 PM

* Temporary Commit at 3/16/2025, 6:37:24 PM

* reorganize fingerprint utils

* Temporary Commit at 3/17/2025, 4:24:33 PM

* Temporary Commit at 3/17/2025, 4:34:41 PM

* Temporary Commit at 3/17/2025, 4:51:09 PM
2025-03-17 17:11:30 -07:00
Stanisław Chmiela ef8d6d08bc [eas-cli] Adhere to .easignore even if requireCommit is true (#2942)
# Why

https://github.com/expo/eas-cli/pull/2925#issuecomment-2710138932

# How

Delete files from `.easignore` even if `requireCommit` is `true`. Instead of erroring, print a warning.

# Test Plan

Adjusted tests.
2025-03-17 18:38:24 +01:00
Mike Hampton 6ba59a321b [ci] Pin to older tj-actions/changed-files commit. (#2948) 2025-03-15 08:01:08 -04:00
Stanisław Chmiela 6e87e94ab3 Do not warn about EAS_PROJECT_ROOT multiple times (#2943)
# Why

https://exponent-internal.slack.com/archives/C017N0N99RA/p1741615912048469

# How

Made it so we don't print warning about `EAS_PROJECT_ROOT` multiple times.

# Test Plan

CI should pass.
2025-03-12 17:26:27 +01:00
Will Schurman 0dbb6890b2 [eas-cli] Add requestId to ApiV2Error (#2941) 2025-03-12 08:44:43 -07:00
dependabot[bot] 2b228272c8 Bump @babel/helpers from 7.16.5 to 7.26.10 (#2940)
Bumps [@babel/helpers](https://github.com/babel/babel/tree/HEAD/packages/babel-helpers) from 7.16.5 to 7.26.10.
- [Release notes](https://github.com/babel/babel/releases)
- [Changelog](https://github.com/babel/babel/blob/main/CHANGELOG.md)
- [Commits](https://github.com/babel/babel/commits/v7.26.10/packages/babel-helpers)

---
updated-dependencies:
- dependency-name: "@babel/helpers"
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-03-12 14:43:34 +01:00
Szymon Dziedzic b5077bc4fd [eas-cli] use correct logic to determine whether artifacts expired in eas build:run (#2931)
* [eas-cli] use correct logic to determine whether artifacts expired in eas build:run

* add changelog

* Update packages/eas-cli/src/run/utils.ts
2025-03-12 11:22:24 +01:00
Expo CI b07315e32d update CHANGELOG.md 2025-03-12 00:07:18 +00:00
Brent Vatne 3c5bb3e815 v15.0.15 2025-03-11 16:57:50 -07:00
Brent Vatne 5755dfdd3a [eas-cli] Pass through the updates version to setUpdatesConfigAsync (#2934) 2025-03-06 16:47:32 -08:00
Expo CI d9e66437f4 update CHANGELOG.md 2025-03-06 14:04:41 +00:00
Szymon Dziedzic faa75393a2 v15.0.14 2025-03-06 14:59:08 +01:00
Szymon Dziedzic 759d749a76 [eas-cli] suggest using eas build:dev when using simulator/emulator dev client configuration (#2929)
* [eas-cli] suggest using eas build:dev when using simulator/emulator dev client configuration

* add changelog
2025-03-06 14:58:12 +01:00
Stanisław ChmielaandSzymon Dziedzic 34c307c829 [eas-cli] Use Git to resolve root path in no-VCS mode (#2904)
<!-- If this PR requires a changelog entry, add it by commenting the PR with the command `/changelog-entry [breaking-change|new-feature|bug-fix|chore] [message]`. -->
<!-- You can skip the changelog check by labeling the PR with "no changelog". -->

# Why

I was thinking we can offer `EAS_NO_VCS=1` as a simple escape hatch for people that are getting weird Git-related issues. One of them may be an old version of Git that does not support our `git clone` properly.

# How

In `getRootPath` of no-VCS mode, return first of those values (in order):
- `EAS_PROJECT_ROOT`
- Git root path
- `process.cwd()`

# Test Plan

Added a basic test.

Co-authored-by: Szymon Dziedzic <[email protected]>
2025-03-04 13:15:48 +01:00
Expo CI 7b101601f2 update CHANGELOG.md 2025-03-04 08:15:59 +00:00
Szymon Dziedzic 668aeb2569 v15.0.13 2025-03-04 09:10:05 +01:00
Szymon Dziedzic 1f68e225d3 upgrade eas-cli-local-build-plugin to 1.0.171 2025-03-04 09:09:57 +01:00
Expo Bot c530d06e22 Upgrade eas-build dependencies (#2927) 2025-03-03 16:24:16 +01:00
Stanisław Chmiela 9bfc44bdbc [eas-cli] Fix .git x .easignore interaction (#2925)
<!-- If this PR requires a changelog entry, add it by commenting the PR with the command `/changelog-entry [breaking-change|new-feature|bug-fix|chore] [message]`. -->
<!-- You can skip the changelog check by labeling the PR with "no changelog". -->

# Why

Thanks to [a conversation on Bluesky](https://bsky.app/profile/sjchmiela.bsky.social/post/3lj6k2xo7rk2g) it turned out [this check](https://github.com/expo/eas-cli/blob/3a434940eb44dfa8dddc42af055793134ca9016f/packages/eas-cli/src/vcs/clients/git.ts#L255-L257) is invalid (always true), because `Ignore` has `DEFAULT_IGNORE`, so even if a user had no `.git` in `.easignore`, especially if they had `requireCommit: true`, `.git` would be removed.

# How

- Added a check to disallow `.easignore` if one has `requireCommit: true`.
- Changed logic around `Ignore` so we have separate `createForCopying` (ignores `.git` and `node_modules` always) and `createForChecking` (does not ignore anything by default). I don't like it, but it should work and is the best of what I could come up with without further refactoring.

# Test Plan

Added more tests.
2025-02-28 15:32:27 +01:00
Kadi Kraman d11e43f8e0 [eas-cli] improve applicationIdSuffix error message (#2924)
* Add productFlavors suggestion to error message

* Add changelog

* Lint
2025-02-28 12:14:17 +00:00
Quinlan Jung c19a737287 [eas-cli] amend fingerprint compare url (#2923)
* [eas-cli] amend fingerprint compare url

* Temporary Commit at 2/27/2025, 11:39:02 AM
2025-02-27 11:51:56 -08:00
Szymon Dziedzic 2790fd4cd5 use lowercase commands descriptions (#2907) 2025-02-27 10:18:44 +01:00
Quinlan Jung 1b713af42f [eas-cli] fix git diff header in fingerprint:compare (#2915)
* [eas-cli] fix git diff header in fingerprint:compare

* Temporary Commit at 2/24/2025, 12:01:45 PM
2025-02-24 12:48:11 -08:00
Quinlan Jung 3ac6472682 [eas-cli] fix formatFields to handle empty array (#2914)
* [eas-cli] fix formatFields to handle empty array

* Temporary Commit at 2/24/2025, 11:40:14 AM
2025-02-24 11:53:02 -08:00
Quinlan Jung 4b8a9d195f [eas-cli] lowercase urls, add more pretty prints (#2909)
* [eas-cli] lowercase urls, add more pretty prints

* Temporary Commit at 2/14/2025, 12:38:47 PM

* Temporary Commit at 2/14/2025, 12:41:29 PM

* Temporary Commit at 2/21/2025, 12:17:24 PM
2025-02-24 11:23:07 -08:00
Expo CI 30c5e9f4c1 update CHANGELOG.md 2025-02-22 01:46:31 +00:00
Brent Vatne d0c8f6bab7 v15.0.12 2025-02-21 17:40:05 -08:00
Evan Bacon faf6bffc62 [cli] bump app store to fix key api (#2916) 2025-02-21 17:39:05 -08:00
Expo CI ad55fdae37 update CHANGELOG.md 2025-02-21 13:59:33 +00:00
Szymon Dziedzic b5f77404cb v15.0.11 2025-02-21 14:53:36 +01:00
Szymon Dziedzic a1d4f26236 [eas-cli] skip creation of testflight group when there are already exisitng testflight groups + allow to opt out of the behavior by setting env var (#2856)
* [eas-cli] skip creation of testflight group when there are already exisitng testflight groups + allow to opt out of the behavior by setting env var

* rename env var

* add changelog
2025-02-21 14:49:32 +01:00
Quinlan Jung 6c7bae2349 [eas-cli] add oxford comma (#2908)
* [eas-cli] add oxford comma

* Temporary Commit at 2/14/2025, 10:00:01 AM
2025-02-14 10:20:50 -08:00
Evan Bacon d7e48c2894 fix(submit): Fix TestFlight internal group creation (#2906)
* Update ensureTestFlightGroup.ts

* Update CHANGELOG.md

* bump apple utils
2025-02-13 10:12:26 -08:00
Theo 918a7f9501 [eas-cli] Clarify error message regarding ignored files (#2905)
<!-- If this PR requires a changelog entry, add it by commenting the PR with the command `/changelog-entry [breaking-change|new-feature|bug-fix|chore] [message]`. -->
<!-- You can skip the changelog check by labeling the PR with "no changelog". -->

# Why

I struggled with adding a new workflow within `.eas/build` since I had `build/` in my `.gitignore`. I removed the line but it was still an issue. Turns out I had the same thing in `.easignore`. I think adding this to the error message could be helpful for others

# How

Not tested
2025-02-12 10:54:21 +01:00
Will Schurman 852dac030f [eas-cli] Narrow amount of data queried for basic update channel operations (#2901) 2025-02-11 20:07:36 -08:00
Expo CI 911cc90b93 update CHANGELOG.md 2025-02-11 19:13:45 +00:00
Stanisław Chmiela b9234abff1 v15.0.10 2025-02-11 20:07:50 +01:00
Stanisław Chmiela 574bde9831 [eas-cli] Do not include files deleted in working directory (#2900)
<!-- If this PR requires a changelog entry, add it by commenting the PR with the command `/changelog-entry [breaking-change|new-feature|bug-fix|chore] [message]`. -->
<!-- You can skip the changelog check by labeling the PR with "no changelog". -->

# Why

When we `git clone` the source repository and then copy worktree changes over, we add and modify files. However, we do not _delete_ files that have been deleted in worktree.

# How

When we operate in `requireCommit: false` mode, we add `--no-checkout` to the `git clone` command. This way the destination directory only has `.git` directory when we start copying files over. Thus, files deleted from worktree will not suddenly appear in tarball.

In `requireCommit: true` mode we don't add `--no-checkout`, because we don't copy files.

# Test Plan

Added test. Tested manually.

Side effect of this is that `git status` reports that all files have been deleted and there's a bunch of untracked files (which, `git add`-ed, make the repository appear as the source repo). I don't know how to copy the Git index 1-1…
2025-02-11 15:46:48 +01:00
Expo CI 78837a413a update CHANGELOG.md 2025-02-09 12:18:18 +00:00
Stanisław Chmiela 6f63315f8e v15.0.9 2025-02-09 13:13:03 +01:00
Stanisław Chmiela e8ac97c8cc [eas-cli] Finally fix Windows not-ignoring files (#2894)
<!-- If this PR requires a changelog entry, add it by commenting the PR with the command `/changelog-entry [breaking-change|new-feature|bug-fix|chore] [message]`. -->
<!-- You can skip the changelog check by labeling the PR with "no changelog". -->

# Why

With `toNamespacedPath` I was finally able to make ignoring files work properly.

# How

Removed `if (__NORMALIZE)` checks.

# Test Plan

Ran `eas build:inspect` on Windows, confirmed no `node_modules` was copied. Also ran `eas build:inspect` on macOS, worked too.
2025-02-09 13:10:14 +01:00
Expo CI a6fc31787d update CHANGELOG.md 2025-02-09 11:40:14 +00:00
Stanisław Chmiela 3056d8e23a v15.0.8 2025-02-09 12:19:05 +01:00
Stanisław Chmiela 1bf6757a72 [eas-cli] Try namespacing paths (#2893)
<!-- If this PR requires a changelog entry, add it by commenting the PR with the command `/changelog-entry [breaking-change|new-feature|bug-fix|chore] [message]`. -->
<!-- You can skip the changelog check by labeling the PR with "no changelog". -->

# Why

`fs` adds a [Win32 File Namespace](https://learn.microsoft.com/en-us/windows/win32/fileio/naming-a-file#win32-file-namespaces) to the beginning of the path which breaks `path.relative` if `src` does not have the namespace.

<img width="899" alt="Zrzut ekranu 2025-02-9 o 12 15 45" src="https://github.com/user-attachments/assets/942b28d1-881f-4e5d-861e-297bb5eddbf2" />

# How

Ensured that running `toNamespacedPath` on an already namespaced path does not add a _second_ namespace:

```
> path.win32.toNamespacedPath("D:\\abc")
'\\\\?\\D:\\abc'
> path.win32.toNamespacedPath(path.win32.toNamespacedPath("D:\\abc"))
'\\\\?\\D:\\abc'
```

Wrapped `src` and `srcFilePath` (two arguments to `path.relative`) with `path.toNamespacedPath` so `path.relative` works.

# Test Plan

Will release and test.
2025-02-09 12:18:13 +01:00
Expo CI b948dd5674 update CHANGELOG.md 2025-02-09 10:05:08 +00:00
Stanisław Chmiela 0e7a8bfe5e v15.0.7 2025-02-09 10:59:55 +01:00
Stanisław Chmiela b86696fcdf [eas-cli] Allow normalizing root path to fix Windows not ignoring files (#2892)
<!-- If this PR requires a changelog entry, add it by commenting the PR with the command `/changelog-entry [breaking-change|new-feature|bug-fix|chore] [message]`. -->
<!-- You can skip the changelog check by labeling the PR with "no changelog". -->

# Why

Since v15 files that should be ignored get added to the tarball on Windows.

# How

After adding some debugging I was able to find that `src`, `srcFilePath` don't match on path separators which breaks `ignore.ignores`.

<img width="827" alt="Zrzut ekranu 2025-02-7 o 21 57 02" src="https://github.com/user-attachments/assets/9ed3b166-dbc0-4287-87a1-0009769e0d4d" />

Added `path.normalize` if `__NORMALIZE` is `1`.

---

Also noticed we used wrong `cwd` for is-tracked check.

# Test Plan

Going to release this and test.
2025-02-09 10:58:50 +01:00
Expo CI f334bdbcc7 update CHANGELOG.md 2025-02-07 14:50:59 +00:00
Stanisław Chmiela f16433048a v15.0.6 2025-02-07 15:39:53 +01:00
Stanisław Chmiela e83061de36 [eas-cli] Add more logging around creating a tarball (#2890)
Should help debug why `node_modules` get included on Windows.
2025-02-07 15:39:21 +01:00
Stanisław Chmiela 73e0170836 Rephrase message at the end of workflow:run (#2886)
https://linear.app/expo/issue/ENG-14752/update-copy-at-the-end-of-workflowrun
2025-02-06 15:52:34 +01:00
Expo CI 078c7287d3 update CHANGELOG.md 2025-02-06 12:49:35 +00:00
Stanisław Chmiela a65e691325 v15.0.5 2025-02-06 11:16:44 +01:00
Stanisław Chmiela b306109676 [eas-cli] Skip makeShallowCopyAsync if requireCommit (#2885)
<!-- If this PR requires a changelog entry, add it by commenting the PR with the command `/changelog-entry [breaking-change|new-feature|bug-fix|chore] [message]`. -->
<!-- You can skip the changelog check by labeling the PR with "no changelog". -->

# Why

`requireCommit: true` setups expect worktree to be clean (e.g. encrypted files to be locked).

# How

Made it so if `requireCommit` is true we don't copy files over.

# Test Plan

I have enabled `git-crypt` in my test repository, added a secret file, ran `easd build:inspect -p android -s archive -o ~/test-production --force`. The clone did contain secret file in plaintext.

Then I enabled `requireCommit: true` and repeated the command. The clone did contain the file encrypted.
2025-02-06 11:04:23 +01:00
Stanisław Chmiela 035ca03b96 [eas-cli] Fix deleting ignored submodules when archiving project (#2884)
<!-- If this PR requires a changelog entry, add it by commenting the PR with the command `/changelog-entry [breaking-change|new-feature|bug-fix|chore] [message]`. -->
<!-- You can skip the changelog check by labeling the PR with "no changelog". -->

# Why

`git ls-files` returns submodules as a single filepath entry, so `rm` must remove it with `-rf`. Fixes https://github.com/expo/eas-cli/issues/2883.

# How

Added `{ recursive: true, force: true }` to `fs.rm`.

# Test Plan

I tried `easd build:inspect -p android -s archive -o ~/testtest --force` on a repo with a submodule in `.easignore`. It succeeded and the submodule was not included in the folder.
2025-02-06 10:45:46 +01:00
Expo CI b3d092423b update CHANGELOG.md 2025-02-05 10:49:05 +00:00
211 changed files with 17613 additions and 5071 deletions

No files matched your search

+11 -1
View File
@@ -3,7 +3,7 @@ commentConfiguration:
epilogue: 'Generated by [CodeMention](https://github.com/expo/expo/actions/workflows/codemention.yaml)'
rules:
- patterns: ['**/*']
mentions: ['szdziedzic', 'khamilowicz', 'sjchmiela', 'radoslawkrzemien']
mentions: ['douglowder']
- patterns: ['packages/eas-cli/src/update/**']
mentions: ['wschurman', 'quinlanj']
- patterns: ['packages/eas-cli/src/commands/update/**']
@@ -18,3 +18,13 @@ rules:
mentions: ['bycedric']
- patterns: ['packages/eas-cli/src/commands/metadata/**']
mentions: ['bycedric']
- patterns: ['packages/eas-cli/src/worker/**']
mentions: ['kitten', 'kadikraman']
- patterns: ['packages/eas-cli/src/commands/deploy/**']
mentions: ['kitten', 'kadikraman']
- patterns: ['packages/eas-cli/src/commands/build/**']
mentions: ['sjchmiela']
- patterns: ['packages/eas-cli/src/build/**']
mentions: ['sjchmiela']
- patterns: ['packages/eas-cli/src/commands/workflow/**']
mentions: ['sjchmiela']
+1 -1
View File
@@ -20,7 +20,7 @@ jobs:
- uses: actions/checkout@v2
- name: Check if CHANGELOG.md was updated
id: changelog-updated
uses: tj-actions/changed-files@v41
uses: tj-actions/changed-files@b1ba699b304f2083b602164e06a89b868c84f076
with:
files: CHANGELOG.md
- name: Fail if CHANGELOG.md was not updated and the "no changelog" label is absent
+11 -6
View File
@@ -18,6 +18,7 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
tag_name: ${{ github.ref }}
release_name: ${{ github.ref }}
draft: true
build-linux:
name: Build for Linux
@@ -27,9 +28,13 @@ jobs:
- uses: actions/checkout@v2
- uses: actions/setup-node@v2
with:
node-version: 20
node-version: 22
- run: yarn install --frozen-lockfile --check-files
- run: yarn build
- name: Ensure GraphQL schema and generated code is up-to-date
working-directory: ${{ github.workspace }}/packages/eas-cli
run: |
yarn verify-graphql-code
- name: Build tarballs
working-directory: ${{ github.workspace }}/packages/eas-cli
run: |
@@ -68,7 +73,7 @@ jobs:
- uses: actions/checkout@v2
- uses: actions/setup-node@v2
with:
node-version: 20
node-version: 22
- run: brew install python-setuptools
env:
HOMEBREW_NO_AUTO_UPDATE: 1
@@ -111,7 +116,7 @@ jobs:
- uses: actions/checkout@v2
- uses: actions/setup-node@v2
with:
node-version: 20
node-version: 22
- run: yarn install --frozen-lockfile --check-files
- run: sudo apt-get install nsis
- run: yarn build
@@ -153,7 +158,7 @@ jobs:
with:
registry-url: 'https://registry.npmjs.org/'
scope: 'expo'
node-version: 20
node-version: 22
- name: Install dependencies
run: yarn install --frozen-lockfile --check-files
- name: Build
@@ -186,7 +191,7 @@ jobs:
token: ${{ secrets.EXPO_BOT_PAT }}
- uses: actions/setup-node@v2
with:
node-version: 20
node-version: 22
- name: Install dependencies
working-directory: ./scripts
run: yarn install --frozen-lockfile --check-files
@@ -206,7 +211,7 @@ jobs:
git config --global user.email "[email protected]"
git config --global user.name "Expo CI"
git add CHANGELOG.md
git commit -m "update CHANGELOG.md"
git commit --allow-empty -m "update CHANGELOG.md"
git push
- name: Prepare changelog for Slack
run: |
+27
View File
@@ -0,0 +1,27 @@
name: Ensure GraphQL schema and generated code is up-to-date
on:
push:
branches: [main]
pull_request:
branches: [main]
schedule:
- cron: '0 8 * * *' # 8:00 AM UTC time every day
concurrency:
group: test-graphql-${{ github.ref }}
cancel-in-progress: true
jobs:
test:
name: Ensure GraphQL schema and generated code is up-to-date
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Setup node
uses: actions/setup-node@v2
with:
node-version: 22
- run: yarn install --frozen-lockfile
- run: yarn verify-graphql-code
working-directory: packages/eas-cli
+1 -1
View File
@@ -52,7 +52,7 @@ jobs:
notify-slack:
runs-on: ubuntu-latest
needs: test
needs: [test]
name: Notify Slack
if: ${{ github.ref == 'refs/heads/main' && always() }}
steps:
+48
View File
@@ -0,0 +1,48 @@
on:
workflow_dispatch:
inputs:
version:
type: string
required: false
description: 'Version number for the release'
dry_run:
type: boolean
required: true
default: true
description: 'Run the release in dry run mode, without pushing any changes'
name: Manually trigger a release
jobs:
release:
name: Trigger a release
runs-on: ubuntu-latest
env:
GITHUB_TOKEN: ${{ secrets.EXPO_BOT_PAT }}
INPUT_VERSION: ${{ github.event.inputs.version }}
INPUT_DRY_RUN: ${{ github.event.inputs.dry_run }}
steps:
- uses: actions/checkout@v4
with:
ref: main
fetch-depth: 0
token: ${{ secrets.EXPO_BOT_PAT }}
- uses: actions/setup-node@v4
with:
node-version: 22
- run: |
# Lerna will not detect changes correctly without having all tags
git fetch --depth=1 origin +refs/tags/*:refs/tags/*
# Checkout the main branch
git checkout main
git config --global user.email "[email protected]"
git config --global user.name "Expo CI"
yarn install --frozen-lockfile --check-files
yarn build
if [[ "$INPUT_VERSION" == "" ]]; then
echo "Releasing with default version"
yarn release
else
echo "Releasing with version $INPUT_VERSION"
yarn release $INPUT_VERSION
fi
@@ -66,7 +66,7 @@ jobs:
destination_branch: 'main'
pr_title: 'Upgrade eas-build dependencies'
pr_body: ':arrow_up: Upgrades [`eas-build`](https://github.com/expo/eas-build) dependencies.'
pr_reviewer: 'szdziedzic,radoslawkrzemien,sjchmiela,khamilowicz'
pr_reviewer: 'sjchmiela'
github_token: ${{ secrets.EXPO_BOT_PAT }}
- uses: actions/github-script@v3
+401
View File
@@ -10,6 +10,407 @@ This is the log of notable changes to EAS CLI and related packages.
### 🐛 Bug fixes
### 🧹 Chores
## [16.18.1](https://github.com/expo/eas-cli/releases/tag/v16.18.1) - 2025-09-10
### 🎉 New features
- CLI debug log should respect DEBUG=`eas:*`. ([#3162](https://github.com/expo/eas-cli/pull/3162) by [@douglowder](https://github.com/douglowder))
- Better description for update:rollback. ([#3154](https://github.com/expo/eas-cli/pull/3154) by [@douglowder](https://github.com/douglowder))
## [16.18.0](https://github.com/expo/eas-cli/releases/tag/v16.18.0) - 2025-08-27
### 🎉 New features
- Show fingerprints in build:view and build:list commands. ([#3137](https://github.com/expo/eas-cli/pull/3137) by [@douglowder](https://github.com/douglowder))
### 🐛 Bug fixes
- Make EXPO_PUBLIC_ env vars plain text, rest sensitive ([#3121](https://github.com/expo/eas-cli/pull/3121) by [@kadikraman](https://github.com/kadikraman))
## [16.17.4](https://github.com/expo/eas-cli/releases/tag/v16.17.4) - 2025-07-31
### 🧹 Chores
- Introduce new release process and workflow. ([#3119](https://github.com/expo/eas-cli/pull/3119) by [@douglowder](https://github.com/douglowder))
- SSO Login: print browser url ([#3113](https://github.com/expo/eas-cli/pull/3113) by [@BackSlasher](https://github.com/BackSlasher))
## [16.17.2](https://github.com/expo/eas-cli/releases/tag/v16.17.2) - 2025-07-23
## [16.17.1](https://github.com/expo/eas-cli/releases/tag/v16.17.1) - 2025-07-23
## [16.17.0](https://github.com/expo/eas-cli/releases/tag/v16.17.0) - 2025-07-18
### 🎉 New features
- Add `deploy:delete` for deleting worker deployments ([#3106](https://github.com/expo/eas-cli/pull/3106) by [@kadikraman](https://github.com/kadikraman))
- Add `deploy:alias:delete` for deleting worker deployment aliases ([#3107](https://github.com/expo/eas-cli/pull/3107) by [@kadikraman](https://github.com/kadikraman))
## [16.16.0](https://github.com/expo/eas-cli/releases/tag/v16.16.0) - 2025-07-16
### 🎉 New features
- Add workflow:view and workflow:logs commands. ([#3090](https://github.com/expo/eas-cli/pull/3090) by [@douglowder](https://github.com/douglowder))
- Add support for providing inputs to new workflow runs ([#3095](https://github.com/expo/eas-cli/pull/3095) by [@sjchmiela](https://github.com/sjchmiela))
### 🐛 Bug fixes
- Refactor capabilities syncing to avoid provisioning profiles becoming invalid. ([#3088](https://github.com/expo/eas-cli/pull/3088) by [@vonovak](https://github.com/vonovak))
- Avoid corrupted EAS Hosting batch upload bodies and double-check length and checksums while uploading. ([#3103](https://github.com/expo/eas-cli/pull/3103) by [@kitten](https://github.com/kitten))
## [16.15.0](https://github.com/expo/eas-cli/releases/tag/v16.15.0) - 2025-07-10
### 🎉 New features
- Implement new EAS Hosting asset upload protocol, which improves performance by grouping files into batched upload API calls ([#3085](https://github.com/expo/eas-cli/pull/3085) by [@kitten](https://github.com/kitten))
### 🧹 Chores
- Update the EAS Hosting worker deployment progress indicator to use a progress bar for asset uploads instead ([#3099](https://github.com/expo/eas-cli/pull/3099) by [@kitten](https://github.com/kitten))
## [16.14.1](https://github.com/expo/eas-cli/releases/tag/v16.14.1) - 2025-07-08
### 🧹 Chores
- Update apple team info as needed. ([#3096](https://github.com/expo/eas-cli/pull/3096) by [@quinlanj](https://github.com/quinlanj))
## [16.14.0](https://github.com/expo/eas-cli/releases/tag/v16.14.0) - 2025-07-07
### 🎉 New features
- Add update:revert-update-rollout command. ([#3068](https://github.com/expo/eas-cli/pull/3068) by [@wschurman](https://github.com/wschurman))
### 🧹 Chores
- Remove unreleased `link` and `unlink` commands from `eas env`. ([#3092](https://github.com/expo/eas-cli/pull/3092) by [@kadikraman](https://github.com/kadikraman))
- Add new CI to verify GraphQL generation. ([#3093](https://github.com/expo/eas-cli/pull/3093) by [@douglowder](https://github.com/douglowder))
- Update GraphQL generation to look in all src directories. ([#3097](https://github.com/expo/eas-cli/pull/3097) by [@wschurman](https://github.com/wschurman))
## [16.13.4](https://github.com/expo/eas-cli/releases/tag/v16.13.4) - 2025-07-04
### 🧹 Chores
- Remove hidden flag from `eas upload`. ([#3014](https://github.com/expo/eas-cli/pull/3014) by [@gabrieldonadel](https://github.com/gabrieldonadel))
## [16.13.3](https://github.com/expo/eas-cli/releases/tag/v16.13.3) - 2025-07-01
### 🐛 Bug fixes
- Fix suggested command to switch Xcode Developer Tools. ([#3079](https://github.com/expo/eas-cli/pull/3079) by [@rolfb](https://github.com/rolfb))
- Skip configuring build profiles when running `eas update:configure` if no build profiles exist. ([#3084](https://github.com/expo/eas-cli/pull/3084) by [@brentvatne](https://github.com/brentvatne))
### 🧹 Chores
- Reword how paid plans are described ([#3082](https://github.com/expo/eas-cli/pull/3082) by [@ide](https://github.com/ide))
## [16.13.2](https://github.com/expo/eas-cli/releases/tag/v16.13.2) - 2025-06-27
### 🧹 Chores
- Amend update override fields. ([#3080](https://github.com/expo/eas-cli/pull/3080) by [@quinlanj](https://github.com/quinlanj))
## [16.13.1](https://github.com/expo/eas-cli/releases/tag/v16.13.1) - 2025-06-25
### 🐛 Bug fixes
- Fix bug in log during update:republish. ([#3067](https://github.com/expo/eas-cli/pull/3067) by [@wschurman](https://github.com/wschurman))
- Fix update group deletion. ([#3069](https://github.com/expo/eas-cli/pull/3069) by [@wschurman](https://github.com/wschurman))
## [16.13.0](https://github.com/expo/eas-cli/releases/tag/v16.13.0) - 2025-06-24
### 🎉 New features
- Add `--what-to-test` option to `build` command. ([#3065](https://github.com/expo/eas-cli/pull/3065) by [@vonovak](https://github.com/vonovak))
### 🐛 Bug fixes
- Hide `workflow:list`. ([#3060](https://github.com/expo/eas-cli/pull/3060) by [@douglowder](https://github.com/douglowder))
- Align text of messages from `build` and `workflow:run`. ([#3061](https://github.com/expo/eas-cli/pull/3061) by [@douglowder](https://github.com/douglowder))
## [16.12.0](https://github.com/expo/eas-cli/releases/tag/v16.12.0) - 2025-06-20
### 🎉 New features
- Add `--what-to-test` option to the `submit` command. ([#3023](https://github.com/expo/eas-cli/pull/3023) by [@vonovak](https://github.com/vonovak))
### 🐛 Bug fixes
- Fixed monorepo support in `workflow:run` if a project is not connected to a GitHub repository. ([#3058](https://github.com/expo/eas-cli/pull/3058) by [@sjchmiela](https://github.com/sjchmiela))
## [16.10.1](https://github.com/expo/eas-cli/releases/tag/v16.10.1) - 2025-06-13
### 🐛 Bug fixes
- Fixed `eas build:dev` command to correctly use passed build profile. ([#3053](https://github.com/expo/eas-cli/pull/3053) by [@sebryu](https://github.com/sebryu))
### 🧹 Chores
- Allow rolling out update on empty branch. ([#3050](https://github.com/expo/eas-cli/pull/3050) by [@wschurman](https://github.com/wschurman))
## [16.10.0](https://github.com/expo/eas-cli/releases/tag/v16.10.0) - 2025-06-12
### 🎉 New features
- [eas-cli] Add workflow:cancel. ([#3048](https://github.com/expo/eas-cli/pull/3048) by [@douglowder](https://github.com/douglowder))
### 🧹 Chores
- Removed repack support. ([#3044](https://github.com/expo/eas-cli/pull/3044) by [@kudo](https://github.com/kudo))
## [16.9.0](https://github.com/expo/eas-cli/releases/tag/v16.9.0) - 2025-06-06
### 🎉 New features
- Add `groups` option to the `submit` command. ([#2891](https://github.com/expo/eas-cli/pull/2891) by [@khamilowicz](https://github.com/khamilowicz))
### 🐛 Bug fixes
- [eas-cli] Ensure workflow:runs returns most recent runs. ([#3045](https://github.com/expo/eas-cli/pull/3045) by [@douglowder](https://github.com/douglowder))
## [16.8.0](https://github.com/expo/eas-cli/releases/tag/v16.8.0) - 2025-06-04
### 🎉 New features
- [eas-cli] Add workflow:list and workflow:runs. ([#3030](https://github.com/expo/eas-cli/pull/3030) by [@douglowder](https://github.com/douglowder))
### 🧹 Chores
- Change location of EAS Update host override config. ([#3042](https://github.com/expo/eas-cli/pull/3042) by [@wschurman](https://github.com/wschurman))
## [16.7.2](https://github.com/expo/eas-cli/releases/tag/v16.7.2) - 2025-06-03
### 🐛 Bug fixes
- Add support for array `flavorDimensions` (`flavorDimensions = ['version']`). ([#3043](https://github.com/expo/eas-cli/pull/3043) by [@sjchmiela](https://github.com/sjchmiela))
## [16.7.1](https://github.com/expo/eas-cli/releases/tag/v16.7.1) - 2025-06-03
### 🐛 Bug fixes
- Fix some unit tests by updating mocks. ([#3035](https://github.com/expo/eas-cli/pull/3035) by [@douglowder](https://github.com/douglowder))
- Fix update republishing not including environment or git info. ([#3036](https://github.com/expo/eas-cli/pull/3036) by [@wschurman](https://github.com/wschurman))
- Do not require `eas.json` or `package.json` files when creating new workflow runs. ([#3028](https://github.com/expo/eas-cli/pull/3028) by [@sjchmiela](https://github.com/sjchmiela))
- Add `EAS_SKIP_CLI_VERSION_CHECK` allowing us to skip `eas-cli` version check (against `eas.json#version`). ([#3041](https://github.com/expo/eas-cli/pull/3041) by [@sjchmiela](https://github.com/sjchmiela))
## [16.7.0](https://github.com/expo/eas-cli/releases/tag/v16.7.0) - 2025-05-29
### 🎉 New features
- Add support for manifest host and asset host overriding for EAS Update. ([#3021](https://github.com/expo/eas-cli/pull/3021) by [@wschurman](https://github.com/wschurman))
- Add rollout flag to update:republish. ([#3029](https://github.com/expo/eas-cli/pull/3029) by [@quinlanj](https://github.com/quinlanj))
## [16.6.2](https://github.com/expo/eas-cli/releases/tag/v16.6.2) - 2025-05-23
### 🧹 Chores
- Further no longer require owner field for SDK >= 53 or canary. ([#3017](https://github.com/expo/eas-cli/pull/3017) by [@wschurman](https://github.com/wschurman))
## [16.6.1](https://github.com/expo/eas-cli/releases/tag/v16.6.1) - 2025-05-14
### 🐛 Bug fixes
- Onboarding: add cwd to spawned git process. ([#3015](https://github.com/expo/eas-cli/pull/3015) by [@quinlanj](https://github.com/quinlanj))
## [16.6.0](https://github.com/expo/eas-cli/releases/tag/v16.6.0) - 2025-05-14
### 🎉 New features
- Add support for uploading iOS internal distribution local builds. ([#3014](https://github.com/expo/eas-cli/pull/3014) by [@gabrieldonadel](https://github.com/gabrieldonadel))
## [16.5.0](https://github.com/expo/eas-cli/releases/tag/v16.5.0) - 2025-05-13
### 🎉 New features
- Add `--wait`/`--no-wait` flag to `eas workflow:run`. ([#3012](https://github.com/expo/eas-cli/pull/3012) by [@sjchmiela](https://github.com/sjchmiela))
- Add `--json` flag to `eas workflow:run`. ([#3013](https://github.com/expo/eas-cli/pull/3013) by [@sjchmiela](https://github.com/sjchmiela))
## [16.4.2](https://github.com/expo/eas-cli/releases/tag/v16.4.2) - 2025-05-10
### 🧹 Chores
- Update `eas-build-cache-provider` function names to use `buildCacheProvider` key. ([#3002](https://github.com/expo/eas-cli/pull/3002) by [@gabrieldonadel](https://github.com/gabrieldonadel))
## [16.4.1](https://github.com/expo/eas-cli/releases/tag/v16.4.1) - 2025-05-07
### 🧹 Chores
- Allow non-standard build types in custom gradle command. ([#3003](https://github.com/expo/eas-cli/pull/3003) by [@khamilowicz](https://github.com/khamilowicz))
## [16.4.0](https://github.com/expo/eas-cli/releases/tag/v16.4.0) - 2025-05-02
### 🎉 New features
- Add `--verbose-logs` flag for `build` command ([#3000](https://github.com/expo/eas-cli/pull/3000) by [@khamilowicz](https://github.com/khamilowicz))
- Add `eas-build-cache-provider` package. ([#3002](https://github.com/expo/eas-cli/pull/3002) by [@gabrieldonadel](https://github.com/gabrieldonadel))
### 🧹 Chores
- Read remote build cache provider types from `@expo/config`. ([#3005](https://github.com/expo/eas-cli/pull/3005) by [@gabrieldonadel](https://github.com/gabrieldonadel))
## [16.3.3](https://github.com/expo/eas-cli/releases/tag/v16.3.3) - 2025-04-24
### 🐛 Bug fixes
- Add `sdk-52` alias to the list or supported android images. ([#2989](https://github.com/expo/eas-cli/pull/2989) by [@kadikraman](https://github.com/kadikraman))
- Detect when expo metro config is used. ([#2996](https://github.com/expo/eas-cli/pull/2996) by [@kadikraman](https://github.com/kadikraman))
### 🧹 Chores
- upload assetmap.json when publishing update. ([#2994](https://github.com/expo/eas-cli/pull/2994) by [@quinlanj](https://github.com/quinlanj))
- Add error messages for CDN-level upload errors. ([#2998](https://github.com/expo/eas-cli/pull/2998) by [@kitten](https://github.com/kitten))
## [16.3.2](https://github.com/expo/eas-cli/releases/tag/v16.3.2) - 2025-04-17
### 🧹 Chores
- Update upload command to display build info. ([#2990](https://github.com/expo/eas-cli/pull/2990) by [@gabrieldonadel](https://github.com/gabrieldonadel))
- change launch asset file extension. ([#2991](https://github.com/expo/eas-cli/pull/2991) by [@quinlanj](https://github.com/quinlanj))
## [16.3.1](https://github.com/expo/eas-cli/releases/tag/v16.3.1) - 2025-04-11
### 🧹 Chores
- Add `--no-dev-client` flag for `build:download` command. ([#2985](https://github.com/expo/eas-cli/pull/2985) by [@gabrieldonadel](https://github.com/gabrieldonadel))
## [16.3.0](https://github.com/expo/eas-cli/releases/tag/v16.3.0) - 2025-04-09
### 🎉 New features
- Add `eas upload` command. ([#2932](https://github.com/expo/eas-cli/pull/2932), [#2981](https://github.com/expo/eas-cli/pull/2981), [#2983](https://github.com/expo/eas-cli/pull/2983) by [@gabrieldonadel](https://github.com/gabrieldonadel))
- Add `eas build:download` command. ([#2982](https://github.com/expo/eas-cli/pull/2982) by [@gabrieldonadel](https://github.com/gabrieldonadel))
## [16.2.2](https://github.com/expo/eas-cli/releases/tag/v16.2.2) - 2025-04-08
## [16.2.1](https://github.com/expo/eas-cli/releases/tag/v16.2.1) - 2025-04-04
### 🧹 Chores
- Bump `@expo/apple-utils` to use async JWT API. ([#2973](https://github.com/expo/eas-cli/pull/2973) by [@EvanBacon](https://github.com/EvanBacon))
## [16.2.0](https://github.com/expo/eas-cli/releases/tag/v16.2.0) - 2025-04-03
### 🎉 New features
- Add environment flag to `eas fingerprint:compare`. ([#2954](https://github.com/expo/eas-cli/pull/2954) by [@quinlanj](https://github.com/quinlanj))
- Add build-profile flag to `eas fingerprint:generate`. ([#2966](https://github.com/expo/eas-cli/pull/2966) by [@quinlanj](https://github.com/quinlanj))
### 🧹 Chores
- Remove hidden flag from `eas fingerprint:generate`. ([#2965](https://github.com/expo/eas-cli/pull/2965) by [@quinlanj](https://github.com/quinlanj))
- Refactor `eas update` command to improve code readability. ([#2976](https://github.com/expo/eas-cli/pull/2976) by [@quinlanj](https://github.com/quinlanj))
- `eas update`: add warning if no build exists with fingerprint. ([#2977](https://github.com/expo/eas-cli/pull/2977) by [@quinlanj](https://github.com/quinlanj))
## [16.1.0](https://github.com/expo/eas-cli/releases/tag/v16.1.0) - 2025-03-26
### 🎉 New features
- Add environment flag to `eas fingerprint:generate`. ([#2951](https://github.com/expo/eas-cli/pull/2951) by [@quinlanj](https://github.com/quinlanj))
- Add `corepack` field to `eas.json`. ([#2964](https://github.com/expo/eas-cli/pull/2964) by [@szdziedzic](https://github.com/szdziedzic))
## [16.0.1](https://github.com/expo/eas-cli/releases/tag/v16.0.1) - 2025-03-20
### 🐛 Bug fixes
- Make `eas update:configure` re-apply configuration from app.json /app.config.js when run multiple times. ([#2957](https://github.com/expo/eas-cli/pull/2957) by [@brentvatne](https://github.com/brentvatne))
## [16.0.0](https://github.com/expo/eas-cli/releases/tag/v16.0.0) - 2025-03-19
### 🛠 Breaking changes
- Add support for `.easignore` when `requireCommit` is set to `true`. ([#2942](https://github.com/expo/eas-cli/pull/2942) by [@sjchmiela](https://github.com/sjchmiela))
- Up to 15.0.0, if `requireCommit` was `true`, `.easignore` was silently ignored.
- Versions 15.0.0-15.0.13 started using `.easignore` to skip files from being bundled into a tarball when `requireCommit` was `true`. This was an unexpected change in behavior.
- To clear this up, versions 15.0.13-15.0.15 were erroring if `.easignore` was present when `requireCommit` was `true`.
- `[email protected]` formalizes the 15.0.0-15.0.13 behavior by adhering to `.easignore` even when `requireCommit` is set to `true`.
- If you know what you're doing and you want to suppress a warning printed, you can do so by setting `EAS_SUPPRESS_REQUIRE_COMMIT_EASIGNORE_WARNING` environment variable to `true`.
### 🎉 New features
- Add requestId to ApiV2Error. ([#2941](https://github.com/expo/eas-cli/pull/2941) by [@wschurman](https://github.com/wschurman))
- Release `eas fingerprint:generate` in hidden mode ([#2937](https://github.com/expo/eas-cli/pull/2937) by [@quinlanj](https://github.com/quinlanj))
### 🐛 Bug fixes
- Use correct logic to determine whether artifacts have expired in `eas build:run` command. ([#2931](https://github.com/expo/eas-cli/pull/2931) by [@szdziedzic](https://github.com/szdziedzic))
## [15.0.15](https://github.com/expo/eas-cli/releases/tag/v15.0.15) - 2025-03-12
### 🐛 Bug fixes
- Pass through the updates version to `setUpdatesConfigAsync`, which expects it in order to determine which field values to use. ([#2934](https://github.com/expo/eas-cli/pull/2934) by [@brentvatne](https://github.com/brentvatne)).
## [15.0.14](https://github.com/expo/eas-cli/releases/tag/v15.0.14) - 2025-03-06
### 🐛 Bug fixes
- In `EAS_NO_VCS=1`, use Git for repository root when `EAS_PROJECT_ROOT` is not set. ([#2901](https://github.com/expo/eas-cli/pull/2901) by [@sjchmiela](https://github.com/sjchmiela))
### 🧹 Chores
- Suggest using `eas build:dev` for matching configurations. ([#2929](https://github.com/expo/eas-cli/pull/2929) by [@szdziedzic](https://github.com/szdziedzic))
## [15.0.13](https://github.com/expo/eas-cli/releases/tag/v15.0.13) - 2025-03-04
### 🐛 Bug fixes
- Fixed `.git` being always unexpectedly removed if you had `requireCommit: true` and `.easignore` present. ([#2925](https://github.com/expo/eas-cli/pull/2925) by [@sjchmiela](https://github.com/sjchmiela))
### 🧹 Chores
- Fix `eas fingerprint:compare` URL generation and pretty prints. ([#2909](https://github.com/expo/eas-cli/pull/2909) by [@quinlanj](https://github.com/quinlanj))
- fix formatFields to handle empty array. ([#2914](https://github.com/expo/eas-cli/pull/2914) by [@quinlanj](https://github.com/quinlanj))
- fix git diff header in `fingerprint:compare`. ([#2915](https://github.com/expo/eas-cli/pull/2915) by [@quinlanj](https://github.com/quinlanj))
- amend fingerprint compare url. ([#2923](https://github.com/expo/eas-cli/pull/2923) by [@quinlanj](https://github.com/quinlanj))
- improve error message for `applicationIdSuffix`. ([#2924](https://github.com/expo/eas-cli/pull/2924) by [@kadikraman](https://github.com/kadikraman))
## [15.0.12](https://github.com/expo/eas-cli/releases/tag/v15.0.12) - 2025-02-22
### 🐛 Bug fixes
- Fix APNS key creation. ([#2916](https://github.com/expo/eas-cli/pull/2916) by [@EvanBacon](https://github.com/EvanBacon))
## [15.0.11](https://github.com/expo/eas-cli/releases/tag/v15.0.11) - 2025-02-21
### 🐛 Bug fixes
- Fix internal TestFlight group creation. ([#2906](https://github.com/expo/eas-cli/pull/2906) by [@EvanBacon](https://github.com/EvanBacon))
### 🧹 Chores
- Narrow amount of data queried for basic update channel operations. ([#2901](https://github.com/expo/eas-cli/pull/2901) by [@wschurman](https://github.com/wschurman))
- Fix `eas fingerprint:compare` description. ([#2908](https://github.com/expo/eas-cli/pull/2908) by [@quinlanj](https://github.com/quinlanj))
- Skip auto-creation of TestFlight group when there are already exisitng TestFlight groups and allow to opt out of the behavior by setting `EAS_NO_AUTO_TESTFLIGHT_SETUP` env var. ([#2856](https://github.com/expo/eas-cli/pull/2856) by [@szdziedzic](https://github.com/szdziedzic))
## [15.0.10](https://github.com/expo/eas-cli/releases/tag/v15.0.10) - 2025-02-11
### 🐛 Bug fixes
- Fix files deleted in working directory not being removed from the project archive when `requireCommit` is false. ([#2900](https://github.com/expo/eas-cli/pull/2900) by [@sjchmiela](https://github.com/sjchmiela))
## [15.0.9](https://github.com/expo/eas-cli/releases/tag/v15.0.9) - 2025-02-09
### 🐛 Bug fixes
- Fix files not being ignored when creating a tarball on Windows in Git repository in no `requireCommit` mode. ([#2894](https://github.com/expo/eas-cli/pull/2894) by [@sjchmiela](https://github.com/sjchmiela))
## [15.0.8](https://github.com/expo/eas-cli/releases/tag/v15.0.8) - 2025-02-09
## [15.0.7](https://github.com/expo/eas-cli/releases/tag/v15.0.7) - 2025-02-09
## [15.0.6](https://github.com/expo/eas-cli/releases/tag/v15.0.6) - 2025-02-07
## [15.0.5](https://github.com/expo/eas-cli/releases/tag/v15.0.5) - 2025-02-06
### 🐛 Bug fixes
- Do not copy files over onto a cloned Git repository when packing the project archive if `requireCommit` is true. ([#2885](https://github.com/expo/eas-cli/pull/2885) by [@sjchmiela](https://github.com/sjchmiela))
- Fix `EISDIR` error when archiving project with submodules ignored. ([#2884](https://github.com/expo/eas-cli/pull/2884) by [@sjchmiela](https://github.com/sjchmiela))
## [15.0.4](https://github.com/expo/eas-cli/releases/tag/v15.0.4) - 2025-02-05
### 🐛 Bug fixes
- Fixed `GitClient` not respecting `.easignore` file. ([#2873](https://github.com/expo/eas-cli/pull/2873) by [@sjchmiela](https://github.com/sjchmiela))
- Fix symlink support in `makeShallowCopyAsync`. ([#2874](https://github.com/expo/eas-cli/pull/2874) by [@sjchmiela](https://github.com/sjchmiela))
- Allow excluding `.git` directory from project archive by adding it to `.easignore`. ([#2879](https://github.com/expo/eas-cli/pull/2879) by [@sjchmiela](https://github.com/sjchmiela))
+1 -1
View File
@@ -1,6 +1,6 @@
# Releasing EAS CLI
1. Run `yarn release` in the repository root folder. The next version is chosen automatically based on the changelog entries. If you want to use different version, pass the version string as a positional argument to the command, e.g. `yarn release 1.2.3`.
1. Invoke the GitHub workflow ["Manually trigger a release"](https://github.com/expo/eas-cli/actions/workflows/trigger-release.yml). The next version is chosen automatically based on the changelog entries. If you want to use different version, pass the version string as an input to the workflow, in semver format, e.g. "1.2.3"
2. That's it! GitHub Actions is going to take care of the rest. Watch the #eas-cli Slack channel for a successful release notification.
## Choosing the next version
+1 -1
View File
@@ -1,5 +1,5 @@
{
"npmClient": "yarn",
"version": "15.0.4",
"version": "16.18.1",
"$schema": "node_modules/lerna/schemas/lerna-schema.json"
}
+1 -1
View File
@@ -28,7 +28,7 @@
"eslint-plugin-node": "11.1.0",
"jest": "29.7.0",
"jest-watch-typeahead": "2.2.2",
"lerna": "7.4.2",
"lerna": "8.2.3",
"oclif": "^3.9.0",
"prettier": "3.1.1",
"ts-jest": "29.1.1",
@@ -0,0 +1,22 @@
# eas-build-cache-provider
A build cache provider plugin for the Expo CLI
To use the EAS remote build provider plugin, install the `eas-build-cache-provider` package as a developer dependency:
```bash
npm install --save-dev eas-build-cache-provider
```
Then, update your **app.json** to include the `buildCacheProvider` property and its provider under `experiments`:
```json
{
"expo": {
...
"experiments": {
"buildCacheProvider": "eas"
}
}
}
```
@@ -0,0 +1,58 @@
{
"name": "eas-build-cache-provider",
"description": "A build cache provider plugin for the Expo CLI",
"version": "16.4.2",
"author": "Expo <[email protected]>",
"bugs": "https://github.com/expo/eas-cli/issues",
"dependencies": {
"@babel/code-frame": "7.23.5",
"@expo/config": "11.0.10",
"@expo/spawn-async": "^1.7.2",
"chalk": "4.1.2",
"figures": "3.2.0",
"fs-extra": "11.2.0",
"getenv": "1.0.0",
"log-symbols": "4.1.0",
"semver": "7.5.2",
"terminal-link": "2.1.1",
"tslib": "2.4.1"
},
"devDependencies": {
"@tsconfig/node18": "18.2.4",
"@types/babel__code-frame": "7.0.3",
"@types/fs-extra": "11.0.4",
"@types/getenv": "^1.0.0",
"memfs": "3.4.13",
"rimraf": "3.0.2",
"typescript": "5.3.3"
},
"engines": {
"node": ">=18.0.0"
},
"homepage": "https://github.com/expo/eas-cli",
"license": "MIT",
"main": "build/index.js",
"types": "build/index.d.ts",
"repository": "expo/eas-cli",
"scripts": {
"build": "tsc --project tsconfig.build.json",
"build-allow-unused": "tsc --project tsconfig.allowUnused.json",
"watch": "yarn build --watch --preserveWatchOutput",
"watch-allow-unused": "yarn build-allow-unused --watch --preserveWatchOutput",
"typecheck": "tsc",
"prepack": "yarn rebuild",
"rebuild": "rimraf build && yarn build",
"test": "jest",
"clean": "rimraf build node_modules yarn-error.log"
},
"files": [
"/build"
],
"publishConfig": {
"access": "public"
},
"volta": {
"node": "20.11.0",
"yarn": "1.22.21"
}
}
@@ -0,0 +1,19 @@
module.exports = {
extends: ['universe/shared/typescript-analysis'],
overrides: [
{
files: ['*.ts', '*.d.ts'],
parserOptions: {
project: './packages/eas-build-cache-provider/tsconfig.json',
},
rules: {
'@typescript-eslint/explicit-function-return-type': [
'warn',
{
allowExpressions: true,
},
],
},
},
],
};
@@ -0,0 +1,38 @@
import { RunOptions, getPackageJson } from '@expo/config';
import { SpawnResult } from '@expo/spawn-async';
export function isSpawnResultError(obj: any): obj is Error & SpawnResult {
return (
obj &&
'message' in obj &&
obj.status !== undefined &&
obj.stdout !== undefined &&
obj.stderr !== undefined
);
}
export function isDevClientBuild({
runOptions,
projectRoot,
}: {
runOptions: RunOptions;
projectRoot: string;
}): boolean {
if (!hasDirectDevClientDependency(projectRoot)) {
return false;
}
if ('variant' in runOptions && runOptions.variant !== undefined) {
return runOptions.variant === 'debug';
}
if ('configuration' in runOptions && runOptions.configuration !== undefined) {
return runOptions.configuration === 'Debug';
}
return true;
}
export function hasDirectDevClientDependency(projectRoot: string): boolean {
const { dependencies = {}, devDependencies = {} } = getPackageJson(projectRoot);
return !!dependencies['expo-dev-client'] || !!devDependencies['expo-dev-client'];
}
@@ -0,0 +1,136 @@
import {
BuildCacheProviderPlugin,
CalculateFingerprintHashProps,
ResolveBuildCacheProps,
UploadBuildCacheProps,
} from '@expo/config';
import spawnAsync from '@expo/spawn-async';
import chalk from 'chalk';
import fs from 'fs-extra';
import path from 'path';
import { isDevClientBuild, isSpawnResultError } from './helpers';
import Log from './log';
async function resolveBuildCacheAsync({
projectRoot,
platform,
fingerprintHash,
runOptions,
}: ResolveBuildCacheProps): Promise<string | null> {
const easJsonPath = path.join(projectRoot, 'eas.json');
if (!(await fs.exists(easJsonPath))) {
Log.debug('eas.json not found, skip checking for remote builds');
return null;
}
Log.log(
chalk`{whiteBright \u203A} {bold Searching builds with matching fingerprint on EAS servers}`
);
try {
const results = await spawnAsync(
'npx',
[
'eas-cli',
'build:download',
`--platform=${platform}`,
`--fingerprint=${fingerprintHash}`,
'--non-interactive',
isDevClientBuild({ runOptions, projectRoot }) ? '--dev-client' : '--no-dev-client',
'--json',
],
{
cwd: projectRoot,
}
);
Log.log(chalk`{whiteBright \u203A} {bold Successfully downloaded cached build}`);
// {
// "path": "/var/folders/03/lppcpcnn61q3mz5ckzmzd8w80000gn/T/eas-cli-nodejs/eas-build-run-cache/c0f9ba9c-0cf1-4c5c-8566-b28b7971050f_22f1bbfa-1c09-4b67-9e4a-721906546b58.app"
// }
const json = JSON.parse(results.stdout.trim());
return json?.path;
} catch (error) {
Log.debug('eas-cli error:', error);
// @TODO(2025-04-11): remove this in a future release
if (isSpawnResultError(error) && error.stderr.includes('command build:download not found')) {
Log.warn(
`To take advantage of EAS build cache provider, upgrade your eas-cli installation to latest.`
);
}
return null;
}
}
async function uploadBuildCacheAsync({
projectRoot,
platform,
fingerprintHash,
buildPath,
}: UploadBuildCacheProps): Promise<string | null> {
const easJsonPath = path.join(projectRoot, 'eas.json');
if (!(await fs.exists(easJsonPath))) {
Log.debug('eas.json not found, skip uploading builds');
return null;
}
try {
Log.log(chalk`{whiteBright \u203A} {bold Uploading build to EAS}`);
const results = await spawnAsync(
'npx',
[
'eas-cli',
'upload',
`--platform=${platform}`,
`--fingerprint=${fingerprintHash}`,
buildPath ? `--build-path=${buildPath}` : '',
'--non-interactive',
'--json',
],
{
cwd: projectRoot,
}
);
// {
// "url": "/var/folders/03/lppcpcnn61q3mz5ckzmzd8w80000gn/T/eas-cli-nodejs/eas-build-run-cache/c0f9ba9c-0cf1-4c5c-8566-b28b7971050f_22f1bbfa-1c09-4b67-9e4a-721906546b58.app"
// }
const json = JSON.parse(results.stdout.trim());
Log.log(chalk`{whiteBright \u203A} {bold Build successfully uploaded: ${json?.url}}`);
return json?.url;
} catch (error) {
Log.debug('eas-cli error:', error);
}
return null;
}
async function calculateEASFingerprintHashAsync({
projectRoot,
platform,
}: CalculateFingerprintHashProps): Promise<string | null> {
// prefer using `eas fingerprint:generate` because it automatically upload sources
try {
const results = await spawnAsync(
'npx',
['eas-cli', 'fingerprint:generate', `--platform=${platform}`, '--json', '--non-interactive'],
{
cwd: projectRoot,
}
);
// {
// "hash": "203f960b965e154b77dc31c6c42e5582e8d77196"
// }
const json = JSON.parse(results.stdout.trim());
return json?.hash;
} catch (error) {
Log.debug('eas-cli error:', error);
}
return null;
}
const EASBuildCacheProvider: BuildCacheProviderPlugin = {
resolveBuildCache: resolveBuildCacheAsync,
uploadBuildCache: uploadBuildCacheAsync,
calculateFingerprintHash: calculateEASFingerprintHashAsync,
};
export default EASBuildCacheProvider;
@@ -0,0 +1,121 @@
import chalk from 'chalk';
import figures from 'figures';
import { boolish } from 'getenv';
import logSymbols from 'log-symbols';
import terminalLink from 'terminal-link';
type Color = (...text: string[]) => string;
export default class Log {
public static readonly isDebug = boolish('EXPO_DEBUG', false);
public static log(...args: any[]): void {
Log.consoleLog(...args);
}
public static newLine(): void {
Log.consoleLog();
}
public static addNewLineIfNone(): void {
if (!Log.isLastLineNewLine) {
Log.newLine();
}
}
public static error(...args: any[]): void {
Log.consoleLog(...Log.withTextColor(args, chalk.red));
}
public static warn(...args: any[]): void {
Log.consoleLog(...Log.withTextColor(args, chalk.yellow));
}
public static debug(...args: any[]): void {
if (Log.isDebug) {
Log.consoleLog(...args);
}
}
public static gray(...args: any[]): void {
Log.consoleLog(...Log.withTextColor(args, chalk.gray));
}
public static warnDeprecatedFlag(flag: string, message: string): void {
Log.warn(`› ${chalk.bold('--' + flag)} flag is deprecated. ${message}`);
}
public static fail(message: string): void {
Log.log(`${chalk.red(logSymbols.error)} ${message}`);
}
public static succeed(message: string): void {
Log.log(`${chalk.green(logSymbols.success)} ${message}`);
}
public static withTick(...args: any[]): void {
Log.consoleLog(chalk.green(figures.tick), ...args);
}
public static withInfo(...args: any[]): void {
Log.consoleLog(chalk.green(figures.info), ...args);
}
private static consoleLog(...args: any[]): void {
Log.updateIsLastLineNewLine(args);
// eslint-disable-next-line no-console
console.log(...args);
}
private static withTextColor(args: any[], chalkColor: Color): string[] {
return args.map(arg => chalkColor(arg));
}
private static isLastLineNewLine = false;
private static updateIsLastLineNewLine(args: any[]): void {
if (args.length === 0) {
Log.isLastLineNewLine = true;
} else {
const lastArg = args[args.length - 1];
if (typeof lastArg === 'string' && (lastArg === '' || lastArg.match(/[\r\n]$/))) {
Log.isLastLineNewLine = true;
} else {
Log.isLastLineNewLine = false;
}
}
}
}
/**
* Prints a link for given URL, using text if provided, otherwise text is just the URL.
* Format links as dim (unless disabled) and with an underline.
*
* @example https://expo.dev
*/
export function link(
url: string,
{ text = url, fallback, dim = true }: { text?: string; dim?: boolean; fallback?: string } = {}
): string {
// Links can be disabled via env variables https://github.com/jamestalmage/supports-hyperlinks/blob/master/index.js
const output = terminalLink(text, url, {
fallback: () =>
fallback ?? (text === url ? chalk.underline(url) : `${text}: ${chalk.underline(url)}`),
});
return dim ? chalk.dim(output) : output;
}
/**
* Provide a consistent "Learn more" link experience.
* Format links as dim (unless disabled) with an underline.
*
* @example Learn more: https://expo.dev
*/
export function learnMore(
url: string,
{
learnMoreMessage: maybeLearnMoreMessage,
dim = true,
}: { learnMoreMessage?: string; dim?: boolean } = {}
): string {
return link(url, { text: maybeLearnMoreMessage ?? 'Learn more', dim });
}
@@ -0,0 +1,4 @@
{
"extends": "./tsconfig",
"exclude": ["**/__mocks__/*", "**/__tests__/*"]
}
@@ -0,0 +1,18 @@
{
"extends": "@tsconfig/node18/tsconfig.json",
"compilerOptions": {
"declaration": true,
"importHelpers": true,
"module": "commonjs",
"moduleResolution": "node",
"noFallthroughCasesInSwitch": true,
"noImplicitOverride": true,
"noImplicitReturns": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"outDir": "build",
"rootDir": "src",
"typeRoots": ["../../node_modules/@types", "../../ts-declarations", "./node_modules/@types"]
},
"include": ["src/**/*"]
}
+440 -104
View File
File diff suppressed because it is too large. Load diff
+1 -7
View File
@@ -1,13 +1,7 @@
overwrite: true
schema: 'https://staging-api.expo.dev/graphql'
documents:
- 'src/graphql/**/!(*.d).{ts,tsx}'
- 'src/credentials/ios/api/graphql/**/!(*.d).{ts,tsx}'
- 'src/credentials/android/api/graphql/**/!(*.d).{ts,tsx}'
- 'src/commands/**/*.ts'
- 'src/branch/**/*.ts'
- 'src/channel/**/*.ts'
- 'src/worker/**/*.ts'
- 'src/**/!(*.d).ts'
generates:
src/graphql/generated.ts:
plugins:
+4569 -794
View File
File diff suppressed because it is too large. Load diff
+12 -8
View File
@@ -1,19 +1,19 @@
{
"name": "eas-cli",
"description": "EAS command line tool",
"version": "15.0.4",
"version": "16.18.1",
"author": "Expo <[email protected]>",
"bin": {
"eas": "./bin/run"
},
"bugs": "https://github.com/expo/eas-cli/issues",
"dependencies": {
"@expo/apple-utils": "2.1.6",
"@expo/apple-utils": "2.1.12",
"@expo/code-signing-certificates": "0.0.5",
"@expo/config": "10.0.6",
"@expo/config-plugins": "9.0.12",
"@expo/eas-build-job": "1.0.165",
"@expo/eas-json": "15.0.0",
"@expo/eas-build-job": "1.0.173",
"@expo/eas-json": "16.18.0",
"@expo/env": "^1.0.0",
"@expo/json-file": "8.3.3",
"@expo/logger": "1.0.117",
@@ -28,7 +28,7 @@
"@expo/results": "1.0.0",
"@expo/rudder-sdk-node": "1.1.1",
"@expo/spawn-async": "1.7.2",
"@expo/steps": "1.0.166",
"@expo/steps": "1.0.173",
"@expo/timeago.js": "1.0.0",
"@oclif/core": "^1.26.2",
"@oclif/plugin-autocomplete": "^2.3.10",
@@ -38,6 +38,7 @@
"ajv": "8.11.0",
"ajv-formats": "2.1.1",
"better-opn": "3.0.2",
"bplist-parser": "^0.3.0",
"chalk": "4.1.2",
"cli-progress": "3.12.0",
"dateformat": "4.6.3",
@@ -48,7 +49,7 @@
"fast-deep-equal": "3.1.3",
"fast-glob": "3.3.2",
"figures": "3.2.0",
"form-data": "4.0.0",
"form-data": "^4.0.4",
"fs-extra": "11.2.0",
"getenv": "1.0.0",
"gradle-to-js": "2.0.1",
@@ -68,6 +69,7 @@
"nanoid": "3.3.8",
"node-fetch": "2.6.7",
"node-forge": "1.3.1",
"node-stream-zip": "1.15.0",
"nullthrows": "1.1.1",
"ora": "5.1.0",
"pkg-dir": "4.2.0",
@@ -78,6 +80,7 @@
"qrcode-terminal": "0.12.0",
"resolve-from": "5.0.0",
"semver": "7.5.4",
"set-interval-async": "3.0.3",
"slash": "3.0.0",
"tar": "6.2.1",
"tar-stream": "3.1.7",
@@ -105,7 +108,7 @@
"@types/getenv": "^1.0.0",
"@types/jsonwebtoken": "8.5.1",
"@types/mime": "3.0.1",
"@types/node-fetch": "2.6.2",
"@types/node-fetch": "2.6.12",
"@types/node-forge": "1.3.1",
"@types/pngjs": "6.0.4",
"@types/promise-retry": "1.1.3",
@@ -116,7 +119,7 @@
"@types/tough-cookie": "4.0.2",
"@types/uuid": "9.0.7",
"@types/wrap-ansi": "3.0.0",
"axios": "0.28.1",
"axios": "1.11.0",
"eslint-plugin-graphql": "4.0.0",
"express": "4.20.0",
"memfs": "3.4.13",
@@ -231,6 +234,7 @@
"test": "jest",
"version": "yarn oclif readme && node scripts/patch-readme && git add README.md",
"generate-graphql-code": "graphql-codegen --config graphql-codegen.yml",
"verify-graphql-code": "./scripts/verify-graphql.sh",
"clean": "rimraf dist build tmp node_modules yarn-error.log"
},
"volta": {
+37
View File
@@ -0,0 +1,37 @@
#!/usr/bin/env bash
function cleanup()
{
echo 'Cleaning up...'
rm -f graphql.schema.json.bak src/graphql/generated.ts.bak
}
# Fail if anything errors
set -eox pipefail
# If this script exits, trap it first and clean up
trap cleanup EXIT
cp graphql.schema.json graphql.schema.json.bak
cp src/graphql/generated.ts src/graphql/generated.ts.bak
yarn generate-graphql-code
if cmp -s graphql.schema.json graphql.schema.json.bak; then
echo "GraphQL schema is up-to-date"
if cmp -s src/graphql/generated.ts src/graphql/generated.ts.bak; then
echo "GraphQL generated code is up-to-date"
exit 0
else
echo "GraphQL code has changed but has not been regenerated. Run `yarn generate-graphql-code` and commit the changes."
exit 1
fi
else
echo "GraphQL schema has changed on the server."
echo "Building eas-json"
cd ../eas-json
yarn build
cd ../eas-cli
echo "Running `yarn tsc` to check for breaking changes..."
yarn tsc
# If there are no errors, then the schema is backwards compatible
exit 0
fi
+3
View File
@@ -6,6 +6,7 @@ export class ApiV2Error extends Error {
readonly expoApiV2ErrorDetails?: JSONValue;
readonly expoApiV2ErrorServerStack?: string;
readonly expoApiV2ErrorMetadata?: object;
readonly expoApiV2ErrorRequestId?: string;
constructor(response: {
message: string;
@@ -13,11 +14,13 @@ export class ApiV2Error extends Error {
stack?: string;
details?: JSONValue;
metadata?: object;
requestId?: string;
}) {
super(response.message);
this.expoApiV2ErrorCode = response.code;
this.expoApiV2ErrorDetails = response.details;
this.expoApiV2ErrorServerStack = response.stack;
this.expoApiV2ErrorMetadata = response.metadata;
this.expoApiV2ErrorRequestId = response.requestId;
}
}
@@ -16,6 +16,7 @@ describe(ApiV2Client, () => {
stack: 'line 1: hello',
details: { who: 'world' },
metadata: { an: 'object' },
requestId: 'request-id',
},
],
});
@@ -37,6 +38,7 @@ describe(ApiV2Client, () => {
expect(error.expoApiV2ErrorDetails).toEqual({ who: 'world' });
expect(error.expoApiV2ErrorMetadata).toEqual({ an: 'object' });
expect(error.expoApiV2ErrorServerStack).toEqual('line 1: hello');
expect(error.expoApiV2ErrorRequestId).toEqual('request-id');
});
it('does not convert non-APIv2 error to ApiV2Error', async () => {
@@ -7,7 +7,16 @@ import { v4 as uuidv4 } from 'uuid';
import { ContextInput, ContextOutput } from '../../commandUtils/EasCommand';
import { DynamicConfigContextFn } from '../../commandUtils/context/DynamicProjectConfigContextField';
import { AppFragment, Role } from '../../graphql/generated';
import {
AppFragment,
Role,
WorkflowJobStatus,
WorkflowJobType,
WorkflowRunByIdWithJobsQuery,
WorkflowRunFragment,
WorkflowRunStatus,
WorkflowRunTriggerEventType,
} from '../../graphql/generated';
export function getMockEasJson(): EasJson {
return {
@@ -81,7 +90,9 @@ export function mockCommandContext<
if (contextKey === 'loggedIn') {
result.loggedIn = {};
}
if (contextKey === 'projectId') {
result.projectId = overrides.projectId ?? mockProjectId;
}
if (contextKey === 'projectDir') {
result.projectDir = projectDir;
}
@@ -168,3 +179,129 @@ export function withLocalVersionSource(easJson: EasJson): EasJson {
},
};
}
export function getMockEmptyWorkflowRunsFragment(): WorkflowRunFragment[] {
return getMockWorkflowRunsFragment();
}
export function getMockWorkflowRunsFragment(
params?:
| undefined
| {
successes?: number;
failures?: number;
pending?: number;
withJobs?: number;
}
): WorkflowRunFragment[] {
const { successes = 0, failures = 0, pending = 0, withJobs = 0 } = params ?? {};
const runs: any[] = [];
for (let i = 0; i < successes; i++) {
runs.push({
id: `success-${i}`,
status: WorkflowRunStatus.Success,
errors: [],
createdAt: '2022-01-01T00:00:00.000Z',
updatedAt: '2022-01-01T00:00:00.000Z',
gitCommitHash: '1234567890',
gitCommitMessage: 'commit message',
workflow: {
name: 'build',
id: 'build',
fileName: 'build.yml',
},
});
}
for (let i = 0; i < failures; i++) {
runs.push({
id: `failure-${i}`,
errors: ['Failed'],
status: WorkflowRunStatus.Failure,
createdAt: '2022-01-01T00:00:00.000Z',
updatedAt: '2022-01-01T00:00:00.000Z',
gitCommitHash: '1234567890',
gitCommitMessage: 'commit message',
workflow: {
name: 'build',
id: 'build',
fileName: 'build.yml',
},
});
}
for (let i = 0; i < pending; i++) {
runs.push({
id: `pending-${i}`,
errors: [],
status: WorkflowRunStatus.InProgress,
createdAt: '2022-01-01T00:00:00.000Z',
updatedAt: '2022-01-01T00:00:00.000Z',
gitCommitHash: '1234567890',
gitCommitMessage: 'commit message',
workflow: {
name: 'build',
id: 'build',
fileName: 'build.yml',
},
});
}
for (let i = 0; i < withJobs; i++) {
runs.push(getMockWorkflowRunWithJobsFragment());
}
return runs;
}
export function getMockWorkflowJobFragment(
runId?: string
): WorkflowRunByIdWithJobsQuery['workflowRuns']['byId']['jobs'][number] {
return {
id: 'job1',
key: 'job1',
name: 'job1',
status: WorkflowJobStatus.Success,
type: WorkflowJobType.Build,
createdAt: '2022-01-01T00:00:00.000Z',
updatedAt: '2022-01-01T00:00:00.000Z',
outputs: {},
errors: [],
workflowRun: {
id: runId ?? 'build1',
},
turtleJobRun: {
id: 'job1',
logFileUrls: ['https://example.com/log1'],
errors: [],
},
};
}
export function getMockWorkflowRunWithJobsFragment(
runID?: string
): WorkflowRunByIdWithJobsQuery['workflowRuns']['byId'] {
const id = runID ?? 'build1';
return {
id,
status: WorkflowRunStatus.Failure,
triggerEventType: WorkflowRunTriggerEventType.Manual,
createdAt: '2022-01-01T00:00:00.000Z',
updatedAt: '2022-01-01T00:00:00.000Z',
gitCommitHash: '1234567890',
gitCommitMessage: 'commit message',
errors: [],
workflow: {
id: 'build',
name: 'build',
fileName: 'build.yml',
app: {
id: mockProjectId,
__typename: 'App',
name: 'App',
ownerAccount: {
id: 'account-id',
name: 'account-name',
__typename: 'Account',
},
},
},
jobs: [getMockWorkflowJobFragment(id)],
};
}
@@ -0,0 +1,188 @@
import {
getMockEmptyWorkflowRunsFragment,
getMockWorkflowJobFragment,
getMockWorkflowRunWithJobsFragment,
getMockWorkflowRunsFragment,
mockCommandContext,
mockProjectId,
mockTestCommand,
} from './utils';
import { fetchRawLogsForJobAsync } from '../../commandUtils/workflow/fetchLogs';
import WorkflowLogView from '../../commands/workflow/logs';
import { AppQuery } from '../../graphql/queries/AppQuery';
import { WorkflowJobQuery } from '../../graphql/queries/WorkflowJobQuery';
import { WorkflowRunQuery } from '../../graphql/queries/WorkflowRunQuery';
import Log from '../../log';
import { promptAsync } from '../../prompts';
jest.mock('../../build/android/version');
jest.mock('../../build/ios/version');
jest.mock('../../project/applicationIdentifier');
jest.mock('../../graphql/queries/AppVersionQuery');
jest.mock('../../graphql/queries/AppQuery');
jest.mock('../../graphql/queries/WorkflowJobQuery');
jest.mock('../../graphql/queries/WorkflowRunQuery');
jest.mock('../../graphql/mutations/AppVersionMutation');
jest.mock('../../project/workflow');
jest.mock('../../project/android/gradleUtils');
jest.mock('../../project/ios/target');
jest.mock('../../project/ios/scheme');
jest.mock('fs');
jest.mock('../../log');
jest.mock('../../prompts');
jest.mock('../../utils/json');
jest.mock('../../commandUtils/workflow/fetchLogs');
describe(WorkflowLogView, () => {
beforeEach(() => {
jest.mocked(WorkflowJobQuery.byIdAsync).mockResolvedValue(getMockWorkflowJobFragment());
jest
.mocked(WorkflowRunQuery.withJobsByIdAsync)
.mockResolvedValue(getMockWorkflowRunWithJobsFragment());
});
afterEach(() => {
jest.clearAllMocks();
});
test('view logs, passing in no parameters, no runs found', async () => {
const ctx = mockCommandContext(WorkflowLogView, {
projectId: mockProjectId,
});
const cmd = mockTestCommand(WorkflowLogView, [], ctx);
jest
.mocked(AppQuery.byIdWorkflowRunsFilteredByStatusAsync)
.mockResolvedValue(getMockEmptyWorkflowRunsFragment());
await cmd.run();
expect(AppQuery.byIdWorkflowRunsFilteredByStatusAsync).toHaveBeenCalledWith(
ctx.loggedIn.graphqlClient,
mockProjectId,
undefined,
20
);
expect(Log.error).toHaveBeenCalledWith('No workflow runs found');
});
test('view logs, passing in no parameters, runs found, no jobs', async () => {
const ctx = mockCommandContext(WorkflowLogView, {
projectId: mockProjectId,
});
const cmd = mockTestCommand(WorkflowLogView, [], ctx);
jest
.mocked(AppQuery.byIdWorkflowRunsFilteredByStatusAsync)
.mockResolvedValue(getMockWorkflowRunsFragment({ successes: 1 }));
jest.mocked(promptAsync).mockResolvedValue({ selectedRun: 'build1' });
await cmd.run();
expect(AppQuery.byIdWorkflowRunsFilteredByStatusAsync).toHaveBeenCalledWith(
ctx.loggedIn.graphqlClient,
mockProjectId,
undefined,
20
);
expect(Log.error).toHaveBeenCalledWith('No job found');
});
test('view logs, passing in no parameters, runs found, jobs found, step selected, no logs', async () => {
const ctx = mockCommandContext(WorkflowLogView, {
projectId: mockProjectId,
});
const cmd = mockTestCommand(WorkflowLogView, [], ctx);
jest
.mocked(AppQuery.byIdWorkflowRunsFilteredByStatusAsync)
.mockResolvedValue(getMockWorkflowRunsFragment({ withJobs: 1 }));
let promptCalls = 0;
jest.mocked(promptAsync).mockImplementation(async () => {
if (promptCalls === 0) {
promptCalls++;
return { selectedRun: 'build1' };
} else if (promptCalls === 1) {
promptCalls++;
return { selectedJob: 0 };
} else {
return { selectedStep: 'step1' };
}
});
jest.mocked(fetchRawLogsForJobAsync).mockResolvedValue(null);
await cmd.run();
expect(AppQuery.byIdWorkflowRunsFilteredByStatusAsync).toHaveBeenCalledWith(
ctx.loggedIn.graphqlClient,
mockProjectId,
undefined,
20
);
expect(promptAsync).toHaveBeenCalledTimes(2);
expect(Log.error).toHaveBeenCalledWith('No logs found');
});
test('view logs, passing in no parameters, runs found, jobs found, step selected, logs found', async () => {
const ctx = mockCommandContext(WorkflowLogView, {
projectId: mockProjectId,
});
const cmd = mockTestCommand(WorkflowLogView, [], ctx);
jest
.mocked(AppQuery.byIdWorkflowRunsFilteredByStatusAsync)
.mockResolvedValue(getMockWorkflowRunsFragment({ withJobs: 1 }));
let promptCalls = 0;
jest.mocked(promptAsync).mockImplementation(async () => {
if (promptCalls === 0) {
promptCalls++;
return { selectedRun: 'build1' };
} else if (promptCalls === 1) {
promptCalls++;
return { selectedJob: 0 };
} else {
return { selectedStep: 'step1' };
}
});
jest
.mocked(fetchRawLogsForJobAsync)
.mockResolvedValue(
'{"result":"test","marker":"test","buildStepDisplayName":"step1","buildStepInternalId":"step1","time":"2022-01-01T00:00:00.000Z","msg":"test"}'
);
await cmd.run();
expect(AppQuery.byIdWorkflowRunsFilteredByStatusAsync).toHaveBeenCalledWith(
ctx.loggedIn.graphqlClient,
mockProjectId,
undefined,
20
);
expect(promptAsync).toHaveBeenCalledTimes(3);
expect(Log.error).not.toHaveBeenCalled();
expect(Log.log).toHaveBeenCalledWith(' 2022-01-01T00:00:00.000Z test');
});
test('view logs for a workflow job, passing in a job ID, all steps', async () => {
const ctx = mockCommandContext(WorkflowLogView, {
projectId: mockProjectId,
});
const cmd = mockTestCommand(WorkflowLogView, ['job1', '--all-steps'], ctx);
await cmd.run();
expect(WorkflowJobQuery.byIdAsync).toHaveBeenCalledWith(ctx.loggedIn.graphqlClient, 'job1', {
useCache: false,
});
});
test('view logs for a workflow job, passing in a run ID', async () => {
const ctx = mockCommandContext(WorkflowLogView, {
projectId: mockProjectId,
});
jest.mocked(promptAsync).mockResolvedValue({ selectedJob: 'job1' });
jest.mocked(WorkflowJobQuery.byIdAsync).mockRejectedValue(new Error('Job not found'));
const cmd = mockTestCommand(WorkflowLogView, ['build1', '--all-steps'], ctx);
await cmd.run();
expect(WorkflowRunQuery.withJobsByIdAsync).toHaveBeenCalledWith(
ctx.loggedIn.graphqlClient,
'build1',
{
useCache: false,
}
);
});
test('view logs for a workflow job, passing in a run ID, non-interactive', async () => {
const ctx = mockCommandContext(WorkflowLogView, {
projectId: mockProjectId,
});
jest.mocked(promptAsync).mockResolvedValue({ selectedJob: 'job1' });
jest.mocked(WorkflowJobQuery.byIdAsync).mockRejectedValue(new Error('Job not found'));
const cmd = mockTestCommand(WorkflowLogView, ['build1', '--non-interactive'], ctx);
try {
await cmd.run();
} catch (e) {
expect(e).toBeInstanceOf(Error);
expect((e as Error).message).toEqual('No workflow job found that matched the provided ID');
}
});
});
@@ -0,0 +1,184 @@
import {
getMockEmptyWorkflowRunsFragment,
getMockWorkflowRunsFragment,
mockCommandContext,
mockProjectId,
mockTestCommand,
} from './utils';
import WorkflowRunList from '../../commands/workflow/runs';
import { WorkflowRunStatus, WorkflowRunTriggerEventType } from '../../graphql/generated';
import { AppQuery } from '../../graphql/queries/AppQuery';
import { WorkflowRunQuery } from '../../graphql/queries/WorkflowRunQuery';
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
jest.mock('../../build/android/version');
jest.mock('../../build/ios/version');
jest.mock('../../project/applicationIdentifier');
jest.mock('../../graphql/queries/AppVersionQuery');
jest.mock('../../graphql/queries/AppQuery');
jest.mock('../../graphql/queries/WorkflowRunQuery');
jest.mock('../../graphql/mutations/AppVersionMutation');
jest.mock('../../project/workflow');
jest.mock('../../project/android/gradleUtils');
jest.mock('../../project/ios/target');
jest.mock('../../project/ios/scheme');
jest.mock('fs');
jest.mock('../../log');
jest.mock('../../prompts');
jest.mock('../../utils/json');
describe(WorkflowRunList, () => {
afterEach(() => {
jest.clearAllMocks();
});
test('list workflow runs with default params', async () => {
const ctx = mockCommandContext(WorkflowRunList, {
projectId: mockProjectId,
});
jest
.mocked(AppQuery.byIdWorkflowRunsFilteredByStatusAsync)
.mockResolvedValue(getMockEmptyWorkflowRunsFragment());
const cmd = mockTestCommand(WorkflowRunList, [], ctx);
await cmd.run();
expect(AppQuery.byIdWorkflowRunsFilteredByStatusAsync).toHaveBeenCalledWith(
ctx.loggedIn.graphqlClient,
mockProjectId,
undefined,
10
);
expect(enableJsonOutput).not.toHaveBeenCalled();
expect(printJsonOnlyOutput).not.toHaveBeenCalled();
});
test('list workflow runs with custom limit', async () => {
const ctx = mockCommandContext(WorkflowRunList, {
projectId: mockProjectId,
});
jest
.mocked(AppQuery.byIdWorkflowRunsFilteredByStatusAsync)
.mockResolvedValue(getMockEmptyWorkflowRunsFragment());
const cmd = mockTestCommand(WorkflowRunList, ['--limit', '100'], ctx);
await cmd.run();
expect(AppQuery.byIdWorkflowRunsFilteredByStatusAsync).toHaveBeenCalledWith(
ctx.loggedIn.graphqlClient,
mockProjectId,
undefined,
100
);
expect(enableJsonOutput).not.toHaveBeenCalled();
expect(printJsonOnlyOutput).not.toHaveBeenCalled();
});
test('list workflow runs with specific filename', async () => {
const ctx = mockCommandContext(WorkflowRunList, {
projectId: mockProjectId,
});
jest.mocked(WorkflowRunQuery.byAppIdFileNameAndStatusAsync).mockResolvedValue([
{
id: 'build',
status: WorkflowRunStatus.Success,
triggerEventType: WorkflowRunTriggerEventType.Manual,
createdAt: '2022-01-01T00:00:00.000Z',
updatedAt: '2022-01-01T00:00:00.000Z',
errors: [],
gitCommitHash: '1234567890',
gitCommitMessage: 'commit message',
workflow: {
id: 'build',
name: 'build',
fileName: 'build.yml',
},
},
]);
const cmd = mockTestCommand(WorkflowRunList, ['--workflow', 'build.yml'], ctx);
await cmd.run();
expect(WorkflowRunQuery.byAppIdFileNameAndStatusAsync).toHaveBeenCalledWith(
ctx.loggedIn.graphqlClient,
mockProjectId,
'build.yml',
undefined,
10
);
expect(enableJsonOutput).not.toHaveBeenCalled();
expect(printJsonOnlyOutput).not.toHaveBeenCalled();
});
test('list only workflow runs with status FAILURE, get json output', async () => {
const ctx = mockCommandContext(WorkflowRunList, {
projectId: mockProjectId,
});
jest
.mocked(AppQuery.byIdWorkflowRunsFilteredByStatusAsync)
.mockResolvedValue(getMockWorkflowRunsFragment({ failures: 1 }));
const cmd = mockTestCommand(WorkflowRunList, ['--status', 'FAILURE', '--json'], ctx);
await cmd.run();
expect(AppQuery.byIdWorkflowRunsFilteredByStatusAsync).toHaveBeenCalledWith(
ctx.loggedIn.graphqlClient,
mockProjectId,
WorkflowRunStatus.Failure,
10
);
expect(printJsonOnlyOutput).toHaveBeenCalledWith([
{
id: 'failure-0',
status: 'FAILURE',
gitCommitHash: '1234567890',
gitCommitMessage: 'commit message',
trigger: '',
triggerType: 'Other',
startedAt: '2022-01-01T00:00:00.000Z',
finishedAt: '2022-01-01T00:00:00.000Z',
workflowId: 'build',
workflowName: 'build',
workflowFileName: 'build.yml',
},
]);
});
test('list only workflow runs with specific filename and status FAILURE, get json output', async () => {
const ctx = mockCommandContext(WorkflowRunList, {
projectId: mockProjectId,
});
jest.mocked(WorkflowRunQuery.byAppIdFileNameAndStatusAsync).mockResolvedValue([
{
id: 'build1',
status: WorkflowRunStatus.Failure,
triggerEventType: WorkflowRunTriggerEventType.Manual,
createdAt: '2022-01-01T00:00:00.000Z',
updatedAt: '2022-01-01T00:00:00.000Z',
gitCommitHash: '1234567890',
gitCommitMessage: 'commit message',
errors: [],
workflow: {
id: 'build',
name: 'build',
fileName: 'build.yml',
},
},
]);
const cmd = mockTestCommand(
WorkflowRunList,
['--workflow', 'build.yml', '--status', 'FAILURE', '--json'],
ctx
);
await cmd.run();
expect(WorkflowRunQuery.byAppIdFileNameAndStatusAsync).toHaveBeenCalledWith(
ctx.loggedIn.graphqlClient,
mockProjectId,
'build.yml',
WorkflowRunStatus.Failure,
10
);
expect(printJsonOnlyOutput).toHaveBeenCalledWith([
{
id: 'build1',
status: 'FAILURE',
gitCommitHash: '1234567890',
gitCommitMessage: 'commit message',
trigger: '',
triggerType: 'Manual',
startedAt: '2022-01-01T00:00:00.000Z',
finishedAt: '2022-01-01T00:00:00.000Z',
workflowId: 'build',
workflowName: 'build',
workflowFileName: 'build.yml',
},
]);
});
});
+5 -1
View File
@@ -95,7 +95,11 @@ export function getExpoWebsiteBaseUrl(): string {
}
}
export function getEASUpdateURL(projectId: string): string {
export function getEASUpdateURL(projectId: string, manifestHostOverride: string | null): string {
if (manifestHostOverride) {
return new URL(projectId, `https://${manifestHostOverride}`).href;
}
if (process.env.EXPO_STAGING) {
return new URL(projectId, `https://staging-u.expo.dev`).href;
} else if (process.env.EXPO_LOCAL) {
@@ -0,0 +1,26 @@
import Log from '../../../log';
import { maybeWarnAboutNonStandardBuildType } from '../build';
const warn = jest.spyOn(Log, 'warn');
describe('maybeWarnAboutNonStandardBuildType', () => {
it('should not warn about non-standard build type', () => {
const buildProfile = {
gradleCommand: ':app:buildRelease',
};
maybeWarnAboutNonStandardBuildType({ buildProfile, buildType: 'release' });
expect(warn).not.toHaveBeenCalled();
});
it('should warn about non-standard build type', () => {
const buildProfile = {
gradleCommand: ':app:buildStage',
};
maybeWarnAboutNonStandardBuildType({ buildProfile, buildType: 'stage' });
expect(warn).toHaveBeenCalled();
});
});
+41 -1
View File
@@ -1,6 +1,7 @@
import { Android, Metadata, Platform, Workflow } from '@expo/eas-build-job';
import { AppVersionSource } from '@expo/eas-json';
import chalk from 'chalk';
import getenv from 'getenv';
import nullthrows from 'nullthrows';
import { transformJob } from './graphql';
@@ -12,7 +13,7 @@ import AndroidCredentialsProvider, {
} from '../../credentials/android/AndroidCredentialsProvider';
import { BuildParamsInput } from '../../graphql/generated';
import { BuildMutation, BuildResult } from '../../graphql/mutations/BuildMutation';
import Log from '../../log';
import Log, { learnMore } from '../../log';
import {
ensureApplicationIdIsDefinedForManagedProjectAsync,
getApplicationIdAsync,
@@ -65,6 +66,10 @@ This means that it will most likely produce an AAB and you will not be able to i
ctx.vcsClient
);
if (gradleContext?.buildType) {
maybeWarnAboutNonStandardBuildType({ buildProfile, buildType: gradleContext.buildType });
}
if (ctx.workflow === Workflow.MANAGED) {
await ensureApplicationIdIsDefinedForManagedProjectAsync(ctx);
}
@@ -168,3 +173,38 @@ async function ensureAndroidCredentialsAsync(
source: credentialsSource,
};
}
export function maybeWarnAboutNonStandardBuildType({
buildProfile,
buildType,
}: {
buildProfile: Pick<CommonContext<Platform.ANDROID>['buildProfile'], 'gradleCommand'>;
buildType: string;
}): void {
const shouldSuppressWarning = getenv.boolish('EAS_BUILD_NO_GRADLE_BUILD_TYPE_WARNING', false);
if (shouldSuppressWarning) {
return;
}
const knownValidBuildTypes = ['debug', 'release'];
if (!knownValidBuildTypes.includes(buildType)) {
Log.warn(
`Custom gradle command "${chalk.bold(
buildProfile.gradleCommand
)}" has non-standard build type: "${chalk.bold(
buildType
)}". Expected the command to end with "${chalk.bold('...Debug')}" or "${chalk.bold(
'...Release'
)}"`
);
Log.warn(
`Ensure the command is spelled correctly and ${chalk.bold(
'build.gradle'
)} is configured correctly. To suppress this warning, set ${chalk.bold(
'EAS_BUILD_NO_GRADLE_BUILD_TYPE_WARNING=true'
)}.`
);
Log.warn(learnMore('https://developer.android.com/build/build-variants#build-types'));
}
}
@@ -12,7 +12,7 @@ import slash from 'slash';
import { AndroidCredentials } from '../../credentials/android/AndroidCredentialsProvider';
import { getCustomBuildConfigPathForJob } from '../../project/customBuildConfig';
import { getUsername } from '../../project/projectUtils';
import { getUsernameForBuildMetadataAndBuildJob } from '../../project/projectUtils';
import { BuildContext } from '../context';
interface JobData {
@@ -29,7 +29,7 @@ export async function prepareJobAsync(
ctx: BuildContext<Platform.ANDROID>,
jobData: JobData
): Promise<Android.Job> {
const username = getUsername(ctx.exp, ctx.user);
const username = getUsernameForBuildMetadataAndBuildJob(ctx.user);
const buildProfile: BuildProfile<Platform.ANDROID> = ctx.buildProfile;
const projectRootDirectory =
slash(path.relative(await ctx.vcsClient.getRootPathAsync(), ctx.projectDir)) || '.';
@@ -57,9 +57,7 @@ export async function prepareJobAsync(
: undefined;
let buildMode;
if (ctx.repack) {
buildMode = BuildMode.REPACK;
} else if (buildProfile.config) {
if (buildProfile.config) {
buildMode = BuildMode.CUSTOM;
} else {
buildMode = BuildMode.BUILD;
@@ -73,11 +71,15 @@ export async function prepareJobAsync(
builderEnvironment: {
image: buildProfile.image,
node: buildProfile.node,
corepack: buildProfile.corepack,
pnpm: buildProfile.pnpm,
bun: buildProfile.bun,
yarn: buildProfile.yarn,
ndk: buildProfile.ndk,
env: buildProfile.env,
env: {
...buildProfile.env,
...(ctx.isVerboseLoggingEnabled ? { EAS_VERBOSE: '1' } : {}),
},
},
cache: {
...cacheDefaults,
@@ -110,11 +112,6 @@ export async function prepareJobAsync(
path: maybeCustomBuildConfigPath,
},
}),
...(ctx.repack && {
customBuildConfig: {
path: '__eas/repack.yml',
},
}),
loggerLevel: ctx.loggerLevel,
// Technically, these are unused, but let's include them here for type consistency.
// See: https://github.com/expo/eas-build/pull/454
+2 -6
View File
@@ -46,6 +46,7 @@ import { withAnalyticsAsync } from '../analytics/common';
import { getExpoWebsiteBaseUrl } from '../api';
import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient';
import { EasCommandError } from '../commandUtils/errors';
import { createFingerprintAsync } from '../fingerprint/cli';
import {
AppPlatform,
BuildFragment,
@@ -68,7 +69,6 @@ import { maybeUploadFingerprintAsync } from '../project/maybeUploadFingerprintAs
import { resolveRuntimeVersionAsync } from '../project/resolveRuntimeVersionAsync';
import { uploadFileAtPathToGCSAsync } from '../uploads';
import { formatBytes } from '../utils/files';
import { createFingerprintAsync } from '../utils/fingerprintCli';
import { printJsonOnlyOutput } from '../utils/json';
import { createProgressTracker } from '../utils/progress';
import { sleepAsync } from '../utils/promise';
@@ -500,11 +500,7 @@ async function handleSingleBuildProgressAsync(
if (build.priority !== BuildPriority.High) {
Log.newLine();
Log.log('Start builds sooner in the priority queue.');
Log.log(
`Sign up for EAS Production or Enterprise at ${link(
formatAccountBillingUrl(accountName)
)}`
);
Log.log(`Sign up for a paid plan at ${link(formatAccountBillingUrl(accountName))}`);
}
Log.newLine();
+2 -1
View File
@@ -62,6 +62,7 @@ export interface BuildContext<T extends Platform> {
requiredPackageManager: NodePackageManager['name'] | null;
vcsClient: Client;
loggerLevel?: LoggerLevel;
repack: boolean;
isVerboseLoggingEnabled: boolean;
whatToTest?: string;
env: Record<string, string>;
}
+6 -3
View File
@@ -42,7 +42,8 @@ export async function createBuildContextAsync<T extends Platform>({
customBuildConfigMetadata,
buildLoggerLevel,
freezeCredentials,
repack,
isVerboseLoggingEnabled,
whatToTest,
env,
}: {
buildProfileName: string;
@@ -64,7 +65,8 @@ export async function createBuildContextAsync<T extends Platform>({
customBuildConfigMetadata?: CustomBuildConfigMetadata;
buildLoggerLevel?: LoggerLevel;
freezeCredentials: boolean;
repack: boolean;
isVerboseLoggingEnabled: boolean;
whatToTest?: string;
env: Record<string, string>;
}): Promise<BuildContext<T>> {
const { exp, projectId } = await getDynamicPrivateProjectConfigAsync({
@@ -150,8 +152,9 @@ export async function createBuildContextAsync<T extends Platform>({
developmentClient,
requiredPackageManager,
loggerLevel: buildLoggerLevel,
repack,
isVerboseLoggingEnabled,
env,
whatToTest,
};
if (platform === Platform.ANDROID) {
const common = commonContext as CommonContext<Platform.ANDROID>;
+8 -11
View File
@@ -14,7 +14,7 @@ import slash from 'slash';
import { IosCredentials, TargetCredentials } from '../../credentials/ios/types';
import { IosJobSecretsInput } from '../../graphql/generated';
import { getCustomBuildConfigPathForJob } from '../../project/customBuildConfig';
import { getUsername } from '../../project/projectUtils';
import { getUsernameForBuildMetadataAndBuildJob } from '../../project/projectUtils';
import { BuildContext } from '../context';
interface JobData {
@@ -32,7 +32,7 @@ export async function prepareJobAsync(
ctx: BuildContext<Platform.IOS>,
jobData: JobData
): Promise<Ios.Job> {
const username = getUsername(ctx.exp, ctx.user);
const username = getUsernameForBuildMetadataAndBuildJob(ctx.user);
const buildProfile: BuildProfile<Platform.IOS> = ctx.buildProfile;
const projectRootDirectory =
slash(path.relative(await ctx.vcsClient.getRootPathAsync(), ctx.projectDir)) || '.';
@@ -49,9 +49,7 @@ export async function prepareJobAsync(
: undefined;
let buildMode;
if (ctx.repack) {
buildMode = BuildMode.REPACK;
} else if (buildProfile.config) {
if (buildProfile.config) {
buildMode = BuildMode.CUSTOM;
} else {
buildMode = BuildMode.BUILD;
@@ -65,13 +63,17 @@ export async function prepareJobAsync(
builderEnvironment: {
image: buildProfile.image,
node: buildProfile.node,
corepack: buildProfile.corepack,
pnpm: buildProfile.pnpm,
bun: buildProfile.bun,
yarn: buildProfile.yarn,
bundler: buildProfile.bundler,
cocoapods: buildProfile.cocoapods,
fastlane: buildProfile.fastlane,
env: buildProfile.env,
env: {
...buildProfile.env,
...(ctx.isVerboseLoggingEnabled ? { EAS_VERBOSE: '1' } : {}),
},
},
cache: {
...cacheDefaults,
@@ -105,11 +107,6 @@ export async function prepareJobAsync(
path: maybeCustomBuildConfigPath,
},
}),
...(ctx.repack && {
customBuildConfig: {
path: '__eas/repack.yml',
},
}),
loggerLevel: ctx.loggerLevel,
// Technically, these are unused, but let's include them here for type consistency.
// See: https://github.com/expo/eas-build/pull/454
+1 -1
View File
@@ -7,7 +7,7 @@ import Log from '../log';
import { ora } from '../ora';
const PLUGIN_PACKAGE_NAME = 'eas-cli-local-build-plugin';
const PLUGIN_PACKAGE_VERSION = '1.0.168';
const PLUGIN_PACKAGE_VERSION = '1.0.171';
export enum LocalBuildMode {
/**
+5 -2
View File
@@ -9,7 +9,10 @@ import { maybeResolveVersionsAsync as maybeResolveIosVersionsAsync } from './ios
import { LocalBuildMode } from './local';
import { BuildDistributionType } from './types';
import Log from '../log';
import { getUsername, isExpoUpdatesInstalled } from '../project/projectUtils';
import {
getUsernameForBuildMetadataAndBuildJob,
isExpoUpdatesInstalled,
} from '../project/projectUtils';
import { readChannelSafelyAsync as readAndroidChannelSafelyAsync } from '../update/android/UpdatesModule';
import { readChannelSafelyAsync as readIosChannelSafelyAsync } from '../update/ios/UpdatesModule';
import { easCliVersion } from '../utils/easCli';
@@ -48,7 +51,7 @@ export async function collectMetadataAsync<T extends Platform>(
ctx.localBuildOptions.localBuildMode === LocalBuildMode.INTERNAL
? false
: await ctx.vcsClient.hasUncommittedChangesAsync(),
username: getUsername(ctx.exp, ctx.user),
username: getUsernameForBuildMetadataAndBuildJob(ctx.user),
message: ctx.message,
...(ctx.platform === Platform.IOS && {
iosEnterpriseProvisioning: resolveIosEnterpriseProvisioning(
@@ -97,7 +97,8 @@ export interface BuildFlags {
message?: string;
buildLoggerLevel?: LoggerLevel;
freezeCredentials: boolean;
repack: boolean;
isVerboseLoggingEnabled?: boolean;
whatToTest?: string;
}
export async function runBuildAndSubmitAsync({
@@ -122,6 +123,7 @@ export async function runBuildAndSubmitAsync({
envOverride?: Env;
}): Promise<{
buildIds: string[];
buildProfiles?: ProfileData<'build'>[];
}> {
await vcsClient.ensureRepoExistsAsync();
await ensureRepoIsCleanAsync(vcsClient, flags.nonInteractive);
@@ -228,6 +230,7 @@ export async function runBuildAndSubmitAsync({
getDynamicPrivateProjectConfigAsync,
customBuildConfigMetadata: customBuildConfigMetadataByPlatform[platform],
env,
easJsonAccessor,
});
if (maybeBuild) {
startedBuilds.push({ build: maybeBuild, buildProfile });
@@ -270,7 +273,7 @@ export async function runBuildAndSubmitAsync({
({ platform }) => toAppPlatform(platform) === startedBuild.build.platform
)
).profile;
const submission = await prepareAndStartSubmissionAsync({
const submission = await prepareAndStartAutoSubmissionAsync({
build: startedBuild.build,
buildCtx: nullthrows(buildCtxByPlatform[startedBuild.build.platform]),
moreBuilds: startedBuilds.length > 1,
@@ -348,6 +351,7 @@ export async function runBuildAndSubmitAsync({
}
return {
buildIds: startedBuilds.map(({ build }) => build.id),
buildProfiles,
};
}
@@ -364,6 +368,7 @@ async function prepareAndStartBuildAsync({
getDynamicPrivateProjectConfigAsync,
customBuildConfigMetadata,
env,
easJsonAccessor,
}: {
projectDir: string;
flags: BuildFlags;
@@ -376,6 +381,7 @@ async function prepareAndStartBuildAsync({
vcsClient: Client;
getDynamicPrivateProjectConfigAsync: DynamicConfigContextFn;
customBuildConfigMetadata?: CustomBuildConfigMetadata;
easJsonAccessor: EasJsonAccessor;
env: Env;
}): Promise<{ build: BuildFragment | undefined; buildCtx: BuildContext<Platform> }> {
const buildCtx = await createBuildContextAsync({
@@ -398,7 +404,8 @@ async function prepareAndStartBuildAsync({
customBuildConfigMetadata,
buildLoggerLevel: flags.buildLoggerLevel ?? (Log.isDebug ? LoggerLevel.DEBUG : undefined),
freezeCredentials: flags.freezeCredentials,
repack: flags.repack,
isVerboseLoggingEnabled: flags.isVerboseLoggingEnabled ?? false,
whatToTest: flags.whatToTest,
env,
});
@@ -422,8 +429,18 @@ async function prepareAndStartBuildAsync({
nonInteractive: flags.nonInteractive,
buildProfile,
env: buildProfile.profile.env,
easJsonAccessor,
});
if (isUsingEASUpdate(buildCtx.exp, buildCtx.projectId)) {
const easJsonCliConfig: EasJson['cli'] =
(await EasJsonUtils.getCliConfigAsync(easJsonAccessor)) ?? {};
if (
isUsingEASUpdate(
buildCtx.exp,
buildCtx.projectId,
easJsonCliConfig.updateManifestHostOverride ?? null
)
) {
const doesChannelExist = await doesChannelExistAsync(graphqlClient, {
appId: buildCtx.projectId,
channelName: buildProfile.profile.channel,
@@ -488,7 +505,7 @@ async function startBuildAsync(ctx: BuildContext<Platform>): Promise<BuildFragme
return await sendBuildRequestAsync();
}
async function prepareAndStartSubmissionAsync({
async function prepareAndStartAutoSubmissionAsync({
build,
buildCtx,
moreBuilds,
@@ -523,6 +540,8 @@ async function prepareAndStartSubmissionAsync({
vcsClient: buildCtx.vcsClient,
isVerboseFastlaneEnabled: false,
specifiedProfile: selectedSubmitProfileName,
groups: undefined, // use groups from submit profile
whatToTest: buildCtx.whatToTest,
});
if (moreBuilds) {
@@ -602,6 +621,7 @@ async function validateExpoUpdatesInstalledAsProjectDependencyAsync({
nonInteractive,
sdkVersion,
env,
easJsonAccessor,
}: {
exp: ExpoConfig;
projectId: string;
@@ -611,6 +631,7 @@ async function validateExpoUpdatesInstalledAsProjectDependencyAsync({
nonInteractive: boolean;
sdkVersion?: string;
env: Env | undefined;
easJsonAccessor: EasJsonAccessor;
}): Promise<void> {
if (isExpoUpdatesInstalledOrAvailable(projectDir, sdkVersion)) {
return;
@@ -632,6 +653,9 @@ async function validateExpoUpdatesInstalledAsProjectDependencyAsync({
message: `Would you like to install the "expo-updates" package and configure EAS Update now?`,
});
if (installExpoUpdates) {
const easJsonCliConfig: EasJson['cli'] =
(await EasJsonUtils.getCliConfigAsync(easJsonAccessor)) ?? {};
await ensureEASUpdateIsConfiguredAsync({
exp,
projectId,
@@ -639,6 +663,7 @@ async function validateExpoUpdatesInstalledAsProjectDependencyAsync({
platform: RequestedPlatform.All,
vcsClient,
env,
manifestHostOverride: easJsonCliConfig.updateManifestHostOverride ?? null,
});
Log.withTick('Installed expo-updates and configured EAS Update.');
throw new Error('Command must be re-run to pick up new updates configuration.');
-1
View File
@@ -35,5 +35,4 @@ export interface BuildFlags {
message?: string;
buildLoggerLevel?: LoggerLevel;
freezeCredentials: boolean;
repack: boolean;
}
@@ -6,6 +6,7 @@ import {
BuildFragment,
BuildStatus as GraphQLBuildStatus,
} from '../../graphql/generated';
import { link } from '../../log';
import { appPlatformDisplayNames } from '../../platform';
import formatFields from '../../utils/formatFields';
@@ -81,7 +82,7 @@ export function formatGraphQLBuild(build: BuildFragment): string {
},
{
label: 'Logs',
value: getBuildLogsUrl(build),
value: link(getBuildLogsUrl(build)),
},
{
label: 'Artifact',
@@ -97,7 +98,28 @@ export function formatGraphQLBuild(build: BuildFragment): string {
return null;
case GraphQLBuildStatus.Finished: {
const url = build.artifacts?.buildUrl;
return url ? url : chalk.red('not found');
return url ? link(url) : chalk.red('not found');
}
default:
return null;
}
},
},
{
label: 'Fingerprint',
get value() {
switch (build.status) {
case GraphQLBuildStatus.New:
case GraphQLBuildStatus.InQueue:
case GraphQLBuildStatus.InProgress:
return '<in progress>';
case GraphQLBuildStatus.PendingCancel:
case GraphQLBuildStatus.Canceled:
case GraphQLBuildStatus.Errored:
return null;
case GraphQLBuildStatus.Finished: {
const hash = build.fingerprint?.hash;
return hash ? chalk.green(hash) : chalk.red('not found');
}
default:
return null;
@@ -43,14 +43,14 @@ const errorCodeToErrorMessageOverride: Record<string, (build: BuildFragment) =>
export function printLogsUrls(builds: BuildFragment[]): void {
if (builds.length === 1) {
Log.log(`Build details: ${link(getBuildLogsUrl(builds[0]))}`);
Log.log(`See logs: ${link(getBuildLogsUrl(builds[0]))}`);
} else {
builds.forEach(build => {
const logsUrl = getBuildLogsUrl(build);
Log.log(
`${appPlatformEmojis[build.platform]} ${
`See ${appPlatformEmojis[build.platform]} ${
appPlatformDisplayNames[build.platform]
} build details: ${link(logsUrl)}`
} logs: ${link(logsUrl)}`
);
});
}
+9
View File
@@ -72,3 +72,12 @@ export function getProjectGitHubSettingsUrl(accountName: string, projectName: st
getExpoWebsiteBaseUrl()
).toString();
}
export function getHostingDeploymentsUrl(accountName: string, projectName: string): string {
return new URL(
`/accounts/${encodeURIComponent(accountName)}/projects/${encodeURIComponent(
projectName
)}/hosting/deployments`,
getExpoWebsiteBaseUrl()
).toString();
}
@@ -18,6 +18,7 @@ export const testUpdateBranch1: UpdateBranchObject = {
isRollBackToEmbedded: false,
manifestPermalink: 'https://u.expo.dev/update/bbe3e63a-f620-4603-8c4d-258f3e51590d',
gitCommitHash: 'e91c5bb733eff675dd8a3722ed5d4f885a4b10ce',
isGitWorkingTreeDirty: false,
actor: {
__typename: 'User',
id: '026ace2b-c1f8-4a02-818d-6b52c100bdf2',
@@ -43,6 +44,7 @@ export const testUpdateBranch1: UpdateBranchObject = {
isRollBackToEmbedded: false,
manifestPermalink: 'https://u.expo.dev/update/db22401b-d321-4629-a068-21d6d1f19c02',
gitCommitHash: 'e91c5bb733eff675dd8a3722ed5d4f885a4b10ce',
isGitWorkingTreeDirty: false,
actor: {
__typename: 'User',
id: '026ace2b-c1f8-4a02-818d-6b52c100bdf2',
@@ -78,6 +80,7 @@ export const testUpdateBranch2: UpdateBranchObject = {
isRollBackToEmbedded: false,
manifestPermalink: 'https://u.expo.dev/update/f04aafc1-0bf5-4548-8c35-726b467e7c40',
gitCommitHash: 'e91c5bb733eff675dd8a3722ed5d4f885a4b10ce',
isGitWorkingTreeDirty: false,
actor: {
__typename: 'User',
id: '026ace2b-c1f8-4a02-818d-6b52c100bdf2',
@@ -103,6 +106,7 @@ export const testUpdateBranch2: UpdateBranchObject = {
isRollBackToEmbedded: false,
manifestPermalink: 'https://u.expo.dev/update/d8d15786-2727-407f-bf3e-f4bda7e397d9',
gitCommitHash: 'e91c5bb733eff675dd8a3722ed5d4f885a4b10ce',
isGitWorkingTreeDirty: false,
actor: {
__typename: 'User',
id: '026ace2b-c1f8-4a02-818d-6b52c100bdf2',
+1 -1
View File
@@ -289,7 +289,7 @@ export async function doesChannelExistAsync(
{ appId, channelName }: { appId: string; channelName: string }
): Promise<boolean> {
try {
await ChannelQuery.viewUpdateChannelAsync(graphqlClient, {
await ChannelQuery.viewUpdateChannelBasicInfoAsync(graphqlClient, {
appId,
channelName,
});
@@ -27,7 +27,7 @@ import {
createAnalyticsAsync,
} from '../analytics/AnalyticsManager';
import { EnvironmentVariableEnvironment } from '../graphql/generated';
import Log from '../log';
import Log, { link } from '../log';
import SessionManager from '../user/SessionManager';
import { Client } from '../vcs/vcs';
@@ -264,6 +264,16 @@ export default abstract class EasCommand extends Command {
const defaultMsg = `${messageLine}${requestIdLine}`;
if (graphQLError.extensions?.errorCode === 'UNAUTHORIZED_ERROR') {
if (defaultMsg.includes('ScopedAccountActorViewerContext') && process.env.EAS_BUILD) {
// We're in EAS, authenticated with a scoped account actor access token.
// We may have not added the scoped actor privacy rule to the right place yet.
return `${chalk.bold(
`You don't have the required permissions to perform this operation.`
)}\n\nWe are in the process of migrating EAS to a more granular permissioning system. If you believe what you're doing is a legitimate operation you should be able to perform, report this to us at ${link(
'https://expo.dev/contact'
)}\n\nOriginal error message: ${defaultMsg}`;
}
return `${chalk.bold(
`You don't have the required permissions to perform this operation.`
)}\n\nThis can sometimes happen if you are logged in as incorrect user.\nRun ${chalk.bold(
@@ -43,6 +43,7 @@ export async function fetchBuildsAsync({
platform?: RequestedPlatform;
profile?: string;
hasFingerprint?: boolean;
fingerprintHash?: string;
};
}): Promise<BuildFragment[]> {
let builds: BuildFragment[];
@@ -56,6 +57,9 @@ export async function fetchBuildsAsync({
if (filters?.hasFingerprint) {
queryFilters['hasFingerprint'] = filters.hasFingerprint;
}
if (filters?.fingerprintHash) {
queryFilters['fingerprintHash'] = filters.fingerprintHash;
}
if (!filters?.statuses) {
builds = await BuildQuery.viewBuildsOnAppAsync(graphqlClient, {
appId: projectId,
@@ -5,7 +5,7 @@ import { getProjectIdAsync } from './contextUtils/getProjectIdAsync';
import { loadServerSideEnvironmentVariablesAsync } from './contextUtils/loadServerSideEnvironmentVariablesAsync';
import { getPublicExpoConfigAsync } from '../../project/expoConfig';
type GetServerSideEnvironmentVariablesFn = (
export type GetServerSideEnvironmentVariablesFn = (
maybeEnv?: Record<string, string>
) => Promise<Record<string, string>>;
@@ -1,6 +1,10 @@
import { vol } from 'memfs';
import { findProjectRootAsync } from '../findProjectDirAndVerifyProjectSetupAsync';
import { easCliVersion } from '../../../../utils/easCli';
import {
findProjectDirAndVerifyProjectSetupAsync,
findProjectRootAsync,
} from '../findProjectDirAndVerifyProjectSetupAsync';
jest.mock('@expo/config');
jest.mock('fs');
@@ -60,3 +64,54 @@ describe(findProjectRootAsync, () => {
expect(projectRoot).toBe('/app');
});
});
describe(findProjectDirAndVerifyProjectSetupAsync, () => {
it('throws if the CLI version is not satisfied', async () => {
vol.fromJSON(
{
'./eas.json': JSON.stringify({ cli: { version: '1.0.0' } }),
'./package.json': JSON.stringify({}),
},
'/app'
);
await expect(findProjectDirAndVerifyProjectSetupAsync({ cwd: '/app' })).rejects.toThrow(
new RegExp(
`^You are on eas-cli@${easCliVersion} which does not satisfy the CLI version constraint defined in eas.json \\(1.0.0\\)`,
's'
)
);
process.env.EAS_SKIP_CLI_VERSION_CHECK = 'true';
await expect(findProjectDirAndVerifyProjectSetupAsync({ cwd: '/app' })).resolves.not.toThrow();
delete process.env.EAS_SKIP_CLI_VERSION_CHECK;
await expect(findProjectDirAndVerifyProjectSetupAsync({ cwd: '/app' })).rejects.toThrow(
new RegExp(
`^You are on eas-cli@${easCliVersion} which does not satisfy the CLI version constraint defined in eas.json \\(1.0.0\\)`,
's'
)
);
});
it('does not throw if the CLI version is satisfied', async () => {
vol.fromJSON(
{
'./eas.json': JSON.stringify({ cli: { version: '>= 1.0.0' } }),
'./package.json': JSON.stringify({}),
},
'/app'
);
await expect(findProjectDirAndVerifyProjectSetupAsync({ cwd: '/app' })).resolves.not.toThrow();
});
it('does not throw if there is no version', async () => {
vol.fromJSON(
{
'./eas.json': JSON.stringify({}),
'./package.json': JSON.stringify({}),
},
'/app'
);
await expect(findProjectDirAndVerifyProjectSetupAsync({ cwd: '/app' })).resolves.not.toThrow();
});
});
@@ -2,6 +2,7 @@ import { EasJsonAccessor, EasJsonUtils } from '@expo/eas-json';
import * as PackageManagerUtils from '@expo/package-manager';
import chalk from 'chalk';
import fs from 'fs-extra';
import getenv from 'getenv';
import path from 'path';
import pkgDir from 'pkg-dir';
import semver from 'semver';
@@ -13,7 +14,14 @@ import { resolveVcsClient } from '../../../vcs';
async function applyCliConfigAsync(projectDir: string): Promise<void> {
const easJsonAccessor = EasJsonAccessor.fromProjectPath(projectDir);
const config = await EasJsonUtils.getCliConfigAsync(easJsonAccessor);
if (config?.version && !semver.satisfies(easCliVersion, config.version)) {
const shouldSkipVersionCheck = getenv.boolish('EAS_SKIP_CLI_VERSION_CHECK', false);
if (
!shouldSkipVersionCheck &&
config?.version &&
!semver.satisfies(easCliVersion, config.version)
) {
throw new Error(
`You are on eas-cli@${easCliVersion} which does not satisfy the CLI version constraint defined in eas.json (${
config.version
@@ -112,8 +120,12 @@ let ranEnsureEasCliIsNotInDependencies = false;
*
* @deprecated Should not be used outside of context functions.
*/
export async function findProjectDirAndVerifyProjectSetupAsync(): Promise<string> {
const projectDir = await findProjectRootAsync();
export async function findProjectDirAndVerifyProjectSetupAsync({
cwd,
}: {
cwd?: string;
} = {}): Promise<string> {
const projectDir = await findProjectRootAsync({ cwd });
await applyCliConfigAsync(projectDir);
if (!ranEnsureEasCliIsNotInDependencies) {
ranEnsureEasCliIsNotInDependencies = true;
@@ -180,7 +180,7 @@ export async function validateOrSetProjectIdAsync({
Log.warn('EAS project not configured.');
const getAccountNameForEASProjectSync = (exp: ExpoConfig, user: Actor): string => {
const getDefaultAccountNameForEASProject = (exp: ExpoConfig, user: Actor): string => {
if (exp.owner) {
return exp.owner;
}
@@ -191,7 +191,7 @@ export async function validateOrSetProjectIdAsync({
return user.username;
case 'Robot':
throw new Error(
'The "owner" manifest property is required when using robot users. See: https://docs.expo.dev/versions/latest/config/app/#owner'
'Must configure EAS project by running "eas init" before using a robot user to manage the project.'
);
}
};
@@ -199,7 +199,7 @@ export async function validateOrSetProjectIdAsync({
const projectId = await fetchOrCreateProjectIDForWriteToConfigWithConfirmationAsync(
graphqlClient,
{
accountName: getAccountNameForEASProjectSync(exp, actor),
accountName: getDefaultAccountNameForEASProject(exp, actor),
projectName: exp.slug,
},
{
@@ -0,0 +1,15 @@
import { WorkflowJobResult } from './types';
// This function is in a separate module for testing purposes
export async function fetchRawLogsForJobAsync(job: WorkflowJobResult): Promise<string | null> {
const firstLogFileUrl = job.turtleJobRun?.logFileUrls?.[0];
if (!firstLogFileUrl) {
return null;
}
const response = await fetch(firstLogFileUrl, {
method: 'GET',
});
const rawLogs = await response.text();
return rawLogs;
}
@@ -0,0 +1,324 @@
import { Choice } from 'prompts';
import { WorkflowJobResult, WorkflowLogs } from './types';
import {
choiceFromWorkflowJob,
choiceFromWorkflowRun,
choicesFromWorkflowLogs,
processLogsFromJobAsync,
processWorkflowRuns,
} from './utils';
import { AppQuery } from '../../graphql/queries/AppQuery';
import { WorkflowJobQuery } from '../../graphql/queries/WorkflowJobQuery';
import { WorkflowRunQuery } from '../../graphql/queries/WorkflowRunQuery';
import Log from '../../log';
import { promptAsync } from '../../prompts';
import { ExpoGraphqlClient } from '../context/contextUtils/createGraphqlClient';
/*
* State machine types and functions for moving between different workflow command states
*/
export enum WorkflowCommandSelectionStateValue {
WORKFLOW_RUN_SELECTION = 'WORKFLOW_RUN_SELECTION',
WORKFLOW_JOB_SELECTION = 'WORKFLOW_JOB_SELECTION',
WORKFLOW_STEP_SELECTION = 'WORKFLOW_STEP_SELECTION',
FINISH = 'FINISH',
ERROR = 'ERROR',
}
const workflowCommandSelectionAllowedStateTransitions: {
[key in WorkflowCommandSelectionStateValue]: WorkflowCommandSelectionStateValue[];
} = {
WORKFLOW_JOB_SELECTION: [
WorkflowCommandSelectionStateValue.WORKFLOW_STEP_SELECTION,
WorkflowCommandSelectionStateValue.WORKFLOW_RUN_SELECTION,
WorkflowCommandSelectionStateValue.ERROR,
WorkflowCommandSelectionStateValue.FINISH,
],
WORKFLOW_RUN_SELECTION: [
WorkflowCommandSelectionStateValue.WORKFLOW_JOB_SELECTION,
WorkflowCommandSelectionStateValue.ERROR,
],
WORKFLOW_STEP_SELECTION: [
WorkflowCommandSelectionStateValue.WORKFLOW_JOB_SELECTION,
WorkflowCommandSelectionStateValue.ERROR,
WorkflowCommandSelectionStateValue.FINISH,
],
ERROR: [WorkflowCommandSelectionStateValue.WORKFLOW_RUN_SELECTION],
FINISH: [],
};
export type WorkflowCommandSelectionState = {
graphqlClient: ExpoGraphqlClient;
projectId: string;
nonInteractive: boolean;
allSteps: boolean;
state: WorkflowCommandSelectionStateValue;
runId?: string;
jobId?: string;
step?: string;
job?: WorkflowJobResult;
logs?: WorkflowLogs | null;
message?: string;
};
export type WorkflowCommandSelectionStateParameters = Omit<
WorkflowCommandSelectionState,
'graphqlClient' | 'projectId' | 'state' | 'nonInteractive' | 'allSteps'
>;
export type WorkflowCommandSelectionAction = (
prevState: WorkflowCommandSelectionState
) => Promise<WorkflowCommandSelectionState>;
export function moveToNewWorkflowCommandSelectionState(
previousState: WorkflowCommandSelectionState,
newStateValue: WorkflowCommandSelectionStateValue,
parameters: WorkflowCommandSelectionStateParameters
): WorkflowCommandSelectionState {
if (
!workflowCommandSelectionAllowedStateTransitions[previousState.state].includes(newStateValue)
) {
const errorMessage = `Invalid state transition from ${
previousState.state
} to ${newStateValue}. Allowed transitions from ${
previousState.state
}: ${workflowCommandSelectionAllowedStateTransitions[previousState.state].join(', ')}`;
throw new Error(errorMessage);
}
return {
...previousState,
state: newStateValue,
...parameters,
};
}
export function moveToWorkflowRunSelectionState(
previousState: WorkflowCommandSelectionState,
params?: {
runId?: string | undefined;
}
): WorkflowCommandSelectionState {
return moveToNewWorkflowCommandSelectionState(
previousState,
WorkflowCommandSelectionStateValue.WORKFLOW_RUN_SELECTION,
{
runId: params?.runId,
jobId: undefined,
}
);
}
export function moveToWorkflowJobSelectionState(
previousState: WorkflowCommandSelectionState,
params: {
jobId?: string | undefined;
runId?: string | undefined;
}
): WorkflowCommandSelectionState {
return moveToNewWorkflowCommandSelectionState(
previousState,
WorkflowCommandSelectionStateValue.WORKFLOW_JOB_SELECTION,
params
);
}
export function moveToWorkflowStepSelectionState(
previousState: WorkflowCommandSelectionState,
params: {
job: WorkflowJobResult;
}
): WorkflowCommandSelectionState {
return moveToNewWorkflowCommandSelectionState(
previousState,
WorkflowCommandSelectionStateValue.WORKFLOW_STEP_SELECTION,
params
);
}
export function moveToWorkflowSelectionFinishedState(
previousState: WorkflowCommandSelectionState,
params: {
step: string;
logs: WorkflowLogs;
}
): WorkflowCommandSelectionState {
return moveToNewWorkflowCommandSelectionState(
previousState,
WorkflowCommandSelectionStateValue.FINISH,
params
);
}
export function moveToWorkflowSelectionErrorState(
previousState: WorkflowCommandSelectionState,
message: string
): WorkflowCommandSelectionState {
return moveToNewWorkflowCommandSelectionState(
previousState,
WorkflowCommandSelectionStateValue.ERROR,
{
message,
}
);
}
// eslint-disable-next-line async-protect/async-suffix
export const workflowRunSelectionAction: WorkflowCommandSelectionAction = async prevState => {
const { graphqlClient, projectId, runId, jobId, allSteps } = prevState;
Log.debug(
`workflowRunSelectionAction: runId = ${runId}, jobId = ${jobId}, allSteps = ${allSteps}`
);
if (runId) {
return moveToWorkflowJobSelectionState(prevState, { runId });
}
const runs = await AppQuery.byIdWorkflowRunsFilteredByStatusAsync(
graphqlClient,
projectId,
undefined,
20
);
if (runs.length === 0) {
return moveToWorkflowSelectionErrorState(prevState, 'No workflow runs found');
}
const processedRuns = processWorkflowRuns(runs);
const choices = processedRuns.map(run => choiceFromWorkflowRun(run));
const selectedId = (
await promptAsync({
type: 'select',
name: 'selectedRun',
message: 'Select a workflow run:',
choices,
})
).selectedRun;
return moveToWorkflowJobSelectionState(prevState, { runId: selectedId });
};
// eslint-disable-next-line async-protect/async-suffix
export const workflowJobSelectionAction: WorkflowCommandSelectionAction = async prevState => {
const { graphqlClient, runId, jobId, nonInteractive, allSteps } = prevState;
Log.debug(
`workflowJobSelectionAction: runId = ${runId}, jobId = ${jobId}, allSteps = ${allSteps}`
);
if (jobId) {
let workflowJobResult = undefined;
try {
workflowJobResult = await WorkflowJobQuery.byIdAsync(graphqlClient, jobId, {
useCache: false,
});
return moveToWorkflowStepSelectionState(prevState, { job: workflowJobResult });
} catch {}
if (nonInteractive && !workflowJobResult) {
return moveToWorkflowSelectionErrorState(
prevState,
'No workflow job found that matched the provided ID'
);
} else {
// The passed in ID may be a run ID, pass it back to run selection action
return moveToWorkflowRunSelectionState(prevState, { runId: jobId });
}
} else {
// No job ID was passed in
if (nonInteractive) {
return moveToWorkflowSelectionErrorState(
prevState,
'No workflow job ID provided in non-interactive mode'
);
} else if (!runId) {
// If no jobId or runId, we should go back to run selection
return moveToWorkflowRunSelectionState(prevState);
}
const workflowRunResult = await WorkflowRunQuery.withJobsByIdAsync(graphqlClient, runId, {
useCache: false,
});
if (!workflowRunResult) {
return moveToWorkflowSelectionErrorState(
prevState,
'No workflow run found that matched the provided ID'
);
}
const choices: Choice[] = [
...workflowRunResult.jobs.map((job, i) => choiceFromWorkflowJob(job, i)),
{
title: 'Go back and select a different workflow run',
value: -1,
},
];
const selectedJobIndex = (
await promptAsync({
type: 'select',
name: 'selectedJob',
message: 'Select a job:',
choices,
})
).selectedJob;
if (selectedJobIndex === -1) {
return moveToWorkflowRunSelectionState(prevState);
}
const selectedJob = workflowRunResult.jobs[selectedJobIndex] as WorkflowJobResult;
return moveToWorkflowStepSelectionState(prevState, { job: selectedJob });
}
};
// eslint-disable-next-line async-protect/async-suffix
export const workflowStepSelectionAction: WorkflowCommandSelectionAction = async prevState => {
const { job, allSteps } = prevState;
Log.debug(`workflowStepSelectionAction: job = ${job?.id}, allSteps = ${allSteps}`);
if (!job) {
return moveToWorkflowSelectionErrorState(prevState, 'No job found');
}
const logs = await processLogsFromJobAsync(job);
if (!logs) {
return moveToWorkflowSelectionErrorState(prevState, 'No logs found');
}
if (allSteps) {
return moveToWorkflowSelectionFinishedState(prevState, { step: '', logs });
}
const choices: Choice[] = [
...choicesFromWorkflowLogs(logs),
{
title: 'Go back and select a different workflow job',
value: 'go-back',
},
];
const selectedStep: string =
(
await promptAsync({
type: 'select',
name: 'selectedStep',
message: 'Select a step:',
choices,
})
).selectedStep ?? '';
if (selectedStep === 'go-back') {
return moveToWorkflowJobSelectionState(prevState, {
runId: job.workflowRun.id,
jobId: undefined,
});
}
return moveToWorkflowSelectionFinishedState(prevState, { step: selectedStep, logs });
};
export const executeWorkflowSelectionActionsAsync: WorkflowCommandSelectionAction =
async prevState => {
let currentState = prevState;
while (
currentState.state !== WorkflowCommandSelectionStateValue.FINISH &&
currentState.state !== WorkflowCommandSelectionStateValue.ERROR
) {
Log.debug(`${currentState.state}`);
switch (currentState.state) {
case WorkflowCommandSelectionStateValue.WORKFLOW_JOB_SELECTION:
currentState = await workflowJobSelectionAction(currentState);
break;
case WorkflowCommandSelectionStateValue.WORKFLOW_RUN_SELECTION:
currentState = await workflowRunSelectionAction(currentState);
break;
case WorkflowCommandSelectionStateValue.WORKFLOW_STEP_SELECTION:
currentState = await workflowStepSelectionAction(currentState);
break;
}
}
return currentState;
};
@@ -0,0 +1,46 @@
import { WorkflowJobByIdQuery, WorkflowRunByIdWithJobsQuery } from '../../graphql/generated';
/*
* Utility types for workflow commands
*/
export enum WorkflowTriggerType {
MANUAL = 'Manual',
GITHUB = 'GitHub',
SCHEDULED = 'Scheduled',
OTHER = 'Other',
}
export type WorkflowRunResult = {
id: string;
status: string;
gitCommitMessage: string | null;
gitCommitHash: string | null;
triggerType: WorkflowTriggerType;
trigger: string | null;
startedAt: string;
finishedAt: string;
workflowId: string;
workflowName: string | null;
workflowFileName: string;
};
export type WorkflowResult = {
id: string;
name?: string | null | undefined;
fileName: string;
createdAt: string;
updatedAt: string;
};
export type WorkflowJobResult =
| WorkflowRunByIdWithJobsQuery['workflowRuns']['byId']['jobs'][number]
| WorkflowJobByIdQuery['workflowJobs']['byId'];
export type WorkflowRunWithJobsResult = WorkflowRunResult & {
jobs: WorkflowJobResult[];
logs?: string;
};
export type WorkflowLogLine = { time: string; msg: string; result?: string; marker?: string };
export type WorkflowLogs = Map<string, WorkflowLogLine[]>;
@@ -0,0 +1,128 @@
import { fetchRawLogsForJobAsync } from './fetchLogs';
import {
WorkflowJobResult,
WorkflowLogLine,
WorkflowLogs,
WorkflowRunResult,
WorkflowTriggerType,
} from './types';
import {
WorkflowRunFragment,
WorkflowRunStatus,
WorkflowRunTriggerEventType,
} from '../../graphql/generated';
import { Choice } from '../../prompts';
export function computeTriggerInfoForWorkflowRun(run: WorkflowRunFragment): {
triggerType: WorkflowTriggerType;
trigger: string | null;
} {
let triggerType = WorkflowTriggerType.OTHER;
let trigger = '';
if (run.actor?.__typename === 'Robot') {
if (run.actor.firstName?.startsWith('GitHub App · ')) {
trigger = `${run.requestedGitRef ?? ''}@${run.gitCommitHash?.substring(0, 12) ?? ''}`;
}
} else if (run.actor?.__typename === 'User') {
trigger = run.actor.username;
}
switch (run.triggerEventType) {
case WorkflowRunTriggerEventType.Manual:
triggerType = WorkflowTriggerType.MANUAL;
break;
case WorkflowRunTriggerEventType.GithubPullRequestLabeled:
case WorkflowRunTriggerEventType.GithubPullRequestOpened:
case WorkflowRunTriggerEventType.GithubPullRequestReopened:
case WorkflowRunTriggerEventType.GithubPullRequestSynchronize:
case WorkflowRunTriggerEventType.GithubPush:
triggerType = WorkflowTriggerType.GITHUB;
break;
case WorkflowRunTriggerEventType.Schedule:
triggerType = WorkflowTriggerType.SCHEDULED;
trigger = run.triggeringSchedule ?? '';
break;
}
return { triggerType, trigger };
}
export function choiceFromWorkflowRun(run: WorkflowRunResult): Choice {
const titleArray = [
run.workflowFileName,
run.status,
run.startedAt,
run.triggerType,
run.trigger,
];
return {
title: titleArray.join(' - '),
value: run.id,
description: `ID: ${run.id}, Message: ${run.gitCommitMessage?.split('\n')[0] ?? ''}`,
};
}
export function choiceFromWorkflowJob(job: WorkflowJobResult, index: number): Choice {
return {
title: `${job.name} - ${job.status}`,
value: index,
description: `ID: ${job.id}`,
};
}
export function choicesFromWorkflowLogs(logs: WorkflowLogs): Choice[] {
return Array.from(logs.keys()).map(step => {
const logLines = logs.get(step);
const stepStatus =
logLines?.filter((line: WorkflowLogLine) => line.marker === 'end-step')[0]?.result ?? '';
return {
title: `${step} - ${stepStatus}`,
value: step,
};
});
}
export function processWorkflowRuns(runs: WorkflowRunFragment[]): WorkflowRunResult[] {
return runs.map(run => {
const finishedAt = run.status === WorkflowRunStatus.InProgress ? null : run.updatedAt;
const { triggerType, trigger } = computeTriggerInfoForWorkflowRun(run);
return {
id: run.id,
status: run.status,
gitCommitMessage: run.gitCommitMessage?.split('\n')[0] ?? null,
gitCommitHash: run.gitCommitHash ?? null,
startedAt: run.createdAt,
finishedAt,
triggerType,
trigger,
workflowId: run.workflow.id,
workflowName: run.workflow.name ?? null,
workflowFileName: run.workflow.fileName,
};
});
}
export async function processLogsFromJobAsync(
job: WorkflowJobResult
): Promise<WorkflowLogs | null> {
const rawLogs = await fetchRawLogsForJobAsync(job);
if (!rawLogs) {
return null;
}
const logs: WorkflowLogs = new Map();
const logKeys = new Set<string>();
rawLogs.split('\n').forEach(line => {
try {
const parsedLine = JSON.parse(line);
const { result, marker } = parsedLine;
const { buildStepDisplayName, buildStepInternalId, time, msg } = parsedLine;
const stepId = buildStepDisplayName ?? buildStepInternalId;
if (stepId) {
if (!logKeys.has(stepId)) {
logKeys.add(stepId);
logs.set(stepId, []);
}
logs.get(stepId)?.push({ time, msg, result, marker });
}
} catch {}
});
return logs;
}
@@ -1,4 +1,5 @@
import { Platform, Workflow } from '@expo/eas-build-job';
import { EasJson, EasJsonAccessor, EasJsonUtils } from '@expo/eas-json';
import { Flags } from '@oclif/core';
import chalk from 'chalk';
@@ -66,8 +67,13 @@ export default class BuildConfigure extends EasCommand {
nonInteractive: false,
vcsClient,
});
if (didCreateEasJson && isUsingEASUpdate(exp, projectId)) {
await ensureEASUpdateIsConfiguredInEasJsonAsync(projectDir);
if (didCreateEasJson) {
const easJsonAccessor = EasJsonAccessor.fromProjectPath(projectDir);
const easJsonCliConfig: EasJson['cli'] =
(await EasJsonUtils.getCliConfigAsync(easJsonAccessor)) ?? {};
if (isUsingEASUpdate(exp, projectId, easJsonCliConfig.updateManifestHostOverride ?? null)) {
await ensureEASUpdateIsConfiguredInEasJsonAsync(projectDir);
}
}
// configure expo-updates
+6 -2
View File
@@ -13,6 +13,7 @@ import { downloadAndRunAsync, runBuildAndSubmitAsync } from '../../build/runBuil
import { ensureRepoIsCleanAsync } from '../../build/utils/repository';
import EasCommand from '../../commandUtils/EasCommand';
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
import { createFingerprintAsync } from '../../fingerprint/cli';
import { BuildFragment, BuildStatus, DistributionType } from '../../graphql/generated';
import { BuildQuery } from '../../graphql/queries/BuildQuery';
import { toAppPlatform } from '../../graphql/types/AppPlatform';
@@ -21,7 +22,6 @@ import { RequestedPlatform } from '../../platform';
import { resolveWorkflowAsync } from '../../project/workflow';
import { confirmAsync, promptAsync } from '../../prompts';
import { expoCommandAsync } from '../../utils/expoCli';
import { createFingerprintAsync } from '../../utils/fingerprintCli';
import { ProfileData, getProfilesAsync } from '../../utils/profiles';
import { Client } from '../../vcs/vcs';
@@ -115,6 +115,7 @@ export default class BuildDev extends EasCommand {
projectId,
platform,
fingerprint,
profile: buildProfile.profileName,
});
if (builds.length !== 0) {
const build = builds[0];
@@ -137,6 +138,7 @@ export default class BuildDev extends EasCommand {
graphqlClient,
projectId,
platform,
profile: buildProfile.profileName,
});
if (
previousBuildsForSelectedProfile.length > 0 &&
@@ -164,7 +166,6 @@ export default class BuildDev extends EasCommand {
json: false,
autoSubmit: false,
localBuildOptions: {},
repack: false,
profile: flags.profile ?? DEFAULT_EAS_BUILD_RUN_PROFILE_NAME,
},
actor,
@@ -308,11 +309,13 @@ export default class BuildDev extends EasCommand {
projectId,
platform,
fingerprint,
profile,
}: {
graphqlClient: ExpoGraphqlClient;
projectId: string;
platform: Platform;
fingerprint?: { hash: string };
profile?: string;
}): Promise<BuildFragment[]> {
return await BuildQuery.viewBuildsOnAppAsync(graphqlClient, {
appId: projectId,
@@ -323,6 +326,7 @@ export default class BuildDev extends EasCommand {
simulator: platform === Platform.IOS ? true : undefined,
distribution: platform === Platform.ANDROID ? DistributionType.Internal : undefined,
developmentClient: true,
buildProfile: profile,
},
offset: 0,
limit: 1,
@@ -0,0 +1,166 @@
import { Platform } from '@expo/eas-build-job';
import { Errors, Flags } from '@oclif/core';
import chalk from 'chalk';
import { pathExists } from 'fs-extra';
import EasCommand from '../../commandUtils/EasCommand';
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
import { EasNonInteractiveAndJsonFlags } from '../../commandUtils/flags';
import { AppPlatform, BuildFragment, BuildStatus, DistributionType } from '../../graphql/generated';
import { BuildQuery } from '../../graphql/queries/BuildQuery';
import { toAppPlatform } from '../../graphql/types/AppPlatform';
import Log from '../../log';
import { promptAsync } from '../../prompts';
import { getEasBuildRunCachedAppPath } from '../../run/run';
import { downloadAndMaybeExtractAppAsync } from '../../utils/download';
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
export default class Download extends EasCommand {
static override description = 'download simulator/emulator builds for a given fingerprint hash';
static override flags = {
fingerprint: Flags.string({
description: 'Fingerprint hash of the build to download',
required: true,
}),
platform: Flags.enum<Platform.IOS | Platform.ANDROID>({
char: 'p',
options: [Platform.IOS, Platform.ANDROID],
}),
'dev-client': Flags.boolean({
description: 'Filter only dev-client builds.',
allowNo: true,
}),
...EasNonInteractiveAndJsonFlags,
};
static override contextDefinition = {
...this.ContextOptions.LoggedIn,
...this.ContextOptions.ProjectId,
};
async runAsync(): Promise<void> {
const {
flags: {
json: jsonFlag,
platform,
fingerprint,
'dev-client': developmentClient,
'non-interactive': nonInteractive,
},
} = await this.parse(Download);
const {
loggedIn: { graphqlClient },
projectId,
} = await this.getContextAsync(Download, {
nonInteractive,
});
if (jsonFlag) {
enableJsonOutput();
}
const selectedPlatform = await resolvePlatformAsync({ nonInteractive, platform });
const build = await this.getBuildAsync({
graphqlClient,
projectId,
platform: selectedPlatform,
fingerprintHash: fingerprint,
developmentClient,
});
const buildArtifactPath = await this.getPathToBuildArtifactAsync(build, selectedPlatform);
if (jsonFlag) {
const jsonResults = { path: buildArtifactPath };
printJsonOnlyOutput(jsonResults);
} else {
Log.log(`Build downloaded to ${chalk.bold(buildArtifactPath)}`);
}
}
private async getBuildAsync({
graphqlClient,
projectId,
platform,
fingerprintHash,
developmentClient,
}: {
graphqlClient: ExpoGraphqlClient;
projectId: string;
platform: AppPlatform;
fingerprintHash: string;
developmentClient: boolean;
}): Promise<BuildFragment> {
const builds = await BuildQuery.viewBuildsOnAppAsync(graphqlClient, {
appId: projectId,
filter: {
platform,
fingerprintHash,
status: BuildStatus.Finished,
simulator: platform === AppPlatform.Ios ? true : undefined,
distribution: platform === AppPlatform.Android ? DistributionType.Internal : undefined,
developmentClient,
},
offset: 0,
limit: 1,
});
if (builds.length === 0) {
throw new Errors.CLIError(
`No builds available for ${platform} with fingerprint hash ${fingerprintHash}`
);
}
Log.succeed(`🎯 Found successful build with matching fingerprint on EAS servers.`);
return builds[0];
}
async getPathToBuildArtifactAsync(build: BuildFragment, platform: AppPlatform): Promise<string> {
const cachedBuildArtifactPath = getEasBuildRunCachedAppPath(
build.project.id,
build.id,
platform
);
if (await pathExists(cachedBuildArtifactPath)) {
Log.newLine();
Log.log(`Using cached build...`);
return cachedBuildArtifactPath;
}
if (!build.artifacts?.applicationArchiveUrl) {
throw new Error('Build does not have an application archive url');
}
return await downloadAndMaybeExtractAppAsync(
build.artifacts.applicationArchiveUrl,
platform,
cachedBuildArtifactPath
);
}
}
async function resolvePlatformAsync({
nonInteractive,
platform,
}: {
nonInteractive: boolean;
platform?: Platform;
}): Promise<AppPlatform> {
if (nonInteractive && !platform) {
throw new Error('Platform must be provided in non-interactive mode');
}
if (platform) {
return toAppPlatform(platform);
}
const { selectedPlatform } = await promptAsync({
type: 'select',
message: 'Select platform',
name: 'selectedPlatform',
choices: [
{ title: 'Android', value: AppPlatform.Android },
{ title: 'iOS', value: AppPlatform.Ios },
],
});
return selectedPlatform;
}
+71 -8
View File
@@ -1,5 +1,5 @@
import { Platform } from '@expo/eas-build-job';
import { EasJsonAccessor, EasJsonUtils, ResourceClass } from '@expo/eas-json';
import { BuildProfile, EasJsonAccessor, EasJsonUtils, ResourceClass } from '@expo/eas-json';
import { LoggerLevel } from '@expo/logger';
import { Errors, Flags } from '@oclif/core';
import chalk from 'chalk';
@@ -17,6 +17,7 @@ import { RequestedPlatform, selectRequestedPlatformAsync } from '../../platform'
import { selectAsync } from '../../prompts';
import uniq from '../../utils/expodash/uniq';
import { enableJsonOutput } from '../../utils/json';
import { ProfileData } from '../../utils/profiles';
import { maybeWarnAboutEasOutagesAsync } from '../../utils/statuspageService';
interface RawBuildFlags {
@@ -36,7 +37,8 @@ interface RawBuildFlags {
message?: string;
'build-logger-level'?: LoggerLevel;
'freeze-credentials': boolean;
repack: boolean;
'verbose-logs'?: boolean;
'what-to-test'?: string;
}
export default class Build extends EasCommand {
@@ -90,6 +92,10 @@ export default class Build extends EasCommand {
helpValue: 'PROFILE_NAME',
exclusive: ['auto-submit'],
}),
'what-to-test': Flags.string({
description:
'Specify the "What to Test" information for the build in TestFlight (iOS-only). To be used with the `auto-submit` flag',
}),
'resource-class': Flags.enum({
options: Object.values(ResourceClass),
hidden: true,
@@ -112,10 +118,9 @@ export default class Build extends EasCommand {
default: false,
description: 'Prevent the build from updating credentials in non-interactive mode',
}),
repack: Flags.boolean({
'verbose-logs': Flags.boolean({
default: false,
hidden: true,
description: 'Use the golden dev client build repack flow as it works for onboarding',
description: 'Use verbose logs for the build process',
}),
...EasNonInteractiveAndJsonFlags,
};
@@ -160,7 +165,7 @@ export default class Build extends EasCommand {
const flagsWithPlatform = await this.ensurePlatformSelectedAsync(flags);
await runBuildAndSubmitAsync({
const { buildProfiles } = await runBuildAndSubmitAsync({
graphqlClient,
analytics,
vcsClient,
@@ -169,6 +174,11 @@ export default class Build extends EasCommand {
actor,
getDynamicPrivateProjectConfigAsync,
});
this.maybeSuggestUsingEasBuildDev({
buildProfiles,
nonInteractive: flags.nonInteractive,
});
}
private sanitizeFlags(
@@ -184,6 +194,13 @@ export default class Build extends EasCommand {
if (flags.json && !nonInteractive) {
Errors.error('--json is allowed only when building in non-interactive mode', { exit: 1 });
}
const autoSubmit = flags['auto-submit'] || flags['auto-submit-with-profile'] !== undefined;
if (flags['what-to-test'] && !autoSubmit) {
Errors.error(
'The --what-to-test flag can only be used with the --auto-submit or --auto-submit-with-profile flag.',
{ exit: 1 }
);
}
const requestedPlatform =
flags.platform &&
@@ -226,13 +243,14 @@ export default class Build extends EasCommand {
wait: flags['wait'],
clearCache: flags['clear-cache'],
json: flags['json'],
autoSubmit: flags['auto-submit'] || flags['auto-submit-with-profile'] !== undefined,
autoSubmit,
submitProfile: flags['auto-submit-with-profile'] ?? profile,
resourceClass: flags['resource-class'],
message,
buildLoggerLevel: flags['build-logger-level'],
freezeCredentials: flags['freeze-credentials'],
repack: flags.repack,
isVerboseLoggingEnabled: flags['verbose-logs'],
whatToTest: flags['what-to-test'],
};
}
@@ -268,6 +286,51 @@ export default class Build extends EasCommand {
requestedPlatform,
};
}
private maybeSuggestUsingEasBuildDev({
buildProfiles,
nonInteractive,
}: {
buildProfiles: ProfileData<'build'>[] | undefined;
nonInteractive: boolean;
}): void {
// suggest using eas build:dev if the build configuration results in simulator/emulator dev client build
if (
!nonInteractive &&
!process.env.CI &&
buildProfiles?.some(({ profile, platform }) => {
if (profile.developmentClient !== true) {
return false;
}
if (profile.distribution !== 'internal') {
return false;
}
if (platform === Platform.IOS) {
const iosProfile = profile as BuildProfile<Platform.IOS>;
if (iosProfile.simulator !== true && iosProfile.withoutCredentials !== true) {
return false;
}
} else {
const androidProfile = profile as BuildProfile<Platform.ANDROID>;
if (
androidProfile.distribution !== 'internal' &&
androidProfile.withoutCredentials !== true
) {
return false;
}
}
return true;
})
) {
Log.newLine();
Log.log(
`🔎 TIP: You are using a build configuration that could benefit from using ${chalk.bold(
'eas build:dev'
)} command. Run it to install and run cached development build, or create a new one if a compatible build doesn't exist yet.`
);
}
}
}
export async function handleDeprecatedEasJsonAsync(
@@ -121,7 +121,6 @@ export default class BuildInspect extends EasCommand {
workingdir: tmpWorkingdir,
artifactsDir: path.join(tmpWorkingdir, 'artifacts'),
},
repack: false,
},
actor,
getDynamicPrivateProjectConfigAsync,
@@ -93,7 +93,6 @@ export default class BuildInternal extends EasCommand {
localBuildMode: LocalBuildMode.INTERNAL,
},
submitProfile: flags['auto-submit-with-profile'] ?? flags.profile,
repack: false,
},
actor,
getDynamicPrivateProjectConfigAsync,
@@ -16,7 +16,6 @@ import { toggleConfirmAsync } from '../../prompts';
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
export default class ChannelDelete extends EasCommand {
static override hidden = true;
static override description = 'Delete a channel';
static override args = [
@@ -52,7 +51,7 @@ export default class ChannelDelete extends EasCommand {
let channelId, channelName;
if (nameArg) {
const { id, name } = await ChannelQuery.viewUpdateChannelAsync(graphqlClient, {
const { id, name } = await ChannelQuery.viewUpdateChannelBasicInfoAsync(graphqlClient, {
appId: projectId,
channelName: nameArg,
});
@@ -94,7 +94,7 @@ export default class ChannelEdit extends EasCommand {
}
const existingChannel = args.name
? await ChannelQuery.viewUpdateChannelAsync(graphqlClient, {
? await ChannelQuery.viewUpdateChannelBasicInfoAsync(graphqlClient, {
appId: projectId,
channelName: args.name,
})
@@ -86,7 +86,7 @@ export default class ChannelPause extends EasCommand {
}
const existingChannel = args.name
? await ChannelQuery.viewUpdateChannelAsync(graphqlClient, {
? await ChannelQuery.viewUpdateChannelBasicInfoAsync(graphqlClient, {
appId: projectId,
channelName: args.name,
})
@@ -86,7 +86,7 @@ export default class ChannelResume extends EasCommand {
}
const existingChannel = args.name
? await ChannelQuery.viewUpdateChannelAsync(graphqlClient, {
? await ChannelQuery.viewUpdateChannelBasicInfoAsync(graphqlClient, {
appId: projectId,
channelName: args.name,
})
@@ -0,0 +1,142 @@
import chalk from 'chalk';
import EasCommand from '../../../commandUtils/EasCommand';
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
import { EasNonInteractiveAndJsonFlags } from '../../../commandUtils/flags';
import Log from '../../../log';
import { Ora, ora } from '../../../ora';
import { toggleConfirmAsync } from '../../../prompts';
import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json';
import {
deleteWorkerDeploymentAliasAsync,
selectWorkerDeploymentAliasOnAppAsync,
} from '../../../worker/deployment';
interface DeployAliasDeleteFlags {
nonInteractive: boolean;
json: boolean;
}
export default class WorkerAliasDelete extends EasCommand {
static override description = 'Delete deployment aliases.';
static override aliases = ['worker:alias:delete'];
static override state = 'preview';
static override args = [{ name: 'ALIAS_NAME' }];
static override flags = {
...EasNonInteractiveAndJsonFlags,
};
static override contextDefinition = {
...this.ContextOptions.DynamicProjectConfig,
...this.ContextOptions.ProjectDir,
...this.ContextOptions.LoggedIn,
};
override async runAsync(): Promise<void> {
const {
args: { ALIAS_NAME: aliasNameFromArg },
flags: rawFlags,
} = await this.parse(WorkerAliasDelete);
const flags = this.sanitizeFlags(rawFlags);
if (flags.json) {
enableJsonOutput();
}
Log.warn('EAS Hosting is still in preview and subject to changes.');
const {
getDynamicPrivateProjectConfigAsync,
loggedIn: { graphqlClient },
} = await this.getContextAsync(WorkerAliasDelete, {
nonInteractive: true,
withServerSideEnvironment: null,
});
const { projectId } = await getDynamicPrivateProjectConfigAsync();
const aliasName = await resolveDeploymentAliasAsync(
flags,
graphqlClient,
projectId,
aliasNameFromArg
);
const isProduction = !aliasName;
if (!flags.nonInteractive) {
Log.addNewLineIfNone();
Log.warn(
isProduction
? `You are about to delete your production alias`
: `You are about to delete alias: "${aliasName}"`
);
Log.newLine();
const confirmed = await toggleConfirmAsync({ message: 'Are you sure you wish to proceed?' });
if (!confirmed) {
Log.log('Aborted.');
return;
}
}
let progress: null | Ora = null;
let deleteResult: null | Awaited<ReturnType<typeof deleteWorkerDeploymentAliasAsync>> = null;
try {
progress = ora(chalk`Deleting alias {bold ${aliasName ?? 'production'}}`).start();
deleteResult = await deleteWorkerDeploymentAliasAsync({
graphqlClient,
appId: projectId,
aliasName,
});
progress.text = chalk`Deleted alias {bold ${aliasName ?? 'production'}}`;
} catch (error: any) {
progress?.fail(chalk`Failed to delete alias {bold ${aliasName ?? 'production'}}`);
throw error;
}
progress?.succeed(chalk`Deleted alias {bold ${aliasName ?? 'production'}}`);
if (flags.json) {
printJsonOnlyOutput({
aliasName: deleteResult.aliasName,
id: deleteResult.id,
});
}
}
private sanitizeFlags(flags: any): DeployAliasDeleteFlags {
return {
nonInteractive: flags['non-interactive'],
json: flags['json'],
};
}
}
async function resolveDeploymentAliasAsync(
flags: DeployAliasDeleteFlags,
graphqlClient: ExpoGraphqlClient,
projectId: string,
aliasNameFromArg?: string
): Promise<string> {
if (aliasNameFromArg?.trim()) {
return aliasNameFromArg.trim().toLowerCase();
}
if (flags.nonInteractive) {
throw new Error('Alias name must be provided in non-interactive mode');
}
const alias = await selectWorkerDeploymentAliasOnAppAsync({
graphqlClient,
appId: projectId,
selectTitle: 'alias to delete',
});
if (!alias) {
throw new Error('No aliases found for this project, create aliases with "eas deploy:alias"');
}
return alias.aliasName;
}
@@ -1,20 +1,23 @@
import { Flags } from '@oclif/core';
import chalk from 'chalk';
import EasCommand from '../../commandUtils/EasCommand';
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
import { EasNonInteractiveAndJsonFlags } from '../../commandUtils/flags';
import { WorkerDeploymentAliasFragment } from '../../graphql/generated';
import Log from '../../log';
import { Ora, ora } from '../../ora';
import { promptAsync } from '../../prompts';
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
import EasCommand from '../../../commandUtils/EasCommand';
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
import { EasNonInteractiveAndJsonFlags } from '../../../commandUtils/flags';
import { WorkerDeploymentAliasFragment } from '../../../graphql/generated';
import Log from '../../../log';
import { Ora, ora } from '../../../ora';
import { promptAsync } from '../../../prompts';
import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json';
import {
assignWorkerDeploymentAliasAsync,
assignWorkerDeploymentProductionAsync,
selectWorkerDeploymentOnAppAsync,
} from '../../worker/deployment';
import { formatWorkerDeploymentJson, formatWorkerDeploymentTable } from '../../worker/utils/logs';
} from '../../../worker/deployment';
import {
formatWorkerDeploymentJson,
formatWorkerDeploymentTable,
} from '../../../worker/utils/logs';
interface DeployAliasFlags {
nonInteractive: boolean;
@@ -0,0 +1,114 @@
import chalk from 'chalk';
import EasCommand from '../../commandUtils/EasCommand';
import { EasNonInteractiveAndJsonFlags } from '../../commandUtils/flags';
import Log from '../../log';
import { Ora, ora } from '../../ora';
import { toggleConfirmAsync } from '../../prompts';
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
import { deleteWorkerDeploymentAsync } from '../../worker/deployment';
interface DeployDeleteFlags {
nonInteractive: boolean;
json: boolean;
}
interface RawDeployDeleteFlags {
'non-interactive': boolean;
json: boolean;
}
export default class WorkerDelete extends EasCommand {
static override description = 'Delete a deployment.';
static override aliases = ['worker:delete'];
static override state = 'preview';
static override args = [{ name: 'DEPLOYMENT_ID' }];
static override flags = {
...EasNonInteractiveAndJsonFlags,
};
static override contextDefinition = {
...this.ContextOptions.DynamicProjectConfig,
...this.ContextOptions.ProjectDir,
...this.ContextOptions.LoggedIn,
};
override async runAsync(): Promise<void> {
const {
args: { DEPLOYMENT_ID: deploymentIdFromArg },
flags: rawFlags,
} = await this.parse(WorkerDelete);
const flags = this.sanitizeFlags(rawFlags);
if (flags.json) {
enableJsonOutput();
}
Log.warn('EAS Hosting is still in preview and subject to changes.');
const {
getDynamicPrivateProjectConfigAsync,
loggedIn: { graphqlClient },
} = await this.getContextAsync(WorkerDelete, {
nonInteractive: true,
withServerSideEnvironment: null,
});
const { projectId } = await getDynamicPrivateProjectConfigAsync();
if (!deploymentIdFromArg) {
if (flags.nonInteractive) {
throw new Error('Deployment ID must be provided in non-interactive mode');
}
throw new Error('Deployment ID is required');
}
if (!flags.nonInteractive) {
Log.addNewLineIfNone();
Log.warn(
`You are about to permanently delete deployment with ID: "${deploymentIdFromArg}"` +
`\nThis action is irreversible.`
);
Log.newLine();
const confirmed = await toggleConfirmAsync({ message: 'Are you sure you wish to proceed?' });
if (!confirmed) {
Log.log('Aborted.');
return;
}
}
let progress: Ora | null = null;
let deleteResult: null | Awaited<ReturnType<typeof deleteWorkerDeploymentAsync>> = null;
try {
progress = ora(chalk`Deleting deployment {bold ${deploymentIdFromArg}}`).start();
deleteResult = await deleteWorkerDeploymentAsync({
graphqlClient,
appId: projectId,
deploymentIdentifier: deploymentIdFromArg,
});
progress.text = chalk`Deleted deployment {bold ${deploymentIdFromArg}}`;
} catch (error: any) {
progress?.fail(chalk`Failed to delete deployment {bold ${deploymentIdFromArg}}`);
throw error;
}
progress?.succeed(chalk`Deleted deployment {bold ${deploymentIdFromArg}}`);
if (flags.json) {
printJsonOnlyOutput({
deploymentId: deleteResult.deploymentIdentifier,
id: deleteResult.id,
});
}
}
private sanitizeFlags(flags: RawDeployDeleteFlags): DeployDeleteFlags {
return {
nonInteractive: flags['non-interactive'],
json: flags['json'],
};
}
}
+105 -59
View File
@@ -1,16 +1,18 @@
import { format as formatTimeAgo } from '@expo/timeago.js';
import { Flags } from '@oclif/core';
import { CombinedError } from '@urql/core';
import chalk from 'chalk';
import fs from 'node:fs';
import * as path from 'node:path';
import { getHostingDeploymentsUrl } from '../../build/utils/url';
import EasCommand from '../../commandUtils/EasCommand';
import { EASEnvironmentFlag, EasNonInteractiveAndJsonFlags } from '../../commandUtils/flags';
import { EnvironmentVariableEnvironment } from '../../graphql/generated';
import Log from '../../log';
import Log, { link } from '../../log';
import { ora } from '../../ora';
import { getOwnerAccountForProjectIdAsync } from '../../project/projectUtils';
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
import { createProgressTracker } from '../../utils/progress';
import * as WorkerAssets from '../../worker/assets';
import {
assignWorkerDeploymentAliasAsync,
@@ -18,9 +20,10 @@ import {
getSignedDeploymentUrlAsync,
} from '../../worker/deployment';
import {
UploadParams,
UploadPayload,
batchUploadAsync,
callUploadApiAsync,
createProgressBar,
uploadAsync,
} from '../../worker/upload';
import {
@@ -29,6 +32,8 @@ import {
getDeploymentUrlFromFullName,
} from '../../worker/utils/logs';
const MAX_UPLOAD_SIZE = 5e8; // 500MB
const isDirectory = (directoryPath: string): Promise<boolean> =>
fs.promises
.stat(directoryPath)
@@ -57,15 +62,20 @@ interface RawDeployFlags {
'dry-run': boolean;
}
interface UploadAssetBatchInstruction {
sha512: string[];
}
interface DeployInProgressParams {
id: string;
fullName: string;
baseURL: string;
token: string;
upload?: UploadAssetBatchInstruction[];
}
export default class WorkerDeploy extends EasCommand {
static override description = 'Deploy your Expo Router web build and API Routes.';
static override description = 'deploy your Expo Router web build and API Routes';
static override aliases = ['worker:deploy'];
static override usage = [chalk`deploy {dim [options]}`, `deploy --prod`];
static override state = 'preview';
@@ -120,7 +130,10 @@ export default class WorkerDeploy extends EasCommand {
} = await this.getContextAsync(WorkerDeploy, { ...flags, withServerSideEnvironment: null });
const projectDist = await resolveExportedProjectAsync(flags, projectDir);
const { projectId } = await getDynamicPrivateProjectConfigAsync();
const { projectId, exp } = await getDynamicPrivateProjectConfigAsync();
const projectName = exp.slug;
const accountName = (await getOwnerAccountForProjectIdAsync(graphqlClient, projectId)).name;
logExportedProjectInfo(projectDist);
@@ -156,14 +169,14 @@ export default class WorkerDeploy extends EasCommand {
tarPath: string,
uploadUrl: string
): Promise<DeployInProgressParams> {
const { response } = await uploadAsync({
url: uploadUrl,
filePath: tarPath,
compress: false,
headers: {
accept: 'application/json',
const payload = { filePath: tarPath };
const { response } = await uploadAsync(
{
baseURL: uploadUrl,
method: 'POST',
},
});
payload
);
if (response.status === 413) {
throw new Error(
'Upload failed! (Payload too large)\n' +
@@ -179,73 +192,79 @@ export default class WorkerDeploy extends EasCommand {
if (!json.success || !json.result || typeof json.result !== 'object') {
throw new Error(json.message ? `Upload failed: ${json.message}` : 'Upload failed!');
}
const { id, fullName, token } = json.result;
const { id, fullName, token, upload } = json.result;
if (typeof token !== 'string') {
throw new Error('Upload failed: API failed to return a deployment token');
} else if (typeof id !== 'string') {
throw new Error('Upload failed: API failed to return a deployment identifier');
} else if (typeof fullName !== 'string') {
throw new Error('Upload failed: API failed to return a script name');
} else if (!Array.isArray(upload) && upload !== undefined) {
throw new Error('Upload failed: API returned invalid asset upload instructions');
}
const baseURL = new URL('/', uploadUrl).toString();
return { id, fullName, baseURL, token };
return { id, fullName, baseURL, token, upload };
}
}
async function uploadAssetsAsync(
assetMap: WorkerAssets.AssetMap,
assetFiles: WorkerAssets.AssetFileEntry[],
deployParams: DeployInProgressParams
): Promise<void> {
const uploadParams: UploadParams[] = [];
const assetPath = projectDist.type === 'server' ? projectDist.clientPath : projectDist.path;
if (!assetPath) {
return;
const baseURL = new URL('/asset/', deployParams.baseURL);
const uploadInit = { baseURL, method: 'POST' };
uploadInit.baseURL.searchParams.set('token', deployParams.token);
const uploadPayloads: UploadPayload[] = [];
if (deployParams.upload) {
const assetsBySHA512 = assetFiles.reduce((map, asset) => {
map.set(asset.sha512, asset);
return map;
}, new Map<string, WorkerAssets.AssetFileEntry>());
const payloads = deployParams.upload
.map(instruction =>
instruction.sha512.map(sha512 => {
const asset = assetsBySHA512.get(sha512);
if (!asset) {
// NOTE(@kitten): This should never happen
throw new Error(
`Uploading assets failed: API instructed us to upload an asset that does not exist`
);
}
return asset;
})
)
.filter(assets => assets && assets.length > 0)
.map(assets => (assets.length > 1 ? { multipart: assets } : { asset: assets[0] }));
uploadPayloads.push(...payloads);
} else {
// NOTE(@kitten): Legacy format which uploads assets one-by-one
uploadPayloads.push(...assetFiles.map(asset => ({ asset })));
}
for await (const asset of WorkerAssets.listAssetMapFilesAsync(assetPath, assetMap)) {
const uploadURL = new URL(`/asset/${asset.sha512}`, deployParams.baseURL);
uploadURL.searchParams.set('token', deployParams.token);
uploadParams.push({ url: uploadURL.toString(), filePath: asset.path });
}
const progress = {
total: uploadParams.length,
pending: 0,
percent: 0,
transferred: 0,
};
const updateProgress = createProgressTracker({
total: progress.total,
message(ratio) {
const percent = `${Math.floor(ratio * 100)}`;
const details = chalk.dim(
`(${progress.pending} Pending, ${progress.transferred} Completed, ${progress.total} Total)`
);
return `Uploading assets: ${percent.padStart(3)}% ${details}`;
},
completedMessage: 'Uploaded assets',
});
const progressTotal = uploadPayloads.reduce(
(acc, payload) => acc + ('multipart' in payload ? payload.multipart.length : 1),
0
);
const progressTracker = createProgressBar(`Uploading ${progressTotal} assets`);
try {
for await (const signal of batchUploadAsync(uploadParams)) {
if ('response' in signal) {
progress.pending--;
progress.percent = ++progress.transferred / progress.total;
} else {
progress.pending++;
}
updateProgress({ progress });
for await (const signal of batchUploadAsync(
uploadInit,
uploadPayloads,
progressTracker.update
)) {
progressTracker.update(signal.progress);
}
} catch (error: any) {
updateProgress({ isComplete: true, error });
progressTracker.stop();
throw error;
} finally {
progressTracker.stop();
}
updateProgress({ isComplete: true });
}
let assetMap: WorkerAssets.AssetMap;
let tarPath: string;
let assetFiles: WorkerAssets.AssetFileEntry[];
let deployResult: DeployInProgressParams;
let progress = ora('Preparing project').start();
@@ -265,12 +284,13 @@ export default class WorkerDeploy extends EasCommand {
manifestResult.conflictingVariableNames.join(' ')
);
}
assetMap = await WorkerAssets.createAssetMapAsync(
projectDist.type === 'server' ? projectDist.clientPath : projectDist.path
assetFiles = await WorkerAssets.collectAssetsAsync(
projectDist.type === 'server' ? projectDist.clientPath : projectDist.path,
{ maxFileSize: MAX_UPLOAD_SIZE }
);
tarPath = await WorkerAssets.packFilesIterableAsync(
emitWorkerTarballAsync({
assetMap,
assetMap: WorkerAssets.assetsToAssetsMap(assetFiles),
manifest: manifestResult.manifest,
})
);
@@ -299,10 +319,36 @@ export default class WorkerDeploy extends EasCommand {
progress.succeed('Created deployment');
} catch (error: any) {
progress.fail('Failed to create deployment');
if (
flags.isProduction &&
error instanceof CombinedError &&
error.graphQLErrors.some(err => {
return (
err.extensions?.errorCode === 'UNAUTHORIZED_ERROR' &&
err.message.includes('AppDevDomainNameEntity') &&
err.message.includes('CREATE')
);
})
) {
throw new Error(
`You have specified the new deployment should be a production deployment, but a production domain has not been set up yet for this app.\n\nRun ${chalk.bold(
'eas deploy --prod'
)} as an app admin on your machine or promote an existing deployment to production on the ${link(
getHostingDeploymentsUrl(accountName, projectName),
{
dim: false,
text: 'Hosting Dashboard',
fallback: `Hosting Dashboard (${getHostingDeploymentsUrl(accountName, projectName)})`,
}
)} to set up a production domain and then try again.`
);
}
throw error;
}
await uploadAssetsAsync(assetMap, deployResult);
await uploadAssetsAsync(assetFiles, deployResult);
await finalizeDeployAsync(deployResult);
let deploymentAlias: null | Awaited<ReturnType<typeof assignWorkerDeploymentAliasAsync>> = null;
@@ -289,7 +289,7 @@ describe(EnvCreate, () => {
});
});
it('creates an account-wide variable and links it', async () => {
it('creates an account-wide variable', async () => {
const command = new EnvCreate(
[
'--name',
@@ -302,7 +302,6 @@ describe(EnvCreate, () => {
'development',
'--scope',
'account',
'--link',
],
mockConfig
);
@@ -330,18 +329,6 @@ describe(EnvCreate, () => {
},
testAccountId
);
expect(EnvironmentVariableMutation.linkSharedEnvironmentVariableAsync).toHaveBeenCalledWith(
graphqlClient,
'testId',
testProjectId,
EnvironmentVariableEnvironment.Production
);
expect(EnvironmentVariableMutation.linkSharedEnvironmentVariableAsync).toHaveBeenCalledWith(
graphqlClient,
'testId',
testProjectId,
EnvironmentVariableEnvironment.Development
);
});
it('prompts for missing arguments', async () => {
@@ -1,271 +0,0 @@
import { Config } from '@oclif/core';
import chalk from 'chalk';
import { getMockAppFragment } from '../../../__tests__/commands/utils';
import {
EnvironmentVariableEnvironment,
EnvironmentVariableScope,
} from '../../../graphql/generated';
import { EnvironmentVariableMutation } from '../../../graphql/mutations/EnvironmentVariableMutation';
import { AppQuery } from '../../../graphql/queries/AppQuery';
import { EnvironmentVariablesQuery } from '../../../graphql/queries/EnvironmentVariablesQuery';
import Log from '../../../log';
import { promptAsync, selectAsync, toggleConfirmAsync } from '../../../prompts';
import EnvLink from '../link';
jest.mock('../../../graphql/queries/EnvironmentVariablesQuery');
jest.mock('../../../graphql/mutations/EnvironmentVariableMutation');
jest.mock('../../../prompts');
jest.mock('../../../graphql/queries/AppQuery');
jest.mock('../../../log');
describe(EnvLink, () => {
const projectId = 'test-project-id';
const variableId = '1';
const graphqlClient = {};
const mockConfig = {} as unknown as Config;
const mockContext = {
projectId,
loggedIn: { graphqlClient },
};
const successMessage = (env: EnvironmentVariableEnvironment): string =>
`Linked variable ${chalk.bold('TEST_VARIABLE')} to project ${chalk.bold(
'@testuser/testpp'
)} in ${env.toLocaleLowerCase()}.`;
beforeEach(() => {
jest.resetAllMocks();
jest.mocked(AppQuery.byIdAsync).mockImplementation(async () => getMockAppFragment());
});
it('links an account-wide variable to the current project in non-interactive mode', async () => {
const mockVariables = [
{
id: variableId,
name: 'TEST_VARIABLE',
scope: EnvironmentVariableScope.Shared,
environments: [EnvironmentVariableEnvironment.Development],
},
];
(EnvironmentVariablesQuery.sharedAsync as jest.Mock).mockResolvedValue(mockVariables);
(EnvironmentVariableMutation.linkSharedEnvironmentVariableAsync as jest.Mock).mockResolvedValue(
mockVariables[0]
);
const command = new EnvLink(
['--variable-name', 'TEST_VARIABLE', '--non-interactive'],
mockConfig
);
// @ts-expect-error
jest.spyOn(command, 'getContextAsync').mockReturnValue(mockContext);
await command.run();
expect(EnvironmentVariablesQuery.sharedAsync).toHaveBeenCalledWith(graphqlClient, {
appId: projectId,
filterNames: ['TEST_VARIABLE'],
});
expect(EnvironmentVariableMutation.linkSharedEnvironmentVariableAsync).toHaveBeenCalledWith(
graphqlClient,
variableId,
projectId
);
expect(Log.withTick).toHaveBeenCalledWith(
successMessage(EnvironmentVariableEnvironment.Development)
);
});
it('links an account-wide variable to the current project to a specified environment', async () => {
const mockVariables = [
{
id: variableId,
name: 'TEST_VARIABLE',
scope: EnvironmentVariableScope.Shared,
environments: [EnvironmentVariableEnvironment.Development],
},
];
(EnvironmentVariablesQuery.sharedAsync as jest.Mock).mockResolvedValue(mockVariables);
(EnvironmentVariableMutation.linkSharedEnvironmentVariableAsync as jest.Mock).mockResolvedValue(
mockVariables[0]
);
const command = new EnvLink(
['--variable-name', 'TEST_VARIABLE', '--environment', 'production', '--non-interactive'],
mockConfig
);
// @ts-expect-error
jest.spyOn(command, 'getContextAsync').mockReturnValue(mockContext);
await command.run();
expect(EnvironmentVariablesQuery.sharedAsync).toHaveBeenCalledWith(graphqlClient, {
appId: projectId,
filterNames: ['TEST_VARIABLE'],
});
expect(EnvironmentVariableMutation.linkSharedEnvironmentVariableAsync).toHaveBeenCalledWith(
graphqlClient,
variableId,
projectId,
EnvironmentVariableEnvironment.Production
);
expect(Log.withTick).toHaveBeenCalledWith(
successMessage(EnvironmentVariableEnvironment.Production)
);
});
it('uses --variable-environment to select the variable with ambigous name', async () => {
const mockVariables = [
{
id: variableId,
name: 'TEST_VARIABLE',
scope: EnvironmentVariableScope.Shared,
environments: [EnvironmentVariableEnvironment.Preview],
},
{
id: 'other-id',
name: 'TEST_VARIABLE',
scope: EnvironmentVariableScope.Shared,
environments: [EnvironmentVariableEnvironment.Development],
},
];
(EnvironmentVariablesQuery.sharedAsync as jest.Mock).mockImplementation(
(_client, { environment }) => {
return mockVariables.filter(v => v.environments.includes(environment));
}
);
(EnvironmentVariableMutation.linkSharedEnvironmentVariableAsync as jest.Mock).mockResolvedValue(
mockVariables[0]
);
(toggleConfirmAsync as jest.Mock).mockResolvedValue(true);
const command = new EnvLink(
[
'--variable-name',
'TEST_VARIABLE',
'--variable-environment',
'development',
'--environment',
'production',
],
mockConfig
);
// @ts-expect-error
jest.spyOn(command, 'getContextAsync').mockReturnValue(mockContext);
await command.runAsync();
expect(EnvironmentVariableMutation.linkSharedEnvironmentVariableAsync).toHaveBeenCalledWith(
graphqlClient,
'other-id',
projectId,
EnvironmentVariableEnvironment.Production
);
expect(Log.withTick).toHaveBeenCalledWith(
successMessage(EnvironmentVariableEnvironment.Production)
);
});
it('prompts for variable selection when the name is ambigous', async () => {
const mockVariables = [
{
id: variableId,
name: 'TEST_VARIABLE',
scope: EnvironmentVariableScope.Shared,
environments: [EnvironmentVariableEnvironment.Preview],
},
{
id: 'other-id',
name: 'TEST_VARIABLE',
scope: EnvironmentVariableScope.Shared,
environments: [EnvironmentVariableEnvironment.Development],
},
];
(EnvironmentVariablesQuery.sharedAsync as jest.Mock).mockResolvedValue(mockVariables);
(EnvironmentVariableMutation.linkSharedEnvironmentVariableAsync as jest.Mock).mockResolvedValue(
mockVariables[0]
);
(selectAsync as jest.Mock).mockResolvedValue(mockVariables[0]);
(promptAsync as jest.Mock).mockResolvedValue({ environments: mockVariables[0].environments });
(toggleConfirmAsync as jest.Mock).mockResolvedValue(true);
const command = new EnvLink([], mockConfig);
// @ts-expect-error
jest.spyOn(command, 'getContextAsync').mockReturnValue(mockContext);
await command.runAsync();
expect(selectAsync).toHaveBeenCalled();
expect(EnvironmentVariableMutation.linkSharedEnvironmentVariableAsync).toHaveBeenCalledWith(
graphqlClient,
variableId,
projectId,
EnvironmentVariableEnvironment.Preview
);
expect(Log.withTick).toHaveBeenCalledWith(
successMessage(EnvironmentVariableEnvironment.Preview)
);
});
it('throws an error when variable name is not found', async () => {
const mockVariables: never[] = [];
(EnvironmentVariablesQuery.sharedAsync as jest.Mock).mockResolvedValue(mockVariables);
const command = new EnvLink(['--variable-name', 'NON_EXISTENT_VARIABLE'], mockConfig);
// @ts-expect-error
jest.spyOn(command, 'getContextAsync').mockReturnValue(mockContext);
await expect(command.runAsync()).rejects.toThrow(
"Account-wide variable NON_EXISTENT_VARIABLE doesn't exist"
);
});
it('uses environments from prompt to both link and unlink environments', async () => {
const mockVariables = [
{
id: variableId,
name: 'TEST_VARIABLE',
scope: EnvironmentVariableScope.Shared,
environments: [EnvironmentVariableEnvironment.Preview],
linkedEnvironments: [EnvironmentVariableEnvironment.Preview],
},
];
(EnvironmentVariablesQuery.sharedAsync as jest.Mock).mockResolvedValue(mockVariables);
(EnvironmentVariableMutation.linkSharedEnvironmentVariableAsync as jest.Mock).mockResolvedValue(
mockVariables[0]
);
(selectAsync as jest.Mock).mockResolvedValue(mockVariables[0]);
(promptAsync as jest.Mock).mockResolvedValue({
environments: [EnvironmentVariableEnvironment.Production],
});
(toggleConfirmAsync as jest.Mock).mockResolvedValue(true);
const command = new EnvLink([], mockConfig);
// @ts-expect-error
jest.spyOn(command, 'getContextAsync').mockReturnValue(mockContext);
await command.runAsync();
expect(promptAsync).toHaveBeenCalled();
expect(EnvironmentVariableMutation.linkSharedEnvironmentVariableAsync).toHaveBeenCalledWith(
graphqlClient,
variableId,
projectId,
EnvironmentVariableEnvironment.Production
);
expect(EnvironmentVariableMutation.unlinkSharedEnvironmentVariableAsync).toHaveBeenCalledWith(
graphqlClient,
variableId,
projectId,
EnvironmentVariableEnvironment.Preview
);
});
it('throws an error when variable name is not found', async () => {
const mockVariables: never[] = [];
(EnvironmentVariablesQuery.sharedAsync as jest.Mock).mockResolvedValue(mockVariables);
const command = new EnvLink(['--variable-name', 'NON_EXISTENT_VARIABLE'], mockConfig);
// @ts-expect-error
jest.spyOn(command, 'getContextAsync').mockReturnValue(mockContext);
await expect(command.runAsync()).rejects.toThrow(
"Account-wide variable NON_EXISTENT_VARIABLE doesn't exist"
);
});
});
@@ -1,226 +0,0 @@
import { Config } from '@oclif/core';
import chalk from 'chalk';
import { getMockAppFragment } from '../../../__tests__/commands/utils';
import {
EnvironmentVariableEnvironment,
EnvironmentVariableScope,
} from '../../../graphql/generated';
import { EnvironmentVariableMutation } from '../../../graphql/mutations/EnvironmentVariableMutation';
import { AppQuery } from '../../../graphql/queries/AppQuery';
import { EnvironmentVariablesQuery } from '../../../graphql/queries/EnvironmentVariablesQuery';
import Log from '../../../log';
import { promptAsync, selectAsync, toggleConfirmAsync } from '../../../prompts';
import EnvUnlink from '../unlink';
jest.mock('../../../graphql/queries/EnvironmentVariablesQuery');
jest.mock('../../../graphql/mutations/EnvironmentVariableMutation');
jest.mock('../../../prompts');
jest.mock('../../../graphql/queries/AppQuery');
jest.mock('../../../log');
describe(EnvUnlink, () => {
const projectId = 'test-project-id';
const variableId = '1';
const graphqlClient = {};
const mockConfig = {} as unknown as Config;
const mockContext = {
projectId,
loggedIn: { graphqlClient },
};
const successMessage = (env: EnvironmentVariableEnvironment): string =>
`Unlinked variable ${chalk.bold('TEST_VARIABLE')} from project ${chalk.bold(
'@testuser/testpp'
)} in ${env.toLocaleLowerCase()}.`;
beforeEach(() => {
jest.resetAllMocks();
jest.mocked(AppQuery.byIdAsync).mockImplementation(async () => getMockAppFragment());
});
it('unlinks an account-wide variable from the current project in non-interactive mode', async () => {
const mockVariables = [
{
id: variableId,
name: 'TEST_VARIABLE',
scope: EnvironmentVariableScope.Shared,
environments: [EnvironmentVariableEnvironment.Development],
linkedEnvironments: [EnvironmentVariableEnvironment.Development],
},
];
(EnvironmentVariablesQuery.sharedAsync as jest.Mock).mockResolvedValue(mockVariables);
(
EnvironmentVariableMutation.unlinkSharedEnvironmentVariableAsync as jest.Mock
).mockResolvedValue(mockVariables[0]);
const command = new EnvUnlink(
['--variable-name', 'TEST_VARIABLE', '--non-interactive'],
mockConfig
);
// @ts-expect-error
jest.spyOn(command, 'getContextAsync').mockReturnValue(mockContext);
await command.run();
expect(EnvironmentVariablesQuery.sharedAsync).toHaveBeenCalledWith(graphqlClient, {
appId: projectId,
filterNames: ['TEST_VARIABLE'],
});
expect(EnvironmentVariableMutation.unlinkSharedEnvironmentVariableAsync).toHaveBeenCalledWith(
graphqlClient,
variableId,
projectId
);
expect(Log.withTick).toHaveBeenCalledWith(
successMessage(EnvironmentVariableEnvironment.Development)
);
});
it('unlinks an account-wide variable from the current project in a specified environment', async () => {
const mockVariables = [
{
id: variableId,
name: 'TEST_VARIABLE',
scope: EnvironmentVariableScope.Shared,
environments: [EnvironmentVariableEnvironment.Development],
linkedEnvironments: [EnvironmentVariableEnvironment.Production],
},
];
(EnvironmentVariablesQuery.sharedAsync as jest.Mock).mockResolvedValue(mockVariables);
(
EnvironmentVariableMutation.unlinkSharedEnvironmentVariableAsync as jest.Mock
).mockResolvedValue(mockVariables[0]);
const command = new EnvUnlink(
['--variable-name', 'TEST_VARIABLE', '--environment', 'production', '--non-interactive'],
mockConfig
);
// @ts-expect-error
jest.spyOn(command, 'getContextAsync').mockReturnValue(mockContext);
await command.run();
expect(Log.withTick).toHaveBeenCalledWith(
successMessage(EnvironmentVariableEnvironment.Production)
);
expect(EnvironmentVariablesQuery.sharedAsync).toHaveBeenCalledWith(graphqlClient, {
appId: projectId,
filterNames: ['TEST_VARIABLE'],
});
expect(EnvironmentVariableMutation.unlinkSharedEnvironmentVariableAsync).toHaveBeenCalledWith(
graphqlClient,
variableId,
projectId,
EnvironmentVariableEnvironment.Production
);
});
it('prompts for variable selection when the name is ambigous', async () => {
const mockVariables = [
{
id: variableId,
name: 'TEST_VARIABLE',
scope: EnvironmentVariableScope.Shared,
environments: [EnvironmentVariableEnvironment.Preview],
linkedEnvironments: [EnvironmentVariableEnvironment.Preview],
},
{
id: 'other-id',
name: 'TEST_VARIABLE',
scope: EnvironmentVariableScope.Shared,
environments: [EnvironmentVariableEnvironment.Development],
linkedEnvironments: [EnvironmentVariableEnvironment.Development],
},
];
(EnvironmentVariablesQuery.sharedAsync as jest.Mock).mockResolvedValue(mockVariables);
(
EnvironmentVariableMutation.unlinkSharedEnvironmentVariableAsync as jest.Mock
).mockResolvedValue(mockVariables[0]);
(selectAsync as jest.Mock).mockResolvedValue(mockVariables[0]);
(promptAsync as jest.Mock).mockResolvedValue({
environments: [],
});
(toggleConfirmAsync as jest.Mock).mockResolvedValue(true);
const command = new EnvUnlink([], mockConfig);
// @ts-expect-error
jest.spyOn(command, 'getContextAsync').mockReturnValue(mockContext);
await command.runAsync();
expect(selectAsync).toHaveBeenCalled();
expect(EnvironmentVariableMutation.unlinkSharedEnvironmentVariableAsync).toHaveBeenCalledWith(
graphqlClient,
variableId,
projectId,
EnvironmentVariableEnvironment.Preview
);
expect(Log.withTick).toHaveBeenCalledWith(
successMessage(EnvironmentVariableEnvironment.Preview)
);
});
it('throws an error when variable name is not found', async () => {
const mockVariables: never[] = [];
(EnvironmentVariablesQuery.sharedAsync as jest.Mock).mockResolvedValue(mockVariables);
const command = new EnvUnlink(['--variable-name', 'NON_EXISTENT_VARIABLE'], mockConfig);
// @ts-expect-error
jest.spyOn(command, 'getContextAsync').mockReturnValue(mockContext);
await expect(command.runAsync()).rejects.toThrow(
"Account-wide variable NON_EXISTENT_VARIABLE doesn't exist"
);
});
it('uses environments from prompt to both link and unlink environments', async () => {
const mockVariables = [
{
id: variableId,
name: 'TEST_VARIABLE',
scope: EnvironmentVariableScope.Shared,
environments: [EnvironmentVariableEnvironment.Preview],
linkedEnvironments: [EnvironmentVariableEnvironment.Preview],
},
];
(EnvironmentVariablesQuery.sharedAsync as jest.Mock).mockResolvedValue(mockVariables);
(EnvironmentVariableMutation.linkSharedEnvironmentVariableAsync as jest.Mock).mockResolvedValue(
mockVariables[0]
);
(selectAsync as jest.Mock).mockResolvedValue(mockVariables[0]);
(promptAsync as jest.Mock).mockResolvedValue({
environments: [EnvironmentVariableEnvironment.Production],
});
(toggleConfirmAsync as jest.Mock).mockResolvedValue(true);
const command = new EnvUnlink([], mockConfig);
// @ts-expect-error
jest.spyOn(command, 'getContextAsync').mockReturnValue(mockContext);
await command.runAsync();
expect(promptAsync).toHaveBeenCalled();
expect(EnvironmentVariableMutation.linkSharedEnvironmentVariableAsync).toHaveBeenCalledWith(
graphqlClient,
variableId,
projectId,
EnvironmentVariableEnvironment.Production
);
expect(EnvironmentVariableMutation.unlinkSharedEnvironmentVariableAsync).toHaveBeenCalledWith(
graphqlClient,
variableId,
projectId,
EnvironmentVariableEnvironment.Preview
);
});
it('throws an error when variable name is not found', async () => {
const mockVariables: never[] = [];
(EnvironmentVariablesQuery.sharedAsync as jest.Mock).mockResolvedValue(mockVariables);
const command = new EnvUnlink(['--variable-name', 'NON_EXISTENT_VARIABLE'], mockConfig);
// @ts-expect-error
jest.spyOn(command, 'getContextAsync').mockReturnValue(mockContext);
await expect(command.runAsync()).rejects.toThrow(
"Account-wide variable NON_EXISTENT_VARIABLE doesn't exist"
);
});
});
+1 -61
View File
@@ -33,12 +33,11 @@ import {
promptVariableValueAsync,
promptVariableVisibilityAsync,
} from '../../utils/prompts';
import { isEnvironment, performForEnvironmentsAsync } from '../../utils/variableUtils';
import { isEnvironment } from '../../utils/variableUtils';
interface RawCreateFlags {
name?: string;
value?: string;
link?: boolean;
force?: boolean;
type?: 'string' | 'file';
visibility?: 'plaintext' | 'sensitive' | 'secret';
@@ -50,7 +49,6 @@ interface RawCreateFlags {
interface CreateFlags {
name?: string;
value?: string;
link?: boolean;
force?: boolean;
type?: 'string' | 'file';
visibility?: 'plaintext' | 'sensitive' | 'secret';
@@ -78,10 +76,6 @@ export default class EnvCreate extends EasCommand {
value: Flags.string({
description: 'Text value or the variable',
}),
link: Flags.boolean({
description: 'Link account-wide variable to the current project',
hidden: true, // every account-wide variable is global for now so it's not user facing
}),
force: Flags.boolean({
description: 'Overwrite existing variable',
default: false,
@@ -114,7 +108,6 @@ export default class EnvCreate extends EasCommand {
'non-interactive': nonInteractive,
environment: environments,
visibility,
link,
force,
type,
fileName,
@@ -154,29 +147,6 @@ export default class EnvCreate extends EasCommand {
});
overwrite = true;
}
if (existingVariable.scope === EnvironmentVariableScope.Shared) {
await this.promptForOverwriteAsync({
nonInteractive,
force,
message: `Account-wide variable with ${name} name already exists on this account.`,
suggestion: 'Do you want to unlink it first?',
});
Log.withTick(
`Unlinking account-wide variable ${chalk.bold(name)} on project ${chalk.bold(
projectDisplayName
)}.`
);
await performForEnvironmentsAsync(environments, async environment => {
await EnvironmentVariableMutation.unlinkSharedEnvironmentVariableAsync(
graphqlClient,
existingVariable.id,
projectId,
environment
);
});
}
}
const variable =
@@ -265,29 +235,6 @@ export default class EnvCreate extends EasCommand {
Log.withTick(
`Created a new variable ${chalk.bold(name)} on account ${chalk.bold(ownerAccount.name)}.`
);
if (link) {
Log.withTick(
`Linking account-wide variable ${chalk.bold(name)} to project ${chalk.bold(
projectDisplayName
)}.`
);
await performForEnvironmentsAsync(environments, async environment => {
await EnvironmentVariableMutation.linkSharedEnvironmentVariableAsync(
graphqlClient,
variable.id,
projectId,
environment
);
});
Log.withTick(
`Linked account-wide variable ${chalk.bold(name)} to project ${chalk.bold(
projectDisplayName
)}.`
);
}
}
}
@@ -402,7 +349,6 @@ export default class EnvCreate extends EasCommand {
value,
environment: newEnvironments,
visibility: newVisibility,
link: rest.link ?? false,
force: rest.force ?? false,
'non-interactive': nonInteractive,
type: newType,
@@ -412,12 +358,6 @@ export default class EnvCreate extends EasCommand {
}
private sanitizeFlags(flags: RawCreateFlags): CreateFlags {
if (flags.scope !== 'account' && flags.link) {
throw new Error(
`Unexpected argument: --link can only be used when creating account-wide variables`
);
}
return {
...flags,
scope:
-204
View File
@@ -1,204 +0,0 @@
import { Flags } from '@oclif/core';
import chalk from 'chalk';
import EasCommand from '../../commandUtils/EasCommand';
import {
EASMultiEnvironmentFlag,
EASNonInteractiveFlag,
EasEnvironmentFlagParameters,
} from '../../commandUtils/flags';
import { EnvironmentVariableEnvironment } from '../../graphql/generated';
import { EnvironmentVariableMutation } from '../../graphql/mutations/EnvironmentVariableMutation';
import { EnvironmentVariablesQuery } from '../../graphql/queries/EnvironmentVariablesQuery';
import Log from '../../log';
import { getDisplayNameForProjectIdAsync } from '../../project/projectUtils';
import { selectAsync } from '../../prompts';
import { promptVariableEnvironmentAsync } from '../../utils/prompts';
import { formatVariableName, isEnvironment } from '../../utils/variableUtils';
type LinkFlags = {
'variable-name'?: string;
'variable-environment'?: EnvironmentVariableEnvironment;
'non-interactive': boolean;
environment?: EnvironmentVariableEnvironment[];
};
export default class EnvLink extends EasCommand {
static override description = 'link an account-wide environment variable to the current project';
// for now we only roll out global account-wide env variables so this should stay hidden
static override hidden = true;
static override flags = {
'variable-name': Flags.string({
description: 'Name of the variable',
}),
'variable-environment': Flags.enum<EnvironmentVariableEnvironment>({
...EasEnvironmentFlagParameters,
description: 'Current environment of the variable to link',
}),
...EASMultiEnvironmentFlag,
...EASNonInteractiveFlag,
};
static override args = [
{
name: 'environment',
description:
"Environment to pull variables from. One of 'production', 'preview', or 'development'.",
required: false,
},
];
static override contextDefinition = {
...this.ContextOptions.ProjectId,
...this.ContextOptions.LoggedIn,
};
async runAsync(): Promise<void> {
const { args, flags } = await this.parse(EnvLink);
let {
'variable-name': name,
'variable-environment': currentEnvironment,
'non-interactive': nonInteractive,
environment: environments,
} = this.validateInputs(flags, args);
const {
projectId,
loggedIn: { graphqlClient },
} = await this.getContextAsync(EnvLink, {
nonInteractive,
});
const projectDisplayName = await getDisplayNameForProjectIdAsync(graphqlClient, projectId);
const variables = await EnvironmentVariablesQuery.sharedAsync(graphqlClient, {
appId: projectId,
environment: currentEnvironment,
filterNames: name ? [name] : undefined,
});
let selectedVariable = variables[0];
if (variables.length > 1) {
if (nonInteractive) {
throw new Error(
'Multiple variables found, run command with --variable-name and --variable-environment arguments.'
);
}
selectedVariable = await selectAsync(
'Select account-wide variable',
variables.map(variable => ({
title: formatVariableName(variable),
value: variable,
}))
);
}
if (!selectedVariable) {
throw new Error(`Account-wide variable ${name} doesn't exist`);
}
let explicitSelect = false;
if (!nonInteractive && !environments) {
const selectedEnvironments =
(selectedVariable.linkedEnvironments ?? []).length > 0
? selectedVariable.linkedEnvironments
: selectedVariable.environments;
environments = await promptVariableEnvironmentAsync({
nonInteractive,
multiple: true,
selectedEnvironments: selectedEnvironments ?? [],
});
explicitSelect = true;
}
if (!environments) {
await EnvironmentVariableMutation.linkSharedEnvironmentVariableAsync(
graphqlClient,
selectedVariable.id,
projectId
);
Log.withTick(
`Linked variable ${chalk.bold(selectedVariable.name)} to project ${chalk.bold(
projectDisplayName
)} in ${selectedVariable.environments?.join(', ').toLocaleLowerCase()}.`
);
return;
}
for (const environment of Object.values(EnvironmentVariableEnvironment)) {
try {
if (
selectedVariable.linkedEnvironments?.includes(environment) ===
environments.includes(environment)
) {
if (!explicitSelect && environments.includes(environment)) {
Log.withTick(
`Variable ${chalk.bold(
selectedVariable.name
)} is already linked to ${environment.toLocaleLowerCase()}.`
);
}
continue;
}
if (environments.includes(environment)) {
await EnvironmentVariableMutation.linkSharedEnvironmentVariableAsync(
graphqlClient,
selectedVariable.id,
projectId,
environment
);
Log.withTick(
`Linked variable ${chalk.bold(selectedVariable.name)} to project ${chalk.bold(
projectDisplayName
)} in ${environment.toLocaleLowerCase()}.`
);
} else if (explicitSelect) {
await EnvironmentVariableMutation.unlinkSharedEnvironmentVariableAsync(
graphqlClient,
selectedVariable.id,
projectId,
environment
);
Log.withTick(
`Unlinked variable ${chalk.bold(selectedVariable.name)} from project ${chalk.bold(
projectDisplayName
)} in ${environment.toLocaleLowerCase()}.`
);
}
} catch (err: any) {
Log.warn(err.message);
}
}
}
private validateInputs(flags: LinkFlags, { environment }: { environment?: string }): LinkFlags {
environment = environment?.toUpperCase();
if (environment && !isEnvironment(environment)) {
throw new Error("Invalid environment. Use one of 'production', 'preview', or 'development'.");
}
const environments = flags.environment
? flags.environment
: environment
? [environment as EnvironmentVariableEnvironment]
: undefined;
if (flags['non-interactive']) {
if (!flags['variable-name']) {
throw new Error(
`Environment variable needs 'name' to be specified when running in non-interactive mode. Run the command with ${chalk.bold(
'--variable-name VARIABLE_NAME'
)} flag to fix the issue`
);
}
}
return {
...flags,
environment: environments,
};
}
}
+3 -3
View File
@@ -246,9 +246,9 @@ export default class EnvPush extends EasCommand {
name,
value,
environments,
visibility: name.startsWith('EXPO_SENSITIVE')
? EnvironmentVariableVisibility.Sensitive
: EnvironmentVariableVisibility.Public,
visibility: name.startsWith('EXPO_PUBLIC_')
? EnvironmentVariableVisibility.Public
: EnvironmentVariableVisibility.Sensitive,
};
}
-192
View File
@@ -1,192 +0,0 @@
import { Flags } from '@oclif/core';
import chalk from 'chalk';
import EasCommand from '../../commandUtils/EasCommand';
import { EASMultiEnvironmentFlag, EASNonInteractiveFlag } from '../../commandUtils/flags';
import { EnvironmentVariableEnvironment } from '../../graphql/generated';
import { EnvironmentVariableMutation } from '../../graphql/mutations/EnvironmentVariableMutation';
import { EnvironmentVariablesQuery } from '../../graphql/queries/EnvironmentVariablesQuery';
import Log from '../../log';
import { getDisplayNameForProjectIdAsync } from '../../project/projectUtils';
import { selectAsync } from '../../prompts';
import { promptVariableEnvironmentAsync } from '../../utils/prompts';
import { formatVariableName, isEnvironment } from '../../utils/variableUtils';
type UnlinkFlags = {
'variable-name'?: string;
'non-interactive': boolean;
environment?: EnvironmentVariableEnvironment[];
};
export default class EnvUnlink extends EasCommand {
static override description =
'unlink an account-wide environment variable from the current project';
// for now we only roll out global account-wide env variables so this should stay hidden
static override hidden = true;
static override flags = {
'variable-name': Flags.string({
description: 'Name of the variable',
}),
...EASMultiEnvironmentFlag,
...EASNonInteractiveFlag,
};
static override contextDefinition = {
...this.ContextOptions.ProjectId,
...this.ContextOptions.LoggedIn,
};
static override args = [
{
name: 'environment',
description:
"Environment to unlink the variable from. One of 'production', 'preview', or 'development'.",
required: false,
},
];
async runAsync(): Promise<void> {
const { args, flags } = await this.parse(EnvUnlink);
let {
'variable-name': name,
'non-interactive': nonInteractive,
environment: unlinkEnvironments,
} = this.validateInputs(flags, args);
const {
projectId,
loggedIn: { graphqlClient },
} = await this.getContextAsync(EnvUnlink, {
nonInteractive,
});
const projectDisplayName = await getDisplayNameForProjectIdAsync(graphqlClient, projectId);
const variables = await EnvironmentVariablesQuery.sharedAsync(graphqlClient, {
appId: projectId,
filterNames: name ? [name] : undefined,
});
let selectedVariable = variables[0];
if (variables.length > 1) {
if (nonInteractive) {
throw new Error("Multiple variables found, please select one using '--variable-name'");
}
selectedVariable = await selectAsync(
'Select account-wide variable',
variables.map(variable => ({
title: formatVariableName(variable),
value: variable,
}))
);
}
if (!selectedVariable) {
throw new Error(`Account-wide variable ${name} doesn't exist`);
}
let explicitSelect = false;
if (!nonInteractive && !unlinkEnvironments) {
const selectedEnvironments =
(selectedVariable.linkedEnvironments ?? []).length > 0
? selectedVariable.linkedEnvironments
: selectedVariable.environments;
const environments = await promptVariableEnvironmentAsync({
nonInteractive,
multiple: true,
selectedEnvironments: selectedEnvironments ?? [],
});
explicitSelect = true;
unlinkEnvironments = Object.values(EnvironmentVariableEnvironment).filter(
env => !environments.includes(env)
);
}
if (!unlinkEnvironments) {
await EnvironmentVariableMutation.unlinkSharedEnvironmentVariableAsync(
graphqlClient,
selectedVariable.id,
projectId
);
Log.withTick(
`Unlinked variable ${chalk.bold(selectedVariable.name)} from project ${chalk.bold(
projectDisplayName
)} in ${selectedVariable.environments?.join(', ').toLocaleLowerCase()}.`
);
return;
}
for (const environment of Object.values(EnvironmentVariableEnvironment)) {
if (
selectedVariable.linkedEnvironments?.includes(environment) !==
unlinkEnvironments.includes(environment)
) {
if (!explicitSelect && unlinkEnvironments.includes(environment)) {
Log.withTick(
`Variable ${chalk.bold(
selectedVariable.name
)} is already unlinked from ${environment.toLocaleLowerCase()}.`
);
}
continue;
}
if (unlinkEnvironments.includes(environment)) {
await EnvironmentVariableMutation.unlinkSharedEnvironmentVariableAsync(
graphqlClient,
selectedVariable.id,
projectId,
environment
);
Log.withTick(
`Unlinked variable ${chalk.bold(selectedVariable.name)} from project ${chalk.bold(
projectDisplayName
)} in ${environment.toLocaleLowerCase()}.`
);
} else if (explicitSelect) {
await EnvironmentVariableMutation.linkSharedEnvironmentVariableAsync(
graphqlClient,
selectedVariable.id,
projectId,
environment
);
Log.withTick(
`Linked variable ${chalk.bold(selectedVariable.name)} to project ${chalk.bold(
projectDisplayName
)} in ${environment.toLocaleLowerCase()}.`
);
}
}
}
private validateInputs(
flags: UnlinkFlags,
{ environment }: { environment?: string }
): UnlinkFlags {
if (flags['non-interactive']) {
if (!flags['variable-name']) {
throw new Error(
'Current name is required in non-interactive mode. Run the command with --variable-name flag.'
);
}
}
if (environment) {
environment = environment.toUpperCase();
if (!isEnvironment(environment)) {
throw new Error(
"Invalid environment. Use one of 'production', 'preview', or 'development'."
);
}
return {
environment: [environment],
...flags,
};
}
return flags;
}
}
@@ -1,4 +1,3 @@
import { Platform, Workflow } from '@expo/eas-build-job';
import { Flags } from '@oclif/core';
import openBrowserAsync from 'better-opn';
import chalk from 'chalk';
@@ -7,16 +6,27 @@ import { getExpoWebsiteBaseUrl } from '../../api';
import { selectBranchOnAppAsync } from '../../branch/queries';
import EasCommand from '../../commandUtils/EasCommand';
import { fetchBuildsAsync, formatBuild } from '../../commandUtils/builds';
import { GetServerSideEnvironmentVariablesFn } from '../../commandUtils/context/ServerSideEnvironmentVariablesContextField';
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
import { EasNonInteractiveAndJsonFlags } from '../../commandUtils/flags';
import {
EasEnvironmentFlagParameters,
EasNonInteractiveAndJsonFlags,
} from '../../commandUtils/flags';
import { diffFingerprint } from '../../fingerprint/cli';
import { abridgedDiff } from '../../fingerprint/diff';
import { Fingerprint, FingerprintDiffItem } from '../../fingerprint/types';
import {
getFingerprintInfoFromLocalProjectForPlatformsAsync,
stringToAppPlatform,
} from '../../fingerprint/utils';
import {
AppPlatform,
BuildFragment,
BuildStatus,
EnvironmentVariableEnvironment,
FingerprintFragment,
UpdateFragment,
} from '../../graphql/generated';
import { FingerprintMutation } from '../../graphql/mutations/FingerprintMutation';
import { AppQuery } from '../../graphql/queries/AppQuery';
import { BuildQuery } from '../../graphql/queries/BuildQuery';
import { FingerprintQuery } from '../../graphql/queries/FingerprintQuery';
@@ -24,14 +34,9 @@ import { UpdateQuery } from '../../graphql/queries/UpdateQuery';
import Log, { learnMore } from '../../log';
import { ora } from '../../ora';
import { RequestedPlatform } from '../../platform';
import { maybeUploadFingerprintAsync } from '../../project/maybeUploadFingerprintAsync';
import { getDisplayNameForProjectIdAsync } from '../../project/projectUtils';
import { resolveWorkflowPerPlatformAsync } from '../../project/workflow';
import { promptAsync, selectAsync } from '../../prompts';
import { selectUpdateGroupOnBranchAsync } from '../../update/queries';
import { Fingerprint, FingerprintDiffItem } from '../../utils/fingerprint';
import { createFingerprintAsync, diffFingerprint } from '../../utils/fingerprintCli';
import { abridgedDiff } from '../../utils/fingerprintDiff';
import formatFields, { FormatFieldsItem } from '../../utils/formatFields';
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
import { Client } from '../../vcs/vcs';
@@ -50,7 +55,7 @@ type FingerprintOrigin = {
};
export default class FingerprintCompare extends EasCommand {
static override description = 'compare fingerprints of the current project, builds and updates';
static override description = 'compare fingerprints of the current project, builds, and updates';
static override strict = false;
static override examples = [
@@ -58,6 +63,7 @@ export default class FingerprintCompare extends EasCommand {
'$ eas fingerprint:compare <FINGERPRINT-HASH> \t # Compare fingerprint against local directory',
'$ eas fingerprint:compare <FINGERPRINT-HASH-1> <FINGERPRINT-HASH-2> \t # Compare provided fingerprints',
'$ eas fingerprint:compare --build-id <BUILD-ID> \t # Compare fingerprint from build against local directory',
'$ eas fingerprint:compare --build-id <BUILD-ID> --environment production \t # Compare fingerprint from build against local directory with the "production" environment',
'$ eas fingerprint:compare --build-id <BUILD-ID-1> --build-id <BUILD-ID-2>\t # Compare fingerprint from a build against another build',
'$ eas fingerprint:compare --build-id <BUILD-ID> --update-id <UPDATE-ID>\t # Compare fingerprint from build against fingerprint from update',
'$ eas fingerprint:compare <FINGERPRINT-HASH> --update-id <UPDATE-ID> \t # Compare fingerprint from update against provided fingerprint',
@@ -91,6 +97,11 @@ export default class FingerprintCompare extends EasCommand {
open: Flags.boolean({
description: 'Open the fingerprint comparison in the browser',
}),
environment: Flags.enum<EnvironmentVariableEnvironment>({
...EasEnvironmentFlagParameters,
description:
'If generating a fingerprint from the local directory, use the specified environment.',
}),
...EasNonInteractiveAndJsonFlags,
};
@@ -99,6 +110,7 @@ export default class FingerprintCompare extends EasCommand {
...this.ContextOptions.ProjectConfig,
...this.ContextOptions.LoggedIn,
...this.ContextOptions.Vcs,
...this.ContextOptions.ServerSideEnvironmentVariables,
};
async runAsync(): Promise<void> {
@@ -110,6 +122,7 @@ export default class FingerprintCompare extends EasCommand {
'build-id': buildIds,
'update-id': updateIds,
open,
environment,
} = flags;
const [buildId1, buildId2] = buildIds ?? [];
const [updateId1, updateId2] = updateIds ?? [];
@@ -119,9 +132,10 @@ export default class FingerprintCompare extends EasCommand {
privateProjectConfig: { projectDir },
loggedIn: { graphqlClient },
vcsClient,
getServerSideEnvironmentVariablesAsync,
} = await this.getContextAsync(FingerprintCompare, {
nonInteractive,
withServerSideEnvironment: null,
withServerSideEnvironment: environment ?? null,
});
if (json) {
enableJsonOutput();
@@ -132,6 +146,7 @@ export default class FingerprintCompare extends EasCommand {
projectDir,
projectId,
vcsClient,
getServerSideEnvironmentVariablesAsync,
{
nonInteractive,
buildId: buildId1,
@@ -148,6 +163,7 @@ export default class FingerprintCompare extends EasCommand {
projectDir,
projectId,
vcsClient,
getServerSideEnvironmentVariablesAsync,
{
nonInteractive,
buildId: buildId2,
@@ -155,6 +171,7 @@ export default class FingerprintCompare extends EasCommand {
hash: hash2,
useProjectFingerprint:
isFirstFingerprintSpecifiedByFlagOrArg && !isSecondFingerprintSpecifiedByFlagOrArg,
environmentForProjectFingerprint: environment,
},
firstFingerprintInfo
);
@@ -238,11 +255,9 @@ export default class FingerprintCompare extends EasCommand {
const project = await AppQuery.byIdAsync(graphqlClient, projectId);
const fingerprintCompareUrl = new URL(
`/accounts/${project.ownerAccount.name}/projects/${project.name}/fingerprints/compare`,
`/accounts/${project.ownerAccount.name}/projects/${project.slug}/fingerprints/compare/${firstFingerprintInfo.fingerprint.hash}/${secondFingerprintInfo.fingerprint.hash}`,
getExpoWebsiteBaseUrl()
);
fingerprintCompareUrl.searchParams.set('a', firstFingerprintInfo.fingerprint.hash);
fingerprintCompareUrl.searchParams.set('b', secondFingerprintInfo.fingerprint.hash);
if (!open) {
Log.newLine();
@@ -262,17 +277,20 @@ async function getFingerprintInfoAsync(
projectDir: string,
projectId: string,
vcsClient: Client,
getServerSideEnvironmentVariablesAsync: GetServerSideEnvironmentVariablesFn,
{
buildId,
updateId,
hash,
useProjectFingerprint,
environmentForProjectFingerprint,
nonInteractive,
}: {
buildId?: string;
updateId?: string;
hash: string;
useProjectFingerprint?: boolean;
environmentForProjectFingerprint?: string;
nonInteractive: boolean;
},
firstFingerprintInfo?: {
@@ -298,13 +316,15 @@ async function getFingerprintInfoAsync(
'First fingerprint must be provided in order to compare against the project.'
);
}
return await getFingerprintInfoFromLocalProjectAsync(
return await getFingerprintInfoFromLocalProjectAsync({
graphqlClient,
projectDir,
projectId,
vcsClient,
firstFingerprintInfo
);
getServerSideEnvironmentVariablesAsync,
firstFingerprintInfo,
environment: environmentForProjectFingerprint,
});
}
if (nonInteractive) {
@@ -312,26 +332,38 @@ async function getFingerprintInfoAsync(
'Insufficent arguments provided for fingerprint comparison in non-interactive mode'
);
}
return await getFingerprintInfoInteractiveAsync(
return await getFingerprintInfoInteractiveAsync({
graphqlClient,
projectDir,
projectId,
vcsClient,
firstFingerprintInfo
);
getServerSideEnvironmentVariablesAsync,
firstFingerprintInfo,
environmentForProjectFingerprint,
});
}
async function getFingerprintInfoInteractiveAsync(
graphqlClient: ExpoGraphqlClient,
projectDir: string,
projectId: string,
vcsClient: Client,
async function getFingerprintInfoInteractiveAsync({
graphqlClient,
projectDir,
projectId,
vcsClient,
getServerSideEnvironmentVariablesAsync,
firstFingerprintInfo,
environmentForProjectFingerprint,
}: {
graphqlClient: ExpoGraphqlClient;
projectDir: string;
projectId: string;
vcsClient: Client;
getServerSideEnvironmentVariablesAsync: GetServerSideEnvironmentVariablesFn;
firstFingerprintInfo?: {
fingerprint: Fingerprint;
platforms?: AppPlatform[];
origin: FingerprintOrigin;
}
): Promise<{ fingerprint: Fingerprint; platforms?: AppPlatform[]; origin: FingerprintOrigin }> {
};
environmentForProjectFingerprint?: string;
}): Promise<{ fingerprint: Fingerprint; platforms?: AppPlatform[]; origin: FingerprintOrigin }> {
const prompt = firstFingerprintInfo
? 'Select the second fingerprint to compare against'
: 'Select a reference fingerprint for comparison';
@@ -349,13 +381,15 @@ async function getFingerprintInfoInteractiveAsync(
'First fingerprint must be provided in order to compare against the project.'
);
}
return await getFingerprintInfoFromLocalProjectAsync(
return await getFingerprintInfoFromLocalProjectAsync({
graphqlClient,
projectDir,
projectId,
vcsClient,
firstFingerprintInfo
);
getServerSideEnvironmentVariablesAsync,
firstFingerprintInfo,
environment: environmentForProjectFingerprint,
});
} else if (originType === FingerprintOriginType.Build) {
const displayName = await getDisplayNameForProjectIdAsync(graphqlClient, projectId);
const buildId = await selectBuildToCompareAsync(graphqlClient, projectId, displayName, {
@@ -409,54 +443,49 @@ async function getFingerprintInfoInteractiveAsync(
}
}
async function getFingerprintInfoFromLocalProjectAsync(
graphqlClient: ExpoGraphqlClient,
projectDir: string,
projectId: string,
vcsClient: Client,
async function getFingerprintInfoFromLocalProjectAsync({
graphqlClient,
projectDir,
projectId,
vcsClient,
getServerSideEnvironmentVariablesAsync,
firstFingerprintInfo,
environment,
}: {
graphqlClient: ExpoGraphqlClient;
projectDir: string;
projectId: string;
vcsClient: Client;
getServerSideEnvironmentVariablesAsync: GetServerSideEnvironmentVariablesFn;
firstFingerprintInfo: {
fingerprint: Fingerprint;
platforms?: AppPlatform[];
origin: FingerprintOrigin;
}
): Promise<{ fingerprint: Fingerprint; platforms?: AppPlatform[]; origin: FingerprintOrigin }> {
};
environment?: string;
}): Promise<{ fingerprint: Fingerprint; platforms?: AppPlatform[]; origin: FingerprintOrigin }> {
const firstFingerprintPlatforms = firstFingerprintInfo.platforms;
if (!firstFingerprintPlatforms) {
if (!firstFingerprintPlatforms || firstFingerprintPlatforms.length === 0) {
throw new Error(
`Cannot compare the local directory against the provided fingerprint hash "${firstFingerprintInfo.fingerprint.hash}" because the associated platform could not be determined. Ensure the fingerprint is linked to a build or update to identify the platform.`
);
}
const workflows = await resolveWorkflowPerPlatformAsync(projectDir, vcsClient);
const optionsFromWorkflow = getFingerprintOptionsFromWorkflow(
firstFingerprintPlatforms,
workflows
);
const projectFingerprint = await createFingerprintAsync(projectDir, {
...optionsFromWorkflow,
platforms: firstFingerprintPlatforms.map(appPlatformToString),
debug: true,
env: undefined,
});
if (!projectFingerprint) {
throw new Error('Project fingerprints can only be computed for projects with SDK 52 or higher');
if (environment) {
Log.log(`🔧 Using environment: ${environment}`);
}
const uploadedFingerprint = await maybeUploadFingerprintAsync({
hash: projectFingerprint.hash,
fingerprint: {
fingerprintSources: projectFingerprint.sources,
isDebugFingerprintSource: Log.isDebug,
},
const env = environment
? { ...(await getServerSideEnvironmentVariablesAsync()), EXPO_NO_DOTENV: '1' }
: undefined;
const fingerprint = await getFingerprintInfoFromLocalProjectForPlatformsAsync(
graphqlClient,
});
await FingerprintMutation.createFingerprintAsync(graphqlClient, projectId, {
hash: uploadedFingerprint.hash,
source: uploadedFingerprint.fingerprintSource,
});
return { fingerprint: projectFingerprint, origin: { type: FingerprintOriginType.Project } };
projectDir,
projectId,
vcsClient,
firstFingerprintPlatforms,
{ env }
);
return { fingerprint, origin: { type: FingerprintOriginType.Project } };
}
async function getFingerprintFromUpdateFragmentAsync(
@@ -528,7 +557,7 @@ async function getFingerprintInfoFromUpdateGroupIdOrUpdateIdAsync(
const project = await AppQuery.byIdAsync(graphqlClient, projectId);
const updateUrl =
getExpoWebsiteBaseUrl() +
`/accounts/${project.ownerAccount.name}/projects/${project.name}/updates/${maybeUpdateGroupId}`;
`/accounts/${project.ownerAccount.name}/projects/${project.slug}/updates/${maybeUpdateGroupId}`;
throw new Error(
`Please pass in your update ID from ${updateUrl} or use interactive mode to select the update ID.`
);
@@ -603,37 +632,6 @@ async function getFingerprintFromFingerprintFragmentAsync(
return (await fingerprintResponse.json()) as Fingerprint;
}
function getFingerprintOptionsFromWorkflow(
platforms: AppPlatform[],
workflowsByPlatform: Record<Platform, Workflow>
): { workflow?: Workflow; ignorePaths?: string[] } {
if (platforms.length === 0) {
throw new Error('Could not determine platform from fingerprint sources');
}
// Single platform case
if (platforms.length === 1) {
const platform = platforms[0];
return { workflow: workflowsByPlatform[appPlatformToPlatform(platform)] };
}
// Multiple platforms case
const workflows = platforms.map(platform => workflowsByPlatform[appPlatformToPlatform(platform)]);
// If all workflows are the same, return the common workflow
const [firstWorkflow, ...restWorkflows] = workflows;
if (restWorkflows.every(workflow => workflow === firstWorkflow)) {
return { workflow: firstWorkflow };
}
// Generate ignorePaths for mixed workflows
const ignorePaths = platforms
.filter(platform => workflowsByPlatform[appPlatformToPlatform(platform)] === Workflow.MANAGED)
.map(platform => `${appPlatformToString(platform)}/**/*`);
return { ignorePaths };
}
function printContentDiff(diff: FingerprintDiffItem): void {
if (diff.op === 'added') {
const sourceType = diff.addedSource.type;
@@ -739,7 +737,10 @@ const PRETTY_CONTENT_ID: Record<string, string> = {
'expoAutolinkingConfig:ios': 'Expo autolinking config (iOS)',
'expoAutolinkingConfig:android': 'Expo autolinking config (Android)',
'packageJson:scripts': 'package.json scripts',
expoConfig: 'Expo config',
expoConfig: 'Expo app config',
'package:react-native': 'React Native package.json',
'rncoreAutolinkingConfig:ios': 'React Native Community autolinking config (iOS)',
'rncoreAutolinkingConfig:android': 'React Native Community autolinking config (Android)',
};
function printContentSource({
@@ -809,40 +810,7 @@ function isJSON(str: string): boolean {
}
}
function appPlatformToPlatform(platform: AppPlatform): Platform {
switch (platform) {
case AppPlatform.Android:
return Platform.ANDROID;
case AppPlatform.Ios:
return Platform.IOS;
default:
throw new Error(`Unsupported platform: ${platform}`);
}
}
function appPlatformToString(platform: AppPlatform): string {
switch (platform) {
case AppPlatform.Android:
return 'android';
case AppPlatform.Ios:
return 'ios';
default:
throw new Error(`Unsupported platform: ${platform}`);
}
}
function stringToAppPlatform(platform: string): AppPlatform {
switch (platform) {
case 'android':
return AppPlatform.Android;
case 'ios':
return AppPlatform.Ios;
default:
throw new Error(`Unsupported platform: ${platform}`);
}
}
export async function selectBuildToCompareAsync(
async function selectBuildToCompareAsync(
graphqlClient: ExpoGraphqlClient,
projectId: string,
projectDisplayName: string,
@@ -0,0 +1,164 @@
import { Env } from '@expo/eas-build-job';
import { EasJsonAccessor } from '@expo/eas-json';
import { Flags } from '@oclif/core';
import { getExpoWebsiteBaseUrl } from '../../api';
import { evaluateConfigWithEnvVarsAsync } from '../../build/evaluateConfigWithEnvVarsAsync';
import EasCommand from '../../commandUtils/EasCommand';
import {
EASEnvironmentFlag,
EasEnvironmentFlagParameters,
EasNonInteractiveAndJsonFlags,
} from '../../commandUtils/flags';
import {
appPlatformToPlatform,
getFingerprintInfoFromLocalProjectForPlatformsAsync,
stringToAppPlatform,
} from '../../fingerprint/utils';
import { AppPlatform, EnvironmentVariableEnvironment } from '../../graphql/generated';
import { AppQuery } from '../../graphql/queries/AppQuery';
import Log, { link } from '../../log';
import { promptAsync } from '../../prompts';
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
import { getProfilesAsync } from '../../utils/profiles';
export default class FingerprintGenerate extends EasCommand {
static override description = 'generate fingerprints from the current project';
static override strict = false;
static override examples = [
'$ eas fingerprint:generate \t # Generate fingerprint in interactive mode',
'$ eas fingerprint:generate --build-profile preview \t # Generate a fingerprint using the "preview" build profile',
'$ eas fingerprint:generate --environment preview \t # Generate a fingerprint using the "preview" environment',
'$ eas fingerprint:generate --json --non-interactive --platform android \t # Output fingerprint json to stdout',
];
static override flags = {
platform: Flags.enum({
char: 'p',
options: ['android', 'ios'],
}),
...EASEnvironmentFlag,
environment: Flags.enum<EnvironmentVariableEnvironment>({
...EasEnvironmentFlagParameters,
exclusive: ['build-profile'],
}),
'build-profile': Flags.string({
char: 'e',
description: 'Name of the build profile from eas.json.',
exclusive: ['environment'],
}),
...EasNonInteractiveAndJsonFlags,
};
static override contextDefinition = {
...this.ContextOptions.ProjectId,
...this.ContextOptions.ProjectConfig,
...this.ContextOptions.LoggedIn,
...this.ContextOptions.Vcs,
...this.ContextOptions.ServerSideEnvironmentVariables,
...this.ContextOptions.DynamicProjectConfig,
};
async runAsync(): Promise<void> {
const { flags } = await this.parse(FingerprintGenerate);
const {
json,
'non-interactive': nonInteractive,
platform: platformStringFlag,
environment,
'build-profile': buildProfileName,
} = flags;
const {
projectId,
privateProjectConfig: { projectDir },
loggedIn: { graphqlClient },
vcsClient,
getServerSideEnvironmentVariablesAsync,
getDynamicPrivateProjectConfigAsync,
} = await this.getContextAsync(FingerprintGenerate, {
nonInteractive,
withServerSideEnvironment: environment ?? null,
});
if (json) {
enableJsonOutput();
}
let platform: AppPlatform;
if (platformStringFlag) {
platform = stringToAppPlatform(platformStringFlag);
} else {
if (nonInteractive) {
throw new Error('Platform must be specified in non-interactive mode with the --p flag');
}
platform = await selectRequestedPlatformAsync();
}
let env: Env | undefined = undefined;
if (environment) {
Log.log(`🔧 Using environment: ${environment}`);
env = { ...(await getServerSideEnvironmentVariablesAsync()), EXPO_NO_DOTENV: '1' };
} else if (buildProfileName) {
Log.log(`🔧 Using build profile: ${buildProfileName}`);
const easJsonAccessor = EasJsonAccessor.fromProjectPath(projectDir);
const buildProfile = (
await getProfilesAsync({
type: 'build',
easJsonAccessor,
platforms: [appPlatformToPlatform(platform)],
profileName: buildProfileName ?? undefined,
projectDir,
})
)[0];
if (!buildProfile) {
throw new Error(`Build profile ${buildProfile} not found for platform: ${platform}`);
}
const configResult = await evaluateConfigWithEnvVarsAsync({
buildProfile: buildProfile.profile,
buildProfileName: buildProfile.profileName,
graphqlClient,
getProjectConfig: getDynamicPrivateProjectConfigAsync,
opts: { env: buildProfile.profile.env },
});
env = configResult.env;
}
const fingerprint = await getFingerprintInfoFromLocalProjectForPlatformsAsync(
graphqlClient,
projectDir,
projectId,
vcsClient,
[platform],
{ env }
);
if (json) {
printJsonOnlyOutput(fingerprint);
return;
}
Log.log(`✅ Fingerprint generated: ${fingerprint.hash}`);
const project = await AppQuery.byIdAsync(graphqlClient, projectId);
const fingerprintUrl = new URL(
`/accounts/${project.ownerAccount.name}/projects/${project.slug}/fingerprints/${fingerprint.hash}`,
getExpoWebsiteBaseUrl()
);
Log.log(`🔍 View the fingerprint at ${link(fingerprintUrl.toString())}`);
Log.log(`💡 If you want to see the entire fingerprint output, pass in the --json flag.`);
}
}
async function selectRequestedPlatformAsync(): Promise<AppPlatform> {
const { requestedPlatform } = await promptAsync({
type: 'select',
message: 'Select platform',
name: 'requestedPlatform',
choices: [
{ title: 'Android', value: AppPlatform.Android },
{ title: 'iOS', value: AppPlatform.Ios },
],
});
return requestedPlatform;
}
@@ -86,7 +86,7 @@ export default class MetadataPull extends EasCommand {
- 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 config.
- ${learnMore('https://docs.expo.dev/eas-metadata/introduction/')}`);
- ${learnMore('https://docs.expo.dev/eas/metadata/')}`);
} catch (error: any) {
handleMetadataError(error);
}
@@ -0,0 +1,38 @@
import EasCommand from '../../commandUtils/EasCommand';
import Log from '../../log';
export default class New extends EasCommand {
static override aliases = ['new'];
static override description = "create a new project set up with Expo's services.";
static override flags = {};
static override args = [{ name: 'TARGET_PROJECT_DIRECTORY' }];
static override contextDefinition = {
...this.ContextOptions.LoggedIn,
...this.ContextOptions.Analytics,
};
async runAsync(): Promise<void> {
const {
args: { TARGET_PROJECT_DIRECTORY: targetProjectDirInput },
} = await this.parse(New);
const {
loggedIn: { actor, graphqlClient },
analytics,
} = await this.getContextAsync(New, {
nonInteractive: false,
});
if (actor.__typename === 'Robot') {
throw new Error(
'This command is not available for robot users. Make sure you are not using a robot token and try again.'
);
}
Log.log(`👋 Welcome to Expo, ${actor.username}!`);
}
}
@@ -307,7 +307,6 @@ export default class Onboarding extends EasCommand {
autoSubmit: false,
localBuildOptions: {},
freezeCredentials: false,
repack: true,
},
actor,
// eslint-disable-next-line async-protect/async-suffix
@@ -401,7 +400,7 @@ async function configureProjectFromBareDefaultExpoTemplateAsync({
const bundleIdentifier = `com.${userPrefix}${stripInvalidCharactersForBundleIdentifier(
app.ownerAccount.name
)}.${slugPrefix}${stripInvalidCharactersForBundleIdentifier(app.slug)}`;
const updateUrl = getEASUpdateURL(app.id);
const updateUrl = getEASUpdateURL(app.id, /* manifestHostOverride */ null);
const easBuildGitHubConfig = {
android: {
+19
View File
@@ -26,6 +26,7 @@ import { maybeWarnAboutEasOutagesAsync } from '../utils/statuspageService';
interface RawCommandFlags {
platform?: string;
profile?: string;
'what-to-test'?: string;
latest?: boolean;
id?: string;
path?: string;
@@ -34,16 +35,19 @@ interface RawCommandFlags {
wait: boolean;
'non-interactive': boolean;
'verbose-fastlane': boolean;
groups?: string[];
}
interface CommandFlags {
requestedPlatform: RequestedPlatform;
profile?: string;
whatToTest?: string;
archiveFlags: SubmitArchiveFlags;
verbose: boolean;
wait: boolean;
nonInteractive: boolean;
isVerboseFastlaneEnabled: boolean;
groups?: string[];
}
export default class Submit extends EasCommand {
@@ -76,6 +80,9 @@ export default class Submit extends EasCommand {
description: 'App archive url',
exclusive: ['latest', 'id', 'path'],
}),
'what-to-test': Flags.string({
description: `Sets the "What to test" information in TestFlight (iOS only).`,
}),
verbose: Flags.boolean({
description: 'Always print logs from EAS Submit',
default: false,
@@ -89,6 +96,12 @@ export default class Submit extends EasCommand {
default: false,
description: 'Enable verbose logging for the submission process',
}),
groups: Flags.string({
description:
'Internal TestFlight testing groups to add the build to (iOS only). Learn more: https://developer.apple.com/help/app-store-connect/test-a-beta-version/add-internal-testers',
multiple: true,
char: 'g',
}),
'non-interactive': Flags.boolean({
default: false,
description: 'Run command in non-interactive mode',
@@ -140,12 +153,14 @@ export default class Submit extends EasCommand {
archiveFlags: flagsWithPlatform.archiveFlags,
nonInteractive: flagsWithPlatform.nonInteractive,
isVerboseFastlaneEnabled: flagsWithPlatform.isVerboseFastlaneEnabled,
groups: flagsWithPlatform.groups,
actor,
graphqlClient,
analytics,
exp,
projectId,
vcsClient,
whatToTest: flagsWithPlatform.whatToTest,
specifiedProfile: flagsWithPlatform.profile,
});
@@ -184,6 +199,8 @@ export default class Submit extends EasCommand {
profile,
'non-interactive': nonInteractive,
'verbose-fastlane': isVerboseFastlaneEnabled,
groups,
'what-to-test': whatToTest,
...archiveFlags
} = flags;
@@ -204,7 +221,9 @@ export default class Submit extends EasCommand {
wait,
profile,
nonInteractive,
whatToTest,
isVerboseFastlaneEnabled,
groups,
};
}
@@ -96,6 +96,7 @@ export default class SubmitInternal extends EasCommand {
projectId,
vcsClient,
specifiedProfile: flags.profile,
groups: undefined, // use groups from submit profile
});
let config;
@@ -114,6 +115,7 @@ export default class SubmitInternal extends EasCommand {
const configInput: z.input<typeof SubmissionConfig.Ios.SchemaZ> = {
ascAppIdentifier: iosConfig.ascAppIdentifier,
isVerboseFastlaneEnabled: iosConfig.isVerboseFastlaneEnabled ?? undefined,
groups: iosConfig.groups ?? undefined,
...(ascApiKeyJson
? { ascApiJsonKey: ascApiKeyJson }
: {
@@ -139,7 +141,7 @@ export default class SubmitInternal extends EasCommand {
graphqlClient,
})
);
const track = graphQlTrackToConfigTrack[androidConfig.track];
const track = graphQlTrackToConfigTrack[androidConfig.track] ?? androidConfig.track;
const configInput: z.input<typeof SubmissionConfig.Android.SchemaZ> = {
changesNotSentForReview,
@@ -35,6 +35,7 @@ const updateStub: UpdateFragment = {
runtimeVersion: 'exposdk:47.0.0',
platform: 'ios',
gitCommitHash: 'commit',
isGitWorkingTreeDirty: false,
manifestFragment: JSON.stringify({ fake: 'manifest' }),
isRollBackToEmbedded: false,
manifestPermalink: 'https://expo.dev/fake/manifest/link',
@@ -32,6 +32,7 @@ const updateStub: UpdateFragment = {
runtimeVersion: 'exposdk:47.0.0',
platform: 'ios',
gitCommitHash: 'commit',
isGitWorkingTreeDirty: false,
manifestFragment: JSON.stringify({ fake: 'manifest' }),
isRollBackToEmbedded: false,
manifestPermalink: 'https://expo.dev/fake/manifest/link',
@@ -32,6 +32,7 @@ const updateStub: UpdateFragment = {
runtimeVersion: 'exposdk:47.0.0',
platform: 'ios',
gitCommitHash: 'commit',
isGitWorkingTreeDirty: false,
manifestFragment: JSON.stringify({ fake: 'manifest' }),
isRollBackToEmbedded: false,
manifestPermalink: 'https://expo.dev/fake/manifest/link',
@@ -1,3 +1,4 @@
import { EasJson, EasJsonAccessor, EasJsonUtils } from '@expo/eas-json';
import { Flags } from '@oclif/core';
import chalk from 'chalk';
@@ -47,6 +48,10 @@ export default class UpdateConfigure extends EasCommand {
await vcsClient.ensureRepoExistsAsync();
const easJsonAccessor = EasJsonAccessor.fromProjectPath(projectDir);
const easJsonCliConfig: EasJson['cli'] =
(await EasJsonUtils.getCliConfigAsync(easJsonAccessor)) ?? {};
await ensureEASUpdateIsConfiguredAsync({
exp,
projectId,
@@ -54,6 +59,8 @@ export default class UpdateConfigure extends EasCommand {
platform: flags['platform'],
vcsClient,
env: undefined,
forceNativeConfigSync: true,
manifestHostOverride: easJsonCliConfig.updateManifestHostOverride ?? null,
});
await ensureEASUpdateIsConfiguredInEasJsonAsync(projectDir);
+8 -37
View File
@@ -1,43 +1,12 @@
import chalk from 'chalk';
import gql from 'graphql-tag';
import EasCommand from '../../commandUtils/EasCommand';
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
import { EasNonInteractiveAndJsonFlags } from '../../commandUtils/flags';
import { withErrorHandlingAsync } from '../../graphql/client';
import {
DeleteUpdateGroupMutation,
UpdateMutationDeleteUpdateGroupArgs,
} from '../../graphql/generated';
import Log from '../../log';
import { confirmAsync } from '../../prompts';
import { scheduleUpdateGroupDeletionAsync } from '../../update/delete';
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
async function deleteUpdateGroupAsync(
graphqlClient: ExpoGraphqlClient,
{
group,
}: {
group: string;
}
): Promise<DeleteUpdateGroupMutation> {
return await withErrorHandlingAsync(
graphqlClient
.mutation<DeleteUpdateGroupMutation, UpdateMutationDeleteUpdateGroupArgs>(
gql`
mutation DeleteUpdateGroup($group: ID!) {
update {
deleteUpdateGroup(group: $group) {
group
}
}
}
`,
{ group }
)
.toPromise()
);
}
import { pollForBackgroundJobReceiptAsync } from '../../utils/pollForBackgroundJobReceiptAsync';
export default class UpdateDelete extends EasCommand {
static override description = 'delete all the updates in an update group';
@@ -73,7 +42,7 @@ export default class UpdateDelete extends EasCommand {
}
if (!nonInteractive) {
const shouldAbort = await confirmAsync({
const shouldContinue = await confirmAsync({
message:
`🚨${chalk.red('CAUTION')}🚨\n\n` +
`${chalk.yellow(`This will delete all of the updates in group "${group}".`)} ${chalk.red(
@@ -81,16 +50,18 @@ export default class UpdateDelete extends EasCommand {
)}\n\n` +
`If you want to revert to a previous publish, you should use 'update --republish' targeted at the last working update group instead.\n\n` +
`An update group should only be deleted in an emergency like an accidental publish of a secret. In this case user 'update --republish' to revert to the last working update group first and then proceed with the deletion. Deleting an update group when it is the latest publish can lead to inconsistent caching behavior by clients.\n\n` +
`Would you like to abort?`,
`Would you like to continue?`,
});
if (shouldAbort) {
if (!shouldContinue) {
Log.log('Aborted.');
return;
}
}
await deleteUpdateGroupAsync(graphqlClient, { group });
const receipt = await scheduleUpdateGroupDeletionAsync(graphqlClient, { group });
const successfulReceipt = await pollForBackgroundJobReceiptAsync(graphqlClient, receipt);
Log.debug('Deletion result', { successfulReceipt });
if (jsonFlag) {
printJsonOnlyOutput({ group });
+150 -79
View File
@@ -1,8 +1,10 @@
import { Workflow } from '@expo/eas-build-job';
import { EasJson, EasJsonAccessor, EasJsonUtils } from '@expo/eas-json';
import { Errors, Flags } from '@oclif/core';
import chalk from 'chalk';
import nullthrows from 'nullthrows';
import { getExpoWebsiteBaseUrl } from '../../api';
import { ensureBranchExistsAsync } from '../../branch/queries';
import { transformFingerprintSource } from '../../build/graphql';
import { ensureRepoIsCleanAsync } from '../../build/utils/repository';
@@ -12,8 +14,9 @@ import { EasNonInteractiveAndJsonFlags, EasUpdateEnvironmentFlag } from '../../c
import { getPaginatedQueryOptions } from '../../commandUtils/pagination';
import fetch from '../../fetch';
import {
AssetMapSourceInput,
EnvironmentVariableEnvironment,
FingerprintInfoGroup,
FingerprintInfoGroup as GraphqlFingerprintInfoGroup,
PublishUpdateGroupInput,
StatuspageServiceName,
UpdateInfoGroup,
@@ -21,9 +24,11 @@ import {
UpdateRolloutInfoGroup,
} from '../../graphql/generated';
import { PublishMutation } from '../../graphql/mutations/PublishMutation';
import { AppQuery } from '../../graphql/queries/AppQuery';
import Log, { learnMore, link } from '../../log';
import { ora } from '../../ora';
import { RequestedPlatform } from '../../platform';
import { maybeUploadAssetMapAsync } from '../../project/maybeUploadAssetMapAsync';
import { maybeUploadFingerprintAsync } from '../../project/maybeUploadFingerprintAsync';
import { getOwnerAccountForProjectIdAsync } from '../../project/projectUtils';
import {
@@ -33,6 +38,7 @@ import {
buildUnsortedUpdateInfoGroupAsync,
collectAssetsAsync,
filterCollectedAssetsByRequestedPlatforms,
findCompatibleBuildsAsync,
generateEasMetadataAsync,
getBranchNameForCommandAsync,
getRuntimeToPlatformsAndFingerprintInfoMappingFromRuntimeVersionInfoObjects,
@@ -206,6 +212,10 @@ export default class UpdatePublish extends EasCommand {
await maybeWarnAboutEasOutagesAsync(graphqlClient, [StatuspageServiceName.EasUpdate]);
const easJsonAccessor = EasJsonAccessor.fromProjectPath(projectDir);
const easJsonCliConfig: EasJson['cli'] =
(await EasJsonUtils.getCliConfigAsync(easJsonAccessor)) ?? {};
await ensureEASUpdateIsConfiguredAsync({
exp: expPossiblyWithoutEasUpdateConfigured,
platform: requestedPlatform,
@@ -213,6 +223,7 @@ export default class UpdatePublish extends EasCommand {
projectId,
vcsClient,
env: undefined,
manifestHostOverride: easJsonCliConfig.updateManifestHostOverride ?? null,
});
const { exp } = await getDynamicPublicProjectConfigAsync();
@@ -268,9 +279,14 @@ export default class UpdatePublish extends EasCommand {
let uploadedAssetCount = 0;
let assetLimitPerUpdateGroup = 0;
let realizedPlatforms: UpdatePublishPlatform[] = [];
let assetMapSource: AssetMapSourceInput | null = null;
try {
const collectedAssets = await collectAssetsAsync(distRoot);
const [collectedAssets, maybeAssetMapSource] = await Promise.all([
collectAssetsAsync(distRoot),
maybeUploadAssetMapAsync(distRoot, graphqlClient),
]);
assetMapSource = maybeAssetMapSource;
const assets = filterCollectedAssetsByRequestedPlatforms(collectedAssets, requestedPlatform);
realizedPlatforms = Object.keys(assets) as UpdatePublishPlatform[];
@@ -478,20 +494,27 @@ export default class UpdatePublish extends EasCommand {
},
};
},
{} as FingerprintInfoGroup
{} as GraphqlFingerprintInfoGroup
);
const assetMapGroup = assetMapSource
? Object.fromEntries(platforms.map(platform => [platform, assetMapSource]))
: null;
return {
branchId: branch.id,
updateInfoGroup: localUpdateInfoGroup,
rolloutInfoGroup: localRolloutInfoGroup,
fingerprintInfoGroup: transformedFingerprintInfoGroup,
assetMapGroup,
runtimeVersion,
message: updateMessage,
gitCommitHash,
isGitWorkingTreeDirty,
awaitingCodeSigningInfo: !!codeSigningInfo,
environment: environment ?? null,
manifestHostOverride: easJsonCliConfig.updateManifestHostOverride ?? null,
assetHostOverride: easJsonCliConfig.updateAssetHostOverride ?? null,
};
}
);
@@ -559,95 +582,143 @@ export default class UpdatePublish extends EasCommand {
}
if (jsonFlag) {
printJsonOnlyOutput(getUpdateJsonInfosForUpdates(newUpdates));
} else {
if (new Set(newUpdates.map(update => update.group)).size > 1) {
Log.addNewLineIfNone();
Log.log(
'👉 Since multiple runtime versions are defined, multiple update groups have been published.'
);
}
return;
}
if (new Set(newUpdates.map(update => update.group)).size > 1) {
Log.addNewLineIfNone();
Log.log(
'👉 Since multiple runtime versions are defined, multiple update groups have been published.'
);
}
for (const runtime of uniqBy(
runtimeToPlatformsAndFingerprintInfoMapping,
version => version.runtimeVersion
)) {
const newUpdatesForRuntimeVersion = newUpdates.filter(
update => update.runtimeVersion === runtime.runtimeVersion
Log.addNewLineIfNone();
const runtimeToCompatibleBuilds = await Promise.all(
runtimeToPlatformsAndFingerprintInfoAndFingerprintSourceMapping.map(obj =>
findCompatibleBuildsAsync(graphqlClient, projectId, obj)
)
);
for (const runtime of uniqBy(
runtimeToPlatformsAndFingerprintInfoMapping,
version => version.runtimeVersion
)) {
const newUpdatesForRuntimeVersion = newUpdates.filter(
update => update.runtimeVersion === runtime.runtimeVersion
);
if (newUpdatesForRuntimeVersion.length === 0) {
throw new Error(
`Publish response is missing updates with runtime ${runtime.runtimeVersion}.`
);
if (newUpdatesForRuntimeVersion.length === 0) {
throw new Error(
`Publish response is missing updates with runtime ${runtime.runtimeVersion}.`
);
}
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 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 = (await getOwnerAccountForProjectIdAsync(graphqlClient, projectId)).name;
const updateGroupUrl = getUpdateGroupUrl(accountName, projectName, updateGroupId);
const updateGroupLink = link(updateGroupUrl, { dim: false });
const projectName = exp.slug;
const accountName = (await getOwnerAccountForProjectIdAsync(graphqlClient, projectId)).name;
const updateGroupUrl = getUpdateGroupUrl(accountName, projectName, updateGroupId);
const updateGroupLink = link(updateGroupUrl, { dim: false });
Log.log(
formatFields([
{ label: 'Branch', value: branchName },
{ label: 'Runtime version', value: runtime.runtimeVersion },
{ 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 }] : []),
...(newAndroidUpdate?.rolloutControlUpdate
? [
{
label: 'Android Rollout',
value: `${newAndroidUpdate.rolloutPercentage}% (Base update ID: ${newAndroidUpdate.rolloutControlUpdate.id})`,
},
]
: []),
...(newIosUpdate?.rolloutControlUpdate
? [
{
label: 'iOS Rollout',
value: `${newIosUpdate.rolloutPercentage}% (Base update ID: ${newIosUpdate.rolloutControlUpdate.id})`,
},
]
: []),
{ label: 'Message', value: updateMessage ?? '' },
...(gitCommitHash
? [
{
label: 'Commit',
value: `${gitCommitHash}${isGitWorkingTreeDirty ? '*' : ''}`,
},
]
: []),
{ label: 'EAS Dashboard', value: updateGroupLink },
])
Log.log(
formatFields([
{ label: 'Branch', value: branchName },
{ label: 'Runtime version', value: runtime.runtimeVersion },
{ 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 }] : []),
...(newAndroidUpdate?.rolloutControlUpdate
? [
{
label: 'Android Rollout',
value: `${newAndroidUpdate.rolloutPercentage}% (Base update ID: ${newAndroidUpdate.rolloutControlUpdate.id})`,
},
]
: []),
...(newIosUpdate?.rolloutControlUpdate
? [
{
label: 'iOS Rollout',
value: `${newIosUpdate.rolloutPercentage}% (Base update ID: ${newIosUpdate.rolloutControlUpdate.id})`,
},
]
: []),
{ label: 'Message', value: updateMessage ?? '' },
...(gitCommitHash
? [
{
label: 'Commit',
value: `${gitCommitHash}${isGitWorkingTreeDirty ? '*' : ''}`,
},
]
: []),
{ label: 'EAS Dashboard', value: updateGroupLink },
])
);
Log.addNewLineIfNone();
if (isUploadedAssetCountAboveWarningThreshold(uploadedAssetCount, assetLimitPerUpdateGroup)) {
Log.warn(
`This update group contains ${uploadedAssetCount} assets and is nearing the server cap of ${assetLimitPerUpdateGroup}.\n` +
`${learnMore('https://docs.expo.dev/eas-update/optimize-assets/', {
learnMoreMessage: 'Consider optimizing your usage of assets',
dim: false,
})}.`
);
Log.addNewLineIfNone();
if (
isUploadedAssetCountAboveWarningThreshold(uploadedAssetCount, assetLimitPerUpdateGroup)
) {
Log.warn(
`This update group contains ${uploadedAssetCount} assets and is nearing the server cap of ${assetLimitPerUpdateGroup}.\n` +
`${learnMore('https://docs.expo.dev/eas-update/optimize-assets/', {
learnMoreMessage: 'Consider optimizing your usage of assets',
dim: false,
})}.`
);
Log.addNewLineIfNone();
}
const fingerprintsWithoutCompatibleBuilds = runtimeToCompatibleBuilds.find(
({ runtimeVersion }) => runtimeVersion === runtime.runtimeVersion
)?.fingerprintInfoGroupWithCompatibleBuilds;
if (fingerprintsWithoutCompatibleBuilds) {
const missingBuilds = Object.entries(fingerprintsWithoutCompatibleBuilds).filter(
([_platform, fingerprintInfo]) => !fingerprintInfo.build
);
if (missingBuilds.length > 0) {
const project = await AppQuery.byIdAsync(graphqlClient, projectId);
Log.warn('No compatible builds found for the following fingerprints:');
for (const [platform, fingerprintInfo] of missingBuilds) {
const fingerprintUrl = new URL(
`/accounts/${project.ownerAccount.name}/projects/${project.slug}/fingerprints/${fingerprintInfo.fingerprintHash}`,
getExpoWebsiteBaseUrl()
);
Log.warn(
formatFields(
[
{
label: `${this.prettyPlatform(platform)} fingerprint`,
value: fingerprintInfo.fingerprintHash,
},
{ label: 'URL', value: fingerprintUrl.toString() },
],
{
labelFormat: label => ` ${chalk.dim(label)}:`,
}
)
);
Log.addNewLineIfNone();
}
}
}
}
}
private prettyPlatform(updatePlatform: string): string {
switch (updatePlatform) {
case 'android':
return 'Android';
case 'ios':
return 'iOS';
default:
return updatePlatform;
}
}
private sanitizeFlags(flags: RawUpdateFlags): UpdateFlags {
const nonInteractive = flags['non-interactive'] ?? false;
+22 -184
View File
@@ -1,20 +1,18 @@
import { Platform } from '@expo/config';
import { Flags } from '@oclif/core';
import { selectBranchOnAppAsync } from '../../branch/queries';
import { selectChannelOnAppAsync } from '../../channel/queries';
import EasCommand from '../../commandUtils/EasCommand';
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
import { EasNonInteractiveAndJsonFlags } from '../../commandUtils/flags';
import { getPaginatedQueryOptions } from '../../commandUtils/pagination';
import { BranchQuery } from '../../graphql/queries/BranchQuery';
import { UpdateQuery } from '../../graphql/queries/UpdateQuery';
import Log from '../../log';
import { promptAsync } from '../../prompts';
import { getBranchFromChannelNameAndCreateAndLinkIfNotExistsAsync } from '../../update/getBranchFromChannelNameAndCreateAndLinkIfNotExistsAsync';
import { selectUpdateGroupOnBranchAsync } from '../../update/queries';
import { UpdateToRepublish, republishAsync } from '../../update/republish';
import { truncateString as truncateUpdateMessage } from '../../update/utils';
import {
UpdateToRepublish,
getOrAskUpdateMessageAsync,
getUpdateGroupOrAskForUpdateGroupAsync,
republishAsync,
} from '../../update/republish';
import { getCodeSigningInfoAsync } from '../../utils/code-signing';
import { enableJsonOutput } from '../../utils/json';
@@ -31,6 +29,7 @@ type UpdateRepublishRawFlags = {
'private-key-path'?: string;
'non-interactive': boolean;
json?: boolean;
'rollout-percentage'?: number;
};
type UpdateRepublishFlags = {
@@ -44,6 +43,7 @@ type UpdateRepublishFlags = {
privateKeyPath?: string;
nonInteractive: boolean;
json: boolean;
rolloutPercentage?: number;
};
export default class UpdateRepublish extends EasCommand {
@@ -86,6 +86,12 @@ export default class UpdateRepublish extends EasCommand {
description: `File containing the PEM-encoded private key corresponding to the certificate in expo-updates' configuration. Defaults to a file named "private-key.pem" in the certificate's directory. Only relevant if you are using code signing: https://docs.expo.dev/eas-update/code-signing/`,
required: false,
}),
'rollout-percentage': Flags.integer({
description: `Percentage of users this update should be immediately available to. Users not in the rollout will be served the previous latest update on the branch, even if that update is itself being rolled out. The specified number must be an integer between 1 and 100. When not specified, this defaults to 100.`,
required: false,
min: 0,
max: 100,
}),
...EasNonInteractiveAndJsonFlags,
};
@@ -112,7 +118,11 @@ export default class UpdateRepublish extends EasCommand {
const codeSigningInfo = await getCodeSigningInfoAsync(exp, flags.privateKeyPath);
const existingUpdates = await getOrAskUpdatesAsync(graphqlClient, projectId, flags);
const existingUpdates = await getUpdateGroupOrAskForUpdateGroupAsync(
graphqlClient,
projectId,
flags
);
const updatesToPublish = existingUpdates.filter(update =>
flags.platform.includes(update.platform as Platform)
);
@@ -130,7 +140,7 @@ export default class UpdateRepublish extends EasCommand {
);
}
if (rawFlags.platform === 'all') {
if (rawFlags.platform !== 'all') {
Log.withTick(`The republished update group will appear only on: ${rawFlags.platform}`);
} else {
const platformsFromUpdates = updatesToPublish.map(update => update.platform);
@@ -163,6 +173,7 @@ export default class UpdateRepublish extends EasCommand {
updateMessage,
codeSigningInfo,
json: flags.json,
rolloutPercentage: flags.rolloutPercentage,
});
}
@@ -191,6 +202,7 @@ export default class UpdateRepublish extends EasCommand {
platform,
updateMessage: rawFlags.message,
privateKeyPath,
rolloutPercentage: rawFlags['rollout-percentage'],
json: rawFlags.json ?? false,
nonInteractive,
};
@@ -224,177 +236,3 @@ async function getOrAskTargetBranchAsync(
// if neither provided, assume republish on same branch
return { branchId: arbitraryUpdate.branchId, branchName: arbitraryUpdate.branchName };
}
/** Retrieve the update group from either the update group id, or select from branch name. */
async function getOrAskUpdatesAsync(
graphqlClient: ExpoGraphqlClient,
projectId: string,
flags: UpdateRepublishFlags
): Promise<UpdateToRepublish[]> {
if (flags.groupId) {
const updateGroup = await UpdateQuery.viewUpdateGroupAsync(graphqlClient, {
groupId: flags.groupId,
});
return updateGroup.map(update => ({
...update,
groupId: update.group,
branchId: update.branch.id,
branchName: update.branch.name,
}));
}
if (flags.nonInteractive) {
throw new Error('Must supply --group when in non-interactive mode');
}
if (flags.branchName) {
return await askUpdatesFromBranchNameAsync(graphqlClient, {
...flags,
branchName: flags.branchName,
projectId,
});
}
if (flags.channelName) {
return await askUpdatesFromChannelNameAsync(graphqlClient, {
...flags,
channelName: flags.channelName,
projectId,
});
}
const { choice } = await promptAsync({
type: 'select',
message: 'Find update by branch or channel?',
name: 'choice',
choices: [
{ title: 'Branch', value: 'branch' },
{ title: 'Channel', value: 'channel' },
],
});
if (choice === 'channel') {
const { name } = await selectChannelOnAppAsync(graphqlClient, {
projectId,
selectionPromptTitle: 'Select a channel to view',
paginatedQueryOptions: {
json: flags.json,
nonInteractive: flags.nonInteractive,
offset: 0,
},
});
return await askUpdatesFromChannelNameAsync(graphqlClient, {
...flags,
channelName: name,
projectId,
});
} else if (choice === 'branch') {
const { name } = await selectBranchOnAppAsync(graphqlClient, {
projectId,
promptTitle: 'Select branch from which to choose update',
displayTextForListItem: updateBranch => ({
title: updateBranch.name,
}),
// discard limit and offset because this query is not their intended target
paginatedQueryOptions: {
json: flags.json,
nonInteractive: flags.nonInteractive,
offset: 0,
},
});
return await askUpdatesFromBranchNameAsync(graphqlClient, {
...flags,
branchName: name,
projectId,
});
} else {
throw new Error('Must choose update via channel or branch');
}
}
/** Ask the user which update needs to be republished by branch name, this requires interactive mode */
async function askUpdatesFromBranchNameAsync(
graphqlClient: ExpoGraphqlClient,
{
projectId,
branchName,
json,
nonInteractive,
}: { projectId: string; branchName: string; json: boolean; nonInteractive: boolean }
): Promise<UpdateToRepublish[]> {
const updateGroup = await selectUpdateGroupOnBranchAsync(graphqlClient, {
projectId,
branchName,
paginatedQueryOptions: getPaginatedQueryOptions({ json, 'non-interactive': nonInteractive }),
});
return updateGroup.map(update => ({
...update,
groupId: update.group,
branchId: update.branch.id,
branchName: update.branch.name,
}));
}
/** Ask the user which update needs to be republished by channel name, this requires interactive mode */
async function askUpdatesFromChannelNameAsync(
graphqlClient: ExpoGraphqlClient,
{
projectId,
channelName,
json,
nonInteractive,
}: { projectId: string; channelName: string; json: boolean; nonInteractive: boolean }
): Promise<UpdateToRepublish[]> {
const { branchName } = await getBranchFromChannelNameAndCreateAndLinkIfNotExistsAsync(
graphqlClient,
projectId,
channelName
);
return await askUpdatesFromBranchNameAsync(graphqlClient, {
projectId,
branchName,
json,
nonInteractive,
});
}
/** Get or ask the user for the update (group) message for the republish */
async function getOrAskUpdateMessageAsync(
updates: UpdateToRepublish[],
flags: UpdateRepublishFlags
): Promise<string> {
if (flags.updateMessage) {
return sanitizeUpdateMessage(flags.updateMessage);
}
if (flags.nonInteractive || flags.json) {
throw new Error('Must supply --message when in non-interactive mode');
}
// This command only uses a single update group to republish, meaning these values are always identical
const oldGroupId = updates[0].groupId;
const oldUpdateMessage = updates[0].message;
const { updateMessage } = await promptAsync({
type: 'text',
name: 'updateMessage',
message: 'Provide an update message.',
initial: `Republish "${oldUpdateMessage!}" - group: ${oldGroupId}`,
validate: (value: any) => (value ? true : 'Update message may not be empty.'),
});
return sanitizeUpdateMessage(updateMessage);
}
function sanitizeUpdateMessage(updateMessage: string): string {
if (updateMessage !== truncateUpdateMessage(updateMessage, 1024)) {
Log.warn('Update message exceeds the allowed 1024 character limit, truncated update message.');
return truncateUpdateMessage(updateMessage, 1024);
}
return updateMessage;
}
@@ -0,0 +1,416 @@
import { ExpoConfig } from '@expo/config';
import { Flags } from '@oclif/core';
import assert from 'assert';
import EasCommand from '../../commandUtils/EasCommand';
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
import { EasNonInteractiveAndJsonFlags } from '../../commandUtils/flags';
import Log, { learnMore } from '../../log';
import { enforceRollBackToEmbeddedUpdateSupportAsync } from '../../project/projectUtils';
import { UpdatePublishPlatform, getUpdateMessageForCommandAsync } from '../../project/publish';
import { confirmAsync, promptAsync } from '../../prompts';
import { scheduleUpdateGroupDeletionAsync } from '../../update/delete';
import {
UpdateToRepublish,
askUpdateGroupForEachPublishPlatformFilteringByRuntimeVersionAsync,
getOrAskUpdateMessageAsync,
getUpdateGroupAsync,
republishAsync,
} from '../../update/republish';
import { publishRollBackToEmbeddedUpdateAsync } from '../../update/roll-back-to-embedded';
import { CodeSigningInfo, getCodeSigningInfoAsync } from '../../utils/code-signing';
import { enableJsonOutput } from '../../utils/json';
import { pollForBackgroundJobReceiptAsync } from '../../utils/pollForBackgroundJobReceiptAsync';
import { Client } from '../../vcs/vcs';
type RolloutUpdate = UpdateToRepublish & {
rolloutPercentage: NonNullable<UpdateToRepublish['rolloutPercentage']>;
};
type RolloutUpdateWithControlUpdate = RolloutUpdate & {
rolloutControlUpdate: NonNullable<UpdateToRepublish['rolloutControlUpdate']>;
};
type UpdateRevertUpdateRolloutRawFlags = {
branch?: string;
channel?: string;
group?: string;
message?: string;
'private-key-path'?: string;
'non-interactive': boolean;
json?: boolean;
};
type UpdateRevertUpdateRolloutFlags = {
branchName?: string;
channelName?: string;
groupId?: string;
updateMessage?: string;
privateKeyPath?: string;
nonInteractive: boolean;
json: boolean;
};
export function nonNullish<TValue>(value: TValue | null | undefined): value is NonNullable<TValue> {
return value !== null && value !== undefined;
}
export default class UpdateRevertUpdateRollout extends EasCommand {
static override description = 'revert a rollout update for a project';
static override flags = {
channel: Flags.string({
description: 'Channel name to select an update group to revert the rollout update from',
exclusive: ['branch', 'group'],
}),
branch: Flags.string({
description: 'Branch name to select an update group to revert the rollout update from',
exclusive: ['channel', 'group'],
}),
group: Flags.string({
description: 'Rollout update group ID to revert',
exclusive: ['branch', 'channel'],
}),
message: Flags.string({
char: 'm',
description: 'Short message describing the revert',
required: false,
}),
'private-key-path': Flags.string({
description: `File containing the PEM-encoded private key corresponding to the certificate in expo-updates' configuration. Defaults to a file named "private-key.pem" in the certificate's directory. Only relevant if you are using code signing: https://docs.expo.dev/eas-update/code-signing/`,
required: false,
}),
...EasNonInteractiveAndJsonFlags,
};
static override contextDefinition = {
...this.ContextOptions.ProjectConfig,
...this.ContextOptions.LoggedIn,
...this.ContextOptions.Vcs,
};
async runAsync(): Promise<void> {
const { flags: rawFlags } = await this.parse(UpdateRevertUpdateRollout);
const flags = this.sanitizeFlags(rawFlags);
const {
privateProjectConfig: { exp, projectId, projectDir },
loggedIn: { graphqlClient },
vcsClient,
} = await this.getContextAsync(UpdateRevertUpdateRollout, {
nonInteractive: flags.nonInteractive,
withServerSideEnvironment: null,
});
if (flags.json) {
enableJsonOutput();
}
const codeSigningInfo = await getCodeSigningInfoAsync(exp, flags.privateKeyPath);
let updateGroupToRepublish: RolloutUpdate[];
if (flags.groupId) {
const updateGroup = await getUpdateGroupAsync(graphqlClient, flags.groupId);
if (!updateGroupIsRolloutUpdateGroup(updateGroup)) {
throw new Error(
`The update group with ID "${flags.groupId}" is not a rollout update group.`
);
}
updateGroupToRepublish = updateGroup;
} else {
const latestUpdateGroupForEachPublishPlatform =
await askUpdateGroupForEachPublishPlatformFilteringByRuntimeVersionAsync(
graphqlClient,
projectId,
flags
);
const uniqueUpdateGroups = getUniqueUpdateGroups(
Object.values(latestUpdateGroupForEachPublishPlatform).filter(nonNullish)
);
const rolloutUpdateGroups = uniqueUpdateGroups.filter(updateGroupIsRolloutUpdateGroup);
if (rolloutUpdateGroups.length === 0) {
throw new Error(`No rollout update groups found.`);
}
if (rolloutUpdateGroups.length === 1) {
updateGroupToRepublish = rolloutUpdateGroups[0];
} else {
const { choice: chosenId } = await promptAsync({
type: 'select',
message: 'Which rollout update group would you like to revert?',
name: 'choice',
choices: rolloutUpdateGroups.map(ug => ({
title: `Rollout update group ID: ${ug[0].groupId}, Platform: ${ug[0].platform}, Rollout Percentage: ${ug[0].rolloutPercentage}`,
value: ug[0].groupId,
})),
});
if (!chosenId) {
throw new Error('No rollout update group selected.');
}
const chosenUpdateGroup = rolloutUpdateGroups.find(ug => ug[0].groupId === chosenId);
if (!chosenUpdateGroup) {
throw new Error('No rollout update group selected.');
}
updateGroupToRepublish = chosenUpdateGroup;
}
}
const rolloutUpdateGroupWithControlUpdates: RolloutUpdateWithControlUpdate[] | null =
updateGroupIsUpdateGroupWithControlUpdate(updateGroupToRepublish)
? updateGroupToRepublish
: null;
if (rolloutUpdateGroupWithControlUpdates) {
await this.deleteRolloutUpdateGroupAndRepublishControlUpdatesAsync({
graphqlClient,
exp,
projectId,
rolloutUpdateGroupWithControlUpdates,
codeSigningInfo,
flags,
});
} else {
await this.deleteRolloutUpdateGroupAndPublishRollBackToEmbeddedAsync({
graphqlClient,
vcsClient,
exp,
projectDir,
projectId,
rolloutUpdateGroup: updateGroupToRepublish,
codeSigningInfo,
flags,
});
}
}
private async deleteRolloutUpdateGroupAndRepublishControlUpdatesAsync({
graphqlClient,
exp,
projectId,
rolloutUpdateGroupWithControlUpdates,
codeSigningInfo,
flags,
}: {
graphqlClient: ExpoGraphqlClient;
exp: ExpoConfig;
projectId: string;
rolloutUpdateGroupWithControlUpdates: RolloutUpdateWithControlUpdate[];
codeSigningInfo: CodeSigningInfo | undefined;
flags: UpdateRevertUpdateRolloutFlags;
}): Promise<void> {
const controlUpdateGroupIdsToRepublish = Array.from(
new Set(rolloutUpdateGroupWithControlUpdates.map(update => update.rolloutControlUpdate.group))
);
const updateGroupsToRepublish = await Promise.all(
controlUpdateGroupIdsToRepublish.map(controlUpdateGroupIdToRepublish =>
getUpdateGroupAsync(graphqlClient, controlUpdateGroupIdToRepublish)
)
);
const updateGroupOrGroupsClause =
controlUpdateGroupIdsToRepublish.length > 1
? `control update groups (IDs: ${controlUpdateGroupIdsToRepublish
.map(id => `"${id}"`)
.join(', ')})`
: `control update group (ID: "${controlUpdateGroupIdsToRepublish[0]}")`;
if (!flags.nonInteractive) {
const confirmMessage = `Are you sure you want to revert the rollout update group with ID "${rolloutUpdateGroupWithControlUpdates[0].groupId}"? This will delete the rollout update group and republish the ${updateGroupOrGroupsClause}.`;
const didConfirm = await confirmAsync({ message: confirmMessage });
if (!didConfirm) {
throw new Error('Aborting...');
}
}
const updateMessages: string[] = [];
for (const updateGroup of updateGroupsToRepublish) {
updateMessages.push(await getOrAskUpdateMessageAsync(updateGroup, flags));
}
// assert all updateGroupsToRepublish have the same branch name and id
const branchNames = updateGroupsToRepublish.flatMap(updateGroup => updateGroup[0].branchName);
const branchIds = updateGroupsToRepublish.map(updateGroup => updateGroup[0].branchId);
assert(
branchNames.every(name => name === branchNames[0]),
'All update groups being republished must belong to the same branch.'
);
assert(
branchIds.every(id => id === branchIds[0]),
'All update groups being republished must belong to the same branch.'
);
const targetBranch = {
branchName: branchNames[0],
branchId: branchIds[0],
};
await this.deleteRolloutUpdateGroupAsync({
graphqlClient,
rolloutUpdateGroup: rolloutUpdateGroupWithControlUpdates,
});
for (let i = 0; i < updateGroupsToRepublish.length; i++) {
const updateGroupToRepublish = updateGroupsToRepublish[i];
const updateMessage = updateMessages[i];
await republishAsync({
graphqlClient,
app: { exp, projectId },
updatesToPublish: updateGroupToRepublish,
targetBranch,
updateMessage,
codeSigningInfo,
json: flags.json,
});
}
}
private async deleteRolloutUpdateGroupAndPublishRollBackToEmbeddedAsync({
graphqlClient,
vcsClient,
exp,
projectDir,
projectId,
rolloutUpdateGroup,
codeSigningInfo,
flags,
}: {
graphqlClient: ExpoGraphqlClient;
vcsClient: Client;
exp: ExpoConfig;
projectDir: string;
projectId: string;
rolloutUpdateGroup: RolloutUpdate[];
codeSigningInfo: CodeSigningInfo | undefined;
flags: UpdateRevertUpdateRolloutFlags;
}): Promise<void> {
const rolloutUpdateGroupId = rolloutUpdateGroup[0].groupId;
if (!flags.nonInteractive) {
const confirmMessage = `Are you sure you want to revert the rollout update group with ID "${rolloutUpdateGroupId}"? This will delete the rollout update group and publish a new roll-back-to-embedded update (no control update to roll back to), whose behavior may not be a true revert depending on the previous state of the branch. ${learnMore(
'https://expo.fyi/eas-update-update-rollouts',
{ learnMoreMessage: 'More info' }
)})`;
const didConfirm = await confirmAsync({ message: confirmMessage });
if (!didConfirm) {
throw new Error('Aborting...');
}
}
// check that the expo-updates package version supports roll back to embedded
await enforceRollBackToEmbeddedUpdateSupportAsync(projectDir);
const updateMessage = await getUpdateMessageForCommandAsync(vcsClient, {
updateMessageArg: flags.updateMessage,
autoFlag: false,
nonInteractive: flags.nonInteractive,
jsonFlag: flags.json,
});
await this.deleteRolloutUpdateGroupAsync({
graphqlClient,
rolloutUpdateGroup,
});
const platforms = rolloutUpdateGroup.map(update => update.platform) as UpdatePublishPlatform[];
const runtimeVersion = rolloutUpdateGroup[0].runtimeVersion;
const targetBranch = {
name: rolloutUpdateGroup[0].branchName,
id: rolloutUpdateGroup[0].branchId,
};
await publishRollBackToEmbeddedUpdateAsync({
graphqlClient,
projectId,
exp,
updateMessage,
branch: targetBranch,
codeSigningInfo,
platforms,
runtimeVersion,
json: flags.json,
});
}
private async deleteRolloutUpdateGroupAsync({
graphqlClient,
rolloutUpdateGroup,
}: {
graphqlClient: ExpoGraphqlClient;
rolloutUpdateGroup: RolloutUpdate[];
}): Promise<void> {
const rolloutUpdateGroupId = rolloutUpdateGroup[0].groupId;
const updateGroupDeletionReceipt = await scheduleUpdateGroupDeletionAsync(graphqlClient, {
group: rolloutUpdateGroupId,
});
const successfulReceipt = await pollForBackgroundJobReceiptAsync(
graphqlClient,
updateGroupDeletionReceipt
);
Log.debug('Rollout update group deletion result', { successfulReceipt });
}
private sanitizeFlags(
rawFlags: UpdateRevertUpdateRolloutRawFlags
): UpdateRevertUpdateRolloutFlags {
const branchName = rawFlags.branch;
const channelName = rawFlags.channel;
const groupId = rawFlags.group;
const nonInteractive = rawFlags['non-interactive'];
const privateKeyPath = rawFlags['private-key-path'];
if (nonInteractive && !groupId) {
throw new Error('Only --group can be used in non-interactive mode');
}
return {
branchName,
channelName,
groupId,
updateMessage: rawFlags.message,
privateKeyPath,
json: rawFlags.json ?? false,
nonInteractive,
};
}
}
function getUniqueUpdateGroups(updateGroups: UpdateToRepublish[][]): UpdateToRepublish[][] {
const uniqueUpdateGroups = new Map<string, UpdateToRepublish[]>();
for (const updateGroup of updateGroups) {
const groupId = updateGroup[0].groupId;
if (!uniqueUpdateGroups.has(groupId)) {
uniqueUpdateGroups.set(groupId, updateGroup);
}
}
return Array.from(uniqueUpdateGroups.values());
}
function updateGroupIsRolloutUpdateGroup(
updateGroup: UpdateToRepublish[]
): updateGroup is RolloutUpdate[] {
return updateGroup.every(updateIsRolloutUpdate);
}
function updateIsRolloutUpdate(updateGroup: UpdateToRepublish): updateGroup is RolloutUpdate {
return updateGroup.rolloutPercentage !== undefined && updateGroup.rolloutPercentage !== null;
}
function updateGroupIsUpdateGroupWithControlUpdate(
updateGroup: RolloutUpdate[]
): updateGroup is RolloutUpdateWithControlUpdate[] {
return updateGroup.every(updateIsRolloutWithControlUpdate);
}
function updateIsRolloutWithControlUpdate(
updateGroup: RolloutUpdate
): updateGroup is RolloutUpdateWithControlUpdate {
return !!updateGroup.rolloutControlUpdate;
}
@@ -1,50 +1,24 @@
import { Platform as PublishPlatform } from '@expo/config';
import { EasJson, EasJsonAccessor, EasJsonUtils } from '@expo/eas-json';
import { Errors, Flags } from '@oclif/core';
import nullthrows from 'nullthrows';
import { ensureBranchExistsAsync } from '../../branch/queries';
import { getUpdateGroupUrl } from '../../build/utils/url';
import EasCommand from '../../commandUtils/EasCommand';
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
import { EasNonInteractiveAndJsonFlags } from '../../commandUtils/flags';
import { getPaginatedQueryOptions } from '../../commandUtils/pagination';
import fetch from '../../fetch';
import {
PublishUpdateGroupInput,
RuntimeFragment,
StatuspageServiceName,
UpdatePublishMutation,
} from '../../graphql/generated';
import { PublishMutation } from '../../graphql/mutations/PublishMutation';
import { RuntimeQuery } from '../../graphql/queries/RuntimeQuery';
import Log, { link } from '../../log';
import { ora } from '../../ora';
import { StatuspageServiceName } from '../../graphql/generated';
import { RequestedPlatform } from '../../platform';
import { enforceRollBackToEmbeddedUpdateSupportAsync } from '../../project/projectUtils';
import {
enforceRollBackToEmbeddedUpdateSupportAsync,
getOwnerAccountForProjectIdAsync,
} from '../../project/projectUtils';
import {
RuntimeVersionInfo,
UpdatePublishPlatform,
defaultPublishPlatforms,
getBranchNameForCommandAsync,
getRuntimeToPlatformsAndFingerprintInfoMappingFromRuntimeVersionInfoObjects,
getUpdateMessageForCommandAsync,
} from '../../project/publish';
import { ensureEASUpdateIsConfiguredAsync } from '../../update/configure';
import { getUpdateJsonInfosForUpdates } from '../../update/utils';
import {
CodeSigningInfo,
checkDirectiveBodyAgainstUpdateInfoGroup,
getCodeSigningInfoAsync,
getDirectiveBodyAsync,
signBody,
} from '../../utils/code-signing';
import uniqBy from '../../utils/expodash/uniqBy';
import formatFields from '../../utils/formatFields';
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
import { Connection, QueryParams, selectPaginatedAsync } from '../../utils/relay';
import { selectRuntimeOnBranchAsync } from '../../update/queries';
import { publishRollBackToEmbeddedUpdateAsync } from '../../update/roll-back-to-embedded';
import { getCodeSigningInfoAsync } from '../../utils/code-signing';
import { enableJsonOutput } from '../../utils/json';
import { maybeWarnAboutEasOutagesAsync } from '../../utils/statuspageService';
type RawUpdateFlags = {
@@ -148,6 +122,10 @@ export default class UpdateRollBackToEmbedded extends EasCommand {
await maybeWarnAboutEasOutagesAsync(graphqlClient, [StatuspageServiceName.EasUpdate]);
const easJsonAccessor = EasJsonAccessor.fromProjectPath(projectDir);
const easJsonCliConfig: EasJson['cli'] =
(await EasJsonUtils.getCliConfigAsync(easJsonAccessor)) ?? {};
await ensureEASUpdateIsConfiguredAsync({
exp: expPossiblyWithoutEasUpdateConfigured,
platform: platformFlag,
@@ -155,6 +133,7 @@ export default class UpdateRollBackToEmbedded extends EasCommand {
projectId,
vcsClient,
env: undefined,
manifestHostOverride: easJsonCliConfig.updateManifestHostOverride ?? null,
});
// check that the expo-updates package version supports roll back to embedded
@@ -193,7 +172,7 @@ export default class UpdateRollBackToEmbedded extends EasCommand {
const selectedRuntime =
runtimeVersionArg ??
(
await UpdateRollBackToEmbedded.selectRuntimeAsync(graphqlClient, {
await selectRuntimeOnBranchAsync(graphqlClient, {
appId: projectId,
branchName,
})
@@ -202,196 +181,16 @@ export default class UpdateRollBackToEmbedded extends EasCommand {
Errors.error('Must select a runtime or provide the --runtimeVersion flag', { exit: 1 });
}
const runtimeToPlatformsAndFingerprintInfoMapping =
getRuntimeToPlatformsAndFingerprintInfoMappingFromRuntimeVersionInfoObjects(
realizedPlatforms.map(platform => ({
platform,
runtimeVersionInfo: {
runtimeVersion: selectedRuntime,
expoUpdatesRuntimeFingerprint: null,
expoUpdatesRuntimeFingerprintHash: null,
},
}))
);
let newUpdates: UpdatePublishMutation['updateBranch']['publishUpdateGroups'];
const publishSpinner = ora('Publishing...').start();
try {
newUpdates = await this.publishRollbacksAsync({
graphqlClient,
updateMessage,
branchId: branch.id,
codeSigningInfo,
runtimeToPlatformsAndFingerprintInfoMapping,
realizedPlatforms,
});
publishSpinner.succeed('Published!');
} catch (e) {
publishSpinner.fail('Failed to publish updates');
throw e;
}
if (jsonFlag) {
printJsonOnlyOutput(getUpdateJsonInfosForUpdates(newUpdates));
} else {
Log.addNewLineIfNone();
for (const runtime of uniqBy(
runtimeToPlatformsAndFingerprintInfoMapping,
version => version.runtimeVersion
)) {
const newUpdatesForRuntimeVersion = newUpdates.filter(
update => update.runtimeVersion === runtime.runtimeVersion
);
if (newUpdatesForRuntimeVersion.length === 0) {
throw new Error(
`Publish response is missing updates with runtime ${runtime.runtimeVersion}.`
);
}
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 = (await getOwnerAccountForProjectIdAsync(graphqlClient, projectId)).name;
const updateGroupUrl = getUpdateGroupUrl(accountName, projectName, updateGroupId);
const updateGroupLink = link(updateGroupUrl, { dim: false });
Log.log(
formatFields([
{ label: 'Branch', value: branchName },
{ label: 'Runtime version', value: runtime.runtimeVersion },
{ 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: updateMessage ?? '' },
{ label: 'EAS Dashboard', value: updateGroupLink },
])
);
Log.addNewLineIfNone();
}
}
}
private async publishRollbacksAsync({
graphqlClient,
updateMessage,
branchId,
codeSigningInfo,
runtimeToPlatformsAndFingerprintInfoMapping,
realizedPlatforms,
}: {
graphqlClient: ExpoGraphqlClient;
updateMessage: string | undefined;
branchId: string;
codeSigningInfo: CodeSigningInfo | undefined;
runtimeToPlatformsAndFingerprintInfoMapping: (RuntimeVersionInfo & {
platforms: UpdatePublishPlatform[];
})[];
realizedPlatforms: PublishPlatform[];
}): Promise<UpdatePublishMutation['updateBranch']['publishUpdateGroups']> {
const rollbackInfoGroups = Object.fromEntries(
realizedPlatforms.map(platform => [platform, true])
);
// Sort the updates into different groups based on their platform specific runtime versions
const updateGroups: PublishUpdateGroupInput[] = runtimeToPlatformsAndFingerprintInfoMapping.map(
({ runtimeVersion, platforms }) => {
const localRollbackInfoGroup = Object.fromEntries(
platforms.map(platform => [platform, rollbackInfoGroups[platform]])
);
return {
branchId,
rollBackToEmbeddedInfoGroup: localRollbackInfoGroup,
runtimeVersion,
message: updateMessage,
awaitingCodeSigningInfo: !!codeSigningInfo,
};
}
);
const newUpdates = await PublishMutation.publishUpdateGroupAsync(graphqlClient, updateGroups);
if (codeSigningInfo) {
Log.log('🔒 Signing roll back');
const updatesTemp = [...newUpdates];
const updateGroupsAndTheirUpdates = updateGroups.map(updateGroup => {
const newUpdates = updatesTemp.splice(
0,
Object.keys(nullthrows(updateGroup.rollBackToEmbeddedInfoGroup)).length
);
return {
updateGroup,
newUpdates,
};
});
await Promise.all(
updateGroupsAndTheirUpdates.map(async ({ newUpdates }) => {
await Promise.all(
newUpdates.map(async newUpdate => {
const response = await fetch(newUpdate.manifestPermalink, {
method: 'GET',
headers: { accept: 'multipart/mixed' },
});
const directiveBody = nullthrows(await getDirectiveBodyAsync(response));
checkDirectiveBodyAgainstUpdateInfoGroup(directiveBody);
const directiveSignature = signBody(directiveBody, codeSigningInfo);
await PublishMutation.setCodeSigningInfoAsync(graphqlClient, newUpdate.id, {
alg: codeSigningInfo.codeSigningMetadata.alg,
keyid: codeSigningInfo.codeSigningMetadata.keyid,
sig: directiveSignature,
});
})
);
})
);
}
return newUpdates;
}
private static async selectRuntimeAsync(
graphqlClient: ExpoGraphqlClient,
{
appId,
branchName,
batchSize = 5,
}: {
appId: string;
branchName: string;
batchSize?: number;
}
): Promise<RuntimeFragment | null> {
const queryAsync = async (queryParams: QueryParams): Promise<Connection<RuntimeFragment>> => {
return await RuntimeQuery.getRuntimesOnBranchAsync(graphqlClient, {
appId,
name: branchName,
first: queryParams.first,
after: queryParams.after,
last: queryParams.last,
before: queryParams.before,
});
};
const getTitleAsync = async (runtime: RuntimeFragment): Promise<string> => {
return runtime.version;
};
return await selectPaginatedAsync({
queryAsync,
getTitleAsync,
printedType: 'target runtime',
pageSize: batchSize,
await publishRollBackToEmbeddedUpdateAsync({
graphqlClient,
projectId,
exp,
updateMessage,
branch,
codeSigningInfo,
platforms: realizedPlatforms,
runtimeVersion: selectedRuntime,
json: jsonFlag,
});
}
@@ -6,7 +6,8 @@ import EasCommand from '../../commandUtils/EasCommand';
import { promptAsync } from '../../prompts';
export default class UpdateRollback extends EasCommand {
static override description = 'roll back to an embedded update or an existing update';
static override description =
'Roll back to an embedded update or an existing update. Users wishing to run this command non-interactively should instead execute "eas update:republish" or "eas update:roll-back-to-embedded".';
static override flags = {
'private-key-path': Flags.string({
+464
View File
@@ -0,0 +1,464 @@
import { IOSConfig } from '@expo/config-plugins';
import { Platform } from '@expo/eas-build-job';
import { Flags } from '@oclif/core';
import fg from 'fast-glob';
import fs from 'fs-extra';
import StreamZip from 'node-stream-zip';
import path from 'path';
import tar from 'tar';
import { v4 as uuidv4 } from 'uuid';
import { getBuildLogsUrl } from '../build/utils/url';
import EasCommand from '../commandUtils/EasCommand';
import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient';
import { EasNonInteractiveAndJsonFlags } from '../commandUtils/flags';
import {
BuildMetadataInput,
DistributionType,
LocalBuildArchiveSourceType,
UploadSessionType,
} from '../graphql/generated';
import { FingerprintMutation } from '../graphql/mutations/FingerprintMutation';
import { LocalBuildMutation } from '../graphql/mutations/LocalBuildMutation';
import { toAppPlatform } from '../graphql/types/AppPlatform';
import Log from '../log';
import { promptAsync } from '../prompts';
import * as xcode from '../run/ios/xcode';
import { uploadFileAtPathToGCSAsync } from '../uploads';
import { fromNow } from '../utils/date';
import { enableJsonOutput, printJsonOnlyOutput } from '../utils/json';
import { getTmpDirectory } from '../utils/paths';
import { parseBinaryPlistBuffer } from '../utils/plist';
import { createProgressTracker } from '../utils/progress';
export default class BuildUpload extends EasCommand {
static override description = 'upload a local build and generate a sharable link';
static override flags = {
platform: Flags.enum<Platform.IOS | Platform.ANDROID>({
char: 'p',
options: [Platform.IOS, Platform.ANDROID],
}),
'build-path': Flags.string({
description: 'Path for the local build',
}),
fingerprint: Flags.string({
description: 'Fingerprint hash of the local build',
}),
...EasNonInteractiveAndJsonFlags,
};
static override contextDefinition = {
...this.ContextOptions.ProjectId,
...this.ContextOptions.LoggedIn,
};
async runAsync(): Promise<void> {
const { flags } = await this.parse(BuildUpload);
const {
'build-path': buildPath,
fingerprint: manualFingerprintHash,
json: jsonFlag,
'non-interactive': nonInteractive,
} = flags;
const {
projectId,
loggedIn: { graphqlClient },
} = await this.getContextAsync(BuildUpload, {
nonInteractive,
});
if (jsonFlag) {
enableJsonOutput();
}
const platform = await this.selectPlatformAsync({ platform: flags.platform, nonInteractive });
const localBuildPath = await resolveLocalBuildPathAsync({
platform,
inputBuildPath: buildPath,
nonInteractive,
});
const {
fingerprintHash: buildFingerprintHash,
developmentClient,
simulator,
...otherMetadata
} = await extractAppMetadataAsync(localBuildPath, platform);
let fingerprint = manualFingerprintHash ?? buildFingerprintHash;
if (fingerprint) {
if (
manualFingerprintHash &&
buildFingerprintHash &&
manualFingerprintHash !== buildFingerprintHash &&
!nonInteractive
) {
const selectedAnswer = await promptAsync({
name: 'fingerprint',
message: `The provided fingerprint hash ${manualFingerprintHash} does not match the fingerprint hash of the build ${buildFingerprintHash}. Which fingerprint do you want to use?`,
type: 'select',
choices: [
{ title: manualFingerprintHash, value: manualFingerprintHash },
{ title: buildFingerprintHash, value: buildFingerprintHash },
],
});
fingerprint = String(selectedAnswer.fingerprint);
}
await FingerprintMutation.createFingerprintAsync(graphqlClient, projectId, {
hash: fingerprint,
});
}
Log.log(`Using build ${localBuildPath}`);
Log.log(`Fingerprint hash: ${fingerprint ?? 'Unknown'}`);
Log.log('Uploading your app archive to EAS');
const bucketKey = await uploadAppArchiveAsync(graphqlClient, localBuildPath);
const build = await LocalBuildMutation.createLocalBuildAsync(
graphqlClient,
projectId,
{ platform: toAppPlatform(platform), simulator },
{ type: LocalBuildArchiveSourceType.Gcs, bucketKey },
{
distribution: DistributionType.Internal,
fingerprintHash: fingerprint,
developmentClient,
...otherMetadata,
}
);
if (jsonFlag) {
printJsonOnlyOutput({ url: getBuildLogsUrl(build) });
return;
}
Log.withTick(`Shareable link to the build: ${getBuildLogsUrl(build)}`);
}
private async selectPlatformAsync({
nonInteractive,
platform,
}: {
nonInteractive: boolean;
platform?: Platform;
}): Promise<Platform> {
if (nonInteractive && !platform) {
throw new Error('Platform must be provided in non-interactive mode');
}
if (platform) {
return platform;
}
const { resolvedPlatform } = await promptAsync({
type: 'select',
message: 'Select platform',
name: 'resolvedPlatform',
choices: [
{ title: 'Android', value: Platform.ANDROID },
{ title: 'iOS', value: Platform.IOS },
],
});
return resolvedPlatform;
}
}
async function resolveLocalBuildPathAsync({
platform,
inputBuildPath,
nonInteractive,
}: {
platform: Platform;
inputBuildPath?: string;
nonInteractive: boolean;
}): Promise<string> {
const rootDir = process.cwd();
let applicationArchivePatternOrPath: string[] = [];
if (inputBuildPath) {
applicationArchivePatternOrPath.push(inputBuildPath);
} else if (platform === Platform.ANDROID) {
applicationArchivePatternOrPath.push('android/app/build/outputs/**/*.{apk,aab}');
} else {
const xcworkspacePath = await xcode.resolveXcodeProjectAsync(rootDir);
const schemes = IOSConfig.BuildScheme.getRunnableSchemesFromXcodeproj(rootDir);
if (xcworkspacePath && schemes.length > 0) {
for (const scheme of schemes) {
const buildSettings = await xcode.getXcodeBuildSettingsAsync(xcworkspacePath, scheme.name);
applicationArchivePatternOrPath = applicationArchivePatternOrPath.concat(
buildSettings.map(({ buildSettings }) => `${buildSettings.BUILD_DIR}/**/*.app`)
);
}
}
}
let applicationArchives = await findArtifactsAsync({
rootDir,
patternOrPathArray: applicationArchivePatternOrPath,
});
if (applicationArchives.length === 0 && !nonInteractive && !inputBuildPath) {
Log.warn(`No application archives found at ${applicationArchivePatternOrPath}.`);
const { path } = await promptAsync({
type: 'text',
name: 'path',
message: 'Provide a path to the application archive:',
validate: value => (value ? true : 'Path may not be empty.'),
});
applicationArchives = await findArtifactsAsync({
rootDir,
patternOrPathArray: [path],
});
}
if (applicationArchives.length === 1) {
return applicationArchives[0];
}
if (applicationArchives.length > 1) {
// sort by modification time
const applicationArchivesInfo = await Promise.all(
applicationArchives.map(async archivePath => ({
path: archivePath,
stat: await fs.stat(archivePath),
}))
);
applicationArchivesInfo.sort((a, b) => b.stat.mtimeMs - a.stat.mtimeMs);
if (nonInteractive) {
return applicationArchivesInfo[0].path;
}
const { selectedPath } = await promptAsync({
type: 'select',
name: 'selectedPath',
message: 'Found multiple application archives. Select one:',
choices: applicationArchivesInfo.map(archive => {
return {
title: `${
archive.path.startsWith(rootDir) ? path.relative(rootDir, archive.path) : archive.path
} (${fromNow(archive.stat.mtime)} ago)`,
value: archive.path,
};
}),
});
return selectedPath;
}
throw new Error(`Found no application archives at ${inputBuildPath}.`);
}
async function findArtifactsAsync({
rootDir,
patternOrPathArray,
}: {
rootDir: string;
patternOrPathArray: string[];
}): Promise<string[]> {
const files = new Set<string>();
for (const patternOrPath of patternOrPathArray) {
if (path.isAbsolute(patternOrPath) && (await fs.pathExists(patternOrPath))) {
files.add(patternOrPath);
} else {
const filesFound = await fg(patternOrPath, {
cwd: rootDir,
onlyFiles: false,
});
filesFound.forEach(file => files.add(file));
}
}
return [...files].map(filePath => {
// User may provide an absolute path as input in which case
// fg will return an absolute path.
if (path.isAbsolute(filePath)) {
return filePath;
}
// User may also provide a relative path in which case
// fg will return a path relative to rootDir.
return path.join(rootDir, filePath);
});
}
async function uploadAppArchiveAsync(
graphqlClient: ExpoGraphqlClient,
originalPath: string
): Promise<string> {
let filePath = originalPath;
if ((await fs.stat(filePath)).isDirectory()) {
await fs.mkdirp(getTmpDirectory());
const tarPath = path.join(getTmpDirectory(), `${uuidv4()}.tar.gz`);
const parentPath = path.dirname(originalPath);
const folderName = path.basename(originalPath);
await tar.create({ cwd: parentPath, file: tarPath, gzip: true }, [folderName]);
filePath = tarPath;
}
const fileSize = (await fs.stat(filePath)).size;
const bucketKey = await uploadFileAtPathToGCSAsync(
graphqlClient,
UploadSessionType.EasShareGcsAppArchive,
filePath,
createProgressTracker({
total: fileSize,
message: 'Uploading to EAS',
completedMessage: 'Uploaded to EAS',
})
);
return bucketKey;
}
function getInfoPlistMetadata(infoPlist: any): {
appName?: string;
appIdentifier?: string;
simulator: boolean;
} {
const appName = infoPlist?.CFBundleDisplayName ?? infoPlist?.CFBundleName;
const appIdentifier = infoPlist?.CFBundleIdentifier;
const simulator = infoPlist?.DTPlatformName?.includes('simulator');
return {
appName,
appIdentifier,
simulator,
};
}
async function extractAppMetadataAsync(
buildPath: string,
platform: Platform
): Promise<{ developmentClient: boolean; simulator: boolean } & BuildMetadataInput> {
let developmentClient = false;
let fingerprintHash: string | undefined;
// By default, we assume the iOS apps are for simulators
let simulator = platform === Platform.IOS;
let appName: string | undefined;
let appIdentifier: string | undefined;
const basePath = platform === Platform.ANDROID ? 'assets/' : buildPath;
const fingerprintFilePath =
platform === Platform.ANDROID ? 'fingerprint' : 'EXUpdates.bundle/fingerprint';
const devMenuBundlePath =
platform === Platform.ANDROID ? 'EXDevMenuApp.android.js' : 'EXDevMenu.bundle/';
const buildExtension = path.extname(buildPath);
if (['.apk', '.aab'].includes(buildExtension)) {
const zip = new StreamZip.async({ file: buildPath });
try {
developmentClient = Boolean(await zip.entry(path.join(basePath, devMenuBundlePath)));
if (await zip.entry(path.join(basePath, fingerprintFilePath))) {
fingerprintHash = (await zip.entryData(path.join(basePath, fingerprintFilePath))).toString(
'utf-8'
);
}
} catch (err) {
Log.error(`Error reading ${buildExtension}: ${err}`);
} finally {
await zip.close();
}
} else if (buildExtension === '.app') {
developmentClient = await fs.exists(path.join(basePath, devMenuBundlePath));
if (await fs.exists(path.join(basePath, 'Info.plist'))) {
const infoPlistBuffer = await fs.readFile(path.join(basePath, 'Info.plist'));
const infoPlist = parseBinaryPlistBuffer(infoPlistBuffer);
({ simulator, appIdentifier, appName } = getInfoPlistMetadata(infoPlist));
}
if (await fs.exists(path.join(basePath, fingerprintFilePath))) {
fingerprintHash = await fs.readFile(path.join(basePath, fingerprintFilePath), 'utf8');
}
} else if (buildExtension === '.ipa') {
const zip = new StreamZip.async({ file: buildPath });
try {
const entries = await zip.entries();
const entriesKeys = Object.keys(entries);
await Promise.all(
entriesKeys.map(async path => {
const infoPlistRegex = /^Payload\/[^/]+\.app\/Info\.plist$/;
if (infoPlistRegex.test(path)) {
const infoPlistBuffer = await zip.entryData(entries[path]);
const infoPlist = parseBinaryPlistBuffer(infoPlistBuffer);
({ simulator, appIdentifier, appName } = getInfoPlistMetadata(infoPlist));
return;
}
if (path.includes('/EXDevMenu.bundle')) {
developmentClient = true;
return;
}
if (path.includes('EXUpdates.bundle/fingerprint')) {
fingerprintHash = (await zip.entryData(entries[path])).toString('utf-8');
}
})
);
} catch (err) {
Log.error(`Error reading ${buildExtension}: ${err}`);
} finally {
await zip.close();
}
} else {
// Use tar to list files in the archive
try {
let fingerprintHashPromise: Promise<string> | undefined;
let infoPlistPromise: Promise<Buffer> | undefined;
await tar.list({
file: buildPath,
// eslint-disable-next-line async-protect/async-suffix
onentry: entry => {
if (entry.path.endsWith(devMenuBundlePath)) {
developmentClient = true;
}
if (entry.path.endsWith(fingerprintFilePath)) {
fingerprintHashPromise = new Promise<string>(async (resolve, reject) => {
try {
let content = '';
for await (const chunk of entry) {
content += chunk.toString('utf8');
}
resolve(content);
} catch (error) {
reject(error);
}
});
}
if (entry.path.endsWith('Info.plist')) {
infoPlistPromise = new Promise<Buffer>(async (resolve, reject) => {
try {
const chunks: Buffer[] = [];
for await (const chunk of entry) {
chunks.push(chunk);
}
const content = Buffer.concat(chunks);
resolve(content);
} catch (error) {
reject(error);
}
});
}
},
});
if (fingerprintHashPromise !== undefined) {
fingerprintHash = await fingerprintHashPromise;
}
if (infoPlistPromise !== undefined) {
const infoPlist = parseBinaryPlistBuffer(await infoPlistPromise);
({ simulator, appIdentifier, appName } = getInfoPlistMetadata(infoPlist));
}
} catch (err) {
Log.error(`Error reading ${buildExtension}: ${err}`);
}
}
return {
developmentClient,
fingerprintHash,
simulator,
appName,
appIdentifier,
};
}
@@ -0,0 +1,243 @@
import { parseInputs, parseJsonInputs, parseWorkflowInputsFromYaml } from '../run';
describe('parseInputs', () => {
it('should parse single key=value pair', () => {
const inputs = parseInputs(['key=value']);
expect(inputs).toEqual({ key: 'value' });
});
it('should parse multiple key=value pairs', () => {
const inputs = parseInputs(['key1=value1', 'key2=value2', 'key3=value3']);
expect(inputs).toEqual({
key1: 'value1',
key2: 'value2',
key3: 'value3',
});
});
it('should handle empty value', () => {
const inputs = parseInputs(['key=']);
expect(inputs).toEqual({ key: '' });
});
it('should handle values with equals signs', () => {
const inputs = parseInputs(['key=value=with=equals']);
expect(inputs).toEqual({ key: 'value=with=equals' });
});
it('should handle values with spaces', () => {
const inputs = parseInputs(['key=value with spaces']);
expect(inputs).toEqual({ key: 'value with spaces' });
});
it('should handle special characters in values', () => {
const inputs = parseInputs(['key=value!@#$%^&*()']);
expect(inputs).toEqual({ key: 'value!@#$%^&*()' });
});
it('should throw error for invalid format without equals', () => {
expect(() => parseInputs(['invalid_format'])).toThrow(
'Invalid input format: invalid_format. Expected key=value format.'
);
});
it('should throw error for empty key', () => {
expect(() => parseInputs(['=value'])).toThrow(
'Invalid input format: =value. Key cannot be empty.'
);
});
it('should handle empty array', () => {
const inputs = parseInputs([]);
expect(inputs).toEqual({});
});
it('should handle mix of valid and complex inputs', () => {
const inputs = parseInputs([
'simple=value',
'complex=key=value=with=multiple=equals',
'empty=',
'spaces=value with spaces',
]);
expect(inputs).toEqual({
simple: 'value',
complex: 'key=value=with=multiple=equals',
empty: '',
spaces: 'value with spaces',
});
});
});
describe('parseJsonInputs', () => {
it('should parse simple JSON object', () => {
const json = '{"key": "value", "number": 42, "bool": true}';
const inputs = parseJsonInputs(json);
expect(inputs).toEqual({
key: 'value',
number: 42,
bool: true,
});
});
it('should handle complex objects', () => {
const json = '{"obj": {"nested": "value"}, "arr": [1, 2, 3]}';
const inputs = parseJsonInputs(json);
expect(inputs).toEqual({
obj: { nested: 'value' },
arr: [1, 2, 3],
});
});
it('should handle empty object', () => {
const json = '{}';
const inputs = parseJsonInputs(json);
expect(inputs).toEqual({});
});
it('should handle string values with special characters', () => {
const json =
'{"special": "value with spaces", "equals": "key=value", "quotes": "value\\"with\\"quotes"}';
const inputs = parseJsonInputs(json);
expect(inputs).toEqual({
special: 'value with spaces',
equals: 'key=value',
quotes: 'value"with"quotes',
});
});
it('should throw error for invalid JSON', () => {
const invalidJson = '{"invalid": json}';
expect(() => parseJsonInputs(invalidJson)).toThrow('Invalid JSON input.');
});
it('should throw error for non-object JSON', () => {
expect(() => parseJsonInputs('["array"]')).toThrow('Invalid JSON input.');
expect(() => parseJsonInputs('"string"')).toThrow('Invalid JSON input.');
expect(() => parseJsonInputs('42')).toThrow('Invalid JSON input.');
expect(() => parseJsonInputs('null')).toThrow('Invalid JSON input.');
});
});
describe('parseWorkflowInputsFromYaml', () => {
it('should parse workflow inputs from YAML', () => {
const yamlConfig = `
on:
workflow_dispatch:
inputs:
environment:
type: string
required: true
description: "Environment to deploy to"
debug:
type: boolean
default: false
description: "Enable debug mode"
version:
type: number
required: true
description: "Version number"
deployment_type:
type: choice
options: ["staging", "production"]
default: "staging"
description: "Type of deployment"
target_env:
type: environment
default: "preview"
description: "Target environment"
jobs:
test:
steps:
- run: echo "test"
`;
const inputs = parseWorkflowInputsFromYaml(yamlConfig);
expect(inputs).toEqual({
environment: {
type: 'string',
required: true,
description: 'Environment to deploy to',
},
debug: {
type: 'boolean',
required: false,
default: false,
description: 'Enable debug mode',
},
version: {
type: 'number',
required: true,
description: 'Version number',
},
deployment_type: {
type: 'choice',
required: false,
default: 'staging',
options: ['staging', 'production'],
description: 'Type of deployment',
},
target_env: {
type: 'environment',
required: false,
default: 'preview',
description: 'Target environment',
},
});
});
it('should handle YAML without workflow_dispatch inputs', () => {
const yamlConfig = `
on:
push:
branches: [main]
jobs:
test:
steps:
- run: echo "test"
`;
const inputs = parseWorkflowInputsFromYaml(yamlConfig);
expect(inputs).toEqual({});
});
it('should handle empty YAML', () => {
const yamlConfig = '';
const inputs = parseWorkflowInputsFromYaml(yamlConfig);
expect(inputs).toEqual({});
});
it('should handle invalid YAML gracefully', () => {
const yamlConfig = 'invalid: yaml: content:';
const { parseWorkflowInputsFromYaml } = require('../run');
const inputs = parseWorkflowInputsFromYaml(yamlConfig);
expect(inputs).toEqual({});
});
it('should default type to string when not specified', () => {
const yamlConfig = `
on:
workflow_dispatch:
inputs:
simple_input:
description: "A simple input"
required: true
`;
const inputs = parseWorkflowInputsFromYaml(yamlConfig);
expect(inputs.simple_input).toEqual({
type: 'string',
description: 'A simple input',
required: true,
});
});
});
@@ -0,0 +1,93 @@
import EasCommand from '../../commandUtils/EasCommand';
import { EASNonInteractiveFlag } from '../../commandUtils/flags';
import { choiceFromWorkflowRun, processWorkflowRuns } from '../../commandUtils/workflow/utils';
import { WorkflowRunStatus } from '../../graphql/generated';
import { WorkflowRunMutation } from '../../graphql/mutations/WorkflowRunMutation';
import { AppQuery } from '../../graphql/queries/AppQuery';
import Log from '../../log';
import { promptAsync } from '../../prompts';
export default class WorkflowRunCancel extends EasCommand {
static override description =
'Cancel one or more workflow runs. If no workflow run IDs are provided, you will be prompted to select IN_PROGRESS runs to cancel.';
static override strict = false;
static override contextDefinition = {
...this.ContextOptions.ProjectId,
...this.ContextOptions.LoggedIn,
};
static override flags = {
...EASNonInteractiveFlag,
};
async runAsync(): Promise<void> {
const { argv } = await this.parse(WorkflowRunCancel);
let nonInteractive = false;
const workflowRunIds: Set<string> = new Set();
// Custom parsing of argv
const tokens: string[] = [...argv];
while (tokens.length > 0) {
const token = tokens.shift();
if (token === '--non-interactive') {
nonInteractive = true;
continue;
} else if (token) {
workflowRunIds.add(token);
}
}
const {
projectId,
loggedIn: { graphqlClient },
} = await this.getContextAsync(WorkflowRunCancel, {
nonInteractive,
});
if (workflowRunIds.size === 0) {
if (nonInteractive) {
throw new Error('Must supply workflow run IDs as arguments when in non-interactive mode');
}
// Run the workflow run list query and select runs to cancel
const queryResult = await AppQuery.byIdWorkflowRunsFilteredByStatusAsync(
graphqlClient,
projectId,
WorkflowRunStatus.InProgress,
50
);
const runs = processWorkflowRuns(queryResult);
if (runs.length === 0) {
Log.warn('No workflow runs to cancel');
return;
}
const answers = await promptAsync({
type: 'multiselect',
name: 'selectedRuns',
message: 'Select IN_PROGRESS workflow runs to cancel',
choices: runs.map(run => choiceFromWorkflowRun(run)),
});
answers.selectedRuns.forEach((id: string) => {
workflowRunIds.add(id);
});
if (workflowRunIds.size === 0) {
Log.warn('No workflow runs to cancel');
return;
}
}
Log.addNewLineIfNone();
for (const workflowRunId of workflowRunIds) {
try {
await WorkflowRunMutation.cancelWorkflowRunAsync(graphqlClient, {
workflowRunId,
});
Log.log(`Workflow run ${workflowRunId} has been canceled.`);
} catch (e: any) {
Log.error(`Failed to cancel workflow run ${workflowRunId}: ${e}`);
}
}
Log.addNewLineIfNone();
}
}
@@ -0,0 +1,64 @@
import EasCommand from '../../commandUtils/EasCommand';
import { EasJsonOnlyFlag } from '../../commandUtils/flags';
import { WorkflowResult } from '../../commandUtils/workflow/types';
import { AppQuery } from '../../graphql/queries/AppQuery';
import Log from '../../log';
import formatFields from '../../utils/formatFields';
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
export default class WorkflowList extends EasCommand {
static override hidden = true;
static override description = 'List workflows for the current project';
static override flags = {
...EasJsonOnlyFlag,
};
static override contextDefinition = {
...this.ContextOptions.ProjectId,
...this.ContextOptions.LoggedIn,
};
async runAsync(): Promise<void> {
const { flags } = await this.parse(WorkflowList);
const {
projectId,
loggedIn: { graphqlClient },
} = await this.getContextAsync(WorkflowList, {
nonInteractive: true,
});
if (flags.json) {
enableJsonOutput();
}
const workflows = await AppQuery.byIdWorkflowsAsync(graphqlClient, projectId);
const result: WorkflowResult[] = workflows.map(workflow => ({
id: workflow.id,
name: workflow.name,
fileName: workflow.fileName,
createdAt: workflow.createdAt,
updatedAt: workflow.updatedAt,
}));
if (flags.json) {
printJsonOnlyOutput(result);
return;
}
Log.addNewLineIfNone();
result.forEach(workflow => {
Log.log(
formatFields([
{ label: 'ID', value: workflow.id },
{ label: 'Name', value: workflow.name ?? '-' },
{ label: 'File name', value: workflow.fileName },
{ label: 'Created At', value: workflow.createdAt },
{ label: 'Updated At', value: workflow.updatedAt },
])
);
Log.addNewLineIfNone();
});
}
}
@@ -0,0 +1,111 @@
import { Flags } from '@oclif/core';
import EasCommand from '../../commandUtils/EasCommand';
import { EASNonInteractiveFlag, EasJsonOnlyFlag } from '../../commandUtils/flags';
import {
WorkflowCommandSelectionState,
WorkflowCommandSelectionStateValue,
executeWorkflowSelectionActionsAsync,
} from '../../commandUtils/workflow/stateMachine';
import { WorkflowLogLine, WorkflowLogs } from '../../commandUtils/workflow/types';
import Log from '../../log';
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
function printLogsForAllSteps(logs: WorkflowLogs): void {
[...logs.keys()].forEach(step => {
const logLines = logs.get(step);
if (logLines) {
Log.log(`Step: ${step}`);
logLines.forEach(line => {
Log.log(` ${line.time} ${line.msg}`);
});
}
Log.addNewLineIfNone();
});
}
export default class WorkflowLogView extends EasCommand {
static override description =
'view logs for a workflow run, selecting a job and step to view. You can pass in either a workflow run ID or a job ID. If no ID is passed in, you will be prompted to select from recent workflow runs for the current project.';
static override flags = {
...EasJsonOnlyFlag,
...EASNonInteractiveFlag,
'all-steps': Flags.boolean({
description:
'Print all logs, rather than prompting for a specific step. This will be automatically set when in non-interactive mode.',
default: false,
}),
};
static override args = [
{ name: 'id', description: 'ID of the workflow run or workflow job to view logs for' },
];
static override contextDefinition = {
...this.ContextOptions.ProjectId,
...this.ContextOptions.LoggedIn,
};
async runAsync(): Promise<void> {
const { args, flags } = await this.parse(WorkflowLogView);
const nonInteractive = flags['non-interactive'];
const allSteps = nonInteractive ? true : flags['all-steps'];
Log.debug(`allSteps = ${allSteps}`);
Log.debug(`nonInteractive = ${nonInteractive}`);
Log.debug(`flags.json = ${flags.json}`);
Log.debug(`args.id = ${args.id}`);
const {
loggedIn: { graphqlClient },
projectId,
} = await this.getContextAsync(WorkflowLogView, {
nonInteractive,
});
if (flags.json) {
enableJsonOutput();
}
const finalSelectionState: WorkflowCommandSelectionState =
await executeWorkflowSelectionActionsAsync({
graphqlClient,
projectId,
nonInteractive,
allSteps,
state: WorkflowCommandSelectionStateValue.WORKFLOW_JOB_SELECTION,
jobId: args.id,
});
if (finalSelectionState.state === WorkflowCommandSelectionStateValue.ERROR) {
Log.error(finalSelectionState.message);
return;
}
const logs = finalSelectionState?.logs as unknown as WorkflowLogs | null;
if (allSteps) {
if (logs) {
if (flags.json) {
printJsonOnlyOutput(Object.fromEntries(logs));
} else {
printLogsForAllSteps(logs);
}
}
} else {
const selectedStep = finalSelectionState?.step as unknown as string;
const logLines = logs?.get(selectedStep);
if (logLines) {
if (flags.json) {
const output: { [key: string]: WorkflowLogLine[] | null } = {};
output[selectedStep] = logLines ?? null;
printJsonOnlyOutput(output);
} else {
logLines.forEach(line => {
Log.log(` ${line.time} ${line.msg}`);
});
}
}
}
}
}
+519 -39
View File
@@ -1,25 +1,104 @@
/**
* EAS Workflow Run Command
*
* This command runs an EAS workflow with support for interactive input prompting.
*
* Input Sources (in order of precedence):
* 1. Command line flags (-F key=value)
* 2. STDIN JSON input (echo '{"key": "value"}' | eas workflow:run)
* 3. Interactive prompts (when required inputs are missing and not in non-interactive mode)
*
* Interactive Prompting:
* - When running in interactive mode (default), the command will automatically prompt
* for any required inputs that are not provided via flags or STDIN
* - Input types supported: string, boolean, number, choice, environment
* - Each input type has appropriate validation and default values
* - Use --non-interactive flag to disable prompting and require all inputs via flags
*
* Example workflow with inputs:
* ```yaml
* on:
* workflow_dispatch:
* inputs:
* environment:
* type: string
* required: true
* description: "Environment to deploy to"
* debug:
* type: boolean
* default: false
* description: "Enable debug mode"
* version:
* type: number
* required: true
* description: "Version number"
* deployment_type:
* type: choice
* options: ["staging", "production"]
* default: "staging"
* description: "Type of deployment"
* ```
*/
import { Flags } from '@oclif/core';
import { CombinedError } from '@urql/core';
import chalk from 'chalk';
import * as fs from 'node:fs';
import * as path from 'node:path';
import slash from 'slash';
import * as YAML from 'yaml';
import { z } from 'zod';
import { getWorkflowRunUrl } from '../../build/utils/url';
import EasCommand from '../../commandUtils/EasCommand';
import { EASNonInteractiveFlag } from '../../commandUtils/flags';
import { WorkflowProjectSourceType } from '../../graphql/generated';
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
import { EASNonInteractiveFlag, EasJsonOnlyFlag } from '../../commandUtils/flags';
import {
WorkflowProjectSourceType,
WorkflowRunByIdQuery,
WorkflowRunStatus,
} from '../../graphql/generated';
import { WorkflowRevisionMutation } from '../../graphql/mutations/WorkflowRevisionMutation';
import { WorkflowRunMutation } from '../../graphql/mutations/WorkflowRunMutation';
import { WorkflowRunQuery } from '../../graphql/queries/WorkflowRunQuery';
import Log, { link } from '../../log';
import { ora } from '../../ora';
import { getOwnerAccountForProjectIdAsync } from '../../project/projectUtils';
import { uploadAccountScopedFileAsync } from '../../project/uploadAccountScopedFileAsync';
import { uploadAccountScopedProjectSourceAsync } from '../../project/uploadAccountScopedProjectSourceAsync';
import { promptAsync } from '../../prompts';
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
import { sleepAsync } from '../../utils/promise';
import { WorkflowFile } from '../../utils/workflowFile';
const EXIT_CODES = {
WORKFLOW_FAILED: 11,
WORKFLOW_CANCELED: 12,
WAIT_ABORTED: 13,
};
export default class WorkflowRun extends EasCommand {
static override description = 'Run an EAS workflow';
static override description = 'run an EAS workflow';
static override args = [{ name: 'file', description: 'Path to the workflow file to run' }];
static override flags = {
...EASNonInteractiveFlag,
wait: Flags.boolean({
default: false,
allowNo: true,
description: 'Exit codes: 0 = success, 11 = failure, 12 = canceled, 13 = wait aborted.',
summary: 'Wait for workflow run to complete',
}),
input: Flags.string({
char: 'F',
aliases: ['f', 'field'],
multiple: true,
description:
'Add a parameter in key=value format. Use multiple instances of this flag to set multiple inputs.',
summary: 'Set workflow inputs',
}),
...EasJsonOnlyFlag,
};
static override contextDefinition = {
@@ -32,6 +111,10 @@ export default class WorkflowRun extends EasCommand {
async runAsync(): Promise<void> {
const { flags, args } = await this.parse(WorkflowRun);
if (flags.json) {
enableJsonOutput();
}
const {
getDynamicPrivateProjectConfigAsync,
loggedIn: { graphqlClient },
@@ -79,9 +162,51 @@ export default class WorkflowRun extends EasCommand {
}
}
let inputs: Record<string, unknown> | undefined;
// Check for stdin input
const stdinData = await maybeReadStdinAsync();
const inputsFromFlags = [...(flags.input ?? [])];
// Validate that both stdin and -F flags are not provided simultaneously
if (stdinData && inputsFromFlags.length > 0) {
throw new Error(
'Cannot use both stdin JSON input and -F flags simultaneously. Please use only one input method.'
);
}
if (stdinData) {
inputs = parseJsonInputs(stdinData);
} else if (inputsFromFlags.length > 0) {
inputs = parseInputs(inputsFromFlags);
}
// Parse workflow inputs from YAML and prompt for missing required inputs
const inputSpecs = parseWorkflowInputsFromYaml(yamlConfig);
if (!flags['non-interactive']) {
inputs = await maybePromptForMissingInputsAsync({ inputSpecs, inputs: inputs ?? {} });
}
if (inputs && Object.keys(inputs).length > 0) {
Log.addNewLineIfNone();
Log.newLine();
Log.log('Running with inputs:');
for (const [key, value] of Object.entries(inputs)) {
Log.log(`- ${chalk.bold(key)}: ${JSON.stringify(value)}`);
}
}
let projectArchiveBucketKey: string;
let easJsonBucketKey: string;
let packageJsonBucketKey: string;
let easJsonBucketKey: string | null = null;
let packageJsonBucketKey: string | null = null;
const easJsonPath = path.join(projectDir, 'eas.json');
const packageJsonPath = path.join(projectDir, 'package.json');
const projectRootDirectory = slash(
path.relative(await vcsClient.getRootPathAsync(), projectDir) || '.'
);
try {
({ projectArchiveBucketKey } = await uploadAccountScopedProjectSourceAsync({
@@ -89,54 +214,409 @@ export default class WorkflowRun extends EasCommand {
vcsClient,
accountId: account.id,
}));
({ fileBucketKey: easJsonBucketKey } = await uploadAccountScopedFileAsync({
graphqlClient,
accountId: account.id,
filePath: path.join(projectDir, 'eas.json'),
maxSizeBytes: 1024 * 1024,
}));
({ fileBucketKey: packageJsonBucketKey } = await uploadAccountScopedFileAsync({
graphqlClient,
accountId: account.id,
filePath: path.join(projectDir, 'package.json'),
maxSizeBytes: 1024 * 1024,
}));
if (await fileExistsAsync(easJsonPath)) {
({ fileBucketKey: easJsonBucketKey } = await uploadAccountScopedFileAsync({
graphqlClient,
accountId: account.id,
filePath: easJsonPath,
maxSizeBytes: 1024 * 1024,
}));
} else {
Log.warn(
`⚠ No ${chalk.bold('eas.json')} found in the project directory. Running ${chalk.bold(
'type: build'
)} jobs will not work. Run ${chalk.bold(
'eas build:configure'
)} to configure your project for builds.`
);
}
if (await fileExistsAsync(packageJsonPath)) {
({ fileBucketKey: packageJsonBucketKey } = await uploadAccountScopedFileAsync({
graphqlClient,
accountId: account.id,
filePath: packageJsonPath,
maxSizeBytes: 1024 * 1024,
}));
} else {
Log.warn(
`⚠ No ${chalk.bold(
'package.json'
)} found in the project directory. It is used to automatically infer best job configuration for your project. You may want to define ${chalk.bold(
'image'
)} property in your workflow to specify the image to use.`
);
}
} catch (err) {
Log.error('Failed to upload project sources.');
throw err;
}
let workflowRunId: string;
try {
const { id: workflowRunId } = await WorkflowRunMutation.createWorkflowRunAsync(
graphqlClient,
{
appId: projectId,
workflowRevisionInput: {
fileName: path.basename(args.file),
yamlConfig,
({ id: workflowRunId } = await WorkflowRunMutation.createWorkflowRunAsync(graphqlClient, {
appId: projectId,
workflowRevisionInput: {
fileName: path.basename(args.file),
yamlConfig,
},
workflowRunInput: {
inputs,
projectSource: {
type: WorkflowProjectSourceType.Gcs,
projectArchiveBucketKey,
easJsonBucketKey,
packageJsonBucketKey,
projectRootDirectory,
},
workflowRunInput: {
projectSource: {
type: WorkflowProjectSourceType.Gcs,
projectArchiveBucketKey,
easJsonBucketKey,
packageJsonBucketKey,
},
},
}
);
},
}));
Log.newLine();
Log.succeed(
`Workflow started successfully. ${link(
getWorkflowRunUrl(account.name, projectName, workflowRunId)
)}`
);
Log.log(`See logs: ${link(getWorkflowRunUrl(account.name, projectName, workflowRunId))}`);
} catch (err) {
Log.error('Failed to start the workflow with the API.');
throw err;
}
if (!flags.wait) {
if (flags.json) {
printJsonOnlyOutput({
id: workflowRunId,
url: getWorkflowRunUrl(account.name, projectName, workflowRunId),
});
}
process.exit(0);
}
Log.newLine();
const { status } = await waitForWorkflowRunToEndAsync(graphqlClient, {
workflowRunId,
});
if (flags.json) {
const workflowRun = await WorkflowRunQuery.withJobsByIdAsync(graphqlClient, workflowRunId, {
useCache: false,
});
printJsonOnlyOutput({
...workflowRun,
url: getWorkflowRunUrl(account.name, projectName, workflowRunId),
});
}
if (status === WorkflowRunStatus.Failure) {
process.exit(EXIT_CODES.WORKFLOW_FAILED);
} else if (status === WorkflowRunStatus.Canceled) {
process.exit(EXIT_CODES.WORKFLOW_CANCELED);
}
}
}
async function waitForWorkflowRunToEndAsync(
graphqlClient: ExpoGraphqlClient,
{ workflowRunId }: { workflowRunId: string }
): Promise<WorkflowRunByIdQuery['workflowRuns']['byId']> {
Log.log('Waiting for workflow run to complete. You can press Ctrl+C to exit.');
const spinner = ora('Currently waiting for workflow run to start.').start();
let failedFetchesCount = 0;
while (true) {
try {
const workflowRun = await WorkflowRunQuery.byIdAsync(graphqlClient, workflowRunId, {
useCache: false,
});
failedFetchesCount = 0;
switch (workflowRun.status) {
case WorkflowRunStatus.InProgress:
spinner.start('Workflow run is in progress.');
break;
case WorkflowRunStatus.ActionRequired:
spinner.warn('Workflow run is waiting for action.');
break;
case WorkflowRunStatus.Canceled:
spinner.warn('Workflow run has been canceled.');
return workflowRun;
case WorkflowRunStatus.Failure:
spinner.fail('Workflow run has failed.');
return workflowRun;
case WorkflowRunStatus.Success:
spinner.succeed('Workflow run completed successfully.');
return workflowRun;
}
} catch {
spinner.text = '⚠ Failed to fetch the workflow run status. Check your network connection.';
failedFetchesCount += 1;
if (failedFetchesCount > 6) {
spinner.fail('Failed to fetch the workflow run status 6 times in a row. Aborting wait.');
process.exit(EXIT_CODES.WAIT_ABORTED);
}
}
await sleepAsync(10 /* seconds */ * 1000 /* milliseconds */);
}
}
async function fileExistsAsync(filePath: string): Promise<boolean> {
return await fs.promises
.access(filePath, fs.constants.F_OK)
.then(() => true)
.catch(() => false);
}
export function parseInputs(inputFlags: string[]): Record<string, string> {
const inputs: Record<string, string> = {};
for (const inputFlag of inputFlags) {
const equalIndex = inputFlag.indexOf('=');
if (equalIndex === -1) {
throw new Error(`Invalid input format: ${inputFlag}. Expected key=value format.`);
}
const key = inputFlag.substring(0, equalIndex);
const value = inputFlag.substring(equalIndex + 1);
if (!key) {
throw new Error(`Invalid input format: ${inputFlag}. Key cannot be empty.`);
}
inputs[key] = value;
}
return inputs;
}
export async function maybeReadStdinAsync(): Promise<string | null> {
// Check if there's data on stdin
if (process.stdin.isTTY) {
return null;
}
return await new Promise((resolve, reject) => {
let data = '';
process.stdin.setEncoding('utf8');
process.stdin.on('readable', () => {
let chunk;
while ((chunk = process.stdin.read()) !== null) {
data += chunk;
}
});
process.stdin.on('end', () => {
const trimmedData = data.trim();
resolve(trimmedData || null);
});
process.stdin.on('error', err => {
reject(err);
});
});
}
export function parseJsonInputs(jsonString: string): Record<string, string> {
try {
const parsed = JSON.parse(jsonString);
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
throw new Error('JSON input must be an object.');
}
return parsed;
} catch (error) {
throw new Error(`Invalid JSON input.`, { cause: error });
}
}
// `z.coerce.boolean()` does `Boolean(val)` under the hood,
// which is not what we want. See:
// https://github.com/colinhacks/zod/issues/2985#issuecomment-2230692578
const booleanLike = z.union([
z.boolean(),
z.number().pipe(z.coerce.boolean()),
z.preprocess(val => {
if (typeof val === 'string') {
if (val.toLowerCase() === 'true') {
return true;
}
if (val.toLowerCase() === 'false') {
return false;
}
}
return val;
}, z.boolean()),
]);
const stringLike = z
.union([
// We're going to coerce numbers and strings into strings.
z.number(),
z.string(),
// We do not allow other primitives, like:
// - bigints, symbols - because YAML does not support them,
// - booleans - because YAML accepts `True` and `true` as boolean input
// and parses both as JS `true` -- if we stringified that,
// we would lose the capital "T" which may not be what the user expects,
// - nulls - user should do `"null"` or not pass the property at all.
])
.pipe(z.coerce.string());
export const WorkflowDispatchInputZ = z
.object({
description: stringLike.optional().describe('Description of the input'),
required: booleanLike.default(false).describe('Whether the input is required'),
})
.and(
z.union([
z.object({
type: z.literal('string').default('string'),
default: stringLike.optional().describe('Default value for the input'),
}),
z.object({
type: z.literal('boolean'),
default: booleanLike.optional().describe('Default value for the input'),
}),
z.object({
type: z.literal('number'),
default: z.number().optional().describe('Default value for the input'),
}),
z.object({
type: z.literal('choice'),
default: stringLike.optional().describe('Default value for the input'),
options: z.array(stringLike).min(1).describe('Options for choice type inputs'),
}),
z.object({
type: z.literal('environment'),
default: z.string().optional().describe('Default value for the input'),
}),
])
);
export function parseWorkflowInputsFromYaml(
yamlConfig: string
): Record<string, z.infer<typeof WorkflowDispatchInputZ>> {
try {
const parsed = YAML.parse(yamlConfig);
return z
.record(z.string(), WorkflowDispatchInputZ)
.default({})
.parse(parsed?.on?.workflow_dispatch?.inputs);
} catch (error) {
Log.warn('Failed to parse workflow inputs from YAML:', error);
return {};
}
}
async function maybePromptForMissingInputsAsync({
inputSpecs,
inputs,
}: {
inputSpecs: Record<string, z.infer<typeof WorkflowDispatchInputZ>>;
inputs: Record<string, unknown>;
}): Promise<Record<string, unknown>> {
const requiredInputs = Object.entries(inputSpecs).filter(([_, spec]) => spec.required);
const missingRequiredInputs = requiredInputs.filter(([key]) => inputs[key] === undefined);
if (missingRequiredInputs.length === 0) {
return inputs;
}
Log.addNewLineIfNone();
Log.log('Some required inputs are missing. Please provide them:');
const nextInputs = { ...inputs };
for (const [key, spec] of missingRequiredInputs) {
const value = await promptForMissingInputAsync({ key, spec });
nextInputs[key] = value;
}
return nextInputs;
}
async function promptForMissingInputAsync({
key,
spec,
}: {
key: string;
spec: z.infer<typeof WorkflowDispatchInputZ>;
}): Promise<unknown> {
const message = spec.description ? `${key} (${spec.description})` : key;
switch (spec.type) {
case 'boolean': {
const { value } = await promptAsync({
type: 'confirm',
name: 'value',
message,
initial: spec.default,
});
return value;
}
case 'number': {
const { value } = await promptAsync({
type: 'number',
name: 'value',
message,
initial: spec.default,
validate: (val: number) => {
if (isNaN(val)) {
return 'Please enter a valid number';
}
return true;
},
});
return value;
}
case 'choice': {
const { value } = await promptAsync({
type: 'select',
name: 'value',
message,
choices: spec.options.map(option => ({
title: option,
value: option,
})),
initial: spec.default,
});
return value;
}
case 'string':
case 'environment': {
const { value } = await promptAsync({
type: 'text',
name: 'value',
message,
initial: spec.default,
validate: (val: string) => {
if (spec.required && (!val || val.trim() === '')) {
return 'This field is required';
}
return true;
},
});
return value;
}
}
}
Loaded 100 of 211 files, more files were not shown because too many files have changed in this diff. Show more