Files
kst4contest/packaging/macos/build-signed-dmg.sh
T
Rsclub2_2andClaude Opus 5.5 88d10259d4 Retry stapler when Apple's ticket service drops the connection (#131)
Three nightly macOS DMG builds on the Intel runner failed in a row after a
successful notarization: `xcrun stapler validate` lost its connection to
api.apple-cloudkit.com ("The network connection was lost.", exit 68). The
ticket was fine; the runner's connection to Apple was not. staple and
validate now get up to five attempts with growing pauses (20, 40, 80,
160 s) before the build fails.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 11:19:26 +02:00

358 lines
15 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 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 <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
# 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 <<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"
# 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"