How to publish to PyPI using GitHub Actions securely
Sign in<br>Subscribe
There have been several security incidents lately that involved compromising GitHub Actions workflows. This has led some to say "GitHub Actions is the weakest link" in publishing and to GitHub publishing a GitHub Actions security roadmap update. But saying it's an issue and acknowledging the fact is one thing, but you still need to do the mitigation work today so you are not going to be the next headline. So this post is going to outline 3 things to do so you can publish to PyPI securely when using GitHub Actions.<br>But before I go any farther, I want to make 2 things very clear. One is this post is in no way meant to shame anyone into using GitHub Actions. For instance, I have heard people trying to shame maintainers into using GitHub Actions to use Trusted Publishing, and I think that's wrong. Now, if you choose to use a platform that supports Trusted Publishing, then you should definitely use it. But Trusted Publishing is not a reason to change your publishing workflow if the one you have is already secure. In other words, use whatever works best for you to publish securely to PyPI, and if that's GitHub Actions then this blog post is for you.<br>Two, the title of this post explicitly says "publishing" and not "building and publishing". Doing builds securely is a separate concern that I am not covering. The one piece of advice I will give, though, is one the Python security developer in residence gave me: you should have building and publishing be separate workflows.<br>With that out of the way, here are 3 steps to securing GitHub Actions for publishing to PyPI that should be relatively painless.<br>Use zizmor<br>The zizmor tool examines your GitHub Actions workflows to find things that at dubious when it comes to security. They pretty much all stem from GitHub Actions having insecure defaults in the name of convenience. There are 2 parts to using zizmor:<br>Make it happy<br>Set it up in CI<br>You can do those two things in whatever order you want but you need to do both to make sure you fix any current issues you have and prevent any new issues from slipping in. Luckily both things are easy to do.<br>Make zizmor happy<br>To run zizmor you can do uvx zizmor --quiet --fix .github/ , pipx zizmor --quiet --fix .github/ , or however you choose to run it. That will run zizmor and fix anything that it can in a clean way. Chances are, though, there will be three things to fix by hand.<br>No permissions by default<br>By default, the token GitHub Actions gives to your workflow via GITHUB_TOKEN is way too broad, so zizmor flags it. Easiest way to fix this issue is to turn off all permissions at the global level for a workflow and then turn any permissions you need on at the job level. So put the following at the global level of your workflow file (I personally put it just before jobs:):<br>permissions: {}If you happen to need some specific permission, you can then specify it per-job so you scope it as tightly as possible. Or if you really need something for everything, you can still set it globally, but you at least you will be explicit about exactly what you want.<br>The reason you do this is you don't want some action to get a hold of your token that can do something as if you're you and do something bad.<br>No persisted credentials after checkout<br>When you use the checkout action, GitHub Actions is running Git on your behalf, complete with credentials so the git checkout command works. The problem is those credentials persist passed the checkout action unless you specifically say to not keep them around. So add the following with: clause to your checkout action:<br>with:<br>persist-credentials: falseYou do this so your credentials don't leak out to some action that will do something bad with them.<br>Pin your actions<br>When you specify an action to use in a workflow, you were probably told to use some Git tag like uses: actions/checkout@v7 which specifies using the v7 tag from the https://github.com/actions/checkout repo. The problem with that is if that action gets compromised, an attacker can just update that tag to point to malicious code and so now you're compromised.<br>You work around this by pinning your actions to commit hashes. This might sound like a massive hassle, but there are tools that can pin all your actions for you.<br>gha-update<br>zizmor --fix --gh-token with a (permissionless) token<br>Pinact<br>Those go from simplest to fanciest, but they all get the job done. I personally use gha-update as it's quick and updates my versions along the way. But if you want to keep your current versions as-is then zizmor will do it for you, but you need to give it a token to do the updates (the token is required to avoid being throttled by GitHub). The best thing to do is to use a permissionless token, but if you're being lazy and trust zizmor (and any tool you might be using to run it, e.g. uvx), you can get a token from gh auth token (the following example is for the Fish shell; adjust the syntax for...