From d02923159a5d02ff1a9e58524b727fa65a598d14 Mon Sep 17 00:00:00 2001 From: Byungjin Park Date: Mon, 21 Sep 2026 01:11:58 +0900 Subject: [PATCH 1/3] feat(actions): annotate the failing lines from validate, tflint, and actionlint - Add `annotations_enabled` (default `true`) to `terraform.validate`, `terraform.tflint`, and `github-actions.actionlint` - `terraform.validate` re-runs with `-json` after a failure and turns each diagnostic into a `::error` / `::warning` command with the module-relative path prefixed by `target_dir` - `terraform.tflint` (compact format) and `actionlint` register a problem matcher around the check step, since both print `file:line:col` lines that are already repository-relative --- .../github-actions.actionlint/action.yaml | 18 +++++++ .../problem-matcher.json | 17 +++++++ .github/actions/terraform.tflint/action.yaml | 18 +++++++ .../terraform.tflint/problem-matcher.json | 18 +++++++ .../actions/terraform.validate/action.yaml | 50 +++++++++++++++++++ 5 files changed, 121 insertions(+) create mode 100644 .github/actions/github-actions.actionlint/problem-matcher.json create mode 100644 .github/actions/terraform.tflint/problem-matcher.json 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' From 665a1bd8a79b4a73a4de3f0c1b79275364d956f2 Mon Sep 17 00:00:00 2001 From: Byungjin Park Date: Mon, 21 Sep 2026 01:12:03 +0900 Subject: [PATCH 2/3] test: fixtures that fail validate, tflint, and actionlint, to show their annotations --- .github/workflows/test.check-annotations.yaml | 70 +++++++++++++++++++ fixtures/annotations/terraform/main.tf | 11 +++ .../terraform/tests/bad.tftest.hcl | 6 ++ fixtures/annotations/workflows/bad.yaml | 6 ++ 4 files changed, 93 insertions(+) create mode 100644 .github/workflows/test.check-annotations.yaml create mode 100644 fixtures/annotations/terraform/main.tf create mode 100644 fixtures/annotations/terraform/tests/bad.tftest.hcl create mode 100644 fixtures/annotations/workflows/bad.yaml diff --git a/.github/workflows/test.check-annotations.yaml b/.github/workflows/test.check-annotations.yaml new file mode 100644 index 0000000..cd187c9 --- /dev/null +++ b/.github/workflows/test.check-annotations.yaml @@ -0,0 +1,70 @@ +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: 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 }} + 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 TFLINT ACTIONLINT ACTIONLINT_OFF; do printf '%s=<%s>\n' "$v" "${!v}"; done + for v in VALIDATE TFLINT ACTIONLINT ACTIONLINT_OFF; do [ "${!v%%/*}" = "failure" ] || { echo "FAIL $v should fail on the fixtures"; exit 1; }; done + echo "All four checks failed as intended; the annotations are on the run summary and in the Files changed tab." 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/terraform/tests/bad.tftest.hcl b/fixtures/annotations/terraform/tests/bad.tftest.hcl new file mode 100644 index 0000000..c2f84ab --- /dev/null +++ b/fixtures/annotations/terraform/tests/bad.tftest.hcl @@ -0,0 +1,6 @@ +run "bad" { + command = plan + assert { + condition = var.name == "x" + } +} 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 }} From 58dac924680ce7f8b9e157777e7357b8adcf9b10 Mon Sep 17 00:00:00 2001 From: Byungjin Park Date: Mon, 21 Sep 2026 01:14:44 +0900 Subject: [PATCH 3/3] test: split the fixtures into an init-time and a validate-time failure --- .github/workflows/test.check-annotations.yaml | 14 +++++++++++--- fixtures/annotations/terraform-init/main.tf | 6 ++++++ .../annotations/terraform/tests/bad.tftest.hcl | 6 ------ 3 files changed, 17 insertions(+), 9 deletions(-) create mode 100644 fixtures/annotations/terraform-init/main.tf delete mode 100644 fixtures/annotations/terraform/tests/bad.tftest.hcl diff --git a/.github/workflows/test.check-annotations.yaml b/.github/workflows/test.check-annotations.yaml index cd187c9..428b9e8 100644 --- a/.github/workflows/test.check-annotations.yaml +++ b/.github/workflows/test.check-annotations.yaml @@ -35,6 +35,13 @@ jobs: 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 @@ -61,10 +68,11 @@ jobs: 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 TFLINT ACTIONLINT ACTIONLINT_OFF; do printf '%s=<%s>\n' "$v" "${!v}"; done - for v in VALIDATE TFLINT ACTIONLINT ACTIONLINT_OFF; do [ "${!v%%/*}" = "failure" ] || { echo "FAIL $v should fail on the fixtures"; exit 1; }; done - echo "All four checks failed as intended; the annotations are on the run summary and in the Files changed tab." + 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/tests/bad.tftest.hcl b/fixtures/annotations/terraform/tests/bad.tftest.hcl deleted file mode 100644 index c2f84ab..0000000 --- a/fixtures/annotations/terraform/tests/bad.tftest.hcl +++ /dev/null @@ -1,6 +0,0 @@ -run "bad" { - command = plan - assert { - condition = var.name == "x" - } -}