Skip to content

Release job keeps the checkout token on disk to git push a tag: replace it with explicit auth so the zizmor artipacked suppression can go #156

Open

Description

@susheem-k

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:

  1. release (contents: write): checks the tag matches the pyproject.toml version, builds the wheel and sdist into dist/, creates the git tag, creates the GitHub Release, then uploads dist/ as a workflow artifact.
  2. 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's permissions: block. In release it can write repository contents.
  • persist-credentials: an input of actions/checkout. When true (the default) checkout writes that token into the local .git/config so later git commands 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}"
  fi

git 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.yml checkout sets persist-credentials: false and the zizmor: ignore[artipacked] comment is gone.
  • A real (or fork) release produces the same tag and GitHub Release as today, and publish still succeeds.
  • zizmor reports no artipacked finding for release.yml.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions