fix(ci): sign macOS Mach-O natives embedded in bundled jars before notarization

The v1.12.2 release was the first to actually codesign + notarize the macOS
artifacts (signing was wired after v1.12.1, which shipped unsigned). Both macOS
legs failed with "Notarization status: Invalid": Apple's notary service recurses
into the bundled jars and rejects the unsigned Mach-O natives inside them
(secp256k1, sqlite-bundled, jna, skiko, jkeychain, kdroidFilter mediaplayer) —
codesign on the .app and the CLI's loose-file loop never descend into jars.

Notary log confirmed the offending entries, e.g.
  sqlite-bundled-jvm.jar/natives/osx_arm64/libsqliteJni.dylib
    -> "not signed with a valid Developer ID certificate" / "no secure timestamp"

Add scripts/sign-macos-jar-natives.sh: a shared helper that signs every macOS
Mach-O inside the bundled jars with hardened runtime + a secure timestamp,
skipping Linux ELF via a `file` Mach-O gate and no-opping when no identity is
set (local/PR builds unchanged). Wire it into:
  - the CLI notarize step (runs before the loose-file signing loop)
  - a desktop signMacJarNatives Gradle task that signs the proguarded jars
    between proguardReleaseJars and createReleaseDistributable, so Compose
    seals already-signed code.

Validated locally on arm64: clean signed createReleaseDistributable produces an
.app that passes `codesign --verify --deep --strict`, with every nested native
carrying Developer ID + hardened runtime + secure timestamp.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Vitor Pamplona
2026-06-19 10:42:06 -04:00
co-authored by Claude Opus 4.8
parent 8ef4e42c16
commit ee58355e6e
3 changed files with 132 additions and 5 deletions
+10 -5
View File
@@ -326,7 +326,12 @@ jobs:
set -euo pipefail
IMG="cli/build/amy-image/amy"
ENTITLEMENTS="cli/packaging/macos/amy.entitlements"
# Sign every Mach-O binary in the bundled JRE. Each is signed
# First sign the macOS Mach-O natives buried INSIDE the bundled jars
# (secp256k1/jna/sqlite/skiko/jkeychain/mediaplayer). The loose-file
# loop below can't see them, but Apple's notary recurses into jars and
# rejects any unsigned Mach-O — so this must run before notarize.
SIGN_IDENTITY="$SIGN_IDENTITY" scripts/sign-macos-jar-natives.sh "$IMG"
# Sign every loose Mach-O binary in the bundled JRE. Each is signed
# independently (no enclosing .app seals them), so order is irrelevant.
# Executables get the hardened-runtime entitlements; dylibs don't.
while IFS= read -r f; do
@@ -341,10 +346,10 @@ jobs:
done < <(find "$IMG" -type f)
codesign --verify --strict --verbose=2 "$IMG/runtime/bin/java"
# Notarize: zip the signed image, submit, wait for Apple's verdict.
# The notary service recursively inspects the lib/*.jar files, so any
# unsigned Mach-O embedded in them (secp256k1/jna/sqlite/skiko natives)
# can come back Invalid. Surface the per-file log so the first real run
# is diagnostic rather than a bare failure.
# The notary service recursively inspects the lib/*.jar files; their
# embedded Mach-O natives are signed by sign-macos-jar-natives.sh
# above. Surface the per-file log on any non-Accepted verdict so a
# regression is diagnostic rather than a bare failure.
ZIP="$RUNNER_TEMP/amy-notarize.zip"
OUT="$RUNNER_TEMP/notary-submit.json"
ditto -c -k --keepParent "$IMG" "$ZIP"
+36
View File
@@ -355,3 +355,39 @@ listOf(
dependsOn(verifyJkeychainNativeSurvivesProguard)
}
}
// macOS notarization: the bundled jars (secp256k1, jna, sqlite-bundled, skiko,
// jkeychain, kdroidFilter mediaplayer) carry Mach-O natives that Compose's
// .app codesign never reaches — codesign doesn't descend into jars, but Apple's
// notary service does and rejects any unsigned Mach-O ("Invalid"). Sign those
// natives inside the *proguarded* jars (the exact artifacts Compose then copies
// into the .app) so the subsequent bundle signing seals already-signed code.
// Runs only on macOS with the Developer ID identity exported — a no-op on every
// other leg and on unsigned local/PR builds.
val signMacJarNatives by tasks.registering {
description = "Codesign macOS Mach-O natives embedded in bundled jars before the .app is sealed + notarized"
group = "build"
dependsOn("proguardReleaseJars")
val proguardDir = layout.buildDirectory.dir("compose/tmp/main-release/proguard")
val script = rootProject.file("scripts/sign-macos-jar-natives.sh")
val identity = providers.environmentVariable("AMETHYST_MAC_SIGN_IDENTITY")
val isMac = System.getProperty("os.name").lowercase().contains("mac")
onlyIf { isMac && !identity.orNull.isNullOrBlank() }
doLast {
val proc =
ProcessBuilder("bash", script.absolutePath, proguardDir.get().asFile.absolutePath)
.redirectErrorStream(true)
.also { it.environment()["SIGN_IDENTITY"] = identity.get() }
.start()
proc.inputStream.bufferedReader().forEachLine { println(it) }
val code = proc.waitFor()
if (code != 0) throw GradleException("sign-macos-jar-natives.sh failed with exit code $code")
}
}
// Must run after ProGuard writes the jars and before Compose assembles + signs
// the .app, so wire it onto createReleaseDistributable (which the DMG packaging
// depends on transitively).
tasks.matching { it.name == "createReleaseDistributable" }.configureEach {
dependsOn(signMacJarNatives)
}
+86
View File
@@ -0,0 +1,86 @@
#!/usr/bin/env bash
#
# sign-macos-jar-natives.sh — codesign the macOS Mach-O native libraries that
# ship *inside* bundled jars (jna, secp256k1, sqlite-bundled, skiko, jkeychain,
# kdroidFilter mediaplayer, …).
#
# Why this exists: `codesign` on an .app bundle and a `find … -type f` signing
# loop both only see *loose* Mach-O files — neither descends into `.jar`
# archives. Apple's notary service, however, recursively inspects every jar and
# rejects any unsigned Mach-O inside it ("Notarization status: Invalid"). So we
# unzip each native, sign it with hardened runtime + a secure timestamp, and
# write it back into the jar. Run this BEFORE the enclosing .app/tarball is
# sealed and notarized.
#
# Usage:
# SIGN_IDENTITY="Developer ID Application: NAME (TEAMID)" \
# scripts/sign-macos-jar-natives.sh <dir-containing-jars> [<dir2> ...]
#
# Idempotent: re-signing an already-signed native just replaces the signature.
# No-op (exit 0) when SIGN_IDENTITY is empty, so unsigned local/PR builds are
# unaffected — exactly like the rest of the release signing path.
set -euo pipefail
IDENTITY="${SIGN_IDENTITY:-}"
if [ -z "$IDENTITY" ]; then
echo "sign-macos-jar-natives: SIGN_IDENTITY unset — skipping (unsigned build)."
exit 0
fi
if [ "$#" -lt 1 ]; then
echo "usage: SIGN_IDENTITY=… $0 <dir-with-jars> [<dir2> ...]" >&2
exit 2
fi
# `jar` (from the JDK) updates a single entry in place without rewriting the
# rest of the archive, so we don't disturb the manifest or other entries.
JAR_BIN="${JAVA_HOME:+$JAVA_HOME/bin/}jar"
command -v "$JAR_BIN" >/dev/null 2>&1 || JAR_BIN="jar"
signed_total=0
jars_touched=0
sign_one_jar() {
local jar="$1"
# Candidate native entries by extension. The `file` check below is the real
# gate — it filters out the many Linux ELF `.so` siblings (jna, sqlite, …)
# and catches macOS Mach-O hiding behind a `.so` name (jkeychain's
# osxkeychain.so). zipinfo -1 lists entry paths only.
local entries
entries="$(zipinfo -1 "$jar" 2>/dev/null | grep -iE '\.(dylib|jnilib|so)$' || true)"
[ -n "$entries" ] || return 0
local work jar_abs dirty=0
work="$(mktemp -d)"
jar_abs="$(cd "$(dirname "$jar")" && pwd)/$(basename "$jar")"
while IFS= read -r entry; do
[ -n "$entry" ] || continue
( cd "$work" && unzip -o -q "$jar_abs" "$entry" )
local f="$work/$entry"
[ -f "$f" ] || continue
case "$(file -b "$f")" in
*Mach-O*)
codesign --force --options runtime --timestamp --sign "$IDENTITY" "$f" >/dev/null 2>&1
codesign --verify --strict "$f"
( cd "$work" && "$JAR_BIN" uf "$jar_abs" "$entry" )
echo " signed: $(basename "$jar") :: $entry"
signed_total=$((signed_total + 1))
dirty=1
;;
esac
done <<< "$entries"
[ "$dirty" -eq 1 ] && jars_touched=$((jars_touched + 1))
rm -rf "$work"
}
for dir in "$@"; do
[ -d "$dir" ] || { echo " (skip, not a dir: $dir)"; continue; }
echo "Scanning for jar-embedded macOS natives under: $dir"
while IFS= read -r jar; do
sign_one_jar "$jar"
done < <(find "$dir" -type f -name '*.jar')
done
echo "sign-macos-jar-natives: signed $signed_total native(s) across $jars_touched jar(s)."