diff --git a/CHANGELOG.md b/CHANGELOG.md index 2e1ae18414..175b30ace4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ This is the log of notable changes to EAS CLI and related packages. - [build-tools] Preapprove custom URL schemes before opening them in iOS Simulator sessions to avoid the first-use confirmation prompt. ([#4274](https://github.com/expo/eas-cli/pull/4274) by [@szdziedzic](https://github.com/szdziedzic)) - [build-tools] Support downloading iOS simulator archives whose app bundle contents are stored at the archive root. ([#4262](https://github.com/expo/eas-cli/pull/4262) by [@szdziedzic](https://github.com/szdziedzic)) +- [eas-cli] Fail fast in `eas deploy` when a Free-plan deployment exceeds the 1 GB EAS Hosting limit, instead of failing only after a full upload. ([#4271](https://github.com/expo/eas-cli/pull/4271) by [@douglowder](https://github.com/douglowder)) ### 🧹 Chores diff --git a/packages/eas-cli/src/billing/__tests__/plans.test.ts b/packages/eas-cli/src/billing/__tests__/plans.test.ts new file mode 100644 index 0000000000..5b40ee2304 --- /dev/null +++ b/packages/eas-cli/src/billing/__tests__/plans.test.ts @@ -0,0 +1,17 @@ +import { FREE_PLAN_PRICE_ID, hasPaidSubscription } from '../plans'; + +describe(hasPaidSubscription, () => { + it('returns false for a null subscription (Free)', () => { + expect(hasPaidSubscription(null)).toBe(false); + }); + + it('returns false for the Free plan price id or a missing planId', () => { + expect(hasPaidSubscription({ planId: FREE_PLAN_PRICE_ID })).toBe(false); + expect(hasPaidSubscription({ planId: null })).toBe(false); + expect(hasPaidSubscription({})).toBe(false); + }); + + it('returns true for a paid plan price id', () => { + expect(hasPaidSubscription({ planId: 'price_starter' })).toBe(true); + }); +}); diff --git a/packages/eas-cli/src/billing/plans.ts b/packages/eas-cli/src/billing/plans.ts index 45440ff9b7..404d1ce00b 100644 --- a/packages/eas-cli/src/billing/plans.ts +++ b/packages/eas-cli/src/billing/plans.ts @@ -10,6 +10,10 @@ export const PLAN_SLUGS = Object.keys(SUBSCRIBABLE_PLANS) as PlanSlug[]; export const FREE_PLAN_PRICE_ID = 'price_free'; +// EAS Hosting deployments on the Free plan are capped at 1 GiB. Larger Free-plan +// deployments are rejected by the server, so `eas deploy` warns/fails before uploading. +export const FREE_PLAN_HOSTING_DEPLOYMENT_SIZE_LIMIT_BYTES = 1024 ** 3; + export function formatStarterSubscribeCommand(accountName?: string): string { return `eas billing:subscribe starter${accountName ? ` --account ${accountName}` : ''}`; } diff --git a/packages/eas-cli/src/commands/deploy/index.ts b/packages/eas-cli/src/commands/deploy/index.ts index f14fda2a01..a938a13fb6 100644 --- a/packages/eas-cli/src/commands/deploy/index.ts +++ b/packages/eas-cli/src/commands/deploy/index.ts @@ -5,6 +5,10 @@ import chalk from 'chalk'; import fs from 'node:fs'; import * as path from 'node:path'; +import { + FREE_PLAN_HOSTING_DEPLOYMENT_SIZE_LIMIT_BYTES, + hasPaidSubscription, +} from '../../billing/plans'; import { getHostingDeploymentsUrl } from '../../build/utils/url'; import EasCommand from '../../commandUtils/EasCommand'; import { @@ -12,9 +16,12 @@ import { EasNonInteractiveAndJsonFlags, resolveNonInteractiveAndJsonFlags, } from '../../commandUtils/flags'; +import { AccountQuery } from '../../graphql/queries/AccountQuery'; import Log, { link } from '../../log'; import { ora } from '../../ora'; import { getOwnerAccountForProjectIdAsync } from '../../project/projectUtils'; +import { confirmAsync } from '../../prompts'; +import { formatBytes } from '../../utils/files'; import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json'; import * as WorkerAssets from '../../worker/assets'; import { @@ -153,7 +160,8 @@ export default class WorkerDeploy extends EasCommand { const { projectId, exp } = await getDynamicPrivateProjectConfigAsync(); const projectName = exp.slug; - const accountName = (await getOwnerAccountForProjectIdAsync(graphqlClient, projectId)).name; + const ownerAccount = await getOwnerAccountForProjectIdAsync(graphqlClient, projectId); + const accountName = ownerAccount.name; logExportedProjectInfo(projectDist); @@ -313,6 +321,49 @@ export default class WorkerDeploy extends EasCommand { assetFiles = await WorkerAssets.collectAssetsAsync(assetPath, { maxFileSize: MAX_UPLOAD_SIZE, }); + + // Check the deployment size against the Free-plan Hosting limit before + // packing/uploading, since the server would otherwise reject an oversized + // deployment only after a long upload. A real deploy fails fast (or prompts + // interactively); a dry run only warns, since it never uploads. The estimate + // is the client asset payload (the bulk of a static export); the plan is + // only queried when actually over the limit. + const estimatedBytes = assetFiles.reduce((total, asset) => total + asset.size, 0); + if (estimatedBytes > FREE_PLAN_HOSTING_DEPLOYMENT_SIZE_LIMIT_BYTES) { + const subscription = await AccountQuery.getSubscriptionAsync( + graphqlClient, + ownerAccount.id + ); + if (!hasPaidSubscription(subscription)) { + progress.stop(); + const billingUrl = `https://expo.dev/accounts/${accountName}/settings/billing`; + const sizeSummary = + `The estimated deployment size (${formatBytes(estimatedBytes)}) exceeds the ` + + `${formatBytes( + FREE_PLAN_HOSTING_DEPLOYMENT_SIZE_LIMIT_BYTES + )} EAS Hosting limit on the Free plan.`; + const remediation = `Upgrade your plan (${billingUrl}) or reduce the deployment size.`; + if (flags.dryRun) { + // Dry run never uploads, so don't block it; just warn that deploying + // this build would be rejected. + Log.warn(`${sizeSummary} Deploying it would fail. ${remediation}`); + } else if (flags.nonInteractive) { + throw new Error(`${sizeSummary} ${remediation} Aborting before upload.`); + } else { + Log.warn(`${sizeSummary} ${remediation}`); + const shouldContinue = await confirmAsync({ + message: 'Continue the deployment anyway?', + initial: false, + }); + if (!shouldContinue) { + Log.log('Deployment cancelled.'); + return; + } + } + progress.start('Preparing project'); + } + } + tarPath = await WorkerAssets.packFilesIterableAsync( emitWorkerTarballAsync({ routesConfig: await WorkerAssets.getRoutesConfigAsync(assetPath),