From e94875cb4ea11f022437b332091d13d2682bf087 Mon Sep 17 00:00:00 2001 From: Kazuho Cryer-Shinozuka Date: Sat, 29 Aug 2026 00:18:18 +0900 Subject: [PATCH 1/8] feat: exclude stacks from the selection with `!` patterns Stack patterns are matched with picomatch, which reads a leading `!` as a negation. Every pattern was matched on its own and the results unioned, so `!A !B` asked for "not A" and "not B" and got back the union of the two, which is every stack there is. Patterns are now split into the ones that select and the ones that exclude: the selection is the union of the former, minus everything the latter match. picomatch's own `scan()` draws the line, so `!(A|B)` stays the extglob it is and `!!A` stays a positive. --- .../cloud-assembly/private/stack-assembly.ts | 64 ++++++++--- .../lib/api/cloud-assembly/stack-selector.ts | 8 ++ .../toolkit-lib/test/actions/destroy.test.ts | 30 +++++ .../stack-selection-exclusion.test.ts | 107 ++++++++++++++++++ packages/aws-cdk/README.md | 12 ++ 5 files changed, 207 insertions(+), 14 deletions(-) create mode 100644 packages/@aws-cdk/toolkit-lib/test/api/cloud-assembly/stack-selection-exclusion.test.ts diff --git a/packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/private/stack-assembly.ts b/packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/private/stack-assembly.ts index d8fd70981..e99c0ff11 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/private/stack-assembly.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/private/stack-assembly.ts @@ -1,10 +1,9 @@ import '../../../private/dispose-polyfill'; import type * as cxapi from '@aws-cdk/cloud-assembly-api'; import chalk from 'chalk'; -import { isMatch as picomatch } from 'picomatch'; +import picomatch from 'picomatch'; import { major } from 'semver'; import { ToolkitError } from '../../../toolkit/toolkit-error'; -import { flatten } from '../../../util'; import type { IoHelper } from '../../io/private'; import { IO } from '../../io/private'; import { StackCollection } from '../stack-collection'; @@ -194,25 +193,25 @@ export class StackAssembly implements IReadableCloudAssembly { return { stacks: matched, - suggestions: options.suggestPatternMatches ? this.suggestionsForPatterns(patterns, matched) : undefined, + suggestions: options.suggestPatternMatches ? this.suggestionsForPatterns(patterns) : undefined, }; } } /** - * For every pattern that matched no stack, collect the hierarchical ids of - * stacks that loosely (case-insensitively) resemble it. Patterns that matched - * at least one stack are omitted; the array is empty when there is no close - * match. Pure computation, never throws, emits no output. + * For every pattern that matches no stack in the assembly, collect the + * hierarchical ids of stacks that loosely (case-insensitively) resemble it. + * The array is empty when there is no close match. Pure computation, never + * throws, emits no output. */ - private suggestionsForPatterns(patterns: string[], matched: StackCollection): Record { + private suggestionsForPatterns(patterns: string[]): Record { const suggestions: Record = {}; - for (const pattern of patterns) { - if (matched.stackArtifacts.some((stack) => picomatch(stack.hierarchicalId, pattern))) { + for (const pattern of patterns.map(parsePattern)) { + if (this.allStacks.some((stack) => picomatch.isMatch(stack.hierarchicalId, pattern.glob))) { continue; } - suggestions[pattern] = this.allStacks - .filter((stack) => picomatch(stack.hierarchicalId.toLowerCase(), pattern.toLowerCase())) + suggestions[pattern.source] = this.allStacks + .filter((stack) => picomatch.isMatch(stack.hierarchicalId.toLowerCase(), pattern.glob.toLowerCase())) .map((stack) => stack.hierarchicalId); } return suggestions; @@ -233,8 +232,8 @@ export class StackAssembly implements IReadableCloudAssembly { patterns: string[], extend: ExpandStackSelection = ExpandStackSelection.NONE, ): Promise { - const matchingPattern = (pattern: string) => (stack: cxapi.CloudFormationStackArtifact) => picomatch(stack.hierarchicalId, pattern); - const matchedStacks = flatten(patterns.map(pattern => stacks.filter(matchingPattern(pattern)))); + const selects = matcherFor(patterns); + const matchedStacks = stacks.filter(stack => selects(stack.hierarchicalId)); return this.extendStacks(matchedStacks, stacks, extend); } @@ -267,6 +266,43 @@ export class StackAssembly implements IReadableCloudAssembly { } } +/** + * Read a pattern the way picomatch reads it: `scan()` strips the negating `!`s + * off the front, leaving extglobs like `!(a|b)` alone, and an even number of + * `!`s cancels out, as it does in picomatch itself. + */ +function parsePattern(pattern: string) { + const { prefix, input } = picomatch.scan(pattern); + + return { + source: pattern, + excludes: prefix.replace(/[^!]/g, '').length % 2 === 1, + glob: input.slice(prefix.length), + }; +} + +/** + * Match the stacks a set of patterns selects: the union of the selecting + * patterns, minus everything the excluding ones match. + * + * Exclusions on their own start from every stack, so `!Stack` selects every + * stack but that one. No patterns at all still selects nothing. + */ +function matcherFor(patterns: string[]): (hierarchicalId: string) => boolean { + const parsed = patterns.map(parsePattern); + const positives = parsed.filter(pattern => !pattern.excludes).map(pattern => picomatch(pattern.glob)); + const negatives = parsed.filter(pattern => pattern.excludes).map(pattern => picomatch(pattern.glob)); + + if (positives.length === 0 && negatives.length === 0) { + return () => false; + } + + return (hierarchicalId) => { + const included = positives.length === 0 || positives.some(matches => matches(hierarchicalId)); + return included && !negatives.some(matches => matches(hierarchicalId)); + }; +} + function indexByHierarchicalId(stacks: cxapi.CloudFormationStackArtifact[]): Map { const result = new Map(); diff --git a/packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/stack-selector.ts b/packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/stack-selector.ts index 60daad5f4..a165dfce4 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/stack-selector.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/stack-selector.ts @@ -78,6 +78,14 @@ export interface StackSelector { /** * A list of patterns to match the stack hierarchical ids * Only used with `PATTERN_*` selection strategies. + * + * A pattern starting with `!` excludes the stacks it matches. The selection is + * the union of the other patterns, minus everything the excluding ones match; + * exclusions on their own start from every stack. `!(...)` is extglob syntax + * and is matched as a regular pattern. + * + * - `['!Prod/Canary']` selects every stack except `Prod/Canary` + * - `['Prod/**', '!Prod/Canary']` selects every stack under `Prod` but that one */ patterns?: string[]; diff --git a/packages/@aws-cdk/toolkit-lib/test/actions/destroy.test.ts b/packages/@aws-cdk/toolkit-lib/test/actions/destroy.test.ts index eaae88baa..5abcfa440 100644 --- a/packages/@aws-cdk/toolkit-lib/test/actions/destroy.test.ts +++ b/packages/@aws-cdk/toolkit-lib/test/actions/destroy.test.ts @@ -212,6 +212,36 @@ describe('destroy', () => { expect(mockDestroyStack).not.toHaveBeenCalled(); }); + test('suggests a closely matching stack when an exclusion does not exist', async () => { + // WHEN + const cx = await builderFixture(toolkit, 'two-empty-stacks'); + await toolkit.destroy(cx, { + stacks: { strategy: StackSelectionStrategy.PATTERN_MATCH, patterns: ['!stack1'] }, + }); + + // THEN: an exclusion was compared against the stacks it left behind, which + // always matches, so nothing was ever reported for it + expect(ioHost.notifySpy).toHaveBeenCalledWith(expect.objectContaining({ + action: 'destroy', + level: 'warn', + code: 'CDK_TOOLKIT_W7010', + message: expect.stringContaining(`${chalk.red('!stack1')} does not exist. Do you mean`), + })); + }); + + test('a stack cancelled out by an exclusion is not reported as missing', async () => { + // WHEN: the exclusion empties the selection, but both patterns match existing stacks + const cx = await builderFixture(toolkit, 'two-empty-stacks'); + await toolkit.destroy(cx, { + stacks: { strategy: StackSelectionStrategy.PATTERN_MATCH, patterns: ['Stack1', '!Stack1'] }, + }); + + // THEN: no "does not exist" for a stack that exists, only the empty-selection warning + expect(ioHost.notifySpy).not.toHaveBeenCalledWith(expect.objectContaining({ code: 'CDK_TOOLKIT_W7010' })); + expect(ioHost.notifySpy).toHaveBeenCalledWith(expect.objectContaining({ code: 'CDK_TOOLKIT_W7011' })); + expect(mockDestroyStack).not.toHaveBeenCalled(); + }); + test('warns about a missing name but still destroys the matching stacks', async () => { // WHEN: one name matches (Stack1), the other does not const cx = await builderFixture(toolkit, 'two-empty-stacks'); diff --git a/packages/@aws-cdk/toolkit-lib/test/api/cloud-assembly/stack-selection-exclusion.test.ts b/packages/@aws-cdk/toolkit-lib/test/api/cloud-assembly/stack-selection-exclusion.test.ts new file mode 100644 index 000000000..2fc07202e --- /dev/null +++ b/packages/@aws-cdk/toolkit-lib/test/api/cloud-assembly/stack-selection-exclusion.test.ts @@ -0,0 +1,107 @@ +import { ExpandStackSelection, StackSelectionStrategy } from '../../../lib/api/cloud-assembly'; +import { Toolkit } from '../../../lib/toolkit'; +import { TestIoHost } from '../../_helpers'; +import type { TestStackArtifact } from '../../_helpers/test-cloud-assembly-source'; +import { TestCloudAssemblySource } from '../../_helpers/test-cloud-assembly-source'; + +const ioHost = new TestIoHost(); +const toolkit = new Toolkit({ ioHost }); + +beforeEach(() => { + ioHost.notifySpy.mockClear(); +}); + +// Patterns used to be matched independently and unioned, so `['!A', '!B']` +// contributed "not A" and "not B" and returned every stack there is. + +const STACKS: TestStackArtifact[] = [ + { stackName: 'StackA', displayName: 'Prod/StackA' }, + { stackName: 'StackB', displayName: 'Prod/StackB' }, + { stackName: 'Canary', displayName: 'Prod/Canary' }, + { stackName: 'DevStack', displayName: 'Dev/DevStack' }, +]; + +async function select(patterns: string[], expand = ExpandStackSelection.NONE) { + const cx = new TestCloudAssemblySource({ stacks: STACKS }); + const stacks = await toolkit.list(cx, { + stacks: { patterns, strategy: StackSelectionStrategy.PATTERN_MATCH, expand }, + }); + return stacks.map(s => s.id).sort(); +} + +describe('exclusion patterns', () => { + test('a single exclusion selects everything else', async () => { + expect(await select(['!Prod/StackB'])).toEqual(['Dev/DevStack', 'Prod/Canary', 'Prod/StackA']); + }); + + test('multiple exclusions remove all of the stacks they match', async () => { + // Used to return every stack, including the two that were excluded + expect(await select(['!Prod/StackB', '!Prod/Canary'])).toEqual(['Dev/DevStack', 'Prod/StackA']); + }); + + test('an exclusion narrows down the stacks matched by the other patterns', async () => { + // Used to return every stack but Canary, because 'Prod/StackA' and + // '!Prod/Canary' were unioned instead of subtracted + expect(await select(['Prod/StackA', '!Prod/Canary'])).toEqual(['Prod/StackA']); + }); + + test('`!(...)` is extglob syntax, not an exclusion', async () => { + // Taken as an exclusion, `!(StackA)` would leave `(StackA)` to exclude and + // select `Prod/StackA` too. As an extglob it does not cross a `/`. + const cx = new TestCloudAssemblySource({ + stacks: [{ stackName: 'StackA' }, { stackName: 'StackB' }, { stackName: 'Nested', displayName: 'Prod/StackA' }], + }); + const stacks = await toolkit.list(cx, { + stacks: { patterns: ['!(StackA)'], strategy: StackSelectionStrategy.PATTERN_MATCH, expand: ExpandStackSelection.NONE }, + }); + + expect(stacks.map(s => s.id).sort()).toEqual(['StackB']); + }); + + test('a doubled `!` cancels out, the way picomatch reads it', async () => { + // Not an exclusion: picomatch matches `!!Prod/StackA` as a positive + expect(await select(['!!Prod/StackA'])).toEqual(['Prod/StackA']); + }); + + test('patterns without an exclusion are unaffected', async () => { + expect(await select(['Prod/StackA', 'Dev/*'])).toEqual(['Dev/DevStack', 'Prod/StackA']); + }); + + test('an empty pattern list still selects nothing', async () => { + expect(await select([])).toEqual([]); + }); +}); + +describe('exclusion patterns and dependency expansion', () => { + const DEPENDENCY: TestStackArtifact = { stackName: 'DependencyStack' }; + const DEPENDENT: TestStackArtifact = { stackName: 'DependentStack', depends: ['DependencyStack'] }; + + test('an excluded stack is still pulled back in as a dependency', async () => { + // Deploying the dependent stack without its dependency is not something the + // toolkit can do, so expansion wins over the exclusion. `--exclusively` + // (ExpandStackSelection.NONE) is how you keep the dependency out. + const cx = new TestCloudAssemblySource({ stacks: [DEPENDENCY, DEPENDENT] }); + const stacks = await toolkit.list(cx, { + stacks: { + patterns: ['DependentStack', '!DependencyStack'], + strategy: StackSelectionStrategy.PATTERN_MATCH, + expand: ExpandStackSelection.UPSTREAM, + }, + }); + + expect(stacks.map(s => s.id).sort()).toEqual(['DependencyStack', 'DependentStack']); + }); + + test('without expansion the exclusion holds', async () => { + const cx = new TestCloudAssemblySource({ stacks: [DEPENDENCY, DEPENDENT] }); + const stacks = await toolkit.list(cx, { + stacks: { + patterns: ['DependentStack', '!DependencyStack'], + strategy: StackSelectionStrategy.PATTERN_MATCH, + expand: ExpandStackSelection.NONE, + }, + }); + + expect(stacks.map(s => s.id).sort()).toEqual(['DependentStack']); + }); +}); diff --git a/packages/aws-cdk/README.md b/packages/aws-cdk/README.md index e44a565a9..fb55924d5 100644 --- a/packages/aws-cdk/README.md +++ b/packages/aws-cdk/README.md @@ -293,6 +293,18 @@ In order to deploy them, you can list the stacks you want to deploy. If your app If you want to deploy all of them, you can use the flag `--all` or the wildcard `*` to deploy all stacks in an app. Please note that, if you have a hierarchy of stacks as described above, `--all` and `*` will only match the stacks on the top level. If you want to match all the stacks in the hierarchy, use `**`. You can also combine these patterns. For example, if you want to deploy all stacks in the `Prod` stage, you can use `cdk deploy PipelineStack/Prod/**`. +A pattern starting with `!` excludes the stacks it matches instead of selecting them, which is useful when you want everything but a handful of stacks. Quote the pattern so your shell does not try to expand the `!` itself: + +```console +$ # every stack except NlbStack +$ cdk deploy '!NlbStack' + +$ # every stack under Prod, except the canary +$ cdk deploy 'PipelineStack/Prod/**' '!PipelineStack/Prod/Canary' +``` + +The selection is the union of the other patterns, minus everything the exclusions match; `!(...)` is extglob syntax, not an exclusion. When you only pass exclusions, they apply to every stack in the app. Note that `--all` cannot be combined with patterns, so use `**` when you want to spell out the starting point. Stacks that a selected stack depends on are still added to the deployment even if you excluded them; pass `--exclusively` (`-e`) to keep them out. + `--concurrency N` allows deploying multiple stacks in parallel while respecting inter-stack dependencies to speed up deployments. It does not protect against CloudFormation and other AWS account rate limiting. #### Parameters From 0aedbf0b773026167c3e979f8659f9084b4565b7 Mon Sep 17 00:00:00 2001 From: Kazuho Cryer-Shinozuka Date: Wed, 2 Sep 2026 18:37:29 +0900 Subject: [PATCH 2/8] address review: compile patterns exactly as picomatch defines them Negation status now comes from parse().negated, the flag matching itself uses, instead of counting `!`s in the scan() prefix. Every pattern is compiled raw - a negated matcher accepts the stacks that survive it - so nothing is stripped any more. Slicing the prefix off broke on a bare `!`, which left an empty pattern that picomatch refuses to compile. --- .../cloud-assembly/private/stack-assembly.ts | 46 ++++++++----------- .../stack-selection-exclusion.test.ts | 4 ++ 2 files changed, 23 insertions(+), 27 deletions(-) diff --git a/packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/private/stack-assembly.ts b/packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/private/stack-assembly.ts index e99c0ff11..313b5d19b 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/private/stack-assembly.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/private/stack-assembly.ts @@ -206,12 +206,17 @@ export class StackAssembly implements IReadableCloudAssembly { */ private suggestionsForPatterns(patterns: string[]): Record { const suggestions: Record = {}; - for (const pattern of patterns.map(parsePattern)) { - if (this.allStacks.some((stack) => picomatch.isMatch(stack.hierarchicalId, pattern.glob))) { + for (const pattern of patterns) { + // The stacks the pattern is about; for a negation, the stacks it excludes + const refersTo = picomatch.parse(pattern).negated + ? (id: string, glob: string) => !picomatch.isMatch(id, glob) + : picomatch.isMatch; + + if (this.allStacks.some((stack) => refersTo(stack.hierarchicalId, pattern))) { continue; } - suggestions[pattern.source] = this.allStacks - .filter((stack) => picomatch.isMatch(stack.hierarchicalId.toLowerCase(), pattern.glob.toLowerCase())) + suggestions[pattern] = this.allStacks + .filter((stack) => refersTo(stack.hierarchicalId.toLowerCase(), pattern.toLowerCase())) .map((stack) => stack.hierarchicalId); } return suggestions; @@ -267,39 +272,26 @@ export class StackAssembly implements IReadableCloudAssembly { } /** - * Read a pattern the way picomatch reads it: `scan()` strips the negating `!`s - * off the front, leaving extglobs like `!(a|b)` alone, and an even number of - * `!`s cancels out, as it does in picomatch itself. - */ -function parsePattern(pattern: string) { - const { prefix, input } = picomatch.scan(pattern); - - return { - source: pattern, - excludes: prefix.replace(/[^!]/g, '').length % 2 === 1, - glob: input.slice(prefix.length), - }; -} - -/** - * Match the stacks a set of patterns selects: the union of the selecting - * patterns, minus everything the excluding ones match. + * Match the stacks a set of patterns selects: OR over the selecting patterns, + * AND over the negated ones. Every pattern is compiled exactly as picomatch + * defines it - a negated matcher accepts the stacks that survive it - and + * `parse().negated` decides which group a pattern belongs to. * - * Exclusions on their own start from every stack, so `!Stack` selects every + * Negations on their own start from every stack, so `!Stack` selects every * stack but that one. No patterns at all still selects nothing. */ function matcherFor(patterns: string[]): (hierarchicalId: string) => boolean { - const parsed = patterns.map(parsePattern); - const positives = parsed.filter(pattern => !pattern.excludes).map(pattern => picomatch(pattern.glob)); - const negatives = parsed.filter(pattern => pattern.excludes).map(pattern => picomatch(pattern.glob)); + const matchers = patterns.map(pattern => ({ negates: picomatch.parse(pattern).negated, matches: picomatch(pattern) })); + const positives = matchers.filter(matcher => !matcher.negates); + const negatives = matchers.filter(matcher => matcher.negates); if (positives.length === 0 && negatives.length === 0) { return () => false; } return (hierarchicalId) => { - const included = positives.length === 0 || positives.some(matches => matches(hierarchicalId)); - return included && !negatives.some(matches => matches(hierarchicalId)); + const included = positives.length === 0 || positives.some(matcher => matcher.matches(hierarchicalId)); + return included && negatives.every(matcher => matcher.matches(hierarchicalId)); }; } diff --git a/packages/@aws-cdk/toolkit-lib/test/api/cloud-assembly/stack-selection-exclusion.test.ts b/packages/@aws-cdk/toolkit-lib/test/api/cloud-assembly/stack-selection-exclusion.test.ts index 2fc07202e..a5b1c44bf 100644 --- a/packages/@aws-cdk/toolkit-lib/test/api/cloud-assembly/stack-selection-exclusion.test.ts +++ b/packages/@aws-cdk/toolkit-lib/test/api/cloud-assembly/stack-selection-exclusion.test.ts @@ -67,6 +67,10 @@ describe('exclusion patterns', () => { expect(await select(['Prod/StackA', 'Dev/*'])).toEqual(['Dev/DevStack', 'Prod/StackA']); }); + test('a bare `!` excludes nothing and does not crash', async () => { + expect(await select(['!'])).toEqual(['Dev/DevStack', 'Prod/Canary', 'Prod/StackA', 'Prod/StackB']); + }); + test('an empty pattern list still selects nothing', async () => { expect(await select([])).toEqual([]); }); From 60f15597f6489823ba97b58b42c9cfbfde21c1c5 Mon Sep 17 00:00:00 2001 From: Kazuho Cryer-Shinozuka Date: Wed, 2 Sep 2026 18:55:58 +0900 Subject: [PATCH 3/8] revert unrelated comment rewording --- .../lib/api/cloud-assembly/private/stack-assembly.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/private/stack-assembly.ts b/packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/private/stack-assembly.ts index 313b5d19b..596614ab4 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/private/stack-assembly.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/private/stack-assembly.ts @@ -199,10 +199,10 @@ export class StackAssembly implements IReadableCloudAssembly { } /** - * For every pattern that matches no stack in the assembly, collect the - * hierarchical ids of stacks that loosely (case-insensitively) resemble it. - * The array is empty when there is no close match. Pure computation, never - * throws, emits no output. + * For every pattern that matched no stack, collect the hierarchical ids of + * stacks that loosely (case-insensitively) resemble it. Patterns that matched + * at least one stack are omitted; the array is empty when there is no close + * match. Pure computation, never throws, emits no output. */ private suggestionsForPatterns(patterns: string[]): Record { const suggestions: Record = {}; From c565a191a2f90eb3a318b67b3b53251701e16368 Mon Sep 17 00:00:00 2001 From: Kazuho Cryer-Shinozuka Date: Fri, 4 Sep 2026 00:58:54 +0900 Subject: [PATCH 4/8] refactor --- .../lib/api/cloud-assembly/private/stack-assembly.ts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/private/stack-assembly.ts b/packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/private/stack-assembly.ts index 596614ab4..154b07b49 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/private/stack-assembly.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/private/stack-assembly.ts @@ -281,17 +281,19 @@ export class StackAssembly implements IReadableCloudAssembly { * stack but that one. No patterns at all still selects nothing. */ function matcherFor(patterns: string[]): (hierarchicalId: string) => boolean { - const matchers = patterns.map(pattern => ({ negates: picomatch.parse(pattern).negated, matches: picomatch(pattern) })); - const positives = matchers.filter(matcher => !matcher.negates); - const negatives = matchers.filter(matcher => matcher.negates); + const positives: picomatch.Matcher[] = []; + const negatives: picomatch.Matcher[] = []; + for (const pattern of patterns) { + (picomatch.parse(pattern).negated ? negatives : positives).push(picomatch(pattern)); + } if (positives.length === 0 && negatives.length === 0) { return () => false; } return (hierarchicalId) => { - const included = positives.length === 0 || positives.some(matcher => matcher.matches(hierarchicalId)); - return included && negatives.every(matcher => matcher.matches(hierarchicalId)); + const included = positives.length === 0 || positives.some(matches => matches(hierarchicalId)); + return included && negatives.every(matches => matches(hierarchicalId)); }; } From 966fcf69b0a1b57c539ea6a98da2532f45ff6924 Mon Sep 17 00:00:00 2001 From: Kazuho Cryer-Shinozuka Date: Fri, 4 Sep 2026 01:32:27 +0900 Subject: [PATCH 5/8] chore: retrigger ci From 24b31fa21c9c8456598927f52e6b8ee7f7ad52b2 Mon Sep 17 00:00:00 2001 From: Kazuho Cryer-Shinozuka Date: Fri, 4 Sep 2026 20:37:51 +0900 Subject: [PATCH 6/8] refactor: reuse the matcher's parse state instead of parsing twice --- .../lib/api/cloud-assembly/private/stack-assembly.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/private/stack-assembly.ts b/packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/private/stack-assembly.ts index 154b07b49..d3aa08c93 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/private/stack-assembly.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/private/stack-assembly.ts @@ -275,7 +275,7 @@ export class StackAssembly implements IReadableCloudAssembly { * Match the stacks a set of patterns selects: OR over the selecting patterns, * AND over the negated ones. Every pattern is compiled exactly as picomatch * defines it - a negated matcher accepts the stacks that survive it - and - * `parse().negated` decides which group a pattern belongs to. + * its parse state's `negated` decides which group a pattern belongs to. * * Negations on their own start from every stack, so `!Stack` selects every * stack but that one. No patterns at all still selects nothing. @@ -284,7 +284,8 @@ function matcherFor(patterns: string[]): (hierarchicalId: string) => boolean { const positives: picomatch.Matcher[] = []; const negatives: picomatch.Matcher[] = []; for (const pattern of patterns) { - (picomatch.parse(pattern).negated ? negatives : positives).push(picomatch(pattern)); + const parsed = picomatch(pattern, undefined, true); + (parsed.state.negated ? negatives : positives).push(parsed); } if (positives.length === 0 && negatives.length === 0) { From 06e2e4d88ea920206e446882e9a06124ae88cb3d Mon Sep 17 00:00:00 2001 From: Kazuho Cryer-Shinozuka Date: Fri, 4 Sep 2026 20:42:25 +0900 Subject: [PATCH 7/8] refactor: compile each pattern once when computing suggestions --- .../lib/api/cloud-assembly/private/stack-assembly.ts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/private/stack-assembly.ts b/packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/private/stack-assembly.ts index d3aa08c93..d39bf70b1 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/private/stack-assembly.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/private/stack-assembly.ts @@ -207,16 +207,18 @@ export class StackAssembly implements IReadableCloudAssembly { private suggestionsForPatterns(patterns: string[]): Record { const suggestions: Record = {}; for (const pattern of patterns) { + const exact = picomatch(pattern, undefined, true); + const loose = picomatch(pattern.toLowerCase()); // The stacks the pattern is about; for a negation, the stacks it excludes - const refersTo = picomatch.parse(pattern).negated - ? (id: string, glob: string) => !picomatch.isMatch(id, glob) - : picomatch.isMatch; + const refersTo = exact.state.negated + ? (matches: picomatch.Matcher, id: string) => !matches(id) + : (matches: picomatch.Matcher, id: string) => matches(id); - if (this.allStacks.some((stack) => refersTo(stack.hierarchicalId, pattern))) { + if (this.allStacks.some((stack) => refersTo(exact, stack.hierarchicalId))) { continue; } suggestions[pattern] = this.allStacks - .filter((stack) => refersTo(stack.hierarchicalId.toLowerCase(), pattern.toLowerCase())) + .filter((stack) => refersTo(loose, stack.hierarchicalId.toLowerCase())) .map((stack) => stack.hierarchicalId); } return suggestions; From 8d1f88c2cdf17841327d7be849976366cc34c3ca Mon Sep 17 00:00:00 2001 From: Kazuho Cryer-Shinozuka Date: Sat, 5 Sep 2026 00:23:33 +0900 Subject: [PATCH 8/8] docs: apply reviewer wording for the stack selection section --- packages/aws-cdk/README.md | 38 +++++++++++++++++++++++++++++++------- 1 file changed, 31 insertions(+), 7 deletions(-) diff --git a/packages/aws-cdk/README.md b/packages/aws-cdk/README.md index fb55924d5..58017509d 100644 --- a/packages/aws-cdk/README.md +++ b/packages/aws-cdk/README.md @@ -291,19 +291,43 @@ You can have multiple stacks in a cdk app. An example can be found in [how to cr In order to deploy them, you can list the stacks you want to deploy. If your application contains pipeline stacks, the `cdk list` command will show stack names as paths, showing where they are in the pipeline hierarchy (e.g., `PipelineStack`, `PipelineStack/Prod`, `PipelineStack/Prod/MyService` etc). -If you want to deploy all of them, you can use the flag `--all` or the wildcard `*` to deploy all stacks in an app. Please note that, if you have a hierarchy of stacks as described above, `--all` and `*` will only match the stacks on the top level. If you want to match all the stacks in the hierarchy, use `**`. You can also combine these patterns. For example, if you want to deploy all stacks in the `Prod` stage, you can use `cdk deploy PipelineStack/Prod/**`. +To deploy every stack in the app, use the `--all` flag or the wildcard `*`. +If your app has stacks inside stages, keep in mind how far each wildcard reaches: -A pattern starting with `!` excludes the stacks it matches instead of selecting them, which is useful when you want everything but a handful of stacks. Quote the pattern so your shell does not try to expand the `!` itself: +- `*` matches one level, so it only picks up top-level stacks (same as `--all`) +- `**` matches any number of levels, so it picks up stacks at every depth + +You can also combine these patterns. +For example, to deploy everything inside the `Prod` stage of a pipeline: ```console -$ # every stack except NlbStack -$ cdk deploy '!NlbStack' +cdk deploy 'PipelineStack/Prod/**' +``` + +##### Excluding stacks -$ # every stack under Prod, except the canary -$ cdk deploy 'PipelineStack/Prod/**' '!PipelineStack/Prod/Canary' +Put a `!` in front of a pattern to leave those stacks out. +Wrap the pattern in quotes so your shell leaves the `!` alone. + +Every stack except `NlbStack`: + +``` +cdk deploy '!NlbStack' ``` -The selection is the union of the other patterns, minus everything the exclusions match; `!(...)` is extglob syntax, not an exclusion. When you only pass exclusions, they apply to every stack in the app. Note that `--all` cannot be combined with patterns, so use `**` when you want to spell out the starting point. Stacks that a selected stack depends on are still added to the deployment even if you excluded them; pass `--exclusively` (`-e`) to keep them out. +Everything under `Prod`, except the canary: + +``` +cdk deploy 'PipelineStack/Prod/**' '!PipelineStack/Prod/Canary' +``` + +CDK picks the stacks your normal patterns match, then drops the ones your `!` patterns match. +If you only pass `!` patterns, they apply to every stack in the app. +`--all` doesn't work together with patterns, so use `**` instead. +An excluded stack still deploys if a selected stack needs it — add `--exclusively` (`-e`) to skip dependencies. +And `!(...)` is shell syntax, not a CDK exclusion. + +##### Deploying stacks in parallel `--concurrency N` allows deploying multiple stacks in parallel while respecting inter-stack dependencies to speed up deployments. It does not protect against CloudFormation and other AWS account rate limiting.