Skip to content

chore: add repo-wide AGENTS.md with agent guidelines #60

chore: add repo-wide AGENTS.md with agent guidelines

chore: add repo-wide AGENTS.md with agent guidelines #60

name: Validate Lockfiles
on:
pull_request:
paths:
- 'uv.lock'
- 'pyproject.toml'
- 'core/**/*.lock'
- 'core/**/pyproject.toml'
- 'contrib/**/*.lock'
- 'contrib/**/pyproject.toml'
- '.github/workflows/validate-lockfiles.yml'
- '.github/scripts/check_lockfile_hashes.py'
push:
branches:
- main
paths:
- 'uv.lock'
- 'pyproject.toml'
- 'core/**/*.lock'
- 'core/**/pyproject.toml'
- 'contrib/**/*.lock'
- 'contrib/**/pyproject.toml'
- '.github/workflows/validate-lockfiles.yml'
- '.github/scripts/check_lockfile_hashes.py'
workflow_dispatch:
permissions:
contents: read
jobs:
validate-lockfiles:
name: Check uv.lock files for policy violations
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # ratchet:actions/checkout@v4
with:
fetch-depth: 0
- name: Install uv
uses: astral-sh/setup-uv@v6
# ---------------------------------------------------------------------------
# Resolve which uv.lock files to validate.
#
# On pull_request: only lockfiles that were added/modified in this PR, plus
# the sibling uv.lock for any pyproject.toml that was changed. The root
# uv.lock is included if the root uv.lock or pyproject.toml was touched.
# On push to main or workflow_dispatch: all uv.lock files under core/ and
# contrib/, plus the root uv.lock (full scan as a safety net).
#
# Samples live exclusively under core/ and contrib/. The root uv.lock is
# treated separately as repo-level infrastructure.
#
# The resolved list is written to $LOCKFILES_FILE (one path per line) so
# that subsequent steps can iterate with `while IFS= read -r` and avoid
# shell word-splitting on paths that contain spaces.
# ---------------------------------------------------------------------------
- name: Resolve lockfiles to validate
id: lockfiles
run: |
set -euo pipefail
LOCKFILES_FILE="${RUNNER_TEMP}/lockfiles.txt"
if [ "${{ github.event_name }}" = "pull_request" ]; then
BASE_REF="${{ github.event.pull_request.base.ref }}"
git fetch origin "$BASE_REF"
CHANGED=$(git diff --name-only "origin/$BASE_REF"...HEAD)
# Collect directly changed uv.lock files under core/ or contrib/
LOCKS=$(echo "$CHANGED" | grep -E "^(core|contrib)/.*uv\.lock$" || true)
# For any changed pyproject.toml under core/ or contrib/, add its sibling uv.lock
while IFS= read -r pyproject; do
[ -z "$pyproject" ] && continue
sibling="$(dirname "$pyproject")/uv.lock"
if [ -f "$sibling" ]; then
LOCKS=$(printf "%s\n%s" "$LOCKS" "$sibling")
fi
done <<< "$(echo "$CHANGED" | grep -E "^(core|contrib)/.*pyproject\.toml$" || true)"
# Include the root uv.lock if the root uv.lock or pyproject.toml was touched
ROOT_CHANGED=$(echo "$CHANGED" | grep -E "^(uv\.lock|pyproject\.toml)$" || true)
if [ -n "$ROOT_CHANGED" ] && [ -f "uv.lock" ]; then
LOCKS=$(printf "%s\n%s" "$LOCKS" "uv.lock")
fi
# Also re-validate everything if the workflow itself changed
WORKFLOW_CHANGED=$(echo "$CHANGED" | grep -E "^\.github/workflows/validate-lockfiles\.yml$" || true)
if [ -n "$WORKFLOW_CHANGED" ]; then
echo "Workflow file changed — falling back to full scan."
LOCKS=$(find uv.lock core contrib -name "uv.lock" 2>/dev/null || true)
fi
# Deduplicate and filter to only existing files
echo "$LOCKS" | sort -u | grep -v '^$' | while IFS= read -r f; do
[ -f "$f" ] && echo "$f"
done > "$LOCKFILES_FILE" || true
else
# push to main or workflow_dispatch — scan everything
find uv.lock core contrib -name "uv.lock" 2>/dev/null > "$LOCKFILES_FILE" || true
fi
if [ ! -s "$LOCKFILES_FILE" ]; then
echo "No uv.lock files to validate."
echo "lockfiles_file=" >> "$GITHUB_OUTPUT"
else
echo "Lockfiles to validate:"
sed 's/^/ /' "$LOCKFILES_FILE"
echo "lockfiles_file=$LOCKFILES_FILE" >> "$GITHUB_OUTPUT"
fi
# ---------------------------------------------------------------------------
# Check 1: No internal/non-PyPI registry URLs
# Catches pkg.dev and any other non-pypi.org source hosts.
# Lockfiles referencing internal registries cannot be reproduced outside the original environment.
# ---------------------------------------------------------------------------
- name: Check for non-PyPI registry URLs
if: steps.lockfiles.outputs.lockfiles_file != ''
run: |
set -euo pipefail
FAILED=0
INTERNAL_PATTERN='pkg\.dev|artifactregistry\.googleapis\.com'
while IFS= read -r lockfile; do
MATCHES=$(grep -En "(url|registry)\s*=.*($INTERNAL_PATTERN)" "$lockfile" || true)
if [ -n "$MATCHES" ]; then
echo "[FAIL] $lockfile contains non-PyPI registry URLs (pkg.dev or similar):"
echo "$MATCHES" | sed 's/^/ /'
FAILED=1
fi
done < "${{ steps.lockfiles.outputs.lockfiles_file }}"
if [ "$FAILED" -eq 1 ]; then
echo ""
echo "Lockfiles must only reference public PyPI (pypi.org)."
echo "Re-run 'uv lock' with only public PyPI configured, or unset any internal"
echo "registry configuration before locking."
exit 1
fi
echo "[PASS] No internal registry URLs found."
# ---------------------------------------------------------------------------
# Check 2: No git/VCS dependencies
# In uv.lock, git dependencies are represented as source = { git = "..." }.
# These are non-reproducible when the ref is a branch name, and bypass
# registry trust entirely.
# ---------------------------------------------------------------------------
- name: Check for VCS (git) dependencies
if: steps.lockfiles.outputs.lockfiles_file != ''
run: |
set -euo pipefail
FAILED=0
while IFS= read -r lockfile; do
MATCHES=$(grep -En "source\s*=.*[{,\s](git)\s*=" "$lockfile" || true)
if [ -n "$MATCHES" ]; then
echo "[FAIL] $lockfile contains git/VCS dependencies:"
echo "$MATCHES" | sed 's/^/ /'
FAILED=1
fi
done < "${{ steps.lockfiles.outputs.lockfiles_file }}"
if [ "$FAILED" -eq 1 ]; then
echo ""
echo "VCS dependencies are not allowed in lockfiles — they are non-reproducible"
echo "and bypass registry-level trust. Pin to a released version on PyPI instead."
exit 1
fi
echo "[PASS] No VCS dependencies found."
# ---------------------------------------------------------------------------
# Check 3: No local path or editable dependencies
# In uv.lock, path/editable/directory sources use unquoted keys in inline
# tables: source = { path = "..." } or source = { editable = "..." }.
# These only exist on the committer's machine and break all other environments.
# ---------------------------------------------------------------------------
- name: Check for local path dependencies
if: steps.lockfiles.outputs.lockfiles_file != ''
run: |
set -euo pipefail
FAILED=0
while IFS= read -r lockfile; do
MATCHES=$(grep -En 'source\s*=.*[{,\s](path|editable|directory)\s*=' "$lockfile" || true)
if [ -n "$MATCHES" ]; then
echo "[FAIL] $lockfile contains local path dependencies:"
echo "$MATCHES" | sed 's/^/ /'
FAILED=1
fi
done < "${{ steps.lockfiles.outputs.lockfiles_file }}"
if [ "$FAILED" -eq 1 ]; then
echo ""
echo "Local path dependencies cannot be reproduced outside the committer's machine."
echo "Publish the package to PyPI or use a workspace dependency instead."
exit 1
fi
echo "[PASS] No local path dependencies found."
# ---------------------------------------------------------------------------
# Check 4: All packages must have hashes
# Missing hashes mean the lockfile cannot verify supply chain integrity.
# uv generates sha256 hashes for all registry packages by default; their
# absence indicates the lockfile was tampered with or generated incorrectly.
# The check logic lives in .github/scripts/check_lockfile_hashes.py to
# avoid embedding Python at column 0 inside this YAML file, which breaks
# the zizmor security scanner's YAML parser.
# ---------------------------------------------------------------------------
- name: Check that all packages have hashes
if: steps.lockfiles.outputs.lockfiles_file != ''
run: |
set -euo pipefail
FAILED=0
while IFS= read -r lockfile; do
uv run python3 .github/scripts/check_lockfile_hashes.py "$lockfile" || FAILED=1
done < "${{ steps.lockfiles.outputs.lockfiles_file }}"
if [ "$FAILED" -eq 1 ]; then
echo ""
echo "All distributions must have sha256 hashes for supply chain integrity."
echo "Re-generate the lockfile with 'uv lock'."
exit 1
fi
echo "[PASS] All distributions have hashes."
# ---------------------------------------------------------------------------
# Check 5: Lockfiles are up-to-date with their pyproject.toml
# Catches the "forgot to re-run uv lock after editing pyproject.toml" mistake.
# ---------------------------------------------------------------------------
- name: Check lockfiles are up-to-date
if: steps.lockfiles.outputs.lockfiles_file != ''
run: |
set -euo pipefail
FAILED=0
FAILED_DIRS=""
while IFS= read -r lockfile; do
lockdir=$(dirname "$lockfile")
if [ ! -f "$lockdir/pyproject.toml" ]; then
continue
fi
# Suppress uv's own error output; we emit a cleaner message below.
if ! uv lock --check --project "$lockdir" 2>/dev/null; then
echo "::error file=$lockfile::$lockfile is out of date — run: uv lock --project $lockdir"
echo "[FAIL] $lockfile is out of date with $lockdir/pyproject.toml"
FAILED_DIRS="${FAILED_DIRS} uv lock --project ${lockdir}"$'\n'
FAILED=1
fi
done < "${{ steps.lockfiles.outputs.lockfiles_file }}"
if [ "$FAILED" -eq 1 ]; then
echo ""
echo "========================================"
echo " ACTION REQUIRED: stale lockfile(s)"
echo "========================================"
echo ""
echo "Run the following command(s) locally, then commit the updated uv.lock file(s):"
echo ""
echo "$FAILED_DIRS"
echo "If you recently edited a pyproject.toml, make sure to run 'uv lock' in the"
echo "same directory before pushing."
echo ""
exit 1
fi
echo "[PASS] All lockfiles are up-to-date."
# ---------------------------------------------------------------------------
# Check 6: Every pyproject.toml (root, core/, contrib/) must have a sibling
# uv.lock. Only checked for pyproject.toml files touched in this PR (or all
# of them on push/dispatch). Skips config-only pyproject.toml files that have
# no [project] table and no [tool.uv] section.
# ---------------------------------------------------------------------------
- name: Check every pyproject.toml has a lockfile
run: |
set -euo pipefail
# On pull_request the base ref was already fetched in the Resolve step;
# no second git fetch needed.
if [ "${{ github.event_name }}" = "pull_request" ]; then
BASE_REF="${{ github.event.pull_request.base.ref }}"
CHANGED=$(git diff --name-only "origin/$BASE_REF"...HEAD)
# Include root pyproject.toml and any under core/ or contrib/
PYPROJECTS=$(echo "$CHANGED" | grep -E "^(pyproject\.toml|(core|contrib)/.*pyproject\.toml)$" || true)
else
PYPROJECTS=$(find pyproject.toml core contrib -name "pyproject.toml" 2>/dev/null || true)
fi
if [ -z "$PYPROJECTS" ]; then
echo "[SKIP] No pyproject.toml files in scope."
exit 0
fi
FAILED=0
while IFS= read -r pyproject; do
[ -z "$pyproject" ] && continue
[ -f "$pyproject" ] || continue # may have been deleted in the PR
HAS_PROJECT=$(grep -c '^\[project\]' "$pyproject" || true)
HAS_UV=$(grep -c '^\[tool\.uv' "$pyproject" || true)
if [ "$HAS_PROJECT" -eq 0 ] && [ "$HAS_UV" -eq 0 ]; then
continue # config-only, no lockfile expected
fi
dir=$(dirname "$pyproject")
if [ ! -f "$dir/uv.lock" ]; then
echo "[FAIL] $pyproject has no sibling uv.lock — run 'uv lock' in $dir"
FAILED=1
fi
done <<< "$PYPROJECTS"
if [ "$FAILED" -eq 1 ]; then
exit 1
fi
echo "[PASS] All in-scope pyproject.toml files have a sibling uv.lock."