Lab 13 — CI With GitHub Actions
Learner Orientation
Lab Metadata
| Item | Value |
|---|---|
| Difficulty | Intermediate |
| Estimated time | 75-120 minutes |
| VMs | 1 |
| Minimum VM RAM | 1024 MB |
| SSH ports | 2219 |
| Internet required | Yes |
Before You Start, You Should Be Able To
- Yeast installed on a Linux/KVM host
- Comfort opening a terminal and changing directories
- Ability to run
yeast up,yeast ssh <instance>, andyeast destroy - Basic comfort with
curl,systemctl, and reading command output - A GitHub account and
ghauthentication when the lab uses GitHub
Where Commands Run
- Run
yeastcommands from this lab folder on your laptop. - Run Linux service commands only after you SSH into the target VM.
- When a command says "from your laptop", leave the VM shell first with
exit. - When a browser URL uses
localhost, check whether the lab asked you to open an SSH tunnel first.
Expected Checkpoints
- After
yeast up,yeast statusshould show the expected VM or VMs as running. - After the main setup steps, the service, tool, or workflow introduced by the lab should respond to the verification commands.
- After
bash assets/validate.sh, the script should report all checks passed. - After
yeast destroy, the lab should be cleaned up before you start the next one.
Common Mistakes To Avoid
- Running a VM command on your laptop, or a laptop command inside the VM.
- Closing an SSH tunnel and then wondering why
localhost:<port>stopped working. - Skipping validation because the final page or command "looked fine".
- Forgetting to run
yeast destroybefore moving to the next lab.
The Story
So far everything you have built has been local: scripts run from your terminal, Ansible run from your laptop, Docker images built on a VM. This works for one person. The moment you have a team, you need automation that runs on every code change — automatically, consistently, without anyone having to remember to do it.
That is CI: Continuous Integration. Every time someone pushes code, a pipeline runs: tests, linting, building, scanning. If it passes, the code is good. If it fails, the developer knows immediately.
GitHub Actions is the CI system built into GitHub. You define pipelines in YAML files that live in your repository. GitHub runs them on every push, pull request, or on a schedule. No separate CI server to maintain.
This lab teaches you to write a real GitHub Actions workflow from scratch, understand every line of it, and connect it to the container work you did in Labs 10–12.
Before You Start — Understanding The Concepts
What Is Continuous Integration?
Continuous Integration is the practice of merging code changes frequently and automatically verifying each merge with a build and test run.
The problem it solves: in teams that do not practice CI, developers work in isolation for days or weeks. When they merge, the integration is painful — everyone's changes conflict, tests fail in unexpected ways, bugs surface that nobody can trace to a specific commit.
With CI, every commit is integrated immediately and automatically verified. Problems surface within minutes, while the context is fresh.
What Is A Pipeline?
A pipeline is a series of automated steps that code goes through after a change. A typical pipeline:
- Checkout — pull the code from the repo
- Lint — check for style and syntax errors
- Test — run unit and integration tests
- Build — compile the binary or build the container image
- Scan — check for security vulnerabilities
- Deploy — push to staging or production (CD: Continuous Deployment)
Each step runs only if the previous one passed. A failure in "Test" stops the pipeline before "Build."
What Is GitHub Actions?
GitHub Actions is GitHub's built-in CI/CD system. Workflows are YAML files in .github/workflows/. When a trigger fires (a push, a PR, a schedule), GitHub spins up a runner — a cloud VM — and executes the workflow.
Runners come in different flavors:
- ubuntu-latest — Ubuntu Linux (most common)
- windows-latest — Windows
- macos-latest — macOS
You can also run self-hosted runners — your own VMs that execute workflows (Lab 14).
What Is A Workflow?
A GitHub Actions workflow is a YAML file defining:
- Triggers (on:) — what events cause the workflow to run
- Jobs — groups of steps that run on the same runner
- Steps — individual commands or actions within a job
on:
push:
branches: [main]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: echo "Hello from CI"
What Is An Action?
An action is a reusable piece of workflow logic, published to the GitHub Marketplace. actions/checkout@v4 checks out your repository code. docker/build-push-action@v5 builds and pushes a Docker image.
You reference actions with uses:. You pass inputs with with:. Actions hide complexity — docker/build-push-action handles layer caching, multi-platform builds, registry login — things that would take 50 lines of shell script.
What Are Secrets In GitHub Actions?
GitHub repositories have an encrypted secrets store. You add secrets in Settings → Secrets → Actions. Workflows reference them as ${{ secrets.MY_SECRET }}.
Secrets are: - Encrypted at rest - Never printed in logs (GitHub masks them) - Only accessible to workflows in the same repository
You use secrets for: Docker Hub credentials, cloud API keys, deployment tokens.
What Is A Job Matrix?
A matrix lets you run the same job with different parameters. Test against Python 3.10, 3.11, and 3.12 simultaneously without writing three separate jobs:
GitHub runs all matrix combinations in parallel.
What You Are Building
A GitHub repository with a CI workflow that: 1. Runs on every push and pull request 2. Lints the code 3. Runs tests 4. Builds the Docker image from Lab 12 5. Scans it with Trivy 6. Reports pass/fail status on the commit
This does not require the Yeast VM — it is entirely on GitHub. The VM is just for tooling (gh CLI).
Prerequisites
- A GitHub account
- The
ghCLI installed (the VM provisions it, or install on your laptop)
Part 1 — Create The Repository
Create a new GitHub repository for this lab:
gh auth login
# Follow prompts to authenticate with GitHub
gh repo create devops-bootcamp-lab13 --public --clone
cd devops-bootcamp-lab13
Or create it on github.com manually and clone it.
Part 2 — The Application Code
Create a small Python app with a test:
mkdir -p src tests
cat > src/app.py << 'EOF'
#!/usr/bin/env python3
import json
import os
from http.server import HTTPServer, BaseHTTPRequestHandler
VERSION = os.getenv("APP_VERSION", "dev")
def create_handler():
class Handler(BaseHTTPRequestHandler):
def do_GET(self):
if self.path == "/healthz":
body = json.dumps({"status": "ok", "version": VERSION}).encode()
self.send_response(200)
elif self.path == "/":
body = json.dumps({"message": "Hello", "version": VERSION}).encode()
self.send_response(200)
else:
body = json.dumps({"error": "not found"}).encode()
self.send_response(404)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def log_message(self, *a):
pass
return Handler
if __name__ == "__main__":
HTTPServer(("0.0.0.0", 8000), create_handler()).serve_forever()
EOF
cat > tests/test_app.py << 'EOF'
import sys
import os
import json
import unittest
from unittest.mock import patch, MagicMock
from io import BytesIO
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'src'))
from app import create_handler
class TestApp(unittest.TestCase):
def _make_request(self, path):
handler_class = create_handler()
mock_request = MagicMock()
mock_request.makefile.return_value = BytesIO(b"")
handler = handler_class.__new__(handler_class)
handler.path = path
handler.headers = {}
responses = []
def send_response(code):
responses.append(code)
def send_header(*a): pass
def end_headers(): pass
buf = BytesIO()
handler.wfile = buf
handler.send_response = send_response
handler.send_header = send_header
handler.end_headers = end_headers
handler.do_GET()
return responses[0], buf.getvalue()
def test_healthz_returns_200(self):
code, _ = self._make_request("/healthz")
self.assertEqual(code, 200)
def test_root_returns_200(self):
code, body = self._make_request("/")
self.assertEqual(code, 200)
data = json.loads(body)
self.assertIn("message", data)
def test_unknown_path_returns_404(self):
code, _ = self._make_request("/doesnotexist")
self.assertEqual(code, 404)
if __name__ == "__main__":
unittest.main()
EOF
The Dockerfile (hardened version from Lab 12):
cat > Dockerfile << 'EOF'
FROM python:3.11-slim
RUN groupadd --gid 1000 appuser && \
useradd --uid 1000 --gid 1000 --no-create-home appuser
WORKDIR /app
COPY --chown=appuser:appuser src/ .
USER appuser
EXPOSE 8000
CMD ["python3", "app.py"]
EOF
Test it works locally before setting up CI:
Part 3 — The GitHub Actions Workflow
Create the workflow directory and file:
mkdir -p .github/workflows
cat > .github/workflows/ci.yml << 'EOF'
name: CI
on:
push:
branches: ["main", "feature/**"]
pull_request:
branches: ["main"]
jobs:
test:
name: Test
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.11"
- name: Run tests
run: python3 -m pytest tests/ -v
build-and-scan:
name: Build and Scan
runs-on: ubuntu-latest
needs: test
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build image
uses: docker/build-push-action@v5
with:
context: .
push: false
tags: myapp:${{ github.sha }}
load: true
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Scan with Trivy
uses: aquasecurity/trivy-action@master
with:
image-ref: myapp:${{ github.sha }}
format: table
exit-code: "0"
severity: CRITICAL,HIGH
ignore-unfixed: true
EOF
Reading The Workflow — Every Line Explained
on: — triggers
This workflow runs on:
- Any push to main or any branch matching feature/** (e.g., feature/auth, feature/new-api)
- Any pull request targeting main
** is a glob pattern: "any characters including /".
jobs: — units of work
Each job runs on its own runner (a fresh VM spun up by GitHub). Jobs run in parallel by default.
needs: test
The build-and-scan job only runs if the test job passes. If tests fail, there is no point building the image. This creates a dependency graph.
uses: actions/checkout@v4
Checks out your repository code onto the runner. Without this, the runner has an empty workspace. @v4 pins the action to version 4. Always pin actions to a version — @master would change behavior when the action authors push updates.
uses: actions/setup-python@v5
Installs Python on the runner with the specified version. Runners have Python pre-installed, but this action lets you pin the exact version and handles caching.
uses: docker/setup-buildx-action@v3
Sets up Docker Buildx — an extended Docker builder with support for multi-platform builds and build caching. Required for the docker/build-push-action.
uses: docker/build-push-action@v5
Builds the Docker image. Key inputs:
- context: . — the build context is the current directory
- push: false — build but do not push to a registry (no credentials configured yet)
- tags: myapp:${{ github.sha }} — tag with the exact git commit hash. ${{ github.sha }} is a built-in variable containing the full SHA.
- load: true — load the built image into the local Docker daemon so Trivy can scan it
- cache-from/cache-to: type=gha — use GitHub Actions cache for Docker layer caching. This makes rebuilds fast — unchanged layers are cached between runs.
uses: aquasecurity/trivy-action@master
Runs Trivy to scan the image.
- exit-code: "0" — do not fail the build even if vulnerabilities are found (for now — in production you set this to "1" to fail on CRITICAL findings)
- ignore-unfixed: true — only report vulnerabilities that have a fix available
${{ github.sha }}
GitHub Actions expressions use ${{ }} syntax. Built-in variables:
- github.sha — the full git commit SHA
- github.ref — the branch or tag ref
- github.actor — who triggered the run
- github.run_number — sequential run counter
- secrets.MY_SECRET — encrypted secret value
Part 4 — Push And Watch It Run
Open your browser and go to:
You will see the workflow running. Click on it to see the live log. Watch each step execute. When it finishes, you get a green checkmark (all passed) or red X (something failed).
Check the status from the terminal:
Part 5 — Making It Fail On Purpose
Break a test:
# Edit tests/test_app.py — change the assertion to expect the wrong status code
sed -i 's/self.assertEqual(code, 200)/self.assertEqual(code, 999)/' tests/test_app.py
git add tests/test_app.py
git commit -m "test: intentional failure"
git push origin main
Watch the Actions tab. The test job fails. The build-and-scan job does not run — needs: test prevents it.
The commit on GitHub shows a red X next to it. This is what CI should do: tell you immediately when something is broken.
Fix it:
sed -i 's/self.assertEqual(code, 999)/self.assertEqual(code, 200)/' tests/test_app.py
git add tests/test_app.py
git commit -m "fix: restore correct test assertion"
git push origin main
Green again.
Part 6 — Adding A Matrix
Test against multiple Python versions simultaneously:
jobs:
test:
name: Test (Python ${{ matrix.python-version }})
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.10", "3.11", "3.12"]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- run: python3 -m pytest tests/ -v
Edit .github/workflows/ci.yml to add the matrix. Push. GitHub now runs three test jobs in parallel, one per Python version. If the code breaks on 3.10 but not 3.11, you see it immediately.
Part 7 — Branch Protection
CI only has value if you enforce it. Go to your GitHub repo:
- Settings → Branches → Add rule
- Branch name pattern:
main - Check: "Require status checks to pass before merging"
- Add your workflow's job names as required checks
- Check: "Require branches to be up to date before merging"
Now no pull request can merge to main until CI passes. This is the enforcement layer that makes CI actually protect your codebase.
Validate Your Work
Note: this script checks your local .github/workflows/ci.yml and gh authentication. Most of the work is verified by actually watching your workflow run on GitHub.
Clean Up
Quick Recap
In Lab 13 — CI With GitHub Actions, you moved from explanation to a working lab environment, verified the result, and practiced the operational habit that matters most: do the work, prove it works, then clean it up.
Keep this pattern for every lab:
- Build the thing.
- Verify it from the right place.
- Read the logs or status when it fails.
- Run the validation script.
- Destroy the lab before moving on.
What You Learned
- What CI is and the problem it solves: automatic verification on every commit
- GitHub Actions structure:
on:,jobs:,steps:,uses:,run: - Triggers: push, pull_request, branch patterns
needs:for job dependency chains: stop before building if tests fail- Actions from the marketplace:
checkout,setup-python,build-push-action,trivy-action ${{ github.sha }}and other built-in expressions- Layer caching in CI:
cache-from: type=ghafor fast Docker builds - Matrix strategy: parallel testing across multiple versions
- Making CI fail on purpose — and seeing how it blocks bad code
- Branch protection: enforcing CI as a merge gate
What Is Next
Lab 14 — Self-Hosted CI Runner
GitHub-hosted runners work. But they have limitations: they are ephemeral, they have no access to private infrastructure, they cost credits for large builds. Lab 14 teaches you to register your own Yeast VM as a GitHub Actions runner, so your CI jobs execute on infrastructure you control.