Tags
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

Dependabot minimum release age and auto-merge — the trap in a private repo

On a private repo the dependency graph is off by default, so Dependabot opened no npm pull request, and the auto-merge workflow merged 45s before CI finished.

On this page

Introduction

I was looking at the pull request list on this blog’s repository and noticed something missing: no dependency bumps. Not a stale one, not a failing one. None. I had written a Dependabot policy weeks earlier, complete with a minimum release age and an auto-merge workflow, and I could not point at a single thing it had done.

Two mechanisms turned out to be inert, and each was hidden by something that looked healthy. The npm ecosystem had never run at all, because the dependency graph is off by default on a private repository and Dependabot needs it to parse manifests. The auto-merge workflow had never waited for anything, because gh pr merge --auto waits for required checks and this repository’s plan cannot mark any check as required.

This article walks through both, and through the policy question underneath them: what a minimum release age actually filters, what it cannot, and why that pair of answers is what lets you skip a semver gate without being reckless.

The policy I already had

The cooldown block was mine, written before any of this. It delays a proposed update until the release has been public for a set number of days, scaled by how large the version jump is.

.github/dependabot.yml
updates:
- package-ecosystem: "npm"
directory: "/"
schedule:
interval: "weekly"
cooldown:
default-days: 7
semver-major-days: 14
semver-minor-days: 7
semver-patch-days: 3
open-pull-requests-limit: 10
ignore:
# TypeScript 7 (the native compiler) does not expose the programmatic API
# that `astro check` relies on, so upgrading breaks `pnpm check`.
- dependency-name: "typescript"
update-types: ["version-update:semver-major"]

Two things about this are worth stating plainly, because both are easy to get wrong.

cooldown does not apply to security updates. That is deliberate on GitHub’s part: a patch for a live advisory should not sit in a queue for a week. It also means the block above was never protecting the security path, which mattered more than I realised at the time.

A github-actions block sits below it with default-days: 3 only. The semver-*-days keys are ignored for that ecosystem, and writing them there would have looked like policy while doing nothing.

No npm pull request had ever been opened

The configuration looked correct, so I started with the evidence instead.

Terminal window
gh pr list --author "app/dependabot" --state all --limit 20
39 build(deps): bump pnpm/action-setup from 6.0.9 to 6.0.10 MERGED 2026-08-10
2 build(deps): bump pnpm/action-setup from 6 to 6.0.9 MERGED 2026-08-03

Two pull requests, both merged, both from the github-actions ecosystem. Nothing from npm, ever. And pnpm outdated said there was work to do: astro was on 7.1.6 with 7.2.2 available.

Two green dependency pull requests are the worst possible evidence here, because they answer the question you think you are asking. Dependabot was running. It had opened pull requests, they had merged, the label was right. Everything visible said the system worked.

The dependency graph was off, and one ecosystem hid it

The agent I was working with went looking at the repository’s own state rather than at the config, and the answer was three API calls deep.

Terminal window
gh api repos/{owner}/{repo}/dependency-graph/sbom --jq '.sbom.packages | length'
# 404
gh api repos/{owner}/{repo}/vulnerability-alerts -i | head -1
# HTTP/2.0 404 Not Found
gh api graphql -f query='{ repository(owner:"…", name:"…") {
dependencyGraphManifests { totalCount } } }'
# {"dependencyGraphManifests":{"totalCount":0}}

Zero manifests. The dependency graph was disabled, which is the default for a private repository, and Dependabot needs it to read package.json and pnpm-lock.yaml. The github-actions ecosystem does not: it parses .github/workflows/*.yml inside the update job and never touches the graph, which is precisely why it kept working while everything else was dead.

Only one Dependabot ecosystem needs the dependency graphTwo lanes. The upper lane runs from the npm manifests through the dependency graph, which is off by default on a private repository, and produces no pull request. The lower lane runs from the workflow files straight to parsing inside the update job, bypassing the graph entirely, and keeps producing pull requests. The working lower lane is what concealed the broken upper one.npm ecosystempackage.json, pnpm-lock.yamlDependency graphoff by default on a private repoNo pull request, evergithub-actions ecosystem.github/workflows/*.ymlParsed inside the update jobPull requests kept arrivingThe working half is what hid the broken half
The half that worked is what concealed the half that did not. Two merged action bumps read as “Dependabot works” while every manifest-based ecosystem was inert.

I enabled alerts, which turns the dependency graph on as a side effect:

Terminal window
gh api -X PUT repos/{owner}/{repo}/vulnerability-alerts
gh api -X PUT repos/{owner}/{repo}/automated-security-fixes

The graph went from 0 manifests to 5, and the SBOM from a 404 to 600 packages. Security updates had never existed on this repository either, and they produced real work immediately: two transitive advisories, in nanoid and path-to-regexp, patched the same day.

That is the private-repo trap in one line. Nothing warns you. There is no banner, no failing check, no empty state that says “this would work if you turned on the graph”. The configuration file is valid, the schedule fires, and the update job finds nothing to update.

Why I did not add a semver gate

With npm traffic about to start, the obvious next question was whether to auto-merge patches only. I said no, and the reason is the thing this whole exercise clarified for me.

There are two classes of bad release, and they need different instruments.

Two failure classes, one filter eachTwo columns. The left column is releases that are bad for everyone: yanked hours after release, hotfixed the next morning, or a compromised publish. Its filter is a minimum release age, because time alone catches all three. The right column is releases that are bad only for you: they build fine everywhere else, break one API you happen to call, and are still broken on day thirty. Its filter is your own CI, because waiting reveals nothing about them.Bad for everyoneYanked hours after releaseHotfixed the next morningCompromised publishMinimum release agecooldown: in dependabot.ymlTime filters it. Waiting is the whole mechanism.Bad for youBuilds fine everywhere elseBreaks one API you callStays broken on day 30Your CIa gate that actually runsOnly your build sees it. Waiting reveals nothing.
A minimum release age and a build are not two strengths of the same filter. They see different failures, and neither one substitutes for the other.

A release that is bad for everyone gets caught by time. It is yanked within hours, or hotfixed the next morning, or it was a compromised publish that got pulled once someone noticed. Waiting is the entire mechanism, and cooldown is exactly that instrument. Nothing about my build makes me better at detecting this class than the rest of the ecosystem is.

A release that is bad only for me is invisible to time. It builds fine everywhere else, it stays up, and day 30 looks exactly like day 1. The only thing that can see it is my own build, because the breakage is in the intersection of that release and this codebase.

A semver gate is a proxy for the second class, used where CI coverage is weak or slow. It says “minors are riskier than patches”, which is true on average and says nothing about whether this minor breaks this repository. If CI genuinely runs and genuinely blocks, the proxy adds nothing the real measurement does not already give you.

That “if” is doing a lot of work, and it turned out to be false here.

The auto-merge workflow had never gated anything

The workflow read like a gate:

.github/workflows/dependabot-auto-merge.yml
on:
pull_request_target:
types: [opened, synchronize, reopened]
jobs:
auto-merge:
if: github.actor == 'dependabot[bot]'
steps:
# …
- name: Enable auto-merge for Dependabot PR
run: gh pr merge --auto --squash "$PR_URL"

--auto asks GitHub to merge once the required checks pass. The catch is in that adjective. This repository is on a free personal plan, so branch protection and rulesets both answer with an error:

Upgrade to GitHub Pro or make this repository public to enable this feature.

No required checks means nothing to wait for, so gh merged on sight. The timestamps on the last dependency bump make it concrete:

PR #39 merged 45 seconds before its build finishedA single time axis spanning about a minute. The pull request opens at zero seconds, is merged at thirteen seconds, and its Check and build job only finishes at fifty-eight seconds. The span between the merge and the finished build is shaded, marking 45 seconds during which the change was already on main with no build result.0s15s30s45s60sseconds after the pull request openedPR openedMergedCheck & build finished45s on main with no build result
PR #39 opened at 16:35:56 and merged at 16:36:09. Its Check & build job finished at 16:36:54, forty-five seconds after the branch was already on main.

There is a second-order effect worth knowing, because it changes how bad this actually was. A merge made with GITHUB_TOKEN triggers no workflow run, and the evidence is on the commits themselves: the bot-merged 8fce42b has zero check runs against it, while the human-merged 67c3d57 has a successful CI push run. So the production deploy job never fired for a dependency merge. The damage from a bad bump was a broken main that surfaces at somebody’s next push, not a broken site.

The obvious repair waits on itself

The first fix anyone reaches for is to make the workflow wait: keep pull_request_target, replace --auto with gh pr checks --watch, merge afterwards. The agent proposed exactly that, then found why it cannot work.

Terminal window
gh pr checks 39
Deploy to Vercel (production) skipping 0
Check & build pass 50s
Vercel pass 0
auto-merge pass 9s

auto-merge is in that list. A pull_request_target job is itself a check on the pull request, so a step inside it that waits for every check to finish is waiting for the job it is running in.

A watch step inside the job it is watchingA cycle of three steps. The auto-merge job, triggered on pull_request_target, appears as a check on the pull request. It then waits for every check on that pull request to finish, and that set includes itself, so the arrow returns to the job. Nothing can complete, and the run only ends at the six-hour job timeout.The auto-merge jobon: pull_request_targetAppears as a check on the PRWaits for every check to finishWaits for itself. Six-hour timeout, then the run fails.gh pr checks 39 lists auto-merge alongside Check & build
The cycle closes on the box it started from. Nothing completes, and the run ends at the six-hour job timeout rather than at a merge.

--required is not an escape either: there are no required checks on this plan, which is the original problem wearing a different hat.

What worked: workflow_run, after CI has concluded

I took the workflow_run route. It fires after the CI run has already finished, executes in the base-repo context with a write token, attaches no check to the pull request, and spends no runner minutes idling.

.github/workflows/dependabot-auto-merge.yml
on:
workflow_run:
workflows: ["CI"]
types: [completed]
concurrency:
group: dependabot-auto-merge-${{ github.event.workflow_run.head_branch }}
cancel-in-progress: false
jobs:
auto-merge:
if: >-
github.event.workflow_run.event == 'pull_request'
&& github.event.workflow_run.conclusion == 'success'

Four steps run under that condition: resolve the pull request, refuse to merge over a check that has already failed, approve, merge.

Two of them carry the parts that are easy to get subtly wrong.

The head SHA has to match the commit CI tested. Dependabot rebases its branches, and gh pr merge merges whatever the branch points at when the command runs, not what the workflow_run event described. Checking it in the resolve step is necessary and not sufficient, because two API round-trips happen in between:

Terminal window
gh pr merge --squash --match-head-commit "$HEAD_SHA" "$NUMBER"

Pending checks are ignored on purpose. The preview smoke test runs on the deployment provider’s timeline rather than CI’s and may never arrive for a given branch, so the step refuses a fail or a cancel bucket and never waits for a pending one. The failure mode is “merged without the smoke result”, which is recoverable, rather than “hangs until timeout”, which is not.

The permissions block that would have made all of it a no-op

The code review before merging found something neither the local dry run nor CI could have caught.

The workflow declared what it needed to write:

permissions:
contents: write
pull-requests: write

Declaring any permission sets every scope you did not list to none. So checks, statuses and actions were all zero, and gh pr checks is not a REST call: it issues one fixed GraphQL rollup query that always selects status contexts, check runs, and the workflow behind each check suite. --json filters client-side and cannot narrow what goes over the wire. The call would have failed on every run:

GraphQL: Resource not accessible by integration

It fails closed, so nothing wrong would have merged. Nothing right would have merged either.

permissions:
contents: write
pull-requests: write
checks: read
statuses: read
actions: read

The reason my local verification missed it is worth keeping. I had run gh pr checks 39 --json name,bucket from a shell against a full-scope token and watched it return sensible JSON. That proves the shape of the output and nothing at all about the workflow’s own token. A scoped credential is a different credential.

Two smaller findings landed with it. A run: step with no shell: key executes under bash -e {0} without pipefail, so gh … | head -1 was taking head’s exit status and would have reported a rate-limited API call as “no Dependabot pull request found” on a green run. And a cancelled check maps to gh’s cancel bucket rather than fail, so the refusal filter had to name both.

What green actually proves

CI on this repository runs pnpm check and pnpm build, and nothing else. Every defect in this story was found by reading, not by a badge going red.

The first real exercise of the new workflow is the astro bump that becomes eligible once its minor cooldown expires. The check is one command:

Terminal window
gh pr view <n> --json mergedAt,statusCheckRollup

mergedAt has to be later than the Check & build entry’s completedAt. That inversion is what exposed the original defect, so it is the right thing to assert against the fix.

Summary

The trap in a private repository is not that things are harder to configure. It is that the defaults are different and the failures are silent. The dependency graph is off, so manifest-based ecosystems never run. Required checks cannot exist, so anything built on --auto merges immediately while looking like it waits.

The policy that came out of it is simpler than the one I would have written before:

Question Answer
Bad for everyone (yanked, hotfixed, compromised) cooldown, scaled by semver distance
Bad only for this repository CI, on a gate that actually blocks
Patch versus minor versus major Not a filter. A proxy for weak CI
Security advisories Never delayed. cooldown does not apply

And two checks worth running against any repository that has a Dependabot config and no Dependabot traffic:

Terminal window
gh api repos/{owner}/{repo}/dependency-graph/sbom --jq '.sbom.packages | length'
gh pr view <n> --json mergedAt,statusCheckRollup

The first tells you whether Dependabot can see your dependencies. The second tells you whether your merge gate is a gate.

References

Share this article