Repository: pulumi/actions
Pull request #834 (2023-01-13 20:50:09)

Support install-only workflow.
This commit adds an `install` command option which halts execution after the Pulumi CLI is installed. This attempts to replicate the `@pulumi/setup-pulumi` action. To test this, most logic in main() was moved to a separate file that can be imported in a test context, unlike `main.ts`, which executes `main()` during resolution.

Fixes https://github.com/pulumi/actions/issues/686

RobbieMcKinstry: @Frassle I still need to rebase but otherwise I'm ready for another review. This [commit message](https://github.com/pulumi/actions/pull/834/commits/d5ff8855977015147c5fc7e61915513a242ba049) explains how I embraced your suggestion and the design constraints on this changeset.
pulumi-bot: <!-- add-pr-comment:add-pr-comment -->

Hi 👋

Thank you for this contribution!
Due to security reasons, we only allow updating the `dist/**` files with our own workflows.
abhinav: Current dependencies on/for this PR:
* master
  * **PR #877** <a href="https://app.graphite.dev/github/pr/pulumi/actions/877" target="_blank"><img src="https://static.graphite.dev/graphite-32x32.png" alt="Graphite" width="10px" height="10px"/></a> 
    * **PR #834** <a href="https://app.graphite.dev/github/pr/pulumi/actions/834" target="_blank"><img src="https://static.graphite.dev/graphite-32x32.png" alt="Graphite" width="10px" height="10px"/></a>  👈

This comment was auto-generated by [Graphite](https://app.graphite.dev/github/pr/pulumi/actions/834?utm_source=stack-comment).
abhinav: Updates:
- Undid the refactor moving code to run.ts because that made it difficult to review the changes.
- Fixed a bug where the CLI was not installed for regular run commands. The job passed for all but macOS because of that bug previously because macOS workers don't have pulumi installed on workers by default. Created #877 to prevent this in the future.
- Added a test to verify the install-only workflow.
- Updated README to reflect new changes.
- Updated action.yml to mark 'command' and 'stack' optional

Patch:
diff --git a/.github/workflows/workflow.yml b/.github/workflows/workflow.yml
index 2fe14a9..9133173 100644
--- a/.github/workflows/workflow.yml
+++ b/.github/workflows/workflow.yml
@@ -45,6 +45,49 @@ jobs:
     outputs:
       changed: ${{ steps.changes.outputs.dist }}
 
+  test-install-only:
+    needs: install-and-build
+    if: ${{ needs.install-and-build.outputs.changed == 'true' }}
+    runs-on: ${{ matrix.os }}
+    name: Install-only on ${{ matrix.os }}
+    strategy:
+      matrix:
+        os: [ubuntu-latest, macos-latest, windows-latest]
+      fail-fast: false
+    steps:
+      - name: Remove pre-installed Pulumi
+        shell: bash
+        env:
+          RUNNER_OS: ${{ matrix.os }}
+        run: |
+          EXT=""
+          if [ "$RUNNER_OS" == "Windows" ]; then
+            EXT=".exe"
+          fi
+
+          if command -v "pulumi${EXT}"; then
+            PULUMI_INSTALL_DIR=$(dirname "$(command -v "pulumi${EXT}")")
+            echo "Deleting Pulumi"
+            rm -v "$PULUMI_INSTALL_DIR"/pulumi*
+          fi
+
+      - uses: actions/checkout@v3
+
+      - name: Download dist artifact
+        uses: actions/download-artifact@v3
+        with:
+          name: dist
+          path: dist
+
+      # If no action is specified, just install.
+      - uses: ./
+        env:
+          PULUMI_CONFIG_PASSPHRASE: not-a-secret
+        with:
+          config-map: "{name: {value: my-pet, secret: false}}"
+
+      - run: pulumi version
+
   test-dotnet-stack:
     needs: install-and-build
     if: ${{ needs.install-and-build.outputs.changed == 'true' }}
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 653702d..7a03d42 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,12 +2,16 @@
 
 ## HEAD (Unreleased)
 
+- feat: Support install-only mode similar to
+  [setup-pulumi](https://github.com/marketplace/actions/setup-pulumi)
+  ([#834](https://github.com/pulumi/actions/pull/834))
+
 - fix: allow `comment-on-pr-number` to be used for `${{ github.event }}` types
   other than `pull_request`
   ([#803](https://github.com/pulumi/actions/issues/803))
 
 - bug: Fix installation on Windows.
-([#851](https://github.com/pulumi/actions/pull/851))
+  ([#851](https://github.com/pulumi/actions/pull/851))
 
 ## 4.0.0 (2023-19-01)
 
diff --git a/README.md b/README.md
index f9cf889..8119ede 100644
--- a/README.md
+++ b/README.md
@@ -34,12 +34,14 @@ This will check out the existing directory and run `pulumi preview`.
 
 The action can be configured with the following arguments:
 
-- `command` (required) - The command to run as part of the action. Accepted
-  values are `up` (update), `refresh`, `destroy` and `preview`.
+- `command` (optional) - The command to run as part of the action. Accepted
+  values are `up` (alias: update), `refresh`, `destroy`, and `preview`. If
+  unspecified, the action will stop after installing Pulumi.
 
-- `stack-name` (required) - The name of the stack that Pulumi will be operating
+- `stack-name` (optional) - The name of the stack that Pulumi will be operating
   on. Use the fully quaified org-name/stack-name when operating on a stack
-  outside of your individual account.
+  outside of your individual account. This field is required if a `command` was
+  specified.
 
 - `work-dir` (optional) - The location of your Pulumi files. Defaults to `./`.
 
@@ -124,6 +126,17 @@ By default, this action will try to authenticate Pulumi with the
 `PULUMI_ACCESS_TOKEN` then you will need to specify an alternative backend via
 the `cloud-url` argument.
 
+### Installation Only
+
+If you want to only install the Pulumi CLI, omit the `command` field of the
+action.
+
+```yaml
+- uses: pulumi/actions@v4
+```
+
+This will install Pulumi and exit without performing any other operations.
+
 ### Stack Outputs
 
 [Stack outputs](https://www.pulumi.com/docs/intro/concepts/stack/#outputs) are
diff --git a/action.yml b/action.yml
index 7ce496e..e0bf63c 100644
--- a/action.yml
+++ b/action.yml
@@ -7,10 +7,10 @@ branding:
 inputs:
   command:
     description: 'Pulumi command to run, eg. up'
-    required: true
+    required: false
   stack-name:
     description: 'Which stack you want to apply to, eg. dev'
-    required: true
+    required: false
   work-dir:
     description: 'Location of your Pulumi files. Defaults to ./'
     required: false
diff --git a/package.json b/package.json
index ba7c3a4..50ded0b 100644
--- a/package.json
+++ b/package.json
@@ -29,7 +29,7 @@
     "@typescript-eslint/parser": "~4.33.0",
     "@vercel/ncc": "^0.36.1",
     "eslint": "~7.32.0",
-    "eslint-config-prettier": "~8.6.0",
+    "eslint-config-prettier": "~8.7.0",
     "eslint-plugin-import": "^2.27.5",
     "eslint-plugin-jest": "~27.2.1",
     "faker": "^6.6.6",
@@ -38,7 +38,7 @@
     "jest-playback": "^3.0.0",
     "lint-staged": "^13.1.2",
     "prettier": "^2.8.4",
-    "rimraf": "~4.1.2",
+    "rimraf": "~4.4.0",
     "ts-jest": "~26.5.6"
   },
   "dependencies": {
diff --git a/src/config.ts b/src/config.ts
index 394044b..5471d4a 100644
--- a/src/config.ts
+++ b/src/config.ts
@@ -4,15 +4,33 @@ import * as rt from 'runtypes';
 import { parseArray, parseBoolean, parseNumber } from './libs/utils';
 
 export const command = rt.Union(
-  rt.Literal('up'),
-  rt.Literal('update'),
-  rt.Literal('refresh'),
   rt.Literal('destroy'),
   rt.Literal('preview'),
+  rt.Literal('refresh'),
+  rt.Literal('up'),
+  rt.Literal('update'),
 );
 
 export type Commands = rt.Static<typeof command>;
 
+// installationConfig is the expected Action inputs when
+// the user intends to download the Pulumi CLI without
+// running any other Pulumi operations.
+// We expect command NOT to be provided.
+export const installationConfig = rt.Record({
+  command: rt.Undefined,
+  pulumiVersion: rt.String,
+});
+
+export type InstallationConfig = rt.Static<typeof installationConfig>;
+
+export function makeInstallationConfig(): rt.Result<InstallationConfig> {
+  return installationConfig.validate({
+    command: getInput('command') || undefined,
+    pulumiVersion: getInput('pulumi-version') || "^3",
+  });
+}
+
 export const options = rt.Partial({
   parallel: rt.Number,
   message: rt.String,
diff --git a/src/main.ts b/src/main.ts
index 8afb4c6..ac745fc 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -8,16 +8,34 @@ import {
 } from '@pulumi/pulumi/automation';
 import invariant from 'ts-invariant';
 import YAML from 'yaml';
-import { Commands, makeConfig } from './config';
+import { Commands, Config, InstallationConfig, makeConfig, makeInstallationConfig } from './config';
 import { environmentVariables } from './libs/envs';
 import { handlePullRequestMessage } from './libs/pr';
 import * as pulumiCli from './libs/pulumi-cli';
 import { login } from './login';
 
 const main = async () => {
+  const downloadConfig = makeInstallationConfig();
+  if (downloadConfig.success) {
+    await installOnly(downloadConfig.value);
+    core.info("Pulumi has been successfully installed. Exiting.");
+    return;
+  }
+
+  // If we get here, we're not in install-only mode.
+  // Attempt to parse the full configuration and run the action.
   const config = await makeConfig();
   core.debug('Configuration is loaded');
+  runAction(config);
+};
+
+// installOnly is the main entrypoint of the program when the user
+// intends to install the Pulumi CLI without running additional commands.
+const installOnly = async (config: InstallationConfig): Promise<void> => {
+  await pulumiCli.downloadCli(config.pulumiVersion);
+}
 
+const runAction = async (config: Config): Promise<void> => {
   await pulumiCli.downloadCli(config.options.pulumiVersion);
   await login(config.cloudUrl, environmentVariables.PULUMI_ACCESS_TOKEN);
 
diff --git a/yarn.lock b/yarn.lock
index e1ae658..0292038 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -1499,6 +1499,13 @@ brace-expansion@^1.1.7:
     balanced-match "^1.0.0"
     concat-map "0.0.1"
 
+brace-expansion@^2.0.1:
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-2.0.1.tgz#1edc459e0f0c548486ecf9fc99f2221364b9a0ae"
+  integrity sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==
+  dependencies:
+    balanced-match "^1.0.0"
+
 braces@^2.3.1:
   version "2.3.2"
   resolved "https://registry.yarnpkg.com/braces/-/braces-2.3.2.tgz#5979fd3f14cd531565e5fa2df1abfff1dfaee729"
@@ -2192,10 +2199,10 @@ escodegen@^2.0.0:
   optionalDependencies:
     source-map "~0.6.1"
 
-eslint-config-prettier@~8.6.0:
-  version "8.6.0"
-  resolved "https://registry.yarnpkg.com/eslint-config-prettier/-/eslint-config-prettier-8.6.0.tgz#dec1d29ab728f4fa63061774e1672ac4e363d207"
-  integrity sha512-bAF0eLpLVqP5oEVUFKpMA+NnRFICwn9X8B5jrR9FcqnYBuPbqWEjTEspPWMj5ye6czoSLDweCzSo3Ko7gGrZaA==
+eslint-config-prettier@~8.7.0:
+  version "8.7.0"
+  resolved "https://registry.yarnpkg.com/eslint-config-prettier/-/eslint-config-prettier-8.7.0.tgz#f1cc58a8afebc50980bd53475451df146c13182d"
+  integrity sha512-HHVXLSlVUhMSmyW4ZzEuvjpwqamgmlfkutD53cYXLikh4pt/modINRcCIApJ84czDxM4GZInwUrromsDdTImTA==
 
 eslint-import-resolver-node@^0.3.7:
   version "0.3.7"
@@ -2726,6 +2733,16 @@ glob@^7.1.1, glob@^7.1.2, glob@^7.1.3, glob@^7.1.4:
     once "^1.3.0"
     path-is-absolute "^1.0.0"
 
+glob@^9.2.0:
+  version "9.2.1"
+  resolved "https://registry.yarnpkg.com/glob/-/glob-9.2.1.tgz#f47e34e1119e7d4f93a546e75851ba1f1e68de50"
+  integrity sha512-Pxxgq3W0HyA3XUvSXcFhRSs+43Jsx0ddxcFrbjxNGkL2Ak5BAUBxLqI5G6ADDeCHLfzzXFhe0b1yYcctGmytMA==
+  dependencies:
+    fs.realpath "^1.0.0"
+    minimatch "^7.4.1"
+    minipass "^4.2.4"
+    path-scurry "^1.6.1"
+
 globals@^11.1.0:
   version "11.12.0"
   resolved "https://registry.yarnpkg.com/globals/-/globals-11.12.0.tgz#ab8795338868a0babd8525758018c2a7eb95c42e"
@@ -4031,6 +4048,11 @@ lru-cache@^6.0.0:
   dependencies:
     yallist "^4.0.0"
 
+lru-cache@^7.14.1:
+  version "7.18.3"
+  resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-7.18.3.tgz#f793896e0fd0e954a59dfdd82f0773808df6aa89"
+  integrity sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==
+
 make-dir@^3.0.0:
   version "3.1.0"
   resolved "https://registry.yarnpkg.com/make-dir/-/make-dir-3.1.0.tgz#415e967046b3a7f1d185277d84aa58203726a13f"
@@ -4138,11 +4160,23 @@ minimatch@^3.0.4, minimatch@^3.1.1, minimatch@^3.1.2:
   dependencies:
     brace-expansion "^1.1.7"
 
+minimatch@^7.4.1:
+  version "7.4.2"
+  resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-7.4.2.tgz#157e847d79ca671054253b840656720cb733f10f"
+  integrity sha512-xy4q7wou3vUoC9k1xGTXc+awNdGaGVHtFUaey8tiX4H1QRc04DZ/rmDFwNm2EBsuYEhAZ6SgMmYf3InGY6OauA==
+  dependencies:
+    brace-expansion "^2.0.1"
+
 minimist@^1.1.1, minimist@^1.2.0, minimist@^1.2.6:
   version "1.2.7"
   resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.7.tgz#daa1c4d91f507390437c6a8bc01078e7000c4d18"
   integrity sha512-bzfL1YUZsP41gmu/qjrEk0Q6i2ix/cVeAhbCbqH9u3zYutS1cLg00qhrD0M2MVdCcx4Sc0UpP2eBWo9rotpq6g==
 
+minipass@^4.0.2, minipass@^4.2.4:
+  version "4.2.4"
+  resolved "https://registry.yarnpkg.com/minipass/-/minipass-4.2.4.tgz#7d0d97434b6a19f59c5c3221698b48bbf3b2cd06"
+  integrity sha512-lwycX3cBMTvcejsHITUgYj6Gy6A7Nh4Q6h9NP4sTHY1ccJlC7yKzDmiShEHsJ16Jf1nKGDEaiHxiltsJEvk0nQ==
+
 mixin-deep@^1.2.0:
   version "1.3.2"
   resolved "https://registry.yarnpkg.com/mixin-deep/-/mixin-deep-1.3.2.tgz#1120b43dc359a785dce65b55b82e257ccf479566"
@@ -4522,6 +4556,14 @@ path-parse@^1.0.7:
   resolved "https://registry.yarnpkg.com/path-parse/-/path-parse-1.0.7.tgz#fbc114b60ca42b30d9daf5858e4bd68bbedb6735"
   integrity sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==
 
+path-scurry@^1.6.1:
+  version "1.6.1"
+  resolved "https://registry.yarnpkg.com/path-scurry/-/path-scurry-1.6.1.tgz#dab45f7bb1d3f45a0e271ab258999f4ab7e23132"
+  integrity sha512-OW+5s+7cw6253Q4E+8qQ/u1fVvcJQCJo/VFD8pje+dbJCF1n5ZRMV2AEHbGp+5Q7jxQIYJxkHopnj6nzdGeZLA==
+  dependencies:
+    lru-cache "^7.14.1"
+    minipass "^4.0.2"
+
 path-type@^4.0.0:
   version "4.0.0"
   resolved "https://registry.yarnpkg.com/path-type/-/path-type-4.0.0.tgz#84ed01c0a7ba380afe09d90a8c180dcd9d03043b"
@@ -4831,10 +4873,12 @@ rimraf@^3.0.0, rimraf@^3.0.2:
   dependencies:
     glob "^7.1.3"
 
-rimraf@~4.1.2:
-  version "4.1.2"
-  resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-4.1.2.tgz#20dfbc98083bdfaa28b01183162885ef213dbf7c"
-  integrity sha512-BlIbgFryTbw3Dz6hyoWFhKk+unCcHMSkZGrTFVAx2WmttdBSonsdtRlwiuTbDqTKr+UlXIUqJVS4QT5tUzGENQ==
+rimraf@~4.4.0:
+  version "4.4.0"
+  resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-4.4.0.tgz#c7a9f45bb2ec058d2e60ef9aca5167974313d605"
+  integrity sha512-X36S+qpCUR0HjXlkDe4NAOhS//aHH0Z+h8Ckf2auGJk3PTnx5rLmrHkwNdbVQuCSUhOyFrlRvFEllZOYE+yZGQ==
+  dependencies:
+    glob "^9.2.0"
 
 rsvp@^4.8.4:
   version "4.8.5"


Test patch:
diff --git a/src/__tests__/run.test.ts b/src/__tests__/run.test.ts
index 90f0b4d..6d3e9fa 100644
--- a/src/__tests__/run.test.ts
+++ b/src/__tests__/run.test.ts
@@ -3,6 +3,57 @@ import { login } from '../login';
 
 const spy = jest.spyOn(pulumiCli, 'run');
 
+const installConfig: Record<string, string> = {
+  command: undefined,
+  "pulumi-version": "^2", // test with a non-default value
+};
+
+describe('Config without a provided command', () => {
+  let oldWorkspace = '';
+  beforeEach(() => {
+    spy.mockClear();
+    jest.resetModules();
+    // Save, then restore the current env var for GITHUB_WORKSPACE 
+    oldWorkspace = process.env.GITHUB_WORKSPACE;
+    process.env.GITHUB_WORKSPACE = 'n/a';
+  });
+  afterEach(() => {
+    process.env.GITHUB_WORKSPACE = oldWorkspace;
+  });
+
+  it('should not be validated by makeConfig', async () => {
+    jest.mock('@actions/core', () => ({
+      getInput: jest.fn((name: string) => {
+        return installConfig[name];
+      }),
+      info: jest.fn(),
+    }));
+    jest.mock('@actions/github', () => ({
+      context: {},
+    }));
+    const { makeConfig } = require('../config');
+    await expect(makeConfig())
+      .rejects
+      .toThrow();
+  });
+
+  it('should be validated by makeInstallationConfig', async() => {
+    jest.mock('@actions/core', () => ({
+      getInput: jest.fn((name: string) => {
+        return installConfig[name];
+      }),
+      info: jest.fn(),
+    }));
+    const { makeInstallationConfig } = require('../config');
+    const conf = makeInstallationConfig()
+    expect(conf.success).toBeTruthy();
+    expect(conf.value).toEqual({
+      command: undefined,
+      pulumiVersion: "^2",
+    });
+  });
+});
+
 describe('main.login', () => {
   beforeEach(() => {
     spy.mockClear();