From 5b3607ec29972dcb76e47aaf8d863867682f4dd7 Mon Sep 17 00:00:00 2001 From: Byungjin Park Date: Wed, 16 Sep 2026 13:22:54 +0900 Subject: [PATCH 01/18] feat(actions): add misc.slug composite action - Turn an arbitrary string into an artifact-safe slug - Append a short digest so two different values never collide --- .github/actions/misc.slug/action.yaml | 44 +++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 .github/actions/misc.slug/action.yaml diff --git a/.github/actions/misc.slug/action.yaml b/.github/actions/misc.slug/action.yaml new file mode 100644 index 0000000..5be71e7 --- /dev/null +++ b/.github/actions/misc.slug/action.yaml @@ -0,0 +1,44 @@ +name: MISC - Slug +description: Turn an arbitrary string into a slug that is safe to use as an artifact or file name, keeping it unique with a short digest of the original. + + +inputs: + value: + required: true + description: "(Required) The string to turn into a slug (e.g. `account/iam / master`)." + prefix: + required: false + description: "(Optional) A prefix prepended to the slug, separated by a hyphen (e.g. `terraform-plan`)." + +outputs: + slug: + value: ${{ steps.slug.outputs.slug }} + description: "The slug. Every character outside `A-Za-z0-9._-` is replaced by a hyphen, and a short digest of the original value is appended so two different inputs never collide." + + +runs: + using: composite + + steps: + - name: Build Slug + id: slug + shell: bash + env: + VALUE: ${{ inputs.value }} + PREFIX: ${{ inputs.prefix }} + run: | + if [ -z "$VALUE" ]; then + echo "::error::Input 'value' is required." + exit 1 + fi + + body="$(printf '%s' "$VALUE" | tr -c 'A-Za-z0-9._-' '-')" + digest="$(printf '%s' "$VALUE" | shasum | cut -c1-8)" + + slug="$body-$digest" + if [ -n "$PREFIX" ]; then + slug="$PREFIX-$slug" + fi + + echo "Slug for '$VALUE': $slug" + echo "slug=$slug" >> "$GITHUB_OUTPUT" From 9a9a61cc3c0f36e40b262d3ca394434e084462cb Mon Sep 17 00:00:00 2001 From: Byungjin Park Date: Wed, 16 Sep 2026 13:23:00 +0900 Subject: [PATCH 02/18] feat(actions): add terraform.plan composite action - Run `terraform plan -out` with `-detailed-exitcode` and expose whether the workspace has changes - Build the summary from `terraform show -json`, never from the log text, and render it as a Markdown table of resource actions - Truncate the resource list at `max_resources` so one workspace cannot fill a pull request comment - Fall back to a plan without `-out` when the backend refuses to save one, which is what a workspace with remote execution does, and say so --- .github/actions/terraform.plan/action.yaml | 202 +++++++++++++++++++++ 1 file changed, 202 insertions(+) create mode 100644 .github/actions/terraform.plan/action.yaml diff --git a/.github/actions/terraform.plan/action.yaml b/.github/actions/terraform.plan/action.yaml new file mode 100644 index 0000000..416b711 --- /dev/null +++ b/.github/actions/terraform.plan/action.yaml @@ -0,0 +1,202 @@ +name: Terraform - Plan +description: Run `terraform plan` against a workspace, save the plan file, and render a readable summary of what would change. On failure, the output is added to the job summary. Requires the `terraform` CLI on the `PATH`. + + +inputs: + target_dir: + required: false + default: ./ + description: "(Optional) The Terraform project directory to plan. Defaults to `./`." + workspace: + required: false + description: "(Optional) The Terraform workspace to plan against, exported as `TF_WORKSPACE`. Defaults to Terraform's `default` workspace." + plan_file: + required: false + description: "(Optional) The path the plan file is written to. Defaults to a file under `RUNNER_TEMP`, which keeps it out of the checkout so it is never committed or linted." + args: + required: false + description: "(Optional) Additional arguments to pass to `terraform plan` (e.g. `-refresh=false`)." + max_resources: + required: false + default: "50" + description: "(Optional) The maximum number of resource changes listed in the summary. Beyond it the list is truncated with a note, so one large workspace cannot fill a pull request comment. Defaults to `50`." + +outputs: + has_changes: + value: ${{ steps.plan.outputs.has_changes }} + description: "Whether the plan contains changes to apply. `true` or `false`." + plan_file: + value: ${{ steps.plan.outputs.plan_file }} + description: "The path of the saved plan file. Empty when the plan failed, or when the backend refused to save one." + plan_file_saved: + value: ${{ steps.plan.outputs.plan_file_saved }} + description: "Whether a plan file was saved. `false` when the workspace runs with remote execution, where the plan cannot be saved locally and so cannot be applied later." + create: + value: ${{ steps.plan.outputs.create }} + description: "The number of resources to be created." + update: + value: ${{ steps.plan.outputs.update }} + description: "The number of resources to be updated in place." + destroy: + value: ${{ steps.plan.outputs.destroy }} + description: "The number of resources to be destroyed." + replace: + value: ${{ steps.plan.outputs.replace }} + description: "The number of resources to be replaced." + summary: + value: ${{ steps.plan.outputs.summary }} + description: "A Markdown summary of the plan, suitable for a job summary or a pull request comment." + + +runs: + using: composite + + steps: + - name: Terraform Init + id: init + shell: bash + env: + TARGET_DIR: ${{ inputs.target_dir }} + WORKSPACE: ${{ inputs.workspace }} + run: | + if [ -n "$WORKSPACE" ]; then + export TF_WORKSPACE="$WORKSPACE" + fi + + terraform -chdir="$TARGET_DIR" init -input=false -no-color 2>&1 | tee "$RUNNER_TEMP/terraform-plan-init.log" + + - name: Add Failure Details to Job Summary + id: init-summary + if: steps.init.outcome == 'failure' + uses: tedilabs/github-actions/.github/actions/github.step-summary@main + with: + title: "❌ terraform init · ${{ inputs.target_dir }}${{ inputs.workspace && format(' ({0})', inputs.workspace) || '' }}" + file: ${{ runner.temp }}/terraform-plan-init.log + lang: text + + - name: Terraform Plan + id: plan + shell: bash + env: + TARGET_DIR: ${{ inputs.target_dir }} + WORKSPACE: ${{ inputs.workspace }} + PLAN_FILE: ${{ inputs.plan_file }} + ARGS: ${{ inputs.args }} + MAX_RESOURCES: ${{ inputs.max_resources }} + run: | + if [ -n "$WORKSPACE" ]; then + export TF_WORKSPACE="$WORKSPACE" + fi + + plan_file="${PLAN_FILE:-$RUNNER_TEMP/terraform.tfplan}" + log="$RUNNER_TEMP/terraform-plan.log" + read -ra extra_args <<< "$ARGS" + + # `-detailed-exitcode` returns 0 for no changes, 1 for an error, and 2 when there are changes. + set +e + terraform -chdir="$TARGET_DIR" plan \ + -out="$plan_file" \ + -input=false \ + -lock=false \ + -no-color \ + -detailed-exitcode \ + "${extra_args[@]}" 2>&1 | tee "$log" + exit_code="${PIPESTATUS[0]}" + set -e + + # A workspace with remote execution cannot save a plan file. Fall back to a plan without + # `-out` so the change is still reported, and mark it as not applicable from a plan file. + plan_file_saved=true + if [ "$exit_code" -eq 1 ] && grep -q "not support saving" "$log"; then + echo "::warning::The backend refuses to save a plan file, which means remote execution. Re-planning without one; this workspace cannot be applied from a saved plan." + plan_file_saved=false + plan_file="" + set +e + terraform -chdir="$TARGET_DIR" plan \ + -input=false \ + -lock=false \ + -no-color \ + -detailed-exitcode \ + "${extra_args[@]}" 2>&1 | tee "$log" + exit_code="${PIPESTATUS[0]}" + set -e + fi + + { + echo "plan_file=$plan_file" + echo "plan_file_saved=$plan_file_saved" + } >> "$GITHUB_OUTPUT" + + if [ "$exit_code" -eq 1 ]; then + echo "::error::terraform plan failed." + exit 1 + fi + + if [ "$exit_code" -eq 2 ]; then + has_changes=true + else + has_changes=false + fi + echo "has_changes=$has_changes" >> "$GITHUB_OUTPUT" + + # Counts and the resource list come from the machine-readable plan, never from the log text. + if [ -n "$plan_file" ]; then + terraform -chdir="$TARGET_DIR" show -json "$plan_file" > "$RUNNER_TEMP/terraform-plan.json" + else + echo '{}' > "$RUNNER_TEMP/terraform-plan.json" + fi + + counts="$(jq -c ' + [(.resource_changes // [])[] | .change.actions] as $a + | { + create: ($a | map(select(. == ["create"])) | length), + update: ($a | map(select(. == ["update"])) | length), + destroy: ($a | map(select(. == ["delete"])) | length), + replace: ($a | map(select(. == ["delete","create"] or . == ["create","delete"])) | length) + } + ' "$RUNNER_TEMP/terraform-plan.json")" + jq -r 'to_entries[] | "\(.key)=\(.value)"' <<< "$counts" >> "$GITHUB_OUTPUT" + + if [ -z "$plan_file" ]; then + # Without a plan file there is nothing machine-readable to summarise, so say so plainly + # rather than rendering an empty table that would read as "no changes". + summary="> [!WARNING]"$'\n'"> This workspace runs with remote execution, so no plan file was saved."$'\n'"> The plan output is in the job log, and this workspace cannot be applied from a saved plan." + else + summary="$(jq -r --argjson counts "$counts" --argjson max "$MAX_RESOURCES" --arg saved "$plan_file_saved" ' + def symbol: + if . == ["create"] then "+" + elif . == ["update"] then "~" + elif . == ["delete"] then "-" + elif . == ["delete","create"] or . == ["create","delete"] then "±" + else "?" end; + [(.resource_changes // [])[] | select(.change.actions != ["no-op"] and .change.actions != ["read"])] as $rows + | if ($rows | length) == 0 then + "No changes. The infrastructure matches the configuration." + else + "**\($counts.create)** to add, **\($counts.update)** to change, **\($counts.destroy)** to destroy, **\($counts.replace)** to replace.", + "", + "| | Resource |", + "|:-:|---|", + ($rows[0:$max][] | "| `\(.change.actions | symbol)` | `\(.address)` |"), + (if ($rows | length) > $max then "\n… and \(($rows | length) - $max) more." else empty end), + (if $saved == "false" then "\n> [!WARNING]\n> This workspace runs remotely, so no plan file was saved and it cannot be applied from one." else empty end) + end + ' "$RUNNER_TEMP/terraform-plan.json")" + fi + + { + echo "summary<> "$GITHUB_OUTPUT" + + echo "$summary" + + - name: Add Failure Details to Job Summary + id: plan-summary + if: steps.plan.outcome == 'failure' + uses: tedilabs/github-actions/.github/actions/github.step-summary@main + with: + title: "❌ terraform plan · ${{ inputs.target_dir }}${{ inputs.workspace && format(' ({0})', inputs.workspace) || '' }}" + file: ${{ runner.temp }}/terraform-plan.log + lang: text From ba36af0ac7082ad2a0e2093504eaf24651463cca Mon Sep 17 00:00:00 2001 From: Byungjin Park Date: Wed, 16 Sep 2026 13:23:00 +0900 Subject: [PATCH 03/18] feat(actions): add terraform.apply composite action - Apply a saved plan file, so the applied change is the reviewed change - Report a stale plan separately from a real failure, since it means the state moved after the plan and needs a fresh plan rather than a retry - Expose added, changed, and destroyed counts and a Markdown summary --- .github/actions/terraform.apply/action.yaml | 139 ++++++++++++++++++++ 1 file changed, 139 insertions(+) create mode 100644 .github/actions/terraform.apply/action.yaml diff --git a/.github/actions/terraform.apply/action.yaml b/.github/actions/terraform.apply/action.yaml new file mode 100644 index 0000000..15a26fe --- /dev/null +++ b/.github/actions/terraform.apply/action.yaml @@ -0,0 +1,139 @@ +name: Terraform - Apply +description: Apply a saved Terraform plan file and render a readable summary of what changed. On failure, the output is added to the job summary. Requires the `terraform` CLI on the `PATH`. + + +inputs: + target_dir: + required: false + default: ./ + description: "(Optional) The Terraform project directory to apply. Defaults to `./`." + workspace: + required: false + description: "(Optional) The Terraform workspace to apply to, exported as `TF_WORKSPACE`. Defaults to Terraform's `default` workspace." + plan_file: + required: true + description: "(Required) The path of the plan file to apply, as saved by the `terraform.plan` action. Applying a saved plan is what keeps the change reviewed in the pull request identical to the change applied." + args: + required: false + description: "(Optional) Additional arguments to pass to `terraform apply`." + +outputs: + added: + value: ${{ steps.apply.outputs.added }} + description: "The number of resources added." + changed: + value: ${{ steps.apply.outputs.changed }} + description: "The number of resources changed." + destroyed: + value: ${{ steps.apply.outputs.destroyed }} + description: "The number of resources destroyed." + stale: + value: ${{ steps.apply.outputs.stale }} + description: "`true` when the apply was rejected because the state moved after the plan was saved. The change needs a fresh plan rather than a retry." + summary: + value: ${{ steps.apply.outputs.summary }} + description: "A Markdown summary of the apply, suitable for a job summary or a pull request comment." + + +runs: + using: composite + + steps: + - name: Terraform Init + id: init + shell: bash + env: + TARGET_DIR: ${{ inputs.target_dir }} + WORKSPACE: ${{ inputs.workspace }} + run: | + if [ -n "$WORKSPACE" ]; then + export TF_WORKSPACE="$WORKSPACE" + fi + + terraform -chdir="$TARGET_DIR" init -input=false -no-color 2>&1 | tee "$RUNNER_TEMP/terraform-apply-init.log" + + - name: Add Failure Details to Job Summary + id: init-summary + if: steps.init.outcome == 'failure' + uses: tedilabs/github-actions/.github/actions/github.step-summary@main + with: + title: "❌ terraform init · ${{ inputs.target_dir }}${{ inputs.workspace && format(' ({0})', inputs.workspace) || '' }}" + file: ${{ runner.temp }}/terraform-apply-init.log + lang: text + + - name: Terraform Apply + id: apply + shell: bash + env: + TARGET_DIR: ${{ inputs.target_dir }} + WORKSPACE: ${{ inputs.workspace }} + PLAN_FILE: ${{ inputs.plan_file }} + ARGS: ${{ inputs.args }} + run: | + if [ -n "$WORKSPACE" ]; then + export TF_WORKSPACE="$WORKSPACE" + fi + + if [ ! -f "$PLAN_FILE" ]; then + echo "::error::Plan file not found: $PLAN_FILE" + exit 1 + fi + + log="$RUNNER_TEMP/terraform-apply.log" + read -ra extra_args <<< "$ARGS" + + set +e + terraform -chdir="$TARGET_DIR" apply \ + -input=false \ + -no-color \ + "${extra_args[@]}" \ + "$PLAN_FILE" 2>&1 | tee "$log" + exit_code="${PIPESTATUS[0]}" + set -e + + # Terraform refuses a saved plan once the state has moved on, which is a normal race + # between merging and applying rather than a broken change. + stale=false + if [ "$exit_code" -ne 0 ] && grep -qiE "saved plan is stale|plan is no longer valid" "$log"; then + stale=true + fi + echo "stale=$stale" >> "$GITHUB_OUTPUT" + + # `Apply complete! Resources: 1 added, 2 changed, 3 destroyed.` + read -r added changed destroyed <<< "$( + sed -n 's/^Apply complete! Resources: \([0-9]*\) added, \([0-9]*\) changed, \([0-9]*\) destroyed\..*/\1 \2 \3/p' "$log" | tail -1 + )" + added="${added:-0}"; changed="${changed:-0}"; destroyed="${destroyed:-0}" + { + echo "added=$added" + echo "changed=$changed" + echo "destroyed=$destroyed" + } >> "$GITHUB_OUTPUT" + + if [ "$exit_code" -ne 0 ]; then + if [ "$stale" = "true" ]; then + summary="> [!WARNING]"$'\n'"> The saved plan no longer matches the state, so nothing was applied. Re-run the plan and apply the new one." + else + summary="> [!CAUTION]"$'\n'"> The apply failed. See the job summary for the output." + fi + else + summary="**$added** added, **$changed** changed, **$destroyed** destroyed." + fi + + { + echo "summary<> "$GITHUB_OUTPUT" + + echo "$summary" + exit "$exit_code" + + - name: Add Failure Details to Job Summary + id: apply-summary + if: steps.apply.outcome == 'failure' + uses: tedilabs/github-actions/.github/actions/github.step-summary@main + with: + title: "❌ terraform apply · ${{ inputs.target_dir }}${{ inputs.workspace && format(' ({0})', inputs.workspace) || '' }}" + file: ${{ runner.temp }}/terraform-apply.log + lang: text From a5781d3fa4f5ea722ae4aa029869aa8ac9dbe7ca Mon Sep 17 00:00:00 2001 From: Byungjin Park Date: Wed, 16 Sep 2026 13:23:01 +0900 Subject: [PATCH 04/18] feat(actions): add github.pr.head-run composite action - Resolve the merged pull request behind a commit and the completed run of a given workflow against that pull request's head - Lets a job triggered by the merge download artifacts the pull request run uploaded, such as a Terraform plan file --- .../actions/github.pr.head-run/action.yaml | 76 +++++++++++++++++++ 1 file changed, 76 insertions(+) create mode 100644 .github/actions/github.pr.head-run/action.yaml diff --git a/.github/actions/github.pr.head-run/action.yaml b/.github/actions/github.pr.head-run/action.yaml new file mode 100644 index 0000000..31a8d23 --- /dev/null +++ b/.github/actions/github.pr.head-run/action.yaml @@ -0,0 +1,76 @@ +name: GitHub - Pull Request Head Run +description: Resolve the pull request that produced the current commit and the workflow run that already ran against its head, so a later job can download the artifacts that run uploaded. + + +inputs: + workflow: + required: true + description: "(Required) The file name of the workflow whose run is looked up (e.g. `terraform.integration.yaml`)." + sha: + required: false + default: ${{ github.sha }} + description: "(Optional) The commit to resolve the pull request from. Defaults to the commit that triggered the current run." + github_token: + required: false + default: ${{ github.token }} + description: "(Optional) The GitHub token used to query the pull request and its workflow runs. Needs `actions: read` in addition to `contents: read`. Defaults to the automatically generated `github.token`." + +outputs: + found: + value: ${{ steps.lookup.outputs.found }} + description: "Whether both a pull request and a matching workflow run were found. `true` or `false`." + pr_number: + value: ${{ steps.lookup.outputs.pr_number }} + description: "The number of the pull request the commit came from. Empty when the commit is not associated with one." + head_sha: + value: ${{ steps.lookup.outputs.head_sha }} + description: "The head commit of that pull request, which is the commit the looked-up run ran against." + run_id: + value: ${{ steps.lookup.outputs.run_id }} + description: "The id of the most recent run of `workflow` against `head_sha`. Empty when none exists." + + +runs: + using: composite + + steps: + - name: Look Up Pull Request Run + id: lookup + shell: bash + env: + GH_TOKEN: ${{ inputs.github_token }} + WORKFLOW: ${{ inputs.workflow }} + SHA: ${{ inputs.sha }} + REPOSITORY: ${{ github.repository }} + run: | + emit() { + { + echo "found=$1" + echo "pr_number=$2" + echo "head_sha=$3" + echo "run_id=$4" + } >> "$GITHUB_OUTPUT" + } + + # A merge commit on the default branch lists the pull request it closed. + pr="$(gh api "repos/$REPOSITORY/commits/$SHA/pulls" \ + --jq 'map(select(.merged_at != null)) | sort_by(.merged_at) | last | .number // empty' 2>/dev/null || true)" + if [ -z "$pr" ]; then + echo "::notice::No merged pull request is associated with $SHA." + emit false "" "" "" + exit 0 + fi + + head_sha="$(gh api "repos/$REPOSITORY/pulls/$pr" --jq '.head.sha')" + + # The run that planned this change is the last one for the pull request head. + run_id="$(gh api "repos/$REPOSITORY/actions/workflows/$WORKFLOW/runs?head_sha=$head_sha&per_page=100" \ + --jq '[.workflow_runs[] | select(.status == "completed")] | sort_by(.run_number) | last | .id // empty' 2>/dev/null || true)" + if [ -z "$run_id" ]; then + echo "::notice::No completed run of $WORKFLOW was found for $head_sha (pull request #$pr)." + emit false "$pr" "$head_sha" "" + exit 0 + fi + + echo "Pull request #$pr, head $head_sha, run $run_id." + emit true "$pr" "$head_sha" "$run_id" From c1195a8dfaa18b0796e0cdd1bf26aa6985d0bba1 Mon Sep 17 00:00:00 2001 From: Byungjin Park Date: Wed, 16 Sep 2026 13:23:08 +0900 Subject: [PATCH 05/18] feat(actions): add details and pr_number to github.matrix-report - Add a `details` input rendered as a collapsed block per row, for content too long for a table cell such as a Terraform plan - Add a `pr_number` input so the report can be posted from events without a pull request context, such as the push that follows a merge --- .../actions/github.matrix-report/action.yaml | 24 ++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/.github/actions/github.matrix-report/action.yaml b/.github/actions/github.matrix-report/action.yaml index 771ff7c..d64653d 100644 --- a/.github/actions/github.matrix-report/action.yaml +++ b/.github/actions/github.matrix-report/action.yaml @@ -16,6 +16,9 @@ inputs: results: required: false description: "(Required in `collect` mode) A JSON object mapping each check name to its step outcome, e.g. `{\"fmt\": \"success\", \"tflint\": \"failure\"}`. Key order defines the column order. Values are usually `steps..outcome`: `success`, `failure`, `skipped`, or `cancelled`. An empty value drops the check from the row, so a check that is turned off for the whole workflow leaves no column behind." + details: + required: false + description: "(Optional, `collect` mode) Markdown shown for this row in a collapsed `
` block below the table, for content too long to sit in a cell such as a Terraform plan. Rows without it are listed in the table only." job_status: required: false description: "(Optional, `collect` mode) The current job status, usually the `job.status` context. When it is `failure` while no check failed (e.g. a setup step failed), the row is flagged as failed." @@ -35,6 +38,9 @@ inputs: required: false default: "true" description: "(Optional, `publish` mode) Whether to post the report as a sticky comment on the pull request. Only applies to `pull_request` events and requires the `pull-requests: write` permission. Defaults to `true`." + pr_number: + required: false + description: "(Optional, `publish` mode) The pull request to comment on. Required on events without a pull request context, such as the push that follows a merge. Defaults to the pull request of the current event." pr_comment_marker: required: false default: matrix-report @@ -68,6 +74,7 @@ runs: env: ID: ${{ inputs.id }} RESULTS: ${{ inputs.results }} + DETAILS: ${{ inputs.details }} JOB_STATUS: ${{ inputs.job_status }} run: | if [ -z "$ID" ] || ! jq -e 'type == "object" and length > 0' <<< "$RESULTS" >/dev/null 2>&1; then @@ -82,8 +89,8 @@ runs: key="$(printf '%s' "$ID" | tr -c 'A-Za-z0-9._-' '-')-$(printf '%s' "$ID" | shasum | cut -c1-8)" dir="$RUNNER_TEMP/matrix-report" mkdir -p "$dir" - jq -n --arg id "$ID" --arg job_status "$JOB_STATUS" --argjson results "$results" \ - '{id: $id, job_status: $job_status, results: $results}' > "$dir/$key.json" + jq -n --arg id "$ID" --arg job_status "$JOB_STATUS" --argjson results "$results" --arg details "$DETAILS" \ + '{id: $id, job_status: $job_status, results: $results, details: $details}' > "$dir/$key.json" echo "key=$key" >> "$GITHUB_OUTPUT" @@ -159,7 +166,17 @@ runs: else footer="❌ $failed of $total failed · see the [run summary]($RUN_URL) for details." fi + # A collapsed block per row keeps a long body, such as a plan, out of the way until it is wanted. + details="$(jq -s -r ' + sort_by(.id)[] + | select((.details // "") != "") + | "
\(.id)\n\n\(.details)\n\n
\n" + ' "${files[@]}")" + printf '### %s\n\n%s\n\n%s\n' "$TITLE" "$table" "$footer" > "$report" + if [ -n "$details" ]; then + printf '\n%s\n' "$details" >> "$report" + fi fi cat "$report" >> "$GITHUB_STEP_SUMMARY" @@ -174,11 +191,12 @@ runs: # Forks run with a read-only token, so a failed comment must not fail the report. - name: Comment on Pull Request id: comment - if: inputs.mode == 'publish' && inputs.pr_comment_enabled == 'true' && github.event_name == 'pull_request' && steps.render.outputs.total != '0' + if: inputs.mode == 'publish' && inputs.pr_comment_enabled == 'true' && (github.event_name == 'pull_request' || inputs.pr_number != '') && steps.render.outputs.total != '0' uses: tedilabs/github-actions/.github/actions/github.pr.sticky-comment@main continue-on-error: true with: marker: ${{ inputs.pr_comment_marker }} + pr_number: ${{ inputs.pr_number }} body: ${{ steps.render.outputs.report }} skip_if_unchanged: "true" github_token: ${{ inputs.github_token }} From ddb00198430e793faa0cb548ccb428dc06247571 Mon Sep 17 00:00:00 2001 From: Byungjin Park Date: Wed, 16 Sep 2026 13:23:08 +0900 Subject: [PATCH 06/18] feat(workflows): add terraform.workspaces.plan reusable workflow - Reuse the integration resolver to find the changed workspaces - Plan each workspace in parallel and upload its plan file as an artifact - Publish one sticky comment holding a per-workspace collapsed plan, so a re-run updates the same comment instead of adding another --- .../workflows/terraform.workspaces.plan.yaml | 236 ++++++++++++++++++ 1 file changed, 236 insertions(+) create mode 100644 .github/workflows/terraform.workspaces.plan.yaml diff --git a/.github/workflows/terraform.workspaces.plan.yaml b/.github/workflows/terraform.workspaces.plan.yaml new file mode 100644 index 0000000..048f9a4 --- /dev/null +++ b/.github/workflows/terraform.workspaces.plan.yaml @@ -0,0 +1,236 @@ +name: Terraform Workspaces - Plan + + +on: + workflow_call: + inputs: + runs_on: + description: > + JSON-encoded runs-on value. + Examples: + - '"ubuntu-latest"' + - '["self-hosted","linux","x64"]' + required: false + type: string + default: '"ubuntu-latest"' + + paths: + type: string + required: false + description: "(Optional) File and directory patterns used to detect changes, one per line. Defaults to the entire repository; changes outside a Terraform project are ignored anyway." + project_marker_file: + type: string + required: false + default: versions.tf + description: "(Optional) The file that identifies a Terraform project directory. Defaults to `versions.tf`." + project_mode: + type: string + required: false + default: auto + description: "(Optional) How a project maps to workspaces: `auto`, `single-workspace`, or `multi-workspace`. Defaults to `auto`." + workspace_marker_file: + type: string + required: false + default: config.yaml + description: "(Optional) The file that identifies a workspace. Defaults to `config.yaml`." + workspace_exclude_dirs: + type: string + required: false + default: common,policies,files + description: "(Optional) Subdirectory names that are not workspaces, used only when `workspace_marker_file` finds nothing. Defaults to `common,policies,files`." + + terraform_version: + type: string + required: false + default: latest + description: "(Optional) The version of `terraform` to install when the repository does not pin one in `mise.toml` or `.tool-versions`. Defaults to `latest`." + terraform_host: + type: string + required: false + default: app.terraform.io + description: "(Optional) The hostname of the Terraform Cloud/Enterprise instance that the `terraform_token` secret authenticates to. Defaults to `app.terraform.io`." + plan_args: + type: string + required: false + description: "(Optional) Additional arguments to pass to `terraform plan` (e.g. `-refresh=false`)." + max_resources: + type: string + required: false + default: "50" + description: "(Optional) The maximum number of resource changes listed per workspace in the pull request comment. Defaults to `50`." + + aws_region: + type: string + required: false + description: "(Optional) The AWS region to configure before planning. Only needed when the workspaces use the AWS provider." + aws_github_oidc_iam_role: + type: string + required: false + description: "(Optional) The ARN of the IAM role to assume through GitHub OIDC before planning. Requires the `id-token: write` permission on the caller. Leave empty when the providers of these workspaces do not need AWS credentials." + + artifact_retention_days: + type: number + required: false + default: 5 + description: "(Optional) How long the saved plan files are kept. A plan file can contain sensitive attribute values and is downloadable by anyone with read access to the repository, so keep this short. Defaults to `5`." + pr_comment_enabled: + type: boolean + required: false + default: true + description: "(Optional) Whether to post the plan report as a single sticky comment on the pull request. Requires the `pull-requests: write` permission on the caller. Defaults to `true`." + + secrets: + terraform_token: + required: false + description: "(Optional) The API token for the Terraform Cloud/Enterprise instance at `terraform_host`, used to reach the state backend and the private registry." + + +jobs: + changed: + name: Detect Changed Workspaces + runs-on: ${{ fromJson(inputs.runs_on) }} + + outputs: + has_targets: ${{ steps.changed-workspaces.outputs.has_targets }} + targets: ${{ steps.changed-workspaces.outputs.targets }} + + steps: + - name: Checkout + id: checkout + uses: actions/checkout@v7 + with: + fetch-depth: 0 + + - name: Get Changed Directories + id: changed-dirs + uses: tedilabs/github-actions/.github/actions/git.changed-dirs@main + with: + paths: ${{ inputs.paths }} + + - name: Resolve Changed Workspaces + id: changed-workspaces + uses: tedilabs/github-actions/.github/actions/terraform.changed-workspaces@main + with: + directories: ${{ steps.changed-dirs.outputs.directories }} + project_marker_file: ${{ inputs.project_marker_file }} + project_mode: ${{ inputs.project_mode }} + workspace_marker_file: ${{ inputs.workspace_marker_file }} + workspace_exclude_dirs: ${{ inputs.workspace_exclude_dirs }} + + + plan: + name: Plan (${{ matrix.project }}${{ matrix.workspace && format(' / {0}', matrix.workspace) || '' }}) + needs: + - changed + if: needs.changed.outputs.has_targets == 'true' + runs-on: ${{ fromJson(inputs.runs_on) }} + + strategy: + fail-fast: false + matrix: + include: ${{ fromJson(needs.changed.outputs.targets) }} + + steps: + - name: Checkout + id: checkout + uses: actions/checkout@v7 + + # Written to a throwaway global mise config, so the repository's own `mise.toml` / `.tool-versions` + # still take precedence while a self-hosted runner's real global config is left untouched. + - name: Set Default Tool Versions + id: default-tools + env: + TERRAFORM_VERSION: ${{ inputs.terraform_version }} + run: | + config_file="$RUNNER_TEMP/mise-defaults.toml" + cat > "$config_file" <> "$GITHUB_ENV" + + - name: Set up tools + id: setup-tools + uses: tedilabs/github-actions/.github/actions/mise.setup-tools@main + + - name: Configure Terraform Credentials + id: configure-credentials + uses: tedilabs/github-actions/.github/actions/terraform.configure-credentials@main + with: + host: ${{ inputs.terraform_host }} + token: ${{ secrets.terraform_token }} + + - name: Configure AWS Credentials + id: configure-aws + if: inputs.aws_github_oidc_iam_role != '' + uses: tedilabs/github-actions/.github/actions/aws.configure-credentials@main + with: + aws_region: ${{ inputs.aws_region }} + aws_github_oidc_iam_role: ${{ inputs.aws_github_oidc_iam_role }} + + - name: Resolve Artifact Name + id: artifact + uses: tedilabs/github-actions/.github/actions/misc.slug@main + with: + prefix: terraform-plan + value: ${{ matrix.project }}${{ matrix.workspace && format('/{0}', matrix.workspace) || '' }} + + - name: Plan + id: plan + continue-on-error: true + uses: tedilabs/github-actions/.github/actions/terraform.plan@main + with: + target_dir: ${{ matrix.project }} + workspace: ${{ matrix.workspace }} + plan_file: ${{ runner.temp }}/terraform.tfplan + args: ${{ inputs.plan_args }} + max_resources: ${{ inputs.max_resources }} + + # The plan file is what makes the apply after the merge identical to the reviewed change. + - name: Upload Plan File + id: upload + if: steps.plan.outputs.plan_file_saved == 'true' + uses: actions/upload-artifact@v7 + with: + name: ${{ steps.artifact.outputs.slug }} + path: ${{ steps.plan.outputs.plan_file }} + retention-days: ${{ inputs.artifact_retention_days }} + if-no-files-found: error + + - name: Collect Results + id: results + if: always() + uses: tedilabs/github-actions/.github/actions/github.matrix-report@main + with: + mode: collect + id: ${{ matrix.project }}${{ matrix.workspace && format(' / {0}', matrix.workspace) || '' }} + id_label: Workspace + artifact_prefix: terraform-plan-report + job_status: ${{ job.status }} + details: ${{ steps.plan.outputs.summary }} + results: | + { + "plan": "${{ steps.plan.outcome }}" + } + + + report: + name: Report + needs: + - changed + - plan + if: always() && needs.changed.outputs.has_targets == 'true' + runs-on: ${{ fromJson(inputs.runs_on) }} + + steps: + - name: Publish Report + id: report + uses: tedilabs/github-actions/.github/actions/github.matrix-report@main + with: + mode: publish + id_label: Workspace + artifact_prefix: terraform-plan-report + title: Terraform Plan + pr_comment_enabled: ${{ inputs.pr_comment_enabled }} + pr_comment_marker: terraform-plan From 74da2212a805883c91ceed552e1279960b05ea4c Mon Sep 17 00:00:00 2001 From: Byungjin Park Date: Wed, 16 Sep 2026 13:23:08 +0900 Subject: [PATCH 07/18] feat(workflows): add terraform.workspaces.apply reusable workflow - Resolve the merged pull request and download the plan files its run saved - Apply one workspace at a time, each under its own concurrency group - Publish the result as its own comment on the merged pull request, kept separate from the plan comment --- .../workflows/terraform.workspaces.apply.yaml | 276 ++++++++++++++++++ 1 file changed, 276 insertions(+) create mode 100644 .github/workflows/terraform.workspaces.apply.yaml diff --git a/.github/workflows/terraform.workspaces.apply.yaml b/.github/workflows/terraform.workspaces.apply.yaml new file mode 100644 index 0000000..e529aac --- /dev/null +++ b/.github/workflows/terraform.workspaces.apply.yaml @@ -0,0 +1,276 @@ +name: Terraform Workspaces - Apply + + +on: + workflow_call: + inputs: + runs_on: + description: > + JSON-encoded runs-on value. + Examples: + - '"ubuntu-latest"' + - '["self-hosted","linux","x64"]' + required: false + type: string + default: '"ubuntu-latest"' + + plan_workflow: + type: string + required: true + description: "(Required) The file name of the caller's plan workflow (e.g. `terraform.plan.yaml`). Its run against the head of the merged pull request is where the plan files are downloaded from." + + paths: + type: string + required: false + description: "(Optional) File and directory patterns used to detect changes, one per line. Defaults to the entire repository; changes outside a Terraform project are ignored anyway." + project_marker_file: + type: string + required: false + default: versions.tf + description: "(Optional) The file that identifies a Terraform project directory. Defaults to `versions.tf`." + project_mode: + type: string + required: false + default: auto + description: "(Optional) How a project maps to workspaces: `auto`, `single-workspace`, or `multi-workspace`. Defaults to `auto`." + workspace_marker_file: + type: string + required: false + default: config.yaml + description: "(Optional) The file that identifies a workspace. Defaults to `config.yaml`." + workspace_exclude_dirs: + type: string + required: false + default: common,policies,files + description: "(Optional) Subdirectory names that are not workspaces, used only when `workspace_marker_file` finds nothing. Defaults to `common,policies,files`." + + terraform_version: + type: string + required: false + default: latest + description: "(Optional) The version of `terraform` to install when the repository does not pin one in `mise.toml` or `.tool-versions`. Defaults to `latest`." + terraform_host: + type: string + required: false + default: app.terraform.io + description: "(Optional) The hostname of the Terraform Cloud/Enterprise instance that the `terraform_token` secret authenticates to. Defaults to `app.terraform.io`." + apply_args: + type: string + required: false + description: "(Optional) Additional arguments to pass to `terraform apply`." + + aws_region: + type: string + required: false + description: "(Optional) The AWS region to configure before applying. Only needed when the workspaces use the AWS provider." + aws_github_oidc_iam_role: + type: string + required: false + description: "(Optional) The ARN of the IAM role to assume through GitHub OIDC before applying. Requires the `id-token: write` permission on the caller." + + pr_comment_enabled: + type: boolean + required: false + default: true + description: "(Optional) Whether to post the apply report as a comment on the merged pull request. Requires the `pull-requests: write` permission on the caller. Defaults to `true`." + + secrets: + terraform_token: + required: false + description: "(Optional) The API token for the Terraform Cloud/Enterprise instance at `terraform_host`, used to reach the state backend and the private registry." + + +jobs: + changed: + name: Detect Changed Workspaces + runs-on: ${{ fromJson(inputs.runs_on) }} + + permissions: + contents: read + actions: read + pull-requests: read + + outputs: + has_targets: ${{ steps.changed-workspaces.outputs.has_targets }} + targets: ${{ steps.changed-workspaces.outputs.targets }} + pr_number: ${{ steps.plan-run.outputs.pr_number }} + plan_run_id: ${{ steps.plan-run.outputs.run_id }} + plan_run_found: ${{ steps.plan-run.outputs.found }} + + steps: + - name: Checkout + id: checkout + uses: actions/checkout@v7 + with: + fetch-depth: 0 + + - name: Get Changed Directories + id: changed-dirs + uses: tedilabs/github-actions/.github/actions/git.changed-dirs@main + with: + paths: ${{ inputs.paths }} + + - name: Resolve Changed Workspaces + id: changed-workspaces + uses: tedilabs/github-actions/.github/actions/terraform.changed-workspaces@main + with: + directories: ${{ steps.changed-dirs.outputs.directories }} + project_marker_file: ${{ inputs.project_marker_file }} + project_mode: ${{ inputs.project_mode }} + workspace_marker_file: ${{ inputs.workspace_marker_file }} + workspace_exclude_dirs: ${{ inputs.workspace_exclude_dirs }} + + - name: Resolve Plan Run + id: plan-run + uses: tedilabs/github-actions/.github/actions/github.pr.head-run@main + with: + workflow: ${{ inputs.plan_workflow }} + + - name: Report a Missing Plan Run + id: guard + if: steps.changed-workspaces.outputs.has_targets == 'true' && steps.plan-run.outputs.found != 'true' + run: | + echo "::error::No plan run was found for the merged pull request, so there is no reviewed plan to apply. Re-run the plan on a new pull request." + exit 1 + + + apply: + name: Apply (${{ matrix.project }}${{ matrix.workspace && format(' / {0}', matrix.workspace) || '' }}) + needs: + - changed + if: needs.changed.outputs.has_targets == 'true' && needs.changed.outputs.plan_run_found == 'true' + runs-on: ${{ fromJson(inputs.runs_on) }} + + permissions: + contents: read + actions: read + id-token: write + + # Two applies of the same workspace must never overlap, whatever else is in flight. + concurrency: + group: terraform-apply-${{ github.repository }}-${{ matrix.project }}-${{ matrix.workspace }} + cancel-in-progress: false + + strategy: + fail-fast: false + max-parallel: 1 + matrix: + include: ${{ fromJson(needs.changed.outputs.targets) }} + + steps: + - name: Checkout + id: checkout + uses: actions/checkout@v7 + + # Written to a throwaway global mise config, so the repository's own `mise.toml` / `.tool-versions` + # still take precedence while a self-hosted runner's real global config is left untouched. + - name: Set Default Tool Versions + id: default-tools + env: + TERRAFORM_VERSION: ${{ inputs.terraform_version }} + run: | + config_file="$RUNNER_TEMP/mise-defaults.toml" + cat > "$config_file" <> "$GITHUB_ENV" + + - name: Set up tools + id: setup-tools + uses: tedilabs/github-actions/.github/actions/mise.setup-tools@main + + - name: Configure Terraform Credentials + id: configure-credentials + uses: tedilabs/github-actions/.github/actions/terraform.configure-credentials@main + with: + host: ${{ inputs.terraform_host }} + token: ${{ secrets.terraform_token }} + + - name: Configure AWS Credentials + id: configure-aws + if: inputs.aws_github_oidc_iam_role != '' + uses: tedilabs/github-actions/.github/actions/aws.configure-credentials@main + with: + aws_region: ${{ inputs.aws_region }} + aws_github_oidc_iam_role: ${{ inputs.aws_github_oidc_iam_role }} + + - name: Resolve Artifact Name + id: artifact + uses: tedilabs/github-actions/.github/actions/misc.slug@main + with: + prefix: terraform-plan + value: ${{ matrix.project }}${{ matrix.workspace && format('/{0}', matrix.workspace) || '' }} + + - name: Download Plan File + id: download + continue-on-error: true + uses: actions/download-artifact@v8 + with: + name: ${{ steps.artifact.outputs.slug }} + path: ${{ runner.temp }}/plan + run-id: ${{ needs.changed.outputs.plan_run_id }} + github-token: ${{ github.token }} + + - name: Report a Missing Plan File + id: missing + if: steps.download.outcome == 'failure' + env: + PLAN_RUN_ID: ${{ needs.changed.outputs.plan_run_id }} + run: | + echo "::error::No plan file was uploaded for this workspace by run $PLAN_RUN_ID. It either failed to plan, or it runs with remote execution, which cannot save a plan." + exit 1 + + - name: Apply + id: apply + if: steps.download.outcome == 'success' + continue-on-error: true + uses: tedilabs/github-actions/.github/actions/terraform.apply@main + with: + target_dir: ${{ matrix.project }} + workspace: ${{ matrix.workspace }} + plan_file: ${{ runner.temp }}/plan/terraform.tfplan + args: ${{ inputs.apply_args }} + + - name: Collect Results + id: results + if: always() + uses: tedilabs/github-actions/.github/actions/github.matrix-report@main + with: + mode: collect + id: ${{ matrix.project }}${{ matrix.workspace && format(' / {0}', matrix.workspace) || '' }} + id_label: Workspace + artifact_prefix: terraform-apply-report + job_status: ${{ job.status }} + details: ${{ steps.apply.outputs.summary }} + results: | + { + "apply": "${{ steps.apply.outcome }}" + } + + + report: + name: Report + needs: + - changed + - apply + if: always() && needs.changed.outputs.has_targets == 'true' && needs.changed.outputs.plan_run_found == 'true' + runs-on: ${{ fromJson(inputs.runs_on) }} + + permissions: + contents: read + pull-requests: write + + steps: + - name: Publish Report + id: report + uses: tedilabs/github-actions/.github/actions/github.matrix-report@main + with: + mode: publish + id_label: Workspace + artifact_prefix: terraform-apply-report + title: Terraform Apply + pr_number: ${{ needs.changed.outputs.pr_number }} + pr_comment_enabled: ${{ inputs.pr_comment_enabled }} + pr_comment_marker: terraform-apply From b1aa35317c51ebd8c86d0112d12b851386c79a3b Mon Sep 17 00:00:00 2001 From: Byungjin Park Date: Wed, 16 Sep 2026 21:26:25 +0900 Subject: [PATCH 08/18] fix(actions): always emit a summary from terraform.plan and terraform.apply - Guard the job summary steps with `always()`. A bare `if:` is implicitly combined with `success()`, so a step conditioned only on another step's failure never runs once that step has failed - Add a summary step that runs whatever happened, carrying the tail of the command that failed, so the caller never reports a bare failure with no reason --- .github/actions/terraform.apply/action.yaml | 4 +-- .github/actions/terraform.plan/action.yaml | 37 +++++++++++++++++---- 2 files changed, 33 insertions(+), 8 deletions(-) diff --git a/.github/actions/terraform.apply/action.yaml b/.github/actions/terraform.apply/action.yaml index 15a26fe..f65754b 100644 --- a/.github/actions/terraform.apply/action.yaml +++ b/.github/actions/terraform.apply/action.yaml @@ -54,7 +54,7 @@ runs: - name: Add Failure Details to Job Summary id: init-summary - if: steps.init.outcome == 'failure' + if: always() && steps.init.outcome == 'failure' uses: tedilabs/github-actions/.github/actions/github.step-summary@main with: title: "❌ terraform init · ${{ inputs.target_dir }}${{ inputs.workspace && format(' ({0})', inputs.workspace) || '' }}" @@ -131,7 +131,7 @@ runs: - name: Add Failure Details to Job Summary id: apply-summary - if: steps.apply.outcome == 'failure' + if: always() && steps.apply.outcome == 'failure' uses: tedilabs/github-actions/.github/actions/github.step-summary@main with: title: "❌ terraform apply · ${{ inputs.target_dir }}${{ inputs.workspace && format(' ({0})', inputs.workspace) || '' }}" diff --git a/.github/actions/terraform.plan/action.yaml b/.github/actions/terraform.plan/action.yaml index 416b711..7565dec 100644 --- a/.github/actions/terraform.plan/action.yaml +++ b/.github/actions/terraform.plan/action.yaml @@ -44,8 +44,8 @@ outputs: value: ${{ steps.plan.outputs.replace }} description: "The number of resources to be replaced." summary: - value: ${{ steps.plan.outputs.summary }} - description: "A Markdown summary of the plan, suitable for a job summary or a pull request comment." + value: ${{ steps.summary.outputs.summary }} + description: "A Markdown summary, suitable for a job summary or a pull request comment. It describes the plan when there is one, and otherwise carries the tail of whichever command failed, so a reader never sees a bare failure with no reason." runs: @@ -67,7 +67,7 @@ runs: - name: Add Failure Details to Job Summary id: init-summary - if: steps.init.outcome == 'failure' + if: always() && steps.init.outcome == 'failure' uses: tedilabs/github-actions/.github/actions/github.step-summary@main with: title: "❌ terraform init · ${{ inputs.target_dir }}${{ inputs.workspace && format(' ({0})', inputs.workspace) || '' }}" @@ -184,17 +184,42 @@ runs: ' "$RUNNER_TEMP/terraform-plan.json")" fi + # Written to a file rather than an output, so the always-running summary step below can + # pick it up whether this step reached the end or not. + printf '%s\n' "$summary" > "$RUNNER_TEMP/terraform-plan-summary.md" + echo "$summary" + + # Runs even when init or plan failed, so the caller always has something to show. + - name: Build Summary + id: summary + if: always() + shell: bash + env: + MAX_LOG_LINES: "30" + run: | + excerpt() { + printf '```text\n%s\n```\n' "$(tail -n "$MAX_LOG_LINES" "$1")" + } + + if [ -f "$RUNNER_TEMP/terraform-plan-summary.md" ]; then + summary="$(cat "$RUNNER_TEMP/terraform-plan-summary.md")" + elif [ -s "$RUNNER_TEMP/terraform-plan.log" ]; then + summary="> [!CAUTION]"$'\n'"> \`terraform plan\` failed."$'\n\n'"$(excerpt "$RUNNER_TEMP/terraform-plan.log")" + elif [ -s "$RUNNER_TEMP/terraform-plan-init.log" ]; then + summary="> [!CAUTION]"$'\n'"> \`terraform init\` failed, so nothing was planned."$'\n\n'"$(excerpt "$RUNNER_TEMP/terraform-plan-init.log")" + else + summary="> [!CAUTION]"$'\n'"> The plan did not run. See the job log." + fi + { echo "summary<> "$GITHUB_OUTPUT" - echo "$summary" - - name: Add Failure Details to Job Summary id: plan-summary - if: steps.plan.outcome == 'failure' + if: always() && steps.plan.outcome == 'failure' uses: tedilabs/github-actions/.github/actions/github.step-summary@main with: title: "❌ terraform plan · ${{ inputs.target_dir }}${{ inputs.workspace && format(' ({0})', inputs.workspace) || '' }}" From 474c6d9611f5f2062e4473fd3ee92490cfbab95b Mon Sep 17 00:00:00 2001 From: Byungjin Park Date: Wed, 16 Sep 2026 22:36:56 +0900 Subject: [PATCH 09/18] feat(actions): add misc.export-env composite action - Export `KEY=value` lines into the job environment - Mask every value, and skip a line whose value is empty, which is what an unset secret looks like - Reject a malformed line, an invalid variable name, and any name the runner owns, so the input cannot change how later steps run --- .github/actions/misc.export-env/action.yaml | 69 +++++++++++++++++++++ 1 file changed, 69 insertions(+) create mode 100644 .github/actions/misc.export-env/action.yaml diff --git a/.github/actions/misc.export-env/action.yaml b/.github/actions/misc.export-env/action.yaml new file mode 100644 index 0000000..122a6c9 --- /dev/null +++ b/.github/actions/misc.export-env/action.yaml @@ -0,0 +1,69 @@ +name: MISC - Export Environment +description: Export `KEY=value` lines into the job environment, so a reusable workflow can receive provider credentials it cannot name in advance. + + +inputs: + env: + required: false + description: "(Optional) The variables to export, one `KEY=value` per line. Blank lines and lines starting with `#` are ignored, and a line whose value is empty is skipped rather than exported as an empty string. Pass this as a secret: every value is masked, but only a value GitHub already knows to be a secret is masked before this action runs." + + +runs: + using: composite + + steps: + - name: Export Environment Variables + id: export + shell: bash + env: + ENV_LINES: ${{ inputs.env }} + run: | + if [ -z "${ENV_LINES//[[:space:]]/}" ]; then + echo "No variables to export." + exit 0 + fi + + line_number=0 + exported=() + while IFS= read -r line; do + line_number=$((line_number + 1)) + line="${line%$'\r'}" + + [ -z "${line//[[:space:]]/}" ] && continue + case "${line#"${line%%[![:space:]]*}"}" in '#'*) continue ;; esac + + if [[ "$line" != *=* ]]; then + echo "::error::Line $line_number is not a KEY=value pair." + exit 1 + fi + + name="${line%%=*}" + value="${line#*=}" + # Trim the surrounding whitespace a block scalar tends to carry. + name="${name#"${name%%[![:space:]]*}"}" + name="${name%"${name##*[![:space:]]}"}" + + if ! [[ "$name" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]]; then + echo "::error::Line $line_number does not start with a valid environment variable name." + exit 1 + fi + + # The runner owns these, and overwriting them changes how every later step runs. + case "$name" in + PATH|GITHUB_*|RUNNER_*) + echo "::error::Refusing to set $name, which belongs to the runner." + exit 1 + ;; + esac + + if [ -z "$value" ]; then + echo "::warning::Skipping $name, whose value is empty. The secret behind it is probably not set." + continue + fi + + echo "::add-mask::$value" + echo "$name=$value" >> "$GITHUB_ENV" + exported+=("$name") + done <<< "$ENV_LINES" + + echo "Exported: ${exported[*]:-(none)}" From b3705b5f027308ac8133c368cfb831c22e958e1d Mon Sep 17 00:00:00 2001 From: Byungjin Park Date: Wed, 16 Sep 2026 22:36:56 +0900 Subject: [PATCH 10/18] feat(workflows): accept provider credentials in the Terraform plan and apply workflows - Add a `provider_env` secret carrying `KEY=value` lines - Export it before planning or applying, so providers that read credentials from the environment, such as Okta, work without the reusable workflow needing an input per provider --- .github/workflows/terraform.workspaces.apply.yaml | 9 +++++++++ .github/workflows/terraform.workspaces.plan.yaml | 9 +++++++++ 2 files changed, 18 insertions(+) diff --git a/.github/workflows/terraform.workspaces.apply.yaml b/.github/workflows/terraform.workspaces.apply.yaml index e529aac..6362c07 100644 --- a/.github/workflows/terraform.workspaces.apply.yaml +++ b/.github/workflows/terraform.workspaces.apply.yaml @@ -78,6 +78,9 @@ on: terraform_token: required: false description: "(Optional) The API token for the Terraform Cloud/Enterprise instance at `terraform_host`, used to reach the state backend and the private registry." + provider_env: + required: false + description: "(Optional) Credentials the Terraform providers read from the environment, one `KEY=value` per line, where each value comes from a secret of the caller. A reusable workflow cannot know which providers a repository uses, so this is the seam for the ones that are not AWS. Every value is masked, and a line whose value is empty is skipped." jobs: @@ -188,6 +191,12 @@ jobs: host: ${{ inputs.terraform_host }} token: ${{ secrets.terraform_token }} + - name: Configure Provider Credentials + id: configure-providers + uses: tedilabs/github-actions/.github/actions/misc.export-env@main + with: + env: ${{ secrets.provider_env }} + - name: Configure AWS Credentials id: configure-aws if: inputs.aws_github_oidc_iam_role != '' diff --git a/.github/workflows/terraform.workspaces.plan.yaml b/.github/workflows/terraform.workspaces.plan.yaml index 048f9a4..e54780b 100644 --- a/.github/workflows/terraform.workspaces.plan.yaml +++ b/.github/workflows/terraform.workspaces.plan.yaml @@ -83,6 +83,9 @@ on: terraform_token: required: false description: "(Optional) The API token for the Terraform Cloud/Enterprise instance at `terraform_host`, used to reach the state backend and the private registry." + provider_env: + required: false + description: "(Optional) Credentials the Terraform providers read from the environment, one `KEY=value` per line, where each value comes from a secret of the caller. A reusable workflow cannot know which providers a repository uses, so this is the seam for the ones that are not AWS. Every value is masked, and a line whose value is empty is skipped." jobs: @@ -161,6 +164,12 @@ jobs: host: ${{ inputs.terraform_host }} token: ${{ secrets.terraform_token }} + - name: Configure Provider Credentials + id: configure-providers + uses: tedilabs/github-actions/.github/actions/misc.export-env@main + with: + env: ${{ secrets.provider_env }} + - name: Configure AWS Credentials id: configure-aws if: inputs.aws_github_oidc_iam_role != '' From a1051c3bf504943ba60db84254795a8ecf57947b Mon Sep 17 00:00:00 2001 From: Byungjin Park Date: Sun, 20 Sep 2026 00:58:14 +0900 Subject: [PATCH 11/18] refactor(actions): run terraform init through shell.run in plan and apply - Replace the `tee` pipeline of the init step with `shell.run`, as `terraform.validate` does, and point the failure summary and the plan summary fallback at its `log_file` output --- .github/actions/terraform.apply/action.yaml | 15 ++++++++------- .github/actions/terraform.plan/action.yaml | 20 +++++++++++--------- 2 files changed, 19 insertions(+), 16 deletions(-) diff --git a/.github/actions/terraform.apply/action.yaml b/.github/actions/terraform.apply/action.yaml index f65754b..7bbfed2 100644 --- a/.github/actions/terraform.apply/action.yaml +++ b/.github/actions/terraform.apply/action.yaml @@ -41,16 +41,17 @@ runs: steps: - name: Terraform Init id: init - shell: bash + uses: tedilabs/github-actions/.github/actions/shell.run@main env: TARGET_DIR: ${{ inputs.target_dir }} WORKSPACE: ${{ inputs.workspace }} - run: | - if [ -n "$WORKSPACE" ]; then - export TF_WORKSPACE="$WORKSPACE" - fi + with: + run: | + if [ -n "$WORKSPACE" ]; then + export TF_WORKSPACE="$WORKSPACE" + fi - terraform -chdir="$TARGET_DIR" init -input=false -no-color 2>&1 | tee "$RUNNER_TEMP/terraform-apply-init.log" + terraform -chdir="$TARGET_DIR" init -input=false -no-color - name: Add Failure Details to Job Summary id: init-summary @@ -58,7 +59,7 @@ runs: uses: tedilabs/github-actions/.github/actions/github.step-summary@main with: title: "❌ terraform init · ${{ inputs.target_dir }}${{ inputs.workspace && format(' ({0})', inputs.workspace) || '' }}" - file: ${{ runner.temp }}/terraform-apply-init.log + file: ${{ steps.init.outputs.log_file }} lang: text - name: Terraform Apply diff --git a/.github/actions/terraform.plan/action.yaml b/.github/actions/terraform.plan/action.yaml index 7565dec..0657adc 100644 --- a/.github/actions/terraform.plan/action.yaml +++ b/.github/actions/terraform.plan/action.yaml @@ -54,16 +54,17 @@ runs: steps: - name: Terraform Init id: init - shell: bash + uses: tedilabs/github-actions/.github/actions/shell.run@main env: TARGET_DIR: ${{ inputs.target_dir }} WORKSPACE: ${{ inputs.workspace }} - run: | - if [ -n "$WORKSPACE" ]; then - export TF_WORKSPACE="$WORKSPACE" - fi + with: + run: | + if [ -n "$WORKSPACE" ]; then + export TF_WORKSPACE="$WORKSPACE" + fi - terraform -chdir="$TARGET_DIR" init -input=false -no-color 2>&1 | tee "$RUNNER_TEMP/terraform-plan-init.log" + terraform -chdir="$TARGET_DIR" init -input=false -no-color - name: Add Failure Details to Job Summary id: init-summary @@ -71,7 +72,7 @@ runs: uses: tedilabs/github-actions/.github/actions/github.step-summary@main with: title: "❌ terraform init · ${{ inputs.target_dir }}${{ inputs.workspace && format(' ({0})', inputs.workspace) || '' }}" - file: ${{ runner.temp }}/terraform-plan-init.log + file: ${{ steps.init.outputs.log_file }} lang: text - name: Terraform Plan @@ -196,6 +197,7 @@ runs: shell: bash env: MAX_LOG_LINES: "30" + INIT_LOG: ${{ steps.init.outputs.log_file }} run: | excerpt() { printf '```text\n%s\n```\n' "$(tail -n "$MAX_LOG_LINES" "$1")" @@ -205,8 +207,8 @@ runs: summary="$(cat "$RUNNER_TEMP/terraform-plan-summary.md")" elif [ -s "$RUNNER_TEMP/terraform-plan.log" ]; then summary="> [!CAUTION]"$'\n'"> \`terraform plan\` failed."$'\n\n'"$(excerpt "$RUNNER_TEMP/terraform-plan.log")" - elif [ -s "$RUNNER_TEMP/terraform-plan-init.log" ]; then - summary="> [!CAUTION]"$'\n'"> \`terraform init\` failed, so nothing was planned."$'\n\n'"$(excerpt "$RUNNER_TEMP/terraform-plan-init.log")" + elif [ -n "$INIT_LOG" ] && [ -s "$INIT_LOG" ]; then + summary="> [!CAUTION]"$'\n'"> \`terraform init\` failed, so nothing was planned."$'\n\n'"$(excerpt "$INIT_LOG")" else summary="> [!CAUTION]"$'\n'"> The plan did not run. See the job log." fi From 7735eac938283f400a248e7215233c3ff12ca13d Mon Sep 17 00:00:00 2001 From: Byungjin Park Date: Mon, 21 Sep 2026 13:44:20 +0900 Subject: [PATCH 12/18] feat(actions): expose the plan as structured outputs and annotate its diagnostics - Run the plan as `terraform plan -json -detailed-exitcode -out=` with the message stream going to a file, then render the readable plan into the log with `terraform show -no-color `. One plan run yields both the log a plain `terraform plan` would have printed and the typed message stream - Add `drift_count`, `warning_count`, and `error_count`, and the JSON outputs `changes`, `drift`, `output_changes`, and `diagnostics`, each truncated to `max_resources`. They carry addresses, actions, and reasons, never attribute values, so they stay far below the 1 MB limit on step outputs - Pass the bulky renderings as paths instead: `plan_json_file` and `stream_file` - Add `annotations_enabled` (default `true`). Diagnostics only exist in the message stream, not in the plan file, so warnings on a successful plan were previously dropped; they are now annotated and listed in the summary - Report drift in the summary as a collapsed list - Stop reporting `plan_file` and `plan_file_saved` when the plan failed and no file was written, which made the caller's artifact upload fail instead of skip --- .github/actions/terraform.plan/action.yaml | 268 +++++++++++++++++---- 1 file changed, 223 insertions(+), 45 deletions(-) diff --git a/.github/actions/terraform.plan/action.yaml b/.github/actions/terraform.plan/action.yaml index 0657adc..3a72007 100644 --- a/.github/actions/terraform.plan/action.yaml +++ b/.github/actions/terraform.plan/action.yaml @@ -1,5 +1,5 @@ name: Terraform - Plan -description: Run `terraform plan` against a workspace, save the plan file, and render a readable summary of what would change. On failure, the output is added to the job summary. Requires the `terraform` CLI on the `PATH`. +description: Run `terraform plan` against a workspace, save the plan file, and expose what would change as structured outputs. The readable plan is rendered from the saved plan file into the job log, and on failure it is added to the job summary. Requires the `terraform` CLI on the `PATH`. inputs: @@ -19,7 +19,11 @@ inputs: max_resources: required: false default: "50" - description: "(Optional) The maximum number of resource changes listed in the summary. Beyond it the list is truncated with a note, so one large workspace cannot fill a pull request comment. Defaults to `50`." + description: "(Optional) The maximum number of entries listed in the summary and in each structured output. Beyond it the list is truncated with a note, so one large workspace cannot fill a pull request comment or the 1 MB output limit. Defaults to `50`." + annotations_enabled: + required: false + default: "true" + description: "(Optional) Whether to annotate the reported lines in the pull request, from the diagnostics of the plan. Warnings are annotated even when the plan succeeds. Defaults to `true`." outputs: has_changes: @@ -31,6 +35,13 @@ outputs: plan_file_saved: value: ${{ steps.plan.outputs.plan_file_saved }} description: "Whether a plan file was saved. `false` when the workspace runs with remote execution, where the plan cannot be saved locally and so cannot be applied later." + plan_json_file: + value: ${{ steps.target.outputs.plan_json_file }} + description: "The path of the `terraform show -json` rendering of the plan file. Holds the full diff including attribute values, which is why it is passed as a path rather than as an output." + stream_file: + value: ${{ steps.target.outputs.stream_file }} + description: "The path of the newline-delimited JSON message stream of `terraform plan -json`. It is the only place the diagnostics live, since a saved plan file does not carry them." + create: value: ${{ steps.plan.outputs.create }} description: "The number of resources to be created." @@ -43,6 +54,29 @@ outputs: replace: value: ${{ steps.plan.outputs.replace }} description: "The number of resources to be replaced." + drift_count: + value: ${{ steps.plan.outputs.drift_count }} + description: "The number of resources that changed outside of Terraform since the last apply." + warning_count: + value: ${{ steps.plan.outputs.warning_count }} + description: "The number of warning diagnostics the plan reported." + error_count: + value: ${{ steps.plan.outputs.error_count }} + description: "The number of error diagnostics the plan reported." + + changes: + value: ${{ steps.plan.outputs.changes }} + description: "A JSON array of the planned changes, each `{action, address, reason}`, truncated to `max_resources`. Read it with `fromJson`. Attribute values are deliberately left out; use `plan_json_file` for those." + drift: + value: ${{ steps.plan.outputs.drift }} + description: "A JSON array of the resources that changed outside of Terraform, each `{action, address}`, truncated to `max_resources`. Read it with `fromJson`." + output_changes: + value: ${{ steps.plan.outputs.output_changes }} + description: "A JSON object mapping each root output to `{action, sensitive}`. Values are never included. Read it with `fromJson`." + diagnostics: + value: ${{ steps.plan.outputs.diagnostics }} + description: "A JSON array of the diagnostics, each `{severity, summary, filename, line}`, truncated to `max_resources`. Read it with `fromJson`." + summary: value: ${{ steps.summary.outputs.summary }} description: "A Markdown summary, suitable for a job summary or a pull request comment. It describes the plan when there is one, and otherwise carries the tail of whichever command failed, so a reader never sees a bare failure with no reason." @@ -52,6 +86,29 @@ runs: using: composite steps: + - name: Resolve Target + id: target + shell: bash + env: + TARGET_DIR: ${{ inputs.target_dir }} + PLAN_FILE: ${{ inputs.plan_file }} + run: | + target_dir="${TARGET_DIR%/}" + target_dir="${target_dir:-.}" + + # Unique per invocation, so calling this action twice in one job never lets the second call read + # the plan file, log or summary the first one left behind. + prefix="$RUNNER_TEMP/terraform-plan-$(head -c 15 /dev/urandom | base64 | tr -dc 'A-Za-z0-9')" + + { + echo "target_dir=$target_dir" + echo "plan_file=${PLAN_FILE:-$prefix.tfplan}" + echo "stream_file=$prefix.ndjson" + echo "plan_json_file=$prefix.json" + echo "log_file=$prefix.log" + echo "summary_file=$prefix.md" + } >> "$GITHUB_OUTPUT" + - name: Terraform Init id: init uses: tedilabs/github-actions/.github/actions/shell.run@main @@ -79,9 +136,13 @@ runs: id: plan shell: bash env: - TARGET_DIR: ${{ inputs.target_dir }} + TARGET_DIR: ${{ steps.target.outputs.target_dir }} WORKSPACE: ${{ inputs.workspace }} - PLAN_FILE: ${{ inputs.plan_file }} + PLAN_FILE: ${{ steps.target.outputs.plan_file }} + STREAM_FILE: ${{ steps.target.outputs.stream_file }} + PLAN_JSON_FILE: ${{ steps.target.outputs.plan_json_file }} + LOG_FILE: ${{ steps.target.outputs.log_file }} + SUMMARY_FILE: ${{ steps.target.outputs.summary_file }} ARGS: ${{ inputs.args }} MAX_RESOURCES: ${{ inputs.max_resources }} run: | @@ -89,43 +150,89 @@ runs: export TF_WORKSPACE="$WORKSPACE" fi - plan_file="${PLAN_FILE:-$RUNNER_TEMP/terraform.tfplan}" - log="$RUNNER_TEMP/terraform-plan.log" + plan_file="$PLAN_FILE" + stream="$STREAM_FILE" + plan_json="$PLAN_JSON_FILE" + log="$LOG_FILE" read -ra extra_args <<< "$ARGS" + # A caller that passes a fixed `plan_file` could otherwise leave one behind for the next call. + rm -f "$plan_file" + + # `-json` emits a typed message stream that carries the diagnostics, which a saved plan file does not. + # It goes to a file rather than the log, because the readable plan is rendered from the plan file below. # `-detailed-exitcode` returns 0 for no changes, 1 for an error, and 2 when there are changes. - set +e + exit_code=0 terraform -chdir="$TARGET_DIR" plan \ + -json \ -out="$plan_file" \ -input=false \ -lock=false \ - -no-color \ -detailed-exitcode \ - "${extra_args[@]}" 2>&1 | tee "$log" - exit_code="${PIPESTATUS[0]}" - set -e + "${extra_args[@]}" > "$stream" 2>&1 || exit_code=$? - # A workspace with remote execution cannot save a plan file. Fall back to a plan without - # `-out` so the change is still reported, and mark it as not applicable from a plan file. + # A workspace with remote execution cannot save a plan file. Fall back to a plan without `-out`, and + # without `-json` so its output is readable on its own, since there is no plan file to render from. plan_file_saved=true - if [ "$exit_code" -eq 1 ] && grep -q "not support saving" "$log"; then + if [ "$exit_code" -eq 1 ] && grep -q "not support saving" "$stream"; then echo "::warning::The backend refuses to save a plan file, which means remote execution. Re-planning without one; this workspace cannot be applied from a saved plan." plan_file_saved=false plan_file="" - set +e + exit_code=0 terraform -chdir="$TARGET_DIR" plan \ -input=false \ -lock=false \ -no-color \ -detailed-exitcode \ - "${extra_args[@]}" 2>&1 | tee "$log" - exit_code="${PIPESTATUS[0]}" - set -e + "${extra_args[@]}" 2>&1 | tee "$log" || exit_code="${PIPESTATUS[0]}" + : > "$stream" + fi + + # `terraform show` re-renders the saved plan without planning again, so the log carries exactly what a + # plain `terraform plan` would have printed, minus the refresh progress. + echo '{}' > "$plan_json" + if [ -n "$plan_file" ] && [ -f "$plan_file" ]; then + terraform -chdir="$TARGET_DIR" show -no-color "$plan_file" | tee "$log" || true + if terraform -chdir="$TARGET_DIR" show -json "$plan_file" > "$plan_json.tmp" 2>/dev/null; then + mv "$plan_json.tmp" "$plan_json" + else + rm -f "$plan_json.tmp" + fi + fi + + # Without a plan file the diagnostics are all there is to show, so render them readably. + if [ ! -s "$log" ] && [ -s "$stream" ]; then + jq -r 'select(.type == "diagnostic") + | .diagnostic + | "\(.severity | ascii_upcase): \(.summary)" + + (if (.range.filename // "") != "" then "\n on \(.range.filename) line \(.range.start.line)" else "" end) + + (if (.detail // "") != "" then "\n\n\(.detail)\n" else "" end) + ' "$stream" | tee "$log" + fi + + diagnostics_json="$(jq -s -c --argjson max "$MAX_RESOURCES" ' + [ .[] | select(.type == "diagnostic") | .diagnostic + | {severity, summary, filename: (.range.filename // ""), line: (.range.start.line // 0)} ] + | .[0:$max] + ' "$stream" 2>/dev/null || echo '[]')" + counts="$(jq -s -c ' + [ .[] | select(.type == "diagnostic") | .diagnostic.severity ] as $s + | {warning: ($s | map(select(. == "warning")) | length), error: ($s | map(select(. == "error")) | length)} + ' "$stream" 2>/dev/null || echo '{"warning":0,"error":0}')" + + # A failed plan writes no file, so neither the path nor the flag may claim one exists; the caller + # gates its artifact upload on them. + if [ -n "$plan_file" ] && [ ! -f "$plan_file" ]; then + plan_file="" + plan_file_saved=false fi { echo "plan_file=$plan_file" echo "plan_file_saved=$plan_file_saved" + echo "warning_count=$(jq -r '.warning' <<< "$counts")" + echo "error_count=$(jq -r '.error' <<< "$counts")" + echo "diagnostics=$diagnostics_json" } >> "$GITHUB_OUTPUT" if [ "$exit_code" -eq 1 ]; then @@ -141,13 +248,7 @@ runs: echo "has_changes=$has_changes" >> "$GITHUB_OUTPUT" # Counts and the resource list come from the machine-readable plan, never from the log text. - if [ -n "$plan_file" ]; then - terraform -chdir="$TARGET_DIR" show -json "$plan_file" > "$RUNNER_TEMP/terraform-plan.json" - else - echo '{}' > "$RUNNER_TEMP/terraform-plan.json" - fi - - counts="$(jq -c ' + resource_counts="$(jq -c ' [(.resource_changes // [])[] | .change.actions] as $a | { create: ($a | map(select(. == ["create"])) | length), @@ -155,15 +256,33 @@ runs: destroy: ($a | map(select(. == ["delete"])) | length), replace: ($a | map(select(. == ["delete","create"] or . == ["create","delete"])) | length) } - ' "$RUNNER_TEMP/terraform-plan.json")" - jq -r 'to_entries[] | "\(.key)=\(.value)"' <<< "$counts" >> "$GITHUB_OUTPUT" + ' "$plan_json")" + jq -r 'to_entries[] | "\(.key)=\(.value)"' <<< "$resource_counts" >> "$GITHUB_OUTPUT" + + # Structured lists stay small on purpose: addresses and actions only, never attribute values, and + # truncated to `max_resources` so they cannot approach the 1 MB limit on step outputs. + { + echo "changes=$(jq -c --argjson max "$MAX_RESOURCES" ' + [ (.resource_changes // [])[] + | select(.change.actions != ["no-op"] and .change.actions != ["read"]) + | {action: (.change.actions | join("+")), address, reason: (.action_reason // "")} ] + | .[0:$max] + ' "$plan_json")" + echo "drift=$(jq -c --argjson max "$MAX_RESOURCES" ' + [ (.resource_drift // [])[] | {action: (.change.actions | join("+")), address} ] | .[0:$max] + ' "$plan_json")" + echo "output_changes=$(jq -c ' + (.output_changes // {}) | with_entries(.value |= {action: (.actions | join("+")), sensitive: ((.before_sensitive // false) or (.after_sensitive // false))}) + ' "$plan_json")" + echo "drift_count=$(jq -r '(.resource_drift // []) | length' "$plan_json")" + } >> "$GITHUB_OUTPUT" if [ -z "$plan_file" ]; then # Without a plan file there is nothing machine-readable to summarise, so say so plainly # rather than rendering an empty table that would read as "no changes". summary="> [!WARNING]"$'\n'"> This workspace runs with remote execution, so no plan file was saved."$'\n'"> The plan output is in the job log, and this workspace cannot be applied from a saved plan." else - summary="$(jq -r --argjson counts "$counts" --argjson max "$MAX_RESOURCES" --arg saved "$plan_file_saved" ' + summary="$(jq -r --argjson counts "$resource_counts" --argjson max "$MAX_RESOURCES" --arg saved "$plan_file_saved" ' def symbol: if . == ["create"] then "+" elif . == ["update"] then "~" @@ -171,24 +290,81 @@ runs: elif . == ["delete","create"] or . == ["create","delete"] then "±" else "?" end; [(.resource_changes // [])[] | select(.change.actions != ["no-op"] and .change.actions != ["read"])] as $rows - | if ($rows | length) == 0 then - "No changes. The infrastructure matches the configuration." + | [(.resource_drift // [])[]] as $drift + | (if ($rows | length) == 0 then + ["No changes. The infrastructure matches the configuration."] else - "**\($counts.create)** to add, **\($counts.update)** to change, **\($counts.destroy)** to destroy, **\($counts.replace)** to replace.", - "", - "| | Resource |", - "|:-:|---|", - ($rows[0:$max][] | "| `\(.change.actions | symbol)` | `\(.address)` |"), - (if ($rows | length) > $max then "\n… and \(($rows | length) - $max) more." else empty end), - (if $saved == "false" then "\n> [!WARNING]\n> This workspace runs remotely, so no plan file was saved and it cannot be applied from one." else empty end) + ["**\($counts.create)** to add, **\($counts.update)** to change, **\($counts.destroy)** to destroy, **\($counts.replace)** to replace.", + "", + "| | Resource |", + "|:-:|---|"] + + [$rows[0:$max][] | "| `\(.change.actions | symbol)` | `\(.address)` |"] + + (if ($rows | length) > $max then ["", "… and \(($rows | length) - $max) more."] else [] end) + end) + + (if ($drift | length) > 0 then + ["", "
⚠️ \($drift | length) changed outside of Terraform", ""] + + [$drift[0:$max][] | "- `\(.address)`"] + + ["", "
"] + else [] end) + + (if $saved == "false" then ["", "> [!WARNING]", "> This workspace runs remotely, so no plan file was saved and it cannot be applied from one."] else [] end) + | join("\n") + ' "$plan_json")" + fi + + # Diagnostics are not in the plan file, so they are appended from the stream. + warnings="$(jq -s -r --argjson max "$MAX_RESOURCES" ' + [ .[] | select(.type == "diagnostic") | .diagnostic | select(.severity == "warning") ] as $w + | if ($w | length) == 0 then empty + else + ["", "
⚠️ \($w | length) warning(s)", ""] + + [$w[0:$max][] | "- **\(.summary)**" + (if (.range.filename // "") != "" then " · `\(.range.filename):\(.range.start.line)`" else "" end)] + + ["", "
"] + | join("\n") end - ' "$RUNNER_TEMP/terraform-plan.json")" + ' "$stream" 2>/dev/null || true)" + if [ -n "$warnings" ]; then + summary="$summary$warnings" fi # Written to a file rather than an output, so the always-running summary step below can # pick it up whether this step reached the end or not. - printf '%s\n' "$summary" > "$RUNNER_TEMP/terraform-plan-summary.md" - echo "$summary" + printf '%s\n' "$summary" > "$SUMMARY_FILE" + + # GitHub only renders annotations from `::error` lines, so the diagnostics are read from the message + # stream. Paths are relative to the module, so `target_dir` is prefixed. + - name: Annotate Findings + id: plan-annotate + if: always() && inputs.annotations_enabled == 'true' + shell: bash + env: + TARGET_DIR: ${{ steps.target.outputs.target_dir }} + STREAM_FILE: ${{ steps.target.outputs.stream_file }} + run: | + if [ -z "$STREAM_FILE" ] || [ ! -s "$STREAM_FILE" ]; then + exit 0 + fi + + prefix="" + if [ "$TARGET_DIR" != "." ] && [ "$TARGET_DIR" != "./" ]; then + prefix="${TARGET_DIR%/}/" + fi + + # A workflow command takes one line, so newlines and the property separators are percent-encoded the + # way the runner decodes them. + jq -r --arg prefix "$prefix" ' + def esc: gsub("%"; "%25") | gsub("\r"; "%0D") | gsub("\n"; "%0A"); + def prop: esc | gsub(":"; "%3A") | gsub(","; "%2C"); + select(.type == "diagnostic") + | .diagnostic + | (if .severity == "error" then "error" else "warning" end) as $severity + | (.summary + (if (.detail // "") != "" then "\n\n" + .detail else "" end) | esc) as $message + | ("terraform plan · " + .summary | prop) as $title + | if (.range.filename // "") != "" then + "::\($severity) file=\($prefix + .range.filename),line=\(.range.start.line),col=\(.range.start.column),title=\($title)::\($message)" + else + "::\($severity) title=\($title)::\($message)" + end + ' "$STREAM_FILE" || true # Runs even when init or plan failed, so the caller always has something to show. - name: Build Summary @@ -198,15 +374,17 @@ runs: env: MAX_LOG_LINES: "30" INIT_LOG: ${{ steps.init.outputs.log_file }} + LOG_FILE: ${{ steps.target.outputs.log_file }} + SUMMARY_FILE: ${{ steps.target.outputs.summary_file }} run: | excerpt() { printf '```text\n%s\n```\n' "$(tail -n "$MAX_LOG_LINES" "$1")" } - if [ -f "$RUNNER_TEMP/terraform-plan-summary.md" ]; then - summary="$(cat "$RUNNER_TEMP/terraform-plan-summary.md")" - elif [ -s "$RUNNER_TEMP/terraform-plan.log" ]; then - summary="> [!CAUTION]"$'\n'"> \`terraform plan\` failed."$'\n\n'"$(excerpt "$RUNNER_TEMP/terraform-plan.log")" + if [ -n "$SUMMARY_FILE" ] && [ -f "$SUMMARY_FILE" ]; then + summary="$(cat "$SUMMARY_FILE")" + elif [ -n "$LOG_FILE" ] && [ -s "$LOG_FILE" ]; then + summary="> [!CAUTION]"$'\n'"> \`terraform plan\` failed."$'\n\n'"$(excerpt "$LOG_FILE")" elif [ -n "$INIT_LOG" ] && [ -s "$INIT_LOG" ]; then summary="> [!CAUTION]"$'\n'"> \`terraform init\` failed, so nothing was planned."$'\n\n'"$(excerpt "$INIT_LOG")" else @@ -225,5 +403,5 @@ runs: uses: tedilabs/github-actions/.github/actions/github.step-summary@main with: title: "❌ terraform plan · ${{ inputs.target_dir }}${{ inputs.workspace && format(' ({0})', inputs.workspace) || '' }}" - file: ${{ runner.temp }}/terraform-plan.log + file: ${{ steps.target.outputs.log_file }} lang: text From 320b416d39143884f61dcd3af544064e7f7ce171 Mon Sep 17 00:00:00 2001 From: Byungjin Park Date: Mon, 21 Sep 2026 14:20:11 +0900 Subject: [PATCH 13/18] feat(actions): render the plan summary as per-resource diffs with links MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Move the summary rendering into `scripts/render-summary.py`, following the `web.s3.delivery` precedent, since slicing the plan text, diffing drift and laying out Markdown outgrew an inline `jq` program - Show each changed resource as a collapsed block titled with an emoji for the action (➕ create, 📝 update, 🗑️ destroy, ♻️ replace) and the reason, holding the attribute diff sliced out of `terraform show` so it reads exactly as Terraform prints it and keeps its redaction of sensitive values - Give drift the same treatment, diffed from `resource_drift` in the plan JSON because Terraform renders no drift section of its own - Link every diagnostic to its line on the commit being planned, using the pull request head rather than the merge commit - Add `diff_enabled`, `diff_max_lines` and `summary_max_bytes`. The last one keeps one workspace from spending the whole 65,536 character budget of the shared pull request comment, closing any open fence or block when it cuts - Report each target under its own heading in `github.matrix-report`, and fall back to the table alone when the comment would exceed the GitHub limit --- .../actions/github.matrix-report/action.yaml | 24 +- .github/actions/terraform.plan/action.yaml | 83 +++--- .../terraform.plan/scripts/render-summary.py | 267 ++++++++++++++++++ 3 files changed, 328 insertions(+), 46 deletions(-) create mode 100755 .github/actions/terraform.plan/scripts/render-summary.py diff --git a/.github/actions/github.matrix-report/action.yaml b/.github/actions/github.matrix-report/action.yaml index d64653d..bd09d6a 100644 --- a/.github/actions/github.matrix-report/action.yaml +++ b/.github/actions/github.matrix-report/action.yaml @@ -136,6 +136,7 @@ runs: ID_LABEL: ${{ inputs.id_label }} TITLE: ${{ inputs.title }} RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + MAX_COMMENT_BYTES: "60000" run: | dir="$RUNNER_TEMP/matrix-report" shopt -s nullglob @@ -166,23 +167,38 @@ runs: else footer="❌ $failed of $total failed · see the [run summary]($RUN_URL) for details." fi - # A collapsed block per row keeps a long body, such as a plan, out of the way until it is wanted. + # One section per row, so a long body such as a plan is read under the heading of the target it + # belongs to rather than as one undifferentiated block. details="$(jq -s -r ' sort_by(.id)[] | select((.details // "") != "") - | "
\(.id)\n\n\(.details)\n\n
\n" + | "#### \(.id)\n\n\(.details)\n" ' "${files[@]}")" printf '### %s\n\n%s\n\n%s\n' "$TITLE" "$table" "$footer" > "$report" if [ -n "$details" ]; then - printf '\n%s\n' "$details" >> "$report" + printf '\n---\n\n%s\n' "$details" >> "$report" fi fi cat "$report" >> "$GITHUB_STEP_SUMMARY" + + # The job summary takes the whole report, but a pull request comment is capped at 65,536 + # characters, so the comment keeps the table and points at the run for the rest. + comment="$report" + if [ "$(wc -c < "$report")" -gt "$MAX_COMMENT_BYTES" ]; then + comment="$RUNNER_TEMP/matrix-report-comment.md" + if [ "$total" -eq 0 ]; then + cp "$report" "$comment" + else + printf '### %s\n\n%s\n\n%s\n\n> [!NOTE]\n> The per-target details were too long for a comment. See the [run summary](%s).\n' \ + "$TITLE" "$table" "$footer" "$RUN_URL" > "$comment" + fi + fi + { echo "report<> "$GITHUB_OUTPUT" + # The summary is rendered even for a failed plan, so its diagnostics reach the report as links + # rather than as a bare excerpt of the log. + render() { + python3 "$RENDER_SCRIPT" \ + --plan-json "$plan_json" \ + --plan-text "$log" \ + --stream "$stream" \ + --target-dir "$TARGET_DIR" \ + --file-url-prefix "$FILE_URL_PREFIX" \ + --max-resources "$MAX_RESOURCES" \ + --diff-max-lines "$DIFF_MAX_LINES" \ + --max-bytes "$SUMMARY_MAX_BYTES" \ + --diff "$DIFF_ENABLED" \ + --failed "$1" + } + if [ "$exit_code" -eq 1 ]; then + render true > "$SUMMARY_FILE" || rm -f "$SUMMARY_FILE" echo "::error::terraform plan failed." exit 1 fi @@ -282,48 +322,7 @@ runs: # rather than rendering an empty table that would read as "no changes". summary="> [!WARNING]"$'\n'"> This workspace runs with remote execution, so no plan file was saved."$'\n'"> The plan output is in the job log, and this workspace cannot be applied from a saved plan." else - summary="$(jq -r --argjson counts "$resource_counts" --argjson max "$MAX_RESOURCES" --arg saved "$plan_file_saved" ' - def symbol: - if . == ["create"] then "+" - elif . == ["update"] then "~" - elif . == ["delete"] then "-" - elif . == ["delete","create"] or . == ["create","delete"] then "±" - else "?" end; - [(.resource_changes // [])[] | select(.change.actions != ["no-op"] and .change.actions != ["read"])] as $rows - | [(.resource_drift // [])[]] as $drift - | (if ($rows | length) == 0 then - ["No changes. The infrastructure matches the configuration."] - else - ["**\($counts.create)** to add, **\($counts.update)** to change, **\($counts.destroy)** to destroy, **\($counts.replace)** to replace.", - "", - "| | Resource |", - "|:-:|---|"] - + [$rows[0:$max][] | "| `\(.change.actions | symbol)` | `\(.address)` |"] - + (if ($rows | length) > $max then ["", "… and \(($rows | length) - $max) more."] else [] end) - end) - + (if ($drift | length) > 0 then - ["", "
⚠️ \($drift | length) changed outside of Terraform", ""] - + [$drift[0:$max][] | "- `\(.address)`"] - + ["", "
"] - else [] end) - + (if $saved == "false" then ["", "> [!WARNING]", "> This workspace runs remotely, so no plan file was saved and it cannot be applied from one."] else [] end) - | join("\n") - ' "$plan_json")" - fi - - # Diagnostics are not in the plan file, so they are appended from the stream. - warnings="$(jq -s -r --argjson max "$MAX_RESOURCES" ' - [ .[] | select(.type == "diagnostic") | .diagnostic | select(.severity == "warning") ] as $w - | if ($w | length) == 0 then empty - else - ["", "
⚠️ \($w | length) warning(s)", ""] - + [$w[0:$max][] | "- **\(.summary)**" + (if (.range.filename // "") != "" then " · `\(.range.filename):\(.range.start.line)`" else "" end)] - + ["", "
"] - | join("\n") - end - ' "$stream" 2>/dev/null || true)" - if [ -n "$warnings" ]; then - summary="$summary$warnings" + summary="$(render false)" fi # Written to a file rather than an output, so the always-running summary step below can diff --git a/.github/actions/terraform.plan/scripts/render-summary.py b/.github/actions/terraform.plan/scripts/render-summary.py new file mode 100755 index 0000000..b3c781c --- /dev/null +++ b/.github/actions/terraform.plan/scripts/render-summary.py @@ -0,0 +1,267 @@ +#!/usr/bin/env python3 +"""Render a Terraform plan as the Markdown summary of the `terraform.plan` action. + +The per-resource diffs are sliced out of `terraform show -no-color`, so they read exactly as +Terraform prints them and keep its redaction of values marked sensitive. Drift has no textual +rendering in Terraform, so it is diffed from the JSON plan instead. +""" + +from __future__ import annotations + +import argparse +import json +import re +import sys + +# The phrase Terraform puts after the address in ` #
`, and how to show it. +ACTIONS = [ + ("will be created", "➕", "create"), + ("will be updated in-place", "📝", "update"), + ("must be replaced", "♻️", "replace"), + ("will be destroyed", "🗑️", "destroy"), + ("will be read during apply", "👁️", "read"), + ("has moved to", "📦", "move"), + ("will be imported", "📥", "import"), +] +RESOURCE_HEADER = re.compile(r"^ # (?P
\S.*?) (?Pwill be .*|must be .*|has moved to .*)$") +# A ` # (because ...)` line continues the header above it rather than starting a new resource. +HEADER_NOTE = re.compile(r"^ # \((?P.*)\)$") + + +def slice_resources(text: str) -> list[dict]: + """Split the readable plan into one chunk per resource, keeping Terraform's own formatting.""" + resources: list[dict] = [] + current: dict | None = None + for line in text.splitlines(): + header = RESOURCE_HEADER.match(line) + if header: + emoji, action = "🔹", "change" + for phrase, phrase_emoji, phrase_action in ACTIONS: + if header.group("phrase").startswith(phrase): + emoji, action = phrase_emoji, phrase_action + break + current = { + "address": header.group("address"), + "phrase": header.group("phrase"), + "emoji": emoji, + "action": action, + "note": "", + "lines": [], + } + resources.append(current) + continue + + note = HEADER_NOTE.match(line) + if note and current is not None and not current["lines"]: + current["note"] = note.group("note") + continue + + if current is not None: + # Everything up to the blank line that follows the closing brace belongs to this resource. + if line.strip() == "" and current["lines"] and current["lines"][-1].strip() in ("}", "]"): + current = None + continue + current["lines"].append(line) + return resources + + +def dedent(lines: list[str]) -> list[str]: + body = [line for line in lines if line.strip()] + if not body: + return lines + indent = min(len(line) - len(line.lstrip()) for line in body) + return [line[indent:] if len(line) >= indent else line for line in lines] + + +def fence(lines: list[str], max_lines: int, lang: str = "diff") -> list[str]: + shown = dedent(lines) + while shown and not shown[-1].strip(): + shown.pop() + truncated = len(shown) - max_lines + if truncated > 0: + shown = shown[:max_lines] + [f"… and {truncated} more line(s); see the job log."] + return [f"```{lang}", *shown, "```"] + + +def details(title: str, body: list[str]) -> list[str]: + return ["
" + title + "", "", *body, "", "
"] + + +def render_value(value) -> str: + if value is None: + return "null" + if isinstance(value, str): + return json.dumps(value) + return json.dumps(value, ensure_ascii=False) + + +def drift_diff(change: dict) -> list[str]: + """Terraform prints no drift section, so compare the JSON before and after ourselves.""" + before, after = change.get("before") or {}, change.get("after") + if after is None: + return ["- # the resource no longer exists"] + lines: list[str] = [] + for key in sorted(set(before) | set(after)): + old, new = before.get(key), after.get(key) + if old == new: + continue + lines.append(f"- {key} = {render_value(old)}") + lines.append(f"+ {key} = {render_value(new)}") + return lines or [" # no attribute difference was reported"] + + +def file_link(prefix: str, target_dir: str, filename: str, line: int) -> str: + label = f"`{filename}:{line}`" if line else f"`{filename}`" + if not prefix or not filename: + return label + path = filename if target_dir in ("", ".") else f"{target_dir}/{filename}" + anchor = f"#L{line}" if line else "" + return f"[{label}]({prefix}{path}{anchor})" + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--plan-json", required=True) + parser.add_argument("--plan-text", default="") + parser.add_argument("--stream", default="") + parser.add_argument("--target-dir", default=".") + parser.add_argument("--file-url-prefix", default="") + parser.add_argument("--max-resources", type=int, default=50) + parser.add_argument("--diff-max-lines", type=int, default=60) + parser.add_argument("--diff", default="true") + parser.add_argument("--max-bytes", type=int, default=30000) + parser.add_argument("--failed", default="false") + args = parser.parse_args() + + show_diff = args.diff == "true" + + try: + with open(args.plan_json, encoding="utf-8") as handle: + plan = json.load(handle) + except (OSError, json.JSONDecodeError): + plan = {} + + plan_text = "" + if args.plan_text: + try: + with open(args.plan_text, encoding="utf-8") as handle: + plan_text = handle.read() + except OSError: + plan_text = "" + + diagnostics = [] + if args.stream: + try: + with open(args.stream, encoding="utf-8") as handle: + for raw in handle: + try: + message = json.loads(raw) + except json.JSONDecodeError: + continue + if message.get("type") == "diagnostic": + diagnostics.append(message.get("diagnostic", {})) + except OSError: + pass + + changes = [ + change + for change in plan.get("resource_changes", []) + if change.get("change", {}).get("actions") not in (["no-op"], ["read"]) + ] + counts = {"create": 0, "update": 0, "destroy": 0, "replace": 0} + for change in changes: + actions = change.get("change", {}).get("actions", []) + if actions == ["create"]: + counts["create"] += 1 + elif actions == ["update"]: + counts["update"] += 1 + elif actions == ["delete"]: + counts["destroy"] += 1 + elif set(actions) == {"create", "delete"}: + counts["replace"] += 1 + drift = plan.get("resource_drift", []) + + out: list[str] = [] + failed = args.failed == "true" + + if failed: + # The diagnostics below say what went wrong, so the plan itself is not described. + out.extend(["> [!CAUTION]", "> `terraform plan` failed."]) + elif not changes: + out.append("No changes. The infrastructure matches the configuration.") + + if not failed and changes: + out.append( + f"**{counts['create']}** to add · **{counts['update']}** to change · " + f"**{counts['destroy']}** to destroy · **{counts['replace']}** to replace" + ) + out.append("") + + sliced = slice_resources(plan_text) if show_diff else [] + by_address = {item["address"]: item for item in sliced} + emoji_by_action = {"create": "➕", "update": "📝", "delete": "🗑️", "replace": "♻️"} + + for change in changes[: args.max_resources]: + address = change.get("address", "") + actions = change.get("change", {}).get("actions", []) + action = "replace" if set(actions) == {"create", "delete"} else (actions[0] if actions else "change") + item = by_address.get(address) + emoji = item["emoji"] if item else emoji_by_action.get(action, "🔹") + reason = (item or {}).get("note") or change.get("action_reason", "").replace("_", " ") + title = f"{emoji} {address}" + if reason: + title += f" — {reason}" + + if item and item["lines"]: + out.extend(details(title, fence(item["lines"], args.diff_max_lines))) + else: + out.append(f"- {emoji} `{address}`" + (f" — {reason}" if reason else "")) + if len(changes) > args.max_resources: + out.extend(["", f"… and {len(changes) - args.max_resources} more resource(s); see the job log."]) + + if drift: + noun = "resource" if len(drift) == 1 else "resources" + out.extend(["", f"**⚠️ {len(drift)} {noun} changed outside of Terraform**", ""]) + for item in drift[: args.max_resources]: + address = item.get("address", "") + body = fence(drift_diff(item.get("change", {})), args.diff_max_lines) if show_diff else [] + title = f"🌀 {address}" + out.extend(details(title, body) if body else [f"- 🌀 `{address}`"]) + if len(drift) > args.max_resources: + out.extend(["", f"… and {len(drift) - args.max_resources} more; see the job log."]) + + for severity, emoji, noun in (("error", "❌", "error"), ("warning", "⚠️", "warning")): + found = [item for item in diagnostics if item.get("severity") == severity] + if not found: + continue + label = noun if len(found) == 1 else f"{noun}s" + out.extend(["", f"**{emoji} {len(found)} {label}**", ""]) + for item in found[: args.max_resources]: + rng = item.get("range") or {} + link = file_link( + args.file_url_prefix, + args.target_dir, + rng.get("filename", ""), + (rng.get("start") or {}).get("line", 0), + ) + suffix = f" · {link}" if rng.get("filename") else "" + out.append(f"- **{item.get('summary', '')}**{suffix}") + + summary = "\n".join(out).strip() + + # The report shares a pull request comment with every other workspace, and GitHub rejects a comment + # over 65,536 characters, so one workspace is cut off well before it can spend the whole budget. + if args.max_bytes > 0 and len(summary) > args.max_bytes: + cut = summary[: args.max_bytes].rsplit("\n", 1)[0] + # Never leave a fence or a details block open, or the rest of the report renders inside it. + if cut.count("```") % 2: + cut += "\n```" + cut += "\n\n
" * max(0, cut.count("
") - cut.count("
")) + summary = cut + "\n\n> [!NOTE]\n> The summary was truncated. The full plan is in the job log." + + print(summary) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) From 3162b05f07bac9c86c949d5feecfb3d3d4b29251 Mon Sep 17 00:00:00 2001 From: Byungjin Park Date: Mon, 21 Sep 2026 14:43:20 +0900 Subject: [PATCH 14/18] refactor(actions): head each target with its own result line in the report MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace the pictographic emoji with Terraform's own notation, coloured so a block can be placed at a glance: 🟩 `+`, 🟨 `~`, 🟥 `-`, 🟧 `±`, 🟪 for drift - Add a `headline` output to `terraform.plan`, a single line such as `🟩 +1 · 🟨 ~1 · 🟪 1 drifted · ⚠️ 1 warning`, and take the counts out of the body so they are read next to the workspace name instead of above the diffs - Give `github.matrix-report` a `headline` input and render each target as a collapsed section headed by `

`, with ✅ or ❌ and the headline on the same line, a rule between targets, and the section left open when it failed - Add `table_enabled`, so a report whose targets each carry their own section can drop the table that would only repeat the headings --- .../actions/github.matrix-report/action.yaml | 46 ++++++--- .github/actions/terraform.plan/action.yaml | 19 +++- .../terraform.plan/scripts/render-summary.py | 97 ++++++++++++------- 3 files changed, 115 insertions(+), 47 deletions(-) diff --git a/.github/actions/github.matrix-report/action.yaml b/.github/actions/github.matrix-report/action.yaml index bd09d6a..478efdb 100644 --- a/.github/actions/github.matrix-report/action.yaml +++ b/.github/actions/github.matrix-report/action.yaml @@ -18,7 +18,10 @@ inputs: description: "(Required in `collect` mode) A JSON object mapping each check name to its step outcome, e.g. `{\"fmt\": \"success\", \"tflint\": \"failure\"}`. Key order defines the column order. Values are usually `steps..outcome`: `success`, `failure`, `skipped`, or `cancelled`. An empty value drops the check from the row, so a check that is turned off for the whole workflow leaves no column behind." details: required: false - description: "(Optional, `collect` mode) Markdown shown for this row in a collapsed `
` block below the table, for content too long to sit in a cell such as a Terraform plan. Rows without it are listed in the table only." + description: "(Optional, `collect` mode) Markdown shown for this target in its own collapsed section, for content too long to sit in a cell such as a Terraform plan. Targets without it are listed in the table only." + headline: + required: false + description: "(Optional, `collect` mode) A single line shown next to the target's name on the section heading, such as the counts of a plan. Only used when `details` is set." job_status: required: false description: "(Optional, `collect` mode) The current job status, usually the `job.status` context. When it is `failure` while no check failed (e.g. a setup step failed), the row is flagged as failed." @@ -38,6 +41,10 @@ inputs: required: false default: "true" description: "(Optional, `publish` mode) Whether to post the report as a sticky comment on the pull request. Only applies to `pull_request` events and requires the `pull-requests: write` permission. Defaults to `true`." + table_enabled: + required: false + default: "true" + description: "(Optional, `publish` mode) Whether the report opens with the table of every target and check. Worth turning off for a report of a single check whose targets each carry `details`, where the table only repeats the section headings. Defaults to `true`." pr_number: required: false description: "(Optional, `publish` mode) The pull request to comment on. Required on events without a pull request context, such as the push that follows a merge. Defaults to the pull request of the current event." @@ -75,6 +82,7 @@ runs: ID: ${{ inputs.id }} RESULTS: ${{ inputs.results }} DETAILS: ${{ inputs.details }} + HEADLINE: ${{ inputs.headline }} JOB_STATUS: ${{ inputs.job_status }} run: | if [ -z "$ID" ] || ! jq -e 'type == "object" and length > 0' <<< "$RESULTS" >/dev/null 2>&1; then @@ -89,8 +97,8 @@ runs: key="$(printf '%s' "$ID" | tr -c 'A-Za-z0-9._-' '-')-$(printf '%s' "$ID" | shasum | cut -c1-8)" dir="$RUNNER_TEMP/matrix-report" mkdir -p "$dir" - jq -n --arg id "$ID" --arg job_status "$JOB_STATUS" --argjson results "$results" --arg details "$DETAILS" \ - '{id: $id, job_status: $job_status, results: $results, details: $details}' > "$dir/$key.json" + jq -n --arg id "$ID" --arg job_status "$JOB_STATUS" --argjson results "$results" --arg details "$DETAILS" --arg headline "$HEADLINE" \ + '{id: $id, job_status: $job_status, results: $results, details: $details, headline: $headline}' > "$dir/$key.json" echo "key=$key" >> "$GITHUB_OUTPUT" @@ -135,6 +143,7 @@ runs: env: ID_LABEL: ${{ inputs.id_label }} TITLE: ${{ inputs.title }} + TABLE_ENABLED: ${{ inputs.table_enabled }} RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} MAX_COMMENT_BYTES: "60000" run: | @@ -167,17 +176,26 @@ runs: else footer="❌ $failed of $total failed · see the [run summary]($RUN_URL) for details." fi - # One section per row, so a long body such as a plan is read under the heading of the target it - # belongs to rather than as one undifferentiated block. + # One collapsed section per target, headed by its name and its own one-line result, and left open + # when it failed so a failure is read without a click. details="$(jq -s -r ' - sort_by(.id)[] - | select((.details // "") != "") - | "#### \(.id)\n\n\(.details)\n" + [ sort_by(.id)[] + | select((.details // "") != "") + | (((.results | map(. == "failure") | any)) or .job_status == "failure") as $failed + | (if $failed then "❌" else "✅" end) as $icon + | (if $failed then " open" else "" end) as $open + | (if (.headline // "") != "" then "  \(.headline)" else "" end) as $note + | "\n

\($icon) \(.id)\($note)

\n\n\(.details)\n\n
" + ] | join("\n\n---\n\n") ' "${files[@]}")" - printf '### %s\n\n%s\n\n%s\n' "$TITLE" "$table" "$footer" > "$report" + printf '### %s\n\n' "$TITLE" > "$report" + if [ "$TABLE_ENABLED" = "true" ]; then + printf '%s\n\n' "$table" >> "$report" + fi + printf '%s\n' "$footer" >> "$report" if [ -n "$details" ]; then - printf '\n---\n\n%s\n' "$details" >> "$report" + printf '\n%s\n' "$details" >> "$report" fi fi @@ -191,8 +209,12 @@ runs: if [ "$total" -eq 0 ]; then cp "$report" "$comment" else - printf '### %s\n\n%s\n\n%s\n\n> [!NOTE]\n> The per-target details were too long for a comment. See the [run summary](%s).\n' \ - "$TITLE" "$table" "$footer" "$RUN_URL" > "$comment" + printf '### %s\n\n' "$TITLE" > "$comment" + if [ "$TABLE_ENABLED" = "true" ]; then + printf '%s\n\n' "$table" >> "$comment" + fi + printf '%s\n\n> [!NOTE]\n> The per-target details were too long for a comment. See the [run summary](%s).\n' \ + "$footer" "$RUN_URL" >> "$comment" fi fi diff --git a/.github/actions/terraform.plan/action.yaml b/.github/actions/terraform.plan/action.yaml index c66eaa7..7d026cf 100644 --- a/.github/actions/terraform.plan/action.yaml +++ b/.github/actions/terraform.plan/action.yaml @@ -89,6 +89,9 @@ outputs: value: ${{ steps.plan.outputs.diagnostics }} description: "A JSON array of the diagnostics, each `{severity, summary, filename, line}`, truncated to `max_resources`. Read it with `fromJson`." + headline: + value: ${{ steps.summary.outputs.headline }} + description: "A single line describing the plan, such as `🟩 +1 · 🟨 ~1 · 🟪 1 drifted`, meant to sit next to the workspace name in a report rather than above the body." summary: value: ${{ steps.summary.outputs.summary }} description: "A Markdown summary, suitable for a job summary or a pull request comment. It describes the plan when there is one, and otherwise carries the tail of whichever command failed, so a reader never sees a bare failure with no reason." @@ -125,6 +128,7 @@ runs: echo "plan_json_file=$prefix.json" echo "log_file=$prefix.log" echo "summary_file=$prefix.md" + echo "headline_file=$prefix.headline" } >> "$GITHUB_OUTPUT" - name: Terraform Init @@ -161,6 +165,7 @@ runs: PLAN_JSON_FILE: ${{ steps.target.outputs.plan_json_file }} LOG_FILE: ${{ steps.target.outputs.log_file }} SUMMARY_FILE: ${{ steps.target.outputs.summary_file }} + HEADLINE_FILE: ${{ steps.target.outputs.headline_file }} ARGS: ${{ inputs.args }} MAX_RESOURCES: ${{ inputs.max_resources }} FILE_URL_PREFIX: ${{ steps.target.outputs.file_url_prefix }} @@ -262,6 +267,7 @@ runs: # rather than as a bare excerpt of the log. render() { python3 "$RENDER_SCRIPT" \ + --part "$2" \ --plan-json "$plan_json" \ --plan-text "$log" \ --stream "$stream" \ @@ -275,7 +281,8 @@ runs: } if [ "$exit_code" -eq 1 ]; then - render true > "$SUMMARY_FILE" || rm -f "$SUMMARY_FILE" + render true body > "$SUMMARY_FILE" || rm -f "$SUMMARY_FILE" + render true headline > "$HEADLINE_FILE" || rm -f "$HEADLINE_FILE" echo "::error::terraform plan failed." exit 1 fi @@ -322,7 +329,8 @@ runs: # rather than rendering an empty table that would read as "no changes". summary="> [!WARNING]"$'\n'"> This workspace runs with remote execution, so no plan file was saved."$'\n'"> The plan output is in the job log, and this workspace cannot be applied from a saved plan." else - summary="$(render false)" + summary="$(render false body)" + render false headline > "$HEADLINE_FILE" fi # Written to a file rather than an output, so the always-running summary step below can @@ -375,6 +383,7 @@ runs: INIT_LOG: ${{ steps.init.outputs.log_file }} LOG_FILE: ${{ steps.target.outputs.log_file }} SUMMARY_FILE: ${{ steps.target.outputs.summary_file }} + HEADLINE_FILE: ${{ steps.target.outputs.headline_file }} run: | excerpt() { printf '```text\n%s\n```\n' "$(tail -n "$MAX_LOG_LINES" "$1")" @@ -390,7 +399,13 @@ runs: summary="> [!CAUTION]"$'\n'"> The plan did not run. See the job log." fi + headline="" + if [ -n "$HEADLINE_FILE" ] && [ -f "$HEADLINE_FILE" ]; then + headline="$(head -n 1 "$HEADLINE_FILE")" + fi + { + echo "headline=$headline" echo "summary< `, and how to show it. +# Terraform's own notation, coloured so a block can be placed at a glance. +SIGNS = { + "create": "🟩 +", + "update": "🟨 ~", + "destroy": "🟥 -", + "replace": "🟧 ±", + "read": "🟦 <=", + "move": "🟪 ->", + "import": "🟦 +", + "drift": "🟪 ~", + "change": "⬜ ?", +} +COUNT_SIGNS = {"create": "🟩 +", "update": "🟨 ~", "destroy": "🟥 -", "replace": "🟧 ±"} ACTIONS = [ - ("will be created", "➕", "create"), - ("will be updated in-place", "📝", "update"), - ("must be replaced", "♻️", "replace"), - ("will be destroyed", "🗑️", "destroy"), - ("will be read during apply", "👁️", "read"), - ("has moved to", "📦", "move"), - ("will be imported", "📥", "import"), + ("will be created", "create"), + ("will be updated in-place", "update"), + ("must be replaced", "replace"), + ("will be destroyed", "destroy"), + ("will be read during apply", "read"), + ("has moved to", "move"), + ("will be imported", "import"), ] RESOURCE_HEADER = re.compile(r"^ # (?P
\S.*?) (?Pwill be .*|must be .*|has moved to .*)$") # A ` # (because ...)` line continues the header above it rather than starting a new resource. @@ -35,15 +48,15 @@ def slice_resources(text: str) -> list[dict]: for line in text.splitlines(): header = RESOURCE_HEADER.match(line) if header: - emoji, action = "🔹", "change" - for phrase, phrase_emoji, phrase_action in ACTIONS: + action = "change" + for phrase, phrase_action in ACTIONS: if header.group("phrase").startswith(phrase): - emoji, action = phrase_emoji, phrase_action + action = phrase_action break current = { "address": header.group("address"), "phrase": header.group("phrase"), - "emoji": emoji, + "sign": SIGNS.get(action, SIGNS["change"]), "action": action, "note": "", "lines": [], @@ -131,6 +144,7 @@ def main() -> int: parser.add_argument("--diff", default="true") parser.add_argument("--max-bytes", type=int, default=30000) parser.add_argument("--failed", default="false") + parser.add_argument("--part", choices=("body", "headline"), default="body") args = parser.parse_args() show_diff = args.diff == "true" @@ -181,61 +195,78 @@ def main() -> int: counts["replace"] += 1 drift = plan.get("resource_drift", []) - out: list[str] = [] failed = args.failed == "true" + errors = [item for item in diagnostics if item.get("severity") == "error"] + warnings = [item for item in diagnostics if item.get("severity") == "warning"] + + def plural(count: int, noun: str) -> str: + return f"{count} {noun}" if count == 1 else f"{count} {noun}s" + + # The headline sits next to the target's name in the report, so it stays to a single line. + if args.part == "headline": + parts: list[str] = [] + if not failed: + if changes: + parts += [f"{COUNT_SIGNS[key]}{counts[key]}" for key in COUNT_SIGNS if counts[key]] + else: + parts.append("no changes") + if drift: + parts.append(f"🟪 {plural(len(drift), 'drifted')}") + if errors: + parts.append(f"❌ {plural(len(errors), 'error')}") + if warnings: + parts.append(f"⚠️ {plural(len(warnings), 'warning')}") + print(" · ".join(parts)) + return 0 + + out: list[str] = [] if failed: # The diagnostics below say what went wrong, so the plan itself is not described. out.extend(["> [!CAUTION]", "> `terraform plan` failed."]) elif not changes: out.append("No changes. The infrastructure matches the configuration.") - - if not failed and changes: - out.append( - f"**{counts['create']}** to add · **{counts['update']}** to change · " - f"**{counts['destroy']}** to destroy · **{counts['replace']}** to replace" - ) - out.append("") - + else: sliced = slice_resources(plan_text) if show_diff else [] by_address = {item["address"]: item for item in sliced} - emoji_by_action = {"create": "➕", "update": "📝", "delete": "🗑️", "replace": "♻️"} for change in changes[: args.max_resources]: address = change.get("address", "") actions = change.get("change", {}).get("actions", []) - action = "replace" if set(actions) == {"create", "delete"} else (actions[0] if actions else "change") + if set(actions) == {"create", "delete"}: + action = "replace" + elif actions == ["delete"]: + action = "destroy" + else: + action = actions[0] if actions else "change" item = by_address.get(address) - emoji = item["emoji"] if item else emoji_by_action.get(action, "🔹") + sign = item["sign"] if item else SIGNS.get(action, SIGNS["change"]) reason = (item or {}).get("note") or change.get("action_reason", "").replace("_", " ") - title = f"{emoji} {address}" + title = f"{sign} {address}" if reason: title += f" — {reason}" if item and item["lines"]: out.extend(details(title, fence(item["lines"], args.diff_max_lines))) else: - out.append(f"- {emoji} `{address}`" + (f" — {reason}" if reason else "")) + out.append(f"- {sign} {address}" + (f" — {reason}" if reason else "")) if len(changes) > args.max_resources: out.extend(["", f"… and {len(changes) - args.max_resources} more resource(s); see the job log."]) if drift: - noun = "resource" if len(drift) == 1 else "resources" - out.extend(["", f"**⚠️ {len(drift)} {noun} changed outside of Terraform**", ""]) + out.extend(["", f"**🟪 {plural(len(drift), 'resource')} changed outside of Terraform**", ""]) for item in drift[: args.max_resources]: address = item.get("address", "") body = fence(drift_diff(item.get("change", {})), args.diff_max_lines) if show_diff else [] - title = f"🌀 {address}" - out.extend(details(title, body) if body else [f"- 🌀 `{address}`"]) + title = f"{SIGNS['drift']} {address}" + out.extend(details(title, body) if body else [f"- {title}"]) if len(drift) > args.max_resources: out.extend(["", f"… and {len(drift) - args.max_resources} more; see the job log."]) - for severity, emoji, noun in (("error", "❌", "error"), ("warning", "⚠️", "warning")): - found = [item for item in diagnostics if item.get("severity") == severity] + for found, emoji, noun in ((errors, "❌", "error"), (warnings, "⚠️", "warning")): if not found: continue - label = noun if len(found) == 1 else f"{noun}s" - out.extend(["", f"**{emoji} {len(found)} {label}**", ""]) + out.extend(["", f"**{emoji} {plural(len(found), noun)}**", ""]) for item in found[: args.max_resources]: rng = item.get("range") or {} link = file_link( From d65629395d2ada8dcc0c017757987f42c3ddea9f Mon Sep 17 00:00:00 2001 From: Byungjin Park Date: Mon, 21 Sep 2026 14:54:13 +0900 Subject: [PATCH 15/18] refactor(actions): give the plan body named sections and drop the repeated counts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove the fixed `terraform plan failed` banner. It never varied, and the target's heading already carries ❌ and the error count. A failure with no diagnostics at all now says so instead - Head every part of the body: `Resource changes`, `Changed outside of Terraform`, `Errors`, `Warnings`. The counts stay on the target heading, so a section only needs a name - Settle the levels: the report title drops to `##`, each target keeps its `

`, and the sections inside one sit at `####` - Divide the target headline into groups, so the resource counts, the drift and the diagnostics read as three things rather than one run-on list --- .../actions/github.matrix-report/action.yaml | 6 ++-- .../terraform.plan/scripts/render-summary.py | 33 ++++++++++++------- 2 files changed, 24 insertions(+), 15 deletions(-) diff --git a/.github/actions/github.matrix-report/action.yaml b/.github/actions/github.matrix-report/action.yaml index 478efdb..44c894f 100644 --- a/.github/actions/github.matrix-report/action.yaml +++ b/.github/actions/github.matrix-report/action.yaml @@ -155,7 +155,7 @@ runs: if [ "$total" -eq 0 ]; then failed=0 - printf '### %s\n\nNo results were collected.\n' "$TITLE" > "$report" + printf '## %s\n\nNo results were collected.\n' "$TITLE" > "$report" else failed="$(jq -s 'map(select((.results | map(. == "failure") | any) or .job_status == "failure")) | length' "${files[@]}")" table="$(jq -s -r --arg label "$ID_LABEL" ' @@ -189,7 +189,7 @@ runs: ] | join("\n\n---\n\n") ' "${files[@]}")" - printf '### %s\n\n' "$TITLE" > "$report" + printf '## %s\n\n' "$TITLE" > "$report" if [ "$TABLE_ENABLED" = "true" ]; then printf '%s\n\n' "$table" >> "$report" fi @@ -209,7 +209,7 @@ runs: if [ "$total" -eq 0 ]; then cp "$report" "$comment" else - printf '### %s\n\n' "$TITLE" > "$comment" + printf '## %s\n\n' "$TITLE" > "$comment" if [ "$TABLE_ENABLED" = "true" ]; then printf '%s\n\n' "$table" >> "$comment" fi diff --git a/.github/actions/terraform.plan/scripts/render-summary.py b/.github/actions/terraform.plan/scripts/render-summary.py index 314956e..eb67c9a 100755 --- a/.github/actions/terraform.plan/scripts/render-summary.py +++ b/.github/actions/terraform.plan/scripts/render-summary.py @@ -36,6 +36,8 @@ ("has moved to", "move"), ("will be imported", "import"), ] +# The report puts each target under an `

`, so a section inside one sits a level below it. +HEADING = "####" RESOURCE_HEADER = re.compile(r"^ # (?P
\S.*?) (?Pwill be .*|must be .*|has moved to .*)$") # A ` # (because ...)` line continues the header above it rather than starting a new resource. HEADER_NOTE = re.compile(r"^ # \((?P.*)\)$") @@ -204,29 +206,35 @@ def plural(count: int, noun: str) -> str: # The headline sits next to the target's name in the report, so it stays to a single line. if args.part == "headline": - parts: list[str] = [] + # Each group is one concern, so they are divided rather than run together. + groups: list[str] = [] if not failed: if changes: - parts += [f"{COUNT_SIGNS[key]}{counts[key]}" for key in COUNT_SIGNS if counts[key]] + groups.append(" ".join(f"{COUNT_SIGNS[key]}{counts[key]}" for key in COUNT_SIGNS if counts[key])) else: - parts.append("no changes") + groups.append("no changes") if drift: - parts.append(f"🟪 {plural(len(drift), 'drifted')}") + groups.append(f"🟪 {plural(len(drift), 'drifted')}") + notices = [] if errors: - parts.append(f"❌ {plural(len(errors), 'error')}") + notices.append(f"❌ {plural(len(errors), 'error')}") if warnings: - parts.append(f"⚠️ {plural(len(warnings), 'warning')}") - print(" · ".join(parts)) + notices.append(f"⚠️ {plural(len(warnings), 'warning')}") + if notices: + groups.append(" ".join(notices)) + print("  │  ".join(groups)) return 0 out: list[str] = [] if failed: - # The diagnostics below say what went wrong, so the plan itself is not described. - out.extend(["> [!CAUTION]", "> `terraform plan` failed."]) + # The heading already carries ❌ and the error count, so there is no banner to repeat it. + if not errors and not warnings: + out.append("The plan failed and reported no diagnostics. See the job log.") elif not changes: out.append("No changes. The infrastructure matches the configuration.") else: + out.extend([f"{HEADING} Resource changes", ""]) sliced = slice_resources(plan_text) if show_diff else [] by_address = {item["address"]: item for item in sliced} @@ -254,7 +262,7 @@ def plural(count: int, noun: str) -> str: out.extend(["", f"… and {len(changes) - args.max_resources} more resource(s); see the job log."]) if drift: - out.extend(["", f"**🟪 {plural(len(drift), 'resource')} changed outside of Terraform**", ""]) + out.extend(["", f"{HEADING} Changed outside of Terraform", ""]) for item in drift[: args.max_resources]: address = item.get("address", "") body = fence(drift_diff(item.get("change", {})), args.diff_max_lines) if show_diff else [] @@ -263,10 +271,11 @@ def plural(count: int, noun: str) -> str: if len(drift) > args.max_resources: out.extend(["", f"… and {len(drift) - args.max_resources} more; see the job log."]) - for found, emoji, noun in ((errors, "❌", "error"), (warnings, "⚠️", "warning")): + for found, heading in ((errors, "Errors"), (warnings, "Warnings")): if not found: continue - out.extend(["", f"**{emoji} {plural(len(found), noun)}**", ""]) + # The count is on the workspace heading already, so the section only needs a name. + out.extend(["", f"{HEADING} {heading}", ""]) for item in found[: args.max_resources]: rng = item.get("range") or {} link = file_link( From d429f51133034200aede65dddd2b8380e64ac054 Mon Sep 17 00:00:00 2001 From: Byungjin Park Date: Mon, 21 Sep 2026 15:06:33 +0900 Subject: [PATCH 16/18] style(actions): raise the report headings and link the whole diagnostic row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Take the report title to `#`, each target to `

` and the sections inside a target to `###`, so the three levels are told apart by size - Set the verdict above the targets as a quote - Separate the headline groups with a middle dot carrying space on both sides - Read a diagnostic location first, `main.tf:5 — Reference to undeclared input variable`, with the whole row as the link to that line --- .../actions/github.matrix-report/action.yaml | 12 +++---- .../terraform.plan/scripts/render-summary.py | 35 +++++++++++-------- 2 files changed, 27 insertions(+), 20 deletions(-) diff --git a/.github/actions/github.matrix-report/action.yaml b/.github/actions/github.matrix-report/action.yaml index 44c894f..4263208 100644 --- a/.github/actions/github.matrix-report/action.yaml +++ b/.github/actions/github.matrix-report/action.yaml @@ -155,7 +155,7 @@ runs: if [ "$total" -eq 0 ]; then failed=0 - printf '## %s\n\nNo results were collected.\n' "$TITLE" > "$report" + printf '# %s\n\nNo results were collected.\n' "$TITLE" > "$report" else failed="$(jq -s 'map(select((.results | map(. == "failure") | any) or .job_status == "failure")) | length' "${files[@]}")" table="$(jq -s -r --arg label "$ID_LABEL" ' @@ -172,9 +172,9 @@ runs: ' "${files[@]}")" if [ "$failed" -eq 0 ]; then - footer="✅ All $total passed." + footer="> ✅ All $total passed." else - footer="❌ $failed of $total failed · see the [run summary]($RUN_URL) for details." + footer="> ❌ $failed of $total failed · see the [run summary]($RUN_URL) for details." fi # One collapsed section per target, headed by its name and its own one-line result, and left open # when it failed so a failure is read without a click. @@ -185,11 +185,11 @@ runs: | (if $failed then "❌" else "✅" end) as $icon | (if $failed then " open" else "" end) as $open | (if (.headline // "") != "" then "  \(.headline)" else "" end) as $note - | "\n

\($icon) \(.id)\($note)

\n\n\(.details)\n\n" + | "\n

\($icon) \(.id)\($note)

\n\n\(.details)\n\n" ] | join("\n\n---\n\n") ' "${files[@]}")" - printf '## %s\n\n' "$TITLE" > "$report" + printf '# %s\n\n' "$TITLE" > "$report" if [ "$TABLE_ENABLED" = "true" ]; then printf '%s\n\n' "$table" >> "$report" fi @@ -209,7 +209,7 @@ runs: if [ "$total" -eq 0 ]; then cp "$report" "$comment" else - printf '## %s\n\n' "$TITLE" > "$comment" + printf '# %s\n\n' "$TITLE" > "$comment" if [ "$TABLE_ENABLED" = "true" ]; then printf '%s\n\n' "$table" >> "$comment" fi diff --git a/.github/actions/terraform.plan/scripts/render-summary.py b/.github/actions/terraform.plan/scripts/render-summary.py index eb67c9a..5b90d11 100755 --- a/.github/actions/terraform.plan/scripts/render-summary.py +++ b/.github/actions/terraform.plan/scripts/render-summary.py @@ -36,8 +36,10 @@ ("has moved to", "move"), ("will be imported", "import"), ] -# The report puts each target under an `

`, so a section inside one sits a level below it. -HEADING = "####" +# The report puts each target under an `

`, so a section inside one sits a level below it. +HEADING = "###" +# Generous space around the divider, so the groups of the headline read apart at a glance. +GROUP_DIVIDER = "  ·  " RESOURCE_HEADER = re.compile(r"^ # (?P
\S.*?) (?Pwill be .*|must be .*|has moved to .*)$") # A ` # (because ...)` line continues the header above it rather than starting a new resource. HEADER_NOTE = re.compile(r"^ # \((?P.*)\)$") @@ -125,13 +127,17 @@ def drift_diff(change: dict) -> list[str]: return lines or [" # no attribute difference was reported"] -def file_link(prefix: str, target_dir: str, filename: str, line: int) -> str: +def diagnostic_row(prefix: str, target_dir: str, filename: str, line: int, message: str) -> str: + """One row per diagnostic, reading location first, with the whole row as the link.""" + if not filename: + return f"- {message}" label = f"`{filename}:{line}`" if line else f"`{filename}`" - if not prefix or not filename: - return label + text = f"{label} — {message}" + if not prefix: + return f"- {text}" path = filename if target_dir in ("", ".") else f"{target_dir}/{filename}" anchor = f"#L{line}" if line else "" - return f"[{label}]({prefix}{path}{anchor})" + return f"- [{text}]({prefix}{path}{anchor})" def main() -> int: @@ -222,7 +228,7 @@ def plural(count: int, noun: str) -> str: notices.append(f"⚠️ {plural(len(warnings), 'warning')}") if notices: groups.append(" ".join(notices)) - print("  │  ".join(groups)) + print(GROUP_DIVIDER.join(groups)) return 0 out: list[str] = [] @@ -278,14 +284,15 @@ def plural(count: int, noun: str) -> str: out.extend(["", f"{HEADING} {heading}", ""]) for item in found[: args.max_resources]: rng = item.get("range") or {} - link = file_link( - args.file_url_prefix, - args.target_dir, - rng.get("filename", ""), - (rng.get("start") or {}).get("line", 0), + out.append( + diagnostic_row( + args.file_url_prefix, + args.target_dir, + rng.get("filename", ""), + (rng.get("start") or {}).get("line", 0), + item.get("summary", ""), + ) ) - suffix = f" · {link}" if rng.get("filename") else "" - out.append(f"- **{item.get('summary', '')}**{suffix}") summary = "\n".join(out).strip() From 568b9180a7ceaa4417b66a60f5ffac0292a91f62 Mon Sep 17 00:00:00 2001 From: Byungjin Park Date: Mon, 21 Sep 2026 15:24:27 +0900 Subject: [PATCH 17/18] style(actions): mark resource actions with circles and drift with a spiral MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 🟢 `+`, 🟡 `~`, 🔴 `-`, 🟠 `±` for the planned actions - 🌀 for drift, deliberately outside that family: it is not a change Terraform planned, so it reads as a different kind of thing and the headline groups separate on colour as well as on the divider --- .../terraform.plan/scripts/render-summary.py | 26 ++++++++++--------- 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/.github/actions/terraform.plan/scripts/render-summary.py b/.github/actions/terraform.plan/scripts/render-summary.py index 5b90d11..a02e52b 100755 --- a/.github/actions/terraform.plan/scripts/render-summary.py +++ b/.github/actions/terraform.plan/scripts/render-summary.py @@ -14,19 +14,21 @@ import sys # The phrase Terraform puts after the address in ` #
`, and how to show it. -# Terraform's own notation, coloured so a block can be placed at a glance. +# Terraform's own notation, coloured so a block can be placed at a glance. Drift is deliberately not a +# circle: it is not a change Terraform planned, so it reads as a different kind of thing. SIGNS = { - "create": "🟩 +", - "update": "🟨 ~", - "destroy": "🟥 -", - "replace": "🟧 ±", - "read": "🟦 <=", - "move": "🟪 ->", - "import": "🟦 +", - "drift": "🟪 ~", - "change": "⬜ ?", + "create": "🟢 +", + "update": "🟡 ~", + "destroy": "🔴 -", + "replace": "🟠 ±", + "read": "🔵 <=", + "move": "🟣 ->", + "import": "🔵 +", + "drift": "🌀 ~", + "change": "⚪ ?", } -COUNT_SIGNS = {"create": "🟩 +", "update": "🟨 ~", "destroy": "🟥 -", "replace": "🟧 ±"} +COUNT_SIGNS = {"create": "🟢 +", "update": "🟡 ~", "destroy": "🔴 -", "replace": "🟠 ±"} +DRIFT_SIGN = "🌀" ACTIONS = [ ("will be created", "create"), ("will be updated in-place", "update"), @@ -220,7 +222,7 @@ def plural(count: int, noun: str) -> str: else: groups.append("no changes") if drift: - groups.append(f"🟪 {plural(len(drift), 'drifted')}") + groups.append(f"{DRIFT_SIGN} {plural(len(drift), 'drifted')}") notices = [] if errors: notices.append(f"❌ {plural(len(errors), 'error')}") From 46a2201b6e50b8059101454e2341a0b7e63bd603 Mon Sep 17 00:00:00 2001 From: Byungjin Park Date: Mon, 21 Sep 2026 14:20:47 +0900 Subject: [PATCH 18/18] test: demo the plan summary through the real matrix report flow --- .github/workflows/test.plan-json-demo.yaml | 106 ++++++++++++++++ fixtures/plan-demo-error/main.tf | 6 + fixtures/plan-demo/main.tf | 40 ++++++ fixtures/plan-demo/terraform.tfstate | 139 +++++++++++++++++++++ 4 files changed, 291 insertions(+) create mode 100644 .github/workflows/test.plan-json-demo.yaml create mode 100644 fixtures/plan-demo-error/main.tf create mode 100644 fixtures/plan-demo/main.tf create mode 100644 fixtures/plan-demo/terraform.tfstate diff --git a/.github/workflows/test.plan-json-demo.yaml b/.github/workflows/test.plan-json-demo.yaml new file mode 100644 index 0000000..1ca5616 --- /dev/null +++ b/.github/workflows/test.plan-json-demo.yaml @@ -0,0 +1,106 @@ +name: Test - plan JSON demo + + +on: + pull_request: + branches: + - "**" + + +jobs: + plan: + name: Plan (${{ matrix.workspace }}) + runs-on: ubuntu-latest + + strategy: + fail-fast: false + matrix: + workspace: + - plan-demo + - plan-demo-error + + 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" + + - name: Plan + id: plan + continue-on-error: true + uses: tedilabs/github-actions/.github/actions/terraform.plan@test/plan-json-demo + with: + target_dir: fixtures/${{ matrix.workspace }} + + - name: Collect Results + id: results + if: always() + uses: tedilabs/github-actions/.github/actions/github.matrix-report@test/plan-json-demo + with: + mode: collect + id: fixtures/${{ matrix.workspace }} + id_label: Workspace + artifact_prefix: plan-json-demo + job_status: ${{ job.status }} + details: ${{ steps.plan.outputs.summary }} + headline: ${{ steps.plan.outputs.headline }} + results: | + { + "plan": "${{ steps.plan.outcome }}" + } + + # Shows that the structured outputs are still there and parse, without filling the report. + - name: Structured outputs + id: outputs + if: always() + env: + HAS_CHANGES: ${{ steps.plan.outputs.has_changes }} + CREATE: ${{ steps.plan.outputs.create }} + UPDATE: ${{ steps.plan.outputs.update }} + DESTROY: ${{ steps.plan.outputs.destroy }} + REPLACE: ${{ steps.plan.outputs.replace }} + DRIFT_COUNT: ${{ steps.plan.outputs.drift_count }} + WARNING_COUNT: ${{ steps.plan.outputs.warning_count }} + ERROR_COUNT: ${{ steps.plan.outputs.error_count }} + CHANGES: ${{ steps.plan.outputs.changes }} + DIAGNOSTICS: ${{ steps.plan.outputs.diagnostics }} + PLAN_JSON_FILE: ${{ steps.plan.outputs.plan_json_file }} + STREAM_FILE: ${{ steps.plan.outputs.stream_file }} + run: | + printf 'has_changes=%s create=%s update=%s destroy=%s replace=%s drift=%s warnings=%s errors=%s\n' \ + "$HAS_CHANGES" "$CREATE" "$UPDATE" "$DESTROY" "$REPLACE" "$DRIFT_COUNT" "$WARNING_COUNT" "$ERROR_COUNT" + printf 'changes (%s bytes): %s\n' "$(printf '%s' "$CHANGES" | wc -c | tr -d ' ')" "$CHANGES" + printf 'diagnostics (%s bytes): %s\n' "$(printf '%s' "$DIAGNOSTICS" | wc -c | tr -d ' ')" "$DIAGNOSTICS" + printf 'plan_json_file: %s bytes\n' "$(wc -c < "$PLAN_JSON_FILE" 2>/dev/null || echo 0)" + printf 'stream_file: %s bytes\n' "$(wc -c < "$STREAM_FILE" 2>/dev/null || echo 0)" + + + report: + name: Report + needs: + - plan + if: always() + runs-on: ubuntu-latest + + permissions: + contents: read + pull-requests: write + + steps: + - name: Publish Report + id: report + uses: tedilabs/github-actions/.github/actions/github.matrix-report@test/plan-json-demo + with: + mode: publish + id_label: Workspace + artifact_prefix: plan-json-demo + title: Terraform Plan + table_enabled: "false" + pr_comment_marker: plan-json-demo diff --git a/fixtures/plan-demo-error/main.tf b/fixtures/plan-demo-error/main.tf new file mode 100644 index 0000000..d27d553 --- /dev/null +++ b/fixtures/plan-demo-error/main.tf @@ -0,0 +1,6 @@ +# Demo fixture. `var.nope` is never declared, so the plan fails and produces an error diagnostic +# with a file and a line, which the action turns into an annotation. + +resource "terraform_data" "broken" { + input = var.nope +} diff --git a/fixtures/plan-demo/main.tf b/fixtures/plan-demo/main.tf new file mode 100644 index 0000000..2f2948b --- /dev/null +++ b/fixtures/plan-demo/main.tf @@ -0,0 +1,40 @@ +# Demo fixture. The committed `terraform.tfstate` stands in for a previous apply, and `managed.txt` +# is deliberately absent so the refresh reports it as changed outside of Terraform. + +terraform { + required_providers { + local = { source = "hashicorp/local", version = "~> 2.5" } + } +} + +variable "size" { + type = string + default = "large" # was "small" in the state, so `change_me` updates and `replace_me` is replaced +} + +resource "local_file" "managed" { + filename = "${path.module}/managed.txt" + content = "original" +} + +resource "terraform_data" "change_me" { + input = var.size +} + +resource "terraform_data" "replace_me" { + input = "x" + triggers_replace = [var.size] +} + +# `terraform_data.gone` is in the state but not here, so it is destroyed. + +output "current" { + value = terraform_data.change_me.output +} + +check "demonstrates_a_warning" { + assert { + condition = var.size == "never-matches" + error_message = "This check fails on purpose so the plan carries a warning." + } +} diff --git a/fixtures/plan-demo/terraform.tfstate b/fixtures/plan-demo/terraform.tfstate new file mode 100644 index 0000000..ffd732e --- /dev/null +++ b/fixtures/plan-demo/terraform.tfstate @@ -0,0 +1,139 @@ +{ + "version": 4, + "terraform_version": "1.16.3", + "serial": 4, + "lineage": "3f3005e9-7198-836e-a124-fe88a66ff8f8", + "outputs": { + "current": { + "value": "small", + "type": "string" + } + }, + "resources": [ + { + "mode": "managed", + "type": "local_file", + "name": "managed", + "provider": "provider[\"registry.terraform.io/hashicorp/local\"]", + "instances": [ + { + "schema_version": 0, + "attributes": { + "content": "original", + "content_base64": null, + "content_base64sha256": "BoLF8gdvCZw0z90VqeBjhJ7UN6SWd+b8xbQZjHZXW+U=", + "content_base64sha512": "xe4Gf7QzeV1cjv7KeGI3kdxs5SQZi3Ij/oMQ+Bo4yRBdqKYXFN1aYz5S2se1ezOUiv2UyzfFIviXgcnCVHGpww==", + "content_md5": "919c8b643b7133116b02fc0d9bb7df3f", + "content_sha1": "d73ef92426f2b11dfc4aed4d4bfc41c49ee1087c", + "content_sha256": "0682c5f2076f099c34cfdd15a9e063849ed437a49677e6fcc5b4198c76575be5", + "content_sha512": "c5ee067fb433795d5c8efeca78623791dc6ce524198b7223fe8310f81a38c9105da8a61714dd5a633e52dac7b57b33948afd94cb37c522f89781c9c25471a9c3", + "directory_permission": "0777", + "file_permission": "0777", + "filename": "./managed.txt", + "id": "d73ef92426f2b11dfc4aed4d4bfc41c49ee1087c", + "sensitive_content": null, + "source": null + }, + "sensitive_attributes": [ + [ + { + "type": "get_attr", + "value": "sensitive_content" + } + ] + ], + "identity_schema_version": 0 + } + ] + }, + { + "mode": "managed", + "type": "terraform_data", + "name": "change_me", + "provider": "provider[\"terraform.io/builtin/terraform\"]", + "instances": [ + { + "schema_version": 0, + "attributes": { + "id": "540dfd82-907b-51b8-9eea-bd37a834d390", + "input": { + "value": "small", + "type": "string" + }, + "output": { + "value": "small", + "type": "string" + }, + "store": null, + "triggers_replace": null + }, + "sensitive_attributes": [], + "identity_schema_version": 0 + } + ] + }, + { + "mode": "managed", + "type": "terraform_data", + "name": "gone", + "provider": "provider[\"terraform.io/builtin/terraform\"]", + "instances": [ + { + "schema_version": 0, + "attributes": { + "id": "70e89507-6664-49c6-2702-3a28e33c4a07", + "input": { + "value": "bye", + "type": "string" + }, + "output": { + "value": "bye", + "type": "string" + }, + "store": null, + "triggers_replace": null + }, + "sensitive_attributes": [], + "identity_schema_version": 0 + } + ] + }, + { + "mode": "managed", + "type": "terraform_data", + "name": "replace_me", + "provider": "provider[\"terraform.io/builtin/terraform\"]", + "instances": [ + { + "schema_version": 0, + "attributes": { + "id": "4383baf0-4fca-b2eb-fc13-f630eec01651", + "input": { + "value": "x", + "type": "string" + }, + "output": { + "value": "x", + "type": "string" + }, + "store": null, + "triggers_replace": { + "value": [ + "small" + ], + "type": [ + "tuple", + [ + "string" + ] + ] + } + }, + "sensitive_attributes": [], + "identity_schema_version": 0 + } + ] + } + ], + "check_results": null +}