Files
kst4contest/packaging/macos/build-signed-dmg.sh
T
Rsclub2_2andClaude Opus 5.5 2b52bd81c0 Show a drag-to-install layout in the macOS DMG
The DMG window now has a background with an arrow from the app to
the Applications folder, in the KST4Contest colours. Finder applies
the layout through AppleScript; if that fails, e.g. on a headless
runner, the script warns and builds the plain DMG as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 22:27:47 +02:00

353 lines
14 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"
# Serially, deliberately. Running codesign concurrently over several files of
# the same bundle fails intermittently -- a CI run died with "replacing existing
# signature" immediately followed by "No such file or directory" for that same
# path, while the identical script passed locally. Each call contacts Apple's
# timestamp server, so this costs about a minute for a runtime this size.
xargs -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"
# The installer window shows a background with an arrow from the app to the
# Applications folder. Finder only stores that layout (.DS_Store) when it has
# arranged a mounted, writable image itself, so this drives Finder through
# AppleScript. That can fail, e.g. without automation permission for Finder or
# on a headless CI runner; the DMG is then built without the layout and only a
# warning is printed. Window size and icon positions must match
# packaging/macos/dmg/render-background.swift.
DMG_BACKGROUND="packaging/macos/dmg/background.tiff"
VOLUME_NAME="KST4Contest"
build_dmg_with_layout() {
local mount_point="/Volumes/$VOLUME_NAME"
local rw_dir rw_dmg size_mb
if [ ! -f "$DMG_BACKGROUND" ]; then
echo " $DMG_BACKGROUND is missing" >&2
return 1
fi
# Finder addresses the disk by its name; a second mounted volume of the same
# name would get another name and break the stored background reference.
if [ -e "$mount_point" ]; then
echo " $mount_point is already mounted, eject it first" >&2
return 1
fi
rw_dir="$(mktemp -d)"
rw_dmg="$rw_dir/KST4Contest-rw.dmg"
# Some headroom for the background picture and the .DS_Store Finder writes.
size_mb=$(( $(du -sm "$STAGE" | cut -f1) + 20 ))
hdiutil create -volname "$VOLUME_NAME" -srcfolder "$STAGE" -fs HFS+ \
-format UDRW -size "${size_mb}m" -ov -quiet "$rw_dmg" || { rm -rf "$rw_dir"; return 1; }
hdiutil attach "$rw_dmg" -readwrite -noverify -noautoopen -quiet || { rm -rf "$rw_dir"; return 1; }
local layout_ok=0
mkdir -p "$mount_point/.background" \
&& cp "$DMG_BACKGROUND" "$mount_point/.background/background.tiff" \
&& osascript <<APPLESCRIPT || layout_ok=1
tell application "Finder"
tell disk "$VOLUME_NAME"
open
set current view of container window to icon view
set toolbar visible of container window to false
set statusbar visible of container window to false
set the bounds of container window to {200, 120, 840, 520}
set viewOptions to the icon view options of container window
set arrangement of viewOptions to not arranged
set icon size of viewOptions to 128
set text size of viewOptions to 13
set background picture of viewOptions to file ".background:background.tiff"
set position of item "KST4Contest.app" of container window to {170, 180}
set position of item "Applications" of container window to {470, 180}
close
open
update without registering applications
delay 2
close
end tell
end tell
APPLESCRIPT
# Give Finder time to write .DS_Store before the image is detached.
sync
sleep 2
rm -rf "$mount_point/.fseventsd"
hdiutil detach "$mount_point" -quiet \
|| hdiutil detach "$mount_point" -force -quiet \
|| { rm -rf "$rw_dir"; return 1; }
if [ "$layout_ok" -ne 0 ]; then
rm -rf "$rw_dir"
return 1
fi
hdiutil convert "$rw_dmg" -format UDZO -ov -quiet -o "$DMG" || { rm -rf "$rw_dir"; return 1; }
rm -rf "$rw_dir"
}
if build_dmg_with_layout; then
echo " installer window layout applied"
else
echo "WARNING: could not apply the DMG window layout, building a plain DMG instead" >&2
rm -f "$DMG"
hdiutil create -volname "$VOLUME_NAME" -srcfolder "$STAGE" \
-ov -format UDZO -quiet "$DMG"
fi
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"