feat(bundled-binary): release-binaries.yml + install docs + binstall metadata

Adds the tag-triggered workflow that builds 4 musl-linux + macOS
tarballs and attaches them to the tag's GitHub Release. Ships a
matching install guide (docs/install/binary.md) with SHA256SUMS
verify, systemd unit, upgrade flow, and hardware notes. Adds
[package.metadata.binstall] so 'cargo binstall oxicloud' works
automatically once the first release lands.

Also re-enables incremental compilation in the dev profile — the
'modest single-crate savings' rationale from when the crate was small
has been outgrown; full rebuild ~10 min is now the dev-loop bottleneck.
This commit is contained in:
Edouard Vanbelle
2026-08-29 10:28:05 +02:00
parent 5c956c9bde
commit fac4214856
4 changed files with 556 additions and 5 deletions
+275
View File
@@ -0,0 +1,275 @@
name: "Release Binaries (musl-linux + macOS)"
# Per-run title shown in the Actions tab — makes it obvious at a
# glance which tag is being packaged and whether a manual run is a
# dry-run (build tarballs into workflow artifacts, DON'T attach to
# any GitHub Release).
run-name: >-
Release Binaries
${{ github.event_name == 'workflow_dispatch' && inputs.dry_run && '[DRY-RUN]' || '' }}
— ${{ github.event.inputs.version || github.ref_name }}
# TRIGGERS — deliberately narrow. This workflow builds 4 platform
# binaries (~15-25 min wall-clock, matrix of native runners) and
# attaches them to a GitHub Release. Running on every push to main
# would be gratuitous CI cost + noise — the point is to package
# releases, not to sanity-check the tip. The bundled-binary
# integration test in ci.yml already covers "does the embed still
# work" on every PR.
on:
push:
tags:
- "v*"
workflow_dispatch:
inputs:
version:
description: 'Existing tag to package (e.g. v0.9.0). Must exist on origin.'
required: true
dry_run:
description: 'Dry run — build + upload tarballs as workflow artifacts, skip attaching to a Release. Use to smoke-test workflow edits without publishing.'
required: false
type: boolean
default: false
# Concurrency key includes the tag ref so different tags don't cancel
# each other; `cancel-in-progress: false` because tag builds are
# unique + immutable — a superseded release build has nothing to cancel.
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: false
permissions:
contents: write
jobs:
# ── Stage 1: Build the SPA once, share across all platforms ─────────
#
# The SvelteKit build is arch-independent so a single ubuntu runner
# produces static-dist/ for every downstream binary-build matrix
# entry — saves ~2 min × 4 = 8 min vs building it per platform.
frontend-build:
name: Build SPA (Vite → static-dist/)
# Publish gate — same fork-friendly pattern as docker-publish.yml.
# Canonical repo always builds; forks stay quiet unless the fork
# owner opts in via `vars.ENABLE_BINARY_RELEASE=true` under Settings
# → Secrets and variables → Actions → Variables.
if: |
github.repository == 'AtalayaLabs/OxiCloud' ||
vars.ENABLE_BINARY_RELEASE == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
# Build the exact tag being packaged. `github.ref` is
# refs/tags/vX.Y.Z on push, refs/heads/... on dispatch (we
# override via `inputs.version` in that case).
ref: ${{ github.event.inputs.version || github.ref }}
- uses: actions/setup-node@v4
with:
node-version: 26.3.0
cache: npm
cache-dependency-path: frontend/package-lock.json
- name: Build SPA
working-directory: frontend
run: npm ci && npm run build
- uses: actions/upload-artifact@v4
with:
name: static-dist
# Repo-root output (SvelteKit adapter-static's `pages:
# '../static-dist'`). Downstream jobs restore it to the same
# location so rust-embed's `#[folder = "static-dist/"]`
# resolves without any path juggling.
path: static-dist/
retention-days: 1
# ── Stage 2: Build one binary per target ────────────────────────────
#
# 4-way matrix — 2 musl-linux (native amd64 + arm64) + 2 macOS
# (Apple Silicon + last Intel runner tier). Windows is deferred.
#
# Linux builds run inside the `rust:1.96-alpine3.24` container the
# Dockerfile already uses — guarantees byte-for-byte parity with the
# published Docker image; zero new toolchain to maintain. macOS builds
# run natively (no cross-compile). See docs/plan/bundled-binary.md § 3
# for the target-matrix rationale.
binary-build:
name: Build ${{ matrix.triple }}
needs: frontend-build
runs-on: ${{ matrix.runner }}
timeout-minutes: 60
strategy:
# `fail-fast: false` — one platform's compile failure shouldn't
# cancel the other three. Partial releases are better than none.
fail-fast: false
matrix:
include:
- triple: x86_64-unknown-linux-musl
runner: ubuntu-22.04
container: rust:1.96-alpine3.24
rustflags: "-C target-cpu=x86-64-v2"
- triple: aarch64-unknown-linux-musl
runner: ubuntu-22.04-arm
container: rust:1.96-alpine3.24
# ARMv8-A baseline — covers Pi 4/5, Graviton, every 64-bit
# ARM Linux server. `generic` is rustc's neutral baseline.
rustflags: "-C target-cpu=generic"
- triple: aarch64-apple-darwin
runner: macos-latest
container: ""
rustflags: "-C target-cpu=apple-m1"
- triple: x86_64-apple-darwin
runner: macos-13
container: ""
rustflags: "-C target-cpu=x86-64-v2"
container: ${{ matrix.container || null }}
steps:
# Alpine container image doesn't ship the deps our build needs
# (git for build.rs's GIT_HASH stamping, musl-dev for aws-lc-sys
# C parts, plus the toolchain scaffold from the Dockerfile
# builder stage). Install once at job start.
- name: Install Alpine build deps
if: matrix.container != ''
run: apk add --no-cache musl-dev pkgconfig gcc perl make bash git curl tar
- uses: actions/checkout@v4
with:
ref: ${{ github.event.inputs.version || github.ref }}
- uses: Swatinem/rust-cache@v2
with:
# Key by triple so the 4 targets don't share caches
# (different feature set + different target triple = different
# compiled artefacts).
key: ${{ matrix.triple }}
- uses: actions/download-artifact@v4
with:
name: static-dist
path: static-dist/
# rustup targets are pre-installed for the runner's own host
# triple; other targets need explicit `rustup target add`. macOS
# runners already have both apple-* triples; only touch this in
# the container path where rustup's default target list is minimal.
- name: Add rustup target
if: matrix.container == '' && matrix.triple != ''
run: rustup target add ${{ matrix.triple }}
# `--features bundled-assets` bakes static-dist/ into the binary
# via rust-embed. `--bin oxicloud` — the single binary the merge
# (Deliverable 1b) consolidated everything into.
- name: Build binary
env:
# Per-triple CPU baseline — release binaries target the widest
# realistic install base for their arch. See
# docs/plan/bundled-binary.md § 3.
RUSTFLAGS: ${{ matrix.rustflags }}
# Git metadata injection — build.rs reads these env vars to
# stamp GIT_HASH / GIT_BRANCH into the binary. Without them
# `oxicloud --version` reports "unknown".
GITHUB_SHA: ${{ github.sha }}
GITHUB_REF_NAME: ${{ github.ref_name }}
run: |
cargo build --release --features bundled-assets --bin oxicloud --target ${{ matrix.triple }}
# Assemble the tarball layout documented in
# docs/plan/bundled-binary.md § 4: oxicloud + example.env +
# LICENSE + README-install.md, rooted under a per-version-per-
# triple directory so `tar xzf` lands cleanly.
- name: Package tarball
run: |
set -euo pipefail
# Version = tag stripped of leading `v` (workflow_dispatch)
# or ref_name stripped (push tag). Falls back to ref_name
# verbatim if neither strip matches.
RAW_REF="${{ github.event.inputs.version || github.ref_name }}"
VERSION="${RAW_REF#v}"
DIST="oxicloud-${VERSION}-${{ matrix.triple }}"
mkdir -p "dist/${DIST}"
cp "target/${{ matrix.triple }}/release/oxicloud" "dist/${DIST}/oxicloud"
cp example.env "dist/${DIST}/example.env"
cp LICENSE "dist/${DIST}/LICENSE"
# README-install.md may not exist yet in early releases —
# ship a stub that points at the docs site so users have
# something in the tarball. Deliverable 6 replaces it with
# a proper install guide.
if [ -f docs/install/binary.md ]; then
cp docs/install/binary.md "dist/${DIST}/README-install.md"
else
cat > "dist/${DIST}/README-install.md" <<'MD'
# OxiCloud — Installation
Full documentation: https://github.com/AtalayaLabs/OxiCloud/tree/main/docs
Quickstart:
1. Set DATABASE_URL to a PostgreSQL 13+ instance
(with pg_trgm + ltree extensions).
2. Copy example.env → .env, edit as needed.
3. Run ./oxicloud.
Optional: install ffmpeg for server-side video thumbnails
(or set OXICLOUD_ENABLE_VIDEO_THUMBNAILS=false to disable).
MD
fi
# Deterministic tar (owner/group/mtime pinned) so re-running
# the build produces byte-identical archives — helps with
# reproducible-build audits and cheap hash verification.
tar --owner=0 --group=0 -czf "dist/${DIST}.tar.gz" -C dist "${DIST}"
ls -la "dist/${DIST}.tar.gz"
- uses: actions/upload-artifact@v4
with:
name: tarball-${{ matrix.triple }}
path: dist/*.tar.gz
retention-days: 1
# ── Stage 3: Attach all tarballs + SHA256SUMS to the Release ────────
#
# `dry_run: true` (workflow_dispatch only) skips this job — the
# binary tarballs stay as workflow artifacts (accessible from the
# run page for 1 day) but nothing lands on any Release.
release:
name: Attach tarballs to GitHub Release
needs: binary-build
if: |
needs.binary-build.result == 'success' &&
(github.event_name != 'workflow_dispatch' || github.event.inputs.dry_run != 'true')
runs-on: ubuntu-latest
steps:
- uses: actions/download-artifact@v4
with:
pattern: tarball-*
path: dist/
merge-multiple: true
- name: Compute SHA256SUMS
run: |
set -euo pipefail
cd dist
# Sort output for stable ordering across re-runs — the file
# doubles as a manifest an operator can `diff` between two
# release runs to prove they're identical.
sha256sum *.tar.gz | sort > SHA256SUMS
cat SHA256SUMS
# softprops/action-gh-release@v2 semantics:
# - If the Release for this tag EXISTS (created by release.yml
# which runs in parallel on the same tag push), attaches the
# files to it.
# - If it doesn't yet exist (race — release.yml still running),
# creates a bare Release which release.yml then fills in with
# notes when it finishes.
# Benign either way; see docs/plan/bundled-binary.md § 5
# "Parallel-fire behaviour on tag push".
- name: Attach to Release
uses: softprops/action-gh-release@v2
with:
files: |
dist/*.tar.gz
dist/SHA256SUMS
fail_on_unmatched_files: true
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+32 -5
View File
@@ -4,6 +4,24 @@ version = "0.8.7"
edition = "2024" edition = "2024"
default-run = "oxicloud" default-run = "oxicloud"
# `cargo binstall oxicloud` — fetches the prebuilt release tarball for the
# host triple from GitHub Releases (attached by
# `.github/workflows/release-binaries.yml`) instead of compiling from
# source. Templates match the tarball naming
# `oxicloud-<version>-<triple>.tar.gz` produced by that workflow.
#
# `pkg-fmt = "tgz"` — otherwise binstall guesses from the URL extension;
# being explicit lets `cargo binstall oxicloud` succeed on Windows too
# (where the URL string parsing differs).
#
# Once the first tagged release lands on GitHub, this becomes a one-line
# install for anyone with the Rust toolchain who prefers not to build
# from source and doesn't want Docker either.
[package.metadata.binstall]
pkg-url = "{ repo }/releases/download/v{ version }/oxicloud-{ version }-{ target }.tar.gz"
pkg-fmt = "tgz"
bin-dir = "oxicloud-{ version }-{ target }/{ bin }{ binary-ext }"
[dependencies] [dependencies]
mimalloc = { version = "0.1.52", default-features = false } mimalloc = { version = "0.1.52", default-features = false }
@@ -979,11 +997,20 @@ opt-level = 1
debug = "line-tables-only" debug = "line-tables-only"
split-debuginfo = "unpacked" split-debuginfo = "unpacked"
# Incremental compilation caches per-function IR fingerprints so a # Incremental compilation caches per-function IR fingerprints so a
# small edit only recompiles what changed. On a single-crate rebuild # small edit only recompiles what changed. The `target/incremental/`
# (oxicloud is one crate) the savings are modest — worth < the ~7 GB # cache costs ~7 GB per profile, but at the current codebase size a
# incremental/ cache costs on disk. Rust-analyzer uses `cargo check`, # full rebuild is ~10 minutes and an incremental single-file edit is
# which has its own cache, so LSP responsiveness is unaffected. # seconds — the disk is worth it and then some. Rust-analyzer's own
incremental = false # `cargo check` cache is separate; LSP responsiveness is unaffected
# either way.
#
# History: this was `= false` early on when the crate was small and
# incremental's savings didn't cover the disk cost. Re-enabled
# 2026-08-29 as the full-rebuild time crossed the "feels annoying"
# threshold on typical dev-loop edits. If disk pressure ever spikes,
# `cargo clean -p oxicloud --profile dev` clears the incremental cache
# without wiping compiled deps.
incremental = true
[profile.bench] [profile.bench]
lto = "fat" lto = "fat"
+10
View File
@@ -68,6 +68,16 @@ docker compose up -d
Open `http://localhost:8086`. Open `http://localhost:8086`.
### Prebuilt binary
Binary releases (Linux musl amd64/arm64, macOS Intel/Apple Silicon)
are attached to every tagged release on GitHub — the whole SPA + all
operator subcommands + migrations bake into a single self-contained
executable. See [`docs/install/binary.md`](docs/install/binary.md) for
the download / verify / systemd walkthrough.
`cargo binstall oxicloud` works too once a release is out.
### Run from source ### Run from source
Requires Rust 1.93+ and PostgreSQL. Requires Rust 1.93+ and PostgreSQL.
+239
View File
@@ -0,0 +1,239 @@
# Installing OxiCloud from a Binary Release
OxiCloud ships prebuilt binaries for common Linux and macOS platforms
attached to every tagged release on GitHub. This page covers downloading,
verifying, and running one.
If you'd rather run OxiCloud as a container, see the Docker image at
`ghcr.io/atalayalabs/oxicloud`. If you're a Rust developer who just
wants the binary without hand-fetching a tarball, `cargo binstall
oxicloud` picks the right archive for your host automatically.
## Which tarball do I want?
Every release attaches four tarballs plus a `SHA256SUMS` manifest. Pick
by your host's architecture and OS:
| Host | Tarball |
|---|---|
| Linux x86-64 (Intel / AMD servers, most VPS, WSL) | `oxicloud-<version>-x86_64-unknown-linux-musl.tar.gz` |
| Linux ARM64 (Raspberry Pi 4/5, Ampere, Graviton, ARM servers) | `oxicloud-<version>-aarch64-unknown-linux-musl.tar.gz` |
| macOS Apple Silicon (M-series) | `oxicloud-<version>-aarch64-apple-darwin.tar.gz` |
| macOS Intel | `oxicloud-<version>-x86_64-apple-darwin.tar.gz` |
The Linux tarballs link against musl, so they run on ANY glibc version
— Alpine, Debian, Ubuntu, Fedora, Arch, Rocky, and every version in
between. You never need to worry about `GLIBC_x.yy not found`.
Windows and 32-bit ARM are not currently shipped.
## Hardware notes
| Model | Notes |
|---|---|
| Pi 5 (4 GB / 8 GB) | Good experience |
| Pi 4 (4 GB / 8 GB) | Solid |
| Pi 4 (2 GB) | Works with face indexing disabled; expect swap under load |
| Pi 3 (any variant) | Marginal — only for a very light single-user personal cloud |
| Pi 2 / Pi Zero / Pi 1 | Not supported (1 GB RAM is below the practical floor) |
| Any ARM64 server | Good — the aarch64 tarball is what you want |
| Any x86-64 server from 2010 or newer | Good — Nehalem / Bulldozer + newer, per the release CPU baseline |
## Verifying the download
Every release ships a `SHA256SUMS` manifest listing every tarball with
its hash. Verify your download before extracting:
```
sha256sum -c SHA256SUMS
```
Only files present in the current directory are checked, so this
succeeds when just the tarball you downloaded matches its entry.
## Extracting
The archive lands as a per-version-per-triple directory next to it:
```
tar xzf oxicloud-<version>-<triple>.tar.gz
cd oxicloud-<version>-<triple>/
ls
# oxicloud example.env LICENSE README-install.md
```
The four files:
- `oxicloud` — the single self-contained binary. The server, all
operator subcommands (`oxicloud opaque setup`, `oxicloud migrate
nfc-filenames`, `oxicloud storage select`), and the SvelteKit web
frontend are all baked in.
- `example.env` — every OxiCloud environment variable documented with
defaults. Copy to `.env` and edit as needed.
- `LICENSE` — the project license.
- `README-install.md` — a shorter version of this page for offline
reference.
## Prerequisites
Only one moving part is required: a PostgreSQL 13+ instance with the
`pg_trgm` and `ltree` extensions available. Anything else you might
need is either baked into the binary or optional.
### Required
- **PostgreSQL 13+** with `pg_trgm` and `ltree` extensions. Any distro
package works (Debian/Ubuntu's `postgresql`, Alpine's `postgresql`,
Homebrew's `postgresql@17`, etc.). Cloud databases like Neon,
Supabase, and RDS also work provided the two extensions are enabled.
### System libraries (usually pre-installed)
- **`ca-certificates`** — for outbound HTTPS (OIDC discovery, S3, magic
links). Pre-installed on essentially every distribution.
- **`tzdata`** — timezone database. Pre-installed on nearly every
distribution; alpine minimal images sometimes need it added.
### Optional
- **`ffmpeg`** — only needed if you want the server to extract a
thumbnail frame from uploaded videos. When ffmpeg is missing the
server logs a warning at boot and videos get a placeholder icon —
everything else keeps working. If your client uploads video
previews itself (some desktop and mobile clients do), or if you
simply don't want thumbnails, set
`OXICLOUD_ENABLE_VIDEO_THUMBNAILS=false` in your `.env` to silence
the warning.
Distro install commands for the optional prerequisite:
| Distro | Command |
|---|---|
| Alpine | `apk add ffmpeg` |
| Debian / Ubuntu | `apt install ffmpeg` |
| Fedora / RHEL | `dnf install ffmpeg` (RPM Fusion for the full codec set) |
| Arch | `pacman -S ffmpeg` |
| macOS | `brew install ffmpeg` |
| Any Linux (portable) | grab a static build from https://github.com/BtbN/FFmpeg-Builds/releases and point `OXICLOUD_FFMPEG_PATH` at it |
## First run
The absolute minimum to boot the server is `DATABASE_URL`:
```
DATABASE_URL="postgres://oxicloud:secret@localhost:5432/oxicloud" \
./oxicloud
```
The binary applies its embedded database migrations on startup, then
listens on `127.0.0.1:8086` by default. Open your browser at
`http://localhost:8086/` and follow the setup flow to create the first
admin account.
For anything more than a smoke test, copy `example.env` to `.env`,
edit it, and run `./oxicloud --config .env` — that pins the config
source and makes stray shell environment variables not silently leak
in.
## Running as a systemd service (Linux)
Move the binary to a system location and create a systemd unit. The
example below runs as a dedicated `oxicloud` user, loads config from
`/etc/oxicloud/oxicloud.env`, and stores data under `/var/lib/oxicloud`.
```
sudo useradd --system --home /var/lib/oxicloud --create-home --shell /usr/sbin/nologin oxicloud
sudo install -m 0755 oxicloud /usr/local/bin/oxicloud
sudo mkdir -p /etc/oxicloud
sudo cp example.env /etc/oxicloud/oxicloud.env
sudo chown -R oxicloud:oxicloud /etc/oxicloud
sudo chmod 0640 /etc/oxicloud/oxicloud.env
```
Create `/etc/systemd/system/oxicloud.service`:
```
[Unit]
Description=OxiCloud self-hosted cloud storage
After=network-online.target postgresql.service
Wants=network-online.target
[Service]
Type=simple
User=oxicloud
Group=oxicloud
WorkingDirectory=/var/lib/oxicloud
ExecStart=/usr/local/bin/oxicloud --config /etc/oxicloud/oxicloud.env
Restart=on-failure
RestartSec=5
# Sandbox — plenty of room to tighten further per your policy
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/var/lib/oxicloud
PrivateTmp=true
[Install]
WantedBy=multi-user.target
```
Enable and start:
```
sudo systemctl daemon-reload
sudo systemctl enable --now oxicloud
sudo systemctl status oxicloud
journalctl -u oxicloud -f
```
Terminate the reverse-proxy (nginx, Caddy, HAProxy, Traefik) in front
of it for TLS and public exposure — OxiCloud itself binds plaintext
HTTP on `127.0.0.1` by default.
## Upgrading
Replace the binary and restart the service:
```
# Download and verify the new tarball
sha256sum -c SHA256SUMS
tar xzf oxicloud-<new-version>-<triple>.tar.gz
cd oxicloud-<new-version>-<triple>/
sudo systemctl stop oxicloud
sudo install -m 0755 oxicloud /usr/local/bin/oxicloud
sudo systemctl start oxicloud
```
Database migrations apply automatically on startup. Rollbacks are not
supported by sqlx's migration model; if you need to roll back, stop
the server, roll back your Postgres data directory to a snapshot, and
install the previous binary.
## Installing via `cargo binstall`
If you already have the Rust toolchain and just want the binary
without hand-picking a tarball:
```
cargo binstall oxicloud
```
`cargo-binstall` reads the URL template baked into the release
metadata, downloads the tarball for your host triple, verifies its
signature (when present), and installs `oxicloud` into
`~/.cargo/bin`. This resolves to the same tarball you'd download by
hand.
## Where to go from here
- Environment reference — see [`docs/config/env.md`](../config/env.md)
for every `OXICLOUD_*` variable and its default.
- Authentication setup (OPAQUE, OIDC, magic links) — see
[`docs/config/authentication.md`](../config/authentication.md).
- Storage backends (local disk, S3, Azure Blob, encryption) — see
[`docs/config/storage.md`](../config/storage.md) if present, or the
entries under `OXICLOUD_STORAGE_*` in the environment reference.
- File a bug or a feature request — GitHub issues at
https://github.com/AtalayaLabs/OxiCloud.