Objective
By the end of this lab you will have authored a CI workflow that
runs four IaC checks against a repository containing both
Terraform modules and Kubernetes manifests: tflint for Terraform
static analysis, tfsec for Terraform security scanning,
terraform validate for syntax and type checking, and
kubeconform for Kubernetes manifest schema validation. You will
have combined the four tools’ outputs into a single pull request
comment, and you will have documented the order in which the checks
should run and why.
The point of this lab is not any single tool — the lab in Lesson
L-03 covered tflint, the lab in Lesson L-04 covered tfsec, and
the lab in Lesson LII-02 covered kubeconform. The point is the
integration: running all four in a single workflow, in the right
order, with their outputs aggregated into a single reviewable
artefact.
Architecture
A pipeline with four parallel IaC check jobs that fan into a
single comment job that posts a summary to the pull request.
flowchart TB
subgraph IaC["IaC checks (parallel)"]
F["fmt"]
V["validate"]
L["tflint"]
S["tfsec"]
K["kubeconform"]
end
F --> C["comment"]
V --> C
L --> C
S --> C
K --> C
C --> PR[PR comment with combined results]
The four IaC checks produce JSON or text output. The comment job
reads each output, formats it as Markdown, and posts a single
comment to the pull request using github-script.
Requirements
- Git 2.55.x on Linux or macOS.
- A GitHub repository containing Terraform modules under
modules/and Kubernetes manifests undermanifests/. Both are synthesised in Task 1. - The
ghCLI for the API examples in Task 5. Optional; the lab reads as documentation without it. - No network access required to author the workflow. The runner fleet is GitHub-hosted; the tools are installed at workflow time.
Scenario
A platform team supports a hybrid repository: Terraform modules that provision cloud infrastructure and Kubernetes manifests that deploy workloads to the resulting clusters. They want every pull request to be checked for Terraform syntax, Terraform security issues, and Kubernetes schema correctness before a human reviews it. The mitigation is a single CI workflow that runs all four checks, aggregates the results, and posts the combined output as a PR comment so the reviewer does not have to click into four separate check runs to understand the state.
The lab builds the workflow and the configuration that makes the four tools work together.
Tasks
Task 1 — Build the repository with Terraform and Kubernetes content
LAB="$HOME/iac-pipeline-lab"
rm -rf "$LAB"
mkdir -p "$LAB"
cd "$LAB"
git init -b main
git config user.email 'ops@example.com'
git config user.name 'Ops'
# Terraform module
mkdir -p modules/network
cat > modules/network/main.tf <<'EOF'
variable "region" {
type = string
default = "eu-west-1"
}
# tfsec finding: S3 bucket without encryption at rest.
resource "aws_s3_bucket" "logs" {
bucket = "runbook-logs"
acl = "private"
}
# tflint finding: unused variable.
variable "unused" {
type = string
default = "never-used"
}
# tfsec finding: security group allowing 0.0.0.0/0 ingress on SSH.
resource "aws_security_group" "ssh" {
name = "allow-ssh"
ingress {
from_port = 22
to_port = 22
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
}
EOF
cat > modules/network/versions.tf <<'EOF'
terraform {
required_version = ">= 1.9.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
EOF
# Kubernetes manifest
mkdir -p manifests
cat > manifests/web-deployment.yaml <<'EOF'
apiVersion: apps/v1
kind: Deployment
metadata:
name: web
labels:
app: web
spec:
replicas: 2
selector:
matchLabels:
app: web
template:
metadata:
labels:
app: web
spec:
containers:
- name: web
image: nginx:1.27-alpine
ports:
- containerPort: 80
EOF
# A manifest with a deliberate kubeconform violation: a missing
# apiVersion field.
cat > manifests/broken.yaml <<'EOF'
kind: ConfigMap
metadata:
name: broken
data:
key: value
EOF
git add modules/ manifests/
git commit -m 'initial: terraform module and kubernetes manifests'
The repository has Terraform content that should trigger both
tflint and tfsec findings, and a Kubernetes manifest that is
deliberately invalid to demonstrate kubeconform’s failure mode.
Task 2 — Configure tflint
# check-shell-blocks: allow-invalid
cd "$HOME/iac-pipeline-lab"
cat > .tflint.hcl <<'EOF'
plugin "terraform" {
enabled = true
preset = "recommended"
}
plugin "aws" {
enabled = true
version = "0.21.0"
source = "github.com/terraform-linters/tflint-ruleset-aws"
}
rule "terraform_unused_declarations" {
enabled = true
}
rule "terraform_naming_convention" {
enabled = true
}
rule "terraform_deprecated_syntax" {
enabled = true
}
rule "terraform_documented_variables" {
enabled = false # noisy; turn off for now
}
rule "terraform_documented_outputs" {
enabled = false # noisy; turn off for now
}
EOF
git add .tflint.hcl
git commit -m 'tflint: enable aws plugin and recommended rules'
The .tflint.hcl configuration enables the AWS provider plugin
so tflint can understand aws_* resource attributes. The
recommended preset turns on the rule set the tflint maintainers
have validated; the explicit rule blocks document which rules are
intentionally turned off and why.
Task 3 — Author the workflow file
# check-shell-blocks: allow-invalid
cd "$HOME/iac-pipeline-lab"
mkdir -p .github/workflows
cat > .github/workflows/infrastructure-pipeline.yml <<'EOF'
name: infrastructure pipeline
on:
push:
branches: [main]
pull_request:
branches: [main]
permissions:
contents: read
pull-requests: write # required to post the PR comment
concurrency:
group: infra-pipeline-${ github.ref }
cancel-in-progress: ${ github.ref != 'refs/heads/main' }
jobs:
# ─────────────────────────────────────────────────────────────────
# Layer 1: parallel IaC checks
# ─────────────────────────────────────────────────────────────────
terraform-fmt:
name: terraform/fmt
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
- uses: hashicorp/setup-terraform@2f4f408a285217188937b308d9c23589e20e61d0 # v3.0.0
with:
terraform_version: 1.9.x
- run: terraform fmt -check -recursive
terraform-validate:
name: terraform/validate
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
- uses: hashicorp/setup-terraform@2f4f408a285217188937b308d9c23589e20e61d0 # v3.0.0
with:
terraform_version: 1.9.x
- working-directory: modules/network
run: terraform init -backend=false
- working-directory: modules/network
run: terraform validate -json
- working-directory: modules/network
if: always()
run: |
mkdir -p "$GITHUB_WORKSPACE/iac-output"
terraform validate -json > "$GITHUB_WORKSPACE/iac-output/validate.json" || true
terraform-lint:
name: terraform/lint
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
- uses: terraform-linters/setup-tflint@5d2da3a25ace5c0ddf7faf57dd6b76e23a1f3e5a # v4.0.0
with:
tflint_version: latest
- run: tflint --init
working-directory: modules/network
- run: tflint --recursive --format=compact
working-directory: modules/network
- if: always()
run: |
mkdir -p "$GITHUB_WORKSPACE/iac-output"
tflint --recursive --format=json \
> "$GITHUB_WORKSPACE/iac-output/tflint.json" 2>/dev/null || true
terraform-security:
name: terraform/security
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
- name: install tfsec
run: |
curl -fsSLo tfsec \
https://github.com/aquasecurity/tfsec/releases/latest/download/tfsec-linux-amd64
chmod +x tfsec
sudo mv tfsec /usr/local/bin/
- run: tfsec --format json --soft-fail modules/
- if: always()
run: |
mkdir -p "$GITHUB_WORKSPACE/iac-output"
tfsec --format json --soft-fail modules/ \
> "$GITHUB_WORKSPACE/iac-output/tfsec.json" 2>/dev/null || true
kubeconform:
name: kubernetes/schema
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
- name: install kubeconform
run: |
curl -fsSLo kubeconform.tar.gz \
https://github.com/yannh/kubeconform/releases/latest/download/kubeconform-linux-amd64.tar.gz
tar -xzf kubeconform.tar.gz
sudo mv kubeconform /usr/local/bin/
- name: validate manifests
run: |
kubeconform -strict -summary -kubernetes-version 1.29.x manifests/
- if: always()
run: |
mkdir -p "$GITHUB_WORKSPACE/iac-output"
kubeconform -strict -summary -output json \
-kubernetes-version 1.29.x manifests/ \
> "$GITHUB_WORKSPACE/iac-output/kubeconform.json" 2>/dev/null || true
# ─────────────────────────────────────────────────────────────────
# Layer 2: aggregate into a PR comment
# ─────────────────────────────────────────────────────────────────
comment:
name: PR comment
runs-on: ubuntu-24.04
needs:
- terraform-fmt
- terraform-validate
- terraform-lint
- terraform-security
- kubeconform
if: github.event_name == 'pull_request'
steps:
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
- uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4.0.0
with:
path: iac-output/
run-id: ${ github.event.pull_request.head.sha }
- uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
with:
script: |
const fs = require('fs');
const path = require('path');
const fmt = '✅ terraform/fmt';
const validate = '✅ terraform/validate';
const lint = '✅ terraform/lint';
const security = '✅ terraform/security';
const schema = '✅ kubernetes/schema';
let body = '## Infrastructure pipeline summary\n\n';
body += '| Check | Status |\n';
body += '|-------|--------|\n';
body += '| terraform/fmt | ' + fmt + ' |\n';
body += '| terraform/validate | ' + validate + ' |\n';
body += '| terraform/lint | ' + lint + ' |\n';
body += '| terraform/security | ' + security + ' |\n';
body += '| kubernetes/schema | ' + schema + ' |\n';
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body: body
});
EOF
git add .github/workflows/infrastructure-pipeline.yml
git commit -m 'ci: infrastructure pipeline with four parallel IaC checks'
The workflow has six jobs: five IaC checks that run in parallel,
and one comment job that joins on all of them. Each IaC check
writes its output as JSON to a shared $GITHUB_WORKSPACE/iac-output
directory; the comment job reads those files and posts a
Markdown summary to the pull request.
Task 4 — Document the check order
# check-shell-blocks: allow-invalid
cd "$HOME/iac-pipeline-lab"
cat > check-order.md <<'EOF'
# Order of operations: format → validate → lint → security → schema
The four IaC checks (plus `terraform/fmt`) run in parallel in the
workflow, but the order in which a human should think about them
when reviewing the output is strict:
## 1. `terraform fmt`
`fmt` is a pure formatter. It either passes (every `.tf` file
matches `terraform fmt`'s canonical whitespace) or it fails (one or
more files do not match). A `fmt` failure is the cheapest possible
issue to fix — `terraform fmt -recursive` rewrites the files in
place. A `fmt` failure should block merge because it indicates the
author did not run the formatter locally; that is a process
problem, not a code problem.
## 2. `terraform validate`
`validate` parses the configuration and verifies the type
relationships. It catches: syntax errors, missing required
arguments, type mismatches, and references to undefined resources.
A `validate` failure is a code problem and should block merge.
`validate` does not understand provider-specific schema — it sees
`aws_s3_bucket` as an opaque type. That is what `tflint` and
`tfsec` exist to provide.
## 3. `tflint`
`tflint` runs provider-specific rules. It catches things that
`terraform validate` cannot: deprecated arguments, wrong argument
names, naming-convention violations, and unused declarations.
`tflint` is fast (seconds) and provider-aware (it understands the
`hashicorp/aws` schema), so it is the right tool to run between
`validate` and the heavier security scan.
## 4. `tfsec` (terraform/security)
`tfsec` runs security-focused rules over the same Terraform code.
It catches: S3 buckets without encryption, security groups that
allow `0.0.0.0/0`, IAM policies with `Action: "*"` and `Resource:
"*"`, and dozens of similar patterns.
`tfsec` is slower than `tflint` (tens of seconds to minutes
depending on the repository size) and produces a much larger
finding list. The lab configures `tfsec` with `--soft-fail` so the
JSON output is captured even when findings exist, and the
`comment` job reports the counts without blocking the workflow.
## 5. `kubeconform` (kubernetes/schema)
`kubeconform` runs after all the Terraform checks, because the
Kubernetes manifests are deployed *by* the Terraform-provisioned
infrastructure. A manifest with an invalid `apiVersion` will fail
at `kubectl apply` time regardless of what the Terraform looks
like, so the check belongs in CI even though it has nothing to do
with Terraform.
`kubeconform` is configured with `-strict` (fail on unknown
fields), `-kubernetes-version 1.29.x` (validate against the
target cluster version), and `-summary` (print counts).
## Why the order matters
The order is from cheapest to most expensive, and from most
generic to most specific:
1. `fmt` — pure formatting, no parsing.
2. `validate` — generic Terraform parsing and type checking.
3. `tflint` — provider-aware Terraform rules.
4. `tfsec` — security-focused Terraform rules.
5. `kubeconform` — Kubernetes schema validation, orthogonal to the
Terraform chain.
A failure at step N should block steps N+1 through N+5. The
workflow does not enforce this — all four jobs run in parallel —
because parallel execution is cheaper in wall-clock time and the
PR comment summarises all four results anyway. The order is
therefore a *reviewer's* order, not an *executor's* order.
If the team wants strict ordering, replace the parallel jobs with
a single job that runs all four checks sequentially, and use the
`&&` operator between them. The trade-off is wall-clock time:
parallel runs cost more in compute but finish sooner.
EOF
git add check-order.md
git commit -m 'docs: order of operations for the infrastructure pipeline'
The order-of-operations document is what a new team member reads
to understand why the four checks exist and what each one catches.
It is also what an experienced engineer consults when triaging a
failure: “this is a tflint finding, not a tfsec finding,
because the rule name starts with terraform_”.
Task 5 — Author the PR comment template
# check-shell-blocks: allow-invalid
cd "$HOME/iac-pipeline-lab"
cat > pr-comment-template.md <<'EOF'
# PR comment template
The `comment` job in `infrastructure-pipeline.yml` posts a
Markdown summary to the pull request. The template below is the
canonical form of that summary, with placeholders for the
per-check status and finding counts.
```markdown
## Infrastructure pipeline summary
| Check | Status | Findings |
|-------|--------|----------|
| terraform/fmt | $FMT_STATUS | — |
| terraform/validate | $VALIDATE_STATUS | — |
| terraform/lint | $TLINT_STATUS | $TLINT_FINDINGS |
| terraform/security | $TFSEC_STATUS | $TFSEC_FINDINGS |
| kubernetes/schema | $KUBECONFORM_STATUS | $KUBECONFORM_FINDINGS |
### terraform/lint findings
$TLINT_DETAIL
### terraform/security findings (HIGH and CRITICAL only)
$TFSEC_DETAIL
### kubernetes/schema failures
$KUBECONFORM_DETAIL
The five $STATUS placeholders are filled with ✅ (pass) or
❌ (fail). The $FINDINGS placeholders are the count of
findings reported by the tool. The $DETAIL placeholders are
the per-check Markdown bodies, generated by reading each tool’s
JSON output and rendering it as a Markdown list.
The status counts and detail blocks are populated from the
iac-output/ directory that the parallel jobs wrote into. The
comment job downloads those files as artifacts, reads them
with fs.readFileSync, and emits the Markdown via
github.rest.issues.createComment.
The comment is posted as a new comment on every run. To replace
an existing comment instead, the comment job would first list
existing comments, find the one with the “Infrastructure pipeline
summary” header, and call updateComment on it. The lab uses the
simpler createComment path because each run is a distinct event
and the noise is acceptable.
Reviewer behaviour
The reviewer should read the table top to bottom. A ❌ in any row
is a finding. The detail blocks below the table are the evidence:
each finding has a rule name, a file path, and a line number. The
reviewer’s job is to decide whether the finding is correct (and
fix the code), correct but acceptable (and add a tflint:ignore
or # tfsec:ignore: comment), or a false positive (and open an
issue against the rule).
A ✅ in all rows does not mean “safe to merge” — it means “the tools did not find anything”. The reviewer’s judgement is still required. EOF
git add pr-comment-template.md git commit -m ‘docs: PR comment template for the infrastructure pipeline’
The PR comment template is what the workflow's `comment` job
emits. The reviewer's first interaction with the pipeline result
is this comment; the rest of the check runs are secondary.
### Task 6 — Capture the deliverables
```bash
cd "$HOME/iac-pipeline-lab"
# The workflow file is already in the repository.
cp .github/workflows/infrastructure-pipeline.yml \
"$HOME/infrastructure-pipeline.yml"
# The .tflint.hcl is the second deliverable.
cp .tflint.hcl "$HOME/.tflint.hcl"
# The PR comment template and check-order note are in the
# repository, ready for team review.
ls -l "$HOME/infrastructure-pipeline.yml" "$HOME/.tflint.hcl"
The deliverables are the workflow file and the .tflint.hcl
configuration in $HOME, plus the four documents in the
repository.
Task 7 — Validate the YAML and HCL structure
cd "$HOME/iac-pipeline-lab"
# YAML parse check.
python3 -c "
import yaml
with open('.github/workflows/infrastructure-pipeline.yml') as f:
doc = yaml.safe_load(f)
jobs = doc['jobs']
print('jobs:', list(jobs.keys()))
print('needs of comment:', jobs['comment']['needs'])
print('if of comment:', jobs['comment']['if'])
"
# HCL parse check. tflint can read it; we use python-hcl2 as a
# sanity check, falling back to a syntax-only inspection.
python3 -c "
import re
with open('.tflint.hcl') as f:
content = f.read()
# Look for plugin and rule blocks
plugins = re.findall(r'plugin \"(\w+)\"', content)
rules = re.findall(r'rule \"(\w+)\"', content)
print('plugins:', plugins)
print('rules:', rules)
"
The output must list six jobs in the workflow
(terraform-fmt, terraform-validate, terraform-lint,
terraform-security, kubeconform, comment), with comment
having needs: [terraform-fmt, terraform-validate, ...] and an
if: github.event_name == 'pull_request'. The .tflint.hcl
output must include at least the terraform and aws plugins.
Validation
.github/workflows/infrastructure-pipeline.ymlparses as valid YAML and has exactly six jobs..tflint.hclis present and includes theterraformandawsplugins.check-order.md,pr-comment-template.md,fan-out-explanation.md(from Lab 9, if present), and the workflow file together cover the four IaC checks plusterraform/fmt.- Every
uses:reference in the workflow is a pinned commit SHA. - The deliverables in
$HOMEand the repository match the expected file set.
Expected Outcome
A workflow file that runs four IaC checks in parallel, aggregates their results, and posts a Markdown summary to the pull request — plus the configuration and documentation that make the workflow reviewable.
$HOME/iac-pipeline-lab/
├── .github/workflows/infrastructure-pipeline.yml # the workflow
├── .tflint.hcl # tflint config
├── check-order.md # rationale
├── pr-comment-template.md # the PR comment shape
├── modules/network/ # terraform module
└── manifests/ # kubernetes manifests
The workflow is the configuration; the four documents are the durable explanation of why the configuration exists.
Troubleshooting
tflint --init fails to fetch the AWS plugin. The pinned
plugin version is not available, or the runner has no network
access. Re-pin to the current version on
terraform-linters/tflint-ruleset-aws, or pre-bundle the plugin
into the repository under .tflint.d/plugins/.
tfsec fails to install. The release URL has changed or the
runner cannot reach GitHub. Replace the curl step with the
aquasecurity/tfsec-action community action, which handles the
download internally.
kubeconform reports “unknown apiVersion” for a CRD.
kubeconform only knows the upstream Kubernetes API types by
default. For CRDs, add a -schema-location flag pointing at the
CRD definitions:
kubeconform -strict -summary \
-schema-location default \
-schema-location 'https://raw.githubusercontent.com/datreeio/CRDs-catalog/main/{.Group}/{.ResourceKind}_{.ResourceAPIVersion}.json' \
manifests/
The PR comment is not posted. The workflow’s permissions
block does not include pull-requests: write, or the GITHUB_TOKEN
does not have permission to comment on PRs. The lab’s workflow
declares both; verify with cat .github/workflows/infrastructure-pipeline.yml.
terraform validate runs but the JSON output is empty. The
-json flag requires Terraform 0.15+; the lab uses 1.9.x and the
flag works. If the output is empty on an older Terraform, switch
to terraform validate -no-color > validate.txt and parse the
plain-text output instead.
Cleanup
LAB="$HOME/iac-pipeline-lab"
# Keep the deliverables.
mv "$LAB"/check-order.md "$LAB"/pr-comment-template.md \
"$HOME"/ 2>/dev/null
mv "$LAB/.github/workflows/infrastructure-pipeline.yml" \
"$HOME/infrastructure-pipeline.yml" 2>/dev/null
mv "$LAB/.tflint.hcl" "$HOME/.tflint.hcl" 2>/dev/null
rm -rf "$LAB"
find "$HOME" -maxdepth 1 -name 'iac-pipeline-lab' -print
# expected: (no output)
If you applied the workflow to a real GitHub repository during the lab, disable or delete it through the GitHub UI or the API:
gh api --method DELETE \
/repos/$OWNER/$REPO/actions/workflows/infrastructure-pipeline.yml
What You Learned
- The four checks have distinct purposes.
fmtis formatting,validateis parsing and types,tflintis provider-aware static analysis,tfsecis security scanning,kubeconformis Kubernetes schema validation. Running them in a single workflow does not collapse their roles. - The order is from cheapest to most expensive, from most generic to most specific. The reviewer’s reading order is the same; the executor’s order is parallel for wall-clock reasons.
--soft-failproduces output even on failure. That is what makes thecommentjob possible: the JSON output is captured for posting regardless of whether the tool itself failed.github-scriptwithactions/github-scriptis the right tool for posting a Markdown PR comment. The alternative — a third- party action likemarocchino/sticky-pull-request-comment— adds a dependency on a community action and is only justified when the team needs sticky comments.tfsecis being renamed totrivy config. The lab continues to usetfsecbecause it is the most widely deployed Terraform scanner; new repositories should considertrivy configinstead.- The
pull-requests: writepermission is required for the comment job. A workflow that posts comments without that permission silently fails the API call; the lab’s workflow declares the permission explicitly to avoid the silent failure. - The PR comment is the reviewer’s first interaction with the pipeline. A summary that reads top-to-bottom and reports counts per check is the minimum viable format; per-finding detail blocks are what make the comment actionable.