724e6bec36
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.
889 lines
41 KiB
Markdown
889 lines
41 KiB
Markdown
# 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:
|
||
|
||
1. Ship **single-file binaries with frontend assets embedded**, for
|
||
the common Linux targets and macOS.
|
||
2. 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-embed` with compile-time deflate
|
||
compression** — smaller binary
|
||
- **`bundled-assets` is opt-in** — default `cargo build` unchanged;
|
||
`just dev` still uses the filesystem `ServeDir` with Vite HMR
|
||
- **Single unified binary** — `oxicloud`, `oxicloud-cli`, and
|
||
`migrate-nfc-filenames` collapse 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`:
|
||
|
||
```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 openapi` recipe → add `--features dev_tools` to the underlying
|
||
`cargo run --bin generate-openapi` call (currently `cargo run --bin
|
||
generate-openapi` per justfile)
|
||
- `tests/api/run.sh` → add `--features test_utils` when 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 a
|
||
> `run(args) -> ExitCode` entrypoint. 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 migrate` in `src/bin/oxicloud-cli.rs`** — moves the ~149
|
||
non-boilerplate lines from `migrate-nfc-filenames.rs::main()` into
|
||
a `run_nfc_filenames(dry_run: bool) -> ExitCode` function.
|
||
`env::args()` parsing goes away; clap handles it.
|
||
- **Delete `src/bin/migrate-nfc-filenames.rs`**.
|
||
- **Delete the `[[bin]]` entry** in `Cargo.toml`.
|
||
- **Update `Dockerfile`** — 6 references to `migrate-nfc-filenames`
|
||
(build commands at :46, :49, :89, `cp` steps at :130, :143, doc
|
||
comment at :170, `COPY --chmod=755 --from=app` at :173).
|
||
- **Update `docs/plan/benchmake-and-performance-tracking.md`** — 2
|
||
references to `migrate-nfc-filenames` at lines :44 and :161. Reword
|
||
to reference `oxicloud-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-run` becomes `docker exec <container>
|
||
oxicloud-cli migrate nfc-filenames --dry-run` (intermediate) then
|
||
`docker exec <container> oxicloud migrate nfc-filenames --dry-run`
|
||
after 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` — was `oxicloud-cli opaque setup`
|
||
- `oxicloud opaque reset --user alice --dry-run` — was `oxicloud-cli
|
||
opaque reset ...`
|
||
- `oxicloud migrate nfc-filenames --dry-run` — was
|
||
`migrate-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 of `main()`, 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 the `Domain` enum + the
|
||
`opaque` and `migrate` submodules moved from
|
||
`src/bin/oxicloud-cli.rs`. Each subcommand module keeps its
|
||
self-contained shape per the growth pattern documented in the
|
||
old `oxicloud-cli.rs` header.
|
||
- **Delete `src/bin/oxicloud-cli.rs`** entirely.
|
||
- **Delete the `[[bin]] name = "oxicloud-cli"` block** in `Cargo.toml`.
|
||
- **`Dockerfile`** — drop all 4 references to `oxicloud-cli` (build
|
||
target lines + COPY steps). Simplified build command becomes
|
||
`cargo build --release --bin oxicloud` — single-binary.
|
||
- **Docs** — all `docker exec <container> oxicloud-cli <domain>
|
||
<action>` become `docker 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
|
||
usual `ServeDir` (or falls back to `frontend/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:
|
||
|
||
```toml
|
||
[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`:
|
||
|
||
```rust
|
||
#[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:
|
||
|
||
```js
|
||
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()`:
|
||
|
||
1. **`spa` ServeDir** (`mod.rs:60-63`) — root fallback with
|
||
`precompressed_br().precompressed_gzip()` and SPA-shell fallback
|
||
pointing at `<static>/index.html`. Under embed: an axum handler
|
||
that resolves the request path against `EmbeddedAssets::get()`,
|
||
200 with correct MIME (via `mime_guess`) if hit, otherwise return
|
||
the embedded `index.html` bytes with `text/html` for SPA client-routing.
|
||
2. **`app_immutable` ServeDir** (`mod.rs:66-77`) — nested at
|
||
`/_app/immutable` with `Cache-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.
|
||
3. **`ServeFile::new(index.html)`** SPA fallback (`mod.rs:63`) —
|
||
folds into (1)'s not-found path.
|
||
4. **CSP inline-script scan** (`mod.rs:163-233`) — currently reads
|
||
every `.html` file in the resolved static dir via
|
||
`std::fs::read_dir` + `std::fs::read_to_string` at boot to compute
|
||
SHA-256 CSP source expressions for every inline `<script>`. Under
|
||
embed: iterate `EmbeddedAssets::iter()` filtered to `.html`
|
||
extensions, 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:
|
||
|
||
```rust
|
||
#[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 to `LocaleRegistry::discover()` at
|
||
`src/common/locale.rs:150-221`, which does `fs::read_dir` +
|
||
`fs::read_to_string` + `serde_json::from_str` on 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: PathBuf` field, does
|
||
`tokio::fs::read_to_string` on `<dir>/<code>.json` per lazy-load
|
||
miss (cached in `RwLock<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 <path> 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**:
|
||
|
||
1. **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.
|
||
2. **Face-indexing regression is a NON-issue.** `faces-onnx` requires
|
||
glibc-only `libonnxruntime.so`; it's already unavailable on the
|
||
Docker image. Users who want face indexing build from source with
|
||
`--features faces-onnx` on a glibc host — same as today, no
|
||
change from musl-only tarballs.
|
||
3. **Zero glibc-version fragmentation.** No `GLIBC_2.35 not found`
|
||
errors on older distros. One binary works everywhere.
|
||
4. **Simpler install docs.** "Download this file, run it" without
|
||
a "which glibc do you have?" branch.
|
||
5. **Marginal perf hit is invisible under I/O-bound OxiCloud workloads.**
|
||
Musl's `malloc` and 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 `image` crate
|
||
- **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 get `README-install.md` instead — shorter,
|
||
focused on "how do I run this thing on this box?"
|
||
- **`oxicloud.service` systemd unit** — inlined as a copy-paste block in
|
||
`README-install.md`. Users have to customize `User=` /
|
||
`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 from
|
||
`README-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` + `ltree`
|
||
extensions
|
||
- **Optional: video thumbnails** — mention ffmpeg + the
|
||
`OXICLOUD_ENABLE_VIDEO_THUMBNAILS=false` kill switch (first
|
||
user-facing surface for this env var, closing the discoverability
|
||
gap flagged in memory `bug_env_docs_video_thumbnails_missing`)
|
||
- **Systemd unit example** — inline copy-paste block, references
|
||
`/etc/oxicloud/oxicloud.env` for env vars
|
||
- **First-run** — direct to `/setup` for admin account creation
|
||
- **Verification** — `sha256sum -c ../SHA256SUMS` for 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.yml` hasn'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 the
|
||
`OXICLOUD_ENABLE_VIDEO_THUMBNAILS=false` kill switch — this is
|
||
the first user-facing surface where the env var is discoverable
|
||
(per memory `bug_env_docs_video_thumbnails_missing`, it's not
|
||
in `example.env` nor `docs/env.md` today). Consider fixing the
|
||
underlying gap in `example.env` + `docs/env.md` as 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/*.md` docs so the next maintainer sees the rationale
|
||
before touching `release-binaries.yml` or 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:
|
||
|
||
```toml
|
||
[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` — add `bundled-assets` + `dev_tools` features,
|
||
`required-features` on gated bins, `rust-embed` optional 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 of `main()`. If a
|
||
subcommand is present → dispatch via new `src/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; contains
|
||
`Domain` enum + submodules moved from `src/bin/oxicloud-cli.rs`.
|
||
- `src/cli/opaque.rs` — NEW. `opaque setup` + `opaque reset` moved
|
||
from the old `oxicloud-cli.rs`.
|
||
- `src/cli/migrate.rs` — NEW. `migrate nfc-filenames` — the ~149
|
||
non-boilerplate lines from the old `migrate-nfc-filenames.rs`,
|
||
wrapped as a clap subcommand.
|
||
- `src/bin/oxicloud-cli.rs` — DELETE (contents absorbed into `src/cli/`).
|
||
- `src/bin/migrate-nfc-filenames.rs` — DELETE (contents absorbed
|
||
into `src/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 a `StaticSource`
|
||
enum under bundled mode; a bare `PathBuf` otherwise
|
||
- `create_web_routes()` (`:47-106`) — swap the two `ServeDir`
|
||
constructions for embedded-asset handlers
|
||
- `content_security_policy()` + `inline_script_csp_hashes()`
|
||
(`:163-233`) — iterate `EmbeddedAssets::iter()` instead of
|
||
`fs::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.
|
||
- `src/main.rs:599-615` — locale-source resolution. Under bundled
|
||
mode, call `LocaleRegistry::discover_embedded()` instead of the
|
||
filesystem variant when `resolve_static_path()` returns
|
||
`StaticSource::Embedded`.
|
||
- `src/common/locale.rs:150-221` — add `LocaleRegistry::discover_embedded()`
|
||
under `#[cfg(feature = "bundled-assets")]`. Same parse + registry
|
||
build, source is `EmbeddedAssets::iter()` filtered to `locales/*.json`.
|
||
- `src/infrastructure/services/file_system_i18n_service.rs` — either
|
||
extend to accept an `EmbeddedLocales` source alongside the
|
||
filesystem one, OR ship a second `EmbeddedI18nService` impl of the
|
||
same trait. Latter avoids polluting the fast filesystem path with
|
||
cfg gates.
|
||
- `build.rs` — EXISTS today (injects `GIT_HASH`/`GIT_BRANCH` from git).
|
||
Extend with a second block: when the `bundled-assets` feature is
|
||
enabled (`env::var("CARGO_FEATURE_BUNDLED_ASSETS").is_ok()`),
|
||
check that repo-root `static-dist/` exists and contains at least
|
||
`index.html`. Emit a clear compile error pointing at
|
||
`just fe-build` / `(cd frontend && npm run build)` if missing.
|
||
Also emit `cargo: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=native` stays. Release CI overrides via per-job
|
||
`RUSTFLAGS` env var, per the load-smoke.yml precedent.
|
||
- `.github/workflows/release-binaries.yml` — NEW: three-stage pipeline.
|
||
- `justfile` — thread `--features dev_tools` into the `openapi` recipe.
|
||
- `tests/api/run.sh` — thread `--features test_utils` into the two
|
||
hurl-helper build lines.
|
||
- `docs/install/binary.md` — NEW: user-facing installation guide.
|
||
|
||
## Verification
|
||
|
||
1. **Local squash check**: after Cargo.toml + `src/cli/` edits, run
|
||
`cargo build --release --bins` and confirm exactly ONE binary
|
||
appears in `target/release/` (`oxicloud`). Run `cargo build --release
|
||
--bins --features test_utils` and confirm the two hurl helpers
|
||
appear. `cargo build --release --bins --features dev_tools`
|
||
should surface `generate-openapi`. Confirm subcommand shape via:
|
||
- `target/release/oxicloud --help` — shows `opaque` + `migrate`
|
||
domains
|
||
- `target/release/oxicloud opaque setup` — prints a fresh
|
||
ServerSetup base64 line (unchanged behaviour vs the old
|
||
`oxicloud-cli opaque setup`)
|
||
- `target/release/oxicloud migrate nfc-filenames --dry-run`
|
||
(against a sandbox DB) — same behaviour as the old
|
||
`migrate-nfc-filenames --dry-run`
|
||
- `target/release/oxicloud` (no args) — starts the server exactly
|
||
as today, no clap-related output surprises before the server
|
||
init banner.
|
||
|
||
2. **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/
|
||
```
|
||
|
||
3. **Filesystem fallback still works in bundled build**: with the
|
||
same binary, point `OXICLOUD_STATIC_PATH` at 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.
|
||
|
||
4. **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.
|
||
|
||
5. **CI dry-run**: dispatch `release-binaries.yml` with `dry_run: true`
|
||
from a fork. Confirms all four matrix entries build successfully,
|
||
tarballs land in the run's artifact list, no release is created.
|
||
|
||
6. **Manual extraction test**: download one tarball, extract, run
|
||
`./oxicloud` with just `DATABASE_URL` set (against a local
|
||
Postgres). Log in, upload a file, check that locale switching
|
||
works, confirm `/api/status` returns healthy. Then repeat on a Pi 5
|
||
for the `aarch64-unknown-linux-musl` variant 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
|
||
|
||
1. **Feature-flag squash** (Deliverable 1). Cargo.toml edits +
|
||
`just openapi` / `tests/api/run.sh` invocation fixes. Verify
|
||
`cargo build --release --bins` no longer builds hurl helpers.
|
||
2. **Merge migrate-nfc-filenames into oxicloud-cli** (Deliverable 1a).
|
||
Move logic to `mod migrate` submodule. Delete standalone bin.
|
||
Verify `oxicloud-cli migrate nfc-filenames --dry-run` works.
|
||
3. **Merge oxicloud-cli into oxicloud** (Deliverable 1b). Move
|
||
`src/bin/oxicloud-cli.rs` contents into new `src/cli/` module,
|
||
wire clap into `main.rs` with implicit-server default. Delete
|
||
`src/bin/oxicloud-cli.rs`. Verify `oxicloud` (no args) still
|
||
starts the server; `oxicloud opaque setup` + `oxicloud migrate
|
||
nfc-filenames --dry-run` work.
|
||
4. **Add `bundled-assets` feature** (Deliverable 2). `rust-embed` +
|
||
`build.rs` guard + `src/interfaces/web/embedded.rs` + locale
|
||
loader alt + CSP scan alt. Verify locally with the smoke sequence
|
||
in Verification §2.
|
||
5. **Add `.github/workflows/release-binaries.yml`** (Deliverable 5).
|
||
Dry-run on a fork. Iterate until all 4 targets green.
|
||
6. **Write docs** (Deliverable 6) — `docs/install/binary.md`. Prose
|
||
only, no snippets that will rot. Include the
|
||
`OXICLOUD_ENABLE_VIDEO_THUMBNAILS=false` callout for tarball users
|
||
without ffmpeg.
|
||
7. **Add `[package.metadata.binstall]` block** (Deliverable 7).
|
||
One-line change enabling `cargo binstall oxicloud`.
|
||
8. **Fix the `OXICLOUD_ENABLE_VIDEO_THUMBNAILS` doc gap** — add to
|
||
`example.env` + `docs/env.md` per memory
|
||
`bug_env_docs_video_thumbnails_missing`. Small companion edit
|
||
surfaced by the binary-install docs work.
|
||
9. **Cut a test tag** (`v0.9.0-rc1`?) on a fork with
|
||
`vars.ENABLE_BINARY_RELEASE=true`. Confirm tarballs attach to the
|
||
Release, SHA256SUMS present, `cargo binstall oxicloud` works.
|
||
10. **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
|