diff --git a/.github/actions/github-actions.actionlint/action.yaml b/.github/actions/github-actions.actionlint/action.yaml index 74ca76e..b7cb7c7 100644 --- a/.github/actions/github-actions.actionlint/action.yaml +++ b/.github/actions/github-actions.actionlint/action.yaml @@ -14,6 +14,10 @@ inputs: required: false default: "true" description: "(Optional) Whether to check `run` scripts with `shellcheck`. Requires the `shellcheck` CLI on the `PATH`. Defaults to `true`." + annotations_enabled: + required: false + default: "true" + description: "(Optional) Whether to annotate the reported lines in the pull request through a problem matcher. Defaults to `true`." outputs: skipped: @@ -66,6 +70,14 @@ runs: actionlint -version | head -n 1 + - name: Register Problem Matcher + id: matcher + if: steps.target.outputs.skipped != 'true' && inputs.annotations_enabled == 'true' + shell: bash + env: + ACTION_PATH: ${{ github.action_path }} + run: echo "::add-matcher::$ACTION_PATH/problem-matcher.json" + - name: Run actionlint id: actionlint if: steps.target.outputs.skipped != 'true' @@ -88,6 +100,12 @@ runs: readarray -t files <<< "$FILES" actionlint "${args[@]}" "${files[@]}" + - name: Remove Problem Matcher + id: matcher-remove + if: always() && steps.matcher.outcome == 'success' + shell: bash + run: echo "::remove-matcher owner=actionlint::" + - name: Add Failure Details to Job Summary id: actionlint-summary if: always() && steps.actionlint.outcome == 'failure' diff --git a/.github/actions/github-actions.actionlint/problem-matcher.json b/.github/actions/github-actions.actionlint/problem-matcher.json new file mode 100644 index 0000000..3ef266e --- /dev/null +++ b/.github/actions/github-actions.actionlint/problem-matcher.json @@ -0,0 +1,17 @@ +{ + "problemMatcher": [ + { + "owner": "actionlint", + "pattern": [ + { + "regexp": "^(.+?):(\\d+):(\\d+): (.+?) \\[(.+?)\\]$", + "file": 1, + "line": 2, + "column": 3, + "message": 4, + "code": 5 + } + ] + } + ] +} diff --git a/.github/actions/terraform.tflint/action.yaml b/.github/actions/terraform.tflint/action.yaml index 0af0c01..519ca96 100644 --- a/.github/actions/terraform.tflint/action.yaml +++ b/.github/actions/terraform.tflint/action.yaml @@ -31,6 +31,10 @@ inputs: required: false default: ${{ github.token }} description: "(Optional) The GitHub token used by `tflint --init` to download plugins from GitHub without hitting the anonymous rate limit. Defaults to the automatically generated `github.token`." + annotations_enabled: + required: false + default: "true" + description: "(Optional) Whether to annotate the reported lines in the pull request through a problem matcher. Only works with the `compact` format. Defaults to `true`." outputs: skipped: @@ -122,6 +126,14 @@ runs: file: ${{ steps.init.outputs.log_file }} lang: text + - name: Register Problem Matcher + id: matcher + if: steps.target.outputs.skipped != 'true' && inputs.annotations_enabled == 'true' && inputs.format == 'compact' + shell: bash + env: + ACTION_PATH: ${{ github.action_path }} + run: echo "::add-matcher::$ACTION_PATH/problem-matcher.json" + - name: Run TFLint id: tflint if: steps.target.outputs.skipped != 'true' @@ -146,6 +158,12 @@ runs: --format "$FORMAT" \ --minimum-failure-severity "$MINIMUM_FAILURE_SEVERITY" + - name: Remove Problem Matcher + id: matcher-remove + if: always() && steps.matcher.outcome == 'success' + shell: bash + run: echo "::remove-matcher owner=tflint::" + - name: Add Failure Details to Job Summary id: tflint-summary if: always() && steps.tflint.outcome == 'failure' diff --git a/.github/actions/terraform.tflint/problem-matcher.json b/.github/actions/terraform.tflint/problem-matcher.json new file mode 100644 index 0000000..aad4a34 --- /dev/null +++ b/.github/actions/terraform.tflint/problem-matcher.json @@ -0,0 +1,18 @@ +{ + "problemMatcher": [ + { + "owner": "tflint", + "pattern": [ + { + "regexp": "^(.+?):(\\d+):(\\d+): (Error|Warning|Notice) - (.+) \\((.+)\\)$", + "file": 1, + "line": 2, + "column": 3, + "severity": 4, + "message": 5, + "code": 6 + } + ] + } + ] +} diff --git a/.github/actions/terraform.validate/action.yaml b/.github/actions/terraform.validate/action.yaml index 122f16d..f4b7653 100644 --- a/.github/actions/terraform.validate/action.yaml +++ b/.github/actions/terraform.validate/action.yaml @@ -22,6 +22,10 @@ inputs: required: false default: "false" description: "(Optional) Whether to also validate the query files (`*.tfquery.hcl`) by passing `-query`, which needs Terraform 1.14 or later. Defaults to `false`." + annotations_enabled: + required: false + default: "true" + description: "(Optional) Whether to annotate the failing lines in the pull request when `terraform validate` fails, by re-running it with `-json` and turning each diagnostic into a workflow error or warning. Defaults to `true`." outputs: skipped: @@ -114,6 +118,52 @@ runs: terraform -chdir="$TARGET_DIR" validate "${args[@]}" + # GitHub only renders annotations from `::error` lines, so the diagnostics are re-read as JSON rather than parsed from the text. + - name: Annotate Findings + id: validate-annotate + if: always() && inputs.annotations_enabled == 'true' && steps.validate.outcome == 'failure' + shell: bash + env: + TARGET_DIR: ${{ steps.target.outputs.target_dir }} + WORKSPACE: ${{ inputs.workspace }} + VALIDATE_TESTS: ${{ inputs.validate_tests }} + TEST_DIRECTORY: ${{ inputs.test_directory }} + VALIDATE_QUERY: ${{ inputs.validate_query }} + run: | + if [ -n "$WORKSPACE" ]; then + export TF_WORKSPACE="$WORKSPACE" + fi + + args=(-json) + if [ "$VALIDATE_TESTS" != "true" ]; then + args+=(-no-tests) + elif [ -n "$TEST_DIRECTORY" ]; then + args+=(-test-directory="$TEST_DIRECTORY") + fi + if [ "$VALIDATE_QUERY" = "true" ]; then + args+=(-query) + fi + + prefix="" + if [ "$TARGET_DIR" != "." ]; then + prefix="$TARGET_DIR/" + fi + + # Paths are relative to the module, and a workflow command takes one line, so newlines and the + # property separators are percent-encoded the way the runner decodes them. + terraform -chdir="$TARGET_DIR" validate "${args[@]}" 2>/dev/null | jq -r --arg prefix "$prefix" ' + def esc: gsub("%"; "%25") | gsub("\r"; "%0D") | gsub("\n"; "%0A"); + def prop: esc | gsub(":"; "%3A") | gsub(","; "%2C"); + (.diagnostics // [])[] + | (if .severity == "error" then "error" else "warning" end) as $severity + | (.summary + (if (.detail // "") != "" then "\n\n" + .detail else "" end) | esc) as $message + | if .range then + "::\($severity) file=\($prefix + .range.filename),line=\(.range.start.line),col=\(.range.start.column),endLine=\(.range.end.line),endColumn=\(.range.end.column),title=\(.summary | prop)::\($message)" + else + "::\($severity) title=\(.summary | prop)::\($message)" + end + ' || true + - name: Add Failure Details to Job Summary id: validate-summary if: always() && steps.validate.outcome == 'failure' diff --git a/.github/workflows/test.check-annotations.yaml b/.github/workflows/test.check-annotations.yaml new file mode 100644 index 0000000..428b9e8 --- /dev/null +++ b/.github/workflows/test.check-annotations.yaml @@ -0,0 +1,78 @@ +name: Test - check annotations + + +on: + pull_request: + branches: + - "**" + + +jobs: + test: + name: Test + runs-on: ubuntu-latest + + steps: + - name: Checkout + id: checkout + uses: actions/checkout@v7 + + - name: Set up tools + id: setup-tools + uses: tedilabs/github-actions/.github/actions/mise.setup-tools@main + with: + mise_toml: | + [tools] + terraform = "1.16.3" + tflint = "0.64.0" + actionlint = "latest" + shellcheck = "latest" + + - name: terraform validate (annotated) + id: validate + continue-on-error: true + uses: tedilabs/github-actions/.github/actions/terraform.validate@feat/check-annotations + with: + target_dir: fixtures/annotations/terraform + + - name: terraform validate (annotated, fails at init) + id: validate-init + continue-on-error: true + uses: tedilabs/github-actions/.github/actions/terraform.validate@feat/check-annotations + with: + target_dir: fixtures/annotations/terraform-init + + - name: tflint (annotated) + id: tflint + continue-on-error: true + uses: tedilabs/github-actions/.github/actions/terraform.tflint@feat/check-annotations + with: + target_dir: fixtures/annotations/terraform + + - name: actionlint (annotated) + id: actionlint + continue-on-error: true + uses: tedilabs/github-actions/.github/actions/github-actions.actionlint@feat/check-annotations + with: + target_dir: fixtures/annotations/workflows + + - name: actionlint (annotations disabled, for comparison) + id: actionlint-off + continue-on-error: true + uses: tedilabs/github-actions/.github/actions/github-actions.actionlint@feat/check-annotations + with: + target_dir: fixtures/annotations/workflows + annotations_enabled: "false" + + - name: Outcomes + id: outcomes + env: + VALIDATE: ${{ steps.validate.outcome }}/${{ steps.validate.outputs.exitcode }} + VALIDATE_INIT: ${{ steps.validate-init.outcome }}/${{ steps.validate-init.outputs.exitcode }} + TFLINT: ${{ steps.tflint.outcome }}/${{ steps.tflint.outputs.exitcode }} + ACTIONLINT: ${{ steps.actionlint.outcome }}/${{ steps.actionlint.outputs.exitcode }} + ACTIONLINT_OFF: ${{ steps.actionlint-off.outcome }}/${{ steps.actionlint-off.outputs.exitcode }} + run: | + for v in VALIDATE VALIDATE_INIT TFLINT ACTIONLINT ACTIONLINT_OFF; do printf '%s=<%s>\n' "$v" "${!v}"; done + for v in VALIDATE VALIDATE_INIT TFLINT ACTIONLINT ACTIONLINT_OFF; do [ "${!v%%/*}" = "failure" ] || { echo "FAIL $v should fail on the fixtures"; exit 1; }; done + echo "All five checks failed as intended; the annotations are on the run summary and in the Files changed tab." diff --git a/fixtures/annotations/terraform-init/main.tf b/fixtures/annotations/terraform-init/main.tf new file mode 100644 index 0000000..bf28724 --- /dev/null +++ b/fixtures/annotations/terraform-init/main.tf @@ -0,0 +1,6 @@ +variable "name" { + type = string + +output "greeting" { + value = var.name +} diff --git a/fixtures/annotations/terraform/main.tf b/fixtures/annotations/terraform/main.tf new file mode 100644 index 0000000..219e133 --- /dev/null +++ b/fixtures/annotations/terraform/main.tf @@ -0,0 +1,11 @@ +variable "name" { + type = string +} + +locals { + unused = "never read" +} + +output "greeting" { + value = var.nope +} diff --git a/fixtures/annotations/workflows/bad.yaml b/fixtures/annotations/workflows/bad.yaml new file mode 100644 index 0000000..4e608ca --- /dev/null +++ b/fixtures/annotations/workflows/bad.yaml @@ -0,0 +1,6 @@ +on: push +jobs: + x: + runs-on: my-custom-runner + steps: + - run: echo ${{ github.event.issue.title }}