lucida 1.1.0

Generate images and video with Google Gemini, Veo, Runway, Kling, a local ComfyUI, FLUX, Stability AI or OpenAI — a CLI and an MCP server
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
name: release

# Runs on a tag push, and can be dispatched manually to attach binaries to an
# existing release.
on:
  push:
    tags: ["v*"]
  workflow_dispatch:
    inputs:
      tag:
        description: "Existing tag to build and attach binaries to"
        required: true
      # For exercising the signing path end to end without touching anything a
      # user can see. A draft release is invisible to `install.sh`, to users, and
      # to `ci.yml`'s pinned-version test — which derives its pin from the
      # second-newest NON-DRAFT release, so a pre-release here would silently
      # move what that job asserts.
      #
      # ⚠ Use a tag that does NOT begin with `v`. Pushing one that does fires
      # the trigger above and publishes a real release before anyone dispatches
      # anything. `signing-test.1` matches nothing and can only be dispatched.
      # The first step of each job enforces this rather than trusting the note.
      #
      # ⚠ The hazard is the box UNTICKED, not ticked, and it is the default.
      # Read at `softprops/action-gh-release` efb3536, the SHA `@v3` resolved on
      # 2026-09-26: a non-prerelease is always CREATED as a draft
      # (`github.ts:1058`) and un-drafted after upload by `finalizeRelease`,
      # which early-returns only when the input is true (`github.ts:762`). So
      # dispatching with this off, against a tag whose release is currently a
      # draft, PUBLISHES that draft. The update path passes
      # `draft: existingRelease.draft` (`github.ts:710`), so the reverse — a
      # ticked box demoting a published release — is what an earlier version of
      # this comment warned about and does not happen. `@v3` floats, so this is
      # a reading of one SHA rather than a guarantee.
      draft:
        description: "Publish to a DRAFT release (signing-path test only)"
        type: boolean
        default: false

# Read by default. Only `publish` writes a release, and it grants itself
# `contents: write` there — so the jobs that handle the Developer ID certificate
# and the notary key hold a token that cannot publish anything. Narrowing this
# only became possible when the three build jobs stopped publishing.
permissions:
  contents: read

jobs:
  # Nothing ships until the suite has run against the exact commit being tagged.
  #
  # It did not, before: each build job smoke-tested its own binary, and smoke
  # checks are shallow by design — they confirm the thing starts, prints its
  # version, and refuses a bad flag. A tag could therefore publish three
  # platform binaries having never run `cargo test` at all. In practice the
  # release always followed a green CI run on main, which is a habit rather than
  # a gate, and a habit does not survive a hotfix tagged in a hurry.
  verify:
    runs-on: ubuntu-latest
    steps:
      # One enforcement point for the `draft` input's one legitimate use, here
      # rather than in `publish`, because every other job needs this one — so a
      # refusal stops the builds too rather than only the release call, and
      # nothing is left half-run. A prose warning is what this replaces; prose
      # does not refuse.
      - name: A draft dispatch is only for a throwaway tag
        if: github.event.inputs.draft == 'true'
        env:
          TAG: ${{ github.event.inputs.tag }}
        run: |
          set -euo pipefail
          case "$TAG" in
            v*)
              echo "refusing: \"$TAG\" is a release tag, and this input exists only"
              echo "for a throwaway tag. Against a real tag it would rewrite the"
              echo "release for a version users already have."
              exit 1
              ;;
            *) echo "ok: \"$TAG\" is not a release tag" ;;
          esac

      - uses: actions/checkout@v7
        with:
          ref: ${{ github.event.inputs.tag || github.ref }}
      - run: cargo test --locked
      - run: cargo clippy --locked --all-targets -- -D warnings

  macos:
    needs: verify
    # A universal binary rather than two downloads. Apple Silicon runners carry
    # both SDKs, so one job covers Apple Silicon and Intel.
    runs-on: macos-latest
    steps:
      - uses: actions/checkout@v7
        with:
          ref: ${{ github.event.inputs.tag || github.ref }}

      - name: Add both Apple targets
        run: rustup target add aarch64-apple-darwin x86_64-apple-darwin

      - name: Build
        run: |
          cargo build --locked --release --target aarch64-apple-darwin
          cargo build --locked --release --target x86_64-apple-darwin

      - name: Fuse into a universal binary
        env:
          TAG: ${{ github.event.inputs.tag || github.ref_name }}
        run: |
          # Version in the filename, so a downloaded binary is still identifiable
          # months later sitting in ~/Downloads.
          NAME="lucida-${TAG#v}-macos-universal"
          lipo -create -output "$NAME" \
            target/aarch64-apple-darwin/release/lucida \
            target/x86_64-apple-darwin/release/lucida
          lipo -info "$NAME"
          chmod +x "$NAME"
          echo "ASSET=$NAME" >> "$GITHUB_ENV"

      - name: Verify it is genuinely universal
        run: |
          lipo -info "$ASSET" | grep -q arm64 || { echo "no arm64 slice"; exit 1; }
          lipo -info "$ASSET" | grep -q x86_64 || { echo "no x86_64 slice"; exit 1; }

      # Everything from here to the cleanup step signs the asset. It all sits
      # AFTER the fuse above, because `lipo` strips signatures — and BEFORE the
      # smoke test and the checksum below, because signing rewrites the file. A
      # checksum taken ahead of it would be the hash of something nobody
      # downloads. That ordering is the whole design; do not reorder these.
      - name: Import the Developer ID certificate
        env:
          CERT_P12_BASE64: ${{ secrets.APPLE_CERT_P12_BASE64 }}
          CERT_PASSWORD: ${{ secrets.APPLE_CERT_PASSWORD }}
        run: |
          set -euo pipefail
          [ -n "$CERT_P12_BASE64" ] || { echo "APPLE_CERT_P12_BASE64 is empty or unset"; exit 1; }
          [ -n "$CERT_PASSWORD" ] || { echo "APPLE_CERT_PASSWORD is empty or unset"; exit 1; }

          # A keychain of its own, with a password generated here and never
          # stored. The login keychain is untouched and the key lives for one job.
          KEYCHAIN="$RUNNER_TEMP/signing.keychain-db"
          KEYCHAIN_PASSWORD=$(openssl rand -base64 24)
          echo "KEYCHAIN=$KEYCHAIN" >> "$GITHUB_ENV"

          security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN"
          # A six-hour timeout and NO lock-on-sleep, because a notarization wait
          # must not end with the keychain locked underneath it. `-lut 21600`
          # stood here and is `-l -u -t`, where `-l` is "lock keychain when the
          # system sleeps" — the opposite of what this comment claimed.
          security set-keychain-settings -u -t 21600 "$KEYCHAIN"
          security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN"

          echo "$CERT_P12_BASE64" | base64 --decode > "$RUNNER_TEMP/cert.p12"
          # -x imports the key as non-extractable: it can sign here and it cannot
          # be exported back out of this keychain.
          security import "$RUNNER_TEMP/cert.p12" -k "$KEYCHAIN" \
            -P "$CERT_PASSWORD" -T /usr/bin/codesign -x
          rm -f "$RUNNER_TEMP/cert.p12"

          # Without this, codesign blocks on a UI prompt no runner can answer.
          security set-key-partition-list -S apple-tool:,apple:,codesign: \
            -s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN" > /dev/null

          # Add to the search list, keeping what was already there. `codesign`
          # and `find-identity` are both given the keychain path explicitly, so
          # this is not what lets them find the key. It is here for chain
          # building: the Developer ID intermediate is resolved through the
          # search list. Removing it is not a free simplification — it cannot be
          # retested without spending another notarization round.
          security list-keychains -d user -s "$KEYCHAIN" \
            $(security list-keychains -d user | tr -d '"')

          # The identity is READ from the keychain rather than held as a secret.
          # A re-issued certificate then needs one secret replaced instead of two
          # kept in step, and this cannot sign with an identity that is not
          # actually present.
          IDENTITY=$(security find-identity -v -p codesigning "$KEYCHAIN" \
            | awk '/Developer ID Application/ { print $2; exit }')
          [ -n "$IDENTITY" ] || {
            echo "no Developer ID Application identity in the imported certificate:"
            security find-identity -v -p codesigning "$KEYCHAIN"
            exit 1
          }
          echo "IDENTITY=$IDENTITY" >> "$GITHUB_ENV"
          echo "imported $IDENTITY"

      - name: Sign
        run: |
          set -euo pipefail
          # Both flags are required for notarization to accept the submission. No
          # entitlements: a static Rust binary with rustls asks for nothing the
          # hardened runtime withholds.
          #
          # ⚠ `--identifier` is not optional here even though codesign will
          # invent one. Absent it, the identifier is derived from the FILENAME —
          # which carries the version — so `Identifier=lucida-1.0.2-...` would
          # differ on every release. The identifier is part of the Designated
          # Requirement, so anything pinning the DR breaks at each release, and
          # `install.sh` renames the file to `lucida` anyway, leaving a version
          # string in the identity of a file that no longer has one in its name.
          # Changing it later changes the DR for everyone holding a signed copy.
          codesign --force --timestamp --options runtime \
            --identifier io.artificialhumanity.lucida \
            --keychain "$KEYCHAIN" --sign "$IDENTITY" "$ASSET"

          codesign --verify --strict --verbose=2 "$ASSET"

          info=$(codesign -dv --verbose=4 "$ASSET" 2>&1)
          echo "$info"
          # Asserting the hardened-runtime flag here names the cause. Letting
          # Apple reject the upload instead reports it as a notarization failure.
          case "$info" in
            *runtime*) echo "  hardened runtime flag present" ;;
            *) echo "the hardened runtime flag is missing"; exit 1 ;;
          esac

      - name: Notarize
        env:
          API_KEY_P8_BASE64: ${{ secrets.APPLE_API_KEY_P8_BASE64 }}
          API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }}
          API_ISSUER_ID: ${{ secrets.APPLE_API_ISSUER_ID }}
        run: |
          set -euo pipefail
          umask 077
          [ -n "$API_KEY_P8_BASE64" ] || { echo "APPLE_API_KEY_P8_BASE64 is empty or unset"; exit 1; }
          [ -n "$API_KEY_ID" ] || { echo "APPLE_API_KEY_ID is empty or unset"; exit 1; }
          [ -n "$API_ISSUER_ID" ] || { echo "APPLE_API_ISSUER_ID is empty or unset"; exit 1; }

          KEY="$RUNNER_TEMP/notary.p8"
          echo "$API_KEY_P8_BASE64" | base64 --decode > "$KEY"

          # notarytool takes an archive, not a bare Mach-O.
          ditto -c -k --keepParent "$ASSET" "$RUNNER_TEMP/submit.zip"

          # --wait blocks until Apple answers, so nothing is published ahead of
          # its verdict. --timeout bounds that wait, and the bound is deliberately
          # loose: measured 19 s, 20 s, 20 s and 42 m 49 s on the same file, so
          # the spread is entirely Apple's. 45m stood here and sat about two
          # minutes above our own worst case — close enough that a slow notary day
          # would fail a release for no defect, and the retry costs another real
          # submission. Waiting longer publishes nothing early, because --wait
          # holds the job rather than the release.
          set +e
          out=$(xcrun notarytool submit "$RUNNER_TEMP/submit.zip" \
            --key "$KEY" --key-id "$API_KEY_ID" --issuer "$API_ISSUER_ID" \
            --wait --timeout 2h --output-format json)
          rc=$?
          set -e
          echo "$out"

          # ⚠ The exit code is NOT the postcondition: `submit` can exit 0 having
          # returned Invalid. Read the status field. python3 ships on the runner.
          status=$(printf '%s' "$out" | python3 -c 'import json,sys; print(json.load(sys.stdin).get("status",""))')
          id=$(printf '%s' "$out" | python3 -c 'import json,sys; print(json.load(sys.stdin).get("id",""))')

          if [ "$status" != "Accepted" ]; then
            echo "notarization returned \"$status\" (notarytool exit $rc)"
            # The log is the only document that says WHY Apple refused, which is
            # why the key is deleted in the cleanup step rather than this one.
            [ -n "$id" ] && xcrun notarytool log "$id" \
              --key "$KEY" --key-id "$API_KEY_ID" --issuer "$API_ISSUER_ID" || true
            exit 1
          fi
          echo "  notarization Accepted, submission $id"

      - name: The signature is still valid after notarization
        run: |
          set -euo pipefail
          # ⚠⚠ READ THIS BEFORE STRENGTHENING THIS STEP. Two instruments have
          # already been tried here and neither asserts what the step name would
          # most like to say, which is "Apple holds a ticket for these bytes".
          #
          # `spctl -a -t exec` was first and CANNOT answer it: it rejects a bare
          # Mach-O on the KIND of artifact, not on trust — "the code is valid but
          # does not seem to be an app" (run 36202951518, with notarization
          # already Accepted). It assesses app bundles. Do not put it back.
          #
          # `--check-notarization` is documented — "force an online notarization
          # check to see if a notarization ticket is available" — and carries no
          # such restriction, so it is kept. But its output is INDISTINGUISHABLE
          # from a plain `--verify`: in run 36206138279 the two printed
          # byte-identical lines 19 s apart. So it cannot be seen to do anything,
          # and this step asserts only what it can show — that the signature is
          # valid and satisfies its Designated Requirement after notarization.
          #
          # ⚠ THE NOTARIZATION EVIDENCE IS THE STEP ABOVE: notarytool's
          # `Accepted`, which is Apple's own verdict and fails the job closed.
          # `-R="=notarized"` is the folklore alternative and is deliberately not
          # used — the codesign manual documents `-R` and no such requirement
          # string, and an assertion resting on folklore is worse than a narrow
          # one that is true.

          # The control comes first. It proves `--verify --strict` refuses a file
          # it should refuse, and it names the reason it expects — a check that
          # "holds" because of an unrecognised option would otherwise print a
          # reassuring line. It does NOT establish anything about the
          # notarization dimension: --verify alone fails an unsigned file.
          probe="$RUNNER_TEMP/unsigned-probe"
          cp "$ASSET" "$probe"
          codesign --remove-signature "$probe"
          if err=$(codesign --verify --strict --check-notarization "$probe" 2>&1); then
            echo "the check passed an UNSIGNED binary, so it asserts nothing"
            exit 1
          fi
          echo "$err"
          case "$err" in
            *"not signed at all"*) echo "  control held, for the expected reason" ;;
            *) echo "the control failed for an unexpected reason, so it proves nothing"; exit 1 ;;
          esac
          rm -f "$probe"

          codesign --verify --strict --verbose=2 --check-notarization "$ASSET"
          echo "  signature valid, and the online notarization check was attempted"

      - name: Remove the keychain and the notary key
        if: always()
        run: |
          security delete-keychain "${KEYCHAIN:-}" 2>/dev/null || true
          rm -f "$RUNNER_TEMP/notary.p8" "$RUNNER_TEMP/cert.p12" \
            "$RUNNER_TEMP/submit.zip" "$RUNNER_TEMP/unsigned-probe"

      # The smoke test below now runs against the SIGNED binary, which is the
      # point: signing rewrote the file, and this is what proves it still runs.
      - name: Smoke test
        run: ./scripts/smoke.sh "./$ASSET"

      - run: shasum -a 256 "$ASSET" > "$ASSET.sha256"

      # This job does not publish. See the `publish` job for why.
      - uses: actions/upload-artifact@v7
        with:
          name: release-macos
          path: |
            ${{ env.ASSET }}
            ${{ env.ASSET }}.sha256
          # Fails fast and names the job, rather than leaving `publish` to report
          # it as a count that is four instead of six.
          if-no-files-found: error
          # Consumed minutes later by `publish` and useless after that.
          retention-days: 1

  linux:
    needs: verify
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
        with:
          ref: ${{ github.event.inputs.tag || github.ref }}

      - name: Build statically against musl
        env:
          TAG: ${{ github.event.inputs.tag || github.ref_name }}
        run: |
          rustup target add x86_64-unknown-linux-musl
          sudo apt-get update && sudo apt-get install -y musl-tools
          cargo build --locked --release --target x86_64-unknown-linux-musl
          NAME="lucida-${TAG#v}-x86_64-linux-musl"
          cp target/x86_64-unknown-linux-musl/release/lucida "$NAME"
          echo "ASSET=$NAME" >> "$GITHUB_ENV"

      - name: Verify it is genuinely static
        run: |
          # `file` says "static-pie linked" for a musl PIE build, not
          # "statically linked" — matching only the latter is how the original
          # check silently passed while asserting nothing. Confirm with ldd too,
          # which answers the question that actually matters.
          desc=$(file "$ASSET")
          echo "  $desc"
          case "$desc" in
            *"static-pie linked"*|*"statically linked"*) ;;
            *) echo "not a static binary"; exit 1 ;;
          esac
          if ldd "$ASSET" 2>&1 | grep -qE "not a dynamic executable|statically linked"; then
            echo "  ldd confirms no dynamic dependencies"
          else
            echo "unexpected dynamic dependencies:"; ldd "$ASSET"; exit 1
          fi

      - name: Smoke test
        run: ./scripts/smoke.sh "./$ASSET"

      - run: sha256sum "$ASSET" > "$ASSET.sha256"

      # This job does not publish. See the `publish` job for why.
      - uses: actions/upload-artifact@v7
        with:
          name: release-linux
          path: |
            ${{ env.ASSET }}
            ${{ env.ASSET }}.sha256
          # Fails fast and names the job, rather than leaving `publish` to report
          # it as a count that is four instead of six.
          if-no-files-found: error
          # Consumed minutes later by `publish` and useless after that.
          retention-days: 1

  windows:
    needs: verify
    runs-on: windows-latest
    # bash throughout, so the same smoke script runs on all three platforms.
    defaults:
      run:
        shell: bash
    steps:
      - uses: actions/checkout@v7
        with:
          ref: ${{ github.event.inputs.tag || github.ref }}

      - name: Build
        env:
          TAG: ${{ github.event.inputs.tag || github.ref_name }}
        run: |
          cargo build --locked --release --target x86_64-pc-windows-msvc
          NAME="lucida-${TAG#v}-x86_64-windows.exe"
          cp target/x86_64-pc-windows-msvc/release/lucida.exe "$NAME"
          echo "ASSET=$NAME" >> "$GITHUB_ENV"

      - name: Smoke test
        run: ./scripts/smoke.sh "./$ASSET"

      - run: sha256sum "$ASSET" > "$ASSET.sha256"

      # This job does not publish. See the `publish` job for why.
      - uses: actions/upload-artifact@v7
        with:
          name: release-windows
          path: |
            ${{ env.ASSET }}
            ${{ env.ASSET }}.sha256
          # Fails fast and names the job, rather than leaving `publish` to report
          # it as a count that is four instead of six.
          if-no-files-found: error
          # Consumed minutes later by `publish` and useless after that.
          retention-days: 1

  # ONE release call, after all three builds, and the only job that publishes.
  #
  # ⚠ Each build job used to call `action-gh-release` itself, and that is a race
  # rather than three independent uploads. The action creates a release as a
  # draft and un-drafts it once THAT job's files are up, so the first job home
  # published a release carrying only its own assets. Notarization turned a
  # seconds-long window into tens of minutes — measured 19 s and 42 m 49 s on the
  # same file — during which the releases page offered a published version whose
  # macOS asset did not exist yet, and `install.sh` on a Mac answered a 404.
  #
  # The failure mode is better now, not just narrower: if any build fails this
  # job does not run, so NOTHING is published. A release missing an asset was the
  # v0.5.0 failure; no release at all is the loud version of it.
  #
  # It also means the `draft` input has ONE consumer instead of three, so the
  # hazard of a flag reaching one racer and not the others cannot arise.
  publish:
    needs: [macos, linux, windows]
    runs-on: ubuntu-latest
    permissions:
      contents: write
      # For `download-artifact` when a run is RE-RUN rather than run: listing
      # another attempt's artifacts goes through the Actions API, and without
      # this a retry can answer "workflow run not found". The likely path to a
      # re-run here is a notarization timeout.
      actions: read
    steps:
      # `digest-mismatch` defaults to `error`, so a corrupted artifact fails here
      # rather than reaching the releases page.
      - uses: actions/download-artifact@v8
        with:
          pattern: release-*
          merge-multiple: true
          path: assets

      - name: Every platform is here, and each binary is covered by its own checksum
        run: |
          set -euo pipefail
          ls -l assets

          # ONE list of platforms, with the expected count derived from it. Two
          # statements of one fact drift: a fourth platform added to the list
          # without the count fires a misleading "expected 6", and a count bumped
          # without the list leaves the new platform counted but never named.
          set -- macos-universal x86_64-linux-musl x86_64-windows.exe
          expected=$(( $# * 2 ))

          n=$(find assets -type f | wc -l | tr -d ' ')
          [ "$n" -eq "$expected" ] || { echo "expected $expected files, found $n"; exit 1; }

          for p in "$@"; do
            ls assets/*"$p" > /dev/null || { echo "missing the $p binary"; exit 1; }
            ls assets/*"$p".sha256 > /dev/null || { echo "missing the $p checksum"; exit 1; }
          done

          # The checksums are what a user runs to decide whether to trust a
          # download, so each is checked against the bytes being published.
          # `shasum -a 256` on macOS and `sha256sum` elsewhere write one format.
          #
          # ⚠ `sha256sum -c` alone does NOT establish this. It verifies whatever
          # path is written INSIDE the file and never that the file's own name
          # and its content refer to the same binary. Measured: with the macOS
          # `.sha256` made to name the Linux binary, `-c` exits 0 having hashed
          # Linux twice and the tampered macOS binary not at all. Today's
          # generation cannot produce that, so this is a guard against a future
          # edit to a build job — which is exactly when nobody would be looking.
          cd assets
          for f in *.sha256; do
            [ "$(wc -l < "$f")" -eq 1 ] || { echo "$f is not a single-line checksum file"; exit 1; }
            named=$(awk '{ print $2 }' "$f")
            # ⚠ The Windows file names `*lucida-...exe`, with coreutils' binary-mode
            # marker, because `sha256sum` on that runner defaults to binary mode.
            # The published checksums have always carried it and both installers
            # read field 1, the hash, so the artifact format is left alone and the
            # comparison tolerates the marker. Found by this very check on run
            # 36211365813 — the platform difference had gone unnoticed because
            # `sha256sum -c` accepts either form.
            named=${named#\*}
            [ "$named" = "${f%.sha256}" ] || { echo "$f names \"$named\", not \"${f%.sha256}\""; exit 1; }
            sha256sum -c "$f"
          done

      # Pinned to the SHA `@v3` resolved on 2026-09-26, which is the SHA the
      # `draft` reading at the top of this file is based on — so that reading is
      # now a property of what runs rather than of what ran once. This is also
      # the only third-party action here holding `contents: write`.
      - uses: softprops/action-gh-release@efb35369e0ad2afab669f228072c1b0d510eae64 # v3
        with:
          tag_name: ${{ github.event.inputs.tag || github.ref_name }}
          draft: ${{ github.event.inputs.draft == 'true' }}
          files: assets/*