# The download site — https://3pm.edgecr4ft.net

Where 3pm is distributed from: the binaries, the SDKs, the documentation
and the landing page. Everything is static files under one nginx root; no
application runs there. This page is how to change it and how to ship the
change.

## What is online

```
/                        index.html      the landing page (distribution/site/index.html)
/install.sh                              installer and updater (distribution/install.sh)
/latest/VERSION                          the current version, one line
/latest/version.json                     that release: version, published_at, install, checksums, assets[]
/vX.Y.Z/                                 3pm-X.Y.Z-<target>.tar.gz ×4, checksums.txt, version.json,
                                         install.sh, release.txt, release.sig (signed release)
/sdk/sdk.json                            every SDK: latest version, files, all versions
/sdk/node|python|cpp|rust/               the SDK archives
/simple/                                 PEP 503 pip index (threepm-io)
/sdk/cargo/                              sparse Cargo registry (config.json + th/re/threepm-io)
/llms.txt                                documentation index for LLMs (llmstxt.org)
/llms-full.txt                           every document in one file
/docs/index.json                         the same index as JSON (bytes, sha256, backend routes)
/docs/**.md                              the documentation, as Markdown
/sitemap.xml, /robots.txt
```

## The three commands that publish it

Each one is a script under `distribution/scripts/`, run from the Mac, and
each one rsyncs to `ubuntu@edgecr4ft.net:/var/www/3pm`. They share the
overrides `THREEPM_PUBLISH_HOST`, `THREEPM_PUBLISH_DIR`,
`THREEPM_PUBLISH_URL`, and they all take `--dry-run` (build, upload
nothing; output under `target/publish*/site`).

| Command | Builds | Uploads |
|---|---|---|
| `just publish` | The five release tarballs (macOS arm64 via cargo here; Linux x86_64/arm64 via cargo-zigbuild on the build host, at glibc 2.28 and as static musl `-musl` builds) | `vX.Y.Z/`, then `install.sh`, `index.html`, `latest/` |
| `just publish-sdk` | The four SDK archives, each tested and installed into an empty project first | `sdk/`, `simple/`, `sdk/cargo/`, `sdk/sdk.json`, `index.html` |
| `just publish-docs` | The documentation (below) | `docs/`, `llms.txt`, `llms-full.txt`, `sitemap.xml`, `robots.txt`, `index.html` |

**The three of them upload `index.html`**, so editing the landing page
alone changes nothing online: run one of them (`just publish-docs` is the
cheapest) to ship it.

Versions: `just publish` bumps the minor version by itself when the
current one is already online, and `--force` republishes it. Each SDK
carries its own version and is skipped when that version is online: bump
it by hand in `packages/node/package.json`,
`packages/python/pyproject.toml`, `packages/cpp/CMakeLists.txt` or
`packages/rust/Cargo.toml` (its `Cargo.lock` too — `publish.sh` stamps
both). Nothing online is ever deleted, and the indexes are rebuilt from
the archives already on the server.

### The Cargo registry

Cargo cannot depend on a tarball URL, so the Rust SDK ships as a `.crate`
plus a **static sparse registry** under `/sdk/cargo/`: `config.json` names
where the `.crate` files are, and `th/re/threepm-io` (cargo's prefix
layout, the name lowercased) holds one JSON line per version with its
`deps`, `features` and `cksum`. Both are plain files nginx serves as they
are — the index file has no extension and goes out as
`application/octet-stream`, which cargo does not mind, and no nginx
change is needed. `write_cargo_index` rebuilds the whole index from every
`.crate` in the site, reading each archive's own manifest with `cargo
metadata`, exactly as `simple/` is rebuilt from every wheel. A
dependency's `registry` is written out as crates.io explicitly: left
null it would mean *this* registry, where nothing but `threepm-io` lives.

The consumer side, in `.cargo/config.toml` beside `Cargo.toml` or in
`$CARGO_HOME`:

```toml
[registries.threepm]
index = "sparse+https://3pm.edgecr4ft.net/sdk/cargo/"
```

```toml
[dependencies]
threepm-io = { version = "0.26.0", registry = "threepm" }
```

`publish-sdk.sh` proves that chain before anything is uploaded: it serves
the staged site on a random local port, points a scratch crate's registry
at `sparse+http://127.0.0.1:<port>/sdk/cargo/` and builds it. A registry
cargo cannot read fails the publish here rather than on a user's machine.

## Signed releases

Every release carries `vX.Y.Z/release.txt` (`version X.Y.Z`, then
`<sha256>  <file>` for each tarball and for `vX.Y.Z/install.sh`, a copy of
the installer of that release) and `vX.Y.Z/release.sig`, the ed25519
signature of those bytes in hex. The `autoupdate` module verifies both
against `RELEASE_KEYS` (`modules/autoupdate/src/manifest.rs`) before it
runs anything; a fresh `curl | sh` does not (first contact).

- **Where it signs:** on the Mac, in `publish.sh` (`sign_release`, through
  `cargo run --example release-key` of `modules/autoupdate`). The key
  never leaves the Mac: not the build host, not the site.
- **The keys:** `just release-keygen` writes `~/.3pm-release/{main,rescue}.{key,pub}`
  (directory 0700, private keys 0600, never overwritten) and prints the
  public halves. `THREEPM_RELEASE_KEY` points `publish.sh` elsewhere. The
  directory is outside `3pm-rd/` on purpose: Mutagen syncs that tree to
  nirmata. `rescue.key` belongs on offline media, not on the Mac.
- **The checks:** `publish.sh` refuses to start without the key (a dry
  run only warns), the signer refuses a key that is not in
  `RELEASE_KEYS`, and the verify step compares the uploaded `release.sig`
  with the local one.
- **Rotation:** add the new public key to `RELEASE_KEYS`, publish that
  release signed by a key already trusted, drop the old key in a later
  release. A node that never updates keeps its old list: there is no
  revocation for it. Lost main key: sign with `rescue.key` (`THREEPM_RELEASE_KEY`)
  a release that carries a new main key.
- **Modules from the site:** a module installed by `3pm install <name>`
  (not bundled) comes from the site's `modules/<name>/…/version.json`,
  which is **not signed**. The autoupdate path never refreshes it:
  `install.sh`, run with `THREEPM_ARCHIVE`, calls `3pm update
  --bundled-modules-only`, so only the modules of the signed archive are
  installed. A `3pm update` typed by hand still takes the site's newer
  release, unsigned (open: sign the module index too).

## The build host

`just publish` compiles three targets for three workspaces — nine cargo
jobs. The six Linux ones run on a build host over ssh; only
`aarch64-apple-darwin` stays on the Mac, because a signed arm64 Mach-O
needs the Apple toolchain. The Mac remains the orchestrator: it bumps the
versions, stages the tarballs, writes the checksums, uploads and verifies.

| Override | Default | Meaning |
|---|---|---|
| `THREEPM_BUILD_HOST` | `unitech@10.0.0.95` (nirmata) | Where the Linux targets compile. **Set it to the empty string to build everything on the Mac** — the way out when the host is down. |
| `THREEPM_BUILD_DIR` | `3pm-rd-publish` | Its directory in the account's `$HOME`. Deliberately not `~/3pm-rd-build`, which `../deploy/test-on-linux.sh` uses: release and test state stay apart. |

`../deploy/deploy-backend.sh` reads `THREEPM_BUILD_HOST` too and builds the
fleet backend there (one target, `x86_64-unknown-linux-gnu.2.28`), in a
directory of its own, `~/3pm-rd-backend-release`: 3pm-rd's `just publish` runs
it at the same time as this script, and two `rsync --delete` into one tree
remove each other's temporary files. It syncs **both** repositories, since
`3pm-backend` path-depends on `../3pm/crates`, and the binary comes back here
to be deployed: the build host has no key for the publish host, and needs none.
The build writes the binary's sha256 next to it; the deploy refuses a binary
that no longer matches, and checks that the restarted process runs that sha.
(Measured 2026-10-09, warm: 1 min 47 on nirmata alongside this script's ten
jobs, 3 min 30 natively on edgecr4ft, whose 2017 EPYC is slow on the backend's
single-codegen-unit crate.)

### Steps, for 3pm-rd's `just publish`

Each script splits into steps, so that 3pm-rd's `just publish` (in
`../justfile`) can run the four builds at once on three machines and upload
only once all four are green:

| Script | Steps |
|---|---|
| `publish.sh` | `--step=version` (bump + stamp the tree), `--step=build` (build, package, sign the version in `Cargo.toml`), `--step=upload` (upload + verify the staging; refuses a version already online without `--force`) |
| `publish-sdk.sh` | `--step=build` (test, package, indexes; what ships is kept in `target/publish-sdk/shipped`), `--step=upload` |
| `publish-automonit.sh` | `--step=build` (3pm need not be online yet), `--step=upload` (requires 3pm vX.Y.Z online) |
| `../deploy/deploy-backend.sh` | `--step=build`, `--step=deploy` |

Without `--step` each script runs its steps in a row, as before (`just
publish` here, `just publish-sdk`, `just publish-automonit`). `--dry-run`
and `--step` do not mix. Before any of it, 3pm-rd's `just publish` runs
`../deploy/quick-tests.sh` (3pm without its e2e binaries, the backend
without `real_agent_*`, both at once on edgecr4ft, about 30 s warm).

Provision a host with `./distribution/scripts/setup-build-host.sh
[user@host]`. It is idempotent and needs no sudo — everything lands in the
account's `$HOME`. It pins rust, zig and cargo-zigbuild to the Mac's
versions (the three constants at the top of the script; bump them together
with `brew list --versions cargo-zigbuild zig`), then proves the chain by
building a hello world and reading back the highest glibc symbol it
requires, which must be 2.28.

Sources reach the host by one `rsync` — after the version bumps, since the
binaries embed the version, and before any job starts. The binaries come
back to the same paths a local build would have written, so staging,
tarballs and checksums are unchanged. A Ctrl-C stops the remote cargo
processes too: they all carry `$THREEPM_BUILD_DIR` in their command line,
which is what `stop_jobs` matches on.

The three publish scripts, `../deploy/deploy-backend.sh` and
`../deploy/quick-tests.sh` share **one**
multiplexed ssh connection per host: a fixed `ControlPath`
(`/tmp/3pm-ssh-$UID/%h`, short because macOS caps a socket path at 104
bytes), so whichever runs first opens the master and the others reuse it,
and `ControlPersist` reaps it rather than any script tearing it down.
Without this, each `ssh` and each `rsync` opens its own handshake; `just
publish` runs them side by side, and a burst of handshakes makes
sshd answer `Exceeded MaxStartups` and drop a leg mid-upload. Each upload
leg is retried three times, and the reachability probe too — one refused
connection is not an unreachable host, and it reports what ssh said.

## Changing the landing page

`distribution/site/index.html` is the whole page: plain HTML, CSS and JS
in one file, no dependency, no build step. Open it in a browser to see the
change (the version badge, the download list and the SDK block stay empty
locally — they are fetched from `latest/version.json` and `sdk/sdk.json`),
then publish it.

For a phone check, headless Chrome enforces a ~500px minimum width:
screenshot the page inside a 380px iframe instead.

## The documentation: `just publish-docs`

`distribution/scripts/publish-docs.sh` builds `target/publish-docs/site`,
then uploads it. `distribution/scripts/docs-site.py` assembles it, and its
`MANIFEST` is the one list of documents: section, path on the site, source,
title, and the line an LLM reads to decide whether to open the document. A
source that no longer exists fails the build.

Three kinds of document:

1. **Written for the site**, under `distribution/site/docs/`:
   `concepts.md` (the model: daemon, apps and instances, statuses, metrics
   layers, SDKs, the fleet vocabulary) and `environment.md` (every
   environment variable, grouped by who reads it).
2. **Generated**, so they cannot go stale:
   - `docs/cli.md` walks `3pm <command> --help` down every subcommand,
     modules included, in a throwaway `THREEPM_HOME` under `/tmp`.
   - `docs/backend/api.md` and `docs/backend/routes/<name>.md` are what a
     freshly built `3pm-backend` answers on `GET /api/v1/routes` (and
     `?route=<name>`): the script builds it from `../3pm-backend`
     (`THREEPM_BACKEND_SRC`), runs it on a random port with a throwaway
     database, asks, and stops it.
   - `docs/backend/cli.md` is `3pm-backend --help`.
3. **Copied from the repositories**: `README.md`, `FEATURES.md`, the
   `docs/` pages, the four SDK READMEs, and the backend's `README.md`,
   `FEATURES.md` and `CHANGELOG.md`.

`llms.txt` lists every document with its description and every backend
route with its method, path and auth; `llms-full.txt` is all of them
concatenated (~800 KiB); `docs/index.json` is the same index with sizes
and sha256.

Adding a document is one line in `MANIFEST`. After any change to the docs,
the CLI or the backend's route table, rerun `just publish-docs`.

## The server

`ubuntu@edgecr4ft.net` (Scaleway, Ubuntu 20.04, nginx 1.18, certbot),
also the host of `fleet.edgecr4ft.net` (the production `3pm-backend`) and
of the apps under `~/3pm-apps/`. DNS is on Route 53, with a wildcard
`*.edgecr4ft.net`.

- Web root `/var/www/3pm`, `autoindex on`, `charset utf-8`.
- Vhost `/etc/nginx/conf.d/3pm.edgecr4ft.net.conf` — the vhosts live in
  `conf.d`, which `nginx.conf` includes before its own server blocks;
  `sites-enabled` is not loaded.
- Cache headers: `no-cache` on `install.sh` and `latest/`, one hour on
  `v*` (a version's files never change without `--force`).
- TLS by certbot (webroot challenge under the same root).

`sudo` needs a password there, so nginx changes are run by hand, not by
the publish scripts.

**Pending**: `.md` is not in nginx's `mime.types`, so `/docs/*.md` is
served as `application/octet-stream` (a browser downloads it instead of
showing it). The fix, in the 443 server block of the vhost:

```nginx
    location /docs/ {
        types { text/markdown md; application/json json; }
        charset_types text/markdown application/json;
        add_header Cache-Control "no-cache";
    }
    location ~ ^/llms(-full)?\.txt$ { add_header Cache-Control "no-cache"; }
```

then `sudo nginx -t && sudo systemctl reload nginx`. `publish-docs.sh`
checks the content type after every upload and prints this block while it
is still wrong.

## Not published here

`distribution/setup-apt.sh` and `setup-yum.sh` still point at the
`releases.3pm.dev` placeholder: there is no apt or yum repository. The
`README.md` install section names them and the GitHub releases page, which
do not exist yet — and it is published as `docs/readme.md` on the site.
