diff --git a/CHANGELOG.md b/CHANGELOG.md index 856b28b..edeaa52 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,23 @@ ## Unreleased +- Close the scribble coverage gap: the RVT1 framing and value-codec surface + (`rivet/protocol` — frames, message-type constants, transport limits, + read/write/encode/decode), application resources (`rivet/resources`), + the diagnostic sink parameter, `install-crash-hook!`, the version + predicates, the Ed25519 key/digest/base64 primitives, canonical manifest + payload helpers, `verify-update-artifact!`, `make-install-plan`, + `current-install-kind`, and the missing `updater?` predicate now have + manual entries. Every public export is covered and the rendered manual + carries no undefined-tag warnings. +- Documentation drift now fails CI instead of rotting silently. The docs + suite derives the current release version from `info.rkt` and rejects a + `docs/ci*.md` guide pinning any other tag, and it checks scribble + coverage in both directions: a public export without a manual mention + fails, and a manual entry for a binding that is no longer exported fails + (defstruct* forms synthesize their constructor, predicate, structure-type + binding, and accessors, mirroring the rendered output). + ## 0.8.1 - Add the macOS `RivetUpdate` Swift module: a shared host-side update client diff --git a/docs/ci.md b/docs/ci.md index 0b57c72..730b01f 100644 --- a/docs/ci.md +++ b/docs/ci.md @@ -22,7 +22,7 @@ release environment. ## Copy-paste GitHub Actions workflow -The following workflow pins Rivet 0.8.0. Update every Rivet package source and +The following workflow pins Rivet 0.8.1. Update every Rivet package source and the Linux setup action to the same tag (or full commit) in one review. A direct Git source is intentional: the Racket Package Catalog has one current entry for a package name and cannot select an older Rivet release. @@ -49,7 +49,7 @@ jobs: distribution: full variant: CS version: '9.3' - - run: raco pkg install --auto --no-docs 'https://github.com/turinglambdaai/rivet.git#v0.8.0' + - run: raco pkg install --auto --no-docs 'https://github.com/turinglambdaai/rivet.git#v0.8.1' - run: raco rivet doctor - run: raco rivet schema check rivet-schema.json - run: raco rivet build @@ -68,7 +68,7 @@ jobs: distribution: full variant: CS version: '9.3' - - run: raco pkg install --auto --no-docs 'https://github.com/turinglambdaai/rivet.git#v0.8.0' + - run: raco pkg install --auto --no-docs 'https://github.com/turinglambdaai/rivet.git#v0.8.1' - run: raco rivet doctor - run: raco rivet schema check rivet-schema.json - run: raco rivet build @@ -78,13 +78,13 @@ jobs: steps: - uses: actions/checkout@v7 - name: Set up embeddable Racket CS - uses: turinglambdaai/rivet/.github/actions/setup-embed-racket@v0.8.0 + uses: turinglambdaai/rivet/.github/actions/setup-embed-racket@v0.8.1 - name: Install GTK4 development files run: | sudo apt-get update sudo apt-get install --yes libgtk-4-dev - name: Install the matching Rivet package and dependencies - run: raco pkg install --auto --no-docs 'https://github.com/turinglambdaai/rivet.git#v0.8.0' + run: raco pkg install --auto --no-docs 'https://github.com/turinglambdaai/rivet.git#v0.8.1' - run: raco rivet doctor - run: raco rivet schema check rivet-schema.json - run: raco rivet build diff --git a/docs/ci.zh-CN.md b/docs/ci.zh-CN.md index 6ce002e..ffb5c5e 100644 --- a/docs/ci.zh-CN.md +++ b/docs/ci.zh-CN.md @@ -19,7 +19,7 @@ CI 应使用与发布任务相同类别的 runner 和工具链: ## 可直接使用的 GitHub Actions 工作流 -下面的工作流固定到 Rivet 0.8.0。升级时,应在同一次评审中把每处 Rivet +下面的工作流固定到 Rivet 0.8.1。升级时,应在同一次评审中把每处 Rivet 包来源和 Linux setup action 更新到同一个标签(或完整 commit)。这里有意 使用 Git 来源:Racket Package Catalog 对每个包名只保留一个当前入口,不能 选择较旧的 Rivet 版本。 @@ -46,7 +46,7 @@ jobs: distribution: full variant: CS version: '9.3' - - run: raco pkg install --auto --no-docs 'https://github.com/turinglambdaai/rivet.git#v0.8.0' + - run: raco pkg install --auto --no-docs 'https://github.com/turinglambdaai/rivet.git#v0.8.1' - run: raco rivet doctor - run: raco rivet schema check rivet-schema.json - run: raco rivet build @@ -65,7 +65,7 @@ jobs: distribution: full variant: CS version: '9.3' - - run: raco pkg install --auto --no-docs 'https://github.com/turinglambdaai/rivet.git#v0.8.0' + - run: raco pkg install --auto --no-docs 'https://github.com/turinglambdaai/rivet.git#v0.8.1' - run: raco rivet doctor - run: raco rivet schema check rivet-schema.json - run: raco rivet build @@ -75,13 +75,13 @@ jobs: steps: - uses: actions/checkout@v7 - name: Set up embeddable Racket CS - uses: turinglambdaai/rivet/.github/actions/setup-embed-racket@v0.8.0 + uses: turinglambdaai/rivet/.github/actions/setup-embed-racket@v0.8.1 - name: Install GTK4 development files run: | sudo apt-get update sudo apt-get install --yes libgtk-4-dev - name: Install the matching Rivet package and dependencies - run: raco pkg install --auto --no-docs 'https://github.com/turinglambdaai/rivet.git#v0.8.0' + run: raco pkg install --auto --no-docs 'https://github.com/turinglambdaai/rivet.git#v0.8.1' - run: raco rivet doctor - run: raco rivet schema check rivet-schema.json - run: raco rivet build diff --git a/rivet/scribblings/rivet.scrbl b/rivet/scribblings/rivet.scrbl index 74a25f8..53c3637 100644 --- a/rivet/scribblings/rivet.scrbl +++ b/rivet/scribblings/rivet.scrbl @@ -4,6 +4,8 @@ racket/contract/base rivet rivet/distribution + rivet/protocol + rivet/resources rivet/system)) @title[#:tag "rivet"]{Rivet: First-Party Native Desktop Applications with Racket} @@ -204,6 +206,14 @@ backpressure.} void?]{ Adapts native file descriptors to binary ports and calls @racket[serve].} +@defparam[current-rivet-diagnostic-sink sink procedure?]{ +Receives each structured diagnostic record emitted by the backend. The +default discards records: embedded desktop processes do not necessarily own +a console, and the first stderr write from a Windows GUI-subsystem process +can allocate a visible console window. Applications that want backend +diagnostics install a sink backed by their structured logger; records use +the @tt{rivet.diagnostic.v1} schema.} + @defproc[(backend-schema) hash?]{Returns one immutable, data-only snapshot with @racket['rpcs], @racket['events], @racket['states], @racket['records], and @racket['enums] entries. This is the supported reflection boundary for code @@ -215,6 +225,104 @@ generation and tooling; registry structs and mutable State cells are private.} @defproc[(record-schema) list?]{Returns the registered Record schema.} @defproc[(enum-schema) list?]{Returns the registered Enum schema.} +@subsection{RVT1 Framing and the Value Codec} + +@defmodule[rivet/protocol] + +Applications normally reach the wire through @racket[serve] and generated +clients. This module is the framing layer underneath: it is public because +debuggers, protocol fuzzers, and alternative transports consume exactly the +same bytes, and because the transport limits are the contract between an +application backend and its host. + +Every frame carries the @tt{RVT1} magic, the protocol version, a message +type, an unsigned 64-bit identifier, and a payload. Payloads use a small +tagged value codec that covers the RVT1 value grammar; framing and the +value codec are independent so later grammar revisions cannot change +transport semantics. + +@defstruct*[frame ([type exact-integer?] + [id exact-nonnegative-integer?] + [payload bytes?])] + #:transparent] { +One wire frame. @racket[type] is one of the @racket[message:]hello +through @racket[message:shutdown] constants, and @racket[id] ranges from +@racket[0] through @math{2^64-1}.} + +@defproc[(write-frame [f frame?] + [out output-port? (current-output-port)]) + void?]{ +Validates the message type, identifier, and payload length, then writes the +frame. Overlong payloads and out-of-range identifiers are rejected before +any bytes reach the port.} + +@defproc[(read-frame [in input-port? (current-input-port)]) + (or/c frame? eof-object?)]{ +Reads exactly one frame, validating its version, type, identifier, and +payload length. Returns @racket[eof] at a clean stream boundary.} + +@defproc[(encode-value [value any/c]) bytes?]{ +Encodes one value using the RVT1 tagged value codec. Depth, node count, and +encoded size are bounded; use @racket[Bytes] instead of huge Lists for bulk +data, because a flat List inside one legal frame can still expand to far +more memory as decoded objects.} + +@defproc[(decode-value [payload bytes?]) any/c]{ +Decodes bytes produced by @racket[encode-value]. Malformed tags, excessive +depth, and excessive node counts are rejected instead of allocating.} + +@defthing[protocol-version exact-nonnegative-integer? #:value 1]{ +The RVT1 protocol version written into every frame and enforced on read.} + +@defthing[max-frame-payload-size exact-nonnegative-integer? #:value 67108864]{ +The largest accepted frame payload: 64 MiB.} + +@defthing[max-value-depth exact-positive-integer? #:value 64]{ +The maximum nesting depth accepted by the value codec.} + +@defthing[max-value-nodes exact-positive-integer? #:value 262144]{ +The maximum number of value nodes, including the root, decoded or encoded +per payload.} + +@defthing[message:hello exact-positive-integer? #:value 1]{ +The host greeting message type.} +@defthing[message:request exact-positive-integer? #:value 2]{ +A client request; the identifier correlates it with a response.} +@defthing[message:response exact-positive-integer? #:value 3]{ +A successful reply to a @racket[message:request].} +@defthing[message:error exact-positive-integer? #:value 4]{ +A failure reply carrying a diagnostic string.} +@defthing[message:event exact-positive-integer? #:value 5]{ +A backend-pushed event.} +@defthing[message:cancel exact-positive-integer? #:value 6]{ +Cancels a pending request by identifier.} +@defthing[message:shutdown exact-positive-integer? #:value 7]{ +Requests orderly backend shutdown.} + +@subsection{Application Resources} + +@defmodule[rivet/resources] + +Locates the read-only application resource directory staged by the build. +The same API answers for development layouts, installed .app bundles, +MSI installs, and Linux prefix layouts, so backends never hardcode +platform resource paths. + +@defparam[current-resource-root root (or/c #f path-string?)]{ +Overrides resource discovery, for hosts with a non-standard layout. When +unset, @racket[resource-root] searches the locations derived from the +current executable.} + +@defproc[(resource-root) path?]{ +Returns the existing resource root, checking a configured or derived +location. Raises when no candidate directory exists, so a mis-staged +application fails at startup instead of at first use.} + +@defproc[(resource-path [piece path-string?] ...) path?]{ +Builds a path inside the resource root. Components must be relative and +must not contain @litchar{.} or @litchar{..} segments; traversal attempts +are rejected.} + @section{Application Identity} @defmodule[rivet/app-info] @@ -304,6 +412,11 @@ Stores a secret through the platform credential service.} @defproc[(secure-store-ref [service string?] [account string?] [default any/c #f]) any/c]{ Reads a secret, returning @racket[default] when no value is present.} @defproc[(secure-store-remove! [service string?] [account string?]) any/c]{Removes a stored secret.} +@defproc[(install-crash-hook! [handler procedure?]) any/c]{ +Installs the platform-native crash handler. When the process fails, the +handler receives a structured crash record; pair it with +@racket[current-rivet-crash-reporter] to route the Racket-side record the +same way.} @subsection{Settings and Diagnostics} @@ -395,6 +508,13 @@ callers cannot bypass the installation invariants.} @defproc[(version? [value any/c]) boolean?]{Recognizes SemVer 2.0 version strings.} @defproc[(version-compare [left string?] [right string?]) (or/c -1 0 1)]{ Compares SemVer precedence; build metadata does not affect the result.} +@defproc[(version=? [left string?] [right string?]) boolean?]{SemVer @math{>=}.} +@defproc[(version>? [left string?] [right string?]) boolean?]{SemVer @math{>}.} @defproc[(valid-channel? [value any/c]) boolean?]{Recognizes @racket['stable], @racket['beta], and @racket['dev].} @defproc[(channel-accepts-version? [channel symbol?] [version string?]) boolean?]{ Checks whether a version is allowed by a release channel.} @@ -430,6 +550,51 @@ authenticate them; update clients should use @racket[verify-signed-manifest].} Verifies the expected key ID and Ed25519 signature over the exact payload bytes before parsing or exposing the manifest.} +@defthing[manifest-schema-version exact-positive-integer? #:value 1]{ +The versioned schema tag carried by every manifest payload.} + +@defproc[(manifest->payload-bytes [manifest update-manifest?]) bytes?]{ +Serializes a manifest into the exact canonical payload bytes a signature +covers. Publishers sign these bytes; there is no second serialization.} + +@defproc[(payload-bytes->manifest [payload bytes?]) update-manifest?]{ +Parses canonical payload bytes into a manifest. The closed schema rejects +unknown fields, trailing JSON values, and contradictory policy, whether or +not the payload was authenticated.} + +@subsection{Key and Digest Primitives} + +The same primitives the updater uses, exposed for release tooling and +tests. Keys are Racket @racket[crypto] key values; readers accept the +standard DER encodings (SubjectPublicKeyInfo, OneAsymmetricKey, +PrivateKeyInfo) and Rivet's textual forms. + +@defproc[(read-ed25519-public-key [path path-string?]) any/c]{ +Reads and validates an Ed25519 public key from a file.} +@defproc[(read-ed25519-private-key [path path-string?]) any/c]{ +Reads and validates an Ed25519 private key from a file.} +@defproc[(bytes->ed25519-public-key [raw bytes?]) any/c]{ +Decodes an embedded public key from bytes, the form release builds embed +in application source.} +@defproc[(ed25519-sign [private-key any/c] [message bytes?]) bytes?]{ +Signs @racket[message] with Ed25519 and returns the 64-byte signature.} +@defproc[(ed25519-verify [public-key any/c] + [message bytes?] + [signature bytes?]) + boolean?]{ +Verifies an Ed25519 signature; @racket[#f] for any malformed signature.} +@defproc[(sha256-bytes [input bytes?]) bytes?]{ +Returns the raw SHA-256 digest of a byte string.} +@defproc[(sha256-file [path path-string?]) bytes?]{ +Returns the raw SHA-256 digest of a file's exact bytes.} +@defproc[(sha256-file/hex [path path-string?]) string?]{ +Returns the lowercase hexadecimal SHA-256 digest of a file, the form signed +manifests carry.} +@defproc[(bytes->base64-string [value bytes?]) string?]{ +Standard base64 encoding, used by signed wrappers and embedded keys.} +@defproc[(base64-string->bytes [value string?]) bytes?]{ +Decodes standard base64, raising on malformed input.} + @subsection{Selecting and Installing an Update} An @racket[updater-config] records the application ID, current version, @@ -464,6 +629,24 @@ Pass @racket[progress-every] to select a byte cadence; @racket[#f] uses the one-percent default. Small cadences are appropriate for an activity watchdog, not direct UI rendering.} +@defproc[(verify-update-artifact! [candidate update-candidate?] [path path-string?]) + path-string?]{ +Checks the downloaded file's byte size and SHA-256 digest against the signed +manifest and returns @racket[path]. Used by custom install flows; +@racket[download-update] already applies it before moving a verified +artifact into place.} + +@defproc[(make-install-plan [candidate update-candidate?] + [downloaded-path path-string?] + [#:backup-path backup-path (or/c #f path-string?) #f] + [#:install install procedure?] + [#:restart restart procedure? void] + [#:rollback rollback procedure? void]) + install-plan?]{ +Builds the plan consumed by @racket[execute-install-plan!] for +product-specific native installers. Rivet's portable adapter constructs its +own plan through @racket[prepare-platform-installation].} + @subsection{Managed Check and Download Lifecycle} @defproc[(current-update-architecture) (or/c 'x64 'arm64)]{ @@ -502,6 +685,9 @@ each check. Unsupported platforms and architectures fail at construction instead of silently selecting the wrong artifact. Installation and restart remain owned by the native host or platform adapter.} +@defproc[(updater? [value any/c]) boolean?]{ +Recognizes an updater created by @racket[make-updater].} + @defproc[(updater-check! [instance updater?]) hash?]{ Synchronously fetches and authenticates the manifest under @racket[check-timeout], selects the matching candidate, and returns a JSON-safe @@ -568,6 +754,17 @@ attempt.} @defproc[(current-update-platform) (or/c 'windows 'macos 'linux)]{ Returns the update-platform symbol for the running operating system.} +@defproc[(current-install-kind [executable (or/c #f path-string?) + (find-system-path 'run-file)]) + (or/c 'portable 'appimage 'package-manager)]{ +Classifies the running installation for artifact selection: AppImage builds +detect their @envvar{APPIMAGE} environment, deb and rpm installations +resolve under @filepath{/opt} or @filepath{/usr}, and everything else is +portable. On Windows and macOS this always reports @racket['portable]. +Pass @racket[#:install-kind] from this procedure to +@racket[select-update] or @racket[make-updater] so a machine never selects +a payload its installation cannot own.} + @defproc[(platform-installer-policy [platform symbol?] [installer symbol?]) (or/c 'portable 'package-manager 'unsupported)]{ diff --git a/tests/documentation.rkt b/tests/documentation.rkt index a767d0b..7b98b64 100644 --- a/tests/documentation.rkt +++ b/tests/documentation.rkt @@ -5,6 +5,7 @@ racket/list racket/path racket/runtime-path + racket/set racket/string) (define-runtime-path repository-root-reference "..") @@ -119,6 +120,16 @@ (find-relative-path repository-root path) expected)))) +;; The CI guide must pin the package version this checkout declares, so a +;; release that forgets the guide turns this suite red instead of shipping +;; instructions for a stale tag. +(define release-version + (second (regexp-match #px"\\(define release-version \"([0-9.]+)\"\\)" + (file->string (build-path repository-root "info.rkt"))))) +(define pinned-tag (format "v~a" release-version)) +(define pinned-package-url + (format "https://github.com/turinglambdaai/rivet.git#v~a" release-version)) + (define ci-guide (build-path repository-root "docs" "ci.md")) (define ci-guide-zh (build-path repository-root "docs" "ci.zh-CN.md")) (for ([expected (in-list '("windows-latest" @@ -132,12 +143,19 @@ (for ([guide (in-list (list ci-guide ci-guide-zh))]) (check-file-contains guide - "https://github.com/turinglambdaai/rivet.git#v0.8.0") - (check-file-contains guide "setup-embed-racket@v0.8.0") + pinned-package-url + (format "docs/ci*.md must pin the current release tag ~a; bump the guide with every release" + pinned-tag)) + (check-file-contains + guide + (format "setup-embed-racket@v~a" release-version) + (format "docs/ci*.md must pin the embeddable-runtime action to ~a" pinned-tag)) (define text (file->string guide)) (check-true (regexp-match? - #px"(?s:linux-host:.*setup-embed-racket@v0[.]8[.]0.*raco pkg install.*rivet[.]git#v0[.]8[.]0)" + (pregexp (format "(?s:linux-host:.*setup-embed-racket@~a.*raco pkg install.*rivet[.]git#~a)" + (regexp-quote pinned-tag) + (regexp-quote pinned-tag))) text) (format "~a must install the pinned package after the embeddable runtime" (find-relative-path repository-root guide)))) @@ -171,3 +189,93 @@ "SetForegroundWindow" "TrackPopupMenu"))]) (check-file-contains system-services-guide expected)) + +;; --- Scribble coverage: every public export documented, no phantom docs --- +;; +;; Documentation rots silently when a new export lands without a manual +;; entry or when a renamed binding leaves stale entries behind. Both +;; directions fail this suite. A name counts as documented when it is +;; mentioned in the scribble source or is synthesized by a @defstruct* +;; form, whose rendered output covers the constructor, predicate, +;; structure-type binding, and every field accessor. + +(define scribble-source + (build-path repository-root "rivet" "scribblings" "rivet.scrbl")) +(define scribble-text (file->string scribble-source)) + +;; rivet/testing is internal test support and deliberately stays out of the +;; user manual. +(define documented-modules + '(rivet rivet/app-info rivet/backend rivet/keel rivet/protocol + rivet/resources rivet/system rivet/distribution)) + +(define ident-chars "A-Za-z0-9_!?<>=/\\+\\-*:.#") +(define (mentioned? name text) + (regexp-match? + (pregexp (format "(?=/\\+\\-*:.#-]+)\\s*\\(\\s*((?:\\[[^]]+\\]\\s*)+)" + scribble-text + #:match-select (lambda (match) (list (list-ref match 1) (list-ref match 2))))) + +(define (defstruct-synthesized-names entry) + (define name (car entry)) + (define field-ids + (map (lambda (field) + (first (string-split + (string-trim (regexp-replace #px"^\\[|\\]$" field "")) " "))) + (regexp-match* #px"\\[[^]]+\\]" (cadr entry)))) + (cons name + (cons (string-append name "?") + (cons (string-append "struct:" name) + (for/list ([field (in-list field-ids)]) + (string-append name "-" field)))))) + +(define synthesized-names (append* (map defstruct-synthesized-names defstruct-entries))) + +(define (module-export-names module-path) + (dynamic-require module-path #f) + (define-values (variables syntax-exports) + (module->exports module-path)) + (define (phase-zero-names exports) + (for*/list ([phase-entry (in-list exports)] + #:when (equal? (car phase-entry) 0) + [export-entry (in-list (cdr phase-entry))]) + (car export-entry))) + (remove-duplicates + (append (phase-zero-names variables) (phase-zero-names syntax-exports)))) + +(define exported-names + (remove-duplicates + (append* (map module-export-names documented-modules)))) + +(for ([name (in-list exported-names)]) + (define as-string (symbol->string name)) + ;; member returns a (truthy) tail list, not #t — assert truthiness, not + ;; boolean identity. + (check-not-false + (or (member as-string synthesized-names) (mentioned? as-string scribble-text)) + (format "public export is not documented in rivet.scrbl: ~a" name))) + +;; Phantom documentation: a documented identifier must still be exported. +(define documented-identifier-rxs + (list #px"@defproc\\[\\(\\s*([A-Za-z0-9!?<>=/\\+\\-*:.#-]+)" + #px"@defthing\\[\\s*([A-Za-z0-9!?<>=/\\+\\-*:.#-]+)" + #px"@defform\\[\\(\\s*([A-Za-z0-9!?<>=/\\+\\-*:.#-]+)" + #px"@defparam\\[\\s*([A-Za-z0-9!?<>=/\\+\\-*:.#-]+)")) +(define documented-identifiers + (remove-duplicates + (append (apply append + (for/list ([rx (in-list documented-identifier-rxs)]) + (regexp-match* rx scribble-text #:match-select cadr))) + synthesized-names))) +(define exported-name-strings (list->set (map symbol->string exported-names))) +(for ([identifier (in-list documented-identifiers)]) + (check-true + (set-member? exported-name-strings identifier) + (format "rivet.scrbl documents ~a, which is not a public export; the manual entry is stale" + identifier)))