name: release
on:
push:
tags: ["v*"]
workflow_dispatch:
inputs:
tag:
description: "Existing tag to build and attach binaries to"
required: true
draft:
description: "Publish to a DRAFT release (signing-path test only)"
type: boolean
default: false
permissions:
contents: read
jobs:
verify:
runs-on: ubuntu-latest
steps:
- 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
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; }
- 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"
- name: Smoke test
run: ./scripts/smoke.sh "./$ASSET"
- run: shasum -a 256 "$ASSET" > "$ASSET.sha256"
- uses: actions/upload-artifact@v7
with:
name: release-macos
path: |
${{ env.ASSET }}
${{ env.ASSET }}.sha256
if-no-files-found: error
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"
- uses: actions/upload-artifact@v7
with:
name: release-linux
path: |
${{ env.ASSET }}
${{ env.ASSET }}.sha256
if-no-files-found: error
retention-days: 1
windows:
needs: verify
runs-on: windows-latest
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"
- uses: actions/upload-artifact@v7
with:
name: release-windows
path: |
${{ env.ASSET }}
${{ env.ASSET }}.sha256
if-no-files-found: error
retention-days: 1
publish:
needs: [macos, linux, windows]
runs-on: ubuntu-latest
permissions:
contents: write
actions: read
steps:
- 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
- uses: softprops/action-gh-release@efb35369e0ad2afab669f228072c1b0d510eae64 with:
tag_name: ${{ github.event.inputs.tag || github.ref_name }}
draft: ${{ github.event.inputs.draft == 'true' }}
files: assets/*