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

- Source: https://oharu121.com/blog/dependabot-minimum-release-age-auto-merge-private-repo/
- Published: 2026-08-17T22:20:29+09:00
- Tags: GitHub Actions, Automation, Developer Tooling

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

```yaml title=".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.

```bash
gh pr list --author "app/dependabot" --state all --limit 20
```

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

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

*Figure — MaskingAsymmetry: 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:

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

*Figure — TwoFilters: 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:

```yaml title=".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:

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

*Figure — MergedBeforeCI: 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.

```bash
gh pr checks 39
```

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

*Figure — WatchDeadlock: 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.

```yaml title=".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:

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

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

```text
GraphQL: Resource not accessible by integration
```

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

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

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

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

- [Dependabot options reference, including the cooldown block and the note that it does not apply to security updates](https://docs.github.com/en/code-security/dependabot/working-with-dependabot/dependabot-options-reference)
- [Configuring Dependabot version updates, which states that the dependency graph must be enabled](https://docs.github.com/en/code-security/how-tos/secure-your-supply-chain/secure-your-dependencies/configure-version-updates)
- [Automatic token authentication, on how declaring any permission sets the rest to none](https://docs.github.com/en/actions/security-for-github-actions/security-guides/automatic-token-authentication)
- [Events that trigger workflows: workflow_run](https://docs.github.com/en/actions/reference/workflows-and-actions/events-that-trigger-workflows#workflow_run)
