Beautiful changelogs and releases for everything
Go 63%
TypeScript 20%
Svelte 13%
1%
Shell 1%
CSS <1%
Nix <1%
JavaScript <1%
HTML <1%

README.md

dist.town #

Sharing your project should be easy. Whatever your project is and wherever it lives, dist.town gives it one page for new versions, changelogs, and any associated files. Powered by AT Protocol.

While heavily inspired by GitHub's Releases feature, there are three important differences:

  1. dist.town projects are optionally linked to source control. A repository is not required to publish a release; feel free to publish whatever you like!
  2. dist.town stores no files (for now?). Any uploaded files live in a location that you control.
  3. Release artifacts are not limited to just files. The record format describes a location by variant — a URL, a blob on your PDS, a blob in another repo's record, an artifact host, or an OCI image reference — and you don't have to include artifacts at all. (What the CLI can put there today is narrower than what the format allows; see Storage.)

dist.town is not trying to be a package manager, nor does it want to enforce specific versioning schemes.

At the end of the day, dist.town's mission is to help you share your project and tell your users (new and old) what has changed over time, no matter what it is. When somebody asks, "Where can I download that?" you can point them at https://dist.town.


How it works #

There are three operating components to dist.town:

Part What it is
cmd/disttown The publisher CLI. Hashes your artifacts, uploads them to your storage, and writes records directly to your PDS.
cmd/appview The reader, and in production the public edge. Takes jetstream as a wake-up signal only, verifies every record against the publisher's own PDS itself (commit signatures, MST proofs), indexes into a disposable SQLite database, and serves the public read plane plus the download front door.
web/ The SvelteKit UI. Consumes only the public read plane — there is no private API, so anything a page can show, your scripts can fetch.

Only the appview holds a public socket. It decides per request which side answers, by URL shape alone: /xrpc/*, release sub-paths (an artifact, SHA256SUMS, or a .sha256 under a project@version segment), /.well-known/did.json, /disttown.schema.json, and the go-import document for ?go-get=1 are served natively; every other path is proxied to SvelteKit. A web outage degrades pages, never downloads.

The records themselves are defined in lexicons/ as town.dist.* schemas. That directory is the source of truth: Go types, CBOR encoders, and the TypeScript client are generated from it. (The CLI reference page is generated too, but from the CLI's own command registry — tools/gen.sh runs both.)

A release is immutable once published. Pointers (latest, next, and any name you like) move independently, and status changes — yank, deprecate, archive — are append-only records rather than edits, so the history of a release is told by adding to it, not by editing it.

Retraction is the fourth lifecycle verb and the one exception: a mistake-release that never should have existed can be removed. One atomic commit writes a retracted status carrying your reason and deletes the release record. The record is gone; the testimony stays, and the site renders a retracted page telling readers what happened and why. Yank is the norm for anything that ever had a consumer — retraction is the last resort, and the CLI says so before it acts.

The CLI updates itself. disttown update resolves its publisher's repo with no dist.town service involved — identity directory to PDS, pointer and release records by direct rkey, lifecycle from the repo's own status records — and verifies the downloaded bytes against the digest in the signed release record before replacing the binary. It asks first. disttown update --check reports without installing.

Changelogs are site.standard.document records, which means they interoperate with the wider standard.site ecosystem: other readers render your release notes with no dist.town-specific support, and you can write a retrospective elsewhere and adopt it as a changelog here. If you already have a Markdown changelog, the CLI can publish and link that for you.

A repo can commit a disttown.jsonc describing what it publishes: the project, the artifact globs, the storage choice, and the account expected to publish. With a manifest and an annotated tag at HEAD, disttown publish needs no arguments — the CLI shows you the plan it inferred and asks before anything uploads. Without a manifest the CLI behaves exactly as it always has. (This repo's own manifest is at the root.)

Settings resolve in one order, most specific first:

flag > environment > manifest > machine config > ask you.

The environment tier is the file-free CI path: DISTTOWN_PDS, DISTTOWN_IDENTIFIER, and DISTTOWN_APP_PASSWORD make a fresh session per run with nothing written to disk, and DISTTOWN_STORAGE_BASE_URL with DISTTOWN_STORAGE_UPLOAD_CMD define storage outright — overriding any profile a manifest names. DISTTOWN_READ_PLANE points the read commands at another appview, and DISTTOWN_UPDATE_SOURCE overrides where disttown update looks.

Storage #

The record format admits five location variants: url, host, blob, recordBlob, and oci. The CLI produces three of them today:

  • url — your own bucket. disttown storage setup --base-url ... with an optional --upload-cmd template, and the CLI uploads.
  • blob — bytes on your own PDS. A manifest declaring the reserved storage name blobs takes this path; it is what this repo's own releases use.
  • recordBlob — a blob already attached to another record, which the CLI writes when it attaches artifacts to an annotated tag on a linked Tangled repo.

host and oci are in the schemas and readers understand them, but no CLI command produces one and the read side does not verify them. Treat them as room the format leaves, not as shipped capability.

Repository layout #

lexicons/          town.dist.* schemas — the source of truth
cmd/disttown       publisher CLI
cmd/appview        indexer, read plane, download front door, public edge
cmd/disttown-admin operator moderation client (not publicly released)
web/               SvelteKit UI
packages/client    generated TypeScript client / SDK
internal/          Go implementation packages
tools/             codegen, cross-compilation, deploy scripts
openspec/          proposals, design decisions, specs
docs/              operator documentation
PRODUCT.md         product record used for design work

The Go module path is dist.town — a vanity path the appview serves itself. ?go-get=1 on any path under the host returns a go-import document naming the repository on Tangled, so go get dist.town/... resolves.

Getting started as a contributor #

Prerequisites #

There's a Nix flake with everything pinned:

nix develop

That gives you Go, gopls, Node, pnpm, flyctl, skopeo, and zip, plus the pinned Playwright Chromium the component workshop's tests run on (the browser comes from Nix, and web/package.json pins playwright to that driver's version — the two move together). Without Nix you'll need Go 1.26+, Node, and pnpm installed yourself, and the browser tests will want a Chromium you supply.

pnpm install

Running the CLI #

go run ./cmd/disttown --help

The CLI talks to your real PDS, so anything beyond --help needs a login. disttown login <handle> opens a browser OAuth flow; add --app-password to be prompted for an app password instead. Note that --app-password prompts — it is not the automation path. Automation uses DISTTOWN_IDENTIFIER and DISTTOWN_APP_PASSWORD (with DISTTOWN_PDS), which need no terminal and write no files.

Running the appview and web UI #

The appview verifies records against publishers' PDSes itself — no sync middleman to run:

# terminal 1 — indexer and read plane on :8480
go run ./cmd/appview

# terminal 2 — web UI on :5173, proxying /xrpc to the appview
pnpm --filter @dist-town/web dev

The appview reads its configuration from the environment. The ones that matter locally:

Variable Default Purpose
APPVIEW_BIND :8480 Listen address
APPVIEW_DB appview.db SQLite index path (disposable — delete it freely)
APPVIEW_JETSTREAM_URL wss://jetstream.us-east.bsky.network/subscribe The signal stream (jetstream v2)
APPVIEW_TRACK — Comma-separated DIDs to index regardless of discovery

APPVIEW_TRACK is the one you'll want first: jetstream discovery only sees new events, so a repo that published before your local appview existed needs a manual on-ramp. The full set of variables, including the production edge and web ones, is in docs/deploy.md.

The component workshop #

pnpm --filter @dist-town/web storybook

Every component state — including the rare ones: yanked, retracted, unverified storage, empty projects — in both themes, on one page. Stories live beside their components as *.stories.svelte; the fixtures they render are factories in web/src/lib/fixtures/, typed over the generated view types, so a lexicon change fails there rather than producing a page the appview would never serve.

Codegen #

Anything under internal/gen, packages/client/src/generated, and web/src/routes/docs/cli/+page.md is generated and committed. After touching lexicons/ — or any CLI command definition — run:

pnpm gen         # regenerate in place
pnpm gen:check   # regenerate and fail if committed output drifted

Note that the CLI reference docs page is generated from the CLI's own command registry, so a flag ships documented or not at all. Don't edit that page by hand.

Checks #

Go tests, codegen drift, svelte-check, and the workshop's story suite run on every push and pull request as Tangled pipelines, defined in .tangled/workflows/. Each runs through nix develop, so CI and your machine use the same shell. Nothing in CI publishes: no pipeline holds a key that can write a record or cut a release.

The same checks, to run yourself:

go test ./...
pnpm --filter @dist-town/web test    # the story matrix, headless
pnpm --filter @dist-town/web check   # svelte-check
pnpm gen:check                       # committed codegen is in sync

pnpm --filter @dist-town/web test runs the workshop's stories as the test suite: 75 stories × light and dark = 150 renders, each of which plays its interactions and passes an a11y check, in headless Chromium. That Chromium comes from the Nix shell, so run it inside nix develop.

How changes get made #

This project uses OpenSpec. Non-trivial work starts as a proposal in openspec/changes/<name>/ — what's changing, the design decisions behind it, spec deltas, and a task list — and gets archived into openspec/specs/ once it's implemented and verified live.

If you're looking for the reasoning behind a design decision, that's where it is. openspec/changes/archive/ is a decent read: each entry records what was tried, what shipped, and occasionally what turned out to be wrong.

For small fixes, don't worry about any of this — just send the patch.

Contributing #

Issues and patches are welcome. You can also request features, make suggestions, and ask questions at dist.town's userinput.app

A few things worth knowing, with the rest in CONTRIBUTING.md:

  • No CLA, no copyright assignment, no sign-off trailer. You keep your copyright; your patch stays under the license of the files it touches.
  • Design decisions live in OpenSpec, not in code comments. If you're wondering why something is the way it is, search openspec/ first.
  • Verify user-visible web UI changes using the dev server before they ship. web/DESIGN.md records the visual system.
  • The web UI may only use the public read plane. If a page needs data, that data has to be fetchable by anyone.

Status #

dist.town is a young project, so lots of things are still moving. The lexicons are not yet published and subject to change. I'm reserving publishing them until they feel sufficiently pressure-tested.

It would be a huge help to me if you were to use dist.town and let me know what you like/dislike about the workflow. I'm dogfooding it to distribute its own CLI and some of my personal projects, but more hands will help me smooth the rough edges.

License #

dist.town is dual-licensed by component:

  • The service — cmd/appview, cmd/disttown-admin, internal/appview, internal/index, and web/ — is AGPL-3.0-or-later. Run a modified dist.town as a public service and you owe your users the source.
  • Everything you need to interoperate — the lexicons/, the TypeScript client, the disttown CLI, and their supporting packages — is Apache-2.0, with no copyleft obligation.

So: implement town.dist.* in anything you like, ship the CLI in your CI, build on the SDK in a closed-source product. A hosted fork of the service itself stays open.

Publishing to dist.town carries no obligation at all. These licenses cover this software, not your releases or your artifacts.

Full detail, and the dependency rule that keeps the split honest, is in LICENSING.md.