├── .github
├── CODEOWNERS
├── ISSUE_TEMPLATE
│ ├── bug_report.md
│ └── feature_request.md
├── PULL_REQUEST_TEMPLATE
│ └── pull_request_template.md
├── assets
│ ├── comment.png
│ └── revisions.png
├── dependabot.yaml
├── examples
│ ├── pr_manual_label.yaml
│ ├── pr_merge_matrix.yaml
│ ├── pr_push_auth.yaml
│ ├── pr_push_lint.yaml
│ ├── pr_push_stages.yaml
│ └── schedule_refresh.yaml
└── workflows
│ ├── tag_release.yaml
│ └── test_ci.yaml
├── LICENSE
├── README.md
├── SECURITY.md
├── action.yml
└── tests
├── fail_data_source_error
└── main.tf
├── fail_format_diff
└── main.tf
├── fail_invalid_resource_type
└── main.tf
├── pass_character_limit
└── main.tf
├── pass_one
└── main.tf
└── tf.sh
/.github/CODEOWNERS:
--------------------------------------------------------------------------------
1 | # Each line is a file pattern followed by one or more owners.
2 | # Order is important as the last matching pair takes precedence.
3 | * @rdhar @rdhar-tc
4 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/bug_report.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Bug report
3 | about: Create a report to help us improve
4 | title: ""
5 | labels: ""
6 | assignees: ""
7 | ---
8 |
9 | #### Describe the bug
10 |
11 |
12 |
13 | #### To Reproduce
14 |
15 |
16 |
17 | 1. Go to '…'
18 | 2. Click on '…'
19 | 3. Scroll down to '…'
20 | 4. Observe error
21 |
22 | #### Expected behavior
23 |
24 |
25 |
26 | #### Screenshots
27 |
28 |
29 |
30 | #### Additional context
31 |
32 |
33 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/feature_request.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Feature request
3 | about: Suggest an idea for this project
4 | title: ""
5 | labels: ""
6 | assignees: ""
7 | ---
8 |
9 | #### Is your feature request related to a problem
10 |
11 |
12 |
13 | #### Describe the solution you'd like
14 |
15 |
16 |
17 | #### Describe alternatives you've considered
18 |
19 |
20 |
21 | #### Additional context
22 |
23 |
24 |
--------------------------------------------------------------------------------
/.github/PULL_REQUEST_TEMPLATE/pull_request_template.md:
--------------------------------------------------------------------------------
1 | #### What kind of change does this PR introduce?
2 |
3 |
4 |
5 | #### What is the current behaviour?
6 |
7 |
8 |
9 | #### What is the new behaviour?
10 |
11 |
12 |
13 | #### Does this PR introduce a breaking change?
14 |
15 |
16 |
--------------------------------------------------------------------------------
/.github/assets/comment.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/OP5dev/TF-via-PR/95c08e93862da6848102e6f44654fd12543afc70/.github/assets/comment.png
--------------------------------------------------------------------------------
/.github/assets/revisions.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/OP5dev/TF-via-PR/95c08e93862da6848102e6f44654fd12543afc70/.github/assets/revisions.png
--------------------------------------------------------------------------------
/.github/dependabot.yaml:
--------------------------------------------------------------------------------
1 | ---
2 | version: 2
3 |
4 | updates:
5 | - package-ecosystem: github-actions
6 | directory: /
7 | schedule:
8 | interval: daily
9 |
10 | # - package-ecosystem: opentofu # https://github.com/opentofu/opentofu/issues/1236
11 | # directories:
12 | # - /sample/bucket
13 | # - /sample/instance
14 | # schedule:
15 | # interval: weekly
16 | # groups:
17 | # terraform:
18 | # patterns:
19 | # - "*"
20 |
--------------------------------------------------------------------------------
/.github/examples/pr_manual_label.yaml:
--------------------------------------------------------------------------------
1 | ---
2 | name: Trigger on labeled and workflow_dispatch manual events on GitHub Enterprise (GHE) self-hosted runner.
3 |
4 | on:
5 | workflow_dispatch:
6 | inputs:
7 | command:
8 | description: TF command
9 | required: true
10 | type: choice
11 | options:
12 | - plan
13 | - apply
14 | default: plan
15 | lock:
16 | description: TF lock
17 | required: false
18 | type: boolean
19 | pull_request:
20 | types: [labeled] # https://docs.github.com/actions/learn-github-actions/events-that-trigger-workflows
21 |
22 | jobs:
23 | tf:
24 | runs-on: self-hosted
25 |
26 | permissions:
27 | actions: read # Required to identify workflow run.
28 | checks: write # Required to add status summary.
29 | contents: read # Required to checkout repository.
30 | pull-requests: write # Required to add comment and label.
31 |
32 | steps:
33 | - name: Checkout repository
34 | uses: actions/checkout@v4
35 |
36 | - name: Setup Terraform
37 | uses: hashicorp/setup-terraform@v3
38 | with:
39 | terraform_wrapper: false
40 |
41 | - name: Provision TF via pull_request
42 | uses: op5dev/tf-via-pr@v13
43 | with:
44 | working-directory: path/to/directory
45 | command: ${{ inputs.command != '' && inputs.command || contains(github.event.pull_request.labels.*.name, 'run-apply') && 'apply' || 'plan' }}
46 | arg-lock: ${{ inputs.lock != '' && inputs.lock || contains(github.event.pull_request.labels.*.name, 'run-apply') }}
47 | plan-encrypt: ${{ secrets.PASSPHRASE }}
48 | format: true
49 | validate: true
50 | env:
51 | GH_ENTERPRISE_TOKEN: ${{ secrets.GH_ENTERPRISE_TOKEN }}
52 | TF_DATA_DIR: path/to/.terraform
53 | TF_LOG: ERROR
54 |
55 | - name: Remove label
56 | if: |
57 | contains(github.event.pull_request.labels.*.name, 'run-plan') ||
58 | contains(github.event.pull_request.labels.*.name, 'run-apply')
59 | env:
60 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
61 | PR_NUMBER: ${{ github.event.number }}
62 | PR_LABEL: ${{ contains(github.event.pull_request.labels.*.name, 'run-plan') && 'run-plan' || 'run-apply' }}
63 | run: gh api /repos/{owner}/{repo}/issues/${PR_NUMBER}/labels/${PR_LABEL} --method DELETE
64 |
--------------------------------------------------------------------------------
/.github/examples/pr_merge_matrix.yaml:
--------------------------------------------------------------------------------
1 | ---
2 | name: Trigger on pull_request (plan) and merge_group (apply) events with OpenTofu in matrix strategy.
3 |
4 | on:
5 | pull_request:
6 | merge_group:
7 |
8 | jobs:
9 | tf:
10 | runs-on: ubuntu-latest
11 |
12 | permissions:
13 | actions: read # Required to identify workflow run.
14 | checks: write # Required to add status summary.
15 | contents: read # Required to checkout repository.
16 | pull-requests: write # Required to add comment and label.
17 |
18 | strategy:
19 | fail-fast: false
20 | matrix:
21 | deployment: [dev, qa, prod]
22 |
23 | environment: ${{ github.event_name == 'merge_group' && matrix.deployment || '' }}
24 |
25 | steps:
26 | - name: Checkout repository
27 | uses: actions/checkout@v4
28 |
29 | - name: Setup TF
30 | uses: opentofu/setup-opentofu@v1
31 | with:
32 | tofu_wrapper: false
33 |
34 | - name: Provision TF
35 | uses: op5dev/tf-via-pr@v13
36 | with:
37 | working-directory: path/to/${{ matrix.deployment }}
38 | command: ${{ github.event_name == 'merge_group' && 'apply' || 'plan' }}
39 | arg-refresh: ${{ github.event_name == 'merge_group' && 'false' || 'true' }} # Skip refresh on apply.
40 | arg-lock: ${{ github.event_name == 'merge_group' }}
41 | arg-var-file: env/${{ matrix.deployment }}.tfvars
42 | arg-workspace: ${{ matrix.deployment }}
43 | plan-encrypt: ${{ secrets.PASSPHRASE }}
44 | plan-parity: true # Prevents stale apply within merge queue.
45 | tool: tofu
46 |
--------------------------------------------------------------------------------
/.github/examples/pr_push_auth.yaml:
--------------------------------------------------------------------------------
1 | ---
2 | name: Trigger on pull_request (plan) and push (apply) events with Terraform, AWS authentication and caching.
3 |
4 | on:
5 | pull_request:
6 | push:
7 | branches: [main]
8 |
9 | jobs:
10 | tf:
11 | runs-on: ubuntu-latest
12 |
13 | permissions:
14 | actions: read # Required to identify workflow run.
15 | checks: write # Required to add status summary.
16 | contents: read # Required to checkout repository.
17 | id-token: write # Required to authenticate via OIDC.
18 | pull-requests: write # Required to add comment and label.
19 |
20 | steps:
21 | - name: Checkout repository
22 | uses: actions/checkout@v4
23 |
24 | - name: Authenticate AWS
25 | uses: aws-actions/configure-aws-credentials@v4
26 | with:
27 | aws-region: us-east-1
28 | role-to-assume: ${{ secrets.AWS_ROLE }}
29 |
30 | - name: Create cache
31 | run: |
32 | mkdir --parents $HOME/.terraform.d/plugin-cache
33 | echo "TF_PLUGIN_CACHE_DIR=$HOME/.terraform.d/plugin-cache" >> $GITHUB_ENV
34 |
35 | - name: Cache TF
36 | uses: actions/cache@v4
37 | with:
38 | path: ~/.terraform.d/plugin-cache
39 | key: cache-tf-${{ runner.os }}-${{ hashFiles('path/to/directory/.terraform.lock.hcl') }}
40 |
41 | - name: Setup TF
42 | uses: hashicorp/setup-terraform@v3
43 | with:
44 | terraform_wrapper: false
45 |
46 | - name: Provision TF
47 | uses: op5dev/tf-via-pr@v13
48 | with:
49 | working-directory: path/to/directory
50 | command: ${{ github.event_name == 'push' && 'apply' || 'plan' }}
51 | arg-lock: ${{ github.event_name == 'push' }}
52 | plan-encrypt: ${{ secrets.PASSPHRASE }}
53 | validate: true
54 | format: true
55 |
--------------------------------------------------------------------------------
/.github/examples/pr_push_lint.yaml:
--------------------------------------------------------------------------------
1 | ---
2 | name: Trigger on pull_request (plan) and push (apply) events with fmt/validate checks and TFLint.
3 |
4 | on:
5 | pull_request:
6 | push:
7 | branches: [main]
8 |
9 | jobs:
10 | tf:
11 | runs-on: ubuntu-latest
12 |
13 | permissions:
14 | actions: read # Required to identify workflow run.
15 | checks: write # Required to add status summary.
16 | contents: read # Required to checkout repository.
17 | pull-requests: write # Required to add comment and label.
18 |
19 | steps:
20 | - name: Checkout repository
21 | uses: actions/checkout@v4
22 |
23 | - name: Setup TF
24 | uses: hashicorp/setup-terraform@v3
25 | with:
26 | terraform_wrapper: false
27 |
28 | - name: Init TF
29 | id: tf
30 | if: ${{ github.event_name == 'pull_request' }}
31 | uses: op5dev/tf-via-pr@v13
32 | with:
33 | working-directory: path/to/directory
34 | command: init
35 | arg-lock: false
36 | format: true
37 | validate: true
38 |
39 | - name: Setup TFLint
40 | if: ${{ github.event_name == 'pull_request' }}
41 | uses: terraform-linters/setup-tflint@v4
42 | with:
43 | tflint_wrapper: true
44 |
45 | - name: Run TFLint
46 | id: tflint
47 | if: ${{ github.event_name == 'pull_request' }}
48 | working-directory: path/to/directory
49 | run: |
50 | tflint --init
51 | tflint --format compact
52 | continue-on-error: true
53 |
54 | - name: Comment if TFLint errors
55 | if: ${{ github.event_name == 'pull_request' && steps.tflint.outputs.exitcode != 0 }}
56 | env:
57 | GH_TOKEN: ${{ github.token }}
58 | run: |
59 | # Compose TFLint output.
60 | tflint='${{ steps.tflint.outputs.stderr || steps.tflint.outputs.stdout }}'
61 | tflint="TFLint error.
62 |
63 | \`\`\`hcl
64 | $(echo "$tflint" | sed 's/`/\\`/g')
65 | \`\`\`
66 | "
67 |
68 | # Get body of PR comment from tf step output.
69 | comment=$(gh api /repos/{owner}/{repo}/issues/comments/${{ steps.tf.outputs.comment-id }} --method GET --jq '.body')
70 |
71 | # Replace placeholder with TFLint output.
72 | comment="${comment///$tflint}"
73 |
74 | # Update PR comment combined with TFLint output.
75 | gh api /repos/{owner}/{repo}/issues/comments/${{ steps.tf.outputs.comment-id }} --method PATCH --field body="$comment"
76 |
77 | # Exit workflow due to TFLint error.
78 | exit 1
79 |
80 | - name: Provision TF
81 | uses: op5dev/tf-via-pr@v13
82 | with:
83 | working-directory: path/to/directory
84 | command: ${{ github.event_name == 'push' && 'apply' || 'plan' }}
85 | arg-lock: ${{ github.event_name == 'push' }}
86 |
--------------------------------------------------------------------------------
/.github/examples/pr_push_stages.yaml:
--------------------------------------------------------------------------------
1 | ---
2 | name: Trigger on pull_request (plan) and push (apply) events with conditional job stages based on plan file.
3 |
4 | on:
5 | pull_request:
6 | push:
7 | branches: [main]
8 |
9 | permissions:
10 | actions: read # Required to identify workflow run.
11 | checks: write # Required to add status summary.
12 | contents: read # Required to checkout repository.
13 | pull-requests: write # Required to add comment and label.
14 |
15 | jobs:
16 | plan:
17 | if: github.event_name == 'pull_request'
18 | runs-on: ubuntu-latest
19 |
20 | steps:
21 | - name: Checkout repository
22 | uses: actions/checkout@v4
23 |
24 | - name: Setup TF
25 | uses: hashicorp/setup-terraform@v3
26 | with:
27 | terraform_wrapper: false
28 |
29 | - name: Plan TF
30 | uses: op5dev/tf-via-pr@v13
31 | with:
32 | working-directory: path/to/directory
33 | command: plan
34 | plan-encrypt: ${{ secrets.PASSPHRASE }}
35 |
36 | pre_apply:
37 | if: github.event_name == 'push'
38 | runs-on: ubuntu-latest
39 |
40 | steps:
41 | - name: Checkout repository
42 | uses: actions/checkout@v4
43 |
44 | - name: Setup TF
45 | uses: hashicorp/setup-terraform@v3
46 | with:
47 | terraform_wrapper: false
48 |
49 | - name: Init TF
50 | id: tf
51 | uses: op5dev/tf-via-pr@v13
52 | with:
53 | working-directory: path/to/directory
54 | command: init
55 | comment-pr: never
56 |
57 | - name: Check for diff
58 | id: check
59 | env:
60 | GH_TOKEN: ${{ github.token }}
61 | path: path/to/directory
62 | plan: ${{ steps.tf.outputs.identifier }}
63 | pass: ${{ secrets.PASSPHRASE }} # For use with "plan-encrypt".
64 | run: |
65 | echo "Download plan file artifact."
66 | artifact_id=$(gh api /repos/{owner}/{repo}/actions/artifacts --method GET --field "name=$plan" --jq '.artifacts[0].id')
67 | gh api /repos/{owner}/{repo}/actions/artifacts/${artifact_id}/zip --method GET > "$plan.zip"
68 | unzip "$plan.zip" -d "$path"
69 | cd "$path"
70 |
71 | echo "Optionally decrypt plan file."
72 | temp=$(mktemp)
73 | printf "%s" "$pass" > "$temp"
74 | openssl enc -aes-256-ctr -pbkdf2 -salt -in "tfplan" -out "tfplan.decrypted" -pass file:"$temp" -d
75 | mv "tfplan.decrypted" "tfplan"
76 |
77 | echo "Check if plan file has diff."
78 | diff_exists=$(tofu show "tfplan" | grep -q "^Plan:" && echo "true" || echo "false")
79 | echo "diff_exists=$diff_exists" >> $GITHUB_OUTPUT
80 |
81 | outputs:
82 | diff_exists: ${{ steps.check.outputs.diff_exists }}
83 |
84 | apply:
85 | needs: pre_apply
86 | if: ${{ needs.pre_apply.outputs.diff_exists == 'true' }}
87 | runs-on: ubuntu-latest
88 |
89 | steps:
90 | - name: Checkout repository
91 | uses: actions/checkout@v4
92 |
93 | - name: Setup TF
94 | uses: hashicorp/setup-terraform@v3
95 | with:
96 | terraform_wrapper: false
97 |
98 | - name: Apply TF
99 | uses: op5dev/tf-via-pr@v13
100 | with:
101 | working-directory: path/to/directory
102 | command: apply
103 | plan-encrypt: ${{ secrets.PASSPHRASE }}
104 |
--------------------------------------------------------------------------------
/.github/examples/schedule_refresh.yaml:
--------------------------------------------------------------------------------
1 | ---
2 | name: Trigger on schedule (cron) event with -refresh-only to open an issue on configuration drift.
3 |
4 | on:
5 | schedule:
6 | - cron: "0 */8 * * 1-5" # Every 8 hours on weekdays.
7 |
8 | jobs:
9 | tf:
10 | runs-on: ubuntu-latest
11 |
12 | permissions:
13 | actions: read # Required to identify workflow run.
14 | checks: write # Required to add status summary.
15 | contents: read # Required to checkout repository.
16 | issues: write # Required to open issue.
17 | pull-requests: write # Required to add comment and label.
18 |
19 | steps:
20 | - name: Checkout repository
21 | uses: actions/checkout@v4
22 |
23 | - name: Setup TF
24 | uses: hashicorp/setup-terraform@v3
25 | with:
26 | terraform_wrapper: false
27 |
28 | - name: Plan TF
29 | id: provision
30 | uses: op5dev/tf-via-pr@v13
31 | with:
32 | working-directory: path/to/directory
33 | command: plan
34 | arg-lock: false
35 | arg-refresh-only: true
36 | plan-encrypt: ${{ secrets.PASSPHRASE }}
37 |
38 | - name: Open issue on drift
39 | if: steps.provision.outputs.exitcode != 0
40 | env:
41 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
42 | diff: ${{ steps.provision.outputs.diff }}
43 | run: ${{ steps.provision.outputs.run-url }}
44 | result: ${{ steps.provision.outputs.result }}
45 | summary: ${{ steps.provision.outputs.summary }}
46 | run: |
47 | gh api /repos/{owner}/{repo}/issues \
48 | --method POST \
49 | --field title="Configuration drift detected" \
50 | --field body="[View log.]($run)
51 | Diff of changes.
52 |
53 | \`\`\`diff
54 | $diff
55 | \`\`\`
56 |
57 | $summary
58 |
59 | \`\`\`hcl
60 | $result
61 | \`\`\`
62 | "
63 |
--------------------------------------------------------------------------------
/.github/workflows/tag_release.yaml:
--------------------------------------------------------------------------------
1 | ---
2 | name: Tag Release
3 |
4 | on:
5 | release:
6 | types: [created]
7 |
8 | jobs:
9 | tag:
10 | runs-on: ubuntu-24.04
11 |
12 | permissions:
13 | contents: write # Required for tag operation.
14 |
15 | steps:
16 | - name: Checkout repository
17 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
18 | with:
19 | fetch-depth: 0 # Fetch tag history.
20 | persist-credentials: false
21 |
22 | - name: Tag release version
23 | env:
24 | GH_TOKEN: ${{ github.token }}
25 | GH_TAG: ${{ github.event.release.tag_name}}
26 | run: |
27 | version=${GH_TAG%%.*}
28 | gh api /repos/${{ github.repository }}/git/refs/tags/${version} --method PATCH --silent --field sha="${GITHUB_SHA}" --field force=true || \
29 | gh api /repos/${{ github.repository }}/git/refs --method POST --silent --field sha="${GITHUB_SHA}" --field ref="refs/tags/${version}"
30 |
--------------------------------------------------------------------------------
/.github/workflows/test_ci.yaml:
--------------------------------------------------------------------------------
1 | ---
2 | name: Test CI
3 |
4 | on:
5 | pull_request:
6 | paths: [action.yml, tests/**, .github/workflows/test_ci.yaml]
7 | types: [opened, reopened, synchronize, closed]
8 |
9 | jobs:
10 | ci:
11 | runs-on: ubuntu-24.04
12 |
13 | permissions:
14 | actions: read # Required to identify workflow run.
15 | checks: write # Required to add status summary.
16 | contents: read # Required to checkout repository.
17 | pull-requests: write # Required to add comment and label.
18 |
19 | strategy:
20 | fail-fast: false
21 | matrix:
22 | tool:
23 | - tofu
24 | - terraform
25 | test:
26 | - pass_one
27 | - pass_character_limit
28 | - fail_data_source_error
29 | - fail_format_diff
30 | - fail_invalid_resource_type
31 |
32 | steps:
33 | - name: Echo context
34 | env:
35 | GH_JSON: ${{ toJson(github) }}
36 | run: echo "$GH_JSON"
37 |
38 | - name: Checkout repository
39 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
40 | with:
41 | persist-credentials: false
42 |
43 | - name: Setup Tofu
44 | if: matrix.tool == 'tofu'
45 | uses: opentofu/setup-opentofu@12f4debbf681675350b6cd1f0ff8ecfbda62027b # v1.0.4
46 | with:
47 | tofu_version: latest
48 | tofu_wrapper: false
49 |
50 | - name: Setup Terraform
51 | if: matrix.tool == 'terraform'
52 | uses: hashicorp/setup-terraform@b9cd54a3c349d3f38e8881555d616ced269862dd # v3.1.2
53 | with:
54 | terraform_version: latest
55 | terraform_wrapper: false
56 |
57 | - name: Provision TF
58 | id: tf
59 | continue-on-error: true
60 | uses: ./
61 | with:
62 | working-directory: tests/${{ matrix.test }}
63 | command: ${{ github.event.pull_request.merged && 'apply' || 'plan' }}
64 | arg-lock: ${{ github.event.pull_request.merged }}
65 | arg-refresh: ${{ github.event.pull_request.merged && 'false' || 'true' }}
66 | arg-workspace: dev
67 |
68 | format: true
69 | validate: true
70 |
71 | plan-encrypt: ${{ secrets.TF_ENCRYPTION }}
72 | plan-parity: true
73 | preserve-plan: true
74 | retention-days: 1
75 |
76 | comment-pr: ${{ matrix.tool == 'tofu' && 'always' || 'never' }}
77 | expand-diff: true
78 | tag-actor: never
79 | tool: ${{ matrix.tool }}
80 |
81 | - name: Echo TF
82 | continue-on-error: true
83 | run: |
84 | echo "check-id: ${{ steps.tf.outputs.check-id }}"
85 | echo "command: ${{ steps.tf.outputs.command }}"
86 | echo "comment-body:"
87 | echo "comment-id: ${{ steps.tf.outputs.comment-id }}"
88 | echo "diff: ${{ steps.tf.outputs.diff }}"
89 | echo "exitcode: ${{ steps.tf.outputs.exitcode }}"
90 | echo "identifier: ${{ steps.tf.outputs.identifier }}"
91 | echo "job-id: ${{ steps.tf.outputs.job-id }}"
92 | echo "plan-id: ${{ steps.tf.outputs.plan-id }}"
93 | echo "plan-url: ${{ steps.tf.outputs.plan-url }}"
94 | echo "result: ${{ steps.tf.outputs.result }}"
95 | echo "run-url: ${{ steps.tf.outputs.run-url }}"
96 | echo "summary: ${{ steps.tf.outputs.summary }}"
97 | ${{ matrix.tool }} -chdir=tests/${{ matrix.test }} show -no-color tfplan
98 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | Apache License
2 | Version 2.0, January 2004
3 | http://www.apache.org/licenses/
4 |
5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6 |
7 | 1. Definitions.
8 |
9 | "License" shall mean the terms and conditions for use, reproduction,
10 | and distribution as defined by Sections 1 through 9 of this document.
11 |
12 | "Licensor" shall mean the copyright owner or entity authorized by
13 | the copyright owner that is granting the License.
14 |
15 | "Legal Entity" shall mean the union of the acting entity and all
16 | other entities that control, are controlled by, or are under common
17 | control with that entity. For the purposes of this definition,
18 | "control" means (i) the power, direct or indirect, to cause the
19 | direction or management of such entity, whether by contract or
20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the
21 | outstanding shares, or (iii) beneficial ownership of such entity.
22 |
23 | "You" (or "Your") shall mean an individual or Legal Entity
24 | exercising permissions granted by this License.
25 |
26 | "Source" form shall mean the preferred form for making modifications,
27 | including but not limited to software source code, documentation
28 | source, and configuration files.
29 |
30 | "Object" form shall mean any form resulting from mechanical
31 | transformation or translation of a Source form, including but
32 | not limited to compiled object code, generated documentation,
33 | and conversions to other media types.
34 |
35 | "Work" shall mean the work of authorship, whether in Source or
36 | Object form, made available under the License, as indicated by a
37 | copyright notice that is included in or attached to the work
38 | (an example is provided in the Appendix below).
39 |
40 | "Derivative Works" shall mean any work, whether in Source or Object
41 | form, that is based on (or derived from) the Work and for which the
42 | editorial revisions, annotations, elaborations, or other modifications
43 | represent, as a whole, an original work of authorship. For the purposes
44 | of this License, Derivative Works shall not include works that remain
45 | separable from, or merely link (or bind by name) to the interfaces of,
46 | the Work and Derivative Works thereof.
47 |
48 | "Contribution" shall mean any work of authorship, including
49 | the original version of the Work and any modifications or additions
50 | to that Work or Derivative Works thereof, that is intentionally
51 | submitted to Licensor for inclusion in the Work by the copyright owner
52 | or by an individual or Legal Entity authorized to submit on behalf of
53 | the copyright owner. For the purposes of this definition, "submitted"
54 | means any form of electronic, verbal, or written communication sent
55 | to the Licensor or its representatives, including but not limited to
56 | communication on electronic mailing lists, source code control systems,
57 | and issue tracking systems that are managed by, or on behalf of, the
58 | Licensor for the purpose of discussing and improving the Work, but
59 | excluding communication that is conspicuously marked or otherwise
60 | designated in writing by the copyright owner as "Not a Contribution."
61 |
62 | "Contributor" shall mean Licensor and any individual or Legal Entity
63 | on behalf of whom a Contribution has been received by Licensor and
64 | subsequently incorporated within the Work.
65 |
66 | 2. Grant of Copyright License. Subject to the terms and conditions of
67 | this License, each Contributor hereby grants to You a perpetual,
68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable
69 | copyright license to reproduce, prepare Derivative Works of,
70 | publicly display, publicly perform, sublicense, and distribute the
71 | Work and such Derivative Works in Source or Object form.
72 |
73 | 3. Grant of Patent License. Subject to the terms and conditions of
74 | this License, each Contributor hereby grants to You a perpetual,
75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable
76 | (except as stated in this section) patent license to make, have made,
77 | use, offer to sell, sell, import, and otherwise transfer the Work,
78 | where such license applies only to those patent claims licensable
79 | by such Contributor that are necessarily infringed by their
80 | Contribution(s) alone or by combination of their Contribution(s)
81 | with the Work to which such Contribution(s) was submitted. If You
82 | institute patent litigation against any entity (including a
83 | cross-claim or counterclaim in a lawsuit) alleging that the Work
84 | or a Contribution incorporated within the Work constitutes direct
85 | or contributory patent infringement, then any patent licenses
86 | granted to You under this License for that Work shall terminate
87 | as of the date such litigation is filed.
88 |
89 | 4. Redistribution. You may reproduce and distribute copies of the
90 | Work or Derivative Works thereof in any medium, with or without
91 | modifications, and in Source or Object form, provided that You
92 | meet the following conditions:
93 |
94 | (a) You must give any other recipients of the Work or
95 | Derivative Works a copy of this License; and
96 |
97 | (b) You must cause any modified files to carry prominent notices
98 | stating that You changed the files; and
99 |
100 | (c) You must retain, in the Source form of any Derivative Works
101 | that You distribute, all copyright, patent, trademark, and
102 | attribution notices from the Source form of the Work,
103 | excluding those notices that do not pertain to any part of
104 | the Derivative Works; and
105 |
106 | (d) If the Work includes a "NOTICE" text file as part of its
107 | distribution, then any Derivative Works that You distribute must
108 | include a readable copy of the attribution notices contained
109 | within such NOTICE file, excluding those notices that do not
110 | pertain to any part of the Derivative Works, in at least one
111 | of the following places: within a NOTICE text file distributed
112 | as part of the Derivative Works; within the Source form or
113 | documentation, if provided along with the Derivative Works; or,
114 | within a display generated by the Derivative Works, if and
115 | wherever such third-party notices normally appear. The contents
116 | of the NOTICE file are for informational purposes only and
117 | do not modify the License. You may add Your own attribution
118 | notices within Derivative Works that You distribute, alongside
119 | or as an addendum to the NOTICE text from the Work, provided
120 | that such additional attribution notices cannot be construed
121 | as modifying the License.
122 |
123 | You may add Your own copyright statement to Your modifications and
124 | may provide additional or different license terms and conditions
125 | for use, reproduction, or distribution of Your modifications, or
126 | for any such Derivative Works as a whole, provided Your use,
127 | reproduction, and distribution of the Work otherwise complies with
128 | the conditions stated in this License.
129 |
130 | 5. Submission of Contributions. Unless You explicitly state otherwise,
131 | any Contribution intentionally submitted for inclusion in the Work
132 | by You to the Licensor shall be under the terms and conditions of
133 | this License, without any additional terms or conditions.
134 | Notwithstanding the above, nothing herein shall supersede or modify
135 | the terms of any separate license agreement you may have executed
136 | with Licensor regarding such Contributions.
137 |
138 | 6. Trademarks. This License does not grant permission to use the trade
139 | names, trademarks, service marks, or product names of the Licensor,
140 | except as required for reasonable and customary use in describing the
141 | origin of the Work and reproducing the content of the NOTICE file.
142 |
143 | 7. Disclaimer of Warranty. Unless required by applicable law or
144 | agreed to in writing, Licensor provides the Work (and each
145 | Contributor provides its Contributions) on an "AS IS" BASIS,
146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147 | implied, including, without limitation, any warranties or conditions
148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149 | PARTICULAR PURPOSE. You are solely responsible for determining the
150 | appropriateness of using or redistributing the Work and assume any
151 | risks associated with Your exercise of permissions under this License.
152 |
153 | 8. Limitation of Liability. In no event and under no legal theory,
154 | whether in tort (including negligence), contract, or otherwise,
155 | unless required by applicable law (such as deliberate and grossly
156 | negligent acts) or agreed to in writing, shall any Contributor be
157 | liable to You for damages, including any direct, indirect, special,
158 | incidental, or consequential damages of any character arising as a
159 | result of this License or out of the use or inability to use the
160 | Work (including but not limited to damages for loss of goodwill,
161 | work stoppage, computer failure or malfunction, or any and all
162 | other commercial damages or losses), even if such Contributor
163 | has been advised of the possibility of such damages.
164 |
165 | 9. Accepting Warranty or Additional Liability. While redistributing
166 | the Work or Derivative Works thereof, You may choose to offer,
167 | and charge a fee for, acceptance of support, warranty, indemnity,
168 | or other liability obligations and/or rights consistent with this
169 | License. However, in accepting such obligations, You may act only
170 | on Your own behalf and on Your sole responsibility, not on behalf
171 | of any other Contributor, and only if You agree to indemnify,
172 | defend, and hold each Contributor harmless for any liability
173 | incurred by, or claims asserted against, such Contributor by reason
174 | of your accepting any such warranty or additional liability.
175 |
176 | END OF TERMS AND CONDITIONS
177 |
178 | Copyright 2016-present Rishav Dhar
179 |
180 | Licensed under the Apache License, Version 2.0 (the "License");
181 | you may not use this file except in compliance with the License.
182 | You may obtain a copy of the License at
183 |
184 | http://www.apache.org/licenses/LICENSE-2.0
185 |
186 | Unless required by applicable law or agreed to in writing, software
187 | distributed under the License is distributed on an "AS IS" BASIS,
188 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
189 | See the License for the specific language governing permissions and
190 | limitations under the License.
191 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | [](https://github.com/hashicorp/setup-terraform "Terraform Compatible.")
2 | [](https://github.com/opentofu/setup-opentofu "OpenTofu Compatible.")
3 | *
4 | [](LICENSE "Apache License 2.0.")
5 | [](https://github.com/op5dev/tf-via-pr/releases "View all releases.")
6 | *
7 | [](https://github.com/op5dev/tf-via-pr "Become a stargazer.")
8 |
9 | # Terraform/OpenTofu via Pull Request (TF-via-PR)
10 |
11 |
12 |
13 |
14 | What does it do?
15 | |
16 |
17 | Who is it for?
18 | |
19 |
20 |
21 |
22 |
23 | - Plan and apply changes with CLI arguments and encrypted plan file to avoid configuration drift.
24 | - Outline diff within up-to-date PR comment and matrix-friendly workflow summary, complete with log.
25 |
26 | |
27 |
28 |
29 | - DevOps and Platform engineers wanting to empower their teams to self-service scalably.
30 | - Maintainers looking to secure their pipeline without the overhead of containers or VMs.
31 |
32 | |
33 |
34 |
35 |
36 |
37 |
38 | ### View: [Usage Examples](#usage-examples) · [Inputs](#inputs) · [Outputs](#outputs) · [Security](#security) · [Changelog](#changelog) · [License](#license)
39 |
40 | [](https://raw.githubusercontent.com/op5dev/tf-via-pr/refs/heads/main/.github/assets/comment.png "View full-size image.")
41 |
42 |
43 |
44 | ## Usage Examples
45 |
46 | ### How to get started?
47 |
48 | ```yaml
49 | on:
50 | pull_request:
51 | push:
52 | branches: [main]
53 |
54 | jobs:
55 | provision:
56 | runs-on: ubuntu-latest
57 |
58 | permissions:
59 | actions: read # Required to identify workflow run.
60 | checks: write # Required to add status summary.
61 | contents: read # Required to checkout repository.
62 | pull-requests: write # Required to add comment and label.
63 |
64 | steps:
65 | - uses: actions/checkout@v4
66 |
67 | - uses: hashicorp/setup-terraform@v3
68 | with:
69 | terraform_wrapper: false
70 |
71 | # Run plan by default, or apply on merge.
72 | - uses: op5dev/tf-via-pr@v13
73 | with:
74 | working-directory: path/to/directory
75 | command: ${{ github.event_name == 'push' && 'apply' || 'plan' }}
76 | arg-lock: ${{ github.event_name == 'push' }}
77 | arg-backend-config: env/dev.tfbackend
78 | arg-var-file: env/dev.tfvars
79 | arg-workspace: dev-use1
80 | plan-encrypt: ${{ secrets.PASSPHRASE }}
81 | ```
82 |
83 | > [!TIP]
84 | >
85 | > - All supported arguments (e.g., `-backend-config`, `-destroy`, `-parallelism`, etc.) are [listed below](#arguments).
86 | > - Environment variables can be passed in for cloud platform authentication (e.g., [configure-aws-credentials](https://github.com/aws-actions/configure-aws-credentials "Configuring AWS credentials for use in GitHub Actions.") for short-lived credentials via OIDC).
87 | > - Recommend setting `terraform_wrapper`/`tofu_wrapper` to `false` in order to output the [detailed exit code](https://developer.hashicorp.com/terraform/cli/commands/plan#detailed-exitcode) for better error handling.
88 |
89 |
90 |
91 | ### Where to find more examples?
92 |
93 | The following workflows showcase common use cases, while a comprehensive list of inputs is [documented](#inputs) below.
94 |
95 |
96 |
97 |
98 |
99 | Runs on pull_request (plan) and push (apply) events with Terraform, AWS authentication and cache.
100 |
101 | |
102 |
103 |
104 | Runs on pull_request (plan) and merge_group (apply) events with OpenTofu in matrix strategy.
105 |
106 | |
107 |
108 |
109 |
110 |
111 | Runs on pull_request (plan) and push (apply) events with fmt/validate checks and TFLint.
112 |
113 | |
114 |
115 |
116 | Runs on pull_request (plan) and push (apply) events with conditional jobs based on plan file.
117 |
118 | |
119 |
120 |
121 |
122 |
123 | Runs on labeled and workflow_dispatch manual events on GitHub Enterprise (GHE) self-hosted runner.
124 |
125 | |
126 |
127 |
128 | Runs on schedule cron event with -refresh-only to open an issue on configuration drift.
129 |
130 | |
131 |
132 |
133 |
134 |
135 |
136 | ### How does encryption work?
137 |
138 | Before the workflow uploads the plan file as an artifact, it can be encrypted-at-rest with a passphrase using `plan-encrypt` input to prevent exposure of sensitive data (e.g., `${{ secrets.PASSPHRASE }}`). This is done with [OpenSSL](https://docs.openssl.org/master/man1/openssl-enc/ "OpenSSL encryption documentation.")'s symmetric stream counter mode ([256 bit AES in CTR](https://docs.openssl.org/3.3/man1/openssl-enc/#supported-ciphers:~:text=192/256%20bit-,AES%20in%20CTR%20mode,-aes%2D%5B128%7C192)) encryption with salt and pbkdf2.
139 |
140 | In order to decrypt the plan file locally, use the following commands after downloading the artifact (adding a whitespace before `openssl` to prevent recording the command in shell history):
141 |
142 | ```fish
143 | unzip
144 | openssl enc -d -aes-256-ctr -pbkdf2 -salt \
145 | -in tfplan.encrypted \
146 | -out tfplan.decrypted \
147 | -pass pass:""
148 | show tfplan.decrypted
149 | ```
150 |
151 |
152 |
153 | ## Inputs
154 |
155 | All supported CLI argument inputs are [listed below](#arguments) with accompanying options, while workflow configuration inputs are listed here.
156 |
157 | ### Configuration
158 |
159 | | Type | Name | Description |
160 | | -------- | ------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
161 | | CLI | `working-directory` | Specify the working directory of TF code, alias of `arg-chdir`.Example: `path/to/directory` |
162 | | CLI | `command` | Command to run between: `plan` or `apply`.1Example: `plan` |
163 | | CLI | `tool` | Provisioning tool to use between: `terraform` or `tofu`.Default: `terraform` |
164 | | CLI | `plan-file` | Supply existing plan file path instead of the auto-generated one.Example: `path/to/file.tfplan` |
165 | | Check | `format` | Check format of TF code.Default: `false` |
166 | | Check | `validate` | Check validation of TF code.Default: `false` |
167 | | Check | `plan-parity` | Replace plan file if it matches a newly-generated one to prevent stale apply.2Default: `false` |
168 | | Security | `plan-encrypt` | Encrypt plan file artifact with the given input.3Example: `${{ secrets.PASSPHRASE }}` |
169 | | Security | `preserve-plan` | Preserve plan file "tfplan" in the given working directory after workflow execution.Default: `false` |
170 | | Security | `upload-plan` | Upload plan file as GitHub workflow artifact.Default: `true` |
171 | | Security | `retention-days` | Duration after which plan file artifact will expire in days.Example: `90` |
172 | | Security | `token` | Specify a GitHub token.Default: `${{ github.token }}` |
173 | | UI | `expand-diff` | Expand the collapsible diff section.Default: `false` |
174 | | UI | `expand-summary` | Expand the collapsible summary section.Default: `false` |
175 | | UI | `label-pr` | Add a PR label with the command input (e.g., `tf:plan`).Default: `true` |
176 | | UI | `comment-pr` | Add a PR comment: `always`, `on-change`, or `never`.4Default: `always` |
177 | | UI | `comment-method` | PR comment by: `update` existing comment or `recreate` and delete previous one.5Default: `update` |
178 | | UI | `tag-actor` | Tag the workflow triggering actor: `always`, `on-change`, or `never`.4Default: `always` |
179 | | UI | `hide-args` | Hide comma-separated list of CLI arguments from the command input.6Default: `detailed-exitcode,parallelism,lock,out,var=` |
180 | | UI | `show-args` | Show comma-separated list of CLI arguments in the command input.6Default: `workspace` |
181 |
182 |
183 |
184 | 1. Both `command: plan` and `command: apply` include: `init`, `fmt` (with `format: true`), `validate` (with `validate: true`), and `workspace` (with `arg-workspace`) commands rolled into it automatically.
185 | To separately run checks and/or generate outputs only, `command: init` can be used.
186 | 1. Originally intended for `merge_group` event trigger, `plan-parity: true` input helps to prevent stale apply within a series of workflow runs when merging multiple PRs.
187 | 1. The secret string input for `plan-encrypt` can be of any length, as long as it's consistent between encryption (plan) and decryption (apply).
188 | 1. The `on-change` option is true when the exit code of the last TF command is non-zero (ensure `terraform_wrapper`/`tofu_wrapper` is set to `false`).
189 | 1. The default behavior of `comment-method` is to update the existing PR comment with the latest plan/apply output, making it easy to track changes over time through the comment's revision history.
190 | [](https://raw.githubusercontent.com/op5dev/tf-via-pr/refs/heads/main/.github/assets/revisions.png "View full-size image.")
191 | 1. It can be desirable to hide certain arguments from the last run command input to prevent exposure in the PR comment (e.g., sensitive `arg-var` values). Conversely, it can be desirable to show other arguments even if they are not in last run command input (e.g., `arg-workspace` or `arg-backend-config` selection).
192 |
193 |
194 |
195 | ### Arguments
196 |
197 | > [!NOTE]
198 | >
199 | > - Arguments are passed to the appropriate TF command(s) automatically, whether that's `fmt`, `init`, `validate`, `plan`, or `apply`.
200 | > - For repeated arguments like `arg-var`, `arg-var-file`, `arg-backend-config`, `arg-replace` and `arg-target`, use commas to separate multiple values (e.g., `arg-var: key1=value1,key2=value2`).
201 |
202 |
203 |
204 | Applicable to respective "plan" and "apply" `command` inputs ("init" included).
205 |
206 | | Name | CLI Argument |
207 | | ------------------------- | ---------------------------------------- |
208 | | `arg-auto-approve` | `-auto-approve` |
209 | | `arg-backend-config` | `-backend-config` |
210 | | `arg-backend` | `-backend` |
211 | | `arg-backup` | `-backup` |
212 | | `arg-chdir` | `-chdir`Alias: `working-directory` |
213 | | `arg-compact-warnings` | `-compact-warnings` |
214 | | `arg-concise` | `-concise` |
215 | | `arg-destroy` | `-destroy` |
216 | | `arg-detailed-exitcode` | `-detailed-exitcode`Default: `true` |
217 | | `arg-force-copy` | `-force-copy` |
218 | | `arg-from-module` | `-from-module` |
219 | | `arg-generate-config-out` | `-generate-config-out` |
220 | | `arg-get` | `-get` |
221 | | `arg-lock-timeout` | `-lock-timeout` |
222 | | `arg-lock` | `-lock` |
223 | | `arg-lockfile` | `-lockfile` |
224 | | `arg-migrate-state` | `-migrate-state` |
225 | | `arg-parallelism` | `-parallelism` |
226 | | `arg-plugin-dir` | `-plugin-dir` |
227 | | `arg-reconfigure` | `-reconfigure` |
228 | | `arg-refresh-only` | `-refresh-only` |
229 | | `arg-refresh` | `-refresh` |
230 | | `arg-replace` | `-replace` |
231 | | `arg-state-out` | `-state-out` |
232 | | `arg-state` | `-state` |
233 | | `arg-target` | `-target` |
234 | | `arg-upgrade` | `-upgrade` |
235 | | `arg-var-file` | `-var-file` |
236 | | `arg-var` | `-var` |
237 | | `arg-workspace` | `-workspace`Alias: `TF_WORKSPACE` |
238 |
239 |
240 |
241 | Applicable only when `format: true`.
242 |
243 | | Name | CLI Argument |
244 | | --------------- | -------------------------------- |
245 | | `arg-check` | `-check`Default: `true` |
246 | | `arg-diff` | `-diff`Default: `true` |
247 | | `arg-list` | `-list` |
248 | | `arg-recursive` | `-recursive`Default: `true` |
249 | | `arg-write` | `-write` |
250 |
251 |
252 |
253 | Applicable only when `validate: true`.
254 |
255 | | Name | CLI Argument |
256 | | -------------------- | ----------------- |
257 | | `arg-no-tests` | `-no-tests` |
258 | | `arg-test-directory` | `-test-directory` |
259 |
260 |
261 |
262 | ## Outputs
263 |
264 | | Type | Name | Description |
265 | | -------- | -------------- | --------------------------------------------- |
266 | | Artifact | `plan-id` | ID of the plan file artifact. |
267 | | Artifact | `plan-url` | URL of the plan file artifact. |
268 | | CLI | `command` | Input of the last TF command. |
269 | | CLI | `diff` | Diff of changes, if present (truncated). |
270 | | CLI | `exitcode` | Exit code of the last TF command. |
271 | | CLI | `result` | Result of the last TF command (truncated). |
272 | | CLI | `summary` | Summary of the last TF command. |
273 | | Workflow | `check-id` | ID of the check run. |
274 | | Workflow | `comment-body` | Body of the PR comment. |
275 | | Workflow | `comment-id` | ID of the PR comment. |
276 | | Workflow | `job-id` | ID of the workflow job. |
277 | | Workflow | `run-url` | URL of the workflow run. |
278 | | Workflow | `identifier` | Unique name of the workflow run and artifact. |
279 |
280 |
281 |
282 | ## Security
283 |
284 | View [security policy and reporting instructions](SECURITY.md).
285 |
286 | > [!TIP]
287 | >
288 | > Pin your workflow version to a specific release tag or SHA to harden your CI/CD pipeline security against supply chain attacks.
289 |
290 |
291 |
292 | ## Changelog
293 |
294 | View [all notable changes](https://github.com/op5dev/tf-via-pr/releases "Releases.") to this project in [Keep a Changelog](https://keepachangelog.com "Keep a Changelog.") format, which adheres to [Semantic Versioning](https://semver.org "Semantic Versioning.").
295 |
296 | > [!TIP]
297 | >
298 | > All forms of **contribution are very welcome** and deeply appreciated for fostering open-source projects.
299 | >
300 | > - [Create a PR](https://github.com/op5dev/tf-via-pr/pulls "Create a pull request.") to contribute changes you'd like to see.
301 | > - [Raise an issue](https://github.com/op5dev/tf-via-pr/issues "Raise an issue.") to propose changes or report unexpected behavior.
302 | > - [Open a discussion](https://github.com/op5dev/tf-via-pr/discussions "Open a discussion.") to discuss broader topics or questions.
303 | > - [Become a stargazer](https://github.com/op5dev/tf-via-pr/stargazers "Become a stargazer.") if you find this project useful.
304 |
305 |
306 |
307 | ### To-Do
308 |
309 | - Handling of inputs which contain space(s) (e.g., `working-directory: path to/directory`).
310 | - Handling of comma-separated inputs which contain comma(s) (e.g., `arg-var: token=1,2,3`)—use `TF_CLI_ARGS` [workaround](https://developer.hashicorp.com/terraform/cli/config/environment-variables#tf_cli_args-and-tf_cli_args_name).
311 |
312 |
313 |
314 | ## License
315 |
316 | - This project is licensed under the permissive [Apache License 2.0](LICENSE "Apache License 2.0.").
317 | - All works herein are my own, shared of my own volition, and [contributors](https://github.com/op5dev/tf-via-pr/graphs/contributors "Contributors.").
318 | - Copyright 2016-present [Rishav Dhar](https://github.com/rdhar "Rishav Dhar's GitHub profile.") — All wrongs reserved.
319 |
--------------------------------------------------------------------------------
/SECURITY.md:
--------------------------------------------------------------------------------
1 | # Security Policy
2 |
3 | Integrating security in your CI/CD pipeline is critical to practicing DevSecOps. This action aims to be secure by default, and it should be complemented with your own review to ensure it meets your (organization's) security requirements.
4 |
5 | - Action dependency is maintained by GitHub and [pinned to a specific SHA](https://docs.github.com/en/actions/security-guides/security-hardening-for-github-actions#using-third-party-actions "Security hardening for GitHub Actions.").
6 | - Restrict changes to certain environments with [deployment protection rules](https://docs.github.com/en/actions/deployment/targeting-different-environments/using-environments-for-deployment#deployment-protection-rules "Configuring environment deployment protection rules.").
7 | - Integrate with [OpenID Connect](https://docs.github.com/en/actions/deployment/security-hardening-your-deployments/configuring-openid-connect-in-cloud-providers "Configuring OpenID Connect in cloud providers.") by passing short-lived credentials as environment variables.
8 |
9 | ## Supported Versions
10 |
11 | | Version | Supported |
12 | | :-----: | :-------: |
13 | | v13.X | Yes |
14 | | ≤ v12.X | No |
15 |
16 | ## Reporting a Vulnerability
17 |
18 | You must never report security related issues, vulnerabilities or bugs including sensitive information to the issue tracker, or elsewhere in public. Instead, sensitive bugs must be sent by email to or reported via [Security Advisory](https://github.com/op5dev/tf-via-pr/security/advisories/new "Create a new security advisory.").
19 |
--------------------------------------------------------------------------------
/action.yml:
--------------------------------------------------------------------------------
1 | ---
2 | name: Terraform/OpenTofu via Pull Request
3 | author: Rishav Dhar (@rdhar)
4 | description: Plan and apply Terraform/OpenTofu via PR automation, using best practices for secure and scalable IaC workflows.
5 |
6 | runs:
7 | using: composite
8 | steps:
9 | - shell: bash
10 | run: |
11 | # Check for required tools.
12 | which gh > /dev/null 2>&1 || { echo "Please install GitHub CLI before running this action as it is required for interacting with GitHub."; exit 1; }
13 | which jq > /dev/null 2>&1 || { echo "Please install jq before running this action as it is required for processing JSON outputs."; exit 1; }
14 | which md5sum > /dev/null 2>&1 || { echo "Please install md5sum before running this action as it is required for naming the plan file artifact uniquely."; exit 1; }
15 | which unzip > /dev/null 2>&1 || { echo "Please install unzip before running this action as it is required for unpacking the plan file artifact."; exit 1; }
16 | which ${{ inputs.tool }} > /dev/null 2>&1 || { echo "Please install ${{ inputs.tool }} before running this action as it is required for provisioning TF code."; exit 1; }
17 | if [[ "${{ inputs.plan-encrypt }}" ]]; then which openssl > /dev/null 2>&1 || { echo "Please install openssl before running this action as it is required for plan file encryption."; exit 1; }; fi
18 | if [[ "${{ inputs.plan-parity }}" ]]; then which diff > /dev/null 2>&1 || { echo "Please install diff before running this action as it is required for comparing plan file parity."; exit 1; }; fi
19 |
20 | - id: arg
21 | env:
22 | TF_WORKSPACE: ${{ env.TF_WORKSPACE || inputs.arg-workspace }}
23 | shell: bash
24 | run: |
25 | # Populate variables.
26 | # Environment variables.
27 | echo GH_API="X-GitHub-Api-Version:2022-11-28" >> "$GITHUB_ENV"
28 | echo GH_TOKEN="${{ inputs.token }}" >> "$GITHUB_ENV"
29 | echo TF_CLI_ARGS=$([[ -n "${{ env.TF_CLI_ARGS }}" ]] && echo "${{ env.TF_CLI_ARGS }} -no-color" || echo "-no-color") >> "$GITHUB_ENV"
30 | echo TF_IN_AUTOMATION="true" >> "$GITHUB_ENV"
31 | echo TF_INPUT="false" >> "$GITHUB_ENV"
32 | echo TF_WORKSPACE="$TF_WORKSPACE" >> "$GITHUB_ENV"
33 | if [[ "$GITHUB_SERVER_URL" != "https://github.com" ]]; then echo GH_HOST=$(echo "$GITHUB_SERVER_URL" | sed 's/.*:\/\///') >> "$GITHUB_ENV"; fi
34 |
35 | # CLI arguments.
36 | echo arg-auto-approve=$([[ -n "${{ inputs.arg-auto-approve }}" ]] && echo " -auto-approve" || echo "") >> "$GITHUB_OUTPUT"
37 | echo arg-backend-config=$([[ -n "${{ inputs.arg-backend-config }}" ]] && echo " -backend-config=${{ inputs.arg-backend-config }}" | sed "s/,/ -backend-config=/g" || echo "") >> "$GITHUB_OUTPUT"
38 | echo arg-backend=$([[ -n "${{ inputs.arg-backend }}" ]] && echo " -backend=${{ inputs.arg-backend }}" || echo "") >> "$GITHUB_OUTPUT"
39 | echo arg-backup=$([[ -n "${{ inputs.arg-backup }}" ]] && echo " -backup=${{ inputs.arg-backup }}" || echo "") >> "$GITHUB_OUTPUT"
40 | echo arg-chdir=$([[ -n "${{ inputs.arg-chdir || inputs.working-directory }}" ]] && echo " -chdir=${{ inputs.arg-chdir || inputs.working-directory }}" || echo "") >> "$GITHUB_OUTPUT"
41 | echo arg-check=$([[ -n "${{ inputs.arg-check }}" ]] && echo " -check" || echo "") >> "$GITHUB_OUTPUT"
42 | echo arg-compact-warnings=$([[ -n "${{ inputs.arg-compact-warnings }}" ]] && echo " -compact-warnings" || echo "") >> "$GITHUB_OUTPUT"
43 | echo arg-concise=$([[ -n "${{ inputs.arg-concise }}" ]] && echo " -concise" || echo "") >> "$GITHUB_OUTPUT"
44 | echo arg-destroy=$([[ -n "${{ inputs.arg-destroy }}" ]] && echo " -destroy" || echo "") >> "$GITHUB_OUTPUT"
45 | echo arg-detailed-exitcode=$([[ -n "${{ inputs.arg-detailed-exitcode }}" ]] && echo " -detailed-exitcode" || echo "") >> "$GITHUB_OUTPUT"
46 | echo arg-diff=$([[ -n "${{ inputs.arg-diff }}" ]] && echo " -diff" || echo "") >> "$GITHUB_OUTPUT"
47 | echo arg-force-copy=$([[ -n "${{ inputs.arg-force-copy }}" ]] && echo " -force-copy" || echo "") >> "$GITHUB_OUTPUT"
48 | echo arg-from-module=$([[ -n "${{ inputs.arg-from-module }}" ]] && echo " -from-module=${{ inputs.arg-from-module }}" || echo "") >> "$GITHUB_OUTPUT"
49 | echo arg-generate-config-out=$([[ -n "${{ inputs.arg-generate-config-out }}" ]] && echo " -generate-config-out=${{ inputs.arg-generate-config-out }}" || echo "") >> "$GITHUB_OUTPUT"
50 | echo arg-get=$([[ -n "${{ inputs.arg-get }}" ]] && echo " -get=${{ inputs.arg-get }}" || echo "") >> "$GITHUB_OUTPUT"
51 | echo arg-list=$([[ -n "${{ inputs.arg-list }}" ]] && echo " -list=${{ inputs.arg-list }}" || echo "") >> "$GITHUB_OUTPUT"
52 | echo arg-lock-timeout=$([[ -n "${{ inputs.arg-lock-timeout }}" ]] && echo " -lock-timeout=${{ inputs.arg-lock-timeout }}" || echo "") >> "$GITHUB_OUTPUT"
53 | echo arg-lock=$([[ -n "${{ inputs.arg-lock }}" ]] && echo " -lock=${{ inputs.arg-lock }}" || echo "") >> "$GITHUB_OUTPUT"
54 | echo arg-lockfile=$([[ -n "${{ inputs.arg-lockfile }}" ]] && echo " -lockfile=${{ inputs.arg-lockfile }}" || echo "") >> "$GITHUB_OUTPUT"
55 | echo arg-migrate-state=$([[ -n "${{ inputs.arg-migrate-state }}" ]] && echo " -migrate-state" || echo "") >> "$GITHUB_OUTPUT"
56 | echo arg-no-tests=$([[ -n "${{ inputs.arg-no-tests }}" ]] && echo " -no-tests" || echo "") >> "$GITHUB_OUTPUT"
57 | echo arg-parallelism=$([[ -n "${{ inputs.arg-parallelism }}" ]] && echo " -parallelism=${{ inputs.arg-parallelism }}" || echo "") >> "$GITHUB_OUTPUT"
58 | echo arg-plugin-dir=$([[ -n "${{ inputs.arg-plugin-dir }}" ]] && echo " -plugin-dir=${{ inputs.arg-plugin-dir }}" || echo "") >> "$GITHUB_OUTPUT"
59 | echo arg-reconfigure=$([[ -n "${{ inputs.arg-reconfigure }}" ]] && echo " -reconfigure" || echo "") >> "$GITHUB_OUTPUT"
60 | echo arg-recursive=$([[ -n "${{ inputs.arg-recursive }}" ]] && echo " -recursive" || echo "") >> "$GITHUB_OUTPUT"
61 | echo arg-refresh-only=$([[ -n "${{ inputs.arg-refresh-only }}" ]] && echo " -refresh-only" || echo "") >> "$GITHUB_OUTPUT"
62 | echo arg-refresh=$([[ -n "${{ inputs.arg-refresh }}" ]] && echo " -refresh=${{ inputs.arg-refresh }}" || echo "") >> "$GITHUB_OUTPUT"
63 | echo arg-replace=$([[ -n "${{ inputs.arg-replace }}" ]] && echo " -replace=${{ inputs.arg-replace }}" | sed "s/,/ -replace=/g" || echo "") >> "$GITHUB_OUTPUT"
64 | echo arg-state-out=$([[ -n "${{ inputs.arg-state-out }}" ]] && echo " -state-out=${{ inputs.arg-state-out }}" || echo "") >> "$GITHUB_OUTPUT"
65 | echo arg-state=$([[ -n "${{ inputs.arg-state }}" ]] && echo " -state=${{ inputs.arg-state }}" || echo "") >> "$GITHUB_OUTPUT"
66 | echo arg-target=$([[ -n "${{ inputs.arg-target }}" ]] && echo " -target=${{ inputs.arg-target }}" | sed "s/,/ -target=/g" || echo "") >> "$GITHUB_OUTPUT"
67 | echo arg-test-directory=$([[ -n "${{ inputs.arg-test-directory }}" ]] && echo " -test-directory=${{ inputs.arg-test-directory }}" || echo "") >> "$GITHUB_OUTPUT"
68 | echo arg-upgrade=$([[ -n "${{ inputs.arg-upgrade }}" ]] && echo " -upgrade" || echo "") >> "$GITHUB_OUTPUT"
69 | echo arg-var-file=$([[ -n "${{ inputs.arg-var-file }}" ]] && echo " -var-file=${{ inputs.arg-var-file }}" | sed "s/,/ -var-file=/g" || echo "") >> "$GITHUB_OUTPUT"
70 | echo arg-var=$([[ -n "${{ inputs.arg-var }}" ]] && echo " -var=${{ inputs.arg-var }}" | sed "s/,/ -var=/g" || echo "") >> "$GITHUB_OUTPUT"
71 | echo arg-write=$([[ -n "${{ inputs.arg-write }}" ]] && echo " -write=${{ inputs.arg-write }}" || echo "") >> "$GITHUB_OUTPUT"
72 | echo arg-workspace=$([[ -n "$TF_WORKSPACE" ]] && echo " -workspace=$TF_WORKSPACE" || echo "") >> "$GITHUB_OUTPUT"
73 |
74 | - id: identifier
75 | env:
76 | GH_MATRIX: ${{ toJSON(matrix) }}
77 | shell: bash
78 | run: |
79 | # Unique identifier.
80 | # Get PR number using GitHub API for different event triggers.
81 | if [[ "$GITHUB_EVENT_NAME" == "push" ]]; then
82 | # List PRs associated with the commit, then get the PR number from the head ref or the latest PR.
83 | associated_prs=$(gh api /repos/${{ github.repository }}/commits/${{ github.event.pull_request.head.sha || github.sha }}/pulls --header "$GH_API" --method GET --field per_page=100)
84 | pr_number=$(echo "$associated_prs" | jq --raw-output '(.[] | select(.head.ref == env.GITHUB_REF_NAME) | .number) // .[0].number // 0')
85 | elif [[ "$GITHUB_EVENT_NAME" == "merge_group" ]]; then
86 | # Get the PR number by parsing the ref name.
87 | pr_number=$(echo "${{ github.ref_name }}" | sed -n 's/.*pr-\([0-9]*\)-.*/\1/p')
88 | else
89 | # Get the PR number from branch name, otherwise fallback on 0 if the PR number is not found.
90 | pr_number=${{ github.event.number || github.event.issue.number }} || $(gh api /repos/${{ github.repository }}/pulls --header "$GH_API" --method GET --field per_page=100 --field head="${{ github.ref_name || github.head_ref || github.ref || '0' }}" | jq '.[0].number // 0')
91 | fi
92 | echo "pr=$pr_number" >> "$GITHUB_OUTPUT"
93 |
94 | # Generate identifier for the workflow run using MD5 hashing algorithm for concise and unique naming.
95 | identifier="${{ steps.arg.outputs.arg-chdir }}${{ steps.arg.outputs.arg-workspace }}${{ steps.arg.outputs.arg-backend-config }}${{ steps.arg.outputs.arg-var-file }}${{ steps.arg.outputs.arg-var }}${{ steps.arg.outputs.arg-replace }}${{ steps.arg.outputs.arg-target }}${{ steps.arg.outputs.arg-destroy }}"
96 | identifier=$(echo -n "$identifier" | md5sum | awk '{print $1}')
97 | echo "name=${{ inputs.tool }}-${pr_number}-${identifier}.tfplan" >> "$GITHUB_OUTPUT"
98 |
99 | # List jobs from the current workflow run.
100 | workflow_run=$(gh api /repos/${{ github.repository }}/actions/runs/${{ github.run_id }}/attempts/${{ github.run_attempt }}/jobs --header "$GH_API" --method GET --field per_page=100)
101 |
102 | # Get the current job ID from the workflow run using different query methods for matrix and regular jobs.
103 | if [[ "$GH_MATRIX" == "null" ]]; then
104 | # For regular jobs, get the ID of the job with the same name as job_id (lowercase and '-' or '_' replaced with ' ').
105 | # Otherwise, get the ID of the first job in the list as a fallback.
106 | job_id=$(echo "$workflow_run" | jq --raw-output '(.jobs[] | select((.name | ascii_downcase | gsub("-|_"; " ")) == (env.GITHUB_JOB | ascii_downcase | gsub("-|_"; " "))) | .id) // .jobs[0].id' | tail -n 1)
107 | else
108 | # For matrix jobs, join the matrix values with comma separator into a single string and get the ID of the job which contains it.
109 | matrix=$(echo "$GH_MATRIX" | jq --raw-output 'to_entries | map(if .value | type == "object" then (.value | to_entries[0].value) else .value end) | join(", ")')
110 | job_id=$(echo "$workflow_run" | jq --raw-output --arg matrix "$matrix" '.jobs[] | select(.name | contains($matrix)) | .id' | tail -n 1)
111 | # For dynamic matrix jobs, retry with exponential backoff until the job ID is found or a timeout occurs.
112 | retry_interval=1
113 | while [[ -z "$job_id" ]]; do
114 | if [[ $retry_interval -gt 64 ]]; then
115 | echo "Unable to locate job ID for matrix: $matrix."
116 | exit 1
117 | fi
118 | echo "Waiting to locate job ID; will try again in $retry_interval seconds."
119 | sleep "$retry_interval"
120 | retry_interval=$((retry_interval * 2))
121 | workflow_run=$(gh api /repos/${{ github.repository }}/actions/runs/${{ github.run_id }}/attempts/${{ github.run_attempt }}/jobs --header "$GH_API" --method GET --field per_page=100)
122 | job_id=$(echo "$workflow_run" | jq --raw-output --arg matrix "$matrix" '.jobs[] | select(.name | contains($matrix)) | .id' | tail -n 1)
123 | done
124 | fi
125 | echo "job=$job_id" >> "$GITHUB_OUTPUT"
126 |
127 | # Get the step number that has status "in_progress" from the current job.
128 | workflow_step=$(echo "$workflow_run" | jq --raw-output --arg job_id "$job_id" '.jobs[] | select(.id == ($job_id | tonumber)) | .steps[] | select(.status == "in_progress") | .number')
129 | echo "step=$workflow_step" >> "$GITHUB_OUTPUT"
130 |
131 | - if: ${{ inputs.format == 'true' }}
132 | id: format
133 | shell: bash
134 | run: |
135 | # TF format.
136 | trap 'exit_code="$?"; echo "exit_code=$exit_code" >> "$GITHUB_OUTPUT"' EXIT
137 | args="${{ steps.arg.outputs.arg-check }}${{ steps.arg.outputs.arg-diff }}${{ steps.arg.outputs.arg-list }}${{ steps.arg.outputs.arg-recursive }}${{ steps.arg.outputs.arg-write }}"
138 | echo "${{ inputs.tool }} fmt${{ steps.arg.outputs.arg-chdir }}${args}" | sed 's/ -/\n -/g' > tf.command.txt
139 | ${{ inputs.tool }}${{ steps.arg.outputs.arg-chdir }} fmt${args} 2> >(tee tf.console.txt) > >(tee tf.console.txt)
140 |
141 | - if: ${{ contains(fromJSON('["plan", "apply", "init"]'), inputs.command) }}
142 | id: initialize
143 | shell: bash
144 | run: |
145 | # TF initialize.
146 | trap 'exit_code="$?"; echo "exit_code=$exit_code" >> "$GITHUB_OUTPUT"' EXIT
147 | args="${{ steps.arg.outputs.arg-backend-config }}${{ steps.arg.outputs.arg-backend }}${{ inputs.tool == 'tofu' && steps.arg.outputs.arg-var-file || '' }}${{ inputs.tool == 'tofu' && steps.arg.outputs.arg-var || '' }}${{ steps.arg.outputs.arg-force-copy }}${{ steps.arg.outputs.arg-from-module }}${{ steps.arg.outputs.arg-get }}${{ steps.arg.outputs.arg-lock-timeout }}${{ steps.arg.outputs.arg-lock }}${{ steps.arg.outputs.arg-lockfile }}${{ steps.arg.outputs.arg-migrate-state }}${{ steps.arg.outputs.arg-plugin-dir }}${{ steps.arg.outputs.arg-reconfigure }}${{ steps.arg.outputs.arg-test-directory }}${{ steps.arg.outputs.arg-upgrade }}"
148 | echo "${{ inputs.tool }} init${{ steps.arg.outputs.arg-chdir }}${args}" | sed 's/ -/\n -/g' > tf.command.txt
149 | ${{ inputs.tool }}${{ steps.arg.outputs.arg-chdir }} init${args} 2> >(tee tf.console.txt) > >(tee tf.console.txt)
150 |
151 | - if: ${{ inputs.validate == 'true' && contains(fromJSON('["plan", "apply", "init"]'), inputs.command) }}
152 | id: validate
153 | shell: bash
154 | run: |
155 | # TF validate.
156 | trap 'exit_code="$?"; echo "exit_code=$exit_code" >> "$GITHUB_OUTPUT"' EXIT
157 | args="${{ inputs.tool == 'tofu' && steps.arg.outputs.arg-var-file || '' }}${{ inputs.tool == 'tofu' && steps.arg.outputs.arg-var || '' }}${{ steps.arg.outputs.arg-no-tests }}${{ steps.arg.outputs.arg-test-directory }}"
158 | echo "${{ inputs.tool }} validate${{ steps.arg.outputs.arg-chdir }}${args}" | sed 's/ -/\n -/g' > tf.command.txt
159 | ${{ inputs.tool }}${{ steps.arg.outputs.arg-chdir }} validate${args} 2> >(tee tf.console.txt) > >(tee tf.console.txt)
160 |
161 | - if: ${{ inputs.label-pr == 'true' && steps.identifier.outputs.pr != 0 && contains(fromJSON('["plan", "apply"]'), inputs.command) }}
162 | continue-on-error: true
163 | shell: bash
164 | run: |
165 | # Label PR.
166 | gh api /repos/${{ github.repository }}/issues/${{ steps.identifier.outputs.pr }}/labels --header "$GH_API" --method POST --silent --field "labels[]=tf:${{ inputs.command }}"
167 |
168 | - if: ${{ inputs.command == 'plan' }}
169 | id: plan
170 | env:
171 | PLAN_FILE: ${{ inputs.plan-file }}
172 | path: ${{ format('{0}{1}tfplan', inputs.arg-chdir || inputs.working-directory, (inputs.arg-chdir || inputs.working-directory) && '/' || '') }}
173 | shell: bash
174 | run: |
175 | # TF plan.
176 | trap 'exit_code="$?"; echo "exit_code=$exit_code" >> "$GITHUB_OUTPUT"; if [[ "$exit_code" == "2" ]]; then exit 0; fi' EXIT
177 | args="${{ steps.arg.outputs.arg-destroy }}${{ steps.arg.outputs.arg-var-file }}${{ steps.arg.outputs.arg-var }}${{ steps.arg.outputs.arg-compact-warnings }}${{ steps.arg.outputs.arg-concise }}${{ steps.arg.outputs.arg-detailed-exitcode }}${{ steps.arg.outputs.arg-generate-config-out }}${{ steps.arg.outputs.arg-lock-timeout }}${{ steps.arg.outputs.arg-lock }}${{ steps.arg.outputs.arg-parallelism }}${{ steps.arg.outputs.arg-refresh-only }}${{ steps.arg.outputs.arg-refresh }}${{ steps.arg.outputs.arg-replace }}${{ steps.arg.outputs.arg-target }} -out=tfplan"
178 | echo "${{ inputs.tool }} plan${{ steps.arg.outputs.arg-chdir }}${args}" | sed 's/ -/\n -/g' > tf.command.txt
179 | if [[ -n "$PLAN_FILE" ]]; then mv --force --verbose "$PLAN_FILE" "$path" 2>/dev/null && exit 0; fi
180 | ${{ inputs.tool }}${{ steps.arg.outputs.arg-chdir }} plan${args} 2> >(tee tf.console.txt) > >(tee tf.console.txt)
181 |
182 | - if: ${{ inputs.command == 'apply' && inputs.arg-auto-approve != 'true' && inputs.plan-file == '' }}
183 | id: download
184 | shell: bash
185 | run: |
186 | # Download plan file.
187 | # Get the artifact ID of the latest matching plan files for download.
188 | artifact_id=$(gh api /repos/${{ github.repository }}/actions/artifacts --header "$GH_API" --method GET --field "name=${{ steps.identifier.outputs.name }}" --jq '.artifacts[0].id' 2>/dev/null)
189 | if [[ -z "$artifact_id" ]]; then echo "Unable to locate plan file: ${{ steps.identifier.outputs.name }}." && exit 1; fi
190 | gh api /repos/${{ github.repository }}/actions/artifacts/${artifact_id}/zip --header "$GH_API" --method GET > "${{ steps.identifier.outputs.name }}.zip"
191 |
192 | # Unzip the plan file to the working directory, then clean up the zip file.
193 | unzip "${{ steps.identifier.outputs.name }}.zip" -d "${{ inputs.arg-chdir || inputs.working-directory }}"
194 | rm --force "${{ steps.identifier.outputs.name }}.zip"
195 |
196 | - if: ${{ inputs.plan-encrypt != '' && steps.download.outcome == 'success' }}
197 | env:
198 | pass: ${{ inputs.plan-encrypt }}
199 | path: ${{ format('{0}{1}tfplan', inputs.arg-chdir || inputs.working-directory, (inputs.arg-chdir || inputs.working-directory) && '/' || '') }}
200 | shell: bash
201 | run: |
202 | # Decrypt plan file.
203 | temp_file=$(mktemp)
204 | printf "%s" "$pass" > "$temp_file"
205 | openssl enc -aes-256-ctr -pbkdf2 -salt -in "$path.encrypted" -out "$path.decrypted" -pass file:"$temp_file" -d
206 | mv --force --verbose "$path.decrypted" "$path"
207 |
208 | - if: ${{ steps.plan.outcome == 'success' || steps.download.outcome == 'success' }}
209 | shell: bash
210 | run: |
211 | # TF show.
212 | ${{ inputs.tool }}${{ steps.arg.outputs.arg-chdir }} show tfplan > tf.console.txt
213 |
214 | # Diff of changes.
215 | # Filter lines starting with " # " and save to tf.diff.txt, then prepend diff-specific symbols based on specific keywords.
216 | grep '^ # ' tf.console.txt | sed \
217 | -e 's/^ # \(.* be created\)/+ \1/' \
218 | -e 's/^ # \(.* be destroyed\)/- \1/' \
219 | -e 's/^ # \(.* be updated\|.* be replaced\)/! \1/' \
220 | -e 's/^ # \(.* be read\)/~ \1/' \
221 | -e 's/^ # \(.*\)/# \1/' > tf.diff.txt || true
222 |
223 | - if: ${{ inputs.plan-encrypt != '' && steps.plan.outcome == 'success' }}
224 | env:
225 | PRESERVE_PLAN: ${{ inputs.preserve-plan }}
226 | pass: ${{ inputs.plan-encrypt }}
227 | path: ${{ format('{0}{1}tfplan', inputs.arg-chdir || inputs.working-directory, (inputs.arg-chdir || inputs.working-directory) && '/' || '') }}
228 | shell: bash
229 | run: |
230 | # Encrypt plan file.
231 | temp_file=$(mktemp)
232 | printf "%s" "$pass" > "$temp_file"
233 | openssl enc -aes-256-ctr -pbkdf2 -salt -in "$path" -out "$path.encrypted" -pass file:"$temp_file"
234 |
235 | # Optionally delete the plan file.
236 | if [[ "$PRESERVE_PLAN" != "true" ]]; then
237 | rm --force "$path"
238 | fi
239 |
240 | - if: ${{ inputs.command == 'plan' && inputs.upload-plan == 'true' && (github.server_url == 'https://github.com' || contains(github.server_url, '.ghe.com')) }}
241 | id: upload
242 | uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
243 | with:
244 | name: ${{ steps.identifier.outputs.name }}
245 | path: ${{ format('{0}{1}tfplan{2}', inputs.arg-chdir || inputs.working-directory, (inputs.arg-chdir || inputs.working-directory) && '/' || '', inputs.plan-encrypt != '' && '.encrypted' || '') }}
246 | retention-days: ${{ inputs.retention-days }}
247 | overwrite: true
248 |
249 | - if: ${{ inputs.command == 'plan' && inputs.upload-plan == 'true' && !(github.server_url == 'https://github.com' || contains(github.server_url, '.ghe.com')) }}
250 | id: upload-v3
251 | uses: actions/upload-artifact@c24449f33cd45d4826c6702db7e49f7cdb9b551d # v3.2.1-node20
252 | with:
253 | name: ${{ steps.identifier.outputs.name }}
254 | path: ${{ format('{0}{1}tfplan', inputs.arg-chdir || inputs.working-directory, (inputs.arg-chdir || inputs.working-directory) && '/' || '') }}
255 | retention-days: ${{ inputs.retention-days }}
256 |
257 | - if: ${{ inputs.plan-parity == 'true' && (steps.download.outcome == 'success' || inputs.plan-file != '') }}
258 | env:
259 | PLAN_FILE: ${{ inputs.plan-file }}
260 | path: ${{ format('{0}{1}tfplan', inputs.arg-chdir || inputs.working-directory, (inputs.arg-chdir || inputs.working-directory) && '/' || '') }}
261 | shell: bash
262 | run: |
263 | # TF plan parity.
264 | # Generate a new plan file, then compare it with the previous one.
265 | # Both plan files are normalized by sorting JSON keys, removing timestamps and ${{ steps.arg.outputs.arg-detailed-exitcode }} to avoid false-positives.
266 | if [[ -n "$PLAN_FILE" ]]; then mv --force --verbose "$PLAN_FILE" "$path" 2>/dev/null; fi
267 | ${{ inputs.tool }}${{ steps.arg.outputs.arg-chdir }} plan${{ steps.arg.outputs.arg-destroy }}${{ steps.arg.outputs.arg-var-file }}${{ steps.arg.outputs.arg-var }}${{ steps.arg.outputs.arg-compact-warnings }}${{ steps.arg.outputs.arg-concise }}${{ steps.arg.outputs.arg-generate-config-out }}${{ steps.arg.outputs.arg-lock-timeout }}${{ steps.arg.outputs.arg-lock }}${{ steps.arg.outputs.arg-parallelism }}${{ steps.arg.outputs.arg-refresh-only }}${{ steps.arg.outputs.arg-refresh }}${{ steps.arg.outputs.arg-replace }}${{ steps.arg.outputs.arg-target }} -out=tfplan.parity
268 | ${{ inputs.tool }}${{ steps.arg.outputs.arg-chdir }} show -json tfplan.parity | jq --sort-keys '[(.resource_changes? // [])[] | select(.change.actions != ["no-op"])]' > tfplan.new
269 | ${{ inputs.tool }}${{ steps.arg.outputs.arg-chdir }} show -json tfplan | jq --sort-keys '[(.resource_changes? // [])[] | select(.change.actions != ["no-op"])]' > tfplan.old
270 | # If both plan files are identical, then replace the old plan file with the new one to prevent avoidable stale apply.
271 | diff tfplan.new tfplan.old && mv --force --verbose "${{ format('{0}{1}tfplan.parity', inputs.arg-chdir || inputs.working-directory, (inputs.arg-chdir || inputs.working-directory) && '/' || '') }}" "${{ format('{0}{1}tfplan', inputs.arg-chdir || inputs.working-directory, (inputs.arg-chdir || inputs.working-directory) && '/' || '') }}"
272 | rm --force tfplan.new tfplan.old "${{ format('{0}{1}tfplan.parity', inputs.arg-chdir || inputs.working-directory, (inputs.arg-chdir || inputs.working-directory) && '/' || '') }}"
273 |
274 | - id: apply
275 | if: ${{ inputs.command == 'apply' }}
276 | env:
277 | PLAN_FILE: ${{ inputs.plan-file }}
278 | PLAN_PARITY: ${{ inputs.plan-parity }}
279 | path: ${{ format('{0}{1}tfplan', inputs.arg-chdir || inputs.working-directory, (inputs.arg-chdir || inputs.working-directory) && '/' || '') }}
280 | shell: bash
281 | run: |
282 | # TF apply.
283 | trap 'exit_code="$?"; echo "exit_code=$exit_code" >> "$GITHUB_OUTPUT"' EXIT
284 | # If arg-auto-approve is true, then pass in variables, otherwise pass in the plan file without variables.
285 | if [[ "${{ inputs.arg-auto-approve }}" == "true" ]]; then
286 | plan="${{ steps.arg.outputs.arg-auto-approve }}"
287 | var_file="${{ steps.arg.outputs.arg-var-file }}"
288 | var="${{ steps.arg.outputs.arg-var }}"
289 | else
290 | if [[ -n "$PLAN_FILE" && "$PLAN_PARITY" != "true" ]]; then mv --force --verbose "$PLAN_FILE" "$path" 2>/dev/null; fi
291 | plan=" tfplan"
292 | var_file=""
293 | var=""
294 | fi
295 | args="${{ steps.arg.outputs.arg-destroy }}${var_file}${var}${{ steps.arg.outputs.arg-backup }}${{ steps.arg.outputs.arg-compact-warnings }}${{ steps.arg.outputs.arg-concise }}${{ steps.arg.outputs.arg-lock-timeout }}${{ steps.arg.outputs.arg-lock }}${{ steps.arg.outputs.arg-parallelism }}${{ steps.arg.outputs.arg-refresh-only }}${{ steps.arg.outputs.arg-refresh }}${{ steps.arg.outputs.arg-replace }}${{ steps.arg.outputs.arg-state-out }}${{ steps.arg.outputs.arg-state }}${{ steps.arg.outputs.arg-target }}${plan}"
296 | echo "${{ inputs.tool }} apply${{ steps.arg.outputs.arg-chdir }}${args}" | sed 's/ -/\n -/g' > tf.command.txt
297 | ${{ inputs.tool }}${{ steps.arg.outputs.arg-chdir }} apply${args} 2> >(tee tf.console.txt) > >(tee tf.console.txt)
298 |
299 | - id: post
300 | if: ${{ !cancelled() && steps.identifier.outcome == 'success' && contains(fromJSON('["plan", "apply", "init"]'), inputs.command) }}
301 | env:
302 | exitcode: ${{ steps.apply.outputs.exit_code || steps.plan.outputs.exit_code || steps.validate.outputs.exit_code || steps.workspace.outputs.exit_code || steps.initialize.outputs.exit_code || steps.format.outputs.exit_code }}
303 | PRESERVE_PLAN: ${{ inputs.preserve-plan }}
304 | shell: bash
305 | run: |
306 | # Post output.
307 | # Parse the "tf.command.txt" file.
308 | command=$(cat tf.command.txt)
309 |
310 | # Remove each comma-delemited "hide-args" argument from the command.
311 | IFS=',' read -ra hide_args <<< "${{ inputs.hide-args }}"
312 | for arg in "${hide_args[@]}"; do
313 | command=$(echo "$command" | grep --invert-match "^ -${arg}" || true)
314 | done
315 |
316 | # Conversely, show each comma-delemited "show-args" argument in the command.
317 | command_append=""
318 | IFS=',' read -ra show_args <<< "${{ inputs.show-args }}"
319 | for arg in "${show_args[@]}"; do
320 | command_append+=$(echo "${{ steps.arg.outputs.arg-workspace }}${{ steps.arg.outputs.arg-backend-config }}${{ steps.arg.outputs.arg-backend }}${{ steps.arg.outputs.arg-backup }}${{ steps.arg.outputs.arg-check }}${{ steps.arg.outputs.arg-compact-warnings }}${{ steps.arg.outputs.arg-concise }}${{ steps.arg.outputs.arg-destroy }}${{ steps.arg.outputs.arg-detailed-exitcode }}${{ steps.arg.outputs.arg-diff }}${{ steps.arg.outputs.arg-force-copy }}${{ steps.arg.outputs.arg-from-module }}${{ steps.arg.outputs.arg-generate-config-out }}${{ steps.arg.outputs.arg-get }}${{ steps.arg.outputs.arg-list }}${{ steps.arg.outputs.arg-lock-timeout }}${{ steps.arg.outputs.arg-lock }}${{ steps.arg.outputs.arg-lockfile }}${{ steps.arg.outputs.arg-migrate-state }}${{ steps.arg.outputs.arg-no-tests }}${{ steps.arg.outputs.arg-parallelism }}${{ steps.arg.outputs.arg-plugin-dir }}${{ steps.arg.outputs.arg-reconfigure }}${{ steps.arg.outputs.arg-recursive }}${{ steps.arg.outputs.arg-refresh-only }}${{ steps.arg.outputs.arg-refresh }}${{ steps.arg.outputs.arg-replace }}${{ steps.arg.outputs.arg-state-out }}${{ steps.arg.outputs.arg-state }}${{ steps.arg.outputs.arg-target }}${{ steps.arg.outputs.arg-test-directory }}${{ steps.arg.outputs.arg-upgrade }}${{ steps.arg.outputs.arg-var-file }}${{ steps.arg.outputs.arg-var }}${{ steps.arg.outputs.arg-write }}${{ steps.arg.outputs.arg-auto-approve }}" | sed 's/ -/\n -/g' | grep "^ -${arg}" || true)
321 | done
322 |
323 | # Consolidate 'command', taking both "hide-args" and "show-args" into account.
324 | command=$(echo "$command" | tr -d '\n')$command_append
325 | echo "command=$command" >> "$GITHUB_OUTPUT"
326 |
327 | # Parse the "tf.console.txt" file, truncated for character limit.
328 | console=$(head --bytes=42000 tf.console.txt)
329 | if [[ ${#console} -eq 42000 ]]; then console="${console}"$'\n…'; fi
330 | echo "result<> "$GITHUB_OUTPUT"
331 |
332 | # Parse the "tf.console.txt" file for the summary.
333 | summary=$(awk '/^(Error:|Plan:|Apply complete!|No changes.|Success)/ {line=$0} END {if (line) print line; else print "View output."}' tf.console.txt)
334 | echo "summary=$summary" >> "$GITHUB_OUTPUT"
335 |
336 | # If "steps.format.outcome" failed, set syntax highlighting to "diff", otherwise set it to "hcl".
337 | syntax="hcl"
338 | if [[ "${{ steps.format.outcome }}" == "failure" ]]; then syntax="diff"; fi
339 |
340 | # Add summary to the job status.
341 | check_run=$(gh api /repos/${{ github.repository }}/check-runs/${{ steps.identifier.outputs.job }} --header "$GH_API" --method PATCH --field "output[title]=${summary}" --field "output[summary]=${summary}")
342 |
343 | # From "check_run", echo "html_url".
344 | check_url=$(echo "$check_run" | jq --raw-output '.html_url')
345 | echo "check_id=$(echo "$check_run" | jq --raw-output '.id')" >> "$GITHUB_OUTPUT"
346 | run_url=$(echo ${check_url}#step:${{ steps.identifier.outputs.step }}:1)
347 | echo "run_url=$run_url" >> "$GITHUB_OUTPUT"
348 |
349 | # If "tf.diff.txt" exists, display it within a "diff" block, truncated for character limit.
350 | if [[ -s tf.diff.txt ]]; then
351 | # Get count of lines in "tf.diff.txt" which do not start with "# ".
352 | diff_count=$(grep --invert-match '^# ' tf.diff.txt | wc --lines)
353 | if [[ $diff_count -eq 1 ]]; then diff_change="change"; else diff_change="changes"; fi
354 |
355 | # Parse diff of changes, truncated for character limit.
356 | diff_truncated=$(head --bytes=24000 tf.diff.txt)
357 | if [[ ${#diff_truncated} -eq 24000 ]]; then diff_truncated="${diff_truncated}"$'\n…'; fi
358 | echo "diff<> "$GITHUB_OUTPUT"
359 |
360 | diff="
361 | Diff of ${diff_count} ${diff_change}.
362 |
363 | \`\`\`diff
364 | ${diff_truncated}
365 | \`\`\`
366 | "
367 | else
368 | diff=""
369 | fi
370 |
371 | # Set flags for creating PR comment and tagging actor.
372 | create_comment=""
373 | tag_actor=""
374 | if [[ $exitcode -ne 0 ]]; then
375 | if [[ "${{ inputs.comment-pr }}" == "on-change" ]]; then create_comment="true"; fi
376 | if [[ "${{ inputs.tag-actor }}" == "on-change" ]]; then tag_actor="true"; fi
377 | fi
378 | if [[ "${{ inputs.comment-pr }}" == "always" ]]; then create_comment="true"; fi
379 | if [[ "${{ inputs.tag-actor }}" == "always" ]]; then tag_actor="true"; fi
380 | if [[ "$tag_actor" == "true" ]]; then handle="@"; else handle=""; fi
381 |
382 | # Collate body content.
383 | body=$(cat <
385 | \`\`\`fish
386 | ${command}
387 | \`\`\`
388 |
389 | ${diff}
390 |
391 | ${summary}
392 |
393 |
394 | ###### By ${handle}${{ github.triggering_actor }} at ${{ github.event.pull_request.updated_at || github.event.comment.created_at || github.event.head_commit.timestamp || github.event.merge_group.head_commit.timestamp }} [(view log)](${run_url}).
395 |
396 |
397 | \`\`\`${syntax}
398 | ${console}
399 | \`\`\`
400 |
401 |
402 |
403 |
404 | EOBODYTFVIAPR
405 | )
406 |
407 | # Post output to job summary.
408 | echo "$body" >> $GITHUB_STEP_SUMMARY
409 | echo "comment_body<> "$GITHUB_OUTPUT"
410 |
411 | # Post PR comment if configured and PR exists.
412 | if [[ "$create_comment" == "true" && "${{ steps.identifier.outputs.pr }}" != "0" ]]; then
413 | # Check if the PR contains a bot comment with the same identifier.
414 | list_comments=$(gh api /repos/${{ github.repository }}/issues/${{ steps.identifier.outputs.pr }}/comments --header "$GH_API" --method GET --field per_page=100)
415 | bot_comment=$(echo "$list_comments" | jq --raw-output --arg identifier "${{ steps.identifier.outputs.name }}" '.[] | select(.user.type == "Bot") | select(.body | contains($identifier)) | .id' | tail -n 1)
416 |
417 | if [[ -n "$bot_comment" ]]; then
418 | if [[ "${{ inputs.comment-method }}" == "recreate" ]]; then
419 | # Delete previous comment before posting a new one.
420 | gh api /repos/${{ github.repository }}/issues/comments/${bot_comment} --header "$GH_API" --method DELETE
421 | pr_comment=$(gh api /repos/${{ github.repository }}/issues/${{ steps.identifier.outputs.pr }}/comments --header "$GH_API" --method POST --field "body=${body}")
422 | echo "comment_id=$(echo "$pr_comment" | jq --raw-output '.id')" >> "$GITHUB_OUTPUT"
423 | elif [[ "${{ inputs.comment-method }}" == "update" ]]; then
424 | # Update existing comment.
425 | pr_comment=$(gh api /repos/${{ github.repository }}/issues/comments/${bot_comment} --header "$GH_API" --method PATCH --field "body=${body}")
426 | echo "comment_id=$(echo "$pr_comment" | jq --raw-output '.id')" >> "$GITHUB_OUTPUT"
427 | fi
428 | else
429 | # Post new comment.
430 | pr_comment=$(gh api /repos/${{ github.repository }}/issues/${{ steps.identifier.outputs.pr }}/comments --header "$GH_API" --method POST --field "body=${body}")
431 | echo "comment_id=$(echo "$pr_comment" | jq --raw-output '.id')" >> "$GITHUB_OUTPUT"
432 | fi
433 | elif [[ "${{ inputs.comment-pr }}" == "on-change" && "${{ steps.identifier.outputs.pr }}" != "0" ]]; then
434 | # Delete previous comment due to no changes.
435 | list_comments=$(gh api /repos/${{ github.repository }}/issues/${{ steps.identifier.outputs.pr }}/comments --header "$GH_API" --method GET --field per_page=100)
436 | bot_comment=$(echo "$list_comments" | jq --raw-output --arg identifier "${{ steps.identifier.outputs.name }}" '.[] | select(.user.type == "Bot") | select(.body | contains($identifier)) | .id' | tail -n 1)
437 |
438 | if [[ -n "$bot_comment" ]]; then
439 | gh api /repos/${{ github.repository }}/issues/comments/${bot_comment} --header "$GH_API" --method DELETE
440 | fi
441 | fi
442 |
443 | # Optionally delete the plan file.
444 | if [[ "$PRESERVE_PLAN" != "true" ]]; then
445 | rm --force "${{ format('{0}{1}tfplan', inputs.arg-chdir || inputs.working-directory, (inputs.arg-chdir || inputs.working-directory) && '/' || '') }}"
446 | fi
447 |
448 | # Clean up files.
449 | rm --force tf.command.txt tf.console.txt tf.diff.txt
450 |
451 | outputs:
452 | check-id:
453 | description: "ID of the check run."
454 | value: ${{ steps.post.outputs.check_id }}
455 | command:
456 | description: "Input of the last TF command."
457 | value: ${{ steps.post.outputs.command }}
458 | comment-body:
459 | description: "Body of the PR comment."
460 | value: ${{ steps.post.outputs.comment_body }}
461 | comment-id:
462 | description: "ID of the PR comment."
463 | value: ${{ steps.post.outputs.comment_id }}
464 | diff:
465 | description: "Diff of changes, if present (truncated)."
466 | value: ${{ steps.post.outputs.diff }}
467 | exitcode:
468 | description: "Exit code of the last TF command."
469 | value: ${{ steps.apply.outputs.exit_code || steps.plan.outputs.exit_code || steps.validate.outputs.exit_code || steps.workspace.outputs.exit_code || steps.initialize.outputs.exit_code || steps.format.outputs.exit_code }}
470 | identifier:
471 | description: "Unique name of the workflow run and artifact."
472 | value: ${{ steps.identifier.outputs.name }}
473 | job-id:
474 | description: "ID of the workflow job."
475 | value: ${{ steps.identifier.outputs.job }}
476 | plan-id:
477 | description: "ID of the plan file artifact."
478 | value: ${{ steps.upload.outputs.artifact-id || steps.upload-v3.outputs.artifact-id }}
479 | plan-url:
480 | description: "URL of the plan file artifact."
481 | value: ${{ steps.upload.outputs.artifact-url || steps.upload-v3.outputs.artifact-url }}
482 | result:
483 | description: "Result of the last TF command (truncated)."
484 | value: ${{ steps.post.outputs.result }}
485 | run-url:
486 | description: "URL of the workflow run."
487 | value: ${{ steps.post.outputs.run_url }}
488 | summary:
489 | description: "Summary of the last TF command."
490 | value: ${{ steps.post.outputs.summary }}
491 |
492 | inputs:
493 | # Action parameters.
494 | command:
495 | default: ""
496 | description: "Command to run between: `plan` or `apply`. Optionally `init` for checks and outputs only (e.g., `plan`)."
497 | required: false
498 | comment-method:
499 | default: "update"
500 | description: "PR comment by: `update` existing comment or `recreate` and delete previous one (e.g., `update`)."
501 | required: false
502 | comment-pr:
503 | default: "always"
504 | description: "Add a PR comment: `always`, `on-change`, or `never` (e.g., `always`)."
505 | required: false
506 | expand-diff:
507 | default: "false"
508 | description: "Expand the collapsible diff section (e.g., `false`)."
509 | required: false
510 | expand-summary:
511 | default: "false"
512 | description: "Expand the collapsible summary section (e.g., `false`)."
513 | required: false
514 | format:
515 | default: "false"
516 | description: "Check format of TF code (e.g., `false`)."
517 | required: false
518 | hide-args:
519 | default: "detailed-exitcode,parallelism,lock,out,var="
520 | description: "Hide comma-separated arguments from the command input (e.g., `detailed-exitcode,lock,out,var=`)."
521 | required: false
522 | label-pr:
523 | default: "true"
524 | description: "Add a PR label with the command input (e.g., `true`)."
525 | required: false
526 | plan-encrypt:
527 | default: ""
528 | description: "Encrypt plan file artifact with the given input (e.g., `secrets.PASSPHRASE`)."
529 | required: false
530 | plan-file:
531 | default: ""
532 | description: "Supply existing plan file path instead of the auto-generated one (e.g., `path/to/file.tfplan`)."
533 | required: false
534 | plan-parity:
535 | default: "false"
536 | description: "Replace the plan file if it matches a newly-generated one to prevent stale apply (e.g., `false`)."
537 | required: false
538 | preserve-plan:
539 | default: "false"
540 | description: "Preserve plan file in the given working directory after workflow execution (e.g., `false`)."
541 | required: false
542 | retention-days:
543 | default: ""
544 | description: "Duration after which plan file artifact will expire in days (e.g., '90')."
545 | required: false
546 | show-args:
547 | default: "workspace"
548 | description: "Show comma-separated arguments in the command input (e.g., `workspace`)."
549 | required: false
550 | tag-actor:
551 | default: "always"
552 | description: "Tag the workflow triggering actor: `always`, `on-change`, or `never` (e.g., `always`)."
553 | required: false
554 | token:
555 | default: ${{ github.token }}
556 | description: "Specify a GitHub token (e.g., `secrets.GITHUB_TOKEN`)."
557 | required: false
558 | tool:
559 | default: "terraform"
560 | description: "Provisioning tool to use between: `terraform` or `tofu` (e.g., `terraform`)."
561 | required: false
562 | upload-plan:
563 | default: "true"
564 | description: "Upload plan file as GitHub workflow artifact (e.g., `true`)."
565 | required: false
566 | validate:
567 | default: "false"
568 | description: "Check validation of TF code (e.g., `false`)."
569 | required: false
570 | working-directory:
571 | default: ""
572 | description: "Specify the working directory of TF code, alias of `arg-chdir` (e.g., `stacks/dev`)."
573 | required: false
574 |
575 | # CLI arguments.
576 | arg-auto-approve:
577 | default: ""
578 | description: "auto-approve"
579 | required: false
580 | arg-backend-config:
581 | default: ""
582 | description: "backend-config"
583 | required: false
584 | arg-backend:
585 | default: ""
586 | description: "backend"
587 | required: false
588 | arg-backup:
589 | default: ""
590 | description: "backup"
591 | required: false
592 | arg-chdir:
593 | default: ""
594 | description: "chdir"
595 | required: false
596 | arg-check:
597 | default: "true"
598 | description: "check"
599 | required: false
600 | arg-compact-warnings:
601 | default: ""
602 | description: "compact-warnings"
603 | required: false
604 | arg-concise:
605 | default: ""
606 | description: "concise"
607 | required: false
608 | arg-destroy:
609 | default: ""
610 | description: "destroy"
611 | required: false
612 | arg-detailed-exitcode:
613 | default: "true"
614 | description: "detailed-exitcode"
615 | required: false
616 | arg-diff:
617 | default: "true"
618 | description: "diff"
619 | required: false
620 | arg-force-copy:
621 | default: ""
622 | description: "force-copy"
623 | required: false
624 | arg-from-module:
625 | default: ""
626 | description: "from-module"
627 | required: false
628 | arg-generate-config-out:
629 | default: ""
630 | description: "generate-config-out"
631 | required: false
632 | arg-get:
633 | default: ""
634 | description: "get"
635 | required: false
636 | arg-list:
637 | default: ""
638 | description: "list"
639 | required: false
640 | arg-lock-timeout:
641 | default: ""
642 | description: "lock-timeout"
643 | required: false
644 | arg-lock:
645 | default: ""
646 | description: "lock"
647 | required: false
648 | arg-lockfile:
649 | default: ""
650 | description: "lockfile"
651 | required: false
652 | arg-migrate-state:
653 | default: ""
654 | description: "migrate-state"
655 | required: false
656 | arg-no-tests:
657 | default: ""
658 | description: "no-tests"
659 | required: false
660 | arg-parallelism:
661 | default: ""
662 | description: "parallelism"
663 | required: false
664 | arg-plugin-dir:
665 | default: ""
666 | description: "plugin-dir"
667 | required: false
668 | arg-reconfigure:
669 | default: ""
670 | description: "reconfigure"
671 | required: false
672 | arg-recursive:
673 | default: "true"
674 | description: "recursive"
675 | required: false
676 | arg-refresh-only:
677 | default: ""
678 | description: "refresh-only"
679 | required: false
680 | arg-refresh:
681 | default: ""
682 | description: "refresh"
683 | required: false
684 | arg-replace:
685 | default: ""
686 | description: "replace"
687 | required: false
688 | arg-state-out:
689 | default: ""
690 | description: "state-out"
691 | required: false
692 | arg-state:
693 | default: ""
694 | description: "state"
695 | required: false
696 | arg-target:
697 | default: ""
698 | description: "target"
699 | required: false
700 | arg-test-directory:
701 | default: ""
702 | description: "test-directory"
703 | required: false
704 | arg-upgrade:
705 | default: ""
706 | description: "upgrade"
707 | required: false
708 | arg-var-file:
709 | default: ""
710 | description: "var-file"
711 | required: false
712 | arg-var:
713 | default: ""
714 | description: "var"
715 | required: false
716 | arg-workspace:
717 | default: ""
718 | description: "workspace"
719 | required: false
720 | arg-write:
721 | default: ""
722 | description: "write"
723 | required: false
724 |
725 | branding:
726 | color: purple
727 | icon: package
728 |
--------------------------------------------------------------------------------
/tests/fail_data_source_error/main.tf:
--------------------------------------------------------------------------------
1 | data "local_file" "path" {
2 | filename = "/error"
3 | }
4 |
--------------------------------------------------------------------------------
/tests/fail_format_diff/main.tf:
--------------------------------------------------------------------------------
1 | resource "random_pet" "name" {
2 | count =1
3 | }
4 |
--------------------------------------------------------------------------------
/tests/fail_invalid_resource_type/main.tf:
--------------------------------------------------------------------------------
1 | resource "random_pets" "name" {
2 | count = 1
3 | }
4 |
--------------------------------------------------------------------------------
/tests/pass_character_limit/main.tf:
--------------------------------------------------------------------------------
1 | resource "random_pet" "name" {
2 | count = 10000
3 | }
4 |
--------------------------------------------------------------------------------
/tests/pass_one/main.tf:
--------------------------------------------------------------------------------
1 | resource "random_pet" "name" {
2 | count = 1
3 | }
4 |
--------------------------------------------------------------------------------
/tests/tf.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | terraform -chdir=tests/pass_one init -no-color 2> >(tee pass_one.txt) > >(tee pass_one.txt)
4 | terraform -chdir=tests/pass_format_diff fmt -check=true -diff=true -no-color 2> >(tee pass_format_diff.txt) > >(tee pass_format_diff.txt)
5 | terraform -chdir=tests/fail_data_source_error init -no-color 2> >(tee fail_data_source_error.txt) > >(tee fail_data_source_error.txt)
6 | terraform -chdir=tests/fail_invalid_resource_type init -no-color 2> >(tee fail_invalid_resource_type.txt) > >(tee fail_invalid_resource_type.txt)
7 |
--------------------------------------------------------------------------------