ci: keyless OIDC release pipeline + SLSA L3 (staging twin of #28025) - #28038
ci: keyless OIDC release pipeline + SLSA L3 (staging twin of #28025)#28038lee-mcfaul2 wants to merge 17 commits into
Conversation
pytest-cov runs with --cov=litellm, which makes coverage.xml store paths relative to the package root (e.g. `proxy/proxy_server.py` instead of `litellm/proxy/proxy_server.py`). Codecov auto-resolves these only when the basename is unique in the repo. Files like proxy_server.py, router.py, utils.py, main.py, and constants.py — which have duplicates under enterprise/ or other subpackages — get silently dropped during ingest. The `fixes: ["::litellm/"]` rule prepends `litellm/` to every uploaded path so they resolve unambiguously. Confirmed against multiple recent coverage.xml artifacts that no uploader currently emits paths already prefixed with `litellm/`, so the rule is safe to apply universally. This restores Codecov visibility for the highest-fix-rate hotspots: proxy_server.py, router.py, proxy/utils.py, litellm_logging.py, constants.py, key_management_endpoints.py, utils.py, main.py, user_api_key_auth.py, team_endpoints.py, and litellm_pre_call_utils.py.
This reverts commit e25a988. The `fixes: ["::litellm/"]` rule turned out to be applied *after* Codecov's auto-resolution, not before. Files with unique basenames (which were auto-resolving correctly to `litellm/<path>`) got an extra `litellm/` prepended, producing `litellm/litellm/<path>` storage. Files with ambiguous basenames (the actual target of the fix) continued to be dropped because the auto-resolution still failed for them. Net result on the verification run: 1375 files now stored under unresolvable `litellm/litellm/...` paths, and the 11 originally-missing hotspots are still missing. Reverting before piling on further changes.
…decov pytest-cov treats --cov=<module-name> as a Python package and emits XML paths relative to the package root, stripping the litellm/ prefix (`proxy/proxy_server.py` instead of `litellm/proxy/proxy_server.py`). Codecov's auto-prefix heuristic then drops every file whose basename is ambiguous in the repo — `proxy_server.py` (3 copies under enterprise/), `router.py` (2 copies), `utils.py` (20+), `main.py` (20+), `constants.py` (2). The 11 highest-fix-rate hotspots have never appeared in Codecov. Switching to --cov=./litellm treats the argument as a path, which makes coverage.xml emit repo-relative paths (`litellm/proxy/proxy_server.py`). Each path is unambiguous, so Codecov resolves all files correctly. Verified locally: rerunning a single proxy_unit_tests test with --cov=./litellm produced `filename="litellm/proxy/proxy_server.py"`, `filename="litellm/router.py"`, and `filename="litellm/types/router.py"` as distinct entries — exactly the disambiguation Codecov needs. Touches every workflow that uploads coverage: the two reusable GHA workflows (_test-unit-base.yml, _test-unit-services-base.yml), test-mcp.yml, and all 14 invocations in .circleci/config.yml.
…itellm_/peaceful-jang-c0e43b
Remove available_on_public_internet gating from delegate-auth-to-upstream paths so oauth2 + delegate_auth_to_upstream interactive servers behave the same when marked internal. Keeps M2M exclusion. Updates tests.
Log verbose_logger.warning when loading oauth2 interactive servers with available_on_public_internet=false and delegate_auth_to_upstream=true (config + DB). Dashboard Alert for the same combo. CLAUDE note for operators. Tests for log and M2M skip.
Removes accidental duplicate alias/mcp_aliases and get_server_prefix logic (fixes PLR0915 and avoids resetting alias after mapping).
…erriAI#27936) _build_mcp_server_table omitted delegate_auth_to_upstream, so GET /v1/mcp/server always returned the default false while the registry kept the DB value. Co-authored-by: Cursor <cursoragent@cursor.com>
…el (BerriAI#27929) * feat(proxy): fix vector store retrieve/list/update/delete routing without model Co-authored-by: Cursor <cursoragent@cursor.com> * fix(proxy): remove unchecked query-param injection in vector store management endpoints Co-authored-by: Cursor <cursoragent@cursor.com> * test(proxy): use subset assertion for vector store route test to allow extra kwargs like shared_session Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com>
…ckBatchCost poller (BerriAI#27984) * fix(managed_batches): convert raw output_file_id to managed ID in CheckBatchCost poller CheckBatchCost bypasses async_post_call_success_hook, causing raw provider output_file_ids to be persisted in LiteLLM_ManagedObjectTable. This fix converts output_file_id and error_file_id to managed base64 IDs before the DB write. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(check_batch_cost): persist managed file before mutating response and propagate team_id - Move setattr after store_unified_file_id so the response only receives the managed ID once the DB record is successfully written. Avoids serializing an orphaned managed ID into file_object when the store call fails. - Populate team_id on the minimal UserAPIKeyAuth from job.team_id so the managed file record is created with the correct team ownership, allowing other team members to access the batch output file via /files/{id}/content. Co-authored-by: Yassin Kortam <yassin@berri.ai> * test(managed_batches): extend test to cover error_file_id conversion Co-authored-by: Cursor <cursoragent@cursor.com> * fix managed file test --------- Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: Yassin Kortam <yassin@berri.ai>
…BerriAI#27912) * fix(vertex-ai): fix zero cost/usage on completed Vertex AI batch jobs Vertex batch jobs recorded 0 spend and 0 tokens after PR BerriAI#25627 added automatic transformation of GCS predictions.jsonl to OpenAI format. Two bugs fixed: 1. batch_utils.py: the Vertex-specific cost/usage reader (calculate_vertex_ai_batch_cost_and_usage) was always invoked and reads raw usageMetadata fields that no longer exist in the OpenAI-shaped output. Now the reader is only used when disable_vertex_batch_output_transformation=True; otherwise the generic path handles the already-transformed OpenAI-shaped content. 2. cost_calculator.py: batch_cost_calculator skipped the global litellm.get_model_info() lookup when a model_info dict was passed in, even when that dict had no pricing fields (e.g. deployment metadata with only id/db_model). It now falls back to the global pricing table when the provided model_info has no pricing data. Co-authored-by: Cursor <cursoragent@cursor.com> * Update litellm/cost_calculator.py Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> * fix(cost-calculator): use not-any guard for pricing fallback in batch_cost_calculator Co-authored-by: Cursor <cursoragent@cursor.com> * fix(cost-calculator): treat explicit zero batch pricing as set in model_info The fallback to litellm.get_model_info() used truthy checks on pricing fields, so 0.0 was treated as missing and replaced by global rates. Use `is not None` like elsewhere in cost calculation. Add regression test. Co-authored-by: Sameer Kankute <Sameerlite@users.noreply.github.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> Co-authored-by: Sameer Kankute <Sameerlite@users.noreply.github.com>
…c0e43b ci: use --cov=./litellm so coverage paths resolve unambiguously in Codecov
…legate_pkce fix(mcp): delegate PKCE bypass for internal MCP servers
* Feat: Add Weighted-Routing Failover
* test(router): cover weighted failover helper functions
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(router): align weighted failover deployment list type with mypy
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(router): address greptile review on weighted failover
- Narrow exception swallowing in `_maybe_run_weighted_failover` to
`openai.APIError` so model failures defer to the regular fallback
while programming bugs (AttributeError/KeyError/TypeError) surface.
- Note async-only limitation of `enable_weighted_failover` in the
Router constructor docstring.
- Make the weighted distribution test less flaky (1000 iterations,
looser bound) and make the non-simple-shuffle test deterministic by
failing both deployments instead of relying on the latency strategy's
first pick.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(router): ensure weighted failover metadata persists in kwargs
The previous `kwargs.setdefault(metadata_variable_name, {}) or {}` returned
a brand-new dict whenever the existing metadata was falsy (empty dict or
None), so writes to `_failover_excluded_ids` never made it back into
`kwargs`. Multi-hop weighted failover then re-selected previously failed
deployments and exhausted `max_fallbacks` prematurely.
Explicitly assign a fresh dict into kwargs when metadata is missing so
mutations are visible to subsequent failover hops.
Co-authored-by: Yassin Kortam <yassin@berri.ai>
* test(router): regression for weighted failover metadata persistence
Asserts kwargs["metadata"]["_failover_excluded_ids"] is populated after
_maybe_run_weighted_failover, proving the metadata dict written by the
helper is the same object that lives in kwargs (no disconnected copy).
Pairs with the prior fix that replaced `setdefault(..., {}) or {}` with
an explicit get/assign so writes survive across hops.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(router): harden weighted failover error/state handling
- Catch RouterRateLimitError (ValueError) alongside openai.APIError in
_maybe_run_weighted_failover so an exhausted intra-group retry falls
through to the regular cross-group fallback path instead of bubbling
out and bypassing configured fallbacks.
- Stop mutating the shared input_kwargs dict; build a local copy with
the weighted-failover keys so the entry (with _excluded_deployment_ids)
cannot leak into later fallback paths reading the same dict.
- _get_excluded_filtered_deployments now returns an empty list when the
exclusion filter removes every healthy deployment, instead of falling
back to the original list. The original-list behavior risked re-picking
the just-failed deployment; callers already handle the empty case by
raising their no-deployments error, which weighted failover now catches
and converts into a normal cross-group fallback.
Co-authored-by: Yassin Kortam <yassin@berri.ai>
* fix(router): fall through to rpm/tpm when total weight is zero
When the weight metric's total is zero (e.g. after weighted-failover
exclusion leaves only zero-weight backups), continue to the next metric
(rpm/tpm) instead of returning a uniform random pick immediately. This
lets rpm/tpm still drive routing when present, and only falls back to
the uniform random pick at the end if no metric provides a positive
total weight.
Co-authored-by: Yassin Kortam <yassin@berri.ai>
* fix(router): skip weighted failover when remaining deployments are all in cooldown
_maybe_run_weighted_failover was computing 'remaining' from all_deployments
(every deployment in the model group, including those in cooldown). This meant
that when all non-excluded deployments were in cooldown the method still invoked
run_async_fallback unnecessarily, which propagated into async_get_healthy_deployments,
found no eligible deployments, and raised RouterRateLimitError — only safely
caught thanks to the earlier exception-broadening fix.
The fix: before computing 'remaining', fetch the current cooldown set via
_async_get_cooldown_deployments and subtract it from all_ids. This allows
_maybe_run_weighted_failover to return None immediately (skipping the
run_async_fallback call entirely) when every non-failed deployment is in cooldown,
letting the caller fall through to the correct cross-group fallback path without
the wasteful extra round-trip.
Tests added:
- unit: _maybe_run_weighted_failover returns None without calling run_async_fallback
when all remaining deployments are in cooldown
- unit: _maybe_run_weighted_failover still calls run_async_fallback when at least
one healthy (non-cooldown) deployment is available
- integration: end-to-end fallthrough to cross-group fallback when remaining
deployments are in cooldown
Co-authored-by: Sameer Kankute <Sameerlite@users.noreply.github.com>
---------
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Yassin Kortam <yassin@berri.ai>
Co-authored-by: Sameer Kankute <Sameerlite@users.noreply.github.com>
BerriAI#27976) * fix(bedrock-mantle): use /anthropic/v1/messages path for Mantle endpoint (BerriAI#27943) * docs: add one-line docstring to _disable_debugging (BerriAI#27894) Squash-merged by litellm-agent from oss-agent-shin's PR. * Add jp. Bedrock cross-region inference profile for claude-sonnet-4-6 (BerriAI#27831) Squash-merged by litellm-agent from Cyberfilo's PR. * Sanitize empty text content blocks on /v1/messages (BerriAI#27832) Squash-merged by litellm-agent from Cyberfilo's PR. * fix(bedrock-mantle): use /anthropic/v1/messages path for Mantle endpoint The bedrock-mantle gateway (Claude Mythos Preview) serves the Anthropic Messages API at /anthropic/v1/messages; /v1/messages returns 404 Not Found. Both AmazonMantleConfig (chat/completions caller route) and AmazonMantleMessagesConfig (anthropic-messages caller route) hardcoded the wrong path, so every Mantle request 404'd before reaching the model. Per the Anthropic docs: "[Claude in Amazon Bedrock] uses the Messages API at /anthropic/v1/messages with SSE streaming." https://platform.claude.com/docs/en/api/claude-on-amazon-bedrock Confirmed independently against the live endpoint: /v1/chat/completions -> 200 OK /v1/messages -> 404 Not Found (what litellm used) /anthropic/v1/messages -> 200 OK (Claude only) Adds a regression test asserting both Mantle configs build the /anthropic/v1/messages path, and updates the existing assertions that encoded the wrong path. --------- Co-authored-by: oss-agent-shin <ext-agent-shin@berri.ai> Co-authored-by: Filippo Menghi <113345637+Cyberfilo@users.noreply.github.com> * fix: sanitize empty text blocks in sync anthropic_messages_handler path Co-authored-by: Yassin Kortam <yassin@berri.ai> --------- Co-authored-by: João Costa <13508071+jpv-costa@users.noreply.github.com> Co-authored-by: oss-agent-shin <ext-agent-shin@berri.ai> Co-authored-by: Filippo Menghi <113345637+Cyberfilo@users.noreply.github.com> Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: Yassin Kortam <yassin@berri.ai>
Publishes LiteLLM to PyPI and GHCR entirely over OIDC, with no long-lived signing keys or registry credentials: - PyPI: OIDC Trusted Publisher upload with PEP 740 attestations, SLSA L3 build provenance via actions/attest-build-provenance, and detached keyless cosign signatures on the sdist and wheel. - Docker: a reusable build-push-sign workflow for the three images (litellm, -database, -non-root), keyless cosign signing, and SLSA L3 provenance attached as an OCI referrer. - Consumer-style verify jobs that re-check every signature and attestation the way a downstream user would (gh attestation verify, cosign verify), so a broken pipeline fails loudly. - A regression test enforcing the supply-chain invariants: SHA-pinned actions, keyless-only, OIDC-only, no static key. The static cosign.pub is removed; keyless verification roots in Fulcio/Rekor, not a checked-in public key.
|
|
Greptile SummaryThis PR adds a fully keyless OIDC release pipeline for both PyPI and GHCR, removes the static
Confidence Score: 3/5The release pipeline hardening is well-constructed, but the MCP server auth change is a backwards-incompatible relaxation of an access boundary with no opt-in flag, and the tests that previously caught it have been inverted. The core release pipeline work (OIDC publish, keyless signing, SLSA provenance, SHA-pinned actions) is thorough and well-tested. The unrelated code changes (Bedrock URL fix, Anthropic message sanitization, batch cost gating) are small and low-risk. However, the MCP server manager change removes an explicit access boundary — available_on_public_internet=False servers are now reachable by anonymous PKCE callers — without a feature flag to preserve existing behaviour. The two tests that enforced the old boundary have been renamed and their assertions reversed rather than the code being fixed, eliminating the regression net. Deployments that relied on that flag to restrict internal MCP servers to authenticated-only callers will be silently exposed after merging. litellm/proxy/_experimental/mcp_server/mcp_server_manager.py and tests/test_litellm/proxy/_experimental/mcp_server/auth/test_user_api_key_auth_mcp.py warrant close review before merging.
|
| Filename | Overview |
|---|---|
| .github/workflows/publish_to_pypi.yml | New OIDC-only PyPI publish pipeline with SLSA L3 provenance, PEP 740 attestations, and keyless cosign signatures; all actions SHA-pinned; missing fork-repo guard present in the Docker counterpart |
| .github/workflows/_publish-container.yml | New reusable container build/sign workflow; keyless cosign, SLSA provenance via attest-build-provenance, failure cleanup, all actions SHA-pinned; looks correct |
| .github/workflows/release-docker.yml | New Docker release orchestrator with fork guard, tag/commit-hash verification, and consumer-style verify-all matrix job; well-structured |
| .github/workflows/create-release.yml | Release body updated from keyful cosign verification instructions to keyless gh-attestation + cosign verify-blob commands; no functional pipeline changes |
| litellm/proxy/_experimental/mcp_server/mcp_server_manager.py | Removes the available_on_public_internet guard from anonymous delegate-server allow-list, allowing internal-only PKCE servers to be reached by anonymous callers; replaces blocking check with a warning log only |
| tests/test_litellm/proxy/_experimental/mcp_server/auth/test_user_api_key_auth_mcp.py | Two security invariant tests renamed and assertions inverted to match the new permissive behavior rather than enforcing the original boundary |
| tests/test_litellm/test_release_workflow_hardening.py | New regression test suite locking in supply-chain invariants (SHA-pinned actions, keyless-only signing, no slsa-github-generator, no static cosign key, consistent cosign version) |
| litellm/router.py | Adds enable_weighted_failover flag and _maybe_run_weighted_failover for intra-group retry on simple-shuffle; stamps failed_deployment_id on exceptions and filters excluded deployments from healthy lists |
| litellm/llms/anthropic/common_utils.py | Adds strip_empty_text_blocks_from_anthropic_messages helper to remove empty/whitespace text blocks that cause 400s on the native Anthropic Messages path |
| litellm/llms/bedrock/chat/mantle/transformation.py | Corrects Bedrock Mantle endpoint URL from /v1/messages to /anthropic/v1/messages |
| litellm/batches/batch_utils.py | Gates the Vertex AI batch output path behind disable_vertex_batch_output_transformation flag so standard cost calculation runs by default |
| litellm/utils.py | Moves get_secret import from TYPE_CHECKING block to a real top-level import; adds _get_excluded_filtered_deployments utility for weighted failover |
Comments Outside Diff (2)
-
litellm/proxy/_experimental/mcp_server/mcp_server_manager.py, line 989-999 (link)Auth boundary removed: internal-only servers now reachable by anonymous callers
The
and getattr(server, "available_on_public_internet", True)guard that previously blockedavailable_on_public_internet=Falseservers from the anonymousdelegate_server_idsallow-list has been removed. Operators who setavailable_on_public_internet=Falsespecifically to prevent unauthenticated internet callers from reaching an internal MCP server will now find that any anonymous client can initiate the PKCE flow and access the upstream OAuth2/authorizeendpoint — the only mitigation is a warning log.The original inline comment ("Internal-only servers must not be reachable from public internet callers who happen to carry an upstream token.") was removed alongside the guard. This is a backwards-incompatible change to the authentication layer with no opt-in flag, and the two tests that enforced the old behavior (
test_delegate_ignored_for_non_public_server,test_get_allowed_servers_excludes_non_public_delegate) have had their assertions inverted rather than the code fixed. If this relaxation is intentional, it should require an explicit opt-in flag (e.g.allow_internal_delegate_anonymous_pkce: true) so existing deployments that rely on theavailable_on_public_internet=Falseguard are not silently broken.Rule Used: What: Fail any PR which may contains a security in... (source)
-
tests/test_litellm/proxy/_experimental/mcp_server/auth/test_user_api_key_auth_mcp.py, line 1491-1539 (link)Security test inverted — previously-failing behavior now asserted as correct
test_delegate_ignored_for_non_public_serverhas been renamedtest_delegate_bypass_for_internal_serverand its assertion reversed: the old test verified that calling an internal-only delegate server raises401, the new test verifies that it succeeds with no auth. The companion testtest_get_allowed_servers_excludes_non_public_delegate→test_get_allowed_servers_includes_internal_delegatesimilarly flips the assertion fromassert "internal-server" not in resulttoassert "internal-server" in result. These tests existed to lock in a security invariant; replacing their bodies to match the new (more permissive) behavior removes the regression net entirely.Rule Used: What: Flag any modifications to existing tests and... (source)
Reviews (1): Last reviewed commit: "ci: add OIDC-rooted keyless release pipe..." | Re-trigger Greptile
| name: Publish to PyPI | ||
|
|
||
| on: | ||
| workflow_dispatch: | ||
|
|
||
| jobs: | ||
| preflight-checks: | ||
| name: Preflight Checks | ||
| runs-on: ubuntu-latest | ||
| timeout-minutes: 10 |
There was a problem hiding this comment.
No fork-repository guard on the workflow
release-docker.yml has if: github.repository == 'BerriAI/litellm' on its preflight job to prevent spurious runs in forks. publish_to_pypi.yml has no equivalent guard. In a fork, a manual workflow_dispatch will run preflight-checks, build, publish-litellm (which will fail at the OIDC step since the Trusted Publisher isn't configured), and verify-slsa — burning CI minutes and potentially producing noisy failure notifications. The PyPI publish itself cannot succeed without the Trusted Publisher binding, so there is no credential risk, but the missing guard is worth aligning with the Docker workflow for consistency.
|
Closing this in favour of #28025, which carries the identical change against the default |
PR overviewMedium: Internal MCP OAuth endpoints bypass network gatingThis PR changes MCP upstream-delegated OAuth so internal-only servers can bypass LiteLLM auth. The streamable MCP routes still apply IP filtering later, but the management OAuth authorize/token endpoints can resolve an internal server by ID without applying that same public/internal check. Security review
Risk: 6/10 |
| @@ -1578,7 +1578,6 @@ async def _mcp_oauth_user_api_key_auth(request: Request) -> UserAPIKeyAuth: | |||
| _s | |||
| and getattr(_s, "auth_type", None) == MCPAuth.oauth2 | |||
| and getattr(_s, "delegate_auth_to_upstream", False) is True | |||
There was a problem hiding this comment.
Medium: Internal MCP OAuth endpoints bypass network gating
An unauthenticated external caller who knows an internal server ID can hit /server/oauth/{server_id}/authorize and /token for a delegate_auth_to_upstream server because this bypass no longer checks available_on_public_internet, and _get_cached_temporary_mcp_server_or_404 resolves get_mcp_server_by_id() without applying the IP filter. Please gate this anonymous OAuth bypass with the same filter_server_ids_by_ip / _is_server_accessible_from_ip check used by MCP tool routes, or keep internal-only delegate servers behind normal LiteLLM auth on these endpoints.
Summary
This adds a fully public, keyless release pipeline for both distribution channels:
litellm,litellm-database, andlitellm-non-root.gh attestation verify,cosign verify), so a broken or unsigned release fails loudly instead of shipping.tests/test_litellm/test_release_workflow_hardening.py) that locks the supply-chain invariants in place (SHA-pinned actions only, keyless only, OIDC only, tag-scoped identities, no static key).Addresses #24524 (which requested cosign + SLSA for the GHCR images). This PR delivers that and extends the same guarantees to the PyPI side.
Why this way?
This is meant to complement the post-incident CI/CD v2 hardening, not relitigate the March 2026 incident. Per the project's own security advisory and the incident timeline, the root cause was: a compromised CI dependency exfiltrated the publishing credential, which was then used to upload
1.82.7/1.82.8to PyPI directly, bypassing the official workflow.Two structural properties follow from that root cause, and this pipeline is built around both:
There is no publishing credential to steal. PyPI upload uses an OIDC Trusted Publisher and GHCR uses the ephemeral, job-scoped
GITHUB_TOKEN. There is no long-livedPYPI_PUBLISHtoken, API key, or password in repository secrets or the workflow environment. The exact asset that was exfiltrated in the incident does not exist in this design — auth is a short-lived OIDC token minted at runtime and scoped to the specific workflow run.An out-of-band artifact is detectable. Even setting credentials aside, every artifact carries SLSA provenance and a keyless signature that cryptographically bind it to a specific public workflow run, repository, and commit SHA. A package that was not built by the public pipeline fails
gh attestation verify/cosign verifyfor downstream users who check.This is the same class of vector that enabled the xz-utils backdoor (CVE-2024-3094): a maintainer directly published a doctored release source tarball that did not correspond to the git tree — the obfuscated payload sat in test fixtures, and the trigger was an autotools macro (
build-to-host.m4) carried only in the tarball, not in the repository, that extracted and linked it during./configure. Source review of the repo could not catch it because the released artifact was never required to match a provenance-attested build of a specific public commit. Provenance over the build output — the property this PR adds — makes "a published artifact that doesn't match a public build from a known commit" a verification failure rather than something invisible until someone diffs the tarball by hand.On the current signing approach: the post-incident control is a long-lived static cosign key (
cosign.pub, introduced in0112e53, documented ascosign verify --key …/cosign.pub). A static key proves only that a holder of the key signed the blob — it does not bind the artifact to a commit or a build, and a key holder is not necessarily the public CI. It is, in that sense, equivalent to a detached GPG signature. It also reintroduces the same category of liability as the incident: a long-lived secret that is valuable to exfiltrate, with no built-in revocation path. Keyless signing removes the key entirely — the signing identity is an ephemeral Fulcio certificate tied to the workflow's OIDC identity, logged in the public Rekor transparency log, and verifiable offline against the Sigstore TUF root. This PR therefore also removescosign.puband updates the README to the keyless verification commands.Taken together, these are two independent exposures that the current posture leaves open at the same time: without artifact-to-build provenance, an xz-class substitution — a published artifact that never corresponded to a public build — is invisible to anyone verifying downstream; and a long-lived signing key alongside a long-lived publish token keeps the credential-theft path that caused the March incident available. Neither control substitutes for the other, which is why this is one cohesive change: OIDC removes the stealable credential, and keyless + provenance makes any out-of-band artifact fail verification. Closing only one of the two leaves the other fully open.
What this delivers
PyPI (
publish_to_pypi.yml)pypa/gh-action-pypi-publish— nopassword:/token.attestations: true), and the workflow asserts the attestation actually landed on PyPI.actions/attest-build-provenance.verify-slsajob that re-verifies provenance + signatures consumer-style.GHCR (
release-docker.yml+ reusable_publish-container.yml)push-to-registry).verify-allmatrix job that re-verifies each image (cosign verify+gh attestation verify oci://…).Both
curl | bash. (This is also the class of weakness — an unpinned CI tool — that was the incident's entry point.)Verify it yourself
During development this pipeline was run end-to-end on a public fork, using temporary repository-gated test scaffolding that has since been removed from the final workflows. Those runs, logs, and signed artifacts remain public and independently re-verifiable:
lee-mcfaul2/litellm@80859952da(rebased onto currentlitellm_internal_staging).Anyone can independently verify a published fork image:
Integration notes
publish_to_pypi.yml, which was removed in3f6c0090c0("remove unused GitHub Actions workflows"). The new file is a hardened replacement, not the old one — flagging explicitly that the modify/delete is intentional, not an accident.shin_agent_oss_staging_05_16_2026— the dated daily staging branch the auto-merge agent on ci: keyless OIDC release pipeline + SLSA L3 provenance (closes #24524) #28025 asked the branch to be opened against. These dated branches roll daily; this one reflects the agent's request at the time of opening. The branch has not been rebased onto the staging tip (per the note below, the choice of which base to land on is left to the maintainers), so GitHub may report divergence/conflicts against this moving branch — that is expected and not a content issue with the change itself.pypi-publish, projectlitellm, default (real) PyPI, workflow filenamepublish_to_pypi.yml, and the existinglitellm-proxy-extraspre-check. The Trusted Publisher binding is(owner, repo, workflow-filename, environment)— all four match the prior configuration, so this slots into the existing PyPI-side setup with no target changes. The only deltas are additive hardening.litellmmust still list workflowpublish_to_pypi.yml+ environmentpypi-publish; thepypi-publish/docker-releaseGitHub environments must exist. GHCR works automatically viaGITHUB_TOKEN.BerriAI/litellm— nothing to strip, gate, or refactor on merge.README.mdis updated to the keyless verification commands. The user-facing docs site is maintained in the separateBerriAI/litellm-docsrepository, so updating any keyful instructions there is a small follow-up doc PR against that repo rather than part of this change.Scope (intentionally not included)
create-release.yml's release-branch flow beyond updating the embedded verification instructions to match the as-built pipeline.enable-docker-hub: false); GHCR is the primary channel.Why there are two PRs
This PR and #28025 are the same branch and the same commit — they differ only in base branch:
litellm_internal_staging(the repository's default branch).shin_agent_oss_staging_05_16_2026, because the auto-merge agent on ci: keyless OIDC release pipeline + SLSA L3 provenance (closes #24524) #28025 reported it could not proceed unless the change was opened against the dated daily staging branch and asked for exactly that.Rather than force-rebasing one branch onto a target that rolls every day, both bases are offered so a maintainer can simply merge whichever fits the current release flow and close the other. No preference on which — they carry identical changes. Happy to rebase, retarget, or close either on request.
I'm happy to discuss and clarify.