diff --git a/.gitignore b/.gitignore
new file mode 100644
index 00000000..27f403a5
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,3 @@
+# IDEs
+.idea
+.vscode
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 00000000..94ec04b6
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,5 @@
+# Changelog
+
+## v1.0.0
+
+- Initial release.
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 00000000..057da245
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,19 @@
+FROM hashicorp/terraform:0.12.28
+
+LABEL repository="https://github.com/robburger/terraform-pr-commenter" \
+ homepage="https://github.com/robburger/terraform-pr-commenter" \
+ maintainer="Rob Burger" \
+ com.github.actions.name="Terraform PR Commenter" \
+ com.github.actions.description="Adds opinionated comments to a PR from Terraform fmt/init/plan output" \
+ com.github.actions.icon="git-pull-request" \
+ com.github.actions.color="purple"
+
+RUN apk add --no-cache -q \
+ bash \
+ curl \
+ jq
+
+ADD entrypoint.sh /entrypoint.sh
+RUN chmod +x /entrypoint.sh
+
+ENTRYPOINT ["/entrypoint.sh"]
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 00000000..5e66944f
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2020 Rob Burger
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/README.md b/README.md
index 648f8f41..ff585038 100644
--- a/README.md
+++ b/README.md
@@ -1,2 +1,208 @@
-# terraform-pr-commenter
-A GitHub Action that adds opinionated comments to a PR from Terraform fmt/init/plan output
+# Terraform PR Commenter
+Adds opinionated comments to PR's based on Terraform `fmt`, `init` and `plan` outputs.
+
+## Summary
+This Docker-based GitHub Action is designed to work in tandem with [hashicorp/setup-terraform](https://github.com/hashicorp/setup-terraform) with the wrapper enabled, taking the output from a `fmt`, `init` or `plan`, formatting it and adding it to a pull request. Any previous comments from this Action are removed to keep the PR timeline clean.
+
+Support (for now) is [limited to Linux](https://help.github.com/en/actions/creating-actions/about-actions#types-of-actions) as Docker-based GitHub Actions can only be used on Linux runners.
+
+## Usage
+This action can only be run after a Terraform `fmt`, `init`, or `plan` has completed, and the output has been captured. Terraform rarely writes to `stdout` and `stderr` in the same action, so we concatenate the `commenter_input`:
+```yaml
+- uses: robburger/terraform-pr-commenter@v1
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ with:
+ commenter_type: fmt/init/plan # Choose one
+ commenter_input: ${{ format('{0}{1}', steps.step_id.outputs.stdout, steps.step_id.outputs.stderr) }}
+ commenter_exitcode: ${{ steps.step_id.outputs.exitcode }}
+```
+
+### Inputs
+| Name | Requirement | Description |
+| -------------------- | ----------- | ----------- |
+| `commenter_type` | _required_ | The type of comment. Options: [`fmt`, `init`, `plan`] |
+| `commenter_input` | _required_ | The comment to post from a previous step output. |
+| `commenter_exitcode` | _required_ | The exit code from a previous step output. |
+
+### Environment Variables
+| Name | Requirement | Description |
+| ------------------------ | ----------- | ----------- |
+| `GITHUB_TOKEN` | _required_ | Used to execute API calls. The `${{ secrets.GITHUB_TOKEN }}` already has permissions, but if you're using your own token, ensure it has the `repo` scope. |
+| `TF_WORKSPACE` | _optional_ | Default: `default`. This is used to separate multiple comments on a pull request in a matrix run. |
+| `EXPAND_SUMMARY_DETAILS` | _optional_ | Default: `true`. This controls whether the comment output is collapsed or not. |
+
+Both of these environment variables can be set at `job` or `step` level. For example, you could collapse all outputs but expand on a `plan`:
+```yaml
+jobs:
+ terraform:
+ name: 'Terraform'
+ runs-on: ubuntu-latest
+ env:
+ EXPAND_SUMMARY_DETAILS: 'false' # All steps will have this environment variable
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v2
+...
+ - name: Post Plan
+ uses: robburger/terraform-pr-commenter@v1
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ EXPAND_SUMMARY_DETAILS: 'true' # Override global environment variable; expand details just for this step
+ with:
+ commenter_type: plan
+ commenter_input: ${{ format('{0}{1}', steps.plan.outputs.stdout, steps.plan.outputs.stderr) }}
+ commenter_exitcode: ${{ steps.plan.outputs.exitcode }}
+...
+```
+
+## Examples
+Single workspace build, full example:
+```yaml
+name: 'Terraform'
+
+on:
+ pull_request:
+ push:
+ branches:
+ - master
+
+jobs:
+ terraform:
+ name: 'Terraform'
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v2
+
+ - name: Setup Terraform
+ uses: hashicorp/setup-terraform@v1
+ env:
+ TF_IN_AUTOMATION: true
+ with:
+ cli_config_credentials_token: ${{ secrets.TF_API_TOKEN }}
+ terraform_version: 0.12.28
+
+ - name: Terraform Format
+ id: fmt
+ run: terraform fmt -check -recursive
+ continue-on-error: true
+
+ - name: Post Format
+ if: always() && github.ref != 'refs/heads/master' && (steps.fmt.outcome == 'success' || steps.fmt.outcome == 'failure')
+ uses: robburger/terraform-pr-commenter@v1
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ with:
+ commenter_type: fmt
+ commenter_input: ${{ format('{0}{1}', steps.fmt.outputs.stdout, steps.fmt.outputs.stderr) }}
+ commenter_exitcode: ${{ steps.fmt.outputs.exitcode }}
+
+ - name: Terraform Init
+ id: init
+ run: terraform init
+
+ - name: Post Init
+ if: always() && github.ref != 'refs/heads/master' && (steps.init.outcome == 'success' || steps.init.outcome == 'failure')
+ uses: robburger/terraform-pr-commenter@v1
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ with:
+ commenter_type: init
+ commenter_input: ${{ format('{0}{1}', steps.init.outputs.stdout, steps.init.outputs.stderr) }}
+ commenter_exitcode: ${{ steps.init.outputs.exitcode }}
+
+ - name: Terraform Plan
+ id: plan
+ run: terraform plan -out workspace.plan
+
+ - name: Post Plan
+ if: always() && github.ref != 'refs/heads/master' && (steps.plan.outcome == 'success' || steps.plan.outcome == 'failure')
+ uses: robburger/terraform-pr-commenter@v1
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ with:
+ commenter_type: plan
+ commenter_input: ${{ format('{0}{1}', steps.plan.outputs.stdout, steps.plan.outputs.stderr) }}
+ commenter_exitcode: ${{ steps.plan.outputs.exitcode }}
+
+ - name: Terraform Apply
+ id: apply
+ if: github.ref == 'refs/heads/master' && github.event_name == 'push'
+ run: terraform apply workspace.plan
+```
+
+Multi-workspace matrix/parallel build:
+```yaml
+...
+jobs:
+ terraform:
+ name: 'Terraform'
+ runs-on: ubuntu-latest
+ strategy:
+ matrix:
+ workspace: [audit, staging]
+ env:
+ TF_WORKSPACE: ${{ matrix['workspace'] }}
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v2
+
+ - name: Setup Terraform
+ uses: hashicorp/setup-terraform@v1
+ env:
+ TF_IN_AUTOMATION: true
+ with:
+ cli_config_credentials_token: ${{ secrets.TF_API_TOKEN }}
+ terraform_version: 0.12.28
+
+ - name: Terraform Init - ${{ matrix['workspace'] }}
+ id: init
+ run: terraform init
+
+ - name: Post Init - ${{ matrix['workspace'] }}
+ if: always() && github.ref != 'refs/heads/master' && (steps.init.outcome == 'success' || steps.init.outcome == 'failure')
+ uses: robburger/terraform-pr-commenter@v1
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ with:
+ commenter_type: init
+ commenter_input: ${{ format('{0}{1}', steps.init.outputs.stdout, steps.init.outputs.stderr) }}
+ commenter_exitcode: ${{ steps.init.outputs.exitcode }}
+
+ - name: Terraform Plan - ${{ matrix['workspace'] }}
+ id: plan
+ run: terraform plan -out ${{ matrix['workspace'] }}.plan
+
+ - name: Post Plan - ${{ matrix['workspace'] }}
+ if: always() && github.ref != 'refs/heads/master' && (steps.plan.outcome == 'success' || steps.plan.outcome == 'failure')
+ uses: robburger/terraform-pr-commenter@v1
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ with:
+ commenter_type: plan
+ commenter_input: ${{ format('{0}{1}', steps.plan.outputs.stdout, steps.plan.outputs.stderr) }}
+ commenter_exitcode: ${{ steps.plan.outputs.exitcode }}
+...
+```
+
+"What's the crazy-looking `if:` doing there?" Good question! It's broken into 3 logic groups separated by `&&`, so all need to return `true` for the step to run:
+1) `always()` - ensures that the step is run regardless of the outcome in any previous steps
+2) `github.ref != 'refs/heads/master'` - prevents the step running on a `master` branch
+3) `(steps.step_id.outcome == 'success' || steps.step_id.outcome == 'failure')` - limits the run to the specific `step_id` only when there's a `success` or `failed` outcome.
+
+In English: "Always run this step, but only on a pull request and only when the previous step succeeds or fails."
+
+## Screenshots
+
+### fmt
+![fmt](images/fmt-output.png)
+
+### plan
+![fmt](images/plan-output.png)
+
+## Troubleshooting & Contributing
+Feel free to head over to the [Issues](https://github.com/robburger/terraform-pr-commenter/issues) tab to see if the issue you're having has already been reported. If not, [open a new one](https://github.com/robburger/terraform-pr-commenter/issues/new) and be sure to include as much relevant information as possible, including code-samples, and a description of what you expect to be happening.
+
+## License
+[MIT](LICENSE)
diff --git a/action.yml b/action.yml
new file mode 100644
index 00000000..713ec796
--- /dev/null
+++ b/action.yml
@@ -0,0 +1,20 @@
+name: 'Terraform PR Commenter'
+description: 'Adds opinionated comments to a PR from Terraform fmt/init/plan output'
+author: 'Rob Burger'
+inputs:
+ commenter_type:
+ description: 'The type of comment. Options: [fmt, init, plan]'
+ required: true
+ commenter_input:
+ description: 'The comment to post from a previous step output'
+ required: true
+ commenter_exitcode:
+ description: 'The exit code from a previous step output'
+ required: true
+runs:
+ using: 'docker'
+ image: 'Dockerfile'
+ args:
+ - ${{ inputs.commenter_type }}
+ - ${{ inputs.commenter_input }}
+ - ${{ inputs.commenter_exitcode }}
diff --git a/entrypoint.sh b/entrypoint.sh
new file mode 100644
index 00000000..dd8e9e0c
--- /dev/null
+++ b/entrypoint.sh
@@ -0,0 +1,214 @@
+#!/usr/bin/env bash
+
+#############
+# Validations
+#############
+PR_NUMBER=$(jq -r ".pull_request.number" "$GITHUB_EVENT_PATH")
+if [[ "$PR_NUMBER" == "null" ]]; then
+ echo "This isn't a PR."
+ exit 0
+fi
+
+if [[ -z "$GITHUB_TOKEN" ]]; then
+ echo "GITHUB_TOKEN environment variable missing."
+ exit 1
+fi
+
+if [[ -z $3 ]]; then
+ echo "There must be an exit code from a previous step."
+ exit 1
+fi
+
+if [[ ! "$1" =~ ^(fmt|init|plan)$ ]]; then
+ echo -e "Unsupported command \"$1\". Valid commands are \"fmt\", \"init\", \"plan\"."
+ exit 1
+fi
+
+##################
+# Shared Variables
+##################
+# Arg 1 is command
+COMMAND=$1
+# Arg 2 is input. We strip ANSI colours.
+INPUT=$(echo "$2" | sed 's/\x1b\[[0-9;]*m//g')
+# Arg 3 is the Terraform CLI exit code
+EXIT_CODE=$3
+
+# Read TF_WORKSPACE environment variable or use "default"
+WORKSPACE=${TF_WORKSPACE:-default}
+
+# Read EXPAND_SUMMARY_DETAILS environment variable or use "true"
+if [[ ${EXPAND_SUMMARY_DETAILS:-true} == "true" ]]; then
+ DETAILS_STATE=" open"
+else
+ DETAILS_STATE=""
+fi
+
+ACCEPT_HEADER="Accept: application/vnd.github.v3+json"
+AUTH_HEADER="Authorization: token $GITHUB_TOKEN"
+CONTENT_HEADER="Content-Type: application/json"
+
+PR_COMMENTS_URL=$(jq -r ".pull_request.comments_url" "$GITHUB_EVENT_PATH")
+PR_COMMENT_URI=$(jq -r ".repository.issue_comment_url" "$GITHUB_EVENT_PATH" | sed "s|{/number}||g")
+
+##############
+# Handler: fmt
+##############
+if [[ $COMMAND == 'fmt' ]]; then
+ # Look for an existing fmt PR comment and delete
+ echo -e "\033[34;1mINFO:\033[0m Looking for an existing fmt PR comment."
+ PR_COMMENT_ID=$(curl -sS -H "$AUTH_HEADER" -H "$ACCEPT_HEADER" -L "$PR_COMMENTS_URL" | jq '.[] | select(.body|test ("### Terraform `fmt` Failed")) | .id')
+ if [ "$PR_COMMENT_ID" ]; then
+ echo -e "\033[34;1mINFO:\033[0m Found existing fmt PR comment: $PR_COMMENT_ID. Deleting."
+ PR_COMMENT_URL="$PR_COMMENT_URI/$PR_COMMENT_ID"
+ curl -sS -X DELETE -H "$AUTH_HEADER" -H "$ACCEPT_HEADER" -L "$PR_COMMENT_URL" > /dev/null
+ else
+ echo -e "\033[34;1mINFO:\033[0m No existing fmt PR comment found."
+ fi
+
+ # Exit Code: 0
+ # Meaning: All files formatted correctly.
+ # Actions: Exit.
+ if [[ $EXIT_CODE -eq 0 ]]; then
+ echo -e "\033[34;1mINFO:\033[0m Terraform fmt completed with no errors. Continuing."
+
+ exit 0
+ fi
+
+ # Exit Code: 1, 2
+ # Meaning: 1 = Malformed Terraform CLI command. 2 = Terraform parse error.
+ # Actions: Build PR comment.
+ if [[ $EXIT_CODE -eq 1 || $EXIT_CODE -eq 2 ]]; then
+ PR_COMMENT="### Terraform \`fmt\` Failed
+Show Output
+
+\`\`\`
+$INPUT
+\`\`\`
+ "
+ fi
+
+ # Exit Code: 3
+ # Meaning: One or more files are incorrectly formatted.
+ # Actions: Iterate over all files and build diff-based PR comment.
+ if [[ $EXIT_CODE -eq 3 ]]; then
+ ALL_FILES_DIFF=""
+ for file in $INPUT; do
+ THIS_FILE_DIFF=$(terraform fmt -no-color -write=false -diff "$file" | sed -n '/@@.*/,//{/@@.*/d;p}')
+ ALL_FILES_DIFF="$ALL_FILES_DIFF
+$file
+
+\`\`\`diff
+$THIS_FILE_DIFF
+\`\`\`
+ "
+ done
+
+ PR_COMMENT="### Terraform \`fmt\` Failed
+$ALL_FILES_DIFF"
+ fi
+
+ # Add fmt failure comment to PR.
+ PR_PAYLOAD=$(echo '{}' | jq --arg body "$PR_COMMENT" '.body = $body')
+ echo -e "\033[34;1mINFO:\033[0m Adding fmt failure comment to PR."
+ curl -sS -X POST -H "$AUTH_HEADER" -H "$ACCEPT_HEADER" -H "$CONTENT_HEADER" -d "$PR_PAYLOAD" -L "$PR_COMMENTS_URL" > /dev/null
+
+ exit 0
+fi
+
+###############
+# Handler: init
+###############
+if [[ $COMMAND == 'init' ]]; then
+ # Look for an existing init PR comment and delete
+ echo -e "\033[34;1mINFO:\033[0m Looking for an existing init PR comment."
+ PR_COMMENT_ID=$(curl -sS -H "$AUTH_HEADER" -H "$ACCEPT_HEADER" -L "$PR_COMMENTS_URL" | jq '.[] | select(.body|test ("### Terraform `init` Failed")) | .id')
+ if [ "$PR_COMMENT_ID" ]; then
+ echo -e "\033[34;1mINFO:\033[0m Found existing init PR comment: $PR_COMMENT_ID. Deleting."
+ PR_COMMENT_URL="$PR_COMMENT_URI/$PR_COMMENT_ID"
+ curl -sS -X DELETE -H "$AUTH_HEADER" -H "$ACCEPT_HEADER" -L "$PR_COMMENT_URL" > /dev/null
+ else
+ echo -e "\033[34;1mINFO:\033[0m No existing init PR comment found."
+ fi
+
+ # Exit Code: 0
+ # Meaning: Terraform successfully initialized.
+ # Actions: Exit.
+ if [[ $EXIT_CODE -eq 0 ]]; then
+ echo -e "\033[34;1mINFO:\033[0m Terraform init completed with no errors. Continuing."
+
+ exit 0
+ fi
+
+ # Exit Code: 1
+ # Meaning: Terraform initialize failed or malformed Terraform CLI command.
+ # Actions: Build PR comment.
+ if [[ $EXIT_CODE -eq 1 ]]; then
+ PR_COMMENT="### Terraform \`init\` Failed
+Show Output
+
+\`\`\`
+$INPUT
+\`\`\`
+ "
+ fi
+
+ # Add init failure comment to PR.
+ PR_PAYLOAD=$(echo '{}' | jq --arg body "$PR_COMMENT" '.body = $body')
+ echo -e "\033[34;1mINFO:\033[0m Adding init failure comment to PR."
+ curl -sS -X POST -H "$AUTH_HEADER" -H "$ACCEPT_HEADER" -H "$CONTENT_HEADER" -d "$PR_PAYLOAD" -L "$PR_COMMENTS_URL" > /dev/null
+
+ exit 0
+fi
+
+###############
+# Handler: plan
+###############
+if [[ $COMMAND == 'plan' ]]; then
+ # Look for an existing plan PR comment and delete
+ echo -e "\033[34;1mINFO:\033[0m Looking for an existing plan PR comment."
+ PR_COMMENT_ID=$(curl -sS -H "$AUTH_HEADER" -H "$ACCEPT_HEADER" -L "$PR_COMMENTS_URL" | jq '.[] | select(.body|test ("### Terraform `plan` .* for Workspace: `'"$WORKSPACE"'`")) | .id')
+ if [ "$PR_COMMENT_ID" ]; then
+ echo -e "\033[34;1mINFO:\033[0m Found existing plan PR comment: $PR_COMMENT_ID. Deleting."
+ PR_COMMENT_URL="$PR_COMMENT_URI/$PR_COMMENT_ID"
+ curl -sS -X DELETE -H "$AUTH_HEADER" -H "$ACCEPT_HEADER" -L "$PR_COMMENT_URL" > /dev/null
+ else
+ echo -e "\033[34;1mINFO:\033[0m No existing plan PR comment found."
+ fi
+
+ # Exit Code: 0, 2
+ # Meaning: 0 = Terraform plan succeeded with no changes. 2 = Terraform plan succeeded with changes.
+ # Actions: Strip out the refresh section (everything before the 72 '-' characters) and build PR comment.
+ if [[ $EXIT_CODE -eq 0 || $EXIT_CODE -eq 2 ]]; then
+ CLEAN_PLAN=$(echo "$INPUT" | sed -nr '/-{72}/,/-{72}/{ /-{72}/d; p }') # Strip refresh section
+ CLEAN_PLAN=${CLEAN_PLAN::65300} # GitHub has a 65535-char comment limit - truncate plan, leaving space for comment wrapper
+ CLEAN_PLAN=$(echo "$CLEAN_PLAN" | sed -E 's/^([[:blank:]]*)([-+~])/\2\1/g') # Move any diff characters to start of line
+ PR_COMMENT="### Terraform \`plan\` Succeeded for Workspace: \`$WORKSPACE\`
+Show Output
+
+\`\`\`diff
+$CLEAN_PLAN
+\`\`\`
+ "
+ fi
+
+ # Exit Code: 1
+ # Meaning: Terraform plan failed.
+ # Actions: Build PR comment.
+ if [[ $EXIT_CODE -eq 1 ]]; then
+ PR_COMMENT="### Terraform \`plan\` Failed for Workspace: \`$WORKSPACE\`
+Show Output
+
+\`\`\`
+$INPUT
+\`\`\`
+ "
+ fi
+
+ # Add plan comment to PR.
+ PR_PAYLOAD=$(echo '{}' | jq --arg body "$PR_COMMENT" '.body = $body')
+ echo -e "\033[34;1mINFO:\033[0m Adding plan comment to PR."
+ curl -sS -X POST -H "$AUTH_HEADER" -H "$ACCEPT_HEADER" -H "$CONTENT_HEADER" -d "$PR_PAYLOAD" -L "$PR_COMMENTS_URL" > /dev/null
+
+ exit 0
+fi
diff --git a/images/fmt-output.png b/images/fmt-output.png
new file mode 100644
index 00000000..bf3bb8a1
Binary files /dev/null and b/images/fmt-output.png differ
diff --git a/images/plan-output.png b/images/plan-output.png
new file mode 100644
index 00000000..3061ea2f
Binary files /dev/null and b/images/plan-output.png differ