Why this matters
When a workflow runs uses: some-org/some-action@v4, GitHub downloads whatever code the v4 tag points to at that moment. Tags and branches can be moved. If the action's repository is compromised, the attacker can point v4 at malicious code, and every workflow using it runs that code on its next trigger, with access to your secrets and your repository.
This is not a theoretical problem. In March 2025 the widely used tj-actions/changed-files action was compromised this way. Its version tags were rewritten to point at code that dumped CI secrets into build logs, and thousands of repositories were affected before the tags were fixed.
A commit SHA is different. It identifies one exact snapshot of code and cannot be changed to point anywhere else. Pinning to a SHA means the code you reviewed is the code that runs, every time.
What good looks like
Every third-party action and reusable workflow is referenced by its full 40-character commit SHA, with the human-readable version kept in a comment:
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad2be3d6a4ef3b74 # v4.2.2
- uses: shivammathur/setup-php@ccf2c627fe61b1b4d924adfcbd19d661a18133a0 # v2.32.0
Actions that live in your own repository, referenced as ./.github/actions/..., do not need pinning. They are already protected by your own branch rules.
A workflow or action kept in a different repository is referenced the same way as a third-party one, by SHA with a version comment. That includes another repository of your own.
How to do it
Pin by hand
- Open the action's repository on GitHub and go to its releases or tags.
- Click the tag you use, for example
v4.2.2, and copy the full commit SHA it points to. - Replace the tag in your workflow with the SHA and add the version as a trailing comment, as in the example above.
Pin with a tool
Doing this by hand across many workflows is tedious. Tools can rewrite every reference for you:
- pinact pins every action in a repository in one run and keeps the version comment.
- zizmor audits workflows for unpinned actions and other risky patterns, and can run in CI to stop new ones being added.
- StepSecurity Secure Repo pins actions through a web interface and opens a pull request.
Keep pins up to date
A pinned SHA does not update itself, so pair pinning with an update bot. Both Dependabot and Renovate understand SHA pins with a version comment and will open pull requests that move the SHA and the comment together.
# .github/dependabot.yml
version: 2
updates:
- package-ecosystem: github-actions
directory: /
schedule:
interval: weekly
Renovate handles pinned actions by default, and its helpers:pinGitHubActionDigests preset will pin any remaining tags for you.
Sharing a workflow across your own repositories
Many maintainers keep one reusable workflow in a hub repository and call it from every package, so a CI change is made once. That is a good pattern, and the caller line still needs a SHA.
Only ./ paths and references into the same repository are covered by the calling repository's branch rules. A separate repository under the same account has its own collaborators, branch and tag settings, and tokens, and a tag in it can be moved like any other tag. A shared workflow that every package calls, with access to every package's secrets, is the single most valuable target in the account. That is the reason to pin it, not a reason to skip it.
jobs:
ci:
uses: your-name/.github/.github/workflows/ci.yml@6c0c0d9a8f9e5f1b2a3c4d5e6f708192a3b4c5d6 # v1.4.3
The pin does not have to be edited by hand. Set the packages up so the update rolls out on its own:
- Change the shared workflow and cut a tag in the hub repository.
- The Dependabot configuration shown above opens a pull request in each package that moves the SHA and the version comment together. Dependabot follows reusable workflow references under
jobs.<id>.usesas well as step actions. Do not list the hub repository underignoreindependabot.yml, or nothing will update. - Each package's own CI runs against the new workflow before the pull request merges, so a bad change to the shared workflow is caught in one pull request instead of reaching every package at once.
- Because the change is your own, let the pull request merge itself. Enable auto-merge in the package repository's settings and add a small workflow:
# .github/workflows/dependabot-auto-merge.yml
name: Dependabot auto-merge
on: pull_request
permissions:
contents: write
pull-requests: write
jobs:
auto-merge:
if: github.actor == 'dependabot[bot]'
runs-on: ubuntu-latest
steps:
- uses: dependabot/fetch-metadata@25dd0e34f4fe68f24cc83900b1fe3fe149efef98 # v3.1.0
id: metadata
- if: startsWith(steps.metadata.outputs.dependency-names, 'your-name/.github')
run: gh pr merge --auto --squash "$PR_URL"
env:
PR_URL: ${{ github.event.pull_request.html_url }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
Renovate covers the same flow. It updates SHA-pinned references by default, and a packageRules entry can merge updates from the hub repository automatically. Set automergeType to branch to skip the pull request entirely. The configuration can also live in the hub repository as a preset that each package extends with github>your-name/.github, so the update policy is shared the same way the workflow is.
{
"extends": ["config:recommended", "helpers:pinGitHubActionDigests"],
"packageRules": [
{ "matchPackageNames": ["your-name/.github"], "automerge": true }
]
}
Pinning the caller freezes the workflow file only. Actions that the shared workflow itself calls by tag still float, so pin those inside the hub repository too.
Things to watch for
- Short SHAs. A 7-character abbreviation is not a pin. Use the full 40-character SHA.
- Reusable workflows.
uses: other-org/repo/.github/workflows/ci.yml@maincarries the same risk as an action and needs the same treatment. - Your own other repositories. The
./exemption covers only the calling repository. Auses:line pointing at another repository you own needs a SHA like any other, and the section above shows how to keep it current without editing every package. - Pinning without an update bot. Pins stop silent changes, but they also stop silent updates, including security fixes. The update bot brings those back as reviewable pull requests.
- Losing the version comment. Without it, nobody can tell what version a SHA is. Keep the comment; the bots rely on it too.