docs /Registries & publishing

Registries & publishing

ply has no registry protocol. A source is a URL template; fetching is construct URL → HTTPS GET → verify sha256 → store. That means any dumb file host is a first-class registry, and there is no rate-limited API in your deploy path.

#Source kinds

[sources]
default = "https://registry.plybox.sh/ply/{package}"   # the official registry
mine    = "github:iluxav/my-packages"                  # GitHub Releases
corp    = "https://artifacts.corp.net/ply"             # any https host
local   = "file:///srv/ply-packages"                   # a directory
Spec Resolves to
github:org/repo https://github.com/org/repo/releases/download/v{version}/{filename}
gitlab:group/proj https://gitlab.com/group/proj/-/releases/v{version}/downloads/{filename}
https://host/path https://host/path/{filename} — {package} in the URL expands to the package name
file:///path local directory

Plain http:// is allowed for localhost and RFC-1918 hosts (handy for a LAN registry); public hosts require --insecure-source — the hash catches tampering either way, this is hygiene.

Dependencies pick a source by alias:

[build.dependencies]
node    = "22"                                    # uses `default`
mytools = { source = "mine", version = "0.1" }    # uses the alias

#The official registry

registry.plybox.sh serves mainstream packages converted from Debian trixie (glibc — npm prebuilts, pip wheels and JNI libraries work untouched), plus runnable services under apps/ — prebuilt, content-addressed, append-only (a published version's manifest never changes, and neither does an arch's bytes once uploaded — bump the version instead, so lockfiles never rot). Reading is a CDN: no accounts, no API, just HTTPS GET on the files below. Publishing goes through ply push — see CLI reference. Browse it at registry.plybox.sh.

#The apps namespace

Everything official — kegs, bases, and the prebuilt services ply run postgres@17 fetches — lives under ply/, one namespace. Whether a package is runnable is a property of its own manifest (it declares an entrypoint), not which shelf it's filed under, so ply/redis is one package now — a library other builds depend on and the service ply run redis@8 fetches — rather than two same-named packages kept apart to avoid colliding. apps/ is a legacy alias: it still serves the copies pushed there before the namespaces unified, for anything still pointed at it, but nothing new publishes there. ply run --source points name resolution at any source spec that follows the layout, including file:/// directories.

#Publishing to the official registry

ply push is two HTTP calls: upload the bytes (mechanical), then publish the record (the manifest — that call is the actual publish). See ply push for the full command; here is the shape it sends and what the registry does with it.

The record — one JSON document per (owner, name, version), the same shape for apps, kegs, and stacks:

{
  "owner": "ply", "name": "postgres", "version": "17.10.7", "type": "app",
  "manifest_toml": "<ply.toml, verbatim, comments included>",
  "manifest": { "package": { "name": "postgres", "...": "..." }, "...": "..." },
  "artifacts": [
    { "arch": "x64", "src": "https://registry.plybox.sh/ply/postgres/postgres-17.10.7-linux-x64.img",
      "sha256": "0cf85787…", "bytes": 110592, "verified": true }
  ]
}

manifest is manifest_toml rendered to JSON, key for key — nothing derived, nothing filled in; manifest_toml is the document people read, manifest is for queries and consumers without a TOML parser. A stack's record has artifacts: [] and manifest_toml = the stack file verbatim, $VAR holes intact.

Once a version's manifest_toml is published it is fixed: republishing the same version with a different manifest is refused (append-only, at artifact granularity); a later push may still add an artifact for an arch that isn't there yet.

verified is per artifact: true only when the registry stored the bytes itself and hashed them; false when you supplied --src and the CLI computed the hash locally instead. The registry does not fetch external artifacts to check them in v1 — a later verification pass may flip the flag without a protocol change.

--src publishes an artifact that lives elsewhere instead of uploading it — a release asset, any static host:

ply push myapp-1.2.0-linux-x64.img \
  --src https://github.com/you/app/releases/download/v1.2.0/myapp-1.2.0-linux-x64.img

{version} and {arch} expand in the URL template, so one --src covers every arch and version you push. The bytes still have to exist locally — ply hashes them itself rather than asking the registry to fetch a URL — and the artifact records verified: false.

#Limits

The official registry accepts pushes from any account, so every account has a budget. The numbers depend on the account's tier: new for its first day, normal after that, and admin for the operators named in the deployment. A refused push is a 429 with the reason and a Retry-After; ply push prints the reason.

limit new normal admin
uploads in flight at once 1 2 4
pushes per hour (upload, URL or publish) 10 60 600
bytes accepted per day 500 MB 2 GB 50 GB
bytes stored in total 2 GB 10 GB 500 GB
packages per namespace 20 200 5000
URL pushes per hour 5 20 200
keys per account 25 25 200

Two more rules do not depend on the tier. An address that keeps sending invalid keys is told to wait after 30 failures in a minute, before any lookup happens. A URL push names where the bytes live, and the registry will not fetch from localhost, a .ply name, or any address that resolves to a private or link-local range.

Operators tune a deployment with PLY_LIMITS, a JSON object keyed by tier holding only the fields to change:

PLY_LIMITS='{"admin":{"bytes_per_day":107374182400},"new":{"pushes_per_hour":5}}'

The field names are the ones in the table, in snake case (concurrent_uploads, pushes_per_hour, bytes_per_day, stored_bytes, packages_per_namespace, url_pushes_per_hour, keys_per_account). Admins are the logins in PLY_ADMIN_LOGINS, the same list that grants the official namespaces.

#Publishing your own packages

Because a registry is just files, publishing is copying:

GitHub Releases (the common case) — name the release tag v<version> and upload the image as an asset. ply build already names artifacts canonically, so a CI job is two lines:

- run: ply build .
- run: gh release upload v1.2.0 myapp-1.2.0-linux-x64.img

Any web host / bucket — upload the image under your chosen prefix.

A directory (great for testing and airgapped deploys):

mkdir -p /srv/ply-packages
cp myapp-1.2.0-linux-x64.img /srv/ply-packages/
# … and on the consuming side:
# [sources] default = "file:///srv/ply-packages"

#Version listing (index.json)

Fetching a pinned version needs nothing but the filename. Resolving a range (node = "22") needs to know which versions exist. On plain http(s) hosts, publish an index.json next to the images — a JSON array of filenames:

["node-22.5.0-linux-x64.img", "node-22.6.0-linux-x64.img"]

Directory sources list files directly, no index needed. Forge sources (GitHub/GitLab) can't list versions yet — pin exact versions for them. The official registry's index.json also lists each version's <name>-<version>.toml (its manifest) alongside the images — ply's own version listing ignores anything that isn't an image filename, so a self-hosted index.json needs only the array above.

#Catalog (state.json)

ply search and ply add read an optional catalog at the source's prefix — the template with /{package} removed:

source catalog
https://registry.plybox.sh/ply/{package} https://registry.plybox.sh/ply/state.json
https://artifacts.corp.net/ply https://artifacts.corp.net/ply/state.json
file:///srv/ply-packages/{package} /srv/ply-packages/state.json

The official registry publishes it; for your own host, a minimal one is:

{ "packages": [
  { "name": "ffmpeg", "description": "Multimedia framework", "license": "LGPL-2.1",
    "versions": [
      { "version": "6.1.1", "img": "ffmpeg-6.1.1-linux-x64.img" },
      { "version": "6.1.1", "img": "ffmpeg-6.1.1-linux-arm64.img" } ] } ] }

Only name, version and img are required; arch is derived from the filename when absent. Forge sources have no catalog. Neither index.json nor state.json is needed to fetch a pinned version.

The official registry's state.json carries more per version, all of it additive — a minimal catalog with just name/version/img still works, and an older ply reading a v3 catalog ignores fields it doesn't know: manifest (the version's .toml URL) and verified, plus fields derived from the manifest for consumers without a parser — volumes, links, publish, dependencies, params — where the .toml stays the source of truth. A stack entry has img: null; both src and manifest point at its .toml.

Additive means new keys, never a changed type. A catalog is parsed in one pass into typed structs, so a field whose type changes doesn't degrade — it fails the whole document, and with it every ply search, ply add and ply up <ns>/<stack> on every ply already installed. That is why dependencies stays the array it has always been ([{"name": "postgresql17", "version": "17"}]) even though the registry holds it as a map internally.

#Private packages

For a private artifact host, anything your network can GET works (VPN, signed URLs, LAN). The hash check makes the transport untrusted by design. Token-authenticated GitHub sources are on the roadmap.

#The testing gift

python3 -m http.server 8000 --directory /srv/ply-packages

…is a complete, working registry. ply's own integration tests spin one up in a tempdir and run the full resolve→fetch→verify path offline in milliseconds. Airgapped deploys are the same trick: rsync the directory, use a file:// source.