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
-
Cut the release as usual (
CHANGELOG.mdheading,scripts/publish.sh --tag vX.Y.Z …). The tag push starts thereleaseworkflow on the public repository. -
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 (
HEADby default). It downloads the archives, rebuilds that commit, runsviiwork-release compareon every archive, signsSHA256SUMSfor the version, checks the signature against that commit's compiled-in keys, and attachesSHA256SUMS.sig.bash scripts/release-sign_test.shexercises it end to end against a stand-ingh.The workflow refuses to re-upload archives once a release carries a signature, and only its
publishjob, which runs nothing butgh, holds write access. -
release-sign.shthen publishes the engine images, from the same local build it just compared:ghcr.io/janit/viiwork-llamacpp-cuda(amd64, arm64),viiwork-llamacpp-vulkanandviiwork-vllm, each tagged with the version. Each is the upstream engine image (pins indocker/pins.env) withviiworkappended as one layer, streamed registry-to-registry bycrane. Itsghtoken needs thewrite:packagesscope (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 dockerbuilds it.SKIP_IMAGES=1signs 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.Zrebuilds, 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 downloadsSHA256SUMS, its signature and this host's archive fromhttps://github.com/janit/viiwork/releases/download/vX.Y.Z/(the onlyupdate.sourcea 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, unpacksviiworkandviiwork-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.SHA256SUMSand its signature always come from GitHub when GitHub answers. The archive comes from the node's viiwork-parrot (viiwork_parrot.api,POST /ensure-releasewith 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(itsinstall.jsonnames 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.binaryis exactly<llama root>/<tag>/llama-<tag>/llama-serverruns 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 (previousinGET /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.jsonwith 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 pathinstall.jsonrecords as the install's binary (/usr/local/bin/viiwork) and nowhere else. - Mac install made by
viiwork init: the CLI~/.local/bin/viiworkis 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 inreleases/cli.jsonand 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 cliby 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 inreleases/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 -drestarts 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, setscurrentback 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/viiworkout 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'simage:line (the file as it was is kept asdocker-compose.yaml.bak) and runsdocker 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 intostate.jsoncannot 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.jsonrecords 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--versionalready 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 asviiwork.prev, and logs what it did. A release that fails to verify leaves the CLI and its.prevas they were. (Before v2.7.2 it wrote/usr/local/bin/viiworkunconditionally, compared its own version rather than the file's, and keptviiwork.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.