Module 7: Gitops Beyond Terraform
7. Hands-on: a minimal application pipeline, just for contrast
Description
This is the only lesson in all of Module 7 where something really runs. You're going to create a disposable repository —outside andes-cargo-infra/, with no link to Andes Cargo at all— with a trivial Python script, and run a workflow with exactly three steps: checkout, lint, test, with act, the same engine you used throughout this guide. The goal isn't to teach you application CI/CD in depth —that's, precisely, cicd-python-backend-guide's and testing-in-cicd-guide's territory—, it's for lesson 6's contrast table to stop being a prose claim and become something you ran with your own eyes: no terraform, no LocalStack, no host.docker.internal — just pip install and a Python interpreter.
Connection to the module
This lesson closes the arc lesson 6 opened: there you systematized the difference in a table; here you live it. Notice a detail worth flagging from the start: you're going to run act push, the exact same command you used for ci.yml and apply.yml in Modules 3 and 5 — the tool doesn't change, what runs inside it does. Lesson 8, this module's project, doesn't return to this pipeline — it uses what you learned here and in lessons 2-6 as the basis for a document, not more code.
The project: one repository, three steps, with no link to Andes Cargo
Create a new folder, outside andes-cargo-infra/ and outside any earlier lab in this guide:
mkdir app-pipeline-lab && cd app-pipeline-lab
git init
mkdir -p .github/workflows
.actrc — the same line you already used in every lab in this guide:
-P ubuntu-latest=catthehacker/ubuntu:act-latest
tracking_code.py — this lesson's trivial script, deliberately simple: a single function that formats a shipment ID as a tracking code:
def format_tracking_code(shipment_id: int) -> str:
"""Format a shipment id as an Andes Cargo tracking code, e.g. 4471 -> 'AC-04471'."""
return f"AC-{shipment_id:05d}"
test_tracking_code.py — two real unit tests, with assert — exactly the kind of test lesson 6 said terraform plan never was:
from tracking_code import format_tracking_code
def test_format_tracking_code_pads_with_zeros():
assert format_tracking_code(4471) == "AC-04471"
def test_format_tracking_code_handles_large_ids():
assert format_tracking_code(123456) == "AC-123456"
.github/workflows/app-ci.yml — the complete pipeline, exactly three named steps, not one more:
name: app-ci
on:
push:
branches: [main]
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: checkout
uses: actions/checkout@v4
- name: lint
run: |
pip install --break-system-packages ruff
ruff check .
- name: test
run: |
pip install --break-system-packages pytest
pytest -v
Compare this, even from memory, against the nine-step ci.yml that closed Module 3 (terraform fmt, init, validate, installing awslocal, confirming the network to LocalStack, installing tflocal, plan, publishing the result). That pipeline needed an external tool (Terraform), a network connection to a simulated service (LocalStack via host.docker.internal), and a way to show a calculation for human review. This pipeline needs none of that — pip install brings the tool, and a test's result is a binary PASSED/FAILED, not something someone has to read and interpret like a plan.
--break-system-packages in every pip install is a real detail of this runner image, not a whim of this lesson: Ubuntu 24.04 (catthehacker/ubuntu:act-latest's base) protects its system Python installation per PEP 668, and without that flag, pip install fails — you're going to see it fail on purpose in this lesson's mistakes section, before adding it.
Confirm act detects the workflow:
git add -A
git -c user.email="you@example.com" -c user.name="you" commit -m "trivial app pipeline for contrast"
act -l
What to expect (literal output, executed to write this lesson):
Stage Job ID Job name Workflow name Workflow file Events
0 build build app-ci app-ci.yml push
Step 1 — Running the complete pipeline with act push
act push
What to expect (literal output, executed to write this lesson — the Apple Silicon architecture warning you already know from Module 1 is omitted for brevity):
[app-ci/build] ⭐ Run Set up job
[app-ci/build] 🚀 Start image=catthehacker/ubuntu:act-latest
[app-ci/build] 🐳 docker pull image=catthehacker/ubuntu:act-latest platform= username= forcePull=true
[app-ci/build] using DockerAuthConfig authentication for docker pull
[app-ci/build] 🐳 docker create image=catthehacker/ubuntu:act-latest platform= entrypoint=["tail" "-f" "/dev/null"] cmd=[] network="host"
[app-ci/build] 🐳 docker run image=catthehacker/ubuntu:act-latest platform= entrypoint=["tail" "-f" "/dev/null"] cmd=[] network="host"
[app-ci/build] 🐳 docker exec cmd=[node --no-warnings -e console.log(process.execPath)] user= workdir=
[app-ci/build] ✅ Success - Set up job
[app-ci/build] ⭐ Run Main checkout
[app-ci/build] 🐳 docker cp src=/path/to/your/lab/. dst=/path/to/your/lab
[app-ci/build] ✅ Success - Main checkout [26.162792ms]
[app-ci/build] ⭐ Run Main lint
[app-ci/build] 🐳 docker exec cmd=[bash -e /var/run/act/workflow/1] user= workdir=
[app-ci/build] | Collecting ruff
[app-ci/build] | Downloading ruff-0.16.3-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl.metadata (26 kB)
[app-ci/build] | Downloading ruff-0.16.3-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl (10.7 MB)
[app-ci/build] | Installing collected packages: ruff
[app-ci/build] | Successfully installed ruff-0.16.3
[app-ci/build] | WARNING: Running pip as the 'root' user can result in broken permissions and conflicting behaviour with the system package manager. It is recommended to use a virtual environment instead: https://pip.pypa.io/warnings/venv
[app-ci/build] | All checks passed!
[app-ci/build] ✅ Success - Main lint [1.943328375s]
[app-ci/build] ⭐ Run Main test
[app-ci/build] 🐳 docker exec cmd=[bash -e /var/run/act/workflow/2] user= workdir=
[app-ci/build] | Collecting pytest
[app-ci/build] | Downloading pytest-9.1.1-py3-none-any.whl.metadata (7.6 kB)
[app-ci/build] | Collecting iniconfig>=1.0.1 (from pytest)
[app-ci/build] | Downloading iniconfig-2.3.0-py3-none-any.whl.metadata (2.5 kB)
[app-ci/build] | Requirement already satisfied: packaging>=22 in /usr/lib/python3/dist-packages (from pytest) (24.0)
[app-ci/build] | Collecting pluggy<2,>=1.5 (from pytest)
[app-ci/build] | Downloading pluggy-1.6.0-py3-none-any.whl.metadata (4.8 kB)
[app-ci/build] | Collecting pygments>=2.7.2 (from pytest)
[app-ci/build] | Downloading pygments-2.20.0-py3-none-any.whl.metadata (2.5 kB)
[app-ci/build] | Installing collected packages: pygments, pluggy, iniconfig, pytest
[app-ci/build] | Successfully installed iniconfig-2.3.0 pluggy-1.6.0 pygments-2.20.0 pytest-9.1.1
[app-ci/build] | WARNING: Running pip as the 'root' user can result in broken permissions and conflicting behaviour with the system package manager. It is recommended to use a virtual environment instead: https://pip.pypa.io/warnings/venv
[app-ci/build] | ============================= test session starts ==============================
[app-ci/build] | platform linux -- Python 3.12.3, pytest-9.1.1, pluggy-1.6.0 -- /usr/bin/python3
[app-ci/build] | cachedir: .pytest_cache
[app-ci/build] | rootdir: /path/to/your/lab
[app-ci/build] | collecting ... collected 2 items
[app-ci/build] |
[app-ci/build] | test_tracking_code.py::test_format_tracking_code_pads_with_zeros PASSED [ 50%]
[app-ci/build] | test_tracking_code.py::test_format_tracking_code_handles_large_ids PASSED [100%]
[app-ci/build] |
[app-ci/build] | ============================== 2 passed in 0.00s ===============================
[app-ci/build] ✅ Success - Main test [2.326557959s]
[app-ci/build] ⭐ Run Complete job
[app-ci/build] Cleaning up container for job build
[app-ci/build] ✅ Success - Complete job
[app-ci/build] 🏁 Job succeeded
(The /path/to/your/lab path in docker cp and in rootdir: is your own app-pipeline-lab/'s on your machine — variable, just like you already saw with actions/checkout in Module 2; github.run_id stays fixed at 1 under act, even though this workflow doesn't print it explicitly. github.sha would vary based on your actual commit, always marked as variable in this guide, even though it isn't printed here either.)
Three things to notice, comparing this against any ci.yml or apply.yml run you already lived through:
- There's no
--container-options. No step in this workflow needs to talk to LocalStack —no AWS, nohost.docker.internal—, so the network flag that was mandatory since Module 2 isn't needed. It's the first time, in this entire guide, thatact pushruns without that flag. checkouttakes milliseconds, doesn't run any Terraform command. Thedocker cpis identical to what you already know from Module 2 —actions/checkoutstill copies your local folder instead of cloning from a remote GitHub—, but after that step,terraformnever shows up anywhere in this output.test's result is binary:PASSED/PASSED,2 passed. Compare this against aterraform plan's output, which never says "passed" or "failed" in that sense — it shows a list of changes for a human to decide on.pytestdecides on its own whether the code does what's expected, with nobody having to read a diff.
Step 2 — Reproducing two real errors, on purpose
Error 1 — Forgetting --break-system-packages
Remove the flag from app-ci.yml's two pip installs (leave it as it was, without that parameter) and run again:
act push
What to expect (literal output, reproduced for this lesson — the lint step is the first to use pip install, so it fails there):
[app-ci/build] ⭐ Run Main lint
[app-ci/build] 🐳 docker exec cmd=[bash -e /var/run/act/workflow/1] user= workdir=
[app-ci/build] | error: externally-managed-environment
[app-ci/build] |
[app-ci/build] | × This environment is externally managed
[app-ci/build] | ╰─> To install Python packages system-wide, try apt install
[app-ci/build] | python3-xyz, where xyz is the package you are trying to
[app-ci/build] | install.
[app-ci/build] |
[app-ci/build] | If you wish to install a non-Debian-packaged Python package,
[app-ci/build] | create a virtual environment using python3 -m venv path/to/venv.
[app-ci/build] | Then use path/to/venv/bin/python and path/to/venv/bin/pip. Make
[app-ci/build] | sure you have python3-full installed.
[app-ci/build] |
[app-ci/build] | note: If you believe this is a mistake, please contact your Python installation or OS distribution provider. You can override this, at the risk of breaking your Python installation or OS, by passing --break-system-packages.
[app-ci/build] | hint: See PEP 668 for the detailed specification.
[app-ci/build] ❌ Failure - Main lint [263.5285ms]
[app-ci/build] exitcode '1': failure
[app-ci/build] ⭐ Run Complete job
[app-ci/build] ✅ Success - Complete job
[app-ci/build] 🏁 Job failed
Error: Job 'build' failed
This isn't an error invented for the lesson — it's pip's real behavior on Ubuntu 24.04+ since it adopted PEP 668 (externally-managed-environment): the operating system's system Python installation is protected by default, so pip install can't accidentally break it by installing something that conflicts with packages the system itself manages via apt. The message even tells you, verbatim, the fix you already applied: passing --break-system-packages. Restore the flag in both pip installs before continuing.
Error 2 — An unused import, caught by the linter
Add a useless line at the top of tracking_code.py:
import os
def format_tracking_code(shipment_id: int) -> str:
"""Format a shipment id as an Andes Cargo tracking code, e.g. 4471 -> 'AC-04471'."""
return f"AC-{shipment_id:05d}"
Run again:
act push
What to expect (literal output, reproduced for this lesson):
[app-ci/build] ⭐ Run Main lint
[app-ci/build] 🐳 docker exec cmd=[bash -e /var/run/act/workflow/1] user= workdir=
[app-ci/build] | Collecting ruff
[app-ci/build] | Downloading ruff-0.16.3-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl.metadata (26 kB)
[app-ci/build] | Installing collected packages: ruff
[app-ci/build] | Successfully installed ruff-0.16.3
[app-ci/build] | WARNING: Running pip as the 'root' user can result in broken permissions and conflicting behaviour with the system package manager. It is recommended to use a virtual environment instead: https://pip.pypa.io/warnings/venv
[app-ci/build] | F401 [*] `os` imported but unused
[app-ci/build] | --> tracking_code.py:1:8
[app-ci/build] | |
[app-ci/build] | 1 | import os
[app-ci/build] | | ^^
[app-ci/build] | help: Remove unused import: `os`
[app-ci/build] | |
[app-ci/build] | - import os
[app-ci/build] | 1 |
[app-ci/build] | |
[app-ci/build] |
[app-ci/build] | Found 1 error.
[app-ci/build] | [*] 1 fixable with the `--fix` option.
[app-ci/build] ❌ Failure - Main lint [2.301302292s]
[app-ci/build] exitcode '1': failure
[app-ci/build] ⭐ Run Complete job
[app-ci/build] ✅ Success - Complete job
[app-ci/build] 🏁 Job failed
Error: Job 'build' failed
F401 is ruff's exact rule code for "unused import" —every ruff rule has a code like this, grouped by family (F is Pyflakes's inherited family)—, and notice this error's most important detail: ruff never got to run pytest at all. The job failed at the lint step, before test ever got a chance to run — the same "early cutoff" behavior you already saw in ci.yml, where a failed terraform fmt -check stops the job before attempting plan. Remove the import os line before continuing — you don't need it for anything else in this lesson.
The final contrast, confirmed with your own eyes
ci.yml (Module 3, infrastructure) | app-ci.yml (this lesson, application) | |
|---|---|---|
| Number of steps | Nine | Three |
| External tool to install | Terraform, awslocal, tflocal | ruff, pytest — installed with pip, no dedicated Action |
Needs --container-options? | Yes, always (talks to LocalStack) | No, never (touches no external service) |
| Result's shape | A text plan, for a human to read and decide on | PASSED/FAILED, a decision already made by the pipeline itself |
| What stops the job if something's wrong? | A failed terraform fmt -check or validate | A failed ruff check (as you just saw) |
Common mistakes
Assuming the PEP 668 error is an act problem, not a Python one (diagnosis-based). What happens: someone sees error: externally-managed-environment and suspects act's installation or the runner image. Why it happens: any error inside an act container feels, at first glance, like a tool problem. How to spot it: if the message includes the phrase externally-managed-environment and mentions PEP 668. How to fix it: it's standard pip behavior on modern Debian/Ubuntu, the same one you'd see running the exact same command outside act, in any recent Ubuntu 24.04+ container — it's not specific to this guide or to catthehacker/ubuntu:act-latest.
Confusing ruff's rule code (F401) with a line number (reading-based). What happens: someone reads F401 and interprets it as "line 401" instead of a specific rule's identifier. Why it happens: both are numbers, and ruff's output shows them close to each other. How to spot it: if you look for line 401 in a 5-line file. How to fix it: the real line is on the --> tracking_code.py:1:8 line (file, line 1, column 8) — F401 is, instead, Pyflakes's specific rule code for "unused import," useful for looking up that exact rule in ruff's documentation or for silencing it in a specific spot if it were ever needed (# noqa: F401, outside this lesson's scope).
Thinking this three-step pipeline "doesn't count" as real CI/CD for being so simple (expectation-based, crosses over with lesson 6). What happens: someone, used to the nine-step ci.yml, concludes app-ci.yml is "too basic" to be a valid application CI/CD example. Why it happens: ci.yml's complexity (Terraform, LocalStack, tflocal) trained the expectation that a "real" pipeline needs a lot of pieces. How to spot it: if your reaction to this lesson is "this is too simple to be real CI/CD." How to fix it: checkout → lint → test is, literally, the minimal core of any real application CI pipeline — a production project would add more steps (building an image, security scanning, deploy), but those three are the heart almost all of them share. This example's simplicity is deliberate, so the contrast with ci.yml is sharp — not a "toy" version of the concept.
Exercises
Exercise 1 — Count the steps from memory. Without looking at this lesson, how many steps does app-ci.yml have, and how many did Module 3's ci.yml have? What three tools explain the difference?
See solution
app-ci.yml: three steps (checkout, lint, test). ci.yml: nine steps. The difference is mainly explained by three tools ci.yml needs and app-ci.yml doesn't: Terraform (install, format, validate, plan), awslocal/tflocal (talk to LocalStack instead of real AWS), and publishing the plan as review evidence ($GITHUB_STEP_SUMMARY) — none of the three have an equivalent in an application pipeline as simple as this lesson's.
Exercise 2 — Explain why --container-options wasn't needed here. In one or two sentences, explain to a colleague why act push ran without the --container-options "--add-host=host.docker.internal:host-gateway" flag in this lesson, when it was mandatory from Module 2 onward.
See solution
That flag exists, specifically, so the ephemeral container act creates for a job can resolve the name host.docker.internal and thus talk to LocalStack, which runs on the host, outside that container. app-ci.yml has no step that needs to talk to LocalStack or any other service outside the container itself —pip install downloads straight from the internet, pytest runs entirely in memory inside the same container—, so there's no reason at all for the job to need to resolve that hostname.
Exercise 3 — Diagnose a failure without seeing the complete output. A colleague tells you: "I ran act push on my own version of app-ci.yml and the job failed, but I still haven't seen the word pytest in the output." Which step failed, for certain, and why can you claim that without seeing the exact error message?
See solution
It failed at the lint step, for certain. This workflow's three steps run in sequential order (checkout → lint → test), and if either of the first two fails, act (just like real GitHub Actions) stops the job immediately — it never gets to run the next step. If the word pytest never shows up in the output, that means the job never reached the third step, so the failure has to have happened in checkout or in lint — and since checkout almost never fails except for a serious Git problem, lint is, with high confidence, the real candidate. It's exactly the same "early cutoff" reasoning you already used with ci.yml when fmt -check failed before reaching plan.
Summary and next step
In this lesson you ran, for real with act, this entire module's only application pipeline: three steps —checkout, lint, test— on a trivial Python script, with no link to Andes Cargo, no Terraform, no LocalStack. You saw the complete successful output, and reproduced two real errors on purpose: PEP 668's externally-managed-environment when forgetting --break-system-packages, and a ruff F401 when leaving an unused import. You confirmed, with your own eyes and not just in prose, the complete contrast table between ci.yml (nine steps, Terraform, LocalStack, a plan for human review) and app-ci.yml (three steps, pip install, a binary result decided by the machine itself).
Before moving on you should be able to: write from memory a minimal application CI pipeline's three steps; explain why this pipeline didn't need --container-options; and read a ruff error (rule code, file, line) without confusing it with a Docker or act problem.
Lesson 8, the project that closes this module, doesn't return to this pipeline or write more YAML — it turns everything you learned across the module's eight lessons into a real document: the ADR where Andes Cargo justifies, in writing, its tool choice.
Resources
- nektosact.com — User Guide — official
actdocumentation, the same tool used throughout this guide, now on an application pipeline. - GitHub — actions/checkout — the Action used in this lesson's
checkoutstep, already familiar since Module 2. - Ruff — Documentation — official documentation for the linter used in the
lintstep, including the rule reference for rules likeF401. - pytest — Documentation — official documentation for the test framework used in this lesson's
teststep. cicd-python-backend-guide(NIEVA) — where an application pipeline like this one gets built in depth, with build, more test types, and a real artifact deployment.