diff --git a/CHANGELOG.md b/CHANGELOG.md index 73370d4..6459f8b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## Unreleased +- Make AppImage the signed Linux update payload when that format is enabled, + emit its SHA-256 sidecar, and add install-kind-aware update selection so + AppImage, portable, and package-manager installations cannot cross streams. - Graduate the Linux package-manager lifecycle gates from a single install to two-version deb/rpm transactions: corrupt upgrades preserve the working install, normal upgrades replace both metadata and payload, Debian rejects diff --git a/docs/release-and-updates.md b/docs/release-and-updates.md index d9c1879..b43ad97 100644 --- a/docs/release-and-updates.md +++ b/docs/release-and-updates.md @@ -15,9 +15,9 @@ RIVET_MINIMUM_UPDATABLE_VERSION=1.0.0 # optional; defaults to 0.0.0 RIVET_UPDATE_ROLLOUT=100 # optional; 0..100 ``` -Run `raco rivet release`. The result is a signed MSI on Windows, a signed/notarized DMG on macOS, or an Ed25519-signed self-contained `.tar.gz` on Linux, plus a versioned portable zip, channel manifest, CycloneDX SBOM, and `THIRD_PARTY_NOTICES.txt`. The channel manifest describes the portable zip; platform installers remain direct-download artifacts. `release --development` exercises the same flow without production platform signing, but the update manifest still requires its independent Ed25519 key. +Run `raco rivet release`. The result is a signed MSI on Windows, a signed/notarized DMG on macOS, or an Ed25519-signed self-contained `.tar.gz` on Linux, plus a versioned portable zip, channel manifest, CycloneDX SBOM, and `THIRD_PARTY_NOTICES.txt`. The channel manifest describes the portable zip on Windows/macOS and on Linux without AppImage; when AppImage packaging is enabled, it describes the exact AppImage instead. Other platform installers remain direct-download artifacts. `release --development` exercises the same flow without production platform signing, but the update manifest still requires its independent Ed25519 key. -On Linux the release additionally builds native system installers: a `.deb` (dpkg-deb, unprivileged, installs under `/opt/` with a desktop entry), an `.rpm` (rpmbuild BUILDROOT, distro-independent), and an `.AppImage` (bundled GTK4 dependency closure, so the same file runs on older distributions). Select the set with the `linux-formats` project setting; the signed tar.gz is always produced as the self-contained installer, while the portable zip is the update-channel payload. Package-manager installs upgrade through the package manager; AppImage installs upgrade by replacing the AppImage. AppImage packaging requires a `linux-icon` PNG in rivet.rktd — the format mandates a top-level icon and Rivet fails closed rather than shipping a placeholder. +On Linux the release additionally builds native system installers: a `.deb` (dpkg-deb, unprivileged, installs under `/opt/` with a desktop entry), an `.rpm` (rpmbuild BUILDROOT, distro-independent), and an `.AppImage` (bundled GTK4 dependency closure, so the same file runs on older distributions). Select the set with the `linux-formats` project setting. The signed tar.gz and portable zip are always retained as release assets. When AppImage is enabled, the signed manifest carries its exact URL, size, SHA-256, and `appimage` installer kind, and release also writes a `.AppImage.sha256` sidecar; no product should infer an unsigned sibling URL. Package-manager installs upgrade through the package manager; AppImage installs use Rivet's verified atomic replacement. AppImage packaging requires a `linux-icon` PNG in rivet.rktd — the format mandates a top-level icon and Rivet fails closed rather than shipping a placeholder. Applications that deliberately ship without an online update channel can run `raco rivet release --without-updates`. The command still builds, packages, platform-signs, verifies, and emits the installer, SBOM, and third-party notices; it skips only the channel manifest and does not read any `RIVET_UPDATE_*` credentials. Combine it with `--development` to exercise the unsigned release flow before publisher credentials exist. Omitting `--without-updates` keeps the fail-closed behavior above: all three update settings are required, and a partial configuration is an error. @@ -84,7 +84,7 @@ Embed only `update-public.der` (or its bytes) in the native host. Key rotation i ## Application API -Require `rivet/distribution`. `fetch-update-manifest` verifies before parsing, `select-update` applies channel/version/rollout/platform policy, and `download-update` enforces limits and hashes. `prepare-platform-installation` turns a verified portable ZIP or Linux AppImage into Rivet's first-party atomic replacement; pass its result to `execute-platform-installation!`. The application supplies restart and health callbacks because it owns its process model and readiness signal. On Windows and macOS the default staged-payload verifier requires Authenticode or a deep strict code signature; development builds may inject an explicit verifier. +Require `rivet/distribution`. `fetch-update-manifest` verifies before parsing, `select-update` applies channel/version/rollout/platform policy, and `download-update` enforces limits and hashes. On Linux, pass `(current-install-kind)` as `#:install-kind`: AppImage and portable installs only select their matching payload, while deb/rpm installations return `package-manager` and do not consume the AppImage feed. `prepare-platform-installation` turns a verified portable ZIP or Linux AppImage into Rivet's first-party atomic replacement; pass its result to `execute-platform-installation!`. The application supplies restart and health callbacks because it owns its process model and readiness signal. On Windows and macOS the default staged-payload verifier requires Authenticode or a deep strict code signature; development builds may inject an explicit verifier. Pass `#:journal-path` to atomically persist every destructive phase. On the next start, reconstruct the same installation and call `recover-platform-installation!`; it accepts only the exact same signed candidate and absolute download/target/backup paths. An interruption before health restores the prior payload. An interruption during the idempotent commit repeats backup cleanup instead of rolling a healthy application back. diff --git a/rivet-cli/appimage.rkt b/rivet-cli/appimage.rkt index c5498ce..eacf8c1 100644 --- a/rivet-cli/appimage.rkt +++ b/rivet-cli/appimage.rkt @@ -17,6 +17,7 @@ racket/set racket/string racket/system + "../rivet/distribution/crypto.rkt" "linux-native-package.rkt" "project.rkt") @@ -65,6 +66,16 @@ (project-version project) (appimage-architecture)))) +(define (write-appimage-checksum! output) + (define sidecar (string->path (string-append (path->string output) ".sha256"))) + (call-with-output-file sidecar + #:exists 'truncate/replace + (lambda (out) + (fprintf out "~a ~a~n" + (sha256-file/hex output) + (path->string (file-name-from-path output))))) + sidecar) + ;; Libraries that must remain the host system's own: the dynamic loader and ;; libc family, and the GL/Vulkan driver stack (the display driver owns it). ;; libstdc++/libgcc are bundled on purpose — old distributions ship older @@ -303,10 +314,12 @@ (when (file-exists? output) (delete-file output)) (putenv "APPIMAGE_EXTRACT_AND_RUN" "1") (run! 'create-appimage! tool (path->string appdir) (path->string output)) + (write-appimage-checksum! output) output) (module+ test-support (provide stage-appdir! write-appimage-apprun! + write-appimage-checksum! appimage-installer-path run/capture)) diff --git a/rivet-cli/installer.rkt b/rivet-cli/installer.rkt index 10a96c2..258f734 100644 --- a/rivet-cli/installer.rkt +++ b/rivet-cli/installer.rkt @@ -206,10 +206,10 @@ #:exists 'truncate/replace (lambda (out) (displayln (bytes->base64-string signature) out)))) - ;; Native system installer formats ride along with the signed tar.gz - ;; payload: deb and rpm integrate with the distribution package manager, - ;; AppImage carries its own GTK4 dependency closure. The tar.gz remains - ;; the update-channel artifact for every format choice. + ;; Native system installer formats ride along with the signed tar.gz: + ;; deb/rpm integrate with the distribution package manager, while AppImage + ;; carries its own GTK4 dependency closure and becomes the signed in-place + ;; update payload when enabled. (for ([format (in-list (project-linux-formats project))]) (case format [("deb") (create-deb! project package)] diff --git a/rivet-cli/project.rkt b/rivet-cli/project.rkt index 8af8dea..694e6ac 100644 --- a/rivet-cli/project.rkt +++ b/rivet-cli/project.rkt @@ -209,7 +209,7 @@ (andmap (lambda (item) (member item '("deb" "rpm" "appimage"))) v))) - "subset of (\"deb\" \"rpm\" \"appimage\") selecting the native Linux installer formats; the signed tar.gz update payload is always produced") + "subset of (\"deb\" \"rpm\" \"appimage\") selecting native Linux installer formats; AppImage becomes the signed update payload when enabled") (optional 'linux-binary-name (lambda (v) (and (string? v) diff --git a/rivet-cli/release.rkt b/rivet-cli/release.rkt index e28d49a..4708c1a 100644 --- a/rivet-cli/release.rkt +++ b/rivet-cli/release.rkt @@ -8,6 +8,7 @@ racket/string "../rivet/distribution/crypto.rkt" "../rivet/distribution/manifest.rkt" + "appimage.rkt" "compliance.rkt" "installer.rkt" "package.rkt" @@ -80,19 +81,40 @@ (path->string (file-name-from-path zip-path))))) zip-path) -(define (portable-update-artifact update-config portable-zip) +(define (update-artifact-for-file update-config payload platform architecture installer) (update-artifact - (release-platform) - (release-architecture) + platform + architecture (string-append (string-trim (update-environment-base-url update-config) "/") "/" - (path->string (file-name-from-path portable-zip))) - (sha256-file/hex portable-zip) - (file-size portable-zip) - 'zip + (path->string (file-name-from-path payload))) + (sha256-file/hex payload) + (file-size payload) + installer '())) +(define (portable-update-artifact update-config portable-zip) + (update-artifact-for-file update-config portable-zip + (release-platform) (release-architecture) 'zip)) + +(define (release-update-artifact update-config project portable-zip + #:platform [platform (release-platform)] + #:architecture [architecture (release-architecture)]) + ;; AppImage is the Linux self-replacing format. When a release produces one, + ;; make it the signed update payload rather than asking products to derive an + ;; unsigned sibling URL from the portable ZIP or tarball name. + (define appimage? (and (eq? platform 'linux) + (member "appimage" (project-linux-formats project)))) + (define payload (if appimage? (appimage-installer-path project) portable-zip)) + (unless (file-exists? payload) + (raise-arguments-error 'release-project! + "selected update payload was not produced" + "installer" (if appimage? 'appimage 'zip) + "payload" payload)) + (update-artifact-for-file update-config payload platform architecture + (if appimage? 'appimage 'zip))) + (define (release-project! project #:production? [production? #t] #:updates? [updates? #t]) @@ -121,7 +143,7 @@ update-config (let* ([previous (getenv "RIVET_PREVIOUS_VERSION")] [artifact - (portable-update-artifact update-config portable-zip)] + (release-update-artifact update-config project portable-zip)] [manifest (update-manifest (project-identifier project) @@ -152,4 +174,5 @@ (values installer manifest-path sbom notices portable-zip)) (module+ test-support - (provide portable-update-artifact)) + (provide portable-update-artifact + release-update-artifact)) diff --git a/rivet/distribution/platform-adapter.rkt b/rivet/distribution/platform-adapter.rkt index 0b1ca01..ee1dc59 100644 --- a/rivet/distribution/platform-adapter.rkt +++ b/rivet/distribution/platform-adapter.rkt @@ -8,12 +8,14 @@ racket/file racket/list racket/path + racket/string racket/system "manifest.rkt" "updater.rkt") (provide (struct-out platform-installation) current-update-platform + current-install-kind platform-installer-policy verify-platform-payload! prepare-platform-installation @@ -32,6 +34,33 @@ "unsupported operating system" "system-type" (system-type 'os))])) +(define (detect-install-kind platform executable appimage) + (cond + [(not (eq? platform 'linux)) 'portable] + [(and appimage (not (string=? (string-trim appimage) ""))) 'appimage] + [else + (define raw + (string-replace (if (path? executable) + (path->string executable) + executable) + "\\" "/")) + (define normalized + (if (string-prefix? raw "/") + raw + (string-replace + (path->string (simplify-path (path->complete-path executable) #f)) + "\\" "/"))) + ;; Rivet's deb and rpm both install under /opt. /usr also covers products + ;; following the conventional rpm layout. Update ownership matters here, + ;; not which package database owns the executable. + (if (or (string-prefix? normalized "/opt/") + (string-prefix? normalized "/usr/")) + 'package-manager + 'portable)])) + +(define (current-install-kind [executable (find-system-path 'run-file)]) + (detect-install-kind (current-update-platform) executable (getenv "APPIMAGE"))) + ;; `portable` means Rivet can replace the application payload itself. ;; `package-manager` means installation must remain under the OS transaction ;; owner; silently unpacking one of these artifacts would bypass elevation, @@ -275,3 +304,6 @@ (platform-installation-plan installation) journal-path #:commit (platform-installation-commit installation))) + +(module+ test-support + (provide detect-install-kind)) diff --git a/rivet/distribution/updater.rkt b/rivet/distribution/updater.rkt index 36e21d8..70580f9 100644 --- a/rivet/distribution/updater.rkt +++ b/rivet/distribution/updater.rkt @@ -72,7 +72,21 @@ #:key-id key-id)) (lambda () (close-input-port in)))) -(define (select-update config manifest) +(define install-kinds '(portable appimage package-manager)) + +(define (artifact-matches-install-kind? artifact install-kind) + (or (not install-kind) + (case install-kind + [(portable) (eq? (update-artifact-installer artifact) 'zip)] + [(appimage) (eq? (update-artifact-installer artifact) 'appimage)] + [(package-manager) + (and (memq (update-artifact-installer artifact) '(deb rpm)) #t)]))) + +(define (select-update config manifest #:install-kind [install-kind #f]) + (unless (or (not install-kind) (memq install-kind install-kinds)) + (raise-argument-error 'select-update + "(or/c #f 'portable 'appimage 'package-manager)" + install-kind)) (cond [(not (string=? (updater-config-application-id config) (update-manifest-application-id manifest))) @@ -91,7 +105,8 @@ #:when (and (eq? (update-artifact-platform item) (updater-config-platform config)) (eq? (update-artifact-architecture item) - (updater-config-architecture config)))) + (updater-config-architecture config)) + (artifact-matches-install-kind? item install-kind))) item)) (and artifact (update-candidate manifest artifact))])) diff --git a/tests/distribution.rkt b/tests/distribution.rkt index f35113d..d558771 100644 --- a/tests/distribution.rkt +++ b/tests/distribution.rkt @@ -37,6 +37,33 @@ "2026-09-28T00:00:00Z" "1.0.0" "1.1.0" #t 100 (list sample-artifact))) +(define linux-config + (updater-config "dev.rivet.test" "1.1.0" 'stable 'linux 'x64 + #f #f 0 (* 1024 1024))) +(define linux-zip + (update-artifact 'linux 'x64 "https://updates.example/app.zip" + (make-string 64 #\b) 4 'zip '())) +(define linux-appimage + (update-artifact 'linux 'x64 "https://updates.example/app.AppImage" + (make-string 64 #\c) 4 'appimage '())) +(define linux-manifest + (struct-copy update-manifest sample-manifest + [artifacts (list linux-zip linux-appimage)])) +(check-eq? (update-candidate-artifact + (select-update linux-config linux-manifest + #:install-kind 'portable)) + linux-zip) +(check-eq? (update-candidate-artifact + (select-update linux-config linux-manifest + #:install-kind 'appimage)) + linux-appimage) +(check-false (select-update linux-config linux-manifest + #:install-kind 'package-manager)) +(check-exn exn:fail:contract? + (lambda () + (select-update linux-config linux-manifest + #:install-kind 'unknown))) + (when private-key (define public-key (datum->pk-key (pk-key->datum private-key 'SubjectPublicKeyInfo) diff --git a/tests/linux-native-installer.rkt b/tests/linux-native-installer.rkt index 7e367f2..39cf340 100644 --- a/tests/linux-native-installer.rkt +++ b/tests/linux-native-installer.rkt @@ -11,7 +11,10 @@ racket/file racket/path racket/string + "../rivet/distribution/crypto.rkt" "../rivet-cli/appimage.rkt" + (only-in (submod "../rivet-cli/appimage.rkt" test-support) + write-appimage-checksum!) "../rivet-cli/deb.rkt" "../rivet-cli/linux-native-package.rkt" "../rivet-cli/project.rkt" @@ -56,6 +59,13 @@ (check-true (regexp-match? #rx"(?m:\\.rpm$)" (path->string (rpm-installer-path project)))) (check-true (regexp-match? #rx"(?m:\\.AppImage$)" (path->string (appimage-installer-path project)))) + (define checksum-payload (build-path temp-root "Demo_App.AppImage")) + (write-bytes/text checksum-payload "appimage-bytes") + (define checksum-sidecar (write-appimage-checksum! checksum-payload)) + (check-equal? + (string-trim (file->string checksum-sidecar)) + (format "~a Demo_App.AppImage" + (sha256-file/hex checksum-payload))) ;; ------------------------------------------------------------- deb (define deb-root (stage-deb-root! project package-dir diff --git a/tests/platform-update-adapter.rkt b/tests/platform-update-adapter.rkt index 1faa731..3c70e1e 100644 --- a/tests/platform-update-adapter.rkt +++ b/tests/platform-update-adapter.rkt @@ -6,10 +6,23 @@ racket/file racket/path racket/port - "../rivet/distribution.rkt") + "../rivet/distribution.rkt" + (submod "../rivet/distribution/platform-adapter.rkt" test-support)) (define platform (current-update-platform)) +(check-equal? (detect-install-kind 'linux "/tmp/App.AppImage" + "/tmp/App.AppImage") + 'appimage) +(check-equal? (detect-install-kind 'linux "/opt/Example/RivetHost" #f) + 'package-manager) +(check-equal? (detect-install-kind 'linux "/usr/bin/example" #f) + 'package-manager) +(check-equal? (detect-install-kind 'linux "/home/user/example" #f) + 'portable) +(check-equal? (detect-install-kind 'windows "C:\\Example\\RivetHost.exe" #f) + 'portable) + (define (candidate installer [version "2.0.0"]) (define artifact (update-artifact platform 'x64 "https://updates.example/application.zip" diff --git a/tests/release-options.rkt b/tests/release-options.rkt index 8dd442e..fdbc78c 100644 --- a/tests/release-options.rkt +++ b/tests/release-options.rkt @@ -5,6 +5,7 @@ racket/path racket/string "../rivet/distribution/manifest.rkt" + "../rivet-cli/appimage.rkt" "../rivet-cli/project.rkt" "../rivet-cli/release.rkt" (submod "../rivet-cli/release.rkt" test-support)) @@ -70,7 +71,44 @@ (check-equal? (update-artifact-installer artifact) 'zip) (check-equal? (update-artifact-size artifact) (file-size archive)) (check-true - (string-suffix? (update-artifact-url artifact) zip-name))) + (string-suffix? (update-artifact-url artifact) zip-name)) + + ;; AppImage is selected by the signed manifest itself. Products never + ;; derive a sibling URL or trust a detached sidecar instead of the feed. + (define appimage-project + (rivet-project temp-root + #hasheq((name . "Example") + (version . "2.3.4") + (linux-formats . ("appimage"))))) + (define appimage (appimage-installer-path appimage-project)) + (call-with-output-file appimage + #:exists 'truncate/replace #:mode 'binary + (lambda (out) (write-bytes #"appimage-payload" out))) + (define appimage-artifact + (parameterize ([current-environment-variables complete-env]) + (release-update-artifact (release-update-environment #t) + appimage-project archive + #:platform 'linux + #:architecture 'x64))) + (check-equal? (update-artifact-installer appimage-artifact) 'appimage) + (check-equal? (update-artifact-size appimage-artifact) + (file-size appimage)) + (check-true + (string-suffix? (update-artifact-url appimage-artifact) + (path->string (file-name-from-path appimage)))) + + (define deb-only-project + (rivet-project temp-root + #hasheq((name . "Example") + (version . "2.3.4") + (linux-formats . ("deb"))))) + (define fallback-artifact + (parameterize ([current-environment-variables complete-env]) + (release-update-artifact (release-update-environment #t) + deb-only-project archive + #:platform 'linux + #:architecture 'x64))) + (check-equal? (update-artifact-installer fallback-artifact) 'zip)) (lambda () (when (directory-exists? temp-root) (delete-directory/files temp-root))))