persist-credentials: false + git push = 💥 and how to fix it


In the light of the recent Tanstack supply-chain attack, pipeline security has become the priority of several projects.

Mistakes in github actions are tricky to see and can have devastating consequences, through PR-controlled variable injection 1.

Luckily there are powerful tools to catch them:

One common suggestion from Zizmor is to set persist-credentials: false when using actions/checkout 2

The problem: persist-credentials: false + https git commands = boom

If you combine these two ingredients you will be greeted with the error

fatal: could not read Username for 'https://github.com/': No such device or address

Workaround 1: Let gh provide credentials

 - name: Commit and push
+   env:
+     GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
   run: |
+    gh auth setup-git
     git commit -m "your message"
     git push

gh auth setup-git configures git to fetch credentials from the gh command, which in turn reads the token from GH_TOKEN.

This avoids storing it in .git/config (which is what persist-credentials: false protects us from).

⚠️
If you self-host your runner, or use a job with a custom container:, you need to install and set up the gh cli yourself. https://docs.github.com/en/actions/how-tos/write-workflows/choose-what-workflows-do/use-github-cli

Workaround 2: Inject the token into the remote URL (and clean it after)

 - name: Commit and push
   env:
     GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
   run: |
     git commit -m "your message"
+    git remote set-url origin "https://x-access-token:${GH_TOKEN}@github.com/${{ github.repository }}"
     git push

+    # Embedding the token in the remote URL writes it to `.git/config`, 
+    # so we reset the URL after pushing.
+    git remote set-url origin "https://github.com/${{ github.repository }}"
⚠️
If git push fails, the credentials stay in .git/config. This is an unlikely attack vector but workaround 1 is safer.