
PyPI's 14-Day File-Lock Rule — What It Actually Closes
Since July 2026, PyPI has quietly rejected a specific kind of upload: adding a new file to a release that's already more than 14 days old. The version itself, its metadata, and every file already attached to it are untouched. What stops working is reaching back into a release two weeks (or two years) after you shipped it and sliding one more wheel or sdist into that same version. If your CI does this — and a surprising number of pipelines do, usually without anyone realizing it's load-bearing — this is the week it starts failing, and the fix is a one-line habit change, not a rewrite.
Why PyPI did this
This wasn't a proactive hardening exercise. It followed a run of real incidents in March 2026, all attributed to the same threat actor, TeamPCP, that showed exactly how dangerous "push one more file to an old release" is when a publishing credential leaks.
Trivy was compromised first, on March 19, 2026. Investigators believe credentials stolen there were reused in the next two incidents.
LiteLLM was hit on March 24. Two malicious releases, litellm==1.82.7 and litellm==1.82.8, were uploaded straight to PyPI — not through the project's normal CI/CD pipeline — and sat live for roughly 40 minutes before PyPI quarantined them. The payload lived in proxy_server.py and was built to steal credentials; 1.82.8 added a .pth file so the payload would trigger on any Python startup on an affected machine, not just when someone actually imported the compromised module. Anyone running the official LiteLLM Proxy Docker image, which pins dependencies from a lockfile instead of resolving live, was never exposed.
Telnyx followed on March 27. Versions 4.87.1 and 4.87.2 of the telnyx SDK — a package researchers estimate pulls around 30,000 downloads a day — shipped code injected into telnyx/_client.py that fetched a second-stage credential harvester hidden inside a WAV audio file via steganography. Same pattern: the source repository stayed clean, the trojaned build went directly to the index.
The common thread across all three: nobody needed to compromise a maintainer's laptop or poison a dependency's source code. A single leaked publishing token was enough to reach into the package index directly and attach a malicious file to a project with years of accumulated trust behind its name — including releases everyone assumed were long since finalized and stable.
What the rule does and doesn't do
PyPI's response, formalized after a pypi/warehouse change merged on July 8 and announced publicly on July 22, is narrow and specifically scoped at that attack:
- Blocked: uploading any new file (wheel, sdist, anything) to a release whose first file was uploaded more than 14 days ago. This applies regardless of auth method — a scoped API token doesn't get an exception.
- Untouched: the release's existing files, its metadata, yanking a release, and publishing a brand-new version. If you need to ship something today, you can always cut
1.4.2— you just can't reach back and modify what1.4.1was.
PyPI's own analysis, cited when the change rolled out, found only 56 of the top 15,000 packages had been doing this deliberately and legitimately — mostly projects adding a Python 3.14-compatible wheel to an already-released version instead of cutting a new patch release for it. That's the one common legitimate pattern this rule breaks, and PyPI's own guidance is explicit about the fix: stop doing that, and cut a new version instead. PyPI currently treats this as a temporary measure with no formal spec-level semantics; it may get formalized later under PEP 694.
Who this actually breaks
If your project has ever done any of the following, you've been relying on behavior that no longer exists:
- A release script that re-triggers on an old git tag to "backfill" a missing wheel for a platform or Python version you didn't build for at release time.
- A hotfix process that patches a
setup.pytypo or metadata field by rebuilding and re-uploading the same version number weeks later. - Any workflow where "re-run the release job" on an old tag is the documented fix for "we forgot a platform wheel."
A complete worked example: the pattern that breaks, and the fix
Here's a realistic GitHub Actions job that used to "work" — re-running a release workflow against an old tag to backfill a missing wheel — and what it looks like after this change, where the only correct option is a new version.
Before — relies on re-uploading to an old release (now rejected after 14 days):
# .github/workflows/backfill-wheel.yml
name: Backfill missing platform wheel
on:
workflow_dispatch:
inputs:
tag:
description: "Existing git tag to backfill, e.g. v2.3.0"
required: true
jobs:
build-and-upload:
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
steps:
- uses: actions/checkout@v4
with:
ref: ${{ inputs.tag }} # checks out the OLD tag, unchanged
- uses: actions/setup-python@v5
with:
python-version: "3.14"
- name: Build wheel for the platform we missed the first time
run: |
pip install build
python -m build --wheel
- name: Upload the new wheel to the SAME existing release
run: |
pip install twine
# This is the step that now fails with a 403 once v2.3.0
# is more than 14 days old — PyPI rejects the new file
# even though the version number (2.3.0) is unchanged.
twine upload --skip-existing dist/*.whlRun that against a tag from six weeks ago today and twine upload comes back with an HTTP 400/403 from PyPI's upload API, not a timeout or a flaky-CI retry candidate. That's the new rule doing exactly what it's supposed to do.
After — cut a real patch version instead of touching the old one:
# .github/workflows/release-patch.yml
name: Release patch for missing platform wheel
on:
push:
tags:
- "v*.*.*" # triggered by a NEW tag, e.g. v2.3.1 — never an old one
jobs:
build-and-upload:
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.14"
- name: Build wheel for this platform, this version
run: |
pip install build
python -m build --wheel
- name: Upload to a brand-new release
run: |
pip install twine
twine upload dist/*.whl# Locally, bump the version instead of reusing the old one
# e.g. in pyproject.toml: version = "2.3.0" -> "2.3.1"
git tag v2.3.1
git push origin v2.3.1
# CI builds and uploads 2.3.1 as a brand-new release — the only
# path PyPI's upload API still allows for "we're missing a wheel"The difference is one line of process, not of code: when you're missing a wheel for an old version, the answer as of mid-2026 is always "cut a new version," never "reopen the old one." If your release tooling has a "backfill" or "republish" mode that targets an existing tag, that mode is now dead weight — worth deleting outright so nobody reaches for it during an incident and gets a confusing 400 at 2am.
The actual point of the change
This rule doesn't stop someone with a stolen token from publishing a malicious new version — it was never meant to. What it closes is the quieter, more dangerous move: reaching back into a release that's already earned trust, that users and lockfiles already treat as a known-good pin, and attaching something new to it without bumping the version anyone would notice. LiteLLM's and Telnyx's compromised releases were both treated internally as "the thing we already have installed" right up until they weren't. A 14-day window doesn't stop a determined attacker with a fresh token — they'll just publish a new version, same as anyone else — but it does close off the specific trick of poisoning something that already looked settled.

Experienced DevOps Engineer proficient in Python, Docker, Kubernetes, CI/CD, and cloud technologies. Specialized in automating processes and building scalable infrastructure for seamless development and deployment.


