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 OIDC instead of stored Azure credentials, with policy checks.” What follows is the kind of first draft an AI assistant hands back for exactly that prompt — plausible YAML, a real azure/login OIDC 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 the AWS sibling review’s worked review 3 — proven fresh here, in this repo — plus one genuinely azurerm-specific OIDC 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: Log in to Azure via OIDC
uses: azure/login@v3
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
- 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 == "azurerm_linux_virtual_machine"
msg := sprintf("%s must set tags", [resource.address])
}
# .tflint.hcl, as generated -- fragment, not a collected fence
plugin "azurerm" {
enabled = true
version = "0.28.0"
source = "github.com/terraform-linters/tflint-ruleset-azurerm"
}

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 the AWS sibling review’s 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:17:9: shellcheck reported issue in this script: SC2086:info:1:40: Double quote to prevent globbing and word splitting [shellcheck]
|
17 | 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 == "azurerm_linux_virtual_machine"
msg := sprintf("%s must set tags", [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 == "azurerm_linux_virtual_machine"
msg := sprintf("%s must set tags", [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 == "azurerm_linux_virtual_machine"
msg := sprintf("%s must set tags", [resource.address])
}

Real, against the identical plan:

$ docker run --rm -v "$(pwd)":/project openpolicyagent/conftest test --policy policy plan.json
FAIL - plan.json - main - azurerm_linux_virtual_machine.app must set tags
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 (an azurerm_linux_virtual_machine with password auth disabled but no SSH-key enforcement shown, no VM extensions restriction):

$ 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: 4, Failed checks: 2, Skipped checks: 0
Check: CKV_AZURE_178: "Ensure linux VM enables SSH with keys for secure communication"
FAILED for resource: azurerm_linux_virtual_machine.app
Check: CKV_AZURE_50: "Ensure Virtual Machine Extensions are not Installed"
FAILED for resource: azurerm_linux_virtual_machine.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.

OIDC federated credential scoped too broadly

Section titled “OIDC federated credential scoped too broadly”

The generated workflow’s azure/login step with client-id/tenant-id/subscription-id is real and correctly-shaped, but nothing in this lesson’s generated code shows the App Registration’s own federated credential — the part that actually decides which GitHub identities may authenticate as it. A federated credential whose subject is scoped only to the repository without a ref (or left at a default that matches any branch) lets any branch — including a pull request from a fork — mint a token that authenticates as this identity. Fix: scope the federated credential’s subject to the exact branch that runs apply, and use a separate, more narrowly-scoped App Registration (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
—OIDC federated credential’s subject missing or too broadScope to repo:org/repo:ref:refs/heads/main — docs-sourced, no Azure tenant in this course

Nine of these ten bugs are identical, provider-agnostic CI/policy mechanics to the AWS sibling review — the same actionlint behaviors, the same -out/-detailed-exitcode gaps, the same disconnected linter configs, the same terragrunt --out-dir fix. The one genuinely azurerm-specific bug is the OIDC trust boundary: AWS scopes an IAM role’s trust policy sub condition, azurerm scopes an App Registration’s federated credential subject — different mechanism, identical failure mode when it’s 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
  OIDC["App Registration federated credential"] -->|"subject too broad -- docs"| OIDC
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 azure/login step with client-id/tenant-id/subscription-id, but no App Registration federated credential. 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?