diff --git a/docs/ci.md b/docs/ci.md index 03b11aa..5f5770b 100644 --- a/docs/ci.md +++ b/docs/ci.md @@ -106,6 +106,15 @@ checksum and there is no `raco pkg install rivet@` selector. The standard hold-back form is the tag-pinned Git source shown above. Racket treats the fragment as the Git revision while retaining normal dependency resolution. +Never leave the fragment empty. `...rivet.git#` with a bare `#` does not fail +the install: Racket resolves it to the default branch, so CI compiles against +moving `main` while the workflow believed it was pinned — an undefined +workflow variable has shipped releases built this way. Always pin a tag or a +commit SHA after `#`, and run `raco rivet doctor --release` before publishing; +the release gate fails on empty refs, on tag refs that do not match the +installed rivet release, on stale generated files, and on host source that +calls the update API with keywords the installed rivet does not accept. + Rivet itself additionally launches the generated GTK4 package inside a headless Weston compositor with `GDK_BACKEND=wayland`. This is a native Wayland gate: `DISPLAY` is unset, so the application cannot silently pass by diff --git a/docs/ci.zh-CN.md b/docs/ci.zh-CN.md index 32b4d71..c8d8ab0 100644 --- a/docs/ci.zh-CN.md +++ b/docs/ci.zh-CN.md @@ -100,6 +100,13 @@ checksum,也不存在 `raco pkg install rivet@` 这种历史版本选 标准的版本保留方式就是上面带标签的 Git 来源;Racket 会把片段解释为 Git revision,同时照常解析依赖。 +绝不要让片段留空。`...rivet.git#` 这种裸 `#` 不会让安装失败:Racket 会把它 +解析到默认分支,CI 于是对着移动的 `main` 编译,而工作流却以为自己在用固定 +版本——曾经有过未定义的工作流变量产生空片段、并以此发版的事故。`#` 之后 +永远固定到标签或 commit SHA,并在发布前运行 `raco rivet doctor --release`; +发布门禁会对空片段、与已安装 rivet 版本不符的标签片段、过期的生成文件、 +以及用已安装 rivet 不接受的关键字调用更新 API 的宿主源码报错。 + 对安全要求高的产品,应把 action 标签换成该标签对应的完整 commit SHA,并在 包 URL 片段中使用同一个 commit。该 action 会下载官方 minimal Racket CS 源码归档、校验 SHA-256、按 diff --git a/docs/release-and-updates.md b/docs/release-and-updates.md index c3c9fc4..41ede3b 100644 --- a/docs/release-and-updates.md +++ b/docs/release-and-updates.md @@ -134,6 +134,35 @@ The lower-level `make-install-plan`, `execute-install-plan!`, and `recover-insta Build metadata does not affect version precedence. +## Update API keyword contract + +The hand-written Racket side of the updater is a small, stable surface. These +are the complete keyword contracts; a keyword not listed here is rejected at +runtime, which is exactly how shipped releases have failed on user machines. +`raco rivet doctor --release` scans host source against the installed rivet's +own contracts, so drift fails in CI instead: + +- `fetch-update-manifest` accepts `#:key-id` `#:maximum-bytes` `#:connect-timeout-seconds` `#:idle-timeout-seconds`. +- `select-update` accepts `#:install-kind`. +- `download-update` accepts `#:on-progress` `#:progress-every` `#:stall-timeout-seconds` `#:total-timeout-seconds`. +- `fetch-sibling-asset` accepts `#:sha256` `#:maximum-bytes` `#:on-progress` `#:connect-timeout-seconds` `#:idle-timeout-seconds`. + +Adding a keyword to one of these functions is a compatible addition for the +contract scanner; removing or renaming one is breaking and needs a release +note. + +## Release gate + +Run `raco rivet doctor --release` in release CI before building installers. +One command and one exit code cover: generated client files match a fresh +regeneration (`raco rivet generate --check` runs that single check +standalone), the committed schema baseline accepts the current backend, +hand-written host sources call the update API only with keywords the +installed rivet accepts, and every `rivet.git#...` pin in the repository is +non-empty and consistent with the installed rivet release. A failing gate +means the release would otherwise ship a stale version stamp, a schema +break, or an updater call that fails only on user machines. + ## Host-side updates on macOS The engine API above covers applications whose update flow runs in Racket. Applications whose macOS host owns the whole flow natively should not hand-roll it: the Swift package exposes `RivetUpdate` (`platform/macos`), a shared update client for installed `.app` bundles. It performs the authenticated check against the same signed wrapper the engine verifies (`RivetUpdateService.check`), a bounded delegate-driven artifact download with progress and cancellation (`downloadAndInstall`), SHA-256 and size verification against the signed manifest, `ditto` unpacking, a bundle-version confirmation, and an atomic swap-with-rollback that relaunches the application. diff --git a/rivet-cli/codegen.rkt b/rivet-cli/codegen.rkt index 6fa3b0e..736a0b7 100644 --- a/rivet-cli/codegen.rkt +++ b/rivet-cli/codegen.rkt @@ -11,6 +11,7 @@ "codegen/type-graph.rkt") (provide generate-clients! + check-generated-clients schema-snapshot write-schema-snapshot! check-schema-compatibility!) @@ -20,7 +21,12 @@ (call-with-output-file path #:exists 'truncate/replace (lambda (out) (display content out)))) -(define (generate-clients! project) +;; Computes the full set of generated client files as (path . content) pairs +;; without touching the disk, plus the schema summary generate-clients! +;; returns. generate-clients! writes these pairs; check-generated-clients +;; diffs them against the tree, so the freshness gate and the writer can +;; never disagree about what a fresh tree looks like. +(define (client-outputs project) (define backend (project-path project (project-ref project 'backend))) (define-values (rpcs events states records enums) (load-schema backend #:id (project-identifier project))) @@ -37,41 +43,68 @@ (when (and (null? rpcs) (null? events) (null? states) (null? records) (null? enums)) (error 'generate-clients! "the backend declares no RPCs, Events, shared states, Records, or Enums")) (validate-native-identifiers! rpcs events states records enums device-rpcs) - (parameterize ([current-records records] - [current-enums enums]) - ;; Force dependency ordering first so recursive record graphs fail before - ;; companion-channel Codable analysis attempts to walk them. - (define schema-types (all-types rpcs events states records enums)) - (define codable-types (device-codable-named-types device-rpcs)) - (void schema-types) - (validate-device-rpcs! device-rpcs) - (parameterize ([current-swift-codable-types codable-types]) - (write-generated! - (project-path project "macos-host" "Sources" "RivetHost" "GeneratedBackend.swift") - (generate-swift rpcs events states records enums device-rpcs - module-name entry-name display-name version build - identifier release-channel))) - (write-generated! - (project-path project "windows" "GeneratedBackend.hpp") - (generate-cpp rpcs events states records enums - module-name entry-name display-name version build - identifier release-channel "rivet::windows")) - (define linux-host (project-path project "linux")) - (when (directory-exists? linux-host) - (write-generated! - (build-path linux-host "GeneratedBackend.hpp") - (generate-cpp rpcs events states records enums - module-name entry-name display-name version build - identifier release-channel "rivet::linux_runtime"))) - ;; Android consumes the typed client from the shared generated tree until - ;; generated Compose projects exist; the package layout keeps the file - ;; drop-in for Gradle source sets. - (write-generated! - (project-path project ".rivet" "generated" "kotlin" "dev" "rivet" - "generated" "GeneratedBackend.kt") - (generate-kotlin rpcs events states records enums - module-name entry-name display-name version build - identifier release-channel))) + (define outputs + (parameterize ([current-records records] + [current-enums enums]) + ;; Force dependency ordering first so recursive record graphs fail before + ;; companion-channel Codable analysis attempts to walk them. + (define schema-types (all-types rpcs events states records enums)) + (define codable-types (device-codable-named-types device-rpcs)) + (void schema-types) + (validate-device-rpcs! device-rpcs) + (define pairs + (list + (cons (project-path project "macos-host" "Sources" "RivetHost" "GeneratedBackend.swift") + (parameterize ([current-swift-codable-types codable-types]) + (generate-swift rpcs events states records enums device-rpcs + module-name entry-name display-name version build + identifier release-channel))) + (cons (project-path project "windows" "GeneratedBackend.hpp") + (generate-cpp rpcs events states records enums + module-name entry-name display-name version build + identifier release-channel "rivet::windows")) + ;; Android consumes the typed client from the shared generated tree + ;; until generated Compose projects exist; the package layout keeps + ;; the file drop-in for Gradle source sets. + (cons (project-path project ".rivet" "generated" "kotlin" "dev" "rivet" + "generated" "GeneratedBackend.kt") + (generate-kotlin rpcs events states records enums + module-name entry-name display-name version build + identifier release-channel)))) + (define linux-host (project-path project "linux")) + (if (directory-exists? linux-host) + (cons (cons (build-path linux-host "GeneratedBackend.hpp") + (generate-cpp rpcs events states records enums + module-name entry-name display-name version build + identifier release-channel "rivet::linux_runtime")) + pairs) + pairs))) + (cons outputs (list rpcs states events records enums))) + +(define (generate-clients! project) + (define plan (client-outputs project)) + (for ([pair (in-list (car plan))]) + (write-generated! (car pair) (cdr pair))) ;; Preserve the historical first two result positions for callers that ;; inspect codegen output programmatically; Events, Records, and Enums follow. - (list rpcs states events records enums)) + (cdr plan)) + +;; The release freshness gate (issue #245): regenerate in memory and compare +;; with the tree. Because the regenerated content always carries the current +;; rivet.rktd version/build stamp, a hand-edited version literal or a +;; forgotten regeneration after a version bump shows up as a stale file. +(define (check-generated-clients project) + (define outputs (car (client-outputs project))) + (define fresh '()) + (define stale '()) + (define missing '()) + (for ([pair (in-list outputs)]) + (define path (car pair)) + (cond + [(not (file-exists? path)) (set! missing (cons path missing))] + [(not (string=? (file->string path) (cdr pair))) + (set! stale (cons path stale))] + [else (set! fresh (cons path fresh))])) + (hash 'fresh (reverse fresh) + 'stale (reverse stale) + 'missing (reverse missing))) diff --git a/rivet-cli/doctor.rkt b/rivet-cli/doctor.rkt index fcc0b1f..58f0755 100644 --- a/rivet-cli/doctor.rkt +++ b/rivet-cli/doctor.rkt @@ -2,16 +2,24 @@ (require json racket/file + racket/list racket/path racket/port + racket/string racket/system + "codegen.rkt" "project.rkt" "runtime.rkt" "windows-tools.rkt") (provide doctor-report doctor-remediations - run-doctor) + run-doctor + installed-rivet-release-version + update-api-contract-violations + repository-pin-findings + release-doctor-report + run-release-doctor) (define (path-string value) (and value (path->string value))) @@ -305,3 +313,267 @@ (pair? (hash-ref report 'warnings '()))) (display-remediations report))]) (if (hash-ref report 'usable) 0 1)) + +;; ---- Release gate (issues #245 and #255) ---- +;; +;; Four incident classes the toolchain previously caught only on user +;; machines: (1) generated client files edited by hand or regenerated +;; against a different manifest (stale version stamps, renamed APIs), (2) +;; host source calling rivet distribution functions with keywords the +;; installed rivet does not accept (the payback #:redirections class), and +;; (3) dependency pins with an empty ref (`URL#` silently resolves to the +;; default branch) or a tag that does not match the installed rivet release. +;; `raco rivet doctor --release` runs all of them as one command so release +;; CI can gate on the exit code. + +(define (call-with-source-forms path visit) + ;; Feeds every top-level form of a source file to visit, skipping the + ;; leading #lang reader directive that plain `read` cannot parse. Reading + ;; the rest as data is deliberate: release checks want every datum, + ;; including quoted ones. + (define (scan-forms! in) + (let loop () + (define datum (with-handlers ([exn:fail? (lambda (_) eof)]) + (read in))) + (unless (eof-object? datum) + (visit datum) + (loop)))) + (call-with-input-file path + (lambda (in) + (define first-line (read-line in 'any)) + (if (and (string? first-line) (regexp-match? #px"^#lang\\b" first-line)) + (scan-forms! in) + ;; First line is ordinary source: reopen and scan from the top. + (call-with-input-file path scan-forms!))))) + +(define (read-info-release-version info-path) + (define found (box #f)) + (call-with-source-forms + info-path + (lambda (datum) + (when (and (pair? datum) + (= (length datum) 3) + (eq? (car datum) 'define) + (eq? (cadr datum) 'release-version)) + (set-box! found (caddr datum))))) + (unbox found)) + +(define (installed-rivet-release-version) + ;; Returns #f when rivet itself is not installed as a collection (the + ;; packaged CLI copies omit it); callers treat #f as "cannot check". + ;; collection-file-path resolves a sub-collection directory, so the + ;; package root that owns the release-version declaration is one level up. + (with-handlers ([exn:fail? (lambda (_) #f)]) + (define collection-info (collection-file-path "info.rkt" "rivet")) + (define root-info + (simplify-path + (build-path (path-only collection-info) 'up "info.rkt"))) + (and (file-exists? root-info) (read-info-release-version root-info)))) + +;; The distribution API surface is intentionally small, but hosts call it +;; from hand-written Racket where a stale keyword is a silent runtime failure +;; in the shipped app (five payback releases shipped with a keyword the +;; updater never accepted). Reading the installed function's own keyword +;; contract with procedure-keywords keeps this check honest across rivet +;; versions instead of duplicating the contract as literals here. +(define update-api-names + '(fetch-update-manifest select-update download-update fetch-sibling-asset)) + +(define (update-api-procedures) + (for/hasheq ([name (in-list update-api-names)]) + (values name (dynamic-require 'rivet/distribution name)))) + +(define (collect-files! root excluded-names extension-regex) + ;; Hand-rolled pruning walk: find-files' predicate cannot skip whole + ;; subtrees, and generated trees, build output, and vendored copies are + ;; owned by rivet, not the host, so pruning them matters. + (define files '()) + (let walk! ([dir root]) + (for ([entry (in-list (directory-list dir #:build? #t))]) + (cond + [(directory-exists? entry) + (unless (member (path->string (last (explode-path entry))) excluded-names) + (walk! entry))] + [(and extension-regex + (regexp-match? extension-regex (path->string entry))) + (set! files (cons entry files))]))) + (reverse files)) + +(define (source-files-under root) + (collect-files! root + '(".git" ".rivet" "compiled" "dist" "node_modules" "stage") + #rx"[.]rkt$")) + +(define pin-regex #px"rivet\\.git#([A-Za-z0-9._-]*)") + +(define (text-files-under root) + (collect-files! root + '(".git" ".rivet" "compiled" "dist" "node_modules" "stage") + #rx"[.](yml|yaml|md|rkt|rktd)$")) + +(define (walk-datum! datum visit) + (cond + [(pair? datum) + (visit datum) + (let loop ([rest datum]) + (unless (null? rest) + (walk-datum! (car rest) visit) + (walk-datum! (cdr rest) visit)))] + [(vector? datum) + (for ([item (in-vector datum)]) (walk-datum! item visit))] + [(box? datum) (walk-datum! (unbox datum) visit)] + [(hash? datum) + (for ([(key value) (in-hash datum)]) + (walk-datum! key visit) + (walk-datum! value visit))] + [else (void)])) + +(define (update-api-contract-violations procedures path) + ;; Returns a list of finding strings for one source file. Quoted data is + ;; scanned too: a keyword-shaped datum inside a quoted form is never a + ;; legitimate updater call, so flagging it fails safe rather than silent. + (define violations '()) + (define (visit form) + (when (pair? form) + (define head (car form)) + (when (and (symbol? head) (hash-has-key? procedures head)) + (define proc (hash-ref procedures head)) + (define-values (_required allowed) (procedure-keywords proc)) + (for ([item (in-list (cdr form))] #:when (keyword? item)) + (unless (memq item allowed) + (set! violations + (cons (format "~a: ~a does not accept ~a (installed rivet accepts ~a)" + path head item + (if (null? allowed) + "no keywords" + (string-join (map (lambda (kw) (format "~a" kw)) allowed) ", "))) + violations))))))) + (call-with-source-forms + path + (lambda (datum) (walk-datum! datum visit))) + (reverse violations)) + +(define (scan-rkt-call-keywords! root) + (define procedures (update-api-procedures)) + (append* + (for/list ([path (in-list (source-files-under root))]) + (update-api-contract-violations procedures path)))) + +(define (repository-pin-findings root installed-version) + ;; Empty refs are the autarx v0.4.1 incident: `URL#` resolves to the + ;; default branch, so CI ran a moving rivet while the release believed it + ;; was pinned. Tag-shaped refs must name the installed release; commit + ;; SHAs cannot be compared and are accepted as-is. + (define expected-tag (and installed-version (string-append "v" installed-version))) + (define findings '()) + (for ([path (in-list (text-files-under root))]) + (define text + (with-handlers ([exn:fail? (lambda (_) "")]) + (file->string path))) + (for ([pin-match (in-list (regexp-match* pin-regex text #:match-select cdr))]) + (define ref (list-ref pin-match 0)) + (cond + [(string=? ref "") + (set! findings + (cons (format "~a: dependency pin `rivet.git#` has an empty ref and silently tracks the default branch; pin a tag or commit SHA" + path) + findings))] + [(and expected-tag + (regexp-match? #rx"^v[0-9]" ref) + (not (string=? ref expected-tag))) + (set! findings + (cons (format "~a: dependency pin `rivet.git#~a` does not match the installed rivet release ~a" + path ref installed-version) + findings))]))) + (reverse findings)) + +(define (release-check name thunk) + (with-handlers + ([exn:fail? + (lambda (error) + (hash 'name name 'status "fail" 'detail (exn-message error)))]) + (define detail (thunk)) + (if detail + (hash 'name name 'status "fail" 'detail detail) + (hash 'name name 'status "pass" 'detail #f)))) + +(define (release-doctor-report project) + (define root (rivet-project-root project)) + (define installed-version (installed-rivet-release-version)) + (define checks + (list + ;; Freshness gate: regenerate in memory and compare, which also proves + ;; every generated version/build stamp equals the rivet.rktd manifest. + (release-check + "generated clients are fresh" + (lambda () + (define result (check-generated-clients project)) + (define stale (hash-ref result 'stale)) + (define missing (hash-ref result 'missing)) + (and (or (pair? stale) (pair? missing)) + (string-append + (if (pair? stale) + (format "~a generated file~a differ~a from a fresh regeneration (hand-edited or stale version stamp); run `raco rivet generate`" + (length stale) + (if (= (length stale) 1) "" "s") + (if (= (length stale) 1) "s" "")) + "") + (if (and (pair? stale) (pair? missing)) "; " "") + (if (pair? missing) + (format "~a generated file~a missing; run `raco rivet generate`" + (length missing) + (if (= (length missing) 1) "" "s")) + ""))))) + (release-check + "schema baseline exists and is compatible" + (lambda () + (define baseline (project-path project "rivet-schema.json")) + (cond + [(not (file-exists? baseline)) + "the project has no committed rivet-schema.json baseline; run `raco rivet schema --output rivet-schema.json` and commit it"] + [else + (define report (check-schema-compatibility! project baseline)) + (and (not (hash-ref report 'compatible)) + (string-append + "the backend schema has breaking changes against the committed baseline: " + (string-join + (for/list ([item (in-list (hash-ref report 'breaking-changes))]) + (hash-ref item 'message)) + "; ")))]))) + (release-check + "host sources call the update API with accepted keywords" + (lambda () + (define violations (scan-rkt-call-keywords! root)) + (and (pair? violations) (string-join violations "; ")))) + (release-check + "dependency pins are non-empty and match the installed release" + (lambda () + (define findings (repository-pin-findings root installed-version)) + (and (pair? findings) (string-join findings "; ")))) + (release-check + "installed rivet release is discoverable" + (lambda () + (and (not installed-version) + "could not read the installed rivet package's release version; install rivet as a package (raco pkg install) before releasing"))))) + (hash 'checks checks + 'failed (length (filter (lambda (check) (string=? (hash-ref check 'status) "fail")) checks)))) + +(define (display-release-doctor report) + (printf "Rivet release doctor\n\n") + (for ([check (in-list (hash-ref report 'checks))]) + (printf " ~a ~a~a\n" + (if (string=? (hash-ref check 'status) "pass") "ok " "FAIL") + (hash-ref check 'name) + (let ([detail (hash-ref check 'detail)]) + (if detail (format "\n ~a" detail) "")))) + (newline) + (printf "rivet: release gate ~a\n" + (if (zero? (hash-ref report 'failed)) "passed" "failed"))) + +(define (run-release-doctor) + (define project + (or (find-project) + (error 'rivet "no rivet.rktd found in this directory or its parents"))) + (define report (release-doctor-report project)) + (display-release-doctor report) + (if (zero? (hash-ref report 'failed)) 0 1)) diff --git a/rivet-cli/main.rkt b/rivet-cli/main.rkt index ba07b45..6ccca21 100644 --- a/rivet-cli/main.rkt +++ b/rivet-cli/main.rkt @@ -34,6 +34,8 @@ " raco rivet new create a new Rivet application\n" " raco rivet doctor inspect the local native toolchain\n" " raco rivet doctor --json emit machine-readable toolchain diagnostics\n" + " raco rivet doctor --release run the release gate: generated-file freshness,\n" + " schema baseline, update-API keyword contracts, pins\n" " raco rivet inspect show the current project's edit and verification map\n" " raco rivet inspect --json emit the agent-readable project contract\n" " raco rivet schema --json emit the current versioned API schema\n" @@ -42,6 +44,8 @@ " raco rivet schema check --json emit a machine-readable compatibility report\n" " raco rivet clean remove generated .rivet/build/dist artifacts\n" " raco rivet build compile backend and native host\n" + " raco rivet generate regenerate Swift, C++, and Kotlin clients\n" + " raco rivet generate --check fail if generated clients differ from the tree\n" " raco rivet dev build and run the current app\n" " raco rivet package create and verify a development distributable\n" " raco rivet package --production create, sign, and verify a production distributable\n" @@ -89,6 +93,9 @@ [(list "doctor" "--json") (define run-doctor (command "doctor" 'run-doctor)) (exit (run-doctor #:json? #t))] + [(list "doctor" "--release") + (define run-release-doctor (command "doctor" 'run-release-doctor)) + (exit (run-release-doctor))] [(list "inspect") (define run-inspect (command "inspect" 'run-inspect)) (exit (run-inspect (current-project!)))] @@ -142,6 +149,22 @@ (define generate-clients! (command "codegen" 'generate-clients!)) (generate-clients! (current-project!)) (say "generated Swift, C++, and Kotlin clients")] + [(list "generate" "--check") + ;; CI freshness gate: regenerate in memory and compare with the tree, + ;; so hand-edited generated files or stale version stamps cannot merge. + (define check-generated-clients (command "codegen" 'check-generated-clients)) + (define result (check-generated-clients (current-project!))) + (define stale (hash-ref result 'stale)) + (define missing (hash-ref result 'missing)) + (for ([path (in-list stale)]) + (say "stale: ~a (differs from a fresh regeneration)" path)) + (for ([path (in-list missing)]) + (say "missing: ~a" path)) + (if (and (null? stale) (null? missing)) + (say "generated clients are fresh") + (begin + (say "run `raco rivet generate` to refresh the generated files") + (exit 1)))] [(list "build") (define build-project! (command "build" 'build-project!)) (define output (build-project! (current-project!))) diff --git a/rivet/tests/codegen.rkt b/rivet/tests/codegen.rkt index 24813d1..3db3f8c 100644 --- a/rivet/tests/codegen.rkt +++ b/rivet/tests/codegen.rkt @@ -144,6 +144,36 @@ RKT (check-equal? (length (second schema)) 1) (check-equal? (length (third schema)) 1) + ;; Release freshness gate (issue #245): a fresh regeneration matches the + ;; tree; a hand-edited generated file or a stale version stamp is reported + ;; as stale, and a deleted file as missing. Running the writer repairs it + ;; because both sides share one regeneration. + (define fresh-report (check-generated-clients project)) + (check-equal? (hash-ref fresh-report 'stale) '()) + (check-equal? (hash-ref fresh-report 'missing) '()) + (check-true (pair? (hash-ref fresh-report 'fresh))) + + (define windows-header + (build-path project-root "windows" "GeneratedBackend.hpp")) + (call-with-output-file windows-header + #:exists 'truncate + (lambda (out) (display "// hand-edited stamp\n" out))) + (define stale-report (check-generated-clients project)) + (check-equal? (hash-ref stale-report 'stale) (list windows-header)) + + (define kotlin-generated + (build-path project-root ".rivet" "generated" "kotlin" "dev" "rivet" + "generated" "GeneratedBackend.kt")) + (delete-file kotlin-generated) + (define missing-report (check-generated-clients project)) + (check-equal? (hash-ref missing-report 'missing) (list kotlin-generated)) + (check-equal? (hash-ref missing-report 'stale) (list windows-header)) + + (void (generate-clients! project)) + (define repaired-report (check-generated-clients project)) + (check-equal? (hash-ref repaired-report 'stale) '()) + (check-equal? (hash-ref repaired-report 'missing) '()) + (define swift (file->string (build-path project-root diff --git a/rivet/tests/doctor.rkt b/rivet/tests/doctor.rkt index dcb1b4f..9ccdb57 100644 --- a/rivet/tests/doctor.rkt +++ b/rivet/tests/doctor.rkt @@ -1,9 +1,13 @@ #lang racket/base (require json + racket/file racket/port rackunit - "../../rivet-cli/doctor.rkt") + "../../rivet-cli/codegen.rkt" + "../../rivet-cli/doctor.rkt" + "../../rivet-cli/project.rkt" + "../../rivet-cli/scaffold.rkt") (define report (doctor-report)) @@ -105,3 +109,134 @@ (check-equal? (hash-ref decoded 'supported) (hash-ref report 'supported)) (check-equal? (hash-ref decoded 'usable) (hash-ref report 'usable)) (check-equal? (hash-ref decoded 'racket-version) (hash-ref report 'racket-version)) + +;; ---- Release gate (issues #245 and #255) ---- +;; +;; The gate is exercised against a real scaffolded project: fresh generated +;; files and honest update-API calls must pass; a hand-edited generated +;; file, a drifted keyword, and an empty pin ref must each fail with a +;; finding that names the cause. + +(define (release-check-named report name) + (for/first ([check (in-list (hash-ref report 'checks))] + #:when (string=? (hash-ref check 'name) name)) + check)) + +(define (release-check-passed? report name) + (define check (release-check-named report name)) + (check-true (and check (string=? (hash-ref check 'status) "pass")) + (format "~a did not pass: ~a" + name (and check (hash-ref check 'detail))))) + +(define (release-check-failed? report name expected-rx) + (define check (release-check-named report name)) + (check-true (and check (string=? (hash-ref check 'status) "fail")) + (format "~a did not fail" name)) + (when check + (check-true (regexp-match? expected-rx (or (hash-ref check 'detail) "")) + (format "~a failed with unexpected detail: ~a" + name (hash-ref check 'detail))))) + +(define temp-gate-root (make-temporary-file "rivet-release-gate-~a" 'directory)) +(dynamic-wind + void + (lambda () + (define project-root (create-project! "gate" temp-gate-root)) + (define project (load-project project-root)) + (write-schema-snapshot! project "rivet-schema.json") + (generate-clients! project) + ;; A stray good-shaped updater call and a workflow pin keep the green + ;; path realistic: the scanner and pin check run on every report. + (call-with-output-file + (build-path project-root "updater-usage.rkt") + #:exists 'truncate + (lambda (out) + (displayln #<string + (build-path repository-root "docs" "release-and-updates.md"))) + +(define contract-doc-line-rx + #px"^\\s*-\\s+`([a-z0-9-]+)` accepts((?: `#:[a-z0-9-]+`)*)[.]\\s*$") + +(define documented-contracts + (for/hash ([line (in-list (string-split contract-doc-text "\n"))] + #:when (regexp-match? contract-doc-line-rx line)) + (define match (regexp-match contract-doc-line-rx line)) + (define name (string->symbol (list-ref match 1))) + (define keywords + (map (lambda (token) (string->symbol (substring token 2))) + (regexp-match* #px"`(#[a-z0-9:-]+)`" (list-ref match 2) + #:match-select cadr))) + (values name keywords))) + +(define contract-procedures + '(fetch-update-manifest select-update download-update fetch-sibling-asset)) + +(for ([name (in-list contract-procedures)]) + (define proc + (dynamic-require 'rivet/distribution name)) + (define-values (_required allowed-keywords) (procedure-keywords proc)) + ;; procedure-keywords returns keyword objects; the documentation bullets + ;; compare as symbols for readability. + (define allowed + (map (lambda (kw) (string->symbol (keyword->string kw))) allowed-keywords)) + (check-true + (hash-has-key? documented-contracts name) + (format "docs/release-and-updates.md has no keyword-contract bullet for ~a" name)) + (when (hash-has-key? documented-contracts name) + ;; Keyword order is not part of the contract; compare as sets. + (check-equal? + (sort (hash-ref documented-contracts name) symbol