Credentials#
This repository holds no secrets. Not “the secrets are well protected” — there are none to protect. That is worth explaining, because it is a deliberate design and it is not what most Python projects do.
Publishing to PyPI without a token#
The traditional arrangement is a long-lived PYPI_API_TOKEN in the repository’s secrets.
It works, and it has an unpleasant property: it is a credential that can publish any
version of your package, from anywhere, forever, and it sits in a store that anyone with
write access to the repository can read through a workflow.
Scenet uses Trusted Publishing instead. PyPI is told, once, through its web interface:
The workflow
release.yml, in the repositoryazias/scenet, running in the environmentpypi, may publish the projectscenet.
At publish time GitHub mints a short-lived OpenID Connect token asserting exactly that identity. PyPI verifies it and issues a publishing token valid for that single run.
So there is no token to create, store, rotate, or leak. A stolen repository secret cannot publish, because there is no repository secret. A workflow on a fork cannot publish, because the identity would not match.
The pypi environment#
release.yml publishes from a GitHub Environment named pypi with a required
reviewer. The run pauses there until a human clicks approve.
This is the difference between “a tag publishes to PyPI” and “a tag proposes publishing
to PyPI”. An accidental tag push — the wrong branch, a typo, a stale local tag pushed by
--tags — reaches the gate and stops.
id-token: write is granted on the publishing job alone, never at workflow level.
That permission is the credential; every other job in the file would otherwise be able to
mint one.
What credentials do exist#
Credential |
Where it lives |
Lifetime |
|---|---|---|
|
Minted per workflow run by GitHub |
The run |
PyPI publishing token |
Exchanged from an OIDC token at publish time |
Minutes |
Both are ephemeral and neither is stored anywhere.
The workflows declare permissions: contents: read at the top level, so GITHUB_TOKEN
starts with the minimum and jobs that need more say so themselves. Only
github-release asks for contents: write.
Every actions/checkout sets persist-credentials: false, so the checkout token is not
left behind in .git/config for a later step — or a compromised dependency’s build script
— to find.
Actions are pinned to commit SHAs#
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
A tag is a mutable pointer. Whoever controls an action’s repository can move v7 to
different code, and every workflow referencing v7 runs it on the next push — with
whatever permissions that job holds. This is the standard supply-chain attack on GitHub
Actions, and it has happened to real, popular actions.
A commit SHA cannot be moved. The # v7.0.1 comment keeps the file readable, and
Dependabot understands the convention well enough to update both the SHA and the comment
together.
If a secret ever becomes necessary#
It has not so far. If it does — publishing the extension to a marketplace would be the likely reason — the rules are:
Never put it in a file. Workflow YAML travels with every fork. A .env in .gitignore
is one git add -f away from being public, and public forever.
Set it from stdin, so it is not in your shell history:
gh secret set OVSX_PAT --env release < token.txt && rm token.txt
Scope it to an environment, not the repository, so only the job that needs it can read it.
Prefer OIDC wherever the receiving service supports it. The reason there is nothing to protect here is that PyPI supports it.
If something does leak#
Rotate first, investigate second. Revoke the credential at the source — PyPI, Azure, wherever it was issued — before working out how it got out. A revoked token cannot be misused while you read logs.
Removing it from git history does not un-leak it. Anything pushed to a public repository should be assumed to have been indexed within minutes.