Repository navigation
Description
Context
TokenOps ships to PyPI from one GitHub Actions workflow, .github/workflows/release.yml. It is started by hand (workflow_dispatch) and has two jobs:
release(contents: write): checks the tag matches thepyproject.tomlversion, builds the wheel and sdist intodist/, creates the git tag, creates the GitHub Release, then uploadsdist/as a workflow artifact.publish(id-token: write): downloads that artifact and publishes it to PyPI through Trusted Publishing (OIDC, no stored API token).
Two terms matter here:
GITHUB_TOKEN: a short-lived token GitHub gives every job. Its power is set by the job'spermissions:block. Inreleaseit can write repository contents.persist-credentials: an input ofactions/checkout. Whentrue(the default) checkout writes that token into the local.git/configso latergitcommands in the same job can authenticate without being told about it.
We recently started running zizmor, a static analyser for workflow files, in CI (see the PR that adds .github/workflows/zizmor.yml). Its artipacked audit flags any checkout that leaves the token in .git/config, because a later step that uploads the workspace as an artifact would publish the token with it.
Problem
The release job's checkout has to keep the token, because a later step uses it:
# .github/workflows/release.yml (on main, lines 29-30 and 91-92)
- uses: actions/checkout@v4
with:
fetch-depth: 0
...
else
git tag "${TAG}" "$GITHUB_SHA"
git push origin "refs/tags/${TAG}"
figit push origin has no credentials of its own. It works only because checkout persisted the token into .git/config. Setting persist-credentials: false on that checkout, which is what zizmor asks for, would make the push fail on the next release.
Observed, from zizmor --offline on the workflow with the pins in place and no suppression:
warning[artipacked]: credential persistence through GitHub Actions artifacts
--> .github\workflows\release.yml:29:9
|
29 | - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
| _________^
30 | | with:
31 | | fetch-depth: 0
| |________________________^ does not set persist-credentials: false
This is not an exploitable leak today. The only artifact the job uploads is dist/ (release.yml:105-108, path: dist/), not the workspace root, so .git/config never enters an artifact. The finding is correct-but-unhelpful, not wrong. To get a clean zizmor run without changing the release path, the PR adds an inline suppression with a comment explaining it:
# Credentials stay persisted on purpose: the tag-and-release step below runs
# `git push origin refs/tags/...` with the job token (contents: write).
- uses: actions/checkout@... # v4.4.0 # zizmor: ignore[artipacked]The cost of the suppression is that it is a standing exception. Someone adding path: . to the upload step later would turn it into a real token leak, and the suppression would hide the warning that should have caught it.
Blast radius: one workflow, one job, only on manual releases. No user-facing effect.
Proposed approach
Remove the need for persisted credentials, then delete the suppression. Two candidate ways, to be chosen after a dry run:
A. Let gh release create make the tag. The job already runs gh release create "${TAG}" ... --target "$GITHUB_SHA" with GH_TOKEN set. When the tag does not exist, gh creates it on the server at the target commit, so the separate git tag + git push pair may be unnecessary. The existing guard (fail if the tag already points at a different commit) uses only local git rev-parse/git rev-list, which work without credentials, since fetch-depth: 0 already fetched the tags. The checkout then becomes persist-credentials: false with no other change.
B. Keep the push, authenticate it explicitly. Push with the token passed only for that command, for example git push "https://x-access-token:${GH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" "refs/tags/${TAG}". Smaller behavioural change, but the token ends up in a command line, so it needs care with logging.
Recommendation: A, if a dry run confirms that gh release create --target <sha> creates a lightweight tag at that exact SHA (the same thing git tag creates today). It deletes code instead of adding code.
Everything else stays the same: the publish job, Trusted Publishing, the artifact flow, and the concurrency group. This changes only how the tag is created.
Tradeoffs / non-scope
- Not testable in a normal PR. The release workflow only runs on manual dispatch and publishes to PyPI. A change here can only be fully proven by a real release. Mitigation: try it on a fork, or split the tag-and-release step so it can run with a dry-run flag, before touching
main. This is the main reason the current PR suppresses the finding instead of fixing it. - Rejected: leave the suppression forever. Cheapest, and safe today, but it is an exception that depends on nobody ever widening the upload path.
- Rejected: a personal access token. It would add a long-lived secret to a repo whose point is Trusted Publishing with no stored tokens.
- Out of scope: pinning actions by SHA and adding zizmor to CI (done in the PR that adds the suppression); the remaining online-only zizmor audits, which only run in CI; Dependabot configuration (deliberately not enabled yet); #111 (CodeQL, pip-audit and property tests) and #152 (CI installing control-plane from
git@main), which are neighbouring CI-security work and own their own scope.
Acceptance
release.ymlcheckout setspersist-credentials: falseand thezizmor: ignore[artipacked]comment is gone.- A real (or fork) release produces the same tag and GitHub Release as today, and
publishstill succeeds. zizmorreports noartipackedfinding forrelease.yml.
Activity
ci: pin actions by SHA and audit workflows with zizmor (#157)