AtlatestRepositoryslate
1
#!/usr/bin/env sigil2
;;; Build and publish Slate CLI releases. Publishing expects Wrangler's ambient3
;;; authentication; no account identifiers or credentials live here. Every4
;;; command has a dry-run form and publishing always targets staging first.6
(import (sigil core) (sigil fs) (sigil json) (sigil path) (sigil process)7
(sigil shell) (sigil string))9
(define platforms10
'("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-source16
"packages/slate-cli/package.sgl")18
(define release-package-sources19
'("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 " requested41
" 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. Development51
;; redirects are deliberately opt-in and must never leak into a manifest.52
(for-each53
(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-" platform73
(if (string=? platform "windows-amd64") ".exe" "")))75
(define (required-env name)76
(let ((value (getenv name)))77
(if (and value (> (string-length value) 0)) value78
(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: hashes96
signature: #{ algorithm: "ed25519"97
key-id: signing-key-id98
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-append115
"https://codeberg.org/sigil/slate/releases/tag/cli-v" version))116
(key-id (required-env "SLATE_RELEASE_SIGNING_KEY_ID"))117
(encoded (string-append118
(json-encode119
(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-release140
-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-each193
(lambda (platform)194
(validate-artifact! platform195
(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-each212
(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
($ sha256sum220
,@(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-each232
(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
(append249
(map250
(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
(list257
(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-each273
(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-each280
(lambda (platform)281
(say (string-append "REDIRECT /downloads/cli/latest/" platform282
" -> /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-each291
(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-each300
(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)))))