Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
223 changes: 128 additions & 95 deletions .github/workflows/tf-plan.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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",
Expand All @@ -101,13 +68,37 @@ jobs:
}
});

core.setOutput('check-run-id', checkRun.data.id);

- 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
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:
Expand All @@ -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;
Expand Down Expand Up @@ -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;
Expand All @@ -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}`);
}
}
26 changes: 15 additions & 11 deletions docs/workflows.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
|---|---|
Expand All @@ -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

Expand Down
Loading