Skip to content

Dependencies & publishing

cljgo has no separate dependency manifest, and never reads deps.edn’s :deps. Dependencies are declared as code in your project’s build.cljgo (ADR 0021), resolved by one resolver that feeds both execution legs — a dependency resolves identically under cljgo run and cljgo build (ADRs 0052/0053).

A project’s source roots default to src and test, whichever exist. So a test tree beside the code works with no declaration:

src/app/core.cljg (ns app.core)
test/app/core_test.cljg (ns app.core-test (:require [app.core :as c]))

Both cljgo run and cljgo build resolve app.core from test/, because src is a root.

Keeping your suite somewhere else? Say so once:

(defn build [b]
(paths b ["src" "spec"])
…)

Roots are appended after the requiring file’s own directory, never before, so a sibling namespace still wins — and registered providers outrank every root, so clojure.* can never be shadowed.

A project with no build.cljgo at all gets its roots from deps.edn’s :paths instead (ADR 0119, v0.8.7) — :paths only, and only in that case; a build.cljgo anywhere in the search wins absolutely. That is what lets a dual-host .cljc library work on cljgo with no second project file. See Dual-host .cljc projects.

From examples/build-deps in the repo — an app depending on a local library:

;; app/build.cljgo
(defn build [b]
(dep b "greetlib" {:path "../greetlib"})
(let [app (exe b {:name "app" :main "src/app/core.cljg"})]
(install b app)
(run b app)))
;; app/src/app/core.cljg — greet.core lives outside this tree;
;; the dependency load path resolves it, in both legs.
(ns app.core
(:require [greet.core :as greet]))
(defn -main [& args]
(println (greet/hello (if (seq args) (first args) "world"))))

A git dependency looks the same, pinned by ref: (dep b "greetlib" {:git "https://…" :ref "v1.2.0"}).

A Clojars/Maven coordinate is the third form (ADR 0095):

(dep b "org.clojure/tools.cli" {:mvn/version "1.1.230"})
(dep b "medley" {:mvn/version "1.4.0"}) ; group == artifact
(mvn-repo b "https://nexus.internal/repository/maven-public") ; optional

Repositories are shopped in order and the first one that answers serves the artifact. The default list is Maven Central, then Clojars — the same order tools.deps uses, so a coordinate published to both resolves to the same artifact on cljgo as on the JVM (it was the other way round before v0.8.8). (mvn-repo …) prepends to that list.

The dependency name is the coordinate (group/artifact; a single segment means group == artifact), so a Maven dep and a git dep can never collide by identity. The three coordinate kinds are mutually exclusive — declaring two is an error, never a precedence rule.

Resolution is pure Go: net/http + archive/zip + encoding/xml. No JVM, no mvn, no Aether. cljgo walks the .pom graph itself (breadth-first, first-wins, honouring <scope>, <optional> and <exclusions>), downloads the jar, and extracts only the Clojure source out of it — .class files and META-INF/ never land on your load path.

cljgo consumes pure-Clojure libraries. Everything else fails loud, per namespace. That is the whole claim, and it is deliberately narrower than “consume the Clojure ecosystem”.

Spike s50 sampled seven real Clojars libraries and resolved them for real: 2 fully consumable, 4 partially, 1 not at all. The pattern is that the reachable set is utility and algorithm libraries — argument parsing, data helpers, templating — and not the Java-wrapping mainstream (HTTP clients, Jackson-backed JSON). Seven is a small sample and is quoted as one.

The gate is the NAMESPACE, not the library

Section titled “The gate is the NAMESPACE, not the library”

One jar routinely mixes both: hiccup ships eight pure namespaces beside two that (:import …) Java. A whole-library gate would be wrong in both directions, so cljgo classifies per namespace:

  • classification happens at resolve, and is recorded in build.lock.edn under :mvn/namespaces {:pure … :java …} — so the report is available offline, before a byte is fetched;
  • the failure happens at require, as I4002, naming the namespace, the coordinate, the offending form, and how many other namespaces in the same library have no Java interop.

The library still resolves and locks. Only the use of a Java namespace fails.

What “no Java interop” measures — and what it does not

Section titled “What “no Java interop” measures — and what it does not”

The resolve line reads

cljgo deps: medley/medley 1.4.0 — 1 namespace(s) with no Java interop

and that sentence is the whole claim. Classification is a read-time check: cljgo’s reader read the file (with :cljgo reader conditionals resolved) and found no Java interop in what survived. It does not compile the namespace, so it cannot promise the namespace compiles — that would need core plus every one of the library’s own requires already loaded, and any gap in cljgo’s analyzer would then be printed as a fact about somebody else’s library.

The line used to say “N namespace(s) usable”, which claimed more than was measured — and a library could then fail to build right after being called usable. If a namespace that passed the check fails anyway, the require raises G5020, which names the measurement that passed, what failed, and that the gap is cljgo’s, not the library’s.

A .cljc is read with cljgo’s platform feature :cljgo (plus :default). cljgo does not claim :clj — that is the JVM’s feature, and claiming it would pull in the very branch a portable library fenced away from non-JVM hosts. This is what makes a library like medley honestly consumable: its java.util calls live in #?(:clj …) branches cljgo never reads, so they are not in the forms at all.

The flip side: a .cljc whose real top-level body is :clj-only gives cljgo nothing loadable, and fails loud with R1012 rather than installing a namespace with no vars.

The POM subset is small on purpose. When a POM needs something outside it, cljgo names the feature and stops (G5011) rather than guess — an uninterpolated or absent version is a wrong version: ${property} interpolation, <dependencyManagement> version supply, <parent> inheritance, version ranges, -SNAPSHOT, <profiles>, <classifier>, and non-jar packaging. A disagreeing version between two requirers is G5013, naming both, resolved with accept-version — no MVS, no silent newest-wins.

org.clojure/clojure, spec.alpha and core.specs.alpha are pruned from every graph (cljgo is the Clojure implementation; its clojure.core is embedded), and the prune is reported, not silent.

Third-party Go modules are one line, not a binding: (go-require app "github.com/gorilla/websocket" "v1.5.3") — cljgo synthesizes the generated go.mod and the emitter links the real module (see the interop guide).

The first cljgo build writes build.lock.edn next to build.cljgo. Commit it. Per dependency it records identity (:git/url, :git/ref, :git/sha), a merkle :tree/hash verified on every read (a git SHA alone is not a content hash), the dep’s source :paths, its transitive :requires, and whether it is :pure?.

Edit a version in build.cljgo and the next cljgo build re-pins it for you. The lock records a hash of the declared set, so cljgo can see that the manifest moved (ADR 0112).

The re-pin is minimal: only the declarations that actually changed, and whatever is reachable only from them, are resolved again. Every other pin is kept exactly as it was — so bumping one library cannot quietly drift every unrelated transitive to whatever is newest today. Cosmetic edits — a comment, a reformat, renaming your exe — declare nothing, so they re-resolve nothing.

For CI, that default is wrong, and there is a flag for it:

Terminal window
cljgo build --locked # or CLJGO_LOCKED=1

Frozen mode turns a stale lock into an error (G5021) and never rewrites it. The case it exists for is a merge — one branch’s build.cljgo landing beside another branch’s build.lock.edn — which would otherwise build green against a dependency graph nobody reviewed. This is not the same as --offline: you can be perfectly online and still want the lock to be the authority.

Once locked, cljgo run resolves the same dependencies the same way — one resolver, both legs, so the interpreter and the binary can never see different library code.

Some deliberate properties of the resolver (ADR 0052):

  • Global content-verified cache under $XDG_CACHE_HOME/cljgo (or ~/.cache/cljgo; override with $CLJGO_CACHE). Entries are immutable read-only trees, so removal is a verb: cljgo cache clean.
  • Version conflicts are a hard error, not silent minimal-version selection — the error names both requirers and both versions.
  • Transitive deps come from the lock as data. A dependency’s build.cljgo is never executed during resolution — no arbitrary code runs just to discover the graph.
  • clojure.* cannot be shadowed by a dependency root — a deliberate, recorded divergence from the JVM classpath.
  • A project-local vendor/<name>/ directory overrides the cache under the same lock hash, for air-gapped or audited builds.

Source files may be .clj, .cljc, .cljg, or .cljgo; the build file is probed as build.cljgo > build.cljg, most-specific-first (ADR 0055).

A cljgo project publishes from the same build.cljgo that builds it — no second manifest (ADR 0054). The project declares a library artifact ((lib b …)), and the target is chosen at the command line:

cljgo publish go # a go-gettable Go module
cljgo publish clojars # pure Clojure source for JVM-Clojure consumers

Flags (all optional): -o dir output directory (default ./publish/<target>), -name lib which library artifact, -module override the module path/coordinate, -runtime cljgo source tree for the generated go.mod (publish go).

Purity decides which targets a library qualifies for, checked at publish time over the whole transitive required surface:

the library uses… publish go publish clojars
pure Clojure only yes yes
require-go (Go interop) yes refused, with file:line

A pure-Clojure library is the only artifact that reaches both worlds. The moment a reachable namespace uses Go interop, it is Go-side only — and publish clojars names the offending file and line instead of shipping a broken download. Go developers then just go get the module; JVM-Clojure developers consume the source via a git coordinate in their deps.edn.

  • publish go wrappers currently expose any signatures; typed signatures from type hints are a tracked follow-up, as is wiring a library’s own third-party go-require into the published module.
  • publish clojars is git-coordinate distribution today — the actual Clojars coordinate/source-jar upload step is deferred.
  • Consuming from Clojars means: cljgo consumes pure-Clojure libraries; everything else fails loud, per namespace. It is not “consume the Clojure ecosystem”. See Consuming Clojars libraries below for what that reaches, measured rather than claimed.
  • c-shared / c-archive library targets (ADR 0013) are not built yet.

To ship an executable instead, see Compile & ship binaries.

Questions or feedback on this page? Comment below with your GitHub account — comments are public and live in the project's GitHub Discussions.