1616#
1717# brew install makensis create-dmg
1818#
19- # macOS artefacts are NOT code-signed. Unsigned .pkg and .dmg payloads are
20- # quarantined by Gatekeeper on download, and approving the outer app does NOT
21- # unquarantine nested helper binaries — they get SIGKILLed silently. Ship the
22- # documented `xattr -dr com.apple.quarantine` step with every macOS artefact.
19+ # macOS artefacts are Developer ID-signed AND notarised when the RL_MAC_* /
20+ # RL_NOTARY_PROFILE variables are set (normally via
21+ # ~/.config/stoatworks/release-signing.env, which rl_init sources) — see the
22+ # macOS signing section. Unset, bundles fall back to ad-hoc signing: an
23+ # entirely unsigned bundle produces "is damaged", and approving the outer app
24+ # does NOT unquarantine nested helper binaries — they get SIGKILLed silently.
25+ # Only the notarised path removes the quarantine prompt altogether; the ad-hoc
26+ # fallback still needs the documented `xattr -dr com.apple.quarantine` step.
2327#
2428# Windows artefacts ARE Authenticode-signed when the RL_SIGN_* variables are
2529# set — see the Windows signing section. Unset, they skip rather than fail, so
@@ -44,9 +48,31 @@ RL_PUBLISHER="${RL_PUBLISHER:-Stoatworks Labs}"
4448RL_URL=" ${RL_URL:- https:// github.com/ stoatworks-labs} "
4549RL_SKIPPED=()
4650
51+ # Captured at source time: the vendored copy lives in each repo's scripts/, so
52+ # this is where per-repo signing extras (mac-entitlements.plist) are looked up.
53+ RL_LIB_DIR=" $( cd " $( dirname " ${BASH_SOURCE[0]} " ) " && pwd) "
54+
4755rl_init () {
4856 RL_NAME=" $1 " ; RL_SLUG=" $2 " ; RL_VERSION=" $3 " ; RL_IDENT=" $4 " ; RL_OUT=" $5 "
4957 mkdir -p " $RL_OUT "
58+
59+ # Machine-local signing configuration. Deliberately a dotfile and not a
60+ # repo file: identity names and the notary profile belong to this Mac, not
61+ # to (mostly public) repos, and CI hosts simply lack the file and skip.
62+ local cfg=" $HOME /.config/stoatworks/release-signing.env"
63+ [[ -f " $cfg " ]] && source " $cfg "
64+
65+ # Tauri signs its own bundles when this is exported, which covers the DMGs
66+ # it builds before any rl_* helper ever sees them.
67+ if [[ -n " ${RL_MAC_SIGN_IDENTITY:- } " && -z " ${APPLE_SIGNING_IDENTITY:- } " ]]; then
68+ export APPLE_SIGNING_IDENTITY=" $RL_MAC_SIGN_IDENTITY "
69+ fi
70+
71+ # A repo that needs hardened-runtime exceptions drops the file next to the
72+ # vendored lib; nothing to wire in the caller.
73+ if [[ -z " ${RL_MAC_ENTITLEMENTS:- } " && -f " $RL_LIB_DIR /mac-entitlements.plist" ]]; then
74+ RL_MAC_ENTITLEMENTS=" $RL_LIB_DIR /mac-entitlements.plist"
75+ fi
5076}
5177
5278rl_note () { printf ' %s\n' " $* " ; }
@@ -745,6 +771,150 @@ NSI
745771 rm -rf " $work "
746772}
747773
774+ # ---------------------------------- macOS Developer ID sign + notarisation --
775+ #
776+ # The real fix for Gatekeeper: sign with a Developer ID Application identity
777+ # (hardened runtime + secure timestamp, both mandatory for notarisation), then
778+ # have Apple notarise the artefact and staple the ticket so verification works
779+ # offline. Configuration (unset means "fall back / skip", never "fail"):
780+ #
781+ # RL_MAC_SIGN_IDENTITY "Developer ID Application: NAME (TEAMID)"
782+ # RL_MAC_INSTALLER_IDENTITY "Developer ID Installer: NAME (TEAMID)" (.pkg)
783+ # RL_NOTARY_PROFILE notarytool keychain profile name
784+ # RL_MAC_ENTITLEMENTS entitlements plist, auto-detected from the
785+ # vendored scripts/mac-entitlements.plist
786+ #
787+ # Normally all four come from ~/.config/stoatworks/release-signing.env via
788+ # rl_init. The notary profile is created once per machine with
789+ # `xcrun notarytool store-credentials` — the app-specific password lives in
790+ # the keychain and never appears in an environment variable.
791+
792+ RL_MAC_SIGNED_COUNT=0
793+ RL_NOTARIZED_COUNT=0
794+
795+ rl_mac_sign_ready () { [[ -n " ${RL_MAC_SIGN_IDENTITY:- } " ]]; }
796+ rl_notary_ready () { [[ -n " ${RL_NOTARY_PROFILE:- } " ]]; }
797+
798+ # Sign a bundle (or single Mach-O) with the Developer ID identity, inside-out:
799+ # nested executables, dylibs and frameworks first, the main binary and the
800+ # bundle itself last, because an outer signature covers the contents as-is.
801+ # Entitlements are applied ONLY to the outer app and its main executable —
802+ # helpers must not inherit exceptions they don't need.
803+ rl_mac_sign () { # rl_mac_sign <path-to-.app-or-binary>
804+ local app=" $1 " f ent=()
805+ [[ -e " $app " ]] || return 0
806+ [[ -n " ${RL_MAC_ENTITLEMENTS:- } " && -f " ${RL_MAC_ENTITLEMENTS:- } " ]] \
807+ && ent=(--entitlements " $RL_MAC_ENTITLEMENTS " )
808+ local sign=(codesign --force --options runtime --timestamp \
809+ --sign " $RL_MAC_SIGN_IDENTITY " )
810+ rl_step " sign $( basename " $app " ) (Developer ID)"
811+
812+ # Already correctly signed — usually by Tauri, which signs during bundling
813+ # when APPLE_SIGNING_IDENTITY is exported. Do NOT re-sign: replacing the
814+ # signature changes the CDHash, and any DMG built from the earlier copy
815+ # would no longer be covered by this app's notarisation.
816+ if codesign --verify --strict --deep " $app " > /dev/null 2>&1 \
817+ && codesign -dvv " $app " 2>&1 | grep -q " Authority=$RL_MAC_SIGN_IDENTITY " \
818+ && codesign -d --verbose=2 " $app " 2>&1 | grep -q ' flags=.*runtime' ; then
819+ RL_MAC_SIGNED_COUNT=$(( RL_MAC_SIGNED_COUNT + 1 ))
820+ rl_note " already signed with this identity, left untouched"
821+ return 0
822+ fi
823+
824+ if [[ -d " $app " ]]; then
825+ # Nested code: everything executable or Mach-O shaped that is not the main
826+ # binary. file(1) is the arbiter — resources are not re-signed.
827+ while IFS= read -r f; do
828+ file -b " $f " 2> /dev/null | grep -q ' Mach-O' || continue
829+ " ${sign[@]} " " $f " > /dev/null 2>&1 \
830+ || { echo " codesign failed on nested $f " >&2 ; return 1; }
831+ done < <( find " $app /Contents" -type f \
832+ \( -perm -u+x -o -name ' *.dylib' -o -name ' *.so' \) \
833+ ! -path " $app /Contents/MacOS/*" 2> /dev/null | sort)
834+ find " $app /Contents" -name ' *.framework' -maxdepth 3 -type d 2> /dev/null \
835+ | while IFS= read -r f; do
836+ " ${sign[@]} " " $f " > /dev/null 2>&1 \
837+ || { echo " codesign failed on framework $f " >&2 ; exit 1; }
838+ done || return 1
839+ while IFS= read -r f; do
840+ " ${sign[@]} " " ${ent[@]} " " $f " > /dev/null 2>&1 \
841+ || { echo " codesign failed on $f " >&2 ; return 1; }
842+ done < <( find " $app /Contents/MacOS" -type f -perm -u+x 2> /dev/null | sort)
843+ fi
844+ " ${sign[@]} " " ${ent[@]} " " $app " > /dev/null 2>&1 \
845+ || { echo " codesign failed on $app " >&2 ; return 1; }
846+
847+ # Verify rather than trust the exit status — same lesson as rl_sign_file.
848+ codesign --verify --strict --deep " $app " 2>&1 | sed ' s/^/ /' | head -5
849+ codesign --verify --strict --deep " $app " > /dev/null 2>&1 \
850+ || { echo " signature did not verify: $app " >&2 ; return 1; }
851+ RL_MAC_SIGNED_COUNT=$(( RL_MAC_SIGNED_COUNT + 1 ))
852+ rl_note " signed $( basename " $app " ) "
853+ }
854+
855+ # Notarise one artefact and staple the ticket. Apps are shipped to Apple as a
856+ # temporary zip and the ticket is stapled to the bundle itself, so anything
857+ # packaged from it afterwards (zip, dmg) carries the ticket. dmg/pkg are
858+ # submitted and stapled as files. Bare zips can be submitted but never
859+ # stapled — notarise the app BEFORE zipping instead.
860+ rl_mac_notarize () { # rl_mac_notarize <path (.app|.dmg|.pkg|.zip)>
861+ local target=" $1 " sub log
862+ rl_notary_ready || { rl_skip " notarisation (no RL_NOTARY_PROFILE)" ; return 0; }
863+ [[ -e " $target " ]] || return 0
864+ rl_step " notarize $( basename " $target " ) "
865+
866+ sub=" $target "
867+ if [[ -d " $target " ]]; then
868+ sub=" $( mktemp -d) /$( basename " $target " ) .zip"
869+ ditto -c -k --keepParent " $target " " $sub "
870+ fi
871+
872+ log=" $( mktemp) "
873+ if ! xcrun notarytool submit " $sub " --keychain-profile " $RL_NOTARY_PROFILE " \
874+ --wait > " $log " 2>&1 || ! grep -q ' status: Accepted' " $log " ; then
875+ echo " notarisation FAILED for $target :" >&2
876+ cat " $log " >&2
877+ # Surface Apple's per-binary reasons; the submission id is in the log.
878+ local id; id=$( grep -m1 ' id:' " $log " | awk ' {print $2}' )
879+ [[ -n " $id " ]] && xcrun notarytool log " $id " \
880+ --keychain-profile " $RL_NOTARY_PROFILE " >&2 || true
881+ rm -f " $log " ; [[ " $sub " != " $target " ]] && rm -rf " $( dirname " $sub " ) "
882+ return 1
883+ fi
884+ rm -f " $log " ; [[ " $sub " != " $target " ]] && rm -rf " $( dirname " $sub " ) "
885+
886+ case " $target " in
887+ * .zip) rl_note " notarised (zip cannot be stapled; contents carry no ticket)" ;;
888+ * ) xcrun stapler staple " $target " > /dev/null \
889+ || { echo " stapler failed on $target " >&2 ; return 1; }
890+ rl_note " notarised and stapled" ;;
891+ esac
892+ RL_NOTARIZED_COUNT=$(( RL_NOTARIZED_COUNT + 1 ))
893+
894+ # The verdict that matches what a user's Mac will decide.
895+ if [[ -d " $target " ]]; then
896+ spctl -a -t install " $target " > /dev/null 2>&1 \
897+ || { echo " spctl rejected $target after notarisation" >&2 ; return 1; }
898+ fi
899+ }
900+
901+ # Sign every Mach-O in a staging tree that is not already validly signed by a
902+ # real identity. Third-party libraries (the NDI runtime is Vizrt-signed) are
903+ # left untouched; ad-hoc signatures verify but carry no Authority line, so
904+ # they are re-signed. This is what makes bare CLI payloads notarisable.
905+ rl_mac_sign_tree () { # rl_mac_sign_tree <dir>
906+ rl_mac_sign_ready || return 0
907+ local f
908+ while IFS= read -r f; do
909+ file -b " $f " 2> /dev/null | grep -q ' Mach-O' || continue
910+ if codesign --verify --strict " $f " > /dev/null 2>&1 \
911+ && codesign -dvv " $f " 2>&1 | grep -q ' Authority=' ; then
912+ continue
913+ fi
914+ rl_mac_sign " $f " || return 1
915+ done < <( find " $1 " -type f | sort)
916+ }
917+
748918# -------------------------------------------------------- macOS ad-hoc sign --
749919#
750920# arm64 Mach-O binaries come out of the linker already ad-hoc signed because the
@@ -753,10 +923,19 @@ NSI
753923# explicitly and identically. Nested code first, outermost last — a signature
754924# over a bundle covers its contents, so re-signing an inner binary afterwards
755925# invalidates the outer one.
926+ #
927+ # When Developer ID signing is configured this upgrades transparently to the
928+ # real thing — sign, notarise, staple — so existing callers get the full chain
929+ # without edits. Ad-hoc remains the unconfigured/CI fallback.
756930
757931rl_adhoc_sign () { # rl_adhoc_sign <path-to-.app>
758932 local app=" $1 " f
759933 [[ -d " $app " ]] || return 0
934+ if rl_mac_sign_ready; then
935+ rl_mac_sign " $app " || return 1
936+ rl_mac_notarize " $app " || return 1
937+ return 0
938+ fi
760939 rl_step " sign $( basename " $app " ) (ad-hoc)"
761940 while IFS= read -r f; do
762941 codesign --force --sign - --timestamp=none " $f " 2> /dev/null || true
@@ -790,6 +969,7 @@ rl_pkg() { # rl_pkg <label> <stagedir> --cli | --app <BundleName>
790969 else
791970 install_location=" /usr/local/${RL_SLUG} "
792971 cp -R " $stage /." " $root /"
972+ rl_mac_sign_tree " $root " || return 1
793973 # Link every executable file at the top level into /usr/local/bin.
794974 {
795975 echo ' #!/bin/sh'
@@ -837,9 +1017,14 @@ DIST
8371017 rm -f " $outfile "
8381018 local pb_args=(--distribution " $work /distribution.xml" --package-path " $work " )
8391019 [[ -n " $licref " ]] && pb_args+=(--resources " $work /resources" )
1020+ [[ -n " ${RL_MAC_INSTALLER_IDENTITY:- } " ]] \
1021+ && pb_args+=(--sign " $RL_MAC_INSTALLER_IDENTITY " --timestamp)
8401022 productbuild " ${pb_args[@]} " " $outfile " > /dev/null
8411023 rl_note " $( basename " $outfile " ) "
8421024 rm -rf " $work "
1025+ # An unsigned pkg cannot be notarised; Apple rejects it at intake.
1026+ [[ -n " ${RL_MAC_INSTALLER_IDENTITY:- } " ]] && { rl_mac_notarize " $outfile " || return 1; }
1027+ return 0
8431028}
8441029
8451030# ------------------------------------------------- macOS multi-part .pkg ----
@@ -899,9 +1084,14 @@ rl_pkg_multi() { # rl_pkg_multi <label> <src:dest> ...
8991084 } > " $work /distribution.xml"
9001085
9011086 rm -f " $outfile "
902- productbuild --distribution " $work /distribution.xml" --package-path " $work " " $outfile " > /dev/null
1087+ local pbm_args=(--distribution " $work /distribution.xml" --package-path " $work " )
1088+ [[ -n " ${RL_MAC_INSTALLER_IDENTITY:- } " ]] \
1089+ && pbm_args+=(--sign " $RL_MAC_INSTALLER_IDENTITY " --timestamp)
1090+ productbuild " ${pbm_args[@]} " " $outfile " > /dev/null
9031091 rl_note " $( basename " $outfile " ) "
9041092 rm -rf " $work "
1093+ [[ -n " ${RL_MAC_INSTALLER_IDENTITY:- } " ]] && { rl_mac_notarize " $outfile " || return 1; }
1094+ return 0
9051095}
9061096
9071097# ------------------------------------------------------------- macOS .dmg ---
@@ -914,6 +1104,7 @@ rl_dmg() { # rl_dmg <label> <stagedir> [--app <BundleName>]
9141104 local outfile=" $RL_OUT /${RL_SLUG} -${RL_VERSION} -${label} .dmg"
9151105 rl_step " dmg ${label} "
9161106 rm -f " $outfile "
1107+ [[ " $mode " != " --app" ]] && { rl_mac_sign_tree " $stage " || return 1; }
9171108
9181109 if [[ " $mode " == " --app" ]] && command -v create-dmg > /dev/null 2>&1 ; then
9191110 # create-dmg exits 2 when it cannot set a custom icon position on a
@@ -924,7 +1115,11 @@ rl_dmg() { # rl_dmg <label> <stagedir> [--app <BundleName>]
9241115 --app-drop-link 400 190 \
9251116 --no-internet-enable \
9261117 " $outfile " " $stage " > /dev/null 2>&1 || true
927- if [[ -f " $outfile " ]]; then rl_note " $( basename " $outfile " ) " ; return 0; fi
1118+ if [[ -f " $outfile " ]]; then
1119+ rl_note " $( basename " $outfile " ) "
1120+ rl_dmg_finish " $outfile " " $mode "
1121+ return $?
1122+ fi
9281123 rl_note " create-dmg failed, falling back to hdiutil"
9291124 fi
9301125
@@ -941,6 +1136,21 @@ rl_dmg() { # rl_dmg <label> <stagedir> [--app <BundleName>]
9411136 rm -f " $hdlog "
9421137 return 1
9431138 fi
1139+ rl_dmg_finish " $outfile " " $mode "
1140+ }
1141+
1142+ # Sign the image itself; notarise it only when it carries a bare payload. An
1143+ # --app dmg holds a bundle that rl_adhoc_sign/rl_mac_sign already notarised, so
1144+ # Apple's hash check covers it and a second submission buys nothing. A CLI dmg
1145+ # has no bundle for a ticket to ride on, so the image is the thing to notarise.
1146+ rl_dmg_finish () { # rl_dmg_finish <dmg> <mode>
1147+ local outfile=" $1 " mode=" ${2:- } "
1148+ rl_mac_sign_ready || return 0
1149+ codesign --force --timestamp --sign " $RL_MAC_SIGN_IDENTITY " " $outfile " \
1150+ > /dev/null 2>&1 || { echo " codesign failed on $outfile " >&2 ; return 1; }
1151+ if [[ " $mode " != " --app" ]]; then
1152+ rl_mac_notarize " $outfile " || return 1
1153+ fi
9441154}
9451155
9461156# --------------------------------------------------- signing status blurb ---
@@ -951,10 +1161,19 @@ rl_dmg() { # rl_dmg <label> <stagedir> [--app <BundleName>]
9511161# on an unsigned one is worse. Driven by RL_SIGNED_COUNT, which only
9521162# rl_sign_file increments, so it cannot drift from reality.
9531163rl_notes_signing () {
1164+ local mac win
1165+ if (( RL_NOTARIZED_COUNT > 0 )) ; then
1166+ mac=" macOS artefacts are Developer ID-signed and notarised by Apple — no quarantine step needed."
1167+ elif (( RL_MAC_SIGNED_COUNT > 0 )) ; then
1168+ mac=" macOS artefacts are Developer ID-signed but NOT notarised: see the README for the quarantine step."
1169+ else
1170+ mac=" macOS artefacts are unsigned: see the README for the quarantine step."
1171+ fi
9541172 if (( RL_SIGNED_COUNT > 0 )) ; then
955- printf ' %s' " Windows artefacts are Authenticode-signed and timestamped. macOS artefacts are unsigned: see the README for the quarantine step."
1173+ win=" Windows artefacts are Authenticode-signed and timestamped."
1174+ printf ' %s %s' " $win " " $mac "
9561175 else
957- printf ' %s' " Unsigned: see the README for the macOS quarantine step. "
1176+ printf ' %s' " $mac "
9581177 fi
9591178}
9601179
0 commit comments