Repository navigation
Release and deployment
Labello publishes stable x86-64 Linux releases through GitHub Actions and deploys them from a runner inside the production Debian 12 LXC. Release builds and production deployment use different runners. The deployment runner does not build code, accept SSH submissions, install packages, or run the deploy job inside a container.
The release workflow verifies the exact main commit, builds the server and
browser payloads in a digest-pinned Debian Bookworm container, attests every release asset,
publishes an immutable stable release, then sends an explicit
repository_dispatch event. Drafts, prereleases, mutable releases, and tags
that do not resolve to the dispatched commit cannot enter deployment.
The release container runs as UID/GID 1000:1000, matching the shared build
runner account. Before checkout, it checks that identity and write access to
HOME, RUNNER_TEMP, GITHUB_WORKSPACE, CARGO_HOME, and RUSTUP_HOME.
GitHub Actions mounts its temporary home at /github/home; Git configuration
and Trunk caches there must remain writable and removable by the runner.
The job sets CARGO_HOME=/github/home/.cargo and creates it as the runner user,
avoiding root-owned registry entries baked into the image's default Cargo home.
The image's Cargo/Rustup executables remain available through PATH, and
RUSTUP_HOME retains the preinstalled pinned toolchain.
Native build outputs, browser assets, and release staging files likewise use
the runner's ownership. Archive entries intentionally retain numeric owner and
group zero for reproducible packaging; this does not change host file ownership.
Before rolling out this workflow on a previously used runner, arrange an idle
maintenance interval and inspect its temporary directory for root-owned entries
left by earlier releases. Repair only the approved temporary paths, preserving
persistent Cargo and target caches. Recheck for active jobs before repair and
verify that the runner account can remove the temporary files afterward. The
workflow does not repair existing ownership or change host permissions.
Inspect existing Labello checkouts too: root-owned .git, target/, or browser
dist/ directories require a separately scoped repair before the unprivileged
job can reuse that checkout.
The deployment workflow runs on the production guest as the hulk account.
It downloads a pinned GitHub CLI into RUNNER_TEMP, verifies the CLI checksum,
checks release immutability and provenance, then streams the verified
deployment bundle to the local Rust transaction manager:
gzip --decompress --stdout "$RUNNER_TEMP/download/labello-deployment-$VERSION.tar.gz" |
/var/lib/labello/bin/labello-deploy receive "$REQUEST_ID"labello-deploy owns every application, configuration, backup, data-restore,
and admission mutation. The workflow polls only its bounded JSON status. A
systemd user service runs the transaction, so runner loss does not terminate
it.
Routine deployment is rootless. The hulk account owns /var/lib/labello
and controls only its systemd user services. It must not retain sudo or Docker
access after guest provisioning. Root performs the one-time setup needed to
create the deployment tree, enable user lingering, install host packages, and
allow unprivileged binding to ports 80 and 443.
This simple account model has a sharp trust boundary: the deployment runner
can read the production configuration and data because it is also the service
account. A restricted organization runner group should therefore allow only
HULKs/labello/.github/workflows/deploy.yml@refs/heads/main. A
repository-scoped runner cannot join such a group. Until it is registered at
organization scope, its private label, the protected production environment,
the repository_dispatch trigger, and the main ref check provide weaker
defense. Never allow pull-request CI to select this runner.
Enable immutable releases before the first stable publication. GitHub applies that setting only to future releases. The release workflow publishes a draft with all assets and then makes it stable; it refuses to dispatch deployment unless GitHub reports the result as immutable.
Create these repository environments:
-
releaseprotects stable publication. The release job uses Linux x86-64 runners from the HULKs organizationDefaultgroup. -
productionprotects the deploy job and should require an operator approval until the deployment has completed its rollout period.
Set these repository variables without recording their values in tracked files or issue text:
| Variable | Contract |
|---|---|
LABELLO_RELEASE_BUILD_IMAGE |
Verified Rust 1.98.0 Bookworm build image pinned with @sha256:<64 hexadecimal digits>
|
LABELLO_DEPLOY_RUNNER |
Private label for the runner inside the production guest |
Runner names, custom labels and groups, hostnames, addresses, and private
registry locations are operational data. Do not put their values in Git,
release assets, workflow output, issue comments, or screenshots. Default is
GitHub's standard organization runner group, not private infrastructure data.
Build deployment/release/Containerfile from the repository root. It pins the
Rust 1.98.0 Bookworm image by digest and includes rustfmt, Clippy, the
wasm32-unknown-unknown target and Trunk 0.21.14. Keep its compiler tag aligned
with rust-toolchain.toml when updating the baseline. The retained
deployment/guest/github-runner-base/Containerfile is historical upstream
reproduction input and is not a Labello build entry point.
Before changing LABELLO_RELEASE_BUILD_IMAGE, build and publish the replacement
image using the authorized registry workflow, then test the exact published
digest in an isolated checkout. Run rustc --version,
./scripts/verify.sh all, and the locked release server/deployment-tool build
inside that image. Verify trunk --version reports 0.21.14. Record the tested
source commit, image digest, compiler and tool versions, commands, and results
in the private rollout record. Keep registry locations and runner details out
of public evidence. An unavailable or older image is an unmet rollout check.
Run these checks with --user 1000:1000, a runner-owned checkout and temporary
home, and the same environment as the release job, including its CARGO_HOME.
Verify dependency downloads into the fresh Cargo home and selection of the
image's pinned Rustup toolchain. Exercise the versioned browser build and release
packaging as well, then check that all generated files have UID/GID 1000:1000
and that the runner account can remove the temporary home and release staging
tree. Use disposable validation data without publishing assets or dispatching
deployment. A root-only build does not satisfy the image verification contract.
The release workflow validates a complete SHA-256 image reference and runs canonical verification before payload compilation. The image rollout checks above establish its compiler and tool prerequisites. Changing the source Containerfile alone does not update the external image or the repository variable; both publication and verified configuration are required before rollout. Future baseline changes follow the coordinated compatibility contract.
The manual release input must be canonical stable SemVer, such as v1.2.3.
The release job refuses leading zeroes, suffixes, an existing tag, an existing
release, a dirty checkout, or a commit other than the fetched main head.
Each release contains:
-
labello-server-x86_64-linux-<version>.tar.gz; -
labello-browser-<version>.tar.gz; -
labello-deployment-<version>.tar.gz, containing the exact tree accepted bylabello-deploy; -
release-metadata-<version>.json, inventorying the payload names, hashes, tag, and source commit; and -
SHA256SUMS, inventorying every other asset and excluding itself.
The browser payload contains MANIFEST.sha256, which inventories every other
browser file and excludes itself. It also contains release.json with the
release tag and source commit. The deployment bundle contains a separate
release-manifest.json for every extracted server and browser file. The Rust
manager rejects missing, additional, duplicate, non-regular, non-UTF-8, unsafe,
or hash-mismatched files and confirms that the browser inventory names the
same release.
Trunk release builds select the wasm-release Cargo profile with size
optimization, whole-program LTO and one codegen unit, then run Binaryen 123's
WASM size optimizer. Image decoding, pixel conversion, font shaping and
rasterization dependencies use speed optimization within that profile.
Development browser builds and native release profiles
retain their usual settings.
The browser's Cargo configuration enables 128-bit WebAssembly SIMD for both
development and release builds. Native targets retain their usual target
features. Browser engines must support WebAssembly SIMD to load the client.
Before creating browser and deployment inventories, the release job runs
labello-deploy compress-browser <browser-root> to generate deterministic
Brotli and gzip sidecars for WASM and JavaScript, including nested snippets.
Both original assets and sidecars enter the inventories and attestations.
Runtime configuration is excluded from compression.
The live Caddy configuration prefers these sidecars, with dynamic zstd/gzip
compression as a fallback. Its explicit response matcher includes
application/wasm on older Caddy versions too. Hashed WASM and JavaScript
receive a one-year immutable cache lifetime; the entry page, release metadata
and JavaScript snippets require revalidation, and runtime configuration stays
no-store. Existing guests need the updated live Caddyfile installed and
reloaded as well as the new browser release to enable this delivery behavior.
The release workflow generates GitHub build-provenance attestations for every
asset. Deployment requires the HULKs/labello repository, the release workflow
path, refs/heads/main, and the exact dispatched source digest. The deploy job
does not read the checksum or metadata files until their attestations have
passed. The read-only Rust verify-release command then rejects unexpected or
duplicate checksum paths and requires the metadata payload names and hashes to
match that checksum inventory exactly.
The stable root is mount-friendly:
/var/lib/labello/
bin/labello-deploy
data/ complete datasetsRoot; may become a mount
config/ operator-edited source configuration
configurations/
<release>/ immutable configuration generation
current -> <release>
releases/
<release>/
server/labello-server
browser/
current -> <release>
backups/<request>/
requests/<request>/journal.json
state/deploy.lock
caddy/
live/Caddyfile
maintenance/Caddyfile
current -> live|maintenance
data/ includes .labello-server/auth.json, private import state, every
dataset, images, events, caches, keybindings, and committed provenance. A
later mount must use this exact location and preserve the hulk UID/GID,
ordinary permissions, atomic rename, file and directory sync, and available
space required by import.
Operators edit only config/. Each transaction copies and verifies it as
configurations/<release> before candidate data access. The release and
configuration links always move together, so executable rollback uses the
matching configuration. Published release and configuration generations are
never edited in place.
The complete Debian 12 LXC source, pinned HULKs runner resources, package list,
Labello release-image layer, rootless hardening, mount preparation, and
user-service installation procedure are in
../deployment/guest/README.md.
receive validates a bounded request ID and a bounded POSIX tar stream, writes
the candidate below a new request directory, verifies the complete candidate
inventory, flushes the initial journal, and starts
labello-deploy@<request>.service. It never replaces an existing request.
The worker takes an exclusive host lock and advances one flushed journal:
- Switch Caddy to maintenance and reload it.
- Stop Labello through its graceful systemd user-service shutdown and wait for the process to exit.
- Inventory the stopped source, copy the complete data root, including dot-directories and empty directories, to a request backup, then require the source and copy to match the pre-copy inventory.
- Record file hashes, entry types, and permission modes, then sync the backup tree.
- Publish the no-replace release and configuration generations.
- Flush
candidate_data_access_startedbefore switching generations or starting the candidate server. - Restart the server and poll the loopback deployment-readiness route for the exact tag and source commit.
- Flush
admission_startedbefore switching Caddy to the live configuration. - Reload Caddy and mark the transaction complete.
The journal phases exposed by status contain only the request ID, release
identity, barriers, phase, and a bounded failure category. They never contain
paths, command output, request bodies, credentials, dataset contents, or probe
data.
After the maintenance transition begins and before candidate data access, a
failure keeps the data untouched, selects the previous release and
configuration when one exists, and remains in maintenance mode. A failed first
installation has its own first_install_failed terminal state. Boot recovery
leaves Caddy live only for a receipt-only request whose worker never began that
transition.
After candidate data access and before admission, the manager stops the candidate, verifies the backup again, clears only the managed data directory, restores every verified entry, selects the previous executable and configuration, and restarts it. A first installation restores the original data but has no previous executable to start.
At admission_started and later, automatic data restoration is forbidden. A
failure enters manual_recovery. Preserve the journal, backup, failed release,
configuration generation, and logs. Keep Caddy in maintenance, determine
whether writes reached admitted traffic, and follow the full-root restore rules
in Operations. Do not edit the journal or
point an older binary at migrated data.
At guest boot, labello-deploy-recover.service examines every nonterminal
journal before the Labello server starts. An uncertain pre-admission candidate
is restored from its verified backup. A post-admission journal becomes
manual_recovery and blocks the server. Boot recovery never resumes a new
candidate automatically. A request that never advanced beyond durable receipt
has not changed a symlink or service; recovery records its previous release and
leaves that live deployment untouched.
GET /deployment/readiness is public because the local transaction manager
cannot use a browser session. The API normally binds to loopback, and Caddy is
the only network path. The response contains only:
- service and compiled release identity;
- current persistence schema version; and
-
okorfailedfor dataset-root traversal and authentication-store load.
The probe does not create, rename, or write any data. It does not prove free space, write capacity, a representative dataset read, import filesystem features, OAuth reachability, or browser networking. The transaction separately verifies the browser inventory and the operator must monitor capacity.
The tracked user service runs Caddy as hulk. The one-time guest sysctl allows
unprivileged processes to bind ports 80 and 443. Caddy terminates TLS, serves
the current browser generation, and strips /api/ before proxying to the
loopback API. It serves the untracked labello.client.json from the current
configuration generation with Cache-Control: no-store. A missing static file
returns 404; there is no SPA fallback.
The maintenance configuration returns 503 for every network request. The transaction manager calls only the loopback API while maintenance is active.
The deployment guest needs outbound HTTPS access to GitHub release, API, and
artifact hosts plus the Sigstore trust and transparency endpoints used by
gh attestation verify. Caddy also needs DNS and outbound access to the
configured certificate authority when it manages public TLS certificates. The
guest does not need inbound SSH for automation. Operator SSH access is outside
the release path and should follow the normal restricted administration
policy.
Before the first production release:
- Complete the guest setup and remove
hulkfromsudoanddocker. - Enable immutable releases and create the two protected environments.
- Set the two repository variables without exposing their values.
- Confirm the deployment runner cannot accept pull-request CI.
- Verify the data mount, ownership, capacity for a full backup, TLS, OAuth, and exact browser origin.
- Start a release with canonical
vX.Y.Zinput. - Record the exact release workflow SHA, source commit, deploy run, request ID, terminal phase, readiness result, and backup verification result.
- Perform a restore drill before treating rollback as operationally proven.
A local transaction test does not prove the real guest's mounts, systemd user manager, Caddy TLS, GitHub environment rules, or runner-group restriction.
Release publication compiles LABELLO_RELEASE_TAG and LABELLO_SOURCE_COMMIT
into both the server API and the executing browser WASM. The versioned-payload
step rebuilds the locked release browser with these values before inventory,
checksums, packaging and attestation. release.json remains release metadata for
artifact verification; the running UI does not fetch it as its own identity.
An already-open browser from release A therefore keeps reporting A after release
B is deployed, while a server refresh can report B.
The public read-only /build-information route exposes the bounded compiled
identity independently of /deployment/readiness. Readiness and admission
semantics are unchanged. Missing build metadata is explicit development or
incomplete identity, never a release inferred from the shared Cargo version.
Setup > About shows both identities and offers plain-text copying. A quiet
lower-right warning appears only when both complete release identities differ.
Server refreshes happen at startup, About opening, retry and visible-tab focus;
failure clears the previously displayed server identity and mismatch.
A confirmed mismatch automatically prepares fresh browser assets and reloads once pending operations and recoverable draft writes finish. The browser fetches a cache-busted entry document and its JS/WASM preload inventory with HTTP cache reload semantics, then navigates to that document. This relies on the complete Trunk distribution's content-hashed JS/WASM URLs, including generated module imports. It does not erase cookies, local storage, IndexedDB, or unrelated caches. Labello installs no service worker; an externally controlling worker blocks this recovery because it could intercept the cache-bypass requests.
A session-storage marker permits one automatic attempt per current target server
identity and endpoint in a tab. A persistent mismatch, unavailable tab storage,
or failed asset preparation leaves an explanation and Retry app update in
About. Manual retry checks the server identity again before attempting recovery.
Incomplete identities and failed identity checks never trigger a reload.
Staged operations without browser recovery, including migration input, revision
decisions, import configuration, and return-to-review reasons, must be finished
or discarded first. Browser-storage failure leaves work in the current tab.
Deployment checks must include an open release A updating to B with warm browser
caches and a deliberately persistent mismatch that stops after one attempt.
Model execution runs on the server. The browser distribution contains no ONNX Runtime Web assets and does not download models. Trunk builds no longer fetch an ONNX Runtime Web archive. Publish the complete browser distribution atomically with the server and configure server model execution as described in prelabels.
Edit documentation in the Labello repository. The wiki is generated from the current documentation; archived plans stay in the repository.
- Labello documentation
- Labello
- Getting started
- Product glossary
- Annotation and review
- Stylus input
- Dataset administration
- Model prelabels
- Dataset import
- Guided box-to-skeleton migration
- Dataset export
- Assignment
- Contribution scoring
- Current limitations