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 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({