AtlatestRepositoryslate

slate / tree / scriptsrelease.sgl

1#!/usr/bin/env sigil
2;;; Build and publish Slate CLI releases. Publishing expects Wrangler's ambient
3;;; authentication; no account identifiers or credentials live here. Every
4;;; command has a dry-run form and publishing always targets staging first.
5
6(import (sigil core) (sigil fs) (sigil json) (sigil path) (sigil process)
7 (sigil shell) (sigil string))
8
9(define platforms
10 '("linux-amd64" "linux-amd64-static" "linux-arm64"
11 "macos-amd64" "macos-arm64"
12 "openbsd-amd64" "openbsd-arm64"
13 "android-arm64" "windows-amd64"))
15(define cli-version-source
16 "packages/slate-cli/package.sgl")
18(define release-package-sources
19 '("packages/slate/package.sgl"
20 "packages/slate-cli/package.sgl"
21 "packages/slate-netem/package.sgl"
22 "packages/slate-shell/package.sgl"))
24(define (say text)
25 (display text)
26 (display "\n"))
28(define (declared-cli-version)
29 (let* ((marker "\n version: \"")
30 (source (read-file-string cli-version-source))
31 (parts (string-split source marker)))
32 (unless (= (length parts) 2)
33 (error (string-append "cannot read authoritative CLI version from "
34 cli-version-source)))
35 (car (string-split (cadr parts) "\""))))
37(define (require-version! requested)
38 (let ((declared (declared-cli-version)))
39 (unless (string=? requested declared)
40 (error (string-append "requested CLI version " requested
41 " does not match declared version " declared)))))
43(define (require-clean-tracked-checkout!)
44 ;; Deliberately ignore untracked files. They may be unrelated user work.
45 (let ((changes (out git status --porcelain --untracked-files=no)))
46 (unless (= (string-length changes) 0)
47 (error "release builds require a clean tracked checkout"))))
49(define (require-release-dependencies!)
50 ;; Release commands resolve only checked-in remote locks. Development
51 ;; redirects are deliberately opt-in and must never leak into a manifest.
52 (for-each
53 (lambda (path)
54 (let ((source (read-file-string path)))
55 (unless (string-contains? source "sigil: \"0.20.3\"")
56 (error (string-append "release package does not require Sigil 0.20.3: " path)))
57 (when (or (string-contains? source "from-path")
58 (string-contains? source "commit:"))
59 (error (string-append "release package contains a path or raw commit dependency: " path)))))
60 release-package-sources)
61 (let ((lock (read-file-string "sigil.lock")))
62 (unless (and (string-contains? lock "name: \"sigil-websocket\"")
63 (string-contains? lock "ref: \"^0.10.8\"")
64 (string-contains? lock "version: \"0.10.8\"")
65 (string-contains? lock "ref: \"^0.20.3\""))
66 (error "workspace lock does not contain the published release dependency floors"))
67 (when (or (string-contains? lock "from-path")
68 (string-contains? lock "../"))
69 (error "workspace release lock contains a local dependency source"))))
71(define (artifact-name platform)
72 (string-append "slate-" platform
73 (if (string=? platform "windows-amd64") ".exe" "")))
75(define (required-env name)
76 (let ((value (getenv name)))
77 (if (and value (> (string-length value) 0)) value
78 (error (string-append name " is required")))))
80(define (file-sha256 path)
81 (car (string-split (out sha256sum ,path) " ")))
83(define (copy-artifact! platform out)
84 (let* ((source-name (if (string=? platform "windows-amd64")
85 "slate.exe" "slate"))
86 (source (path-join "build" platform "bin" source-name))
87 (target (path-join out (artifact-name platform))))
88 (unless (file-exists? source)
89 (error (string-append "build produced no artifact: " source)))
90 ($ cp ,source ,target)))
92(define (release-manifest version dir public-base notes-url signing-key-id)
93 (let loop ((rest platforms) (urls #{}) (hashes #{}))
94 (if (null? rest)
95 #{ schema: 1 stream: "cli" latest: version platforms: urls sha256: hashes
96 signature: #{ algorithm: "ed25519"
97 key-id: signing-key-id
98 url: (string-append public-base "/v" version ".json.sig") }
99 release-notes-url: notes-url }
100 (let* ((platform (car rest))
101 (name (artifact-name platform))
102 (path (path-join dir name)))
103 (unless (file-exists? path)
104 (error (string-append "missing release artifact: " path)))
105 (loop (cdr rest)
106 (dict-set urls (string->keyword platform)
107 (string-append public-base "/v" version "/" name))
108 (dict-set hashes (string->keyword platform)
109 (file-sha256 path)))))))
111(define (write-manifests! version dir)
112 (let* ((base (or (getenv "SLATE_RELEASE_PUBLIC_BASE")
113 "https://useslate.org/releases/cli"))
114 (notes (string-append
115 "https://codeberg.org/sigil/slate/releases/tag/cli-v" version))
116 (key-id (required-env "SLATE_RELEASE_SIGNING_KEY_ID"))
117 (encoded (string-append
118 (json-encode
119 (release-manifest version dir base notes key-id)) "\n")))
120 (write-file-string (path-join dir (string-append "v" version ".json")) encoded)
121 (write-file-string (path-join dir "latest.json") encoded)))
123(define (sign-manifest! version dir)
124 (let ((key (required-env "SLATE_RELEASE_SIGNING_KEY"))
125 (manifest (path-join dir (string-append "v" version ".json")))
126 (signature (path-join dir (string-append "v" version ".json.sig"))))
127 ;; OpenSSH writes the detached signature beside the input as PATH.sig.
128 ($ ssh-keygen -Y sign -f ,key -n slate-cli-release ,manifest)
129 (unless (file-exists? signature)
130 (error "ssh-keygen produced no detached manifest signature"))))
132(define (verify-manifest-signature! version dir)
133 (let ((allowed (required-env "SLATE_RELEASE_ALLOWED_SIGNERS"))
134 (identity (required-env "SLATE_RELEASE_SIGNING_KEY_ID"))
135 (manifest (path-join dir (string-append "v" version ".json")))
136 (signature (path-join dir (string-append "v" version ".json.sig"))))
137 (unless (file-exists? signature)
138 (error "missing detached manifest signature"))
139 ($ ssh-keygen -Y verify -f ,allowed -I ,identity -n slate-cli-release
140 -s ,signature in: ,(read-file-string manifest))))
142(define (u16-le bytes offset)
143 (+ (bytevector-u8-ref bytes offset)
144 (* 256 (bytevector-u8-ref bytes (+ offset 1)))))
146(define (u32-le bytes offset)
147 (+ (u16-le bytes offset) (* 65536 (u16-le bytes (+ offset 2)))))
149(define (bytes-at? bytes offset expected)
150 (and (<= (+ offset (length expected)) (bytevector-length bytes))
151 (let loop ((rest expected) (i offset))
152 (or (null? rest)
153 (and (= (car rest) (bytevector-u8-ref bytes i))
154 (loop (cdr rest) (+ i 1)))))))
156(define (valid-elf? bytes machine)
157 (and (bytes-at? bytes 0 '(127 69 76 70))
158 (>= (bytevector-length bytes) 20)
159 (= (u16-le bytes 18) machine)))
161(define (valid-mach-o? bytes cpu-type)
162 (and (bytes-at? bytes 0 '(207 250 237 254))
163 (>= (bytevector-length bytes) 8)
164 (= (u32-le bytes 4) cpu-type)))
166(define (valid-pe-amd64? bytes)
167 (and (bytes-at? bytes 0 '(77 90))
168 (>= (bytevector-length bytes) 64)
169 (let ((pe-offset (u32-le bytes 60)))
170 (and (<= (+ pe-offset 6) (bytevector-length bytes))
171 (bytes-at? bytes pe-offset '(80 69 0 0))
172 (= (u16-le bytes (+ pe-offset 4)) 34404)))))
174(define (valid-platform-binary? platform bytes)
175 (cond ((string=? platform "windows-amd64") (valid-pe-amd64? bytes))
176 ((string=? platform "macos-amd64") (valid-mach-o? bytes 16777223))
177 ((string=? platform "macos-arm64") (valid-mach-o? bytes 16777228))
178 ((or (string=? platform "linux-amd64")
179 (string=? platform "linux-amd64-static")
180 (string=? platform "openbsd-amd64"))
181 (valid-elf? bytes 62))
182 (else (valid-elf? bytes 183))))
184(define (validate-artifact! platform path)
185 (unless (file-exists? path)
186 (error (string-append "missing release artifact: " path)))
187 (unless (valid-platform-binary? platform (read-file-bytes path))
188 (error (string-append "wrong executable format or architecture for "
189 platform ": " path))))
191(define (validate-release! version dir)
192 (for-each
193 (lambda (platform)
194 (validate-artifact! platform
195 (path-join dir (artifact-name platform))))
196 platforms)
197 (let ((versioned (path-join dir (string-append "v" version ".json")))
198 (latest (path-join dir "latest.json")))
199 (unless (and (file-exists? versioned) (file-exists? latest))
200 (error "missing release manifests"))
201 (unless (string=? (read-file-string versioned) (read-file-string latest))
202 (error "versioned and latest CLI manifests are not byte-identical"))
203 (verify-manifest-signature! version dir)))
205(define (build! version out)
206 (require-version! version)
207 (require-release-dependencies!)
208 (require-clean-tracked-checkout!)
209 (requires (tools sigil git cp sha256sum ssh-keygen))
210 (ensure-directory out)
211 (for-each
212 (lambda (platform)
213 (display (string-append "Building " platform "\n"))
214 ($ sigil build slate-cli --config ,platform)
215 (copy-artifact! platform out))
216 platforms)
217 (write-manifests! version out)
218 (sign-manifest! version out)
219 ($ sha256sum
220 ,@(map (lambda (platform) (path-join out (artifact-name platform))) platforms)
221 out: ,(path-join out "SHA256SUMS"))
222 (validate-release! version out))
224(define (build-dry-run version out)
225 (require-version! version)
226 (require-release-dependencies!)
227 (say "DRY RUN: no files will be written and no remote calls will be made")
228 (say "preflight: require clean tracked checkout (untracked files ignored)")
229 (say "preflight: published Sigil 0.20.3 and sigil-websocket 0.10.8 dependencies")
230 (say (string-append "preflight: declared CLI version = " version))
231 (for-each
232 (lambda (platform)
233 (say (string-append "build: sigil build slate-cli --config " platform))
234 (say (string-append "artifact: " (path-join out (artifact-name platform)))))
235 platforms)
236 (say (string-append "manifest: " (path-join out (string-append "v" version ".json"))))
237 (say (string-append "manifest: " (path-join out "latest.json") " (byte-identical)"))
238 (say (string-append "signature: ed25519 detached "
239 (path-join out (string-append "v" version ".json.sig"))))
240 (say (string-append "checksums: " (path-join out "SHA256SUMS"))))
242(define (publish-file! bucket key path content-type cache-control)
243 ($ wrangler r2 object put --remote ,(string-append bucket "/" key)
244 --file ,path --content-type ,content-type --cache-control ,cache-control))
246(define (publication-plan version dir prefix)
247 (let ((version-prefix (string-append prefix "/cli/v" version)))
248 (append
249 (map
250 (lambda (platform)
251 (list (string-append version-prefix "/" (artifact-name platform))
252 (path-join dir (artifact-name platform))
253 "application/octet-stream"
254 "public, max-age=31536000, immutable"))
255 platforms)
256 (list
257 (list (string-append prefix "/cli/v" version ".json")
258 (path-join dir (string-append "v" version ".json"))
259 "application/json" "public, max-age=31536000, immutable")
260 (list (string-append prefix "/cli/v" version ".json.sig")
261 (path-join dir (string-append "v" version ".json.sig"))
262 "application/octet-stream" "public, max-age=31536000, immutable")
263 (list (string-append prefix "/cli/latest.json")
264 (path-join dir "latest.json")
265 "application/json" "no-cache")))))
267(define (print-publication-plan version dir)
268 (require-version! version)
269 (let ((prefix (or (getenv "SLATE_RELEASE_STAGING_PREFIX") "staging")))
270 (say "DRY RUN: no files will be written and no remote calls will be made")
271 (say "publication order (latest.json is the visibility switch):")
272 (for-each
273 (lambda (item)
274 (say (string-append "PUT " (car item)
275 " <- " (cadr item)
276 " | Content-Type: " (caddr item)
277 " | Cache-Control: " (cadddr item))))
278 (publication-plan version dir prefix))
279 (for-each
280 (lambda (platform)
281 (say (string-append "REDIRECT /downloads/cli/latest/" platform
282 " -> /releases/cli/v" version "/"
283 (artifact-name platform))))
284 platforms)))
286(define (publish! version dir)
287 (require-version! version)
288 (requires (tools wrangler ssh-keygen))
289 ;; Validate the complete local set before making any remote mutation.
290 (for-each
291 (lambda (platform)
292 (unless (file-exists? (path-join dir (artifact-name platform)))
293 (error (string-append "missing release artifact: " (artifact-name platform)))))
294 platforms)
295 (validate-release! version dir)
296 (let* ((bucket (required-env "SLATE_RELEASE_BUCKET"))
297 (prefix (or (getenv "SLATE_RELEASE_STAGING_PREFIX") "staging")))
298 ;; latest.json is deliberately last: it is the visibility switch.
299 (for-each
300 (lambda (item)
301 (publish-file! bucket (car item) (cadr item) (caddr item) (cadddr item)))
302 (publication-plan version dir prefix))))
304(define (usage)
305 (display "usage: scripts/release.sgl build|build-dry-run|publish|publish-dry-run <version> <dir>\n")
306 (exit 2))
308(let ((args (cddr (command-line))))
309 (if (not (= (length args) 3)) (usage)
310 (cond ((string=? (car args) "build") (build! (cadr args) (caddr args)))
311 ((string=? (car args) "build-dry-run")
312 (build-dry-run (cadr args) (caddr args)))
313 ((string=? (car args) "publish") (publish! (cadr args) (caddr args)))
314 ((string=? (car args) "publish-dry-run")
315 (print-publication-plan (cadr args) (caddr args)))
316 (else (usage)))))