Module 8: Capstone The Andes Cargo Security Gate
3. Hands-on: chaining the gate into `ci.yml`
Description
This lesson turns lesson 2's diagram into real YAML, and runs it for real, twice, with act pull_request against a real Docker runner. The first run confirms the three jobs exist and are correctly chained; the rest of the module (lessons 4 and 5) is going to reuse this exact same ci.yml against two different changes — one that passes, one that doesn't. Everything you see here ran to write this lesson, with the same runner image (catthehacker/ubuntu:act-latest) cicd-and-gitops-on-aws-guide and this guide's Modules 5 and 6 already used.
Isolation note. As in Module 5, lesson 7, and Module 6, lesson 8, this extended ci.yml runs on a new, disposable lab (its own git init), not on the andes-cargo-infra/ you've been accumulating since Module 1 — act running against Docker touches .git/ in ways you don't want mixed with your main project.
Connection to the module
This guide's lessons 4 through 6 already tested conftest, Trivy, and cosign in isolation, each by hand, in your terminal. This lesson doesn't repeat any of those tests — it takes the three already-verified commands and puts each one inside its own ci.yml job, with needs: declaring the exact chain lesson 2 drew.
Analogy: installing the airport's checkpoints, one by one, and testing the complete chain
Lesson 2 was the blueprint. This is the construction: install the first checkpoint, test it works alone; install the second, test the first feeds it correctly; install the third, test the complete chain end to end. No real airport tests its three checkpoints for the first time on opening day — it tests them, together, before the first passenger arrives. This lesson is that acceptance test.
Step 1 — The lab, with M1-M7's inherited pieces
mkdir andes-cargo-infra && cd andes-cargo-infra
git init -b main
Copy the complete HCL, hardened end to end by Modules 1 through 7 (the root files, modules/, lambda/, policy/, .trivyignore), and Module 6's three supply-chain artifacts (sbom.cyclonedx.json, cosign.pub, manifest.sig — cosign.key stays out of the repository, gitignored since Module 6, lesson 5):
cat > .gitignore <<'EOF'
cosign.key
tfplan
tfplan.json
.terraform/
EOF
cat > .actrc <<'EOF'
-P ubuntu-latest=catthehacker/ubuntu:act-latest
EOF
git add -A && git commit -m "Bootstrap the extended security gate: policy-check + iac-scan + verify-artifact chained in ci.yml"
Step 2 — ci.yml: three new jobs, each with its needs:
This is the ci.yml inherited from cicd-and-gitops-on-aws-guide (the original terraform-checks job, unchanged) with three new jobs added alongside. Notice the Trivy step: it moves from inside terraform-checks (where Module 5, lesson 7, had put it) into its own job, iac-scan — exactly the change lesson 2 justified.
name: ci
on:
pull_request:
branches: [main]
jobs:
policy-check:
runs-on: ubuntu-latest
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 init
run: terraform init -input=false
- name: Terraform plan
run: terraform plan -out=tfplan -input=false
- name: Convert plan to JSON
run: terraform show -json tfplan > tfplan.json
- name: Install conftest
run: |
curl -sL -o conftest.tar.gz \
https://github.com/open-policy-agent/conftest/releases/download/v0.69.0/conftest_0.69.0_Linux_x86_64.tar.gz
tar -xzf conftest.tar.gz conftest
sudo mv conftest /usr/local/bin/
- name: Evaluate the policy library against the plan
run: conftest test tfplan.json -p policy/
iac-scan:
needs: policy-check
runs-on: ubuntu-latest
steps:
- name: Check out andes-cargo-infra
uses: actions/checkout@v4
- name: Install Trivy
run: |
curl -sfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh | sh -s -- -b /usr/local/bin v0.74.0
- name: IaC security scan (Trivy)
run: trivy config --exit-code 1 --severity CRITICAL,HIGH .
verify-artifact:
needs: iac-scan
runs-on: ubuntu-latest
steps:
- name: Check out andes-cargo-infra
uses: actions/checkout@v4
- name: Install envsubst (required by the cosign installer, missing on act's runner image)
run: apt-get update -qq && apt-get install -y -qq gettext-base
- name: Install cosign
uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6
- name: Verify function.zip against manifest.sig
run: |
cosign verify-blob \
--key cosign.pub \
--bundle manifest.sig \
--insecure-ignore-tlog=true \
lambda/function.zip
Three decisions, none arbitrary:
policy-checkrunsterraform init/planfrom scratch, inside the same job. Unliketerraform-checks(which also runs them, for its own purpose),policy-checkneeds its own copy of theplanbecauseconftestcan't evaluate anything without the JSONterraform show -jsonproduces. It's duplicated work in appearance — two jobs runningterraform init— but it's the correct price for each job being independent and able to run on a different runner, sharing no state with each other (the alternative, passing thetfplanfrom one job to another withactions/upload-artifact/download-artifact, is exactly the patternci.yml/apply.ymlalready use between different workflows — within the same workflow, each job in this gate deliberately stays self-sufficient).sudo mv conftest /usr/local/bin/, a new detail in this module. Module 4, lesson 3, installedconftestin the student's working directory, where it was already on that terminal session's effectivePATH. Inside a GitHub Actions runner, thePATHdoesn't include the working directory by default — moving it to/usr/local/bin/(which is on thePATH) is the step that makes the next step,conftest test, find the binary without needing a relative path.- The
Install envsubststep is identical, line by line, to the one Module 6, lesson 8, already documented forapply.yml. It's the same real incompatibility betweenact'smediumimage andsigstore/cosign-installer— it repeats here becauseverify-artifactis, literally, the same job that module wrote, now also inci.yml.
Step 3 — First run: the three jobs, chained, all green
act pull_request -e .github/act-events/pr-event.json
What to expect (literal — run to write this lesson, with real Docker and the catthehacker/ubuntu:act-latest image; trimmed to the lines showing progress and result, filtering the repeated docker pull/docker exec noise act prints on every step):
[ci/policy-check] ⭐ Run Main Terraform init
[ci/policy-check] ✅ Success - Main Terraform init [16.926710125s]
[ci/policy-check] ⭐ Run Main Terraform plan
[ci/policy-check] ✅ Success - Main Terraform plan [5.83613625s]
[ci/policy-check] ⭐ Run Main Convert plan to JSON
[ci/policy-check] ✅ Success - Main Convert plan to JSON [2.367656084s]
[ci/policy-check] ⭐ Run Main Install conftest
[ci/policy-check] ✅ Success - Main Install conftest [2.035634042s]
[ci/policy-check] ⭐ Run Main Evaluate the policy library against the plan
[ci/policy-check] |
[ci/policy-check] | 4 tests, 4 passed, 0 warnings, 0 failures, 0 exceptions
[ci/policy-check] ✅ Success - Main Evaluate the policy library against the plan [267.45375ms]
[ci/policy-check] 🏁 Job succeeded
[ci/iac-scan ] ⭐ Run Main Install Trivy
[ci/iac-scan ] ✅ Success - Main Install Trivy [4.488681208s]
[ci/iac-scan ] ⭐ Run Main IaC security scan (Trivy)
[ci/iac-scan ] | Report Summary
[ci/iac-scan ] | ┌───────────────────────────┬───────────┬───────────────────┐
[ci/iac-scan ] | │ Target │ Type │ Misconfigurations │
[ci/iac-scan ] | ├───────────────────────────┼───────────┼───────────────────┤
[ci/iac-scan ] | │ . │ terraform │ 0 │
[ci/iac-scan ] | ├───────────────────────────┼───────────┼───────────────────┤
[ci/iac-scan ] | │ dynamodb.tf │ terraform │ 0 │
[ci/iac-scan ] | ├───────────────────────────┼───────────┼───────────────────┤
[ci/iac-scan ] | │ lambda.tf │ terraform │ 0 │
[ci/iac-scan ] | ├───────────────────────────┼───────────┼───────────────────┤
[ci/iac-scan ] | │ modules/s3-bucket/main.tf │ terraform │ 0 │
[ci/iac-scan ] | ├───────────────────────────┼───────────┼───────────────────┤
[ci/iac-scan ] | │ secrets.tf │ terraform │ 0 │
[ci/iac-scan ] | └───────────────────────────┴───────────┴───────────────────┘
[ci/iac-scan ] ✅ Success - Main IaC security scan (Trivy) [1.837377541s]
[ci/iac-scan ] 🏁 Job succeeded
[ci/verify-artifact] ⭐ Run Main Install cosign
[ci/verify-artifact] ✅ Success - Main Install cosign [4.038418125s]
[ci/verify-artifact] ⭐ Run Main Verify function.zip against manifest.sig
[ci/verify-artifact] | WARNING: Skipping tlog verification is an insecure practice that lacks transparency and auditability verification for the blob.
[ci/verify-artifact] | Verified OK
[ci/verify-artifact] ✅ Success - Main Verify function.zip against manifest.sig [87.011458ms]
[ci/verify-artifact] 🏁 Job succeeded
Three 🏁 Job succeeded, in the chain's exact order. act respected needs: with no need to be explicitly told: it started policy-check first (no declared dependency), waited for its success, started iac-scan, waited for its own, and only then started verify-artifact. The same 4 tests, 4 passed you already saw running conftest by hand in Module 4, the same Misconfigurations: 0 across the five files you already saw with Trivy in Module 5 (after that lesson's .trivyignore was calibrated), the same Verified OK from Module 6 — all three, now, running inside a real pipeline, not in your terminal.
A real detail worth documenting: tfplan.json confuses Trivy if it's left in the directory
While preparing this lesson, running trivy config . outside a CI job, directly against andes-cargo-infra/, with tfplan.json still present in the directory from an earlier terraform plan, produced this:
ERROR [terraform parser] Error parsing file module="root" file_path="main.tf" cause="<nil>" err="main.tf:137,7-8: Invalid expression..."
A confusing error — it mentions main.tf, a file that doesn't even exist in this project — because Trivy, when scanning a directory, tries to automatically detect a Terraform plan snapshot alongside the .tf files, and tfplan.json matches that pattern closely enough for Trivy to try parsing it as if it were HCL. The final result (Misconfigurations: 0 on the real files) doesn't change, but the error message is pure noise, with no relation to a real finding. Inside iac-scan, this problem never shows up — the job does a clean checkout on every run, with no leftover tfplan.json from an earlier terraform plan —, but it's worth knowing about if you ever run trivy config . by hand, in the same directory where you already ran terraform plan -out=tfplan.
Common mistakes
Installing conftest/Trivy/cosign into the runner's working directory, without moving them to a PATH directory. What happens: someone copies the corresponding lesson's install command (Module 4, 5, or 6) as-is, without the extra sudo mv .../usr/local/bin/ a CI runner needs. How to spot it: the install step finishes successfully, but the next step (conftest test, for example) fails with conftest: command not found. How to fix it: on your local terminal, the working directory is usually on the session's effective PATH; inside an Actions runner, it isn't by default — always move the downloaded binary to /usr/local/bin/ (or add the directory to PATH with echo "$dir" >> $GITHUB_PATH) before using it in a later step.
Confusing needs: policy-check (a single name) with needs: [policy-check] (a one-element list). What happens: someone, reviewing this ci.yml's YAML syntax, wonders whether needs: iac-scan (no brackets) works the same as needs: [iac-scan]. How to spot it: both forms are valid and equivalent in the GitHub Actions specification — it isn't an error, but it can raise unnecessary doubts when reading the file. How to fix it: use the bracketless form when depending on a single job (as in this ci.yml), and the list form only when depending on more than one — it's a readability convention, not a functional difference, but keeping it consistent helps anyone reading the file tell, at a glance, a simple dependency from a real sync point.
Running act pull_request without the -e flag and being surprised by an empty event. What happens: someone runs act pull_request without specifying .github/act-events/pr-event.json, and act synthesizes a minimal Pull Request event, without the fields a real workflow might expect (PR number, base branch, etc.). How to spot it: if your workflow doesn't use any of those fields — like this lesson's ci.yml, which doesn't need them — the result doesn't visibly change; but if some future job did need them, it would fail confusingly. How to fix it: always pass -e with an explicit event, even if the current ci.yml doesn't need it — it's the same discipline cicd-and-gitops-on-aws-guide already established, and it avoids surprises as the workflow grows.
Exercises
Exercise 1 — Run act pull_request -j iac-scan in isolation, and explain why it works without policy-check having run first. Using the -j flag you already saw in lesson 2, run only the iac-scan job. Why doesn't it fail, despite needs: policy-check being declared?
See solution
act -j <job> runs the requested job in isolation, ignoring any declared needs: — it's a debugging mode, not a faithful simulation of the complete pipeline (the same behavior lesson 2's Exercise 3 already predicted). iac-scan doesn't depend, at execution time, on any file or state policy-check produces — unlike verify-artifact inside the original apply.yml, which did need the tfplan downloaded from an earlier job — so it runs with no problem in isolation. This is useful for quickly debugging a specific job, but it does not confirm needs: is correctly chained — for that, the only valid test is running the complete workflow, without -j, like this lesson's Step 3.
Exercise 2 — Calculate the complete gate's approximate total time, adding up this lesson's three real "evaluation" times (not counting installation). Using the bracketed times from Step 3's output (Evaluate the policy library..., IaC security scan (Trivy), Verify function.zip...), what's the combined time of the three controls, not counting the time to install each tool?
See solution
267.45375ms (policy-check) + 1.837377541s (iac-scan) + 87.011458ms (verify-artifact) ≈ 2.19 seconds of real evaluation, across all three controls combined — a tiny fraction next to the 16.9 seconds terraform init alone took in the same job. The point of the exercise is noticing that this security gate's real cost, once the tools are installed, is nearly negligible — most of a real pipeline's time goes into installing dependencies and initializing Terraform, not evaluating the security policies themselves. It's a strong argument, with real numbers, against the common objection that "a security gate slows down the pipeline."
Exercise 3 — Design a hypothetical fourth job, sbom-check, that validates sbom.cyclonedx.json still exists and isn't empty, and decide where to chain it. Without writing the complete YAML, describe in prose: which job would it depend on (needs:), and why does that specific spot in the chain make sense under lesson 2's increasing-cost criterion?
See solution
A reasonable answer: sbom-check doesn't depend on any Terraform plan or any costly external tool — it only needs to confirm a file exists and has content (test -s sbom.cyclonedx.json, for example) —, so, following the increasing-cost criterion, it should run first, even before policy-check, or in parallel with it (with no needs: at all, since it doesn't depend on any of the other three's results). Chaining it at the end, after verify-artifact, would be the opposite design mistake lesson 1 already warned about with Trivy and terraform init: spending the most expensive controls' time before an almost-instant check that could have failed fast, first.
Summary and next step
In this lesson you built ci.yml with three new jobs — policy-check, iac-scan, verify-artifact —, each with needs: pointing at the previous one, and ran them end to end with act pull_request against real Docker: all three finished with 🏁 Job succeeded, in the exact order the chain declares. You confirmed act respects needs: with no extra flag needed, and documented a real detail — tfplan.json confusing Trivy's automatic detector — that doesn't affect the gate's result but is worth knowing.
Lesson 4 reuses this exact ci.yml, with no change, against a real, small business change — proof that the gate lets through what should pass, not just stops what should stop.
Resources
- GitHub Docs —
jobs.<job_id>.needs— official reference, already cited in lesson 2. - nektosact.com — User Guide — complete
actreference, including the-jflag used in Exercise 1. - This course, Module 4, lesson 3; Module 5, lesson 3; Module 6, lesson 5 — the origin of each install command this lesson adapts to a CI runner.
cicd-and-gitops-on-aws-guide, Module 3, lesson 8 — the original nine-stepci.yml, this file's starting point.