Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions .github/workflows/voltage-buzz-gpui-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ on:
- apps/buzz-gpui/scripts/test-package-build.py
- apps/buzz-gpui/scripts/bundle-macos-*
- apps/buzz-gpui/scripts/check-macos-*
- apps/buzz-gpui/scripts/notarize-macos.sh
- apps/buzz-gpui/packaging/macos/**

concurrency:
group: buzz-gpui-release-${{ github.ref }}
Expand Down Expand Up @@ -134,10 +136,56 @@ jobs:
save-if: ${{ github.ref == 'refs/heads/main' }}
# Keep the release build when packaging fails after it.
cache-on-failure: true
# With Voltage's Developer ID in the repository's secrets, the app is
# signed for notarization; without it (pull requests from forks get no
# secrets), ad hoc.
- name: Developer ID keychain
id: signing
env:
P12: ${{ secrets.MACOS_DEVELOPER_ID_P12 }}
P12_PASSWORD: ${{ secrets.MACOS_DEVELOPER_ID_P12_PASSWORD }}
EVENT: ${{ github.event_name }}
run: |
if [ -z "$P12" ]; then
if [ "$EVENT" = workflow_dispatch ]; then
echo "::warning::No Developer ID: this release's macOS images are signed ad hoc and not notarized."
fi
exit 0
fi
keychain="$RUNNER_TEMP/developer-id.keychain-db"
password="$(openssl rand -hex 16)"
security create-keychain -p "$password" "$keychain"
security set-keychain-settings -lut 21600 "$keychain"
security unlock-keychain -p "$password" "$keychain"
printf '%s' "$P12" | base64 --decode >"$RUNNER_TEMP/developer-id.p12"
security import "$RUNNER_TEMP/developer-id.p12" -k "$keychain" \
-P "$P12_PASSWORD" -T /usr/bin/codesign
rm "$RUNNER_TEMP/developer-id.p12"
# codesign may use the key without a prompt.
security set-key-partition-list -S apple-tool:,apple: -s -k "$password" "$keychain" >/dev/null
security list-keychains -d user -s "$keychain" login.keychain-db
identity="$(security find-identity -v -p codesigning "$keychain" \
| awk '/"Developer ID Application: / { print $2; exit }')"
if [ -z "$identity" ]; then
echo "::error::MACOS_DEVELOPER_ID_P12 holds no Developer ID Application identity."
exit 1
fi
echo "MACOS_SIGN_IDENTITY=$identity" >>"$GITHUB_ENV"
echo "signed=true" >>"$GITHUB_OUTPUT"
- run: scripts/package-macos.sh
# The bundle loads only what it carries, and plays media with the
# GStreamer framework moved away.
- run: scripts/check-macos-dmg.sh dist/*.dmg
- name: Notarize
if: steps.signing.outputs.signed == 'true'
env:
APPLE_API_KEY: ${{ secrets.APPLE_API_KEY_P8 }}
APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }}
APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
run: |
export APPLE_API_KEY_PATH="$RUNNER_TEMP/notary-key.p8"
printf '%s\n' "$APPLE_API_KEY" >"$APPLE_API_KEY_PATH"
scripts/notarize-macos.sh dist/*.dmg
- run: ls -l dist/*.dmg
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
Expand Down
29 changes: 24 additions & 5 deletions apps/buzz-gpui/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,15 +37,16 @@ keyring, and they stop when the app quits.

## Laptop: install the app (macOS)

The macOS build is not signed or notarized. It carries the GStreamer
libraries and plugins it plays audio and video with, so nothing else has
to be installed.
The macOS build carries the GStreamer libraries and plugins it plays audio
and video with, so nothing else has to be installed.

1. Open `Buzz-<version>-macos-arm64.dmg` (Apple silicon) or
`Buzz-<version>-macos-x86_64.dmg` (Intel).
2. Drag Buzz to Applications.
3. The first time, right-click Buzz in Applications and choose Open. If
macOS still refuses, run `xattr -dr com.apple.quarantine /Applications/Buzz.app`.
3. A build signed with a Developer ID and notarized opens like any other
app. A build signed ad hoc does not: the first time, right-click Buzz in
Applications and choose Open. If macOS still refuses, run
`xattr -dr com.apple.quarantine /Applications/Buzz.app`.

Build the image on a Mac with `scripts/package-macos.sh`. It writes
`dist/Buzz-<version>-macos-<arch>.dmg` for the Mac's own architecture. It
Expand All @@ -58,6 +59,13 @@ The script copies the libraries and plugins the app uses into `Buzz.app`.
from outside itself and the system, and plays media with the framework
moved aside (it uses `sudo`).

The script signs ad hoc unless `MACOS_SIGN_IDENTITY` names a Developer ID
Application identity in the keychain. With one, it signs for notarization:
hardened runtime, secure timestamps, and the entitlements in
`packaging/macos`. `scripts/notarize-macos.sh <dmg>` then notarizes the
image with an App Store Connect API key, staples the ticket, and checks
that Gatekeeper accepts the app as notarized.

## Laptop: install the app (Windows)

The Windows build is not signed. It carries the GStreamer libraries and
Expand Down Expand Up @@ -101,6 +109,17 @@ The Build workflow makes all of these packages. It runs by hand, for pull
requests that change it, and for `v*` tags, which also attach the packages
to a GitHub release.

Voltage's release workflow signs and notarizes the macOS images when the
repository has these secrets, and warns on a release without them:

| Secret | Value |
|--------|-------|
| `MACOS_DEVELOPER_ID_P12` | The Developer ID Application certificate and its private key, as a base64 `.p12` |
| `MACOS_DEVELOPER_ID_P12_PASSWORD` | The `.p12` password |
| `APPLE_API_KEY_P8` | An App Store Connect API key (`.p8` contents) with the Developer role |
| `APPLE_API_KEY_ID` | That key's ID |
| `APPLE_API_ISSUER` | The issuer ID of the team's API keys |

## Dev server: run `buzzd` (NixOS)

Add the flake as an input and enable the module:
Expand Down
14 changes: 14 additions & 0 deletions apps/buzz-gpui/packaging/macos/buzz-gpui.entitlements
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<!-- The app's executable, under the hardened runtime of a Developer ID build. -->
<plist version="1.0">
<dict>
<!-- Voice notes record from the microphone. -->
<key>com.apple.security.device.audio-input</key>
<true/>
<!-- GStreamer's ORC compiles audio and video kernels into writable,
executable memory it maps without MAP_JIT. -->
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
<true/>
</dict>
</plist>
17 changes: 17 additions & 0 deletions apps/buzz-gpui/packaging/macos/node.entitlements
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<!-- The bundled Node, re-signed with the app: the code-generation
entitlements Node's own release is signed with (tools/osx-entitlements.plist),
without get-task-allow, which notarization refuses, and without those
for loading native addons or DYLD_ variables, which Buzz's plugins do not
use. -->
<plist version="1.0">
<dict>
<key>com.apple.security.cs.allow-jit</key>
<true/>
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
<true/>
<key>com.apple.security.cs.disable-executable-page-protection</key>
<true/>
</dict>
</plist>
42 changes: 29 additions & 13 deletions apps/buzz-gpui/scripts/check-macos-dmg.sh
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
# uses, encodes a voice note and plays it and the test clips (WebM and
# MP3 over HTTP, QuickTime from a file, whose index is at the end), while
# dyld reports loading nothing from outside the bundle and the system.
# A Developer ID build passes the self-check as signed, under the
# hardened runtime, before the trace.
#
# For CI: it moves /Library/Frameworks/GStreamer.framework (with sudo) and
# puts it back when done.
Expand Down Expand Up @@ -91,22 +93,36 @@ for _ in $(seq 50); do
sleep 0.2
done

# A clean environment, except for variables that would pull in the
# GStreamer moved aside if the app honoured them.
log="$work/check.log"
status=0
mkdir "$work/home"
elsewhere="$hidden/Versions/1.0/lib"
env -i HOME="$work/home" TMPDIR="$work" PATH=/usr/bin:/bin \
GST_PLUGIN_PATH="$elsewhere/gstreamer-1.0" \
GST_PLUGIN_SYSTEM_PATH="$elsewhere/gstreamer-1.0" \
GST_PLUGIN_PATH_1_0="$elsewhere/gstreamer-1.0" \
GIO_EXTRA_MODULES="$elsewhere/gio/modules" \
DYLD_PRINT_LIBRARIES=1 \
"$app/Contents/MacOS/buzz-gpui" --gst-check \
"http://127.0.0.1:$port/clip.webm" \
"http://127.0.0.1:$port/ping.mp3" \
"file://$media/clip.mov" >"$log" 2>&1 || status=$?
# The self-check, in a clean environment except for variables that would
# pull in the GStreamer moved aside if the app honoured them, plus any given.
gst_check() {
env -i HOME="$work/home" TMPDIR="$work" PATH=/usr/bin:/bin \
GST_PLUGIN_PATH="$elsewhere/gstreamer-1.0" \
GST_PLUGIN_SYSTEM_PATH="$elsewhere/gstreamer-1.0" \
GST_PLUGIN_PATH_1_0="$elsewhere/gstreamer-1.0" \
GIO_EXTRA_MODULES="$elsewhere/gio/modules" \
"$@" "$app/Contents/MacOS/buzz-gpui" --gst-check \
"http://127.0.0.1:$port/clip.webm" \
"http://127.0.0.1:$port/ping.mp3" \
"file://$media/clip.mov"
}

# A Developer ID build runs under the hardened runtime, where dyld ignores
# DYLD_ variables. It must play media as signed; the images it loads are then
# traced in this copy signed ad hoc, which loads the same ones.
if codesign --display --verbose=1 "$app" 2>&1 | grep -q '^CodeDirectory.*flags=.*runtime'; then
echo "== The signed app, under the hardened runtime"
gst_check || { echo "the self-check exited with $?"; exit 1; }
for file in "$app"/Contents/MacOS/*; do
codesign --force --sign - "$file" 2>/dev/null
done
echo "== The same app, signed ad hoc so that dyld reports what it loads"
fi
status=0
gst_check DYLD_PRINT_LIBRARIES=1 >"$log" 2>&1 || status=$?
grep -v '^dyld\[' "$log"
[ "$status" = 0 ] || { echo "the self-check exited with $status"; exit 1; }

Expand Down
43 changes: 43 additions & 0 deletions apps/buzz-gpui/scripts/notarize-macos.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
#!/usr/bin/env bash
# Notarizes a .dmg that scripts/package-macos.sh signed with a Developer ID,
# staples the ticket to it, and checks that Gatekeeper accepts the image and
# the app inside as notarized, so it opens on another Mac with no warning.
#
# Authenticates with an App Store Connect API key:
# APPLE_API_KEY_PATH the .p8 key file
# APPLE_API_KEY_ID its key ID
# APPLE_API_ISSUER its issuer ID
#
# Usage: scripts/notarize-macos.sh dist/Buzz-<version>-macos-<arch>.dmg
set -euo pipefail
dmg="$1"
work="$(mktemp -d)"
mount="$work/mount"
cleanup() {
hdiutil detach "$mount" >/dev/null 2>&1 || true
rm -rf "$work"
}
trap cleanup EXIT

credentials=(--key "$APPLE_API_KEY_PATH" --key-id "$APPLE_API_KEY_ID" --issuer "$APPLE_API_ISSUER")
# notarytool exits 0 for a rejected submission too; the status says which.
xcrun notarytool submit "$dmg" "${credentials[@]}" --wait --timeout 1h \
--output-format json >"$work/submission.json"
cat "$work/submission.json"
read -r id status < <(python3 -c '
import json, sys
s = json.load(open(sys.argv[1]))
print(s["id"], s["status"])' "$work/submission.json")
if [ "$status" != Accepted ]; then
echo "Notarization ended $status. Apple's log:" >&2
xcrun notarytool log "$id" "${credentials[@]}" >&2 || true
exit 1
fi

xcrun stapler staple "$dmg"
xcrun stapler validate "$dmg"
spctl --assess --type open --context context:primary-signature --verbose=2 "$dmg"
mkdir "$mount"
hdiutil attach -nobrowse -readonly -mountpoint "$mount" "$dmg" >/dev/null
spctl --assess --type execute --verbose=2 "$mount/Buzz.app" 2>&1 | tee "$work/assess"
grep -q 'source=Notarized Developer ID' "$work/assess"
43 changes: 31 additions & 12 deletions apps/buzz-gpui/scripts/package-macos.sh
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
#!/usr/bin/env bash
# Builds an ad-hoc signed Buzz.app and wraps it in a .dmg. Runs on macOS,
# Builds a signed Buzz.app and wraps it in a .dmg. Runs on macOS,
# for the architecture of the machine it runs on. The app carries the
# GStreamer it plays media with, copied from the official GStreamer
# framework (the runtime and development .pkg installers from
Expand All @@ -10,6 +10,12 @@
#
# Usage: scripts/package-macos.sh -> dist/Buzz-<version>-macos-<arch>.dmg
# GSTREAMER_PREFIX overrides where the framework is.
#
# MACOS_SIGN_IDENTITY, when set, is a Developer ID Application identity in
# the keychain (its name or SHA-1). The app and image are then signed with it
# for notarization (scripts/notarize-macos.sh): hardened runtime, a secure
# timestamp, and the entitlements in packaging/macos. Otherwise the
# signature is ad hoc.
set -euo pipefail
node_version=22.23.3
node_sha256_arm64=23b25245dcfb9af7262f8ff142e9e2e0af025368117329e7a7458a51e5922f53
Expand Down Expand Up @@ -107,22 +113,32 @@ for manifest in "$work"/plugins/*/buzz-plugin.json; do
done
rm -rf "$work/node" "$work/plugins" "$node_tarball"

# Ad-hoc signature: Apple Silicon refuses to run unsigned code at all, and
# install_name_tool broke the signatures the copies came with. Each
# library, plugin and helper first, then the app around them (signing the
# main executable signs the app).
# Apple Silicon refuses to run unsigned code at all, and install_name_tool
# broke the signatures the copies came with. Each library, plugin and helper
# first, then the app around them (signing the bundle signs the main
# executable).
identity="${MACOS_SIGN_IDENTITY:--}"
sign() {
local options=(--force --sign "$identity")
if [ "$identity" != - ]; then
options+=(--options runtime --timestamp)
fi
# Quiet about the signatures it replaces, loud about failures.
if ! out="$(codesign "${options[@]}" "$@" 2>&1)"; then
echo "$out" >&2
exit 1
fi
}
find "$app/Contents" -type f ! -path "$app/Contents/MacOS/buzz-gpui" \
\( -name '*.dylib' -o -name '*.so' -o -perm -u+x \) -print0 \
| while IFS= read -r -d '' file; do
if file -b "$file" | grep -q Mach-O; then
# Quiet about the signatures it replaces, loud about failures.
if ! out="$(codesign --force --sign - "$file" 2>&1)"; then
echo "$out" >&2
exit 1
fi
if [ "$file" = "$app/Contents/Resources/node/bin/node" ]; then
sign --entitlements packaging/macos/node.entitlements "$file"
elif file -b "$file" | grep -q Mach-O; then
sign "$file"
fi
done
codesign --force --deep --sign - "$app"
sign --entitlements packaging/macos/buzz-gpui.entitlements "$app"
codesign --verify --deep --strict "$app"

dmg="dist/Buzz-$version-macos-$arch.dmg"
Expand All @@ -134,5 +150,8 @@ rm -rf "$iconset"
size_mb="$(du -sm "$work" | cut -f1)"
hdiutil create -volname Buzz -srcfolder "$work" -size "$((size_mb * 5 / 4 + 20))m" \
-ov -format UDZO "$dmg"
if [ "$identity" != - ]; then
codesign --sign "$identity" --timestamp "$dmg"
fi
rm -rf "$work"
ls -la "$dmg"
Loading