Dependabot, CodeQL, and the install delay

The GitHub-native guardrails most repos enable backwards or not at all — Dependabot, CodeQL, SECURITY.md, an install delay, and an auto-merge workflow.
Most repos enable Dependabot, then forget it exists for months. Most never enable CodeQL at all. The SECURITY.md file gets copied from a template, never read again, and points to an inbox nobody monitors. And the one setting that would have caught the xz-utils and eslint-config-prettier compromises before they reached production — pnpm's minimumReleaseAge — is still off by default on most installs.
The supply-chain risk has only sharpened with AI in the loop. AI-generated code can introduce a CVE before anyone notices — pulling in deprecated libraries, generating unsafe patterns, suggesting packages that look fine until they aren't. The guardrails below are what catches that.
This is a setup guide for the four guardrails that turn a GitHub repo from "we hope nothing breaks" into a repo that catches problems before they ship. Nothing exotic. All free on public and team plans. All configurable in under an hour total. The order matters — get them in the right order and they reinforce each other.
What Dependabot and CodeQL actually do
These two get conflated constantly. They're solving different problems.
Dependabot watches your dependencies. Three things, actually. It opens version-update PRs on a schedule you pick (weekly is the sweet spot — daily becomes noise). It opens security PRs the moment a CVE drops on something in your lockfile. And it surfaces vulnerability alerts in the GitHub Security tab whether you act on them or not. It does not look at your code — only at what you pull in.
CodeQL scans your code. It's GitHub's static analysis engine. It compiles your repo into a queryable database, then runs a battery of queries against it looking for taint flows, injection patterns, hardcoded secrets, and language-specific footguns. It runs on push, on pull request, and on a weekly schedule. The findings land in the same Security tab as Dependabot alerts.
Think of it this way — Dependabot is the doorman checking IDs, CodeQL is the security camera watching what happens inside. You want both. Neither replaces the other.
Setting up Dependabot step by step
Two pieces. A config file that tells Dependabot what to update, and a repo setting that turns on security alerts.
First, the config. Create .github/dependabot.yml:
version: 1
updates:
- package-ecosystem: "npm"
directory: "/"
schedule:
interval: "weekly"
day: "monday"
open-pull-requests-limit: 10
groups:
prod-deps:
dependency-type: "production"
dev-deps:
dependency-type: "development"
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
day: "monday"
open-pull-requests-limit: 5
A few notes on the shape. The grouping block is the difference between five PRs a week and forty. Without groups, Dependabot opens one PR per dependency bump. With it, all production deps land in one PR and all dev deps in another. Reviewable in fifteen minutes instead of an afternoon.
Swap npm for pnpm or yarn if that's your manager. Add a second github-actions block — workflow files pin third-party actions, those need updates too, and most teams forget this entirely.
Then commit, push, and walk over to Settings → Advanced Security. Turn on:
- Dependency graph — off by default on private repos, required for everything else
- Dependabot alerts — surfaces CVEs in the Security tab
- Dependabot security updates — opens patch PRs automatically when alerts fire
- Grouped security updates — bundles multiple alerts touching the same package manager into a single PR
- Dependabot version updates — confirms the
dependabot.ymlyou just committed
The version-update PRs start arriving the following Monday. Security PRs arrive whenever a CVE drops on something in your lockfile — could be in five minutes.
Setting up CodeQL step by step
Two paths. Default setup, which is a checkbox. Or advanced setup, which is a workflow file. Start with default. You can always graduate.
In the repo, go to Settings → Advanced Security, scroll to the Code scanning section, and click Set up → Default. GitHub auto-detects languages — JavaScript and TypeScript get picked up together, Python and Ruby separately, Java and Kotlin together. Pick the query suite (default is fine; security-extended catches more but adds noise) and the events that trigger scans. The standard config is push to main, pull requests against main, and a weekly cron.
That's it. Within ten minutes the first scan finishes. Findings show up under Security → Code scanning.
If you outgrow default — and you probably will, the moment you want to ignore generated files or scan a non-main branch — switch to advanced. GitHub generates a starter workflow at .github/workflows/codeql.yml. Strip it down to what you need:
name: CodeQL
on:
push:
branches: [main]
pull_request:
branches: [main]
schedule:
- cron: '0 6 * * 1'
jobs:
analyze:
runs-on: ubuntu-latest
permissions:
security-events: write
contents: read
strategy:
matrix:
language: [javascript-typescript]
steps:
- uses: actions/checkout@v4
- uses: github/codeql-action/init@v3
with:
languages: ${{ matrix.language }}
config-file: ./.github/codeql/codeql-config.yml
- uses: github/codeql-action/analyze@v3
The config-file reference is where things get interesting — covered below.
SECURITY.md — what it is, where it goes?
SECURITY.md is the file someone reads when they think they found a vulnerability in your code. It answers two questions — how do I report this, and how long until someone replies?
Place it at the repo root, or inside .github/, or inside docs/. GitHub looks in all three. Root is most visible — the Security tab links to it automatically, and forks pick it up. Use the root unless you have a reason not to.
You need this file because the alternative is a stranger filing a public GitHub issue titled "SQL injection in your API," which is how vulnerabilities turn into incidents instead of patches.
Here's a template that covers the basics:
# Security Policy
## Supported versions
| Version | Supported |
|---------|-----------|
| 2.x | Yes |
| 1.x | Critical fixes only |
| < 1.0 | No |
## Reporting a vulnerability
To report a vulnerability, use GitHub's private vulnerability reporting:
https://github.com/<owner>/<repo>/security/advisories/new
Or email security@<your-domain>.
Please include:
- A description of the issue and the impact
- Steps to reproduce or a proof of concept
- The affected version(s)
## What to expect
- Acknowledgement within 72 hours
- A status update within 7 days
- Coordinated disclosure timeline agreed before any public write-up
## What not to do
- Do not open a public issue or pull request
- Do not run automated scanners against production endpoints
- Do not exfiltrate user data to demonstrate impact
We credit reporters in release notes unless you ask otherwise.
That's the simple version. Go advanced when you have a real disclosure pipeline — a bug bounty, a CNA assignment, a regulated industry, or a dedicated security team. At that point you'll want sections covering scope (in/out of scope endpoints), safe harbor language, public PGP keys, SLAs by severity, and a hall of fame. Until then, this template does the job.
If you want to draft a custom one quickly, the prompt that works well:
Write a SECURITY.md for a [project type] called [name]. It lives at [URL]. Supported versions are [list]. The disclosure email is [email] and GitHub private reporting is enabled. Response SLA is [X hours] for acknowledgement and [Y days] for status updates. Scope includes [list]. Out of scope: [list]. Include safe-harbor language. Keep it under 200 lines. No emojis, no marketing copy, sentence case headings.
Specifics in equals specifics out. Vague prompt produces a generic template you'll need to rewrite anyway.
The install delay
Most npm supply-chain attacks have a half-life measured in hours. A maintainer's account gets compromised, a malicious version gets published, and within twelve to forty-eight hours either the maintainer or the registry rolls it back. The damage is done to anyone whose CI ran an install during the window. If your install had refused to fetch anything younger than a day, you wouldn't have been one of them.
Between September and November 2025, every major JS package manager added this. pnpm shipped it first as minimumReleaseAge in v10.16. Yarn followed with npmMinimalAgeGate in v4.10. Bun added minimumReleaseAge in v1.3. npm added min-release-age in CLI 11.10. Same idea, four different names, four different time units — and that's where most people trip up.
Here's the cheat sheet.
pnpm — pnpm-workspace.yaml, value in minutes:
minimumReleaseAge: 1440
minimumReleaseAgeExclude:
- '@yourorg/*'
pnpm 11 ships this enabled by default at 1440 minutes (24 hours). Any lockfile entry whose registry publish time is younger than the cutoff fails the install with ERR_PNPM_MINIMUM_RELEASE_AGE_VIOLATION. Local dev, CI, everywhere. On pnpm 10.x the same key works in .npmrc as minimum-release-age.
npm — .npmrc at the project root, value in days:
min-release-age=1
Requires npm CLI 11.10 or newer. Unlike pnpm, npm's first cut does not yet ship a bypass list — every package gets the same gate, no per-package exemptions. An open RFC tracks adding min-release-age-exclude; until it lands, the workaround is a temporary lower value in .npmrc when you need an urgent patch through.
Yarn — .yarnrc.yml, value in minutes (or a duration string like "7d" in newer versions):
npmMinimalAgeGate: 1440
npmPreapprovedPackages:
- "@yourorg/*"
Requires Yarn 4.10 or newer. npmPreapprovedPackages accepts both glob patterns and exact package names — handy for letting internal packages bypass the gate without disabling it project-wide.
Bun — bunfig.toml, value in seconds:
[install]
minimumReleaseAge = 86400
minimumReleaseAgeExcludes = ["@yourorg/core"]
Requires Bun 1.3 or newer. The seconds unit is the easiest one to typo — 86400 is 24 hours, 604800 is a week. Note also that bun upgrade doesn't yet honor this setting (open issue at the time of writing), so the gate applies to dependency installs but not to upgrading Bun itself.
The mechanism is less important than the policy — refuse to install anything published in the last N hours.
The N is the actual question. Three good values:
24 hours is the default for most teams. It catches the obvious attacks — the ones that get rolled back the same day — and rarely blocks a legitimate update. Use this when you ship to production frequently and can tolerate a one-day delay on patches.
3 days is the right call for production-critical or regulated environments. Most maintainer-account compromises get caught within 72 hours by either the maintainer noticing the unauthorized release, the registry flagging suspicious package activity, or community researchers spotting unusual dependency changes.
7 days is for security-conscious projects that don't ship daily — internal tools, slow-moving libraries, anything where a week's delay on a patch is fine. This catches almost every compromised release that gets caught at all. The cost is that your security patches for unrelated CVEs also wait a week, which is why most production teams don't go this far.
When a hotfix really can't wait, the emergency bypass for each manager:
- pnpm —
pnpm install --config.minimumReleaseAge=0for a one-off install - npm — temporarily comment out
min-release-agein.npmrc, or set it to0for one install - Yarn — add the package to
npmPreapprovedPackages(preferred) or lowernpmMinimalAgeGatefor one install - Bun — add the package to
minimumReleaseAgeExcludesinbunfig.toml
Make any of those a documented escape hatch, not a habit.
Auto-merging Dependabot PRs — and when to keep a human in the loop
Five Dependabot PRs a week become five hundred over two years. Nobody reviews five hundred PRs. Automate the boring ones.
The pattern that works — auto-merge patch and minor bumps after CI passes, require human review for majors. Drop this into .github/workflows/dependabot-auto-merge.yml:
name: Dependabot auto-merge
on: pull_request_target
permissions:
contents: write
pull-requests: write
jobs:
auto-merge:
if: github.actor == 'dependabot[bot]' && github.base_ref == 'develop'
runs-on: ubuntu-latest
steps:
- name: Fetch Dependabot metadata
id: meta
uses: dependabot/fetch-metadata@v2
- name: Approve and merge patch + minor
if: contains(fromJSON('["version-update:semver-patch","version-update:semver-minor"]'), steps.meta.outputs.update-type)
run: gh pr merge --auto --squash "$PR_URL"
env:
PR_URL: ${{ github.event.pull_request.html_url }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
A few things going on. The base ref is intentionally develop, not main — release flow stays human-reviewed. The auto-merge only kicks in once branch protection's required checks pass, so your CI pipeline becomes the gate. And the fetch-metadata action gives you update-type so you can branch on patch vs minor vs major.
When to keep a human in the loop:
Major version bumps, always. Semver says majors can break. Even framework patch releases occasionally ship behavioral changes. Read the changelog.
Anything touching auth, crypto, payments, or your build toolchain. Webpack, Vite, esbuild, your TypeScript version, your test runner, your auth library — these have outsized blast radius when they go wrong. Worth ten minutes of human eyes.
Production environments with no staging gate. If a merge to main ships straight to users, the cost of a bad auto-merge is higher than the time saved.
The first month after enabling auto-merge. Run it in shadow mode — let the workflow open auto-merge requests but require manual approval. Confirm CI is actually catching what you think it is. Then flip the switch.
Everything else — patch bumps to lodash, minor bumps to date libraries, weekly action updates — can merge itself overnight while CI does the work.
When default CodeQL isn't enough
Default CodeQL is good. It catches the obvious — SQL injection patterns, XSS sinks, prototype pollution, command injection, hardcoded credentials in obvious places. For a typical web app it covers about 80% of what you'd want to catch. Pair it with a hardened lint config — the ESLint flat config for Next.js 16 is a good starting point — and most everyday issues get caught before CodeQL even runs.
The other 20% is when default CodeQL produces too much noise, misses too much, or doesn't understand your codebase's conventions. Three signs it's time to extend:
- Half the findings are in generated code, fixtures, or vendored files
- A whole framework or in-house wrapper isn't being recognized as a sink (so taint flows die early)
- Your team has rolled its own sanitization helpers that CodeQL doesn't know about
Switch to advanced setup, then create .github/codeql/codeql-config.yml:
name: "Custom CodeQL config"
queries:
- uses: security-and-quality
- uses: security-extended
paths:
- src/
- lib/
paths-ignore:
- "**/__tests__/**"
- "**/*.test.ts"
- "**/*.spec.ts"
- "src/generated/**"
- "vendor/**"
query-filters:
- exclude:
id: js/unused-local-variable
- exclude:
tags contain:
- test
The four levers — query packs, paths, paths-ignore, and query-filters. Query packs add or replace the default suite. Paths and paths-ignore scope where CodeQL looks. Query-filters silence specific noisy queries without disabling whole categories.
For custom queries, drop .ql files into a folder and reference the folder under queries:. Writing CodeQL queries is a separate rabbit hole — start with the official cookbook, fork an existing query, and modify. Don't try to write one from scratch on day one.
The honest pacing — most teams need extended queries plus path exclusions, nothing more. Custom .ql files are a small percentage of teams. If CodeQL is finding real issues but you're drowning in noise, the answer is almost always paths-ignore plus a few query-filter excludes, not bespoke queries.
The shape of a hardened repo is small. A Dependabot config in
.github/. A CodeQL setup, default or advanced. A SECURITY.md at the root. AminimumReleaseAgein the workspace file. An auto-merge workflow for the boring bumps. Five files total, an hour of setup, and the difference between learning about a compromised dependency from your error tracker and learning about it from Dependabot's PR three days before it would have hit your install.
Set them up in that order. Don't skip the install delay.