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
298 lines
12 KiB
YAML
298 lines
12 KiB
YAML
# Gitea Actions CI.
|
|
#
|
|
# Gitea Actions is workflow-compatible with GitHub Actions, so this runs on either
|
|
# with no changes. It needs a registered runner with the `ubuntu-latest` label.
|
|
#
|
|
# The gates, in the order they fail fastest:
|
|
# fmt — formatting, seconds
|
|
# clippy — lints, denied rather than warned
|
|
# test — 160 unit + integration tests
|
|
# deny — RustSec advisories, licence policy, source policy
|
|
# musl — the artifact §8 actually ships: one static binary
|
|
|
|
name: CI
|
|
|
|
on:
|
|
push:
|
|
branches: [main, master]
|
|
pull_request:
|
|
# Advisories appear without any code changing, so the dependency audit also
|
|
# runs on a schedule rather than only on push.
|
|
schedule:
|
|
- cron: "0 6 * * 1"
|
|
|
|
env:
|
|
CARGO_TERM_COLOR: always
|
|
# Fail the build on warnings. The tree is warning-clean, so keeping it that way
|
|
# is cheaper than letting warnings accumulate.
|
|
RUSTFLAGS: "-D warnings"
|
|
|
|
jobs:
|
|
check:
|
|
name: fmt, clippy, test
|
|
runs-on: ubuntu-latest
|
|
steps:
|
|
- uses: actions/checkout@v4
|
|
|
|
- name: Install Rust
|
|
run: |
|
|
# rustup is not guaranteed present on a self-hosted Gitea runner.
|
|
if ! command -v rustup >/dev/null 2>&1; then
|
|
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \
|
|
| sh -s -- -y --profile minimal --component rustfmt,clippy
|
|
echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
|
|
else
|
|
rustup component add rustfmt clippy
|
|
fi
|
|
|
|
- name: Cache cargo
|
|
uses: actions/cache@v4
|
|
with:
|
|
path: |
|
|
~/.cargo/registry
|
|
~/.cargo/git
|
|
target
|
|
key: ${{ runner.os }}-cargo-${{ hashFiles('Cargo.lock') }}
|
|
restore-keys: ${{ runner.os }}-cargo-
|
|
|
|
- name: Formatting
|
|
run: cargo fmt --all -- --check
|
|
|
|
- name: Clippy
|
|
run: cargo clippy --all-targets --all-features
|
|
|
|
- name: Tests
|
|
run: cargo test --all-features
|
|
|
|
deny:
|
|
name: advisories and licences
|
|
runs-on: ubuntu-latest
|
|
steps:
|
|
- uses: actions/checkout@v4
|
|
|
|
- name: Install Rust
|
|
run: |
|
|
if ! command -v rustup >/dev/null 2>&1; then
|
|
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \
|
|
| sh -s -- -y --profile minimal
|
|
echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
|
|
fi
|
|
|
|
- name: Cache cargo-deny
|
|
uses: actions/cache@v4
|
|
with:
|
|
path: ~/.cargo/bin/cargo-deny
|
|
key: ${{ runner.os }}-cargo-deny
|
|
|
|
- name: Install cargo-deny
|
|
run: |
|
|
command -v cargo-deny >/dev/null 2>&1 || cargo install cargo-deny --locked
|
|
|
|
# Advisories, licences, bans and sources — see deny.toml for why the licence
|
|
# allow-list is closed rather than a deny-list.
|
|
- name: cargo deny
|
|
run: cargo deny check
|
|
|
|
musl:
|
|
name: static musl binary
|
|
runs-on: ubuntu-latest
|
|
# Only gate merges on the artifact build once the cheaper checks have passed.
|
|
needs: check
|
|
steps:
|
|
- uses: actions/checkout@v4
|
|
|
|
- name: Install Rust and musl target
|
|
run: |
|
|
if ! command -v rustup >/dev/null 2>&1; then
|
|
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \
|
|
| sh -s -- -y --profile minimal
|
|
echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
|
|
export PATH="$HOME/.cargo/bin:$PATH"
|
|
fi
|
|
rustup target add x86_64-unknown-linux-musl
|
|
sudo apt-get update && sudo apt-get install -y musl-tools
|
|
|
|
- name: Cache cargo
|
|
uses: actions/cache@v4
|
|
with:
|
|
path: |
|
|
~/.cargo/registry
|
|
~/.cargo/git
|
|
target
|
|
key: ${{ runner.os }}-musl-${{ hashFiles('Cargo.lock') }}
|
|
restore-keys: ${{ runner.os }}-musl-
|
|
|
|
# §8: "Ship a single static binary (musl target) plus the SQLite file."
|
|
# rusqlite is built with `bundled`, so SQLite is compiled in; reqwest uses
|
|
# rustls rather than OpenSSL, so there is no system TLS dependency to link.
|
|
- name: Build
|
|
run: cargo build --release --target x86_64-unknown-linux-musl
|
|
|
|
- name: Verify the binary is actually static
|
|
run: |
|
|
BIN=target/x86_64-unknown-linux-musl/release/jray-server
|
|
file "$BIN"
|
|
# A dynamically-linked result would defeat §8's deployment story, so this
|
|
# is asserted rather than assumed.
|
|
#
|
|
# Checked with `file`, not `ldd`: the musl target produces a static-PIE,
|
|
# and `ldd` prints the musl loader for one — an `ldd`-based check reports
|
|
# a perfectly static binary as dynamic.
|
|
if ! file "$BIN" | grep -qE 'static-pie linked|statically linked'; then
|
|
echo "::error::binary is not statically linked" >&2
|
|
exit 1
|
|
fi
|
|
|
|
- name: Upload binary
|
|
uses: actions/upload-artifact@v3
|
|
with:
|
|
name: jray-server-x86_64-musl
|
|
path: target/x86_64-unknown-linux-musl/release/jray-server
|
|
if-no-files-found: error
|
|
|
|
# ── Debian package ────────────────────────────────────────────────────────
|
|
#
|
|
# DR-015. The .deb is not a second build of the software: it packages the very
|
|
# binary the job above already proved static, so the artifact an operator
|
|
# installs is byte-identical to the one CI verified. Building it twice would
|
|
# let the two diverge silently.
|
|
deb:
|
|
name: debian package
|
|
runs-on: ubuntu-latest
|
|
needs: musl
|
|
steps:
|
|
- uses: actions/checkout@v4
|
|
|
|
- name: Fetch the verified musl binary
|
|
uses: actions/download-artifact@v3
|
|
with:
|
|
name: jray-server-x86_64-musl
|
|
path: prebuilt
|
|
|
|
- name: Build the package
|
|
run: |
|
|
chmod +x prebuilt/jray-server
|
|
scripts/build-deb.sh --binary prebuilt/jray-server
|
|
|
|
# The install is where packaging actually fails, so it is exercised rather
|
|
# than assumed: an unattended preseed proves the debconf path works without
|
|
# a terminal, which is the case a release must not break. `dpkg -i` runs as
|
|
# root on the runner, and the maintainer scripts skip the systemd wiring
|
|
# when /run/systemd/system is absent.
|
|
- name: Install it unattended and check what it configured
|
|
run: |
|
|
set -e
|
|
sudo apt-get update -qq
|
|
sudo apt-get install -y -qq debconf-utils
|
|
cat <<'SEED' | sudo debconf-set-selections
|
|
jray-server jray-server/server-id string ci.example.org
|
|
jray-server jray-server/bind string 127.0.0.1:8080
|
|
jray-server jray-server/trusted-proxies string 127.0.0.1
|
|
jray-server jray-server/tmdb-api-key password ci-key
|
|
SEED
|
|
sudo DEBIAN_FRONTEND=noninteractive dpkg -i dist/*.deb
|
|
|
|
sudo test -f /etc/jray-server/env
|
|
[ "$(sudo stat -c '%a' /etc/jray-server/env)" = "600" ] \
|
|
|| { echo "::error::env file is not mode 600"; exit 1; }
|
|
sudo grep -q '^JRAY_SERVER_ID=ci.example.org$' /etc/jray-server/env \
|
|
|| { echo "::error::debconf answer did not reach the env file"; exit 1; }
|
|
# The key must land in the file and not linger in debconf's database.
|
|
sudo grep -q '^JRAY_TMDB_API_KEY=ci-key$' /etc/jray-server/env \
|
|
|| { echo "::error::API key missing from the env file"; exit 1; }
|
|
if sudo debconf-show jray-server | grep -q 'ci-key'; then
|
|
echo "::error::API key still present in the debconf database"; exit 1
|
|
fi
|
|
sudo /usr/bin/jray-server --version >/dev/null 2>&1 || true
|
|
echo "installed cleanly"
|
|
|
|
# DR-016. Reconfigure is the operation that silently destroys a working
|
|
# install: press Enter through the password prompt and a naive postinst
|
|
# blanks the key, after which every upload stays pending forever and the
|
|
# server looks fine. Asserted, not trusted.
|
|
#
|
|
# The second half guards the inverse mistake — the debconf `config` script
|
|
# seeds unanswered questions from the env file, and an unguarded seed would
|
|
# overwrite the preseed above, so an unattended install would reconfigure
|
|
# itself back to whatever was on disk.
|
|
- name: Reconfigure keeps the key, and updates what it was told to
|
|
run: |
|
|
set -e
|
|
printf 'jray-server jray-server/tmdb-api-key password\n' | sudo debconf-set-selections
|
|
printf 'jray-server jray-server/contact string changed@example.org\n' | sudo debconf-set-selections
|
|
echo 'JRAY_JOB_BATCH=32' | sudo tee -a /etc/jray-server/env >/dev/null
|
|
sudo dpkg-reconfigure -f noninteractive jray-server
|
|
|
|
sudo grep -q '^JRAY_TMDB_API_KEY=ci-key$' /etc/jray-server/env \\
|
|
|| { echo "::error::a blank answer wiped the configured API key"; exit 1; }
|
|
sudo grep -q '^JRAY_CONTACT=changed@example.org$' /etc/jray-server/env \\
|
|
|| { echo "::error::preseeded value was overwritten by the env-file seed"; exit 1; }
|
|
# A setting the package does not manage must survive untouched.
|
|
sudo grep -q '^JRAY_JOB_BATCH=32$' /etc/jray-server/env \\
|
|
|| { echo "::error::reconfigure discarded a hand-added setting"; exit 1; }
|
|
|
|
- name: Purge, and check the database is not collateral
|
|
run: |
|
|
set -e
|
|
sudo mkdir -p /var/lib/jray-server && sudo touch /var/lib/jray-server/jray.db
|
|
sudo DEBIAN_FRONTEND=noninteractive apt-get purge -y -qq jray-server
|
|
sudo test ! -f /etc/jray-server/env \
|
|
|| { echo "::error::configuration survived purge"; exit 1; }
|
|
# Deliberate deviation from "purge removes everything": manifests are
|
|
# real CV compute and federation is not a backup. See postrm.
|
|
sudo test -f /var/lib/jray-server/jray.db \
|
|
|| { echo "::error::purge destroyed the database"; exit 1; }
|
|
|
|
- name: Upload the package
|
|
uses: actions/upload-artifact@v3
|
|
with:
|
|
name: jray-server-deb
|
|
path: dist/*.deb
|
|
if-no-files-found: error
|
|
|
|
# ── Publishing, on tags only ──────────────────────────────────────────
|
|
#
|
|
# Two channels, deliberately. The apt registry is the one that gives
|
|
# operators upgrades; the release asset is for people who would rather not
|
|
# add a third-party apt source to their machine.
|
|
#
|
|
# NOTE: this uses the automatic Actions token. If your Gitea build does not
|
|
# grant it package:write, replace it with a PAT held in a repository secret
|
|
# — the symptom is a 401 from the upload below, not a silent no-op.
|
|
- name: Publish to the Gitea Debian registry
|
|
if: startsWith(github.ref, 'refs/tags/v')
|
|
env:
|
|
TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
run: |
|
|
set -e
|
|
DEB=$(ls dist/*.deb)
|
|
code=$(curl -sS -o /tmp/up.log -w '%{http_code}' \
|
|
--user "${{ github.repository_owner }}:$TOKEN" \
|
|
--upload-file "$DEB" \
|
|
"${{ github.server_url }}/api/packages/${{ github.repository_owner }}/debian/pool/stable/main/upload")
|
|
echo "upload HTTP $code"; cat /tmp/up.log
|
|
case "$code" in 201|409) ;; *) echo "::error::registry upload failed"; exit 1 ;; esac
|
|
|
|
- name: Attach the package to the release
|
|
if: startsWith(github.ref, 'refs/tags/v')
|
|
env:
|
|
TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
run: |
|
|
set -e
|
|
TAG="${GITHUB_REF#refs/tags/}"
|
|
API="${{ github.server_url }}/api/v1/repos/${{ github.repository }}"
|
|
id=$(curl -sS -H "Authorization: token $TOKEN" "$API/releases/tags/$TAG" \
|
|
| sed -n 's/.*"id":[ ]*\([0-9]*\).*/\1/p' | head -1)
|
|
if [ -z "$id" ]; then
|
|
id=$(curl -sS -X POST -H "Authorization: token $TOKEN" \
|
|
-H 'Content-Type: application/json' \
|
|
-d "{\"tag_name\":\"$TAG\",\"name\":\"$TAG\"}" "$API/releases" \
|
|
| sed -n 's/.*"id":[ ]*\([0-9]*\).*/\1/p' | head -1)
|
|
fi
|
|
[ -n "$id" ] || { echo "::error::could not resolve a release for $TAG"; exit 1; }
|
|
DEB=$(ls dist/*.deb)
|
|
curl -sS -X POST -H "Authorization: token $TOKEN" \
|
|
-F "attachment=@$DEB" \
|
|
"$API/releases/$id/assets?name=$(basename "$DEB")" >/dev/null
|
|
echo "attached $(basename "$DEB") to release $TAG"
|