Every v* tag on the public repository gets release archives for linux/amd64, linux/arm64 and darwin/arm64:

viiwork_vX.Y.Z_linux_amd64.tar.gz     viiwork, viiwork-accept, LICENSE
viiwork_vX.Y.Z_linux_arm64.tar.gz
viiwork_vX.Y.Z_darwin_arm64.tar.gz
SHA256SUMS                            sha256 of each archive
SHA256SUMS.sig                        ed25519 signature over the version and SHA256SUMS

CI builds the archives and uploads them with an unsigned SHA256SUMS. The publisher then rebuilds the same version from their own checkout, checks that every file matches CI's byte for byte, and only then signs. Nothing downloaded is ever run on the machine that holds the key, so neither a compromised workflow nor anyone who can move the public tag can get other code signed. The signature covers the version, and only a SHA256SUMS listing exactly that version's three archives is signed, so a signature can never be replayed for another release. CI never holds the key, so a compromised workflow can publish archives but cannot get them signed. A release without SHA256SUMS.sig is not installable by viiwork update: a node refuses to stage it.

Builds are reproducible

Every binary is built by scripts/gobuild.sh — make, CI and the local rebuild alike — with CGO_ENABLED=0, -trimpath, -buildvcs=false and only -X main.version in -ldflags. scripts/build-release.sh pins the compiler to go.mod's exact version (GOTOOLCHAIN) and writes deterministic archives. bash scripts/build-release_test.sh builds the committed tree twice from two directories and checks that every byte agrees.

Publishing a release

  1. Cut the release as usual (CHANGELOG.md heading, scripts/publish.sh --tag vX.Y.Z …). The tag push starts the release workflow on the public repository.

  2. When the workflow has finished, on the publisher's machine:

    scripts/release-sign.sh vX.Y.Z [COMMIT]
    

    Run it in the private repository at the commit that was published (HEAD by default). It downloads the archives, rebuilds that commit, runs viiwork-release compare on every archive, signs SHA256SUMS for the version, checks the signature against that commit's compiled-in keys, and attaches SHA256SUMS.sig. bash scripts/release-sign_test.sh exercises it end to end against a stand-in gh.

    The workflow refuses to re-upload archives once a release carries a signature, and only its publish job, which runs nothing but gh, holds write access.

  3. release-sign.sh then publishes the engine images, from the same local build it just compared: ghcr.io/janit/viiwork-llamacpp-cuda (amd64, arm64), viiwork-llamacpp-vulkan and viiwork-vllm, each tagged with the version. Each is the upstream engine image (pins in docker/pins.env) with viiwork appended as one layer, streamed registry-to-registry by crane. Its gh token needs the write:packages scope (gh auth refresh -s write:packages). A new package on ghcr is private until made public once in its settings. gfx906 (Radeon VII) has no published image: make docker builds it. SKIP_IMAGES=1 signs without publishing. Every image records its base by digest (org.opencontainers.image.base.digest), and a released tag is written once. If a push fails after the signature is attached, IMAGES_ONLY=1 scripts/release-sign.sh vX.Y.Z rebuilds, compares, verifies the existing signature and publishes only the images. The registry login lives in the run's temporary directory and goes with it.

    The base images keep their own HEALTHCHECK (llama.cpp's probes :8080); a compose file sets its own.

viiwork-parrot follows the releases feed and seeds each release as a torrent once its signature is attached.

The signing key

go run ./cmd/viiwork-release keygen makes the pair once, on the publisher's machine: the private key at ~/.config/viiwork/release.key (mode 0600; sign refuses a key others can read) and the public key at internal/release/keys/release.pub, which is committed and compiled into every build. keygen refuses to overwrite an existing key.

Back the private key up offline. Losing it, or suspecting it has leaked, means a rotation: make a new pair under a new file name (--pub internal/release/keys/<year>.pub), ship a release carrying both public keys, sign later releases with the new key, and remove the old public key once no node runs a version that lacks the new one.

Checking a download by hand

The checksum proves the archive is the one listed in SHA256SUMS. The signature proves SHA256SUMS is the publisher's, for exactly that version. Checking the signature needs a checkout and Go 1.27.2:

curl -fLO "$base/SHA256SUMS.sig"                  # beside the archive and SHA256SUMS
sha256sum --check --ignore-missing SHA256SUMS      # on a Mac: shasum -a 256 --check --ignore-missing SHA256SUMS
git clone --branch vX.Y.Z https://github.com/janit/viiwork viiwork-src
(cd viiwork-src && go run ./cmd/viiwork-release verify --version vX.Y.Z ../SHA256SUMS ../SHA256SUMS.sig)
# prints "signature ok"

How a node updates itself

A node takes part only with update.enabled: true. Everything lives under <state_dir>/releases/: state.json (current, last_good, pending, the recorded launcher), previous (the release that was last good before last_good) and one directory per staged version. previous is a file of its own because a launcher from v2.6.0-beta4 refuses any field in state.json it does not know.

  • Stage (POST /v1/update/stage {"version": "vX.Y.Z"}): the node downloads SHA256SUMS, its signature and this host's archive from https://github.com/janit/viiwork/releases/download/vX.Y.Z/ (the only update.source a node accepts; a redirect is followed only to GitHub's asset hosts), verifies the signature for that version against the compiled-in keys, checks the archive, unpacks viiwork and viiwork-accept, runs the new binary with --version (it must run here and report that version) and --engine-requirements (this host's engines must be new enough). Anything that fails leaves nothing behind.

    SHA256SUMS and its signature always come from GitHub when GitHub answers. The archive comes from the node's viiwork-parrot (viiwork_parrot.api, POST /ensure-release with the other mesh members as peer hints), checked against GitHub's sums; when parrot is absent, refuses, shows no progress for 2 minutes, has not finished after 5, or hands over an archive that does not match, the node logs why and downloads the archive from GitHub (a parrot answering 503, which has no feed yet, is waited out within those limits). The host's engine helper on a Docker install stages through the same parrot. Only while GitHub is unreachable — no connection, a 30 s timeout, a connection dropped mid-answer, 5xx, 429 — does the node take parrot's copy of the signed files, verified against the same keys. A definite answer from GitHub (404, 403, a bad signature) is final, and so is any other answer: a redirect off GitHub, or signed files over their size cap.

    On a Mac set up by viiwork init (its install.json names a llama root), staging first reads the new binary's --build-info. When it carries a llama.cpp pin with its macOS digest, the node fetches that build into the llama root from the release's fixed URL, with no GitHub API call, and requires the digest to match; the engine requirement is then checked against that build, which is the one the release will run.

  • The Mac's engine follows the binary. A model whose llamacpp.binary is exactly <llama root>/<tag>/llama-<tag>/llama-server runs the build of the pin the running viiwork carries, when that build is present, and the configured one otherwise. Activating a release therefore moves the engine with it, and a rollback moves it back, with nothing recorded. A binary anywhere else is used as written. When a release confirms, builds under the llama root that no kept release runs (current, last good, previous, the floor, this process, and any tag the config names) are removed; if any of those cannot be read, nothing is.

  • Activate (POST /v1/update/activate): refused while any model is still loading. Otherwise it records the backends healthy right now as the baseline, marks the version pending, answers 202, shuts down in the usual order and execs the launcher, which runs the staged release in the same process.

  • Confirm: the new release becomes last good once every baseline backend is healthy again. A baseline backend going dead, or the window passing (update.confirm_timeout, else the sum of the models' startup timeouts plus 10 minutes, fetching time excluded), rolls it back. A release that crashes before it can confirm is started twice; the third start runs last good. Every start counts, a routine restart during the window included.

  • Rollback (POST /v1/update/rollback): back to last good at once. On a host already on its last good release, back to the release that was last good before it (previous in GET /v1/update), which becomes last good again; refused when there is none, or its binary no longer verifies. It is remembered for that one last good release only, so after a newer image resets the node to its builtin binary there is nothing to go back to.

  • A newer image or binary installed out of band always wins over an older staged release. A floor that is the pending release (the same version, exactly) runs it and keeps it pending, so it is still confirmed, not forgotten. A floor the node installed itself by following a confirmed release (a Mac, below) is not out of band: it is recorded in releases/cli.json with its sha256, and while the launcher is exactly that file the state still decides, so a rollback below it runs.

Writes need a meshauth signature in a secured mesh and come only from loopback in an open one, exactly like alias writes. GET /v1/update reports the state, the staged versions and the installed engine versions.

The host's CLI follows the release

viiwork update moves the node; the viiwork on the host's PATH is a separate file, and an old one lacks newer commands and asks for older confirmation phrases. Since v2.7.2 it follows the release, only ever forward (a CLI at or past the release, or a developer's build, is left alone), and always by one rename in its own directory, with the CLI it replaced kept as <path>.prev (overwritten each time):

  • Docker install made by viiwork init: the engine helper does it (below), as root, from its own verification, to the path install.json records as the install's binary (/usr/local/bin/viiwork) and nowhere else.
  • Mac install made by viiwork init: the CLI ~/.local/bin/viiwork is the LaunchAgent's binary, the launcher that hands over to a staged release. The node runs as the user who owns it, so when a release confirms, the node installs that staged release there itself (the binary it was handed over to, re-checked against the sha256 recorded when it was staged), records it in releases/cli.json and moves the launcher's recorded sha256 with it. Without that record the next start would treat the new launcher as an out-of-band upgrade and forget the staged releases, rollback included.
  • Anything else (a hand-built node, a Docker host not made by viiwork init, an install whose manifest records no binary): viiwork update cli by hand, below.

viiwork update cli (with sudo where the CLI is root's) asks the node on loopback which release it runs (GET /v1/update, running), takes that release's viiwork from <state_dir>/releases/<version>/, and verifies it as staging does: the signed SHA256SUMS from GitHub, the archive from GitHub or the host's viiwork-parrot, staged afresh beside the CLI with this binary's keys, and the node's copy must be byte for byte that release's viiwork. It then replaces the running CLI (links resolved), or --to PATH, keeping <path>.prev. --dry-run says what it would do; --state-dir names the state directory when the config (--config, else this machine's) does not. It refuses when it cannot write the CLI's directory, when the release is not in the state directory, or when anything fails to verify. On a host whose CLI is also the node's own binary (a hand-built systemd node started from it), the next start treats the new file like any newer binary installed out of band.

A CLI must itself be v2.7.2 or newer to have the command. To bring an older one up once, run a v2.7.2-or-newer viiwork from a release archive checked by hand (above) as sudo ./viiwork update cli --to <the old CLI> — never the copy in the state directory, which a container can write and which would run as root. A Mac needs nothing: the node installs its CLI when v2.7.2 itself confirms. A Docker install made by viiwork init with the engine helper needs nothing either.

On a Docker install: the image follows the release

A container cannot replace its own image, so on a Docker install made by viiwork init a small helper on the host does it: viiwork engine-sync, run as root from /usr/local/bin/viiwork by viiwork-engine.path (when the node's state.json or image request changes) and viiwork-engine.timer (every five minutes). Its one rule: the compose file's image tag is the node's current release, or for builtin the tag the install had when the helper first ran, which it records in its own root-only directory, /var/lib/viiwork-engine/sync.json. There the release is the image, engine included:

  • Stage also asks the helper, through releases/engine-request.json (a version and a request id, nothing else), to verify and pull the release's image. The helper answers in releases/engine-result.json, with the engine version the image carries, which the release's engine requirement is checked against. A bad image fails the stage, before any host restarts, and activation never waits on a pull.
  • Activate is refused (409) until the helper has prepared that exact version's image: stage it first. Activate and rollback then save the state and answer 202, and never exec a staged binary: the helper swaps the image and docker compose up -d restarts the node through its ordinary shutdown. The new container counts its starts of the pending release and the confirmer decides it as usual; a rollback, the confirmer's included, sets current back and the helper swaps the previous image in (still local, so nothing is pulled). If the node is still running two minutes after an activate or rollback — no helper, or a broken one — it restores the state it had and logs why.
  • Swap. The helper verifies the release itself, with the keys compiled into the host binary and the same staging code a node uses, into /var/lib/viiwork-engine/releases/; pulls <repo>:<version>; reads the image's /usr/local/bin/viiwork out of a container it creates and never starts; and swaps only when that file is byte for byte the signed archive's. It then rewrites the compose file's image: line (the file as it was is kept as docker-compose.yaml.bak) and runs docker compose up -d. The engine layers under viiwork are trusted as at install time: by registry and tag.
  • Only forward, or back to what ran here. The helper moves to a version older than the running tag only when it is the install's own tag or one of the last eight it swapped to itself (kept in sync.json); anything else is refused and logged, pre-pulls included. So a node that writes an older release into state.json cannot skip /v1/update's consent for a downgrade; a rollback goes back to a release this host has run.
  • Disk. An image already on the host is used, not pulled again. The helper removes every image it pulled itself once nothing needs it, keeping the running tag, the install's own, the last two it ran and the one prepared for the latest stage; an image it did not pull is never removed.
  • Itself, and the host's CLI. Once the node has confirmed the release its image carries, the helper installs its own verified copy of that release's binary at the path install.json records as the install's binary (/usr/local/bin/viiwork, which is also the helper), so the host's CLI and the helper's own rules move with the release. It writes nowhere else, and nothing when the manifest records no binary. It skips a CLI whose --version already reports the release or a newer one, never takes the node's staged copy (the container can write it), replaces the file by rename and keeps the old one as viiwork.prev, and logs what it did. A release that fails to verify leaves the CLI and its .prev as they were. (Before v2.7.2 it wrote /usr/local/bin/viiwork unconditionally, compared its own version rather than the file's, and kept viiwork.bak.)

What it trusts. The helper runs as root, and the node's releases directory is the container's to write. From it the helper takes state.json's version names and an image request's version and id, each validated as exactly that, and nothing else: never a path, a repository or a URL. It reads them through an os.Root on the state directory, refuses a link anywhere there, reads only small regular files without blocking, and replaces its result file by rename, so a planted link can make it neither read nor write elsewhere. The repository comes from the host's compose file (and must be one of the published ghcr.io/janit/viiwork-* images), the download root is GitHub's (the only update.source a config may name), and where things are from the host's install.json. A compromised node can still ask for any signed release, newer than the running one, or one this host has already run.

A node knows it is on such an install from the engine_helper entry in the install.json its container sees in /etc/viiwork. Anything else — a hand-built node, or an install from before v2.6.1 until sudo viiwork init adds the helper — updates its viiwork binary only, as before: the engine stays as installed, and staging refuses a release whose engine requirement the host does not meet. journalctl -u viiwork-engine.service shows what the helper did and why.

Rolling a release across the mesh

viiwork update status                        # every member: running, current, last good, previous, pending, engines
viiwork update                               # the latest signed release, everywhere it is enabled
viiwork update --to v2.6.0 --hosts node-a,node-b   # one version, some hosts
viiwork update --parallel 3                  # activate up to three hosts at a time
viiwork update rollback --host node-b              # one host back a release

After a host confirms, the rollout also waits until the node it talks to sees that host alive on the new release, so a release that breaks mesh membership stops the rollout at the first host.

A rollout lists its plan — every member with what will happen to it, or why it is skipped (not alive, updates off, already there, newer, older than rolling updates) — and asks for YES I WANT TO UPDATE THE NODES ABOVE (or --confirm). It then stages the release on every chosen host — all at once, at most eight together, since staging changes nothing a host runs — so a download, signature or engine problem stops everything before any host restarts, and activates them one at a time, the node it talks to last. Each host must come back on the new release and confirm it before the next starts; a host that rolls itself back, or misses its own confirmation deadline, stops the rollout with the rest untouched.

--parallel N activates in waves of up to N hosts instead, and waits for every host of a wave before the next. The node the CLI talks to is still last and alone. No wave holds every alive host that lists a model in its status, counting hosts outside the rollout, so a model served by two or more machines never goes offline; a model on one machine is offline while it restarts, as it is one at a time. The plan shows the waves before the phrase. If any host of a wave fails, the CLI still follows the rest of that wave, reports where each host ended up, and starts no later wave. In a secured mesh, run it where the mesh secret is loaded. On a Linux node installed by viiwork init, mesh.env is readable by root only: sudo sh -c 'set -a; . /etc/viiwork/mesh.env; /usr/local/bin/viiwork update'. On a Mac installed by viiwork init, the CLI reads the secret from the node's LaunchAgent. Without a secret the CLI says so before asking for anything. An open mesh accepts update writes only from each node's own machine, so there a rollout updates only the node the CLI talks to — run it on that machine against 127.0.0.1 — and lists the others as skipped. A version below a host's installed binary is skipped too: the node's newer-binary rule would undo it at the next start. Names in --hosts that are not members are refused.