feat(deb): package the server, and ask the questions that fail silently
CI / fmt, clippy, test (pull_request) Successful in 1m48s
CI / advisories and licences (pull_request) Successful in 42s
CI / static musl binary (pull_request) Successful in 1m46s
CI / debian package (pull_request) Failing after 3h10m28s

DR-015, DR-016. §8 already shipped a static binary and an optional
container; this adds the third form, and it packages the SAME binary the
musl job proved static rather than building its own. Two builds of the
same commit could diverge, and the whole point of that assertion is that
the artifact an operator installs is the one that was checked.

Built with dpkg-deb from an explicit staging tree rather than cargo-deb.
debconf's `config` script and `templates` live in the control archive
next to the maintainer scripts, and controlling that archive directly
beats discovering what a wrapper will copy into it. dpkg-dev is on every
Debian builder, so this adds no build dependency.

Why debconf at all: two settings fail SILENTLY when unset. Without
JRAY_TMDB_API_KEY every upload stays `pending` and is never listed;
without JRAY_TRUSTED_PROXIES the X-Forwarded-For header is ignored, so
every client shares one rate-limit bucket and every abuse report points
at the proxy. Both leave a server that works and is quietly doing the
wrong thing — the worst thing to leave to a README nobody reads.

Three properties, each a way packaging usually goes wrong:

The generated config is NOT a dpkg conffile. It is written from the
debconf answers, so shipping it as one would make dpkg prompt on every
upgrade about changes the package itself had made.

Hand edits survive. postinst rewrites only the keys debconf manages;
comments, ordering and any other setting are left alone.

A blank key on reconfigure keeps the existing one. Otherwise pressing
Enter through a dpkg-reconfigure would unpublish every future upload.

The seeding guard is worth its comment, because the obvious version is
wrong twice over. `config` seeds unanswered questions from the env file
so a reconfigure shows what is actually in force. Seeding
unconditionally overwrites a preseed — debconf-set-selections marks what
it sets as seen — so every unattended install would quietly reconfigure
itself back to whatever was on disk. Guarding on an empty value does not
work either: server-id and bind carry template Defaults, so db_get
returns "localhost" for a question nobody answered. The test is the
`seen` flag, which is the actual question being asked.

Purge keeps the database, knowingly departing from the expectation that
purge removes everything. Manifests are the output of real CV compute on
media the operator may no longer have, and §8 says federation is
explicitly not a backup. Destroying that during an `apt purge` is not a
trade worth making for tidiness; postrm names the path instead.

The nginx example is documentation, not installed configuration. The
proxy usually runs on a different host from the server, so a file
dropped into this machine's nginx would be in the wrong place — and §8
leaves the edge to the operator deliberately.

Verified by running it, not by reading it: a full lifecycle in a
bookworm container — build, preseeded install, mode-600 env file, key
absent from debconf's database afterwards, `systemd-analyze verify` on
the unit, the installed binary answering /health and /ready, reconfigure
preserving both the key and an unmanaged setting, and purge leaving the
database. It failed on the seeding bug above the first time, which is
why that guard exists. CI runs the same checks against every build.

TRACES: DR-015, DR-016 | PR-004
This commit is contained in:
2026-09-06 09:58:47 +02:00
parent b4cf0c0fbf
commit 6c0c80f20b
12 changed files with 817 additions and 0 deletions
+142
View File
@@ -0,0 +1,142 @@
#!/bin/bash
# build-deb.sh — stage and build the jray-server Debian package.
#
# Built with dpkg-deb from an explicit staging tree rather than with cargo-deb.
# The reason is debconf: its `config` script and `templates` live in the control
# archive alongside the maintainer scripts, and controlling that archive directly
# is simpler than discovering what a wrapper will and will not copy into it.
# dpkg-dev is present on any Debian builder, so this adds no build dependency.
#
# Usage:
# scripts/build-deb.sh # build the binary, then package
# scripts/build-deb.sh --binary path/to/bin # package an existing binary
# scripts/build-deb.sh --version 1.2.3 # override the computed version
# scripts/build-deb.sh --out dist # output directory
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$REPO_ROOT"
TARGET="x86_64-unknown-linux-musl"
BINARY=""
VERSION=""
OUT="$REPO_ROOT/dist"
while [ $# -gt 0 ]; do
case "$1" in
--binary) BINARY="${2:?--binary needs a path}"; shift ;;
--version) VERSION="${2:?--version needs a value}"; shift ;;
--out) OUT="${2:?--out needs a path}"; shift ;;
-h|--help) sed -n '2,16p' "${BASH_SOURCE[0]}"; exit 0 ;;
*) echo "error: unknown argument '$1'" >&2; exit 2 ;;
esac
shift
done
# ── Version ─────────────────────────────────────────────────────────────────
#
# A tagged commit packages as that tag. Anything else packages as a PRE-release
# of the version in Cargo.toml: `0.1.0~git20260905.9bcc765` sorts BELOW `0.1.0`
# in dpkg's ordering, because `~` sorts before everything including the empty
# string. That is what makes a master build upgradeable to the eventual release
# rather than blocking it — the mistake would be `0.1.0+git...`, which sorts
# above and would leave apt refusing the real 0.1.0.
if [ -z "$VERSION" ]; then
if tag=$(git describe --exact-match --tags HEAD 2>/dev/null); then
VERSION="${tag#v}"
else
cargo_version=$(sed -n 's/^version *= *"\(.*\)"/\1/p' Cargo.toml | head -1)
VERSION="${cargo_version}~git$(date -u +%Y%m%d).$(git rev-parse --short HEAD)"
fi
fi
# ── Binary ──────────────────────────────────────────────────────────────────
if [ -z "$BINARY" ]; then
echo "=== building $TARGET"
cargo build --release --target "$TARGET"
BINARY="target/$TARGET/release/jray-server"
fi
[ -f "$BINARY" ] || { echo "error: no binary at $BINARY" >&2; exit 1; }
# The package claims no libc dependency, which is only honest if the binary
# genuinely has none. Asserted with `file` rather than `ldd`: a musl static-PIE
# makes ldd print the musl loader path, so an ldd check calls a static binary
# dynamic. Same reasoning as the Dockerfile and the CI musl job.
if command -v file >/dev/null 2>&1; then
linkage=$(file -b "$BINARY")
case "$linkage" in
*"static-pie linked"*|*"statically linked"*) ;;
*) echo "error: $BINARY is not static ($linkage)." >&2
echo " The package declares no libc dependency, so a dynamic" >&2
echo " binary here would install cleanly and then fail to run." >&2
exit 1 ;;
esac
fi
# ── Stage ───────────────────────────────────────────────────────────────────
STAGE="$(mktemp -d)"
trap 'rm -rf "$STAGE"' EXIT
install -d -m 0755 "$STAGE/DEBIAN"
install -d -m 0755 "$STAGE/usr/bin"
install -d -m 0755 "$STAGE/lib/systemd/system"
install -d -m 0755 "$STAGE/usr/share/doc/jray-server/examples"
install -m 0755 "$BINARY" "$STAGE/usr/bin/jray-server"
install -m 0644 packaging/jray-server.service "$STAGE/lib/systemd/system/jray-server.service"
install -m 0644 packaging/examples/nginx-jray-server.conf \
"$STAGE/usr/share/doc/jray-server/examples/nginx-jray-server.conf"
install -m 0644 SPEC.md "$STAGE/usr/share/doc/jray-server/SPEC.md"
install -m 0644 README.md "$STAGE/usr/share/doc/jray-server/README.md"
# Licences. LICENSE-DATA is not the code licence: contributed manifests are CC0
# while the server itself is GPLv3, and shipping only one of them would misstate
# what the operator is redistributing.
install -m 0644 LICENSE "$STAGE/usr/share/doc/jray-server/LICENSE"
install -m 0644 LICENSE-DATA "$STAGE/usr/share/doc/jray-server/LICENSE-DATA"
install -m 0755 packaging/debian/config "$STAGE/DEBIAN/config"
install -m 0755 packaging/debian/postinst "$STAGE/DEBIAN/postinst"
install -m 0755 packaging/debian/prerm "$STAGE/DEBIAN/prerm"
install -m 0755 packaging/debian/postrm "$STAGE/DEBIAN/postrm"
install -m 0644 packaging/debian/templates "$STAGE/DEBIAN/templates"
SIZE=$(du -ks "$STAGE" | cut -f1)
# No libc, no libsqlite3, no CA bundle: SQLite is compiled in (`rusqlite`
# bundled), the TLS roots are compiled in (`reqwest` rustls-tls uses
# webpki-roots), and the target is musl static. The only dependencies are the
# two the maintainer scripts themselves call.
cat > "$STAGE/DEBIAN/control" <<EOF
Package: jray-server
Version: $VERSION
Section: net
Priority: optional
Architecture: amd64
Depends: debconf (>= 0.5) | debconf-2.0, init-system-helpers (>= 1.54)
Installed-Size: $SIZE
Maintainer: Duncan Tourolle <duncan@tourolle.paris>
Homepage: https://gitea.tourolle.paris/dtourolle/JRay-public-server
Description: JRay public server - community manifest exchange
Serves and accepts JRay manifests: per-actor scene windows contributed by
media-centre users and matched to a cut by runtime and audio signature.
.
One static binary and one SQLite file, behind a reverse proxy the operator
provides. The server stores no binary content by design - no images, no
embeddings, no opaque blobs - which is what makes it safe for a volunteer to
run.
.
An example nginx site is installed under
/usr/share/doc/jray-server/examples/, to be copied to whichever host runs
your proxy.
EOF
mkdir -p "$OUT"
DEB="$OUT/jray-server_${VERSION}_amd64.deb"
dpkg-deb --root-owner-group --build "$STAGE" "$DEB" >/dev/null
echo "=== built $DEB"
dpkg-deb --info "$DEB" | sed 's/^/ /'
echo "=== contents"
dpkg-deb --contents "$DEB" | sed 's/^/ /'