Worked review 3: a CI pipeline and policy checks
The feature
Section titled “The feature”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.
The generated code
Section titled “The generated code”# .github/workflows/terraform.yml, as generated -- fragment, not a collected fencename: terraformon: 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 abovedb_password = "Sup3rSecret!2024"api_token = "ghp_1234567890abcdefghijklmnopqrstuvwxyz"# policy/deny_no_tags.rego, as generated -- fragment, not a collected fencepackage 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 fenceplugin "azurerm" { enabled = true version = "0.28.0" source = "github.com/terraform-linters/tflint-ruleset-azurerm"}The review walk
Section titled “The review walk”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@masterShow 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_FILEShow 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_passwordTF_VAR_db_password=SuperSecret123🐛 Spot the bug
- name: planrun: | echo "TF_VAR_db_password=$TF_VAR_db_password" terraform plan -detailed-exitcode -out=$PLAN_FILEenv: 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-approveterraform_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 tfplanApply complete! Resources: 1 added, 0 changed, 0 destroyed.🐛 Spot the bug
- run: terraform init- run: terraform apply -auto-approveShow 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.backupSame bug as AI bug catalog entry 4. Fix:
# .gitignore, fixed.terraform/*.tfstate*.tfstate.backupterraform.tfvars: a real secret, committed alongside the workflow
Section titled “terraform.tfvars: a real secret, committed alongside the workflow”# terraform.tfvars, as generateddb_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 bodypackage 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.jsonError: 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 bodypolicy/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, fixedpackage 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.jsonFAIL - 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/tflint1 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 --quietPassed 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.appCheck: CKV_AZURE_50: "Ensure Virtual Machine Extensions are not Installed"FAILED for resource: azurerm_linux_virtual_machine.apprun --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-approveShow 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.
What changed and why
Section titled “What changed and why”| # | Bug | Fix |
|---|---|---|
| 1 | actions/checkout@master — mutable ref, actionlint accepts it identically to a SHA | Pin to a full commit SHA |
| 2 | -out=$PLAN_FILE unquoted in a run: script | -out="$PLAN_FILE" — real actionlint/shellcheck SC2086 finding |
| 3 | Secret-backed TF_VAR_db_password echoed in a debug line | Never echo it — real env output proved it is a plain, readable variable once set |
| 4 | apply -auto-approve with no saved plan file | plan -out=tfplan as an artifact, apply tfplan in the apply job |
| 5 | -detailed-exitcode computed, its 3-way exit code never branched on | Capture $?, branch on 0/1/2 explicitly |
| 6 | .terraform.lock.hcl gitignored alongside .terraform/ | Only .terraform/ ignored |
| 7 | terraform.tfvars with a real secret, committed | Deleted; secrets sourced from TF_VAR_* CI secrets only |
| 8 | Pre-v1 Rego (deny[msg] { ... }) | import rego.v1 + deny contains msg if { ... } |
| 9 | .tflint.hcl/checkov config present, no CI step ever runs either | Real tflint/checkov steps added — both tools proven to find real issues against this exact main.tf |
| 10 | No --out-dir on terragrunt run --all plan | --out-dir <dir> in plan, same dir in apply |
| — | OIDC federated credential’s subject missing or too broad | Scope to repo:org/repo:ref:refs/heads/main — docs-sourced, no Azure tenant in this course |
Mental Model
Section titled “Mental Model”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.
Common Misconceptions
Section titled “Common Misconceptions”- “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 ownmain.tf, found real issues that a CI run relying only on the config files’ presence would never see. - “
actionlintcatches unpinned third-party actions.” Proven above: it requires a@refto exist syntactically, but does not distinguish a mutable branch name like@masterfrom 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