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:
- 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!
- dist.town stores no files (for now?). Any uploaded files live in a location that you control.
- 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-cmdtemplate, and the CLI uploads.blob— bytes on your own PDS. A manifest declaring the reserved storage nameblobstakes 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.mdrecords 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, andweb/— 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, thedisttownCLI, 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.