mirror of
https://github.com/praktimarc/kst4contest.git
synced 2026-08-24 11:06:59 +02:00
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
266 lines
11 KiB
Bash
Executable File
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"
|