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.

A blue robot head with a white visor, two round eyes, a short antenna and square ears, above the lowercase dependabot wordmark in near-black on a white card
On this page

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.

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

Terminal window
IN
gh api repos/oharu121/<repo-name>/automated-security-fixes
OUT
{"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 is open: Dependabot errors out saying it does not support updating transitive dependencies for pnpm. Underneath it, pnpm#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.

The same update reaches the package on npm and stops on pnpmOne Dependabot security update splits into two columns. The npm column finds a parent version permitting the patched child, bumps both, and opens a pull request. The pnpm column runs pnpm update dep@version, which does nothing to a transitive dependency, so the alert stays open and no pull request is ever raised.Dependabot security updatenpmFind a parent version thatpermits the patched childBump parent and childPull request openedpatchedpnpmRun pnpm updatedep@versionNo-op on a transitive depAlert stays opennever arrives
On npm, Dependabot bumps the parent to reach the patched child. The pnpm path stops at the updater’s own command, which does nothing.

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.

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 asWhat it matchesLifetime
nanoid@<3.3.18: ^3.3.18only a resolution below 3.3.18goes inert once the tree passes 3.3.18
nanoid: ^3.3.18every nanoid in the tree, foreverpermanent

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.

First attempt: A scheduled routine on Claude cloud session

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:

[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?

StepNeeds human judgement?
Run pnpm audit --jsonNo
Skip packages declared in package.jsonNo, a lookup
Derive pkg@<X: ^X from the advisoryNo, the version is a field in the JSON
Write the override, install, check, buildNo
Write the pull request bodyNo, it was transcribed audit fields
Decide a runtime override needs route verificationYes
Diagnose an override that will not resolveYes

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

How classify() sorts an audited packagePackages carrying advisories enter a chain of four gates in order. A direct dependency is skipped as Dependabot's job. A package already named in the overrides file is skipped as already attempted. An advisory whose patched range cannot be parsed becomes an issue for a person. A package whose findings reach runtime code becomes an issue for a person. Anything still remaining becomes a plan to write the range-keyed override. Running the script with --verify turns off the overrides check and leaves the other three gates in place.pnpm audit --jsonpackages carrying advisoriesdirect dependency?skipDependabot's jobalready in overrides?skipalready attemptedpatched range unreadable?issueneeds a humanreaches runtime code?issueruntime-scoped--verifyskips this gateplanwrite pkg@<X: ^X
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.

A producer needs its own identity; a consumer does notA left-to-right chain: pull request, CI runs, verdict, merge. The advisory patcher sits upstream of the pull request and must create it, so a GITHUB_TOKEN push there means no checks ever run. Auto-merge sits downstream of the verdict and only consumes it, so its own GITHUB_TOKEN push is harmless because the work is already finished.Pull requestCI runsVerdictMergeAdvisory patchermust create the eventupstreamAuto-mergeconsumes a verdictdownstreamGITHUB_TOKEN here:no checks ever runGITHUB_TOKEN here:harmless, work is done
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.

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.

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:

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

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.

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

Share this article