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' 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