Private package registries — plan
Hosting PyPI, Hex, Cargo and apt repositories inside the forge, the way the OCI registry is hosted alongside it today.
Spec details are quoted inline deliberately: they were the expensive part to establish, and an implementer shouldn’t have to re-derive them.
The framing
These are not four features. They are one substrate with four format adapters. What they share already exists:
| Need | Already there |
|---|---|
| Blob storage | GitGud.Storage — put/get/delete/presign_url behaviour, backs LFS + CI artifacts |
| Namespacing | Package / PackageVersion, per repo |
| Auth tokens | RegistryToken (ggrt_…), hashed, pull/push scopes |
| UI | /r/:owner/:name/packages |
What differs per format is only the index shape and the upload
framing. That is a @behaviour with four impls, the same move
GitGud.Storage already makes for backends.
Why this differs from the OCI decision: zot is embedded because the distribution spec is a genuine protocol — manifests, blob dedup, cross-mounting, GC, conformance. An apt repository is static files over plain HTTP. There is no daemon to embed. The only hard part is OpenPGP signing, which OTP cannot do.
UI surface
One page per repo listing what has been published and is installable —
extending /r/:owner/:name/packages from “container images” to
“artifacts, by format”, each with its versions and a copy-pasteable
install line.
Phase 0 — substrate
GitGud.Packages.Formatbehaviour:index/2,store/3,content_type/1.formatcolumn onPackage(oci|pypi|hex|cargo|deb); backfill existing rows tooci.- Generalise
RegistryTokenpast OCI. The token itself is already format-agnostic — only the auth header differs per ecosystem. - Route
/pkg/:format/:owner/:name/*rest. - Nothing user-visible ships. Everything after is an adapter.
Phase 1 — PyPI
Smallest, no cryptography, proves the substrate.
PEP 503 (Simple Repository API)
- Normalisation, exactly:
re.sub(r"[-_.]+", "-", name).lower(). Apply before any lookup. /simple/lists projects;/simple/<normalized>/lists files.- Anchor text is the filename;
hrefmay carry#sha256=<hex>. - Trailing slashes are mandatory. Unslashed URLs must redirect.
- Clients request normalised URLs directly; servers may redirect unnormalised ones.
PEP 691 (JSON Simple API)
- Content negotiation on
Accept; respond withContent-Type:application/vnd.pypi.simple.v1+jsonapplication/vnd.pypi.simple.v1+htmltext/htmlis a legacy alias forv1+html406if nothing matches
- Root:
{"meta": {"api-version": "1.0"}, "projects": [{"name": …}]} - Project:
{"meta": …, "name": …, "files": [...]} - File entry required fields:
filename,url,hashes—hashesmust be present even when empty.sha256preferred. - Optional:
requires-python,yanked,dist-info-metadata,gpg-sig.
Serve both serialisations off one data structure; let Accept pick.
Upload: twine multipart POST, Basic auth against a registry
token.
Consume: pip install --index-url https://<host>/pkg/pypi/<owner>/<repo>/simple/
Phase 2 — Hex
Officially supported, and dogfooding, since this is an Elixir project.
Reference behaviour (mix hex.registry build):
openssl genrsa -out private_key.pem
mkdir public
mix hex.registry build public --name=acme --private-key=private_key.pem
# public/ → public_key, names, versions, tarballs/
Consumer:
mix hex.repo add acme <url> --public-key=public/public_key
# then in deps: {:dep, "~> 1.0", repo: "acme"}
Format (registry v2)
- Resources:
/names,/versions,/packages/<NAME>, plus/tarballs/<NAME>-<VERSION>.tarand/public_key. - Protobuf-encoded, gzip-compressed.
- Every resource is wrapped in
Signed{payload, signature}. - The signature is an RSA signature over the SHA-512 digest of the payload (both unencoded).
- Repositories are required to sign all registry resources.
Reuses the RSA keypair machinery already in
mix gitgud.registry.keygen.
Two ways in — shell out to mix hex.registry build per repo, or
generate the protobuf directly. Prefer the second: it makes publishing
a request rather than a rebuild. The first is only a faster start.
Phase 3 — Cargo
Sparse HTTP index (sparse+https://, default since Rust 1.68).
Skip the git-index form — tempting because this is a git server, but
sparse is simpler and is the modern path.
Directory prefix by lowercased name length:
| Length | Path |
|---|---|
| 1 | 1/{name} |
| 2 | 2/{name} |
| 3 | 3/{first}/{name} |
| 4+ | {first2}/{next2}/{name} — e.g. cargo → ca/rg/cargo |
config.json at the index root:
{"dl": "…/api/v1/crates", "api": "…", "auth-required": true}
dl supports markers {crate}, {version}, {prefix},
{lowerprefix}, {sha256-checksum}; with no markers,
/{crate}/{version}/download is appended.
Each index file is newline-delimited JSON, one object per version:
name, vers, deps[] (name, req, features, optional,
default_features, target, kind, registry, package), cksum
(sha256), features, features2, yanked, links, v,
rust_version.
- Only
yankedmay change after creation. features2andv: 2are required if any feature usesdep:orpkg?/featsyntax.rust_versioncarries no operator (1.60, not=1.60).
Caching: honour ETag/If-None-Match (preferred) or
Last-Modified/If-Modified-Since; return 304. The index is read
constantly, so this matters. Missing crates → 404/410/451.
Publish: PUT /api/v1/crates/new,
Content-Type: application/octet-stream, token in Authorization.
Body framing:
- u32 little-endian — JSON metadata length
- JSON metadata
- u32 little-endian —
.cratelength .cratebytes
Success {"warnings": {"invalid_categories": [], "invalid_badges": [], "other": []}};
errors {"errors": [{"detail": "…"}]}.
Others: yank DELETE /api/v1/crates/{n}/{v}/yank, unyank PUT …/unyank,
owners GET|PUT|DELETE /api/v1/crates/{n}/owners, search
GET /api/v1/crates?q=&per_page=. These return {"ok": true}.
Phase 4 — apt, with a Sequoia NIF
Last, because signing is the only real unknown.
Tooling correction: sequoia-sq is the CLI. The library crate is
sequoia-openpgp, and that is what Rustler binds. Follow the
existing pattern — native/gitgud_gix, rustler 0.34,
crate-type = ["cdylib"] — with native/gitgud_pgp beside it exposing
roughly sign_clearsign/2, sign_detached/2, generate_key/1.
Decide the crypto backend first. sequoia-openpgp defaults to
Nettle, which pulls nettle/gmp system libraries into the runtime
image — and this project ships k8s manifests. Evaluate crypto-openssl
or the pure-Rust crypto-rust backend before committing, so the NIF
does not drag native deps into the image.
Format
dists/$DIST/InRelease ← clearsigned, preferred
dists/$DIST/Release + Release.gpg ← legacy, older clients
dists/$DIST/$COMP/binary-$ARCH/Packages{,.xz}
pool/$COMP/$p/$pkg/<name>_<ver>_<arch>.deb
Releasemandatory fields: Suite and/or Codename, Architectures, Components, Date, SHA256.- Clients must not use MD5Sum/SHA1 for security and must require SHA256 or SHA512. SHA256 is the floor.
Packagesstanza needsPackage,Filename,Size,sha256(lowercase field names here, unlikeRelease’sSHA256).Description-md5is mandatory as of APT 2.7.7.- Servers should offer xz only; clients are required to support it.
- Checksums and sizes must be provided for uncompressed variants too.
Plus .deb control parsing: ar archive → control.tar.{gz,xz,zst} →
control stanza.
Fallback: if the crypto backend question turns ugly, aptly
becomes the zot of apt and Phase 4 becomes an integration instead —
without disturbing Phases 1–3.
Order and reasoning
PyPI → Hex → Cargo → apt.
PyPI proves the substrate with no cryptography. Hex is near-free afterwards and gets used immediately. Cargo is mechanical. apt is last because the Sequoia NIF is the only genuine unknown, and it is the one phase with a clean fallback.
Scope: Phase 0+1 together are comparable to the PR review flow. All
four is considerably more. Land PyPI end-to-end and actually
pip install from it before committing to the rest.