diff --git a/.github/workflows/linux-installers.yml b/.github/workflows/linux-installers.yml new file mode 100644 index 00000000..9fc1c555 --- /dev/null +++ b/.github/workflows/linux-installers.yml @@ -0,0 +1,400 @@ +name: Linux Installer Lifecycle + +on: + pull_request: + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: linux-installers-${{ github.event_name }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + build: + name: Build x64 deb, rpm, and AppImage + runs-on: ubuntu-24.04 + timeout-minutes: 30 + steps: + - uses: actions/checkout@v7 + - name: Set up embeddable Racket CS + uses: ./.github/actions/setup-embed-racket + - name: Install Rivet and native packaging tools + shell: bash + run: | + set -euo pipefail + sudo apt-get update + sudo apt-get install --yes desktop-file-utils libgtk-4-dev libsecret-1-dev rpm + raco pkg install --auto --no-docs --name rivet --link "$PWD" + - name: Build release artifacts + shell: bash + env: + APPIMAGE_EXTRACT_AND_RUN: "1" + run: | + set -euo pipefail + root="$RUNNER_TEMP/rivet-linux-installers" + mkdir -p "$root" + cd "$root" + raco rivet new Smoke + cd Smoke + mkdir -p assets/nested + printf %s packaged-resource > assets/nested/product.txt + python3 - <<'PY' + import struct, zlib + from pathlib import Path + + def chunk(tag, data): + crc = zlib.crc32(tag + data) & 0xFFFFFFFF + return struct.pack(">I", len(data)) + tag + data + struct.pack(">I", crc) + + ihdr = struct.pack(">IIBBBBB", 1, 1, 8, 6, 0, 0, 0) + idat = zlib.compress(b"\x00\x00\x00\x00\xff") + Path("assets/icon.png").write_bytes( + b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", ihdr) + + chunk(b"IDAT", idat) + chunk(b"IEND", b"")) + + path = Path("rivet.rktd") + original = path.read_text(encoding="utf-8") + updated = original.replace( + '(resources . ())', + '(resources . ("assets")) ' + '(linux-icon . "assets/icon.png") ' + '(linux-formats . ("deb" "rpm" "appimage"))') + if updated == original: + raise SystemExit("failed to configure Linux installer smoke") + path.write_text(updated, encoding="utf-8") + PY + raco rivet release --development --without-updates + raco rivet verify + artifacts="$RUNNER_TEMP/rivet-linux-native-artifacts" + diagnostics="$RUNNER_TEMP/rivet-linux-build-diagnostics" + mkdir -p "$artifacts" "$diagnostics" + cp dist/*.deb dist/*.rpm dist/*.AppImage "$artifacts/" + sha256sum "$artifacts"/* > "$diagnostics/SHA256SUMS" + rpm -qpi "$artifacts"/*.rpm > "$diagnostics/rpm-info.txt" + rpm -qp --requires "$artifacts"/*.rpm > "$diagnostics/rpm-requires.txt" + raco rivet doctor --json > "$diagnostics/doctor.json" + - name: Upload installers + uses: actions/upload-artifact@v6 + with: + name: linux-native-installers + path: ${{ runner.temp }}/rivet-linux-native-artifacts + if-no-files-found: error + - name: Upload build diagnostics + if: always() + uses: actions/upload-artifact@v6 + with: + name: linux-native-build-diagnostics + path: ${{ runner.temp }}/rivet-linux-build-diagnostics + if-no-files-found: warn + + appimage-wayland: + name: AppImage launch on Ubuntu 24.04 Wayland + needs: build + runs-on: ubuntu-24.04 + timeout-minutes: 20 + steps: + - uses: actions/download-artifact@v7 + with: + name: linux-native-installers + path: ${{ runner.temp }}/installers + - name: Install headless Wayland compositor + run: | + sudo apt-get update + sudo apt-get install --yes weston + - name: Launch AppImage outside the build tree + shell: bash + env: + APPIMAGE_EXTRACT_AND_RUN: "1" + run: | + set -euo pipefail + diagnostics="$RUNNER_TEMP/rivet-appimage-diagnostics" + mkdir -p "$diagnostics" + export XDG_RUNTIME_DIR="$RUNNER_TEMP/rivet-appimage-runtime" + export WAYLAND_DISPLAY=wayland-rivet-appimage + export GDK_BACKEND=wayland + unset DISPLAY + mkdir -p "$XDG_RUNTIME_DIR" + chmod 700 "$XDG_RUNTIME_DIR" + weston --backend=headless-backend.so \ + --socket="$WAYLAND_DISPLAY" --idle-time=0 \ + --log="$diagnostics/weston.log" & + weston_pid=$! + app_pid="" + stop_bounded() { + local pid="$1" + local label="$2" + kill -TERM "$pid" 2>/dev/null || true + for _ in $(seq 1 200); do + kill -0 "$pid" 2>/dev/null || return 0 + sleep 0.05 + done + kill -KILL "$pid" 2>/dev/null || true + echo "$label did not stop within ten seconds of SIGTERM" >&2 + return 1 + } + cleanup() { + local status=$? + trap - EXIT + if [[ -n "$app_pid" ]] && kill -0 "$app_pid" 2>/dev/null; then + stop_bounded "$app_pid" application || status=1 + wait "$app_pid" 2>/dev/null || true + fi + kill "$weston_pid" 2>/dev/null || true + wait "$weston_pid" 2>/dev/null || true + exit "$status" + } + trap cleanup EXIT + for _ in $(seq 1 100); do + test -S "$XDG_RUNTIME_DIR/$WAYLAND_DISPLAY" && break + kill -0 "$weston_pid" 2>/dev/null || { + cat "$diagnostics/weston.log" + exit 1 + } + sleep 0.05 + done + test -S "$XDG_RUNTIME_DIR/$WAYLAND_DISPLAY" + appimage="$(find "$RUNNER_TEMP/installers" -type f -name '*.AppImage' -print -quit)" + test -n "$appimage" + chmod +x "$appimage" + "$appimage" >"$diagnostics/application.log" 2>&1 & + app_pid=$! + for _ in $(seq 1 50); do + kill -0 "$app_pid" 2>/dev/null || { + wait "$app_pid" || true + cat "$diagnostics/application.log" + exit 1 + } + sleep 0.1 + done + stop_bounded "$app_pid" application + wait "$app_pid" || test "$?" -eq 143 + app_pid="" + trap - EXIT + kill "$weston_pid" 2>/dev/null || true + wait "$weston_pid" 2>/dev/null || true + - name: Upload AppImage diagnostics + if: always() + uses: actions/upload-artifact@v6 + with: + name: ubuntu-appimage-wayland-diagnostics + path: ${{ runner.temp }}/rivet-appimage-diagnostics + if-no-files-found: warn + + debian-deb-wayland: + name: deb install and launch on Debian 13 Wayland + needs: build + runs-on: ubuntu-24.04 + timeout-minutes: 20 + container: debian:13 + steps: + - uses: actions/download-artifact@v7 + with: + name: linux-native-installers + path: /tmp/rivet-installers + - name: Install desktop runtime + env: + DEBIAN_FRONTEND: noninteractive + run: | + apt-get update + apt-get install --yes \ + dbus-daemon desktop-file-utils libgtk-4-1 libsecret-1-0 \ + procps weston + - name: Install, launch, and uninstall deb + shell: bash + run: | + set -euo pipefail + diagnostics=/tmp/rivet-debian-diagnostics + mkdir -p "$diagnostics" + deb_artifact="$(find /tmp/rivet-installers -type f -name '*.deb' -print -quit)" + test -n "$deb_artifact" + deb_package="$(dpkg-deb --field "$deb_artifact" Package)" + test -n "$deb_package" + apt-get install --yes "$deb_artifact" >"$diagnostics/apt-install.log" 2>&1 + dpkg-query --show --showformat='${Package} ${Version} ${Architecture}\n' \ + "$deb_package" >"$diagnostics/installed-version.txt" + test -x /opt/Smoke/RivetHost + test -f /usr/share/applications/Smoke.desktop + test -f /usr/share/pixmaps/Smoke.png + desktop-file-validate /usr/share/applications/Smoke.desktop + test "$(cat /opt/Smoke/app/assets/nested/product.txt)" = packaged-resource + + export XDG_RUNTIME_DIR=/tmp/rivet-debian-wayland-runtime + export WAYLAND_DISPLAY=wayland-rivet-debian + export GDK_BACKEND=wayland + unset DISPLAY + mkdir -p "$XDG_RUNTIME_DIR" + chmod 700 "$XDG_RUNTIME_DIR" + weston --backend=headless-backend.so \ + --socket="$WAYLAND_DISPLAY" --idle-time=0 \ + --log="$diagnostics/weston.log" & + weston_pid=$! + app_pid="" + stop_bounded() { + local pid="$1" + local label="$2" + kill -TERM "$pid" 2>/dev/null || true + for _ in $(seq 1 200); do + kill -0 "$pid" 2>/dev/null || return 0 + sleep 0.05 + done + kill -KILL "$pid" 2>/dev/null || true + echo "$label did not stop within ten seconds of SIGTERM" >&2 + return 1 + } + cleanup() { + local status=$? + trap - EXIT + if [[ -n "$app_pid" ]] && kill -0 "$app_pid" 2>/dev/null; then + stop_bounded "$app_pid" application || status=1 + wait "$app_pid" 2>/dev/null || true + fi + kill "$weston_pid" 2>/dev/null || true + wait "$weston_pid" 2>/dev/null || true + apt-get remove --yes "$deb_package" \ + >>"$diagnostics/apt-remove.log" 2>&1 || true + exit "$status" + } + trap cleanup EXIT + for _ in $(seq 1 100); do + test -S "$XDG_RUNTIME_DIR/$WAYLAND_DISPLAY" && break + kill -0 "$weston_pid" 2>/dev/null || { + cat "$diagnostics/weston.log" + exit 1 + } + sleep 0.05 + done + test -S "$XDG_RUNTIME_DIR/$WAYLAND_DISPLAY" + /opt/Smoke/RivetHost >"$diagnostics/application.log" 2>&1 & + app_pid=$! + for _ in $(seq 1 50); do + kill -0 "$app_pid" 2>/dev/null || { + wait "$app_pid" || true + cat "$diagnostics/application.log" + exit 1 + } + sleep 0.1 + done + stop_bounded "$app_pid" application + wait "$app_pid" || test "$?" -eq 143 + app_pid="" + kill "$weston_pid" 2>/dev/null || true + wait "$weston_pid" 2>/dev/null || true + apt-get remove --yes "$deb_package" >"$diagnostics/apt-remove.log" 2>&1 + test ! -e /opt/Smoke/RivetHost + test ! -e /usr/share/applications/Smoke.desktop + test ! -e /usr/share/pixmaps/Smoke.png + trap - EXIT + - name: Upload Debian diagnostics + if: always() + uses: actions/upload-artifact@v6 + with: + name: debian-deb-wayland-diagnostics + path: /tmp/rivet-debian-diagnostics + if-no-files-found: warn + + fedora-rpm-wayland: + name: rpm install and launch on Fedora 44 Wayland + needs: build + runs-on: ubuntu-24.04 + timeout-minutes: 20 + container: fedora:44 + steps: + - uses: actions/download-artifact@v7 + with: + name: linux-native-installers + path: /tmp/rivet-installers + - name: Install desktop runtime + run: dnf install --assumeyes dbus-daemon desktop-file-utils gtk4 procps-ng weston + - name: Install, launch, and uninstall rpm + shell: bash + run: | + set -euo pipefail + diagnostics=/tmp/rivet-fedora-diagnostics + mkdir -p "$diagnostics" + rpm_artifact="$(find /tmp/rivet-installers -type f -name '*.rpm' -print -quit)" + test -n "$rpm_artifact" + rpm_package="$(rpm -qp --qf '%{NAME}' "$rpm_artifact")" + test -n "$rpm_package" + dnf install --assumeyes "$rpm_artifact" >"$diagnostics/dnf-install.log" 2>&1 + rpm -q --qf '%{NAME} %{VERSION}-%{RELEASE} %{ARCH}\n' "$rpm_package" \ + >"$diagnostics/installed-version.txt" + test -x /opt/Smoke/RivetHost + test -f /usr/share/applications/Smoke.desktop + test -f /usr/share/pixmaps/Smoke.png + desktop-file-validate /usr/share/applications/Smoke.desktop + test "$(cat /opt/Smoke/app/assets/nested/product.txt)" = packaged-resource + + export XDG_RUNTIME_DIR=/tmp/rivet-fedora-wayland-runtime + export WAYLAND_DISPLAY=wayland-rivet-fedora + export GDK_BACKEND=wayland + unset DISPLAY + mkdir -p "$XDG_RUNTIME_DIR" + chmod 700 "$XDG_RUNTIME_DIR" + weston --backend=headless-backend.so \ + --socket="$WAYLAND_DISPLAY" --idle-time=0 \ + --log="$diagnostics/weston.log" & + weston_pid=$! + app_pid="" + stop_bounded() { + local pid="$1" + local label="$2" + kill -TERM "$pid" 2>/dev/null || true + for _ in $(seq 1 200); do + kill -0 "$pid" 2>/dev/null || return 0 + sleep 0.05 + done + kill -KILL "$pid" 2>/dev/null || true + echo "$label did not stop within ten seconds of SIGTERM" >&2 + return 1 + } + cleanup() { + local status=$? + trap - EXIT + if [[ -n "$app_pid" ]] && kill -0 "$app_pid" 2>/dev/null; then + stop_bounded "$app_pid" application || status=1 + wait "$app_pid" 2>/dev/null || true + fi + kill "$weston_pid" 2>/dev/null || true + wait "$weston_pid" 2>/dev/null || true + dnf remove --assumeyes "$rpm_package" >>"$diagnostics/dnf-remove.log" 2>&1 || true + exit "$status" + } + trap cleanup EXIT + for _ in $(seq 1 100); do + test -S "$XDG_RUNTIME_DIR/$WAYLAND_DISPLAY" && break + kill -0 "$weston_pid" 2>/dev/null || { + cat "$diagnostics/weston.log" + exit 1 + } + sleep 0.05 + done + test -S "$XDG_RUNTIME_DIR/$WAYLAND_DISPLAY" + /opt/Smoke/RivetHost >"$diagnostics/application.log" 2>&1 & + app_pid=$! + for _ in $(seq 1 50); do + kill -0 "$app_pid" 2>/dev/null || { + wait "$app_pid" || true + cat "$diagnostics/application.log" + exit 1 + } + sleep 0.1 + done + stop_bounded "$app_pid" application + wait "$app_pid" || test "$?" -eq 143 + app_pid="" + kill "$weston_pid" 2>/dev/null || true + wait "$weston_pid" 2>/dev/null || true + dnf remove --assumeyes "$rpm_package" >"$diagnostics/dnf-remove.log" 2>&1 + test ! -e /opt/Smoke/RivetHost + test ! -e /usr/share/applications/Smoke.desktop + test ! -e /usr/share/pixmaps/Smoke.png + trap - EXIT + - name: Upload Fedora diagnostics + if: always() + uses: actions/upload-artifact@v6 + with: + name: fedora-rpm-wayland-diagnostics + path: /tmp/rivet-fedora-diagnostics + if-no-files-found: warn diff --git a/CHANGELOG.md b/CHANGELOG.md index bd6c01b7..146b4d16 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +- Add real Linux installer lifecycle gates: build x64 rpm/AppImage artifacts, + install and launch the rpm on Fedora 44 under native Wayland, launch the + AppImage outside the build tree on Ubuntu 24.04, uninstall the rpm, and + retain package/compositor/application diagnostics as CI artifacts. - Add first-party portable update installation for Windows, macOS, and Linux: verified ZIP/AppImage payloads are staged, platform-signature checked, atomically replaced, health-gated, durably committed, and recoverable after diff --git a/docs/linux-display.md b/docs/linux-display.md index 223c378f..3c599a9a 100644 --- a/docs/linux-display.md +++ b/docs/linux-display.md @@ -49,6 +49,35 @@ desktop coverage. Linux production graduation still requires install, upgrade, GPU, input, scaling, portal, GNOME, and KDE evidence across the declared distribution matrix. +## Distribution lifecycle matrix + +Rivet's support promise follows vendor maintenance instead of accumulating an +unbounded list of historical images: + +| Family | Qualification policy | Automated release evidence | +| --- | --- | --- | +| Ubuntu | Supported LTS releases while they receive standard security maintenance | Ubuntu 24.04: deb lifecycle and AppImage launch under headless native Wayland | +| Debian | Current stable release during Debian's regular support period | Debian 13: deb install, launch, and uninstall under headless native Wayland | +| Fedora | Current and previous Fedora releases while upstream still maintains them | Fedora 44: rpm install, launch, and uninstall under headless native Wayland | + +The matrix is reviewed for every Rivet minor release. A distribution enters the +support table only after its native package is installed by the real package +manager, launched outside the build tree through `GDK_BACKEND=wayland` with no +`DISPLAY`, and cleanly removed. AppImage is tested independently because its +dependency closure and replacement policy differ from deb/rpm. CI uploads the +package-manager transcript, exact installed version, compositor log, and +application log so a failed gate can be reproduced rather than guessed at. +The installed-package gates also validate the desktop entry, packaged icon, +resource payload, graceful SIGTERM handling, and complete metadata removal. +The Linux integration runner separately exercises single-instance forwarding, +autostart persistence, crash/shutdown hooks, and capability-conditioned +notifications, StatusNotifierItem tray, and Secret Service storage. + +Fedora advances on its roughly thirteen-month upstream lifecycle; CI pins a +specific maintained release instead of using `fedora:latest`. Ubuntu LTS and +Debian stable advance only in a reviewable matrix change. X11/XWayland remains +outside this release matrix. + ## Removal threshold X11 compatibility can be removed when upstream GTK or the supported diff --git a/rivet-cli/rpm.rkt b/rivet-cli/rpm.rkt index b40816c1..18b3a558 100644 --- a/rivet-cli/rpm.rkt +++ b/rivet-cli/rpm.rkt @@ -52,6 +52,18 @@ (call-with-output-file destination #:exists 'truncate/replace (lambda (out) + ;; The pinned embeddable Racket archive is built on Ubuntu, whose + ;; libtinfo exports versioned ncurses symbols. Fedora intentionally + ;; publishes the same libtinfo.so.6 ABI without those versioned RPM + ;; Provides. Keep rpmbuild's dependency discovery for every other ELF + ;; requirement, but replace only that unsatisfiable metadata edge with + ;; the Fedora package that owns libtinfo.so.6. The native Wayland RPM + ;; install/launch gate proves the resulting runtime link, rather than + ;; papering over all automatically discovered dependencies. + ;; RPM expands macro bodies before compiling this regular expression, + ;; so the generated spec needs two backslashes to deliver one escaped + ;; punctuation character to the dependency filter. + (display "%global __requires_exclude ^libtinfo\\\\.so\\\\.6\\\\(NCURSES6_TINFO_.*$\n" out) (define (tag key value) (fprintf out "~a: ~a\n" key value)) (tag "Name" (rpm-package-name project)) @@ -65,6 +77,7 @@ (tag "URL" "https://github.com/turinglambdaai/rivet") (tag "BuildArch" (rpm-architecture)) (tag "Requires" "gtk4") + (tag "Requires" "ncurses-libs") (display "\n%description\n" out) (fprintf out "~a native desktop app. The application payload and the\n" (project-display-name project)) diff --git a/tests/linux-native-installer.rkt b/tests/linux-native-installer.rkt index 436d51c4..7e367f29 100644 --- a/tests/linux-native-installer.rkt +++ b/tests/linux-native-installer.rkt @@ -86,6 +86,11 @@ (check-true (regexp-match? #rx"(?m:^Version: 1\\.2\\.0$)" spec)) (check-true (regexp-match? #rx"(?m:^Release: 7$)" spec)) (check-true (regexp-match? #rx"(?m:^Requires: gtk4$)" spec)) + (check-true (regexp-match? #rx"(?m:^Requires: ncurses-libs$)" spec)) + (check-true + (string-contains? + spec + "%global __requires_exclude ^libtinfo\\\\.so\\\\.6\\\\(NCURSES6_TINFO_.*$")) (check-true (regexp-match? #rx"\"/opt/Demo_App/\\*\"" spec)) ;; System-owned directories must not be re-owned by the package. (check-false (regexp-match? #rx"%dir \"/usr/share/applications\"" spec))