# I Read the CI of 300 Popular Repos. 145 Still Run Third-Party Actions by Tag or Branch

> I parsed 4,750 workflows from 300 popular repos. 145 of 265 that use third-party actions still reference one by a movable tag or branch.

- URL: https://www.kielltampubolon.id/writing/i-read-the-ci-of-300-popular-repos-145-still-run-third-party-actions-by-tag/
- Author: Kiell Tampubolon
- Published: 2026-10-25
- Last updated: 2026-10-25
- Language: en
- Tags: Security Engineering, Engineering Practice

## 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](https://github.com/advisories/GHSA-mrrh-fwg8-r2c3) 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](https://snyk.io/articles/trivy-github-actions-supply-chain-compromise/), 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](https://www.stepsecurity.io/blog/supply-chain-compromise-codfish-semantic-release-action) 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](https://docs.github.com/en/actions/reference/security/secure-use) says to "Pin references to actions to a commit SHA in your workflow files". Since [15 August 2025](https://github.blog/changelog/2025-08-15-github-actions-policy-now-supports-blocking-and-sha-pinning-actions/), 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](https://github.blog/changelog/2026-10-14-github-actions-workflow-lockfiles-in-public-preview/), GitHub put workflow lockfiles (`.github/workflows/actions.lock`, generated by `gh actions-lock`) into public preview. So: how far along are popular repos?

## Method

1. **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.
2. **Fetch.** The contents API listed `.github/workflows`, and every `.yml`/`.yaml` was downloaded and cached. Read-only `gh` token, GET requests only.
3. **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, like `release/v1` or `stable`, I resolved through the API.
4. **Risky patterns.** Top-level `permissions:`, `pull_request_target` combined with a checkout of the PR head, and attacker-controlled contexts (`github.event.issue.title`, `pull_request.title`, `head_ref`, `comment.body` and the rest of GitHub's list) written straight into a `run:` step.

The core of the classifier:

```python
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

![Third-party refs by pin type for all, AI and general repos, and the share of repos with at least one unpinned third-party action: 55%, 65% and 44%.](https://www.kielltampubolon.id/writing/i-read-the-ci-of-300-popular-repos-145-still-run-third-party-actions-by-tag/img-1.png)

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

![SHA share for the 12 most-used third-party actions, from 71% for codecov/codecov-action down to 31% for pypa/gh-action-pypi-publish.](https://www.kielltampubolon.id/writing/i-read-the-ci-of-300-popular-repos-145-still-run-third-party-actions-by-tag/img-2.png)

| 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.

![SHA share with and without Dependabot or Renovate: 77% vs 37% overall, 66% vs 33% in AI repos, 93% vs 45% in general repos.](https://www.kielltampubolon.id/writing/i-read-the-ci-of-300-popular-repos-145-still-run-third-party-actions-by-tag/img-3.png)

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

1. **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:

```bash
gh api repos/OWNER/ACTION/commits/v1.2.3 --jq .sha
```

```yaml
- uses: OWNER/ACTION@<full 40-character sha> # v1.2.3
```

2. **Let a bot keep the pins current.** Dependabot, in `.github/dependabot.yml`:

```yaml
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`.

3. **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-lock` lockfile preview.

4. **Start every workflow with minimal permissions** and raise them per job:

```yaml
permissions:
  contents: read

jobs:
  release:
    permissions:
      contents: write
```

5. **Never put untrusted input inside `run:`.** Pass it through `env:`. Adapted from GitHub's docs:

```yaml
- name: Check PR title
  env:
    TITLE: ${{ github.event.pull_request.title }}
  run: |
    if [[ "$TITLE" =~ ^octocat ]]; then
      echo "PR title starts with 'octocat'"
    fi
```

6. **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](https://github.blog/changelog/2025-11-07-actions-pull_request_target-and-environment-branch-protections-changes/) 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 `@main` internally still moves.
- **Settings are invisible.** A repo can set the default `GITHUB_TOKEN` to read-only or enforce SHA pinning in its settings, and neither shows up in YAML. "No `permissions:` 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](https://www.kielltampubolon.id/writing/a-1-day-cooldown-would-have-blocked-70-of-2026s-hijacked-npm-releases/) and [the LiteLLM supply chain backdoor](https://www.kielltampubolon.id/writing/the-litellm-supply-chain-backdoor-what-3-hours-means-for-your-ai-agent-gateway/).

## FAQ

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