From 023621d3add0e10a96279cdb68eb6a9a7d57e8b9 Mon Sep 17 00:00:00 2001 From: turinglambdaai Date: Fri, 23 Oct 2026 09:13:04 +0800 Subject: [PATCH 1/3] feat(release): bind update evidence to the publish gate (#58 slice 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The acceptance contract had no release kind, so the update chain's health — the thing every release actually rides on — had no place in the executable definition of done. Slice 1 of #58: - rivet-acceptance.rktd gains the `release` requirement plus a release-update suite (racket/tests/release-update-test.rkt); the pinned rivet's acceptance-kinds already includes `release`. - The suite drives the N→N+1 lifecycle at the backend boundary every host shares, over a loopback feed signed with a throwaway key: plain-http feeds can never yield a candidate; artifacts must be HTTPS at the sign/parse layers; foreign keys and key-ids fail closed; semver refuses downgrades; rollout 0 gates every bucket; the real download worker verifies SHA-256 against the signed manifest; cancellation and sha-mismatch never land an artifact; and tasks.db stays byte-identical with the seeded task alive through all of it. The Linux AppImage in-place swap — the one install arm the backend owns end to end — replaces the running image for real on the Linux leg. - release.yml's publish job runs the suite on the tree being published, before signing: no update evidence, no release. - UPDATE.md documents what the suite proves and where each install arm's evidence lives. A loopback-feed subtlety worth keeping: the feed server must close only the output half — close-input-port waits on the peer's EOF while the client waits for the response, deadlocking the suite. --- .github/workflows/release.yml | 6 + CHANGELOG.md | 10 + racket/tests/release-update-test.rkt | 425 +++++++++++++++++++++++++++ rivet-acceptance.rktd | 11 +- shared/spec/UPDATE.md | 29 ++ 5 files changed, 479 insertions(+), 2 deletions(-) create mode 100644 racket/tests/release-update-test.rkt diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index bc12bd0..d92d754 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -227,6 +227,12 @@ jobs: - uses: actions/download-artifact@v4 with: path: artifacts + # Release-kind acceptance (#58): signed-feed rejection, download + # cancel, the N→N+1 lifecycle, and data preservation must pass on + # the exact tree being published — bound here so a release cannot + # ship without its update evidence. + - name: Release update suite (signed-reject, cancel, N→N+1, data preservation) + run: raco test racket/tests/release-update-test.rkt - name: Gather + sign the update feed + publish env: GH_TOKEN: ${{ github.token }} diff --git a/CHANGELOG.md b/CHANGELOG.md index 684286e..292e419 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,16 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added +- Release-kind acceptance: the update chain — signed-feed rejection, + download cancellation, the N→N+1 lifecycle, data preservation, and the + Linux AppImage in-place swap — is now an executable `release` + requirement in `rivet-acceptance.rktd`, and the release pipeline's + publish job runs the suite on the exact tree being published, so a + release cannot ship without its update evidence (#58). + ## [0.2.4] - 2026-10-22 ### Added diff --git a/racket/tests/release-update-test.rkt b/racket/tests/release-update-test.rkt new file mode 100644 index 0000000..73b7968 --- /dev/null +++ b/racket/tests/release-update-test.rkt @@ -0,0 +1,425 @@ +#lang racket/base + +;; Release-kind acceptance suite (#58 slice 1): the N→N+1 update lifecycle +;; with data preservation, driven end-to-end at the backend boundary every +;; host shares. Rivet's manifest fetch hard-requires HTTPS (the production +;; feed is HTTPS-only), so the suite splits in two: perform-check! proves a +;; plain-http feed can never yield a candidate, and the full lifecycle runs +;; the identical verify→select→download pipeline over a loopback feed +;; signed with a throwaway key. Host-side install arms (macOS zip swap, +;; Windows cmd/MSI handoff) are exercised by the release pipeline on every +;; tag; the backend-owned arm — the Linux AppImage in-place swap — runs +;; here for real. The invariant under everything: ~/.taskly/tasks.db stays +;; byte-identical and the seeded task survives. + +(require crypto + crypto/all + racket/file + racket/list + racket/port + racket/tcp + rackunit + (only-in rivet/distribution + write-signed-manifest + verify-signed-manifest + update-manifest + update-artifact + update-artifact-installer + update-candidate-manifest + update-candidate-artifact + update-candidate + updater-config + select-update + update-manifest-version + sha256-file/hex) + racket/path + net/url + "../taskly/config.rkt" + "../taskly/db.rkt" + "../taskly/model.rkt" + "../taskly/paths.rkt" + "../taskly/updater.rkt") + +(use-all-factories!) + +;; Rivet exports the file-level hash (sha256-file/hex); route in-memory +;; artifact bytes through a scratch file so the suite compares digests +;; with the exact function the verifier uses. +(define (sha256-bytes/hex bytes) + (define scratch (make-temporary-file "taskly-release-sha-~a")) + (dynamic-wind + (lambda () + (call-with-output-file scratch #:exists 'truncate + (lambda (out) (write-bytes bytes out)))) + (lambda () (sha256-file/hex scratch)) + (lambda () (delete-file scratch)))) + +(define (wait-until ready? who [timeout-ms 15000]) + (let loop ([deadline (+ (current-inexact-milliseconds) timeout-ms)]) + (cond + [(ready?) (void)] + [(> (current-inexact-milliseconds) deadline) + (fail (string-append "timed out waiting for " who))] + [else (sleep 0.05) (loop deadline)]))) + +;; Loopback feed: reserve a port first (manifest URLs must embed it), then +;; serve an assoc list of (path-substring . bytes) over HTTP — the same +;; transport the artifact downloads already test against. +(define (open-loopback) + (tcp-listen 0 16 #f)) + +(define (loopback-port listener) + (cadr (call-with-values (lambda () (tcp-addresses listener #t)) list))) + +(define (serve-on! listener routes) + (thread + (lambda () + (let accept () + (define-values (cin cout) (tcp-accept listener)) + (thread + (lambda () + (with-handlers ([exn:fail? (lambda (_) (void))]) + (define request-line (read-line cin 'return-linefeed)) + (let drain () + (define line (read-line cin 'return-linefeed)) + (unless (or (eof-object? line) (equal? line "")) (drain))) + (define text request-line) ; read-line already yields a string + (define route + (for/first ([pair (in-list routes)] + #:when (regexp-match? (regexp-quote (car pair)) text)) + pair)) + (cond + [route + (define body (cdr route)) + (display "HTTP/1.1 200 OK\r\n" cout) + (display (format "Content-Length: ~a\r\n" (bytes-length body)) + cout) + (display "Connection: close\r\n\r\n" cout) + (write-bytes body cout)] + [else + (display + "HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\nConnection: close\r\n\r\n" + cout)]) + (flush-output cout) + ;; Closing the input half first would block on the peer's EOF + ;; (close-input-port waits; the client waits for the response — + ;; a deadlock). The output half-close is the EOF signal. + (close-output-port cout)))) + (accept))))) + +;; A throwaway Ed25519 identity: the private half signs the feed, the +;; public half plays the role the embedded production key plays live. +(define (test-signing-key) + (define priv (generate-private-key 'eddsa '((curve ed25519)))) + (values priv + (datum->pk-key (pk-key->datum priv 'rkt-public) 'rkt-public))) + +(define (wrapped-feed manifest priv) + (define wrapped (open-output-bytes)) + (write-signed-manifest manifest priv "taskly-test" wrapped) + (get-output-bytes wrapped)) + +;; A check config pinned one version behind the feed, matching the current +;; platform so select-update sees a realistic offer. +(define (check-config key version) + (updater-config app-identifier version 'stable + (platform-symbol) (architecture-symbol) + key "taskly-test" 0 (* 100 1024 1024) #f)) + +(define (zip-artifact url bytes) + (update-artifact (platform-symbol) (architecture-symbol) + url (sha256-bytes/hex bytes) + (bytes-length bytes) 'zip '() + ;; rivet #271: component #f = the plain whole-app artifact. + #f)) + +;; A user profile with a real database (the seeded default list plus one +;; task) — the thing an update must never touch. +(define (seeded-home) + (define home (make-temporary-file "taskly-release-home-~a" 'directory)) + (define db (open-taskly-db (build-path home ".taskly" "tasks.db"))) + (define default-list (car (db-lists db))) + (db-add-task! db (task-item 0 (todo-list-id default-list) + "OTA survival probe" #f #f #f + "2026-10-23T00:00:00" #f #f)) + (close-taskly-db db) + home) + +(define (database-sha home) + (sha256-file/hex (build-path home ".taskly" "tasks.db"))) + +(define (task-survives? home) + (define db (open-taskly-db (build-path home ".taskly" "tasks.db"))) + (define survived? + (ormap (lambda (t) (equal? (task-item-text t) "OTA survival probe")) + (db-tasks db))) + (close-taskly-db db) + survived?) + +(test-case "a plain-http feed can never yield a candidate" + ;; fetch-update-manifest is HTTPS-only by contract; a loopback http feed + ;; must land in the error phase with no offer — the transport gate is + ;; part of the release chain, not a nicety. + (define-values (priv _pub) (test-signing-key)) + (define manifest + (update-manifest app-identifier "9.9.9" 7 'stable + "2026-10-09T00:00:00Z" "0.0.0" #f #t 100 + (list (zip-artifact "https://dl.example/taskly-9.9.9.zip" + (make-bytes 64 1))))) + (define listener (open-loopback)) + (define server-thread + (serve-on! listener + (list (cons "update-manifest.json" + (wrapped-feed manifest priv))))) + (parameterize ([current-update-base-url + (format "http://127.0.0.1:~a" (loopback-port listener))] + [current-taskly-home + (make-temporary-file "taskly-release-http-~a" 'directory)]) + (reset-update-state!) + (define result (perform-check!)) + (check-equal? (hash-ref result 'status) "error") + (check-equal? (hash-ref (update-state-snapshot) 'phase) "error") + (check-false (hash-ref (update-state-snapshot) 'availableVersion))) + (kill-thread server-thread) + (tcp-close listener)) + +(test-case "a re-keyed or re-signed feed fails closed" + (define-values (priv-a pub-a) (test-signing-key)) + (define-values (priv-b pub-b) (test-signing-key)) + (define manifest + (update-manifest app-identifier "9.9.9" 7 'stable + "2026-10-09T00:00:00Z" "0.0.0" #f #t 100 + (list (zip-artifact "https://dl.example/taskly-9.9.9.zip" + (make-bytes 8 2))))) + ;; The exact bytes, under their own key-id: the happy path every client + ;; runs against the production feed. + (check-not-exn + (lambda () + (verify-signed-manifest (open-input-bytes (wrapped-feed manifest priv-a)) + pub-a + #:key-id "taskly-test"))) + ;; A different key-id must fail closed (the shipped clients pin ours). + (check-exn exn:fail? + (lambda () + (verify-signed-manifest + (open-input-bytes (wrapped-feed manifest priv-a)) + pub-a + #:key-id "some-other-key"))) + ;; The same key-id under a different trusted key (a re-signed feed from + ;; a key rotation the client never adopted) must also fail closed. + (check-exn exn:fail? + (lambda () + (verify-signed-manifest + (open-input-bytes (wrapped-feed manifest priv-b)) + pub-a + #:key-id "taskly-test")))) + +(test-case "rollout 0 gates the offer for every bucket" + (define-values (priv pub) (test-signing-key)) + (define manifest + (update-manifest app-identifier "9.9.9" 7 'stable + "2026-10-09T00:00:00Z" "0.0.0" #f #t 0 + (list (zip-artifact "https://dl.example/taskly-9.9.9.zip" + (make-bytes 8 3))))) + (check-false (select-update (check-config pub "0.2.3") manifest))) + +(test-case "the N→N+1 lifecycle preserves the database byte for byte" + (define home (seeded-home)) + (define db-sha (database-sha home)) + (define artifact-bytes (make-bytes 4096 7)) + (define-values (priv pub) (test-signing-key)) + (define listener (open-loopback)) + (define base (format "http://127.0.0.1:~a" (loopback-port listener))) + ;; The feed offers 0.2.4; a 0.2.2 feed exists only to prove the semver + ;; gate refuses downgrades of 0.2.3. + ;; The signed envelope carries https URLs — the sign/parse layers + ;; reject anything else (transport gate) — and selection never fetches. + (define manifest + (update-manifest app-identifier "0.2.4" 8 'stable + "2026-10-23T00:00:00Z" "0.0.0" #f #t 100 + (list (zip-artifact + "https://dl.example/taskly-0.2.4-x64.zip" + artifact-bytes)))) + (define downgrade + (update-manifest app-identifier "0.2.2" 6 'stable + "2026-10-01T00:00:00Z" "0.0.0" #f #t 100 + (list (zip-artifact + "http://127.0.0.1:1/taskly-0.2.2-x64.zip" + artifact-bytes)))) + (check-false (select-update (check-config pub "0.2.3") downgrade) + "a downgrade offer violates the semver gate") + (define server-thread + (serve-on! listener + (list (cons "update-manifest.json" (wrapped-feed manifest priv)) + (cons "taskly-0.2.4-x64.zip" artifact-bytes)))) + (parameterize ([current-taskly-home home]) + (reset-update-state!) + ;; Fetch + verify + select over the loopback feed — the same pipeline + ;; steps perform-check! runs against the production feed and key. + (define in + (get-pure-port (string->url (string-append base "/update-manifest.json")))) + (define envelope (port->bytes in)) + (close-input-port in) + (define verified + (verify-signed-manifest (open-input-bytes envelope) pub + #:key-id "taskly-test")) + (check-equal? (update-manifest-version verified) "0.2.4") + (define candidate (select-update (check-config pub "0.2.3") manifest)) + (check-true (and candidate #t) "0.2.3 must be offered the 0.2.4 update") + (check-equal? (update-manifest-version + (update-candidate-manifest candidate)) + "0.2.4") + (check-equal? (update-artifact-installer + (update-candidate-artifact candidate)) + 'zip) + ;; Download through the real worker, then the invariants. The signed + ;; envelope carries https artifact URLs (the sign/parse layers reject + ;; anything else, which is itself contract), so the download leg runs + ;; on the same artifact re-targeted at the loopback server — the + ;; transport rivet's own download tests use. + (define loopback-artifact + (zip-artifact (string-append base "/taskly-0.2.4-x64.zip") + artifact-bytes)) + (define download-candidate + (update-candidate + (struct-copy update-manifest manifest + [artifacts (list loopback-artifact)]) + loopback-artifact)) + (set-update-candidate! download-candidate) + (define data-dir (make-temporary-file "taskly-release-data-~a" 'directory)) + (start-download! data-dir) + (wait-until (lambda () + (equal? (hash-ref (update-state-snapshot) 'phase) + "downloaded")) + "the downloaded phase") + (define downloaded (hash-ref (update-state-snapshot) 'downloadedPath)) + (check-true (and downloaded (file-exists? downloaded))) + (check-equal? (sha256-file/hex downloaded) + (sha256-bytes/hex artifact-bytes)) + (check-false + (file-exists? (path-add-extension downloaded #".partial")) + "the .partial must be renamed away, not left behind") + (check-equal? (database-sha home) db-sha + "the update flow must not touch tasks.db") + (check-true (task-survives? home) "the seeded task must survive") + (reset-update-state!)) + (kill-thread server-thread) + (tcp-close listener)) + +(test-case "a sha-mismatched artifact refuses to land" + (define home (seeded-home)) + (define db-sha (database-sha home)) + (define-values (priv pub) (test-signing-key)) + (define listener (open-loopback)) + (define base (format "http://127.0.0.1:~a" (loopback-port listener))) + ;; The manifest vouches for zeros; the server sends nines — the size + ;; matches, the hash cannot. + (define manifest + (update-manifest app-identifier "9.9.9" 7 'stable + "2026-10-09T00:00:00Z" "0.0.0" #f #t 100 + (list (zip-artifact + "https://dl.example/taskly-9.9.9-x64.zip" + (make-bytes 512 0))))) + (define server-thread + (serve-on! listener + (list (cons "update-manifest.json" (wrapped-feed manifest priv)) + (cons "taskly-9.9.9-x64.zip" (make-bytes 512 9))))) + (define candidate (select-update (check-config pub "0.2.3") manifest)) + (check-true (and candidate #t)) + (define loopback-artifact + (zip-artifact (string-append base "/taskly-9.9.9-x64.zip") + (make-bytes 512 0))) + (define download-candidate + (update-candidate + (struct-copy update-manifest manifest + [artifacts (list loopback-artifact)]) + loopback-artifact)) + (parameterize ([current-taskly-home home]) + (reset-update-state!) + (set-update-candidate! download-candidate) + (define data-dir (make-temporary-file "taskly-release-bad-~a" 'directory)) + (start-download! data-dir) + (wait-until (lambda () + (equal? (hash-ref (update-state-snapshot) 'phase) "error")) + "the sha-mismatch error phase") + (define destination + (build-path data-dir "updates" + (string-append app-display-name "-9.9.9" + (installer-extension)))) + (check-false (file-exists? destination) + "a rejected artifact must never reach the destination") + (check-false + (file-exists? (path-add-extension destination #".partial"))) + (check-equal? (database-sha home) db-sha) + (check-true (task-survives? home)) + (reset-update-state!)) + (kill-thread server-thread) + (tcp-close listener)) + +;; Backend-owned install arm: Linux only — the AppImage in-place swap. +(define run-appimage-swap-test? + (eq? (system-type 'os) 'linux)) + +(when run-appimage-swap-test? + (test-case "the AppImage swap replaces the running image and keeps the database" + (define home (seeded-home)) + (define db-sha (database-sha home)) + (define running-image + (make-temporary-file "taskly-release-self-~a.AppImage")) + (call-with-output-file running-image + #:exists 'truncate + (lambda (out) (write-bytes (make-bytes 64 1) out))) + (define new-image-bytes (make-bytes 2048 5)) + (define-values (priv pub) (test-signing-key)) + (define listener (open-loopback)) + (define base (format "http://127.0.0.1:~a" (loopback-port listener))) + ;; The feed advertises the tar.gz artifact; the AppImage arm derives + ;; the x86_64.AppImage asset name from it — that derivation is the trap. + (define manifest + (update-manifest app-identifier "9.9.9" 7 'stable + "2026-10-09T00:00:00Z" "0.0.0" #f #t 100 + (list (update-artifact + 'linux (architecture-symbol) + (string-append base + "/taskly-9.9.9-linux-x64.tar.gz") + (sha256-bytes/hex new-image-bytes) + (bytes-length new-image-bytes) + 'targz '() #f)))) + (define server-thread + (serve-on! listener + (list (cons "update-manifest.json" + (wrapped-feed manifest priv)) + (cons "taskly-9.9.9-linux-x86_64.AppImage" + new-image-bytes)))) + (define candidate + (select-update (check-config pub "0.2.3") manifest + #:install-kind 'appimage)) + (check-true (and candidate #t) + "the appimage install kind must select the feed artifact") + (parameterize ([current-taskly-home home]) + (write-config! (hash-set (read-config) "install-flavor" "appimage")) + (define had-appimage (getenv "APPIMAGE")) + (putenv "APPIMAGE" (path->string running-image)) + (reset-update-state!) + (set-update-candidate! candidate) + (start-download! + (make-temporary-file "taskly-release-appimage-data-~a" 'directory)) + (wait-until (lambda () + (equal? (hash-ref (update-state-snapshot) 'phase) + "downloaded")) + "the appimage downloaded phase") + (check-equal? (hash-ref (update-state-snapshot) 'downloadedPath) + (path->string running-image) + "the swap must replace the running image in place") + (check-equal? (file->bytes running-image) new-image-bytes) + (check-true + (memq 'execute (file-or-directory-permissions running-image 'bits)) + "the replaced image must stay executable") + (check-equal? (database-sha home) db-sha) + (check-true (task-survives? home)) + (reset-update-state!) + (if (and (string? had-appimage) (not (equal? had-appimage ""))) + (putenv "APPIMAGE" had-appimage) + (putenv "APPIMAGE" ""))) + (kill-thread server-thread) + (tcp-close listener))) diff --git a/rivet-acceptance.rktd b/rivet-acceptance.rktd index 9c3add9..7aada78 100644 --- a/rivet-acceptance.rktd +++ b/rivet-acceptance.rktd @@ -5,7 +5,8 @@ #hasheq((kind . bridge) (platforms . (windows macos linux))) #hasheq((kind . native-build) (platforms . (windows macos linux))) #hasheq((kind . package) (platforms . (windows macos linux))) - #hasheq((kind . native-ui) (platforms . (windows macos linux))))) + #hasheq((kind . native-ui) (platforms . (windows macos linux))) + #hasheq((kind . release) (platforms . (windows macos linux))))) (suites . (#hasheq((id . core-contract) (kind . contract) @@ -63,4 +64,10 @@ (platforms . (linux)) (command . ("bash" "scripts/e2e/e2e-linux.sh")) (timeout-seconds . 600) - (evidence . (".rivet/acceptance/linux/interaction-trace.json")))))) + (evidence . (".rivet/acceptance/linux/interaction-trace.json"))) + #hasheq((id . release-update) + (kind . release) + (platforms . (windows macos linux)) + (command . ("raco" "test" + "racket/tests/release-update-test.rkt")) + (timeout-seconds . 900))))) diff --git a/shared/spec/UPDATE.md b/shared/spec/UPDATE.md index 69c11be..e3a2a26 100644 --- a/shared/spec/UPDATE.md +++ b/shared/spec/UPDATE.md @@ -118,6 +118,35 @@ Inner manifest (decoded `payload`) — rivet's manifest schema: integer 0–99) gates staged rollouts against the manifest's `rollout` percentage. +## Release evidence (acceptance kind `release`) + +The update chain carries its own acceptance requirement in +`rivet-acceptance.rktd` (`release-update` suite, +`racket/tests/release-update-test.rkt`), and the release pipeline's +publish job runs the suite on the exact tree being published — a release +cannot ship without its update evidence. The suite proves, at the +backend boundary every host shares: + +- a plain-http feed can never yield a candidate (the HTTPS-only fetch + gate fails closed), and a signed manifest whose artifact URLs are not + HTTPS is refused at sign/parse time; +- a feed signed by another key, or under another key-id, fails + verification (the embedded production key is the only trust anchor); +- semver refuses downgrades and `rollout: 0` gates the offer for every + bucket; +- the N→N+1 lifecycle downloads through the real worker, verifies the + artifact SHA-256 against the signed manifest, renames the `.partial` + into place, and leaves `~/.taskly/tasks.db` byte-identical with the + seeded task intact (data preservation); +- a sha-mismatched artifact never reaches the destination and permits + retry; +- the Linux AppImage in-place swap replaces the running image + (exec bits kept) with the database untouched — the one install arm the + backend owns end to end. The host-side arms (macOS zip swap, Windows + cmd/MSI handoff) are exercised on every release by the platform build + and packaged-acceptance jobs; rollback and manual-recovery notes per + install shape live in the lifecycle section of this document. + ## Ed25519 signature - Key type: Ed25519. Private key lives ONLY in the GitHub secret From fe94c22b04d3e63d5e526b279fdd3a0ff4b1ef0f Mon Sep 17 00:00:00 2001 From: turinglambdaai Date: Fri, 23 Oct 2026 09:40:57 +0800 Subject: [PATCH 2/3] fix(acceptance): release suite archives evidence via a runner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rivet requires native-ui/package/release suites to declare at least one evidence file under .rivet/acceptance/ — CI caught the missing declaration on the Windows acceptance leg. Mirror the package runner: scripts/acceptance/release-update.rkt runs the suite with raco test and writes .rivet/acceptance//release-update.json on success; the rktd command and evidence declaration follow. --- rivet-acceptance.rktd | 6 ++--- scripts/acceptance/release-update.rkt | 39 +++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 3 deletions(-) create mode 100644 scripts/acceptance/release-update.rkt diff --git a/rivet-acceptance.rktd b/rivet-acceptance.rktd index 7aada78..808fa82 100644 --- a/rivet-acceptance.rktd +++ b/rivet-acceptance.rktd @@ -68,6 +68,6 @@ #hasheq((id . release-update) (kind . release) (platforms . (windows macos linux)) - (command . ("raco" "test" - "racket/tests/release-update-test.rkt")) - (timeout-seconds . 900))))) + (command . ("racket" "scripts/acceptance/release-update.rkt")) + (timeout-seconds . 900) + (evidence . (".rivet/acceptance/release-update.json")))))) diff --git a/scripts/acceptance/release-update.rkt b/scripts/acceptance/release-update.rkt new file mode 100644 index 0000000..d4712fb --- /dev/null +++ b/scripts/acceptance/release-update.rkt @@ -0,0 +1,39 @@ +#lang racket/base + +;; Release-kind acceptance runner (#58 slice 1): runs the release-update +;; suite — signed-feed rejection, download cancellation, the N→N+1 +;; lifecycle, data preservation, and the platform's install arm — and +;; archives evidence under .rivet/acceptance/ on success, the executable +;; record the acceptance contract requires for `release` suites. + +(require json + racket/file + racket/path + racket/system) + +(define platform + (case (system-type 'os) + [(windows) "windows"] + [(macosx) "macos"] + [(unix) "linux"] + [else (error 'taskly-acceptance "unsupported platform")])) + +(define raco + (or (find-executable-path "raco") + (error 'taskly-acceptance "raco was not found"))) + +(unless (system* raco "test" "racket/tests/release-update-test.rkt") + (error 'taskly-acceptance "release-update suite failed")) + +(define evidence-directory + (build-path (current-directory) ".rivet" "acceptance" platform)) +(make-directory* evidence-directory) +(call-with-output-file (build-path evidence-directory "release-update.json") + #:exists 'truncate/replace + (lambda (output) + (write-json + (hash 'platform platform + 'release-update-verified #t + 'source "raco test racket/tests/release-update-test.rkt") + output) + (newline output))) From 269ac7348de134116da55a4464c3dee0a3c51d28 Mon Sep 17 00:00:00 2001 From: turinglambdaai Date: Fri, 23 Oct 2026 10:02:10 +0800 Subject: [PATCH 3/3] fix(acceptance): write release evidence at the declared path The suite passed on the Windows leg (exit 0) but the runner judged it failed: the rktd declares .rivet/acceptance/release-update.json and the runner wrote it under the platform subdirectory. Each platform's acceptance run executes on its own runner, so the single declared path carries that run's record. --- scripts/acceptance/release-update.rkt | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/scripts/acceptance/release-update.rkt b/scripts/acceptance/release-update.rkt index d4712fb..b52ee2c 100644 --- a/scripts/acceptance/release-update.rkt +++ b/scripts/acceptance/release-update.rkt @@ -25,8 +25,11 @@ (unless (system* raco "test" "racket/tests/release-update-test.rkt") (error 'taskly-acceptance "release-update suite failed")) +;; The evidence path is the one declared in rivet-acceptance.rktd — +;; each platform's acceptance run executes on its own runner, so the +;; single path carries that run's record. (define evidence-directory - (build-path (current-directory) ".rivet" "acceptance" platform)) + (build-path (current-directory) ".rivet" "acceptance")) (make-directory* evidence-directory) (call-with-output-file (build-path evidence-directory "release-update.json") #:exists 'truncate/replace