Second of seven posts on how I keep everything I run up to date without trusting fresh releases. Part one set the rule; this one writes it down once for every repository.

You add Renovate to one repository and tune it until it behaves. Then you copy its renovate.json into the next repository, and the next. Six months later every repository runs a slightly different policy, and you can no longer say which one does what.

That was me, with forty repositories. The rule from part one (a week for patches, two weeks for minors, a month and a review for majors) was written down in forty places, and no two of them agreed.

This post shows how I replaced them with one policy file that every repository references in a single line, on GitHub, GitLab and Codeberg alike. You can copy the setup and the decisions worth making only once: which updates skip the wait, which few still wait for a person, how each pull request says what will happen to it, and how to validate the file so it cannot quietly break every repository at once.

One file, referenced everywhere

The policy lives in a public repository, mbologna/.github, as renovate-config.json. In most of my repositories this is the entire Renovate configuration (here is one):

json
{
  "$schema": "https://docs.renovatebot.com/renovate-schema.json",
  "extends": ["github>mbologna/.github//renovate-config.json"]
}

Renovate calls a shared configuration like this a preset. Do not name it renovate.json, or Renovate also reads it as the configuration of the hub (the repository that hosts it), and settings meant only for the hub reach every repository that extends it. The hub gets its own one-line renovate.json instead.

A .github repository does not make other repositories inherit Renovate settings, as I once believed; each one opts in with that line.

The preset holds rules for twenty-six package managers, each added the first time some repository needed it and then available to all. When I change a waiting period, I edit one file and every repository follows at its next run.

Repositories add only their differences: a merge window for my infrastructure, and a post-update script for the dotfiles. None of them restates the age rule. The preset itself sets no merge hours, because in every other repository a merge deploys nothing: images are built only when a release ships, and a running app changes only when my infrastructure repository bumps its version, inside its own window, which part four sets. So everywhere else an update merges as soon as CI passes, on any day. Two kinds of repository do deploy on merge, my Cloud Run services and this blog, and they now do it at any hour, by choice.

The same file on GitLab and Codeberg

The policy file is the same on all three forges; only the way Renovate runs differs:

ForgeHow Renovate runs
GitHubthe hosted Renovate app, nothing to operate
GitLaba Kubernetes CronJob in my own cluster, with the renovate/renovate image
Codeberg (Forgejo)a second CronJob next to it, with RENOVATE_PLATFORM=forgejo

On GitHub and GitLab alike, the forge does the merging: Renovate sets GitHub’s auto-merge or GitLab’s merge-when-pipeline-succeeds flag, and the pull request merges itself the moment its required checks pass, instead of at Renovate’s next visit. That is only safe when the required checks are the real CI, so each GitHub ruleset now requires the repository’s own CI jobs, not just the title check; otherwise auto-merge could merge on a half-run CI. Two repositories still let Renovate merge. My infrastructure repository’s merges deploy, inside a Tuesday to Thursday 00:00-02:00 UTC window, and the forge’s auto-merge ignores Renovate’s schedule. This blog’s repository is private, and GitHub’s free plan offers no rulesets on private repositories, so auto-merge there would merge before CI.

Two more settings matter outside GitHub.

GITHUB_COM_TOKEN. extends is a network request, not a local include: Renovate on GitLab or Forgejo fetches the preset from GitHub at every run. Without a token it hits GitHub’s anonymous rate limit, with an error that does not look like a configuration problem.

RENOVATE_ALLOWED_COMMANDS. My dotfiles pin a checksum for every binary they download, so a version bump also needs a new checksum. Renovate can compute it with postUpgradeTasks, but only if the runner allows that exact command:

yaml
RENOVATE_ALLOWED_COMMANDS: '["^node scripts/update_chezmoiexternal_checksums\\.mjs$"]'

Anchor the regex at both ends. The allowlist sits in the CronJob’s environment, not in the repository whose command it allows, so a merge request there cannot widen it.

What skips the wait, and what waits for me

Part one’s table decided how long each kind of update waits. In one sentence, the policy is: Renovate asks for me only on real breaking changes; everything else that passed its age gate and CI merges itself, and security fixes skip the gate.

It used to ask for much more: every CI workflow change outside a short list of publishers, every new language version, every Hugo minor (although the pull request build deploys a preview of the site and checks it). Then I counted. Of the 41 pull requests that had waited for me on GitHub in 45 days, I merged 40 unchanged. A review that always ends in a merge only adds a delay, and the age gate already provides one.

Three rows of the table need more than a number: security fixes, the weekly lockfile refresh and CI workflow changes. For them the preset decides on trust rather than time, once for every repository.

Security fixes skip the wait, not the review

json
"vulnerabilityAlerts": {
  "minimumReleaseAge": "1 minute",
  "minimumReleaseAgeBehaviour": "timestamp-optional"
},
"osvVulnerabilityAlerts": true

The wait buys information, and a published advisory is that information arriving, so a fix for a vulnerability in something I run skips the release age. Renovate reads advisories from GitHub’s database and, with osvVulnerabilityAlerts, from OSV.

The block sets no automerge, on purpose: Renovate applies vulnerabilityAlerts on top of every other rule, so automerge: true there would also merge a major version or a CI workflow change. Without it, a security fix follows the same rules as any other update, minus the wait: a major that would wait for me still does.

The lockfile refresh merges itself only where the wait holds

json
"lockFileMaintenance": {
  "enabled": true,
  "minimumReleaseAge": "7 days",
  "minimumReleaseAgeBehaviour": "timestamp-optional",
  "automerge": false
}

Once a week Renovate refreshes every lockfile except Terraform’s, moving every transitive dependency at once. It checks no release ages for that itself and hands the age to the package manager: npm installs with --before, Poetry gets the same limit, and for uv I set exclude-newer in the project. Not every package manager can enforce an age, so automerge stays off here and one rule turns it back on for npm, Poetry and uv. The rest would take whatever was published that morning. Composer’s refresh is off altogether: my PHP CI runs composer audit on the runtime dependencies instead, so a vulnerable transitive package turns the build red. My Python CI does the same with pip-audit, on the runtime dependencies in the lock file (dev dependencies excluded). Cargo and Bundler refresh once a month and wait for me. A repository that knows more can opt in itself: my infrastructure’s CI lockfile is compiled by uv pip compile --exclude-newer=P7D, which Renovate re-runs on every refresh, so that repository turns automerge on for its own lockfile. The preset cannot tell such a lockfile from an ungated one.

Direct updates get a weekly batch too. Minor and patch updates from PyPI and npm arrive in one “non-major dependencies” pull request per repository, opened on Monday or Tuesday. The batch started on GitLab, where every merge request costs a run on my own CI runner; it now applies to every repository on every forge.

Workflow changes: pinned, gated, then merged

json
"packageRules": [
  {
    "matchManagers": ["github-actions"],
    "matchDepNames": ["!mbologna/.github"],
    "groupName": "github-actions",
    "schedule": ["* * * * 1,2"]
  },
  {
    "matchManagers": ["github-actions"],
    "matchUpdateTypes": ["digest"],
    "minimumReleaseAge": "7 days"
  }
]

A GitHub Actions step is code that runs with the repository’s permissions and can read its secrets. The Bitwarden compromise from part one went through exactly that: a changed workflow minted a publishing token and shipped the credential stealer. A malicious action does its damage the first time it runs in my CI, so it must not run before others have had time to notice it.

For a while that meant every Actions update waited for me, however old, except those from a short list of organisations whose actions half of GitHub runs. My reviews found nothing the age gate had not, so now every publisher gets the gates: every action is pinned to a commit SHA, the version that runs is exactly the one that waited, and patch, minor and digest updates merge themselves once CI passes. They arrive together as one pull request per repository, opened on Monday or Tuesday. (Monday morning alone did not work: the hosted Renovate app visits a quiet repository at irregular hours and missed that window for weeks.) A digest update, an action’s tag moved to a new commit, waits seven days like a patch.

Majors wait thirty days, and then the short list’s merge themselves too, in a single rule:

json
{
  "matchManagers": ["github-actions"],
  "matchPackageNames": ["actions/**", "github/**", "docker/**", "astral-sh/**",
                        "google-github-actions/**", "sigstore/**", "peaceiris/**"],
  "matchUpdateTypes": ["major"],
  "automerge": true
}

For these publishers a major is nearly always a new Node.js runtime for the action, and GitHub’s runner images get the same treatment. (The real rule is a matchJsonata expression that covers both, plus my own packages.) Every other major waits for me, in a pull request of its own: Renovate automerges a group only if every member may, so a major grouped with routine updates would hold all of them back.

pre-commit hooks get no such digest update at all. An action’s major tag (v6) moves with every release by design; a hook’s exact release tag (v1.2.3) should never move, and one that points at a new commit is what a hijacked tag looks like. So Renovate opens nothing: the repository stays on the commit it already pins, and the hook’s next real release arrives as a normal update. Those are on in every repository (Renovate leaves pre-commit support off by default): they wait out the same age gate, arrive in one pull request a week and merge themselves.

Except where the waiting already happened

My own shared workflows, in the hub, are the exception:

json
{
  "matchManagers": ["github-actions"],
  "matchDepNames": ["mbologna/.github"],
  "minimumReleaseAge": "1 minute",
  "minimumReleaseAgeBehaviour": "timestamp-optional",
  "schedule": ["* * * * 1,2"],
  "automerge": true
}

Nearly every line removes a wait the rest of the policy adds. Callers reference the hub by commit SHA, which has no publication date, so by default Renovate would consider it “not old enough yet” for ever; timestamp-optional lets it through. The one wait it keeps is the schedule: the pull request opens once a week, on Monday or Tuesday, and merges as soon as CI passes. Before I batched it, every hub commit meant a pull request in each of nineteen repositories, up to fifty-five a week. My GitLab CI components, the same kind of shared code, get the same rule, and so does everything else I publish under my own name, images and npm plugins too: no age gate at all, majors included.

This is not self-trust. The hub runs the same policy on itself, so any change there has already waited, been reviewed and passed CI. That is the most reusable idea in this post: an update has served its waiting period if it served it somewhere you can verify. It only holds if the version downstream is exactly the one that waited, so pin references to a commit SHA, not a tag. A tag can later be moved to different code without a new release for the age gate to see; a SHA cannot. k3s-oci’s CI shows both halves: it calls the hub’s reusable workflows by SHA, and Renovate moves that SHA forward.

Titles and labels that say what will happen

A policy that decides on its own has to show what it decided. Renovate’s default titles do not scan well, so the preset rewrites them:

text
chore(deps): update docker/renovate from 44.155.9 to 44.164.7 (minor)
  • chore(deps): (commitMessagePrefix) is the Conventional Commits form my own commits use. Security fixes get fix(security): and a security label, or chore(security): when the package is only a development dependency (npm devDependencies, Composer’s require-dev, a uv or Poetry dev group): my release tool releases at once on fix(security):, and an image that does not contain the package gains nothing from a release.
  • docker/renovate (commitMessageTopic) is a short ecosystem prefix (npm/, docker/, helm/, action/, pip/) plus the package name without its registry.
  • from 44.155.9 to 44.164.7 (minor) (commitMessageExtra) names both versions and the update type, which decides the waiting period. A digest update names the tag and both short digests. The new digest matters: Renovate treats two pull requests with the same title as the same update, so without it every digest bump after the first would lose its automerge.

Every pull request also carries dependencies and exactly one of two labels, automerge if the policy will merge it or manual-review if it waits for me, plus security or major where they apply, so one search lists my whole queue. A manual one adds a single line to its description, starting “Why this waits for you:”. For the rest, Renovate’s own Configuration section in every pull request already states the schedule and whether it will merge itself.

Checking the policy against its history

With the decision on every pull request, the history can be audited. Across my twelve public GitHub repositories with Renovate pull requests from 20 May to 17 October 2026: 744 merged, 611 of them marked for automerge. None of the 33 major updates and none of the 118 third-party Actions updates was marked. The one security fix, a php_codesniffer advisory, opened at 01:56 UTC and merged at 04:48, in a repository I had not touched for five weeks.

Two pull requests did not match. Back then a template put an automerge tag in the title, recomputing the conditions instead of reading the policy’s decision. When I switched automerge off for Hugo in the policy, the template did not follow, and two Hugo pull requests promised a merge that never came. Two copies of one rule agree until someone edits one of them.

So the template went. Every rule that sets automerge now sets the label in the same object (automerge next to true, manual-review next to false), with manual-review as the default, so an unlabelled rule claims less rather than more. Later rules replace both settings together, so they always agree. Only security fixes still need a small template, because Renovate’s security settings replace the labels; it adds the security label and a note on top of the rest.

The note in the description went the same way. Each pull request used to explain its schedule and release age in a paragraph of its own, but Renovate appends notes and extra labels across every matching rule and cannot take one back. When a later rule turned automerge back on, an earlier “merge this by hand” note stayed, and pull requests said one thing and did another. So that note is gone, and the rules that hold an update for me come last, after every rule that could turn automerge back on. Only they add a note: the one line on why.

The cost: one file can break everything

A single policy file is a single point of failure, and it fails silently. In October Renovate flagged the preset as invalid: an option removed long before (transitiveRemediation), a manager under its old name (regex instead of custom.regex), and a rangeStrategy in the same rule as matchUpdateTypes, which Renovate does not accept. All three dated from the first commit. Every repository was affected, and nothing told me; I found it by reading a run’s log by hand.

A rule can also be valid and wrong everywhere. With a lockfile, rangeStrategy: "bump" (which my preset set for npm, Composer and Cargo) takes its candidates from the manifest’s minimum, not the locked version, so after the weekly refresh moved a package up, Renovate proposed a “new” version below it: phpstan from 2.3.1 to 2.3.0. Those downgrades passed CI and merged themselves in three repositories. I deleted the rule and went back to the default, auto.

Now a workflow validates the preset on every change to it and once a week:

bash
out=$(npx --yes --package renovate renovate-config-validator \
  --no-global renovate-config.json renovate.json 2>&1 |
  grep -vE "^npm (notice|warn)") || true
printf '%s\n' "$out"
if grep -q '"errors":' <<<"$out"; then
  echo "::error::Renovate preset has configuration errors"
  exit 1
fi
if grep -q '"warnings":' <<<"$out"; then
  echo "::warning::Renovate preset is valid but raised warnings"
fi
  • It uses the latest Renovate, because the hosted app upgrades itself. On an older Node.js, npx quietly picks an older Renovate, and for a while mine did.
  • It fails on errors, not warnings. The validator exits non-zero for both, so the script reads the output instead.
  • It passes --no-global. Without it, the files are validated as self-hosted global config, a different schema that accepts things a repository config would reject.

The validator checks shape, not meaning. A rule for matchUpdateTypes: ["lockFileUpdate"] (Renovate spells it lockfileUpdate, case-sensitively) never matched, and neither did the uv entry in two rules: there is no such package manager (uv lockfiles belong to pep621). Both passed validation for months. Check every rule that names something against a debug run that shows it matching.

What to take from this

  • Put your Renovate policy in one preset referenced by a single extends line, not named renovate.json.
  • Outside GitHub, set GITHUB_COM_TOKEN if the preset lives there, and allowlist post-upgrade commands on the runner with an anchored regex.
  • Let security fixes skip the wait, not the review. An automerge inside vulnerabilityAlerts overrides every rule that holds an update back.
  • Ask for a person only where a person adds something: real breaking changes, and updates no gate can hold back. Count how often your reviews end in an unchanged merge.
  • Pin CI actions to a commit SHA and give them the same gates. They run with your repository’s permissions, and a SHA guarantees that what runs is the release that waited.
  • Skip the second wait only when the first is verifiable, as with your own reusable workflows pinned by SHA.
  • Set the label in the same rule that makes the decision, and you can audit the policy from its history.
  • Validate a shared preset in CI against the latest Renovate, with --no-global, failing only on errors, and confirm in a debug run that each rule matches something.