~ knotless Search/
↑↓ move↵ open⇧↵ all results

chadtmiller.com/knotless

git that lives in your Atmosphere account

7
377 lines · 17.6 KB Raw History

Releases

A release is one version of a package that a repository publishes: a set of files people download and install, such as the archives of a command-line tool. In knotless a release is a record, dev.knotless.release, in the repository owner's atproto account, beside the repository's refs and packs (see storage.md). Its files are blobs, the binary data a PDS keeps next to an account's records. There is no registry server: the owner's PDS holds the release, and anyone can read it from there.

This page explains why releases are stored this way. The commands that publish and read them, knotless publish, knotless releases and knotless download, are in ../reference/cli.md, and the MCP tools that do the same, publish, releases and read_release, in ../reference/mcp.md.

The problem

Publishing a release means putting files where people can install them from. Here that place is an account on a stock PDS, the server that holds the account's records and blobs and signs them. Four things about it shape the design.

The PDS caps each blob. The reference PDS takes blobs up to 5 MB by default, and knotless's own CLI archives are 7.6 to 8.4 MB each.

Nothing can be installed on the PDS. knotless runs on any PDS as it ships, so it cannot add a registry endpoint, a hook, or a check the server enforces. Every rule has to hold through the records the stock server already accepts.

A published version must never change. Someone who installs 1.2.0 today and again next year must get the same bytes, and two publishes racing to the same version must not leave a mix of both.

Readers fetch through caches. A blob may come from the PDS, a CDN or knotless-serve. A reader needs to know a file is the one published without trusting whoever handed it over.

A reader also wants to know what a release was built from: which commit, and which CI run.

One record per version

Each version of each package is its own record. The record is created once and never updated, so the version cannot change after it is published. Deleting the record unpublishes the version.

A record is addressed by its record key (rkey), and keys sort as strings. A release's key is built so the keys of one repository, and of one package within it, sit together:

<repo key>.<package hash>.<version hash>

The repo key is the repository's own key, a TID (a time-ordered key). Each hash is the same short hash refs use: the first 26 characters of the lowercase, unpadded base32 SHA-256 of the name. Hashing keeps any package name or version within the characters an rkey allows, at a fixed length.

Because of this layout, one listRecords call that starts at a prefix and stops at the first key outside it reads a whole list. Starting at the repo key reads every release of the repository. Starting at the repo key and the package hash reads one package's versions. Refs and packs are read the same way (storage.md).

A record holds:

  • repo: the repository's key.
  • package and version: the names, in full.
  • files: 1 to 64 files, each with its name, size, sha256 and the blobs that hold it.
  • commit: the commit it was built from, optional.
  • check: the AT-URI of the check record of the run that built it, optional.
  • notes: release notes in Markdown, optional.
  • createdAt.

Package names follow npm's rules, so that the npm protocol can later be served from the same records. Versions are Semantic Versioning 2.0.

Details:

  • A package name is lowercase letters, digits, -, ., _ and ~, not starting with . or _, after an optional @scope/, at most 214 bytes.
  • A version is at most 256 bytes.
  • A file name is letters, digits, ., _, + and -, not starting with ., at most 255 bytes, and distinct within its release. A file cannot be empty.
  • notes is at most 20,000 bytes and 5,000 graphemes.
  • The schema is lexicons/dev/knotless/release.json.

Files larger than a blob

A file that does not fit in one blob is split into parts, each its own blob, listed in order under the file's parts. A reader joins them back together. The splitting is the code that splits large git packs on push, so releases and packs meet the PDS's limit the same way.

Unlike a pack, a release file has no inline form: every file is blobs, even a small one.

Details:

  • Parts are KNOTLESS_CHUNK_SIZE bytes, 4,000,000 by default, capped at the blobUploadLimit the PDS gives in describeServer.
  • Four parts upload at a time. When the PDS says a part is too large, the size halves and the upload tries again.
  • Each blob is stored as application/octet-stream.
  • Each part costs one blob upload, and bsky.social allows 1,000 a day. A release of four 8 MB archives costs eight.

Publishing

Only the repository's owner publishes, logged in as that account. The writes go to the owner's PDS, as a push's do.

A publish runs in this order:

  1. Check everything locally: the package name, the version, and each file's name, size and uniqueness.
  2. Open the repository, and refuse a private one.
  3. Read the record at the release's key, and stop if it exists, before uploading anything.
  4. Upload each file's parts.
  5. Create the record with applyWrites.

The last step is what keeps a version fixed. applyWrites writes its records together or not at all, and a create fails if the record already exists. Two publishes racing to the same version both upload their blobs, but only one creates the record; the other is told the version is published already. The early read in step 3 only saves a publish from uploading files it cannot use.

Inside a runner's step, commit defaults to the commit the run is checking (KNOTLESS_RUN_SHA). A flag sets it otherwise, and another names the check record.

The latest version

The latest version of a package is the highest version with no pre-release part. 1.1.0-rc.1 is never the latest, even when it is the highest. knotless download takes the latest when no version is named.

npm keeps a latest tag as mutable state that each publish moves. knotless has no such record. The latest is computed from the versions that exist, so publishes have nothing shared to contend on, and no tag can point at a version that is not there.

Trusting a file

The record lives in the owner's account repository, which the PDS signs. Each file's size and sha256 are in that signed record. A reader downloads the parts, joins them, and checks the length and the SHA-256 against the record. If either differs, the download fails with an error that what was read is not the file its release names.

This is what makes any cache safe to read through. The blobs can come from anywhere; the signed record says what they must be.

Anyone can read a public repository's releases, with no login: listRecords for the records and getBlob for the parts.

Installing from releases

knotless-serve hands out the client at /install.sh and /releases/. Given a repository, --releases knotless://<owner>/<name>, it answers them from that repository's releases of the knotless package: /releases/latest is the latest version, <version>/<file> a file of it, and <version>/<file>.sha256 the hash its record holds. The script is the same one that installs from a directory, so the URLs are the same.

Serve reads each file from the owner's PDS the first time it is asked for, checks it against its record, and keeps it by its hash for good, since a release's files never change. It lists the releases again once a minute, so a new version is the latest within one.

The script checks each archive against the .sha256 beside it, which serve takes from the record. It trusts the server it came from, as curl | sh always does. Serve does not: what it hands out is what the owner's account signed.

Images

A container image is a release too. An image is content-addressed already: a manifest names its config and layers by their SHA-256, and an OCI image layout keeps each blob under its digest, with an index.json naming the manifest. knotless publish --oci <dir> publishes such a layout as a release's files: the index, and each blob named sha256-<hex>. The sha256 each file has in the record is then the blob's digest, so the record that makes a download safe to read through any cache does the same for a pull. The index.json must name one manifest (else index.json names more or less than one manifest: publish one image, of one or many platforms, at a time), and the image is held to a release's 64 files: the index and at most 63 blobs.

knotless-serve answers pulls in the OCI distribution protocol from those records: docker pull knotless.dev/<owner>/<repo>/<package>:<version>, or knotless.dev/<owner>/<repo>:<version> for the package named as the repository. A tag is a version, or latest, computed as for any release, so the registry keeps no tags of its own either. Its manifest is the one the release's index.json names, which may be an index of several platforms.

An image can be pushed there too, as to any registry: docker login with the owner's handle and an app password, then docker push. The password gets a token from serve by logging in to the owner's PDS, and is kept nowhere. Each blob is checked against its digest and uploaded to the owner's PDS as it lands; the manifest pushed with a version as its tag makes the release, with every blob it reaches and an index.json naming it, as knotless publish --oci would, though with no commit, check or notes. The record is the owner's, made with their session, so serve holds nothing a rebuild from the PDS would lose but the pushes still under way. A version is pushed once, and latest never: it is computed.

knotless publishes its own image so. Once main's deploy is up, its image step saves the image it runs, which docker save writes as an OCI image layout, and publishes it as the release of the knotless-server package at main's version, if that version has none yet: docker pull knotless.dev/chadtmiller.com/knotless/knotless-server. It is one image for every service a host runs, each started by its own command, as Loki and Concourse ship theirs: one build to make and publish, rather than an image a service, which would share all but their entry point. deploy/README.md lists the commands. It is one platform, linux/amd64, as the host builds it, and its layers are as docker keeps them, uncompressed.

On the pages

knotless-serve indexes release records as it indexes the rest, so a repository's pages show what it publishes. Under its name, one line says the release to show and links to it: the latest of the package named as the repository, its own, marked Latest; else the newest that is its package's latest; or, when every release is a pre-release, the newest of all, marked Pre-release. Beside it, how many packages the repository publishes and how many of them are images, linked to its releases.

A Releases tab, with how many releases there are, opens the releases page: every version of every package, newest first, each with its notes, the commit it was built from and its files, each with its size and SHA-256. An image shows the docker pull command for it rather than its blobs.

Each file's name links to download it, from /<owner>/<repo>/releases/download/<package>/<version>/<file>. knotless-serve answers that as it answers /releases/ for the repository it installs from, and through the same code: it reads the file's parts from the owner's PDS the first time it is asked for, joins them, checks the whole against the record, and keeps it by its hash. It hands out only what the index has, so a release taken down, or a repository or account taken down, is not found, and it reads as no one, so a private repository's is not found either. The file goes out to be saved, never shown: application/octet-stream, attachment under its own name, nosniff and a sandbox. Its hash is its ETag, and it is no-cache, so a cache asks again each time, at the cost of a 304, and a takedown holds at once. With more than one package, a chip for each, with its latest version, shows one package's releases alone. A record that is no release, by its package's name or its version, is passed by. A private repository's releases are refused, so its pages show none.

What a release was built from

Two optional fields link a release to its source. commit names the commit it was built from. check names the check record of the run that built and published it, which a runner writes in its own account (see runner.md).

A check is evidence only from a runner the repository trusts. The schema says a reader trusts it only when its runner is one of the accounts the repository record lists in runners, and only once that check passed at commit. No reader checks this yet.

On GitHub, Releases attach files to a tag, and only lately can a release be made immutable. npm's provenance and GitHub's artifact attestations are Sigstore signatures kept apart from the package. Here the record is immutable from its creation, and the link to the commit and the check sits in the same signed account repository as the files' hashes.

Why not a registry on the PDS

pds.js serves npm and OCI from the PDS itself, as extensions (@pdsjs/npm, @pdsjs/oci), with one mutable record per package that holds every version and tag. Clients that speak npm or OCI then work against the PDS directly.

knotless installs nothing on a PDS, so it cannot take that path. One record per package would also bring back the contention knotless removed elsewhere: every publish rewrites the same record. It is the lesson that split git refs into one record each (prior-art.md). With one record per version, two publishes of different versions never touch the same record.

npm-compatible package names leave room to serve the npm protocol later, from knotless-serve rather than the PDS.

How knotless versions itself

knotless's own CLI is published as releases of the knotless package from its repository, on one track. Its version is set with knope and changeset files rather than conventional commits.

A pull that changes what users of the CLI see adds a change file under .changeset/ with knope document-change, naming the bump. knope release gathers them: it bumps the workspace version in Cargo.toml and Cargo.lock, writes CHANGELOG.md, and deletes the change files. That goes to main as a pull like any other.

A change file is easy to forget, so the check check asks for one: a pull that changes the client's code, crates/knotless/src or crates/knotless-mcp/src, fails without a new file under .changeset/, unless its body says Changeset: none on a line of its own, for a change no one using knotless sees.

Publishing follows from the version. The release workflow runs on each push to main that changes Cargo.toml. If that version has no release yet, it waits for the commit's seven checks to pass, builds the client for macOS and Linux, each on aarch64 and x86_64, and publishes the four archives as the owner, with the version's section of the changelog as the notes and the commit it was built from. It then tags that commit v<version>, so a git checkout finds each version by name. A version already published makes it pass at once, so a push that changes Cargo.toml for another reason publishes nothing. A version is never published from a commit whose checks did not pass, since it could never be taken back.

All four targets build on the Linux runner, with cargo-zigbuild: the client links no macOS framework, so zig's linker can link it for macOS.

A canary build, if there is one, would be a pre-release version, which is never the latest.

Keeping clients current

A client bug can harm repositories: a compaction that dropped packs others still needed once left a repository no fresh clone could read, and any client still running that version could do it again after the fix was out. So the appview tells clients both what is newest and what is too old.

Once a day the client asks its appview for /releases/latest and for dev.knotless.describeServer, whose client.minimum is the oldest version it asks for (--client-minimum, set only when a release is found to do harm), with a reason. A newer release is said on stderr after the command, as gh does, so output that scripts read stays the same. A client older than the minimum refuses every command but those that get it current (upgrade, login, logout, help), saying why. The check fails open: an appview that does not answer, or answers nothing, leaves what was last known, and runs nothing it would not have. KNOTLESS_NO_UPDATE_CHECK turns it off, and KNOTLESS_IGNORE_MINIMUM lets an old client run anyway, for someone who knows the harm does not reach them.

knotless upgrade runs the appview's /install.sh, the same script that installed it, into the directory the binary is in. A copy put there by cargo or a package manager is that tool's to upgrade, so upgrade says so instead of writing over it. The client never upgrades itself unasked: a binary that changes under a script is worse than one that says it is old.

Not yet

  • The npm protocol is not served from releases yet.
  • A layer of hundreds of megabytes is many blob uploads, which a PDS counts against its rate limits.
  • Serve hands out the app password's session for an hour to whoever has the token, and trusts the TLS in front of it to keep the password from anyone else.
  • A private repository's releases, kept in a space, are refused.
  • A runner does not yet give its steps its check's URI, so check is not filled in automatically in a run.
  • No reader yet checks a release's check against the repository's runners.