# Beta Release Checklist Status: Closed-beta release candidate — `v0.2.0-rc.12` (2026-10-16). It is rc.11 (the Discord-like profile card and the A-to-Z e2e fixes on top of rc.10), plus handles, pronouns and profile colours, mentions that know who they name, a live lobby, ban lengths and a ban list, and the fixes from a three-review bug hunt and the profile-fields e2e run. Two reviews fed it: - The beta-readiness review ([BETA_READINESS_REVIEW.md](BETA_READINESS_REVIEW.md)) found moderation, voice, desktop and release-pipeline defects that the earlier "ready" claim missed. - The second security review ([SECURITY_REVIEW_2026-10-13.md](SECURITY_REVIEW_2026-10-13.md)) found 7 medium and 11 low findings. Everything both reviews found is fixed. Release checks: - The full end-to-end suite passes 148/148 against the production image. - The updater chain was drilled end to end against the real signed releases on a local production stack, rc.5 → rc.6 and rc.8 → rc.9 (see "Update drill" below). - The published rc.9 Windows installer passed the install smoke (install, launch, reinstall, uninstall). Before inviting testers, three things still need your infrastructure or hardware: - one fresh install on a real VPS with DNS and Let's Encrypt; - desktop PTT on macOS and Linux; - a coturn relay test from a UDP-blocked network. ## Pre-beta verification - [x] Beta-readiness review remediated (2026-09-19): S1 role escalation, S2 bans, S3 lobby leak, S4 voice moderation, S5–S11, V1–V14, I1–I9, I11 — see CHANGELOG "[Unreleased] - beta readiness remediation" - [x] CI: real-UI voice + realtime E2E (`voice-ui-audio.spec.ts`) and real-Postgres integration/ban tests in the pipeline - [x] CI: the published-image job asserts no baked localhost media URL and no pem/key/dump files in `/app` - [x] New RC tags from the remediated main: - `v0.2.0-rc.6`: gate, candidate, exact-digest Trivy scan, promote, GitHub release and three-platform desktop bundles all green. - `v0.2.0-rc.7`: adds the coturn and desktop-connect fixes found while drilling rc.6. Verified after release: - `lfctl update apply` from rc.5 to the signed rc.7 manifest: healthy, data intact, runtime `livekitUrl`; - the published Windows installer (checksum verified) connects and delivers global PTT and shortcuts to the page; - coturn starts with the shipped template (14 deny ranges, healthy). - [x] Desktop on **Windows** (real Windows 11, WebView2 driven over CDP): - the connect screen reaches an HTTPS instance; - global Ctrl+Space press and release, Ctrl+Shift+M and Ctrl+Shift+D arrive in the page; - the instance page gets "not allowed by ACL" for every command. rc.5 failed the same test: Connect was broken and the page received no PTT events. - [ ] Desktop: the same check on **macOS** and **Linux** - [x] Dev-dependency upgrade: vitest 4.1, happy-dom 20 and a patched vite/eslint chain; `pnpm audit` is clean (#23) - [x] Security audit findings remediated; further hardening now comes from release drills and runtime testing - [x] CI: Ubuntu + Windows verify, Docker build, production compose config - [x] CI: Production TLS E2E (HTTPS, HTTP→HTTPS redirect, WSS upgrade, CSP nonce + hydration) - [x] CI: Two-client voice E2E (real WebRTC through LiveKit) - [x] CI: Destructive backup drill (backup → destroy → restore → verify) - [x] CI: Real-Postgres migration + channel visibility + ownership integration tests - [x] Security: CodeQL security-extended, RustSec (Cargo.lock), pnpm audit (prod) - [x] Security: Trivy CRITICAL+HIGH gate on OUR web image (CI + the exact released digest BEFORE release tags are attached — candidate push → digest scan → promote); third-party images gate CRITICAL with HIGH reported (upstream base-layer HIGHs are outside our control — per-image ignores document exceptions) - [x] Security: Branch protection with 16+ required checks, force-push disabled - [x] Supply chain: All Docker images digest-pinned (@sha256), Dependabot docker-compose - [x] Supply chain: Dockerfile base image digest-pinned (node:22-bookworm-slim) - [x] Supply chain: Plugin artifact hash pinning (review hash'ler, install doğrular) - [x] Desktop: Native state binding (session swap closed), GETDEL handoff, deep-link gate - [x] Registry: Account-bound challenge + domain proof + SSRF-safe fetch + key rotation - [x] Realtime: WS event-driven invalidation + 30s periodic reauth + SSE instant abort - [x] Authorization: Centralized membership+visibility gates, moderation hierarchy - [x] Updates: Safety gates (available/supported/signature/major) + strict (auto-created) backup + signed-digest deploy + migrate + health check + persisted version state + app-level rollback - [x] Release: tag push gates on ALL CI+security checks (19 contexts, completed+success) before publishing - [x] Release: `release-manifest.json` generated per release (Ed25519-signed when `LF_RELEASE_SIGNING_KEY` secret is set) - [x] Release signing key provisioned: public half committed at `infra/update/release-public.pem` (keyId `34c793ff090fc436`), private half in the `LF_RELEASE_SIGNING_KEY` secret — every release manifest ships signed ## Known release-engineering constraints (pre-stable decisions) - **Release image is explicitly pinned to linux/amd64 for beta** (the workflow sets `platforms: linux/amd64` — not a runner default). Beta targets standard x86_64 VPSes. ARM64 (Oracle ARM, Raspberry Pi class) requires `platforms: linux/amd64,linux/arm64` + QEMU in the build and a multi-arch scan strategy — evaluate after beta. - **Candidates live in the public package**: GHCR visibility is package-level and public→private is not reversible, so after the first release the `candidate-` refs are anonymously pullable pre-scan. They are the exact bytes the scan gates; release tags/manifests still only appear post-scan. If the strict "no unscanned bytes publicly readable" bar is wanted for stable, move candidates to a separate private package (e.g. `lobbyforge-candidates`) before v1.0. - **Scanner pin ≠ pin forever**: the Trivy scanner image is digest-pinned for immutability, but its VERSION must still be bumped deliberately as maintenance (0.74.0 is current; 0.65.0 is pinned). The vulnerability DB refreshes on every run regardless — the pin only fixes the scanner binary itself. ## Release signing key The private half of the manifest signing key (`LF_RELEASE_SIGNING_KEY`) signs every `release-manifest.json` that `lfctl update` trusts. Treat it as the most sensitive secret in the project: - **Keep it out of the repo checkout.** `infra/keys/` is gitignored, but the PEM is still on disk inside the working tree, where tools, backups, editors and agents can read it. Move `infra/keys/release-ed25519-private.pem` to an offline or password-manager location outside the clone. Only the public half (`infra/update/release-public.pem`) belongs in the repo. - **Protect the `release` environment.** The `github-release` job in `.github/workflows/release.yml` runs in the `release` environment. GitHub creates it on the first run with no rules. In Settings → Environments → `release`, the owner should: set deployment branches and tags to "Selected" with the tag rule `v*`, and optionally add required reviewers. Then add `LF_RELEASE_SIGNING_KEY` as an environment secret and delete the repository-level secret, so no other workflow or branch can read it. ## Release policy: migrations must be rollback-safe (expand/contract) App rollback (`lfctl update rollback`) restores the previous image — the database schema is NOT rolled back (drizzle migrations are forward-only). For that to be safe, releases MUST use expand/contract migrations: 1. **Expand release**: additive schema changes only (new nullable columns, new tables). The OLD app image must run correctly against the expanded schema — this is what rollback re-deploys. 2. **Contract release (later)**: only after the release that stopped reading/writing the old shape has been deployed everywhere, a subsequent release may drop columns/tables. A release that DROPS or narrows schema the previous image still uses makes rollback a lie; breaking migrations require a backup restore instead (`lfctl backup restore --file --to `). ## Known limitations (documented in ADRs) - [ADR-001] Plugin runtime: reviewed-only trust model (child-process + container, NOT hostile-code sandbox) - [ADR-002] Plugin client UI: server-only (sandboxed iframe is the post-beta target) - [ADR-003] Supply chain: digest-pinned (IMPLEMENTED) - [ADR-004] Governance: graduated (admin bypass + unsigned commits OK for single-developer beta) - [ADR-005] Desktop: unsigned builds (code signing before public distribution) ## Beta deployment ```bash # Install (Linux/macOS) — the installer needs the repo files next to it # (compose stack, nginx/livekit templates, lfctl), so clone first: # curl | bash does NOT work. git clone --branch --depth 1 https://github.com/Juanka-e/LobbyForge.git cd LobbyForge && bash install.sh # Default TLS: Let's Encrypt. Behind Cloudflare with an Origin CA # certificate instead? See docs/DEPLOY_CLOUDFLARE.md. # IPv6 on the host? Check Admin → Health for a `client_address` warning: # with Docker's defaults every IPv6 visitor can share one address # (docs/DEPLOY_CLOUDFLARE.md#client-addresses-behind-docker). # Updates — every GitHub release publishes a SIGNED release-manifest.json # that pins the immutable image digest. lfctl finds the newest release of # the channel through the GitHub API and verifies it with the committed # official public key. `--channel stable` (the default) takes full releases # only; while every release is a pre-release (-rc.N), add --channel beta. node scripts/lfctl.mjs update check --channel beta # what's available (signature verified) node scripts/lfctl.mjs update plan --channel beta # review the plan node scripts/lfctl.mjs update apply --channel beta --yes # auto-backup + gates + signed-digest deploy + migrate + health node scripts/lfctl.mjs update rollback # restore the previous recorded image + version # Forks: LOBBYFORGE_RELEASE_REPO=/, or override with # --manifest / --public-key or # LOBBYFORGE_RELEASE_MANIFEST / LOBBYFORGE_RELEASE_PUBLIC_KEY_PEM. ``` ## What to test in beta 1. **Voice**: Join a room with 2+ clients (browser + desktop), test mic/camera/screen share 2. **Chat**: Text channels, DMs, typing indicators, message history 3. **Plugin games**: Start Hushle in a voice room, play a round 4. **Discovery**: Register on the official directory, heartbeat, moderate 5. **Self-host**: Install, configure, update, backup + restore 6. **Desktop**: Connect to instance, login handoff, global push-to-talk ## Release drill — v0.2.0-rc (results: rc.1 + rc.2) The unified `v*` release pipeline ran for real on GitHub. rc.1 exercised the chain up to promote and caught two integration bugs (desktop pnpm ordering; artifact download pattern); rc.2 completed the release. - GitHub `releases/latest` does NOT include prereleases — for RC updater tests pass the manifest explicitly: `--manifest https://github.com/Juanka-e/LobbyForge/releases/download/v0.2.0-rc.2/release-manifest.json` Results (final: **v0.2.0-rc.4 — all jobs green, full artifact set**): - [x] RC tag pushed → 19 checks green → release gate passed → GHCR push - [x] ghcr.io/juanka-e/lobbyforge package is PUBLIC - [x] Anonymous digest pull (no GitHub credentials) — verified locally (rc.1, rc.2 digests) - [x] `lfctl update check` against REAL release manifests: signature valid (committed public key), digest pinned; semver correct both ways (0.1.9 → rc.4 offered; 0.2.0 final is NOT "upgraded" to an RC) - [x] Desktop artifacts for ALL THREE platforms with one flat SHA256SUMS.txt: linux rpm + AppImage + deb, macOS dmg, Windows NSIS setup.exe (MSI can't hold semver prereleases — NSIS-only on Windows) - [x] Fail-closed gate proven live: rc.3 was REFUSED (a check state the gate could not accept), no release created — then the rule was refined (cancelled runs are non-authoritative) and rc.4 shipped Iterations caught by the drill (exactly its purpose): rc.1 — desktop pnpm setup order + SBOM artifact in the download; rc.2 — Windows MSI semver limit; rc.3 — cancelled duplicate check runs tripping the gate; rc.4 — clean end-to-end. - [ ] Fresh tagged install (`git clone --branch v0.2.0-rc.12` or later + install.sh) on a clean VPS (needs real DNS + Let's Encrypt — not reproducible locally) - [x] Real old → RC update via `lfctl update apply` (local production stack, see below) - [x] Forced failure drill: health fails after recreate → old containers restored (live, see below) - [x] `lfctl backup restore` round-trip (live, see below) ## Update drill — rc.8 → rc.9 (2026-10-13, local production stack) **Setup:** - rc.8's checkout and production compose file, with prefixed names and shifted ports; - configs from `render-configs.sh`, a `.env.prod` laid out as `install.sh` writes it, and a self-signed certificate; - rc.8 pinned to its signed digest. **Seeded through the TLS edge:** - the owner and a member who joined through an invite; - a role-gated channel; - 9 messages; - a bot with a token; - a Hushle session. rc.8's own `lfctl` ran every step. | Step | Result | |---|---| | `update check` against the real rc.9 manifest | The signature is **valid** against the committed key; rc.8 → rc.9 is offered. A digest changed by one character is refused. | | Published rc.9 image hygiene | No localhost media URL in the browser bundles. No pem/key/dump/env files besides the release public key. The revision label matches the manifest's `gitSha`. | | rc.8 running on the `.env.example` placeholder secret, then `update apply` | **Recovered, but silently.** The backup was verified and the digest pulled. **Migrations ran.** The rc.9 ws-gateway then refused the secret, and lfctl printed "OLD CONTAINERS RESTORED AND HEALTHY". `.env.prod` and the data are byte-identical. The reason never reached the operator: it was in the replaced containers' logs. | | `update apply` with a real secret | **25 s.** A fresh backup was created and verified. The rc.9 digest was deployed and migrations 0047–0049 are in place. Health is green. `.env.prod` and `deployment-state.json` have a `previous` pointer. | | After the update | **The data is intact:** same row counts and content hashes. **What works:** the voice token returns the runtime `livekitUrl` and the TURN servers, and the bot token works on v1 and v2. **Sessions:** an **rc.8 session cookie stays signed in and is re-signed for 7 days** (`Max-Age=604800`, same `auth_time`). **rc.9 features:** a chat poll and an account language work. A guest avatar upload now gets 403. | | `update rollback` | **18 s.** rc.8 is healthy on the expanded schema. rc.9 cookies, sign-in and the channel holding the rc.9 poll all work; the poll shows as a plain message. A `NULL` `users.locale` works. | | Roll forward again | 25 s, healthy, with `previous` set again. | | `backup restore` into an empty database | The newest dump matches the live database: 49 tables, the same counts and hashes. The pre-update dump restores the rc.8 schema. | | Forced failure **before** recreate (a manifest signed with a throwaway key, pointing at an image without node) | Stopped at `apply-migrations`. The container IDs did not change and `.env.prod` is byte-identical. The same manifest checked against the committed key is refused as INVALID. | | Load (k6 through the TLS edge, captcha off) | p95 28 ms; guest creation p95 36 ms; heartbeat p95 28 ms; 0 of 41 requests failed with the script fix below. | **Found by the drill and fixed after rc.9:** - **The placeholder secret:** - `lfctl update apply` now refuses it before the backup ("Nothing was changed."). - The migrator refuses it before touching the schema, so an older `lfctl` stops there too. - After a failed recreate, `lfctl` prints the new containers' last log lines before restoring the old ones. - **web did not exit on a refused secret:** Next 16.4 logs "Failed to prepare server" and keeps answering 500. It now exits in production. - **Doctor TURN check:** it read two variables nothing writes and said TURN was not configured on installs that had it. - **k6 duplicate cookie:** the load script sent the session cookie twice on each presence user's first heartbeat. The server refused those requests (400), and every run failed its own threshold. - **Hushle start before the packs were listed:** a start sent through the API on a fresh instance found no packs (404), because the built-in packs were seeded only by the pack list. - **Image version label:** the release image said `candidate-`. - **Corrected here:** the comment in migration 0048 says an account created while rolled back gets `'en'`. rc.8 actually writes `NULL`, which is harmless. ## Update drill — rc.5 → rc.6 (2026-09-19, local production stack) Setup: - The `v0.2.0-rc.5` checkout, running the production compose file with rendered configs, a self-signed TLS certificate, and rc.5 pinned to its signed GHCR digest. - Project, network and container names were prefixed and host ports shifted so the stack could run next to other stacks. - It was seeded through the nginx TLS edge: owner setup plus 3 messages. - The old (rc.5) `lfctl` performed every step. | Step | Result | |---|---| | `update check` against the real rc.6 manifest | signature **valid** (committed key), rc.5 → rc.6 offered | | Published rc.6 image hygiene | no localhost media URL in the browser bundles; no pem/key/dump files | | `update apply --yes` | fresh backup created and verified (sha256); digest deployed; migration 0036 applied; health green; `.env.prod` and `deployment-state.json` updated with a `previous` pointer | | After the update | all app containers on the rc.6 digest and healthy; the 3 messages intact; the voice token returns the runtime `livekitUrl` | | `update rollback` | rc.5 runs healthy on the expanded schema (expand-only migration confirmed); login and data OK | | `backup restore` into an empty DB | 3 messages and 1 user restored with the pre-update schema | | Forced failure **before** recreate (image without node, signed with a throwaway key via `--public-key`) | stopped at `apply-migrations`; old containers untouched; `.env.prod` restored | | Forced failure **after** recreate (rc.6 without a Next build: migrations pass, web unhealthy) | "OLD CONTAINERS RESTORED AND HEALTHY"; `.env.prod` restored; data intact | Found by the drill and fixed in rc.7: - **coturn aborted on the IPv6 CIDR deny rules**, so TURN never ran on any install. - **The desktop connect screen could not invoke** (`withGlobalTauri`). ## Reporting issues - Security: use GitHub Security Advisories (private) - Bugs: open a GitHub issue with reproduction steps - Feature requests: open a GitHub issue with the `enhancement` label