
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.
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.
gh pr list --author "app/dependabot" --state all --limit 2039 build(deps): bump pnpm/action-setup from 6.0.9 to 6.0.10 MERGED 2026-08-102 build(deps): bump pnpm/action-setup from 6 to 6.0.9 MERGED 2026-08-03Two 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.
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.
I enabled alerts, which turns the dependency graph on as a side effect:
gh api -X PUT repos/{owner}/{repo}/vulnerability-alertsgh api -X PUT repos/{owner}/{repo}/automated-security-fixesThe 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.
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:
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:
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.
gh pr checks 39Deploy to Vercel (production) skipping 0Check & build pass 50sVercel pass 0auto-merge pass 9sauto-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.
--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.
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:
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: writeDeclaring 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 integrationIt 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: readThe 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:
gh pr view <n> --json mergedAt,statusCheckRollupmergedAt 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:
gh api repos/{owner}/{repo}/dependency-graph/sbom --jq '.sbom.packages | length'gh pr view <n> --json mergedAt,statusCheckRollupThe first tells you whether Dependabot can see your dependencies. The second tells you whether your merge gate is a gate.
References
- Dependabot options reference, including the cooldown block and the note that it does not apply to security updates
- Configuring Dependabot version updates, which states that the dependency graph must be enabled
- Automatic token authentication, on how declaring any permission sets the rest to none
- Events that trigger workflows: workflow_run