diff --git a/.github/workflows/tf-plan.yaml b/.github/workflows/tf-plan.yaml index a5fc260..6a24ec2 100644 --- a/.github/workflows/tf-plan.yaml +++ b/.github/workflows/tf-plan.yaml @@ -30,46 +30,11 @@ on: required: true jobs: - validate: + validate-and-plan: runs-on: ubuntu-latest environment: plan permissions: contents: read - steps: - - name: Checkout GCSS - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 - with: - repository: G-Research/github-terraformer - ref: ${{ inputs.gcss_ref }} - persist-credentials: false - - - name: Checkout config repo - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 - with: - ref: ${{ inputs.commit_sha }} - token: ${{ secrets.gh_token }} - path: gcss_config - persist-credentials: false - - - name: Validate repos/*.yaml - uses: ./.github/actions/validate-repo-configs - with: - config-path: gcss_config - fallback-schema-path: ${{ inputs.fallback_schema_path }} - - - name: Validate organisation/*.yaml - uses: ./.github/actions/validate-org-configs - with: - config-path: gcss_config - protected-owners: ${{ vars.PROTECTED_OWNERS }} - - terraform-plan: - needs: validate - runs-on: ubuntu-latest - environment: plan - permissions: - pull-requests: write - contents: read steps: - name: Generate a token uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 @@ -79,18 +44,20 @@ jobs: private-key: ${{ secrets.app_private_key }} owner: ${{ github.repository_owner }} - - name: Create in-progress check-run + - name: Open check run uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 + id: open-check + continue-on-error: true env: COMMIT_SHA: ${{ inputs.commit_sha }} with: github-token: ${{ steps.generate-token.outputs.token }} script: | - const detailsUrl = `${context.serverUrl}/${context.payload.repository.full_name}/actions/runs/${context.runId}`; + const detailsUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`; - await github.rest.checks.create({ - owner: context.payload.repository.owner.login, - repo: context.payload.repository.name, + const checkRun = await github.rest.checks.create({ + owner: context.repo.owner, + repo: context.repo.repo, name: "Terraform plan", head_sha: process.env.COMMIT_SHA, status: "in_progress", @@ -101,6 +68,8 @@ jobs: } }); + core.setOutput('check-run-id', checkRun.data.id); + - name: Checkout GCSS uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 with: @@ -108,6 +77,28 @@ jobs: ref: ${{ inputs.gcss_ref }} persist-credentials: false + - name: Checkout config repo + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + with: + ref: ${{ inputs.commit_sha }} + token: ${{ secrets.gh_token }} + path: gcss_config + persist-credentials: false + + - name: Validate repos/*.yaml + uses: ./.github/actions/validate-repo-configs + id: validate-repos + with: + config-path: gcss_config + fallback-schema-path: ${{ inputs.fallback_schema_path }} + + - name: Validate organisation/*.yaml + uses: ./.github/actions/validate-org-configs + id: validate-org + with: + config-path: gcss_config + protected-owners: ${{ vars.PROTECTED_OWNERS }} + - name: GCSS config setup uses: ./.github/actions/gcss-config-setup with: @@ -123,50 +114,73 @@ jobs: tfc-workspace: ${{ vars.WORKSPACE }} refresh: 'false' - - name: Post plan summary and handle check-run + - name: Report result if: always() uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 env: + COMMIT_SHA: ${{ inputs.commit_sha }} + CHECK_RUN_ID: ${{ steps.open-check.outputs.check-run-id }} + JOB_STATUS: ${{ job.status }} + VALIDATE_REPOS_OUTCOME: ${{ steps.validate-repos.outcome }} + VALIDATE_ORG_OUTCOME: ${{ steps.validate-org.outcome }} PLAN_SUMMARY: ${{ steps.graformer.outputs.plan-summary }} - PR_NUMBER: ${{ github.event.workflow_run.pull_requests[0].number }} PLAN_EXITCODE: ${{ steps.graformer.outputs.plan-exitcode }} - COMMIT_SHA: ${{ inputs.commit_sha }} + PR_NUMBER: ${{ github.event.workflow_run.pull_requests[0].number }} with: github-token: ${{ steps.generate-token.outputs.token }} script: | - function isPlanSuccessful() { - let error = false; - let planSummary = null; - - if(process.env.PLAN_EXITCODE === "1"){ - error = true; - } else { - try { - planSummary = JSON.parse(process.env.PLAN_SUMMARY); - } catch(e){ - error = true; - console.log("Error parsing plan summary:", e); - } + const detailsUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`; + + function checkRunOutcome() { + if (process.env.JOB_STATUS === "cancelled") { + return { + conclusion: "cancelled", + title: "Terraform Plan Cancelled", + summary: `The run was cancelled before the Terraform plan completed. Please check workflow logs for details: ${detailsUrl}`, + text: "Run cancelled" + }; + } + + if (process.env.VALIDATE_REPOS_OUTCOME === "failure" || process.env.VALIDATE_ORG_OUTCOME === "failure") { + return { + conclusion: "failure", + title: "Configuration Validation Failed", + summary: `The configuration did not pass validation, so no Terraform plan was run. Please check workflow logs for details: ${detailsUrl}`, + text: "Error validating configuration files" + }; + } + + const planFailed = { + conclusion: "failure", + title: "Terraform Plan Failed", + summary: `The Terraform plan did not complete successfully. Please check workflow logs for details: ${detailsUrl}`, + text: "Error running terraform plan", + body: `Error running terraform plan. Please check workflow logs for details: ${detailsUrl}` + }; + + if (process.env.JOB_STATUS !== "success" || process.env.PLAN_EXITCODE === "1") { + return planFailed; + } + + try { + return { planSummary: JSON.parse(process.env.PLAN_SUMMARY) }; + } catch(e) { + console.log("Error parsing plan summary:", e); + return planFailed; } - - return { error, planSummary }; } - + function formatResources(arr) { return arr?.length ? arr.join("\n") : ""; } - - const detailsUrl = `${context.serverUrl}/${context.payload.repository.full_name}/actions/runs/${context.runId}`; + + const outcome = checkRunOutcome(); let conclusion, title, summary, text, body; - - const { error, planSummary } = isPlanSuccessful(); - if (error) { - conclusion = "failure"; - title = "Terraform Plan Failed"; - summary = `The Terraform plan did not complete successfully. Please check workflow logs for details: ${detailsUrl}`; - text = "Error running terraform plan"; - body = `Error running terraform plan. Please check workflow logs for details: ${detailsUrl}`; + + if (!outcome.planSummary) { + ({ conclusion, title, summary, text, body } = outcome); } else { + const planSummary = outcome.planSummary; const recreateCount = planSummary.recreate?.length || 0; const updateCount = planSummary.update?.length || 0; const importCount = planSummary.import?.length || 0; @@ -234,14 +248,14 @@ jobs: body = body.trim(); conclusion = "success"; - + switch (process.env.PLAN_EXITCODE) { - case "0": + case "0": title = "Terraform plan: no changes"; summary = `Terraform executed successfully with **no changes** required. Please check workflow logs for details: ${detailsUrl}`; text = "No infrastructure changes detected"; break; - case "2": + default: title = `Terraform plan: ${planResultSummary}`; summary = `Terraform executed successfully and **changes are required**. Please check workflow logs for details: ${detailsUrl}`; text = body; @@ -251,28 +265,47 @@ jobs: break; } } - - const prNumber = process.env.PR_NUMBER; - if (prNumber) { - await github.rest.issues.createComment({ - owner: context.payload.repository.owner.login, - repo: context.payload.repository.name, - issue_number: prNumber, - body, + + const output = { title, summary, text }; + const checkRunId = process.env.CHECK_RUN_ID; + + if (checkRunId) { + await github.rest.checks.update({ + owner: context.repo.owner, + repo: context.repo.repo, + check_run_id: Number(checkRunId), + status: "completed", + conclusion: conclusion, + details_url: detailsUrl, + output: output + }); + } else { + await github.rest.checks.create({ + owner: context.repo.owner, + repo: context.repo.repo, + name: "Terraform plan", + head_sha: process.env.COMMIT_SHA, + status: "completed", + conclusion: conclusion, + details_url: detailsUrl, + output: output }); } - - await github.rest.checks.create({ - owner: context.payload.repository.owner.login, - repo: context.payload.repository.name, - name: "Terraform plan", - head_sha: process.env.COMMIT_SHA, - status: "completed", - conclusion: conclusion, - details_url: detailsUrl, - output: { - title: title, - summary: summary, - text: text + + const prNumber = process.env.PR_NUMBER; + if (body && prNumber) { + if (body.length > 65000) { + body = `Plan output is too big to post as a comment. Please check the workflow logs for details: ${detailsUrl}`; } - }); + + try { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber, + body, + }); + } catch(e) { + core.warning(`Failed to post plan summary comment: ${e.message}`); + } + } diff --git a/docs/workflows.md b/docs/workflows.md index 07beb88..e34b2fd 100644 --- a/docs/workflows.md +++ b/docs/workflows.md @@ -29,21 +29,25 @@ ### ✅ `Validate and Plan` Workflow - **Trigger**: `workflow_run` from the config repo, on every pull request. -- **Behavior**: - 1. `validate` checks `repos/*.yaml` against the repository schema and `organisation/*.yaml` against the teams/members schemas and cross-file rules — including the protected-owner rule, which fails the PR if a protected identity is removed from or demoted in `members.yaml`. - 2. `terraform-plan` (`needs: validate`, so it is skipped when validation fails) runs `terraform plan` on Terraform Cloud and posts the result as a PR comment and a check-run. +- **Behavior**: a single `validate-and-plan` job, top to bottom: + 1. Opens a check run named **`Terraform plan`** on the head commit, `in_progress`, before any work happens. + 2. Validates `repos/*.yaml` against the repository schema and `organisation/*.yaml` against the teams/members schemas and cross-file rules — including the protected-owner rule, which fails the PR if a protected identity is removed from or demoted in `members.yaml`. + 3. Runs `terraform plan` on Terraform Cloud. Like every later step it is skipped once validation has failed, so an invalid config never reaches Terraform. + 4. `Report result` runs on every path and concludes that same check run exactly once: `success` with the plan summary, `failure` for a validation or plan failure, `cancelled` for a cancelled run. It posts the plan summary as a PR comment whenever the plan actually ran. + +`Terraform plan` is the check to list as a required status check. It is opened up front and always concluded, so a failed validation now reports a failed check instead of leaving the pull request waiting on a check that never arrives. #### Consumer setup requirements -Both jobs run in the **`plan`** environment. Variables are read from that environment by the workflow itself: +The job runs in the **`plan`** environment. Variables are read from that environment by the workflow itself: -| Name | Read by | Purpose | -|---|---|---| -| `PROTECTED_OWNERS` | `validate` | Comma-separated org logins that must stay owners in `organisation/members.yaml` | -| `APP_ID` | `terraform-plan` | GitHub App used to post the plan comment and check-run | -| `WORKSPACE` | `terraform-plan` | Terraform Cloud workspace | +| Name | Purpose | +|---|---| +| `PROTECTED_OWNERS` | Comma-separated org logins that must stay owners in `organisation/members.yaml` | +| `APP_ID` | GitHub App that posts the check run and the plan comment (needs **Checks: write**) | +| `WORKSPACE` | Terraform Cloud workspace | -The caller must also pass all three `workflow_call` secrets — these are declared on the reusable workflow, not looked up by the jobs: +The caller must also pass all three `workflow_call` secrets — these are declared on the reusable workflow, not looked up by the job: | Name | Purpose | |---|---| @@ -55,7 +59,7 @@ The caller must also pass all three `workflow_call` secrets — these are declar > `PROTECTED_OWNERS` is deployment config and is deliberately read from the environment rather than passed in by the caller, so that a pull request cannot weaken the rule it is validated against. Leaving it unset is not a way to opt out: whenever any `organisation/` config is present, `validate-org` fails. A config repo with no organisation config at all needs no list and passes. > [!WARNING] -> The `plan` environment **must not** have required-reviewer or wait-timer protection rules. Protection rules apply per job, and `validate` now runs in this environment too — so an approval gate costs **two** approvals per pull request, and the author sees no validation feedback until the first one lands. Consumers that had reviewers on `plan` for the `terraform-plan` job need to remove them, or accept that cost. +> The `plan` environment **must not** have required-reviewer or wait-timer protection rules. Everything, validation included, runs in this environment — so an approval gate holds up the whole pull request, and the check run does not even reach `in_progress` until someone approves. ### 🔍 `Drift Check` Workflow