# A GitHub Action that patches the pnpm advisories Dependabot cannot — and the authentication approach decided the solution

> Dependabot cannot update transitive dependencies on pnpm. Building the GitHub Action that patches them, and why its credential decided the design.

- Source: https://oharu121.com/blog/dependabot-transitive-pnpm-advisories-github-action-cloud-agent/
- Published: 2026-09-04T23:45:21+09:00
- Tags: pnpm, GitHub Actions, Security, Automation, Node.js, Claude Code

---
## Introduction

Dependabot reported **four high-severity advisories** on this blog's repository, and I wondered why it never raised a pull request with the security patch. I suspected the reason was that the vulnerable package was a transitive dependency, which Dependabot cannot bump.

The guess was right, and the reason is structural: **Dependabot does not support transitive dependency updates for pnpm at all.** The same issue would not have come up had I been using npm. Left alone, the alerts would have stayed open indefinitely.

I tried several approaches including a Claude routine, but the solution that shipped is a **GitHub App bot** patching them on a schedule. This article covers the triage and how the final decision was made.

*Image: The repository's Dependabot alerts page filtered to is:closed, showing 0 Open and 6 Closed. Four fast-uri advisories closed as fixed yesterday, plus nanoid and path-to-regexp closed as fixed three weeks earlier. Every row is marked High and detected in pnpm-lock.yaml*

*Four fast-uri advisories closed as fixed yesterday, plus nanoid and path-to-regexp closed as fixed three weeks earlier. Every one of them was detected in `pnpm-lock.yaml`.*

## Why Dependabot never opened a pull request

```bash
gh api repos/oharu121/<repo-name>/automated-security-fixes
# {"enabled":true,"paused":false}
```

This command confirms that the security feature was on. However, Dependabot had opened **zero** security pull requests. Actually this is not the first time that I faced such issues. The two previous transitive alerts, `nanoid` and `path-to-regexp`, had both been patched by hand.

The cause is pnpm, not transitiveness. [dependabot-core#13177](https://github.com/dependabot/dependabot-core/issues/13177) is open: Dependabot errors out saying it does not support updating transitive dependencies for pnpm. Underneath it, [pnpm#12744](https://github.com/pnpm/pnpm/issues/12744) records that `pnpm update <dep>@<version>` is a no-op on a transitive dependency, and that is the exact command Dependabot's updater runs.

*Figure — TransitiveReach: On npm, Dependabot bumps the parent to reach the patched child. The pnpm path stops at the updater's own command, which does nothing.*

> **CAREFUL**
>
> **A quiet alert list is not proof of safety.** [dependabot-core#10534](https://github.com/dependabot/dependabot-core/issues/10534) records pnpm v9 lockfiles silently ending alert coverage after an upgrade, with no notice that it had stopped.

## The fix the repository already had

Now we know that `pnpm update` wouldn't fix the issue because `pnpm update` is a no-op on a transitive dependency. The fix that this repository previously adopted was override.

```yaml title="pnpm-workspace.yaml"
overrides:
  nanoid@<3.3.18: ^3.3.18
  path-to-regexp@<6.3.0: ^6.3.0
```

The overrides live in `pnpm-workspace.yaml`. **Everything that matters here is on the left of the colon**, and the two forms behave in opposite ways:

| Written as | What it matches | Lifetime |
| --- | --- | --- |
| `nanoid@<3.3.18: ^3.3.18` | only a resolution below 3.3.18 | goes inert once the tree passes 3.3.18 |
| `nanoid: ^3.3.18` | every `nanoid` in the tree, forever | permanent |

The key is a *selector*, not a name. The **range-keyed form** applies only while something still resolves below the patched version and retires itself afterwards, whereas the **bare name pins the dependency permanently**, so the day a legitimate dependent needs nanoid 4.x it is forced back to 3.x and breaks.

> **HEADS UP**
>
> **This is pnpm 11 throughout.** The file an override belongs in has moved between majors, so check your own version before copying the block above. On 11, `pnpm.overrides` in `package.json` is ignored with a single `[WARN]` line and the install still reports `Already up to date`.

## First attempt: A scheduled routine on Claude cloud session

*Image: The cloud routine's edit dialog, named pnpm transitive advisory patcher. Its instructions tell it to find transitive dependency advisories Dependabot cannot fix and open a pull request patching them, with a background note citing dependabot-core#13177 and an instruction to ignore direct dependencies. It is scoped to the oharu121/oharu-tech-blog repository on Sonnet 5, and set to run daily at 10:07 JST*

*The first attempt as configured: a daily cloud session, with the whole audit procedure written out as prose for a model to follow.*

It worked. On its first run it opened a pull request with a correctly range-keyed override, a comment block matching the file's existing style, and a body that transcribed the audit accurately. It took about fifteen minutes.

One thing nagged, though. It printed this on every install:

```text
[WARN] Unsupported engine: wanted: {"node":">=24"} (current: {"node":"v22.22.2","pnpm":"11.3.0"})
```

The cloud environment ran Node 22 against a repository that declares `>=24`. Its `pnpm check` and `pnpm build` were therefore green against a runtime the site does not ship on, which meant its own verification could not be trusted without CI re-running the same commands.

Then I asked the question that changed the design: **do we really need AI involved, or can we make it completely mechanical?**

| Step | Needs human judgement? |
| --- | --- |
| Run `pnpm audit --json` | No |
| Skip packages declared in `package.json` | No, a lookup |
| Derive `pkg@<X: ^X` from the advisory | No, the version is a field in the JSON |
| Write the override, install, check, build | No |
| Write the pull request body | No, it was transcribed audit fields |
| Decide a runtime override needs route verification | **Yes** |
| Diagnose an override that will not resolve | **Yes** |

The two that need judgement are rare, and the design had already decided the agent was not allowed to act on either of them. So it was doing clerical work every day in order to be available for an exception it would have escalated anyway.

## How the workflow runs

Every "No" row above became a step in [`advisory-patch.yml`](https://github.com/oharu121/oharu-tech-blog/blob/main/.github/workflows/advisory-patch.yml), and the two "Yes" rows became an escalation. Five steps, in order:

1. **Install dependencies**, so the audit runs against the tree the repository actually resolves.
2. **Detect and apply.** `advisory-patch.ts` reads `pnpm audit --json`, skips anything declared in `package.json`, derives `pkg@<X: ^X` from the advisory, writes it into `pnpm-workspace.yaml`, and installs.
3. **Escalate anything needing judgement**, as an issue rather than a pull request. These are the two "Yes" rows: a runtime-scoped advisory, and an override that will not resolve.
4. **Verify the patch.** Audit again and confirm the override took.
5. **Open the pull request**, with the body transcribed from the audit fields.

Steps 2 and 4 both call the same function, `classify()`, and that is where every mechanical decision actually happens. It runs each audited package through four gates in order, sorting it into one of three buckets: patch it, escalate it, or leave it alone.

*Figure — ClassifyFunnel: Order matters. A direct dependency never reaches the override check, and an unreadable patched range never reaches the runtime-scope check.*

When a single package carries four advisories, as `fast-uri` did, the override has to clear all four at once, so `classify()` takes the **highest** patched floor in the group rather than the first one it reads.

Step 4 is the only caller that changes the chain, and the overrides check is why. By the time step 4 runs, step 2 has already written this package into `pnpm-workspace.yaml`, so that check would drop the exact package step 4 is asking about, and **step 4 would report a clean tree no matter what `pnpm install` resolved.** Running the script as `--verify` turns off the overrides check and leaves the other three in place.

## Why credential matters when running the workflow

A GitHub Action is undoubtedly better than Claude's cloud session. It runs `NODE_VERSION: "24"`, the same as CI, so its verification means what it says. It finishes in seconds. And the logic lives in a `.ts` file that `pnpm check` type-checks and anyone can run locally.

However, the challenge is that the workflow has to *open* the pull request that CI checks, and GitHub Actions will not let one workflow start another: **a push made with `GITHUB_TOKEN` triggers no workflow run.** The pull request still opens. What never happens is the run that would check it. GitHub does this because a workflow triggered by push that also makes a push would otherwise loop by triggering itself.

> **HEADS UP**
>
> **Pull requests are the near-miss case.** A pull request *created* with `GITHUB_TOKEN` does queue its checks, but holds them in an approval-required state behind an "Approve workflows to run" button. Same dead end for unattended automation, and worth knowing because it looks nothing like the push case.

*Figure — ProducerConsumer: Auto-merge consumes a verdict CI has already produced, so its own inert push costs nothing. The patcher sits upstream and must create the event CI reacts to.*

Dependabot is exempt from the recursion guard because it is a separate app identity rather than `GITHUB_TOKEN`, which is why its pull requests do get checks. This is the exact pattern we should follow.

The original try was a personal access token, but that introduced a few problems. A PAT borrows my identity, so `author.login` cannot tell the automation apart from me, and the auto-merge gate had to fall back to matching a branch name that anyone could type.

## The final landing solution: GitHub App

A GitHub App solves that by having an identity of its own. Its installation token is minted per run and revoked when the job ends, and the merge gate becomes an authorship check again.

*Image: The GitHub App's public page, showing the name Oharu Advisory Patcher, a description explaining that it opens pull requests for transitive advisories Dependabot cannot patch on pnpm, and the note that revoking the App stops the automation*

*The description is written for whoever finds this page in six months, including the line saying what breaks if they revoke it.*

Setting one up is four steps, none of which a session can do for you:

1. **Register the App** under Settings → Developer settings → GitHub Apps, granting it Contents, Pull requests and Issues write.
2. **Generate a private key**, which downloads a `.pem` file you get exactly one copy of.
3. **Install it on the repository.** Registering and installing are separate acts, and an App that is registered but not installed fails at the first API call.
4. **Add two repository secrets**: the App's Client ID, and the whole `.pem` including its header and footer lines.

*Image: The GitHub App install page for Oharu Advisory Patcher, showing the account oharu121 with a greyed-out Installed button and a settings gear*

*Step 3. The greyed-out Installed button is the difference between an App that exists and an App that can act on this repository.*

Those two secrets meet the workflow in one step, which mints the token everything else uses:

```yaml title=".github/workflows/advisory-patch.yml"
permissions:
  contents: read

jobs:
  patch:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/create-github-app-token@v3.2.0
        id: app-token
        with:
          client-id: ${{ secrets.ADVISORY_APP_CLIENT_ID }}
          private-key: ${{ secrets.ADVISORY_APP_PRIVATE_KEY }}
          permission-contents: write
          permission-pull-requests: write
          permission-issues: write

      - uses: actions/checkout@v7
        with:
          token: ${{ steps.app-token.outputs.token }}
```

Three things in that snippet are easy to get wrong. It is **`client-id`, not `app-id`**, because v3 marks the older input deprecated. The workflow-level `permissions` stays at `contents: read` because the write scopes belong on the App token instead; putting them there would grant them to a `GITHUB_TOKEN` that no step here reaches for. And **`checkout` has to be handed the token explicitly**, because its default is `GITHUB_TOKEN`. Take that default and the eventual push triggers nothing, which is the failure this whole section exists to avoid.

> **HEADS UP**
>
> **The login is `app/<slug>`, not `<slug>[bot]`.** `gh pr list --json author` normalises app-bot logins, so the raw value for Dependabot is `app/dependabot`. The `[bot]` spelling is what the web interface and `git log` show.

## What shipped

The workflow runs daily at 10:17 JST. On a quiet day it installs, audits, finds nothing and exits in about a minute.

On 2026-09-05 it found the same `fast-uri` advisory this article opens with, wrote the override, verified on Node 24, and opened a pull request. **Auto-merge admitted it and squashed it, with nobody touching the repository.**

*Image: Pull request 272 titled fix(deps): patch fast-uri advisory, marked Merged, merged by github-actions bot into main from the branch fix/advisory-dev-fast-uri, with the comment authored by oharu-advisory-patcher carrying a Bot label*

*The author is the App and the merger is the auto-merge workflow. Two separate identities, which is what the credential decision was for.*

## Summary

We have learned that Dependabot does not support transitive dependency updates for pnpm at all. That's why Dependabot never opened a security pull request on my repo.

A push made with `GITHUB_TOKEN` triggers no workflow run. That's why we need a different credential other than `GITHUB_TOKEN` to trigger the workflow.

For me the biggest takeaway is not the fix itself but the tool choice. We finally landed on a GitHub App, which is the best match for this scenario. This is my first time creating one, and it is something I would definitely want to dig into further.

## References

- [dependabot-core#13177, the open issue tracking pnpm transitive dependency support](https://github.com/dependabot/dependabot-core/issues/13177)
- [pnpm#12744, where `pnpm up dep@version` is confirmed not to work on transitive dependencies](https://github.com/pnpm/pnpm/issues/12744)
- [dependabot-core#10534, pnpm v9 lockfiles silently ending Dependabot alert coverage](https://github.com/dependabot/dependabot-core/issues/10534)
- [pnpm's `overrides` documentation, including the range-keyed selector form](https://pnpm.io/settings#overrides)
- [actions/create-github-app-token, where `app-id` is deprecated in favour of `client-id`](https://github.com/actions/create-github-app-token)
