Skip to content

Commit 4521fd7

Browse files
scripts: re-vendor release harness
1 parent 3f99a09 commit 4521fd7

1 file changed

Lines changed: 144 additions & 26 deletions

File tree

scripts/release-lib.sh

Lines changed: 144 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -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.
432432
rl_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

806806
RL_MAC_SIGNED_COUNT=0
807+
RL_CS_ERR="${TMPDIR:-/tmp}/rl-codesign-err.$$"
807808
RL_NOTARIZED_COUNT=0
808809

809810
rl_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+
817865
rl_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

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

Comments
 (0)