Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
5b3607e
feat(actions): add misc.slug composite action
posquit0 Sep 16, 2026
9a9a61c
feat(actions): add terraform.plan composite action
posquit0 Sep 16, 2026
ba36af0
feat(actions): add terraform.apply composite action
posquit0 Sep 16, 2026
a5781d3
feat(actions): add github.pr.head-run composite action
posquit0 Sep 16, 2026
c1195a8
feat(actions): add details and pr_number to github.matrix-report
posquit0 Sep 16, 2026
ddb0019
feat(workflows): add terraform.workspaces.plan reusable workflow
posquit0 Sep 16, 2026
74da221
feat(workflows): add terraform.workspaces.apply reusable workflow
posquit0 Sep 16, 2026
b1aa353
fix(actions): always emit a summary from terraform.plan and terraform…
posquit0 Sep 16, 2026
474c6d9
feat(actions): add misc.export-env composite action
posquit0 Sep 16, 2026
b3705b5
feat(workflows): accept provider credentials in the Terraform plan an…
posquit0 Sep 16, 2026
a1051c3
refactor(actions): run terraform init through shell.run in plan and a…
posquit0 Sep 19, 2026
7735eac
feat(actions): expose the plan as structured outputs and annotate its…
posquit0 Sep 21, 2026
320b416
feat(actions): render the plan summary as per-resource diffs with links
posquit0 Sep 21, 2026
3162b05
refactor(actions): head each target with its own result line in the r…
posquit0 Sep 21, 2026
d656293
refactor(actions): give the plan body named sections and drop the rep…
posquit0 Sep 21, 2026
d429f51
style(actions): raise the report headings and link the whole diagnost…
posquit0 Sep 21, 2026
568b918
style(actions): mark resource actions with circles and drift with a s…
posquit0 Sep 21, 2026
46a2201
test: demo the plan summary through the real matrix report flow
posquit0 Sep 21, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 64 additions & 8 deletions .github/actions/github.matrix-report/action.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,12 @@ 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.<id>.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 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."
Expand All @@ -35,6 +41,13 @@ 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."
pr_comment_marker:
required: false
default: matrix-report
Expand Down Expand Up @@ -68,6 +81,8 @@ runs:
env:
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
Expand All @@ -82,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" \
'{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" --arg headline "$HEADLINE" \
'{id: $id, job_status: $job_status, results: $results, details: $details, headline: $headline}' > "$dir/$key.json"

echo "key=$key" >> "$GITHUB_OUTPUT"

Expand Down Expand Up @@ -128,7 +143,9 @@ 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: |
dir="$RUNNER_TEMP/matrix-report"
shopt -s nullglob
Expand All @@ -138,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" '
Expand All @@ -155,17 +172,55 @@ 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.
details="$(jq -s -r '
[ 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 " &nbsp;<sub>\(.headline)</sub>" else "" end) as $note
| "<details\($open)>\n<summary><h2>\($icon) <code>\(.id)</code>\($note)</h2></summary>\n\n\(.details)\n\n</details>"
] | join("\n\n---\n\n")
' "${files[@]}")"

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%s\n' "$details" >> "$report"
fi
printf '### %s\n\n%s\n\n%s\n' "$TITLE" "$table" "$footer" > "$report"
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' "$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

{
echo "report<<MATRIX_REPORT_EOF"
cat "$report"
cat "$comment"
echo "MATRIX_REPORT_EOF"
echo "total=$total"
echo "failed=$failed"
Expand All @@ -174,11 +229,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 }}
76 changes: 76 additions & 0 deletions .github/actions/github.pr.head-run/action.yaml
Original file line number Diff line number Diff line change
@@ -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"
69 changes: 69 additions & 0 deletions .github/actions/misc.export-env/action.yaml
Original file line number Diff line number Diff line change
@@ -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)}"
44 changes: 44 additions & 0 deletions .github/actions/misc.slug/action.yaml
Original file line number Diff line number Diff line change
@@ -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"
Loading
Loading