One of uika's build-tool integrations.
The tool is published to Maven Central as net.exoego.uika/clojure-uika and
declared as a deps.edn alias, the same shape tools.build uses. The alias
carries :ns-default, which keeps the calls below unqualified. A tool installed
with -Ttools install instead needs every call qualified, as in
exoego.uika/dump-classpath.
;; deps.edn
{:aliases
{:uika {:deps {net.exoego.uika/clojure-uika {:mvn/version "VERSION_PLACEHOLDER"}}
:ns-default exoego.uika
;; optional: settings every call shares
:exec-args {:fail-on "reachable" :exclude-file "uika-exclude.toml"}}}}$ clojure -T:uika dump-classpath # writes target/uika/classpath.json
$ clojure -T:uika dump-classpath :output '"/tmp/after.json"' :aliases '[:prod]'
$ clojure -T:uika upgrade-check :before '"/tmp/before.json"' :after '"/tmp/after.json"'Put your settings in the alias's :exec-args once. Every call accepts every
option and ignores the ones it does not use, so dump-classpath runs fine with
:fail-on set. A key on the command line overrides the same key in
:exec-args.
The dump records the resolved Maven coordinates from the project's own
deps.edn basis (:local/root and git deps are coordinate-less, like the other
tools' project dependencies), and upgrade-check downloads the CLI jar
from Maven Central into ~/.cache/uika (UIKA_CLI_URL to override the URL,
UIKA_CLI_PATH to skip the download, or :cli-path to do the same from the
call). The jar runs on the JVM that runs the tool, which therefore has to be
Java 17 or newer. On an older one, point :cli-path at a script that starts
the jar on a newer JDK. The CLI's version
is taken from the tool's own coordinate in the runtime basis, so the one
:mvn/version in the alias pins the tool and the CLI together;
:cli-version and UIKA_CLI_VERSION override it, in that order.
AOT-compile your namespaces before the dump. uika checks class files, so
Clojure code that is only source on the classpath is invisible to it. Run the
tools.build compile-clj and point :class-dir at its output. Interop calls
without type hints go through runtime reflection and leave no reference in the
class file either, so set *warn-on-reflection* and hint the calls you want
checked.
dump-classpath builds nothing. Run your own compile step first, such as a
tools.build task, or the dump holds none of your classes. The check then warns
that no application root matched, and :fail-on reachable fails like any.
The linkage-check job dumps a baseline from the PR's base branch and the
PR's own classpath, and fails on broken references between the two. The
alias lives in the project's committed deps.edn, so the jobs need no
install step. The dump-baseline job and the marked steps are the
optional caching half, explained in
Caching the baseline.
# .github/workflows/linkage-check.yml
name: linkage-check
on:
pull_request:
paths:
- '**/deps.edn'
- .github/workflows/linkage-check.yml
push:
branches: [develop]
workflow_dispatch: # backfill the current tip
# a PR update supersedes the running check, and baseline dumps get per-SHA
# groups so a develop push never cancels one
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.sha }}
cancel-in-progress: true
jobs:
# Optional: dumps the baseline once per push so the PR job can fetch it
# instead of resolving the base branch. To opt out, delete this job, the
# push and workflow_dispatch triggers, and the marked steps in
# linkage-check.
dump-baseline:
if: github.event_name != 'pull_request'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
# ... You may need to setup Java and the Clojure CLI here ....
- run: clojure -T:uika dump-classpath :output '"/tmp/classpath.json"'
- uses: actions/upload-artifact@v7
with:
name: uika-baseline-${{ github.sha }}
path: /tmp/classpath.json
retention-days: 30 # a PR's base.sha is always a recent tip
# the dump names JARs by absolute path, and these caches are the only
# place the old versions are guaranteed to exist (tools.deps resolves
# :mvn deps into the Maven local repository, git deps into ~/.gitlibs)
- uses: actions/cache/save@v6
with:
path: |
~/.m2/repository
~/.gitlibs
key: uika-baseline-deps-${{ github.sha }}
linkage-check:
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
permissions:
contents: read
actions: read # to read the baseline artifact
steps:
- uses: actions/checkout@v7
# ... You may need to setup Java and the Clojure CLI here ....
# Cached-baseline fast path. These steps skip while no artifact
# exists. Delete them together with the dump-baseline job if you do
# not cache.
- name: Restore baseline dependencies
id: baseline-deps
# a fork PR's build code could read private dependencies out of this
# cache, so only same-repo PRs take the fast path
if: github.event.pull_request.head.repo.full_name == github.repository
uses: actions/cache/restore@v6
with:
path: |
~/.m2/repository
~/.gitlibs
key: uika-baseline-deps-${{ github.event.pull_request.base.sha }}
- name: Fetch baseline artifact
id: baseline-artifact
# without the old JARs the baseline would under-report, so skip it
if: steps.baseline-deps.outputs.cache-hit == 'true'
continue-on-error: true
env:
GH_TOKEN: ${{ github.token }}
run: |
# To look up the artifact from another run (the develop push)
id=$(gh api \
"repos/${{ github.repository }}/actions/artifacts?name=uika-baseline-${{ github.event.pull_request.base.sha }}&per_page=5" \
--jq '[.artifacts[] | select(.expired == false)][0].id // empty')
test -n "$id"
gh api "repos/${{ github.repository }}/actions/artifacts/$id/zip" > /tmp/baseline.zip
unzip -o /tmp/baseline.zip -d /tmp/baseline
mv /tmp/baseline/classpath.json /tmp/before.json
- name: Dump PR classpath
# compile your Java or AOT classes first, since the dump builds nothing
run: clojure -T:uika dump-classpath :output '"/tmp/after.json"'
- name: Dump baseline classpath (fallback)
id: baseline-fallback
if: steps.baseline-artifact.outcome != 'success'
# a PR whose base cannot produce a baseline skips the check instead
# of failing it
continue-on-error: true
run: |
git fetch --depth=1 origin ${{ github.event.pull_request.base.sha }}
git checkout ${{ github.event.pull_request.base.sha }}
if clojure -T:uika dump-classpath :output '"/tmp/before.json"'; then
status=0
else
status=1
fi
git checkout -
exit $status
- name: Check broken references
if: steps.baseline-artifact.outcome == 'success' || steps.baseline-fallback.outcome == 'success'
run: clojure -T:uika upgrade-check :before '"/tmp/before.json"' :after '"/tmp/after.json"'The fallback resolves the base branch on the PR runner, which puts a
second checkout and a cold resolution on the PR's critical path every time.
The baseline only feeds the version diff, so the dump-baseline job
produces it once per push instead. The fallback stays for SHAs with no
usable baseline. Those are SHAs predating the job, expired artifacts, and
PRs not targeting develop. Deleting the marked blocks is also safe,
because an if: reads a missing step's outcome as empty, never as
success.
A fetched dump names JARs by absolute path. On GitHub Actions both jobs
run on the same runner image under the same $HOME, so those paths line
up as long as the files exist. The old-version JARs are the gap, because
the PR job resolves the new versions and nothing pulls the old ones in on
its own, and a compared-pair JAR uika cannot open exits 2 rather than
degrading to a warning. The cache save and restore close that gap.
Every option is a keyword argument, set in the alias's :exec-args or on the
call. It is used by upgrade-check unless the entry says otherwise. Every call
accepts every option below and ignores the ones it does not use. A key that no
call knows is an error rather than a silent no-op, so a misspelling cannot
quietly disable a flag. Watch for the Leiningen plugin's spellings: it says
:exclude-files and :class-load-logs where this tool says :exclude-file
and :class-load-log.
:fail-onisnever,reachableorany.reachableneeds the AOT output in the dump. Without it your namespaces root nothing, and a break used only from them passes.:exclude-filetakes one path or a vector of paths.- There is no module model to read a compile target from, so
:jdk-releasedefaults to the project's own JVM release. Set it to override that, or to 0 to disable the API layer.dump-classpathuses it too, to record the release the application runs on, so one value in:exec-argsserves both calls. There 0 keeps the derived value. :merged-classpath truechecks the union of every module's classpath once instead of each module against its own resolution. Adeps.ednproject is one module, so this only matters for a dump another tool wrote.:jfrand:class-load-logsupply runtime load evidence, below.:draft-exclude-fileis where rules drafted from it are written.:cli-versionand:cli-pathpick the CLI, andUIKA_CLI_VERSION,UIKA_CLI_PATHandUIKA_CLI_URLdo the same from the environment. The path takes the jar, or an executable that runs it. A path that is not a file, or an executable that lost its bit, fails naming the one you set,:cli-pathor the variable.UIKA_CLI_URLhas to name the jar.dump-classpathalone uses:output(defaulttarget/uika/classpath.json),:dirto point at another project'sdeps.edn(default: where the tool was invoked),:aliasesto include in the resolution, and:class-dirfor the AOT output of a tools.buildcompile-clj. The project's own:pathsare recorded too, but only those that exist as directories. A:class-diris recorded even when it is missing, and the check then warns that it skipped it.upgrade-checkignores:dir, so its paths stay relative to where you run it.upgrade-checkalone uses:evidence-work-dir, where recordings are converted, defaulting totarget/uika.:output,:beforeand:afterchange on every call, so pass them on the command line rather than in:exec-args.
Collect by running the current, not yet upgraded build's test suite (or a staging soak) with JFR recording class loads. There is no test task to inject the flag into, so add it to your own test JVM invocation, the way the Maven recipe does:
-XX:StartFlightRecording:jdk.ClassLoad#enabled=true,jdk.ClassLoad#stackTrace=true,filename=<dir>Create <dir> first: given a missing parent JFR aborts JVM startup, but given
an existing parent it silently records to a single clobbered file at that
path. Quote the filename value when the path carries a comma — the comma is
the option delimiter, and an unquoted one silently truncates filename= with
exit 0, leaving the directory empty.
Consume with :jfr, pointed at that directory or at a single recording:
$ clojure -T:uika upgrade-check :before '"/tmp/before.json"' :after '"/tmp/after.json"' \
:jfr '"/tmp/uika-jfr"'Recordings are converted with the JDK's own JFR reader before the CLI runs,
text logs in the same directory ride along unchanged, and a recording handed to
:class-load-log is converted too. Conversion needs the tool itself on Java
17+, the same floor the recording test JVMs already have for the flag syntax.
:draft-exclude-file drafts exclude rules from the
same evidence.
The base-branch-to-PR CI wiring is the same for every tool, with this page's two commands inside it.