diff --git a/CHANGELOG.md b/CHANGELOG.md index 31e292c..9007431 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,17 @@ ## Unreleased +- Let one update feed carry multiple installables per platform. Artifacts + gain an optional `component` discriminator (`app`, `cli`, or `tool`) that + participates in the manifest's duplicate-identity rule, writers emit the + legacy shape when no component is set, and `updater-config` selects by + component; a config without one keeps matching component-less entries + only. `raco rivet release --update-payload :[:]` + (repeatable) expands project-relative globs into component-tagged feed + entries, so products shipping an app and a CLI — or a portable tar.gz + instead of the zip — stop splitting into two feeds. The + `updater-config` struct gains the component field as its last argument. + - Add `fetch-sibling-asset` to `rivet/distribution` and the `raco rivet sign-manifest` subcommand. The sibling helper checks the HTTP status before saving, enforces the declared Content-Length against the diff --git a/rivet-cli/main.rkt b/rivet-cli/main.rkt index aa7309c..30f0cc7 100644 --- a/rivet-cli/main.rkt +++ b/rivet-cli/main.rkt @@ -49,6 +49,8 @@ " raco rivet release build signed installer, update manifest, SBOM, and notices\n" " raco rivet release --development exercise release flow without platform production signing\n" " raco rivet release --without-updates release without an update manifest or update key\n" + " raco rivet release --update-payload :[:]\n" + " add a per-platform update payload to the feed\n" " raco rivet manifest-compose [--output ]\n" " raco rivet sign-manifest --application-id --version --build \n" " --channel --base-url \n" @@ -169,18 +171,22 @@ (say "production packaged, signed, and verified ~a~a" output (if launch-smoke? "" " (launch smoke skipped)"))] - [(or (list "release") - (list "release" "--development") - (list "release" "--without-updates") - (list "release" "--development" "--without-updates") - (list "release" "--without-updates" "--development")) + [(cons "release" release-args) (define release-project! (command "release" 'release-project!)) - (define production? (not (member "--development" args))) - (define updates? (not (member "--without-updates" args))) + (define production? (not (member "--development" release-args))) + (define updates? (not (member "--without-updates" release-args))) + (define update-payloads + (let loop ([remaining release-args] [payloads '()]) + (match remaining + ['() (reverse payloads)] + [(list* "--update-payload" spec tail) + (loop tail (cons spec payloads))] + [(cons _ tail) (loop tail payloads)]))) (define-values (installer manifest sbom notices portable-zip) (release-project! (current-project!) #:production? production? - #:updates? updates?)) + #:updates? updates? + #:update-payloads update-payloads)) (say "release installer: ~a" installer) (say "portable zip: ~a" portable-zip) (when (eq? (system-type 'os) 'unix) diff --git a/rivet-cli/release.rkt b/rivet-cli/release.rkt index 88839f9..8dc18f2 100644 --- a/rivet-cli/release.rkt +++ b/rivet-cli/release.rkt @@ -1,7 +1,9 @@ #lang racket/base -(require file/zip +(require file/glob + file/zip racket/file + racket/list racket/path racket/string "../rivet/distribution/crypto.rkt" @@ -19,7 +21,8 @@ release-update-environment portable-zip-path create-portable-zip! - release-update-artifacts) + release-update-artifacts + parse-update-payload-spec) (define (required-environment name) (define value (getenv name)) @@ -63,7 +66,8 @@ (sha256-file/hex payload) (file-size payload) installer - '())) + '() + #f)) (define (portable-update-artifact update-config portable-zip) (update-artifact-for-file update-config portable-zip @@ -91,9 +95,54 @@ ;; MSI and hand it to the shared Windows handoff; AppImage installs select the ;; self-replacing image. macOS's host client intentionally consumes the zip — ;; the DMG remains the human installer. +;; +;; Products that publish additional update payloads per platform (a CLI next +;; to the app, a tar.gz instead of the zip) declare them with +;; `--update-payload :[:]`, repeatable. Globs +;; expand relative to the project root; each match joins the feed with the +;; component discriminator, and a triple without a component keeps the +;; legacy shape. +(define (parse-update-payload-spec who spec) + (define parts (string-split spec ":")) + (unless (and (>= (length parts) 2) (<= (length parts) 3)) + (raise-arguments-error who + "--update-payload expects :[:]" + "value" spec)) + (define installer (string->symbol (list-ref parts 0))) + (unless (memq installer '(zip msi deb rpm appimage)) + (raise-arguments-error who + "update payload installer must be zip, msi, deb, rpm, or appimage" + "value" spec)) + (define component + (and (= (length parts) 3) + (let ([name (string->symbol (list-ref parts 2))]) + (unless (memq name '(app cli tool)) + (raise-arguments-error who + "update payload component must be app, cli, or tool" + "value" spec)) + name))) + (values installer (list-ref parts 1) component)) + +(define (expand-update-payloads update-config project platform architecture specs) + (append* + (for/list ([spec (in-list specs)]) + (define-values (installer pattern component) + (parse-update-payload-spec 'release-project! spec)) + (define matches + (sort (glob (project-path project pattern)) pathstring (current-seconds))) + (rfc3339-now)) (hash-ref options 'minimum-version "0.0.0") (hash-ref options 'previous-version #f) (hash-ref options 'rollback-allowed? #f) @@ -157,7 +158,8 @@ (sha256-file/hex path) (file-size path) (requested-artifact-installer artifact) - '())))) + '() + #f)))) (make-parent-directory* output) (call-with-output-file output #:exists 'truncate/replace diff --git a/rivet/distribution/managed-updater.rkt b/rivet/distribution/managed-updater.rkt index 0aed960..fa53ecb 100644 --- a/rivet/distribution/managed-updater.rkt +++ b/rivet/distribution/managed-updater.rkt @@ -221,7 +221,8 @@ key (updater-key-id instance) (updater-rollout-bucket instance) - (updater-maximum-download-bytes instance))) + (updater-maximum-download-bytes instance) + #f)) (define (available-result instance candidate) (define manifest (update-candidate-manifest candidate)) diff --git a/rivet/distribution/manifest.rkt b/rivet/distribution/manifest.rkt index afa0f79..af9c78a 100644 --- a/rivet/distribution/manifest.rkt +++ b/rivet/distribution/manifest.rkt @@ -20,7 +20,7 @@ (define manifest-schema-version 1) (struct update-artifact - (platform architecture url sha256 size installer arguments) + (platform architecture url sha256 size installer arguments component) #:transparent) (struct update-manifest @@ -32,7 +32,11 @@ '(schema application_id version build channel published_at minimum_version previous_version rollback_allowed rollout artifacts)) (define artifact-fields - '(platform architecture url sha256 size installer arguments)) + '(platform architecture url sha256 size installer arguments component)) +;; Components discriminate multiple installables per (platform, architecture, +;; installer): a product may ship an app and a CLI built from one release. +;; Legacy manifests carry no component; those entries are the plain artifact. +(define artifact-components '(app cli tool)) (define wrapper-fields '(schema payload signature)) (define signature-fields '(algorithm key_id value)) @@ -69,13 +73,18 @@ value) (define (artifact->jsexpr artifact) - (hasheq 'platform (symbol->string (update-artifact-platform artifact)) - 'architecture (symbol->string (update-artifact-architecture artifact)) - 'url (update-artifact-url artifact) - 'sha256 (string-downcase (update-artifact-sha256 artifact)) - 'size (update-artifact-size artifact) - 'installer (symbol->string (update-artifact-installer artifact)) - 'arguments (update-artifact-arguments artifact))) + (define base + (hasheq 'platform (symbol->string (update-artifact-platform artifact)) + 'architecture (symbol->string (update-artifact-architecture artifact)) + 'url (update-artifact-url artifact) + 'sha256 (string-downcase (update-artifact-sha256 artifact)) + 'size (update-artifact-size artifact) + 'installer (symbol->string (update-artifact-installer artifact)) + 'arguments (update-artifact-arguments artifact))) + (if (update-artifact-component artifact) + (hash-set base 'component + (symbol->string (update-artifact-component artifact))) + base)) (define (manifest->payload-bytes manifest) (unless (update-manifest? manifest) @@ -106,6 +115,19 @@ (lambda (v) (and (string? v) (regexp-match? #px"^[0-9A-Fa-f]{64}$" v))) "64 hexadecimal SHA-256 characters")) + (define component + (cond + [(not (hash-has-key? value 'component)) #f] + [(eq? (hash-ref value 'component) 'null) #f] + [else + (define name (required value 'component string? "string")) + (define symbol-name (string->symbol name)) + (unless (memq symbol-name artifact-components) + (raise-arguments-error 'payload-bytes->manifest + "artifact component is not supported" + "component" name + "supported" artifact-components)) + symbol-name])) (update-artifact (string->symbol (required value 'platform string? "string")) (string->symbol (required value 'architecture string? "string")) @@ -118,7 +140,8 @@ (string->symbol (required value 'installer string? "string")) (required value 'arguments (lambda (v) (and (list? v) (andmap string? v))) - "array of strings"))) + "array of strings") + component)) (define (payload-bytes->manifest payload) (define value @@ -183,9 +206,13 @@ (define duplicate-identity (check-duplicates (map (lambda (artifact) + ;; The component discriminator is part of the identity: one feed + ;; may carry an app and a CLI built from the same release, while + ;; component-less legacy entries stay unique per triple. (list (update-artifact-platform artifact) (update-artifact-architecture artifact) - (update-artifact-installer artifact))) + (update-artifact-installer artifact) + (update-artifact-component artifact))) artifacts) equal?)) (when duplicate-identity diff --git a/rivet/distribution/updater.rkt b/rivet/distribution/updater.rkt index fd100ff..c629f18 100644 --- a/rivet/distribution/updater.rkt +++ b/rivet/distribution/updater.rkt @@ -25,7 +25,8 @@ (struct updater-config (application-id current-version channel platform architecture - public-key expected-key-id rollout-bucket maximum-download-bytes) + public-key expected-key-id rollout-bucket maximum-download-bytes + component) #:transparent) (struct update-candidate (manifest artifact) #:transparent) @@ -289,12 +290,14 @@ [(>= (updater-config-rollout-bucket config) (update-manifest-rollout manifest)) #f] [else + (define component (updater-config-component config)) (define artifact (for/first ([item (in-list (update-manifest-artifacts manifest))] #:when (and (eq? (update-artifact-platform item) (updater-config-platform config)) (eq? (update-artifact-architecture item) (updater-config-architecture config)) + (eq? (update-artifact-component item) component) (artifact-matches-install-kind? item install-kind))) item)) (and artifact (update-candidate manifest artifact))])) diff --git a/rivet/tests/distribution.rkt b/rivet/tests/distribution.rkt index 5c75b7f..ffe47cf 100644 --- a/rivet/tests/distribution.rkt +++ b/rivet/tests/distribution.rkt @@ -55,7 +55,7 @@ (define sample-artifact (update-artifact 'windows 'x64 "https://updates.example/app.msi" - (make-string 64 #\a) 4 'msi '("/quiet"))) + (make-string 64 #\a) 4 'msi '("/quiet") #f)) (define sample-manifest (update-manifest "dev.rivet.test" "1.2.0" 12 'stable "2026-09-28T00:00:00Z" "1.0.0" "1.1.0" @@ -63,13 +63,13 @@ (define linux-config (updater-config "dev.rivet.test" "1.1.0" 'stable 'linux 'x64 - #f #f 0 (* 1024 1024))) + #f #f 0 (* 1024 1024) #f)) (define linux-zip (update-artifact 'linux 'x64 "https://updates.example/app.zip" - (make-string 64 #\b) 4 'zip '())) + (make-string 64 #\b) 4 'zip '() #f)) (define linux-appimage (update-artifact 'linux 'x64 "https://updates.example/app.AppImage" - (make-string 64 #\c) 4 'appimage '())) + (make-string 64 #\c) 4 'appimage '() #f)) (define linux-manifest (struct-copy update-manifest sample-manifest [artifacts (list linux-zip linux-appimage)])) @@ -90,10 +90,10 @@ (define windows-config (updater-config "dev.rivet.test" "1.1.0" 'stable 'windows 'x64 - #f #f 0 (* 1024 1024))) + #f #f 0 (* 1024 1024) #f)) (define windows-zip (update-artifact 'windows 'x64 "https://updates.example/app.zip" - (make-string 64 #\d) 4 'zip '())) + (make-string 64 #\d) 4 'zip '() #f)) (define windows-manifest (struct-copy update-manifest sample-manifest [artifacts (list windows-zip sample-artifact)])) @@ -220,7 +220,7 @@ (define config (updater-config "dev.rivet.test" "1.1.0" 'stable 'windows 'x64 - public-key "release-2026" 0 (* 1024 1024))) + public-key "release-2026" 0 (* 1024 1024) #f)) (check-true (update-candidate? (select-update config sample-manifest))) (check-false (select-update (struct-copy updater-config config [current-version "1.2.0"]) diff --git a/rivet/tests/managed-updater.rkt b/rivet/tests/managed-updater.rkt index 0955c24..ce51ead 100644 --- a/rivet/tests/managed-updater.rkt +++ b/rivet/tests/managed-updater.rkt @@ -7,7 +7,7 @@ (define artifact (update-artifact 'windows 'x64 "https://updates.example/app.zip" - (make-string 64 #\a) 100 'zip '())) + (make-string 64 #\a) 100 'zip '() #f)) (define manifest (update-manifest "dev.rivet.managed-test" "2.0.0" 2 'stable "2026-10-13T00:00:00Z" "0.0.0" "1.0.0" diff --git a/rivet/tests/manifest-compose.rkt b/rivet/tests/manifest-compose.rkt index b95af8a..70eab16 100644 --- a/rivet/tests/manifest-compose.rkt +++ b/rivet/tests/manifest-compose.rkt @@ -19,7 +19,7 @@ (define temp-root (make-temporary-file "rivet-manifest-compose-~a" 'directory)) (define (artifact platform architecture url) - (update-artifact platform architecture url (make-string 64 #\a) 1 'msi '())) + (update-artifact platform architecture url (make-string 64 #\a) 1 'msi '() #f)) (define (manifest #:artifacts artifacts #:version [version "1.4.0"]) (update-manifest "dev.rivet.smoke" version 7 'stable "2026-10-09T10:00:00Z" "0.0.0" #f #t 100 artifacts)) diff --git a/rivet/tests/platform-update-adapter.rkt b/rivet/tests/platform-update-adapter.rkt index 5be3c9c..e9c1bf5 100644 --- a/rivet/tests/platform-update-adapter.rkt +++ b/rivet/tests/platform-update-adapter.rkt @@ -26,7 +26,7 @@ (define (candidate installer [version "2.0.0"]) (define artifact (update-artifact platform 'x64 "https://updates.example/application.zip" - (make-string 64 #\a) 1 installer '())) + (make-string 64 #\a) 1 installer '() #f)) (update-candidate (update-manifest "dev.rivet.adapter-test" version 2 'stable "2026-10-10T00:00:00Z" "1.0.0" "1.0.0" diff --git a/rivet/tests/sign-manifest.rkt b/rivet/tests/sign-manifest.rkt index 78e20b7..645876e 100644 --- a/rivet/tests/sign-manifest.rkt +++ b/rivet/tests/sign-manifest.rkt @@ -108,7 +108,7 @@ (define sidecar (path-add-extension artifact #".sha256")) (check-true (file-exists? sidecar)) (check-regexp-match - #rx"^[0-9a-f]{64} App-1\\.2\\.3-macos-arm64\\.zip$" + #px"^[0-9a-f]{64} App-1\\.2\\.3-macos-arm64\\.zip" (file->string sidecar)) ;; --- fail-closed publish --------------------------------------------------- @@ -124,7 +124,7 @@ #:exists 'truncate/replace (lambda (out) (write-bytes (make-bytes 128 9) out))) (define other-output (build-path root "other-update.json")) -(check-exn #rx"verification failed|unexpected key|malformed" +(check-exn #rx"verification failed|unexpected key|malformed|could not decode" (lambda () (sign-manifest! (list (requested-artifact 'linux 'x64 'zip (path->string other-artifact))) diff --git a/rivet/tests/update-components.rkt b/rivet/tests/update-components.rkt new file mode 100644 index 0000000..2201bfa --- /dev/null +++ b/rivet/tests/update-components.rkt @@ -0,0 +1,131 @@ +#lang racket/base + +;; The manifest component discriminator (#248) and the release +;; --update-payload expansion (#244): legacy manifests keep working, a +;; component-bearing feed selects by component, and payload specs expand +;; globs into component-tagged artifacts. + +(require rackunit + racket/file + racket/path + racket/string + "../../rivet/distribution/manifest.rkt" + "../../rivet/distribution/updater.rkt" + "../../rivet-cli/project.rkt" + (rename-in (submod "../../rivet-cli/release.rkt" test-support) + [parse-update-payload-spec parse-update-payload-spec/t] + [expand-update-payloads expand-update-payloads/t])) + +;; --- legacy shape round-trips without component ---------------------------- + +(define legacy + (update-manifest "dev.rivet.test" "1.2.0" 12 'stable + "2026-09-28T00:00:00Z" "1.0.0" "1.1.0" + #t 100 + (list (update-artifact 'windows 'x64 + "https://updates.example/app.zip" + (make-string 64 #\a) + 4 'zip '() #f)))) +(define legacy-bytes (manifest->payload-bytes legacy)) +(check-false (regexp-match? #"component" legacy-bytes)) +(check-equal? (payload-bytes->manifest legacy-bytes) legacy) + +;; --- component round-trips and validates ---------------------------------- + +(define component-manifest + (update-manifest "dev.rivet.test" "1.2.0" 12 'stable + "2026-09-28T00:00:00Z" "1.0.0" "1.1.0" + #t 100 + (list (update-artifact 'macos 'arm64 + "https://updates.example/app.zip" + (make-string 64 #\b) + 4 'zip '() 'app) + (update-artifact 'macos 'arm64 + "https://updates.example/cli.zip" + (make-string 64 #\c) + 4 'zip '() 'cli)))) +(define component-bytes (manifest->payload-bytes component-manifest)) +(check-true (regexp-match? #"\"component\":\"app\"" component-bytes)) +(check-equal? (payload-bytes->manifest component-bytes) component-manifest) + +;; Unsupported component names fail closed. +(check-exn #rx"artifact component is not supported" + (lambda () + (payload-bytes->manifest + (manifest->payload-bytes + (update-manifest "dev.rivet.test" "1.2.0" 12 'stable + "2026-09-28T00:00:00Z" "1.0.0" "1.1.0" + #t 100 + (list (update-artifact 'macos 'arm64 + "https://updates.example/x.zip" + (make-string 64 #\d) + 4 'zip '() 'bundle))))))) + +;; --- selection by component ------------------------------------------------ + +(define config + (updater-config "dev.rivet.test" "1.1.0" 'stable 'macos 'arm64 + #f #f 0 (* 1024 1024) 'app)) +(check-equal? (update-candidate-artifact + (select-update config component-manifest)) + (update-artifact 'macos 'arm64 + "https://updates.example/app.zip" + (make-string 64 #\b) + 4 'zip '() 'app)) +(check-equal? + (update-candidate-artifact + (select-update (struct-copy updater-config config [component 'cli]) + component-manifest + #:install-kind 'portable)) + (update-artifact 'macos 'arm64 + "https://updates.example/cli.zip" + (make-string 64 #\c) + 4 'zip '() 'cli)) +;; A legacy config without a component only matches component-less entries, +;; so this component-bearing feed has nothing for it. +(check-false (select-update (struct-copy updater-config config [component #f]) + component-manifest)) + +;; --- payload spec parsing -------------------------------------------------- + +(check-exn #rx"--update-payload expects" + (lambda () (parse-update-payload-spec/t 'test "zip"))) +(check-exn #rx"update payload installer must be" + (lambda () (parse-update-payload-spec/t 'test "tgz:dist/x.tar.gz"))) +(check-exn #rx"update payload component must be" + (lambda () (parse-update-payload-spec/t 'test "zip:dist/x.zip:bundle"))) + +;; --- glob expansion -------------------------------------------------------- + +(define root (make-temporary-file "rivet-update-payloads-~a" 'directory)) +(define project + (rivet-project root (hasheq 'name "Payload" 'version "1.0.0"))) +(define dist (build-path root "dist")) +(make-directory* dist) +(call-with-output-file (build-path dist "Payload-tool-linux-x64.tar.gz") + (lambda (out) (write-bytes (make-bytes 32 5) out))) +(call-with-output-file (build-path dist "Payload-tool-linux-arm64.tar.gz") + (lambda (out) (write-bytes (make-bytes 32 6) out))) + +(define update-config + (update-environment "https://updates.example/payload/" + (make-bytes 32 1) + "release-2026")) +(define expanded + (expand-update-payloads/t update-config project 'linux 'x64 + (list "zip:dist/*-linux-x64.tar.gz:tool"))) +(check-equal? (length expanded) 1) +(define expanded-artifact (car expanded)) +(check-equal? (update-artifact-component expanded-artifact) 'tool) +(check-equal? (update-artifact-installer expanded-artifact) 'zip) +(check-true + (string-suffix? (update-artifact-url expanded-artifact) + "Payload-tool-linux-x64.tar.gz")) +(check-equal? (update-artifact-size expanded-artifact) 32) + +(check-exn #rx"matched no files" + (lambda () + (expand-update-payloads/t update-config project 'linux 'x64 + (list "zip:dist/*-nope.tar.gz:tool")))) + +(delete-directory/files root)