
An implementation guide for engineers who own GitHub Actions workflows and organization settings, built on GitHub documentation, changelogs and advisories, the CISA alert on tj-actions and reviewdog, and OpenSSF Scorecard, reviewed October 7, 2026. It covers reference mutability, the SHA pinning policy, Dependabot updates, token and secret scoping, admission review and the gaps pinning leaves.
At a glance
Key findings
- Attackers moved several tj-actions/changed-files version tags to one malicious commit in March 2025, so workflows that referenced those tags ran code that printed runner secrets to build logs. [1][2]
- Since August 15, 2025, the allowed actions policy can require full-length commit SHAs at enterprise, organization or repository level, and unpinned actions fail; reusable workflows may still use tags. [5][6]
- SHA-pinned actions get no Dependabot alerts, so pinned estates need version updates, a cooldown and advisory monitoring in place of alerts. [4][14]
- A pinned action can still read every secret in its job and use the token's permissions, so read-only defaults, job separation and OIDC limit what a malicious pinned action can take. [4][16][17]
- Pinning does not cover tag references inside composite actions or code fetched at run time; immutable releases help tag pins, and GitHub's transitive lock files remain on the roadmap. [3][10][13]
What moved in the tj-actions compromise
Use two controls together. First, reference every third-party action by its full 40-character commit SHA, resolved from the action's own repository, and make the organization enforce that with the policy "Require actions to be pinned to a full-length commit SHA". Second, shrink what any step can reach: a read-only GITHUB_TOKEN by default, write scopes and secrets only in the jobs that need them, and short-lived OIDC credentials instead of stored cloud keys. Pinning decides which code runs. Permissions decide what that code can take if it turns out to be hostile, and a pin cannot answer that question for you. [4][5]
The tj-actions incident shows why the first control exists. According to GitHub's advisory, between March 14 and March 15, 2025, attackers retroactively modified multiple version tags of tj-actions/changed-files so they referenced one malicious commit, 0e58ed8671d6b60d0890c21b07f8835ace038e67. That commit fetched a Python script that scanned the Runner Worker process memory for secrets and printed them, double base64-encoded, into the build log. Workflows that referenced a tag picked up the new code on their next run with no change on their side, and in public repositories anyone could read those logs. The advisory put the reach at over 23,000 repositories and named v46.0.1 as the fixed release. [1][2]
CISA's alert, first published March 18, 2025 and revised on March 19 and March 26, asked organizations to look for any version of the action used between 2025-03-12 00:00 UTC and 2025-03-15 12:00 UTC, a wider window than the advisory's. It said the compromise was potentially enabled by an earlier one: reviewdog/action-setup@v1, altered on March 11, 2025 between 18:42 and 20:31 UTC. CISA added both CVE-2025-30066 and CVE-2025-30154 to its Known Exploited Vulnerabilities catalog on March 26. [1]
Two details from the advisories shape the rest of this guide. The tj-actions advisory told users who had referenced the malicious commit directly by its SHA to update, which is a reminder that a SHA guarantees the code will not change, not that the code is good. The reviewdog advisory said other reviewdog actions that use reviewdog/action-setup@v1 "would also be compromised, regardless of version or pinning method", because those composite actions called the setup action by tag. A pin fixes one layer; whatever that layer calls by tag can still move. [2][3]
Because the payload read process memory, it did not need anyone to pass it a secret as an input. Treat every secret the job can reach as exposed to every action in that job. The response side of these incidents, from finding affected runs to rotating credentials, is covered separately; this guide is about prevention.
Reference types and what can change
A uses: value names a repository and a Git ref. GitHub's workflow syntax shows four common forms: a commit SHA, a major version tag such as @v6, a specific version tag such as @v6.2.0, and a branch such as @main. Only the first is fixed. GitHub's secure use reference calls a full-length commit SHA "currently the only way to use an action as an immutable release", because substituting different content under the same SHA would need a SHA-1 collision for a valid Git object. Tags and branches are pointers, and the same page warns that a tag "can be moved or deleted if a bad actor gains access to the repository storing the action". [4][16]
Major version tags are designed to move. The workflow syntax describes them as the way to receive critical fixes while staying compatible, and adds that this behavior is at the discretion of the action's author. That is the property the tj-actions attacker used: workflows had already agreed to run whatever the tag pointed at next. [16]
Where the SHA comes from matters as much as its length. GitHub tells you to verify that a SHA is from the action's repository and not from a fork. Resolve it yourself from the upstream tag rather than copying it from a pull request, an issue comment or a blog post. For an annotated tag, git ls-remote prints two lines: the tag object and, with a ^{} suffix, the commit it points to. Pin the commit. [4]
Three other reference types need their own rule. A docker:// reference names a registry image by tag in GitHub's examples, and a registry tag can be repointed by whoever controls that image repository, so treat it the way you treat an action tag. A reusable workflow called from another repository can still be referenced by tag even when SHA pinning is enforced. And a reference that starts with $/, added to GitHub.com on July 30, 2026, resolves to the workflow's own repository at the commit that is already running. [6][9][16]
# Example: resolve a release tag to the commit it points at, from the
# action's own repository. For an annotated tag, use the line ending in ^{}.
git ls-remote --tags https://github.com/example-org/example-action "refs/tags/v3.1.0*"
# Output shape (placeholder values):
# 4444444444444444444444444444444444444444 refs/tags/v3.1.0
# 2222222222222222222222222222222222222222 refs/tags/v3.1.0^{}Only a commit SHA fixes the code
Tags, branches and image tags are pointers; even a SHA pin leaves nested references open. [3][4][6]

Source. GitHub secure use reference, organization Actions settings, immutable releases documentation, self-repository syntax changelog and reviewdog advisory. [3][4][6][9][10]
Method. Conceptual comparison assembled from documented behavior; rows are reference forms, not measurements. Docker image references are discussed in the text because GitHub does not document how the pinning policy treats them.
Accessible table and figure data
| Reference | Changed by | Pinning policy | Leaves open |
|---|---|---|---|
| a branch, @main | any push to the branch | rejects it | different code on each run |
| a major tag, @v6 | the maintainer, each release | rejects it | whatever the next release holds |
| a version tag, @v6.2.0 | anyone gaining repository write access | rejects it | a moved or deleted tag |
| an immutable release tag | nothing while the release exists | rejects it | releases the publisher did not lock |
| a full commit SHA | nothing short of a SHA-1 collision | accepts it | nested refs and downloads |
| a reusable workflow tag | the called repository's owner | accepts the tag | the workflow's own action refs |
| a self reference, $/path | your own commits | accepts it | nothing outside this repository |
| Reference | Changed by | Pinning policy | Leaves open |
|---|---|---|---|
| a branch, @main | any push to the branch | rejects it | different code on each run |
| a major tag, @v6 | the maintainer, each release | rejects it | whatever the next release holds |
| a version tag, @v6.2.0 | anyone gaining repository write access | rejects it | a moved or deleted tag |
| an immutable release tag | nothing while the release exists | rejects it | releases the publisher did not lock |
| a full commit SHA | nothing short of a SHA-1 collision | accepts it | nested refs and downloads |
| a reusable workflow tag | the called repository's owner | accepts the tag | the workflow's own action refs |
| a self reference, $/path | your own commits | accepts it | nothing outside this repository |
Enforce SHA pinning at the organization
GitHub added SHA pinning enforcement to the allowed actions policy on August 15, 2025. A checkbox appears under each policy choice at enterprise, organization and repository level, except where Actions is disabled, and according to the announcement "any workflow that attempts to use an action that isn't pinned will fail". The requirement covers actions from your own organization and those authored by GitHub, such as actions/checkout, so it is not only a third-party rule. The same release added explicit blocking: prefix an allowlist entry with ! and it is evaluated last, overriding anything that would otherwise allow the action. [5][6]
The setting is also exposed in the REST API as the sha_pinning_required boolean on the organization and repository Actions permissions endpoints, next to allowed_actions, which is one of all, local_only or selected. That makes it auditable. A script that reads both values for every repository shows where the policy is on, where repositories still allow any action, and where an organization default is being relied on without anyone having checked it. [7]
Expect enforcement to look past your own workflow files. In a public issue filed in September 2026, the Pester project reported a run that failed during "Set up job" with the message "The action actions/cache@v4.2.0 is not allowed in pester/Pester because all actions must be pinned to a full-length commit SHA". The tag reference was inside a third-party composite action that the workflow called, not in the workflow itself. GitHub's policy documentation does not describe nested evaluation, but this report shows it happening, and it means a repository whose workflows are fully pinned can still break when the policy is switched on. [8]
Actions that live in the same repository used to be a gap. Before the $/ syntax, a workflow referenced its own actions either with ./ after a checkout or with a hard-coded version, and GitHub's announcement says the second approach "quietly defeated commit SHA pinning". A $/ reference needs runner version 2.336.0 or newer, must not carry an @ref suffix, and is not available in GitHub Enterprise Server. [9][16]
A workable rollout runs in this order. Inventory unpinned references with OpenSSF Scorecard, whose Pinned-Dependencies check looks for unpinned dependencies in workflows, Dockerfiles and shell scripts, or with a repository search. Pin them, including inside your own composite actions. Turn the policy on for a few repositories that run often, fix what fails at "Set up job", then enable it for the organization. Keep the blocklist for emergencies: when an advisory names an action, an entry such as !owner/action@* stops it everywhere while you investigate. [5][6][18]
# Example: report the Actions policy for an organization and each repository.
# Needs a token that can read organization and repository Actions settings.
ORG="example-org"
gh api "orgs/$ORG/actions/permissions" \
--jq '{allowed_actions, sha_pinning_required}'
gh api --paginate "orgs/$ORG/repos" --jq '.[].name' |
while read -r repo; do
gh api "repos/$ORG/$repo/actions/permissions" \
--jq "[\"$repo\", .enabled, .allowed_actions, .sha_pinning_required] | @tsv"
done
# After the pilot, enable it for the organization. Keep enabled_repositories
# and allowed_actions at the values the first command returned.
# gh api -X PUT "orgs/$ORG/actions/permissions" \
# -f enabled_repositories=all -f allowed_actions=selected -F sha_pinning_required=trueKeep pins current without blind updates
A pinned action does not receive fixes on its own. Scorecard's documentation makes the same point about pinning in general and recommends automated update tooling plus quick updates. For workflows, that tool is Dependabot version updates with package-ecosystem: "github-actions" and directory: "/", which makes Dependabot read .github/workflows and a root action.yml or action.yaml. Since October 2022, when Dependabot bumps a SHA-pinned action it also rewrites the version in the trailing comment, so a pin such as @<sha> # v3.1.0 stays readable. [14][15][18]
Pinning changes how you hear about vulnerabilities. GitHub's secure use reference states that Dependabot "only creates alerts for vulnerable actions that use semantic versioning and will not create alerts for actions pinned to SHA values". An estate that pins everything therefore depends on version update pull requests, on the dependency graph, which records the version or SHA each workflow uses, and on watching the GitHub Advisory Database for the actions ecosystem. Plan that monitoring before enforcement removes the alerts you used to get. [4]
Dependabot now waits before proposing new releases. Version updates have a default cooldown of three days, even when cooldown is not configured, and the default-days option changes it. For GitHub Actions only default-days is supported, not the separate major, minor and patch settings, and no cooldown applies to security updates. A longer cooldown gives maintainers and the community time to notice a malicious new release before a bot proposes it. It does nothing about a tag that is moved in place to point at different code, which is the case the SHA pin covers. [14]
Review the bump, not just the green check. The pull request diff shows one SHA replaced by another, which says nothing about the code between them. Open the upstream comparison between the old and new commits, confirm that the new SHA is what the upstream tag resolves to, and read changes to action.yml, to bundled JavaScript under directories such as dist/, and to any nested uses: lines. Grouping action updates into one weekly pull request keeps that review to a predictable slot.
# Example .github/dependabot.yml for workflow and action references.
version: 2
updates:
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
cooldown:
default-days: 7
groups:
actions:
patterns:
- "*"Reduce token and secret reach
GitHub is explicit about the stakes. A compromised action "would have access to all secrets configured on your repository, and may be able to use the GITHUB_TOKEN to write to the repository". An action can also read the token through the github.token context even when the workflow never passes it, so leaving it out of the inputs is not a restriction. The only restriction is the permission set the token carries. [4][17]
Set permissions at the top of every workflow to contents: read, or to {} when the jobs do not need repository access, and add write scopes only in the jobs that use them. Once any permission is specified, every permission that is not listed is set to none. Scorecard's Token-Permissions check awards its highest score to exactly this shape: read-only at the top level, writes declared per job. Then check the organization default. New organizations on GitHub.com start with the restricted setting, which grants read access to contents and packages only, but an older organization may still issue read and write tokens to every workflow that does not say otherwise. [6][16][18]
Scope secrets to jobs, not steps, when you reason about exposure. Passing a secret through a single step's env keeps it out of other steps' environment variables, which helps against careless logging. It did not help against the tj-actions payload, which read the runner process memory instead of waiting to be handed a value. The stronger boundary is to run third-party actions such as linters, formatters and changed-file detectors in a job that holds no secrets and only read permissions, and to pass results to the deploying job as outputs or artifacts. GitHub warns that jobs in a workflow can interact, for example through environment variables, shared directories or the Docker socket, so treat job separation as a reduction rather than isolation, especially on self-hosted runners that are reused. [2][4]
Then replace what a thief would want most. Cloud credentials stored as repository secrets can become short-lived tokens issued through OpenID Connect, requested with id-token: write in the one job that deploys. Environment secrets can sit behind required reviewers, so a job cannot read them until someone approves the run. And avoid pull_request_target unless you need it, because those runs have access to secrets and may have repository write access. [4][16]
# Example workflow. Every SHA and version comment is a placeholder: resolve
# each SHA from the upstream tag named in its comment before use.
name: build-and-deploy
on:
push:
branches: [main]
permissions:
contents: read
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@1111111111111111111111111111111111111111 # v7.0.1
- uses: example-org/lint-action@2222222222222222222222222222222222222222 # v3.1.0
deploy:
needs: lint
runs-on: ubuntu-latest
environment: production
permissions:
contents: read
id-token: write
steps:
- uses: actions/checkout@1111111111111111111111111111111111111111 # v7.0.1
- uses: aws-actions/configure-aws-credentials@3333333333333333333333333333333333333333 # v6.3.0
with:
role-to-assume: arn:aws:iam::111122223333:role/example-deploy
aws-region: us-east-1
- run: ./scripts/deploy.shPin the code, then shrink its reach
The hardened workflow fixes which code runs and limits what any one step can take.

Source. Conceptual comparison based on GitHub secure use, workflow syntax and Dependabot documentation. [4][14][16]
Method. Conceptual hypothetical workflow; no repository was inspected.
Accessible table and figure data
| Aspect | Before | After |
|---|---|---|
| Action references | tags such as @v4 that can move | full commit SHAs with version comments |
| Token permissions | the repository default, often read and write | read-only at the top, writes per job |
| Third-party actions | in the job that deploys | in a job with no secrets |
| Cloud credentials | a long-lived access key in a secret | an OIDC token in one job |
| Deployment secrets | readable by any run | behind an environment with required reviewers |
| Updates | manual, or never | reviewed Dependabot bumps after a cooldown |
| Aspect | Before | After |
|---|---|---|
| Action references | tags such as @v4 that can move | full commit SHAs with version comments |
| Token permissions | the repository default, often read and write | read-only at the top, writes per job |
| Third-party actions | in the job that deploys | in a job with no secrets |
| Cloud credentials | a long-lived access key in a secret | an OIDC token in one job |
| Deployment secrets | readable by any run | behind an environment with required reviewers |
| Updates | manual, or never | reviewed Dependabot bumps after a cooldown |
Admission review for new actions
The allowed actions policy gives you a place to make admission explicit. With "Allow OWNER, and select non-OWNER, actions and reusable workflows", local actions and your organization's own are allowed, and you can add actions created by GitHub, Marketplace actions from verified creators, and up to 1,000 named patterns. Patterns accept a tag or a SHA, as in OWNER/REPOSITORY@TAG-OR-SHA. GitHub describes the verified creator badge as a signal that the publishing team's identity was verified, which is not a review of the code. [4][6]
Allowlisting by SHA turns every update into a second gate. If the allowlist names example-org/lint-action@<sha>, a Dependabot pull request that moves to a new SHA fails until someone adds the new commit to the list. That is extra work for each bump, and it is the only setting here that the organization enforces on every new commit of a third-party action, whoever merges the bump. A middle path is to allow trusted publishers by owner wildcard and reserve SHA entries for actions that run in jobs with secrets.
The review itself is short and concrete. Read action.yml to learn whether the action runs JavaScript, a composite list of steps or a container. For composite actions, list every nested uses: and check that each is pinned. For container actions, check whether the image is built from a Dockerfile in the repository or pulled by tag. Search the code for network calls and downloads, since the tj-actions payload fetched its script at run time from gist.githubusercontent.com, and for anything that writes secrets or tokens to logs or unexpected hosts, which is what GitHub's guidance asks you to audit. Running Scorecard against the action's own repository shows whether its maintainers pin their dependencies and restrict their tokens. [2][4][18]
Some actions fail the review and are still needed. Copying one into a repository your organization controls makes you its maintainer, including for updates, which is a real cost but a known one. Replacing a small action with a few lines of shell removes the dependency entirely, and for many changed-file, labeling and notification steps that is the cheaper option.
Admit a new action in five questions
Prefer no new dependency; otherwise pin it, check what it pulls in, and limit what it can reach.

Source. Conceptual decision aid based on GitHub secure use guidance, the organization allowed actions policy, the tj-actions and reviewdog advisories and Scorecard checks. [2][3][4][6][18]
Method. Conceptual ordering of review steps; it does not replace reading the action's code.
Accessible table and figure data
| Question | Yes, then | No, then |
|---|---|---|
| Can an approved action or a short run step do it? | use that; admit nothing new | review the candidate |
| Is the SHA resolved from the upstream repository? | check what it calls | reject and resolve it again |
| Are all nested actions and images pinned? | check what it can reach | copy and pin it, or ask upstream |
| Does it need secrets or write scopes? | grant only those, in its own job | run it in a read-only job |
| Does it download code or call hosts at run time? | record them and prefer checksummed downloads | add its SHA to the allowlist |
| Question | Yes, then | No, then |
|---|---|---|
| Can an approved action or a short run step do it? | use that; admit nothing new | review the candidate |
| Is the SHA resolved from the upstream repository? | check what it calls | reject and resolve it again |
| Are all nested actions and images pinned? | check what it can reach | copy and pin it, or ask upstream |
| Does it need secrets or write scopes? | grant only those, in its own job | run it in a read-only job |
| Does it download code or call hosts at run time? | record them and prefer checksummed downloads | add its SHA to the allowlist |
What pinning does not solve
SHA pinning closes one route, the moved tag, and leaves several others open. Each needs its own control.
- A malicious commit pinned on purpose or by mistake. The tj-actions advisory had to tell users who referenced the bad SHA directly to update. Review and admission are the controls here, not immutability. [2]
- Tag references inside actions you pinned. The reviewdog composite actions were exposed whatever pinning method their callers used. Enforcement at the organization catches unpinned nested actions, as the Pester report shows, but only for
uses:references. [3][8] - Code fetched at run time. A pinned action that downloads an installer, a script or a
latestbinary runs whatever that URL returns today. Scorecard's Pinned-Dependencies check covers Dockerfiles and shell scripts for this reason. [18] - Tags on immutable releases. Immutable releases became generally available on October 28, 2025: the tag of an immutable release cannot be moved or deleted while the release exists, and the tag name cannot be reused even after deletion. Only the repository or organization that publishes the action can turn this on, existing releases stay mutable unless republished, and the SHA pinning policy still rejects tags, so this improves tag pins without replacing SHA pins. [5][10][11]
- Immutable actions published as packages. GitHub's roadmap item for immutable actions, which planned to publish actions as OCI packages, is closed as not planned. Do not design around it. [12]
- Transitive locking. GitHub's March 26, 2026 roadmap describes a
dependencies:section that locks direct and transitive action references by SHA, with a public preview targeted three to six months out. As of October 7, 2026 no release announcement was found, so treat it as roadmap rather than a control you have. [13]
Method and provenance
Source-led technical analysis of GitHub documentation, changelogs, REST API reference and security advisories, the CISA alert and OpenSSF Scorecard documentation, with original diagrams and explicitly hypothetical examples. Sources were reviewed on October 7, 2026.
No GitHub organization, repository or workflow run was inspected. Policy behavior is bounded to the cited documentation and one public issue report as of the review date; nested enforcement is not described in GitHub's documentation, and roadmap items may change.
AI assistance. AI assisted research synthesis, drafting, diagram planning and visual production, with deterministic editorial checks. No personal deployment experience, independent human review or live test is claimed.
Published under the Cloud Security Desk organizational byline. Read the practitioner guide policy.
References
- tj-actions changed-files through 45.0.7 allows remote attackers to discover secrets by reading actions logs (GHSA-mrrh-fwg8-r2c3) GitHub Advisory Database. Published . Accessed .
- Multiple reviewdog actions were compromised during a specific time period (GHSA-qmg3-hpqr-gqvc) reviewdog. Published . Accessed .
- Secure use reference GitHub. Accessed .
- GitHub Actions policy now supports blocking and SHA pinning actions GitHub. Published . Accessed .
- Disabling or limiting GitHub Actions for your organization GitHub. Accessed .
- REST API endpoints for GitHub Actions permissions GitHub. Accessed .
- Module cache in code-analysis blocks SHA pinning enforcement (issue 3036) Pester project. Published . Accessed .
- Reference same-repository actions with self-repository syntax GitHub. Published . Accessed .
- Immutable releases GitHub. Accessed .
- Immutable releases are now generally available GitHub. Published . Accessed .
- Immutable Actions [GA] (public roadmap issue 592) GitHub. Accessed .
- What's coming to our GitHub Actions 2026 security roadmap GitHub. Published . Accessed .
- Dependabot options reference GitHub. Accessed .
- Dependabot now updates comments in GitHub Actions workflows referencing action versions GitHub. Published . Accessed .
- Workflow syntax for GitHub Actions GitHub. Accessed .
- Use GITHUB_TOKEN for authentication in workflows GitHub. Accessed .
- OpenSSF Scorecard checks OpenSSF. Accessed .