macos-13 runner tier is being phased out by GitHub — queues persistently exceeded 1 h during v0.9.0-rc1 build. Intel Mac users fall back to 'cargo install --features bundled-assets' from source, Docker --platform linux/amd64, or a Linux VM.
41 KiB
Bundled Binary Distribution — Multi-Platform Plan
Context
Users have asked for a way to run OxiCloud without Docker — a plain
binary. Today release.yml only creates a GitHub Release with notes;
no binary is attached. The Docker workflow (docker-publish.yml) ships
multi-arch images, but that's a separate audience.
The blocker for a "just download and run" experience is that the
oxicloud binary depends on the SvelteKit build output (static-dist/
under <static_path>/static-dist/, resolved by
src/interfaces/web/mod.rs::resolve_static_path at boot). Two files
to distribute per platform is friction; a single self-contained binary
is what users actually want.
The ask has two parts:
- Ship single-file binaries with frontend assets embedded, for the common Linux targets and macOS.
- Audit the current binary set — the crate produces 6+ binaries today, some of which are test-only. Strip anything that shouldn't ship to end users.
The intended outcome: a v0.9.0 release attaches 4 musl-static
tarballs (Linux amd64/arm64 + macOS Intel/Apple Silicon), each
~15-30 MB, containing a single oxicloud binary with assets +
operator tools + one-off migrations all baked in. User extracts the
tarball, sets DATABASE_URL, runs ./oxicloud — server up.
Subcommands (oxicloud opaque setup, oxicloud migrate nfc-filenames --dry-run) provide operator access to the same tools
currently split across oxicloud-cli and migrate-nfc-filenames.
Design shape (confirmed 2026-08-27):
- musl-only Linux — parity with the existing Docker image (Alpine base), no glibc-version fragmentation
- Assets embedded via
rust-embedwith compile-time deflate compression — smaller binary bundled-assetsis opt-in — defaultcargo buildunchanged;just devstill uses the filesystemServeDirwith Vite HMR- Single unified binary —
oxicloud,oxicloud-cli, andmigrate-nfc-filenamescollapse into one clap-driven executable with implicit-server default (backwards compat with existing Docker CMD / systemd units)
Current binary inventory
From Cargo.toml + src/bin/:
| Binary | Path | Purpose | Ship to end users? |
|---|---|---|---|
oxicloud |
src/main.rs (implicit) |
Server | YES |
oxicloud-cli |
src/bin/oxicloud-cli.rs |
Operator toolbox (opaque setup/reset) |
MERGED — absorbed into oxicloud per Deliverable 1b |
migrate-nfc-filenames |
src/bin/migrate-nfc-filenames.rs |
One-off filename migration (historical, June 2026 fix) | MERGED — absorbed into oxicloud migrate nfc-filenames per Deliverable 1a→1b |
generate-openapi |
src/bin/generate-openapi.rs |
Regenerate resources/gen/openapi.json |
NO — dev tool, gate behind dev_tools feature |
opaque-hurl-helper |
src/bin/opaque-hurl-helper.rs |
Hurl test companion (OPRF client) | NO — gate behind test_utils feature |
dpop-hurl-helper |
src/bin/dpop-hurl-helper.rs |
Hurl test companion (ES256 DPoP proof) | NO — gate behind test_utils feature |
load-seed |
src/bin/load-seed.rs |
Test fixture seeder | Already gated behind load_seed_bin feature ✅ |
After Deliverables 1 + 1a + 1b, cargo build --release --bins
produces exactly ONE binary: oxicloud. That single binary ships in
the tarball and in the Docker image.
Deliverables
1. Squash test/dev binaries with required-features
Cargo respects required-features per [[bin]] — a binary is only
built when its listed features are active. This gates test helpers
out of cargo build --release --bins cleanly without needing custom
Cargo commands or shell trimming.
Edits to Cargo.toml:
[features]
# ... existing features ...
dev_tools = [] # NEW: gates ops tooling that shouldn't ship
[[bin]]
name = "opaque-hurl-helper"
path = "src/bin/opaque-hurl-helper.rs"
required-features = ["test_utils"] # NEW gate
[[bin]]
name = "dpop-hurl-helper"
path = "src/bin/dpop-hurl-helper.rs"
required-features = ["test_utils"] # NEW gate
[[bin]]
name = "generate-openapi"
path = "src/bin/generate-openapi.rs"
required-features = ["dev_tools"] # NEW gate — `just openapi` flips it
# [[bin]] name = "migrate-nfc-filenames" ← DELETED per Deliverable 1a
# [[bin]] name = "oxicloud-cli" ← DELETED per Deliverable 1b
Existing invocations that need adjustment:
just openapirecipe → add--features dev_toolsto the underlyingcargo run --bin generate-openapicall (currentlycargo run --bin generate-openapiper justfile)tests/api/run.sh→ add--features test_utilswhen building the two hurl helpers (shape confirmed:cargo build [--release] --bin opaque-hurl-helper/ same for dpop in each helper's build-if-missing branch)
After these edits + Deliverables 1a + 1b: cargo build --release --bins
produces exactly ONE binary — oxicloud. Everything else falls out of
the default build set.
1a. Merge migrate-nfc-filenames into oxicloud-cli
The standalone migrate-nfc-filenames binary is a June-2026 one-off:
it cleans up NFD/NFC filename collisions in databases populated
before the write-time fix (normalize_storage_name() at
src/domain/services/path_service.rs:36, called from
src/infrastructure/repositories/pg/file_blob_read_repository.rs:1062).
New installs never need it; only pre-June 2026 databases do.
oxicloud-cli's header docstring (src/bin/oxicloud-cli.rs:20-23)
already documents the growth pattern for absorbing tools like this:
"each new domain gets its own module below (e.g.
mod opaque) with a#[derive(Subcommand)]enum for its actions and arun(args) -> ExitCodeentrypoint. Keep each module self-contained so a future extraction is a file move."
Note: this Deliverable is an intermediate step. Deliverable 1b then
absorbs oxicloud-cli itself into oxicloud, so the final CLI form
becomes oxicloud migrate nfc-filenames --dry-run — but 1a lands
first so the migration logic is proven inside the clap subcommand
tree before the main-binary merge.
Edits:
- New
mod migrateinsrc/bin/oxicloud-cli.rs— moves the ~149 non-boilerplate lines frommigrate-nfc-filenames.rs::main()into arun_nfc_filenames(dry_run: bool) -> ExitCodefunction.env::args()parsing goes away; clap handles it. - Delete
src/bin/migrate-nfc-filenames.rs. - Delete the
[[bin]]entry inCargo.toml. - Update
Dockerfile— 6 references tomigrate-nfc-filenames(build commands at :46, :49, :89,cpsteps at :130, :143, doc comment at :170,COPY --chmod=755 --from=appat :173). - Update
docs/plan/benchmake-and-performance-tracking.md— 2 references tomigrate-nfc-filenamesat lines :44 and :161. Reword to referenceoxicloud-cli migrate nfc-filenames(or, after 1b,oxicloud migrate nfc-filenames) and update the Cargo.toml placement example. - Any operator runbook that documents
docker exec <container> migrate-nfc-filenames --dry-runbecomesdocker exec <container> oxicloud-cli migrate nfc-filenames --dry-run(intermediate) thendocker exec <container> oxicloud migrate nfc-filenames --dry-runafter 1b.
Effort: ~1.5 hours mechanical. Extracts the "should the tarball ship migrate-nfc-filenames?" question entirely — everything now ships as one operator toolbox binary that also happens to include the historical migration.
Future v1.0 removal path (deferred): delete mod migrate block + one
enum variant + docs. Much cleaner than removing a whole .rs file +
Cargo entry + Dockerfile refs.
1b. Merge oxicloud-cli into oxicloud
Single binary — server + operator tools + migrations — with an
implicit-server subcommand tree. oxicloud with no arguments
starts the server (backwards compat with existing Docker CMD /
systemd units / user configs). Subcommands add operator actions on
top.
After merge, the CLI shape is:
$ oxicloud --help
Usage: oxicloud [OPTIONS] [COMMAND]
Commands:
opaque OPAQUE aPAKE substrate management
migrate One-time data migrations
If no command is given, oxicloud starts the server (see docs/config).
Concrete forms:
oxicloud— start server (unchanged)oxicloud opaque setup— wasoxicloud-cli opaque setupoxicloud opaque reset --user alice --dry-run— wasoxicloud-cli opaque reset ...oxicloud migrate nfc-filenames --dry-run— wasmigrate-nfc-filenames --dry-run(via Deliverable 1a)
Backwards-compat guarantee: oxicloud with no args continues to
start the server. Every existing CMD ["oxicloud"], ExecStart=/usr/local/bin/oxicloud,
docker-compose entry, and k8s Deployment keeps working unchanged.
Users updating to v0.9.0 see no surprise.
Migration impact: the user-visible break is that oxicloud-cli opaque setup (etc.) no longer exists as a separate binary. Given the
current audience for oxicloud-cli is very small (essentially only
the maintainer), the migration cost is trivial. Any user who had
scripted it can adapt with a one-line find/replace.
Edits:
src/main.rs— top ofmain(), before the current server init, parse args via clap. If a subcommand is provided, dispatch to it and exit; otherwise fall through to the existing server-init path. Zero-arg startup cost stays ≤ microseconds (clap parse of empty args).src/cli/mod.rs— NEW module. Contains theDomainenum + theopaqueandmigratesubmodules moved fromsrc/bin/oxicloud-cli.rs. Each subcommand module keeps its self-contained shape per the growth pattern documented in the oldoxicloud-cli.rsheader.- Delete
src/bin/oxicloud-cli.rsentirely. - Delete the
[[bin]] name = "oxicloud-cli"block inCargo.toml. Dockerfile— drop all 4 references tooxicloud-cli(build target lines + COPY steps). Simplified build command becomescargo build --release --bin oxicloud— single-binary.- Docs — all
docker exec <container> oxicloud-cli <domain> <action>becomedocker exec <container> oxicloud <domain> <action>. Same shape, one fewer word.
Effort: ~2 hours mechanical. Comparable to Deliverable 1a but with
slightly more care at the main.rs entry point for the args-vs-server
branch.
Tarball layout simplification — the tarball now ships exactly ONE binary:
oxicloud-0.9.0-<triple>/
├── oxicloud (single file, server + tools + embedded assets)
├── example.env
├── LICENSE
└── README-install.md
That's the "just download and run" ethos in physical form: one file, one command, done.
2. Add bundled-assets cargo feature
Purpose: at compile time, choose between filesystem-served static
assets (current behaviour — filesystem ServeDir) and
embedded-into-binary assets (via rust-embed). Feature is
opt-in — the default cargo build --release still produces a
filesystem-based binary, matching the current Docker image behaviour
(where assets are separate volume layers). Release tarballs are built
with --features bundled-assets.
Dev mode is untouched. just dev runs PROFILE=dev cargo run +
npm run dev, neither of which activates bundled-assets. The dev
workflow continues to:
- Serve from
frontend/via Vite's dev server with HMR - Backend reads static assets from
<static_path>/static-dist/via the usualServeDir(or falls back tofrontend/static/when the build hasn't been run) - No rebuild required to change locales, styles, or vendor JS
The bundled-assets code paths only compile when the feature is
explicitly enabled — under a #[cfg(feature = "bundled-assets")] gate.
The non-feature build's binary shape, ergonomics, and dev loop stay
identical to today.
Measured footprint (2026-08-27):
| Slice | Size | Notes |
|---|---|---|
Total static-dist/ uncompressed |
9.8 MB | 499 files |
_app/ (SvelteKit bundle) |
3.3 MB | JS + CSS chunks |
vendors/ |
3.6 MB | maplibre-gl 1.0 MB, pdf.worker 1.0 MB, others |
locales/ |
2.2 MB | 16 locales, ru.json + hi.json largest at ~116-140 KB |
logo/, geo/, basemaps/, workers/, misc |
~600 KB | |
.tar.gz compressed |
4.65 MB | realistic embed cost after brotli/gzip inside binary |
.tar.xz compressed |
4.22 MB | not what rust-embed uses; reference only |
Expected release-binary size with embed: oxicloud today ships in
the 30-60 MB range (stripped, LTO). Add ~5-10 MB for embedded
static-dist. Tarball compression on top → ~20-30 MB shipped per
platform. Four platforms × ~25 MB = ~100 MB per release. Well within
GitHub Releases limits.
Cargo.toml additions:
[features]
bundled-assets = ["dep:rust-embed", "dep:mime_guess"]
[dependencies]
rust-embed = { version = "8", features = ["compression"], optional = true }
mime_guess = { version = "2", optional = true }
Runtime shape — a new module src/interfaces/web/embedded.rs:
#[cfg(feature = "bundled-assets")]
#[derive(rust_embed::RustEmbed)]
#[folder = "static-dist/"] // ← repo-root, matches SvelteKit adapter-static output
#[include = "*"]
#[exclude = "*.br"] // Vite's precompressed sibling — response compression handles on wire
#[exclude = "*.gz"] // ditto
pub struct EmbeddedAssets;
The #[folder] path is relative to Cargo.toml (repo root), where the
SvelteKit adapter-static config in frontend/svelte.config.js emits:
adapter: adapter({
pages: '../static-dist',
assets: '../static-dist',
...
})
The current filesystem shape (at src/interfaces/web/mod.rs:47-106)
is more than one ServeDir — the embed swap replaces FOUR sites, all
downstream of resolve_static_path():
spaServeDir (mod.rs:60-63) — root fallback withprecompressed_br().precompressed_gzip()and SPA-shell fallback pointing at<static>/index.html. Under embed: an axum handler that resolves the request path againstEmbeddedAssets::get(), 200 with correct MIME (viamime_guess) if hit, otherwise return the embeddedindex.htmlbytes withtext/htmlfor SPA client-routing.app_immutableServeDir (mod.rs:66-77) — nested at/_app/immutablewithCache-Control: public, max-age=31536000, immutable. Under embed: same handler shape as (1), scoped to the_app/immutable/prefix, plus a.layer()that stamps the immutable cache header.ServeFile::new(index.html)SPA fallback (mod.rs:63) — folds into (1)'s not-found path.- CSP inline-script scan (
mod.rs:163-233) — currently reads every.htmlfile in the resolved static dir viastd::fs::read_dir+std::fs::read_to_stringat boot to compute SHA-256 CSP source expressions for every inline<script>. Under embed: iterateEmbeddedAssets::iter()filtered to.htmlextensions, pull bytes via::get(), hash the same way. Same arithmetic, different source. Boot-time only.
All four flow through resolve_static_path() at src/interfaces/web/mod.rs:25-35
— that helper is the natural pivot. Add a returned enum:
#[cfg(feature = "bundled-assets")]
pub enum StaticSource {
Filesystem(PathBuf), // OXICLOUD_STATIC_PATH points at a real dir
Embedded, // fall through to compiled-in bytes
}
Then the four callsites (create_web_routes + CSP scan) match on
StaticSource and pick their implementation. Under the default
feature set (no bundled-assets), the enum degrades to a bare
PathBuf — zero runtime cost, no cfg pollution across the wider
codebase.
Locale loading — also needs embed treatment. Two callsites read locales at runtime:
src/main.rs:599-615— resolves<static_path>/locales/at boot and passes it toLocaleRegistry::discover()atsrc/common/locale.rs:150-221, which doesfs::read_dir+fs::read_to_string+serde_json::from_stron each of 16 files. Currently fail-fast panics if the directory is missing.src/infrastructure/services/file_system_i18n_service.rs— the runtime translator,translations_dir: PathBuffield, doestokio::fs::read_to_stringon<dir>/<code>.jsonper lazy-load miss (cached inRwLock<HashMap<Locale, Value>>).
Under bundled-assets, both get an alternative implementation that
reads from EmbeddedAssets (locale files are at
static-dist/locales/*.json, picked up by the same folder embed).
Recommended shape: constructor pair —
LocaleRegistry::discover_filesystem(path) and
#[cfg(feature = "bundled-assets")] LocaleRegistry::discover_embedded().
main.rs picks based on the resolved StaticSource. Simpler than a
trait-based indirection for two static sources with the same interface.
Frontend at runtime ALSO fetches /locales/*.json for client-side
i18n — this path is served by the same static router in (1) above,
so no separate work; the embed already covers it.
Precedence rule: even in a bundled build, honour OXICLOUD_STATIC_PATH
when it points at an existing directory. Lets ops override embedded
assets for locale patches / theming without a full rebuild. The
resolve_static_path return value is checked at boot; a real directory
wins over embedded fallback. If the resolved directory does NOT exist,
fall through to the embedded handler cleanly (log at info level:
"OXICLOUD_STATIC_PATH points at which doesn't exist; serving
embedded assets").
Build-time invariant: cargo build --features bundled-assets requires
static-dist/ to exist AND be non-empty. Add a build.rs check that
emits a clear error if missing, pointing at just fe-build /
(cd frontend && npm run build).
Precompression + embed strategy: minimize binary size by storing assets compressed inside the binary, and use axum's response compression on the wire.
rust-embed's compression feature deflate-compresses each embedded
file at compile time. Files are decompressed lazily on first access
and cached in a per-file OnceCell for the remainder of the process.
Warms up quickly under real traffic — the first user's page load
touches ~30 files, all cached from then on.
On the wire, response compression is handled by axum's
CompressionLayer (tower-http) applied to the static router
subtree. Browsers get Content-Encoding: br when they Accept-Encoding
brotli; gzip fallback; identity for clients that ask for neither.
Projected embed size after excludes + rust-embed deflate compression:
~4-5 MB. Matches the .tar.xz reference size and roughly halves
what raw-embed-plus-siblings would cost. Runtime CPU: negligible under
any real load; the compressed variants would benefit from a
reverse-proxy cache in front for CPU-tight hosts (Pi 4/5).
Consequence for the nginx/reverse-proxy story users will run in
front: the binary responds correctly to Accept-Encoding: br, gzip
without configuration. Users terminating TLS at their proxy get
compressed responses either way (proxy passes through or re-compresses
its cache).
3. Target matrix — musl-only Linux
Three triples cover the practical need:
| Triple | Runner + toolchain | Notes |
|---|---|---|
x86_64-unknown-linux-musl |
ubuntu-22.04 + musl-tools + rustup target add |
Static, no glibc dep, runs on ANY Linux distro from Alpine to CentOS 7 to Debian 10 to Ubuntu 25.04. Cross-compiled natively with glibc host + musl target; produces same output as the alpine-container path we originally planned. |
aarch64-unknown-linux-musl |
ubuntu-22.04-arm + musl-tools + rustup target add |
Same shape as the amd64 twin. Native ARM64 runner (no QEMU). Pi 4/5, ARM servers, Graviton. |
aarch64-apple-darwin |
macos-latest |
Apple Silicon, native |
Historical note — Intel macOS dropped 2026-08-29 (Apple phasing
out macos-13; runner-availability tax exceeded value). Intel Mac
users fall back to cargo install, Docker --platform linux/amd64,
or one of the Linux musl tarballs inside a Linux VM.
Historical note — Alpine-container approach abandoned 2026-08-29
in favour of native cross-compile. Original plan built inside the
Dockerfile's rust:1.96-alpine3.24 for byte-for-byte parity with
Docker; broke on ubuntu-22.04-arm because JS-based GitHub Actions
(checkout, artifact steps, setup-node) can't run inside Alpine on
ARM64 (Node.js binary requires glibc; the x64-Alpine workaround
doesn't extend to arm64). Native ubuntu-22.04 + musl-tools +
rustup target add produces the same --target *-musl output
without the container gymnastics.
Rationale for musl-only Linux:
- Parity with Docker. The Docker image is already Alpine/musl — users get identical runtime behaviour whether they pull the container or the tarball. One build shape, one test surface.
- Face-indexing regression is a NON-issue.
faces-onnxrequires glibc-onlylibonnxruntime.so; it's already unavailable on the Docker image. Users who want face indexing build from source with--features faces-onnxon a glibc host — same as today, no change from musl-only tarballs. - Zero glibc-version fragmentation. No
GLIBC_2.35 not founderrors on older distros. One binary works everywhere. - Simpler install docs. "Download this file, run it" without a "which glibc do you have?" branch.
- Marginal perf hit is invisible under I/O-bound OxiCloud workloads.
Musl's
mallocand DNS resolver quirks matter for allocation-heavy / DNS-heavy servers; OxiCloud is neither.
External runtime dependencies — complete list. Codebase audit
2026-08-27 confirmed ffmpeg is the ONLY Command::new invocation
in src/; no other subprocess deps exist.
| Category | Dep | Required? | Notes |
|---|---|---|---|
| Subprocess | ffmpeg |
Optional | Video thumbnails. Kill switch: OXICLOUD_ENABLE_VIDEO_THUMBNAILS=false. Path override: OXICLOUD_FFMPEG_PATH |
| System lib | ca-certificates |
Required | Outbound HTTPS (OIDC, S3, webhooks). Pre-installed on nearly every distro |
| System lib | tzdata |
Required | Timezone DB for chrono. Pre-installed on nearly every distro |
| External service | PostgreSQL 13+ | Required | With pg_trgm + ltree extensions. TCP/loopback only — no libpq client lib needed |
| Runtime dylib | libonnxruntime.so + ONNX models |
N/A for tarball | Face indexing (glibc-only, requires build from source with --features faces-onnx). Not shipped in musl tarballs — Docker/tarball users don't have this feature |
Explicit non-deps (worth documenting to preempt questions):
- No libpq — sqlx uses pure-Rust tokio-postgres
- No git — only build-time metadata via
build.rs, never runtime - No ImageMagick / libvips — image thumbnails via pure-Rust
imagecrate - No pandoc / rst2html / etc. — no document conversion
- No systemd/launchd — daemon lifecycle user-managed
- No sendmail / SMTP CLI — email via pure-Rust SMTP client
Per-distro install command (for README-install.md):
| Distro | Command |
|---|---|
| Alpine | apk add ca-certificates tzdata ffmpeg |
| Debian / Ubuntu | apt install ca-certificates tzdata ffmpeg |
| Fedora / RHEL | dnf install ca-certificates tzdata ffmpeg (RPMFusion for full codec set) |
| Arch | pacman -S ca-certificates tzdata ffmpeg |
| macOS | brew install ffmpeg (ca-certificates + tzdata built in) |
| Portable Linux | Static ffmpeg from https://github.com/BtbN/FFmpeg-Builds/releases + OXICLOUD_FFMPEG_PATH=<path> |
Postgres install is documented separately (project docs) since it's a per-distro-per-version story with per-extension setup.
Windows deliberately deferred — sqlx feature set, some C deps, testing story on Windows are all extra work.
Pi 2 / 32-bit ARM (armv7-unknown-linux-gnueabihf) excluded —
1 GB RAM is below OxiCloud's practical floor even with face indexing
disabled.
Building strategy for Linux musl targets — run the compilation
inside the rust:1.96-alpine3.24 container image the Dockerfile
already uses. Guarantees byte-for-byte parity with what ends up in
the published Docker image; zero new toolchain to maintain. Runner
just needs Docker (all GitHub-hosted Linux runners have it). No
rustup target add, no apt install musl-tools.
CPU baseline — the repo sets -C target-cpu=native for x86_64 and
aarch64 hosts (.cargo/config.toml:11-12). That flag makes the binary
use every CPU feature the BUILDER exposes — great for local dev,
catastrophic for distributed binaries: a runner with AVX-512 produces
a binary that segfaults on any older CPU. Precedent for the fix at
.github/workflows/load-smoke.yml:28, which already overrides with
RUSTFLAGS="-C target-cpu=x86-64-v3" for load tests.
Per-target baseline for release-binaries.yml:
| Triple | RUSTFLAGS |
|---|---|
x86_64-unknown-linux-musl |
-C target-cpu=x86-64-v2 |
aarch64-unknown-linux-musl |
-C target-cpu=generic (safe ARMv8-A baseline) |
aarch64-apple-darwin |
-C target-cpu=apple-m1 |
x86-64-v2 covers ~2010+ processors (Nehalem, Bulldozer). Widest
realistic install base for a "runs everywhere" tarball. Notably
different from Docker's x86-64-v3 (per load-smoke.yml:28) — Docker
targets performance-tuned deployments, tarballs target maximum
compatibility.
Trade-off left on the table: BLAKE3 SIMD + image codecs run somewhat slower on v2 than v3. For a self-hosted personal cloud workload this is invisible; for anyone who wants max perf, the Docker image is still their better option.
4. Tarball layout
One archive per platform. Four files inside, all rooted under a per-version-per-triple directory so extraction lands cleanly:
oxicloud-0.9.0-<triple>/
├── oxicloud ← the single binary (server + tools + embedded assets)
├── example.env ← copied verbatim from repo root (50 KB, all env vars documented)
├── LICENSE ← copied verbatim from repo root
└── README-install.md ← NEW, ~100 lines, tarball-audience-specific
Deliberate exclusions:
README.md(repo root, 10 KB) — the GitHub landing page: features, screenshots, tech stack, contribution guide. Wrong orientation for a downloaded tarball. Users getREADME-install.mdinstead — shorter, focused on "how do I run this thing on this box?"oxicloud.servicesystemd unit — inlined as a copy-paste block inREADME-install.md. Users have to customizeUser=/WorkingDirectory=anyway; a documented example beats a shipped file that pretends to be canonical.CHANGELOG.md— the GitHub Release page carries the notes. Duplicating invites drift.docs/— full documentation stays on GitHub, linked fromREADME-install.md.
README-install.md content shape (~100 lines):
- Quickstart — required env vars, one-command run
- PostgreSQL setup — link to project docs; note
pg_trgm+ltreeextensions - Optional: video thumbnails — mention ffmpeg + the
OXICLOUD_ENABLE_VIDEO_THUMBNAILS=falsekill switch (first user-facing surface for this env var, closing the discoverability gap flagged in memorybug_env_docs_video_thumbnails_missing) - Systemd unit example — inline copy-paste block, references
/etc/oxicloud/oxicloud.envfor env vars - First-run — direct to
/setupfor admin account creation - Verification —
sha256sum -c ../SHA256SUMSfor tarball integrity - Upgrading — replace binary in place, restart service; migrations
run automatically on boot per
sqlx::migrate!() - Support links — GitHub Issues, docs site
- Docker note — for users who want the container path instead
Tarball name: oxicloud-<version>-<triple>.tar.gz.
macOS tarballs stay .tar.gz too (not .zip) — Homebrew formulas
handle either, and it keeps the CI packaging step uniform. Same
extraction UX cross-platform (tar xzf).
SHA256SUMS file lists all archives with hashes at the release-level
(next to the tarballs, not inside them) — standard OSS practice.
Users verify via sha256sum -c SHA256SUMS before extraction.
5. New workflow: .github/workflows/release-binaries.yml
Three-stage pipeline, shared frontend build:
1. frontend-build (ubuntu-latest, single job)
- checkout
- Node 26 setup
- npm ci && npm run build (writes static-dist/ at repo root)
- upload static-dist/ as artifact "static-dist"
2. binary-build (matrix over 4 targets, needs: frontend-build)
- checkout
- download static-dist artifact into repo-root static-dist/
- Linux targets: docker run rust:1.96-alpine3.24, cargo build inside
- macOS targets: rustup target add + native cargo build
- cargo build --release --features bundled-assets --bin oxicloud
- tar czf oxicloud-<version>-<triple>.tar.gz oxicloud-<version>-<triple>/
- upload tarball as per-platform artifact
3. release (ubuntu-latest, needs: binary-build)
- download all tarball artifacts
- compute SHA256SUMS
- softprops/action-gh-release@v2 with files: dist/*
Triggers: push: tags: v* (real releases) + workflow_dispatch with
dry_run: true toggle (build tarballs, upload as workflow artifacts,
skip attaching to a release).
Interaction with existing release.yml: new file, because the
current release.yml is tiny (create release + notes) and mixing
concerns would clutter it. release.yml stays as "make the GitHub
Release exist"; release-binaries.yml stacks binaries into it. Both
trigger on push: tags: v*.
Parallel-fire behaviour on tag push — on git push origin v0.9.0,
three workflows fire simultaneously:
tag push v0.9.0
│
├─── release.yml (~1 min) Release + notes
├─── docker-publish.yml (~30-45 min) multi-arch Docker → GHCR + DockerHub
└─── release-binaries.yml (~25-30 min) 4 tarballs → attach to Release
Total wall-clock: ~30-45 min (dominated by whichever build is slower). No sequencing between the three — each has a single responsibility and runs independently.
Race with release.yml is benign because release-binaries.yml
uses softprops/action-gh-release@v2, which:
- Adds files to an existing Release if one exists for the tag.
- Creates the Release (with default settings, no notes) if
release.ymlhasn't finished yet.
Worst case: release-binaries.yml finishes first on a tiny tag, creates
a bare Release, release.yml catches up and fills in the notes. Users
see the Release progressively; nothing breaks. If this becomes annoying
in practice (unlikely — release.yml is ~1 min), flip
release-binaries.yml to on: workflow_run: { workflows: ["Release"], types: [completed] } to serialize.
Concurrency: same ${{ github.workflow }}-${{ github.ref }} group as
docker-publish.yml, but cancel-in-progress: false — every tag is
unique and immutable, so a superseded release build has nothing to
cancel.
Publish gate: same fork-friendly pattern as docker-publish.yml —
if: github.repository == 'AtalayaLabs/OxiCloud' || vars.ENABLE_BINARY_RELEASE == 'true'. Prevents forks from
auto-attaching binaries to their own tag pushes.
6. Docs
docs/install/binary.md— quickstart per platform, verify SHA256SUMS, minimum env vars (DATABASE_URL), systemd unit example, Pi-specific advice (link to the "verified on" hardware table). Prose only — no code snippets that could go stale. Include a "server-side video thumbnails" callout naming theOXICLOUD_ENABLE_VIDEO_THUMBNAILS=falsekill switch — this is the first user-facing surface where the env var is discoverable (per memorybug_env_docs_video_thumbnails_missing, it's not inexample.envnordocs/env.mdtoday). Consider fixing the underlying gap inexample.env+docs/env.mdas a companion edit to this PR — small win, high visibility.README.md— add a one-line pointer under Installation: "Binary releases attached to each GitHub Release — see [docs/install/binary.md]". Do NOT list per-triple download links by hand; they'd rot.- This file — the design record. Kept alongside other
docs/plan/*.mddocs so the next maintainer sees the rationale before touchingrelease-binaries.ymlor the embed layer.
7. Cargo.toml [package.metadata.binstall] block
Free win: cargo binstall oxicloud starts working once the tarballs
land on GitHub Releases with predictable names. Two-line metadata
block declares the URL template:
[package.metadata.binstall]
pkg-url = "{ repo }/releases/download/v{ version }/oxicloud-{ version }-{ target }.tar.gz"
bin-dir = "oxicloud-{ version }-{ target }/{ bin }{ binary-ext }"
No CI change; the tarballs already follow this shape from Deliverable 4.
Critical files
Cargo.toml— addbundled-assets+dev_toolsfeatures,required-featureson gated bins,rust-embedoptional dep,[package.metadata.binstall]block. Delete the[[bin]] name = "oxicloud-cli"and[[bin]] name = "migrate-nfc-filenames"blocks (Deliverables 1a + 1b).src/main.rs— add clap parsing at the top ofmain(). If a subcommand is present → dispatch via newsrc/cli/module; otherwise fall through to the existing server-init path (backwards-compat implicit-server mode).src/cli/mod.rs— NEW. Root of the operator-tools tree; containsDomainenum + submodules moved fromsrc/bin/oxicloud-cli.rs.src/cli/opaque.rs— NEW.opaque setup+opaque resetmoved from the oldoxicloud-cli.rs.src/cli/migrate.rs— NEW.migrate nfc-filenames— the ~149 non-boilerplate lines from the oldmigrate-nfc-filenames.rs, wrapped as a clap subcommand.src/bin/oxicloud-cli.rs— DELETE (contents absorbed intosrc/cli/).src/bin/migrate-nfc-filenames.rs— DELETE (contents absorbed intosrc/cli/migrate.rs).src/interfaces/web/mod.rs— 400-line file, owns the static-serving surface. Four sites gain a#[cfg(feature = "bundled-assets")]alternative:resolve_static_path()(:25-35) — returns aStaticSourceenum under bundled mode; a barePathBufotherwisecreate_web_routes()(:47-106) — swap the twoServeDirconstructions for embedded-asset handlerscontent_security_policy()+inline_script_csp_hashes()(:163-233) — iterateEmbeddedAssets::iter()instead offs::read_dir- Import block + type imports for the new source enum
src/interfaces/web/embedded.rs— NEW:#[derive(RustEmbed)]struct- two axum handlers (root/SPA-fallback +
_app/immutable-prefixed with cache header) + shared MIME helper. ~100 lines.
- two axum handlers (root/SPA-fallback +
src/main.rs:599-615— locale-source resolution. Under bundled mode, callLocaleRegistry::discover_embedded()instead of the filesystem variant whenresolve_static_path()returnsStaticSource::Embedded.src/common/locale.rs:150-221— addLocaleRegistry::discover_embedded()under#[cfg(feature = "bundled-assets")]. Same parse + registry build, source isEmbeddedAssets::iter()filtered tolocales/*.json.src/infrastructure/services/file_system_i18n_service.rs— either extend to accept anEmbeddedLocalessource alongside the filesystem one, OR ship a secondEmbeddedI18nServiceimpl of the same trait. Latter avoids polluting the fast filesystem path with cfg gates.build.rs— EXISTS today (injectsGIT_HASH/GIT_BRANCHfrom git). Extend with a second block: when thebundled-assetsfeature is enabled (env::var("CARGO_FEATURE_BUNDLED_ASSETS").is_ok()), check that repo-rootstatic-dist/exists and contains at leastindex.html. Emit a clear compile error pointing atjust fe-build/(cd frontend && npm run build)if missing. Also emitcargo:rerun-if-changed=static-dist/so a rebuild of the frontend re-triggers rust-embed's compile-time embed step..cargo/config.toml— NO CHANGES. The dev-preserving default of-C target-cpu=nativestays. Release CI overrides via per-jobRUSTFLAGSenv var, per the load-smoke.yml precedent..github/workflows/release-binaries.yml— NEW: three-stage pipeline.justfile— thread--features dev_toolsinto theopenapirecipe.tests/api/run.sh— thread--features test_utilsinto the two hurl-helper build lines.docs/install/binary.md— NEW: user-facing installation guide.
Verification
-
Local squash check: after Cargo.toml +
src/cli/edits, runcargo build --release --binsand confirm exactly ONE binary appears intarget/release/(oxicloud). Runcargo build --release --bins --features test_utilsand confirm the two hurl helpers appear.cargo build --release --bins --features dev_toolsshould surfacegenerate-openapi. Confirm subcommand shape via:target/release/oxicloud --help— showsopaque+migratedomainstarget/release/oxicloud opaque setup— prints a fresh ServerSetup base64 line (unchanged behaviour vs the oldoxicloud-cli opaque setup)target/release/oxicloud migrate nfc-filenames --dry-run(against a sandbox DB) — same behaviour as the oldmigrate-nfc-filenames --dry-runtarget/release/oxicloud(no args) — starts the server exactly as today, no clap-related output surprises before the server init banner.
-
Local bundled-assets smoke:
(cd frontend && npm ci && npm run build) # writes ../static-dist/ cargo build --release --features bundled-assets --bin oxicloud # Wipe static-dist/ or point OXICLOUD_STATIC_PATH somewhere # nonexistent to force the embedded path to be exercised. mv static-dist/ static-dist.hidden/ OXICLOUD_STATIC_PATH=/tmp/nonexistent DATABASE_URL=... target/release/oxicloud # Hit http://localhost:8086 — SPA shell + locales must load. # Restore afterward: mv static-dist.hidden/ static-dist/ -
Filesystem fallback still works in bundled build: with the same binary, point
OXICLOUD_STATIC_PATHat a real static-dist, confirm files served from disk (change a file, no rebuild → change visible in browser). Verifies the precedence rule from Deliverable 2. -
Non-bundled build still works:
cargo build --release(without--features bundled-assets) → binary boots + serves from./static/static-dist/as today. Zero regression on the Docker image path. -
CI dry-run: dispatch
release-binaries.ymlwithdry_run: truefrom a fork. Confirms all four matrix entries build successfully, tarballs land in the run's artifact list, no release is created. -
Manual extraction test: download one tarball, extract, run
./oxicloudwith justDATABASE_URLset (against a local Postgres). Log in, upload a file, check that locale switching works, confirm/api/statusreturns healthy. Then repeat on a Pi 5 for theaarch64-unknown-linux-muslvariant if convenient.
Not in scope
- Windows target — separate work when demand appears.
- glibc Linux tarballs — musl covers the Linux audience per the
design shape above; users wanting glibc-specific features
(
faces-onnx) build from source. - 32-bit ARM (
armv7) — hardware below the workload floor. - Debian/RPM packages — post-tarball layer, adds repo-hosting burden.
- Homebrew tap — trivial once tarballs exist; separate decision.
- Signing (Sigstore/GPG) — worth adding but scope-creeping; SHA256SUMS is the minimum table stakes for this PR.
Delivery order
- Feature-flag squash (Deliverable 1). Cargo.toml edits +
just openapi/tests/api/run.shinvocation fixes. Verifycargo build --release --binsno longer builds hurl helpers. - Merge migrate-nfc-filenames into oxicloud-cli (Deliverable 1a).
Move logic to
mod migratesubmodule. Delete standalone bin. Verifyoxicloud-cli migrate nfc-filenames --dry-runworks. - Merge oxicloud-cli into oxicloud (Deliverable 1b). Move
src/bin/oxicloud-cli.rscontents into newsrc/cli/module, wire clap intomain.rswith implicit-server default. Deletesrc/bin/oxicloud-cli.rs. Verifyoxicloud(no args) still starts the server;oxicloud opaque setup+oxicloud migrate nfc-filenames --dry-runwork. - Add
bundled-assetsfeature (Deliverable 2).rust-embed+build.rsguard +src/interfaces/web/embedded.rs+ locale loader alt + CSP scan alt. Verify locally with the smoke sequence in Verification §2. - Add
.github/workflows/release-binaries.yml(Deliverable 5). Dry-run on a fork. Iterate until all 4 targets green. - Write docs (Deliverable 6) —
docs/install/binary.md. Prose only, no snippets that will rot. Include theOXICLOUD_ENABLE_VIDEO_THUMBNAILS=falsecallout for tarball users without ffmpeg. - Add
[package.metadata.binstall]block (Deliverable 7). One-line change enablingcargo binstall oxicloud. - Fix the
OXICLOUD_ENABLE_VIDEO_THUMBNAILSdoc gap — add toexample.env+docs/env.mdper memorybug_env_docs_video_thumbnails_missing. Small companion edit surfaced by the binary-install docs work. - Cut a test tag (
v0.9.0-rc1?) on a fork withvars.ENABLE_BINARY_RELEASE=true. Confirm tarballs attach to the Release, SHA256SUMS present,cargo binstall oxicloudworks. - When happy, cut on canonical.
Total scope: ~2.5 days of careful work.
- Deliverables 1 + 1a + 1b: ~5 hours mechanical (Cargo config, CLI merge, subcommand tree)
- Deliverable 2: ~1 day — the only piece with real design surface (embed swap, four cfg sites, locale + CSP loaders)
- Deliverable 5: ~4 hours workflow authoring + iteration
- Deliverables 6-8: ~4 hours docs + small edits
- Verification + iteration: ~4 hours