My journey into Clojure dependency automation
I self-publish about 18 Clojure libraries under net.clojars.savya/*: mostly abandoned ones I revived because I have used them in the past and thought they needed some love and attention, and a few greenfield ones that I noticed no one had thought to do. 18 repos is 18 dependency trees rotting quietly in the background. I badly wanted GitHub’s own Dependabot to work for Clojure, but if you’re reading this, you know that I was disappointed.
There have been multiple requests for Clojure support in Dependabot since the dawn of mankind (or at least since 2018):
-
Was it requested thrice in 3 different issues, only to be closed later? The answer is yes:
-
Were PRs ever opened instead of waiting on issues to be resolved? Yes! And also closed later:
-
Are there still open issues tracking the same feature? 100%:
-
Did heroes before us try to do it themselves? Yup:
- https://github.com/CGA1123/dependabot-lein-runner
- https://github.com/pitch-io/clojure-dependabot
- Feeds your dep list to real Dependabot through the Dependency Submission API, though that’s usually meant for security alerts, not dependency bumps. I found out about it only after I implemented my changes to all my repos and started writing this. Salute to the maintainer, but on with the story.
This is my journey, and where I landed: antq plus a GitHub Actions workflow for the whole fleet, and Renovate for the one repo where antq will hand you a green PR that breaks the build.
TL;DR
| Tool | project.clj? | deps.edn? | Opens PRs? | Verdict |
|---|---|---|---|---|
| Dependabot | no | no | yes (other ecosystems) | No Clojure ecosystem. Updates your Actions pins and nothing else. |
| Renovate | no native manager | yes (deps-edn) | yes | Native to deps.edn only. A custom manager unlocks project.clj. |
| lein-ancient | yes | no | no (report/upgrade) | Leiningen only. Can’t span a mixed fleet. |
| antq | yes | yes (+ bb.edn, pom.xml, Actions) | no by itself (wrap in CI) | Fleet winner, with two mandatory flags. |
I was able to get 17 repos to work with antq in a scheduled Actions workflow. I put it in the 18th as well, but it was behaving strangely. This was in jackdaw, a fork of an unmaintained Apache Kafka distributed streaming platform. antq cannot control which Maven registry a package resolves against, and that gap ships a build-breaking PR that looks fine until it isn’t.
Why “just use X” is harder than it looks
The fleet of 18 are split three ways, and that split is the whole reason for my writing:
- 13 pure Leiningen (
project.cljonly) - ~4 on
deps.edn(some also carry aproject.cljin which case the lein file is the source of truth) - 1 Leiningen monorepo (
environ, twoproject.cljfiles in subdirs)
Any tool that understands only one build system forces a two-tool split across the fleet.
Status of Dependabot
Dependabot supports a fixed list of ecosystems (Bundler, Cargo, Maven via pom.xml, npm, pip, and so on). A Leiningen project.clj is not a pom.xml. A deps.edn is nothing it recognizes. You can’t wait it out, either. Folks started asking for support in 2018. It is 2026 (time moves faster as the brain ages, so this hit me really hard).
Renovate: right idea, missing manager
Renovate is the obvious “Dependabot but multi-language” pick, and it does have a Clojure story, just a smaller one than its reputation. It ships a deps-edn manager and a clojure datasource (Clojars + Maven Central).
What it does not ship is a Leiningen project.clj manager. That’s renovate#2158, open and unimplemented.
For my fleet that kills it as a default. 13 of 18 repos are pure Leiningen. Native Renovate would update deps on exactly one repo, do nothing on the lein ones, and on the dual repos it would bump deps.edn while the build reads project.clj. Updating the file nobody builds from is worse than doing nothing.
lein-ancient: great, if you never leave Leiningen
lein ancient reports outdated deps, lein ancient upgrade rewrites project.clj. Old, reliable, does the job. But:
- Leiningen only. The second
buddy-auth(deps.edn) shows up, you’re back to two tools. - It runs as a lein plugin, so CI has to stand up Leiningen and a profile to invoke it. More moving parts than a one-line command.
- It’s been archived since early 2025.
Now I have to admit that lein is the old way of doing things, and deps.edn is the modern way of initializing a Clojure project. And I myself am to blame for forking unmaintained projects in hopes of modernizing them, but still keeping them on lein. But that’s an adventure for another day, and probably a new post filled with horrors, I don’t know yet.
antq: the one that spans the fleet
antq reads project.clj, deps.edn, bb.edn, shadow-cljs.edn, pom.xml, and GitHub Actions, and it runs straight off the Clojure CLI without touching Leiningen. One invocation covers every repo:
clojure -Sdeps '{:deps {com.github.liquidz/antq {:mvn/version "2.11.1276"}}}' -M -m antq.core
antq doesn’t open PRs on its own, so the recipe is a scheduled Actions workflow: run antq --upgrade, hand the diff to peter-evans/create-pull-request.
Credit where due, this isn’t original. nnichols/clojure-dependency-update-action wraps exactly this (antq + create-PR), and so did its now-deprecated predecessor. I built mine from the primitives for control. One tell worth noting: that action is maintenance-only now, and its author points people at Renovate. File it away.
The workflow
name: deps
on:
schedule:
- cron: '21 5 * * 1' # Mondays 05:21 UTC
workflow_dispatch:
permissions:
contents: write
pull-requests: write
jobs:
antq:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with: { distribution: temurin, java-version: '21' }
- uses: DeLaGuardo/setup-clojure@13.4
with: { cli: latest }
- name: Upgrade outdated dependencies
run: >
clojure -Sdeps '{:deps {com.github.liquidz/antq {:mvn/version "2.11.1276"}}}'
-M -m antq.core --upgrade --force --skip=github-action --exclude org.clojure/clojure
- uses: peter-evans/create-pull-request@v7
with:
branch: deps/antq
delete-branch: true
commit-message: 'chore(deps): bump outdated dependencies'
title: 'chore(deps): bump outdated dependencies'
labels: dependencies
--skip=github-action
antq will “upgrade” the action pins inside the workflow file itself (actions/checkout@v4 to v5). The default GITHUB_TOKEN can’t push to .github/workflows/*; it would need a workflows scope you can’t grant it. The push dies with:
! [remote rejected] deps/antq -> deps/antq (refusing to allow a GitHub App to
create or update workflow `.github/workflows/deps.yml` without `workflows` permission)
--skip=github-action keeps antq on Clojure deps. Let Dependabot move the Actions pins if you want them moved.
--exclude org.clojure/clojure
antq bumps every occurrence of a dep. If your project.clj runs a version matrix:
:profiles {:clojure-1-10 {:dependencies [[org.clojure/clojure "1.10.3"]]}
:clojure-1-11 {:dependencies [[org.clojure/clojure "1.11.4"]]}
:clojure-1-12 {:dependencies [[org.clojure/clojure "1.12.0"]]}}
antq rewrites all three to 1.12.5 and quietly collapses your matrix to a single version. For a library the Clojure version is a test target, not a dep to chase. The matrix is deliberate, so exclude org.clojure/clojure and leave it pinned. I learnt the hard way by shipping a PR with this exact collapse before I caught it.
The one setting, and why it’s safe
For the default GITHUB_TOKEN to open PRs at all, you have to flip on Settings > Actions > General > “Allow GitHub Actions to create and approve pull requests” (can_approve_pull_request_reviews). There’s no create-only toggle, GitHub bundles create and approve into one switch, which made me nervous. It shouldn’t here because:
- Approve is not merge. The toggle grants no merge power. Merging needs
contents: write, a separate permission the workflow already holds just to push. - A bot approval is inert with no branch protection requiring reviews. Self-approval only does anything when there’s a required-review rule to satisfy. Solo repos have none, so an approved PR just sits until you merge it.
GITHUB_TOKENis ephemeral, scoped per job, gone when the job ends. A personal access token (PAT) that you’d usually reach for is the higher-risk option: a standing credential copied across N repos. The “safer” PAT is actually less contained.
For solo libraries with no required reviews, the toggle is the simpler, lower-risk choice. If you do gate merges on reviews, I’d recommend using a GitHub App instead.
That covers the fleet. But antq has no per-package registry control, and on one repo that’s a loaded gun.
Where antq breaks: the Kafka -ce collision
jackdaw puts the Confluent Maven repo in its resolver. Confluent republishes the Apache Kafka artifacts under the same org.apache.kafka/* coordinates with their own 8.x-ce versions. antq checks every configured repo, sees 8.3.0-ce sitting next to Apache’s 4.3.0, calls 8.3.0-ce newer, and opens this:
- [org.apache.kafka/kafka-clients "4.3.0"]
+ [org.apache.kafka/kafka-clients "8.3.0-ce"]
- [org.apache.kafka/kafka-streams "4.3.0"]
+ [org.apache.kafka/kafka-streams "8.3.0-ce"]
Confluent Platform 8.x is roughly Apache Kafka 4.x, so the bump is wrong and breaks the build, and it reads like an ordinary upgrade PR. It also stomped a deliberately pinned jackson line. I merged this without double-checking and broke main.
There’s no flag for it. antq’s --exclude is per-artifact only (group/artifact, no group globs), so you’d hand-maintain an exclude list of every Kafka, Confluent, and jackson coordinate, and it falls over the moment someone adds one. The real problem is that antq can’t say which registry a package resolves against.
Renovate can. This is the twist from earlier: the missing Leiningen manager isn’t a dead end. Renovate’s custom regex manager reads project.clj, and the clojure datasource takes per-package registryUrls. Pin org.apache.kafka/* to Maven Central only and Renovate never sees the Confluent builds at all. The collision goes from “something you remember to dodge” to structurally impossible.
jackdaw’s renovate.json
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": ["config:recommended", "helpers:pinGitHubActionDigests"],
"forkProcessing": "enabled",
"minimumReleaseAge": "14 days",
"internalChecksFilter": "strict",
"schedule": ["before 6am on monday"],
"dependencyDashboard": true,
"customManagers": [{
"customType": "regex",
"managerFilePatterns": ["/(^|/)project\\.clj$/"],
"matchStrings": ["\\[(?<depName>[\\w.-]+/[\\w.-]+)\\s+\"(?<currentValue>[^\"]+)\""],
"datasourceTemplate": "clojure"
}],
"packageRules": [
{ "matchDatasources": ["clojure"],
"registryUrls": ["https://repo.clojars.org", "https://repo.maven.apache.org/maven2"] },
{ "matchPackageNames": ["/^org\\.apache\\.kafka/"], // kills the -ce mirror
"registryUrls": ["https://repo.maven.apache.org/maven2"] },
{ "matchPackageNames": ["/^io\\.confluent/"],
"registryUrls": ["https://packages.confluent.io/maven/", "https://repo.maven.apache.org/maven2"] },
{ "matchPackageNames": ["/^org\\.apache\\.kafka/", "/^io\\.confluent/"], "groupName": "kafka" },
{ "matchPackageNames": ["/^org\\.apache\\.kafka/", "/^io\\.confluent/"],
"matchUpdateTypes": ["major"], "dependencyDashboardApproval": true },
{ "matchPackageNames": ["com.fasterxml.jackson.core/jackson-core",
"com.fasterxml.jackson.core/jackson-databind"], "groupName": "jackson" }
]
}
That gets jackdaw real PRs through the Renovate App (a proper bot identity, so the create+approve question never comes up), Kafka and Confluent grouped so they move together, major bumps held behind dashboard approval, jackson kept aligned, and the -ce mirror gone by construction.
One gotcha here: The clojure datasource needs registryUrls or every lookup returns no-result. My first cut kept only the Kafka/Confluent rules and dropped the catch-all, so every package failed to resolve. The matchDatasources: ["clojure"] catch-all has to be there, listed first, with per-package rules overriding it.
It opens the PR. You still own the breakage.
Renovate raising a PR doesn’t make the PR good. A recent one bumped the Confluent stack 8.2.1 to 8.3.0 and CI went red. Confluent had dropped curl from the cp-schema-registry image, so the compose healthcheck (curl -f on /subjects) hit command-not-found, the container never reported healthy, and the whole integration stack failed. The bot can’t know that. The fix was swapping the healthcheck to the python3 that ships in the image.
While here: turn on minimumReleaseAge (a cooldown, I run 14 days). Malicious or broken releases usually get caught and yanked within hours to days, so a cooldown means the bot never even opens the PR for a poisoned version. It only holds PRs back when paired with internalChecksFilter: "strict". The helpers:pinGitHubActionDigests preset above is the other half: it pins every Action to an immutable commit SHA instead of a movable tag, which is exactly the hole the supply chain hijacks go through.
The decision boundary
This is the part no README or existing post spells out, and it’s the actual point:
- antq is good for flat dependency trees, with one registry, no version-scheme games and no family of dependencies that need to move in lockstep. The 2 flags in the actions were the only compromise there, which I think was worth making.
- Renovate is best when you need per-registry control, grouping, or major-bump gating, heavyweight libs with paired releases (Kafka/Confluent) or maybe a second resolver in the mix.
I don’t know whether this is right or wrong, but I’ve come to the conclusion that until you have a second Maven repository in your resolver, or there are deps that have to move together, antq is all you need! Tiny library repos should be completely fine with just antq. But for larger projects, Renovate is probably the way to go.
What I’d tell past me
- Dependabot still won’t touch Clojure deps, and the trail of closed issues shows it’s not for lack of asking.
- Renovate’s native Clojure reach is
deps.ednonly, but its custom manager is the most powerful option once you actually need it. lein ancientis fine if you’re all-Leiningen and want zero cleverness. But then you’re probably still living in 2017.- antq is the right default for a mixed fleet. Learn the two flags and the one setting.
- The day a repo gets a second registry, reach for Renovate’s custom manager before antq ships you a wrong “upgrade.”
I’m Savyasachi (Savy), a backend engineer in the Bay Area. I’ve barely scratched the surface of open source, but it’s been a lot of fun. No idea what I’ll write about next. More at savyasachi.dev · GitHub · LinkedIn.
← Back to Writing