Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
17 changes: 17 additions & 0 deletions packages/eas-cli/src/billing/__tests__/plans.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
4 changes: 4 additions & 0 deletions packages/eas-cli/src/billing/plans.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}` : ''}`;
}
Expand Down
53 changes: 52 additions & 1 deletion packages/eas-cli/src/commands/deploy/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,23 @@ 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 {
EASEnvironmentFlag,
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 {
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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),
Expand Down
Loading