Files
kst4contest/packaging/macos/build-signed-dmg.sh
T
Rsclub2_2andClaude Opus 5 3f55c5b74a 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
2026-08-22 16:23:57 +02:00

266 lines
11 KiB
Bash
Executable File

#!/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"