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 84ddbd3a0785ec81a01b62e14b05e206bfe6a950 Mon Sep 17 00:00:00 2001 From: Byungjin Park Date: Mon, 21 Sep 2026 01:14:41 +0900 Subject: [PATCH 2/3] feat(actions): annotate terraform init diagnostics as well - A syntax error or a broken test file fails `terraform init`, which loads the configuration, so `validate` never runs; re-run `init -json` in that case and feed its diagnostics through the same formatter --- .../actions/terraform.validate/action.yaml | 35 +++++++++++-------- 1 file changed, 21 insertions(+), 14 deletions(-) diff --git a/.github/actions/terraform.validate/action.yaml b/.github/actions/terraform.validate/action.yaml index f4b7653..5cfe4f3 100644 --- a/.github/actions/terraform.validate/action.yaml +++ b/.github/actions/terraform.validate/action.yaml @@ -25,7 +25,7 @@ inputs: 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`." + description: "(Optional) Whether to annotate the failing lines in the pull request when `terraform init` or `terraform validate` fails, by re-running the failed command with `-json` and turning each diagnostic into a workflow error or warning. Defaults to `true`." outputs: skipped: @@ -118,12 +118,14 @@ 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. + # GitHub only renders annotations from `::error` lines, so the diagnostics are re-read as JSON rather than parsed + # from the text. A syntax error fails `init`, which loads the configuration, so both commands are covered. - name: Annotate Findings id: validate-annotate - if: always() && inputs.annotations_enabled == 'true' && steps.validate.outcome == 'failure' + if: always() && inputs.annotations_enabled == 'true' && (steps.init.outcome == 'failure' || steps.validate.outcome == 'failure') shell: bash env: + INIT_OUTCOME: ${{ steps.init.outcome }} TARGET_DIR: ${{ steps.target.outputs.target_dir }} WORKSPACE: ${{ inputs.workspace }} VALIDATE_TESTS: ${{ inputs.validate_tests }} @@ -134,14 +136,20 @@ runs: 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) + if [ "$INIT_OUTCOME" = "failure" ]; then + args=(init -backend=false -input=false -json) + filter='select(.type == "diagnostic") | .diagnostic' + else + args=(validate -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 + filter='.diagnostics[]?' fi prefix="" @@ -151,11 +159,10 @@ runs: # 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" ' + terraform -chdir="$TARGET_DIR" "${args[@]}" 2>/dev/null | jq -c "$filter" | 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 + (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)" From 0eb5454e5c91d658ee1d8c7f765aed1be2ac0181 Mon Sep 17 00:00:00 2001 From: Byungjin Park Date: Mon, 21 Sep 2026 01:45:13 +0900 Subject: [PATCH 3/3] refactor(actions): name the tool in every annotation and drop the problem matchers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Re-run `tflint --format json` and `actionlint -format '{{json .}}'` after a failure and emit `::error` / `::warning` / `::notice` commands, as `terraform.validate` already does, instead of registering problem matchers - Put the tool and the rule or summary in the annotation title (`tflint · terraform_unused_declarations`, `actionlint · runner-label`, `terraform validate · Reference to undeclared input variable`); a problem matcher cannot set a title - Remove the two `problem-matcher.json` files and the register/remove steps --- .../github-actions.actionlint/action.yaml | 41 +++++++++++------ .../problem-matcher.json | 17 ------- .github/actions/terraform.tflint/action.yaml | 46 +++++++++++++------ .../terraform.tflint/problem-matcher.json | 18 -------- .../actions/terraform.validate/action.yaml | 8 ++-- 5 files changed, 66 insertions(+), 64 deletions(-) delete mode 100644 .github/actions/github-actions.actionlint/problem-matcher.json delete 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 b7cb7c7..6b29361 100644 --- a/.github/actions/github-actions.actionlint/action.yaml +++ b/.github/actions/github-actions.actionlint/action.yaml @@ -17,7 +17,7 @@ inputs: 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`." + description: "(Optional) Whether to annotate the reported lines in the pull request when `actionlint` fails, by re-running it with a JSON format and turning each finding into a workflow error. Defaults to `true`." outputs: skipped: @@ -70,14 +70,6 @@ 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' @@ -100,11 +92,34 @@ runs: readarray -t files <<< "$FILES" actionlint "${args[@]}" "${files[@]}" - - name: Remove Problem Matcher - id: matcher-remove - if: always() && steps.matcher.outcome == 'success' + # GitHub only renders annotations from `::error` lines, so the findings are re-read as JSON. The files are passed + # repository-relative, and `actionlint` reports them as given. + - name: Annotate Findings + id: actionlint-annotate + if: always() && inputs.annotations_enabled == 'true' && steps.actionlint.outcome == 'failure' shell: bash - run: echo "::remove-matcher owner=actionlint::" + env: + FILES: ${{ steps.target.outputs.files }} + CONFIG: ${{ steps.target.outputs.config }} + SHELLCHECK_ENABLED: ${{ inputs.shellcheck_enabled }} + run: | + args=(-no-color -format '{{json .}}') + if [ -n "$CONFIG" ]; then + args+=(-config-file "$CONFIG") + fi + if [ "$SHELLCHECK_ENABLED" != "true" ]; then + args+=(-shellcheck=) + fi + + # A workflow command takes one line, so newlines and the property separators are percent-encoded the way + # the runner decodes them. + readarray -t files <<< "$FILES" + actionlint "${args[@]}" "${files[@]}" 2>/dev/null | jq -r ' + def esc: gsub("%"; "%25") | gsub("\r"; "%0D") | gsub("\n"; "%0A"); + def prop: esc | gsub(":"; "%3A") | gsub(","; "%2C"); + .[] + | "::error file=\(.filepath),line=\(.line),col=\(.column)\(if (.end_column // 0) > 0 then ",endColumn=\(.end_column)" else "" end),title=\("actionlint · " + .kind | prop)::\(.message | esc)" + ' || true - name: Add Failure Details to Job Summary id: actionlint-summary diff --git a/.github/actions/github-actions.actionlint/problem-matcher.json b/.github/actions/github-actions.actionlint/problem-matcher.json deleted file mode 100644 index 3ef266e..0000000 --- a/.github/actions/github-actions.actionlint/problem-matcher.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "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 519ca96..4a2e10f 100644 --- a/.github/actions/terraform.tflint/action.yaml +++ b/.github/actions/terraform.tflint/action.yaml @@ -34,7 +34,7 @@ inputs: 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`." + description: "(Optional) Whether to annotate the reported lines in the pull request when `tflint` fails, by re-running it with `--format json` and turning each issue into a workflow error, warning, or notice. Defaults to `true`." outputs: skipped: @@ -126,14 +126,6 @@ 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' @@ -158,11 +150,39 @@ runs: --format "$FORMAT" \ --minimum-failure-severity "$MINIMUM_FAILURE_SEVERITY" - - name: Remove Problem Matcher - id: matcher-remove - if: always() && steps.matcher.outcome == 'success' + # GitHub only renders annotations from `::error` lines, so the issues are re-read as JSON. The paths in that + # output are relative to the working directory, so they already point into the repository. + - name: Annotate Findings + id: tflint-annotate + if: always() && inputs.annotations_enabled == 'true' && steps.tflint.outcome == 'failure' shell: bash - run: echo "::remove-matcher owner=tflint::" + env: + TARGET_DIR: ${{ steps.target.outputs.target_dir }} + CONFIG: ${{ steps.target.outputs.config }} + RECURSIVE: ${{ inputs.recursive }} + run: | + args=(--chdir "$TARGET_DIR") + if [ -n "$CONFIG" ]; then + args+=(--config "$CONFIG") + fi + if [ "$RECURSIVE" = "true" ]; then + args+=(--recursive) + fi + + # A workflow command takes one line, so newlines and the property separators are percent-encoded the way + # the runner decodes them. + tflint "${args[@]}" --format json 2>/dev/null | jq -r ' + def esc: gsub("%"; "%25") | gsub("\r"; "%0D") | gsub("\n"; "%0A"); + def prop: esc | gsub(":"; "%3A") | gsub(","; "%2C"); + (.issues[] | {severity: .rule.severity, title: ("tflint · " + .rule.name), message, range}), + (.errors[] | {severity: (.severity // "error"), title: "tflint", message, range}) + | (if .severity == "error" then "error" elif .severity == "warning" then "warning" else "notice" end) as $severity + | if .range then + "::\($severity) file=\(.range.filename),line=\(.range.start.line),col=\(.range.start.column),endLine=\(.range.end.line),endColumn=\(.range.end.column),title=\(.title | prop)::\(.message | esc)" + else + "::\($severity) title=\(.title | prop)::\(.message | esc)" + end + ' || true - name: Add Failure Details to Job Summary id: tflint-summary diff --git a/.github/actions/terraform.tflint/problem-matcher.json b/.github/actions/terraform.tflint/problem-matcher.json deleted file mode 100644 index aad4a34..0000000 --- a/.github/actions/terraform.tflint/problem-matcher.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "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 5cfe4f3..a0e5770 100644 --- a/.github/actions/terraform.validate/action.yaml +++ b/.github/actions/terraform.validate/action.yaml @@ -137,9 +137,11 @@ runs: fi if [ "$INIT_OUTCOME" = "failure" ]; then + tool="terraform init" args=(init -backend=false -input=false -json) filter='select(.type == "diagnostic") | .diagnostic' else + tool="terraform validate" args=(validate -json) if [ "$VALIDATE_TESTS" != "true" ]; then args+=(-no-tests) @@ -159,15 +161,15 @@ runs: # 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" "${args[@]}" 2>/dev/null | jq -c "$filter" | jq -r --arg prefix "$prefix" ' + terraform -chdir="$TARGET_DIR" "${args[@]}" 2>/dev/null | jq -c "$filter" | jq -r --arg prefix "$prefix" --arg tool "$tool" ' def esc: gsub("%"; "%25") | gsub("\r"; "%0D") | gsub("\n"; "%0A"); def prop: esc | gsub(":"; "%3A") | gsub(","; "%2C"); (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)" + "::\($severity) file=\($prefix + .range.filename),line=\(.range.start.line),col=\(.range.start.column),endLine=\(.range.end.line),endColumn=\(.range.end.column),title=\($tool + " · " + .summary | prop)::\($message)" else - "::\($severity) title=\(.summary | prop)::\($message)" + "::\($severity) title=\($tool + " · " + .summary | prop)::\($message)" end ' || true