Sign and notarize macOS builds

jpackage cannot produce a distributable macOS bundle on its own. It ad-hoc
signs the embedded runtime and then re-runs codesign on the same files without
--force, which codesign rejects; and "--type dmg --app-image" re-signs the app
it is handed, replacing a Developer ID signature with an ad-hoc one. So the
build now creates an unsigned app-image, signs it from the inside out, and
wraps it with hdiutil.

Apple's notary service also unpacks JARs and checks the native libraries
inside them, which sqlite-jdbc ships for both architectures. Those are signed
before the bundle is sealed, since rewriting a JAR afterwards would invalidate
the seal. A preflight check verifies Apple's two criteria locally, so a missed
binary costs seconds rather than a round trip to the notary service.

Two long-standing defects surfaced while testing and are fixed here: the
bundle identifier defaulted to the main class's package name (kst4contest.view
instead of de.x08.KST4Contest), and every release reported version 1.0 in
Finder because --app-version was never passed. Neither affects existing users:
the app keeps its settings in ~/.praktiKST, independent of the bundle ID.

Both workflows call the same script the local Mac uses, so the two cannot
drift apart. Signing needs a keychain that can answer a UI prompt, which a
runner cannot, so ci-import-cert.sh creates a throwaway keychain whose
password is generated per job and discarded with it. Notarization goes through
an App Store Connect API key and needs no keychain at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WYcmHra3YndA1ahkHeNdJ2
This commit is contained in:
2026-08-22 16:23:57 +02:00
co-authored by Claude Opus 5
parent 399e5f34b7
commit 3f55c5b74a
6 changed files with 406 additions and 44 deletions
+27 -22
View File
@@ -7,6 +7,7 @@ on:
paths:
- "src/**"
- "packaging/icons/**"
- "packaging/macos/**"
- "pom.xml"
- "mvnw"
- "mvnw.cmd"
@@ -634,34 +635,38 @@ jobs:
- name: Ensure mvnw is executable
run: chmod +x mvnw
- name: Build JAR and copy runtime dependencies
run: |
./mvnw -B -DskipTests package dependency:copy-dependencies -DincludeScope=runtime -DoutputDirectory=target/dist-libs
cp "$(ls -t target/praktiKST-*.jar | head -n 1)" target/dist-libs/app.jar
- name: Build macOS DMG with jpackage
run: |
mkdir -p dist
ADD_MODULES="$(java packaging/AddModules.java)"
jpackage \
--type dmg \
--name KST4Contest \
--icon packaging/icons/kst4contest.icns \
--input target/dist-libs \
--main-jar app.jar \
--main-class kst4contest.view.Kst4ContestApplication \
--module-path target/dist-libs \
--add-modules "$ADD_MODULES" \
--dest dist
- name: Import signing certificate
env:
MACOSX_DEPLOYMENT_TARGET: "13.0"
MACOS_CERT_P12: ${{ secrets.MACOS_CERT_P12 }}
MACOS_CERT_PASSWORD: ${{ secrets.MACOS_CERT_PASSWORD }}
run: ./packaging/macos/ci-import-cert.sh
# Builds the jar, signs the app bundle and every native library inside it,
# wraps it into a DMG and has Apple notarize the result. Same script the
# local Mac uses, so the two cannot drift apart.
- name: Build signed and notarized DMG
env:
MACOS_NOTARY_KEY: ${{ secrets.MACOS_NOTARY_KEY }}
NOTARY_KEY_ID: ${{ secrets.MACOS_NOTARY_KEY_ID }}
NOTARY_ISSUER: ${{ secrets.MACOS_NOTARY_ISSUER }}
run: |
printf '%s' "$MACOS_NOTARY_KEY" | base64 --decode > "$RUNNER_TEMP/notary.p8"
export NOTARY_KEY="$RUNNER_TEMP/notary.p8"
./packaging/macos/build-signed-dmg.sh
- name: Remove signing credentials
if: always()
run: |
rm -f "$RUNNER_TEMP/notary.p8"
if [ -n "${SIGNING_KEYCHAIN:-}" ]; then
security delete-keychain "$SIGNING_KEYCHAIN" || true
fi
- name: Rename DMG artifact
run: |
DMG=$(ls dist/*.dmg | head -n 1)
if [ -z "$DMG" ]; then
echo "No DMG produced by jpackage" && exit 1
echo "No DMG produced by the build" && exit 1
fi
mv "$DMG" "dist/${ASSET_BASENAME}-macos-${ARCH}.dmg"
+26 -22
View File
@@ -522,35 +522,39 @@ jobs:
- name: Ensure mvnw is executable
run: chmod +x mvnw
- name: Build JAR and copy runtime dependencies
run: |
./mvnw -B -DskipTests package dependency:copy-dependencies -DincludeScope=runtime -DoutputDirectory=target/dist-libs
cp "$(ls -t target/praktiKST-*.jar | head -n 1)" target/dist-libs/app.jar
- name: Build macOS DMG with jpackage
run: |
mkdir -p dist
ADD_MODULES="$(java packaging/AddModules.java)"
jpackage \
--type dmg \
--name KST4Contest \
--icon packaging/icons/kst4contest.icns \
--input target/dist-libs \
--main-jar app.jar \
--main-class kst4contest.view.Kst4ContestApplication \
--module-path target/dist-libs \
--add-modules "$ADD_MODULES" \
--dest dist
- name: Import signing certificate
env:
MACOSX_DEPLOYMENT_TARGET: "13.0"
MACOS_CERT_P12: ${{ secrets.MACOS_CERT_P12 }}
MACOS_CERT_PASSWORD: ${{ secrets.MACOS_CERT_PASSWORD }}
run: ./packaging/macos/ci-import-cert.sh
# Builds the jar, signs the app bundle and every native library inside it,
# wraps it into a DMG and has Apple notarize the result. Same script the
# local Mac uses, so the two cannot drift apart.
- name: Build signed and notarized DMG
env:
MACOS_NOTARY_KEY: ${{ secrets.MACOS_NOTARY_KEY }}
NOTARY_KEY_ID: ${{ secrets.MACOS_NOTARY_KEY_ID }}
NOTARY_ISSUER: ${{ secrets.MACOS_NOTARY_ISSUER }}
run: |
printf '%s' "$MACOS_NOTARY_KEY" | base64 --decode > "$RUNNER_TEMP/notary.p8"
export NOTARY_KEY="$RUNNER_TEMP/notary.p8"
./packaging/macos/build-signed-dmg.sh
- name: Remove signing credentials
if: always()
run: |
rm -f "$RUNNER_TEMP/notary.p8"
if [ -n "${SIGNING_KEYCHAIN:-}" ]; then
security delete-keychain "$SIGNING_KEYCHAIN" || true
fi
- name: Rename DMG artifact
run: |
ARCH=$(uname -m)
DMG=$(ls dist/*.dmg | head -n 1)
if [ -z "$DMG" ]; then
echo "No DMG produced by jpackage" && exit 1
echo "No DMG produced by the build" && exit 1
fi
mv "$DMG" "dist/KST4Contest-${{ github.ref_name }}-macos-${ARCH}.dmg"
+3
View File
@@ -44,3 +44,6 @@ website/_site/
# Local secrets for act testing
.secrets
# Apple notarization private keys - never commit these
*.p8
+265
View File
@@ -0,0 +1,265 @@
#!/usr/bin/env bash
#
# Local signed (and optionally notarized) macOS build.
#
# jpackage cannot sign the app itself: it ad-hoc signs the embedded runtime and
# then re-runs codesign on the same files without --force, which codesign
# rejects with "is already signed". So this builds an unsigned app-image, signs
# it from the inside out ourselves, and only then wraps it into a DMG.
#
# Required:
# SIGNING_IDENTITY The name part of the Developer ID Application certificate,
# without the "Developer ID Application: " prefix. Example:
# SIGNING_IDENTITY="Philipp Wagner (ABCDE12345)"
# List available ones with:
# security find-identity -v -p codesigning
#
# Optional:
# Notarization, either as three separate values (what CI uses)...
# NOTARY_KEY Path to the App Store Connect .p8 private key
# NOTARY_KEY_ID The key's ID, also part of the .p8 filename
# NOTARY_ISSUER The issuer UUID, shown above the key list in the portal
# ...or as a keychain profile previously created with
# NOTARY_PROFILE xcrun notarytool store-credentials <name>
#
# With neither, the build is signed but not notarized -- enough to test
# locally, not enough to distribute.
#
set -euo pipefail
cd "$(dirname "$0")/../.."
REPO_ROOT="$PWD"
BUNDLE_ID="de.x08.KST4Contest"
ENTITLEMENTS="packaging/macos/kst4contest.entitlements"
if [ -z "${SIGNING_IDENTITY:-}" ]; then
echo "SIGNING_IDENTITY is not set. Available signing identities:" >&2
security find-identity -v -p codesigning >&2 || true
exit 1
fi
FULL_IDENTITY="Developer ID Application: $SIGNING_IDENTITY"
# notarytool takes either an API key triple or a stored keychain profile. The
# triple needs no keychain at all, which is why CI uses it.
NOTARY_ARGS=()
if [ -n "${NOTARY_KEY:-}" ] && [ -n "${NOTARY_KEY_ID:-}" ] && [ -n "${NOTARY_ISSUER:-}" ]; then
NOTARY_ARGS=(--key "$NOTARY_KEY" --key-id "$NOTARY_KEY_ID" --issuer "$NOTARY_ISSUER")
elif [ -n "${NOTARY_PROFILE:-}" ]; then
NOTARY_ARGS=(--keychain-profile "$NOTARY_PROFILE")
fi
echo "==> Building JAR and collecting runtime dependencies"
chmod +x mvnw
./mvnw -B -DskipTests package \
dependency:copy-dependencies -DincludeScope=runtime -DoutputDirectory=target/dist-libs
JAR="$(ls -t target/praktiKST-*.jar | head -n 1)"
cp "$JAR" target/dist-libs/app.jar
# jpackage only accepts a numeric major[.minor[.patch]] as the macOS bundle
# version, so a Maven qualifier like "-nightly" has to be trimmed off.
POM_VERSION="${JAR##*/praktiKST-}"
POM_VERSION="${POM_VERSION%.jar}"
APP_VERSION="$(printf '%s' "$POM_VERSION" | sed -e 's/[^0-9.].*$//' -e 's/\.*$//')"
[ -n "$APP_VERSION" ] || { echo "Could not derive app version from $JAR" >&2; exit 1; }
echo "==> Version: $POM_VERSION -> bundle version $APP_VERSION"
echo "==> Step 1/4: jpackage app-image (unsigned)"
rm -rf dist
mkdir -p dist
ADD_MODULES="$(java packaging/AddModules.java)"
MACOSX_DEPLOYMENT_TARGET="13.0" jpackage \
--type app-image \
--name KST4Contest \
--app-version "$APP_VERSION" \
--icon packaging/icons/kst4contest.icns \
--input target/dist-libs \
--main-jar app.jar \
--main-class kst4contest.view.Kst4ContestApplication \
--module-path target/dist-libs \
--add-modules "$ADD_MODULES" \
--mac-package-identifier "$BUNDLE_ID" \
--mac-package-name KST4Contest \
--dest dist/appimage
APP="dist/appimage/KST4Contest.app"
[ -d "$APP" ] || { echo "jpackage produced no app image" >&2; exit 1; }
echo "==> Step 2/4: signing bundle contents (this takes a few minutes)"
# Apple's notary service unpacks JARs and checks the native libraries inside
# them. sqlite-jdbc ships libsqlitejdbc.dylib for both architectures that way,
# and an unsigned binary in there fails the whole submission. So sign those
# first: the app bundle's seal covers Contents/app, and rewriting a JAR
# afterwards would invalidate it.
echo " scanning jars for native libraries"
find "$APP/Contents/app" -name '*.jar' -type f | while read -r JARPATH; do
# Only unpack jars that can plausibly hold a native library. Note the
# plain grep: "grep -q" exits at the first match, which hands unzip a
# SIGPIPE, and under "set -o pipefail" that failure becomes the pipeline's
# status -- inverting this very test.
if ! unzip -l "$JARPATH" | grep -E '\.(dylib|jnilib|so)$' >/dev/null; then
continue
fi
JARABS="$(cd "$(dirname "$JARPATH")" && pwd)/$(basename "$JARPATH")"
JARTMP="$(mktemp -d)"
unzip -q "$JARABS" -d "$JARTMP"
NATIVES="$(mktemp)"
( cd "$JARTMP" && find . -type f \( -name '*.dylib' -o -name '*.jnilib' -o -name '*.so' \) \
| while read -r n; do
if [ "$(file --mime-type -b "$n")" = "application/x-mach-binary" ]; then
printf '%s\n' "${n#./}"
fi
done ) > "$NATIVES"
if [ -s "$NATIVES" ]; then
echo " $(basename "$JARPATH"): $(wc -l < "$NATIVES" | tr -d ' ') native lib(s)"
( cd "$JARTMP" && xargs -I {} codesign --force --timestamp --options runtime \
--sign "$FULL_IDENTITY" {} < "$NATIVES" )
# Update in place rather than repacking, so the rest of the jar --
# manifest, module descriptor, entry order -- stays byte for byte.
( cd "$JARTMP" && xargs jar --update --file "$JARABS" < "$NATIVES" )
fi
rm -rf "$JARTMP" "$NATIVES"
done
# Every Mach-O file has to carry its own signature before the enclosing bundle
# can be sealed, so collect them first. jpackage leaves them ad-hoc signed,
# hence --force on every call.
# file(1) pads its output into columns when given several arguments at once,
# so ask it one file at a time with -b and get an unambiguous answer.
MACHO_LIST="$(mktemp)"
find "$APP" -type f -print0 | while IFS= read -r -d '' f; do
case "$(file --mime-type -b "$f")" in
application/x-mach-binary) printf '%s\n' "$f" ;;
esac
done > "$MACHO_LIST"
COUNT="$(wc -l < "$MACHO_LIST" | tr -d ' ')"
echo " $COUNT Mach-O files to sign"
# codesign contacts Apple's timestamp server on every call, so run a handful in
# parallel or this takes far longer than it needs to.
xargs -P 8 -I {} codesign --force --timestamp --options runtime \
--sign "$FULL_IDENTITY" {} < "$MACHO_LIST"
rm -f "$MACHO_LIST"
# The embedded JDK is a bundle in its own right and must be sealed before the
# app that contains it.
echo " sealing embedded runtime"
codesign --force --timestamp --options runtime \
--sign "$FULL_IDENTITY" "$APP/Contents/runtime"
# Entitlements go on the outermost bundle: the hardened runtime derives the
# process's entitlements from the main executable's signature.
echo " sealing app bundle"
codesign --force --timestamp --options runtime \
--entitlements "$ENTITLEMENTS" \
--sign "$FULL_IDENTITY" "$APP"
# Apple rejects the whole submission over a single unsigned native library, and
# a round trip to the notary service costs minutes. Check its two criteria --
# a Developer ID authority and a secure timestamp -- locally first.
echo " preflight: verifying native libraries inside jars"
PREFLIGHT_ERRORS="$(mktemp)"
find "$APP/Contents/app" -name '*.jar' -type f | while read -r JARPATH; do
if ! unzip -l "$JARPATH" | grep -E '\.(dylib|jnilib|so)$' >/dev/null; then
continue
fi
CHECKTMP="$(mktemp -d)"
unzip -q "$JARPATH" -d "$CHECKTMP"
find "$CHECKTMP" -type f \( -name '*.dylib' -o -name '*.jnilib' -o -name '*.so' \) \
| while read -r NATIVE; do
[ "$(file --mime-type -b "$NATIVE")" = "application/x-mach-binary" ] || continue
INFO="$(codesign -dv --verbose=2 "$NATIVE" 2>&1 || true)"
LABEL="$(basename "$JARPATH")/${NATIVE#"$CHECKTMP"/}"
printf '%s' "$INFO" | grep -q "Authority=Developer ID Application" \
|| echo "$LABEL: not signed with a Developer ID certificate" >> "$PREFLIGHT_ERRORS"
printf '%s' "$INFO" | grep -q "Timestamp=" \
|| echo "$LABEL: signature has no secure timestamp" >> "$PREFLIGHT_ERRORS"
done
rm -rf "$CHECKTMP"
done
if [ -s "$PREFLIGHT_ERRORS" ]; then
echo "ERROR: these would fail notarization:" >&2
sed 's/^/ /' "$PREFLIGHT_ERRORS" >&2
rm -f "$PREFLIGHT_ERRORS"
exit 1
fi
rm -f "$PREFLIGHT_ERRORS"
echo " preflight ok"
echo "==> Step 3/4: building the dmg"
# Not with jpackage: "jpackage --type dmg --app-image" re-signs the app it is
# handed, replacing our Developer ID signature with an ad-hoc one and dropping
# the hardened runtime flag. hdiutil copies the bundle verbatim instead.
DMG="dist/KST4Contest-${APP_VERSION}.dmg"
STAGE="$(mktemp -d)"
# ditto rather than cp -R: it preserves the extended attributes the code
# signature depends on.
ditto "$APP" "$STAGE/KST4Contest.app"
ln -s /Applications "$STAGE/Applications"
hdiutil create -volname "KST4Contest" -srcfolder "$STAGE" \
-ov -format UDZO -quiet "$DMG"
rm -rf "$STAGE"
[ -f "$DMG" ] || { echo "hdiutil produced no DMG" >&2; exit 1; }
# Signing the DMG itself is not what Gatekeeper judges -- that is the .app
# inside -- but Apple expects the container to be signed too.
codesign --force --timestamp --sign "$FULL_IDENTITY" "$DMG"
echo "==> Built $DMG"
echo "==> Step 4/4: verification"
if [ ${#NOTARY_ARGS[@]} -gt 0 ]; then
echo " submitting for notarization (waits for Apple's verdict)"
# Without a timeout a stalled submission would hang a CI job forever.
xcrun notarytool submit "$DMG" "${NOTARY_ARGS[@]}" --wait --timeout 30m
echo " stapling ticket"
xcrun stapler staple "$DMG"
else
echo " no notarization credentials set, skipping notarization"
fi
# Everything below inspects the app as it actually ships, mounted from the DMG,
# rather than the staging copy on disk.
MOUNT_POINT="$(mktemp -d)"
hdiutil attach "$DMG" -nobrowse -quiet -mountpoint "$MOUNT_POINT"
trap 'hdiutil detach "$MOUNT_POINT" -quiet 2>/dev/null || hdiutil detach "$MOUNT_POINT" -force -quiet 2>/dev/null || true' EXIT
SHIPPED_APP="$MOUNT_POINT/KST4Contest.app"
echo "--- codesign --verify on the app inside the DMG ---"
codesign --verify --deep --strict --verbose=2 "$SHIPPED_APP"
echo "--- app identity ---"
codesign -dv --verbose=2 "$SHIPPED_APP" 2>&1 | grep -iE "identifier|authority|teamidentifier|flags"
# An ad-hoc signature here means something along the way re-signed the bundle.
if codesign -dv "$SHIPPED_APP" 2>&1 | grep -q "adhoc"; then
echo "ERROR: the app inside the DMG is ad-hoc signed, not Developer ID signed" >&2
exit 1
fi
echo "--- entitlements as signed ---"
codesign -d --entitlements - --xml "$SHIPPED_APP" 2>/dev/null | plutil -convert xml1 -o - - | grep -E "key|true|false"
echo "--- dmg identity ---"
codesign -dv --verbose=2 "$DMG" 2>&1 | grep -iE "authority|teamidentifier" | head -2
echo "--- spctl assessment ---"
# Without notarization this reports "rejected"; that is expected.
spctl --assess --type execute --verbose=4 "$SHIPPED_APP" || true
if [ ${#NOTARY_ARGS[@]} -gt 0 ]; then
echo "--- stapler validate ---"
xcrun stapler validate "$DMG"
fi
echo
echo "Done: $REPO_ROOT/$DMG"
+64
View File
@@ -0,0 +1,64 @@
#!/usr/bin/env bash
#
# Import the Developer ID certificate into a throwaway keychain on a CI runner.
#
# A runner cannot answer the keychain's authorization dialog, so the login
# keychain is unusable there. This creates a dedicated keychain instead, whose
# password is generated here and needed nowhere else -- it is discarded with the
# keychain at the end of the job.
#
# Reads from the environment:
# MACOS_CERT_P12 base64 of the exported .p12
# MACOS_CERT_PASSWORD the password that .p12 was exported with
#
# Exports to $GITHUB_ENV:
# SIGNING_IDENTITY for packaging/macos/build-signed-dmg.sh
# SIGNING_KEYCHAIN so the cleanup step knows what to delete
#
set -euo pipefail
: "${MACOS_CERT_P12:?MACOS_CERT_P12 is not set}"
: "${MACOS_CERT_PASSWORD:?MACOS_CERT_PASSWORD is not set}"
: "${RUNNER_TEMP:?RUNNER_TEMP is not set}"
: "${GITHUB_ENV:?GITHUB_ENV is not set}"
KEYCHAIN="$RUNNER_TEMP/kst4contest-signing.keychain-db"
KEYCHAIN_PASSWORD="$(uuidgen)"
CERT="$RUNNER_TEMP/cert.p12"
printf '%s' "$MACOS_CERT_P12" | base64 --decode > "$CERT"
security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN"
# Keychains re-lock after five minutes by default, which would strand a build
# halfway through signing.
security set-keychain-settings -lut 21600 "$KEYCHAIN"
security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN"
security import "$CERT" -k "$KEYCHAIN" -P "$MACOS_CERT_PASSWORD" \
-T /usr/bin/codesign -T /usr/bin/security
rm -f "$CERT"
# Lets codesign reach the private key without the UI prompt a runner has no way
# of answering.
security set-key-partition-list -S apple-tool:,apple:,codesign: \
-s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN" >/dev/null
# codesign searches the keychain list, so the new keychain has to be on it --
# added to whatever the runner already had, not in place of it.
EXISTING_KEYCHAINS="$(security list-keychains -d user | sed -e 's/^[[:space:]]*"//' -e 's/"$//')"
# shellcheck disable=SC2086
security list-keychains -d user -s "$KEYCHAIN" $EXISTING_KEYCHAINS
IDENTITY="$(security find-identity -v -p codesigning "$KEYCHAIN" \
| sed -n 's/.*"Developer ID Application: \(.*\)".*/\1/p' | head -n 1)"
if [ -z "$IDENTITY" ]; then
echo "No 'Developer ID Application' identity found in the imported certificate." >&2
echo "What the keychain does contain:" >&2
security find-identity -v -p codesigning "$KEYCHAIN" >&2
exit 1
fi
echo "Imported identity: Developer ID Application: $IDENTITY"
echo "SIGNING_IDENTITY=$IDENTITY" >> "$GITHUB_ENV"
echo "SIGNING_KEYCHAIN=$KEYCHAIN" >> "$GITHUB_ENV"
+21
View File
@@ -0,0 +1,21 @@
<?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>
<!-- The JVM compiles bytecode to machine code at runtime and executes it
from memory it allocated itself. Under the hardened runtime all three
of these are required or the app is killed on launch. -->
<key>com.apple.security.cs.allow-jit</key>
<true/>
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
<true/>
<!-- jpackage bundles JavaFX native libraries that are signed with our own
identity rather than Apple's, and the JVM dlopen()s them at runtime. -->
<key>com.apple.security.cs.disable-library-validation</key>
<true/>
<!-- KST4Contest talks to the ON4KST chat servers. -->
<key>com.apple.security.network.client</key>
<true/>
</dict>
</plist>