From e274477f7ee08ba58cfa708759423a5939501690 Mon Sep 17 00:00:00 2001 From: Tony Le <27826928+Krustytoe@users.noreply.github.com> Date: Thu, 15 Oct 2026 09:29:40 -0500 Subject: [PATCH] feat!: apply the reviewed plan behind an environment gate - terraform-plan: -detailed-exitcode, has_changes output, plan in job summary, optional upload of the saved plan (upload_plan, 1-day retention) - terraform-apply: required `environment` input declared on the apply job (GitHub rejects environment: on a job that calls a reusable workflow); plan_artifact_name applies the exact saved plan; falls back to a fresh plan with a warning when omitted - fix: reference this repo's composite actions at the release tag instead of @main; release-please now bumps them via extra-files - release-please: bump-minor-pre-major so breaking changes stay in 0.x BREAKING CHANGE: terraform-apply requires an `environment` input. Move `environment:` from the calling job into `with:`. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/terraform-apply.yml | 41 +++++++++++++---- .github/workflows/terraform-plan.yml | 61 ++++++++++++++++++++++---- README.md | 63 ++++++++++++++++++++------- release-please-config.json | 9 +++- 4 files changed, 139 insertions(+), 35 deletions(-) diff --git a/.github/workflows/terraform-apply.yml b/.github/workflows/terraform-apply.yml index a38c32b..d1ddbaf 100644 --- a/.github/workflows/terraform-apply.yml +++ b/.github/workflows/terraform-apply.yml @@ -7,8 +7,16 @@ on: description: "Path to the Terraform configuration, relative to repo root." type: string required: true + environment: + description: "GitHub environment for this job. Its protection rules (required reviewers) are the approval gate before apply." + type: string + required: true + plan_artifact_name: + description: "Plan artifact uploaded by terraform-plan (upload_plan: true) earlier in this run. When set, applies exactly that reviewed plan." + type: string + default: "" terraform_version: - description: "Terraform version constraint, e.g. \"~1.10\"." + description: "Terraform version constraint, e.g. \"~1.10\". Must match the version that produced the plan." type: string default: "~1.10" cloud: @@ -29,8 +37,6 @@ on: AZURE_SUBSCRIPTION_ID: required: false -# Callers set environment: on the job that calls this workflow. -# GitHub environment protection rules provide the approval gate before apply runs. permissions: contents: read id-token: write @@ -38,26 +44,30 @@ permissions: jobs: apply: runs-on: ubuntu-latest + # Environments can't be set on a job that calls a reusable workflow, so the gate is declared here. + # This also makes the OIDC subject claim repo:/:environment:, which the + # github-oidc-role modules in terraform-aws/azure-platform-modules trust. + environment: ${{ inputs.environment }} defaults: run: working-directory: ${{ inputs.working_directory }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: Krustytoe/platform-workflows/actions/terraform-setup@main + - uses: Krustytoe/platform-workflows/actions/terraform-setup@v0.2.0 # x-release-please-version with: terraform_version: ${{ inputs.terraform_version }} - name: Authenticate to AWS if: inputs.cloud == 'aws' - uses: Krustytoe/platform-workflows/actions/aws-oidc-auth@main + uses: Krustytoe/platform-workflows/actions/aws-oidc-auth@v0.2.0 # x-release-please-version with: role_arn: ${{ secrets.AWS_ROLE_ARN }} region: ${{ inputs.aws_region }} - name: Authenticate to Azure if: inputs.cloud == 'azure' - uses: Krustytoe/platform-workflows/actions/azure-oidc-auth@main + uses: Krustytoe/platform-workflows/actions/azure-oidc-auth@v0.2.0 # x-release-please-version with: client_id: ${{ secrets.AZURE_CLIENT_ID }} tenant_id: ${{ secrets.AZURE_TENANT_ID }} @@ -66,5 +76,20 @@ jobs: - name: init run: terraform init -input=false - - name: apply - run: terraform apply -auto-approve -input=false + - name: Download reviewed plan + if: inputs.plan_artifact_name != '' + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + name: ${{ inputs.plan_artifact_name }} + path: ${{ inputs.working_directory }} + + # A saved plan applies without prompting, and Terraform refuses it if state changed since it was made. + - name: apply reviewed plan + if: inputs.plan_artifact_name != '' + run: terraform apply -input=false tfplan + + - name: apply (fresh plan, not reviewed) + if: inputs.plan_artifact_name == '' + run: | + echo "::warning::No plan_artifact_name given: applying a fresh plan nobody reviewed. Use terraform-plan with upload_plan: true." + terraform apply -auto-approve -input=false diff --git a/.github/workflows/terraform-plan.yml b/.github/workflows/terraform-plan.yml index ac951ca..a9febcb 100644 --- a/.github/workflows/terraform-plan.yml +++ b/.github/workflows/terraform-plan.yml @@ -19,6 +19,18 @@ on: description: "AWS region. Required when cloud = \"aws\"." type: string default: "" + upload_plan: + description: "Upload the saved plan as an artifact so terraform-apply applies exactly what was reviewed." + type: boolean + default: false + plan_artifact_name: + description: "Artifact name for the saved plan. Must be unique per working directory within a run." + type: string + default: "tfplan" + outputs: + has_changes: + description: "\"true\" when the plan contains changes (terraform plan -detailed-exitcode returned 2)." + value: ${{ jobs.plan.outputs.has_changes }} secrets: AWS_ROLE_ARN: description: "IAM role ARN to assume via OIDC. Required when cloud = \"aws\"." @@ -41,26 +53,28 @@ permissions: jobs: plan: runs-on: ubuntu-latest + outputs: + has_changes: ${{ steps.plan.outputs.has_changes }} defaults: run: working-directory: ${{ inputs.working_directory }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: Krustytoe/platform-workflows/actions/terraform-setup@main + - uses: Krustytoe/platform-workflows/actions/terraform-setup@v0.2.0 # x-release-please-version with: terraform_version: ${{ inputs.terraform_version }} - name: Authenticate to AWS if: inputs.cloud == 'aws' - uses: Krustytoe/platform-workflows/actions/aws-oidc-auth@main + uses: Krustytoe/platform-workflows/actions/aws-oidc-auth@v0.2.0 # x-release-please-version with: role_arn: ${{ secrets.AWS_ROLE_ARN }} region: ${{ inputs.aws_region }} - name: Authenticate to Azure if: inputs.cloud == 'azure' - uses: Krustytoe/platform-workflows/actions/azure-oidc-auth@main + uses: Krustytoe/platform-workflows/actions/azure-oidc-auth@v0.2.0 # x-release-please-version with: client_id: ${{ secrets.AZURE_CLIENT_ID }} tenant_id: ${{ secrets.AZURE_TENANT_ID }} @@ -78,12 +92,31 @@ jobs: - name: tflint run: tflint --recursive + # -detailed-exitcode: 0 = no changes, 1 = error, 2 = changes present. - name: plan id: plan run: | - terraform plan -no-color -input=false 2>&1 | tee /tmp/tfplan.txt - echo "exit_code=${PIPESTATUS[0]}" >> "$GITHUB_OUTPUT" - continue-on-error: true + set +e + terraform plan -no-color -input=false -detailed-exitcode -out=tfplan 2>&1 | tee /tmp/tfplan.txt + code=${PIPESTATUS[0]} + echo "exit_code=${code}" >> "$GITHUB_OUTPUT" + if [ "$code" = "2" ]; then has_changes=true; else has_changes=false; fi + echo "has_changes=${has_changes}" >> "$GITHUB_OUTPUT" + + # Job summary is what an environment approver sees on the run page before approving apply. + - name: Write plan to job summary + if: always() && steps.plan.outputs.exit_code != '' + env: + WORKING_DIR: ${{ inputs.working_directory }} + run: | + { + echo "## Terraform plan: \`${WORKING_DIR}\`" + echo + echo '```' + head -c 900000 /tmp/tfplan.txt + echo + echo '```' + } >> "$GITHUB_STEP_SUMMARY" - name: Post plan to PR if: github.event_name == 'pull_request' @@ -99,13 +132,13 @@ jobs: const body = plan.length > maxLen ? plan.slice(0, maxLen) + '\n\n_Output truncated — see workflow logs for the full plan._' : plan; - const icon = process.env.PLAN_EXIT_CODE === '0' ? '✅' : '❌'; + const icon = { '0': '✅ No changes', '2': '📝 Changes' }[process.env.PLAN_EXIT_CODE] ?? '❌ Failed'; await github.rest.issues.createComment({ issue_number: context.issue.number, owner: context.repo.owner, repo: context.repo.repo, body: [ - `## ${icon} Terraform Plan — \`${process.env.WORKING_DIR}\``, + `## ${icon}: Terraform Plan — \`${process.env.WORKING_DIR}\``, '', '
Show plan output', '', @@ -117,6 +150,16 @@ jobs: ].join('\n'), }); + # The plan file can contain sensitive values in plaintext: keep retention short. + - name: Upload plan + if: inputs.upload_plan && steps.plan.outputs.has_changes == 'true' + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: ${{ inputs.plan_artifact_name }} + path: ${{ inputs.working_directory }}/tfplan + retention-days: 1 + if-no-files-found: error + - name: Fail on plan error - if: steps.plan.outputs.exit_code != '0' + if: steps.plan.outputs.exit_code != '0' && steps.plan.outputs.exit_code != '2' run: exit 1 diff --git a/README.md b/README.md index 882c565..ad7b7d5 100644 --- a/README.md +++ b/README.md @@ -16,11 +16,13 @@ flowchart LR p5["Plan posted as PR comment"] end + replan["Post-merge plan
saved as artifact + job summary"] + scan["trivy-scan.yml
fs / config / image"] sarif["SARIF in GitHub Security tab"] review{"Human review + merge"} gate{"GitHub environment
approval gate"} - apply["terraform-apply.yml
init + apply"] + apply["terraform-apply.yml
applies the exact saved plan"] auth["aws-oidc-auth / azure-oidc-auth
short-lived OIDC credentials"] pr --> p1 @@ -31,7 +33,8 @@ flowchart LR pr --> scan scan --> sarif p5 --> review - review -- "push to main" --> gate + review -- "push to main" --> replan + replan --> gate gate --> apply auth -.-> p4 auth -.-> apply @@ -40,10 +43,11 @@ flowchart LR ## Design decisions - **No long-lived cloud credentials anywhere.** Both clouds authenticate through OIDC federation; there are no access keys or client secrets to store, rotate, or leak. -- **The approval gate lives in GitHub environments, not in workflow logic.** Required reviewers are configured per environment in repo settings, so approvals have an audit trail and can't be bypassed by editing YAML in a PR. +- **The approval gate lives in GitHub environments, not in workflow logic.** Required reviewers are configured per environment in repo settings, so approvals have an audit trail and can't be bypassed by editing YAML in a PR. The apply job declares the environment itself, which also scopes the OIDC subject claim to `environment:`. +- **What gets approved is what gets applied.** The post-merge plan is saved as a short-lived artifact and shown in the run's job summary; the approver reviews it at the environment gate, and apply runs that exact plan file. If state drifted in between, Terraform rejects the stale plan instead of applying something nobody saw. - **Plans are reviewable where the change is reviewed.** The plan lands as a collapsible PR comment, so approvers see infrastructure impact next to the code diff. - **Least-privilege tokens.** Each workflow declares only the `permissions` it needs; the scan job gets only `contents: read` and `security-events: write`. -- **Supply-chain pinning.** Third-party actions are pinned to full commit SHAs with the version as a comment, so Dependabot can propose bumps as reviewable PRs. Callers pin this repo the same way. +- **Supply-chain pinning.** Third-party actions are pinned to full commit SHAs with the version as a comment, so Dependabot can propose bumps as reviewable PRs. This repo's own composite actions are referenced by release tag, kept in step by release-please, so a merge to `main` never changes a released workflow. Callers pin this repo by SHA. - **Scan findings are visible, not build-breaking, by default.** Trivy results go to the Security tab as SARIF, so teams can triage against a remediation SLA instead of having pipelines blocked by a newly published CVE. ## Versioning @@ -54,14 +58,14 @@ Releases are cut by [release-please](https://github.com/googleapis/release-pleas uses: Krustytoe/platform-workflows/.github/workflows/trivy-scan.yml@ # v0.1.0 ``` -Avoid `@main`: any merge here would immediately change every caller's pipeline. +Avoid `@main`: any merge here would immediately change every caller's pipeline. The reusable workflows follow the same rule internally: they call this repo's composite actions at the matching release tag, which release-please bumps in each release PR. ## Contents | Type | Name | What it does | |---|---|---| -| Reusable workflow | [`terraform-plan`](#terraform-plan) | fmt → init → validate → tflint → plan; posts plan output as a PR comment | -| Reusable workflow | [`terraform-apply`](#terraform-apply) | init → apply; pair with a GitHub environment for an approval gate | +| Reusable workflow | [`terraform-plan`](#terraform-plan) | fmt → init → validate → tflint → plan; posts plan to the PR and job summary, optionally saves it for apply | +| Reusable workflow | [`terraform-apply`](#terraform-apply) | Applies the exact reviewed plan, behind a GitHub environment approval gate | | Reusable workflow | [`trivy-scan`](#trivy-scan) | Trivy scan on filesystem/IaC or container image; uploads SARIF to GitHub Security | | Composite action | [`aws-oidc-auth`](#aws-oidc-auth) | OIDC auth to AWS — no long-lived keys | | Composite action | [`azure-oidc-auth`](#azure-oidc-auth) | OIDC auth to Azure — no client secrets | @@ -71,7 +75,7 @@ Avoid `@main`: any merge here would immediately change every caller's pipeline. ## terraform-plan -Runs `fmt -check → init → validate → tflint → plan` and posts the plan output as a collapsible PR comment. Supports AWS and Azure; leave `cloud` empty for local/offline validation. +Runs `fmt -check → init → validate → tflint → plan` and posts the plan output as a collapsible PR comment and to the run's job summary. With `upload_plan: true` it saves the plan file as an artifact (1-day retention) for `terraform-apply`. Supports AWS and Azure; leave `cloud` empty for local/offline validation. ```yaml jobs: @@ -95,6 +99,12 @@ jobs: | `terraform_version` | string | `~1.10` | Version constraint | | `cloud` | string | `""` | `aws`, `azure`, or empty | | `aws_region` | string | `""` | Required when `cloud = aws` | +| `upload_plan` | boolean | `false` | Save the plan as an artifact (only when it has changes) | +| `plan_artifact_name` | string | `tfplan` | Artifact name; make it unique per working directory in a run | + +| Output | Description | +|---|---| +| `has_changes` | `"true"` when the plan has changes; use it to skip apply on no-op plans | | Secret | Required | Description | |---|---|---| @@ -107,25 +117,36 @@ jobs: ## terraform-apply -Runs `init → apply`. Call this after plan is reviewed. Pair with a [GitHub environment](https://docs.github.com/en/actions/deployment/targeting-different-environments) to require explicit approval before apply runs. +Runs `init → apply ` inside a [GitHub environment](https://docs.github.com/en/actions/deployment/targeting-different-environments), so the environment's required reviewers approve before anything changes. GitHub doesn't allow `environment:` on a job that *calls* a reusable workflow, so pass it as an input. ```yaml jobs: apply: + needs: plan + if: needs.plan.outputs.has_changes == 'true' uses: Krustytoe/platform-workflows/.github/workflows/terraform-apply.yml@v0.2.0 # x-release-please-version - environment: prod # approval gate lives here permissions: contents: read id-token: write with: working_directory: deploy/terraform + environment: prod # approval gate + OIDC sub claim environment:prod + plan_artifact_name: tfplan # from terraform-plan with upload_plan: true cloud: aws aws_region: us-gov-west-1 secrets: AWS_ROLE_ARN: ${{ secrets.AWS_ROLE_ARN }} ``` -Inputs and secrets are identical to `terraform-plan`. +| Input | Type | Default | Description | +|---|---|---|---| +| `working_directory` | string | **required** | Same directory the plan ran in | +| `environment` | string | **required** | GitHub environment whose protection rules gate the apply | +| `plan_artifact_name` | string | `""` | Plan artifact from this run. If empty, applies a fresh unreviewed plan and logs a warning | +| `terraform_version` | string | `~1.10` | Must match the version that produced the plan | +| `cloud` / `aws_region` | string | `""` | Same as `terraform-plan` | + +Secrets are identical to `terraform-plan`. --- @@ -196,7 +217,7 @@ Installs Terraform and tflint, then runs `tflint --init` to download configured ## Tying it together -A full plan-then-apply flow for a Terraform repo: +A full plan-then-apply flow for a Terraform repo. PRs get a plan comment; after merge, the plan is re-run against `main`, saved, and applied only after an environment reviewer approves it. ```yaml on: @@ -215,25 +236,35 @@ jobs: working_directory: deploy/terraform cloud: aws aws_region: us-gov-west-1 + upload_plan: ${{ github.event_name == 'push' }} secrets: - AWS_ROLE_ARN: ${{ secrets.AWS_ROLE_ARN }} + AWS_ROLE_ARN: ${{ secrets.AWS_PLAN_ROLE_ARN }} # read-only role apply: needs: plan - if: github.ref == 'refs/heads/main' + if: github.event_name == 'push' && needs.plan.outputs.has_changes == 'true' uses: Krustytoe/platform-workflows/.github/workflows/terraform-apply.yml@v0.2.0 # x-release-please-version - environment: prod permissions: contents: read id-token: write with: working_directory: deploy/terraform + environment: prod + plan_artifact_name: tfplan cloud: aws aws_region: us-gov-west-1 secrets: - AWS_ROLE_ARN: ${{ secrets.AWS_ROLE_ARN }} + AWS_ROLE_ARN: ${{ secrets.AWS_APPLY_ROLE_ARN }} # write role ``` +**Use separate roles for plan and apply.** The two jobs present different OIDC subject claims, so each role's trust policy can be scoped tightly: + +| Job | OIDC `sub` claim | Role | +|---|---|---| +| plan (PR) | `repo:/:pull_request` | Read-only | +| plan (after merge) | `repo:/:ref:refs/heads/main` | Read-only | +| apply | `repo:/:environment:prod` | Write; only reachable through the approval gate | + ## License MIT diff --git a/release-please-config.json b/release-please-config.json index 18f11d3..f4b6329 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -5,7 +5,12 @@ "release-type": "simple", "include-component-in-tag": false, "changelog-path": "CHANGELOG.md", - "extra-files": ["README.md"] + "extra-files": [ + "README.md", + ".github/workflows/terraform-plan.yml", + ".github/workflows/terraform-apply.yml" + ] } - } + }, + "bump-minor-pre-major": true }