Lesson 29 — Distributing packages: publish, install, update, fork
Goal: by the end of this lesson you can publish a namespace as an immutable package version, then browse the registry and install / update / roll back / uninstall / fork packages — all without leaving the graph. Placement follows intent: install is a build act, so it lives on the Build surface's packages context-bar chip; publish is an authoring act on what you built, so it lives as a ⬆ action on the namespace in the Explorer.
Concepts introduced: registry, :package-version,
publish, pin (:package-install), reference-install vs
fork (copy-on-write), version constraint / latest /
rollback, withdraw, the packages chip (Build surface) and the
per-namespace ⬆ publish action, publish visibility
(org-private vs public) and the publish-packages capability,
the Organization surface's governance view, and — beyond the
registry — an external package pulled by git coord
(executor-packages.edn).
Authoring vs distributing
Lesson 28 was about authoring a package — the fns.edn /
impls.clj / package.edn files on disk that load at startup.
This lesson is about distributing one: taking a namespace
that already lives in the graph and turning it into a versioned,
installable artifact other branches (and, later, other people)
can pull in.
Two different things share the word "package":
| On disk (Lesson 28) | In the registry (this lesson) |
|---|---|
| A directory loaded at boot | A :package-version row: an immutable snapshot of a namespace's fn-defs |
| One copy, shared by the whole install | Named + semver-versioned; many versions coexist |
| Changes when you edit the files + rebuild | Frozen once published — re-publishing the same (name, version) is rejected |
Publish — freeze a namespace into the registry
Publishing exports the fn-def subtree rooted at a namespace and
stores it as a :package-version. Using the mycorp.hello
package from Lesson 28:
curl -X POST http://localhost:9002/api/packages/publish \
-H "Authorization: Bearer $AUTH_TOKEN" \
-H "Content-Type: application/json" \
-d '{"name":"hello","version":"1.0.0","ns-root":"mycorp.hello"}'
# → {"ok":true,"name":"hello","version":"1.0.0","fn-count":1,...}
ns-root is the namespace to snapshot; name + version are
how the registry indexes it. Bump a fn in mycorp.hello,
bb rebuild, and publish again as 1.1.0 — now the registry
holds both versions. GET /api/packages lists the index.
The version number is checked, not just recorded. Before writing the
row, publish diffs the bundle against the newest version already
below the one you named: a public fn-def that disappeared, an arg
that is gone or newly required or narrower, a binding you dropped
(so callers must now supply that arg), a wider return type. If the
bundle breaks 1.1.0 and you called it 1.2.0, the answer is
{"ok":false,"reason":"breaking-change","previous":"1.1.0","changes":[…]}
with each change spelled out (kind, fn, arg, old, new), and
nothing is written. Call it 2.0.0 and it publishes — a break has to
leave the previous version's ^ range, so a consumer pinned with
^1.1 never auto-advances into it. Adding fn-defs, optional args or
wider types is compatible and publishes under any higher version.
You can also publish from the editor — and because publishing
is an authoring act on a namespace, it lives on the namespace.
In the Explorer, hover a namespace row and click its ⬆ button
(next to rename / add / hide). A small popover opens with the
package name (pre-filled from the namespace's last segment) and
a version; the namespace itself is the ns-root. Click
Publish — the same export-and-freeze step, no curl needed, and
it confirms with the published fn-count. The curl above is the
programmatic equivalent for scripts / CI. Either way, publishing is
a deliberate "author decides to release" action; installing happens
on the packages chip below.
(The ⬆ action shows only when the optional registry package
is installed on the deployment, only when you're signed in — and,
on a multi-tenant cloud, only when you hold the
publish-packages org capability. The org owner always holds
it; grant it to other members from Roles or Grants on the
Organization surface. On a self-hosted single-tenant install there
is no capability system, so publishing is simply open.)
Who sees what you publish — visibility
On a multi-tenant cloud, a published version is private to your
organization by default: it lands in the registry stamped with
your org, and only your org's members see it when browsing. The ⬆
popover has a "Public — visible outside your organization"
checkbox for the explicit opt-in — tick it and the version becomes
visible platform-wide. In the packages chip's browse list, private
versions carry a private badge. (The programmatic
equivalent: "public": true in the publish JSON body.)
On a single-tenant install there is no org boundary to be private within — the checkbox isn't shown, and every published version is visible to every user.
The packages chip — browse & install
Installing is a build act — you're adding a building block to your project — so it lives with the other project-context chips on the Build surface, not on an admin page. In the context bar (top of the Build surface, alongside the workspace and branch chips) click packages. A popover opens:
Packages
Package Version
hello 1.0.0 [1.0.0 ↑] × ← installed pins on THIS branch
▾ + Install a package ← native <details>, click to open
hello 1.0.0 [Install] [Fork] ← the registry index
hello 1.1.0 [Install] [Fork]
The top table is what's installed on the current branch
(remember branches from Lesson 8 — pins are per-branch, so dev
and prod can run different versions). The <details> below it
is the registry — every published version, with an action
per row. (The packages chip appears only when the optional
registry package is installed on the deployment.)
Install — by reference, not by copy
Click Install next to hello 1.1.0. Graphden:
- Materializes the version's fns under a version-qualified
namespace —
mycorp.hello@1-1-0(dots in the version become dashes). Idempotent: a second install is a no-op. - Writes a pin — a
:package-installrow saying "this branch useshelloat1.1.0". The pin, plus the visible materialized fns, IS the install.
Nothing is copied into your own namespaces — you reference
mycorp.hello@1-1-0. The panel refreshes to show the new pin in
the installed table.
Update / rollback — repoint the pin, rewrite your refs
Each installed row has a version input prefilled with the current
version and an ↑ button. Type a different version and click
↑:
1.1.0→ forward to a newer version.1.0.0→ rollback — the same button, an older version. The operation is symmetric.latestor a constraint like>=1.1— the highest published match is resolved.
Update doesn't just move the pin: it rewrites your project's own
references from the old version-qualified namespace to the new
one (so fns you built on top of mycorp.hello@1-0-0 now point at
@1-1-0). Package-internal refs are left alone. Same version =
no-op.
Fork — copy-on-write when you want to edit
Install references fns you can't change (they're the
package's). When you want to modify a package, click Fork
instead. Fork copies the version's fns into the graph at
their original namespace (mycorp.hello, not the versioned
one), so they become ordinary editable fn-defs — and writes no
pin (it's a copy, not a reference). A short notice confirms it;
reload to see the copied fns in the explorer tree.
| Install | Fork | |
|---|---|---|
| Rows | Referenced (shared) | Copied (yours) |
| Namespace | ns@version (qualified) |
ns (original) |
| Editable? | No | Yes |
| Writes a pin? | Yes | No |
Uninstall
The × on an installed row drops the pin for this branch. The
materialized ns@version fns stay (another branch may still
reference them) — uninstall only removes this branch's claim on
the package. Remove the last pin and the table collapses to the
empty-state notice.
Withdraw — retract a published version
Uninstall is the consumer's act; withdraw is the publisher's. A published version is immutable, but not eternal — publishing the wrong namespace or the wrong number shouldn't be permanent. The publisher can delete the registry row:
curl -X DELETE "http://localhost:9002/api/packages/withdraw?name=hello&version=1.0.0" \
-H "Authorization: Bearer $AUTH_TOKEN"
# → {"ok":true,"withdrawn":"hello"}
Rules:
- Refused with 409
still-installedwhile any branch still pins the package — a pin resolves through the version row, so withdrawing under it would break installs. Consumers unpin (uninstall or update away) first. - 404
no-such-versionwhen the(name, version)pair is unknown. - Withdrawing is gated by the same
publish-packagescapability as publishing, and only on your own org's rows — a non-publisher can't erase what your org shipped. - The materialized
ns@versionfns are not removed — by then they're ordinary graph content, deleted like any other namespace if you want them gone too.
There's no panel button for this — withdraw is an API-only, deliberate act.
Governance — the Organization surface's packages view
Publish and install are everyday Build-surface acts; oversight lives on the Organization surface. Its packages section is a read-only governance view with three parts:
- a who-may-publish note — states the
publish-packagescapability rule and the default visibility on this deployment; - the catalog — every version your org published (Package / Version / Visibility / Published);
- the install audit — every pin: which package, at which version, on which branch, installed when.
It is deliberately not an install surface — there's no Install button here. Reviewing what your org ships and consumes happens on Organization; acting on it happens on the Build packages chip.
Try it
- Hover the
mycorp.hellonamespace in the Explorer, click ⬆, and publish it ashello1.0.0(or use thecurlabove). - Edit
:greetinmycorp.hello/fns.edn(change the greeting),bb rebuild, publish again as1.1.0(the ⬆ popover again). - Open the packages chip in the Build context bar, expand
+ Install a package, click Install on
hello 1.1.0. Watch the pin appear. - Type
1.0.0in the installed row's version box, click↑— you've rolled back. Type1.1.0,↑— forward again. - Click Fork on
hello 1.0.0, reload —mycorp.hello's fns are now editable copies. - Click
×— the pin's gone. - Open the Organization surface's packages section — the
governance catalog lists both published
helloversions with their visibility; the install audit shows a row per pin (re-install first if you removed the pin in step 6). - Withdraw
hello 1.0.0with thecurlabove — it's unpinned, so the row disappears from the registry browse. Now try withdrawing1.1.0while its pin from step 7 exists: a 409still-installedrefusal. Uninstall first, and the withdraw goes through.
Installing from ANOTHER graphden's registry
The browse <details> has one more affordance under the local table:
a small form — registry URL / package name / version — that pulls
a published package from a different graphden (say, graphden.dev
into your self-hosted install). Behind the scenes the version is
mirrored into your local registry first (an immutable local copy,
never re-published as public), then installed exactly as above —
reference, pin, secrets manifest and all. If the remote registry
requires auth, the server presents its GRAPHDEN_REGISTRY_TOKEN; the
browser never handles that credential.
Beyond the registry — an external package from its own git repo
Everything above lives inside one graphden install: the registry is a table in your database, and publish / install / fork move fn-defs around within it. There's a second, complementary axis of distribution — an external package that lives in its own git repository and is pulled onto the classpath as a Clojure dependency. This is how a Type-2 package (one that ships its own base-fn impls, not just fn-defs — Lesson 28) reaches a graphden it wasn't authored in.
The moving parts, with the worked example mathx (a tiny package whose
whole job is one :gcd base-fn plus a :gcd-with-12 fn-def):
-
The package is its own repo.
graphden/graphden-mathxis a normal Clojure project — adeps.ednand apackages/mathx/resource tree (package.edn+ops/fns.edn+ops/impls.clj), exactly the on-disk shape from Lesson 28, just outside the main tree. -
The consuming graphden lists it by git coord — in
deps.edn(so it's on the classpath) and inresources/executor-packages.edn, the operator's one-file manifest of external packages:{:packages [{:name "mathx" :lib mathx/mathx :coord {:git/url "https://github.com/Graphden/graphden-mathx.git" :git/sha "8337147…"}}]} -
bb rebuildpulls it in. The build clones the repo at that sha, bundles itspackages/mathx/resources into the uberjar, and the loader syncs it at boot alongsidecore/web/app— you'll seeLoading package: mathxin the logs. Its base-fn (:gcd) and fn-def (:gcd-with-12) are then first-class:POST /api/executeof:gcd-with-12 {b 18}returns6.
Unlike a registry install, this is a rebuild-time, operator action, not an in-editor click — the package is code, so it enters through the build, not the graph. Two practical notes:
- Dev stays offline. While developing the package you don't want
every
bb testreaching for git. The:test/:devaliases carry an:override-depsthat pointsmathxat a local checkout (external-packages/mathx), so lint/test resolve the in-tree copy and never touch the network. Only the build (bb rebuild) uses the git coord. - A private package repo needs build-time access. If the repo is
private, the build host must be able to read it (an
ssh-agentholding your key, or a read-only deploy key).bb test/bb devdon't — they use the local override. See docs/DEPLOYMENT.md and docs/PACKAGE_DISTRIBUTION.md § 5.
What we glossed over
- The programmatic API — every panel action has a JSON
sibling:
POST /api/packages/{install,update,fork}andGET /api/packages[/:name/:version]. The panel's own endpoints (/api/packages/panel-*) return refreshed HTML instead; the JSON ones return{ok, …}for scripts and CI. See docs/PACKAGE_DISTRIBUTION.md. - Dependencies — a published bundle records the external fn-names it depends on; install/fork reject if a dependency is absent from the target graph.
- The other cross-install routes — beyond the external git package above, pulling a registry package from one graphden install into a different one (download as EDN, the cloud reference-install with capability grants) is the rest of docs/PACKAGE_DISTRIBUTION.md; the registry half of this lesson stays within one install.
Next
Lesson 09 — State: cells, swap, and a graph-native cache: holding mutable state in the graph that survives across calls.