This is the written version of a talk given at PyCon US 2026.
The path a Python wheel takes from a git tag to a pip install increasingly runs through a single hosted runner executing a YAML file, which is the slightly long-winded way of saying that your GitHub Actions workflow has become part of PyPI's security model. There are 386,957 PyPI packages with a public GitHub repository in the ecosyste.ms index at the moment, of which 152,318 have at least one Actions workflow, and 56,490 of those reference pypa/gh-action-pypi-publish somewhere in the repository. Travis, CircleCI, and Azure Pipelines together account for the single-digit-percent remainder.
PyPI now accepts short-lived OIDC tokens minted by Actions in place of long-lived API tokens, with the publisher configuration on PyPI's side naming the repository, the workflow file, and optionally an environment, so the workflow's identity is the credential that ships your release. I think trusted publishing is one of the better things to happen to Python packaging in years, and about 22% of the 56,490 repositories publishing through pypa/gh-action-pypi-publish have migrated to it, leaving 44,181 still uploading with a stored PYPI_API_TOKEN that I'd like to see follow. The trusted-publishing model also means PyPI's trust in a release now rests on the integrity of an Actions run, which is the argument I made in more detail in GitHub Actions is the weakest link earlier this year. PEP 740 attestations, Sigstore, and SLSA provenance all verify that an artifact came from a particular workflow at a particular commit; none of them verify that nothing tampered with the workflow before the upload step ran. The signing step is the last thing that happens in the pipeline, with every preceding step in scope for an attacker who can reach any of them.
The other thing that gives me a tractable view of all this is zizmor, William Woodruff's static analyser for Actions workflows, which reads .github/workflows/ and reports findings as named audits with severity and confidence. The findings below come from running it across the entire PyPI corpus and joining the output back to the ecosyste.ms dataset; the tooling is at github.com/andrew/pycon and the scan it draws on ran 9-11 May 2026 with zizmor pinned to 1.24.1.
If you squint at it the right way, a uses: line is a dependency declaration, which is the longer argument I made in GitHub Actions is a Package Manager at the end of last year. It pulls code from another repository and executes it on your runner with whatever permissions the job has, which functionally is pip install with one difference: the thing after the @ is a git ref, which can be moved by whoever controls the source. Running git tag -f v41 <new sha> && git push -f origin v41 updates every workflow on @v41 to execute the new commit on its next run, and re-running last week's green build picks up the new code without any change on the consumer's side. There is no lockfile to record the resolution you accepted yesterday and no --require-hashes to reject anything that drifts from it. The action's release isn't signed, no resolver can show you the transitive tree, and there is no equivalent of PEP 592 yanking; a hijacked tag stays hijacked until someone notices and force-pushes it back. Composite actions resolve their own uses: lines at runtime, so an action you have pinned to a SHA can still pull other/helper@main internally and you would never see it from your own workflow file.
This shape has been exploited six times in the eighteen months I've been paying attention. spotbugs in November 2024 had a pull_request_target workflow that checked out and ran code from a fork PR with secrets in scope, which let the attacker steal a maintainer's Personal Access Token. The PAT sat unused for four months until March 2025, when the reviewdog and tj-actions tags were force-pushed in a chain that ended with tj-actions/changed-files dumping runner memory to public workflow logs across roughly twenty-three thousand repositories. Trivy's action had 75 of its 76 version tags force-pushed in March 2026, twice in three weeks. Ultralytics in December 2024 and elementary-data in April 2026 both ended with malicious wheels on PyPI; the first arrived via cache poisoning from a fork PR that the release workflow later restored, and the second via a ${{ github.event.comment.body }} interpolation that let anyone with a GitHub account run shell on the runner by leaving a comment on an old issue. The most recent, on 11 May 2026 (yesterday, as I'm writing this), was a chain across the @tanstack/* npm packages: a pull_request_target job called bundle-size.yml ran fork code, the fork code poisoned the pnpm store cache, the publish workflow restored that cache, and the cached code extracted the runner's OIDC token from memory and uploaded 42 packages with 84 malicious versions directly to npm. Trusted publishing didn't help, because the workflow had a legitimate id-token: write permission and the attacker was already executing inside it. The postmortem is worth reading in full, and it's worth noting that nothing about the chain is npm-specific: swap the pnpm store cache for actions/cache against a pip download cache and PyPI for npm and the attack works identically. 1,348 of the PyPI repositories I scanned have both dangerous-triggers and cache-poisoning findings in the same scan, which is the audit-shape population that this exact chain applies to.
For each PyPI package with a linked GitHub repository I shallow-cloned the repository, ran zizmor --format=json on .github/workflows/, and separately extracted every uses: line into an actions inventory; both went into SQLite. Findings are the YAML-permits-this kind: zizmor cannot see whether a repository's "Workflow permissions" default has been flipped to read-only, whether a secret is environment-scoped behind a required reviewer, or whether branch protection would stop the push that an injection would otherwise enable. The numbers below should be read as exposure counts under those caveats rather than as exploitability today against any particular repository. Roughly one in five of the linked repository URLs in PyPI metadata turn out to fail to clone, either 404'd outright or moved to a private repo or otherwise gone; you can still pip install those packages but their source is no longer publicly readable, which deserves a separate talk.
The single largest finding class is excessive-permissions, in 102,235 repositories, which fires when a workflow has no permissions: block and the job's GITHUB_TOKEN therefore inherits the repository default. For any repository created before February 2023 that default includes contents: write and actions: write, so a step that has been compromised by any other means can push commits, dispatch other workflows, and pivot to actions the original author did not intend. Most of what follows reads better as combinations of audits than as a list of independent findings: an excessive-permissions workflow on its own is harmless, and so is a template-injection on its own in many cases, but the two together are how an attacker gets a release out of someone else's repository. The remedy for this particular audit is permissions: {} at the top of every workflow file with explicit grants per job.
Next in volume is unpinned-uses at 85,774 repositories, which is roughly 91% of the repositories that use any third-party action at all. The four published advisories in this class line up exactly with the four known tag-hijack compromises: tj-actions, reviewdog, Trivy, and xygeni. The remediation is to pin every third-party action to a 40-character commit SHA; both Dependabot and Renovate understand SHA pins and will update them. A month after the second Trivy compromise, 403 PyPI packages were still on aquasecurity/trivy-action by tag, and a year after CVE-2025-30066 there are still 336 referencing tj-actions/changed-files by a moveable ref. Pinning actions/* itself is a wash, because GitHub's own organisation being compromised would already invalidate the runner image you're executing on.
Of the 49 advisories currently published under ecosystem:actions in GHSA, 27 are the same pattern: a ${{ }} expression containing attacker-influenced data interpolated directly into a run: block. The expansion happens before the shell parses the script, so a PR title or branch name or comment body becomes shell source. zizmor calls this template-injection and counts 21,166 repositories with at least one instance. elementary-data is the worked example: an issue_comment trigger echoed github.event.comment.body into bash, an account created two days earlier left a comment that closed the echo and appended curl | bash, the default GITHUB_TOKEN was write-scoped because there was no permissions: block, and ten minutes later there was a malicious 0.23.3 on PyPI with a .pth file that exfiltrated SSH keys and cloud credentials on the next interpreter startup. We had scanned that repository two weeks earlier and three separate audits, template-injection, excessive-permissions, and use-trusted-publishing, each independently flagged a step in the chain that would have stopped the attack if it had been remediated.
Narrowing the 21,166 down to repositories where the injected expression actually carries attacker-controlled data gives 1,396, narrowing again to triggers like issues and issue_comment where secrets are always in scope produces 99, and after deduplicating shared repositories and checking job-level permissions, ten of those have a write-scoped token in the same job as a stored PyPI credential. The biggest among them is sqlglot at around 11.6M downloads a month, which has been disclosed; the other nine are still going through coordinated disclosure and are unnamed here. The remediation is to pass the value through env: and reference the shell variable, which carries the same data without triggering the pre-parse expansion.
use-trusted-publishing is in 44,181 repositories and has no advisories in GHSA, because storing a long-lived PYPI_API_TOKEN is not itself a CVE; it's the reason the other audits matter, since an injection that lands in a job with no credential to steal is an injection that doesn't end on PyPI. The repositories on this list include six at 896M monthly downloads, fsspec at 616M, pyasn1 at 430M, tomli at 377M, greenlet at 337M, and sqlalchemy at 335M. If anyone in the Python packaging community wants a prioritised outreach campaign for OIDC adoption, this is the input set.
Ultralytics' release workflow in December 2024 restored an actions/cache entry that a fork PR had poisoned and ended up shipping a crypto miner to PyPI; the shape of that compromise is what zizmor's cache-poisoning audit catches, which fires for 15,371 repositories where a privileged job restores from a cache namespace that a lower-privilege job in the same repository can write into. There are two advisories in this class, both variants of the Ultralytics story. The remediation is to not restore caches in jobs that build or publish artifacts.
dangerous-triggers is the smallest of the six at 7,025 repositories, with eight advisories. It fires on pull_request_target and workflow_run, which both run in the base repository's context with secrets available, and the usual mistake is to then check out the PR's head and run its tests, executing the fork's code with those secrets. The spotbugs PAT theft that started the reviewdog chain was this pattern, as was the Ultralytics entry point. For most workflows the answer is plain pull_request, where fork PRs get a read-capped token and no secrets at all, and the cases that need pull_request_target should never check out the PR head and should never restore caches.
The actions inventory tells you something orthogonal: who Python CI actually depends on. pypa/gh-action-pypi-publish is in 56,490 repositories at 84.0% unpinned, codecov/codecov-action in 20,651 at 91.8%, astral-sh/setup-uv in 17,047 at 85.6%, then softprops/action-gh-release, the docker actions, pre-commit/action, and pypa/cibuildwheel. After the first few names the owners are increasingly individuals: softprops, peter-evans who maintains sixteen separate actions, dtolnay, ncipollo, JamesIves, each one person whose key compromise would affect thousands of dependent repositories. actions/create-release is in 1,956 repositories at 98.7% unpinned despite having been archived by GitHub in March 2021, and the corresponding archived-uses audit catches 3,625 repositories on at least one action whose maintainer has stopped working on it. I wrote about this shape, dependencies that are alive in the sense that they still install but dead in the sense that nobody is fixing them any more, in Weekend at Bernie's a few days ago; actions/create-release is one of the more on-the-nose examples.
Zizmor's audits stop at the workflow YAML; what the action that workflow calls does at runtime is a separate audit problem. As a one-action case study I ran a deeper audit on pypa/cibuildwheel, which sits in 2,650 publish workflows and whose own repository already runs zizmor on itself. The only finding in the Python code was a low-severity chmod path traversal whose precondition (a hostile PyPy or GraalPy release zip served from python.org) already implies code execution, so the bug grants nothing additional to that adversary. What the deeper audit did surface is that cibuildwheel fetches and executes Python interpreters, virtualenv, Node.js, nuget, and python-build-standalone tarballs from seven upstream hosts at runtime, over HTTPS with no hash pin, which is a transitive dependency tree that does not appear in any action.yml and that every workflow using cibuildwheel inherits without seeing. The popular composite actions I checked otherwise either pin their internal uses: lines to SHAs or only pull actions/*; the long tail is less careful, and aio-libs/create-release for example pulls three third-party actions by tag from inside its composite definition.
The risk concentrates further if you look at which third-party actions run in the same job as pypa/gh-action-pypi-publish itself, where a tag hijack would execute alongside the publish credential and could tamper with the wheel between the build step and the upload step. astral-sh/setup-uv is in 3,819 such jobs at 90.5% unpinned, softprops/action-gh-release in 2,448 at 93.7%, and the long tail of release-helper actions follows at similar ratios. The interesting outlier is step-security/harden-runner at 144 publish jobs and 2.4% unpinned, an order of magnitude better than everything else on the list. The audience that would benefit most from pinning is precisely the one not running the kind of tool that would tell them so.
Python packaging spent more than a decade building the controls that Actions doesn't have. A requirements.txt with --require-hashes, a uv.lock, or a PEP 751 lockfile means the resolution you ran yesterday is the resolution you install tomorrow, and a tag move upstream changes nothing because you've recorded the artifact hash. PEP 592 yanking lets a maintainer pull a release back so resolvers stop selecting it, where in Actions a hijacked tag stays live until somebody force-pushes it back, and the tj-actions tags were malicious for hours before that happened. PEP 740 attestations bind the resulting artifact to the workflow that produced it, which is the same shape of identity that trusted publishing relies on at the upload step.
GitHub's 2026 security roadmap announces workflow dependency locking that records direct and transitive action SHAs in a dependencies: section of the workflow file, policy controls that can prohibit pull_request_target outright at the organisation level, secrets that can be scoped so repository write access no longer implies access to the secret itself, and an egress firewall for hosted runners with both monitor and enforce modes. None of these has a committed ship date as of writing and the roadmap is best read as direction rather than delivery, but the locking feature is the lockfile, arriving roughly thirteen years after pip got --require-hashes, and I'm relieved to see it acknowledged even if I'd rather not have written a talk about its absence in the meantime.
If you maintain a Python package that publishes from Actions, the change that gives you the most security for the least effort is migrating to trusted publishing with an environment, where the environment has either required reviewers or a branch restriction, and the PyPI-side trusted-publisher configuration is bound to that environment name. OIDC alone removes the long-lived credential that an attacker would otherwise steal, but the environment is what prevents the elementary-data pivot in which an injected step with actions: write dispatches the real publish workflow under the original maintainer's identity and gets a valid OIDC token because the workflow filename and repository claims still match. The intercom-client compromise in April was a different shape of the same vulnerability: id-token: write on a tag-push trigger with no environment configured, attacker pushes a tag they control, the workflow runs, the OIDC token is valid, the malicious version is uploaded, the workflow run is deleted afterwards so the audit trail goes with it. Without the environment binding there is nothing on the PyPI side that distinguishes the legitimate publisher from a tag-push attacker who can reach the same workflow file. The TanStack compromise sharpens the same point further: attacker code that is already executing inside the publish workflow, having arrived via a poisoned cache, can mint the OIDC token from runner memory before the publish step ever gets to use it. The environment binding does not close that path, which is why the rules below about minimal publish jobs and not restoring caches in publish jobs do real work rather than being defence in depth for its own sake.
Inside the workflow file itself, a permissions: {} block at the top with explicit grants per job removes the inheritance of write-by-default that turned the elementary-data injection into a release. Third-party actions can be pinned to a 40-character SHA, with Dependabot or Renovate maintaining those pins as their upstreams release new versions; the few cases where this is tedious are usually actions/*, which is the one organisation where pinning gives you nothing because its compromise invalidates the runner image you're executing on anyway. The other recurring source of foot-guns is ${{ github.event.* }} values referenced inside run: blocks, where the expression engine substitutes the value before bash gets to it. Passing those values through env: defuses the whole class without changing what data the script can read.
A separate concern is the publish job itself, which can be kept to actions/checkout, actions/download-artifact, and pypa/gh-action-pypi-publish, with the wheel built in a different job that hands the artifact across; a hijack of any third-party action then never runs in the same process as the publish credential. Wiring zizmorcore/zizmor-action into a pull_request workflow makes all of the checks above blocking rather than aspirational. As a sanity check that any of this is achievable in practice, requests, pytest, stamina, flask, django, and boto3 currently scan with zero zizmor findings on their main branches, and their release workflows are reasonable templates to start from.
I've been re-scanning the PyPI critical subset at intervals to see whether publicised compromises change behaviour at all. Between 6 April and 28 April, which spans the second Trivy incident and elementary-data, deduplicated findings for unpinned-uses fell from 7,446 to 6,320, artipacked from 2,755 to 2,337, and excessive-permissions from 2,186 to 1,887, all close to a fifteen percent drop, with apispec, awscli, and babel cleaning up entirely. Between 28 April and 11 May those same numbers were flat. Maintainers respond to the incidents they see in their feeds and then attention moves on, which is roughly what you should expect, and which is why the check belongs in CI as a blocking step where it runs on every change without anyone needing to remember.