diff --git a/.github/workflows/release-binaries.yml b/.github/workflows/release-binaries.yml new file mode 100644 index 00000000..00a463f6 --- /dev/null +++ b/.github/workflows/release-binaries.yml @@ -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 }} diff --git a/Cargo.toml b/Cargo.toml index aacf31cf..f1a21208 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,6 +4,24 @@ version = "0.8.7" edition = "2024" 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--.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] mimalloc = { version = "0.1.52", default-features = false } @@ -979,11 +997,20 @@ opt-level = 1 debug = "line-tables-only" split-debuginfo = "unpacked" # Incremental compilation caches per-function IR fingerprints so a -# small edit only recompiles what changed. On a single-crate rebuild -# (oxicloud is one crate) the savings are modest — worth < the ~7 GB -# incremental/ cache costs on disk. Rust-analyzer uses `cargo check`, -# which has its own cache, so LSP responsiveness is unaffected. -incremental = false +# small edit only recompiles what changed. The `target/incremental/` +# cache costs ~7 GB per profile, but at the current codebase size a +# full rebuild is ~10 minutes and an incremental single-file edit is +# seconds — the disk is worth it and then some. Rust-analyzer's own +# `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] lto = "fat" diff --git a/README.md b/README.md index 0ff14ec5..b5b5450a 100644 --- a/README.md +++ b/README.md @@ -68,6 +68,16 @@ docker compose up -d 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 Requires Rust 1.93+ and PostgreSQL. diff --git a/docs/install/binary.md b/docs/install/binary.md new file mode 100644 index 00000000..f62c6fb3 --- /dev/null +++ b/docs/install/binary.md @@ -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--x86_64-unknown-linux-musl.tar.gz` | +| Linux ARM64 (Raspberry Pi 4/5, Ampere, Graviton, ARM servers) | `oxicloud--aarch64-unknown-linux-musl.tar.gz` | +| macOS Apple Silicon (M-series) | `oxicloud--aarch64-apple-darwin.tar.gz` | +| macOS Intel | `oxicloud--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--.tar.gz +cd oxicloud--/ +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--.tar.gz +cd oxicloud--/ + +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.