From 666939398d2a6a4f812f4866a2d25a9fd0b5a037 Mon Sep 17 00:00:00 2001 From: Byungjin Park Date: Mon, 21 Sep 2026 01:55:45 +0900 Subject: [PATCH 1/2] feat(actions): annotate findings from terraform fmt, terraform-docs, and check-jsonschema - Add `annotations_enabled` (default `true`) to the three remaining check actions on the same pattern as validate, tflint, and actionlint - `terraform.fmt` reads the file and hunk line from the diff it captured; `terraform.docs` reads the stale file from its `is out of date` line; both prefix `target_dir` since the tools print module-relative paths - `github-actions.check-jsonschema` re-runs with `--output-format json`; schema validation has no line numbers, so the annotation points at the file and names the failing JSON path --- .../action.yaml | 25 +++++++++++++++++ .github/actions/terraform.docs/action.yaml | 22 +++++++++++++++ .github/actions/terraform.fmt/action.yaml | 27 +++++++++++++++++++ 3 files changed, 74 insertions(+) diff --git a/.github/actions/github-actions.check-jsonschema/action.yaml b/.github/actions/github-actions.check-jsonschema/action.yaml index 3b7dd9b..f40a8fa 100644 --- a/.github/actions/github-actions.check-jsonschema/action.yaml +++ b/.github/actions/github-actions.check-jsonschema/action.yaml @@ -9,6 +9,10 @@ inputs: target_dir: required: false description: "(Optional) The directory to validate. Defaults to `.github/workflows` for `schema_type: workflows` and to `.github/actions` for `schema_type: actions`." + annotations_enabled: + required: false + default: "true" + description: "(Optional) Whether to annotate the failing files in the pull request when the validation fails, by re-running `check-jsonschema` with `--output-format json`. Schema validation reports no line numbers, so the annotation points at the file and names the failing JSON path. Defaults to `true`." outputs: skipped: @@ -94,6 +98,27 @@ runs: readarray -t files <<< "$FILES" check-jsonschema --builtin-schema "$SCHEMA" "${files[@]}" + # GitHub only renders annotations from `::error` lines, so the errors are re-read as JSON. The file names are + # the ones passed in, which are repository-relative already. + - name: Annotate Findings + id: validate-annotate + if: always() && inputs.annotations_enabled == 'true' && steps.validate.outcome == 'failure' + shell: bash + env: + SCHEMA_TYPE: ${{ inputs.schema_type }} + SCHEMA: ${{ steps.target.outputs.schema }} + FILES: ${{ steps.target.outputs.files }} + run: | + # 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" + check-jsonschema --builtin-schema "$SCHEMA" --output-format json "${files[@]}" 2>/dev/null | jq -r --arg type "$SCHEMA_TYPE" ' + def esc: gsub("%"; "%25") | gsub("\r"; "%0D") | gsub("\n"; "%0A"); + def prop: esc | gsub(":"; "%3A") | gsub(","; "%2C"); + (.errors // [])[] + | "::error file=\(.filename),title=\("check-jsonschema · " + $type | prop)::\(.path + ": " + .message | esc)" + ' || true + - name: Add Failure Details to Job Summary id: validate-summary if: always() && steps.validate.outcome == 'failure' diff --git a/.github/actions/terraform.docs/action.yaml b/.github/actions/terraform.docs/action.yaml index e7b9096..914cb25 100644 --- a/.github/actions/terraform.docs/action.yaml +++ b/.github/actions/terraform.docs/action.yaml @@ -40,6 +40,10 @@ inputs: args: required: false description: "(Optional) Additional arguments to pass to `terraform-docs` (e.g. `--sort-by required`)." + annotations_enabled: + required: false + default: "true" + description: "(Optional) Whether to annotate the out-of-date documentation file in the pull request when the check fails. Only applies with `check: true`. Defaults to `true`." outputs: skipped: @@ -136,6 +140,24 @@ runs: terraform-docs "${args[@]}" . + # `terraform-docs` names the stale file (`Error: is out of date`), relative to the module it ran in. + - name: Annotate Findings + id: docs-annotate + if: always() && inputs.annotations_enabled == 'true' && inputs.check == 'true' && steps.docs.outcome == 'failure' + shell: bash + env: + TARGET_DIR: ${{ steps.target.outputs.target_dir }} + LOG_FILE: ${{ steps.docs.outputs.log_file }} + run: | + prefix="" + if [ "$TARGET_DIR" != "." ]; then + prefix="$TARGET_DIR/" + fi + + sed -n 's/^Error: \(.*\) is out of date$/\1/p' "$LOG_FILE" | while IFS= read -r file; do + echo "::error file=$prefix$file,title=terraform-docs · Out of date::Regenerate this file with \`terraform-docs\`." + done + - name: Add Failure Details to Job Summary id: docs-summary if: always() && steps.docs.outcome == 'failure' diff --git a/.github/actions/terraform.fmt/action.yaml b/.github/actions/terraform.fmt/action.yaml index af686a4..b8ab640 100644 --- a/.github/actions/terraform.fmt/action.yaml +++ b/.github/actions/terraform.fmt/action.yaml @@ -11,6 +11,10 @@ inputs: required: false default: "true" description: "(Optional) Whether to also check subdirectories. Defaults to `true`." + annotations_enabled: + required: false + default: "true" + description: "(Optional) Whether to annotate the unformatted lines in the pull request when `terraform fmt` fails, from the hunks of the diff it printed. Defaults to `true`." outputs: skipped: @@ -66,6 +70,29 @@ runs: terraform -chdir="$TARGET_DIR" fmt "${args[@]}" + # The diff already names each file (`+++ new/`) and the first line of every hunk (`@@ -a,b +c,d @@`), so + # the annotations are read from the captured output. Paths are relative to `target_dir`. + - name: Annotate Findings + id: fmt-annotate + if: always() && inputs.annotations_enabled == 'true' && steps.fmt.outcome == 'failure' + shell: bash + env: + TARGET_DIR: ${{ steps.target.outputs.target_dir }} + LOG_FILE: ${{ steps.fmt.outputs.log_file }} + run: | + prefix="" + if [ "$TARGET_DIR" != "." ]; then + prefix="$TARGET_DIR/" + fi + + awk -v prefix="$prefix" ' + /^\+\+\+ new\// { file = substr($0, 9); next } + /^@@ / && file != "" { + line = $3; sub(/^\+/, "", line); sub(/,.*/, "", line) + printf "::error file=%s%s,line=%s,title=terraform fmt · Not formatted::Run `terraform fmt` to format this file.\n", prefix, file, line + } + ' "$LOG_FILE" + - name: Add Failure Details to Job Summary id: fmt-summary if: always() && steps.fmt.outcome == 'failure' From ed51f8c4cacf14b64d7e91499b48de8582da056a Mon Sep 17 00:00:00 2001 From: Byungjin Park Date: Mon, 21 Sep 2026 01:55:50 +0900 Subject: [PATCH 2/2] test: fixtures that fail fmt, docs, and check-jsonschema, to show their annotations --- .../test.check-annotations-remaining.yaml | 81 +++++++++++++++++++ fixtures/annotations2/actions/bad/action.yaml | 3 + fixtures/annotations2/docs/README.md | 1 + fixtures/annotations2/docs/main.tf | 3 + .../annotations2/docs/modules/a/README.md | 1 + fixtures/annotations2/docs/modules/a/main.tf | 1 + fixtures/annotations2/tf/main.tf | 7 ++ fixtures/annotations2/tf/sub/out.tf | 3 + fixtures/annotations2/workflows/bad.yaml | 6 ++ 9 files changed, 106 insertions(+) create mode 100644 .github/workflows/test.check-annotations-remaining.yaml create mode 100644 fixtures/annotations2/actions/bad/action.yaml create mode 100644 fixtures/annotations2/docs/README.md create mode 100644 fixtures/annotations2/docs/main.tf create mode 100644 fixtures/annotations2/docs/modules/a/README.md create mode 100644 fixtures/annotations2/docs/modules/a/main.tf create mode 100644 fixtures/annotations2/tf/main.tf create mode 100644 fixtures/annotations2/tf/sub/out.tf create mode 100644 fixtures/annotations2/workflows/bad.yaml diff --git a/.github/workflows/test.check-annotations-remaining.yaml b/.github/workflows/test.check-annotations-remaining.yaml new file mode 100644 index 0000000..ad97ed9 --- /dev/null +++ b/.github/workflows/test.check-annotations-remaining.yaml @@ -0,0 +1,81 @@ +name: Test - check annotations remaining + + +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" + terraform-docs = "0.24.0" + "pipx:check-jsonschema" = "latest" + + - name: terraform fmt (annotated) + id: fmt + continue-on-error: true + uses: tedilabs/github-actions/.github/actions/terraform.fmt@feat/check-annotations-remaining + with: + target_dir: fixtures/annotations2/tf + + - name: terraform-docs (annotated, recursive) + id: docs + continue-on-error: true + uses: tedilabs/github-actions/.github/actions/terraform.docs@feat/check-annotations-remaining + with: + target_dir: fixtures/annotations2/docs + check: "true" + recursive: "true" + + - name: check-jsonschema workflows (annotated) + id: cj-workflows + continue-on-error: true + uses: tedilabs/github-actions/.github/actions/github-actions.check-jsonschema@feat/check-annotations-remaining + with: + schema_type: workflows + target_dir: fixtures/annotations2/workflows + + - name: check-jsonschema actions (annotated) + id: cj-actions + continue-on-error: true + uses: tedilabs/github-actions/.github/actions/github-actions.check-jsonschema@feat/check-annotations-remaining + with: + schema_type: actions + target_dir: fixtures/annotations2/actions + + - name: terraform fmt (annotations disabled, for comparison) + id: fmt-off + continue-on-error: true + uses: tedilabs/github-actions/.github/actions/terraform.fmt@feat/check-annotations-remaining + with: + target_dir: fixtures/annotations2/tf + annotations_enabled: "false" + + - name: Outcomes + id: outcomes + env: + FMT: ${{ steps.fmt.outcome }}/${{ steps.fmt.outputs.exitcode }} + DOCS: ${{ steps.docs.outcome }}/${{ steps.docs.outputs.exitcode }} + CJ_WORKFLOWS: ${{ steps.cj-workflows.outcome }}/${{ steps.cj-workflows.outputs.exitcode }} + CJ_ACTIONS: ${{ steps.cj-actions.outcome }}/${{ steps.cj-actions.outputs.exitcode }} + FMT_OFF: ${{ steps.fmt-off.outcome }}/${{ steps.fmt-off.outputs.exitcode }} + run: | + for v in FMT DOCS CJ_WORKFLOWS CJ_ACTIONS FMT_OFF; do printf '%s=<%s>\n' "$v" "${!v}"; done + for v in FMT DOCS CJ_WORKFLOWS CJ_ACTIONS FMT_OFF; do [ "${!v%%/*}" = "failure" ] || { echo "FAIL $v should fail on the fixtures"; exit 1; }; done + echo "All five checks failed as intended." diff --git a/fixtures/annotations2/actions/bad/action.yaml b/fixtures/annotations2/actions/bad/action.yaml new file mode 100644 index 0000000..c0f87a7 --- /dev/null +++ b/fixtures/annotations2/actions/bad/action.yaml @@ -0,0 +1,3 @@ +name: bad +runs: + using: composite diff --git a/fixtures/annotations2/docs/README.md b/fixtures/annotations2/docs/README.md new file mode 100644 index 0000000..9db5694 --- /dev/null +++ b/fixtures/annotations2/docs/README.md @@ -0,0 +1 @@ +# stale diff --git a/fixtures/annotations2/docs/main.tf b/fixtures/annotations2/docs/main.tf new file mode 100644 index 0000000..77e5cc9 --- /dev/null +++ b/fixtures/annotations2/docs/main.tf @@ -0,0 +1,3 @@ +variable "name" { + type = string +} diff --git a/fixtures/annotations2/docs/modules/a/README.md b/fixtures/annotations2/docs/modules/a/README.md new file mode 100644 index 0000000..9db5694 --- /dev/null +++ b/fixtures/annotations2/docs/modules/a/README.md @@ -0,0 +1 @@ +# stale diff --git a/fixtures/annotations2/docs/modules/a/main.tf b/fixtures/annotations2/docs/modules/a/main.tf new file mode 100644 index 0000000..b5a7160 --- /dev/null +++ b/fixtures/annotations2/docs/modules/a/main.tf @@ -0,0 +1 @@ +variable "a" {} diff --git a/fixtures/annotations2/tf/main.tf b/fixtures/annotations2/tf/main.tf new file mode 100644 index 0000000..7a065a0 --- /dev/null +++ b/fixtures/annotations2/tf/main.tf @@ -0,0 +1,7 @@ +variable "name" { + type = string +} + +locals { + x = 1 +} diff --git a/fixtures/annotations2/tf/sub/out.tf b/fixtures/annotations2/tf/sub/out.tf new file mode 100644 index 0000000..f61ccbf --- /dev/null +++ b/fixtures/annotations2/tf/sub/out.tf @@ -0,0 +1,3 @@ +output "x" { + value = 1 +} diff --git a/fixtures/annotations2/workflows/bad.yaml b/fixtures/annotations2/workflows/bad.yaml new file mode 100644 index 0000000..20229b4 --- /dev/null +++ b/fixtures/annotations2/workflows/bad.yaml @@ -0,0 +1,6 @@ +name: bad +on: push +jobs: + x: + runs-on: ubuntu-latest + steps: not-a-list