Skip to content
Merged
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
81 changes: 73 additions & 8 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@ name: Release
# APPLE_CERTIFICATE_PASSWORD, APPLE_SIGNING_IDENTITY, APPLE_TEAM_ID and for notarization either an App Store Connect
# API key (APPLE_API_ISSUER, APPLE_API_KEY = key id, APPLE_API_KEY_P8 = contents of the .p8) or APPLE_ID + APPLE_PASSWORD.
#
# Official releases fail instead of shipping anything unsigned: the DMGs, the apps inside them, the update bundles and
# the standalone macOS binaries are all checked with scripts/macos-verify.sh, and verify-downloads.yml re-checks what
# usegodmode.com serves every morning (it opens an issue when Gatekeeper would refuse a download).
#
# Required secrets for auto-update: TAURI_SIGNING_PRIVATE_KEY + TAURI_SIGNING_PRIVATE_KEY_PASSWORD (the key matching
# `plugins.updater.pubkey` in tauri.conf.json). They sign the update bundles and add latest.json to the release;
# installed apps pick the update up once the release is published.
Expand Down Expand Up @@ -91,6 +95,41 @@ jobs:
exit 1
fi

- name: Check the Apple signing setup
if: github.repository == 'codextde/godmode-bot'
env:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }}
APPLE_API_KEY_P8: ${{ secrets.APPLE_API_KEY_P8 }}
run: |
# An official release is never shipped unsigned: macOS would only offer to move it to the Trash.
missing=0
for name in APPLE_CERTIFICATE APPLE_CERTIFICATE_PASSWORD APPLE_SIGNING_IDENTITY APPLE_TEAM_ID APPLE_API_ISSUER APPLE_API_KEY APPLE_API_KEY_P8; do
if [ -z "${!name}" ]; then echo "::error::the $name secret is missing (see the top of release.yml)"; missing=1; fi
done
[ "$missing" = 0 ] || exit 1
[[ "$APPLE_SIGNING_IDENTITY" == "Developer ID Application: "*"($APPLE_TEAM_ID)" || "$APPLE_SIGNING_IDENTITY" =~ ^[0-9A-Fa-f]{40}$ ]] ||
{ echo "::error::APPLE_SIGNING_IDENTITY must be a Developer ID Application identity of team $APPLE_TEAM_ID"; exit 1; }
printf '%s' "$APPLE_CERTIFICATE" | base64 --decode > "$RUNNER_TEMP/cert.p12"
export PW="$APPLE_CERTIFICATE_PASSWORD"
openssl pkcs12 -in "$RUNNER_TEMP/cert.p12" -passin env:PW -nokeys -clcerts -out "$RUNNER_TEMP/cert.pem" 2>/dev/null ||
openssl pkcs12 -legacy -in "$RUNNER_TEMP/cert.p12" -passin env:PW -nokeys -clcerts -out "$RUNNER_TEMP/cert.pem" ||
{ echo "::error::APPLE_CERTIFICATE can't be opened with APPLE_CERTIFICATE_PASSWORD"; exit 1; }
subject=$(openssl x509 -in "$RUNNER_TEMP/cert.pem" -noout -subject)
until=$(openssl x509 -in "$RUNNER_TEMP/cert.pem" -noout -enddate | cut -d= -f2)
rm -f "$RUNNER_TEMP/cert.p12"
[[ "$subject" == *"$APPLE_TEAM_ID"* ]] || { echo "::error::APPLE_CERTIFICATE belongs to another team: $subject"; exit 1; }
openssl x509 -in "$RUNNER_TEMP/cert.pem" -noout -checkend 0 >/dev/null ||
{ echo "::error::the Developer ID certificate expired on $until: create a new one on developer.apple.com and update APPLE_CERTIFICATE"; exit 1; }
openssl x509 -in "$RUNNER_TEMP/cert.pem" -noout -checkend $((45 * 86400)) >/dev/null ||
echo "::warning::the Developer ID certificate expires on $until: renew it before then"
rm -f "$RUNNER_TEMP/cert.pem"
echo "Signing as $APPLE_SIGNING_IDENTITY, certificate valid until $until"

- name: Create (or reuse) the draft release
id: release
env:
Expand Down Expand Up @@ -201,7 +240,11 @@ jobs:
printf '%s\n' "$APPLE_API_KEY_P8" > "$key_path"
echo "APPLE_API_KEY_PATH=$key_path" >> "$GITHUB_ENV"
fi
if [ -z "$APPLE_CERTIFICATE" ]; then
if [ -z "$APPLE_CERTIFICATE" ] || [ -z "$APPLE_API_KEY_P8" ]; then
if [ "$GITHUB_REPOSITORY" = "codextde/godmode-bot" ]; then
echo "::error::the Apple signing secrets are missing: an unsigned app can only be moved to the Trash on a Mac"
exit 1
fi
echo "::warning::macOS build is not signed with a Developer ID (APPLE_CERTIFICATE missing): Gatekeeper will block it"
fi

Expand Down Expand Up @@ -236,11 +279,9 @@ jobs:
- name: Check the macOS signature and notarization
if: runner.os == 'macOS' && env.APPLE_CERTIFICATE != ''
run: |
app=$(ls -d apps/desktop/src-tauri/target/${{ matrix.rust_target }}/release/bundle/macos/*.app | head -n1)
codesign --verify --deep --strict --verbose=2 "$app"
codesign -dvv "$app" 2>&1 | grep -E "Authority=Developer ID Application|TeamIdentifier"
xcrun stapler validate "$app"
spctl --assess --type execute --verbose=2 "$app"
bundle=apps/desktop/src-tauri/target/${{ matrix.rust_target }}/release/bundle
apps=("$bundle"/macos/*.app) updates=("$bundle"/macos/*.app.tar.gz)
scripts/macos-verify.sh "${apps[0]}" "${updates[0]}"

# The bundler notarizes the .app only; a downloaded DMG that is signed but not notarized can still warn on open.
- name: Notarize the DMG and replace it in the release
Expand All @@ -252,7 +293,8 @@ jobs:
dmg=$(ls apps/desktop/src-tauri/target/${{ matrix.rust_target }}/release/bundle/dmg/*.dmg | head -n1)
xcrun notarytool submit "$dmg" --key "$APPLE_API_KEY_PATH" --key-id "$APPLE_API_KEY" --issuer "$APPLE_API_ISSUER" --wait --timeout 30m
xcrun stapler staple "$dmg"
spctl --assess --type open --context context:primary-signature --verbose=2 "$dmg"
# What a user downloads: the DMG and the app inside it, as Gatekeeper sees them.
scripts/macos-verify.sh "$dmg"
# GitHub stores spaces in asset names as dots.
name=$(basename "$dmg" | tr ' ' '.')
old=$(gh api "repos/$GITHUB_REPOSITORY/releases/$RELEASE_ID/assets?per_page=100" --jq ".[] | select(.name == \"$name\") | .id")
Expand Down Expand Up @@ -314,7 +356,7 @@ jobs:
fail-fast: false
matrix:
include:
# Built on Linux: Linux + Windows. macOS binaries are built (and signed ad hoc) on macOS.
# Built on Linux: Linux + Windows. macOS binaries are built, signed and notarized on macOS.
- os: ubuntu-latest
targets: bun-linux-x64 bun-linux-x64-baseline bun-linux-arm64 bun-windows-x64
- os: macos-latest
Expand All @@ -340,6 +382,29 @@ jobs:
bun run scripts/build.ts --target="$target" --smoke
done

- name: Sign and notarize the macOS binaries
if: runner.os == 'macOS'
env:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }}
APPLE_API_KEY_P8: ${{ secrets.APPLE_API_KEY_P8 }}
run: |
# Ad-hoc signed binaries downloaded in a browser can only be moved to the Trash.
if [ -z "$APPLE_CERTIFICATE" ] || [ -z "$APPLE_API_KEY_P8" ]; then
if [ "$GITHUB_REPOSITORY" = "codextde/godmode-bot" ]; then echo "::error::the Apple signing secrets are missing"; exit 1; fi
echo "::warning::macOS binaries stay ad-hoc signed (no Apple signing secrets)"
exit 0
fi
export APPLE_API_KEY_PATH="$RUNNER_TEMP/AuthKey_$APPLE_API_KEY.p8"
printf '%s\n' "$APPLE_API_KEY_P8" > "$APPLE_API_KEY_PATH"
scripts/macos-sign-binaries.sh packages/core/bin/godmode-darwin-*
packages/core/bin/godmode-darwin-arm64 version
scripts/macos-verify.sh packages/core/bin/godmode-darwin-*

- name: Upload to the draft release
working-directory: packages/core/bin
env:
Expand Down
65 changes: 65 additions & 0 deletions .github/workflows/verify-downloads.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
name: Verify macOS downloads

# What a Mac user gets from usegodmode.com and the in-app updater must open without a Gatekeeper prompt:
# Developer ID Application: Codext GmbH, notarized, stapled. Runs every morning and after each published release;
# a failure opens (or updates) an issue so a broken signature never stays unnoticed.

on:
schedule:
- cron: "17 5 * * *"
release:
types: [published]
workflow_dispatch:

permissions:
contents: read
issues: write

jobs:
verify:
name: Gatekeeper check
runs-on: macos-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v7

- name: Download what users download
run: |
mkdir -p "$RUNNER_TEMP/dl" && cd "$RUNNER_TEMP/dl"
# The website picks the latest published release; a release event may beat its five-minute cache.
if [ "${{ github.event_name }}" = release ]; then sleep 360; fi
for platform in mac mac-intel; do
curl -fsSL --retry 3 -o "$platform.dmg" "https://usegodmode.com/download/$platform"
done
for platform in server-macos-arm64 server-macos-x64; do
curl -fsSL --retry 3 -o "godmode-$platform" "https://usegodmode.com/download/$platform"
chmod +x "godmode-$platform"
done
curl -fsSL --retry 3 -o latest.json "https://usegodmode.com/api/update/latest.json" ||
gh release download --repo "$GITHUB_REPOSITORY" --pattern latest.json --clobber
for arch in darwin-aarch64 darwin-x86_64; do
url=$(jq -r --arg p "$arch" '.platforms[$p].url' latest.json)
curl -fsSL --retry 3 -o "update-$arch.app.tar.gz" "$url"
done
# Mark everything as downloaded by a browser, like a user's copy.
for f in *; do xattr -w com.apple.quarantine "0081;$(printf '%x' "$(date +%s)");Safari;" "$f"; done
ls -la
env:
GH_TOKEN: ${{ github.token }}

- name: Check signatures and notarization
run: scripts/macos-verify.sh "$RUNNER_TEMP"/dl/*.dmg "$RUNNER_TEMP"/dl/*.app.tar.gz "$RUNNER_TEMP"/dl/godmode-server-*

- name: Open an issue when a download is broken
if: failure() && github.event_name != 'workflow_dispatch'
env:
GH_TOKEN: ${{ github.token }}
run: |
title="macOS downloads fail the Gatekeeper check"
body="The [Verify macOS downloads]($GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID) run found a download that macOS would refuse to open. Check the log, fix the signing (see the top of release.yml) and re-run the release."
number=$(gh issue list --repo "$GITHUB_REPOSITORY" --state open --search "\"$title\" in:title" --json number --jq '.[0].number // empty')
if [ -n "$number" ]; then
gh issue comment "$number" --repo "$GITHUB_REPOSITORY" --body "$body"
else
gh issue create --repo "$GITHUB_REPOSITORY" --title "$title" --body "$body"
fi
57 changes: 57 additions & 0 deletions scripts/macos-sign-binaries.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
#!/usr/bin/env bash
# Signs standalone macOS binaries with the Developer ID (hardened runtime, timestamp) and notarizes them, so a
# browser download opens without "can't be opened, move to the Trash". A bare binary can't carry a stapled ticket:
# Gatekeeper looks the notarization up online.
#
# scripts/macos-sign-binaries.sh <binary>...
#
# Env: APPLE_CERTIFICATE (base64 .p12), APPLE_CERTIFICATE_PASSWORD, APPLE_SIGNING_IDENTITY,
# APPLE_API_KEY_PATH (.p8), APPLE_API_KEY (key id), APPLE_API_ISSUER.
set -euo pipefail

for name in APPLE_CERTIFICATE APPLE_CERTIFICATE_PASSWORD APPLE_SIGNING_IDENTITY APPLE_API_KEY_PATH APPLE_API_KEY APPLE_API_ISSUER; do
[ -n "${!name:-}" ] || { echo "::error::$name is not set"; exit 1; }
done

here="$(cd "$(dirname "$0")" && pwd)"
work="$(mktemp -d)"
keychain="$work/signing.keychain-db"
pass="$(openssl rand -hex 16)"
old_keychains=()
while IFS= read -r line; do
line="${line#"${line%%[![:space:]]*}"}"
line="${line#\"}"
line="${line%\"}"
[ -n "$line" ] && old_keychains+=("$line")
done < <(security list-keychains -d user)
cleanup() {
security list-keychains -d user -s "${old_keychains[@]}" >/dev/null 2>&1 || true
security delete-keychain "$keychain" >/dev/null 2>&1 || true
rm -rf "$work"
}
trap cleanup EXIT

printf '%s' "$APPLE_CERTIFICATE" | base64 --decode > "$work/cert.p12"
security create-keychain -p "$pass" "$keychain"
security set-keychain-settings -lut 3600 "$keychain"
security unlock-keychain -p "$pass" "$keychain"
security import "$work/cert.p12" -k "$keychain" -P "$APPLE_CERTIFICATE_PASSWORD" -T /usr/bin/codesign >/dev/null
security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$pass" "$keychain" >/dev/null
security list-keychains -d user -s "$keychain" "${old_keychains[@]}"

for bin in "$@"; do
codesign --force --options runtime --timestamp --entitlements "$here/macos/binary.entitlements" \
--keychain "$keychain" --sign "$APPLE_SIGNING_IDENTITY" "$bin"
zip="$work/$(basename "$bin").zip"
ditto -c -k --keepParent "$bin" "$zip"
result="$(xcrun notarytool submit "$zip" --key "$APPLE_API_KEY_PATH" --key-id "$APPLE_API_KEY" --issuer "$APPLE_API_ISSUER" \
--wait --timeout 30m --output-format json)"
status="$(plutil -extract status raw -o - - <<<"$result" 2>/dev/null || echo unknown)"
if [ "$status" != "Accepted" ]; then
id="$(plutil -extract id raw -o - - <<<"$result" 2>/dev/null || true)"
echo "::error::notarization of $bin: $status"
[ -n "$id" ] && xcrun notarytool log "$id" --key "$APPLE_API_KEY_PATH" --key-id "$APPLE_API_KEY" --issuer "$APPLE_API_ISSUER" || true
exit 1
fi
echo "signed and notarized: $bin"
done
120 changes: 120 additions & 0 deletions scripts/macos-verify.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
#!/usr/bin/env bash
# Fails unless every given macOS download opens without a Gatekeeper prompt: signed with the Developer ID of
# Codext GmbH, hardened runtime, secure timestamp, and (apps and DMGs) notarized with a stapled ticket.
#
# scripts/macos-verify.sh <file.dmg | file.app | file.app.tar.gz | binary>...
#
# APPLE_TEAM_ID (default STSYBH724K) is the team the signature must belong to; CERT_WARN_DAYS (default 45) warns
# when the signing certificate runs out soon, and the check fails once it has.
set -euo pipefail

TEAM="${APPLE_TEAM_ID:-STSYBH724K}"
WARN_DAYS="${CERT_WARN_DAYS:-45}"
WORK="$(mktemp -d)"
MOUNTS=()
cleanup() {
for m in "${MOUNTS[@]:-}"; do [ -n "$m" ] && hdiutil detach "$m" -quiet -force 2>/dev/null || true; done
rm -rf "$WORK"
}
trap cleanup EXIT

fail() {
echo "::error::$*"
exit 1
}

signer() {
local path="$1" kind="${2:-code}" info
info="$(codesign -dvv "$path" 2>&1)" || fail "$path is not signed"
grep -q "^Authority=Developer ID Application: .*($TEAM)$" <<<"$info" ||
fail "$path is not signed with a Developer ID of team $TEAM ($(grep -m1 -E '^(Authority|Signature)=' <<<"$info" || echo unsigned))"
grep -q "^TeamIdentifier=$TEAM$" <<<"$info" || fail "$path has another team identifier"
[ "$kind" = disk ] || grep -q "flags=.*runtime" <<<"$info" || fail "$path is signed without the hardened runtime (notarization needs it)"
grep -q "^Timestamp=" <<<"$info" || fail "$path has no secure timestamp"
codesign --verify --strict --verbose=2 "$path"
certificate "$path"
echo " signed: $(grep -m1 '^Authority=' <<<"$info" | cut -d= -f2-)"
}

certificate() {
local dir="$WORK/certs-$RANDOM"
mkdir -p "$dir"
(cd "$dir" && codesign -d --extract-certificates "$1" 2>/dev/null)
local leaf="$dir/codesign0"
[ -f "$leaf" ] || fail "could not read the signing certificate of $1"
local until
until="$(openssl x509 -inform DER -in "$leaf" -noout -enddate | cut -d= -f2)"
# The signatures stay valid after expiry thanks to the timestamp, but new releases can no longer be signed.
openssl x509 -inform DER -in "$leaf" -noout -checkend 0 >/dev/null || fail "the Developer ID certificate expired on $until: create a new one (see the top of release.yml)"
if ! openssl x509 -inform DER -in "$leaf" -noout -checkend $((WARN_DAYS * 86400)) >/dev/null; then
echo "::warning::the Developer ID certificate expires on $until: renew it and update APPLE_CERTIFICATE before then"
fi
}

gatekeeper() {
local out
out="$(spctl --assess --verbose=2 "$@" 2>&1)" || fail "Gatekeeper rejects ${*: -1}: $out"
grep -q "source=Notarized Developer ID" <<<"$out" || fail "${*: -1} is not notarized: $out"
}

app() {
local path="$1"
echo "app: $path"
signer "$path"
codesign --verify --deep --strict --verbose=2 "$path"
xcrun stapler validate "$path" >/dev/null || fail "$path has no stapled notarization ticket"
gatekeeper --type execute "$path"
if command -v syspolicy_check >/dev/null; then
syspolicy_check distribution "$path" || fail "syspolicy_check rejects $path"
fi
}

dmg() {
local path="$1" mnt
echo "dmg: $path"
signer "$path" disk
xcrun stapler validate "$path" >/dev/null || fail "$path has no stapled notarization ticket"
gatekeeper --type open --context context:primary-signature "$path"
mnt="$WORK/mnt-$RANDOM"
mkdir -p "$mnt"
# The DMG carries a licence agreement: `yes` accepts it like a click on Agree.
hdiutil attach -nobrowse -readonly -noautoopen -mountpoint "$mnt" "$path" < <(yes) >/dev/null || fail "could not open $path"
MOUNTS+=("$mnt")
local found=0
for a in "$mnt"/*.app; do
[ -d "$a" ] || continue
found=1
app "$a"
done
[ "$found" = 1 ] || fail "$path contains no app"
}

tarball() {
local path="$1" dir="$WORK/tar-$RANDOM"
echo "update bundle: $path"
mkdir -p "$dir"
tar -xzf "$path" -C "$dir"
local found=0
for a in "$dir"/*.app; do
[ -d "$a" ] || continue
found=1
app "$a"
done
[ "$found" = 1 ] || fail "$path contains no app"
}

[ $# -gt 0 ] || fail "usage: $0 <file.dmg|file.app|file.app.tar.gz|binary>..."
for target in "$@"; do
[ -e "$target" ] || fail "$target does not exist"
case "$target" in
*.dmg) dmg "$target" ;;
*.app) app "$target" ;;
*.tar.gz) tarball "$target" ;;
*)
# A command-line binary: Gatekeeper checks its notarization online (a ticket can't be stapled to it).
echo "binary: $target"
signer "$target"
;;
esac
done
echo "All macOS downloads pass Gatekeeper."
13 changes: 13 additions & 0 deletions scripts/macos/binary.entitlements
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<?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">
<plist version="1.0">
<dict>
<!-- Bun (JavaScriptCore) needs JIT under the hardened runtime, like the app's core sidecar. -->
<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-library-validation</key>
<true/>
</dict>
</plist>
Loading