From 4ec6593c9d24f654efe44eaa57699972746768eb Mon Sep 17 00:00:00 2001 From: Vidit Shah Date: Thu, 8 Oct 2026 12:27:16 +0530 Subject: [PATCH 1/2] fix(toolkit-lib): warn on empty change sets for unsuccessful stacks --- .../lib/api/deployments/deploy-stack.ts | 8 ++- .../test/api/deployments/deploy-stack.test.ts | 60 +++++++++++++++++++ 2 files changed, 67 insertions(+), 1 deletion(-) diff --git a/packages/@aws-cdk/toolkit-lib/lib/api/deployments/deploy-stack.ts b/packages/@aws-cdk/toolkit-lib/lib/api/deployments/deploy-stack.ts index 23fddcb9d..86052e54b 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/api/deployments/deploy-stack.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/api/deployments/deploy-stack.ts @@ -483,7 +483,13 @@ class FullCloudFormationDeployment { }); } - if (this.options.forceDeployment) { + if (!this.cloudFormationStack.stackStatus.isDeploySuccess) { + await this.ioHelper.defaults.warn( + `CloudFormation reported no changes for ${this.stackName}, but the stack is in ${this.cloudFormationStack.stackStatus.name}. ` + + 'No update was performed and this does not indicate a successful deployment. ' + + 'Inspect the stack events and resolve the stack status before deploying again.', + ); + } else if (this.options.forceDeployment) { await this.ioHelper.defaults.warn( [ 'You used the --force flag, but CloudFormation reported that the deployment would not make any changes.', diff --git a/packages/@aws-cdk/toolkit-lib/test/api/deployments/deploy-stack.test.ts b/packages/@aws-cdk/toolkit-lib/test/api/deployments/deploy-stack.test.ts index 31632fb46..7f4d64ebb 100644 --- a/packages/@aws-cdk/toolkit-lib/test/api/deployments/deploy-stack.test.ts +++ b/packages/@aws-cdk/toolkit-lib/test/api/deployments/deploy-stack.test.ts @@ -921,6 +921,66 @@ test('deployStack reports no change if describeChangeSet returns an error that i expect(deployResult.type === 'did-deploy-stack' && deployResult.noOp).toEqual(true); }); +test.each([false, true])('empty change set warns about UPDATE_FAILED with force=%s', async (forceDeployment) => { + givenStackExists({ StackStatus: StackStatus.UPDATE_FAILED }); + givenTemplateIs(defaultTargetTemplate()); + fakeCfn.overrideChangeSetChanges = []; + + const result = await testDeployStack({ + ...standardDeployStackArguments(), + forceDeployment, + }); + + expect(result.type === 'did-deploy-stack' && result.noOp).toBe(true); + expect(ioHost.notifySpy).toHaveBeenCalledWith(expect.objectContaining({ + level: 'warn', + message: expect.stringContaining('withouterrors, but the stack is in UPDATE_FAILED'), + })); + expect(ioHost.notifySpy).not.toHaveBeenCalledWith(expect.objectContaining({ + message: expect.stringContaining('all resources are already up-to-date'), + })); + expect(mockCloudFormationClient).toHaveReceivedCommand(DeleteChangeSetCommand); + expect(mockCloudFormationClient).not.toHaveReceivedCommand(ExecuteChangeSetCommand); +}); + +test.each([ + StackStatus.CREATE_COMPLETE, + StackStatus.UPDATE_COMPLETE, + StackStatus.IMPORT_COMPLETE, +])('empty change set keeps the force warning for %s', async (stackStatus) => { + givenStackExists({ StackStatus: stackStatus }); + fakeCfn.overrideChangeSetChanges = []; + + await testDeployStack({ ...standardDeployStackArguments(), forceDeployment: true }); + + expect(ioHost.notifySpy).toHaveBeenCalledWith(expect.objectContaining({ + level: 'warn', + message: expect.stringContaining('all resources are already up-to-date'), + })); + expect(ioHost.notifySpy).not.toHaveBeenCalledWith(expect.objectContaining({ + message: expect.stringContaining('this does not indicate a successful deployment'), + })); +}); + +test('empty change set warns about a rolled-back stack and preserves it with no-execute', async () => { + givenStackExists({ StackStatus: StackStatus.UPDATE_ROLLBACK_COMPLETE }); + fakeCfn.overrideChangeSetChanges = []; + + const result = await testDeployStack({ + ...standardDeployStackArguments(), + deploymentMethod: { method: 'change-set', execute: false }, + }); + + expect(result.type === 'did-deploy-stack' && result.noOp).toBe(true); + expect(ioHost.notifySpy).toHaveBeenCalledWith(expect.objectContaining({ + level: 'warn', + message: expect.stringContaining('stack is in UPDATE_ROLLBACK_COMPLETE'), + })); + // Only the pre-creation cleanup runs; the newly created empty change set is retained. + expect(mockCloudFormationClient).toHaveReceivedCommandTimes(DeleteChangeSetCommand, 1); + expect(mockCloudFormationClient).not.toHaveReceivedCommand(ExecuteChangeSetCommand); +}); + test('deployStack throws error in case of early validation failures', async () => { mockCloudFormationClient.on(DescribeChangeSetCommand).resolves({ ChangeSetName: 'cdk-deploy-change-set', From 59a39805fba754946ce44f8ff88fc612811eb528 Mon Sep 17 00:00:00 2001 From: Vidit Shah Date: Wed, 21 Oct 2026 11:53:39 +0530 Subject: [PATCH 2/2] fix(toolkit-lib): narrow empty change set status warnings --- .../lib/api/deployments/deploy-stack.ts | 6 +++-- .../test/api/deployments/deploy-stack.test.ts | 27 +++++++++++++++++++ 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/packages/@aws-cdk/toolkit-lib/lib/api/deployments/deploy-stack.ts b/packages/@aws-cdk/toolkit-lib/lib/api/deployments/deploy-stack.ts index 86052e54b..3c041c1ec 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/api/deployments/deploy-stack.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/api/deployments/deploy-stack.ts @@ -483,11 +483,13 @@ class FullCloudFormationDeployment { }); } - if (!this.cloudFormationStack.stackStatus.isDeploySuccess) { + if (this.update && !this.cloudFormationStack.stackStatus.isDeploySuccess) { await this.ioHelper.defaults.warn( `CloudFormation reported no changes for ${this.stackName}, but the stack is in ${this.cloudFormationStack.stackStatus.name}. ` + 'No update was performed and this does not indicate a successful deployment. ' + - 'Inspect the stack events and resolve the stack status before deploying again.', + (this.cloudFormationStack.stackStatus.isRollbackSuccess + ? 'The previous deployment rolled back; review the stack events before retrying.' + : 'Inspect the stack events and resolve the stack status before deploying again.'), ); } else if (this.options.forceDeployment) { await this.ioHelper.defaults.warn( diff --git a/packages/@aws-cdk/toolkit-lib/test/api/deployments/deploy-stack.test.ts b/packages/@aws-cdk/toolkit-lib/test/api/deployments/deploy-stack.test.ts index 7f4d64ebb..e6f52c412 100644 --- a/packages/@aws-cdk/toolkit-lib/test/api/deployments/deploy-stack.test.ts +++ b/packages/@aws-cdk/toolkit-lib/test/api/deployments/deploy-stack.test.ts @@ -921,6 +921,26 @@ test('deployStack reports no change if describeChangeSet returns an error that i expect(deployResult.type === 'did-deploy-stack' && deployResult.noOp).toEqual(true); }); +test.each<[string, StackStatus | undefined]>([ + ['new stack', undefined], + ['unexecuted CREATE change set', StackStatus.REVIEW_IN_PROGRESS], +])('empty change set does not warn about an unsuccessful deployment for %s', async (_description, stackStatus) => { + if (stackStatus) { + givenStackExists({ StackStatus: stackStatus }); + } + fakeCfn.overrideChangeSetChanges = []; + + const result = await testDeployStack(standardDeployStackArguments()); + + expect(result.type === 'did-deploy-stack' && result.noOp).toBe(true); + expect(ioHost.notifySpy).not.toHaveBeenCalledWith(expect.objectContaining({ + level: 'warn', + message: expect.stringContaining('this does not indicate a successful deployment'), + })); + expect(mockCloudFormationClient).toHaveReceivedCommand(DeleteChangeSetCommand); + expect(mockCloudFormationClient).not.toHaveReceivedCommand(ExecuteChangeSetCommand); +}); + test.each([false, true])('empty change set warns about UPDATE_FAILED with force=%s', async (forceDeployment) => { givenStackExists({ StackStatus: StackStatus.UPDATE_FAILED }); givenTemplateIs(defaultTargetTemplate()); @@ -976,6 +996,13 @@ test('empty change set warns about a rolled-back stack and preserves it with no- level: 'warn', message: expect.stringContaining('stack is in UPDATE_ROLLBACK_COMPLETE'), })); + expect(ioHost.notifySpy).toHaveBeenCalledWith(expect.objectContaining({ + level: 'warn', + message: expect.stringContaining('The previous deployment rolled back; review the stack events before retrying.'), + })); + expect(ioHost.notifySpy).not.toHaveBeenCalledWith(expect.objectContaining({ + message: expect.stringContaining('resolve the stack status'), + })); // Only the pre-creation cleanup runs; the newly created empty change set is retained. expect(mockCloudFormationClient).toHaveReceivedCommandTimes(DeleteChangeSetCommand, 1); expect(mockCloudFormationClient).not.toHaveReceivedCommand(ExecuteChangeSetCommand);