Most CI pipelines run tests. Far fewer pipelines actually stop bad code from reaching production. The difference is a quality gate: a check that must pass before the pipeline is allowed to move to the next stage.
In this tutorial, you'll build a GitHub Actions pipeline with four gates:
- Lint: code style and obvious errors
- Unit tests + coverage threshold: fail if coverage drops below a minimum
- API tests: verify the running service behaves correctly
- Manual approval: a human signs off before production
By the end, a failed gate will block deployment automatically.
Prerequisites
- A GitHub repository with a small Python web service (the same ideas apply to any language)
- Basic familiarity with YAML
-
pytestandpytest-covin yourrequirements.txt
Step 1: Create the workflow file
Create .github/workflows/pipeline.yml:
# .github/workflows/pipeline.yml
name: CI/CD Pipeline
on:
push:
branches: [main]
pull_request:
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- run: pip install ruff
- run: ruff check .
This first job is your cheapest gate. Linting takes seconds, so it should run first and fail fast.
Step 2: Add unit tests with a coverage threshold
Add a second job. The key line is --cov-fail-under=80, which makes pytest exit with an error if coverage drops below 80%.
unit-tests:
runs-on: ubuntu-latest
needs: lint
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- run: pip install -r requirements.txt
- run: pytest tests/unit --cov=app --cov-fail-under=80
needs: lint creates the gate: this job only starts if linting passed.
Tip: Don't start with an aggressive threshold on a legacy codebase. Measure your current coverage, set the threshold slightly below it, and raise it over time. A gate that always fails gets disabled.
Step 3: Add API tests against the running service
Unit tests check functions in isolation. API tests check that the service actually works when it runs. Start the app in the background, wait for it to be healthy, then run the tests:
api-tests:
runs-on: ubuntu-latest
needs: unit-tests
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- run: pip install -r requirements.txt
- name: Start service
run: |
uvicorn app.main:app --port 8000 &
for i in $(seq 1 30); do
curl -sf http://localhost:8000/health && break
sleep 1
done
- run: pytest tests/api --base-url http://localhost:8000
The health-check loop matters. Without it, tests often start before the server is ready, which is one of the most common causes of flaky pipelines.
Step 4: Require manual approval before production
GitHub environments let you require a reviewer before a job runs. In your repository, go to Settings → Environments → New environment, name it production, and add yourself under Required reviewers.
Then reference it in a deploy job:
deploy:
runs-on: ubuntu-latest
needs: api-tests
if: github.ref == 'refs/heads/main'
environment: production
steps:
- uses: actions/checkout@v4
- run: ./scripts/deploy.sh
Now the pipeline pauses after the API tests pass and waits for an approval in the GitHub UI.
Step 5: Protect the main branch
Gates only work if nobody can bypass them. Under Settings → Branches, add a branch protection rule for main and enable Require status checks to pass before merging. Select lint, unit-tests and api-tests.
Pull requests can no longer be merged while any gate is failing.
What you built
| Gate | Catches | Typical runtime |
|---|---|---|
| Lint | Style issues, unused imports, syntax errors | Seconds |
| Unit tests + coverage | Logic bugs, untested code | 1–3 minutes |
| API tests | Integration and contract failures | 2–5 minutes |
| Manual approval | Business or timing risk | Human decision |
Ordering gates from cheapest to most expensive keeps feedback fast: most problems are caught in the first minute, and expensive checks only run on code that is already in reasonable shape.
Next steps
- Run API tests in parallel with a matrix strategy as the suite grows.
- Publish test reports as artifacts so failures are easy to debug.
- Add a security scanning gate (for example, dependency scanning) before deploy.
Top comments (0)