@@ -430,7 +430,7 @@ rl_sign_file() { # rl_sign_file <path-to-exe-or-dll>
430430# installer. Order matters: payload first, installer last, because the
431431# installer's signature covers the compressed payload as-is.
432432rl_sign_windows () { # rl_sign_windows <stagedir-or-file> [...]
433- local target
433+ local target f
434434 if ! rl_sign_ready; then
435435 if [[ -n " ${RL_SIGN_ENDPOINT:- } " ]] && ! command -v jsign > /dev/null 2>&1 ; then
436436 rl_skip " Windows signing (jsign not installed: brew install jsign)"
@@ -804,16 +804,64 @@ NSI
804804# the keychain and never appears in an environment variable.
805805
806806RL_MAC_SIGNED_COUNT=0
807+ RL_CS_ERR=" ${TMPDIR:-/ tmp} /rl-codesign-err.$$ "
807808RL_NOTARIZED_COUNT=0
808809
809810rl_mac_sign_ready () { [[ -n " ${RL_MAC_SIGN_IDENTITY:- } " ]]; }
810- rl_notary_ready () { [[ -n " ${RL_NOTARY_PROFILE:- } " ]]; }
811+
812+ # Two ways to authenticate to the notary service, preferred order:
813+ #
814+ # 1. An App Store Connect API key — RL_NOTARY_KEY (path to the .p8),
815+ # RL_NOTARY_KEY_ID, RL_NOTARY_ISSUER. A file on disk, so nothing can
816+ # quietly remove it.
817+ # 2. A notarytool keychain profile — RL_NOTARY_PROFILE.
818+ #
819+ # The keychain profile disappeared twice mid-campaign on 2026-08-04 (every call
820+ # failing "No Keychain password item found for profile"), stalling the fleet
821+ # both times and needing the app-specific password re-entered by hand. The API
822+ # key exists to not depend on that item.
823+ rl_notary_key_ready () {
824+ [[ -n " ${RL_NOTARY_KEY:- } " && -f " ${RL_NOTARY_KEY:- } " \
825+ && -n " ${RL_NOTARY_KEY_ID:- } " && -n " ${RL_NOTARY_ISSUER:- } " ]]
826+ }
827+ rl_notary_ready () { rl_notary_key_ready || [[ -n " ${RL_NOTARY_PROFILE:- } " ]]; }
828+
829+ # The credential flags for `xcrun notarytool`, as an array.
830+ rl_notary_args () {
831+ if rl_notary_key_ready; then
832+ printf ' %s\n' --key " $RL_NOTARY_KEY " --key-id " $RL_NOTARY_KEY_ID " \
833+ --issuer " $RL_NOTARY_ISSUER "
834+ else
835+ printf ' %s\n' --keychain-profile " $RL_NOTARY_PROFILE "
836+ fi
837+ }
811838
812839# Sign a bundle (or single Mach-O) with the Developer ID identity, inside-out:
813840# nested executables, dylibs and frameworks first, the main binary and the
814841# bundle itself last, because an outer signature covers the contents as-is.
815842# Entitlements are applied ONLY to the outer app and its main executable —
816843# helpers must not inherit exceptions they don't need.
844+ # Sign the loose Mach-O files under <root>, without descending into any nested
845+ # bundle — those are signed as units by the caller, and reaching inside one
846+ # after it is sealed invalidates it. Used for an app's Contents and, just as
847+ # importantly, for the inside of a framework version: Electron parks
848+ # Helpers/chrome_crashpad_handler in there, and codesign validates
849+ # subcomponents, so signing the version directory fails with "code object is
850+ # not signed at all" until that binary is signed first.
851+ rl_mac_sign_loose () { # rl_mac_sign_loose <root> [<path-prefix-to-skip>]
852+ local root=" $1 " skip=" ${2:- } " f
853+ while IFS= read -r f; do
854+ [[ -n " $skip " && " $f " == $skip * ]] && continue
855+ rl_grep ' Mach-O' " $( file -b " $f " 2> /dev/null || true) " || continue
856+ codesign --force --options runtime --timestamp \
857+ --sign " $RL_MAC_SIGN_IDENTITY " " $f " 2> " $RL_CS_ERR " > /dev/null \
858+ || { echo " codesign failed on nested $f : $( cat " $RL_CS_ERR " ) " >&2 ; return 1; }
859+ done < <( find " $root " \( -type d \( -name ' *.app' -o -name ' *.framework' \) \) -prune \
860+ -o -type f \
861+ \( -perm -u+x -o -name ' *.dylib' -o -name ' *.so' -o -name ' *.node' \) \
862+ -print 2> /dev/null | sort)
863+ }
864+
817865rl_mac_sign () { # rl_mac_sign <path-to-.app-or-binary>
818866 local app=" $1 " f ent=()
819867 [[ -e " $app " ]] || return 0
@@ -849,27 +897,69 @@ rl_mac_sign() { # rl_mac_sign <path-to-.app-or-binary>
849897 while IFS= read -r f; do
850898 rl_mac_sign " $f " || return 1
851899 done < <( find " $app /Contents" -mindepth 2 -type d -name ' *.app' -prune 2> /dev/null | sort)
852- # Nested loose code: everything executable or Mach-O shaped that is not the
853- # main binary. file(1) is the arbiter — resources are not re-signed.
900+ # Frameworks are signed as units, never by reaching inside them: codesign
901+ # refuses a binary that is part of a bundle ("bundle format unrecognized"),
902+ # which is what "Electron Framework.framework/Versions/A/Electron Framework"
903+ # is. For a versioned framework the real bundle is the version directory,
904+ # so sign each Versions/<V> (skipping the Current symlink) and then the
905+ # framework itself. No entitlements — these are libraries, not programs.
854906 while IFS= read -r f; do
855- rl_grep ' Mach-O' " $( file -b " $f " 2> /dev/null || true) " || continue
856- " ${sign[@]} " " $f " > /dev/null 2>&1 \
857- || { echo " codesign failed on nested $f " >&2 ; return 1; }
858- done < <( find " $app /Contents" -type d -name ' *.app' -prune -o -type f \
859- \( -perm -u+x -o -name ' *.dylib' -o -name ' *.so' -o -name ' *.node' \) \
860- ! -path " $app /Contents/MacOS/*" -print 2> /dev/null | sort)
861- find " $app /Contents" -maxdepth 3 -type d -name ' *.app' -prune -o \
862- -maxdepth 3 -type d -name ' *.framework' -print 2> /dev/null \
863- | while IFS= read -r f; do
864- " ${sign[@]} " " $f " > /dev/null 2>&1 \
865- || { echo " codesign failed on framework $f " >&2 ; exit 1; }
866- done || return 1
867- while IFS= read -r f; do
868- " ${sign[@]} " " ${ent[@]} " " $f " > /dev/null 2>&1 \
869- || { echo " codesign failed on $f " >&2 ; return 1; }
870- done < <( find " $app /Contents/MacOS" -type f -perm -u+x 2> /dev/null | sort)
907+ # A framework's main binary is covered by signing the bundle, and
908+ # signing it directly fails the same way its version directory does.
909+ local v inner signed_version=0 fwmain
910+ fwmain=" $( basename " $f " ) " ; fwmain=" ${fwmain% .framework} "
911+ if [[ -d " $f /Versions" ]]; then
912+ while IFS= read -r v; do
913+ [[ -L " $v " ]] && continue # Versions/Current, normally a symlink
914+ # ...but a zip built without -y stores it as an EMPTY REAL DIRECTORY
915+ # instead (resolve-configurator's Python.framework ships that way).
916+ # codesign rejects an empty directory as "bundle format unrecognized",
917+ # and there is nothing in it to sign anyway.
918+ [[ -z " $( ls -A " $v " 2> /dev/null) " ]] && continue
919+ # Inside-out within the version: nested bundles, then loose code,
920+ # then the version directory itself.
921+ while IFS= read -r inner; do
922+ rl_mac_sign " $inner " || return 1
923+ done < <( find " $v " -mindepth 1 -type d \
924+ \( -name ' *.app' -o -name ' *.framework' \) -prune 2> /dev/null | sort)
925+ rl_mac_sign_loose " $v " " $v /$fwmain " || return 1
926+ " ${sign[@]} " " $v " 2> " $RL_CS_ERR " > /dev/null \
927+ || { echo " codesign failed on framework version $v : $( cat " $RL_CS_ERR " ) " >&2 ; return 1; }
928+ signed_version=1
929+ done < <( find " $f /Versions" -mindepth 1 -maxdepth 1 -type d 2> /dev/null | sort)
930+ fi
931+ if (( ! signed_version )) ; then
932+ # PyInstaller ships a stripped-down Python.framework under
933+ # Contents/Resources with no Versions/ and no Info.plist. It is a
934+ # directory that merely ends in .framework, and codesign rightly calls
935+ # it "bundle format unrecognized". Signing the Mach-O files inside it is
936+ # both possible and sufficient — that is what the notary checks — so
937+ # only attempt the bundle signature when it really is a bundle.
938+ rl_mac_sign_loose " $f " || return 1
939+ if [[ -f " $f /Resources/Info.plist" || -f " $f /Info.plist" ]]; then
940+ " ${sign[@]} " " $f " 2> " $RL_CS_ERR " > /dev/null \
941+ || { echo " codesign failed on framework $f : $( cat " $RL_CS_ERR " ) " >&2 ; return 1; }
942+ else
943+ rl_note " not a real bundle, signed its contents: $( basename " $f " ) "
944+ fi
945+ fi
946+ done < <( find " $app /Contents" -type d -name ' *.app' -prune -o \
947+ -type d -name ' *.framework' -print 2> /dev/null | sort)
948+
949+ # Everything else Mach-O shaped that is not the main binary. Bundles are
950+ # pruned — re-signing their contents now would break the seals just made.
951+ rl_mac_sign_loose " $app /Contents" " $app /Contents/MacOS/" || return 1
952+ # Contents/MacOS is deliberately NOT signed file-by-file. The bundle
953+ # signature below already covers everything in it, and signing the main
954+ # executable on its own makes codesign validate the whole enclosing bundle
955+ # early — which fails on a PyInstaller app, where Contents/Frameworks holds
956+ # base_library.zip and codesign treats that as unsigned nested code.
871957 fi
872- " ${sign[@]} " " ${ent[@]} " " $app " > /dev/null 2>&1 \
958+ # ${a[@]+"${a[@]}"}: bash 3.2 — still /bin/bash on macOS, and what launchd
959+ # runs — treats "${a[@]}" on an EMPTY array as an unbound variable under
960+ # set -u and dies. That is why the auto-signer failed on a host where every
961+ # interactive run had worked: this shell had bash 5 first on PATH.
962+ " ${sign[@]} " ${ent[@]+" ${ent[@]} " } " $app " > /dev/null 2>&1 \
873963 || { echo " codesign failed on $app " >&2 ; return 1; }
874964
875965 # Verify rather than trust the exit status — same lesson as rl_sign_file.
@@ -897,15 +987,43 @@ rl_mac_notarize() { # rl_mac_notarize <path (.app|.dmg|.pkg|.zip)>
897987 ditto -c -k --keepParent " $target " " $sub "
898988 fi
899989
990+ # `local` on the loop variable is load-bearing: without it this clobbers a
991+ # caller's `$a`, and posthoc-sign.sh iterates its assets in exactly that
992+ # variable — the asset name came back empty and the re-upload tried to POST
993+ # the working directory.
994+ local -a cred=(); local cred_line
995+ while IFS= read -r cred_line; do cred+=(" $cred_line " ); done < <( rl_notary_args)
900996 log=" $( mktemp) "
901- if ! xcrun notarytool submit " $sub " --keychain-profile " $RL_NOTARY_PROFILE " \
902- --wait > " $log " 2>&1 || ! grep -q ' status: Accepted' " $log " ; then
997+
998+ # The keychain profile lookup fails intermittently — "No Keychain password
999+ # item found for profile" — and then succeeds again later with nothing
1000+ # changed. It stalled the 2026-08-04 fleet run twice and looked like the
1001+ # credential had been deleted. It is transient, so retry it rather than
1002+ # abandoning a release that is otherwise ready. An API key (RL_NOTARY_KEY)
1003+ # avoids the lookup entirely and is preferred where one is configured.
1004+ # Transient failures worth retrying rather than failing a release over:
1005+ # * the keychain lookup flaking (see above)
1006+ # * Apple's notary service timing out — on 2026-08-04 it went unreachable
1007+ # mid-run and took out 18 repos in a couple of minutes, each failing
1008+ # instantly on NSURLErrorDomain -1001 rather than on anything we did.
1009+ # A rejection by Apple is NOT transient and must not be retried: it means the
1010+ # artefact is wrong, and retrying only hides it.
1011+ local attempt out
1012+ for attempt in 1 2 3 4; do
1013+ xcrun notarytool submit " $sub " " ${cred[@]} " --wait > " $log " 2>&1 || true
1014+ out=" $( cat " $log " ) "
1015+ rl_grep ' No Keychain password item found|The request timed out|Could not connect|NSURLErrorDomain|HTTPError' \
1016+ " $out " || break
1017+ rl_note " transient notary failure, retry $attempt of 4"
1018+ sleep $(( attempt * 30 ))
1019+ done
1020+
1021+ if ! rl_grepF ' status: Accepted' " $( cat " $log " ) " ; then
9031022 echo " notarisation FAILED for $target :" >&2
9041023 cat " $log " >&2
9051024 # Surface Apple's per-binary reasons; the submission id is in the log.
9061025 local id; id=$( grep -m1 ' id:' " $log " | awk ' {print $2}' )
907- [[ -n " $id " ]] && xcrun notarytool log " $id " \
908- --keychain-profile " $RL_NOTARY_PROFILE " >&2 || true
1026+ [[ -n " $id " ]] && xcrun notarytool log " $id " " ${cred[@]} " >&2 || true
9091027 rm -f " $log " ; [[ " $sub " != " $target " ]] && rm -rf " $( dirname " $sub " ) "
9101028 return 1
9111029 fi
@@ -982,7 +1100,7 @@ rl_adhoc_sign() { # rl_adhoc_sign <path-to-.app>
9821100# --app "Foo.app" the bundle is installed into /Applications
9831101
9841102rl_pkg () { # rl_pkg <label> <stagedir> --cli | --app <BundleName>
985- local label=" $1 " stage=" $2 " mode=" $3 " appname=" ${4:- } "
1103+ local label=" $1 " stage=" $2 " mode=" $3 " appname=" ${4:- } " b
9861104 rl_step " pkg ${label} "
9871105 local work root scripts component outfile
9881106 work=" $( mktemp -d) " ; root=" $work /root" ; scripts=" $work /scripts"
0 commit comments