Repository: cdklabs/cdk-pipelines-github
Pull request #67 (2022-01-14 00:49:21)

feat: support cfn outputs
This PR adds support for cfn outputs. You can specify a Cfn Output as an environment variable like so:

```ts
pipeline.addStage(myStage, {
      pre: [new ShellStep('Pre', {
        commands: ['echo hello'],
      })],
      post: [new ShellStep('Post', {
        envFromCfnOutputs: {
          FN_NAME: myStage.fnName,
        },
        commands: ['echo FN_NAME equals: $FN_NAME'],
      })],
    });
```

This translates to a `deploy.yml` file with an output for the deploy step:

```yaml
    outputs:
      myout: \${{ steps.Deploy.outputs.myout }}
```

As well as sets the environment variable for the Post step:

```yaml
    env:
      FN_NAME: \${{ needs.StageA-FunctionStack-Deploy.outputs.myout }}
    steps:
      - run: \\"echo FN_NAME equals: $FN_NAME\\"
```


Patch:
diff --git a/API.md b/API.md
index 7587d9e..02cafe6 100644
--- a/API.md
+++ b/API.md
@@ -312,7 +312,7 @@ Name | Type | Description
 **if**? | <code>string</code> | You can use the if conditional to prevent a job from running unless a condition is met.<br/>__*Optional*__
 **name**? | <code>string</code> | The name of the job displayed on GitHub.<br/>__*Optional*__
 **needs**? | <code>Array<string></code> | Identifies any jobs that must complete successfully before this job will run.<br/>__*Optional*__
-**outputs**? | <code>Map<string, [JobStepOutput](#cdk-pipelines-github-jobstepoutput)></code> | A map of outputs for a job.<br/>__*Optional*__
+**outputs**? | <code>Map<string, string></code> | A map of outputs for a job.<br/>__*Optional*__
 **services**? | <code>Map<string, [ContainerOptions](#cdk-pipelines-github-containeroptions)></code> | Used to host service containers for a job in a workflow.<br/>__*Optional*__
 **strategy**? | <code>[JobStrategy](#cdk-pipelines-github-jobstrategy)</code> | A strategy creates a build matrix for your jobs.<br/>__*Optional*__
 **timeoutMinutes**? | <code>number</code> | The maximum number of minutes to let a job run before GitHub automatically cancels it.<br/>__*Default*__: 360
diff --git a/README.md b/README.md
index 116712e..4147e60 100644
--- a/README.md
+++ b/README.md
@@ -114,8 +114,6 @@ This is work in progress. The following features are still not supported:
 
 * [ ] Credentials and roles (document permissions required, etc)
 * [ ] Support Docker image assets
-* [ ] Support Pre/post steps
-* [ ] Support CFN output bindings
 * [ ] Anti-tamper check for CI runs (`synth` should fail if `CI=1` and the workflow has changed)
 * [ ] Revise Documentation
 
diff --git a/src/pipeline.ts b/src/pipeline.ts
index 767014b..462c2a2 100644
--- a/src/pipeline.ts
+++ b/src/pipeline.ts
@@ -2,7 +2,7 @@ import { mkdirSync, writeFileSync } 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, Step } from 'aws-cdk-lib/pipelines';
+import { PipelineBase, PipelineBaseProps, ShellStep, StackAsset, StackDeployment, StackOutputReference, Step } 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';
@@ -93,6 +93,7 @@ export class GitHubWorkflow extends PipelineBase {
   private readonly buildContainer?: github.ContainerOptions;
   private readonly preBuildSteps: github.JobStep[];
   private readonly postBuildSteps: github.JobStep[];
+  private readonly jobOutputs: Record<string, github.JobStepOutput[]> = {};
 
   constructor(scope: Construct, id: string, props: GitHubWorkflowProps) {
     super(scope, id, props);
@@ -158,7 +159,6 @@ export class GitHubWorkflow extends PipelineBase {
     }
 
     // convert jobs to a map and make sure there are no duplicates
-
     const jobmap: Record<string, github.Job> = {};
     for (const job of jobs) {
       if (job.id in jobmap) {
@@ -167,6 +167,9 @@ export class GitHubWorkflow extends PipelineBase {
       jobmap[job.id] = snakeCaseKeys(job.definition);
     }
 
+    // Update jobs with late-bound output requests
+    this.insertJobOutputs(jobmap);
+
     const workflow = {
       name: this.workflowName,
       on: snakeCaseKeys(this.workflowTriggers, '_'),
@@ -184,6 +187,26 @@ export class GitHubWorkflow extends PipelineBase {
     writeFileSync(this.workflowPath, yaml);
   }
 
+  private insertJobOutputs(jobmap: Record<string, github.Job>) {
+    for (const [jobId, jobOutputs] of Object.entries(this.jobOutputs)) {
+      jobmap[jobId] = {
+        ...jobmap[jobId],
+        outputs: {
+          ...jobmap[jobId].outputs,
+          ...this.renderJobOutputs(jobOutputs),
+        },
+      };
+    }
+  }
+
+  private renderJobOutputs(outputs: github.JobStepOutput[]) {
+    const renderedOutputs: Record<string, string> = {};
+    for (const output of outputs) {
+      renderedOutputs[output.outputName] = `\${{ steps.${output.stepId}.outputs.${output.outputName} }}`;
+    }
+    return renderedOutputs;
+  }
+
   /**
    * Make an action from the given node and/or step
    */
@@ -287,7 +310,6 @@ export class GitHubWorkflow extends PipelineBase {
     if (stack.executionRoleArn) {
       params['role-arn'] = resolve(stack.executionRoleArn);
     }
-
     const assumeRoleArn = stack.assumeRoleArn ? resolve(stack.assumeRoleArn) : undefined;
 
     return {
@@ -300,6 +322,7 @@ export class GitHubWorkflow extends PipelineBase {
         steps: [
           ...this.stepsToConfigureAws({ region, assumeRoleArn }),
           {
+            id: 'Deploy',
             uses: 'aws-actions/aws-cloudformation-github-deploy@v1',
             with: params,
           },
@@ -358,10 +381,40 @@ export class GitHubWorkflow extends PipelineBase {
     };
   }
 
-  private jobForScriptStep(node: AGraphNode, step: ShellStep): Job {
+  /**
+   * Searches for the stack that produced the output via the current
+   * job's dependencies.
+   *
+   * This function should always find a stack, since it is guaranteed
+   * that a CfnOutput comes from a referenced stack.
+   */
+  private findStackOfOutput(ref: StackOutputReference, node: AGraphNode) {
+    for (const dep of node.allDeps) {
+      if (dep.data?.type === 'execute' && ref.isProducedBy(dep.data.stack)) {
+        return dep.uniqueId;
+      }
+    }
+    // Should never happen
+    throw new Error(`The output ${ref.outputName} is not referenced by any of the dependent stacks!`);
+  }
+
+  private addJobOutput(jobId: string, output: github.JobStepOutput) {
+    if (this.jobOutputs[jobId] === undefined) {
+      this.jobOutputs[jobId] = [output];
+    } else {
+      this.jobOutputs[jobId].push(output);
+    }
+  }
 
-    if (Object.keys(step.envFromCfnOutputs).length > 0) {
-      throw new Error('"envFromOutputs" is not supported');
+  private jobForScriptStep(node: AGraphNode, step: ShellStep): Job {
+    const envVariables: Record<string, string> = {};
+    for (const [envName, ref] of Object.entries(step.envFromCfnOutputs)) {
+      const jobId = this.findStackOfOutput(ref, node);
+      this.addJobOutput(jobId, {
+        outputName: ref.outputName,
+        stepId: 'Deploy',
+      });
+      envVariables[envName] = `\${{ needs.${jobId}.outputs.${ref.outputName} }}`;
     }
 
     const downloadInputs = new Array<github.JobStep>();
@@ -401,7 +454,10 @@ export class GitHubWorkflow extends PipelineBase {
         },
         runsOn: RUNS_ON,
         needs: this.renderDependencies(node),
-        env: step.env,
+        env: {
+          ...step.env,
+          ...envVariables,
+        },
         steps: [
           ...downloadInputs,
           ...installSteps,
@@ -518,7 +574,8 @@ function snakeCaseKeys<T = unknown>(obj: T, sep = '-'): T {
 
   const result: Record<string, unknown> = {};
   for (let [k, v] of Object.entries(obj)) {
-    if (typeof v === 'object' && v != null) {
+    // we don't want to snake case environment variables
+    if (k !== 'env' && typeof v === 'object' && v != null) {
       v = snakeCaseKeys(v);
     }
     result[decamelize(k, { separator: sep })] = v;
diff --git a/src/workflows-model.ts b/src/workflows-model.ts
index a6da656..f53ae7e 100644
--- a/src/workflows-model.ts
+++ b/src/workflows-model.ts
@@ -74,7 +74,7 @@ export interface Job {
    * A map of outputs for a job. Job outputs are available to all downstream
    * jobs that depend on this job.
    */
-  readonly outputs?: Record<string, JobStepOutput>;
+  readonly outputs?: Record<string, string>;
 
   /**
    * A map of environment variables that are available to all steps in the


Test patch:
diff --git a/test/__snapshots__/github.test.ts.snap b/test/__snapshots__/github.test.ts.snap
index 01dd250..76249c0 100644
--- a/test/__snapshots__/github.test.ts.snap
+++ b/test/__snapshots__/github.test.ts.snap
@@ -152,6 +152,16 @@ jobs:
           role-duration-seconds: 1800
       - name: Publish Assets-FileAsset6
         run: /bin/bash ./cdk.out/publish-Assets-FileAsset6-step.sh
+  StageA-Pre:
+    name: Pre
+    permissions:
+      contents: read
+    runs-on: ubuntu-latest
+    needs:
+      - Build-Build
+    env: {}
+    steps:
+      - run: echo hello
   StageA-BucketStack-Deploy:
     name: Deploy StageABucketStackEAC67DBE
     permissions:
@@ -160,6 +170,7 @@ jobs:
       - Build-Build
       - Assets-FileAsset1
       - Assets-FileAsset2
+      - StageA-Pre
       - Build-Build
     runs-on: ubuntu-latest
     steps:
@@ -172,7 +183,8 @@ jobs:
           role-duration-seconds: 1800
           role-to-assume: arn:aws:iam::111111111111:role/cdk-hnb659fds-deploy-role-111111111111-us-east-1
           role-external-id: Pipeline
-      - uses: aws-actions/aws-cloudformation-github-deploy@v1
+      - id: Deploy
+        uses: aws-actions/aws-cloudformation-github-deploy@v1
         with:
           name: StageA-BucketStack
           template: https://cdk-hnb659fds-assets-111111111111-us-east-1.s3.us-east-1.amazonaws.com/edf2c6bcaf00190295ae3fb871034a825a9bc599ce0c57bde46ed35fe45a9718.json
@@ -187,6 +199,7 @@ jobs:
       - Assets-FileAsset3
       - Assets-FileAsset4
       - StageA-BucketStack-Deploy
+      - StageA-Pre
       - Build-Build
     runs-on: ubuntu-latest
     steps:
@@ -199,12 +212,29 @@ jobs:
           role-duration-seconds: 1800
           role-to-assume: arn:aws:iam::111111111111:role/cdk-hnb659fds-deploy-role-111111111111-us-east-1
           role-external-id: Pipeline
-      - uses: aws-actions/aws-cloudformation-github-deploy@v1
+      - id: Deploy
+        uses: aws-actions/aws-cloudformation-github-deploy@v1
         with:
           name: StageA-FunctionStack
-          template: https://cdk-hnb659fds-assets-111111111111-us-east-1.s3.us-east-1.amazonaws.com/c6eb397e9674810c84c295ca34efc7dd668cd429b6b9ae23d55951827cb0b25e.json
+          template: https://cdk-hnb659fds-assets-111111111111-us-east-1.s3.us-east-1.amazonaws.com/78a7673e89f59d0a6a53ec86baeb411cef605a4e970616398035dacb4bbd9ca0.json
           no-fail-on-empty-changeset: \\"1\\"
           role-arn: arn:aws:iam::111111111111:role/cdk-hnb659fds-cfn-exec-role-111111111111-us-east-1
+    outputs:
+      myout: \${{ steps.Deploy.outputs.myout }}
+  StageA-Post:
+    name: Post
+    permissions:
+      contents: read
+    runs-on: ubuntu-latest
+    needs:
+      - StageA-FunctionStack-Deploy
+      - StageA-BucketStack-Deploy
+      - StageA-FunctionStack-Deploy
+      - Build-Build
+    env:
+      FN_NAME: \${{ needs.StageA-FunctionStack-Deploy.outputs.myout }}
+    steps:
+      - run: \\"echo FN_NAME equals: $FN_NAME\\"
   StageB-BucketStack-Deploy:
     name: Deploy StageBBucketStackDF3FFF07
     permissions:
@@ -216,6 +246,8 @@ jobs:
       - Build-Build
       - StageA-BucketStack-Deploy
       - StageA-FunctionStack-Deploy
+      - StageA-Pre
+      - StageA-Post
     runs-on: ubuntu-latest
     steps:
       - uses: aws-actions/configure-aws-credentials@v1
@@ -227,7 +259,8 @@ jobs:
           role-duration-seconds: 1800
           role-to-assume: arn:aws:iam::222222222222:role/cdk-hnb659fds-deploy-role-222222222222-eu-west-2
           role-external-id: Pipeline
-      - uses: aws-actions/aws-cloudformation-github-deploy@v1
+      - id: Deploy
+        uses: aws-actions/aws-cloudformation-github-deploy@v1
         with:
           name: StageB-BucketStack
           template: https://cdk-hnb659fds-assets-222222222222-eu-west-2.s3.eu-west-2.amazonaws.com/0251b0cee8f2645a2a13ae23fd40a19825d67955f2cbcd1576812addf4065a29.json
@@ -245,6 +278,8 @@ jobs:
       - Build-Build
       - StageA-BucketStack-Deploy
       - StageA-FunctionStack-Deploy
+      - StageA-Pre
+      - StageA-Post
     runs-on: ubuntu-latest
     steps:
       - uses: aws-actions/configure-aws-credentials@v1
@@ -256,10 +291,11 @@ jobs:
           role-duration-seconds: 1800
           role-to-assume: arn:aws:iam::222222222222:role/cdk-hnb659fds-deploy-role-222222222222-eu-west-2
           role-external-id: Pipeline
-      - uses: aws-actions/aws-cloudformation-github-deploy@v1
+      - id: Deploy
+        uses: aws-actions/aws-cloudformation-github-deploy@v1
         with:
           name: StageB-FunctionStack
-          template: https://cdk-hnb659fds-assets-222222222222-eu-west-2.s3.eu-west-2.amazonaws.com/fb62e56a6c1688f9100e146f40f609285a8d795b319efec11fa84896917aebf5.json
+          template: https://cdk-hnb659fds-assets-222222222222-eu-west-2.s3.eu-west-2.amazonaws.com/ae5ab5677896732ad54d73c29bcf216d5000b0be5f4b62bd2e29fb29ef976b6e.json
           no-fail-on-empty-changeset: \\"1\\"
           role-arn: arn:aws:iam::222222222222:role/cdk-hnb659fds-cfn-exec-role-222222222222-eu-west-2
 "
@@ -390,7 +426,8 @@ jobs:
           role-duration-seconds: 1800
           role-to-assume: arn:aws:iam::111111111111:role/cdk-hnb659fds-deploy-role-111111111111-us-east-1
           role-external-id: Pipeline
-      - uses: aws-actions/aws-cloudformation-github-deploy@v1
+      - id: Deploy
+        uses: aws-actions/aws-cloudformation-github-deploy@v1
         with:
           name: MyStack-MyStack
           template: https://cdk-hnb659fds-assets-111111111111-us-east-1.s3.us-east-1.amazonaws.com/52c2fe7f725481c405841b55cfb41b81965fadaa71665186ffb031ecd3f0ef36.json
diff --git a/test/example-app.ts b/test/example-app.ts
index f2540be..94aa408 100644
--- a/test/example-app.ts
+++ b/test/example-app.ts
@@ -1,7 +1,7 @@
 import * as fs from 'fs';
 import * as os from 'os';
 import * as path from 'path';
-import { App, RemovalPolicy, Stack, Stage, StageProps } from 'aws-cdk-lib';
+import { App, CfnOutput, RemovalPolicy, Stack, Stage, StageProps } from 'aws-cdk-lib';
 import * as lambda from 'aws-cdk-lib/aws-lambda';
 import * as s3 from 'aws-cdk-lib/aws-s3';
 import { EnvironmentUtils } from 'aws-cdk-lib/cx-api';
@@ -78,12 +78,25 @@ export class GitHubExampleApp extends App {
       ],
     });
 
-    pipeline.addStage(new MyStage(this, 'StageA', { env: EnvironmentUtils.parse(props.envA) }));
+    const myStage = new MyStage(this, 'StageA', { env: EnvironmentUtils.parse(props.envA) });
+    pipeline.addStage(myStage, {
+      pre: [new ShellStep('Pre', {
+        commands: ['echo hello'],
+      })],
+      post: [new ShellStep('Post', {
+        envFromCfnOutputs: {
+          FN_NAME: myStage.fnName,
+        },
+        commands: ['echo FN_NAME equals: $FN_NAME'],
+      })],
+    });
+
     pipeline.addStage(new MyStage(this, 'StageB', { env: EnvironmentUtils.parse(props.envB) }));
   }
 }
 
 class MyStage extends Stage {
+  public readonly fnName: CfnOutput;
   constructor(scope: App, id: string, props: StageProps) {
     super(scope, id, props);
 
@@ -104,6 +117,10 @@ class MyStage extends Stage {
       },
     });
 
+    this.fnName = new CfnOutput(fnStack, 'myout', {
+      value: fn.functionName,
+    });
+
     bucket.grantRead(fn);
   }
 }
\ No newline at end of file
diff --git a/test/manual-test.ts b/test/manual-test.ts
index 9936d5e..76919da 100644
--- a/test/manual-test.ts
+++ b/test/manual-test.ts
@@ -18,7 +18,7 @@ if (!account) {
 const app = new GitHubExampleApp({
   repoDir: '.',
   envA: `aws://${account}/us-east-1`,
-  envB: `aws://${account}/eu-west-2`,
+  envB: `aws://${account}/eu-west-1`,
 });
 
 app.synth();