GitHub Actions
This activity puts into practice the concepts from the CI/CD Pipelines lecture. Starting from a small Python web application, you will build a pipeline that follows the feedback loop the lecture describes: lint the code, run the tests across two Python versions, build a Docker image, smoke-test the running container, and push it to GitHub Container Registry. By the end, you will have a two-job pipeline with the same dependency pattern and quality gates you would see in a production repository.
What You Will Need
Section titled “What You Will Need”- A GitHub account with a public repository you will create for this activity
- Git configured with your GitHub username and email
- A text editor
- A terminal with
bashavailable
Bootstrap the Application
Section titled “Bootstrap the Application”Before writing any workflow YAML, you need something to build and test. You will create a minimal Python web service, a short test file, and a Dockerfile. All three live in the repository root.
-
Create a new public GitHub repository. Go to github.com/new, name it
cs312-ci-activity, set visibility to Public and click Create repository. -
Clone the repository:
Terminal window git clone https://github.com/YOUR_GITHUB_USERNAME/cs312-ci-activity.gitcd cs312-ci-activityReplace
YOUR_GITHUB_USERNAMEwith your actual handle. -
Create
app.py:from flask import Flask, jsonifyapp = Flask(__name__)@app.route("/")def index():return jsonify({"status": "ok", "version": "1.0.0"})if __name__ == "__main__":app.run(host="0.0.0.0", port=8080)This is a Flask web application with a single endpoint. When the container runs,
GET /returns a JSON object. It is intentionally small: the goal of this activity is the pipeline, not the application. -
Create
test_app.py:from app import appdef test_index():client = app.test_client()response = client.get("/")assert response.status_code == 200assert response.get_json()["status"] == "ok"app.test_client()is Flask’s built-in test client. It sends requests to the application without starting a real HTTP server, so the test runs with no ports open and no external dependencies. -
Create
requirements.txt:flask==3.1.0pytest==8.3.4flake8==7.1.0Pinning exact versions makes builds reproducible: every run, on every runner, installs the same libraries from the same source.
-
Create the
Dockerfile:FROM python:3.12-slimWORKDIR /appCOPY requirements.txt .RUN pip install --no-cache-dir -r requirements.txtCOPY app.py .EXPOSE 8080CMD ["python", "app.py"]The
COPY requirements.txtandRUN pip installsteps come beforeCOPY app.pydeliberately. Docker builds images layer by layer and caches each layer separately. When onlyapp.pychanges, Docker reuses the cached pip layer instead of re-installing every dependency. This is the same principle as pip caching in the Actions workflow you are about to write. -
Commit and push everything:
Terminal window git add app.py test_app.py requirements.txt Dockerfilegit commit -m "Add application source and Dockerfile"git push origin main
Your First Workflow: Lint and Test
Section titled “Your First Workflow: Lint and Test”The cheapest stage of the feedback loop is checking the code without running it. You will create a workflow with one job, quality, that lints the Python source with flake8 and runs the tests with pytest. If either step fails, the pipeline stops here before spending any runner time on a Docker build.
Notice the permissions block near the top of the file. GitHub generates an automatic GITHUB_TOKEN for every workflow run, and repository or organization defaults can still be broader than this workflow actually needs. Explicitly declaring contents: read limits the token to the one permission the quality job requires, which is the least-privilege principle the lecture described applied to a real workflow.
-
Create
.github/workflows/ci.yml, creating the.github/workflows/directories if your editor asks:name: CIon:push:branches: [main]pull_request:branches: [main]workflow_dispatch:permissions:contents: readjobs:quality:runs-on: ubuntu-lateststeps:- uses: actions/checkout@v6- name: Set up Pythonuses: actions/setup-python@v6with:python-version: '3.12'cache: pip- name: Install dependenciesrun: pip install -r requirements.txt- name: Lintrun: flake8 .- name: Testrun: pytest -vGitHub recognizes workflow files only under
.github/workflows/, which is why the path matters. Thecache: pipparameter inactions/setup-pythonstores pip’s download cache between runs, keyed to a hash ofrequirements.txt. The first run downloads and saves all packages; every subsequent run restores them instead of pulling from PyPI. You will see a line likeCache restored from key: setup-python-...starting with the second run.The
workflow_dispatch:entry enables manual runs from the Actions tab, but GitHub exposes the Run workflow button only when the workflow file exists on the repository’s default branch. You will satisfy that requirement in step 3 when you commit and push this file tomain. -
Enable GitHub Actions for the repository. Open your repository on GitHub and click the Actions tab. If GitHub shows a prompt to enable workflows for this new repository, enable them before the next push. If Actions are already enabled, continue to the next step.
-
Commit and push:
Terminal window git add .github/workflows/ci.ymlgit commit -m "Add CI workflow with lint and test"git push -
Return to the Actions tab on your repository. You should see a workflow named CI in progress or just finished. Click into it, then click quality to see the job steps.
-
Expand the Lint and Test steps. The Lint step should complete silently: no output means
flake8found no issues. The Test step should print something like:test_app.py::test_index PASSED [100%]1 passed in 0.11sBoth steps show checkmarks.
-
Trigger the workflow manually. Return to the Actions tab and click CI in the left sidebar. Because the workflow file now exists on
main, you should see a Run workflow button near the top right of the run list. Click it, leave the branch set tomain, and click the green Run workflow button. A new run appears in the list labeled with aworkflow_dispatchevent. Open it: for this workflow, the jobs run the same way as the push-triggered run. The relevant behavior change here is thatgithub.event_nameequalsworkflow_dispatchrather thanpush. This is how deploy workflows let a human choose when to ship without requiring a code commit.
Building the Docker Image
Section titled “Building the Docker Image”Passing lint and tests is necessary but not sufficient: the lecture’s feedback loop ends at the registry, not the test runner. You will add a second job, build, that starts only after quality passes and runs only on pushes to main. This job builds the Docker image, starts the container to verify it actually launches and responds to an HTTP request, and then pushes the verified image to GitHub Container Registry.
The build job authenticates using GITHUB_TOKEN, the same short-lived secret GitHub created automatically for the quality job. You do not create or store it anywhere: it is always available as secrets.GITHUB_TOKEN, and a job-specific packages: write permission allows this job to push to the registry without granting that permission to quality.
-
Add the
buildjob to.github/workflows/ci.yml. Append these lines after the last line of thequalityjob block, at the same indentation level asquality:underjobs::build:needs: qualityif: github.event_name == 'push' && github.ref == 'refs/heads/main'runs-on: ubuntu-latestpermissions:contents: readpackages: writesteps:- uses: actions/checkout@v6- name: Set up Docker Buildxuses: docker/setup-buildx-action@v4- name: Log in to GitHub Container Registryuses: docker/login-action@v4with:registry: ghcr.iousername: ${{ github.actor }}password: ${{ secrets.GITHUB_TOKEN }}- name: Normalize image namerun: echo "IMAGE_NAME=ghcr.io/${GITHUB_REPOSITORY,,}:latest" >> "$GITHUB_ENV"- name: Build imageuses: docker/build-push-action@v7with:context: .push: falseload: truetags: app:smoke-testcache-from: type=ghacache-to: type=gha,mode=max- name: Smoke-test the containerrun: |docker run -d --name smoke -p 8080:8080 app:smoke-testfor i in {1..10}; doif curl --fail --silent http://localhost:8080; thenbreakfisleep 1donecurl --fail http://localhost:8080docker stop smoke && docker rm smoke- name: Push to registryrun: |docker tag app:smoke-test ${{ env.IMAGE_NAME }}docker push ${{ env.IMAGE_NAME }}needs: qualitydeclares the dependency:buildwill not start untilqualityfinishes successfully. Ifqualityfails,buildis skipped entirely and no runner time is spent on the image.The
if:condition restrictsbuildto pushes onmain. Pull requests still run thequalityjob, giving reviewers fast feedback on linting and tests, but thebuildjob is skipped until code actually lands onmain. That keeps the registry clean and the workflow easier to read than putting the condition inside individual Docker steps.The
Normalize image namestep lowercases the GHCR tag. Container image names must be lowercase, so this avoids failures if the repository owner or name contains uppercase characters. In Bash,${GITHUB_REPOSITORY,,}converts any uppercase letters to lowercase before writing the result toGITHUB_ENV, making it available to all later steps in the same job.The
Build imagestep usespush: falseandload: true. This builds the image and loads it into the runner’s local Docker daemon without pushing it anywhere. TheSmoke-test the containerstep then starts that local image and retries the HTTP check for a few seconds while Flask finishes starting. The--failflag makescurlexit with a nonzero code for any HTTP error response, which fails the step and skips the push. Only after the container passes does the final step send the verified image to GHCR. -
Commit and push:
Terminal window git commit -am "Add Docker build, smoke test, and push job"git push -
Open the new Actions run. The run page now shows two jobs:
qualityandbuild, with an arrow pointing fromqualitytobuild. Watch them in sequence:buildstays queued untilqualityreports success, then starts immediately. -
Expand the Smoke-test step inside the
buildjob. You will seedocker runstart the container, followed bycurloutput showing the JSON response from your application:{"status":"ok","version":"1.0.0"}This is step 6 of the lecture’s feedback loop: you are verifying that the artifact you will actually deploy, the running container, behaves correctly, not just the source code that went into it.
-
Find your Docker image. After both jobs show green checkmarks, check your repository page on GitHub for a Packages section or open the package from your account’s Packages view if the sidebar has not updated yet. You should find
cs312-ci-activitythere, with the image tag, push timestamp, and your GitHub username as the owner. -
Push a small change and watch the layer cache in action:
Terminal window # Edit app.py to change "1.0.0" to "1.0.1", then:git commit -am "Bump version"git pushOpen the new run’s
buildjob and expand the Build image step. Layers that did not change (the base image and the pip installation layer) will appear asCACHED. Only the layer that copiesapp.pyrebuilds. ThePush to registrystep sends only the changed layer to GHCR, not the entire image.
Testing Across Versions
Section titled “Testing Across Versions”A single Python version tells you the code works on 3.12. It does not tell you whether a teammate running 3.11 is about to hit a syntax difference or a compatibility edge case in a dependency. Rather than duplicating the quality job, you can ask GitHub Actions to generate one job per version automatically with a matrix strategy.
-
Update the
qualityjob in.github/workflows/ci.ymlto replace the hardcoded Python version with a matrix. Replace the entirequality:block with this version:quality:runs-on: ubuntu-lateststrategy:matrix:python-version: ['3.11', '3.12']fail-fast: falsesteps:- uses: actions/checkout@v6- name: Set up Pythonuses: actions/setup-python@v6with:python-version: ${{ matrix.python-version }}cache: pip- name: Install dependenciesrun: pip install -r requirements.txt- name: Lintrun: flake8 .- name: Testrun: pytest -vThe
strategy.matrixblock tells GitHub to run thequalityjob once for each value inpython-version. The two jobs run in parallel on separate runners. The${{ matrix.python-version }}expression insetup-pythonsubstitutes the current matrix value into that step, so each job installs the correct interpreter.Setting
fail-fast: falsetells GitHub to run all matrix combinations to completion even if one fails. With the default (fail-fast: true), a failure on 3.11 would cancel the 3.12 job before it finished, leaving you with an incomplete picture of which versions are broken. -
Commit and push:
Terminal window git commit -am "Add matrix builds across Python 3.11 and 3.12"git push -
Open the Actions run. The job list now shows two
qualityentries:quality (3.11)andquality (3.12). Both run in parallel on separate runners. Thebuildjob still shows a single arrow fromquality, but it waits for both matrix instances to succeed before starting. When you declareneeds: quality, GitHub waits for every matrix member of that job, not just the first one to finish. -
Notice the job names in the run summary. GitHub labels each matrix job with its parameter values in parentheses. On a larger matrix, such as four Python versions across two operating systems, you would see eight labeled boxes. Any single failure is immediately identifiable by name without opening a log.
Saving Test Results
Section titled “Saving Test Results”The pytest output scrolls past in the log and disappears when the runner is torn down. If a test fails in a run from last Tuesday, you want to know which specific assertion failed and what value it received, not just that the job was red. Artifacts let you attach files to a workflow run so they are downloadable long after the log has scrolled away.
You will add two things to the quality job: a JUnit XML report from pytest, and an upload step that saves it even when the tests fail, since that is exactly when you need the report most.
-
Update the
Teststep inside thequalityjob to emit an XML report alongside the normal output:- name: Testrun: pytest -v --junitxml=pytest-report.xml -
Add an upload step at the end of the
qualityjob’ssteps:list, after the Test step:- name: Upload test reportif: always()uses: actions/upload-artifact@v7with:name: pytest-report-${{ matrix.python-version }}path: pytest-report.xmlretention-days: 7if-no-files-found: errorif: always()runs this step even if the Test step above it failed. Without it, a failing test would halt the job before reaching the upload step, and the report would be lost precisely when you need it most. The artifact name includes${{ matrix.python-version }}so the two parallel matrix jobs do not conflict by uploading files with the same name.if-no-files-found: erroralso catches the different failure mode where pytest crashes before it writes the XML file at all. -
Commit and push:
Terminal window git commit -am "Emit JUnit report and upload as artifact"git push -
Open the completed run in the Actions tab. Scroll to the bottom of the run summary page. You will see an Artifacts section listing
pytest-report-3.11andpytest-report-3.12. Click either entry to download a zip file containingpytest-report.xml. Open the file: it is standard JUnit XML, readable by any CI dashboard, test analytics tool, or IDE that understands test results.
Let the Pipeline Protect You
Section titled “Let the Pipeline Protect You”The pipeline’s real value appears when something goes wrong. You will introduce a lint error and watch the pipeline block it before a single Docker layer is built.
-
Introduce a lint error. Open
app.pyand add an unused import at the top:import osfrom flask import Flask, jsonify -
Commit and push:
Terminal window git commit -am "Oops: add unused import"git push -
Open the Actions tab. Both
quality (3.11)andquality (3.12)jobs will fail. Click into either run and expand the Lint step. You will see output like:./app.py:1:1: F401 'os' imported but unusedflake8reports the file, line, column, and error code. Thebuildjob shows Skipped becauseneeds: qualitynever allowed it to start. The pipeline did not pull any Docker base image, run Buildx, or spend any runner time on the build at all. The lint check, the cheapest step in the pipeline, stopped everything before the expensive work began. -
Fix the file. Remove
import os, leaving only the original two lines:from flask import Flask, jsonify -
Commit and push the fix:
Terminal window git commit -am "Remove unused import"git push -
Wait for the run to finish. All
qualityjobs and thebuildjob should show green checkmarks. If GitHub has linked the package to the repository UI already, the Packages section in the sidebar will show a fresh push timestamp. If not, open the package from your account’s Packages view and confirm the owner, push timestamp, andlatesttag there.
Going Further
Section titled “Going Further”You have built the full feedback loop from the lecture: lint, test across Python versions, build an image, smoke-test the running container, upload a test report, and push the verified image to a registry. Two extensions move this pipeline closer to what production setups actually look like.
Add concurrency control to the build job. Right now, if you push two commits to main within thirty seconds, two build jobs can race each other. Add a concurrency block to the build job to prevent this:
build: needs: quality if: github.event_name == 'push' && github.ref == 'refs/heads/main' concurrency: group: build-main cancel-in-progress: false runs-on: ubuntu-latest ...With cancel-in-progress: false, GitHub allows the currently running build to finish and queues at most one additional run. Any further runs that arrive while one is queued replace the queued entry, so only the latest pending commit survives. Push three commits in quick succession and watch the run list: the middle run is cancelled before it reaches the build job.
Push to Amazon ECR using OIDC instead of GHCR. GHCR is convenient because it is built into GitHub and requires no external credentials. ECR is what most production systems running on AWS use. The modern pattern is OIDC: AWS trusts GitHub’s identity provider, and the workflow exchanges a short-lived GitHub-issued token for temporary IAM credentials that expire when the job finishes.