Dependency maintenance on autopilot with Renovate
Part 12's scanners tell you which dependencies are vulnerable; someone still has to upgrade them. Self-hosted Renovate as a scheduled CI job, the two tokens that keep it alive, and the pre-flight check that turned a recurring debugging session into a one-line log read.
- gitlab
- ci-cd
- devops
Part 12 ended with scanners that tell you, on every merge request, which dependencies carry known vulnerabilities. That’s the detection half of the problem. The upgrades themselves are still manual, and manual doesn’t scale: across ten repos, dependency bumps are the chore everyone agrees matters and nobody picks up. Lockfiles age quietly until a scanner finding forces a two-major-version jump under deadline pressure.
The last part of this series hands the chore to a bot: self-hosted Renovate running as a scheduled pipeline in its own project, opening merge requests across the whole group while everyone sleeps.
A project whose only job is opening MRs
Renovate ships as a container image, which settles the hosting question: it can run as a CI job like everything else in this series. The image processes the repositories it’s pointed at and exits; scheduling is on you, which is exactly what pipeline schedules are for. Give it a dedicated project (group/renovate-runner) whose pipeline is a single job:
renovate:
image: renovate/renovate:41 # pin a major (see part 12's :latest lesson)
rules:
- if: $CI_PIPELINE_SOURCE == "schedule"
variables:
RENOVATE_PLATFORM: gitlab
RENOVATE_ENDPOINT: $CI_API_V4_URL
RENOVATE_AUTODISCOVER: 'true'
RENOVATE_AUTODISCOVER_FILTER: group/*
script:
- renovateA pipeline schedule (the same mechanism part 11 used for nightly E2E) fires it once a night. autodiscover makes Renovate walk every repository its token can reach, narrowed by the filter; if that feels too broad, list repositories explicitly in the config file instead. How each repo behaves once Renovate visits it (what gets grouped into one MR, when MRs open, what merges automatically) lives in that repo’s renovate.json, and we keep ours boring: patch and minor updates grouped, majors separate, a cap on open MRs so nobody wakes up to thirty. That file belongs to Renovate’s docs more than to this series, so I’ll keep it at that.
The Renovate team maintains a ready-made renovate-runner project with CI templates for exactly this setup; ours started as a copy of it.
Two tokens, both masked
Renovate needs credentials for two different hosts, and it’s worth being precise about what each one does.
The first is RENOVATE_TOKEN: a GitLab access token with the api scope and at least the Developer role on every repo it manages, which is the minimum for opening merge requests. Create it under a bot account. Part 10 made that argument for release commits and it transfers wholesale (automation tied to a person’s token dies with their offboarding), plus one new reason here: scheduled pipelines run with the permissions of the schedule’s owner, so the schedule in the runner project should belong to the bot too.
The second is RENOVATE_GITHUB_COM_TOKEN, the one people skip because nothing visibly breaks without it. Most open-source packages host their releases on github.com, and Renovate fetches changelogs from there to embed in MR descriptions; without a token those lookups get rate-limited away and your MRs arrive as bare version bumps. With it, the reviewer reads the release notes inside the MR instead of opening a browser tab per dependency. Public read access is all it needs.
Both are stored as masked CI/CD variables in the runner project, so they can’t leak into job logs, and both carry expiry dates. Hold that detail; it’s about to matter.
Failing before Renovate does
A Renovate run over a group takes a while. When a token is bad, the failure surfaces as a stack trace many minutes in, buried in a wall of log output, and it doesn’t say “your token expired” — it says whatever the HTTP client felt like saying. The first time we hit it, figuring out which of the two tokens had died and where to fix it cost most of a morning.
So the job now validates both tokens before Renovate starts. A small script curls one cheap authenticated endpoint per host and fails with a message written for whoever reads the log next:
#!/usr/bin/env bash
set -euo pipefail
code=$(curl -s -o /dev/null -w '%{http_code}' \
--header "PRIVATE-TOKEN: ${RENOVATE_TOKEN}" \
"${CI_API_V4_URL}/user")
if [ "$code" != "200" ]; then
echo "RENOVATE_TOKEN is invalid or expired (GitLab replied ${code})."
echo "Rotate it: group/renovate-runner > Settings > CI/CD > Variables."
exit 1
fi
code=$(curl -s -o /dev/null -w '%{http_code}' \
--header "Authorization: Bearer ${RENOVATE_GITHUB_COM_TOKEN}" \
"https://api.github.com/rate_limit")
if [ "$code" != "200" ]; then
echo "RENOVATE_GITHUB_COM_TOKEN is invalid (github.com replied ${code})."
echo "Changelogs will stop rendering in MRs until it is rotated."
exit 1
fiA failed run now reads as one line: which token, what happened, where to rotate it. The script lives in the runner repo and runs from the job’s before_script; Renovate only launches if both checks pass.
The weeks the bot went quiet
The pre-flight check exists because of what happened without it. Our GitLab token reached its expiry date, Renovate started failing every night, and for a few weeks nothing visible happened at all. Dependency MRs stopped showing up — but the absence of MRs looks exactly like a quiet stretch in the ecosystem. The scheduled pipeline failed punctually every night, and its notifications went to the schedule owner: the bot account, whose inbox nobody read. We only noticed when someone asked why a package we’d patched weeks earlier was still old in one repo.
Two changes fixed it, and both were needed. The pre-flight check made the failure diagnosable in one line instead of a debugging session. And scheduled-pipeline failures now land somewhere humans actually look: forwarding the bot account’s notification email to the team alias works, as does a tiny final job with when: on_failure that posts to the team chat. Both changes went in the same week; the token has expired twice since, and each time the fix took the five minutes it always should have.
One-click upgrades for everything else
Renovate deliberately doesn’t get to do everything. A framework major that needs the migration guide open, a design-system bump that needs visual review: those upgrades want a human deciding when. Even so, they shouldn’t cost a manual checkout, bump, push, and MR form.
So the shared CI library from part 8 carries a manual job that does the mechanical part:
.bump-dependency:
rules:
- when: manual
allow_failure: true
script:
- git checkout -b "bump-${DEPENDENCY}"
- pnpm update --latest "${DEPENDENCY}"
- git commit -am "chore: bump ${DEPENDENCY}"
- git push "https://bot:${BOT_TOKEN}@${CI_SERVER_HOST}/${CI_PROJECT_PATH}.git"
"HEAD:bump-${DEPENDENCY}"
- >
curl --fail --request POST
--header "PRIVATE-TOKEN: ${BOT_TOKEN}"
--data-urlencode "source_branch=bump-${DEPENDENCY}"
--data-urlencode "target_branch=${CI_DEFAULT_BRANCH}"
--data-urlencode "title=chore: bump ${DEPENDENCY}"
"${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/merge_requests"The allow_failure: true matters: a manual job introduced through rules blocks the pipeline by default, and this one should never hold anything up. The real version also sets the bot’s git identity (part 10’s user.name/user.email dance) and handles the branch already existing; this is the readable core. To use it, a consumer repo extends the hidden job, someone opens the pipeline, presses play, and types DEPENDENCY=react into the variables form GitLab shows for manual jobs. The push triggers a pipeline on the new branch, so the MR arrives with CI already running, authored by the same bot that signs the releases and the Renovate MRs.
Where this leaves you
This series started thirteen parts ago with an empty .gitlab-ci.yml and a single job running lint. What runs now builds only what changed, releases itself exactly once per merge, reports E2E results every morning, scans the diff instead of crying wolf, and opens its own upgrade MRs: each piece added because a concrete problem demanded it, in an order where every rung assumes only the ones below. If you landed on this post first, part 1 is where the ladder starts.
Renovate opened two MRs while I was drafting this. I merged one. Good bot.