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:

  1. 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.
  2. Writes a pin — a :package-install row saying "this branch uses hello at 1.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.
  • latest or 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-installed while 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-version when the (name, version) pair is unknown.
  • Withdrawing is gated by the same publish-packages capability as publishing, and only on your own org's rows — a non-publisher can't erase what your org shipped.
  • The materialized ns@version fns 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-packages capability 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

  1. Hover the mycorp.hello namespace in the Explorer, click , and publish it as hello 1.0.0 (or use the curl above).
  2. Edit :greet in mycorp.hello/fns.edn (change the greeting), bb rebuild, publish again as 1.1.0 (the popover again).
  3. Open the packages chip in the Build context bar, expand + Install a package, click Install on hello 1.1.0. Watch the pin appear.
  4. Type 1.0.0 in the installed row's version box, click — you've rolled back. Type 1.1.0, — forward again.
  5. Click Fork on hello 1.0.0, reload — mycorp.hello's fns are now editable copies.
  6. Click × — the pin's gone.
  7. Open the Organization surface's packages section — the governance catalog lists both published hello versions with their visibility; the install audit shows a row per pin (re-install first if you removed the pin in step 6).
  8. Withdraw hello 1.0.0 with the curl above — it's unpinned, so the row disappears from the registry browse. Now try withdrawing 1.1.0 while its pin from step 7 exists: a 409 still-installed refusal. 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):

  1. The package is its own repo. graphden/graphden-mathx is a normal Clojure project — a deps.edn and a packages/mathx/ resource tree (package.edn + ops/fns.edn + ops/impls.clj), exactly the on-disk shape from Lesson 28, just outside the main tree.

  2. The consuming graphden lists it by git coord — in deps.edn (so it's on the classpath) and in resources/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…"}}]}
    
  3. bb rebuild pulls it in. The build clones the repo at that sha, bundles its packages/mathx/ resources into the uberjar, and the loader syncs it at boot alongside core / web / app — you'll see Loading package: mathx in the logs. Its base-fn (:gcd) and fn-def (:gcd-with-12) are then first-class: POST /api/execute of :gcd-with-12 {b 18} returns 6.

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 test reaching for git. The :test / :dev aliases carry an :override-deps that points mathx at 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-agent holding your key, or a read-only deploy key). bb test / bb dev don'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} and GET /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.