├── .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 | [![Terraform Compatible](https://img.shields.io/badge/Terraform-Compatible-844FBA?logo=terraform&logoColor=white)](https://github.com/hashicorp/setup-terraform "Terraform Compatible.") 2 | [![OpenTofu Compatible](https://img.shields.io/badge/OpenTofu-Compatible-FFDA18?logo=opentofu&logoColor=white)](https://github.com/opentofu/setup-opentofu "OpenTofu Compatible.") 3 | * 4 | [![GitHub license](https://img.shields.io/github/license/op5dev/tf-via-pr?logo=apache&label=License)](LICENSE "Apache License 2.0.") 5 | [![GitHub release tag](https://img.shields.io/github/v/release/op5dev/tf-via-pr?logo=semanticrelease&label=Release)](https://github.com/op5dev/tf-via-pr/releases "View all releases.") 6 | * 7 | [![GitHub repository stargazers](https://img.shields.io/github/stars/op5dev/tf-via-pr)](https://github.com/op5dev/tf-via-pr "Become a stargazer.") 8 | 9 | # Terraform/OpenTofu via Pull Request (TF-via-PR) 10 | 11 | 12 | 13 | 16 | 19 | 20 | 21 | 27 | 33 | 34 |
14 |

What does it do?

15 |
17 |

Who is it for?

18 |
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 |
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 |
35 | 36 |
37 | 38 | ### View: [Usage Examples](#usage-examples) · [Inputs](#inputs) · [Outputs](#outputs) · [Security](#security) · [Changelog](#changelog) · [License](#license) 39 | 40 | [![PR comment of plan output with "Diff of changes" section expanded.](/.github/assets/comment.png)](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 | 102 | 107 | 108 | 109 | 114 | 119 | 120 | 121 | 126 | 131 | 132 |
98 |

#1 example ⤴

99 |

Runs on pull_request (plan) and push (apply) events with Terraform, AWS authentication and cache.

100 |
101 |
103 |

#2 example ⤴

104 |

Runs on pull_request (plan) and merge_group (apply) events with OpenTofu in matrix strategy.

105 |
106 |
110 |

#3 example ⤴

111 |

Runs on pull_request (plan) and push (apply) events with fmt/validate checks and TFLint.

112 |
113 |
115 |

#4 example ⤴

116 |

Runs on pull_request (plan) and push (apply) events with conditional jobs based on plan file.

117 |
118 |
122 |

#5 example ⤴

123 |

Runs on labeled and workflow_dispatch manual events on GitHub Enterprise (GHE) self-hosted runner.

124 |
125 |
127 |

#6 example ⤴

128 |

Runs on schedule cron event with -refresh-only to open an issue on configuration drift.

129 |
130 |
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`.1
Example: `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.2
Default: `false` | 168 | | Security | `plan-encrypt` | Encrypt plan file artifact with the given input.3
Example: `${{ 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`.4
Default: `always` | 177 | | UI | `comment-method` | PR comment by: `update` existing comment or `recreate` and delete previous one.5
Default: `update` | 178 | | UI | `tag-actor` | Tag the workflow triggering actor: `always`, `on-change`, or `never`.4
Default: `always` | 179 | | UI | `hide-args` | Hide comma-separated list of CLI arguments from the command input.6
Default: `detailed-exitcode,parallelism,lock,out,var=` | 180 | | UI | `show-args` | Show comma-separated list of CLI arguments in the command input.6
Default: `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 | [![PR comment revision history comparing plan and apply outputs.](/.github/assets/revisions.png)](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 | --------------------------------------------------------------------------------