I Read the CI of 300 Popular Repos. 145 Still Run Third-Party Actions by Tag or Branch
Key takeaways
- 145 of 265 repos (54.7%) that use third-party actions reference at least one by a tag or branch, and in the AI half it is 88 of 135 (65.2%).
- Across 5,640 third-party references, 64.1% are pinned to a full SHA: 55.0% in AI/LLM/MCP repos against 78.3% in general repos.
- The median AI repo pins 23% of its third-party actions, while the median general repo pins 100%.
- Repos with Dependabot or Renovate for actions pin 77.0% of third-party references to a SHA, against 37.2% without an updater.
- 11 days after GitHub's workflow lockfile preview, 0 of the 300 repos have an actions.lock, and 83 of 123 pypa/gh-action-pypi-publish references use the release/v1 branch.
On 25 October 2026 I downloaded every workflow file from 300 popular open-source repos, half of them AI, LLM and MCP projects, and parsed 4,750 workflows. 265 of the 300 use at least one third-party action. 145 of those 265 (54.7%) still reference at least one of them by a tag or a branch, which is a pointer that whoever controls that action can move. In the AI half it is 88 of 135 (65.2%).
TL;DR (for non-technical readers)
- Most open-source projects use small add-ons (“actions”) written by other people to build and release their software. You can point to an add-on by a version name, which its owner can quietly change, or by a fixed fingerprint, which cannot change.
- I checked 300 popular projects. Of the 265 that use outside add-ons, 145 still use at least one by its changeable name. In AI projects it is about 2 in 3.
- This exact weakness was abused at least three times in the past 19 months: in March 2025, March 2026 and June 2026. Projects that used the fixed fingerprint were not affected.
- The fix is cheap: use the fingerprint, let a bot keep it updated, and give the build robot only the permissions it needs.
Why now
Three incidents, all the same shape:
- tj-actions/changed-files, March 2025. The GitHub advisory for CVE-2025-30066 says attackers retroactively pointed version tags at a malicious commit that printed CI secrets into public logs, impacting “over 23,000 repositories” between 14 and 15 March 2025.
- aquasecurity/trivy-action, March 2026. According to Snyk’s write-up, attackers force-pushed malicious code to 75 of 76 version tags, exposed for about 12 hours on 19 and 20 March 2026.
- codfish/semantic-release-action, June 2026. StepSecurity reports a malicious commit force-pushed on 24 June 2026 and version tags repointed to it.
In all three, users who pinned a full commit SHA were not affected. GitHub’s own secure use reference says to “Pin references to actions to a commit SHA in your workflow files”. Since 15 August 2025, administrators can enforce that with a checkbox in the allowed actions policy at enterprise, organization or repository level, and block specific actions with a ! prefix. And on 14 October 2026, GitHub put workflow lockfiles (.github/workflows/actions.lock, generated by gh actions-lock) into public preview. So: how far along are popular repos?
Method
- Repo list. GitHub search sorted by stars, non-archived, non-fork, pushed since 25 April 2026. AI queries:
topic:llm,topic:mcp,topic:model-context-protocol,topic:ai-agents,topic:large-language-models,topic:agents,topic:rag,topic:mcp-server. General:language:python,language:typescript,language:javascript, with any repo carrying an AI or ML topic moved to the AI group. From 1,921 candidates I walked down by stars, skipped 11 awesome-lists and 25 repos without workflows, and stopped at 150 per group. The smallest repo has 38,791 stars and the median 65,347. - Fetch. The contents API listed
.github/workflows, and every.yml/.yamlwas downloaded and cached. Read-onlyghtoken, GET requests only. - Classify. Each
uses:is local (./),docker://, first-party (actions/*,github/*), same-owner (the repo’s own org) or third-party. Refs are a 40-hex SHA, a version tag, or a branch. Refs that were neither, likerelease/v1orstable, I resolved through the API. - Risky patterns. Top-level
permissions:,pull_request_targetcombined with a checkout of the PR head, and attacker-controlled contexts (github.event.issue.title,pull_request.title,head_ref,comment.bodyand the rest of GitHub’s list) written straight into arun:step.
The core of the classifier:
SHA40 = re.compile(r"^[0-9a-f]{40}$")
VER = re.compile(r"^v?(\d+)(\.\d+)?(\.\d+)?([-+.].*)?$")
path, _, ref = uses.partition("@")
owner = path.split("/")[0].lower()
kind = "first_party" if owner in {"actions", "github"} else "third_party"
ref_class = "sha" if SHA40.match(ref) else "tag" if VER.match(ref) else "branch"
The fetch finished at 12:41 WIB (05:41 UTC): 29,330 uses: references, 5,640 of them to 472 distinct third-party actions.
Results

| All | AI/LLM/MCP | General | |
|---|---|---|---|
| Third-party refs | 5,640 | 3,438 | 2,202 |
| Pinned to full SHA | 64.1% | 55.0% | 78.3% |
| By tag | 32.3% | 39.8% | 20.6% |
| By branch | 3.6% | 5.2% | 1.1% |
| Repos using third-party actions | 265 | 135 | 130 |
| …with at least one unpinned ref | 145 (54.7%) | 88 (65.2%) | 57 (43.8%) |
| …fully SHA-pinned | 120 (45.3%) | 47 (34.8%) | 73 (56.2%) |
| …with zero SHA pins | 93 (35.1%) | 55 (40.7%) | 38 (29.2%) |
| Median repo’s SHA share | 75% | 23% | 100% |
Of the 1,822 tag references, 1,525 are major-only like @v4, the kind of tag that is designed to move. When people do pin, they mostly do it well: 2,579 of 3,614 SHA references (71.4%) carry a version comment.
The most-used third-party actions, plus anthropics/claude-code-action (rank 16) because it is so common in AI repos:

| Action | Repos | Refs | SHA share |
|---|---|---|---|
| docker/login-action | 95 | 369 | 61% |
| docker/setup-buildx-action | 89 | 278 | 61% |
| docker/build-push-action | 79 | 226 | 57% |
| astral-sh/setup-uv | 62 | 470 | 43% |
| pnpm/action-setup | 55 | 533 | 49% |
| pypa/gh-action-pypi-publish | 48 | 123 | 31% |
| oven-sh/setup-bun | 47 | 300 | 66% |
| softprops/action-gh-release | 46 | 71 | 41% |
| anthropics/claude-code-action | 21 | 41 | 61% |
pypa/gh-action-pypi-publish stands out: 83 of its 123 references use the release/v1 branch, which is what the project’s own README uses in its examples. That action handles PyPI publishing credentials, so it is the one I would pin first. tj-actions/changed-files, 19 months after its compromise, is still in 11 repos, and 8 of its 23 references are tags.
Permissions and the risky patterns:
| All | AI/LLM/MCP | General | |
|---|---|---|---|
Workflows with top-level permissions: | 70.3% | 67.6% | 74.3% |
Workflows with no permissions: at any level | 878 (18.5%) | 579 (20.4%) | 299 (15.6%) |
| Repos where every workflow sets it | 84 (28.0%) | 29 (19.3%) | 55 (36.7%) |
Repos with a pull_request_target workflow | 122 | 64 | 58 |
| …checking out the PR head | 5 | 4 | 1 |
Repos with untrusted context in run: | 7 | 6 | 1 |
Repos with .github/workflows/actions.lock | 0 | 0 | 0 |
Three findings
1. The median AI repo pins less than a quarter of its third-party actions. The median general repo pins all of them: 23% against 100%. Counted per reference, it is 55.0% against 78.3%. AI projects are younger and move fast, and their CI shows it. That is uncomfortable, because their workflows can hold model API keys, PyPI and npm tokens and registry credentials.
2. Having a bot for updates goes hand in hand with pinning. 145 of the 300 repos (48.3%) configure Dependabot for github-actions or Renovate. In those repos, 77.0% of third-party references are SHA-pinned, and 91 of 139 are fully pinned. Without an updater, it is 37.2%, and 29 of 126.

This is a correlation, not proof. My reading: pinning once is easy, and keeping dozens of SHAs current by hand is the part that hurts, so repos that pin tend to be the ones that automated the updates.
3. The scary patterns are rare, and mostly gated. 122 repos (40.7%) have a pull_request_target workflow, but only 5 check out the PR’s code in it. Two of those put the job behind a GitHub environment used as a manual approval gate. The other 3 deserve a closer look, and I am handling them privately. 7 repos write an attacker-influenced value straight into run:. On reading them, every one was on an unprivileged trigger, gated by an if: such as “same-repo branch” or “author is a member”, or behind a trust-check job. None looked exploitable from outside, but each is one edited if: away from being exploitable. I am not naming any of these repos.
And the newest control has not landed yet: 11 days after the public preview, none of the 300 repos has an actions.lock.
Fix checklist
- Pin with a SHA plus a version comment. Resolve the tag to a commit, and check that it comes from the action’s own repo, not a fork:
gh api repos/OWNER/ACTION/commits/v1.2.3 --jq .sha
- uses: OWNER/ACTION@<full 40-character sha> # v1.2.3
- Let a bot keep the pins current. Dependabot, in
.github/dependabot.yml:
version: 2
updates:
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
GitHub’s docs say Dependabot updates actions/checkout@<commit> references, and updates the version comment “when the comment is on the same line”. One catch from the same docs: Dependabot alerts are only raised for actions referenced by semantic version, not for SHA pins. On Renovate, add the helpers:pinGitHubActionDigests preset to extends.
-
Enforce it. The allowed actions policy in your repository, organization or enterprise Actions settings has a checkbox to require full-length SHA pinning; unpinned workflows then fail. For transitive pins, try the
gh actions-locklockfile preview. -
Start every workflow with minimal permissions and raise them per job:
permissions:
contents: read
jobs:
release:
permissions:
contents: write
- Never put untrusted input inside
run:. Pass it throughenv:. Adapted from GitHub’s docs:
- name: Check PR title
env:
TITLE: ${{ github.event.pull_request.title }}
run: |
if [[ "$TITLE" =~ ^octocat ]]; then
echo "PR title starts with 'octocat'"
fi
- Do not check out PR code in
pull_request_target. If you must, put the job behind an environment with required reviewers. Since 8 December 2025 GitHub always takes the workflow file from the default branch for this trigger, but that does not make an explicit checkout of the PR head safe.
Caveats
- Static analysis only. I read YAML. I did not run anything, and composite actions inside the repos (
.github/actions/*) and transitive actions were not scanned. - A SHA pin is not a full fix. A pinned action that downloads a script or uses
@maininternally still moves. - Settings are invisible. A repo can set the default
GITHUB_TOKENto read-only or enforce SHA pinning in its settings, and neither shows up in YAML. “Nopermissions:block” is not proof of a write token. - Classification limits. Version-like refs were counted as tags without checking the API, and a few could be branches. Same-org actions (for example a project’s own reusable workflows) were left out of third-party counts.
- Sample. 300 repos by stars and topics, one day, default branch only. The AI split follows self-chosen GitHub topics.
I am publishing aggregates only.
Closing
Pinning is not exotic anymore: 120 of 265 repos pin every third-party action, and GitHub now lets you enforce it with a checkbox. The gap is in fast-moving AI repos, and in workflows nobody has touched since they were copied from a README.
If you maintain a popular repo that still uses @v4: what is actually stopping you, the review noise from update PRs, or something else?
Related: a 1-day cooldown would have blocked 70% of 2026’s hijacked npm releases and the LiteLLM supply chain backdoor.
Frequently asked questions
- How do I pin a GitHub Action to a commit SHA?
- Resolve the tag to a commit with gh api repos/OWNER/ACTION/commits/v1.2.3 --jq .sha, check it comes from the action's own repo and not a fork, then write uses: OWNER/ACTION@<full 40-character sha> # v1.2.3 in the workflow, keeping the version as a comment.
- Does Dependabot update SHA-pinned actions?
- Yes. Add the github-actions package-ecosystem to .github/dependabot.yml (version 2, directory /, a weekly schedule). Dependabot updates actions/checkout@<commit> references and the version comment when it is on the same line. One catch: Dependabot alerts are only raised for actions referenced by semantic version, not for SHA pins. On Renovate, add the helpers:pinGitHubActionDigests preset.
- How can I enforce SHA pinning across a repository or organization?
- Since 15 August 2025 the allowed actions policy at repository, organization or enterprise level has a checkbox to require full-length SHA pinning, and unpinned workflows then fail. You can also block specific actions with a ! prefix. GitHub's workflow lockfile preview (.github/workflows/actions.lock, generated by gh actions-lock) covers transitive pins.
- How many popular repos pin their third-party actions by SHA?
- Of 265 repos using third-party actions, 120 (45.3%) pin every one to a full SHA and 145 (54.7%) have at least one tag or branch reference. Per reference, 64.1% of 5,640 are SHA-pinned, 32.3% use a tag and 3.6% a branch.
About the author
Security Engineer & Technical Writer
Security engineer and technical writer based in Batam, Indonesia. I review and harden MCP servers and AI agents, build security automation, and write up what holds and what fails. I also train on DNS security as a PANDI .id Academy trainer.
Related articles
-
9 min read
A 1-Day Cooldown Would Have Blocked 70% of 2026's Hijacked npm Releases. Against Typosquats, 7 Days Still Misses 45%
I replayed 11,556 malicious npm and PyPI packages from 2026 against a dependency cooldown. Hijacks die fast, typosquats don't, and fixes pay the bill.
- Security Engineering
- Engineering Practice
-
9 min read
Python 3.15 Is Out. I Checked the Top 500 PyPI Packages: 97% Install From a Wheel, 12 Still Run Build Scripts
I classified the latest release of the 500 top PyPI packages for Python 3.15. 485 install from a wheel; 12 fall back to running build code, led by PyYAML.
- Engineering Practice
- Security Engineering
-
6 min read
UUIDv7 Is a Great Database Key and a Bad Clock: 4 Tests Before You Migrate
UUIDv7 is a great database key but a poor clock. Four quick tests, what the embedded timestamp leaks, and a rule of thumb for using UUIDv7 as a primary key.
- Security Engineering
- Engineering Practice
All writing | RSS | Markdown