#!/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 takes the unsigned bundle from # :app-desktop:createDistributable, 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 # # 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 # The DMG carries the same version the Compose packaging stamps into the # bundle. gradle.properties keeps that value free of a qualifier, because the # DMG and MSI formats reject one. APP_VERSION="$(grep -m1 '^composePackageVersion=' gradle.properties | cut -d= -f2)" [ -n "$APP_VERSION" ] || { echo "composePackageVersion missing from gradle.properties" >&2; exit 1; } echo "==> Bundle version: $APP_VERSION" echo "==> Step 1/4: building the unsigned app bundle" chmod +x gradlew # createDistributable builds the bundle including its jlinked runtime. Name, # icon, bundle identifier and the JDK module list all come from # app-desktop/build.gradle.kts, so this script cannot drift from the CI build. MACOSX_DEPLOYMENT_TARGET="13.0" ./gradlew -S :app-desktop:createDistributable rm -rf dist mkdir -p dist/appimage # ditto and not cp -R: the signing steps below depend on extended attributes, # and only ditto preserves them. ditto app-desktop/build/compose/binaries/main/app/KST4Contest.app \ dist/appimage/KST4Contest.app APP="dist/appimage/KST4Contest.app" [ -d "$APP" ] || { echo "createDistributable produced no app bundle" >&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 <&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" # stapler talks to Apple's CloudKit ticket service. On the hosted Intel runners # that connection drops now and then ("The network connection was lost.", # exit 68) although the ticket is fine, so a lost connection is retried. with_retries() { local attempt=1 max_attempts=5 delay=20 until "$@"; do if [ "$attempt" -ge "$max_attempts" ]; then echo " giving up after $attempt attempts: $*" >&2 return 1 fi echo " attempt $attempt failed, retrying in ${delay}s: $*" >&2 sleep "$delay" attempt=$((attempt + 1)) delay=$((delay * 2)) done } 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" with_retries 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 ---" with_retries xcrun stapler validate "$DMG" fi echo echo "Done: $REPO_ROOT/$DMG"