I switched Renovate off six months ago, right before a holiday. That part was deliberate — I didn’t want two weeks of dependency PRs waiting in an inbox I had no intention of reading.

Turning it back on afterwards was not deliberate. It simply never happened.

Nothing reminded me, because that’s not a thing infrastructure does. There’s no alert for “a bot you disabled is still disabled.” No dashboard goes red. The cluster keeps running perfectly well on last winter’s dependencies, and the gap between what you’re running and what the ecosystem has moved on to just widens quietly in the background.

And even before the holiday, it hadn’t been doing much. The setup was scoped to Helm charts only — no npm, no Go modules, no Dockerfiles, no Flutter, no CI workflows. The chart running the bot itself was hundreds of releases behind. Debug logging nobody read. No dependency dashboard. So “six months off” is really “six months off, on top of a setup that covered maybe a fifth of what it should have.”

A temporary off-switch with no return date is just a permanent one you haven’t admitted to yet.

Today I decided to fix that in one sitting, and I did it in a way I hadn’t tried before: I gave Claude Code direct access to everything — kubectl against the cluster, the Argo CD API and CLI, my Forgejo instance via API token, and all 178 repositories — and then handed over the whole job. Not “help me write a Renovate config.” The actual job: get everything current, keep everything running.

This is what that looked like.


What I Actually Handed Over

Worth being precise about the blast radius, because “I gave an AI agent access to my infrastructure” is the kind of sentence that deserves detail.

  • kubectl against the cluster — read and write
  • Argo CD via both the CLI and its MCP server — including sync
  • Forgejo via a personal access token — repos, branches, PRs, merges into main, org secrets
  • Every repository I own, checked out locally, with the ability to build, test and push

There was no carve-out. No “propose it and I’ll click merge.” No approval queue on the cluster side either. If the agent decided a pull request was ready, it merged it. If it decided an Argo application needed syncing, it synced it. If a workflow needed a new org-level secret, it set one.

I want to be honest about why, because the tidy answer would be that I’d thought hard about the trust boundary. I hadn’t. The practical reality is that once something has kubectl write access to the cluster, holding back the merge button is security theatre — the smaller permission is a rounding error next to the larger one you already granted. Keeping merges manual would have made me feel more in control while changing essentially nothing about what could go wrong.

What that leaves you with is a system where the only real gate is verification. Not my judgement, not a review step — whether the thing actually builds and runs. That turns out to be the whole story of the day, and it’s why the two things that broke are exactly the two things nothing verified.

The mix of interfaces mattered too. The Forgejo web UI needs a browser login, so scraping it was never on the table — the REST API with a token was the only sane entry point. That got wrapped into a reusable skill: endpoints, domain knowledge about my org, guardrails, with the token living outside it in ~/.forgejo-token. Once that existed, “list the open PRs and merge the ready ones” stopped being a research task and became a function call.


Step One: Fix the Bot Before Fixing the Dependencies

The instinct is to switch the bot back on and go straight at the pile of outdated dependencies. Wrong order. The bot itself was the oldest dependency in the estate, and turning it on as-is would only have rediscovered Helm charts.

The Renovate Helm chart went from 46.0.3 to 46.251.0 — a gap of a few hundred releases, which tells you exactly how long nobody had looked. More importantly, the deployment switched to the full image instead of the slim one. That single change is what makes lockfile regeneration possible: without it Renovate can bump a package.json but can’t produce a matching package-lock.json, and for Go modules it can’t touch go.sum at all. Half of what I wanted was impossible on the slim image.

Then the config, which had rotted in the usual ways:

  • All package managers enabled. Previously: Helm. Now: Dockerfiles, docker-compose, npm, Go modules, pip/poetry, Flutter/pub, and Forgejo/GitHub Actions workflows.
  • Dependency dashboard on. One issue per repo showing exactly what’s pending. This is the single highest-value setting in Renovate and it was off. Invisible work is work that never happens.
  • A GitHub token as a host rule. Without it you get 60 API requests an hour and no changelogs. With it, 5000 an hour and actual release notes on every PR — which is the difference between reviewing a major bump and guessing at one.
  • Registry access fixed. The old token was missing the read:package scope, so every lookup against my own container images failed silently. Renovate was skipping my own images and not complaining about it.
  • Cleanup. Deprecated fileMatch and stale apiVersionOverrides removed, log level from debug back to info, config:recommended in the repo config.

So the six-month gap was really two problems stacked: one off-switch I never flipped back, and behind it four configuration mistakes and a missing token scope that would have limited the bot to a fraction of the estate anyway.


The Flood

Turn autodiscovery back on across 178 repositories with all managers enabled, and you get what you’d expect: 82 open pull requests within one run, growing past 100 across the day as later runs picked up the managers that had never been enabled before.

This is the moment where the API access paid for itself. Thirty-two of those were onboarding PRs — Renovate’s “shall I start managing this repo?” handshake. They contain no dependency changes at all, only a config file. Reviewing them individually is theatre. They got bulk-merged through the API in one pass. Renovate then auto-closed twenty of its own now-stale PRs and grouped what remained.

Final state of that run: 178 repos processed, zero errors, zero warnings.

That number is the one I actually care about. Not “all dependencies current” — that’s a moving target and always will be. “Zero errors across 178 repos” means the bot can now see everything. Whether I act on what it sees is a separate decision, and one I can now make deliberately.


The Automerge Policy, and Its Honest Downside

The policy I settled on:

  • Non-major updates (patch, minor, digest) → automerged straight onto the branch, no PR
  • Major updates → a PR, always, for human review

Branch automerge means Renovate commits the update directly to main when checks pass, without ever opening a PR. For an estate this size that’s the difference between a manageable inbox and an unusable one.

It also let two silent breakages through in a single day.

That’s the honest part. Automerge without meaningful CI checks isn’t automation, it’s just applying diffs with extra steps. Both incidents below trace back to something landing on main that nothing verified. The fix isn’t to turn automerge off — it’s to make sure PR builds actually run, so the automerge path has a real gate in front of it instead of a decorative one.


Reality Check: 55 Argo CD Applications

Bumping dependencies is easy. Bumping dependencies without breaking a running cluster is the actual work. With 55 applications in Argo CD, a chart bump wave is guaranteed to surface a handful of upstream breaking changes.

Four of them were interesting enough to be worth writing down, because none were bugs — all four were upstream working as designed.

A chart that refuses to install on purpose

One sync started failing with a templating error that read, roughly, “this is the last version I release.” Not a bug. The maintainer had shipped a final release containing a deliberate fail that blocks installation until you explicitly acknowledge the chart is end-of-life:

forgejo-runner:
  knownLastVersion: true

A tripwire, not a defect. It worked exactly as intended — it made me notice. The real fix is migrating to the official replacement chart before the old one goes offline entirely, which is now a scheduled task rather than a future outage.

CRDs too large for client-side apply

After a Postgres operator chart upgrade, two CRDs refused to sync. The cause is a limit most people meet exactly once: kubectl apply stores the full previous manifest in the kubectl.kubernetes.io/last-applied-configuration annotation, and annotations cap at 256 KiB. Modern CRDs with rich validation schemas blow straight past that.

syncOptions:
  - CreateNamespace=true
  - ServerSideApply=true

Server-Side Apply doesn’t use the annotation at all, and it strips the oversized one on first sync. One line. The diagnosis took considerably longer than the fix — most of it spent tracing which app-of-apps actually owned that Application definition.

An ingress chart aligning its own naming

A major ingress controller chart release renamed its logging keys to match upstream syntax: what used to live under logs.general is now log, and logs.access is now accessLog. The schema rejects the old top-level key outright, so both my private and public instances stopped rendering at the same moment.

Rename the keys, leave the config underneath untouched, then validate with the same helm template invocation Argo CD uses — not a simplified version of it. That last detail matters more than it sounds. Validating with different flags than your CD tool uses is how you get a green local check and a red sync.

A base image that got stricter

The container-in-container image used by my CI runners had been bumped the previous day by the automerge path. The new version rejects an operation the runner depends on, and every build in the estate broke at once. Diagnosis came from the runner logs on the cluster; the fix was pinning to the last working version plus a Renovate hold so it doesn’t come back next week.

This is the automerge downside made concrete. A digest bump on a base image is exactly the kind of change that looks trivial in a diff and takes down every pipeline you own.


The Major PRs: Where the Verification Standard Earned Its Keep

Non-majors are a throughput problem. Majors are a reasoning problem, and they’re where the day got interesting.

Roughly a dozen major PRs needed real work — mostly Node.js applications, some Flutter apps, a few Go services. These were handled by five agents running in parallel, one repository each, with one non-negotiable rule:

A major upgrade isn’t done when it compiles. It’s done when it produces a working artifact.

For the Node apps that meant a successful production build. For the Flutter apps, an actual debug APK on disk — and for one of them a signed release APK. Not “the analyzer is clean,” not “the types check.” A file you could install.

That standard caught things a diff review never would. A framework major that needed a database client’s entirely new adapter architecture rewritten around it. Android Gradle Plugin 9 removing a DSL block that a third-party plugin still used, which only surfaces at build time. A Go library’s v3 module path migration — plus a duplicate require line that Renovate itself had introduced. A compile SDK bump demanded transitively by a permissions plugin, invisible until the build reached that stage.

Two PRs were closed rather than fixed. A linter major was blocked by a plugin ecosystem that hasn’t caught up, and no amount of local cleverness changes that. “Upstream isn’t ready” is a valid, final answer, and recognising it quickly is worth more than a heroic workaround that has to be maintained forever.


The Reserved Name

The second incident of the day was the better puzzle.

A deployment step started returning 403 — but only in some repositories, and only after the runner update. The org-level secret it used was named FORGEJO_TOKEN. That name is reserved: since the runner update, Actions injects its own repo-scoped token under exactly that variable, silently shadowing the org secret. The workflow got a valid token with the wrong scope, so it authenticated fine and then failed on permissions.

Renaming to DEPLOY_TOKEN fixed it immediately.

I like this one because it’s the kind of failure that’s genuinely hard for a human to reason about — the value isn’t missing, it’s replaced, and the error message points at permissions rather than at naming. It’s also the kind of thing an agent finds fast, because it will read the runner source and changelog without getting bored.


Policy as Code, in the Right Place

Some majors are blocked for reasons that won’t change this month. A linter waiting on its plugin ecosystem. A TypeScript major that half the toolchain isn’t ready for. Those need holds — but where you put a hold turns out to be a design decision.

My first instinct was the central Renovate config. One place, all rules, tidy. That’s wrong, and I changed it.

Holds now live in each repository’s own renovate.json, and each one carries two things: the reason it exists, and the condition that lifts it.

{
  "packageRules": [
    {
      // held: plugin ecosystem not compatible with v10 yet
      // lift when: the plugin publishes v10 support
      "matchPackageNames": ["eslint"],
      "allowedVersions": "<10"
    }
  ]
}

A central config gives you a list of rules whose reasons have evaporated within three months. Nobody remembers why <10 is there, so nobody dares remove it, so it stays forever. Put it next to the code it constrains, with a written expiry condition, and it becomes reviewable. The repository carries its own dependency policy, and that policy explains itself to whoever reads it next — including me, in November, having forgotten all of this.

Which is the same mistake as the one that started this whole day, in miniature. A hold without a lift condition and a bot switched off without a return date are the same object: a temporary decision with no mechanism to revisit it. The fix in both cases isn’t discipline. It’s writing the expiry down somewhere that will be read.


What the Agent Was Actually Good At

Stepping back from the changelog, the interesting question isn’t “did it work.” It’s which parts it was good at, because that’s what generalises.

It doesn’t get bored. Fifty-three branches enumerated one by one. Over a hundred PRs triaged across the day. Twenty-two identified as zombies — already superseded by manual bumps on main, or leftovers from an older branch naming scheme. That’s not a hard task, it’s a tedious one, and tedium is exactly where humans start pattern-matching instead of checking.

Parallelism is real leverage. Five repositories, five agents, five independent build loops. Wall time is the slowest one, not the sum. Major upgrades are a natural fit here because they’re genuinely independent — no shared state between one app’s framework migration and another’s.

It reads the sources humans skip. Runner changelogs, chart templates, upstream migration guides, issue threads. The reserved-secret-name discovery came out of reading source code that I would have gotten to about four hypotheses later.

It closes the verification loop. This is the one that changed my mind. An agent that can build isn’t guessing about compatibility — it’s measuring it. Every claim in the day’s summary is backed by an artifact that exists, and that’s a categorically different kind of confidence than “the diff looks reasonable.”

And the places where full autonomy showed its edges:

Judgement calls get made whether or not you’re there. Closing two PRs as upstream-blocked rather than forcing them through is a decision about maintenance burden over the next six months, not a technical one. I agree with the call — but I agreed with it afterwards, reading the summary. That’s a different thing from making it.

Blast radius is a preference, not a fact. Pin, hold, or migrate is a question about how much of my time I want to spend later. The trade-off can be laid out precisely; the weighting is mine, and if I’m not in the loop it gets guessed at. Reasonably, in this case. Still guessed at.

The meta-problem is easy to miss from inside the work. The most valuable output of the day wasn’t any individual fix — it was noticing that the pattern behind two separate incidents is automerge with nothing verifying it. That only surfaces when you look at the day as a whole instead of at the task in front of you, and it’s the one thing I’d have been annoyed to have missed.


The Trust Model, Reconsidered

I’d give the same access again. Full read everywhere is non-negotiable — an agent that has to ask permission to look at things just produces worse hypotheses more slowly. And the write access is what made the day possible at all: a merge queue waiting on me would have stretched this across a fortnight of evenings, which in practice means it wouldn’t have finished.

But “I’d do it again” isn’t the same as “it was well-designed.” Handing over merge rights wasn’t a considered decision, it was the absence of one. And an unconsidered permission grant that happens to work out is luck, not architecture.

What I actually got away with is this: for most of the day, the verification was better than any review I’d have done. Every major upgrade produced a real build artifact. Every chart fix was validated with the same helm template invocation Argo CD uses. That’s measurement, not opinion, and it beats me squinting at a diff.

And where it went wrong, it went wrong in precisely the place where nothing was measuring: non-major automerge, landing on main with no CI in front of it. A base image digest bump broke every pipeline in the estate. The knock-on made a secret-shadowing bug much harder to see. Neither is an autonomy failure — no human reviewing that diff would have caught either one. They’re both verification failures.

So the change isn’t to claw back permissions. Adding myself as a merge gate would reintroduce latency without adding signal, and a human rubber-stamping diffs is just latency wearing a safety vest. The change is to make the gate real:

  • PR builds on every Renovate branch, now that the runners work again
  • No automerge path without a check in front of it — that’s what let both incidents through
  • Holds that carry their own lift condition, so nothing sits blocked for six months by accident

Full autonomy plus real verification is a defensible system. Full autonomy plus decorative verification is what I actually had this morning. The gap between those two is the entire lesson.


Where It Landed

  • 178 repositories processed, zero errors, zero warnings
  • 100+ pull requests triaged over the course of the day, 0 open at the end of it
  • 55 Argo CD applications synced and healthy
  • 12 major upgrades resolved — seven fixed and verified, two closed as upstream-blocked, three merged
  • 0 merges reviewed by a human before landing
  • 2 production incidents diagnosed and fixed
  • 4 upstream chart breaking changes handled
  • Renovate runs at 07:00 daily; non-majors land on their own, majors ask

The dependency debt wasn’t the real problem. The real problem was that I made a perfectly reasonable decision — pause the bot before a holiday — and then had no mechanism anywhere that would ever bring it up again. Six months of drift out of one unreverted, entirely sensible choice.

Handing over full access — including the merge button — is what made catching up feasible in a day instead of a quarter. That part genuinely worked, and I’d do it again. But the agent is the cleanup crew, not the fix. What stops this recurring is duller: a dependency dashboard per repo, PR builds that actually gate the automerge path, and holds that carry the condition that lifts them.

If you take one thing from this: whenever you switch something off “for now”, write down what turns it back on. Otherwise “for now” is a duration you’ll measure in repositories.