CI Pipelines for a Python App
A 1-hour hands-on session: GitHub Actions and Jenkins, end to end.
Session plan
| Time | Segment |
|---|---|
| 0:00 to 0:05 | Prerequisites check and repo setup |
| 0:05 to 0:15 | Build the sample Python app and tests |
| 0:15 to 0:25 | GitHub Actions workflow |
| 0:25 to 0:45 | Jenkins setup and Jenkinsfile |
| 0:45 to 0:55 | Trigger both pipelines, break a test, observe failures |
| 0:55 to 1:00 | Recap and next steps |
Prerequisites
- Python 3.11 or later, Git, and a code editor
- A GitHub account and an empty public repository, for example
python-ci-demo - Docker Desktop (or Docker Engine on Linux) to run Jenkins locally
- Optional: a Jenkins installation on a VM if you don't want to use Docker
Verify the basics:
python3 --version
git --version
docker --version
Part 1: Sample Python app (0:05 to 0:15)
Create the project structure:
mkdir python-ci-demo && cd python-ci-demo
git init -b main
mkdir -p app tests
touch app/__init__.py tests/__init__.py
def add(a: float, b: float) -> float:
return a + b
def divide(a: float, b: float) -> float:
if b == 0:
raise ValueError("Cannot divide by zero")
return a / b
from app.calculator import add, divide
def run() -> None:
print(f"2 + 3 = {add(2, 3)}")
print(f"10 / 4 = {divide(10, 4)}")
if __name__ == "__main__":
run()
import pytest
from app.calculator import add, divide
def test_add():
assert add(2, 3) == 5
def test_divide():
assert divide(10, 2) == 5
def test_divide_by_zero_raises():
with pytest.raises(ValueError):
divide(1, 0)
pytest==8.3.3
pytest-cov==5.0.0
flake8==7.1.1
[pytest]
pythonpath = .
testpaths = tests
.venv/
__pycache__/
.pytest_cache/
.coverage
coverage.xml
reports/
*.tar.gz
Run locally to confirm everything works before automating it:
python3 -m venv .venv
source .venv/bin/activate # Windows: .venv\Scripts\activate
pip install -r requirements.txt
flake8 app tests
pytest -v
Commit and push:
git add . && git commit -m "Add sample app and tests"
git remote add origin https://github.com/<your-user>/python-ci-demo.git
git push -u origin main
Part 2: GitHub Actions (0:15 to 0:25)
Create the workflow file at .github/workflows/ci.yml:
name: Python CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.11", "3.12"]
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
cache: pip
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
- name: Lint with flake8
run: flake8 app tests
- name: Run tests with coverage
run: pytest --junitxml=reports/junit.xml --cov=app --cov-report=term-missing
- name: Upload test results
if: always()
uses: actions/upload-artifact@v4
with:
name: junit-results-py${{ matrix.python-version }}
path: reports/junit.xml
build:
needs: test
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Package application
run: tar czf app-${{ github.sha }}.tar.gz app requirements.txt
- name: Upload package
uses: actions/upload-artifact@v4
with:
name: app-package
path: app-${{ github.sha }}.tar.gz
Commit and push, then check the Actions tab in GitHub. The matrix produces two test jobs, followed by the build job after both pass.
Part 3: Jenkins (0:25 to 0:45)
3.1 Run Jenkins
docker run -d --name jenkins \
-p 8080:8080 -p 50000:50000 \
-v jenkins_home:/var/jenkins_home \
jenkins/jenkins:lts-jdk17
Get the initial admin password and open the UI:
docker logs jenkins 2>&1 | grep -A 2 "Please use the following password"
# or
docker exec jenkins cat /var/jenkins_home/secrets/initialAdminPassword
Go to http://localhost:8080, paste the password, choose Install suggested plugins, and create an admin user.
3.2 Install Python in the Jenkins container
The LTS image may not include Python 3 with venv support, so install it:
docker exec -u root jenkins bash -c "apt-get update && apt-get install -y python3 python3-venv python3-pip git"
Verify:
docker exec jenkins python3 --version
3.3 Add the Jenkinsfile to the repository
Create Jenkinsfile in the repo root:
pipeline {
agent any
options {
timestamps()
disableConcurrentBuilds()
buildDiscarder(logRotator(numToKeepStr: '10'))
}
stages {
stage('Checkout') {
steps {
checkout scm
}
}
stage('Setup') {
steps {
sh '''
python3 -m venv .venv
. .venv/bin/activate
pip install --upgrade pip
pip install -r requirements.txt
'''
}
}
stage('Lint') {
steps {
sh '. .venv/bin/activate && flake8 app tests'
}
}
stage('Test') {
steps {
sh '''
. .venv/bin/activate
mkdir -p reports
pytest --junitxml=reports/junit.xml --cov=app --cov-report=term-missing
'''
}
post {
always {
junit 'reports/junit.xml'
}
}
}
stage('Package') {
steps {
sh 'tar czf app-${BUILD_NUMBER}.tar.gz app requirements.txt'
archiveArtifacts artifacts: 'app-*.tar.gz', fingerprint: true
}
}
}
post {
always {
cleanWs()
}
}
}
Commit and push the Jenkinsfile:
git add Jenkinsfile && git commit -m "Add Jenkinsfile" && git push
3.4 Create the Jenkins pipeline job
- On the Jenkins dashboard, click New Item.
- Name it
python-ci-demo, select Pipeline, and click OK. - Under Build Triggers, check Poll SCM and enter
H/5 * * * *. This checks for new commits every five minutes. A GitHub webhook is the better option for production, but it requires a publicly reachable Jenkins URL. - Under Pipeline, set Definition to Pipeline script from SCM.
- Set SCM to Git, enter your repository URL (
https://github.com/<your-user>/python-ci-demo.git), and set Branch Specifier to*/main. - Keep Script Path as
Jenkinsfileand click Save. - Click Build Now and open the console output to follow the run.
Part 4: Trigger, observe, and break things (0:45 to 0:55)
This is the most useful part of the session. Introduce a failure on purpose and watch both systems catch it.
Create a feature branch with a broken test:
git checkout -b feature/break-test
Edit tests/test_calculator.py and change assert add(2, 3) == 5 to assert add(2, 3) == 6. Then add a lint violation, such as an unused import, to app/main.py:
import os # unused
Commit, push, and open a pull request:
git add . && git commit -m "Introduce failures" && git push -u origin feature/break-test
Observe:
- GitHub Actions: the PR shows failed checks. Click into the job log and find the flake8 error and the failing assertion.
- Jenkins: the job fails at the Lint stage, so the Test stage never runs. The JUnit report shows nothing because the pipeline stopped early. Discuss why fail-fast ordering saves time.
Fix both problems, push again, and confirm both pipelines go green. Then merge the PR into main.
Part 5: Recap and next steps (0:55 to 1:00)
Key concepts covered:
- CI triggers: push and pull request events in GitHub Actions versus SCM polling or webhooks in Jenkins
- Pipeline as code:
ci.ymlandJenkinsfileboth live in the repository - Fail-fast stages: lint before test, test before package
- Test reporting: JUnit XML is consumed by both platforms
- Artifacts: packaged output is stored by each system for later deployment
Suggested next steps:
- Add a Docker build stage and push the image to a registry such as Azure Container Registry or Docker Hub.
- Store secrets in GitHub Secrets and Jenkins Credentials, not in the repository.
- Add a deployment stage gated on the
mainbranch, with a manual approval step in Jenkins. - Replace Poll SCM with a GitHub webhook, using ngrok for local testing.
- Use a Jenkins agent with the Docker Pipeline plugin so each build runs in a clean
python:3.12-slimcontainer.
Troubleshooting quick reference
| Symptom | Likely cause and fix |
|---|---|
ModuleNotFoundError: app in pytest | Confirm pytest.ini contains pythonpath = . |
python3: command not found in Jenkins | Re-run the apt-get install step in section 3.2 |
venv creation fails in Jenkins | Install python3-venv for the matching Python version |
| JUnit report not shown in Jenkins | Confirm the JUnit plugin is installed and the path is reports/junit.xml |
| Jenkins can't clone the repo | Use a public repo, or add a GitHub credential under Manage Jenkins, then Credentials |
| Workflow doesn't trigger | Check the branches: filter and confirm the file is in .github/workflows/ |