The canonical guide to every supported way of installing and operating LibreDB Studio, and to how the release pipeline publishes each channel. For a one-line-per-channel overview, see the Install matrix in the README.
| Channel | Best for | Section |
|---|---|---|
| Docker | Servers, PaaS, CI, quickest start | Docker |
| Helm | Kubernetes | Helm (Kubernetes) |
| npx | Trying it on a laptop with Node installed | npx |
| Homebrew | macOS / Linux workstations | Homebrew |
| .deb / .rpm | Debian/Ubuntu and RHEL/Fedora servers (systemd) | Linux packages (.deb / .rpm) |
| Snap | Ubuntu and other snapd systems | Snap |
| Windows (winget / Chocolatey / Scoop / portable zip) | Windows workstations | Windows |
| Desktop app (AppImage, .deb, FlatPark) | Linux desktops - an application window, no browser tab | Desktop app |
| Unraid | An Unraid server - one click from the Apps tab | Unraid |
| Sealos | One-click managed Kubernetes, nothing to install locally | Sealos |
All non-Docker channels ship or download the same standalone server payload (Next.js
standalone output, started with node server.js) built by
scripts/build-standalone-payload.sh and attached to
GitHub releases by .github/workflows/release-artifacts.yml.
Standalone artifacts (tarballs, .deb/.rpm, Homebrew formula, snap) are published on release,
starting with the first release that includes the release-artifacts workflow — older releases
have Docker images only.
Runtime note: every channel here runs the production server under Node (
node server.js), including the Docker image — the default tag's runner stage isnode:26.9.0-trixie-slim, the-alpinetag's isnode:26.9.0-alpine3.23and the-alpine-slimtag's is Alpine's ownnodejspackage (see Image tag model), and in all threeCMDexecsnode server.js; Bun is only used to install dependencies during the Docker build and for local development (bun dev). The SQLite DB provider adapts to whichever runtime it finds (bun:sqliteunder Bun,node:sqliteunder Node) — see docs/providers/sqlite.md, Runtime & driver selection.
Every channel works with no configuration, except the two catalog templates that collect
credentials in their own install form (see the last bullet). When JWT_SECRET / ADMIN_PASSWORD are not set
(and the auth provider is not OIDC), the server generates them on first start, persists them in
<data dir>/auth-bootstrap.json (file mode 0600), and prints the admin password once to the
server log:
============================================================
LibreDB Studio first run: generated admin credentials
Email: admin@libredb.org
Password: <generated>
Stored in <data dir>/auth-bootstrap.json (delete the file to regenerate)
============================================================
- The data dir is the directory of
STORAGE_SQLITE_PATH(default./datainside the payload / container). Each channel below documents where that is and how to read the log. - Explicitly set environment variables always take precedence; only missing values are generated.
- If the data dir is not persisted (ephemeral container, no volume), new credentials are generated on every recreate.
- The two catalog templates are the exception, because their install form asks for the
credentials up front: the Unraid template requires
ADMIN_PASSWORDandJWT_SECRET, and the Sealos template collects an admin password and setsAUTH_BOOTSTRAP=off. Both therefore start with credentials you chose, print no generated-password banner, and write noauth-bootstrap.json. Zero-config describes what the server does when a channel leaves those variables unset.
Strict mode: set AUTH_BOOTSTRAP=off to disable generation and require explicit
JWT_SECRET and ADMIN_PASSWORD (recommended for production; a missing ADMIN_PASSWORD then
surfaces as a clear error on the login page instead of silently generated credentials in
collected logs, and a missing JWT_SECRET stops the server at boot with an actionable banner
rather than starting a deployment whose every login is 503). Every
channel that starts the server itself, the Helm chart included, defaults to zero-config and takes
strict mode as an opt-in; the Sealos template is the one channel that ships
with it already on. Unrecognized AUTH_BOOTSTRAP values log a warning and keep bootstrap on.
Every native channel is local-first: the server binds to 127.0.0.1 by default, and
exposing it on the network is an explicit opt-in. That is a deliberate choice about a process
sharing a user's machine, and it has not changed. (The Docker image and the Helm chart are the
exception - a container binds all of its own addresses and is isolated by container networking
instead. Since chart 0.1.42 and the image that ships with it, that set is not hardcoded: the container's
entrypoint resolves a bind address at startup and prefers ::, all addresses of both families.)
HOSTNAME is the bind address wherever the server is started directly and the value was chosen -
Docker, Helm and the systemd units, the Snap daemon included; the .deb/.rpm and Homebrew
wrappers, a direct snap run and the Windows launcher take LIBREDB_BIND instead and discard an
inherited HOSTNAME. The npx launcher reads both: --host, then LIBREDB_BIND, then HOSTNAME
only when it differs
from the machine's own hostname, so a value a shell or a container runtime exported is ignored
rather than bound (#813). The note below the table
covers the address family either one selects.
| Channel | Default bind | How to expose |
|---|---|---|
| npx | 127.0.0.1 |
npx @libredb/studio --host 0.0.0.0, or LIBREDB_BIND=0.0.0.0, or a HOSTNAME that differs from the machine's own name |
| .deb / .rpm (systemd) | 127.0.0.1 |
HOSTNAME=0.0.0.0 in /etc/libredb-studio/env, then restart |
| .deb / .rpm (direct run) | 127.0.0.1 |
LIBREDB_BIND=0.0.0.0 libredb-studio |
| Homebrew service | 127.0.0.1 |
run the binary manually with LIBREDB_BIND=0.0.0.0, or front it with a reverse proxy |
| Snap | 127.0.0.1 |
sudo systemctl edit snap.libredb-studio.libredb-studio.service with [Service] Environment=HOSTNAME=0.0.0.0 |
| Docker / Helm | resolved at startup: :: (container-internal, both families) where the namespace allows it, 0.0.0.0 where it does not |
publish/route ports as usual (-p, Service/Ingress); HOSTNAME=0.0.0.0 (chart: config.bindAddress) to pin it back to IPv4 |
For anything reachable from a network, prefer a reverse proxy with TLS in front and strict mode
(AUTH_BOOTSTRAP=off) with explicit credentials.
A direct run of the .deb/.rpm wrapper, the Homebrew binary or the snap launcher ignores any
inherited HOSTNAME (empty, or - under Docker - the container ID Next.js would otherwise bind to)
and defaults to loopback; LIBREDB_BIND is the explicit opt-in for that case. Under systemd,
HOSTNAME in /etc/libredb-studio/env or the snap unit's drop-in is still the override, since the
unit resolves it before the wrapper runs (detected via the systemd-set INVOCATION_ID, so the
wrapper leaves it untouched there). The
Windows launcher rebuilds HOSTNAME from LIBREDB_BIND on every run, with no systemd exception.
The npx launcher ignores an inherited HOSTNAME by the same rule and for the same reason: a value
equal to the machine's own hostname is exactly what Docker's container id and a kubelet's pod name
look like from inside, so it is not read as a choice (#813). Its overrides are --host first, then
LIBREDB_BIND - which is also the way out of the one case the rule costs: a container started with
--hostname naming itself, where that name and an injected one are indistinguishable.
Address family (IPv4, IPv6, dual-stack). The bind address accepts an IPv6 literal in every
channel, because whichever variable that channel reads ends up as the host argument of a plain
server.listen(port, hostname) in the standalone Next.js server
(next/dist/server/lib/start-server.js) - so it is Node, not Next, that gives :: its meaning.
(Next touches [::] only to format the URL it prints at startup.) Use HOSTNAME where the server is started directly
(Docker, Helm, the systemd units, Snap), --host or LIBREDB_BIND on the npx launcher, and
LIBREDB_BIND in the
wrapper channels (a direct
.deb/.rpm run, Homebrew, the Windows launcher), per the paragraph above. The values that
matter:
HOSTNAME |
LIBREDB_BIND |
Listens on |
|---|---|---|
0.0.0.0 |
0.0.0.0 |
all IPv4 addresses (what the container resolver falls back to) |
:: |
:: |
all IPv6 addresses and, in a Node server, all IPv4 addresses through the same socket, so one listener serves both families (see the caveat below) |
127.0.0.1 |
127.0.0.1 |
IPv4 loopback (the native-channel default) |
::1 |
::1 |
IPv6 loopback - the IPv6 equivalent of that local-first default |
The dual-stack behaviour of :: has one documented caveat, net.ipv6.bindv6only=1, where a
socket bound to :: accepts IPv6 only. Two things about it are worth stating precisely, because
both were measured rather than assumed:
- The sysctl is network-namespace scoped, not a host-wide property, and a fresh namespace
initializes to
0. A Docker container or a Kubernetes pod therefore gets0whatever the node reads, unless the namespace is overridden explicitly (docker run --sysctl net.ipv6.bindv6only=1, a podsecurityContext.sysctlsentry) or it shares the host's namespace (docker run --network host,hostNetwork: true). Checking the node and finding1does not mean::drops IPv4 in your container. - Even under
bindv6only=1, a Node listener stays dual-stack: libuv clearsIPV6_V6ONLYon the socket unlessipv6Onlyis requested, and the standalone server calls a plainserver.listen(port, hostname), so it never requests it. Measured underdocker run --sysctl net.ipv6.bindv6only=1: a::listener answered127.0.0.1, the container's own bridge address, and a published port from the host. A plain socket in another language, in the same namespace, was refused - so the sysctl was genuinely in force and Node was genuinely overriding it.
That caveat is therefore something the image detects rather than a reason its default cannot change. The container's entrypoint resolves the bind address once at startup:
- An explicit
LIBREDB_BIND, or an explicitHOSTNAMEdistinguishable from the container id or pod name a runtime injects, is honoured verbatim and stops there. - Otherwise it proves dual-stack instead of inferring it: bind a throwaway listener on
::on an ephemeral port, connect to it over127.0.0.1, and choose::if that connection is accepted. A positive probe is the only reliable test - under bothbindv6only=1anddisable_ipv6=1the bind itself succeeds, so atry/catcharound it would never fire. - If
::cannot be bound at all - a kernel with noAF_INET6- it chooses0.0.0.0. - If
::bound but refused the IPv4 client, it chooses0.0.0.0only when a non-loopback IPv4 address exists.127.0.0.1does not count: an IPv6-only container still has one, and counting it is exactly how an IPv6-only host ends up unreachable. - Otherwise - an IPv6-only listener with no IPv4 to lose - it stays on
::.
It logs one line naming the address it picked and the evidence for it, so docker logs /
kubectl logs answers "what is it listening on" without a shell. No configuration that reaches
users over IPv4 today loses it, and an IPv6-only host works with no flags.
To overrule the resolver, set the address yourself. Docker:
docker run -p 3000:3000 -e HOSTNAME=0.0.0.0 ghcr.io/libredb/libredb-studio:latestFor Helm, config.bindAddress writes the ConfigMap key (empty by default, which is the "resolve
it" sentinel):
config:
bindAddress: "0.0.0.0"extraEnv still wins over that - it renders into the container's env: list, which overrides the
same key delivered through envFrom.
A dual-stack Service no longer needs a second setting. The chart's
service.ipFamilyPolicy/service.ipFamiliesvalues give the Service an IPv6 address, and the pod now listens on one by default. What has not changed is that Kubernetes never checks what address the container actually bound: the IPv6 EndpointSlice is populated from the pod's IPv6 address regardless, and traffic to it hits a pod with no IPv6 listener, which answersconnection refused. The kubelet probes the pod's primary IP (IPv4 on a typical cluster), so the pod still reports Ready and nothing self-heals. That is now reachable only by pinning the pod to an IPv4 literal (config.bindAddress, or anextraEnvHOSTNAME) alongside a dual-stack Service, and the chart warns at install time when you do. See the chart README.
The native channels are unaffected by all of this. They stay on 127.0.0.1 by default - a
deliberate local-first choice for a server sharing someone's machine, not an oversight the
container change should propagate - and treat any exposure, IPv4 or IPv6, as an explicit opt-in:
LIBREDB_BIND=:: (or LIBREDB_BIND=::1 for IPv6 loopback) for the wrapper channels, HOSTNAME
for the rest. There is no probe and no auto-selection in those channels.
Release tags carry no v prefix (tag 0.9.41 == package.json version). Each release ships:
| Artifact | Name | Targets |
|---|---|---|
| Standalone server tarball | libredb-studio-standalone-<version>-<os>-<arch>.tar.gz |
linux-x64, linux-arm64, darwin-x64, darwin-arm64 |
| Standalone server zip (Windows) | libredb-studio-standalone-<version>-win32-x64.zip |
win32-x64 (bundled Node runtime + libredb-studio.exe launcher) |
| Checksums | SHA256SUMS |
covers all standalone tarballs and the win32 zip |
| Debian package | libredb-studio_<version>_<arch>.deb (+ .sha256 sidecar) |
amd64, arm64 |
| RPM package | libredb-studio-<version>.<arch>.rpm (+ .sha256 sidecar) |
x86_64, aarch64 |
| Snap | libredb-studio_<version>_<arch>.snap |
amd64, arm64 (also published to the Snap Store) |
| Desktop AppImage | libredb-studio-desktop-<version>-linux-<arch>.AppImage (+ .sha256 sidecar) |
x64, arm64 (also the artifact the in-repo Flatpak manifest repacks) |
| Desktop Debian package | libredb-studio-desktop_<version>_<arch>.deb (+ .sha256 sidecar) |
amd64, arm64 (from 0.9.62; the artifact FlatPark pins as extra-data) |
SHA256SUMS covers the standalone tarballs and the win32 zip; each .deb/.rpm/.AppImage
ships its own per-file <artifact>.sha256 sidecar instead (those are built in separate jobs).
Two different .debs ship per release and they are not interchangeable.
libredb-studio_<version>_<arch>.deb is the headless server: it installs a systemd unit and is
built by nfpm from packaging/linux/nfpm.yaml. libredb-studio-desktop_<version>_<arch>.deb is the
GUI app: a desktop entry, the Tauri shell, the pinned Node sidecar and the server payload, built by
the Tauri bundler in the same job as the AppImage. Different file names and different dpkg package
names (libredb-studio vs libredb-studio-desktop), so both can be installed on one machine.
Standalone tarball entries are rooted under a top-level libredb-studio-<version>/ directory
(not a tarbomb) - extract with tar --strip-components=1 (tar xzf <artifact> --strip-components=1), which is what the npx launcher, the deb/rpm/snap packaging jobs, and
scripts/build-standalone-payload.sh's own --smoke self-test all do; Homebrew strips the single
top-level directory automatically for its main url/sha256 download, so the formula needs no
extra flag.
The win32 zip is deliberately FLAT (entries at the archive root, no versioned wrapper
directory - scripts/lib/pack-standalone-zip.sh): winget resolves the manifest's
NestedInstallerFiles.RelativeFilePath against the zip root and wingetcreate update never
rewrites that path, so it must stay libredb-studio.exe across releases; Chocolatey likewise
unzips straight into its package tools directory. Windows Explorer's "Extract All" already
defaults to a folder named after the zip, so manual extraction stays tidy.
The payload contains only the runtime (server.js, package.json, .next, node_modules,
public, an empty data/, plus LICENSE/README.md): Next.js output file tracing sweeps the
repo root into .next/standalone, so payload assembly prunes the non-runtime extras (docs,
source, tooling configs, deploy manifests, local build leftovers) via a deny-list
(scripts/lib/prune-standalone-payload.sh).
The deny-list covers project-conventional paths only - on a local (non-CI) build, arbitrary
personal files sitting at the repo root can still be traced in, so keep secrets out of the
repo root (CI release checkouts are clean; published artifacts are unaffected).
Download URL pattern:
https://github.com/libredb/libredb-studio/releases/download/<version>/<artifact>.
ghcr.io/libredb/libredb-studio is the canonical image (no pull rate limits). Docker Hub
(libredb/libredb-studio) is a discoverability mirror only.
# Zero-config: the first run prints the generated admin password to the log
docker run --name libredb-studio -p 3000:3000 \
-v libredb-data:/app/data \
ghcr.io/libredb/libredb-studio:latest
docker logs libredb-studio # shows the first-run credentials bannerThe /app/data volume persists the generated credentials and the server-side SQLite storage;
without it, a recreated container generates new credentials.
Production (strict mode, explicit secrets):
docker run --name libredb-studio -p 3000:3000 \
-e AUTH_BOOTSTRAP=off \
-e JWT_SECRET="$(openssl rand -base64 32)" \
-e ADMIN_EMAIL=admin@libredb.org \
ghcr.io/libredb/libredb-studio:latestYour shell expands $(openssl rand -base64 32) before docker run sees it, so the secret is a
real 44-character one and no secret is written down here - it is the same fix line the startup
banner prints when JWT_SECRET is too short, and a placeholder that cleared the 32-character
minimum would be a published working secret. It is a NEW secret on each run, though: recreate the
container and every existing session is invalidated, so pass a value you keep (from your own
secret store) for a container that is meant to be replaceable.
All environment variables are documented in .env.example; a ready-to-use
compose file is docker-compose.example.yml. The container
picks its own bind address at startup and prefers ::, so it is reachable over both families
without a flag; add -e HOSTNAME=0.0.0.0 to pin it to IPv4 - see
Network exposure.
Published by .github/workflows/docker-build-push.yml:
| Tag | Published from | Mutability |
|---|---|---|
<version> (e.g. 0.9.41) |
GitHub release (or manual dispatch) only | pinned, never overwritten by branch pushes |
latest |
GitHub release only | moves on each release |
main |
every push to main (including PR merges) |
moving pre-release tag |
dev |
every push to a feat/** / fix/** branch |
moving development tag |
sha-<commit> |
every build | immutable |
Use <version> or sha-<commit> for reproducible deployments; main / dev are for testing
unreleased code.
Variants. Every tag above is published three times, once per base image (#840). The suffix is
appended to whatever the tag would otherwise be, so 0.16.2, 0.16.2-alpine and
0.16.2-alpine-slim are the same release on three bases, and latest-alpine and dev-alpine exist
for the same reason latest and dev do.
| Suffix | Dockerfile | Base | Engines | Use |
|---|---|---|---|---|
| none | Dockerfile |
node:26.9.0-trixie-slim (glibc) |
all, and the only one where Oracle Thick mode can be layered on | the default; unchanged, and what every example in this repository pulls |
-alpine |
Dockerfile.alpine |
node:26.9.0-alpine3.23 (musl) |
all, Oracle Thin only | a much smaller OS attack surface: measured with Trivy 0.73.0 on 2026-09-15, the Debian base carries 3 CRITICAL / 52 HIGH OS findings that belong to the distro (the newest node:26-trixie-slim scores identically) against 0 / 2 for node:26-alpine |
-alpine-slim |
Dockerfile.alpine-slim |
alpine:3.23 with Alpine's own nodejs package |
all except DuckDB | smallest; see the trade below |
-alpine-slim trades features for size and is the only variant that does. It drops the DuckDB
driver (four packages ending in a ~70 MB libduckdb.so) and sharp/libvips, and it runs Alpine's
packaged Node rather than the official image's unstripped binary, which is the single largest
saving. Opening a DuckDB connection
on it fails with a message naming the tags that do ship the driver, not a module-resolution stack.
Its Node version follows Alpine's package index rather than a Dependabot-tracked pin, and Alpine
ships English-only ICU data — safe here because every server-side locale call in src passes
en-US explicitly.
Oracle Thick mode needs Oracle Instant Client, which has no musl build, so it is reachable on the default tag only — see providers/oracle.md.
All three prune the payload. Next's output file tracing sweeps the repository root into
.next/standalone, so every image used to unpack src/, scripts/, the lockfile and the tooling
configs onto /app. Each Dockerfile now runs scripts/lib/prune-standalone-payload.sh in its
builder stage — the same deny-list the release tarballs, .deb/.rpm, snap and the npx cache
already share (#124) — so a new repo-root file leaves every artifact family through one edit.
Architectures: every tag published from main, a release or a manual dispatch is a
linux/amd64 + linux/arm64 manifest. Branch previews (dev and the sha- tag of a
feat/** / fix/** push) are linux/amd64 only — the build job has no native arm64 runner, so
arm64 goes through QEMU and dominates the job's runtime; spending that on every commit of an open
PR buys nothing, since the only consumer of dev is the amd64 Channel E2E gate. Pull main (or a
released version) when you need arm64 from an unreleased line.
helm repo add libredb https://libredb.org/libredb-studio/
helm install libredb libredb/libredb-studio \
--set secrets.jwtSecret=$(openssl rand -base64 32) \
--set secrets.adminPassword=MyAdmin123Or from the OCI registry:
helm install libredb oci://ghcr.io/libredb/charts/libredb-studio \
--set secrets.jwtSecret=$(openssl rand -base64 32) \
--set secrets.adminPassword=MyAdmin123The chart defaults to zero-config bootstrap like every other channel (config.authBootstrap: ""
omits the variable, so the app default applies): helm install works with no values at all. For
production, inject real secrets as above, or enforce them with strict mode
(--set config.authBootstrap=off).
The chart's own README.md
is the canonical description of that behaviour — credential retrieval, what strict mode requires per
auth provider, persistence and the single-replica constraint. Full values reference:
charts/libredb-studio/README.md; chart architecture:
docs/HELM_CHART.md.
On a dual-stack cluster, set service.ipFamilyPolicy (and optionally service.ipFamilies) - that
is the whole recipe since chart 0.1.42, because the container resolves its own bind address and
prefers ::. The pairing to avoid is the opposite one: an IPv4 literal in config.bindAddress (or
in an extraEnv HOSTNAME entry, which wins over it) in front of a dual-stack Service advertises
an IPv6 address that refuses every connection, and the install notes warn about exactly that. See
Network exposure.
operator/ packages the published Helm chart as a codeless helm-operator
(operator-sdk helm plugin): a LibreDBStudio custom resource whose spec mirrors
the chart values.
The one value the spec does not mirror is extraObjects, which the CRD refuses with a CEL rule (#1526).
The operator applies the release with its own cluster-wide service account, so a manifest listed there would be created with the operator's permissions instead of those of whoever wrote the resource, and the editor role is meant to grant no RBAC.
Plain Helm applies a release with the installer's own credentials, and Argo CD within the limits of its AppProject, so the chart offers the value there.
The chart's Rancher mode is not offered through the operator either, in this release: the operator's role grants no ui.cattle.io NavLink, no ClusterRole or ClusterRoleBinding and no read on CloudNativePG Clusters, so the API server also refuses the exporter's Roles as an escalation, and a resource with rancher.navLink.enabled, accessReview.enabled or discovery.kubernetes.enabled fails to reconcile, and Rancher installs go through Helm or Rancher's Apps page.
Publishing works in two stages:
- Controller image —
.github/workflows/operator-release.ymlbuilds and pushesghcr.io/libredb/libredb-studio-operator:<version>(amd64+arm64) on every app release. Chart releases (libredb-studio-x.y.ztags) are skipped quietly. The embedded chart's declared dependencies are vendored withhelm dependency buildright before the image build;charts/*.tgzis never committed. - Catalog bundle —
operator/bundle/(generated bymake -C operator bundle, which stamps the versionedcontainerImageCSV annotation and validates with--select-optional suite=operatorframework— the same suite the community pipelines run; no validator flags a missingcontainerImage, so the stamp is enforced by the Makefile, not by validation) is the submission source for both community catalogs: k8s-operatorhub/community-operators (operatorhub.io, plain bundle-directory PR) and redhat-openshift-ecosystem/community-operators-prod (OpenShift console). The community-operators-prod submission uses the FBC contribution mode (fbc.enabled: trueplus a per-OCP-versioncatalog_mappingin the submission'sci.yaml): bundle-directory PRs without FBC are still accepted, but FBC is the pipeline's recommended mode and automates promotion into each new OpenShift version's catalog (version_promotion_strategy, defaultreview-needed). Channeloperatorhub-communityindistribution/channels.yamltracks it (status: livesince both first listings merged; tracking issue #152). Both are the community catalogs — the OpenShift console lists the operator under Community with its unsupported-operator warning — and not the Red Hat certified catalog, which is a separate partner programme.
Two copies are kept honest by CI: the embedded chart copy
(operator/helm-charts/) is enforced by the required chart:check gate
(chart:bump refreshes it), and bundle freshness (operator/config vs the
generated operator/bundle) is checked in the Helm Chart Lint job whenever a
PR touches operator/. The operator Makefile derives VERSION from
package.json, so there is no fourth hand-maintained version string.
A community-operators-prod release in FBC mode always takes two merges, and only the first one is triggered by the bundle:
-
Bundle PR —
operators/libredb-studio-operator/<version>/(a copy ofoperator/bundle) plusci.yaml,Makefileandcatalog-templates/. Merging it runs the release pipeline, which publishesquay.io/community-operator-pipeline-prod/libredb-studio-operator:<version>. The operator is released at this point but is in no catalog yet, so nothing is installable from the OpenShift console. -
Catalog PR — the rendered
catalogs/v4.15..v4.22/libredb-studio-operator/catalog.yamlfiles, one per OCP version in thecatalog_mapping. Render them from a fork's checkout:cd operators/libredb-studio-operator make catalogs # opm alpha render-template, driven by ci.yaml make validate-catalogs # opm validate; must pass for every mapped version
The renderer applies
--migrate-level bundle-object-to-csv-metadataonly for v4.17+, which is why the pre-4.17 catalogs keep the base64olm.bundle.objectform and the newer ones do not.
rh-operator-bundle-bot opens that second PR by itself (updating both the
template and the rendered catalogs) only when the bundle version directory
carries a release-config.yaml; otherwise the release pipeline just prints
the manual instructions in its summary comment. 0.9.59 shipped without one, so
its catalog PR
(community-operators-prod#10581)
was rendered and opened by hand. Every submission since carries the file at
operators/libredb-studio-operator/<version>/release-config.yaml, written by
the submit-catalogs job rather than by hand:
---
catalog_templates:
- template_name: basic.yaml
channels: [alpha]
replaces: libredb-studio-operator.v<previous version in this catalog>replaces and not skipRange. A skipRange contributes no edge to the FBC
graph, so the rendered channel keeps two heads and opm validate rejects the
catalog with multiple channel heads found in graph - measured on
community-operators-prod#11106, whose add-bundle-to-fbc-dryrun failed exactly
that way before the field was changed. The value is the same derived
predecessor the CSV's spec.replaces carries and is written at submission
time rather than stored in this repo; see
The update graph.
Per the pipeline's own schema
(release-config-schema.json)
only template_name and channels are required; replaces, skips and
skipRange are all optional and all apply to basic templates only.
version_promotion_strategy in ci.yaml is a separate knob (promotion into
each newly added OpenShift version's catalog) and does not substitute for
release-config.yaml. The operatorhub.io submission
(k8s-operatorhub/community-operators) has no such second step: its
bundle-directory PR is the whole listing.
So on this catalog a merged version directory is not yet a node of the channel graph: that happens only when the bot's "Catalog update" PR adds it to catalog-templates/basic.yaml, and until then a submission that replaces it fails add-bundle-to-fbc-dryrun with multiple channel heads found in graph (measured on community-operators-prod#11290, submitted while 0.16.0's catalog update #11204 was still open).
The submit-catalogs job therefore reads that template and skips, naming the version, while the predecessor is a bundle but not yet a channel entry.
Both submissions are opened by the submit-catalogs job in
.github/workflows/operator-release.yml (issue #656).
It runs with needs: build-and-push, because the catalog pipelines pull the controller image onto a real cluster and it has to exist and be public first.
The job is a matrix over the two catalogs with fail-fast: false: they have independent review queues and one failing must not cancel the other.
The submission is switched from distribution/channels.yaml like every other optional channel, via update.ci_enabled on operatorhub-community.
Every "nothing to do" branch exits 0 and names the condition, so a legitimately impossible submission never paints a release run red.
A skip that means a release did not reach a catalog is a ::warning:: rather than a ::notice::, because one of them mutes every later release until somebody acts on it:
| Condition | Why it is not a failure |
|---|---|
update.ci_enabled is not true |
the channel is switched off in the inventory |
OPERATOR_CATALOG_TOKEN absent |
a fork has no token, and the workflow still has to run |
| the operator has no directory in that catalog | a first listing needs review and metadata this path does not carry |
| the catalog already carries this version | a rerun after a merge |
| an open submission for any other version | see below, this one is a hard stop |
| FBC only: the predecessor is a bundle directory but not yet an entry of the channel template | its catalog update has to merge first, or the new bundle replaces a node the graph does not have |
| the release is a prerelease | the catalogs take bare semver directories only |
Three conditions are hard failures rather than skips, because each is a misconfiguration on our side that would otherwise break the channel quietly:
the controller image is not anonymously pullable, the fork's parent is not the upstream, and a GitHub API read fails.
Two things it refuses rather than guesses.
A GitHub API read that fails stops the job instead of defaulting to "submit", because a decision made on an unread search is how a duplicate or a graph-breaking pull request gets opened.
And a fork whose parent is not the upstream stops the job: gh repo sync --source cannot be trusted for that check, since it calls POST /merge-upstream first and that endpoint takes neither a source nor a force parameter, so a fork pointing at the wrong parent would sync from the wrong repository and still exit 0.
The job asserts the parent, calls merge-upstream directly, then asserts the fork is identical or behind.
Any unmerged submission for another version is a hard stop.
The predecessor is derived from the catalog's main, so a submission that is open but unmerged is invisible to it.
Submitting past a pending version points the graph over it, and once both merge the pending bundle is dangling.
The skip names the blocking version and its pull request number, because a skip that does not say what to go and merge is a mute.
A version the catalog already carries is never treated as pending, whatever an open pull request does to its directory.
This is what lost VictoriaMetrics 0.48.2, and their record is the reason to take it seriously: victoriametrics-bot landed 45 of 50 submissions on operatorhub.io, and four of the five that did not land are versions the catalog has never carried.
Detection is path-based, never title-based.
The hub rewrites submission titles on open and on every push, inserting markers in a fixed order (operator [O] [R] [N] [CI] [PK] <name> (<versions>)) and listing more than one version for a multi-version pull request.
Our own #8794 was accepted and renamed to operator [N] [CI] libredb-studio-operator (0.9.59).
So the job searches for open pull requests mentioning the operator and then reads each one's changed files, treating operators/<operator>/<version>/... as the submission and ignoring a ci.yaml edit or the bot's catalogs/v4.x/... follow-up.
scripts/operator-catalog-submission.mjs holds that logic as pure functions, tested in tests/unit/operator-catalog-submission.test.ts; the workflow step is a thin caller.
Identity matters more than the fork does.
Upstream's authorization check compares the account that OPENED the pull request against the operator's ci.yaml reviewers list on the base branch, and nothing reads the fork owner or the commit author: opp-env.sh does it case-insensitively for the hub's authorized-changes label, and check_permissions.py does the same for the prod approved label.
Our list is cevheri and yusuf-gundogdu, so the token must belong to one of them; a separate bot identity would have to be added upstream first, and every PR would wait for a human label until it was.
OPERATOR_CATALOG_TOKEN is therefore a classic PAT with public_repo on one of those accounts: a fine-grained token cannot be granted pull-request write on a repository we do not own, and wingetcreate's secret carries the same constraint for the same reason.
Signing is DCO, not GPG.
create-pull-request writes the Signed-off-by trailer from its committer input, whose default is github-actions[bot], so committer and author are both set to the same real identity; sign-commits stays off, because it recreates commits through the API and discards those identities, leaving a trailer that no longer matches the author.
The forks are libredb/community-operators and libredb/community-operators-prod.
They were transferred from the cevheri account rather than re-forked, which is the only way that keeps parent pointing at the upstream: an org fork created from a personal fork records the personal fork as its parent, a parent cannot be changed afterwards, and the only escape ("leave fork network") is permanent and destroys the pull requests.
The job asserts that parent on every run for the same reason.
What stays manual: the first listing in each catalog, the merge itself (the hub automerges once its checks pass, prod merges on its own pipeline), and the operatorhub.io index publication, which lags a merge by hours.
Both catalogs need to know where a new bundle sits relative to the last one, or the older bundle becomes a dangling bundle and the catalog build fails. The graph is expressed in two fields, and only one of them is knowable from this repo.
olm.skipRange, owned by the repo.
make -C operator bundle stamps >=0.0.0 <$(VERSION) into the CSV annotations, next to the containerImage stamp, and greps it back.
"Supersede everything below me" is derivable from package.json and stays true however many releases go unsubmitted.
The upper bound is exclusive on purpose; <= would put the bundle inside its own skip range.
Note the upstream failure mode this stamp-and-verify guards against: an unparseable range is ignored with a log warning by _resolve_skip_range rather than rejected, so a typo would degrade to no range at all and only surface later as a dangling bundle.
The range is parsed by semantic_version.NpmSpec.
spec.replaces, injected at submission time and deliberately absent here.
Its only correct value is the version immediately preceding this one in the catalog being submitted to.
That is external state: it lags our releases by however many submissions are unmerged, and the two catalogs disagree with each other.
Nothing in this repo can derive it, so nothing in this repo declares it.
The submission path derives it from the catalog itself and patches it into the bundle copy.
The consequence looks like an omission and is not: the committed bundle on its own does not pass opm index add --mode replaces.
Measured, with a local registry and two bundle images:
--mode |
bundle | exit |
|---|---|---|
replaces |
committed bundle, skipRange only | 1, add prunes bundle libredb-studio-operator.v0.9.59 |
replaces |
with spec.replaces injected |
0 |
semver |
committed bundle, skipRange only | 0 |
tests/unit/operator-bundle-update-graph.test.ts asserts that absence rather than merely documenting it, because the obvious "fix" is to add a declared value back, and a declared value is correct only by coincidence and stale by default.
Two facts about the gates, both learned the hard way on the first 0.14.1 submission:
- The binding gate is the deploy job, not the static check.
The k8s-operatorhub deploy jobs (
kiwi,lemon,orange) build a test catalog withopm index add --mode replaces, and the mode is read straight from the submission'sci.yaml:Setting index add mode from 'updateGraph' value to 'replaces'in the job log. That command readsspec.replacesandspec.skipsand neverolm.skipRange. - A green
check_dangling_bundlesproves nothing about that. The static check in operatorcert seeds its graph from_resolve_skip_rangebefore it looks at the pointers, so it passes on a skipRange alone; it is also decorated@skip_fbc, so it never runs for community-operators-prod. A submission that passes it can still fail to build.
Do not switch updateGraph to semver-mode to avoid the pointer.
It works, and it would remove the pointer entirely, since the graph becomes the sorted bundle list.
But upstream marks the switch as one-way and forbids spec.replaces under it, which would permanently cost us the ability to publish a leaf or out-of-order bundle.
Note also that the prose docs call semver-mode the default while the implementation defaults to replaces-mode (config.get("updateGraph", "replaces-mode")), so the mode stays written out explicitly in each submission's ci.yaml.
Requires Node.js 24+ on Linux, macOS (x64 / arm64), or Windows (x64); Node 24 LTS is the
reference runtime - it is what the release payload is built on. The launcher checks the runtime up
front and spells out what another Node cannot do (scripts/engine-smoke.sh tests every supported
tier in CI):
| Node | Support |
|---|---|
| 24 LTS (recommended) | Everything works |
| 25, 26+ | Everything works. The payload is built on Node 24, but its only native module (better-sqlite3) has shipped N-API prebuilds since v13, so one binary is valid across Node majors - STORAGE_PROVIDER=sqlite included. The node26 leg of scripts/engine-smoke.sh asserts exactly this by running a Node-24-built payload on Node 26. |
| < 24 | Below the engines.node floor. See the note below - these users are not refused, they are pinned. |
Below the floor, npm pins rather than refuses.
engines.nodedoes not produce an error for a bare spec: npm's version picker silently resolves the newest engine-compatible release instead, so a Node 20/22 user runningnpx @libredb/studiolands on the last release that still allowed their runtime - with no notice that a newer Studio exists. That pin is only acceptable while it lands on a runnable release, which thepinnedlegs ofnpx-engine-smoke.ymlassert against the live registry (issue #130 was this mechanism dropping users onto an ancient, bin-less version). To make the pin visible, the release runbooknpm deprecates the last pre-floor version with a pointer to the Node 24 requirement - a deprecation notice is the one message npm does print on install. A user who pins the new version explicitly (npx @libredb/studio@<version>) gets the launcher's own preflight refusal naming the required runtime.
The npm package stays a pure library for
libredb-platform; the launcher downloads the matching standalone archive (tar.gz; the flat zip
on Windows) from the GitHub release, verifies it against the SHA256SUMS release asset, caches
it under ~/.libredb-studio/<version>/, and starts node server.js:
npx @libredb/studio # first run downloads + verifies, then starts
npx @libredb/studio --port 8080 # or set PORT
npx @libredb/studio --help--archive starts from a local tarball and skips checksum verification unless you pin a
digest with --archive-sha256 <hex> - only use archives you built yourself or obtained from a
trusted source. Downloads retry transient failures with
backoff and abort when the connection stalls. The cache in ~/.libredb-studio/<version>/ lives
in your home directory's trust domain; --verify-cache re-checks the cached tarball against the
cached SHA256SUMS and re-extracts the payload. Re-extraction (--verify-cache and every
--archive run) preserves the payload's data/ directory - generated auth-bootstrap.json
credentials and any STORAGE_PROVIDER=sqlite state survive across it, so a re-verify or a
rebuilt archive never invalidates a previously printed admin password.
-
Later runs start straight from the cache (per-version directory; delete it to force a re-download).
-
All environment variables are forwarded to the server (
PORT,HOSTNAME,JWT_SECRET,ADMIN_PASSWORD,AUTH_BOOTSTRAP,STORAGE_PROVIDER,STORAGE_SQLITE_PATH,LLM_*, ...). Missing auth secrets are handled by the zero-config first run, which prints the admin password to the terminal. -
--archive <path>(env:LIBREDB_STUDIO_ARCHIVE) starts from a local standalone tarball instead of downloading — useful for testing a tarball built withscripts/build-standalone-payload.sh(checksum verification is skipped and the archive is re-extracted on every run). -
On Windows the launcher downloads the win32 zip and extracts it with the built-in
System32\tar.exe(bsdtar); see Windows (issue #114). -
Versions released before the standalone tarballs existed have no artifacts; the launcher detects this (HTTP 404) and suggests
npx @libredb/studio@latest. -
The npm package is published with npm provenance (
npm publish --provenanceinnpm-publish.yml, from 0.9.63). npmjs stores a Sigstore attestation naming the repo, workflow and commit that built the tarball, sonpm audit signaturesverifies the package against a trust root outside the release:npm i @libredb/studio && npm audit signatures # "verified registry signatures / attestations"
This covers the package you install from npm. The standalone archive the launcher downloads from the GitHub release carries its own SLSA attestation, and from 0.9.63 the launcher checks it - see below.
After the checksum passes, the launcher runs gh attestation verify on the archive, pinned to the
workflow that signs releases (--signer-workflow …/release-artifacts.yml, so an attestation from
any other workflow or a branch build fails the policy). It runs where checksum verification runs:
on a fresh download and on --verify-cache, never on a plain cache hit, and never for --archive
(a local build was never claimed to come from a release).
The policy is tri-state, and the distinction is deliberate:
| Outcome | Launcher |
|---|---|
| Verified | prints Provenance verified … and starts |
Cannot verify - no gh, not authenticated (gh auth login), no network, API rate limit, or a release older than 0.9.63 |
prints a warning naming the reason and starts on checksum alone |
| Rejected - the attestation fails the signer policy, or a release from 0.9.63 on has no attestation for the archive's digest | refuses to start (exit 1) |
The last row is the case the whole feature exists for. An attacker who can replace a release asset
can replace its SHA256SUMS line too, so the checksum still matches - but they cannot forge an
attestation. For a release that must have one, "GitHub holds no attestation for this digest" is the
tampering signal itself, not missing information.
gh stays optional: the package's contract is zero runtime dependencies, so everything that merely
prevents verification warns and continues. To start anyway after a rejection (accepting the risk,
e.g. during a GitHub attestation outage): LIBREDB_STUDIO_SKIP_PROVENANCE=1.
Verify by hand at any time:
gh attestation verify ~/.libredb-studio/<version>/libredb-studio-standalone-<version>-<os>-<arch>.tar.gz \
--repo libredb/libredb-studio \
--signer-workflow libredb/libredb-studio/.github/workflows/release-artifacts.ymlThe formula tracks the latest release (it is rendered and pushed to
libredb/homebrew-tap by release CI):
# One-time: Homebrew's untrusted-tap policy requires trusting third-party
# taps. `brew trust` needs Homebrew 6+ - if it prints "Unknown command",
# run `brew update` first (pre-6 Homebrew installs the tap without a trust
# step, but the command below would stop the chain).
brew trust libredb/tap
brew install libredb/tap/libredb-studio
# Foreground (first run prints the generated admin password to the terminal)
libredb-studio
# Or as a background service
brew services start libredb-studio- The formula depends on Homebrew's
node@24— the reference runtime the payload is built and smoke-tested on — and installs the standalone payload into the keg'slibexec;libredb-studioon your PATH runs it. The pin used to be a hard requirement, because the bundled better-sqlite3 binding was compiled per Node ABI and a newer major could not load it. Since better-sqlite3 13 that is no longer true (N-API prebuilds, see the npx support table), so the pin is now conservatism rather than necessity: it keeps Homebrew users on the major CI actually exercises instead of whichever one the floatingnodeformula points at. brew services start libredb-studioruns the server on port 3000 with server-side SQLite storage (it setsSTORAGE_PROVIDER=sqlite) under$(brew --prefix)/var/libredb-studio/— the data dir where generated credentials are persisted. The service does not capture stdout, so read the generated password from$(brew --prefix)/var/libredb-studio/auth-bootstrap.json. On Linux,brew servicesregisters a per-user systemd unit (~/.config/systemd/user): it starts at login — not at boot — and stops when your last session ends unless lingering is enabled (loginctl enable-linger $USER).- Running
libredb-studiodirectly also defaultsSTORAGE_SQLITE_PATHto$(brew --prefix)/var/libredb-studio/libredb-storage.db— the same locationbrew servicesuses — unless you set it explicitly. This keeps the zero-configauth-bootstrap.json(and anySTORAGE_PROVIDER=sqlitedata) outside the versioned keg so it survivesbrew upgrade, and lets both run modes share one data dir.
All configuration is environment-driven. The formula does not ship an env file (unlike
.deb/.rpm).
Foreground (libredb-studio): export variables in your shell, or prefix the command.
Explicit values override zero-config first run. Bind with
LIBREDB_BIND (the wrapper maps it to HOSTNAME — see Network exposure):
# AI, auth, OIDC, or Postgres storage — same variables as .env.example
export LLM_PROVIDER=openai LLM_API_KEY=sk-... LLM_MODEL=gpt-4o
# export NEXT_PUBLIC_AUTH_PROVIDER=oidc OIDC_ISSUER=... OIDC_CLIENT_ID=... OIDC_CLIENT_SECRET=...
# export STORAGE_PROVIDER=postgres STORAGE_POSTGRES_URL=postgresql://user:pass@127.0.0.1:5432/libredb
# LIBREDB_BIND=0.0.0.0 libredb-studio # expose beyond loopback
libredb-studiobrew services: the service block only sets STORAGE_PROVIDER=sqlite,
STORAGE_SQLITE_PATH, and HOSTNAME=127.0.0.1. Homebrew has no supported way to inject
extra env into that plist (upgrades regenerate it). For LLM, OIDC, Postgres storage, strict
auth, or a non-loopback bind, run the binary in the foreground with the env above (or put a
reverse proxy in front of the loopback service).
Full variable reference: .env.example. OIDC: docs/OIDC.md.
Storage: docs/STORAGE.md.
Native packages for Debian/Ubuntu and RHEL/Fedora (amd64/x86_64 and arm64/aarch64) are attached
to every GitHub release. They bundle the standalone server together
with a private, checksum-verified Node.js runtime under /usr/lib/libredb-studio — nothing else
to install — and register a hardened systemd service:
VERSION=<version> # e.g. 0.9.42 - release tags have no v prefix
# Debian / Ubuntu
curl -fsSLO "https://github.com/libredb/libredb-studio/releases/download/${VERSION}/libredb-studio_${VERSION}_amd64.deb"
curl -fsSLO "https://github.com/libredb/libredb-studio/releases/download/${VERSION}/libredb-studio_${VERSION}_amd64.deb.sha256"
sha256sum -c "libredb-studio_${VERSION}_amd64.deb.sha256"
sudo dpkg -i "libredb-studio_${VERSION}_amd64.deb"
# RHEL / Fedora / Rocky
curl -fsSLO "https://github.com/libredb/libredb-studio/releases/download/${VERSION}/libredb-studio-${VERSION}.x86_64.rpm"
curl -fsSLO "https://github.com/libredb/libredb-studio/releases/download/${VERSION}/libredb-studio-${VERSION}.x86_64.rpm.sha256"
sha256sum -c "libredb-studio-${VERSION}.x86_64.rpm.sha256"
sudo rpm -i "libredb-studio-${VERSION}.x86_64.rpm"Operate it with systemd:
sudo systemctl enable --now libredb-studio # start now and on boot
journalctl -u libredb-studio # first run prints the generated admin password here
sudo systemctl restart libredb-studio # apply configuration changes- Configuration lives in
/etc/libredb-studio/env(KEY=valuelines, loaded by the unit viaEnvironmentFile). The installed file is a commented template coveringPORT,HOSTNAME,AUTH_BOOTSTRAP,JWT_SECRET,ADMIN_*/USER_*, andLLM_*. It is mode 0600 (read by systemd as root before privileges drop, so secrets stay away from other users) and marked as a config file — package upgrades never overwrite your edits. - State (server-side SQLite storage and generated credentials) lives in
/var/lib/libredb-studio, owned by the service'sDynamicUseraccount. - The unit runs with systemd hardening (
ProtectSystem=strict,NoNewPrivileges, empty capability set, ...) and only writes its state directory. - The
libredb-studiocommand (/usr/bin/libredb-studio) can also be run directly without systemd; configuration then comes from your shell environment, and state defaults to${XDG_STATE_HOME:-~/.local/state}/libredb-studio/whenSTORAGE_SQLITE_PATHis unset (the payload directory under/usr/libis read-only). - Removal (
apt remove/rpm -e) stops and disables the service; upgrades restart it if it is running (standard systemd maintainer scripts,packaging/linux/scripts/).
libredb-studio-bin repackages the prebuilt standalone tarball for Arch Linux and its derivatives
(x86_64 and aarch64). It installs the same launcher, systemd unit and /etc/libredb-studio/env
template as the .deb and .rpm, so the operating notes in the section above (configuration,
state directory, journalctl) apply unchanged:
paru -S libredb-studio-bin # or yay, or a manual makepkg from the AUR clone
sudo systemctl enable --now libredb-studio
journalctl -u libredb-studio # first run prints the generated admin password here- Node.js comes from the distribution, not from the package: it depends on
nodejs-lts-krypton(24.x, which provides/usr/bin/node) and links the launcher'snode/bin/nodeto it. Arch's plainnodejsis on 26.x; the 24.x line is the same pin the Homebrew formula uses (node@24). The two cannot be installed together. - Status. The package is staged in
packaging/aur/and the channel ispendingindistribution/channels.yamluntil the first push to the AUR (#971). The AUR account is the project's, registered withchannels@libredb.org, and the first push is made by hand from it. - Updates are automatic once the channel is live. The
aurjob inrelease-artifacts.ymlrenders thePKGBUILDfor each stable tag, builds and lints it in an Arch container, and pushes it over SSH with theAUR_SSH_PRIVATE_KEYsecret. The release switchboard reports the channel as enabled only while it islive, so flipping the status is the switch. Details and the manual first push:packaging/aur/README.md. - What was verified, and what was not, is listed in that README.
Published on the Snap Store for amd64 and arm64 (live
since 0.9.52; release CI publishes every release to the stable channel):
sudo snap install libredb-studio
sudo snap logs libredb-studio # first run prints the generated admin password here- Installing the snap starts a background daemon serving on port 3000.
- State (SQLite storage, generated credentials) lives in
$SNAP_DATA(/var/snap/libredb-studio/current). - The snap is strictly confined with only the
network/network-bindinterfaces: TCP database connections are the supported path; unix-socket connections to databases on the host (e.g./var/run/postgresql/) are not supported. - The
.snapfile is also attached to each GitHub release for offline installs (sudo snap install --dangerous libredb-studio_<version>_amd64.snap).
Unlike the .deb/.rpm packages (which ship /etc/libredb-studio/env), the snap has no
dedicated config file. Override environment variables with a systemd drop-in on the unit snapd
generates:
sudo systemctl edit snap.libredb-studio.libredb-studio.serviceAdd [Service] Environment= lines, then restart:
sudo systemctl restart snap.libredb-studio.libredb-studio.serviceThe drop-in is written to
/etc/systemd/system/snap.libredb-studio.libredb-studio.service.d/override.conf
(root-owned). Explicit values override the defaults in
snap/local/launch.sh and take precedence over
zero-config first run generation. The snap manifest deliberately sets
no environment: snap-exec applies a manifest environment: over the unit's, so a key set there
would silently ignore the drop-in (#807). Note that systemctl edit
creates the drop-in world-readable (mode 0644, like any systemd override), so after adding
secrets tighten it — systemd reads drop-ins as root, so this does not affect the service:
sudo chmod 600 /etc/systemd/system/snap.libredb-studio.libredb-studio.service.d/override.confExample drop-in (uncomment and fill what you need):
[Service]
# Bind (default is loopback — see Network exposure above)
#Environment=HOSTNAME=0.0.0.0
# Auth (optional; omit to keep zero-config bootstrap)
# JWT_SECRET must be 32+ chars: generate one with `openssl rand -base64 32` and
# paste the output. systemd does not expand a command here, and Environment= is
# split on whitespace, so a `$(...)` value is refused as an invalid environment
# block - the service then fails to start at all.
#Environment=AUTH_BOOTSTRAP=off
#Environment=JWT_SECRET=
#Environment=ADMIN_EMAIL=admin@libredb.org
#Environment=ADMIN_PASSWORD=
# AI query assistance
#Environment=LLM_PROVIDER=gemini
#Environment=LLM_API_KEY=
#Environment=LLM_MODEL=gemini-2.5-flash
#Environment=LLM_API_URL=http://127.0.0.1:11434/v1
# OIDC (login page reads NEXT_PUBLIC_AUTH_PROVIDER at runtime)
#Environment=NEXT_PUBLIC_AUTH_PROVIDER=oidc
#Environment=OIDC_ISSUER=https://example.auth0.com
#Environment=OIDC_CLIENT_ID=
#Environment=OIDC_CLIENT_SECRET=
# Persisted storage (default: sqlite under $SNAP_DATA — leave unset to keep it)
#Environment=STORAGE_PROVIDER=postgres
#Environment=STORAGE_POSTGRES_URL=postgresql://user:pass@127.0.0.1:5432/libredb?sslmode=disableStorage: by default the snap sets STORAGE_PROVIDER=sqlite and
STORAGE_SQLITE_PATH=$SNAP_DATA/libredb-storage.db — no further config is required. To switch
to server-side Postgres, uncomment the STORAGE_PROVIDER / STORAGE_POSTGRES_URL lines above
(TCP only; unix-socket Postgres on the host is not reachable under strict confinement).
Full variable reference: .env.example. OIDC setup details:
docs/OIDC.md. Storage providers: docs/STORAGE.md.
The win32-x64 standalone zip is built and attached to every
GitHub release since 0.9.59.
winget is live: the first listing
(microsoft/winget-pkgs#402985) merged on
2026-07-31, so winget install LibreDB.Studio resolves from the community repository and every
release now submits its update PR automatically. Chocolatey is live as well: the first push
(0.9.59) was approved by a community moderator on 2026-08-24, so choco install libredb-studio
resolves from the community repository and every release packs and pushes automatically (track
issue #114).
Scoop is live too: the Extras bucket listing
(ScoopInstaller/Extras#18786) merged on
2026-09-25.
This repository's release CI does nothing for it.
The bucket's Excavator workflow checks our release feed every four hours and rewrites the
manifest's version, URL and hash itself.
It finds releases through the manifest's checkver regex, releases/tag/([\d.]+)", and the hash
through the release's SHA256SUMS.
Renaming the release tag shape, the zip, or SHA256SUMS therefore stops Scoop updates without any
failure in this repository.
Chocolatey was switched off in the inventory until its moderation cleared (
update.ci_enabledindistribution/channels.yaml— see Turning a channel's automation off). Chocolatey is the reason the switch exists:push.chocolatey.organswers403 Forbiddenfor every version while an account's first submission is unapproved, which failed the 0.9.60 and 0.9.61 release runs even though both releases published correctly. The 2026-08-24 approval setci_enabled: trueandstatus: livein a single edit — exactly what winget got once its listing merged. The community feed has moved with every release since and serves 0.13.6 today, behind the current release, which is moderation lag; do not re-dispatchrelease-artifactsfor an already-published version to backfill the feed, because its asset steps would fight the immutable release — push a back version by hand (the same choco container the job uses for Chocolatey; the manual manifest recipe for winget).
# winget
winget install LibreDB.Studio
# Chocolatey
choco install libredb-studio
# Scoop (Extras bucket)
scoop bucket add extras
scoop install extras/libredb-studio
# Then, from any terminal - first run prints the generated admin credentials
libredb-studioOrganizations: from 2027-01-01 Chocolatey's Terms of Service restrict using the community repository to manage organizational fleets — pull the package into an internal NuGet-compatible repository (Nexus, Artifactory, ProGet) or a proxy that caches the community repository upstream, and install from there (policy update). The restriction is on consumption, not publication: it does not touch this repository's ability to publish, personal use stays permitted, and
choco install libredb-studiois unchanged when it resolves from an internal mirror. winget carries no equivalent restriction.
Open http://127.0.0.1:3000 and log in with the printed credentials. All three packages install the same standalone zip: the server payload, a bundled private Node.js runtime (node\node.exe),
and the libredb-studio.exe launcher.
The Db2 driver's native addon also needs the Microsoft Visual C++ 2015-2022 x64 redistributable (it imports VCRUNTIME140.dll).
winget and Chocolatey install it as a declared dependency (Microsoft.VCRedist.2015+.x64, vcredist140); with Scoop, install it once with scoop install extras/vcredist2022 if the machine lacks it.
The launcher mirrors the Linux packages' contract:
- Local-first bind (issue #134): the server binds
127.0.0.1regardless of any inheritedHOSTNAME; setLIBREDB_BIND=0.0.0.0to expose it on the network. - State: server-side SQLite storage and generated first-run credentials live under
%LOCALAPPDATA%\LibreDB\Studio\(created on first run) unlessSTORAGE_SQLITE_PATHis set, so upgrades and reinstalls never wipe state. - Configuration: environment variables only — the same set as Docker and the Linux packages
(
PORT,LIBREDB_BIND,JWT_SECRET,ADMIN_PASSWORD,LLM_*,OIDC_*,STORAGE_*, ...). See Configuration above and.env.example.
Download libredb-studio-standalone-<version>-win32-x64.zip and SHA256SUMS from the
release, verify, extract, run:
# Verify (compare against the SHA256SUMS line for the zip)
Get-FileHash .\libredb-studio-standalone-<version>-win32-x64.zip -Algorithm SHA256
# Extract (Explorer "Extract All...", or - tar -C needs the directory to exist:)
mkdir libredb-studio
tar -xf .\libredb-studio-standalone-<version>-win32-x64.zip -C libredb-studio
# Run from the extracted directory
cd libredb-studio
.\libredb-studio.exeThe zip is flat by design (see Release artifact naming). npx @libredb/studio also works on Windows (Node 24+): it downloads this zip, verifies it against
SHA256SUMS, and runs the payload with your own Node runtime.
Every other channel on this page ships LibreDB Studio as a server you open in a browser. The
desktop build ships it as an application window: a Tauri v2 shell starts the same standalone
server as a local sidecar on a random loopback port, waits for it to become healthy, signs you in
and shows the workspace. There is no browser tab, no port to remember and no password prompt for
your own machine. Details of the shell itself are in
desktop/README.md (issue
#232).
State lives in the per-user data directory - ~/.local/share/org.libredb.Studio for the
AppImage, ~/.var/app/org.libredb.Studio/data/org.libredb.Studio under Flatpak - and holds the
SQLite storage database plus the generated admin credentials (auth-bootstrap.json, mode 0600).
Deleting that directory resets the app.
The session cookie is not marked Secure when a request arrives on a loopback host over plain
http, which is what makes the desktop session work: the cookie store behind WebKitGTK discards
a Secure cookie delivered over http instead of ignoring the flag the way Chromium does on
localhost. Nothing changes for any other deployment - a request on a public host, or one a
proxy forwarded with x-forwarded-proto: https, still gets Secure.
# Download the AppImage and its checksum sidecar from the release, verify, run
curl -fLO https://github.com/libredb/libredb-studio/releases/download/<version>/libredb-studio-desktop-<version>-linux-x64.AppImage
curl -fLO https://github.com/libredb/libredb-studio/releases/download/<version>/libredb-studio-desktop-<version>-linux-x64.AppImage.sha256
sha256sum -c libredb-studio-desktop-<version>-linux-x64.AppImage.sha256
chmod +x libredb-studio-desktop-<version>-linux-x64.AppImage
./libredb-studio-desktop-<version>-linux-x64.AppImageThe AppImage needs FUSE to mount itself; on a system without it (or in a container), run it with
--appimage-extract-and-run. Built for x64 and arm64 against current desktop distributions -
the server packages (.deb/.rpm, Snap, Docker) remain the path for headless or older systems.
curl -fLO https://github.com/libredb/libredb-studio/releases/download/<version>/libredb-studio-desktop_<version>_amd64.deb
curl -fLO https://github.com/libredb/libredb-studio/releases/download/<version>/libredb-studio-desktop_<version>_amd64.deb.sha256
sha256sum -c libredb-studio-desktop_<version>_amd64.deb.sha256
sudo apt install ./libredb-studio-desktop_<version>_amd64.debShips from 0.9.62 for amd64 and arm64. Unlike the AppImage it needs no FUSE, integrates with
the desktop menu on install, and takes libwebkit2gtk-4.1-0 and libgtk-3-0 from the distribution
rather than bundling them - so it is the smaller, better-behaved choice on a Debian or Ubuntu
desktop, and the older-distro caveat that applies to the AppImage applies here too.
This is not the server package. libredb-studio_<version>_<arch>.deb installs a systemd
service; this one installs an application. They have different dpkg package names and can be
installed side by side.
The bundled Node sidecar is installed as /usr/bin/libredb-studio-node, not /usr/bin/node:
that path belongs to the distribution's nodejs package and dpkg refuses to let a second package
claim it.
FlatPark is a signed Flatpak remote and the Flatpak channel for LibreDB Studio, live since 0.9.62 (#241):
flatpak --user remote-add --if-not-exists flatpark https://dl.flatpark.org/flatpark.flatpakrepo
# Flathub is added for the org.gnome.Platform runtime only - the app itself is not published there
flatpak --user remote-add --if-not-exists flathub https://dl.flathub.org/repo/flathub.flatpakrepo
flatpak --user install flatpark org.libredb.Studio
flatpak run org.libredb.StudioFlatPark pins the GUI .deb as extra-data: it builds nothing itself, so your own machine
downloads that exact release asset at install time and unpacks it inside the sandbox. It does not
accept AppImages at all - its runtime has no libfuse.
The Flatpak sandbox has network access and no filesystem access. Databases reachable over TCP -
including a database on the host at 127.0.0.1 - work out of the box; opening local SQLite files
or connecting through a Unix socket needs an explicit grant, the same trade-off the strictly
confined Snap makes:
# Local Postgres over its unix socket
flatpak override --user --filesystem=/run/postgresql:ro org.libredb.Studio
# Local MySQL/MariaDB over its unix socket
flatpak override --user --filesystem=/var/run/mysqld:ro org.libredb.Studio
# A directory of SQLite database files
flatpak override --user --filesystem=~/databases org.libredb.Studio
# Revert every override
flatpak override --user --reset org.libredb.StudioThe descriptor set, the local build flow and the submission checklist live in
packaging/flatpark/README.md.
LibreDB Studio is not on Flathub, and flatpak install flathub org.libredb.Studio will not
work. Install from the FlatPark remote above - same app id, same sandbox policy, same
flatpak override commands.
The submission (flathub/flathub#9538) was declined on 2026-07-30 under Flathub's generative AI policy. The reviewer's position was that developing with an AI assistant disqualifies the app, irrespective of who designed, reviewed and released it. We closed the PR ourselves rather than argue the point; the closing comment leaves the verifiable maintenance record on the thread.
packaging/flatpak/ stays in the tree and stays honest: the manifest
still renders, and flatpak-smoke.yml still builds the
AppImage and repacks it whenever those paths change. But a resubmission would also have to be built
from source - repacking a released AppImage is a second, independent blocker under Flathub's
requirements - so treat that directory as dormant rather than as pending work
(#232).
Neither channel needs a published release to test. Both take a locally built artifact:
# FlatPark: build the GUI .deb, then the Flatpak that pins it.
# --deb-only skips the AppImage, so the linuxdeploy GTK toolchain (librsvg2-dev
# and friends) is not needed.
gh release download <version> --repo libredb/libredb-studio \
--pattern "libredb-studio-standalone-<version>-linux-x64.tar.gz"
bash scripts/build-desktop-appimage.sh dist-desktop \
--payload libredb-studio-standalone-<version>-linux-x64.tar.gz --deb-only --smoke
bash scripts/build-flatpark-local.sh \
dist-desktop/libredb-studio-desktop_<version>_amd64.deb --install
flatpak run org.libredb.Studio//stable
# packaging/flatpak (dormant, see above): build the AppImage, then the Flatpak that repacks it.
bash scripts/build-desktop-appimage.sh dist-desktop --payload <tarball> --smoke
bash scripts/build-flatpak-local.sh \
dist-desktop/libredb-studio-desktop-<version>-linux-x64.AppImage --install
flatpak run org.libredb.StudioDrop --payload to build the payload from the working tree instead of a released tarball - slower,
but it tests the code you actually have.
Both need the builder and the runtime the manifests declare:
flatpak install -y flathub org.flatpak.Builder org.gnome.Platform//50 org.gnome.Sdk//50The FlatPark script builds on branch stable, so its ref does not collide with a Flathub-style
local build of the same app id. Note that both share ~/.var/app/org.libredb.Studio/ - the app
id is the same - so deleting that directory resets connections and query history for whichever
builds you have installed. To remove a local FlatPark build:
flatpak --user uninstall -y org.libredb.Studio//stable
flatpak --user remote-delete libredb-flatpark-localBoth channels install the same published container image through a catalog the platform itself
publishes, so a user browsing that platform finds LibreDB Studio without ever visiting this repo.
They are documented here rather than under deploy/<provider>/ because neither descriptor lives in
this repo: the Sealos template lives upstream in
labring-actions/templates and the Unraid template
in libredb/unraid-templates, and neither has a
deploy/<provider>/ folder here at all.
Most catalog channels have no such folder; for each one, links.docs in distribution/channels.yaml names the page that documents it, and docs/CHANNELS.md, the full channel list, links that page in its Guide column.
The catalog channels that DO keep a folder keep their notes in deploy/<provider>/README.md - CapRover and Railway alongside the source
descriptor itself, Dokploy, Kubero and Cosmos as notes only, since those three descriptors are also
authored upstream.
Both templates pin an explicit version tag, not latest, so a new release reaches these users
only when the template is bumped - on_demand for both in
distribution/channels.yaml.
Listed in Community Applications (CA) since 2026-08-04 (issue #283). In the Unraid web UI: Apps tab, search for LibreDB Studio, then Install.
- The web UI host port defaults to 3006, mapped to container port 3000. Any free host port works; the template steers away from 3000 because it commonly collides with other apps.
- App Data is
/mnt/user/appdata/libredb-studiomapped to/app/data, holding the server-side SQLite storage (STORAGE_SQLITE_PATH=/app/data/libredb-storage.db) - that database is what survives container updates and recreation. Noauth-bootstrap.jsonis written here, because the template always suppliesADMIN_PASSWORDandJWT_SECRETand generation short-circuits when both are set: back up the database, and keep the credentials you typed into the template. - Credentials are entered in the Add Container form, not read back from a log: the template
marks
ADMIN_EMAIL,ADMIN_PASSWORD(minimum 8 characters) andJWT_SECRET(minimum 32 characters) as required fields. That is a template choice for a NAS audience, not an app requirement - see the exception noted under Zero-config first run. AUTH_COOKIE_SECURE=falseships as the default because a LAN install is served over plain HTTP. The Helm chart exposes the same switch asconfig.authCookieSecurerather than shipping it set: a chart install is as likely to sit behind an ingress terminating TLS, where the flag must stay on. Set it totrueonce the app sits behind HTTPS (a reverse proxy); leaving itfalseon a public host sends the session cookie in cleartext.- The container is unprivileged, on
bridgenetworking, and needs no other service - databases are reached over TCP from the Unraid host's network.
Because the template repo is LibreDB-owned, a version bump is a commit in
libredb/unraid-templates rather than a PR here or upstream; CA then serves it after its next
catalog build, which is when Unraid offers the update.
Listed in the Sealos App Store since 2026-08-04 (issue #276, labring-actions/templates#739). Click Deploy Now and supply an administrator email and password (minimum 8 characters); the template provisions compute, networking, storage and ingress, so there is nothing to install locally.
- Storage defaults to SQLite on a 1 GiB PVC. The template also offers Use PostgreSQL
storage, which provisions a KubeBlocks-managed PostgreSQL instance and points
STORAGE_PROVIDER/STORAGE_POSTGRES_URLat it - seedocs/STORAGE.md. - The template sets
AUTH_BOOTSTRAP=offand generatesJWT_SECRETitself, so the deployment starts in strict mode with the password you entered - there is no generated-credentials banner to read from the pod log. - Bumps go in as a template PR to
labring-actions/templates. That repo's default branch iskb-0.9, notmainormaster, which is what both the drift-check pin URL and any bump PR must target.
Listed on Dokku's community plugins page since 2026-10-08 (dokku/dokku#9116).
Dokku has no application catalog, so the channel is a Dokku plugin, libredb/dokku-libredb-studio, rather than a template.
On the Dokku host:
sudo dokku plugin:install https://github.com/libredb/dokku-libredb-studio.git
dokku libredb-studio:install
dokku letsencrypt:enable libredb-studiolibredb-studio:install creates the libredb-studio app, deploys a pinned image tag, connects Studio to every postgres, mysql, mariadb, mongo and redis service on the host, and prints the admin login.
Services created or destroyed later are added to or removed from Studio by the plugin, through the seed connection file Studio re-reads, with no restart.
The login cookie is Secure, hence the letsencrypt step; flags, the network model and password recovery are in the plugin's README.
The plugin is LibreDB-owned, so a version bump is a commit and a release in that repo, not a PR here or upstream.
Publicly listed since 2026-09-04 as a free Terraform Kubernetes app: a customer finds LibreDB Studio in the Marketplace catalog inside the Google Cloud console and deploys it into their own GKE cluster. Deploying requires the Kubernetes Engine Admin role on the target project.
It is the one channel in the inventory with no public artefact to read a version from, and that
shapes everything below. The listing, its version and its Terraform module are all held in Google's
Producer Portal; the Helm chart and the container image are served from a private Artifact Registry
under the libredb-public project. So pin.strategy is none in
distribution/channels.yaml and the listed version is checked by
hand against the portal — the drift table cannot do it, and a pin that guessed would be worse than
no pin.
The Marketplace version is its own number. Google requires the chart and the image tags of a
Terraform Kubernetes app to share a MAJOR.MINOR, which the repo's chart version (0.1.x) and the
application version (0.14.x) do not. The published copy is therefore packaged with a version of
its own — helm package --version <n> --app-version <n> at submission time, leaving the repo's
chart untouched. The listing currently reads 0.10, so a Marketplace version is not comparable
with a release tag and must not be read as one.
Every change is a portal submission, not a pull request. update.method is manual_ui and
update.sla is on_demand because each new version goes through Google's own review before it
reaches customers; nothing here can be driven from release CI, and no upstream repository accepts a
bump PR for it.
The single source of truth for the release archives also works locally (Linux and macOS; on
Windows it runs under Git Bash and produces the flat zip — the release job then adds the
bundled Node runtime and launcher, see packaging/windows/README.md):
bun install
bash scripts/build-standalone-payload.sh dist --smoke
# -> dist/libredb-studio-standalone-<version>-<os>-<arch>.tar.gz--smoke boots the packed payload with node server.js and requires
GET /api/db/health to return 200. Run the result via the npx launcher:
npx @libredb/studio --archive dist/libredb-studio-standalone-<version>-<os>-<arch>.tar.gz.
LibreDB Studio for VS Code lives in vscode-extension/, an npm package of its own with a version line of its own.
It has been on the Visual Studio Marketplace since 21 October 2026, under the publisher libredb: 0.1.0 and 0.1.1 as pre-releases, then 0.2.0 as its first release.
A listing with pre-releases only refuses a plain install (code --install-extension libredb.libredb-studio fails with "it has no release version"), which is why 0.2.0 is a release.
This section is its release runbook.
Its build, tests and CI job are in docs/TOOLCHAIN.md, "VS Code extension".
The extension's version is the version of vscode-extension/package.json, independent of the product's bare semver.
The Marketplace takes major.minor.patch only, with a pre-release as a flag rather than a suffix, and recommends an odd minor for pre-releases.
So every 0.1.x is a pre-release, 0.1.0 included, and the first release is 0.2.0.
A later pre-release takes the next odd minor, 0.3.x, and stays above the release users have, so only those who chose pre-releases get it.
vscode-extension/test/unit/manifest.test.ts holds the version to the 0.4.x line, the release after 0.2.x that opens every pure-JS engine, so a version that leaves it changes that case in the same PR, and test/unit/readme.test.ts holds the README to calling itself a pre-release exactly on an odd minor.
- The version step is a PR to
mainthat runsnpm version <version> --no-git-tag-versioninvscode-extension/, which writespackage.jsonandpackage-lock.jsontogether;vscode-extension/test/unit/manifest.test.tsfails when the two drift. - The release notes are that PR's body.
No
CHANGELOG.mdships: the repository has no changelog generator, andvscode-extension/scripts/check-vsix.mjsrefuses a packed changelog. - Once the PR has merged, the owner tags
main's merge commitvscode-extension-<version>and pushes the tag. The namespace is the extension's own, like the chart'slibredb-studio-<chart version>, so product tags stay bare semver andrelease-artifacts.ymlnever sees it.
vscode-extension-release.yml runs on a pushed vscode-extension-* tag.
Its verify job runs vscode-extension/scripts/release-tag.mjs, which refuses a tag that is not vscode-extension- plus the manifest's version, and refuses a tag whose commit is not on main.
Its gates job then calls vscode-extension.yml, the same reusable workflow every pull request runs, smoke matrix included, which uploads the tested VSIX as the vscode-extension-vsix workflow artifact and the packaged build's dist/*.js.map as vscode-extension-sourcemaps.
It publishes nothing: the workflow has no publish job and no environment, and tests/unit/vscode-extension-workflow.test.ts holds that until a PR adds one.
No GitHub Release is created either.
npm-publish.yml, docker-build-push.yml and aws-ami-build.yml run on every release: published and skip only the chart's libredb-studio- prefix, so a GitHub Release for the extension would start a product publish and, while the aws-marketplace channel is live, an AMI build from the root package.json version.
operator-release.yml runs on it too, but already skips a tag that is not an app semver.
Until a publish job exists, the owner uploads the VSIX the gates tested; nothing is rebuilt for the upload.
- Download the
vscode-extension-vsixartifact of the tag'svscode-extension-release.ymlrun, or ofmain'svscode-extension.ymlrun at the tagged commit, and check that its manifest namesPublisher="libredb", the version, and, on an odd minor, thePreReleaseproperty. - Install it into a fresh VS Code profile (
code --user-data-dir <dir> --extensions-dir <dir> --install-extension <file>) and run one query on each v0 engine. - On https://marketplace.visualstudio.com/manage/publishers/libredb, upload it as a new version of LibreDB Studio, or through New extension > Visual Studio Code for a new extension.
- When the Marketplace has verified it, check that
code --install-extension libredb.libredb-studioinstalls that version in another fresh profile, with--pre-releaseon an odd minor.
A version number can be uploaded once, so a fix to a published version is the next patch.
The listing's text and images come from the packed README.md; the management page has no field for them, so changing them is a new version.
- The Marketplace publisher id is
libredb, display name LibreDB; it cannot change, and the extension's id islibredb.libredb-studio. - The publisher is owned by named members' Microsoft accounts, added by User ID on the publisher's Members page, so no single account holds it.
- 0.1.0 was uploaded by hand from
mainat 15fff4b04 (thevscode-extension-vsixartifact of run 39575773660), with no token, after a check on the four v0 engines in a fresh VS Code profile. - The tag namespace is
vscode-extension-<version>, as in "Cutting a version" above andCLAUDE.md's "Tag namespace" paragraph.
- The Marketplace authentication for a publish job, before global Azure DevOps PATs retire on 2026-12-01: trusted publishing (
vsce publish --oidcwithid-token: write) or Entra ID (--azure-credential). - The publish job:
vsce publish --pre-release --packagePath <file>of the VSIX the gates tested, from a GitHub environment that requires a reviewer, and who that reviewer is. - When the
VS Code Extensionjob and its macOS and Windows smoke matrix become required checks. - Whether the extension ever gets GitHub Releases; if it does,
npm-publish.yml,docker-build-push.ymlandaws-ami-build.ymlmust first learn to skipvscode-extension-tags. - The Open VSX namespace
libredb, and when to publish there (ovsx publish --pre-release) for Cursor, Windsurf and VSCodium, after their extension host runtimes are measured.
The extension is the vscode-marketplace row of distribution/channels.yaml: kind: marketplace, runtime: channel_supplied, method: manual_ui.
evaluateChannel in scripts/distribution-check.mjs compares a pinned row with the root package.json version, which is not the extension's, so the row has strategy: none and the listed version is checked by hand, as Azure's and AWS's are.
A pin that reads the listing's version against vscode-extension/package.json would need its own comparison in the checker, and its test.
Release publishing is driven by two workflows, both triggered on release: published (plus
workflow_dispatch):
docker-build-push.yml— GHCR image (version +latesttags) and the Docker Hub mirror.release-artifacts.yml— standalone tarballs + the win32 zip +SHA256SUMS,.deb/.rpm(nfpm,packaging/linux/), the Homebrew formula (packaging/homebrew/rendered byscripts/render-homebrew-formula.mjs), the snap (snap/snapcraft.yaml), and — after the release publishes — the Chocolatey push and winget update PR (packaging/chocolatey/andpackaging/winget/rendered byscripts/render-windows-packaging.mjs).- The npm package (
@libredb/studio, which carries the npx launcherbin/studio.js) is published by the separate npm-publish workflow, also onrelease: published.
Every zero-config channel must prove — in a real browser — that a fresh install
serves the embedded sample connections ("Sample (LibreDB)", "Sample
(Employees)"). One bun run test:e2e is not enough: each channel boots the
payload through a different launcher, cwd, and STORAGE_SQLITE_PATH, so a
packaging bug (a payload missing seed-assets/, a dropped fileset entry)
surfaces only in the affected channel.
The shared spec is e2e/embedded-samples.spec.ts;
playwright.channel.config.ts runs it against an
externally booted server (CHANNEL_E2E_BASE_URL, no webServer). The orchestrator
boots one channel, waits for health (and best-effort for the async seed), runs
Playwright, and tears down:
scripts/channel-embedded-sample-e2e.sh <channel> [artifact]
scripts/channel-embedded-sample-e2e.sh all # every channel feasible here
# channels: tarball | npx | docker | deb | rpm | snap | homebrew
scripts/channel-embedded-sample-e2e.sh tarball dist/libredb-studio-standalone-*.tar.gz
scripts/channel-embedded-sample-e2e.sh docker ghcr.io/libredb/libredb-studio:mainall skips infeasible channels (no docker daemon, no passwordless sudo, not
macOS, missing artifact) and prints a summary. Where each channel runs in CI:
| Channel | CI gate |
|---|---|
| next-dev | ci.yml e2e job (regular Playwright run includes the spec) |
| tarball, npx | ci.yml channel-e2e job (payload artifact) |
| docker | docker-build-push.yml channel-e2e job (pushed image; gates the Helm release dispatch) |
| deb, rpm | release-artifacts.yml linux-packages job (amd64; alongside the zero-config smoke) |
| snap | release-artifacts.yml snap job (amd64; runs before the store publish) |
| homebrew | not in CI (linux runners) — run locally on macOS |
All channels have now had their first live run (the Snap publish completed its first with 0.9.52, validated per this runbook). Right after publishing a release:
-
Watch the
release-artifactsrun: all four tarball legs green (if themacos-15-intelormacos-14runner labels ever disappear, check the current labels in actions/runner-images - do not fall back to retiredmacos-13or paid-largelabels blindly),.deb/.rpmuploaded with.sha256sidecars,SHA256SUMScomplete, tap push and snap jobs behaving per their secrets. -
npx @libredb/studio@<version>on a clean machine: download + checksum + first-run banner + login. Thenpx Engine Smokeworkflow (npx-engine-smoke.yml) runs automatically after a successful NPM Publish: it waits for the registry to serve the released version, then runs barenpx @libredb/studioon Node 24/26 and asserts each resolves exactly that release, and on Node 22 asserts the engines floor pins it to an older but still runnable release (the #130 regression class - npm's picker avoids engine-incompatible versions for bare specs and never says so). Check that it went green; dispatch it manually to re-run. Then run thenpm deprecatestep below so the pin stops being silent. -
brew tap libredb/tap && brew install libredb-studio && brew services start libredb-studio. -
Download the
.debon Debian/Ubuntu:dpkg -i,systemctl start libredb-studio, health 200 on127.0.0.1:3000; verify the arm64 package on an arm64 machine (the CI smoke covers amd64 only; the bundled node arch is statically asserted for both). -
Spot-check provenance on one downloaded asset and the image - a green release job proves the attestation step ran, not that the published asset is the one it covers:
gh attestation verify <asset> --repo libredb/libredb-studioandgh attestation verify oci://ghcr.io/libredb/libredb-studio:<version> --repo libredb/libredb-studio, plusnpm audit signaturesafter installing the package (see Verifying a release artifact). -
Only when a release raises
engines.node: deprecate the last version below the new floor, so the users npm silently pins there are told why.npm deprecateis the only npm mechanism that prints a message on install, and it targets exactly the pinned population - callers who resolve a supported version never see it.npm deprecate '@libredb/studio@<=0.10.0' \ 'LibreDB Studio 0.11.0+ requires Node.js 24 LTS; npm pinned you to this release because your Node is older. Upgrade Node, or run: docker run -p 3000:3000 ghcr.io/libredb/libredb-studio:latest'
Deprecating a version does not unpublish or hide it: it keeps installing and running exactly as before. Undo with
npm deprecate '@libredb/studio@<=0.10.0' ''.
Standalone tarballs and .deb/.rpm packages are checksum-verified against SHA256SUMS /
per-package .sha256 sidecars (see Release artifact naming) — both
from the same GitHub release. That pairing detects corruption, not substitution: whoever can
replace an asset can replace its checksum line too. The .snap release asset ships no sidecar
(--dangerous installs skip the Snap Store's own verification).
Signed provenance moves the trust root out of the release — the signer is the workflow's GitHub OIDC identity (repo + workflow + commit), recorded in a public transparency log. All three steps of issue #123 landed for 0.9.63:
| Step | Scope | State |
|---|---|---|
| npm provenance | npm tarball (npm publish --provenance, Sigstore bundle held by npmjs) |
done (0.9.63) — verify with npm audit signatures |
| SLSA build provenance | standalone tarballs, win32 zip, .deb/.rpm, desktop AppImage + GUI .deb, .snap, GHCR image (actions/attest-build-provenance) |
done (0.9.63) — verify with gh attestation verify |
| Launcher-side verification | bin/studio.js checks the downloaded archive's attestation when gh is present |
done (0.9.63) — see Launcher provenance check |
No step needed a new secret: the attestations live in GitHub's attestation store, not in the release, so nothing an attacker can reach by replacing a release asset also lets them forge a signature.
# Any release asset (tarball, zip, .deb, .rpm, .AppImage, .snap)
gh attestation verify libredb-studio-standalone-0.9.63-linux-x64.tar.gz \
--repo libredb/libredb-studio
# The container image, by digest or by tag
gh attestation verify oci://ghcr.io/libredb/libredb-studio:0.9.63 \
--repo libredb/libredb-studioA pass prints the workflow and commit that produced the artifact. gh attestation verify reaches
GitHub for the bundle, so it needs network access; --bundle verifies a previously downloaded
one offline.
Notes on what is and is not covered:
- The
.snapattestation only exists when the release actually built one (the job is gated onSNAPCRAFT_STORE_CREDENTIALS); Snap Store installs are verified by the store instead. SHA256SUMSand the.sha256sidecars are deliberately not attested — a signature over a checksum file proves nothing about the artifact it describes. The artifacts are the subjects.- Images built from branch pushes (the mutable
main/devtags) are not attested; only release-context builds are. - The image attestation is not pushed to GHCR as an OCI referrer, so
cosign-style registry-only verification will not find it — fetch it from GitHub withgh attestation verify oci://…. - Buildx's own inline provenance on the manifest is unrelated and unsigned; it is left at the action's defaults on purpose (changing it rewrites the manifest structure, which the Docker Hub mirror's immutable-tag rules are sensitive to).
Failure modes to expect on release day: npm publish --provenance hard-fails if the job lacks
id-token: write or if Sigstore is unreachable, and actions/attest-build-provenance fails the
same way — with a job-level attestations: write missing, or if the GitHub attestation API is
down. A failed npm publish cannot be retried on the same tag (follow the usual rule and cut the
next patch version); a failed attestation happens before gh release upload in the same job, so
re-running that job is safe — the release is still a draft at that point.
Optional-channel steps are skipped cleanly when their secret is absent, so forks and partial setups still publish the rest:
| Secret | Gates | Without it |
|---|---|---|
TAP_GITHUB_TOKEN |
Rendering and pushing the Homebrew formula to libredb/homebrew-tap (needs write access to that repo) |
Tap update skipped; tarballs still attach to the release |
SNAPCRAFT_STORE_CREDENTIALS |
The entire snap build/publish job (exported via snapcraft export-login) |
Snap job skipped |
DOCKER_HUB_TOKEN (+ DOCKER_HUB_USERNAME and DOCKER_HUB_ORGANIZATION variables) |
The Docker Hub mirror push. Two variables, not one: DOCKER_HUB_USERNAME is the owner who logs in (an organization cannot sign in), DOCKER_HUB_ORGANIZATION is the namespace the image is published under. They were the same value while libredb was a user account; converting it to an organization on 2026-09-01 split them |
GHCR-only publish |
CHOCO_API_KEY |
The chocolatey job: choco pack + choco push to https://push.chocolatey.org/ (API key of the libredb community account) |
Chocolatey publish skipped; the win32 zip still attaches to the release |
| — | Every row above whose channel is switchable also needs update.ci_enabled: true in distribution/channels.yaml: the secret says CI can publish, the flag says it should. See Turning a channel's automation off |
Channel skipped with a notice; the release publishes normally |
OPERATOR_CATALOG_TOKEN |
The submit-catalogs job in operator-release.yml: bundle PRs to k8s-operatorhub/community-operators and redhat-openshift-ecosystem/community-operators-prod. Classic PAT with public_repo on an account in the operator's upstream ci.yaml reviewers list, because that login is what upstream authorizes |
Catalog submission skipped with a notice |
AUR_SSH_PRIVATE_KEY |
The aur job: render, build and git push of libredb-studio-bin to ssh://aur@aur.archlinux.org/libredb-studio-bin.git. The private half of the SSH key registered on the project's AUR account (channels@libredb.org). Runs only while the aur channel is live |
AUR push skipped |
WINGETCREATE_GITHUB_TOKEN |
The winget job: wingetcreate update --submit PRs to microsoft/winget-pkgs. Classic PAT with public_repo scope — wingetcreate does not support fine-grained PATs |
winget submission skipped |
CAPROVER_CATALOG_TOKEN |
caprover-fork.yml: fast-forwarding the libredb/caprover-one-click-apps fork, creating it first when update.fork.mode is create_or_update, and pushing the libredb-studio-<version> branch a member opens the CapRover catalog PR from. For mode: update, a fine-grained PAT with access to libredb/caprover-one-click-apps only and Contents read and write; create_or_update also needs GitHub's fork endpoint (Administration write on the org's repositories). See Staging an upstream catalog change on a fork |
The workflow validates the templates and pushes nothing |
The chocolatey and winget jobs run strictly after publish-release: both channels download
the zip from the release URL, which is public only once the release is published. A failure there
never unpublishes or blocks the release (same trust model as the npm/Docker dispatch chain) —
but it does make the workflow run report failure overall, which reads as a failed release when
it was not. That is why a channel known to be unable to publish is switched off in the inventory
rather than left to fail: the run's colour has to mean something.
By contrast, the windows-package job (which builds the win32 zip) is a hard release gate,
exactly like the POSIX build matrix: the zip is on the publish-release required-assets list, so
a Windows build failure blocks the whole release train by design — a release without its
Windows artifact would strand winget/Chocolatey (their manifests point at that exact asset) and
npx on win32.
The win32 zip has shipped in every release since 0.9.59. The release automation for winget
and Chocolatey is secret-gated and ready — both CHOCO_API_KEY (API key of the libredb
account on community.chocolatey.org) and WINGETCREATE_GITHUB_TOKEN are configured in the
repo's Actions secrets. The FIRST listing in each community catalog is a one-time human step
(same pattern as the Snap Store name registration). Both first submissions were made with
0.9.59, and both are done: winget merged on 2026-07-31 and Chocolatey was approved on
2026-08-24; each is live with ci_enabled: true.
-
Chocolatey — DONE: the 0.9.59 push was approved by moderator
flcdrgon 2026-08-24 and the package is listed at community.chocolatey.org/packages/libredb-studio. The release's chocolatey job packs and pushes every version automatically. The first push enters human moderation; while a version sits in the moderation queue, pushing further versions can be rejected — if a release-time push fails during that window, re-run the job after approval. -
winget — DONE: microsoft/winget-pkgs#402985 (the Microsoft CLA is signed on that first PR). The first manifest could not be submitted by
wingetcreate update— the package id did not exist yet, and the release job detects that and skips with a notice — so it was rendered from the in-repo templates and opened by hand. Later releases submit update PRs automatically; the same manual recipe still covers a back version (a release published while the channel was switched off, since re-dispatchingrelease-artifactsfor a published version is not an option):VERSION=<version> gh release download "$VERSION" --pattern SHA256SUMS --dir dist for t in packaging/winget/*.tmpl; do out="manifests/l/LibreDB/Studio/$VERSION/$(basename "${t%.tmpl}")" node scripts/render-windows-packaging.mjs "$t" dist/SHA256SUMS "$VERSION" "$out" done
Then either
wingetcreate submit --token <classic-PAT> manifests/l/LibreDB/Studio/$VERSION(Windows only — wingetcreate ships a win-x64 binary), or, from any OS, commit the three files to a branch on amicrosoft/winget-pkgsfork and open the PR withgh— the whole submission is "addmanifests/l/LibreDB/Studio/<version>/", nothing else changes. Title convention:New package: <id> version <version>for a first listing,New version: <id> version <version>for an update. Upstream's validation pipeline downloads theInstallerUrland verifiesInstallerSha256, so the release must already be published. -
After each catalog goes live: flip its
distribution/channels.yamlentry tostatus: live, setlinks.first_pr, and add a measurable pin where one exists. Chocolatey is measured by aremote_filepin on the community OData feed filtered toIsLatestVersion, which is what makes a single-match regex possible there: the feed enumerates every published version, so an unfiltered listing would yield as many disagreeing matches as there are versions. Note what that pin measures: the feed lists approved versions only, so a ChocolateyDRIFTrow means the version is still in moderation, not that the push failed. winget is measured by a probe, not a regex pin (pin.strategy: probe,github-dir-max-version): it publishes no floating "latest" document — the winget-pkgs contents listing enumerates every published version — so the checker takes the highest version enumerated there. A regex pin cannot express that, because it requires all matches in a source to agree. The same probe measuresoperatorhub-community, with onepin.urlsentry per community catalog. Note what alocal_filepin would have measured there instead:operator/bundleis only the submission source, so it reads the version we are ready to submit, never the version either catalog serves.
choco push publishes nothing by itself. The community repository is a moderated feed: every new
version is human-reviewed before approval — only trusted packages skip that — and the moderation
team's own figure for the wait is "a few days to a few weeks"
(moderation,
Behind the Curtain).
An unapproved version stays unlisted, so choco install libredb-studio keeps serving the previous
one until a moderator clicks approve.
This is the one structural difference from winget. There is no PR to open and nothing manual per
release — the community repository takes a .nupkg over an API and the chocolatey job pushes it —
but what follows the push is a volunteer's queue, not an automated merge. Two consequences are
encoded in the release path:
- The push step is
continue-on-error;choco packis not.push.chocolatey.organswers403for causes that belong to that queue rather than to this repository: "package has a version in moderation and no approved versions" (what failed the 0.9.60 and 0.9.61 runs) and "package has too many existing versions in moderation" (common errors), the second of which becomes reachable whenever releases outpace the queue. A release that published correctly must not go red for that. A pack failure is our own broken template, so it still fails the run.tests/unit/release-chocolatey.test.tslocks both halves. - The run's job summary carries a "Chocolatey" section with the push outcome and what the feed
says about that exact version. It reads
Packages(Id='libredb-studio',Version='<version>')— the only public read that answers for a version the feed does not list yet, since a feed-wide$filter=IsApproved eq falsereturns nothing whileeq truereturns rows.
Trusted status is the fix, and it is a human decision. The FAQ names two routes to it — "You
write the underlying software that the package installs" and a track record of clean submissions —
and is equally clear that switching a package to trusted "is a manual change by a moderator. It is
not an automated process, and does not happen immediately even if you are the software author", in
most cases only "after a few versions have been approved by moderators without any changes being
required". LibreDB qualifies on the first route and has one clean approval (0.9.59). After two or
three more, ask on the Chocolatey Community Hub (#community-maintainers) as the software vendor —
tracked as REL3 in docs/BACKLOG.md. A trusted package skips human review entirely,
and the channel then behaves the way winget does today.
For the coverage matrix (live counts by category and platform, full channel
table), see docs/CHANNELS.md. Regenerate it with
bun run distribution:matrix after editing the inventory; bun run distribution:matrix --check
fails when the generated regions are stale.
distribution/channels.yaml is the machine-readable inventory
of every distribution channel: identity, business category, update policy, provenance links, and (where measurable)
where its pinned version lives. bun run distribution:check
(scripts/distribution-check.mjs) compares every live
channel's pin against package.json and prints a markdown drift table; the weekly
distribution-check.yml workflow writes the same
table to its Job Summary (cron + manual dispatch — deliberately not release: published, which
GITHUB_TOKEN-published releases never fire). The checker only reads the inventory; bumping a
pin or editing a channel entry is always a human commit.
Tiers describe who controls publication:
| Tier | Meaning | Examples |
|---|---|---|
| 0 | Core registries, published directly by release CI | GitHub Releases, GHCR, Docker Hub, npm |
| 1 | Packaged formats owned by this repo, CI-published | Helm, Homebrew tap, Snap, .deb/.rpm, desktop AppImage |
| 2 | LibreDB-owned copies and listings, bumped by hand | Railway, Koyeb button, Fly.io config, Render Blueprint, Unraid CA template |
| 3 | Upstream community catalogs, bumped via PR | CapRover official, Dokploy, Cosmos, Kubero, Sealos, TrueNAS SCALE |
| 4 | Partner or curated catalogs (not self-serve) | Rancher partner charts, Koyeb catalog, DigitalOcean, Google Cloud Marketplace, winget, Chocolatey, Scoop, Flathub |
Categories (category on every channel) are the business-facing buckets rendered in
docs/CHANNELS.md: registries-releases, containers,
kubernetes-operators, package-managers, os-desktop, paas-catalogs,
deploy-recipes, cloud-marketplaces. They are independent of tier (who
publishes). paas-catalogs and deploy-recipes look similar but answer different
questions: paas-catalogs means the platform itself lists LibreDB Studio in its own
catalog, so a user browsing that platform discovers it without visiting this repo;
deploy-recipes means we publish a config file or instructions and the platform does
not list us anywhere, so discovery only happens through our repo. There is no closed
category: a declined or retired channel is status: deprecated with its category
unchanged (Flathub is category: package-managers, status: deprecated) — category
describes what the channel technically is, status describes its lifecycle, and an
earlier revision that conflated the two (Flathub filed under a closed category) is
what produced a false coverage claim in the scorecard.
Kind (kind on every channel) is the technical shape of the artefact — a Helm
chart, a container image, a curated marketplace listing — and is validated against a
fixed enum in scripts/distribution-check.mjs (CHANNEL_KINDS). It is independent of
category: kubernetes-operators (category) spans helm-chart, operator-catalog
and partner-catalog (kind), and paas-template (kind) spans both paas-catalogs and
deploy-recipes (category). Neither axis determines the other, so both are kept and
validated separately rather than collapsed into one.
Platforms (platforms on every channel, at least one) are the user-facing axis rendered
in docs/CHANNELS.md: linux, macos, windows, container, kubernetes,
cloud. They are independent of both tier (who publishes) and category (which business
bucket). A channel may list several; the matrix always renders them in that canonical order.
Runtime ownership (runtime on every channel) records who provides the Node.js the server
runs on, which is the axis that decides whether raising engines.node is a breaking change for a
channel's users or an invisible build detail (issue #326):
| Value | Meaning | Channels |
|---|---|---|
user_supplied |
The user's own node executes the payload, so the floor is theirs to meet. A release that raises it is user-visible here and nowhere else. |
npm, github-release |
channel_supplied |
The channel provides the runtime — bundled inside the artefact (container image, snap, deb/rpm, Windows zip, Flatpak, the Tauri sidecar), inherited from an image a template deploys, or installed as a declared package dependency (Homebrew's node@24). Raising the floor is transparent. |
every other channel |
It is deliberately not derived from kind: os-package covers deb/rpm, which ship a private Node
(packaging/linux/fetch-node.sh installs bin/node into the payload), while package-registry
covers npm, which does not. Issue #326 assumed deb/rpm were user-supplied; the packaging scripts
say otherwise, which is exactly why this is a stated field rather than an inference.
SLAs (update.sla) state how quickly a channel is expected to follow a release:
every_release (bumped as part of releasing), minor_plus (bumped for minor releases and
above), major_only, on_demand (bumped when someone gets to it — the honest default for PaaS
catalog templates).
Pin strategies: local_file (version lives in files in this repo), remote_file (fetched
from an upstream raw URL, best-effort — fetch failures degrade to UNKNOWN and never fail the
run), probe (see below), none (nothing to measure, stated explicitly with a note). The two
regex strategies carry an extract pattern with exactly one capture group; when a source yields
multiple different matches the channel is reported instead of silently using the first hit.
Probes (#182) measure the channels whose served state is not one document a single regex can
read. Each names a probe and the urls it needs; the URLs live in the inventory rather than in
the script so the unit tests can point them at a local server and the suite never touches the
real network. Probe failures degrade to UNKNOWN exactly like a failed remote_file fetch — but a
tag that is genuinely absent is DRIFT, not UNKNOWN, which is the whole point: a publish step
that skipped silently must not read as "could not measure".
| Probe | Channel | What it measures |
|---|---|---|
ghcr-tag-digest |
docker-ghcr |
that the digest :latest resolves to equals the released version tag's (anonymous pull token — no secret, works from a fork) |
dockerhub-tag-digest |
docker-hub-mirror |
the same digest equality on the mirror; an absent version tag is how an expired DOCKER_HUB_TOKEN (whose push step skips silently) becomes visible |
snap-store-channel |
snap |
the stable version of every listed architecture, each as its own source, so one lagging build is drift rather than a pass; edge may legitimately differ and is ignored |
github-dir-max-version |
winget, operatorhub-community |
the highest version each named catalog directory enumerates, because none of them publishes a floating "latest" document. Every entry under pin.urls is measured as its own source, so the two operator catalogs (operatorhub.io and the OpenShift console, bumped by separate upstream PRs) report separately and a lagging one is drift rather than a pass |
Because a probe resolves versions in code, a channel measured this way needs no extract. A
probe that reports several sources (Snap's architectures) reuses the same rule as a multi-file
local_file pin: disagreeing sources are drift, with every source shown.
Strict mode: bun run distribution:check --strict exits non-zero only for local_file
channels with sla: every_release that are drifted or unmeasurable. Remote catalogs and
on_demand templates never gate, so strict is enableable today without first paying off
historical PaaS drift. For the Helm chart the enforcement remains the required chart:check CI
gate (#138) — the matrix row is visibility, not a second gate. --json emits the rows for
scripting.
Since #425 channels.yaml is read by the
product itself, not only by the docs and the drift table. bun run channels:showcase
(scripts/generate-channel-showcase.mjs) emits
src/lib/distribution/channels.generated.ts — the
live channels, each mapped to one of the login hero's four rows (containers, Kubernetes, PaaS,
packages) by its category, plus the platforms those channels cover — and the login page renders
that module. bun run channels:showcase:check regenerates it in memory and fails on drift; it runs
in the required lint-and-build job next to chart:check, so the committed module is always the
inventory.
The consequence worth stating: flipping a channel's status to live publishes it on the login
page, with no component edit — the same one-line edit that already moves it in
docs/CHANNELS.md and in the drift table. Only live reaches the UI, so a pending
submission or a deprecated listing renders nothing at all, and the count the page states is a
.length over that array rather than a written number. The generator refuses to guess: a category
with no row mapping throws instead of defaulting, so adding a business bucket to the inventory fails
the gate loudly rather than quietly dropping a channel out of the product's front door.
The channel's label on that page is its short_name when it has one and its name otherwise, so
a channel whose full name is too long for the hero is shortened in channels.yaml — never in the
component. The whole point of the generated module is that no channel name is typed into JSX.
Some channels can be temporarily unable to publish for reasons outside this repository — a package
waiting in community moderation, a listing still under review. Left to fail they make a published
release report failure, which is worse than not publishing: the run's colour stops meaning
anything. update.ci_enabled in channels.yaml is the switch:
- id: chocolatey
update:
method: ci_publish
sla: every_release
ci_enabled: false # 403 for every version while the first push is in moderationThat is the state chocolatey carried from 0.9.60 until its approval on 2026-08-24; it is kept
here as the worked example because it is the case the switch was built for.
The release workflow reads it (distribution-check.mjs --ci-outputs in the channels job, whose
outputs each channel's availability step consults), so the edit is the whole switch — no
secret to delete, no workflow change, and the decision is reviewable in a diff next to the
channel's status and note. A channel is published only when its flag says true, its
status is live, and its secret is present. The status half is what lets a channel be staged
before its account exists: the AUR package landed while the channel was pending (#971), and
setting it live is what starts its publish job.
Three deliberate constraints:
- Only optional channels may carry the flag: the set is
SWITCHABLE_CHANNEL_IDSinscripts/distribution-check.mjs, which is the list - it is not restated here, because a copy would go stale the next time a channel joins. The core release path (github-release,docker-ghcr,npm,helm) and the assetspublish-releaserequires (.deb/.rpm, AppImage, the win32 zip) have no switch, because one mistypedfalsethere would silently ship a release with no npm package or no image.parseChannelsrejects the flag anywhere else, so this is enforced, not just documented. - It is required, never defaulted, on every channel in that set: CI behaviour for a channel has to be a stated decision in this file, not an omission.
- Failure leaves channels off, never the release blocked. The
channelsjob iscontinue-on-error; if it cannot run, its outputs are empty, every gate reads "not true", and the optional channels skip while the release publishes normally. A forgotten re-enable surfaces in the weekly drift table rather than in a broken release.
Do not confuse it with status: status describes the channel's listing (chocolatey was
pending while its first push sat in moderation, even though the zip it points at was built and
required on every release), whereas ci_enabled describes only whether release CI may publish
it. The two move together when a listing lands — winget went pending/false to live/true
in a single edit once #402985 merged, and chocolatey did the same on its 2026-08-24 approval. A channel whose listing will never happen is
deprecated, not pending — flathub is the worked example.
Adding a channel = one new entry in channels.yaml (copy a neighbour of the same tier; the
schema is validated on every run). Set links.first_pr to the PR that landed the listing, and
update links.last_bump_pr whenever a version-bump PR for that channel merges — it is null
until the first post-listing bump and is displayed, not auto-discovered.
Root-level PaaS configs. Two tier-2 channels ship their descriptor at the repo root rather
than under deploy/<provider>/: fly.toml (Fly.io) and
render.yaml (Render). This is deliberate, not a stray file — their tooling
auto-detects the config at the working-directory root: fly launch/fly deploy read
./fly.toml (see docs/FLY.md) and Render's Blueprint auto-detects render.yaml, so
the documented "clone and deploy" flow only works from that location. Catalog-based channels
instead keep whatever they own under deploy/<provider>/, because the consumable artifact lives in
an external catalog. Only CapRover and Railway own a source descriptor there
(deploy/caprover/libredb-studio.yml, deploy/railway/template.json): the in-repo file is the
source that gets pushed or PR'd upstream, which is why Railway is pinned local_file. CapRover is
pinned remote_file even so - that pin must measure what the catalog actually serves - and its two
in-repo templates are held to package.json by chart:check instead: they once fell 45 patch
versions behind the catalog before anyone noticed
(#268), and chart:bump now moves them with
every release. Dokploy, Kubero and Cosmos keep only a README there -
their descriptors are authored in the upstream catalog repo, so all three are pinned remote_file
and a bump is an upstream PR with nothing to change here. Several catalog channels keep no
descriptor here at all. Two of them are pinned remote_file against the repository that does
hold it — the Sealos template in labring-actions/templates, the Unraid CA template in
libredb/unraid-templates — and both are documented under
App catalogs; TrueNAS SCALE is pinned the same way against
truenas/apps, CasaOS against IceWhaleTech/CasaOS-AppStore, YunoHost against
YunoHost-Apps/libredb_studio_ynh, the Dokku plugin against
libredb/dokku-libredb-studio, and Pethost against pethost-dev/templates, where Pethost
authors the template. The three open submissions (Umbrel, Easypanel, Portainer) have nothing to
pin until their upstream PR merges, and each entry's note names the pin to add on that day.
Google Cloud Marketplace and Railyard have nothing to pin even in principle: Google holds the artefacts in its Producer Portal and a private Artifact Registry, and Railyard keeps the template on its own side and says it builds main at deploy time.
Neither Fly.io nor
Render has a marketplace or template gallery to publish into, which is why the repo file itself is the
deliverable (pin.strategy: local_file for the version-pinned fly.toml; none for
render.yaml, which builds from the repo Dockerfile and tracks whatever main builds).
DigitalOcean measures the live listing, not the Packer workflow's version
input: its remote_file pin reads custom_data.version from the public
Marketplace page. Drift and unreadable metadata remain visible in the weekly
report without blocking a release. Snapshot builds and Vendor Portal updates
stay manual and on demand; every successful publish-release job adds a
versioned build, test, submit and verify checklist to its summary. See the
DigitalOcean build guide.
Some catalogs take a version bump only as a pull request that a person tested, so release CI prepares the change and stops short of the pull request.
update.fork in channels.yaml holds what the workflow may do for such a channel.
The set of channels that carry it is FORK_STAGED_CHANNEL_IDS in scripts/distribution-check.mjs, and like ci_enabled the field is required there and rejected everywhere else.
- id: caprover-official
update:
method: upstream_pr
sla: on_demand
fork:
mode: update # or create_or_update: create a missing fork in the libredb org first
push: auto # or manual: a release run validates only, and a member pushes by handCapRover is the one such channel.
Its catalog's maintainer keeps bumps manual (caprover/one-click-apps#1334), so caprover-fork.yml stages both templates on libredb/caprover-one-click-apps after each release, and a member opens the pull request after testing them; deploy/caprover/README.md has the steps.
docker-build-push.yml dispatches it once the release images passed their channel E2E.
The workflow reads the settings with distribution-check.mjs --fork-outputs caprover-official, pushes only for a live channel, and needs the CAPROVER_CATALOG_TOKEN secret; without it a run validates and pushes nothing.
It stages the release tag's templates unchanged, because chart:bump moves their version with package.json and chart:check holds every commit to it.
A prerelease is never staged: while package.json carries one, chart:bump leaves the templates on the last stable version, and the dispatch skips a tag with a suffix.
- Operator first listings and per-release submissions: done.
Submissions are automated, see Automated submission.
What follows is how the first ones landed.
The controller image was the first half:
ghcr.io/libredb/libredb-studio-operator:0.9.59was built once by manualworkflow_dispatchfrom the post-mergemaincommit (the0.9.59tag carries neitheroperator/nor the workflow file, and GitHub only dispatches workflows that exist on the chosen ref) and the GHCR package is public, which community catalog CI requires. From 0.9.60 on the tag ref carries the operator and the normal tag-pinned dispatch chain applies. Both catalogs then merged their first listing: the OpenShift console via the FBC bundle PR (community-operators-prod#10497) plus its rendered catalogs (community-operators-prod#10581), and operatorhub.io via its bundle PR (k8s-operatorhub/community-operators#8794, merged 2026-09-08).operatorhub-communityisliveaccordingly. Both catalogs carried only 0.9.59 for weeks after that, which is why the per-release submission is now automated; 0.14.1 was the catch-up, landing as k8s-operatorhub/community-operators#9219 and community-operators-prod#11106. One thing a merge still does not finish: operatorhub.io serves from its own index build, which lags by hours.https://operatorhub.io/api/operator?packageName=libredb-studio-operatoris the read that answers for it, and it returned "can't find" for hours after #8794 merged while a control query forargocd-operatorreturned a full record. - Snap Store listing screenshots: the description and icon ship with the snap
(
snap/snapcraft.yaml,public/logo.svg), but screenshots are a manual upload in the Snap Store web UI (https://snapcraft.io/libredb-studio/listing). The snap name is registered andSNAPCRAFT_STORE_CREDENTIALSis configured — the channel went live with 0.9.52. - Website install docs: the libredb-website documentation must be updated with the new channels (npx, Homebrew, .deb/.rpm, Snap) — a cross-repo step and part of issue #111's "README and website docs" acceptance criterion (and implicitly of #110/#112/#113).
- Windows first listings — done. winget:
microsoft/winget-pkgs#402985 merged on
2026-07-31. Chocolatey: the 0.9.59 push cleared human moderation on 2026-08-24. Both channels
are
livewith release-CI updates enabled, per the Windows first-listing checklist — tracked in issue #114. - Flathub submission — closed, not shipping.
flathub/flathub#9538 was declined on 2026-07-30
under Flathub's generative AI policy and closed from our side; see
Flathub (submission declined). The Flatpak channel is
FlatPark, live since 0.9.62. The domain-verification file at
https://libredb.org/.well-known/org.flathub.VerifiedApps.txtis served but carries no token, since Flathub only issues one after publication. Tracked in issue #232. - Desktop app, remaining channels: the Tauri v2 wrapper now ships as an AppImage on Linux
(see Desktop app). The macOS
.dmgplus brew cask, the Microsoft Store MSIX and the Tauri updater are still open and need paid signing identities — seedocs/DESKTOP_WRAPPER_SPIKE.md.
Deviations and partial deliveries to record on the tracking issues when closing them, so the record matches the implementation:
- #110 (npx): the "Works on Linux, macOS, and Windows" acceptance criterion was initially
NOT delivered for Windows (the launcher exited on
win32with a Docker pointer); the win32 path shipped with #114 — the npx launcher now downloads the win32 zip and runs it with the user's Node runtime. - #111 (Homebrew): the website half of "Install instructions added to README and website docs" is a cross-repo step (see Manual steps above).
- #113 (Snap): closed after the 0.9.52 live validation (store publish from release CI,
amd64+arm64 on
stable). Listing screenshots remain a store-side manual upload (see Manual steps above); version-bump auto-refresh is observable at the next release. - #115 (desktop wrapper spike): the written go/no-go recommendation is delivered
(
docs/DESKTOP_WRAPPER_SPIKE.md), but the hands-on spike scope (Tauri prototype, WebKitGTK/Monaco validation, unsigned PoC builds) was re-scoped into its Phase 1 — close as "recommendation delivered, hands-on spike open" and open the Phase 1 follow-up issue. - #118 (Helm AUTH_BOOTSTRAP): the chart originally defaulted to strict mode
(
config.authBootstrap: "off"), deviating from the issue's "default to the app default (on)" wording — generated credentials in centrally collected pod logs are undesirable. This was later reversed for the Rancher partner-charts certification, whose repository requires charts to be deployable with default values: the chart now defaults to""(zero-config bootstrap), and strict mode remains available viaconfig.authBootstrap=off.