Module 3: The Iac Pipeline Fmt Validate Plan
8. Project: Andes Cargo's `ci.yml`
Description
This is the project that closes Module 3. Lessons 4 through 7 built ci.yml piece by piece —fmt, validate, the LocalStack connection, plan, publishing as evidence— testing each one separately. This project runs them all together, in a single file, end to end, against a real change to Andes Cargo's HCL: exactly the change Module 2's pr-event.json anticipated from the start —"Add tags to the shipment documents bucket"— now made real. You're going to add a new tag to the andes-cargo-shipment-docs bucket, run act pull_request -e pr-event.json one last time, and read the complete ci.yml working the way it would on a real Pull Request: fmt → init → validate → LocalStack connection → plan → published summary.
Connection to the module
This project doesn't introduce any new step — it's the integration of everything you already built. The ci.yml you run here is exactly the same file you built, line by line, in lessons 4 through 7. The only genuinely new thing is the business change that triggers it: this module's first and only line of HCL, minimal and with a clear purpose, consistent with the feature/add-shipment-tags branch you wrote by hand in Module 2, lesson 6.
The complete ci.yml
.github/workflows/ci.yml, as it stands at the close of this module:
name: ci
on:
pull_request:
branches: [main]
jobs:
terraform-checks:
runs-on: ubuntu-latest
env:
AWS_ACCESS_KEY_ID: test
AWS_SECRET_ACCESS_KEY: test
AWS_DEFAULT_REGION: us-east-1
AWS_ENDPOINT_URL: http://host.docker.internal:4566
steps:
- name: Check out andes-cargo-infra
uses: actions/checkout@v4
- name: Set up Terraform
uses: hashicorp/setup-terraform@v3
with:
terraform_version: "1.15.8"
- name: Terraform format check
run: terraform fmt -check -recursive
- name: Terraform init
run: terraform init -input=false
- name: Terraform validate
run: terraform validate
- name: Install awslocal
run: pip3 install --quiet --break-system-packages awscli awscli-local
- name: Confirm the runner can reach LocalStack on the host
continue-on-error: true
run: awslocal s3 ls
- name: Install tflocal
run: pip3 install --quiet --break-system-packages terraform-local
- name: Terraform plan
run: tflocal plan -input=false -no-color | tee plan-output.txt
- name: Publish the plan to the job summary
run: |
{
echo "## Terraform plan — andes-cargo-infra"
echo '```'
cat plan-output.txt
echo '```'
} >> "$GITHUB_STEP_SUMMARY"
Nine steps, each with a specific role you already know: check style, install Terraform, check syntax, confirm the network path, calculate what would change, and publish that calculation where someone can read it. None of the nine, at any point, writes real infrastructure — the apply that would do that only arrives in Module 5.
The real change: adding the Compliance tag to the manifest bucket
Remember the pr-event.json you wrote in Module 2, lesson 6: PR #42, branch feature/add-shipment-tags → main, title "Add tags to the shipment documents bucket." Until now, that title described an intent — this project makes it real. Andes Cargo needs to mark the andes-cargo-shipment-docs bucket with a compliance tag, so external audit tools (outside this guide's scope) can identify which buckets are subject to a manifest-retention policy.
Open s3.tf and modify the module "shipment_docs_bucket" block:
module "shipment_docs_bucket" {
source = "./modules/s3-bucket"
bucket_name = var.bucket_name
enable_versioning = true
bucket_policy_json = data.aws_iam_policy_document.require_https.json
tags = merge(local.common_tags, {
Compliance = "manifest-retention-required"
})
}
The only change: tags = local.common_tags becomes tags = merge(local.common_tags, { Compliance = "manifest-retention-required" }). merge() is a native Terraform function that combines two or more maps into one —here, it takes the three tags that already came from local.common_tags (Project, Environment, ManagedBy) and adds a fourth (Compliance) without touching the others. It's, literally, the only line of business HCL this entire module adds — consistent with what DISEÑO.md promised from the start: automating the pipeline, not rewriting the project.
Running the complete ci.yml, end to end
terraform fmt s3.tf
git diff --stat
What to expect (literal):
s3.tf | 4 +++-
1 file changed, 3 insertions(+), 1 deletion(-)
Now run the complete pipeline, exactly as it would run on a real Pull Request:
act pull_request -e .github/act-events/pr-event.json -j terraform-checks
What to expect (literal output, executed to write this lesson — summary of the nine steps, in order, with this run's real times):
[ci/terraform-checks] ⭐ Run Set up job
[ci/terraform-checks] ✅ Success - Set up job
[ci/terraform-checks] ⭐ Run Main Check out andes-cargo-infra
[ci/terraform-checks] ✅ Success - Main Check out andes-cargo-infra [25.3615ms]
[ci/terraform-checks] ⭐ Run Main Set up Terraform
[ci/terraform-checks] ✅ Success - Main Set up Terraform [2.306658833s]
[ci/terraform-checks] ⭐ Run Main Terraform format check
[ci/terraform-checks] ✅ Success - Main Terraform format check [138.0015ms]
[ci/terraform-checks] ⭐ Run Main Terraform init
[ci/terraform-checks] ✅ Success - Main Terraform init [10.937006333s]
[ci/terraform-checks] ⭐ Run Main Terraform validate
[ci/terraform-checks] | Success! The configuration is valid.
[ci/terraform-checks] ✅ Success - Main Terraform validate [1.800934209s]
[ci/terraform-checks] ⭐ Run Main Install awslocal
[ci/terraform-checks] ✅ Success - Main Install awslocal [11.648304875s]
[ci/terraform-checks] ⭐ Run Main Confirm the runner can reach LocalStack on the host
[ci/terraform-checks] | Could not connect to the endpoint URL: "http://host.docker.internal:4566/"
[ci/terraform-checks] Failed but continue next step
[ci/terraform-checks] ❌ Failure - Main Confirm the runner can reach LocalStack on the host [8.406601667s]
[ci/terraform-checks] ⭐ Run Main Install tflocal
[ci/terraform-checks] ✅ Success - Main Install tflocal [2.2965225s]
[ci/terraform-checks] ⭐ Run Main Terraform plan
[ci/terraform-checks] | Plan: 12 to add, 0 to change, 0 to destroy.
[ci/terraform-checks] ✅ Success - Main Terraform plan [4.3687605s]
[ci/terraform-checks] ⭐ Run Main Publish the plan to the job summary
[ci/terraform-checks] ✅ Success - Main Publish the plan to the job summary [70.591959ms]
[ci/terraform-checks] ⚙ Summary - ## Terraform plan — andes-cargo-infra
[ci/terraform-checks] ⭐ Run Complete job
[ci/terraform-checks] ✅ Success - Complete job
[ci/terraform-checks] 🏁 Job succeeded
Plan: 12 to add, 0 to change, 0 to destroy. — the same resource count as lesson 6, because the new tag doesn't create or destroy any resource, it only changes an attribute inside resources that were being created for the first time anyway. Confirm it by looking for the tag inside the complete plan:
grep -A1 "Compliance" plan-output.txt
What to expect (literal, two occurrences — one in tags, another in tags_all, from the aws_s3_bucket block inside module.shipment_docs_bucket):
+ "Compliance" = "manifest-retention-required"
+ "Environment" = "dev"
--
+ "Compliance" = "manifest-retention-required"
+ "Environment" = "dev"
This is, literally, the evidence someone reviewing Pull Request #42 would see in the job summary: a plan with 12 resources, nothing destroyed, with the new tag showing up exactly where the PR's title promised it would —on the manifest bucket, not on any other resource.
Committing the change
git add s3.tf
git commit -m "Add Compliance tag to the shipment-docs bucket"
git log --oneline
What to expect (representative for the commit hashes, literal for the structure and the messages — six new commits since Module 2's close, one for each piece this module added):
a10c9b7 Add Compliance tag to the shipment-docs bucket
7b6421e ci.yml: publish the plan to the job summary
dfcbfce ci.yml: install tflocal and run terraform plan
70472a2 ci.yml: confirm the runner can reach LocalStack via host.docker.internal
ba47438 Add ci.yml: terraform fmt and validate as CI steps
5094200 Add hello-andes-cargo.yml: first workflow reaching for LocalStack via host.docker.internal
4e4f5c5 Add .secrets (gitignored) and secrets-test workflows for act --secret-file / -s
b42e54d Add pr-event.json and print-event.yml to practice act -e
ce6efa5 Bootstrap CI/CD layer: initialize git repository, .actrc, and .github/workflows/
git status
What to expect (literal):
On branch main
nothing to commit, working tree clean
Closing Module 3
You completed the module that builds half of Andes Cargo pipeline's CI. Review what you're taking with you:
- The pattern, with an exact source:
planon Pull Request,applyon merge, in two separate workflows —not a condition inside a shared file— cited from HashiCorp's official tutorial, with the concrete security reason (avoiding the "pwn request" vulnerability family) explained in depth (lesson 2). - Terraform, installed on every run:
hashicorp/setup-terraform@v3, pinned to1.15.8, confirmed with a command that looks for the binary before and after installing it — direct proof that a runner remembers nothing between runs, unlike your laptop (lesson 3). fmtandvalidate, watched failing and getting fixed twice: a formatting error (exit code3, with no more detail than the filename) and a reference error (complete message, with a suggested fix) — each caught by the right tool, in the right order (lesson 4).- The LocalStack connection, tested before trusting it:
host.docker.internal:4566, with an honest failure (LocalStack off, 11 seconds of real retrying) that didn't block the rest of the job thanks tocontinue-on-error: true(lesson 5). - This guide's first real
terraform plan: 12 resources, calculated with no need for LocalStack running —a verified finding aboutskip_requesting_account_id, with the honest warning that it's a property of this specific moment in the project, not a general guarantee (lesson 6). - The
planas evidence, two forms:$GITHUB_STEP_SUMMARY, confirmed working underact; and the direct Pull Request comment viaactions/github-script, shown in complete YAML, honestly labeled as not-executable without a real Pull Request (lesson 7). - Everything together, against a real change: the
Compliancetag on the manifest bucket, exactly what Pull Request #42's title promised since Module 2, run end to end withact pull_request -e pr-event.json(this project).
What comes next
Module 4 builds the security half that makes it possible for a real apply to exist without compromising a long-lived credential: GitHub Secrets, why a credential never lives in the repository, what OIDC federation is —shown in complete YAML, with the exact technical reason it doesn't run under act— and GitHub Environments (dev/prod) as approval control. Module 5 takes that secrets handling and the ci.yml you just finished, and builds apply.yml: the workflow that, triggered only by push to main —never by an unreviewed Pull Request— applies exactly the plan a person already approved here.
Common mistakes
Forgetting terraform fmt s3.tf after editing the bucket by hand (flow-based, the easiest one to make in this specific project). What happens: someone writes the merge(...) block with slightly different indentation than this lesson's, and the Terraform format check step fails when running the complete ci.yml, interrupting the rest of the job before reaching the plan. How to spot it: the same pattern from lesson 4 —iam.tf (or, in this case, s3.tf) printed, followed by Terraform exited with code 3. How to fix it: run terraform fmt s3.tf (without -check) before committing any HCL change, a habit worth keeping for the rest of this guide.
Expecting the resource count to change because a tag was added (conceptual). What happens: someone sees Plan: 12 to add —the same number as lesson 6, without the new tag— and wonders whether the change was actually applied to the HCL. How to spot it: comparing the count against previous lessons and seeing no difference. How to fix it: a new tag modifies an attribute inside a resource that was already being created — it doesn't add or remove any complete resource. Plan:'s count counts resources, not attributes; to confirm the change is there, check the plan's content (like this lesson's grep), not the last line's number.
Thinking this project already applied the tag to LocalStack (expectation-based, this module's most important mistake to close on). What happens: someone, after seeing Plan: 12 to add green, runs awslocal s3api get-bucket-tagging --bucket andes-cargo-shipment-docs expecting to see the Compliance tag already present. How to spot it: that command would fail (LocalStack isn't even running in this module) or, with LocalStack running but no apply executed, wouldn't show the tag. How to fix it: remember this module's complete thesis — you built half of CI, the half that calculates and shows. No command in this module, in this project, applied anything real. The tag exists in the plan, ready to be reviewed; actually applying it is, explicitly, Module 5's job.
Exercises
Exercise 1 — Explain the change's narrative coherence. Without looking at this lesson, explain to a colleague why this project's HCL change (Compliance tag on the bucket) isn't arbitrary, but was anticipated since Module 2.
See solution
The pr-event.json written by hand in Module 2, lesson 6, already included the title "Add tags to the shipment documents bucket" for PR #42, on the feature/add-shipment-tags branch — a simulated event describing a change's intent before that change actually existed in the HCL. This project closes that loop: it makes real, in s3.tf, exactly what the simulated Pull Request's title promised, so every piece of this guide —from the branch name to the plan's content— tells the same coherent story, with no numbers or names made up along the way.
Exercise 2 — Reconstruct the complete ci.yml from memory. Without looking at this lesson, write (on paper or in an editor) ci.yml's nine step names, in the correct order. Then compare against the real file.
See solution
In order: 1. Check out andes-cargo-infra. 2. Set up Terraform. 3. Terraform format check. 4. Terraform init. 5. Terraform validate. 6. Install awslocal. 7. Confirm the runner can reach LocalStack on the host. 8. Install tflocal. 9. Terraform plan. Plus a tenth, publishing the summary: 10. Publish the plan to the job summary. If you reconstructed this order without looking, you've internalized lessons 4 through 7's complete progression: cheap before expensive, verification before networking, networking before plan, plan before publishing.
Exercise 3 — Decide what would happen if Module 6's guardrail already existed. Looking ahead to Module 6 (which you're going to build later in this guide): that module adds a guardrail that fails the job if the plan tries to destroy the Shipments table. If that guardrail were already active in this ci.yml, would you expect this lesson's change (adding a tag to the bucket) to trigger it? Justify it.
See solution
No, it wouldn't trigger it. Module 6's guardrail specifically checks whether the JSON plan contains a destroy action against the Shipments table — this change doesn't touch the Shipments table at all (it touches the andes-cargo-shipment-docs bucket, a completely different resource), and either way, no action in this project is a destruction: it's 12 creations, zero changes, zero destructions, against state that doesn't have anything applied yet. The guardrail is designed to detect a very specific, dangerous pattern, not to block any HCL change in general.
Summary and next step
In this project you ran the complete ci.yml, end to end, against a real change: a compliance tag added to Andes Cargo's manifest bucket, exactly the intent Pull Request #42's simulation announced since Module 2. The nine steps ran in order —fmt → init → validate → LocalStack connection (with an honest, non-blocking failure) → plan (12 resources, no need for LocalStack running) → published summary— confirming that half of the pipeline's CI works end to end, not just piece by piece.
Before moving on you should be able to: reconstruct the complete ci.yml from memory, with the nine steps in the correct order; explain why this module, at no point, touched real infrastructure; and say precisely what review evidence would exist, today, for someone to approve merging PR #42.
With this, Module 3 is closed. You have half of the pipeline's CI complete, tested end to end, with a real plan ready to be reviewed.
Next module: the secrets handling that makes building the other half possible — GitHub Secrets, why a long-lived credential in a repository is the antipattern this guide's own market audit flags as the competition's most-cited gap, and federated OIDC, shown in real YAML, as the modern alternative.
Resources
- nektosact.com — User Guide — complete reference for
act pull_request -e, used end to end in this project. - HashiCorp Developer — Automate Terraform with GitHub Actions — the complete pattern this module implements, cited in lesson 2.
- Terraform Docs — the
mergefunction — the function used in this project's HCL change. terraform-and-iac-guide(NIEVA) — the completeandes-cargo-infra/project, automated without being rewritten, except for this single line of tags.