About this course
<p>Your workflow uses <code>actions/checkout@v4</code>. Today, that tag points to a vetted release. Tomorrow, a compromised maintainer or attacker moves the tag to malicious code. Your pipeline runs it with access to <code>GITHUB_TOKEN</code>.</p>
<p>Third-party <strong>Actions are dependencies</strong>. A floating tag behaves like an unpinned package dependency. If the reference changes, the workflow can execute different code without any change to your repository.</p>
<p>This article shows you how to prevent poisoned continuous integration (CI) dependencies by pinning Actions to full commit SHAs, validating those pins against release tags, restricting permitted actions, and automating reviewed updates with Dependabot.</p>
<p><strong>Who this is for:</strong> Platform and DevSecOps engineers securing GitHub Actions workflows.</p>
<p><strong>Prerequisites:</strong></p>
<ul>
<li><p>Repository admin access</p>
</li>
<li><p>Workflows using <code>uses: org/action@ref</code> syntax</p>
</li>
</ul>
<h2 id="heading-the-quick-reference">The Quick Reference:</h2>
<p>Here's what you'll learn how to do here:</p>
<ul>
<li><p>Pin every <code>uses:</code> reference to a <strong>full commit SHA</strong>, not a moving tag such as <code>@v4</code>.</p>
</li>
<li><p>Validate that each recorded SHA matches the release you reviewed before approving it.</p>
</li>
<li><p>Maintain an <strong>allowlist</strong> of permitted actions at the organization or repository level.</p>
</li>
<li><p>Enable <strong>Dependabot</strong> for GitHub Actions version bumps with review.</p>
</li>
<li><p>Prefer official or verified creators. Mirror critical actions internally if needed.</p>
</li>
<li><p>Verify pins in pull request checks before merge.</p>
</li>
</ul>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-the-quick-reference">The Quick Reference</a></p>
</li>
<li><p><a href="#heading-why-floating-tags-fail">Why Floating Tags Fail</a></p>
</li>
<li><p><a href="#heading-pin-actions-to-commit-shas">Pin Actions to Commit SHAs</a></p>
</li>
<li><p><a href="#heading-validate-the-commit-shas">Validate the Commit SHAs</a></p>
</li>
<li><p><a href="#heading-enable-dependabot-for-actions">Enable Dependabot for Actions</a></p>
</li>
<li><p><a href="#heading-allowlist-actions">Allowlist Actions</a></p>
</li>
<li><p><a href="#heading-enforce-pins-in-pull-requests">Enforce Pins in Pull Requests</a></p>
</li>
<li><p><a href="#heading-vet-third-party-actions">Vet Third-Party Actions</a></p>
</li>
<li><p><a href="#heading-how-to-verify-this-works">How to Verify This Works</a></p>
</li>
<li><p><a href="#heading-when-this-breaks-down">When This Breaks Down</a></p>
</li>
<li><p><a href="#heading-conclusion">Conclusion</a></p>
</li>
<li><p><a href="#heading-references">References</a></p>
</li>
</ul>
<h2 id="heading-why-floating-tags-fail">Why Floating Tags Fail</h2>
<p>GitHub Actions lets you reference a dependency with a branch name, version tag, or commit SHA. These references don't provide the same level of stability. A branch can change at any time, and a version tag can be moved to a different commit after you review it. A commit SHA identifies one specific revision.</p>
<p>The table below compares the common reference styles and shows why a moving tag creates a supply chain risk:</p>
<table>
<thead>
<tr>
<th>Reference style</th>
<th>Risk</th>
</tr>
</thead>
<tbody><tr>
<td><code>@main</code></td>
<td>Runs whatever code is on the branch when the workflow starts</td>
</tr>
<tr>
<td><code>@v4</code></td>
<td>The tag can move, so the same label can execute different code</td>
</tr>
<tr>
<td><code>@v4.2.1</code></td>
<td>More specific, but still a mutable tag</td>
</tr>
<tr>
<td><code>@abc1234...</code> (full SHA)</td>
<td>Identifies one immutable commit</td>
</tr>
</tbody></table>
<p>The first three references fail because the name doesn't permanently identify the code that GitHub will execute. A full SHA solves that specific problem by making the workflow change only when someone changes the reference in the repository.</p>
<p><strong>Key idea:</strong> CI pipelines deserve the same dependency discipline as application code.</p>
<h2 id="heading-pin-actions-to-commit-shas">Pin Actions to Commit SHAs</h2>
<p>Pinning an Action means replacing its branch or version tag with the full SHA of the commit you reviewed. GitHub then checks out that exact revision, even if the publisher later moves the original tag.</p>
<p>This first example is <strong>bad</strong> because both references use mutable version tags. The workflow may run different Action code later without a corresponding change in your repository:</p>
<pre><code class="language-yaml">- uses: actions/checkout@v4
- uses: actions/setup-node@v4
</code></pre>
<p>This second example is <strong>good</strong> because each reference uses a full 40-character commit SHA. The comments preserve the readable release versions for maintainers, but GitHub uses the SHA rather than the comments:</p>
<pre><code class="language-yaml">- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- uses: actions/setup-node@4992456781334f2795ae9dd169f5041797d85689 # v4.4.0
</code></pre>
<p>Find the SHA on the action's GitHub release page or:</p>
<pre><code class="language-bash">gh api repos/actions/checkout/git/ref/tags/v4.2.2 --jq '.object.sha'
</code></pre>
<p>Use the exact release tag you reviewed. Add a comment with the human-readable version for maintainability. The comment helps a reviewer, but it's not part of the security control. GitHub executes the commit identified by the SHA.</p>
<p>Don't use a shortened SHA. A full 40-character SHA makes accidental collisions and ambiguous reviews less likely.</p>
<h2 id="heading-validate-the-commit-shas">Validate the Commit SHAs</h2>
<p>A full SHA protects the workflow from a tag moving after you merge it, but you still need to validate the SHA before approving it. The release tag and commit should be reviewed together. This check compares the commit behind the reviewed tag with the commit recorded in the workflow:</p>
<pre><code class="language-bash">#!/usr/bin/env bash
set -euo pipefail
repository="actions/checkout"
release_tag="v4.2.2"
expected_sha="11bd71901bbe5b1630ceea73d27597364c9af683"
actual_sha="$({
git ls-remote "https://github.com/${repository}.git" \
"refs/tags/${release_tag}" "refs/tags/${release_tag}^{}"
} | awk -v tag="refs/tags/${release_tag}" '
$2 == tag "^{}" { print $1; found = 1 }
$2 == tag { fallback = $1 }
END { if (!found) print fallback }
')"
if [[ "${actual_sha}" != "${expected_sha}" ]]; then
printf 'SHA mismatch for %s %s\n' "${repository}" "${release_tag}" >&2
printf 'Expected: %s\nActual: %s\n' "${expected_sha}" "${actual_sha}" >&2
exit 1
fi
printf 'Validated %s@%s\n' "${repository}" "${expected_sha}"
</code></pre>
<p>This is release-reference validation, not a claim that a repository is trustworthy. Read the action source, review its permissions, and record why you approved the dependency. For higher-assurance environments, mirror critical actions internally and validate the mirrored artifact through your normal repository controls.</p>
<h2 id="heading-enable-dependabot-for-actions">Enable Dependabot for Actions</h2>
<p>Dependabot is GitHub's automated dependency-update service. For GitHub Actions, it checks the workflow references for newer releases and opens pull requests that update them. This gives you a reviewable place to inspect and approve an Action update instead of changing pins manually or using floating tags.</p>
<p>Create <code>.github/dependabot.yml</code>:</p>
<pre><code class="language-yaml">version: 2
updates:
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
groups:
actions:
patterns:
- "*"
</code></pre>
<p>Dependabot opens pull requests when pinned SHAs have newer releases. Review and merge like application dependencies.</p>
<p>Don't configure Dependabot to merge these updates automatically until you have a review process for action changes. An update can contain new permissions, changed scripts, or a new transitive dependency.</p>
<p>For each update pull request, compare the old and new commit, inspect the action's release notes, and review changes to <code>action.yml</code> JavaScript bundles, shell scripts, and workflow permissions. Confirm that the new commit belongs to the release you intended to adopt. Run the workflow in a test repository or environment when the action handles deployment, publishing, credentials, or other high-impact operations.</p>
<p>The goal isn't to reject every update. The goal is to make the trust decision visible and repeatable. A reviewer should be able to answer three questions before merging: what code changed, why is the new version needed, and what permissions can that code use?</p>
<h2 id="heading-allowlist-actions">Allowlist Actions</h2>
<p>An Action allowlist is a repository or organization policy that limits which publishers and repositories workflows are allowed to call. It reduces the chance that a contributor introduces an unknown or unreviewed Action into a trusted pipeline.</p>
<p>Organization administrators can configure it at <strong>Settings → Actions → Policies → Allow specified actions</strong>. Choose the narrowest policy that matches your workflows, then add only the Action repositories your teams have reviewed.</p>
<p>Example allowlist patterns:</p>
<pre><code class="language-text">actions/checkout@*
actions/setup-node@*
actions/cache@*
docker/*
my-org/*
</code></pre>
<p>With this policy, Actions that don't match the approved patterns are denied. Repositories under the organization inherit the policy, subject to the organization's GitHub plan and repository settings.</p>
<p>For a single repo without an org policy, use <strong>Settings → Actions → General → Allow actions created by GitHub, and select non-GitHub actions</strong> and restrict to verified creators only.</p>
<p>An allowlist limits which action identities may run. It doesn't replace SHA pinning. A permitted action should still be referenced by a reviewed full SHA in every workflow.</p>
<h2 id="heading-enforce-pins-in-pull-requests">Enforce Pins in Pull Requests</h2>
<p>Run a workflow linter in CI and add a repository check that fails when a workflow introduces a non-SHA reference. Pin the checker itself before using it in a protected workflow. Replace the placeholder below with the full commit SHA you verified for the actionlint release you selected:</p>
<pre><code class="language-yaml">name: actionlint
on:
pull_request:
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
- uses: rhysd/actionlint@03d0035246f3e81f36aed592ffb4bebf33a03106 # v1.7.7
with:
args: -color
</code></pre>
<p>Resolve the release tag, review the commit, and record the full 40-character SHA just as you did for <code>actions/checkout</code>.</p>
<p>You can also use a small check to catch common floating references. Treat this as a backup signal, not a YAML parser or a complete supply chain policy:</p>
<pre><code class="language-yaml"> - name: Reject floating action tags
run: |
if grep -RInE '^[[:space:]]*-?[[:space:]]*uses:[[:space:]]*[^#]+@(main|master|v[0-9]+)([[:space:]]|$)' .github/workflows/; then
echo "Floating action refs found. Pin to full SHA."
exit 1
fi
</code></pre>
<p>The check should run on pull requests and on protected branches. A pull request can pass this test and still contain a malicious commit, so combine it with code review, the allowlist, and the SHA validation process.</p>
<h2 id="heading-vet-third-party-actions">Vet Third-Party Actions</h2>
<p>Before adding a community action to the allowlist:</p>
<ol>
<li><p>Read the action's <code>action.yml</code> and entry script.</p>
</li>
<li><p>Check star history, maintainer reputation, and open security issues.</p>
</li>
<li><p>Pin SHA and fork to <code>my-org/action-name</code> if the action is critical but externally maintained.</p>
</li>
</ol>
<p>Also inspect the permissions available to the workflow. An action that can read repository secrets or write releases deserves more scrutiny than an action that only formats a file. Set the workflow's top-level permissions to the minimum required, then grant additional permissions to individual jobs only when needed.</p>
<h2 id="heading-how-to-verify-this-works">How to Verify This Works</h2>
<ol>
<li><p>Run the SHA validation script for every action you approve and expect all comparisons to pass.</p>
</li>
<li><p>Open a test pull request that changes an action to<code>@main</code>, and confirm the CI gate fails.</p>
</li>
<li><p>Add a trailing comment after a floating tag and confirm your parser or linter still rejects it.</p>
</li>
<li><p>Confirm organization policy blocks a disallowed action in a test workflow.</p>
</li>
<li><p>Confirm Dependabot opens an action update pull request and that the change receives normal review.</p>
</li>
<li><p>Review the workflow run permissions and verify that the action can't access credentials it doesn't need.</p>
</li>
</ol>
<h2 id="heading-when-this-breaks-down">When This Breaks Down</h2>
<ol>
<li><p><strong>Emergency patches:</strong> SHA pinning slows hotfixes. Dependabot plus on-call review is safer than temporarily using a floating tag.</p>
</li>
<li><p><strong>Composite actions in private repos:</strong> pin internal actions the same way as public ones.</p>
</li>
<li><p><strong>Reusable workflows:</strong> pin the reusable workflow ref to SHA as well as steps inside it.</p>
</li>
<li><p><strong>Annotated tags and mirrors:</strong> resolve annotated tags to their commit before recording a SHA, and validate internal mirrors through your own change-control process.</p>
</li>
<li><p><strong>False sense of safety:</strong> pinning without reading code still trusts the maintainer at pin time. Combine pinning with allowlists, least-privilege permissions, and review.</p>
</li>
</ol>
<h2 id="heading-conclusion">Conclusion</h2>
<p>In this tutorial, you learned how to prevent poisoned CI dependencies by pinning GitHub Actions to reviewed commit SHAs, validating those references, allowlisting trusted actions, enabling Dependabot updates, and enforcing pins in pull request checks.</p>
<p>The result is a layered control: a reviewed immutable reference, a restricted set of permitted actions, automated update proposals, and a pull request check that catches regressions before merge.</p>
<h2 id="heading-references">References</h2>
<ul>
<li><p><a href="https://docs.github.com/en/actions/security-guides/security-hardening-for-github-actions">GitHub Actions, Security hardening</a></p>
</li>
<li><p><a href="https://docs.github.com/en/code-security/dependabot/working-with-dependabot/keeping-your-actions-up-to-date-with-dependabot">GitHub, Dependabot version updates for Actions</a></p>
</li>
<li><p><a href="https://www.stepsecurity.io/blog/pinning-github-actions-for-enhanced-security">StepSecurity, Pin GitHub Actions</a></p>
</li>
<li><p><a href="https://github.com/ossf/scorecard/blob/main/docs/checks.md#pinned-dependencies">OpenSSF, Source compromise defenses</a></p>
</li>
</ul>