Repository: pulumi/actions (ts)

Issue:
Support Update Plans
## Hello!
<!-- Please leave this section as-is, it's designed to help others in the community know how to interact with our GitHub issues. -->

- Vote on this issue by adding a 👍 reaction
- If you want to implement this feature, comment to let us know (we'll work with you on design, scheduling, etc.)

## Issue details
As part of our pipeline, we do the following:

- Run unit tests against our codebase
- Do a Pulumi Preview, which then someone reviews, approves, and then the author merges.

On merge (`push`) we then run the pipeline without tests and `up` instead of `preview.` 

This presents a problem: What the reviewer signed off on is not guaranteed to be what actually gets deployed. If the `preview` showing as a PR comment is a few hours old, and someone has found a way to "clickops" something, Pulumi has the potential to undo said change (which tbh is okay.) In the worst case, it does something completely different altogether than what has been planned.

Pulumi appears to have solved this with the public preview of "[Update Plans](https://www.pulumi.com/docs/concepts/update-plans/)" but this functionality is not exposed via the Github Action.

I'd like to add this functionality to the GitHub action via adding `plan` to `config.options`. 

### Affected area/feature

Affected area would be the Github Actions repository. Would need changes to `config.ts`, `main.ts`, some tests, `README.md`, and the `examples` directory to give users a good example.


Pull request description:
feat: Add Update Plan Functionality
Related to / resolves #991

This commit does the following:
- Updates `src/config.ts` to expose the `plan` option
- Updates the `README.md` to reflect that this option is now available.
- Adds a new test that will do a `preview`, save the plan, and then attempt to `up` using the plan.
- Update `action.yml` to reflect this new option
- `.github/workflows/workflow.yml`:
  - Adds new steps to each job for generating an Update Plan and ensuring that the file has content when testing `preview`

Patch:
diff --git a/.github/workflows/workflow.yml b/.github/workflows/workflow.yml
index 540fc18..b0539e6 100644
--- a/.github/workflows/workflow.yml
+++ b/.github/workflows/workflow.yml
@@ -174,6 +174,34 @@ jobs:
           work-dir: .github/test-stacks/dotnet
           config-map: '{name: {value: my-pet, secret: false}}'
 
+      - name: Ensure Update Plan is Not Generated
+        shell: bash
+        if: matrix.command == 'preview'
+        working-directory: .github/test-stacks/dotnet
+        run: |
+          ! test -f update-plan.json || exit 1
+
+      - name: Run Pulumi Preview with Update Plan
+        if: matrix.command == 'preview'
+        uses: ./
+        env:
+          PULUMI_CONFIG_PASSPHRASE: not-a-secret
+        with:
+          command: ${{ matrix.command }}
+          cloud-url: file://~
+          stack-name: dev
+          upsert: true
+          work-dir: .github/test-stacks/dotnet
+          config-map: '{name: {value: hostname, secret: false}}'
+          plan: update-plan.json
+
+      - name: Ensure Update Plan is Generated and Not Empty
+        shell: bash
+        if: matrix.command == 'preview'
+        working-directory: .github/test-stacks/dotnet
+        run: |
+          test -s update-plan.json
+
   test-golang-stack:
     needs: install-and-build
     if:
@@ -226,6 +254,34 @@ jobs:
           work-dir: .github/test-stacks/golang
           config-map: '{name: {value: my-user-name, secret: false}}'
 
+      - name: Ensure Update Plan is Not Generated
+        shell: bash
+        if: matrix.command == 'preview'
+        working-directory: .github/test-stacks/golang
+        run: |
+          ! test -f update-plan.json || exit 1
+
+      - name: Run Pulumi Preview with Update Plan
+        if: matrix.command == 'preview'
+        uses: ./
+        env:
+          PULUMI_CONFIG_PASSPHRASE: not-a-secret
+        with:
+          command: ${{ matrix.command }}
+          cloud-url: file://~
+          stack-name: dev
+          upsert: true
+          work-dir: .github/test-stacks/golang
+          config-map: '{name: {value: hostname, secret: false}}'
+          plan: update-plan.json
+
+      - name: Ensure Update Plan is Generated and Not Empty
+        shell: bash
+        if: matrix.command == 'preview'
+        working-directory: .github/test-stacks/golang
+        run: |
+          test -s update-plan.json
+
   test-nodejs-stack:
     needs: install-and-build
     if:
@@ -282,6 +338,35 @@ jobs:
           work-dir: .github/test-stacks/nodejs
           config-map: '{name: {value: hostname, secret: false}}'
 
+      - name: Ensure Update Plan is Not Generated
+        shell: bash
+        if: matrix.command == 'preview'
+        working-directory: .github/test-stacks/nodejs
+        run: |
+          ! test -f update-plan.json || exit 1
+
+      - name: Run Pulumi Preview with Update Plan
+        if: matrix.command == 'preview'
+        uses: ./
+        env:
+          PULUMI_CONFIG_PASSPHRASE: not-a-secret
+        with:
+          command: ${{ matrix.command }}
+          cloud-url: file://~
+          stack-name: dev
+          upsert: true
+          work-dir: .github/test-stacks/nodejs
+          config-map: '{name: {value: hostname, secret: false}}'
+          plan: update-plan.json
+
+      - name: Ensure Update Plan is Generated and Not Empty
+        shell: bash
+        if: matrix.command == 'preview'
+        working-directory: .github/test-stacks/nodejs
+        run: |
+          test -s update-plan.json
+
+
   test-python-stack:
     needs: install-and-build
     if:
@@ -361,6 +446,34 @@ jobs:
           work-dir: .github/test-stacks/python
           config-map: '{name: {value: hostname, secret: false}}'
 
+      - name: Ensure Update Plan is Not Generated
+        shell: bash
+        if: matrix.command == 'preview'
+        working-directory: .github/test-stacks/python
+        run: |
+          ! test -f update-plan.json || exit 1
+
+      - name: Run Pulumi Preview with Update Plan
+        if: matrix.command == 'preview'
+        uses: ./
+        env:
+          PULUMI_CONFIG_PASSPHRASE: not-a-secret
+        with:
+          command: ${{ matrix.command }}
+          cloud-url: file://~
+          stack-name: dev
+          upsert: true
+          work-dir: .github/test-stacks/python
+          config-map: '{name: {value: hostname, secret: false}}'
+          plan: update-plan.json
+
+      - name: Ensure Update Plan is Generated and Not Empty
+        shell: bash
+        if: matrix.command == 'preview'
+        working-directory: .github/test-stacks/python
+        run: |
+          test -s update-plan.json
+
   test-generic-inputs:
     needs: install-and-build
     if:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index cbe3bdd..a0e0ec7 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,9 @@
 
 ## HEAD (Unreleased)
 
+- feat: Add Update Plan Functionality
+  ([#994](https://github.com/pulumi/actions/pull/994))
+
 --
 
 ## 4.4.0 (2023-06-05)
@@ -86,7 +89,7 @@
 
 - enhancement: Add `pulumi-version` option to allow pinning the version of the
   CLI [PR](https://github.com/pulumi/actions/pull/661) fixes
-  [#437 ](https://github.com/pulumi/actions/issues/437)
+  [#437](https://github.com/pulumi/actions/issues/437)
 
 ## 3.18.1 (2022-07-07)
 
diff --git a/README.md b/README.md
index 0604fe5..3e9917d 100644
--- a/README.md
+++ b/README.md
@@ -124,6 +124,14 @@ The action can be configured with the following arguments:
 - `exclude-protected` - (optional) Skip destroying protected resources. Only
   valid when `command` is `destroy`.
 
+- `plan` - (optional) Used for
+  [update plans](https://www.pulumi.com/docs/concepts/update-plans/)
+  - On `preview`: Where to save the update plan. If you choose to use this in a
+    different run of your workflow (let's say you create an update plan via a
+    preview on Pull Request creation and want to `up` using the plan) you must
+    upload the plan as an artifact, and retrieve it wherever you run `up`
+  - On `up`: Where to read the update plan from.
+
 By default, this action will try to authenticate Pulumi with
 [Pulumi Cloud](https://app.pulumi.com/). If you have not specified a
 `PULUMI_ACCESS_TOKEN` then you will need to specify an alternative backend via
diff --git a/action.yml b/action.yml
index 86654e2..0f5b995 100644
--- a/action.yml
+++ b/action.yml
@@ -93,6 +93,9 @@ inputs:
     description: 'Skip destroying protected resources. Only valid when command is destroy.'
     required: false
     default: 'false'
+  plan:
+    description: 'Where to either save an Update Plan or read an Update Plan from'
+    required: false
 outputs:
   output:
     description: Output from running command
diff --git a/src/config.ts b/src/config.ts
index b75815e..f84e027 100644
--- a/src/config.ts
+++ b/src/config.ts
@@ -69,6 +69,7 @@ export function makeConfig() {
         alternatives: ['always', 'never', 'raw', 'auto'] as const,
       }),
       excludeProtected: getBooleanInput('exclude-protected'),
+      plan: getInput('plan'),
     },
   };
 }


Test patch:
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index 604498e..6be609e 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -56,3 +56,51 @@ jobs:
           work-dir: .github/test-stacks/golang
           config-map: "{name: {value: test, secret: false}}"
       - run: echo 'The random string is `${{ steps.pulumi.outputs.name }}`'
+  test-update-plan: # make sure the action works on a clean machine without building
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/checkout@v3
+      - uses: actions/setup-go@v4
+        with:
+          go-version: '^1.13.1'
+      - run: |
+          pulumi login --local
+          pulumi stack init dev
+        working-directory: .github/test-stacks/golang
+        env:
+          PULUMI_CONFIG_PASSPHRASE: not-a-secret
+      - uses: actions/setup-node@v3
+        with:
+          node-version: 16.x
+          cache: yarn
+      - run: yarn install
+      - run: yarn build
+      - name: Generate Update Plan via Pulumi Preview
+        uses: ./
+        if: always()
+        id: pulumi-preview
+        env:
+          PULUMI_CONFIG_PASSPHRASE: not-a-secret
+        with:
+          command: preview
+          cloud-url: file://~
+          stack-name: dev
+          upsert: true
+          work-dir: .github/test-stacks/golang
+          config-map: "{name: {value: test, secret: false}}"
+          plan: /tmp/update-plan.json
+      - name: Apply Update Plan via Pulumi Up
+        uses: ./
+        if: always()
+        id: pulumi-up
+        env:
+          PULUMI_CONFIG_PASSPHRASE: not-a-secret
+        with:
+          command: up
+          cloud-url: file://~
+          stack-name: dev
+          upsert: true
+          work-dir: .github/test-stacks/golang
+          config-map: "{name: {value: test, secret: false}}"
+          plan: /tmp/update-plan.json
+      - run: echo 'The random string is `${{ steps.pulumi-up.outputs.name }}`'
diff --git a/src/__tests__/config.test.ts b/src/__tests__/config.test.ts
index 57d6346..c6b1344 100644
--- a/src/__tests__/config.test.ts
+++ b/src/__tests__/config.test.ts
@@ -16,6 +16,7 @@ const defaultConfig: Record<string, string> = {
   diff: 'false',
   'target-dependents': 'false',
   'exclude-protected': 'false',
+  'plan': '',
 };
 
 function setupMockedConfig(config: Record<string, string>) {
@@ -49,6 +50,7 @@ describe('config.ts', () => {
           "expectNoChanges": false,
           "message": "",
           "parallel": undefined,
+          "plan": "",
           "policyPackConfigs": Array [],
           "policyPacks": Array [],
           "replace": Array [],