ci: add supply-chain guard to block fork PRs that modify dependencies - #26511
Conversation
Add a new CI workflow that rejects pull requests from forks when they:
- Modify uv.lock (any change at all)
- Add new dependencies to any pyproject.toml file (root, litellm-proxy-extras, enterprise)
Security properties:
- Uses pull_request (not pull_request_target) so no secrets are exposed
- All action refs pinned to full SHA hashes
- persist-credentials: false on all checkouts
- permissions: {} (no GitHub token permissions)
- No user-controlled input in run: blocks (no script injection)
- Proper TOML parsing via stdlib tomllib (not regex on raw text)
- Only triggers when dependency files are actually changed (paths filter)
Internal PRs (from branches in the canonical repo) skip the job entirely.
Co-authored-by: Krrish Dholakia <krrish-berri-2@users.noreply.github.com>
|
|
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
Low: CI-only change with no security issuesThis PR adds a GitHub Actions workflow that blocks fork PRs from modifying dependency files ( Status: 0 open Posted by Veria AI · 2026-04-25T19:50:46.405Z |
Greptile SummaryThis PR adds a new GitHub Actions workflow that blocks fork PRs from introducing new Python dependencies or modifying the
Confidence Score: 4/5Safe to merge; all three issues are P2 edge cases that fail-safe (the check blocks rather than permits) and are unlikely to occur against litellm's real pyproject files. Only P2 findings present — the workflow's security invariants are solid and it cannot be bypassed. The gaps cause confusing error messages or false-positive failures in rare edge cases, not security holes. .github/workflows/guard-fork-dependencies.yml — the
|
| Filename | Overview |
|---|---|
| .github/workflows/guard-fork-dependencies.yml | New supply-chain guard workflow — well-structured overall (SHA-pinned actions, no secrets, pull_request trigger) but has three edge-case logic gaps: misleading error when uv.lock is absent, unhandled pr_file-deleted scenario in check_deps, and a false-positive comm comparison when the PR pyproject.toml has zero dependencies. |
Flowchart
%%{init: {'theme': 'neutral'}}%%
flowchart TD
A[pull_request event] --> B{Is fork PR?\nhead.repo != github.repository}
B -- No --> C[Skip job entirely\ninternal PR unaffected]
B -- Yes --> D{paths filter:\nuv.lock or pyproject.toml changed?}
D -- No --> E[Workflow not triggered]
D -- Yes --> F[Checkout base branch → ./base]
F --> G[Checkout PR head → ./pr]
G --> H{diff base/uv.lock\nvs pr/uv.lock}
H -- Same --> I[uv.lock ✅]
H -- Different or missing --> J[❌ exit 1 Lockfile changed]
I --> K[extract_deps.py: parse base pyproject.toml files]
K --> L[extract_deps.py: parse PR pyproject.toml files]
L --> M{comm -13\nnew names in PR?}
M -- None --> N[✅ All checks pass]
M -- New names found --> O[❌ exit 1 New dependencies blocked]
Reviews (1): Last reviewed commit: "ci: add supply-chain guard to block fork..." | Re-trigger Greptile
| if [ ! -f "$base_file" ] && [ ! -f "$pr_file" ]; then | ||
| return 0 | ||
| fi | ||
|
|
||
| if [ ! -f "$base_file" ] && [ -f "$pr_file" ]; then | ||
| echo "::error::Fork PR introduces a new $label that does not exist on the base branch." | ||
| return 1 | ||
| fi |
There was a problem hiding this comment.
Missing guard for deleted
pr_file
When a fork PR deletes a pyproject.toml that exists on the base branch, base_file is present but pr_file is absent. Neither early-return guard covers this case (! -f base && ! -f pr or ! -f base && -f pr), so execution falls through to python3 /tmp/extract_deps.py "$pr_file", which raises FileNotFoundError. With set -euo pipefail active the shell exits immediately without printing a ::error:: annotation, producing a confusing job failure instead of a clear message.
| if [ ! -f "$base_file" ] && [ ! -f "$pr_file" ]; then | |
| return 0 | |
| fi | |
| if [ ! -f "$base_file" ] && [ -f "$pr_file" ]; then | |
| echo "::error::Fork PR introduces a new $label that does not exist on the base branch." | |
| return 1 | |
| fi | |
| if [ ! -f "$base_file" ] && [ ! -f "$pr_file" ]; then | |
| return 0 | |
| fi | |
| if [ ! -f "$base_file" ] && [ -f "$pr_file" ]; then | |
| echo "::error::Fork PR introduces a new $label that does not exist on the base branch." | |
| return 1 | |
| fi | |
| if [ -f "$base_file" ] && [ ! -f "$pr_file" ]; then | |
| echo "$label: file removed in PR — no new dependencies possible." | |
| return 0 | |
| fi |
| run: | | ||
| if ! diff -q base/uv.lock pr/uv.lock >/dev/null 2>&1; then | ||
| echo "::error::Fork PRs must not modify uv.lock. Dependency lockfile changes must come from a branch in the canonical repository." | ||
| exit 1 | ||
| fi | ||
| echo "uv.lock is unchanged." |
There was a problem hiding this comment.
diff exit code 2 triggers misleading "must not modify uv.lock" error
diff exits with status 2 (not 1) when one of the compared paths does not exist. Because the step uses ! diff ... 2>&1, any missing file (e.g. base/uv.lock absent on a branch that predates lockfile tracking) causes the condition to be true and emits "Fork PRs must not modify uv.lock" — a misleading message that hides the real cause.
| run: | | |
| if ! diff -q base/uv.lock pr/uv.lock >/dev/null 2>&1; then | |
| echo "::error::Fork PRs must not modify uv.lock. Dependency lockfile changes must come from a branch in the canonical repository." | |
| exit 1 | |
| fi | |
| echo "uv.lock is unchanged." | |
| if [ ! -f base/uv.lock ] && [ ! -f pr/uv.lock ]; then | |
| echo "uv.lock does not exist in either tree — skipping check." | |
| elif ! diff -q base/uv.lock pr/uv.lock >/dev/null 2>&1; then | |
| echo "::error::Fork PRs must not modify uv.lock. Dependency lockfile changes must come from a branch in the canonical repository." | |
| exit 1 | |
| else | |
| echo "uv.lock is unchanged." | |
| fi |
| base_deps=$(python3 /tmp/extract_deps.py "$base_file") | ||
| pr_deps=$(python3 /tmp/extract_deps.py "$pr_file") | ||
|
|
||
| new_deps=$(comm -13 <(echo "$base_deps") <(echo "$pr_deps")) |
There was a problem hiding this comment.
echo "$empty_var" injects a blank line into comm
When pr_deps is empty (a pyproject.toml with no tracked dependencies), echo "$pr_deps" emits a single newline that comm treats as an empty-string entry. Since "" sorts before any real package name, comm -13 sees it as a line present only in the PR stream and sets new_deps to a non-empty value (just whitespace), which causes [ -n "$new_deps" ] to fire — a false-positive "new dependency" failure.
| new_deps=$(comm -13 <(echo "$base_deps") <(echo "$pr_deps")) | |
| new_deps=$(comm -13 <(printf '%s\n' $base_deps | sort) <(printf '%s\n' $pr_deps | sort)) |
…dencies-eec1 ci: add supply-chain guard to block fork PRs that modify dependencies
Relevant issues
Supply-chain security hardening for dependency files.
Pre-Submission checklist
tests/test_litellm/directory, Adding at least 1 test is a hard requirement - see detailsmake test-unit@greptileaiand received a Confidence Score of at least 4/5 before requesting a maintainer reviewType
🚄 Infrastructure
Changes
Adds a new GitHub Actions workflow (
guard-fork-dependencies.yml) that blocks pull requests from forks when they:uv.lock— any change to the lockfile from a fork is rejected outrightpyproject.toml— checks root,litellm-proxy-extras/, andenterprise/pyproject.toml files using proper TOML parsing (stdlibtomllib) to detect newly introduced package namesSecurity properties
pull_request(notpull_request_target) — no secrets exposed to fork codeuses:refs pinned to full SHA hashespersist-credentials: falseon all checkoutspermissions: {}at workflow level (no GitHub token permissions)title,body,branch, etc.) interpolated inrun:blocksuv.lockorpyproject.tomlfiles are actually modifiedHow it works
if:condition skips it entirely for internal PRs (same-repo branches), so it never blocks maintainer workuv.lockwithdiff[project].dependencies,[project.optional-dependencies],[dependency-groups]) usingtomlliband PEP 508 name parsingValidation
pyproject.tomlfiles in the repocomm-based diff logic verified with both positive (new dep detected) and negative (identical deps) casesactionlint(zero errors)Note: This is a CI-only change (new workflow file). No code tests are affected — the workflow itself only runs on fork PRs that modify dependency files.
Slack Thread