Repository: expo/expo
Pull request #36029: feat(cli): Add support for using remote builds as cache
Author: login: gabrieldonadel
Labels: name: bot: passed checks
description: ExpoBot has nothing to complain about
name: published
description: Changes from the PR have been published to npm
name: bot: fingerprint compatible
Created: 2025-04-09T00:22:12Z | Merged: 2025-04-10T03:52:00Z

# Why

To take full advantage of the benefits provided by fingerprinting, users should be able to configure their project in a way that 
allows them to reuse builds with the same fingerprint during local development. Initially, this will be done through EAS but in follow-up PRs we should add support for custom providers.

# How

Add experimental `remoteBuildCache` field to `ExpoConfig` and add support for using remote builds as cache in `expo run`

# Test Plan

### Setup

 1. Add the following to your `app.json`
```
{
    "experiments": {
      "remoteBuildCache": {
        "provider": "eas"
      }
    }
}
```

2. Trigger Android and iOS builds on EAS and ensure the fingerprint matches the hash output of `npx @expo/fingerprint fingerprint:generate`

### Actions
  - Run `nexpo run:android` and `nexpo run:ios`
  - Observe the build process being skiped


# Checklist

<!--
Please check the appropriate items below if they apply to your diff.
-->

- [ ] I added a `changelog.md` entry and rebuilt the package sources according to [this short guide](https://github.com/expo/expo/blob/main/CONTRIBUTING.md#-before-submitting)
- [ ] This diff will work correctly for `npx expo prebuild` & EAS Build (eg: updated a module plugin).
- [ ] Conforms with the [Documentation Writing Style Guide](https://github.com/expo/expo/blob/main/guides/Expo%20Documentation%20Writing%20Style%20Guide.md)



Diff:
diff --git a/packages/@expo/cli/CHANGELOG.md b/packages/@expo/cli/CHANGELOG.md
index b9b17a8a921a87..d8a864646e28d7 100644
--- a/packages/@expo/cli/CHANGELOG.md
+++ b/packages/@expo/cli/CHANGELOG.md
@@ -6,6 +6,8 @@
 
 ### 🎉 New features
 
+- Add support for using remote builds as cache ([#36029](https://github.com/expo/expo/pull/36029) by [@gabrieldonadel](https://github.com/gabrieldonadel))
+
 ### 🐛 Bug fixes
 
 - Fix fusebox log formatting and silence migration warning. ([#35911](https://github.com/expo/expo/pull/35911) by [@EvanBacon](https://github.com/EvanBacon))
diff --git a/packages/@expo/cli/src/run/android/__tests__/runAndroidAsync-test.ts b/packages/@expo/cli/src/run/android/__tests__/runAndroidAsync-test.ts
index 67bdfbbd914d64..25e028b910872f 100644
--- a/packages/@expo/cli/src/run/android/__tests__/runAndroidAsync-test.ts
+++ b/packages/@expo/cli/src/run/android/__tests__/runAndroidAsync-test.ts
@@ -42,7 +42,16 @@ describe(resolveOptionsAsync, () => {
   afterEach(() => vol.reset());
 
   it(`runs android`, async () => {
-    vol.fromJSON(rnFixture, '/');
+    vol.fromJSON(
+      {
+        ...rnFixture,
+        '/package.json': JSON.stringify({}),
+        'node_modules/expo/package.json': JSON.stringify({
+          version: '53.0.0',
+        }),
+      },
+      '/'
+    );
 
     await runAndroidAsync('/', {});
 
diff --git a/packages/@expo/cli/src/run/android/runAndroidAsync.ts b/packages/@expo/cli/src/run/android/runAndroidAsync.ts
index e0ca0849185916..68fd0a3892997f 100644
--- a/packages/@expo/cli/src/run/android/runAndroidAsync.ts
+++ b/packages/@expo/cli/src/run/android/runAndroidAsync.ts
@@ -1,3 +1,4 @@
+import { getConfig } from '@expo/config';
 import chalk from 'chalk';
 import fs from 'fs';
 import path from 'path';
@@ -14,6 +15,7 @@ import { ensurePortAvailabilityAsync } from '../../utils/port';
 import { getSchemesForAndroidAsync } from '../../utils/scheme';
 import { ensureNativeProjectAsync } from '../ensureNativeProject';
 import { logProjectLogsLocation } from '../hints';
+import { resolveRemoteBuildCache } from '../remoteBuildCache';
 import { startBundlerAsync } from '../startBundler';
 
 const debug = require('debug')('expo:run:android');
@@ -24,6 +26,17 @@ export async function runAndroidAsync(projectRoot: string, { install, ...options
   setNodeEnv(isProduction ? 'production' : 'development');
   require('@expo/env').load(projectRoot);
 
+  const projectConfig = getConfig(projectRoot);
+  if (!options.binary && projectConfig.exp.experiments?.remoteBuildCache) {
+    const localPath = await resolveRemoteBuildCache(projectRoot, {
+      platform: 'android',
+      provider: projectConfig.exp.experiments?.remoteBuildCache.provider,
+    });
+    if (localPath) {
+      options.binary = localPath;
+    }
+  }
+
   await ensureNativeProjectAsync(projectRoot, { platform: 'android', install });
 
   const props = await resolveOptionsAsync(projectRoot, options);
diff --git a/packages/@expo/cli/src/run/ios/__tests__/runIosAsync-test.ts b/packages/@expo/cli/src/run/ios/__tests__/runIosAsync-test.ts
index 74e85bf1012567..9bfc56a9e7d291 100644
--- a/packages/@expo/cli/src/run/ios/__tests__/runIosAsync-test.ts
+++ b/packages/@expo/cli/src/run/ios/__tests__/runIosAsync-test.ts
@@ -79,7 +79,16 @@ describe(resolveOptionsAsync, () => {
 
   it(`runs ios on simulator`, async () => {
     mockPlatform('darwin');
-    vol.fromJSON(rnFixture, '/');
+    vol.fromJSON(
+      {
+        ...rnFixture,
+        '/package.json': JSON.stringify({}),
+        'node_modules/expo/package.json': JSON.stringify({
+          version: '53.0.0',
+        }),
+      },
+      '/'
+    );
 
     await runIosAsync('/', {});
 
@@ -121,7 +130,16 @@ describe(resolveOptionsAsync, () => {
     });
     jest.mocked(isSimulatorDevice).mockReturnValueOnce(false);
     mockPlatform('darwin');
-    vol.fromJSON(rnFixture, '/');
+    vol.fromJSON(
+      {
+        ...rnFixture,
+        '/package.json': JSON.stringify({}),
+        'node_modules/expo/package.json': JSON.stringify({
+          version: '53.0.0',
+        }),
+      },
+      '/'
+    );
 
     await runIosAsync('/', { device: '00008101-001964A22629003A' });
 
diff --git a/packages/@expo/cli/src/run/ios/runIosAsync.ts b/packages/@expo/cli/src/run/ios/runIosAsync.ts
index 581a98a4dbcefb..9823e7f9907780 100644
--- a/packages/@expo/cli/src/run/ios/runIosAsync.ts
+++ b/packages/@expo/cli/src/run/ios/runIosAsync.ts
@@ -1,3 +1,4 @@
+import { getConfig } from '@expo/config';
 import spawnAsync from '@expo/spawn-async';
 import chalk from 'chalk';
 import fs from 'fs';
@@ -21,6 +22,7 @@ import { getValidBinaryPathAsync } from './validateExternalBinary';
 import { exportEagerAsync } from '../../export/embed/exportEager';
 import { getContainerPathAsync, simctlAsync } from '../../start/platforms/ios/simctl';
 import { CommandError } from '../../utils/errors';
+import { resolveRemoteBuildCache } from '../remoteBuildCache';
 
 const debug = require('debug')('expo:run:ios');
 
@@ -39,6 +41,17 @@ export async function runIosAsync(projectRoot: string, options: Options) {
   // Resolve the CLI arguments into useable options.
   const props = await profile(resolveOptionsAsync)(projectRoot, options);
 
+  const projectConfig = getConfig(projectRoot);
+  if (!options.binary && projectConfig.exp.experiments?.remoteBuildCache && props.isSimulator) {
+    const localPath = await resolveRemoteBuildCache(projectRoot, {
+      platform: 'ios',
+      provider: projectConfig.exp.experiments?.remoteBuildCache.provider,
+    });
+    if (localPath) {
+      options.binary = localPath;
+    }
+  }
+
   if (options.rebundle) {
     Log.warn(`The --unstable-rebundle flag is experimental and may not work as expected.`);
     // Get the existing binary path to re-bundle the app.
diff --git a/packages/@expo/cli/src/run/remoteBuildCache.ts b/packages/@expo/cli/src/run/remoteBuildCache.ts
new file mode 100644
index 00000000000000..b9b0ae59d894fb
--- /dev/null
+++ b/packages/@expo/cli/src/run/remoteBuildCache.ts
@@ -0,0 +1,74 @@
+import { ExpoConfig } from '@expo/config';
+import { ModPlatform } from '@expo/config-plugins';
+import spawnAsync from '@expo/spawn-async';
+import fs from 'fs';
+import path from 'path';
+
+import { Log } from '../log';
+
+const debug = require('debug')('expo:run:remote-build') as typeof console.log;
+
+export async function resolveRemoteBuildCache(
+  projectRoot: string,
+  {
+    platform,
+    provider,
+  }: {
+    platform: ModPlatform;
+    provider?: Required<Required<ExpoConfig>['experiments']>['remoteBuildCache']['provider'];
+  }
+): Promise<string | null> {
+  const Fingerprint = importFingerprintForDev(projectRoot);
+  if (!Fingerprint) {
+    debug('@expo/fingerprint is not installed in the project, skip checking for remote builds');
+    return null;
+  }
+  const fingerprint = await Fingerprint.createFingerprintAsync(projectRoot);
+
+  if (provider === 'eas') {
+    const easJsonPath = path.join(projectRoot, 'eas.json');
+    if (!fs.existsSync(easJsonPath)) {
+      debug('eas.json not found, skip checking for remote builds');
+      return null;
+    }
+
+    Log.log(`Searching builds with matching fingerprint on EAS servers`);
+    try {
+      const results = await spawnAsync(
+        'npx',
+        [
+          'eas-cli',
+          'build:download',
+          `--platform=${platform}`,
+          `--fingerprint=${fingerprint.hash}`,
+          '--non-interactive',
+          '--dev-client',
+          '--json',
+        ],
+        {
+          cwd: projectRoot,
+        }
+      );
+
+      Log.log(`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) {
+      debug('eas-cli error:', error);
+      return null;
+    }
+  }
+
+  return null;
+}
+
+function importFingerprintForDev(projectRoot: string): null | typeof import('@expo/fingerprint') {
+  try {
+    return require(require.resolve('@expo/fingerprint', { paths: [projectRoot] }));
+  } catch {
+    return null;
+  }
+}
diff --git a/packages/@expo/config-types/build/ExpoConfig.d.ts b/packages/@expo/config-types/build/ExpoConfig.d.ts
index 2297735311c3b2..6ff08c3c26833b 100644
--- a/packages/@expo/config-types/build/ExpoConfig.d.ts
+++ b/packages/@expo/config-types/build/ExpoConfig.d.ts
@@ -284,6 +284,12 @@ export interface ExpoConfig {
          * Experimentally enable React Server Functions support in Expo CLI and Expo Router.
          */
         reactServerFunctions?: boolean;
+        /**
+         * Experimentally enable downloading cached builds from remote.
+         */
+        remoteBuildCache?: {
+            provider: 'eas';
+        };
     };
     /**
      * Internal properties for developer tools
diff --git a/packages/@expo/config-types/src/ExpoConfig.ts b/packages/@expo/config-types/src/ExpoConfig.ts
index 33042548f78ce7..605e0fb940b045 100644
--- a/packages/@expo/config-types/src/ExpoConfig.ts
+++ b/packages/@expo/config-types/src/ExpoConfig.ts
@@ -288,6 +288,12 @@ export interface ExpoConfig {
      * Experimentally enable React Server Functions support in Expo CLI and Expo Router.
      */
     reactServerFunctions?: boolean;
+    /**
+     * Experimentally enable downloading cached builds from remote.
+     */
+    remoteBuildCache?: {
+      provider: 'eas';
+    };
   };
   /**
    * Internal properties for developer tools


Reviews:
user: login: brentvatne
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-09T00:31:21Z
user: login: brentvatne
body: looks like a good first pass at downloading the builds. let's integrate automatically uploading builds with `eas upload` after successful local build on cache miss in the next pr
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-09T00:34:03Z
user: login: brentvatne
state: APPROVED
author_association: MEMBER
submitted_at: 2025-04-09T00:34:20Z
user: login: brentvatne
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-09T00:46:10Z
user: login: brentvatne
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-09T00:48:51Z
user: login: gabrieldonadel
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-09T01:01:05Z
user: login: gabrieldonadel
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-09T01:25:49Z
user: login: brentvatne
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-09T03:11:15Z
user: login: gabrieldonadel
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-09T13:25:14Z
user: login: byCedric
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-17T15:32:56Z

Review comments:
diff_hunk: @@ -0,0 +1,67 @@
+import { ExpoConfig } from '@expo/config';
+import { ModPlatform } from '@expo/config-plugins';
+import spawnAsync from '@expo/spawn-async';
+import fs from 'fs';
+import path from 'path';
+
+const debug = require('debug')('expo:run:remote-build') as typeof console.log;
+
+export async function resolveRemoteBuildCache(
+  projectRoot: string,
+  {
+    platform,
+    provider,
+  }: { platform: ModPlatform; provider?: ExpoConfig['experiments']['remoteBuildCache']['provider'] }
+): Promise<string | null> {
+  const Fingerprint = importFingerprintForDev(projectRoot);
+  if (!Fingerprint) {
+    debug('@expo/fingerprint is not installed in the project, skip checking for remote builds');
+    return null;
+  }
+  const fingerprint = await Fingerprint.createFingerprintAsync(projectRoot);
path: packages/@expo/cli/src/run/resolveRemoteBuildCache.ts
user: login: brentvatne
body: this is going to be a bit tricky, since we end up using slightly different defaults with `eas fingerprint:generate` i believe - so we might not get as much re-use from this as we'd like. we can follow up on this afterwards
created_at: 2025-04-09T00:31:21Z
author_association: MEMBER
diff_hunk: @@ -32,13 +34,30 @@ export async function runIosAsync(projectRoot: string, options: Options) {
 
   const install = !!options.install;
 
+  let maybePromptToSyncPods = false;
+  // Only prompt to sync pods if the user is not using a custom binary.
   if ((await ensureNativeProjectAsync(projectRoot, { platform: 'ios', install })) && install) {
-    await maybePromptToSyncPodsAsync(projectRoot);
+    maybePromptToSyncPods = true;
path: packages/@expo/cli/src/run/ios/runIosAsync.ts
user: login: brentvatne
body: given that this could impact the fingerprint, maybe we will need to run pod install before generating it? (if we determine that we need to do so)
created_at: 2025-04-09T00:46:10Z
author_association: MEMBER
diff_hunk: @@ -32,13 +34,30 @@ export async function runIosAsync(projectRoot: string, options: Options) {
 
   const install = !!options.install;
 
+  let maybePromptToSyncPods = false;
+  // Only prompt to sync pods if the user is not using a custom binary.
   if ((await ensureNativeProjectAsync(projectRoot, { platform: 'ios', install })) && install) {
-    await maybePromptToSyncPodsAsync(projectRoot);
+    maybePromptToSyncPods = true;
path: packages/@expo/cli/src/run/ios/runIosAsync.ts
user: login: brentvatne
body: this does raise an interesting question to follow up with later though - how should we integrate usage of this build caching with projects that don't use `npx expo run:ios` to build locally? right now they would use `eas build:dev` or `eas build:run` or some other method of downloading the build that they created on eas, but we could possibly integrate it into `npx expo start` as well - if you are going to launch with the `i` or `a` hotkeys in the interactive prompt into a dev build then we could check for a cached build and run it. but that will take additional work to figure out a good ux for, so let's not focus on that yet
created_at: 2025-04-09T00:48:51Z
author_association: MEMBER
diff_hunk: @@ -32,13 +34,30 @@ export async function runIosAsync(projectRoot: string, options: Options) {
 
   const install = !!options.install;
 
+  let maybePromptToSyncPods = false;
+  // Only prompt to sync pods if the user is not using a custom binary.
   if ((await ensureNativeProjectAsync(projectRoot, { platform: 'ios', install })) && install) {
-    await maybePromptToSyncPodsAsync(projectRoot);
+    maybePromptToSyncPods = true;
path: packages/@expo/cli/src/run/ios/runIosAsync.ts
user: login: gabrieldonadel
body: Maybe we should try to infer if it's a managed project and based on that decide to run or not pod install?
https://github.com/expo/eas-cli/blob/5537e364eb65551a3c99fb7cce5b797082c4b23f/packages/eas-cli/src/fingerprint/cli.ts#L90-L93
created_at: 2025-04-09T01:01:05Z
author_association: MEMBER
diff_hunk: @@ -32,13 +34,30 @@ export async function runIosAsync(projectRoot: string, options: Options) {
 
   const install = !!options.install;
 
+  let maybePromptToSyncPods = false;
+  // Only prompt to sync pods if the user is not using a custom binary.
   if ((await ensureNativeProjectAsync(projectRoot, { platform: 'ios', install })) && install) {
-    await maybePromptToSyncPodsAsync(projectRoot);
+    maybePromptToSyncPods = true;
path: packages/@expo/cli/src/run/ios/runIosAsync.ts
user: login: gabrieldonadel
body: reverted
created_at: 2025-04-09T01:25:48Z
author_association: MEMBER
diff_hunk: @@ -288,6 +288,12 @@ export interface ExpoConfig {
      * Experimentally enable React Server Functions support in Expo CLI and Expo Router.
      */
     reactServerFunctions?: boolean;
+    /**
+     * Experimentally enable downloading cached builds from remote.
+     */
+    remoteBuildCache?: {
+      provider: 'eas';
+    };
path: packages/@expo/config-types/src/ExpoConfig.ts
user: login: brentvatne
body: this will need to be added to the schema on universe https://github.com/expo/universe/blob/main/server/www/xdl-schemas/UNVERSIONED-schema.json#L1491
created_at: 2025-04-09T03:11:15Z
author_association: MEMBER
diff_hunk: @@ -288,6 +288,12 @@ export interface ExpoConfig {
      * Experimentally enable React Server Functions support in Expo CLI and Expo Router.
      */
     reactServerFunctions?: boolean;
+    /**
+     * Experimentally enable downloading cached builds from remote.
+     */
+    remoteBuildCache?: {
+      provider: 'eas';
+    };
path: packages/@expo/config-types/src/ExpoConfig.ts
user: login: gabrieldonadel
body: Yep, here it is https://github.com/expo/universe/pull/19600
created_at: 2025-04-09T13:25:14Z
author_association: MEMBER
diff_hunk: @@ -24,6 +26,17 @@ export async function runAndroidAsync(projectRoot: string, { install, ...options
   setNodeEnv(isProduction ? 'production' : 'development');
   require('@expo/env').load(projectRoot);
 
+  const projectConfig = getConfig(projectRoot);
+  if (!options.binary && projectConfig.exp.experiments?.remoteBuildCache) {
+    const localPath = await resolveRemoteBuildCache(projectRoot, {
+      platform: 'android',
+      provider: projectConfig.exp.experiments?.remoteBuildCache.provider,
+    });
+    if (localPath) {
+      options.binary = localPath;
+    }
+  }
path: packages/@expo/cli/src/run/android/runAndroidAsync.ts
user: login: byCedric
body: This should have been moved to `resolveOptionsAsync` - it's part of the options resolution, not sure why we put it here.
created_at: 2025-04-17T15:29:32Z
author_association: MEMBER
diff_hunk: @@ -39,6 +41,17 @@ export async function runIosAsync(projectRoot: string, options: Options) {
   // Resolve the CLI arguments into useable options.
   const props = await profile(resolveOptionsAsync)(projectRoot, options);
 
+  const projectConfig = getConfig(projectRoot);
+  if (!options.binary && projectConfig.exp.experiments?.remoteBuildCache && props.isSimulator) {
+    const localPath = await resolveRemoteBuildCache(projectRoot, {
+      platform: 'ios',
+      provider: projectConfig.exp.experiments?.remoteBuildCache.provider,
+    });
+    if (localPath) {
+      options.binary = localPath;
+    }
+  }
+
path: packages/@expo/cli/src/run/ios/runIosAsync.ts
user: login: byCedric
body: This should have been moved to `resolveOptionsAsync` - it's part of the options resolution, not sure why we put it here.
created_at: 2025-04-17T15:30:18Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,74 @@
+import { ExpoConfig } from '@expo/config';
+import { ModPlatform } from '@expo/config-plugins';
+import spawnAsync from '@expo/spawn-async';
+import fs from 'fs';
+import path from 'path';
+
+import { Log } from '../log';
+
+const debug = require('debug')('expo:run:remote-build') as typeof console.log;
+
+export async function resolveRemoteBuildCache(
+  projectRoot: string,
+  {
+    platform,
+    provider,
+  }: {
+    platform: ModPlatform;
+    provider?: Required<Required<ExpoConfig>['experiments']>['remoteBuildCache']['provider'];
+  }
+): Promise<string | null> {
+  const Fingerprint = importFingerprintForDev(projectRoot);
+  if (!Fingerprint) {
+    debug('@expo/fingerprint is not installed in the project, skip checking for remote builds');
+    return null;
+  }
+  const fingerprint = await Fingerprint.createFingerprintAsync(projectRoot);
+
+  if (provider === 'eas') {
+    const easJsonPath = path.join(projectRoot, 'eas.json');
+    if (!fs.existsSync(easJsonPath)) {
+      debug('eas.json not found, skip checking for remote builds');
+      return null;
+    }
+
+    Log.log(`Searching builds with matching fingerprint on EAS servers`);
+    try {
+      const results = await spawnAsync(
+        'npx',
+        [
+          'eas-cli',
+          'build:download',
+          `--platform=${platform}`,
+          `--fingerprint=${fingerprint.hash}`,
+          '--non-interactive',
+          '--dev-client',
+          '--json',
+        ],
+        {
+          cwd: projectRoot,
+        }
+      );
+
+      Log.log(`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) {
+      debug('eas-cli error:', error);
+      return null;
+    }
+  }
+
+  return null;
+}
+
+function importFingerprintForDev(projectRoot: string): null | typeof import('@expo/fingerprint') {
+  try {
+    return require(require.resolve('@expo/fingerprint', { paths: [projectRoot] }));
+  } catch {
path: packages/@expo/cli/src/run/remoteBuildCache.ts
user: login: byCedric
body: This will always swallow _any_ error, actually verify we are only swallowing "does not exist" errors here - otherwise we won't spot any other obvious issues with this.
created_at: 2025-04-17T15:31:56Z
author_association: MEMBER

PR comments:
user: login: github-actions[bot]
created_at: 2025-04-09T00:22:29Z
body: Subscribed to pull request
| File Patterns | Mentions |
| - | - |
| packages/@expo/cli/\*\* | @EvanBacon, @bycedric |
| packages/@expo/config-types/\*\* | @EvanBacon, @douglowder |

Generated by [CodeMention](https://github.com/expo/expo/actions/workflows/codemention.yaml)
<!-- codemention header -->
author_association: CONTRIBUTOR
user: login: gabrieldonadel
created_at: 2025-04-09T14:14:45Z
body: > looks like a good first pass at downloading the builds. let's integrate automatically uploading builds with `eas upload` after successful local build on cache miss in the next pr

Uploading is implemented here https://github.com/expo/expo/pull/36047
author_association: MEMBER
user: login: brentvatne
created_at: 2025-04-09T17:08:28Z
body: let's add some logging so users can understand what is happening behind the scenes
author_association: MEMBER