Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Publishing a package

Browse packages · Publish a package · Standards

The SSPUR registry is sspur-packages, a public GitHub repository with one file per package. Its site lists every package. This page covers using published packages, publishing your own, and what happens in between.

Using a published package

sspur search finds packages and sspur add adds one. This example reads a local copy of an index, the SSPUR_INDEX directory next to it; without SSPUR_INDEX, sspur reads sspur-packages.

$ cd ../shop
$ SSPUR_INDEX=../index sspur search money
money                    1.0.0      Amounts of money in cents
acme/money               1.0.0      Amounts in a currency, as acme counts them
$ SSPUR_INDEX=../index sspur add money
added money 1.0.0 #33pnngsyjpwa (registry ^1.0.0): Money, add
$ sspur run main.ssp
42

sspur add money picks the newest version and writes money = { version = "^1.0.0" } to sspur.toml. To choose a version:

CommandWritesAllows
sspur add moneyversion = "^1.4.2"the newest version now, and later any 1.x from it on
sspur add money@1.2.3version = "=1.2.3"exactly 1.2.3
sspur add money@^1.2version = "^1.2"any 1.x from 1.2.0 on
sspur add money@~1.2.0version = "~1.2.0"patches only: 1.2.x

In a 0.x line ^0.3 accepts 0.3.x only, as in Cargo, since 0.3 to 0.4 may break. A requirement in sspur.toml reads as Cargo reads it, so a bare version = "1.2" there is ^1.2; only sspur add and sspur source take a whole version on the command line as that exact version. sspur deps tree shows what you use, each package with the version the lock pins, its hash, and what it depends on.

The lock pins the version by its commit, the hash of its exports, and the SHA-256 of its source snapshot. From sspur-packages the snapshot is a release asset, https://github.com/sspurlang/sspur-packages/releases/download/money-1.0.0/money-1.0.0.tar; here it is the file next to the index:

$ cat sspur.lock
# sspur.lock: generated by sspur. Each dependency is pinned by the hash of its exports.

[[dep]]
name = "money"
version = "1.0.0"
source = "registry"
ref = "^1.0.0"
rev = "57c0821dba1d2eda9551d69bb0edb7bbfdfde1bd"
hash = "33pnngsyjpwa2kanfmgj2gi53dzugrczxi3awxezzjm6s44i4yqq"
git = "https://github.com/alice/money.git"
snapshot = "../index/money-1.0.0.tar"
sha256 = "12df3c1f9bff6230559c0cb05edbd996ee8e2b7d581f57503b372f092f34cc03"
deps = []

Builds read only the lock and the cache, so they work offline and give the same program every time. Nothing in a package runs when it is installed: sspur downloads the snapshot, checks its SHA-256, typechecks the package and checks that it hashes to the published hash. If the snapshot is missing it uses the author’s repository at the pinned commit, which must give exactly the same bytes. A snapshot that doesn’t match is refused. sspur source money@1.0.0 downloads and verifies a version into a directory without building it, so you can read it first.

Scoped packages

A name can have a scope, the GitHub user or organization that publishes it: acme/money and money are different packages, and so are acme/money and alice/money (ADR 0039). sspur add acme/money uses the name after the scope as the dependency key, the name the code writes (use money.{Money}, money.of(...)). When the key is taken, sspur add says so, and --as picks another:

$ cd ledger
$ SSPUR_INDEX=../index sspur add money
added money 1.0.0 #33pnngsyjpwa (registry ^1.0.0): Money, add
$ SSPUR_INDEX=../index sspur add acme/money
the key money is taken (by money); add acme/money under another key with 'sspur add acme/money --as NAME'
$ SSPUR_INDEX=../index sspur add acme/money --as acme_money
added acme_money 1.0.0 #7qieh4mdpcko (registry acme/money ^1.0.0): Money, of
$ sspur run main.ssp
42 cents and 3 EUR

Both are then in one program, each with its own Money type:

use money.{Money, add}

fn main() -> Unit ! log
= do
  cents = add(Money{cents: 2}, Money{cents: 40})
  euros = acme_money.of(3, "EUR")
  log("{cents.cents} cents and {euros.units} {euros.currency}")

The manifest names the scoped package with package, under its key:

$ cat sspur.toml
[package]
name = "ledger"
version = "0.1.0"
src = "main.ssp"

[deps]
money = { version = "^1.0.0" }
acme_money = { package = "acme/money", version = "^1.0.0" }

The lock records the full name, and sspur deps tree shows the key a package is used under:

$ sspur deps tree
ledger 0.1.0
  acme/money 1.0.0 #7qieh4mdpcko registry (as acme_money)
  money 1.0.0 #33pnngsyjpwa registry

Updating

sspur update moves every registry dependency to the newest version its requirement allows (or sspur update money just one), prints what changed in each export, typechecks your code against it, and refuses if your code no longer checks. Each package has one version in a graph: the highest that meets every requirement on it, from your manifest and from your dependencies’. When none does, E_DEP_CONFLICT says who needs what.

Before you publish

A published package is a public GitHub repository with sspur.toml at its top, with a description and a licence. Its name is either flat, money, or scoped by the repository’s owner, acme/money, which no one else can take: only acme (a user) or a public member of acme (an organization) can publish it:

[package]
name = "money"
version = "1.0.0"
description = "Amounts of money in cents"
license = "MIT"
src = "lib.ssp"

It needs a LICENSE file with an OSI-approved licence, a README.md, a doc comment on every public definition (the // lines directly above it), and at least one test. Its dependencies must be published packages too:

// An amount of money in cents.
pub type Money = {cents: Int} derive Eq, Show

// The sum of two amounts.
pub fn add(a: Money, b: Money) -> Money
= Money{cents: a.cents + b.cents}

test t_add = add(Money{cents: 1}, Money{cents: 2}) == Money{cents: 3}

sspur api shows the public API as the registry will list it, with the doc comments:

$ cd money
$ sspur api
money 1.0.0 #33pnngsyjpwa
  lib.ssp:2  pub type Money = {cents: Int} derive Eq, Show
      An amount of money in cents.
  lib.ssp:5  pub fn add(a: Money, b: Money) -> Money
      The sum of two amounts.

Commit, tag the version (git tag v1.0.0), and push the branch and the tag.

Big packages

A large library is one package with modules: path dependencies inside its own directory, each a directory with its own sspur.toml and source, as the web-app template’s models = { path = "models" }:

[package]
name = "acme/text"
version = "1.0.0"
description = "Text helpers, in two modules"
license = "MIT"
src = "lib.ssp"

[deps]
strings = { path = "strings" }
numbers = { path = "src/numbers" }

A module can use another (strings = { path = "../../strings" } in src/numbers/sspur.toml) and published packages. The registry checks every module as it checks the root: it parses, typechecks, is formatted, has a doc on every public definition, passes its tests natively and interpreted, holds no secrets and follows the review rules, and a failure points at the file from the top of the repository, strings/lib.ssp:12. sspur publish runs every module’s tests side by side, in a copy of the committed files.

The package’s API is what the root module exports. A module’s pub definitions are for the other modules; dependents don’t see them unless the root re-exports them, with a type alias or a function:

// A string with its length known, as the strings module defines it.
pub type Sized = strings.Sized

// The text cut to `n` characters.
pub fn cut(s: Str, n: Int) -> Str
= strings.cut(s, n)

// How many characters `n` has when written out.
pub fn digits(n: Int) -> Int
= numbers.digits(n)

test t_text = cut("hello", 3) == "hel" and digits(42) == 2

A module can also be part of the API itself: mark it public = true in the root’s sspur.toml, models = { path = "models", public = true }, and dependents import its pub definitions with use pkg.models.{Doc, parse}. Or name them qualified, as a package’s own names: pkg.models.Doc{title: "x"}, d: pkg.models.Doc, pkg.models.parse(s) and | pkg.models.Draft => ... work without the use. A module that isn’t public stays private to the package (E_PKG_UNKNOWN says how to make it public), and older clients, which don’t read public, build the package as before but can’t import the module.

So the semver check compares the root’s exports and those of its public modules, named models.Doc, and the site’s API docs list them; each module is tested on its own:

$ cd text
$ sspur test lib.ssp
1 passed, 0 failed
$ sspur api
acme/text 1.0.0 #cwlfb74s3y7z
  lib.ssp:2  pub type Sized = strings.Sized
      A string with its length known, as the strings module defines it.
  lib.ssp:5  pub fn cut(s: Str, n: Int) -> Str
      The text cut to `n` characters.
  lib.ssp:9  pub fn digits(n: Int) -> Int
      How many characters `n` has when written out.
$ cd src/numbers
$ sspur test lib.ssp
1 passed, 0 failed

A dependent builds the modules as part of the package, under the package’s own name, so its own strings module, if it has one, is a different package. The limits are 2000 files, 1 MiB per file, 4 MiB of SSPUR source and 20 MiB per repository, about twice the size of the largest Apache Commons library.

Publishing

Run sspur publish in the package. It runs the registry’s checks on the committed package, the same program the registry runs, including the tests natively and in the interpreter. It prints a line per check (ok build.parse The source parses, or FAIL pkg.docs with the file, the line and the fix) and ends with money 1.0.0: every blocking check passed. With the GitHub CLI installed and signed in, it opens the publish request for you; otherwise it prints a link to the request form, filled in. sspur publish --dry-run only checks. The Publish page does the same in a browser: enter your GitHub username, pick the repository and the tag, and it runs the checks it can run there (all but the tests) before you submit.

The request is an issue in sspur-packages. Its workflow checks the version again, runs the tests, and opens a pull request that adds the version to the index. The description lists every check, the effects of each public function and the API changes since the last version. When every blocking check passes, the workflow stores a snapshot of the source as a release asset and merges the pull request. Within a few minutes sspur add sees the new version.

The first version of a new package name, scoped or not, also waits for a maintainer of sspur-packages to comment /approve on the request, once, after every check has passed. That is a look at the name and what the package is for, against squatting and spam, not a code review. Every later version merges on its checks alone.

Credit

The request issue is yours: you opened it, and GitHub counts it. The pull request is opened by the registry’s bot, because the workflow has only its own token, never yours. The commit that lands on the index’s main branch is yours too: the workflow authors it with your GitHub noreply address (<id>+<login>@users.noreply.github.com, from the request’s author), merges it with gh pr merge --squash --author-email set to that address, and adds a Co-authored-by line for you. GitHub counts a commit in your contributions when its author or co-author address belongs to your account, it is on the repository’s default branch, the repository is not a fork, and you have opened an issue there, which the request is. So each published version shows on your profile with your avatar on the commit.

When something fails, the pull request stays open with each failure at its file and line and how to fix it, and the request gets a short comment. Fix the package, then move the tag (git tag -f v1.0.0 && git push -f origin v1.0.0) and comment /recheck on the request, or tag the fix and edit the request’s Ref. Until a version is published its tag may move. Afterwards nothing can change it: the index pins the commit and the snapshot.

What the checks look for

The standards list all 39 checks with their reasons and fixes. In short:

  • the source parses, typechecks with no warnings, is formatted the way sspur fmt prints it (which keeps comments), and its tests pass natively and in the interpreter;
  • the version is new and matches the API change: removing or changing a public signature needs a new major version (a new minor version in 0.x), adding one a new minor version;
  • a capability effect the previous version didn’t have (net, fs, proc, env, io, db, ffi, unsafe, dev) is acknowledged in the request, with sspur publish --ack net or the request’s Effects field;
  • the name follows the rules and isn’t reserved; one too close to an existing name gets a warning;
  • a scope is the repository’s GitHub owner, and the request comes from that user or a public member of that organization;
  • the first version comes from the repository’s owner (for an organization’s repository, a public member of it), and later ones from the package’s owners, from the same repository or the one GitHub says it moved to; a new flat name isn’t claimed from a fork (publish a fork under your own scope);
  • an OSI licence, a README, docs and tests; no binaries, no secrets, no build or install hooks, dependencies that are published packages or the package’s own modules, within the size limits;
  • no holes left in the code, a reason next to every run_cmd, and the naming conventions.

What the registry stores

The index is one JSON file per package, packages/mo/money.json (packages/@acme/money.json for acme/money). The entry of each version pins its tag, commit, export hash and snapshot, and records its effects, dependencies, licence and public API, which the package page and the API diff of the next version use:

{
  "schema": 1,
  "name": "money",
  "description": "Amounts of money in cents",
  "repository": "https://github.com/alice/money",
  "owners": [
    "alice"
  ],
  "keywords": [],
  "versions": [
    {
      "version": "1.0.0",
      "git": "https://github.com/alice/money.git",
      "tag": "v1.0.0",
      "commit": "57c0821dba1d2eda9551d69bb0edb7bbfdfde1bd",
      "hash": "33pnngsyjpwa2kanfmgj2gi53dzugrczxi3awxezzjm6s44i4yqq",
      "snapshot": {
        "url": "../index/money-1.0.0.tar",
        "sha256": "12df3c1f9bff6230559c0cb05edbd996ee8e2b7d581f57503b372f092f34cc03",
        "size": 3072
      },
      "effects": [],
      "deps": {},
      "license": "MIT",
      "yanked": false,
      "deprecated": null,
      "published": "2026-10-16T00:00:00Z",
      "publisher": "alice",
      "request": 1,
      "api": [
        {
          "name": "Money",
          "kind": "type",
          "sig": "pub type Money = {cents: Int} derive Eq, Show",
          "sig_hash": "6ihg53i2inlz3szh7cttn66vfy",
          "hash": "hfvfubcxmqiswfgotgbugouc43jjtmenxfexempzjcxxsjqa757q",
          "effects": [],
          "doc": "An amount of money in cents."
        },
        {
          "name": "add",
          "kind": "fn",
          "sig": "fn add(a: Money, b: Money) -> Money",
          "sig_hash": "nz5nqlv4q2jpi6sk7rg6gs5waq",
          "hash": "rb32qyrp2s7b2gnaigumjosg6xpjai5sedd47pocadh2oo5kftwa",
          "effects": [],
          "doc": "The sum of two amounts."
        }
      ]
    }
  ]
}

index.json sums up every package for sspur search and the site:

{
  "schema": 1,
  "packages": [
    {
      "name": "acme/money",
      "description": "Amounts in a currency, as acme counts them",
      "latest": "1.0.0",
      "versions": 1,
      "keywords": [],
      "updated": "2026-10-16T00:00:00Z",
      "yanked": false,
      "deprecated": null
    },
    {
      "name": "money",
      "description": "Amounts of money in cents",
      "latest": "1.0.0",
      "versions": 1,
      "keywords": [],
      "updated": "2026-10-16T00:00:00Z",
      "yanked": false,
      "deprecated": null
    }
  ]
}

Yanking and deprecating

sspur yank money@1.0.0 (or acme/money@1.0.0) asks to yank a version: new locks skip it, and existing locks keep working. --undo takes it back, and --deprecate "use money2" marks it deprecated instead. Only the package’s owners can do either. Published versions are never deleted.

The sspur.toml and sspur.lock pages describe the formats, the command line reference has every flag, and ADR 0038 explains the design and what it does and doesn’t protect against. That is the end of the handbook. The tutorials build real programs with what it covered, and the cheat sheet fits the syntax on one page.