Build the mental model
An action is a packaged, reusable step: a repository containing an action.yml plus code - JavaScript, a Docker image, or a composite list of other steps. When you write uses: actions/checkout@v4, Actions finds github.com/actions/checkout, resolves the ref v4, and executes that code on your runner. That last clause is the whole security story: you are letting somebody else's code run inside a machine that holds your repository token and any secrets that job can see.
The ref after @ can be a branch, a tag, or a commit SHA. @v4 looks like a version number, but it is only a git tag, and tags can be moved. The actions/* organisation deliberately advances the v4 tag as it releases v4.1.7, v4.2.0. That is a genuine benefit: you get bug fixes and security patches without touching your YAML. It is also the risk: the same unchanged line of YAML can run different code tomorrow. If a maintainer account is compromised, which has happened more than once, an attacker repoints the tag at a malicious commit and every repository using it runs that code on its next build.
So the practical guidance splits by trust. Pinning first-party actions/* to a major tag is widely accepted. Third-party actions should be pinned to a full 40-character commit SHA, because a SHA names content and cannot be re-pointed. A trailing comment recording which tag that SHA was handles the readability cost, and Dependabot can bump those pins. Before adopting a new action, ask three questions: when was it last committed to, how many maintainers stand behind it, and do you actually need it - many marketplace actions replace a single run: line.
HOW A USES REFERENCE RESOLVES, AND WHERE IT CAN MOVE
----------------------------------------------------
uses: actions/checkout@v4
| | |
owner repo ref
|
v
+---------------+
| git tag v4 | <-- a MOVING pointer
+---------------+
|
resolves to
v
+-------------------------------+
| commit 1a2b3c4... (today) |
+-------------------------------+
the owner (or whoever steals the account) can re-point it:
+---------------+
| git tag v4 | - - - - +
+---------------+ |
v
+-------------------------------+ |
| commit 1a2b3c4... (yesterday) | |
+-------------------------------+ |
| commit deadbee... (malicious) |<+
+-------------------------------+
your YAML line never changed. The code it runs did.
uses: some-owner/some-action@<40-char-sha> # cannot moveConnect it to a real scenario
The workflow below shows both halves of the practice. actions/checkout, actions/setup-node, actions/cache, actions/upload-artifact and pnpm/action-setup are pinned to major tags. These are maintained by GitHub itself and by the pnpm project, and tag pinning is the widely accepted trade-off there.
Study the cache step. Its key contains ${{ runner.os }} and ${{ hashFiles('pnpm-lock.yaml') }}. hashFiles produces a hash of that file's contents, so the moment dependencies change the key changes and a fresh cache is written instead of a stale one being reused - which is exactly the bug you get from a hand-written constant key. restore-keys gives a prefix fallback so a slightly changed lockfile still starts from a mostly-warm cache.
At the bottom, note the commented SHA-pin pattern. When you adopt a third-party action, open its repository, find the release you want, copy the full 40-character commit SHA of that tag, put it after the @, and record the tag in a trailing comment so a human reading the file still knows which version it is. Then add .github/dependabot.yml with package-ecosystem: "github-actions" so Dependabot opens pull requests to move those pins forward. That combination gives you immutability by default and updates on a schedule you control, rather than updates that arrive silently mid-week.
Try the working example
name: Pinned Actions
on:
pull_request:
branches: [main]
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: First-party action, pinned to a major tag
uses: actions/checkout@v4
- name: Set up pnpm
uses: pnpm/action-setup@v4
with:
version: 9
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: pnpm
- name: Cache the framework build output
uses: actions/cache@v4
with:
path: .next/cache
key: next-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
restore-keys: |
next-${{ runner.os }}-
- run: pnpm install --frozen-lockfile
- run: pnpm build
- name: Upload the build output
uses: actions/upload-artifact@v4
with:
name: web-build
path: .next/
# A third-party action should be pinned by full commit SHA, like this:
#
# - name: Publish the test report
# uses: some-owner/some-action@<40-character-commit-sha> # v1.9.1
#
# Copy the SHA from the action's release page, and keep the tag it
# matched in the trailing comment so humans can still read the file.
Every pull request targeting main runs a single build job. Its steps execute in order: check out the repository, install pnpm 9, install Node 20 with the pnpm store cached, restore .next/cache under a key derived from the lockfile hash, install dependencies with a frozen lockfile, build, and upload .next/ as an artifact named web-build. If the lockfile is unchanged the cache key matches and the cache is restored as a hit; if it changed, the key is new, restore-keys falls back to the closest prefix match, and a fresh cache is saved at the end of the run. Any step exiting non-zero fails the job and reports a failed check on the pull request. The commented block at the end runs nothing - it documents the SHA-pinning form for third-party actions.5-minute try-it
List every uses: line in the workflows of a repository you actually work on. For each one that is not under actions/, check when it was last committed to, how many stars it has, and how many people maintain it. Then pick one and convert it from a tag pin to a SHA pin: copy the full commit SHA from its release page, put it after the @, and add a trailing comment naming the tag. Finally add .github/dependabot.yml with the github-actions ecosystem enabled, push, and see what update pull requests Dependabot opens for you.
One important caution
Writing uses: some-owner/some-action@main. Every commit the maintainer pushes to that branch runs inside your pipeline immediately, with no review and no version to roll back to.
Handing secrets to a third-party action without ever reading its source. It executes on your runner with access to whatever that job can see - trusting it is the same decision as installing an unaudited dependency.
GitHub Docs - Security hardening for GitHub Actions — CI/CD with GitHub Actions