Skip to content

Latest commit

 

History

History
276 lines (239 loc) · 12.8 KB

File metadata and controls

276 lines (239 loc) · 12.8 KB

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.

PR gate on GitHub Actions

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"'

Caching the baseline

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.

Options

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-on is never, reachable or any. reachable needs the AOT output in the dump. Without it your namespaces root nothing, and a break used only from them passes.
  • :exclude-file takes one path or a vector of paths.
  • There is no module model to read a compile target from, so :jdk-release defaults to the project's own JVM release. Set it to override that, or to 0 to disable the API layer. dump-classpath uses it too, to record the release the application runs on, so one value in :exec-args serves both calls. There 0 keeps the derived value.
  • :merged-classpath true checks the union of every module's classpath once instead of each module against its own resolution. A deps.edn project is one module, so this only matters for a dump another tool wrote.
  • :jfr and :class-load-log supply runtime load evidence, below. :draft-exclude-file is where rules drafted from it are written.
  • :cli-version and :cli-path pick the CLI, and UIKA_CLI_VERSION, UIKA_CLI_PATH and UIKA_CLI_URL do 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-path or the variable. UIKA_CLI_URL has to name the jar.
  • dump-classpath alone uses :output (default target/uika/classpath.json), :dir to point at another project's deps.edn (default: where the tool was invoked), :aliases to include in the resolution, and :class-dir for the AOT output of a tools.build compile-clj. The project's own :paths are recorded too, but only those that exist as directories. A :class-dir is recorded even when it is missing, and the check then warns that it skipped it. upgrade-check ignores :dir, so its paths stay relative to where you run it.
  • upgrade-check alone uses :evidence-work-dir, where recordings are converted, defaulting to target/uika.
  • :output, :before and :after change on every call, so pass them on the command line rather than in :exec-args.

Runtime load evidence (JFR)

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.