diff --git a/.github/actions/github.matrix-report/action.yaml b/.github/actions/github.matrix-report/action.yaml index 771ff7c..4263208 100644 --- a/.github/actions/github.matrix-report/action.yaml +++ b/.github/actions/github.matrix-report/action.yaml @@ -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..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." @@ -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 @@ -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 @@ -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" @@ -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 @@ -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" ' @@ -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 "  \(.headline)" else "" end) as $note + | "\n

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

\n\n\(.details)\n\n" + ] | 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<> "$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" 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)}" 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" diff --git a/.github/actions/terraform.apply/action.yaml b/.github/actions/terraform.apply/action.yaml new file mode 100644 index 0000000..7bbfed2 --- /dev/null +++ b/.github/actions/terraform.apply/action.yaml @@ -0,0 +1,140 @@ +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 + uses: tedilabs/github-actions/.github/actions/shell.run@main + env: + TARGET_DIR: ${{ inputs.target_dir }} + WORKSPACE: ${{ inputs.workspace }} + with: + run: | + if [ -n "$WORKSPACE" ]; then + export TF_WORKSPACE="$WORKSPACE" + fi + + terraform -chdir="$TARGET_DIR" init -input=false -no-color + + - name: Add Failure Details to Job Summary + id: init-summary + 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) || '' }}" + file: ${{ steps.init.outputs.log_file }} + 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: 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) || '' }}" + file: ${{ runner.temp }}/terraform-apply.log + lang: text diff --git a/.github/actions/terraform.plan/action.yaml b/.github/actions/terraform.plan/action.yaml new file mode 100644 index 0000000..7d026cf --- /dev/null +++ b/.github/actions/terraform.plan/action.yaml @@ -0,0 +1,421 @@ +name: Terraform - Plan +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: + 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 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`." + diff_enabled: + required: false + default: "true" + description: "(Optional) Whether the summary shows the attribute diff of each changed resource in a collapsed block. The diff is sliced out of `terraform show`, so values marked `sensitive` stay redacted, but every other attribute value becomes visible wherever the summary is published. Set to `false` to list the addresses only. Defaults to `true`." + diff_max_lines: + required: false + default: "60" + description: "(Optional) The maximum number of diff lines shown per resource before the block is truncated. Defaults to `60`." + summary_max_bytes: + required: false + default: "30000" + description: "(Optional) The maximum size of the rendered summary. One workspace has to stay well inside the 65,536 character limit of a pull request comment, which the report shares with every other workspace. Defaults to `30000`." + +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." + 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." + 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." + 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`." + + 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." + + +runs: + using: composite + + steps: + - name: Resolve Target + id: target + shell: bash + env: + TARGET_DIR: ${{ inputs.target_dir }} + PLAN_FILE: ${{ inputs.plan_file }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + 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')" + + # Diagnostics point at a file and a line, so link them to the blob on the commit being planned. + # On a pull request `github.sha` is the merge commit, whose tree is not what the author sees. + sha="${HEAD_SHA:-$GITHUB_SHA}" + + { + echo "target_dir=$target_dir" + echo "file_url_prefix=$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/blob/$sha/" + 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" + echo "headline_file=$prefix.headline" + } >> "$GITHUB_OUTPUT" + + - name: Terraform Init + id: init + uses: tedilabs/github-actions/.github/actions/shell.run@main + env: + TARGET_DIR: ${{ inputs.target_dir }} + WORKSPACE: ${{ inputs.workspace }} + with: + run: | + if [ -n "$WORKSPACE" ]; then + export TF_WORKSPACE="$WORKSPACE" + fi + + terraform -chdir="$TARGET_DIR" init -input=false -no-color + + - name: Add Failure Details to Job Summary + id: init-summary + 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) || '' }}" + file: ${{ steps.init.outputs.log_file }} + lang: text + + - name: Terraform Plan + id: plan + shell: bash + env: + TARGET_DIR: ${{ steps.target.outputs.target_dir }} + WORKSPACE: ${{ inputs.workspace }} + 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 }} + HEADLINE_FILE: ${{ steps.target.outputs.headline_file }} + ARGS: ${{ inputs.args }} + MAX_RESOURCES: ${{ inputs.max_resources }} + FILE_URL_PREFIX: ${{ steps.target.outputs.file_url_prefix }} + DIFF_ENABLED: ${{ inputs.diff_enabled }} + DIFF_MAX_LINES: ${{ inputs.diff_max_lines }} + SUMMARY_MAX_BYTES: ${{ inputs.summary_max_bytes }} + RENDER_SCRIPT: ${{ github.action_path }}/scripts/render-summary.py + run: | + if [ -n "$WORKSPACE" ]; then + export TF_WORKSPACE="$WORKSPACE" + fi + + 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. + exit_code=0 + terraform -chdir="$TARGET_DIR" plan \ + -json \ + -out="$plan_file" \ + -input=false \ + -lock=false \ + -detailed-exitcode \ + "${extra_args[@]}" > "$stream" 2>&1 || exit_code=$? + + # 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" "$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="" + 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]}" + : > "$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" + + # 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" \ + --part "$2" \ + --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 body > "$SUMMARY_FILE" || rm -f "$SUMMARY_FILE" + render true headline > "$HEADLINE_FILE" || rm -f "$HEADLINE_FILE" + 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. + resource_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) + } + ' "$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="$(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 + # pick it up whether this step reached the end or not. + 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 + id: summary + if: always() + shell: bash + 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 }} + HEADLINE_FILE: ${{ steps.target.outputs.headline_file }} + run: | + excerpt() { + printf '```text\n%s\n```\n' "$(tail -n "$MAX_LOG_LINES" "$1")" + } + + 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 + 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<> "$GITHUB_OUTPUT" + + - name: Add Failure Details to Job Summary + id: plan-summary + 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) || '' }}" + file: ${{ steps.target.outputs.log_file }} + lang: text 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..a02e52b --- /dev/null +++ b/.github/actions/terraform.plan/scripts/render-summary.py @@ -0,0 +1,316 @@ +#!/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. +# 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": "⚪ ?", +} +COUNT_SIGNS = {"create": "🟢 +", "update": "🟡 ~", "destroy": "🔴 -", "replace": "🟠 ±"} +DRIFT_SIGN = "🌀" +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"), +] +# 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.*)\)$") + + +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: + action = "change" + for phrase, phrase_action in ACTIONS: + if header.group("phrase").startswith(phrase): + action = phrase_action + break + current = { + "address": header.group("address"), + "phrase": header.group("phrase"), + "sign": SIGNS.get(action, SIGNS["change"]), + "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 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}`" + 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"- [{text}]({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") + parser.add_argument("--part", choices=("body", "headline"), default="body") + 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", []) + + 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": + # Each group is one concern, so they are divided rather than run together. + groups: list[str] = [] + if not failed: + if changes: + groups.append(" ".join(f"{COUNT_SIGNS[key]}{counts[key]}" for key in COUNT_SIGNS if counts[key])) + else: + groups.append("no changes") + if drift: + groups.append(f"{DRIFT_SIGN} {plural(len(drift), 'drifted')}") + notices = [] + if errors: + notices.append(f"❌ {plural(len(errors), 'error')}") + if warnings: + notices.append(f"⚠️ {plural(len(warnings), 'warning')}") + if notices: + groups.append(" ".join(notices)) + print(GROUP_DIVIDER.join(groups)) + return 0 + + out: list[str] = [] + + if 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} + + for change in changes[: args.max_resources]: + address = change.get("address", "") + actions = change.get("change", {}).get("actions", []) + 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) + sign = item["sign"] if item else SIGNS.get(action, SIGNS["change"]) + reason = (item or {}).get("note") or change.get("action_reason", "").replace("_", " ") + 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"- {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: + 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 [] + 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 found, heading in ((errors, "Errors"), (warnings, "Warnings")): + if not found: + continue + # 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 {} + out.append( + diagnostic_row( + args.file_url_prefix, + args.target_dir, + rng.get("filename", ""), + (rng.get("start") or {}).get("line", 0), + item.get("summary", ""), + ) + ) + + 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()) diff --git a/.github/workflows/terraform.workspaces.apply.yaml b/.github/workflows/terraform.workspaces.apply.yaml new file mode 100644 index 0000000..6362c07 --- /dev/null +++ b/.github/workflows/terraform.workspaces.apply.yaml @@ -0,0 +1,285 @@ +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." + 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: + 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 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 != '' + 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 diff --git a/.github/workflows/terraform.workspaces.plan.yaml b/.github/workflows/terraform.workspaces.plan.yaml new file mode 100644 index 0000000..e54780b --- /dev/null +++ b/.github/workflows/terraform.workspaces.plan.yaml @@ -0,0 +1,245 @@ +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." + 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: + 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 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 != '' + 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 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 +}