Skip to content

Worked review 3: a CI pipeline and policy checks

The prompt was: “Write a GitHub Actions pipeline that runs Terraform plan on pull requests and apply on merge to main, using Workload Identity Federation instead of a stored GCP key, with policy checks.” What follows is the kind of first draft an AI assistant hands back for exactly that prompt — plausible YAML, a real google-github-actions/auth action, a Rego policy that reads like every OPA tutorial. It has 10 real bugs; check the AI bug catalog for the general pattern behind several of them (entries 4, 18, 19, 20, 22). Nine of these ten are identical, provider-agnostic CI/policy mechanics to both sibling reviews’ worked review 3 — proven fresh here, in this repo — plus one genuinely GCP-specific Workload Identity Federation bug. This review complements — and links to — CI/CD for Infrastructure, which shows the corrected pipeline shape in full. Every claim below was proven with real actionlint, tflint, checkov, and conftest (via docker run --rm against the pinned rhysd/actionlint, ghcr.io/terraform-linters/tflint, bridgecrew/checkov, and openpolicyagent/conftest images), plus real Terraform exit codes and a real terragrunt run --all plan --out-dir.

# .github/workflows/terraform.yml, as generated -- fragment, not a collected fence
name: terraform
on:
pull_request:
push:
branches: [main]
jobs:
plan:
runs-on: ubuntu-latest
permissions:
id-token: write
contents: read
steps:
- uses: actions/checkout@master
- uses: hashicorp/setup-terraform@v3
- name: Authenticate to GCP via Workload Identity Federation
uses: google-github-actions/auth@v3
with:
workload_identity_provider: projects/111122223333/locations/global/workloadIdentityPools/github-pool/providers/github-provider
service_account: github-actions-terraform@acme-prod-123456.iam.gserviceaccount.com
- run: terraform init
- name: plan
run: |
echo "TF_VAR_db_password=$TF_VAR_db_password"
terraform plan -detailed-exitcode -out=$PLAN_FILE
env:
PLAN_FILE: tfplan
TF_VAR_db_password: ${{ secrets.DB_PASSWORD }}
apply:
needs: plan
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@master
- uses: hashicorp/setup-terraform@v3
- run: terraform init
- run: terraform apply -auto-approve
# ci/gitignore, as generated -- fragment, not a collected fence
.terraform/
.terraform.lock.hcl
*.tfstate
*.tfstate.backup
# terraform.tfvars, as generated -- fragment, committed alongside the workflow above
db_password = "Sup3rSecret!2024"
api_token = "ghp_1234567890abcdefghijklmnopqrstuvwxyz"
# policy/deny_no_tags.rego, as generated -- fragment, not a collected fence
package main
deny[msg] {
resource := input.resource_changes[_]
resource.type == "google_compute_instance"
msg := sprintf("%s must set labels", [resource.address])
}
# .tflint.hcl, as generated -- fragment, not a collected fence
plugin "google" {
enabled = true
version = "0.30.0"
source = "github.com/terraform-linters/tflint-ruleset-google"
}

Workflow: actions/checkout@master — a mutable branch treated as pinned

Section titled “Workflow: actions/checkout@master — a mutable branch treated as pinned”

Symptom — real actionlint, rhysd/actionlint, one-shot container:

$ docker run --rm -v "$PWD:/repo" -w /repo rhysd/actionlint:latest -color
(no output -- 0 exit code)

🐛 Spot the bug

- uses: actions/checkout@master
Show the answer

Same bug as AI bug catalog entry 19, and identical mechanics to both sibling reviews’ entry 1 — proven fresh here. actionlint only requires a @ref token to exist syntactically; removing it entirely (uses: actions/checkout with no @ at all) is a real, caught error — confirmed separately: specifying action "actions/checkout" in invalid format because ref is missing. But @master supplies a syntactically valid ref, so actionlint accepts it identically to a pinned commit SHA. Fix: uses: actions/checkout@<40-char-sha> # v5.0.0.

Workflow: a shellcheck issue actionlint genuinely does catch

Section titled “Workflow: a shellcheck issue actionlint genuinely does catch”

Symptom — real actionlint against run: terraform plan -detailed-exitcode -out=$PLAN_FILE:

$ docker run --rm -v "$PWD:/repo" -w /repo rhysd/actionlint:latest -color
.github/workflows/terraform.yml:16:9: shellcheck reported issue in this script: SC2086:info:1:40: Double quote to prevent globbing and word splitting [shellcheck]
|
16 | run: |
| ^~~~

🐛 Spot the bug

run: |
terraform plan -detailed-exitcode -out=$PLAN_FILE
Show the answer

actionlint embeds shellcheck against every multi-line run: script. $PLAN_FILE unquoted is subject to word-splitting and glob expansion by the shell. Fix: -out="$PLAN_FILE".

Workflow: env: TF_VAR_db_password echoed in a debug line

Section titled “Workflow: env: TF_VAR_db_password echoed in a debug line”

Symptom — real, env reproducing exactly what a CI runner’s environment looks like once env: sets a secret-backed variable:

$ export TF_VAR_db_password="SuperSecret123"
$ env | grep TF_VAR_db_password
TF_VAR_db_password=SuperSecret123

🐛 Spot the bug

- name: plan
run: |
echo "TF_VAR_db_password=$TF_VAR_db_password"
terraform plan -detailed-exitcode -out=$PLAN_FILE
env:
TF_VAR_db_password: ${{ secrets.DB_PASSWORD }}
Show the answer

GitHub Actions masks a recognized secret value in raw log output, but proven above, the plain shell fact underneath is that TF_VAR_db_password is a completely ordinary environment variable the moment env: sets it. Fix: never echo a secret-backed variable, even for debugging.

apply: no saved plan — terraform apply -auto-approve re-plans from scratch

Section titled “apply: no saved plan — terraform apply -auto-approve re-plans from scratch”

Symptom — real, side by side. First, apply with no saved plan file:

$ terraform apply -auto-approve
terraform_data.x: Refreshing state...
Plan: 1 to add, 0 to change, 0 to destroy.
Apply complete! Resources: 1 added, 0 changed, 0 destroyed.

Then, a real saved plan applied by its file path instead:

$ terraform plan -out=tfplan
$ terraform apply tfplan
Apply complete! Resources: 1 added, 0 changed, 0 destroyed.

🐛 Spot the bug

- run: terraform init
- run: terraform apply -auto-approve
Show the answer

The apply job never downloads the plan the plan job computed. Proven above: apply -auto-approve with no plan file prints its own fresh Refreshing state... / Plan: lines — it re-derives a plan from scratch immediately before applying it. Applying a real saved plan file instead prints neither line. Fix: terraform plan -out=tfplan as a build artifact in the plan job, downloaded and applied verbatim (terraform apply tfplan) in the apply job.

plan: -detailed-exitcode computed, its three-way exit code never actually read

Section titled “plan: -detailed-exitcode computed, its three-way exit code never actually read”

Symptom — real, the same command’s real exit code in two different states:

$ terraform plan -detailed-exitcode # a resource actually needs to change
$ echo $?
2
$ terraform plan -detailed-exitcode # re-run immediately after, nothing left to change
$ echo $?
0

🐛 Spot the bug

terraform plan -detailed-exitcode -out=$PLAN_FILE
# (workflow never checks $? here at all)
Show the answer

-detailed-exitcode exists specifically so a script can tell “real changes are ready to review” (2) apart from “a real error occurred” (1) apart from “nothing to do” (0) — proven above. Fix: capture $? explicitly and branch on it, the way this course’s CI/CD for Infrastructure lesson’s pipeline does.

.gitignore: .terraform.lock.hcl gitignored alongside .terraform/

Section titled “.gitignore: .terraform.lock.hcl gitignored alongside .terraform/”
# .gitignore, as generated
.terraform/
.terraform.lock.hcl
*.tfstate
*.tfstate.backup

Same bug as AI bug catalog entry 4. Fix:

# .gitignore, fixed
.terraform/
*.tfstate
*.tfstate.backup

terraform.tfvars: a real secret, committed alongside the workflow

Section titled “terraform.tfvars: a real secret, committed alongside the workflow”
# terraform.tfvars, as generated
db_password = "Sup3rSecret!2024"
api_token = "ghp_1234567890abcdefghijklmnopqrstuvwxyz"

Same bug as AI bug catalog entry 22 — worse in a CI context specifically, because a committed .tfvars ships to every checkout, every fork’s pull request, and every CI log that happens to cat a file for debugging. Fix: delete the file from the working tree and from git history; source db_password/api_token exclusively from TF_VAR_* environment variables backed by CI secrets.

policy/deny_no_tags.rego: pre-v1 Rego syntax fails to load

Section titled “policy/deny_no_tags.rego: pre-v1 Rego syntax fails to load”
# policy/deny_no_tags.rego, as generated -- @expect-error rego_parse_error: `if` keyword is required before rule body
package main
deny[msg] {
resource := input.resource_changes[_]
resource.type == "google_compute_instance"
msg := sprintf("%s must set labels", [resource.address])
}

Symptom — real conftest test, current openpolicyagent/conftest, against a real terraform show -json plan:

$ docker run --rm -v "$(pwd)":/project openpolicyagent/conftest test --policy policy plan.json
Error: running test: load: loading policies: load: 2 errors occurred during loading:
policy/deny_no_tags.rego:3: rego_parse_error: `if` keyword is required before rule body
policy/deny_no_tags.rego:3: rego_parse_error: `contains` keyword is required for partial set rules

🐛 Spot the bug

deny[msg] {
resource := input.resource_changes[_]
resource.type == "google_compute_instance"
msg := sprintf("%s must set labels", [resource.address])
}
Show the answer

Same bug as AI bug catalog entry 20. Fix:

# policy/deny_no_tags.rego, fixed
package main
import rego.v1
deny contains msg if {
resource := input.resource_changes[_]
resource.type == "google_compute_instance"
msg := sprintf("%s must set labels", [resource.address])
}

Real, against the identical plan:

$ docker run --rm -v "$(pwd)":/project openpolicyagent/conftest test --policy policy plan.json
FAIL - plan.json - main - google_compute_instance.app must set labels
1 test, 0 passed, 0 warnings, 1 failure, 0 exceptions

.tflint.hcl/checkov: real, working, never wired into CI at all

Section titled “.tflint.hcl/checkov: real, working, never wired into CI at all”

Symptom — real tflint and checkov, one-shot containers, against the pipeline’s own main.tf (a google_compute_instance with a default network, no Shielded VM, no project-wide-SSH-key block, no CSEK encryption):

$ docker run --rm -v "$(pwd)":/data -w /data ghcr.io/terraform-linters/tflint
1 issue(s) found:
Warning: terraform "required_version" attribute is required (terraform_required_version)
on main.tf line 1:
1: terraform {
$ docker run --rm -v "$(pwd)":/tf bridgecrew/checkov -d /tf --compact --quiet
Passed checks: 5, Failed checks: 3, Skipped checks: 0
Check: CKV_GCP_38: "Ensure VM disks for critical VMs are encrypted with Customer Supplied Encryption Keys (CSEK)"
FAILED for resource: google_compute_instance.app
Check: CKV_GCP_32: "Ensure 'Block Project-wide SSH keys' is enabled for VM instances"
FAILED for resource: google_compute_instance.app
Check: CKV_GCP_39: "Ensure Compute instances are launched with Shielded VM enabled"
FAILED for resource: google_compute_instance.app

run --all plan with no --out-dir at the Terragrunt layer

Section titled “run --all plan with no --out-dir at the Terragrunt layer”

Symptom — real terragrunt run --all plan --out-dir, a real per-unit plan file for every unit in the tree, and the exact next command printed:

$ terragrunt run --all --non-interactive --no-color --out-dir /tmp/tg-plans -- plan
[live/prod/app] terraform apply "/tmp/tg-plans/live/prod/app/tfplan.tfplan"
❯❯ Run Summary 4 units
Succeeded 4
$ find /tmp/tg-plans -maxdepth 3
/tmp/tg-plans/live/dev/network
/tmp/tg-plans/live/dev/app
/tmp/tg-plans/live/prod/network
/tmp/tg-plans/live/prod/app

🐛 Spot the bug

terragrunt run --all -- plan
# ... later, in a separate job/step ...
terragrunt run --all -- apply -auto-approve
Show the answer

Exactly the same bug as the plain-Terraform “no -out” entry above, one layer up. Fix: run --all plan --out-dir <dir> in the plan job, run --all apply --out-dir <dir> (the same directory) in the apply job.

Workload Identity Federation’s attribute condition scoped too broadly

Section titled “Workload Identity Federation’s attribute condition scoped too broadly”

The generated workflow’s google-github-actions/auth step with workload_identity_provider/service_account is real and correctly-shaped, but nothing in this lesson’s generated code shows the provider’s own attribute condition — the part that actually decides which GitHub identities may impersonate that service account. A Workload Identity Pool provider with no attribute condition, or one that only checks the repository without the branch/ref, lets any branch — including a pull request from a fork — impersonate the service account through the pool. Fix: an attribute condition scoping assertion.ref to the exact branch that runs apply, and a separate, more narrowly-scoped service account (or none at all) for the plan job that runs on pull requests from untrusted forks.

#BugFix
1actions/checkout@master — mutable ref, actionlint accepts it identically to a SHAPin to a full commit SHA
2-out=$PLAN_FILE unquoted in a run: script-out="$PLAN_FILE" — real actionlint/shellcheck SC2086 finding
3Secret-backed TF_VAR_db_password echoed in a debug lineNever echo it — real env output proved it is a plain, readable variable once set
4apply -auto-approve with no saved plan fileplan -out=tfplan as an artifact, apply tfplan in the apply job
5-detailed-exitcode computed, its 3-way exit code never branched onCapture $?, branch on 0/1/2 explicitly
6.terraform.lock.hcl gitignored alongside .terraform/Only .terraform/ ignored
7terraform.tfvars with a real secret, committedDeleted; secrets sourced from TF_VAR_* CI secrets only
8Pre-v1 Rego (deny[msg] { ... })import rego.v1 + deny contains msg if { ... }
9.tflint.hcl/checkov config present, no CI step ever runs eitherReal tflint/checkov steps added — both tools proven to find real issues against this exact main.tf
10No --out-dir on terragrunt run --all plan--out-dir <dir> in plan, same dir in apply
—Workload Identity Pool provider’s attribute condition missing or too broadScope assertion.ref to the exact branch — docs-sourced, no GCP project in this course

Nine of these ten bugs are identical, provider-agnostic CI/policy mechanics to both sibling reviews — the same actionlint behaviors, the same -out/-detailed-exitcode gaps, the same disconnected linter configs, the same terragrunt --out-dir fix. The one genuinely GCP-specific bug is the trust boundary: AWS scopes an IAM role’s trust policy sub condition, azurerm scopes an App Registration’s federated credential subject, GCP scopes a Workload Identity Pool provider’s attribute condition on assertion.ref — three different mechanisms, identical failure mode when any of them is too broad.

  • “If a .tflint.hcl/checkov config file exists in the repo, it’s being enforced.” Proven above: both tools, run directly against the pipeline’s own main.tf, found real issues that a CI run relying only on the config files’ presence would never see.
  • “actionlint catches unpinned third-party actions.” Proven above: it requires a @ref to exist syntactically, but does not distinguish a mutable branch name like @master from an immutable commit SHA.
  • “GitHub’s secret masking means a TF_VAR_* secret can’t leak through CI logs.” Proven above: the moment a secret becomes a plain environment variable, it is readable by any tool that dumps its environment.
flowchart TD
  Y["workflow YAML"] --> AL["actionlint"]
  AL -->|"1: @master accepted, ref exists"| AL
  AL -->|"2: unquoted $PLAN_FILE, real SC2086"| AL
  Y --> R["terraform plan -detailed-exitcode"]
  R -->|"3: TF_VAR_* echoed"| R
  R -->|"5: exit code 0/1/2 never branched"| R
  R --> A["terraform apply"]
  A -->|"4: no -out, re-plans from scratch"| A
  G[".gitignore / terraform.tfvars"] -->|"6: lockfile ignored"| G
  G -->|"7: secret committed"| G
  P["policy/*.rego"] --> CF["conftest"]
  CF -->|"8: pre-v1 Rego, load error"| CF
  L[".tflint.hcl / checkov config"] -->|"9: never wired into CI"| L
  TG["terragrunt run --all plan"] -->|"10: no --out-dir"| TG
  WIF["Workload Identity Pool provider"] -->|"attribute condition too broad -- docs"| WIF
Where each bug sits in this pipeline's real toolchain
actions/checkout@master and a bare uses: actions/checkout with no ref at all are both potential problems. What does real actionlint show for each?
terraform apply -auto-approve runs with no saved plan file from an earlier plan step. What does the real apply output show, compared to applying a saved plan file?
A .tflint.hcl and a checkov config both exist in the repository, but the generated GitHub Actions workflow never has a step that runs either tool. Running them directly against the pipeline's own main.tf -- what do they find?
The generated workflow shows a real google-github-actions/auth step with workload_identity_provider/service_account, but no attribute condition on the pool provider. Why does this matter?
A workflow step sets env: TF_VAR_db_password: ${{ secrets.DB_PASSWORD }} and a later line does echo "TF_VAR_db_password=$TF_VAR_db_password". Why is GitHub's log masking not a reliable defense here?