From 8377e0f40913787b6009e9e60b1b6ba0130b64de Mon Sep 17 00:00:00 2001 From: Serhii Lizunov <34252204+LizunovSergey@users.noreply.github.com> Date: Sat, 19 Sep 2026 13:41:22 +0200 Subject: [PATCH 1/2] [eas-cli] Fail the deploy when an alias was not moved to the new deployment assignAlias resolves with the alias as it now stands, which is not necessarily the deployment that was requested, and neither caller looked at the result. A promotion that left production on the previous deployment therefore printed "Promoted deployment to production" and exited 0. Compare the returned alias against the deployment that was asked for and throw when they differ, the way assignDevDomainNameAsync already checks its own mutation's result. --- .../src/worker/__tests__/deployment.test.ts | 104 ++++++++++++++++++ packages/eas-cli/src/worker/deployment.ts | 31 +++++- 2 files changed, 133 insertions(+), 2 deletions(-) diff --git a/packages/eas-cli/src/worker/__tests__/deployment.test.ts b/packages/eas-cli/src/worker/__tests__/deployment.test.ts index f429741c4a..ab0c548d0e 100644 --- a/packages/eas-cli/src/worker/__tests__/deployment.test.ts +++ b/packages/eas-cli/src/worker/__tests__/deployment.test.ts @@ -3,6 +3,8 @@ import { promptAsync } from '../../prompts'; import { assertValidDevDomainName, assignDevDomainNameAsync, + assignWorkerDeploymentAliasAsync, + assignWorkerDeploymentProductionAsync, getSignedDeploymentUrlAsync, } from '../deployment'; import { DeploymentsMutation } from '../mutations'; @@ -248,3 +250,105 @@ describe(getSignedDeploymentUrlAsync, () => { expect(DeploymentsMutation.assignDevDomainNameAsync).not.toHaveBeenCalled(); }); }); + +function createAliasResult({ + aliasName, + deploymentIdentifier, +}: { + aliasName: string | null; + deploymentIdentifier: string; +}): any { + return { + id: 'alias-id', + aliasName, + url: 'https://warp-nexus.expo.app', + workerDeployment: { + id: 'worker-deployment-id', + url: `https://warp-nexus--${deploymentIdentifier}.expo.app`, + deploymentIdentifier, + deploymentDomain: 'warp-nexus', + createdAt: '2026-09-11T00:00:00.000Z', + }, + }; +} + +describe(assignWorkerDeploymentProductionAsync, () => { + beforeEach(() => { + jest.resetAllMocks(); + }); + + it('returns the alias when production was moved to the requested deployment', async () => { + const graphqlClient = {} as ExpoGraphqlClient; + const alias = createAliasResult({ aliasName: null, deploymentIdentifier: 'y3iw49p3qz' }); + jest.mocked(DeploymentsMutation.assignAliasAsync).mockResolvedValueOnce(alias); + + await expect( + assignWorkerDeploymentProductionAsync({ + graphqlClient, + appId: 'test-app-id', + deploymentId: 'y3iw49p3qz', + }) + ).resolves.toBe(alias); + }); + + it('throws when production still points at the previous deployment', async () => { + // Repros expo/eas-cli#4388: the mutation resolves, so the command printed + // "Promoted deployment to production" and exited 0 while production kept + // serving the previous bundle. + const graphqlClient = {} as ExpoGraphqlClient; + jest + .mocked(DeploymentsMutation.assignAliasAsync) + .mockResolvedValueOnce( + createAliasResult({ aliasName: null, deploymentIdentifier: 'c0547b2ed2' }) + ); + + await expect( + assignWorkerDeploymentProductionAsync({ + graphqlClient, + appId: 'test-app-id', + deploymentId: 'y3iw49p3qz', + }) + ).rejects.toThrow( + 'Production was not moved to deployment "y3iw49p3qz" and still points to "c0547b2ed2".' + ); + }); +}); + +describe(assignWorkerDeploymentAliasAsync, () => { + beforeEach(() => { + jest.resetAllMocks(); + }); + + it('returns the alias when it was moved to the requested deployment', async () => { + const graphqlClient = {} as ExpoGraphqlClient; + const alias = createAliasResult({ aliasName: 'staging', deploymentIdentifier: 'y3iw49p3qz' }); + jest.mocked(DeploymentsMutation.assignAliasAsync).mockResolvedValueOnce(alias); + + await expect( + assignWorkerDeploymentAliasAsync({ + graphqlClient, + appId: 'test-app-id', + deploymentId: 'y3iw49p3qz', + aliasName: 'staging', + }) + ).resolves.toBe(alias); + }); + + it('names the alias when it still points at the previous deployment', async () => { + const graphqlClient = {} as ExpoGraphqlClient; + jest + .mocked(DeploymentsMutation.assignAliasAsync) + .mockResolvedValueOnce( + createAliasResult({ aliasName: 'staging', deploymentIdentifier: 'c0547b2ed2' }) + ); + + await expect( + assignWorkerDeploymentAliasAsync({ + graphqlClient, + appId: 'test-app-id', + deploymentId: 'y3iw49p3qz', + aliasName: 'staging', + }) + ).rejects.toThrow('Alias "staging" was not moved to deployment "y3iw49p3qz"'); + }); +}); diff --git a/packages/eas-cli/src/worker/deployment.ts b/packages/eas-cli/src/worker/deployment.ts index 3e8cc86e9b..20fd9529c2 100644 --- a/packages/eas-cli/src/worker/deployment.ts +++ b/packages/eas-cli/src/worker/deployment.ts @@ -250,11 +250,13 @@ export async function assignWorkerDeploymentAliasAsync({ deploymentId: string; aliasName: string; }): ReturnType { - return await DeploymentsMutation.assignAliasAsync(graphqlClient, { + const alias = await DeploymentsMutation.assignAliasAsync(graphqlClient, { appId, deploymentId, aliasName, }); + assertAliasPointsToDeployment(alias, deploymentId); + return alias; } export async function assignWorkerDeploymentProductionAsync({ @@ -266,11 +268,36 @@ export async function assignWorkerDeploymentProductionAsync({ appId: string; deploymentId: string; }): ReturnType { - return await DeploymentsMutation.assignAliasAsync(graphqlClient, { + const alias = await DeploymentsMutation.assignAliasAsync(graphqlClient, { appId, deploymentId, aliasName: null, // this will assign the deployment as production }); + assertAliasPointsToDeployment(alias, deploymentId); + return alias; +} + +/** + * The mutation resolves with the alias as it now stands, which is not necessarily the deployment + * that was requested. Without this check the caller reports success and exits 0 while the alias + * still resolves to the previous deployment, which is what expo/eas-cli#4388 describes for + * `--prod`. `assignDevDomainNameAsync` already compares its own mutation's result against what it + * asked for; this does the same for aliases. + */ +function assertAliasPointsToDeployment( + alias: Awaited>, + requestedDeploymentId: string +): void { + const assignedDeploymentId = alias.workerDeployment.deploymentIdentifier; + if (String(assignedDeploymentId) === String(requestedDeploymentId)) { + return; + } + const target = alias.aliasName ? `Alias "${alias.aliasName}"` : 'Production'; + throw new Error( + `${target} was not moved to deployment "${requestedDeploymentId}" and still points to ` + + `"${assignedDeploymentId}". The deployment itself was created and is reachable at its own ` + + `URL, so retrying the command is safe.` + ); } export async function selectWorkerDeploymentOnAppAsync({ From 0b52caeb069e10c661720a8420d152ef11432e55 Mon Sep 17 00:00:00 2001 From: Serhii Lizunov <34252204+LizunovSergey@users.noreply.github.com> Date: Sat, 19 Sep 2026 14:04:16 +0200 Subject: [PATCH 2/2] [eas-cli] Add changelog entry --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 53b0001220..499454d1e3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ This is the log of notable changes to EAS CLI and related packages. - [build-tools] Allow expo.test subdomains to open local and staging simulator previews. ([#4480](https://github.com/expo/eas-cli/pull/4480) by [@hirbod](https://github.com/hirbod)) - [build-tools] Retry App Store Connect connection failures and safely recover interrupted status reads and upload commits. ([#4520](https://github.com/expo/eas-cli/pull/4520) by [@sjchmiela](https://github.com/sjchmiela)) +- [eas-cli] Fail `eas deploy --prod` and `eas deploy:alias` when the alias was not moved to the new deployment, instead of reporting success and exiting 0. ([#4443](https://github.com/expo/eas-cli/pull/4443) by [@LizunovSergey](https://github.com/LizunovSergey)) ### 🧹 Chores