Lab 04 — Wire Up Your First GitHub Actions Pipeline
Lesson 04 · Cloud Infrastructure & DevOps
Goal: end the lab with ci.yml committed on a feature branch, the pipeline running on push, and a green check on a pull request.
Prerequisites
- Lesson 03 lab done:
docker compose upbrings the API and worker up locally without errors - A GitHub account and your repo fork pushed to GitHub (you need an Actions-enabled repository)
- Git configured locally (
git config --global user.nameanduser.emailset)
Step 1 — Create the workflow file
The complete ci.yml is already committed in the course repository at cloud-devops/sample-app/.github/workflows/ci.yml. Walk through each section to understand it before running it.
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
# Cancel superseded runs on the same branch (saves minutes).
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.11"
cache: pip
cache-dependency-path: |
cloud-devops/sample-app/api/requirements.txt
cloud-devops/sample-app/worker/requirements.txt
- name: Install ruff
run: pip install ruff==0.6.9
- name: Lint
run: ruff check cloud-devops/sample-app/api cloud-devops/sample-app/worker
- name: Format check
run: ruff format --check cloud-devops/sample-app/api cloud-devops/sample-app/worker
test:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
python-version: ["3.11", "3.12"]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
cache: pip
cache-dependency-path: |
cloud-devops/sample-app/api/requirements.txt
cloud-devops/sample-app/worker/requirements.txt
- name: Install API deps
run: pip install -r cloud-devops/sample-app/api/requirements.txt
- name: Install worker deps
run: pip install -r cloud-devops/sample-app/worker/requirements.txt
- name: Run API tests
run: pytest cloud-devops/sample-app/api/tests -v
- name: Run worker tests
run: pytest cloud-devops/sample-app/worker/tests -v
build:
runs-on: ubuntu-latest
needs: [lint, test]
strategy:
matrix:
service: [api, worker]
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- name: Build ${{ matrix.service }} image
uses: docker/build-push-action@v5
with:
context: ./cloud-devops/sample-app/${{ matrix.service }}
push: false
tags: ${{ matrix.service }}:ci-${{ github.sha }}
cache-from: type=gha,scope=${{ matrix.service }}
cache-to: type=gha,mode=max,scope=${{ matrix.service }}
What each job does:
lint— installs ruff (pinned to 0.6.9) and checks both services for style issues and formatting. Fails fast on any ruff finding.test— installs deps and runs pytest on both services. Thefail-fast: falseon the matrix means both Python versions finish even if one fails, so you can see whether a failure is version-specific.build— builds both images with BuildKit and GHA layer cache. Only runs iflintandtestpass first (needs:). Thescopeparameter keeps the api and worker layer caches separate.
Step 2 — Test the workflow locally with act (optional)
nektos/act is a tool that runs GitHub Actions workflows locally against a Docker daemon — useful for iterating without pushing commits. Install on macOS:
brew install act
Run just the lint job:
act -j lint
One paragraph of caution: act emulates the runner environment but is not identical to GitHub’s hosted runners. Actions that rely on runner-specific metadata (e.g., github.sha resolution, OIDC token issuance) may behave differently. Use act for fast local iteration, but treat the real GitHub run as the source of truth.
Step 3 — Commit and push to a feature branch
git checkout -b ci/initial-pipeline
git add cloud-devops/sample-app/.github/workflows/ci.yml
git commit -m "ci: add initial pipeline"
git push -u origin ci/initial-pipeline
The push triggers the pull_request event against main once you open the PR in Step 5, but the push trigger only fires on commits directly to main. Right now you are on a feature branch, so only the PR trigger will fire when you open the PR.
Step 4 — Watch the run
Navigate to your repository on GitHub and click the Actions tab. The CI workflow appears. Click through to see the five jobs in parallel:
linttest (3.11)test (3.12)build (api)— starts only after lint and test succeedbuild (worker)— starts only after lint and test succeed
Expected timings: cold cache run takes approximately 3–5 minutes total. After the cache warms (second run), expect 1–2 minutes. You can see the cache hit/miss in the “Set up Python” step log.
Step 5 — Open a PR and observe the check
Open a pull request from ci/initial-pipeline → main on GitHub. The CI check appears at the bottom of the PR under “Checks.” At this point the check is informational — it does not block the merge button. That changes in Lesson 05, where branch protection makes these checks required. For now, confirm you can see the green checkmarks from all five jobs.
Step 6 — Intentionally break a test
Edit cloud-devops/sample-app/api/tests/test_app.py. Change one assertion to assert an incorrect value (e.g., change assert response.status_code == 200 to assert response.status_code == 999). Commit and push:
git add cloud-devops/sample-app/api/tests/test_app.py
git commit -m "test: intentional break"
git push
Watch the test matrix on GitHub — both test (3.11) and test (3.12) should turn red. The build jobs will be skipped because test failed (needs: is respected). Then revert the change:
git revert HEAD
git push
Step 7 — Verify the cache is working
On the second or third run after the first push, expand the Set up Python step in one of the test jobs. You should see a line like:
Cache hit: pip-Linux-x64-3.11-...
On the build (api) step, expand the Build api image log. In the BuildKit output, most layers should show CACHED rather than executing. The only layers that rebuild are those after the last changed layer (typically the COPY . . step if you edited source code).
What you should leave with
- A green CI pipeline running on every push and PR targeting
main - Understanding of how matrix and cache interact: two Python versions tested in parallel; deps cached across runs
- One tested red-green-revert cycle: broken test → red check → revert → green check
Troubleshooting
Common problems
“Lint fails on ruff format --check” → run ruff format cloud-devops/sample-app/api cloud-devops/sample-app/worker locally, commit the reformatted files, and push again. The CI format check enforces that files are already auto-formatted.
“Pip install fails with a cache key error” → the cache-dependency-path glob doesn’t match any file. Check that the paths in the YAML exactly match the locations of your requirements.txt files (they are relative to the repository root).
“Docker build times out” → the first run is 3–5 minutes and that is normal. If a run consistently exceeds 10 minutes, the Dockerfile is probably re-installing deps after every code change — review the layer order (copy requirements.txt and pip install before copying the rest of the source; see Lesson 02).
“Actions tab says no workflow runs” → confirm the YAML file is in .github/workflows/ci.yml (not workflows/ci.yml or any other path). GitHub only scans .github/workflows/.