diff --git a/.github/actions/github-actions.actionlint/action.yaml b/.github/actions/github-actions.actionlint/action.yaml index 74ca76e..6b29361 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 when `actionlint` fails, by re-running it with a JSON format and turning each finding into a workflow error. Defaults to `true`." outputs: skipped: @@ -88,6 +92,35 @@ runs: readarray -t files <<< "$FILES" actionlint "${args[@]}" "${files[@]}" + # 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 + 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 if: always() && steps.actionlint.outcome == 'failure' diff --git a/.github/actions/terraform.tflint/action.yaml b/.github/actions/terraform.tflint/action.yaml index 0af0c01..4a2e10f 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 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: @@ -146,6 +150,40 @@ runs: --format "$FORMAT" \ --minimum-failure-severity "$MINIMUM_FAILURE_SEVERITY" + # 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 + 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 if: always() && steps.tflint.outcome == 'failure' diff --git a/.github/actions/terraform.validate/action.yaml b/.github/actions/terraform.validate/action.yaml index 122f16d..a0e5770 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 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: @@ -114,6 +118,61 @@ 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. 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.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 }} + TEST_DIRECTORY: ${{ inputs.test_directory }} + VALIDATE_QUERY: ${{ inputs.validate_query }} + run: | + if [ -n "$WORKSPACE" ]; then + export TF_WORKSPACE="$WORKSPACE" + 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) + elif [ -n "$TEST_DIRECTORY" ]; then + args+=(-test-directory="$TEST_DIRECTORY") + fi + if [ "$VALIDATE_QUERY" = "true" ]; then + args+=(-query) + fi + filter='.diagnostics[]?' + 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" "${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=\($tool + " · " + .summary | prop)::\($message)" + else + "::\($severity) title=\($tool + " · " + .summary | prop)::\($message)" + end + ' || true + - name: Add Failure Details to Job Summary id: validate-summary if: always() && steps.validate.outcome == 'failure'