Skip to content

Commit da40fd9

Browse files
scripts: re-vendor release harness with macOS Developer ID signing + notarisation
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 4973bfe commit da40fd9

2 files changed

Lines changed: 263 additions & 18 deletions

File tree

scripts/release-lib.sh

Lines changed: 227 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,14 @@
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}"
4448
RL_URL="${RL_URL:-https://github.com/stoatworks-labs}"
4549
RL_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+
4755
rl_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

5278
rl_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

757931
rl_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.
9531163
rl_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

Comments
 (0)