You write flask>=2.0 in requirements.txt. Today pip installs Flask 3.1.0 and everything works. Tomorrow an attacker publishes a compromised 3.1.1. Your next build silently downloads malware because it satisfies >=2.0. You shipped a backdoor without changing a line of code.
Hash pinning stops this. When you pin dependencies with cryptographic hashes, pip verifies the package contents match what you originally tested. If someone tampers with the file on PyPI or intercepts your download, the hash won't match and the install fails. This template gives you a working CI configuration that enforces hash-pinned dependencies across your Python projects.
What This Template Does
This GitHub Actions workflow enforces hash-pinned dependencies in your Python project. It generates a lockfile with SHA256 hashes for every package and transitive dependency, validates those hashes on every CI run, and blocks builds if someone tries to install an unverified package. The workflow also runs pip-audit to catch known CVEs in your dependency tree before they reach production.
The template uses uv, a fast Python package manager that makes hash pinning practical. Traditional pip workflows require manually running pip-compile --generate-hashes and committing multi-thousand-line lockfiles. uv handles this automatically and runs significantly faster.
Prerequisites
Before implementing this workflow, you need:
- A Python project with a
pyproject.tomlorrequirements.infile listing your direct dependencies - GitHub Actions enabled on your repository
- Python 3.8 or later (the workflow installs
uvwhich supports all active Python versions) - Understanding that this creates a lockfile you must commit to version control
You don't need uv installed locally. The workflow installs it in CI. However, you'll want it locally to regenerate lockfiles when you add dependencies: curl -LsSf https://astral.sh/uv/install.sh | sh
The CI Workflow Template
Create .github/workflows/verify-dependencies.yml:
name: Verify Dependencies
on:
pull_request:
paths:
- 'pyproject.toml'
- 'requirements.in'
- 'uv.lock'
push:
branches: [main]
schedule:
# Run weekly to catch new CVEs in existing dependencies
- cron: '0 9 * * 1'
jobs:
verify:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install uv
uses: astral-sh/setup-uv@v4
with:
version: "latest"
- name: Set up Python
run: uv python install 3.12
- name: Verify lockfile is up to date
run: |
uv lock --locked
if [ -n "$(git status --porcelain uv.lock)" ]; then
echo "uv.lock is out of date. Run 'uv lock' locally and commit."
exit 1
fi
- name: Install dependencies with hash verification
run: uv sync --frozen
- name: Audit for known vulnerabilities
run: |
uv tool run pip-audit --require-hashes --desc
- name: Generate SBOM
run: |
uv tool run cyclonedx-py environment --format json --output sbom.json
- name: Upload SBOM artifact
uses: actions/upload-artifact@v4
with:
name: sbom
path: sbom.json
retention-days: 90
How to Customize It
For monorepos with multiple Python projects: Add a working-directory parameter to each step or use a matrix strategy to verify each project's lockfile separately. You'll need one uv.lock per project.
For private package indexes: Add your index configuration to pyproject.toml:
[[tool.uv.index]]
url = "https://your-internal-pypi.example.com/simple"
name = "internal"
Then modify the install step to use --index-url or configure authentication via environment variables.
For projects not using pyproject.toml: If you're maintaining a legacy requirements.in file, replace uv lock with uv pip compile requirements.in --generate-hashes -o requirements.txt. The workflow will verify requirements.txt instead of uv.lock.
For organizations with delayed mirroring: If you run an internal PyPI mirror with a seven-day delay (letting the community find compromised packages before you consume them), point uv at your mirror URL. This only works if you maintain the infrastructure to keep that mirror current and monitor for incidents.
For faster CI on large dependency trees: Cache the uv package cache between runs by adding:
- name: Cache uv packages
uses: actions/cache@v4
with:
path: ~/.cache/uv
key: ${{ runner.os }}-uv-${{ hashFiles('uv.lock') }}
Validation Steps
After adding this workflow, verify it works correctly:
Trigger a clean build: Push a commit and confirm the workflow passes. Check the Actions tab for the "Verify Dependencies" run.
Test lockfile drift detection: Modify
pyproject.tomlto add a new dependency but don't updateuv.lock. Push the change. The workflow should fail at "Verify lockfile is up to date" with instructions to runuv locklocally.Test hash verification: Manually corrupt a hash in
uv.lock(change one character in anysha256=value). Push it. The workflow should fail at "Install dependencies with hash verification" because the downloaded package won't match the corrupted hash.Test vulnerability detection: Add a package with a known CVE to your dependencies (you can temporarily add an old version of a package). Run
uv lock, commit, and push. Thepip-auditstep should fail and report the CVE details.Verify SBOM generation: Download the SBOM artifact from a successful workflow run. Open
sbom.jsonand confirm it lists all your dependencies with version numbers and hashes. You'll need this file when the next Ultralytics-style compromise happens and you need to answer "are we affected?" in minutes instead of days.
When you add a new dependency locally, run uv add package-name and it will update uv.lock with hashes automatically. Commit both files. The workflow enforces that nobody can bypass this process by installing unverified packages in CI.
This isn't perfect. Hash pinning stops tampering but won't save you if you installed a malicious package on day one. That's why the workflow also runs pip-audit for known CVEs and generates SBOMs for incident response. Layer your defenses.





