← Writing

My journey into Clojure dependency automation

· 12 min read

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):

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

Toolproject.clj?deps.edn?Opens PRs?Verdict
Dependabotnonoyes (other ecosystems)No Clojure ecosystem. Updates your Actions pins and nothing else.
Renovateno native manageryes (deps-edn)yesNative to deps.edn only. A custom manager unlocks project.clj.
lein-ancientyesnono (report/upgrade)Leiningen only. Can’t span a mixed fleet.
antqyesyes (+ 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.clj only)
  • ~4 on deps.edn (some also carry a project.clj in which case the lein file is the source of truth)
  • 1 Leiningen monorepo (environ, two project.clj files 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_TOKEN is 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:

Keeping a Clojure repo current Second Maven registry, lockstep families, or major bumps to gate? No Yes antq + GitHub Actions --skip=github-action --exclude org.clojure/clojure Renovate custom regex manager + per-package registryUrls
antq until a second registry or a lockstep family shows up; then Renovate.
  • 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.edn only, but its custom manager is the most powerful option once you actually need it.
  • lein ancient is 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