From 9c0f9132170f7a9bc6fa90130285ff8db172540b Mon Sep 17 00:00:00 2001 From: Rob Giseburt Date: Mon, 10 Oct 2022 17:09:02 -0500 Subject: [PATCH 1/6] Adjust projen setting so tests against snapshots don't always simply pass --- .projen/tasks.json | 9 +++++++++ .projenrc.js | 4 ++++ package.json | 1 + 3 files changed, 14 insertions(+) diff --git a/.projen/tasks.json b/.projen/tasks.json index 206be5bc..344c8d30 100644 --- a/.projen/tasks.json +++ b/.projen/tasks.json @@ -246,6 +246,15 @@ } ] }, + "test:update": { + "name": "test:update", + "description": "Update jest snapshots", + "steps": [ + { + "exec": "jest --updateSnapshot --passWithNoTests --all" + } + ] + }, "test:watch": { "name": "test:watch", "description": "Run jest in watch mode", diff --git a/.projenrc.js b/.projenrc.js index 9255e329..cb3f5b2e 100644 --- a/.projenrc.js +++ b/.projenrc.js @@ -1,4 +1,5 @@ const { awscdk } = require('projen'); +const { UpdateSnapshot } = require('projen/lib/javascript'); const project = new awscdk.AwsCdkConstructLibrary({ name: 'cdk-pipelines-github', @@ -10,6 +11,9 @@ const project = new awscdk.AwsCdkConstructLibrary({ defaultReleaseBranch: 'main', repositoryUrl: 'https://github.com/cdklabs/cdk-pipelines-github.git', bundledDeps: ['decamelize', 'yaml', 'fast-json-patch'], + jestOptions: { + updateSnapshot: UpdateSnapshot.NEVER, + }, publishToPypi: { distName: 'cdk-pipelines-github', diff --git a/package.json b/package.json index d6ab60ad..efa6b7d0 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "pre-compile": "npx projen pre-compile", "release": "npx projen release", "test": "npx projen test", + "test:update": "npx projen test:update", "test:watch": "npx projen test:watch", "unbump": "npx projen unbump", "upgrade": "npx projen upgrade", From 7f50a44e79ac0ea618495480933ef8dcb048ba07 Mon Sep 17 00:00:00 2001 From: Rob Giseburt Date: Mon, 10 Oct 2022 17:19:31 -0500 Subject: [PATCH 2/6] change `GitHubActionStep.jobStep` to `GitHubActionStep.jobSteps` (plural) --- .projen/tasks.json | 2 +- API.md | 6 +++--- src/pipeline.ts | 4 +--- src/steps/github-action-step.ts | 10 +++++----- test/__snapshots__/stage-options.test.ts.snap | 2 ++ test/stage-options.test.ts | 12 ++++++++---- 6 files changed, 20 insertions(+), 16 deletions(-) diff --git a/.projen/tasks.json b/.projen/tasks.json index 344c8d30..56d9a2b8 100644 --- a/.projen/tasks.json +++ b/.projen/tasks.json @@ -239,7 +239,7 @@ "description": "Run tests", "steps": [ { - "exec": "jest --passWithNoTests --all --updateSnapshot" + "exec": "jest --passWithNoTests --all" }, { "spawn": "eslint" diff --git a/API.md b/API.md index 9f6ae114..c53605e4 100644 --- a/API.md +++ b/API.md @@ -247,7 +247,7 @@ new GitHubActionStep(id: string, props: GitHubActionStepProps) * **id** (string) *No description* * **props** ([GitHubActionStepProps](#cdk-pipelines-github-githubactionstepprops)) *No description* - * **jobStep** ([JobStep](#cdk-pipelines-github-jobstep)) The Job step. + * **jobSteps** (Array<[JobStep](#cdk-pipelines-github-jobstep)>) The Job steps. * **env** (Map) Environment variables to set. __*Optional*__ @@ -258,7 +258,7 @@ new GitHubActionStep(id: string, props: GitHubActionStepProps) Name | Type | Description -----|------|------------- **env** | Map | -**jobStep** | [JobStep](#cdk-pipelines-github-jobstep) | +**jobSteps** | Array<[JobStep](#cdk-pipelines-github-jobstep)> | @@ -804,7 +804,7 @@ Name | Type | Description Name | Type | Description -----|------|------------- -**jobStep** | [JobStep](#cdk-pipelines-github-jobstep) | The Job step. +**jobSteps** | Array<[JobStep](#cdk-pipelines-github-jobstep)> | The Job steps. **env**? | Map | Environment variables to set.
__*Optional*__ diff --git a/src/pipeline.ts b/src/pipeline.ts index e6474004..9c1e57f9 100644 --- a/src/pipeline.ts +++ b/src/pipeline.ts @@ -658,9 +658,7 @@ export class GitHubWorkflow extends PipelineBase { runsOn: this.runner.runsOn, needs: this.renderDependencies(node), env: step.env, - steps: [ - step.jobStep, - ], + steps: step.jobSteps, }, }; } diff --git a/src/steps/github-action-step.ts b/src/steps/github-action-step.ts index 87a164eb..d377989d 100644 --- a/src/steps/github-action-step.ts +++ b/src/steps/github-action-step.ts @@ -3,26 +3,26 @@ import { JobStep } from '../workflows-model'; export interface GitHubActionStepProps { /** - * The Job step. + * The Job steps. */ - readonly jobStep: JobStep; + readonly jobSteps: JobStep[]; /** * Environment variables to set. */ readonly env?: Record; -} +}; /** * Specifies a GitHub Action as a step in the pipeline. */ export class GitHubActionStep extends Step { public readonly env: Record; - public readonly jobStep: JobStep; + public readonly jobSteps: JobStep[]; constructor(id: string, props: GitHubActionStepProps) { super(id); - this.jobStep = props.jobStep; + this.jobSteps = props.jobSteps; this.env = props.env ?? {}; } } diff --git a/test/__snapshots__/stage-options.test.ts.snap b/test/__snapshots__/stage-options.test.ts.snap index 0ba942a1..5892fc6b 100644 --- a/test/__snapshots__/stage-options.test.ts.snap +++ b/test/__snapshots__/stage-options.test.ts.snap @@ -110,6 +110,8 @@ jobs: - Build-Build env: {} steps: + - name: Checkout + uses: actions/checkout@v2 - name: post deploy action uses: my-post-deploy-action@1.0.0 with: diff --git a/test/stage-options.test.ts b/test/stage-options.test.ts index c2ef7524..d7b3f1db 100644 --- a/test/stage-options.test.ts +++ b/test/stage-options.test.ts @@ -216,24 +216,27 @@ test('can set pre/post github action job step', () => { pipeline.addStageWithGitHubOptions(stage, { pre: [new GitHubActionStep('PreDeployAction', { - jobStep: { + jobSteps: [{ name: 'pre deploy action', uses: 'my-pre-deploy-action@1.0.0', with: { 'app-id': 1234, 'secrets': 'my-secrets', }, - }, + }], })], post: [new GitHubActionStep('PostDeployAction', { - jobStep: { + jobSteps: [{ + name: 'Checkout', + uses: 'actions/checkout@v2', + }, { name: 'post deploy action', uses: 'my-post-deploy-action@1.0.0', with: { 'app-id': 4321, 'secrets': 'secrets', }, - }, + }], })], }); @@ -242,5 +245,6 @@ test('can set pre/post github action job step', () => { expect(readFileSync(pipeline.workflowPath, 'utf-8')).toMatchSnapshot(); expect(readFileSync(pipeline.workflowPath, 'utf-8')).toContain('my-pre-deploy-action\@1\.0\.0'); expect(readFileSync(pipeline.workflowPath, 'utf-8')).toContain('my-post-deploy-action\@1\.0\.0'); + expect(readFileSync(pipeline.workflowPath, 'utf-8')).toContain('actions/checkout@v2'); }); }); From 2f73c9c1a6c6801413fc757d48f9cc29b9fb0544 Mon Sep 17 00:00:00 2001 From: Rob Giseburt Date: Mon, 10 Oct 2022 17:25:23 -0500 Subject: [PATCH 3/6] add optional `if` property to `GitHubActionStep` --- API.md | 3 +++ src/pipeline.ts | 4 ++-- src/steps/github-action-step.ts | 9 +++++++++ test/__snapshots__/stage-options.test.ts.snap | 4 ++++ test/stage-options.test.ts | 13 ++++++++++--- 5 files changed, 28 insertions(+), 5 deletions(-) diff --git a/API.md b/API.md index c53605e4..676eb811 100644 --- a/API.md +++ b/API.md @@ -249,6 +249,7 @@ new GitHubActionStep(id: string, props: GitHubActionStepProps) * **props** ([GitHubActionStepProps](#cdk-pipelines-github-githubactionstepprops)) *No description* * **jobSteps** (Array<[JobStep](#cdk-pipelines-github-jobstep)>) The Job steps. * **env** (Map) Environment variables to set. __*Optional*__ + * **if** (string) jobs..if. - overrides the JobSettings if provided, empty string (`''`) will remove the `if` clause. __*Optional*__ @@ -259,6 +260,7 @@ Name | Type | Description -----|------|------------- **env** | Map | **jobSteps** | Array<[JobStep](#cdk-pipelines-github-jobstep)> | +**if**? | string | __*Optional*__ @@ -806,6 +808,7 @@ Name | Type | Description -----|------|------------- **jobSteps** | Array<[JobStep](#cdk-pipelines-github-jobstep)> | The Job steps. **env**? | Map | Environment variables to set.
__*Optional*__ +**if**? | string | jobs..if. - overrides the JobSettings if provided, empty string (`''`) will remove the `if` clause.
__*Optional*__ diff --git a/src/pipeline.ts b/src/pipeline.ts index 9c1e57f9..2f4eddaf 100644 --- a/src/pipeline.ts +++ b/src/pipeline.ts @@ -346,7 +346,7 @@ export class GitHubWorkflow extends PipelineBase { return this.jobForAssetPublish(node, node.data.assets, options); case 'prepare': - throw new Error('"prepare" is not supported by GitHub Worflows'); + throw new Error('"prepare" is not supported by GitHub Workflows'); case 'execute': return this.jobForDeploy(node, node.data.stack, node.data.captureOutputs); @@ -651,7 +651,7 @@ export class GitHubWorkflow extends PipelineBase { id: node.uniqueId, definition: { name: step.id, - ...this.jobSettings, + if: step.if === '' ? undefined : (step.if ?? this.jobSettings?.if), permissions: { contents: github.JobPermission.WRITE, }, diff --git a/src/steps/github-action-step.ts b/src/steps/github-action-step.ts index d377989d..9041b6a7 100644 --- a/src/steps/github-action-step.ts +++ b/src/steps/github-action-step.ts @@ -11,6 +11,13 @@ export interface GitHubActionStepProps { * Environment variables to set. */ readonly env?: Record; + + /** + * jobs..if. - overrides the JobSettings if provided, empty string (`''`) will remove the `if` clause + * + * @see https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idif + */ + readonly if?: string; }; /** @@ -19,10 +26,12 @@ export interface GitHubActionStepProps { export class GitHubActionStep extends Step { public readonly env: Record; public readonly jobSteps: JobStep[]; + public readonly if?: string; constructor(id: string, props: GitHubActionStepProps) { super(id); this.jobSteps = props.jobSteps; this.env = props.env ?? {}; + this.if = props.if; } } diff --git a/test/__snapshots__/stage-options.test.ts.snap b/test/__snapshots__/stage-options.test.ts.snap index 5892fc6b..e4dfa541 100644 --- a/test/__snapshots__/stage-options.test.ts.snap +++ b/test/__snapshots__/stage-options.test.ts.snap @@ -10,6 +10,7 @@ on: jobs: Build-Build: name: Synthesize + if: check on Synthesize and Publish Assets permissions: contents: read id-token: none @@ -30,6 +31,7 @@ jobs: path: cdk.out Assets-FileAsset1: name: Publish Assets Assets-FileAsset1 + if: check on Synthesize and Publish Assets needs: - Build-Build permissions: @@ -73,6 +75,7 @@ jobs: secrets: my-secrets MyStack-MyStack-Deploy: name: Deploy MyStack098574E7 + if: check on Deploy MyStackXYZ only permissions: contents: read id-token: none @@ -102,6 +105,7 @@ jobs: role-arn: arn:aws:iam::111111111111:role/cdk-hnb659fds-cfn-exec-role-111111111111-us-east-1 MyStack-PostDeployAction: name: PostDeployAction + if: check on PostDeployAction only permissions: contents: write runs-on: ubuntu-latest diff --git a/test/stage-options.test.ts b/test/stage-options.test.ts index d7b3f1db..717233d2 100644 --- a/test/stage-options.test.ts +++ b/test/stage-options.test.ts @@ -175,7 +175,7 @@ describe('job settings', () => { commands: ['yarn build'], }), jobSettings: { - if: 'github.repository == \'another/repo\'', + if: 'github.repository == \'another/repoA\'', }, }); @@ -187,13 +187,14 @@ describe('job settings', () => { pipeline.addStageWithGitHubOptions(stage, { jobSettings: { - if: 'github.repository == \'github/repo\'', + if: 'github.repository == \'github/repoB\'', }, }); app.synth(); - expect(readFileSync(pipeline.workflowPath, 'utf-8')).toContain('if: github.repository == \'github/repo\'\n'); + expect(readFileSync(pipeline.workflowPath, 'utf-8')).toContain('if: github.repository == \'another/repoA\'\n'); + expect(readFileSync(pipeline.workflowPath, 'utf-8')).toContain('if: github.repository == \'github/repoB\'\n'); }); }); }); @@ -206,6 +207,7 @@ test('can set pre/post github action job step', () => { installCommands: ['yarn'], commands: ['yarn build'], }), + jobSettings: { if: 'check on Synthesize and Publish Assets' }, }); const stage = new Stage(app, 'MyStack', { @@ -224,6 +226,7 @@ test('can set pre/post github action job step', () => { 'secrets': 'my-secrets', }, }], + if: '', // remove the if statement })], post: [new GitHubActionStep('PostDeployAction', { jobSteps: [{ @@ -237,7 +240,9 @@ test('can set pre/post github action job step', () => { 'secrets': 'secrets', }, }], + if: 'check on PostDeployAction only', })], + jobSettings: { if: 'check on Deploy MyStackXYZ only' }, }); app.synth(); @@ -246,5 +251,7 @@ test('can set pre/post github action job step', () => { expect(readFileSync(pipeline.workflowPath, 'utf-8')).toContain('my-pre-deploy-action\@1\.0\.0'); expect(readFileSync(pipeline.workflowPath, 'utf-8')).toContain('my-post-deploy-action\@1\.0\.0'); expect(readFileSync(pipeline.workflowPath, 'utf-8')).toContain('actions/checkout@v2'); + expect(readFileSync(pipeline.workflowPath, 'utf-8')).toContain('check on Synthesize and Publish Assets'); + expect(readFileSync(pipeline.workflowPath, 'utf-8')).toContain('check on Deploy MyStackXYZ only'); }); }); From 4679f48afc9d8fb04270c8c378cbfea68e2de688 Mon Sep 17 00:00:00 2001 From: Rob Giseburt Date: Wed, 12 Oct 2022 17:14:33 -0500 Subject: [PATCH 4/6] Adjust `GitHubActionStep.if` property usage as well as how `GitHubWorkflowProps.if` is applied --- API.md | 4 +- src/pipeline.ts | 20 +++++-- src/steps/github-action-step.ts | 16 +++++- test/__snapshots__/github.test.ts.snap | 3 - test/__snapshots__/stage-options.test.ts.snap | 17 +++--- test/stage-options.test.ts | 55 +++++++++++-------- 6 files changed, 71 insertions(+), 44 deletions(-) diff --git a/API.md b/API.md index 676eb811..789e9df4 100644 --- a/API.md +++ b/API.md @@ -249,7 +249,7 @@ new GitHubActionStep(id: string, props: GitHubActionStepProps) * **props** ([GitHubActionStepProps](#cdk-pipelines-github-githubactionstepprops)) *No description* * **jobSteps** (Array<[JobStep](#cdk-pipelines-github-jobstep)>) The Job steps. * **env** (Map) Environment variables to set. __*Optional*__ - * **if** (string) jobs..if. - overrides the JobSettings if provided, empty string (`''`) will remove the `if` clause. __*Optional*__ + * **if** (string) Add an addition `if` clause on the `job.*` step for this `GitHubActionStep`. __*Optional*__ @@ -808,7 +808,7 @@ Name | Type | Description -----|------|------------- **jobSteps** | Array<[JobStep](#cdk-pipelines-github-jobstep)> | The Job steps. **env**? | Map | Environment variables to set.
__*Optional*__ -**if**? | string | jobs..if. - overrides the JobSettings if provided, empty string (`''`) will remove the `if` clause.
__*Optional*__ +**if**? | string | Add an addition `if` clause on the `job.*` step for this `GitHubActionStep`.
__*Optional*__ diff --git a/src/pipeline.ts b/src/pipeline.ts index 2f4eddaf..a0be5e26 100644 --- a/src/pipeline.ts +++ b/src/pipeline.ts @@ -410,7 +410,7 @@ export class GitHubWorkflow extends PipelineBase { id: jobId, definition: { name: `Publish Assets ${jobId}`, - ...this.jobSettings, + ...this.renderJobSettingParameters(), needs: this.renderDependencies(node), permissions: { contents: github.JobPermission.READ, @@ -481,7 +481,7 @@ export class GitHubWorkflow extends PipelineBase { id: node.uniqueId, definition: { name: `Deploy ${stack.stackArtifactId}`, - ...this.jobSettings, + ...this.renderJobSettingParameters(), ...this.stackProperties[stack.stackArtifactId]?.settings, permissions: { contents: github.JobPermission.READ, @@ -532,7 +532,7 @@ export class GitHubWorkflow extends PipelineBase { id: node.uniqueId, definition: { name: 'Synthesize', - ...this.jobSettings, + ...this.renderJobSettingParameters(true), permissions: { contents: github.JobPermission.READ, // The Synthesize job does not use the GitHub Action Role on its own, but it's possible @@ -626,7 +626,7 @@ export class GitHubWorkflow extends PipelineBase { id: node.uniqueId, definition: { name: step.id, - ...this.jobSettings, + ...this.renderJobSettingParameters(), permissions: { contents: github.JobPermission.READ, }, @@ -651,7 +651,8 @@ export class GitHubWorkflow extends PipelineBase { id: node.uniqueId, definition: { name: step.id, - if: step.if === '' ? undefined : (step.if ?? this.jobSettings?.if), + ...this.renderJobSettingParameters(), + if: step.if, permissions: { contents: github.JobPermission.WRITE, }, @@ -779,6 +780,15 @@ export class GitHubWorkflow extends PipelineBase { return deps.map(x => x.uniqueId); } + + private renderJobSettingParameters(isBuildStep = false) { + if (isBuildStep) { + return { + if: this.jobSettings?.if, + }; + } + return {}; + } } interface Context { diff --git a/src/steps/github-action-step.ts b/src/steps/github-action-step.ts index 9041b6a7..acd526c4 100644 --- a/src/steps/github-action-step.ts +++ b/src/steps/github-action-step.ts @@ -13,9 +13,23 @@ export interface GitHubActionStepProps { readonly env?: Record; /** - * jobs..if. - overrides the JobSettings if provided, empty string (`''`) will remove the `if` clause + * Add an addition `if` clause on the `job.*` step for this `GitHubActionStep` + * + * Note that setting this may allow the job to run even if any of the jobs it depends on fails. + * + * In cases where it's only desired to run when previous jobs succeed, then use `success()`, such as: + * + * ```ts + * const postStep = new GitHubActionStep('PostDeployAction', { + * jobSteps: [ + * // ... + * ], + * if: "success() && contains(github.event.issue.labels.*.name, 'cleanup')", + * }); + * ``` * * @see https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idif + * @see https://docs.github.com/en/actions/learn-github-actions/expressions#success */ readonly if?: string; }; diff --git a/test/__snapshots__/github.test.ts.snap b/test/__snapshots__/github.test.ts.snap index 9f76bd58..5e2de2da 100644 --- a/test/__snapshots__/github.test.ts.snap +++ b/test/__snapshots__/github.test.ts.snap @@ -431,7 +431,6 @@ jobs: path: cdk.out Assets-FileAsset1: name: Publish Assets Assets-FileAsset1 - if: github.repository == 'account/repo' needs: - Build-Build permissions: @@ -461,7 +460,6 @@ jobs: run: /bin/bash ./cdk.out/publish-Assets-FileAsset1-step.sh Assets-FileAsset2: name: Publish Assets Assets-FileAsset2 - if: github.repository == 'account/repo' needs: - Build-Build permissions: @@ -491,7 +489,6 @@ jobs: run: /bin/bash ./cdk.out/publish-Assets-FileAsset2-step.sh MyStack-MyStack-Deploy: name: Deploy MyStack098574E7 - if: github.repository == 'account/repo' permissions: contents: read id-token: none diff --git a/test/__snapshots__/stage-options.test.ts.snap b/test/__snapshots__/stage-options.test.ts.snap index e4dfa541..e5d8258c 100644 --- a/test/__snapshots__/stage-options.test.ts.snap +++ b/test/__snapshots__/stage-options.test.ts.snap @@ -8,9 +8,9 @@ on: - main workflow_dispatch: {} jobs: - Build-Build: + Build-Synth: name: Synthesize - if: check on Synthesize and Publish Assets + if: contains(fromJson('[\\"push\\", \\"pull_request\\"]'), github.event_name) permissions: contents: read id-token: none @@ -31,9 +31,8 @@ jobs: path: cdk.out Assets-FileAsset1: name: Publish Assets Assets-FileAsset1 - if: check on Synthesize and Publish Assets needs: - - Build-Build + - Build-Synth permissions: contents: read id-token: none @@ -65,7 +64,7 @@ jobs: contents: write runs-on: ubuntu-latest needs: - - Build-Build + - Build-Synth env: {} steps: - name: pre deploy action @@ -75,12 +74,12 @@ jobs: secrets: my-secrets MyStack-MyStack-Deploy: name: Deploy MyStack098574E7 - if: check on Deploy MyStackXYZ only + if: success() && contains(github.event.issue.labels.*.name, 'deploy') permissions: contents: read id-token: none needs: - - Build-Build + - Build-Synth - Assets-FileAsset1 - MyStack-PreDeployAction runs-on: ubuntu-latest @@ -105,13 +104,13 @@ jobs: role-arn: arn:aws:iam::111111111111:role/cdk-hnb659fds-cfn-exec-role-111111111111-us-east-1 MyStack-PostDeployAction: name: PostDeployAction - if: check on PostDeployAction only + if: failure() && contains(github.event.issue.labels.*.name, 'cleanupFailure') permissions: contents: write runs-on: ubuntu-latest needs: - MyStack-MyStack-Deploy - - Build-Build + - Build-Synth env: {} steps: - name: Checkout diff --git a/test/stage-options.test.ts b/test/stage-options.test.ts index 717233d2..4285281e 100644 --- a/test/stage-options.test.ts +++ b/test/stage-options.test.ts @@ -203,11 +203,11 @@ test('can set pre/post github action job step', () => { withTemporaryDirectory((dir) => { const pipeline = new GitHubWorkflow(app, 'Pipeline', { workflowPath: `${dir}/.github/workflows/deploy.yml`, - synth: new ShellStep('Build', { + synth: new ShellStep('Synth', { installCommands: ['yarn'], commands: ['yarn build'], }), - jobSettings: { if: 'check on Synthesize and Publish Assets' }, + jobSettings: { if: 'contains(fromJson(\'["push", "pull_request"]\'), github.event_name)' }, }); const stage = new Stage(app, 'MyStack', { @@ -217,32 +217,38 @@ test('can set pre/post github action job step', () => { new Stack(stage, 'MyStack'); pipeline.addStageWithGitHubOptions(stage, { + jobSettings: { if: "success() && contains(github.event.issue.labels.*.name, 'deploy')" }, + pre: [new GitHubActionStep('PreDeployAction', { - jobSteps: [{ - name: 'pre deploy action', - uses: 'my-pre-deploy-action@1.0.0', - with: { - 'app-id': 1234, - 'secrets': 'my-secrets', + jobSteps: [ + { + name: 'pre deploy action', + uses: 'my-pre-deploy-action@1.0.0', + with: { + 'app-id': 1234, + 'secrets': 'my-secrets', + }, }, - }], - if: '', // remove the if statement + ], })], + post: [new GitHubActionStep('PostDeployAction', { - jobSteps: [{ - name: 'Checkout', - uses: 'actions/checkout@v2', - }, { - name: 'post deploy action', - uses: 'my-post-deploy-action@1.0.0', - with: { - 'app-id': 4321, - 'secrets': 'secrets', + jobSteps: [ + { + name: 'Checkout', + uses: 'actions/checkout@v2', }, - }], - if: 'check on PostDeployAction only', + { + name: 'post deploy action', + uses: 'my-post-deploy-action@1.0.0', + with: { + 'app-id': 4321, + 'secrets': 'secrets', + }, + }, + ], + if: "failure() && contains(github.event.issue.labels.*.name, 'cleanupFailure')", })], - jobSettings: { if: 'check on Deploy MyStackXYZ only' }, }); app.synth(); @@ -251,7 +257,8 @@ test('can set pre/post github action job step', () => { expect(readFileSync(pipeline.workflowPath, 'utf-8')).toContain('my-pre-deploy-action\@1\.0\.0'); expect(readFileSync(pipeline.workflowPath, 'utf-8')).toContain('my-post-deploy-action\@1\.0\.0'); expect(readFileSync(pipeline.workflowPath, 'utf-8')).toContain('actions/checkout@v2'); - expect(readFileSync(pipeline.workflowPath, 'utf-8')).toContain('check on Synthesize and Publish Assets'); - expect(readFileSync(pipeline.workflowPath, 'utf-8')).toContain('check on Deploy MyStackXYZ only'); + expect(readFileSync(pipeline.workflowPath, 'utf-8')).toContain('contains(fromJson(\'["push", "pull_request"]\'), github.event_name)'); + expect(readFileSync(pipeline.workflowPath, 'utf-8')).toContain("success() && contains(github.event.issue.labels.*.name, 'deploy')"); + expect(readFileSync(pipeline.workflowPath, 'utf-8')).toContain("failure() && contains(github.event.issue.labels.*.name, 'cleanupFailure')"); }); }); From c3c312e89c5f92958baba94f31b61d7bb3859322 Mon Sep 17 00:00:00 2001 From: Rob Giseburt Date: Thu, 13 Oct 2022 10:23:23 -0500 Subject: [PATCH 5/6] Update src/pipeline.ts Co-authored-by: Kaizen Conroy <36202692+kaizencc@users.noreply.github.com> --- src/pipeline.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/pipeline.ts b/src/pipeline.ts index d9d1c160..0ae23452 100644 --- a/src/pipeline.ts +++ b/src/pipeline.ts @@ -787,6 +787,7 @@ export class GitHubWorkflow extends PipelineBase { if: this.jobSettings?.if, }; } + // in the future, additional job settings may be rendered here return {}; } } From c09691d52a097ea08933df0da79dff462748fc52 Mon Sep 17 00:00:00 2001 From: Rob Giseburt Date: Fri, 14 Oct 2022 20:10:35 -0500 Subject: [PATCH 6/6] add `GitHubStage` and `GitHubWorkflow.addWave()`, related tests and docs --- API.md | 151 +++++++ src/pipeline.ts | 119 ++++- src/stage.ts | 46 ++ test/__snapshots__/stage-options.test.ts.snap | 415 +++++++++++++++++- test/stage-options.test.ts | 309 +++++++++++-- 5 files changed, 986 insertions(+), 54 deletions(-) create mode 100644 src/stage.ts diff --git a/API.md b/API.md index 5222dadd..a696cef0 100644 --- a/API.md +++ b/API.md @@ -7,6 +7,7 @@ Name|Description [DockerCredential](#cdk-pipelines-github-dockercredential)|Represents a credential used to authenticate to a docker registry. [GitHubActionRole](#cdk-pipelines-github-githubactionrole)|Creates or references a GitHub OIDC provider and accompanying role that trusts the provider. [GitHubActionStep](#cdk-pipelines-github-githubactionstep)|Specifies a GitHub Action as a step in the pipeline. +[GitHubWave](#cdk-pipelines-github-githubwave)|Multiple stages that are deployed in parallel. [GitHubWorkflow](#cdk-pipelines-github-githubworkflow)|CDK Pipelines for GitHub workflows. [JsonPatch](#cdk-pipelines-github-jsonpatch)|Utility for applying RFC-6902 JSON-Patch to a document. [Runner](#cdk-pipelines-github-runner)|The type of runner to run the job on. @@ -264,6 +265,87 @@ Name | Type | Description +## class GitHubWave + +Multiple stages that are deployed in parallel. + +A `Wave`, but with addition GitHub options - created by `GitHubWorkflow.addWave()` or +`GitHubWorkflow.addGitHubWave()` - DO NOT CREATE DIRECTLY + +__Extends__: [pipelines.Wave](#aws-cdk-lib-pipelines-wave) + +### Initializer + + + + +```ts +new GitHubWave(id: string, pipeline: GitHubWorkflow, props?: WaveProps) +``` + +* **id** (string) Identifier for this Wave. +* **pipeline** ([GitHubWorkflow](#cdk-pipelines-github-githubworkflow)) GitHubWorkflow that this wave is part of. +* **props** ([pipelines.WaveProps](#aws-cdk-lib-pipelines-waveprops)) *No description* + * **post** (Array<[pipelines.Step](#aws-cdk-lib-pipelines-step)>) Additional steps to run after all of the stages in the wave. __*Default*__: No additional steps + * **pre** (Array<[pipelines.Step](#aws-cdk-lib-pipelines-step)>) Additional steps to run before any of the stages in the wave. __*Default*__: No additional steps + + + +### Properties + + +Name | Type | Description +-----|------|------------- +**id** | string | Identifier for this Wave. + +### Methods + + +#### addStage(stage, options?) + +Add a Stage to this wave. + +It will be deployed in parallel with all other stages in this +wave. + +```ts +addStage(stage: Stage, options?: AddStageOpts): StageDeployment +``` + +* **stage** ([Stage](#aws-cdk-lib-stage)) *No description* +* **options** ([pipelines.AddStageOpts](#aws-cdk-lib-pipelines-addstageopts)) *No description* + * **post** (Array<[pipelines.Step](#aws-cdk-lib-pipelines-step)>) Additional steps to run after all of the stacks in the stage. __*Default*__: No additional steps + * **pre** (Array<[pipelines.Step](#aws-cdk-lib-pipelines-step)>) Additional steps to run before any of the stacks in the stage. __*Default*__: No additional steps + * **stackSteps** (Array<[pipelines.StackSteps](#aws-cdk-lib-pipelines-stacksteps)>) Instructions for stack level steps. __*Default*__: No additional instructions + +__Returns__: +* [pipelines.StageDeployment](#aws-cdk-lib-pipelines-stagedeployment) + +#### addStageWithGitHubOptions(stage, options?) + +Add a Stage to this wave. + +It will be deployed in parallel with all other stages in this +wave. + +```ts +addStageWithGitHubOptions(stage: Stage, options?: AddGitHubStageOptions): StageDeployment +``` + +* **stage** ([Stage](#aws-cdk-lib-stage)) *No description* +* **options** ([AddGitHubStageOptions](#cdk-pipelines-github-addgithubstageoptions)) *No description* + * **post** (Array<[pipelines.Step](#aws-cdk-lib-pipelines-step)>) Additional steps to run after all of the stacks in the stage. __*Default*__: No additional steps + * **pre** (Array<[pipelines.Step](#aws-cdk-lib-pipelines-step)>) Additional steps to run before any of the stacks in the stage. __*Default*__: No additional steps + * **stackSteps** (Array<[pipelines.StackSteps](#aws-cdk-lib-pipelines-stacksteps)>) Instructions for stack level steps. __*Default*__: No additional instructions + * **gitHubEnvironment** (string) Run the stage in a specific GitHub Environment. __*Default*__: no GitHub environment + * **jobSettings** ([JobSettings](#cdk-pipelines-github-jobsettings)) Job level settings that will be applied to all jobs in the stage. __*Optional*__ + * **stackCapabilities** (Array<[StackCapabilities](#cdk-pipelines-github-stackcapabilities)>) In some cases, you must explicitly acknowledge that your CloudFormation stack template contains certain capabilities in order for CloudFormation to create the stack. __*Default*__: ['CAPABILITY_IAM'] + +__Returns__: +* [pipelines.StageDeployment](#aws-cdk-lib-pipelines-stagedeployment) + + + ## class GitHubWorkflow CDK Pipelines for GitHub workflows. @@ -313,6 +395,22 @@ Name | Type | Description ### Methods +#### addGitHubWave(id, options?) + + + +```ts +addGitHubWave(id: string, options?: WaveOptions): GitHubWave +``` + +* **id** (string) *No description* +* **options** ([pipelines.WaveOptions](#aws-cdk-lib-pipelines-waveoptions)) *No description* + * **post** (Array<[pipelines.Step](#aws-cdk-lib-pipelines-step)>) Additional steps to run after all of the stages in the wave. __*Default*__: No additional steps + * **pre** (Array<[pipelines.Step](#aws-cdk-lib-pipelines-step)>) Additional steps to run before any of the stages in the wave. __*Default*__: No additional steps + +__Returns__: +* [GitHubWave](#cdk-pipelines-github-githubwave) + #### addStageWithGitHubOptions(stage, options?) Deploy a single Stage by itself with options for further GitHub configuration. @@ -336,6 +434,59 @@ addStageWithGitHubOptions(stage: Stage, options?: AddGitHubStageOptions): StageD __Returns__: * [pipelines.StageDeployment](#aws-cdk-lib-pipelines-stagedeployment) +#### addWave(id, options?) + +Add a Wave to the pipeline, for deploying multiple Stages in parallel. + +Use the return object of this method to deploy multiple stages in parallel. + +Example: + +```ts +declare const pipeline: pipelines.CodePipeline; + +const wave = pipeline.addWave('MyWave'); +wave.addStage(new MyApplicationStage(this, 'Stage1')); +wave.addStage(new MyApplicationStage(this, 'Stage2')); +``` + +```ts +addWave(id: string, options?: WaveOptions): Wave +``` + +* **id** (string) *No description* +* **options** ([pipelines.WaveOptions](#aws-cdk-lib-pipelines-waveoptions)) *No description* + * **post** (Array<[pipelines.Step](#aws-cdk-lib-pipelines-step)>) Additional steps to run after all of the stages in the wave. __*Default*__: No additional steps + * **pre** (Array<[pipelines.Step](#aws-cdk-lib-pipelines-step)>) Additional steps to run before any of the stages in the wave. __*Default*__: No additional steps + +__Returns__: +* [pipelines.Wave](#aws-cdk-lib-pipelines-wave) + +#### addingStageFromWave(stage, stageDeployment, options?) + +Support adding stages with GitHub options to waves - should ONLY be called internally. + +Use `pipeline.addWave()` and it'll call this when `wave.addStage()` is called. + +`pipeline.addStage()` will also call this, since it calls `pipeline.addWave().addStage()`. + +```ts +addingStageFromWave(stage: Stage, stageDeployment: StageDeployment, options?: AddGitHubStageOptions): void +``` + +* **stage** ([Stage](#aws-cdk-lib-stage)) *No description* +* **stageDeployment** ([pipelines.StageDeployment](#aws-cdk-lib-pipelines-stagedeployment)) *No description* +* **options** ([AddGitHubStageOptions](#cdk-pipelines-github-addgithubstageoptions)) *No description* + * **post** (Array<[pipelines.Step](#aws-cdk-lib-pipelines-step)>) Additional steps to run after all of the stacks in the stage. __*Default*__: No additional steps + * **pre** (Array<[pipelines.Step](#aws-cdk-lib-pipelines-step)>) Additional steps to run before any of the stacks in the stage. __*Default*__: No additional steps + * **stackSteps** (Array<[pipelines.StackSteps](#aws-cdk-lib-pipelines-stacksteps)>) Instructions for stack level steps. __*Default*__: No additional instructions + * **gitHubEnvironment** (string) Run the stage in a specific GitHub Environment. __*Default*__: no GitHub environment + * **jobSettings** ([JobSettings](#cdk-pipelines-github-jobsettings)) Job level settings that will be applied to all jobs in the stage. __*Optional*__ + * **stackCapabilities** (Array<[StackCapabilities](#cdk-pipelines-github-stackcapabilities)>) In some cases, you must explicitly acknowledge that your CloudFormation stack template contains certain capabilities in order for CloudFormation to create the stack. __*Default*__: ['CAPABILITY_IAM'] + + + + #### protected doBuildPipeline() Implemented by subclasses to do the actual pipeline construction. diff --git a/src/pipeline.ts b/src/pipeline.ts index 0ae23452..ef59d4ec 100644 --- a/src/pipeline.ts +++ b/src/pipeline.ts @@ -2,12 +2,13 @@ import { mkdirSync, writeFileSync, readFileSync, existsSync } from 'fs'; import * as path from 'path'; import { Stage } from 'aws-cdk-lib'; import { EnvironmentPlaceholders } from 'aws-cdk-lib/cx-api'; -import { PipelineBase, PipelineBaseProps, ShellStep, StackAsset, StackDeployment, StackOutputReference, StageDeployment, Step } from 'aws-cdk-lib/pipelines'; +import { AddStageOpts, PipelineBase, PipelineBaseProps, ShellStep, StackAsset, StackDeployment, StackOutputReference, StageDeployment, Step, Wave, WaveOptions, WaveProps } from 'aws-cdk-lib/pipelines'; import { AGraphNode, PipelineGraph, Graph, isGraph } from 'aws-cdk-lib/pipelines/lib/helpers-internal'; import { Construct } from 'constructs'; import * as decamelize from 'decamelize'; import { DockerCredential } from './docker-credentials'; import { awsCredentialStep } from './private/aws-credentials'; +import { GitHubStage } from './stage'; import { AddGitHubStageOptions } from './stage-options'; import { GitHubActionStep } from './steps/github-action-step'; import * as github from './workflows-model'; @@ -166,8 +167,13 @@ export class GitHubWorkflow extends PipelineBase { private readonly assetHashMap: Record = {}; private readonly runner: github.Runner; private readonly publishAssetsAuthRegion: string; - private readonly stackProperties: Record> = {}; + private readonly stackProperties: Record = {}; private readonly jobSettings?: JobSettings; + private builtGH = false; // cannot be `built` since that's defined in the parent as private constructor(scope: Construct, id: string, props: GitHubWorkflowProps) { super(scope, id, props); @@ -225,6 +231,57 @@ export class GitHubWorkflow extends PipelineBase { return stageDeployment; } + + /** + * Add a Wave to the pipeline, for deploying multiple Stages in parallel + * + * Use the return object of this method to deploy multiple stages in parallel. + * + * Example: + * + * ```ts + * declare const pipeline: pipelines.CodePipeline; + * + * const wave = pipeline.addWave('MyWave'); + * wave.addStage(new MyApplicationStage(this, 'Stage1')); + * wave.addStage(new MyApplicationStage(this, 'Stage2')); + * ``` + */ + public addWave(id: string, options?: WaveOptions): Wave { + return this.addGitHubWave(id, options); + } + + public addGitHubWave(id: string, options?: WaveOptions): GitHubWave { + if (this.builtGH) { + throw new Error('addWave: can\'t add Waves anymore after buildPipeline() has been called'); + } + + const wave = new GitHubWave(id, this, options); + this.waves.push(wave); + return wave; + } + + /** + * Support adding stages with GitHub options to waves - should ONLY be called internally. + * + * Use `pipeline.addWave()` and it'll call this when `wave.addStage()` is called. + * + * `pipeline.addStage()` will also call this, since it calls `pipeline.addWave().addStage()`. + */ + public addingStageFromWave(stage: Stage, stageDeployment: StageDeployment, options?: AddGitHubStageOptions) { + if (!(stage instanceof GitHubStage) && options === undefined) { + return; + } + + const ghStage = stage instanceof GitHubStage ? stage : undefined; + + // keep track of GitHub specific options + const stacks = stageDeployment.stacks; + this.addStackProps(stacks, 'environment', ghStage?.props?.gitHubEnvironment ?? options?.gitHubEnvironment); + this.addStackProps(stacks, 'capabilities', ghStage?.props?.stackCapabilities ?? options?.stackCapabilities); + this.addStackProps(stacks, 'settings', ghStage?.props?.jobSettings ?? options?.jobSettings); + } + private addStackProps(stacks: StackDeployment[], key: string, value: any) { if (value === undefined) { return; } for (const stack of stacks) { @@ -236,6 +293,7 @@ export class GitHubWorkflow extends PipelineBase { } protected doBuildPipeline() { + this.builtGH = true; const app = Stage.of(this); if (!app) { throw new Error('The GitHub Workflow must be defined in the scope of an App'); @@ -829,6 +887,63 @@ function snakeCaseKeys(obj: T, sep = '-'): T { return result as any; } +/** + * Multiple stages that are deployed in parallel + * + * A `Wave`, but with addition GitHub options - created by `GitHubWorkflow.addWave()` or + * `GitHubWorkflow.addGitHubWave()` - DO NOT CREATE DIRECTLY + */ +export class GitHubWave extends Wave { + constructor( + /** Identifier for this Wave */ + public readonly id: string, + /** GitHubWorkflow that this wave is part of */ + private pipeline: GitHubWorkflow, + props: WaveProps = {}, + ) { + super(id, props); + } + + /** + * Add a Stage to this wave + * + * It will be deployed in parallel with all other stages in this + * wave. + */ + public addStage(stage: Stage, options: AddStageOpts = {}) { + const stageDeployment = super.addStage(stage, options); + this.pipeline.addingStageFromWave(stage, stageDeployment); + return stageDeployment; + } + + /** + * Add a Stage to this wave + * + * It will be deployed in parallel with all other stages in this + * wave. + */ + public addStageWithGitHubOptions(stage: Stage, options?: AddGitHubStageOptions): StageDeployment { + const stageDeployment = super.addStage(stage, options); + this.pipeline.addingStageFromWave(stage, stageDeployment, options); + return stageDeployment; + } + + // /** + // * Add an additional step to run before any of the stages in this wave + // */ + // public addPre(...steps: Step[]) { + // this.pre.push(...steps); + // } + + // /** + // * Add an additional step to run after all of the stages in this wave + // */ + // public addPost(...steps: Step[]) { + // this.post.push(...steps); + // } +} + + /** * Names of secrets for AWS credentials. */ diff --git a/src/stage.ts b/src/stage.ts new file mode 100644 index 00000000..afeb3910 --- /dev/null +++ b/src/stage.ts @@ -0,0 +1,46 @@ +import { Stage, StageProps } from 'aws-cdk-lib'; +import { Construct } from 'constructs'; +import { JobSettings } from './pipeline'; +import { StackCapabilities } from './stage-options'; + + +export interface GitHubStageProps extends StageProps { + /** + * Run the stage in a specific GitHub Environment. If specified, + * any protection rules configured for the environment must pass + * before the job is set to a runner. For example, if the environment + * has a manual approval rule configured, then the workflow will + * wait for the approval before sending the job to the runner. + * + * Running a workflow that references an environment that does not + * exist will create an environment with the referenced name. + * @see https://docs.github.com/en/actions/deployment/targeting-different-environments/using-environments-for-deployment + * + * @default - no GitHub environment + */ + readonly gitHubEnvironment?: string; + + /** + * In some cases, you must explicitly acknowledge that your CloudFormation + * stack template contains certain capabilities in order for CloudFormation + * to create the stack. + * + * If insufficiently specified, CloudFormation returns an `InsufficientCapabilities` + * error. + * + * @default ['CAPABILITY_IAM'] + */ + readonly stackCapabilities?: StackCapabilities[]; + + /** + * Job level settings that will be applied to all jobs in the stage. + * Currently the only valid setting is 'if'. + */ + readonly jobSettings?: JobSettings; +} + +export class GitHubStage extends Stage { + constructor(scope: Construct, id: string, public readonly props?: GitHubStageProps) { + super(scope, id, props); + } +} \ No newline at end of file diff --git a/test/__snapshots__/stage-options.test.ts.snap b/test/__snapshots__/stage-options.test.ts.snap index 7c59dc13..e1892f16 100644 --- a/test/__snapshots__/stage-options.test.ts.snap +++ b/test/__snapshots__/stage-options.test.ts.snap @@ -61,7 +61,7 @@ jobs: - id: Publish name: Publish Assets-FileAsset1 run: /bin/bash ./cdk.out/publish-Assets-FileAsset1-step.sh - MyStack-PreDeployAction: + MyPrePostStack-PreDeployAction: name: PreDeployAction permissions: contents: write @@ -75,8 +75,8 @@ jobs: with: app-id: 1234 secrets: my-secrets - MyStack-MyStack-Deploy: - name: Deploy MyStack098574E7 + MyPrePostStack-MyStack-Deploy: + name: Deploy MyPrePostStackMyStack8AD5AF9E if: success() && contains(github.event.issue.labels.*.name, 'deploy') permissions: contents: read @@ -84,7 +84,7 @@ jobs: needs: - Build-Synth - Assets-FileAsset1 - - MyStack-PreDeployAction + - MyPrePostStack-PreDeployAction runs-on: ubuntu-latest steps: - name: Authenticate Via GitHub Secrets @@ -100,19 +100,19 @@ jobs: - id: Deploy uses: aws-actions/aws-cloudformation-github-deploy@v1 with: - name: MyStack-MyStack + name: MyPrePostStack-MyStack template: https://cdk-hnb659fds-assets-111111111111-us-east-1.s3.us-east-1.amazonaws.com/\${{ needs.Assets-FileAsset1.outputs.asset-hash }}.json no-fail-on-empty-changeset: \\"1\\" role-arn: arn:aws:iam::111111111111:role/cdk-hnb659fds-cfn-exec-role-111111111111-us-east-1 - MyStack-PostDeployAction: + MyPrePostStack-PostDeployAction: name: PostDeployAction if: failure() && contains(github.event.issue.labels.*.name, 'cleanupFailure') permissions: contents: write runs-on: ubuntu-latest needs: - - MyStack-MyStack-Deploy + - MyPrePostStack-MyStack-Deploy - Build-Synth env: {} steps: @@ -432,6 +432,164 @@ jobs: " `; +exports[`github stages in waves works 1`] = ` +"# AUTOMATICALLY GENERATED FILE, DO NOT EDIT MANUALLY. +# Generated by AWS CDK and [cdk-pipelines-github](https://github.com/cdklabs/cdk-pipelines-github) + +name: deploy +on: + push: + branches: + - main + workflow_dispatch: {} +jobs: + Build-Build: + name: Synthesize + if: contains(github.event.issue.labels.*.name, 'deployToA') || + contains(github.event.issue.labels.*.name, 'deployToB') + permissions: + contents: read + id-token: none + runs-on: ubuntu-latest + needs: [] + env: {} + steps: + - name: Checkout + uses: actions/checkout@v2 + - name: Install + run: yarn + - name: Build + run: yarn build + - name: Upload cdk.out + uses: actions/upload-artifact@v2.1.1 + with: + name: cdk.out + path: cdk.out + Assets-FileAsset1: + name: Publish Assets Assets-FileAsset1 + needs: + - Build-Build + permissions: + contents: read + id-token: none + runs-on: ubuntu-latest + outputs: + asset-hash: \${{ steps.Publish.outputs.asset-hash }} + steps: + - name: Download cdk.out + uses: actions/download-artifact@v2 + with: + name: cdk.out + path: stage.out + - name: Install + run: npm install --no-save cdk-assets + - name: Authenticate Via GitHub Secrets + uses: aws-actions/configure-aws-credentials@v1 + with: + aws-region: us-west-2 + role-duration-seconds: 1800 + role-skip-session-tagging: true + aws-access-key-id: \${{ secrets.AWS_ACCESS_KEY_ID }} + aws-secret-access-key: \${{ secrets.AWS_SECRET_ACCESS_KEY }} + - id: Publish + name: Publish Assets-FileAsset1 + run: /bin/bash ./cdk.out/publish-Assets-FileAsset1-step.sh + MyWave-PreWaveAction: + name: PreWaveAction + permissions: + contents: write + runs-on: ubuntu-latest + needs: + - Build-Build + env: {} + steps: + - name: pre wave action + uses: my-pre-wave-action@1.0.0 + with: + app-id: 1234 + secrets: my-secrets + MyWave-MyStageA-MyStackA-Deploy: + name: Deploy MyStageAMyStackA0F0BE321 + if: success() && contains(github.event.issue.labels.*.name, 'deployToA') + permissions: + contents: read + id-token: none + needs: + - Build-Build + - Assets-FileAsset1 + - MyWave-PreWaveAction + runs-on: ubuntu-latest + steps: + - name: Authenticate Via GitHub Secrets + uses: aws-actions/configure-aws-credentials@v1 + with: + aws-region: us-east-1 + role-duration-seconds: 1800 + role-skip-session-tagging: true + aws-access-key-id: \${{ secrets.AWS_ACCESS_KEY_ID }} + aws-secret-access-key: \${{ secrets.AWS_SECRET_ACCESS_KEY }} + role-to-assume: arn:aws:iam::111111111111:role/cdk-hnb659fds-deploy-role-111111111111-us-east-1 + role-external-id: Pipeline + - id: Deploy + uses: aws-actions/aws-cloudformation-github-deploy@v1 + with: + name: MyStageA-MyStackA + template: https://cdk-hnb659fds-assets-111111111111-us-east-1.s3.us-east-1.amazonaws.com/\${{ + needs.Assets-FileAsset1.outputs.asset-hash }}.json + no-fail-on-empty-changeset: \\"1\\" + role-arn: arn:aws:iam::111111111111:role/cdk-hnb659fds-cfn-exec-role-111111111111-us-east-1 + MyWave-MyStageB-MyStackB-Deploy: + name: Deploy MyStageBMyStackBFE4B1ADE + if: success() && contains(github.event.issue.labels.*.name, 'deployToB') + permissions: + contents: read + id-token: none + needs: + - Build-Build + - Assets-FileAsset1 + - MyWave-PreWaveAction + runs-on: ubuntu-latest + steps: + - name: Authenticate Via GitHub Secrets + uses: aws-actions/configure-aws-credentials@v1 + with: + aws-region: us-east-1 + role-duration-seconds: 1800 + role-skip-session-tagging: true + aws-access-key-id: \${{ secrets.AWS_ACCESS_KEY_ID }} + aws-secret-access-key: \${{ secrets.AWS_SECRET_ACCESS_KEY }} + role-to-assume: arn:aws:iam::12345678901:role/cdk-hnb659fds-deploy-role-12345678901-us-east-1 + role-external-id: Pipeline + - id: Deploy + uses: aws-actions/aws-cloudformation-github-deploy@v1 + with: + name: MyStageB-MyStackB + template: https://cdk-hnb659fds-assets-12345678901-us-east-1.s3.us-east-1.amazonaws.com/\${{ + needs.Assets-FileAsset1.outputs.asset-hash }}.json + no-fail-on-empty-changeset: \\"1\\" + role-arn: arn:aws:iam::12345678901:role/cdk-hnb659fds-cfn-exec-role-12345678901-us-east-1 + MyWave-PostWaveAction: + name: PostWaveAction + if: failure() && contains(github.event.issue.labels.*.name, 'cleanupFailure') + permissions: + contents: write + runs-on: ubuntu-latest + needs: + - MyWave-MyStageA-MyStackA-Deploy + - MyWave-MyStageB-MyStackB-Deploy + - Build-Build + env: {} + steps: + - name: Checkout + uses: actions/checkout@v2 + - name: post wave action + uses: my-post-wave-action@1.0.0 + with: + app-id: 4321 + secrets: secrets +" +`; + exports[`job settings can specify job settings at stage level 1`] = ` "# AUTOMATICALLY GENERATED FILE, DO NOT EDIT MANUALLY. # Generated by AWS CDK and [cdk-pipelines-github](https://github.com/cdklabs/cdk-pipelines-github) @@ -523,3 +681,246 @@ jobs: role-arn: arn:aws:iam::111111111111:role/cdk-hnb659fds-cfn-exec-role-111111111111-us-east-1 " `; + +exports[`stages in github waves works 1`] = ` +"# AUTOMATICALLY GENERATED FILE, DO NOT EDIT MANUALLY. +# Generated by AWS CDK and [cdk-pipelines-github](https://github.com/cdklabs/cdk-pipelines-github) + +name: deploy +on: + push: + branches: + - main + workflow_dispatch: {} +jobs: + Build-Build: + name: Synthesize + permissions: + contents: read + id-token: none + runs-on: ubuntu-latest + needs: [] + env: {} + steps: + - name: Checkout + uses: actions/checkout@v2 + - name: Install + run: yarn + - name: Build + run: yarn build + - name: Upload cdk.out + uses: actions/upload-artifact@v2.1.1 + with: + name: cdk.out + path: cdk.out + Assets-FileAsset1: + name: Publish Assets Assets-FileAsset1 + needs: + - Build-Build + permissions: + contents: read + id-token: none + runs-on: ubuntu-latest + outputs: + asset-hash: \${{ steps.Publish.outputs.asset-hash }} + steps: + - name: Download cdk.out + uses: actions/download-artifact@v2 + with: + name: cdk.out + path: stage.out + - name: Install + run: npm install --no-save cdk-assets + - name: Authenticate Via GitHub Secrets + uses: aws-actions/configure-aws-credentials@v1 + with: + aws-region: us-west-2 + role-duration-seconds: 1800 + role-skip-session-tagging: true + aws-access-key-id: \${{ secrets.AWS_ACCESS_KEY_ID }} + aws-secret-access-key: \${{ secrets.AWS_SECRET_ACCESS_KEY }} + - id: Publish + name: Publish Assets-FileAsset1 + run: /bin/bash ./cdk.out/publish-Assets-FileAsset1-step.sh + MyWave-MyStageA-MyStackA-Deploy: + name: Deploy MyStageAMyStackA0F0BE321 + if: success() && contains(github.event.issue.labels.*.name, 'deployToA') + permissions: + contents: read + id-token: none + needs: + - Build-Build + - Assets-FileAsset1 + runs-on: ubuntu-latest + steps: + - name: Authenticate Via GitHub Secrets + uses: aws-actions/configure-aws-credentials@v1 + with: + aws-region: us-east-1 + role-duration-seconds: 1800 + role-skip-session-tagging: true + aws-access-key-id: \${{ secrets.AWS_ACCESS_KEY_ID }} + aws-secret-access-key: \${{ secrets.AWS_SECRET_ACCESS_KEY }} + role-to-assume: arn:aws:iam::111111111111:role/cdk-hnb659fds-deploy-role-111111111111-us-east-1 + role-external-id: Pipeline + - id: Deploy + uses: aws-actions/aws-cloudformation-github-deploy@v1 + with: + name: MyStageA-MyStackA + template: https://cdk-hnb659fds-assets-111111111111-us-east-1.s3.us-east-1.amazonaws.com/\${{ + needs.Assets-FileAsset1.outputs.asset-hash }}.json + no-fail-on-empty-changeset: \\"1\\" + role-arn: arn:aws:iam::111111111111:role/cdk-hnb659fds-cfn-exec-role-111111111111-us-east-1 + MyWave-MyStageB-MyStackB-Deploy: + name: Deploy MyStageBMyStackBFE4B1ADE + if: success() && contains(github.event.issue.labels.*.name, 'deployToB') + permissions: + contents: read + id-token: none + needs: + - Build-Build + - Assets-FileAsset1 + runs-on: ubuntu-latest + steps: + - name: Authenticate Via GitHub Secrets + uses: aws-actions/configure-aws-credentials@v1 + with: + aws-region: us-east-1 + role-duration-seconds: 1800 + role-skip-session-tagging: true + aws-access-key-id: \${{ secrets.AWS_ACCESS_KEY_ID }} + aws-secret-access-key: \${{ secrets.AWS_SECRET_ACCESS_KEY }} + role-to-assume: arn:aws:iam::12345678901:role/cdk-hnb659fds-deploy-role-12345678901-us-east-1 + role-external-id: Pipeline + - id: Deploy + uses: aws-actions/aws-cloudformation-github-deploy@v1 + with: + name: MyStageB-MyStackB + template: https://cdk-hnb659fds-assets-12345678901-us-east-1.s3.us-east-1.amazonaws.com/\${{ + needs.Assets-FileAsset1.outputs.asset-hash }}.json + no-fail-on-empty-changeset: \\"1\\" + role-arn: arn:aws:iam::12345678901:role/cdk-hnb659fds-cfn-exec-role-12345678901-us-east-1 +" +`; + +exports[`stages in pipeline works with \`if\` 1`] = ` +"# AUTOMATICALLY GENERATED FILE, DO NOT EDIT MANUALLY. +# Generated by AWS CDK and [cdk-pipelines-github](https://github.com/cdklabs/cdk-pipelines-github) + +name: deploy +on: + push: + branches: + - main + workflow_dispatch: {} +jobs: + Build-Build: + name: Synthesize + permissions: + contents: read + id-token: none + runs-on: ubuntu-latest + needs: [] + env: {} + steps: + - name: Checkout + uses: actions/checkout@v2 + - name: Install + run: yarn + - name: Build + run: yarn build + - name: Upload cdk.out + uses: actions/upload-artifact@v2.1.1 + with: + name: cdk.out + path: cdk.out + Assets-FileAsset1: + name: Publish Assets Assets-FileAsset1 + needs: + - Build-Build + permissions: + contents: read + id-token: none + runs-on: ubuntu-latest + outputs: + asset-hash: \${{ steps.Publish.outputs.asset-hash }} + steps: + - name: Download cdk.out + uses: actions/download-artifact@v2 + with: + name: cdk.out + path: stage.out + - name: Install + run: npm install --no-save cdk-assets + - name: Authenticate Via GitHub Secrets + uses: aws-actions/configure-aws-credentials@v1 + with: + aws-region: us-west-2 + role-duration-seconds: 1800 + role-skip-session-tagging: true + aws-access-key-id: \${{ secrets.AWS_ACCESS_KEY_ID }} + aws-secret-access-key: \${{ secrets.AWS_SECRET_ACCESS_KEY }} + - id: Publish + name: Publish Assets-FileAsset1 + run: /bin/bash ./cdk.out/publish-Assets-FileAsset1-step.sh + MyStageA-MyStackA-Deploy: + name: Deploy MyStageAMyStackA0F0BE321 + if: success() && contains(github.event.issue.labels.*.name, 'deployToA') + permissions: + contents: read + id-token: none + needs: + - Build-Build + - Assets-FileAsset1 + runs-on: ubuntu-latest + steps: + - name: Authenticate Via GitHub Secrets + uses: aws-actions/configure-aws-credentials@v1 + with: + aws-region: us-east-1 + role-duration-seconds: 1800 + role-skip-session-tagging: true + aws-access-key-id: \${{ secrets.AWS_ACCESS_KEY_ID }} + aws-secret-access-key: \${{ secrets.AWS_SECRET_ACCESS_KEY }} + role-to-assume: arn:aws:iam::111111111111:role/cdk-hnb659fds-deploy-role-111111111111-us-east-1 + role-external-id: Pipeline + - id: Deploy + uses: aws-actions/aws-cloudformation-github-deploy@v1 + with: + name: MyStageA-MyStackA + template: https://cdk-hnb659fds-assets-111111111111-us-east-1.s3.us-east-1.amazonaws.com/\${{ + needs.Assets-FileAsset1.outputs.asset-hash }}.json + no-fail-on-empty-changeset: \\"1\\" + role-arn: arn:aws:iam::111111111111:role/cdk-hnb659fds-cfn-exec-role-111111111111-us-east-1 + MyStageB-MyStackB-Deploy: + name: Deploy MyStageBMyStackBFE4B1ADE + if: success() && contains(github.event.issue.labels.*.name, 'deployToB') + permissions: + contents: read + id-token: none + needs: + - Build-Build + - Assets-FileAsset1 + - MyStageA-MyStackA-Deploy + runs-on: ubuntu-latest + steps: + - name: Authenticate Via GitHub Secrets + uses: aws-actions/configure-aws-credentials@v1 + with: + aws-region: us-east-1 + role-duration-seconds: 1800 + role-skip-session-tagging: true + aws-access-key-id: \${{ secrets.AWS_ACCESS_KEY_ID }} + aws-secret-access-key: \${{ secrets.AWS_SECRET_ACCESS_KEY }} + role-to-assume: arn:aws:iam::12345678901:role/cdk-hnb659fds-deploy-role-12345678901-us-east-1 + role-external-id: Pipeline + - id: Deploy + uses: aws-actions/aws-cloudformation-github-deploy@v1 + with: + name: MyStageB-MyStackB + template: https://cdk-hnb659fds-assets-12345678901-us-east-1.s3.us-east-1.amazonaws.com/\${{ + needs.Assets-FileAsset1.outputs.asset-hash }}.json + no-fail-on-empty-changeset: \\"1\\" + role-arn: arn:aws:iam::12345678901:role/cdk-hnb659fds-cfn-exec-role-12345678901-us-east-1 +" +`; diff --git a/test/stage-options.test.ts b/test/stage-options.test.ts index 48cde4d0..52964211 100644 --- a/test/stage-options.test.ts +++ b/test/stage-options.test.ts @@ -1,7 +1,9 @@ import { readFileSync } from 'fs'; import { Stack, Stage } from 'aws-cdk-lib'; import { ShellStep } from 'aws-cdk-lib/pipelines'; -import { GitHubWorkflow, StackCapabilities, GitHubActionStep } from '../src'; +import * as YAML from 'yaml'; +import { GitHubWorkflow, StackCapabilities, GitHubActionStep, AddGitHubStageOptions } from '../src'; +import { GitHubStage, GitHubStageProps } from '../src/stage'; import { withTemporaryDirectory, TestApp } from './testutil'; let app: TestApp; @@ -57,8 +59,9 @@ describe('github environment', () => { const testStage = new Stage(app, 'MyStage1', { env: { account: '111111111111', region: 'us-east-1' }, }); - const prodStage = new Stage(app, 'MyStage2', { + const prodStage = new GitHubStage(app, 'MyStage2', { env: { account: '222222222222', region: 'us-west-2' }, + gitHubEnvironment: 'prod', }); // Two stacks @@ -68,9 +71,7 @@ describe('github environment', () => { pipeline.addStageWithGitHubOptions(testStage, { gitHubEnvironment: 'test', }); - pipeline.addStageWithGitHubOptions(prodStage, { - gitHubEnvironment: 'prod', - }); + pipeline.addStage(prodStage); app.synth(); @@ -193,8 +194,9 @@ describe('job settings', () => { app.synth(); - expect(readFileSync(pipeline.workflowPath, 'utf-8')).toContain('if: github.repository == \'another/repoA\'\n'); - expect(readFileSync(pipeline.workflowPath, 'utf-8')).toContain('if: github.repository == \'github/repoB\'\n'); + const workflowFileContents = readFileSync(pipeline.workflowPath, 'utf-8'); + expect(workflowFileContents).toContain('if: github.repository == \'another/repoA\'\n'); + expect(workflowFileContents).toContain('if: github.repository == \'github/repoB\'\n'); }); }); }); @@ -210,56 +212,273 @@ test('can set pre/post github action job step', () => { jobSettings: { if: 'contains(fromJson(\'["push", "pull_request"]\'), github.event_name)' }, }); - const stage = new Stage(app, 'MyStack', { + const stage = new GitHubStage(app, 'MyPrePostStack', { env: { account: '111111111111', region: 'us-east-1' }, + jobSettings: { if: "success() && contains(github.event.issue.labels.*.name, 'deploy')" }, }); new Stack(stage, 'MyStack'); - pipeline.addStageWithGitHubOptions(stage, { - jobSettings: { if: "success() && contains(github.event.issue.labels.*.name, 'deploy')" }, + pipeline.addStage(stage, { + pre: [ + new GitHubActionStep('PreDeployAction', { + jobSteps: [ + { + name: 'pre deploy action', + uses: 'my-pre-deploy-action@1.0.0', + with: { + 'app-id': 1234, + 'secrets': 'my-secrets', + }, + }, + ], + }), + ], + - pre: [new GitHubActionStep('PreDeployAction', { - jobSteps: [ - { - name: 'pre deploy action', - uses: 'my-pre-deploy-action@1.0.0', - with: { - 'app-id': 1234, - 'secrets': 'my-secrets', + post: [ + new GitHubActionStep('PostDeployAction', { + jobSteps: [ + { + name: 'Checkout', + uses: 'actions/checkout@v2', }, - }, - ], - })], - - - post: [new GitHubActionStep('PostDeployAction', { - jobSteps: [ - { - name: 'Checkout', - uses: 'actions/checkout@v2', - }, - { - name: 'post deploy action', - uses: 'my-post-deploy-action@1.0.0', - with: { - 'app-id': 4321, - 'secrets': 'secrets', + { + name: 'post deploy action', + uses: 'my-post-deploy-action@1.0.0', + with: { + 'app-id': 4321, + 'secrets': 'secrets', + }, }, - }, + ], + if: "failure() && contains(github.event.issue.labels.*.name, 'cleanupFailure')", + }), + ], + }); + + app.synth(); + + const workflowFileContents = readFileSync(pipeline.workflowPath, 'utf-8'); + expect(workflowFileContents).toMatchSnapshot(); + expect(workflowFileContents).toContain('my-pre-deploy-action\@1\.0\.0'); + expect(workflowFileContents).toContain('my-post-deploy-action\@1\.0\.0'); + expect(workflowFileContents).toContain('actions/checkout@v2'); + expect(workflowFileContents).toContain('contains(fromJson(\'["push", "pull_request"]\'), github.event_name)'); + expect(workflowFileContents).toContain("success() && contains(github.event.issue.labels.*.name, 'deploy')"); + expect(workflowFileContents).toContain("failure() && contains(github.event.issue.labels.*.name, 'cleanupFailure')"); + }); +}); + +test('stages in github waves works', () => { + withTemporaryDirectory((dir) => { + const pipeline = new GitHubWorkflow(app, 'Pipeline', { + workflowPath: `${dir}/.github/workflows/deploy.yml`, + synth: new ShellStep('Build', { + installCommands: ['yarn'], + commands: ['yarn build'], + }), + }); + + const stageA = new Stage(app, 'MyStageA', { + env: { account: '111111111111', region: 'us-east-1' }, + }); + + new Stack(stageA, 'MyStackA'); + + const wave = pipeline.addGitHubWave('MyWave'); + + const stageAOptions: AddGitHubStageOptions = { + jobSettings: { + if: "success() && contains(github.event.issue.labels.*.name, 'deployToA')", + }, + }; + wave.addStageWithGitHubOptions(stageA, stageAOptions); + + const stageBOptions: GitHubStageProps = { + env: { account: '12345678901', region: 'us-east-1' }, + jobSettings: { + if: "success() && contains(github.event.issue.labels.*.name, 'deployToB')", + }, + }; + const stageB = new GitHubStage(app, 'MyStageB', stageBOptions); + + new Stack(stageB, 'MyStackB'); + + wave.addStage(stageB); + + app.synth(); + + const workflowFileContents = readFileSync(pipeline.workflowPath, 'utf-8'); + expect(workflowFileContents).toMatchSnapshot(); + + const yaml = YAML.parse(workflowFileContents); + expect(yaml).toMatchObject({ + jobs: { + 'MyWave-MyStageA-MyStackA-Deploy': { + if: stageAOptions.jobSettings?.if, + }, + 'MyWave-MyStageB-MyStackB-Deploy': { + if: stageBOptions.jobSettings?.if, + }, + }, + }); + }); +}); + + +test('github stages in waves works', () => { + withTemporaryDirectory((dir) => { + const buildIfStatement = "contains(github.event.issue.labels.*.name, 'deployToA') || contains(github.event.issue.labels.*.name, 'deployToB')"; + const pipeline = new GitHubWorkflow(app, 'Pipeline', { + workflowPath: `${dir}/.github/workflows/deploy.yml`, + synth: new ShellStep('Build', { + installCommands: ['yarn'], + commands: ['yarn build'], + }), + jobSettings: { + if: buildIfStatement, + }, + }); + + const stageAOptions: GitHubStageProps = { + jobSettings: { + if: "success() && contains(github.event.issue.labels.*.name, 'deployToA')", + }, + }; + const stageA = new GitHubStage(app, 'MyStageA', { + env: { account: '111111111111', region: 'us-east-1' }, + ...stageAOptions, + }); + + new Stack(stageA, 'MyStackA'); + + const stageBOptions: GitHubStageProps = { + env: { account: '12345678901', region: 'us-east-1' }, + jobSettings: { + if: "success() && contains(github.event.issue.labels.*.name, 'deployToB')", + }, + }; + const stageB = new GitHubStage(app, 'MyStageB', stageBOptions); + + new Stack(stageB, 'MyStackB'); + + // Make a wave to have the stages be parallel (not depend on each other) + const wave = pipeline.addWave('MyWave', + { + pre: [ + new GitHubActionStep('PreWaveAction', { + jobSteps: [ + { + name: 'pre wave action', + uses: 'my-pre-wave-action@1.0.0', + with: { + 'app-id': 1234, + 'secrets': 'my-secrets', + }, + }, + ], + }), ], - if: "failure() && contains(github.event.issue.labels.*.name, 'cleanupFailure')", - })], + + + post: [ + new GitHubActionStep('PostWaveAction', { + jobSteps: [ + { + name: 'Checkout', + uses: 'actions/checkout@v2', + }, + { + name: 'post wave action', + uses: 'my-post-wave-action@1.0.0', + with: { + 'app-id': 4321, + 'secrets': 'secrets', + }, + }, + ], + if: "failure() && contains(github.event.issue.labels.*.name, 'cleanupFailure')", + }), + ], + }, + ); + wave.addStage(stageA); + wave.addStage(stageB); + + app.synth(); + + const workflowFileContents = readFileSync(pipeline.workflowPath, 'utf-8'); + expect(workflowFileContents).toMatchSnapshot(); + + const yaml = YAML.parse(workflowFileContents); + expect(yaml).toMatchObject({ + jobs: { + 'Build-Build': { + if: buildIfStatement, + }, + 'MyWave-MyStageA-MyStackA-Deploy': { + if: stageAOptions.jobSettings?.if, + }, + 'MyWave-MyStageB-MyStackB-Deploy': { + if: stageBOptions.jobSettings?.if, + }, + }, + }); + }); +}); + + +test('stages in pipeline works with `if`', () => { + withTemporaryDirectory((dir) => { + const pipeline = new GitHubWorkflow(app, 'Pipeline', { + workflowPath: `${dir}/.github/workflows/deploy.yml`, + synth: new ShellStep('Build', { + installCommands: ['yarn'], + commands: ['yarn build'], + }), + }); + + const stageA = new Stage(app, 'MyStageA', { + env: { account: '111111111111', region: 'us-east-1' }, }); + new Stack(stageA, 'MyStackA'); + + const stageAOptions: AddGitHubStageOptions = { + jobSettings: { + if: "success() && contains(github.event.issue.labels.*.name, 'deployToA')", + }, + }; + pipeline.addStageWithGitHubOptions(stageA, stageAOptions); + + const stageBOptions: GitHubStageProps = { + env: { account: '12345678901', region: 'us-east-1' }, + jobSettings: { + if: "success() && contains(github.event.issue.labels.*.name, 'deployToB')", + }, + }; + const stageB = new GitHubStage(app, 'MyStageB', stageBOptions); + + new Stack(stageB, 'MyStackB'); + + pipeline.addStage(stageB); + app.synth(); - expect(readFileSync(pipeline.workflowPath, 'utf-8')).toMatchSnapshot(); - expect(readFileSync(pipeline.workflowPath, 'utf-8')).toContain('my-pre-deploy-action\@1\.0\.0'); - expect(readFileSync(pipeline.workflowPath, 'utf-8')).toContain('my-post-deploy-action\@1\.0\.0'); - expect(readFileSync(pipeline.workflowPath, 'utf-8')).toContain('actions/checkout@v2'); - expect(readFileSync(pipeline.workflowPath, 'utf-8')).toContain('contains(fromJson(\'["push", "pull_request"]\'), github.event_name)'); - expect(readFileSync(pipeline.workflowPath, 'utf-8')).toContain("success() && contains(github.event.issue.labels.*.name, 'deploy')"); - expect(readFileSync(pipeline.workflowPath, 'utf-8')).toContain("failure() && contains(github.event.issue.labels.*.name, 'cleanupFailure')"); + const workflowFileContents = readFileSync(pipeline.workflowPath, 'utf-8'); + expect(workflowFileContents).toMatchSnapshot(); + + const yaml = YAML.parse(workflowFileContents); + expect(yaml).toMatchObject({ + jobs: { + 'MyStageA-MyStackA-Deploy': { + if: stageAOptions.jobSettings?.if, + }, + 'MyStageB-MyStackB-Deploy': { + if: stageBOptions.jobSettings?.if, + }, + }, + }); }); });