Repository: pulumi/actions (ts)

Issue:
Passing arguments (e.g. `diff`, `parallel`)
Currently, there is no way to pass any arguments. During development, we added this input:
https://github.com/pulumi/actions/blob/f1f4ffe44d9a9375fc3196af16f72a8e12b1b265/src/config.ts#L21

Due to the way Pulumi Automation API is built and the way they are handled, we can't just pass that 👆 over to any of our functions and call it a day. We either have to add some functionality in order to pass arguments to Automation API or find another option.

As of right now, Automation API requires `pulumi` CLI. This might change in the future, so IMHO, we should probably not expose arguments as if they were command-line arguments/parameters (e.g. `--diff`). We should expose them as named arguments (e.g. `diff: `), so they can be passed along to Automation API.

We should also update our README to explain that this action now uses Pulumi Automation API.

Pull request:
feat: Add extra options
fixes #88

**Note**: `args` is removed.

Patch:
diff --git a/.eslintrc.json b/.eslintrc.json
index f9feca9..3deb689 100644
--- a/.eslintrc.json
+++ b/.eslintrc.json
@@ -4,8 +4,7 @@
     "plugin:@typescript-eslint/eslint-recommended",
     "plugin:@typescript-eslint/recommended",
     "plugin:jest/recommended",
-    "prettier",
-    "prettier/@typescript-eslint"
+    "prettier"
   ],
   "env": {
     "browser": false,
diff --git a/.github/workflows/workflow_options.yml b/.github/workflows/workflow_options.yml
new file mode 100644
index 0000000..d2c9ff4
--- /dev/null
+++ b/.github/workflows/workflow_options.yml
@@ -0,0 +1,45 @@
+name: 'Application with Extra Options'
+on:
+  pull_request:
+  push:
+    branches:
+      - main
+      - master
+
+jobs:
+  test-workflow:
+    runs-on: ${{ matrix.os }}
+    name: Testing Pulumi ${{ matrix.command }} on ${{ matrix.os }}
+    strategy:
+      matrix:
+        command: [ up, refresh, destroy, preview ]
+        os: [ubuntu-latest, macos-latest, windows-latest]
+      fail-fast: false
+    steps:
+      - uses: actions/checkout@v2
+      - uses: actions/setup-node@v1
+        with:
+          node-version: 14.x
+      - name: Install pulumi
+        uses: pulumi/action-install-pulumi-cli@v1.0.1
+      - name: Create local stack
+        run: |
+          pulumi login --local
+          pulumi stack init dev
+        working-directory: .github/test-stacks/nodejs
+        env:
+          PULUMI_CONFIG_PASSPHRASE: not-a-secret
+      - run: npm install
+        working-directory: .github/test-stacks/nodejs
+      - uses: ./
+        env:
+          PULUMI_CONFIG_PASSPHRASE: not-a-secret
+        with:
+          command: ${{ matrix.command }}
+          cloud-url: file://~
+          stack-name: dev
+          work-dir: .github/test-stacks/nodejs
+          parallel: 3
+          message: this-is-just-a-test
+          expect-no-changes: false
+          diff: true
diff --git a/README.md b/README.md
index 7d15b37..3f8c8e7 100644
--- a/README.md
+++ b/README.md
@@ -53,12 +53,35 @@ The action can be configured with the following arguments:
   equivalent of what would be passed to the `pulumi login` command. The action
   will login to the appropriate backend on your behalf provided it is configured
   with the correct access credentials for that backend.
+
 - `comment-on-pr` - (optional) If `true`, then the action will add the results
   of the Pulumi action to the PR
 
 - `github-token` - (required if comment-on-pr) A GitHub token that has access
   levels to allow the Action to comment on a PR.
 
+### Extra options
+
+- `parallel` - (optional) Allow P resource operations to run in parallel at once
+  (1 for no parallelism). Defaults to unbounded.
+
+- `message` - (optional) Optional message to associate with the update operation
+
+- `expect-no-changes` - (optional) Return an error if any changes occur during
+  this update
+
+- `diff` - (optional) Display operation as a rich diff showing the overall
+  change
+
+- `replace` - (optional) Specify resources to replace. Multiple resources can be
+  specified one per line
+
+- `target` - (optional) Specify a single resource URN to update. Other resources
+  will not be updated. Multiple resources can be specified one per line
+
+- `target-dependents` - (optional) Allows updating of dependent targets
+  discovered but not specified in target.
+
 By default, this action will try to authenticate Pulumi with the
 [Pulumi SaaS](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 b3bca8e..cfd7089 100644
--- a/action.yml
+++ b/action.yml
@@ -27,6 +27,30 @@ inputs:
     description: 'A cloud URL to log in to'
     required: false
     default: ''
+  parallel:
+    description: 'Allow P resource operations to run in parallel at once (1 for no parallelism). Defaults to unbounded.'
+    required: false
+    default: '2147483647'
+  message:
+    description: 'Optional message to associate with the update operation'
+    required: false
+    default: ''
+  expect-no-changes:
+    description: 'Return an error if any changes occur during this update'
+    required: false
+  diff:
+    description: 'Display operation as a rich diff showing the overall change'
+    required: false
+  replace:
+    description: 'Specify resources to replace. Multiple resources can be specified one per line'
+    required: false
+  target:
+    description: 'Specify a single resource URN to update. Other resources will not be updated. Multiple resources can be specified one per line'
+    required: false
+  target-dependents:
+    description: 'Allows updating of dependent targets discovered but not specified in target.'
+    required: false
+    default: 'false'
 outputs:
   output:
     description: Output from running command
diff --git a/src/config.ts b/src/config.ts
index 094d46f..f22132e 100644
--- a/src/config.ts
+++ b/src/config.ts
@@ -1,5 +1,6 @@
 import { getInput } from '@actions/core';
 import * as rt from 'runtypes';
+import { parseArray, parseBoolean, parseNumber } from './libs/utils';
 
 export const command = rt.Union(
   rt.Literal('up'),
@@ -11,29 +12,53 @@ export const command = rt.Union(
 
 export type Commands = rt.Static<typeof command>;
 
-export const config = rt.Record({
-  command: command,
-  stackName: rt.String,
-  workDir: rt.String,
-  cloudUrl: rt.String.Or(rt.Undefined),
-  githubToken: rt.String.Or(rt.Undefined),
-  commentOnPr: rt.Boolean,
-  args: rt.String.Or(rt.Undefined),
-  upsert: rt.Boolean.Or(rt.Undefined),
+export const options = rt.Partial({
+  parallel: rt.Number,
+  message: rt.String,
+  expectNoChanges: rt.Boolean,
+  diff: rt.Boolean,
+  replace: rt.Array(rt.String),
+  target: rt.Array(rt.String),
+  targetDependents: rt.Boolean,
 });
 
+export const config = rt
+  .Record({
+    // Required options
+    command: command,
+    stackName: rt.String,
+    workDir: rt.String,
+    commentOnPr: rt.Boolean,
+    options: options,
+  })
+  .And(
+    rt.Partial({
+      // Optional options
+      cloudUrl: rt.String,
+      githubToken: rt.String,
+      upsert: rt.Boolean,
+    }),
+  );
+
 export type Config = rt.Static<typeof config>;
 
 export async function makeConfig(): Promise<Config> {
-  const [command, ...args] = getInput('command', { required: true }).split(' ');
   return config.check({
-    command,
+    command: getInput('command', { required: true }),
     stackName: getInput('stack-name', { required: true }),
     workDir: getInput('work-dir') || './',
     cloudUrl: getInput('cloud-url'),
     githubToken: getInput('github-token'),
-    commentOnPr: getInput('comment-on-pr') === 'true' ? true : false,
-    args: getInput('args') || args.join(' '),
-    upsert: getInput('upsert') === 'true' ? true : false,
+    commentOnPr: parseBoolean(getInput('comment-on-pr')),
+    upsert: parseBoolean(getInput('upsert')),
+    options: {
+      parallel: parseNumber(getInput('parallel')),
+      message: getInput('message'),
+      expectNoChanges: parseBoolean(getInput('expect-no-changes')),
+      diff: parseBoolean(getInput('diff')),
+      replace: parseArray(getInput('replace')),
+      target: parseArray(getInput('target')),
+      targetDependents: parseBoolean(getInput('target-dependents')),
+    },
   });
 }
diff --git a/src/libs/utils.ts b/src/libs/utils.ts
index 4ade285..3a164d2 100644
--- a/src/libs/utils.ts
+++ b/src/libs/utils.ts
@@ -7,3 +7,28 @@ export function invariant(
     throw new Error(message);
   }
 }
+
+export function parseArray(input: string): string[] {
+  return parseUndefined(input)
+    ? input.split(/\r?\n/).reduce<string[]>(
+        (acc, line) =>
+          acc
+            .concat(line.split(','))
+            .filter((pat) => pat)
+            .map((pat) => pat.trim()),
+        [],
+      )
+    : undefined;
+}
+
+export function parseUndefined(input: string): string | undefined {
+  return input === undefined || input === '' ? undefined : input;
+}
+
+export function parseBoolean(input: string): boolean | undefined {
+  return parseUndefined(input) ? input === 'true' : undefined;
+}
+
+export function parseNumber(input: string): number | undefined {
+  return parseUndefined(input) ? Number(input) : undefined;
+}
diff --git a/src/main.ts b/src/main.ts
index c5a4b78..151bc25 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -45,12 +45,15 @@ const main = async () => {
   };
 
   const actions: Record<Commands, () => Promise<string>> = {
-    up: () => stack.up({ onOutput }).then((r) => r.stdout),
-    update: () => stack.up({ onOutput }).then((r) => r.stdout),
-    refresh: () => stack.refresh({ onOutput }).then((r) => r.stdout),
-    destroy: () => stack.destroy({ onOutput }).then((r) => r.stdout),
+    up: () => stack.up({ onOutput, ...config.options }).then((r) => r.stdout),
+    update: () =>
+      stack.up({ onOutput, ...config.options }).then((r) => r.stdout),
+    refresh: () =>
+      stack.refresh({ onOutput, ...config.options }).then((r) => r.stdout),
+    destroy: () =>
+      stack.destroy({ onOutput, ...config.options }).then((r) => r.stdout),
     preview: async () => {
-      const { stdout, stderr } = await stack.preview();
+      const { stdout, stderr } = await stack.preview(config.options);
       onOutput(stdout);
       onOutput(stderr);
       return stdout;


Test patch:
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index 1f47873..5a17bc8 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -34,33 +34,6 @@ jobs:
       - uses: ./
         if: always()
         id: pulumi
-        env:
-          PULUMI_CONFIG_PASSPHRASE: not-a-secret
-        with:
-          command: up --skip-preview
-          cloud-url: file://~
-          stack-name: dev
-          work-dir: .github/test-stacks/golang
-      - run: echo "The random string is ${{ steps.pulumi.outputs.name }}"
-  test-args:
-    runs-on: ubuntu-latest
-    steps:
-      - uses: actions/checkout@v2
-      - uses: actions/setup-go@v2
-        with:
-          go-version: '^1.13.1'
-      - name: Install pulumi
-        uses: pulumi/action-install-pulumi-cli@v1.0.1
-      - run: |
-          pulumi login --local
-          pulumi stack init dev
-        working-directory: .github/test-stacks/golang
-        env:
-          PULUMI_CONFIG_PASSPHRASE: not-a-secret
-      - run: yarn install
-      - run: yarn build
-      - uses: ./
-        if: always()
         env:
           PULUMI_CONFIG_PASSPHRASE: not-a-secret
         with:
@@ -68,30 +41,4 @@ jobs:
           cloud-url: file://~
           stack-name: dev
           work-dir: .github/test-stacks/golang
-          args: --skip-preview
-  test-command-args:
-    runs-on: ubuntu-latest
-    steps:
-      - uses: actions/checkout@v2
-      - uses: actions/setup-go@v2
-        with:
-          go-version: '^1.13.1'
-      - name: Install pulumi
-        uses: pulumi/action-install-pulumi-cli@v1.0.1
-      - run: |
-          pulumi login --local
-          pulumi stack init dev
-        working-directory: .github/test-stacks/golang
-        env:
-          PULUMI_CONFIG_PASSPHRASE: not-a-secret
-      - run: yarn install
-      - run: yarn build
-      - uses: ./
-        if: always()
-        env:
-          PULUMI_CONFIG_PASSPHRASE: not-a-secret
-        with:
-          command: up --skip-preview
-          cloud-url: file://~
-          stack-name: dev
-          work-dir: .github/test-stacks/golang
+      - run: echo "The random string is ${{ steps.pulumi.outputs.name }}"
diff --git a/src/__tests__/config.test.ts b/src/__tests__/config.test.ts
index c2299c8..5d906d0 100644
--- a/src/__tests__/config.test.ts
+++ b/src/__tests__/config.test.ts
@@ -26,14 +26,22 @@ describe('config.ts', () => {
     const c = await makeConfig();
     expect(c).toBeTruthy();
     expect(c).toEqual({
-      args: '',
       command: 'up',
       stackName: 'dev',
       cloudUrl: 'file://~',
       githubToken: 'n/a',
       commentOnPr: false,
       workDir: './',
-      upsert: false,
+      upsert: undefined,
+      options: {
+        parallel: undefined,
+        message: undefined,
+        diff: undefined,
+        expectNoChanges: undefined,
+        replace: undefined,
+        target: undefined,
+        targetDependents: undefined,
+      },
     });
   });
   it('should fail if configuration are invalid', async () => {
@@ -66,14 +74,22 @@ describe('config.ts', () => {
     const c = await makeConfig();
     expect(c).toBeTruthy();
     expect(c).toEqual({
-      args: '',
       command: 'up',
       stackName: 'dev',
       cloudUrl: 'file://~',
       githubToken: 'n/a',
       commentOnPr: true,
       workDir: './',
-      upsert: false,
+      upsert: undefined,
+      options: {
+        parallel: undefined,
+        message: undefined,
+        diff: undefined,
+        expectNoChanges: undefined,
+        replace: undefined,
+        target: undefined,
+        targetDependents: undefined,
+      },
     });
   });
 });
diff --git a/src/libs/__tests__/utils.test.ts b/src/libs/__tests__/utils.test.ts
new file mode 100644
index 0000000..1979412
--- /dev/null
+++ b/src/libs/__tests__/utils.test.ts
@@ -0,0 +1,40 @@
+import { parseArray, parseBoolean, parseNumber } from '../utils';
+
+describe('utils.ts', () => {
+  it('should parse input to array', () => {
+    const input = `
+      hello
+      world
+    `;
+
+    const undef = '';
+
+    const inputArray = parseArray(input);
+
+    expect(inputArray[0]).toBe('hello');
+    expect(inputArray[1]).toBe('world');
+    expect(parseArray(undef)).toBeUndefined();
+  });
+
+  it('should parse boolean', () => {
+    const truthy = 'true';
+    const falsy = 'false';
+    const falsyTypo = 'fl';
+    const undef = '';
+
+    expect(parseBoolean(truthy)).toBeTruthy();
+    expect(parseBoolean(falsy)).toBeFalsy();
+    expect(parseBoolean(falsyTypo)).toBeFalsy();
+    expect(parseBoolean(undef)).toBeUndefined();
+  });
+
+  it('should parse number', () => {
+    const truthy = '1';
+    const falsy = 'false';
+    const undef = '';
+
+    expect(parseNumber(truthy)).toBe(1);
+    expect(parseNumber(falsy)).toBeNaN();
+    expect(parseNumber(undef)).toBeUndefined();
+  });
+});