Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 15 additions & 61 deletions Scripts/build-and-sign.sh
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ SCRIPT_DIR=$(cd "$(dirname "$0")" >/dev/null && pwd)
source "$SCRIPT_DIR/lib/xcode.sh"
source "$SCRIPT_DIR/lib/signing.sh"
source "$SCRIPT_DIR/lib/deploy-lock.sh"
source "$SCRIPT_DIR/lib/deploy-app.sh"
source "$SCRIPT_DIR/lib/submodules.sh"
source "$SCRIPT_DIR/lib/sparkle.sh"
export KEYPATH_PROJECT_DIR="$SCRIPT_DIR/.."
Expand Down Expand Up @@ -451,6 +452,9 @@ else

echo "📋 Submitting for notarization..."
NOTARY_PROFILE="${NOTARY_PROFILE:-KeyPath-Profile}"
# Prefer the file-based App Store Connect API key when present: keychain
# profiles become unreadable if the session locks mid-build.
kp_notary_default_auth_from_environment
kp_notarize_zip "${DIST_DIR}/${APP_NAME}.zip" "$NOTARY_PROFILE"

echo "🔖 Stapling notarization..."
Expand Down Expand Up @@ -478,70 +482,20 @@ if [ "${SKIP_DEPLOY:-0}" = "1" ]; then
exit 0
fi

# Stop running KeyPath and kanata BEFORE replacing the app bundle.
# Replacing binaries while the process is live causes macOS to detect
# code page mismatches and kill the process with:
# SIGKILL (Code Signature Invalid) / CODESIGNING / Invalid Page
# If kanata is killed mid-keystroke, the virtual HID holds the key down
# and the character repeats infinitely.
if pgrep -x "KeyPath" > /dev/null; then
echo "🛑 Stopping running KeyPath before deploy..."
killall KeyPath 2>/dev/null || true

# Wait up to 5 seconds for graceful shutdown
for i in {1..10}; do
if ! pgrep -x "KeyPath" > /dev/null; then
break
fi
sleep 0.5
done

# Force kill if still running
if pgrep -x "KeyPath" > /dev/null; then
echo " ⚠️ Process still running, force killing..."
killall -9 KeyPath 2>/dev/null || true
sleep 1
# A build that was submitted for notarization must never reach /Applications
# without its stapled ticket: Gatekeeper rejects the installed copy, and any
# later local re-sign changes the cdhash so the eventual ticket can no longer
# be stapled in place (2026-07-30 RC incident).
if [ "${SKIP_NOTARIZE:-}" != "1" ]; then
echo "🔎 Verifying stapled ticket before deploy..."
if ! kp_staple_validate "$APP_BUNDLE"; then
echo "❌ $APP_BUNDLE has no valid stapled notarization ticket; refusing to deploy." >&2
echo " Resume without rebuilding: Scripts/release-candidate.sh --resume-notarization" >&2
exit 1
fi
fi

# Verify no KeyPath process remains
if pgrep -x "KeyPath" > /dev/null; then
echo " ❌ ERROR: Failed to stop KeyPath process" >&2
echo " Please manually quit KeyPath and run the deploy manually." >&2
exit 1
fi

# Stop kanata so it doesn't get killed by the bundle replacement.
if pgrep -x "kanata" > /dev/null; then
echo "🛑 Stopping kanata service..."
KANATA_PID=$(pgrep -x "kanata")
sudo kill "$KANATA_PID" 2>/dev/null || true
sleep 1
fi

echo "📂 Deploying to /Applications..."
SYSTEM_APPS_DIR="/Applications"
APP_DEST="$SYSTEM_APPS_DIR/${APP_NAME}.app"
rm -rf "$APP_DEST"
if ditto "$APP_BUNDLE" "$APP_DEST"; then
echo "✅ Deployed latest $APP_NAME to $APP_DEST"
else
echo "⚠️ WARNING: Failed to copy $APP_NAME to $APP_DEST" >&2
echo "💡 TIP: You may need to manually copy dist/${APP_NAME}.app to /Applications/" >&2
fi

echo "🚪 Restarting app..."
echo " Starting new KeyPath..."
open "$APP_DEST"

# Wait for new process to start and verify
sleep 2
if pgrep -x "KeyPath" > /dev/null; then
NEW_PID=$(pgrep -x "KeyPath")
echo " ✅ KeyPath restarted successfully (PID: $NEW_PID)"
else
echo " ⚠️ WARNING: KeyPath may not have started. Run manually: open $APP_DEST" >&2
fi
kp_deploy_bundle_to_applications "$APP_BUNDLE" "$APP_NAME"

# ─────────────────────────────────────────────────────────────────────
# Publish help content to website
Expand Down
81 changes: 81 additions & 0 deletions Scripts/lib/deploy-app.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
#!/bin/bash

# Shared /Applications deploy for release flows.
#
# The bundle is copied verbatim with ditto — never re-signed here. Re-signing
# after notarization changes the cdhash, which detaches the app from its
# notarization ticket and makes stapling the installed copy impossible
# (2026-07-30 RC incident). Both build-and-sign.sh and notarize-resume.sh must
# deploy the exact stapled dist bundle through this function.

kp_deploy_bundle_to_applications() {
local app_bundle=$1
local app_name=$2

# Stop running KeyPath and kanata BEFORE replacing the app bundle.
# Replacing binaries while the process is live causes macOS to detect
# code page mismatches and kill the process with:
# SIGKILL (Code Signature Invalid) / CODESIGNING / Invalid Page
# If kanata is killed mid-keystroke, the virtual HID holds the key down
# and the character repeats infinitely.
if pgrep -x "$app_name" > /dev/null; then
echo "🛑 Stopping running $app_name before deploy..."
killall "$app_name" 2>/dev/null || true

# Wait up to 5 seconds for graceful shutdown
local i
for i in {1..10}; do
if ! pgrep -x "$app_name" > /dev/null; then
break
fi
sleep 0.5
done

# Force kill if still running
if pgrep -x "$app_name" > /dev/null; then
echo " ⚠️ Process still running, force killing..."
killall -9 "$app_name" 2>/dev/null || true
sleep 1
fi
fi

# Verify no KeyPath process remains
if pgrep -x "$app_name" > /dev/null; then
echo " ❌ ERROR: Failed to stop $app_name process" >&2
echo " Please manually quit $app_name and run the deploy manually." >&2
return 1
fi

# Stop kanata so it doesn't get killed by the bundle replacement.
if pgrep -x "kanata" > /dev/null; then
echo "🛑 Stopping kanata service..."
local kanata_pid
kanata_pid=$(pgrep -x "kanata")
sudo kill "$kanata_pid" 2>/dev/null || true
sleep 1
fi

echo "📂 Deploying to /Applications..."
local app_dest="/Applications/${app_name}.app"
rm -rf "$app_dest"
if ditto "$app_bundle" "$app_dest"; then
echo "✅ Deployed latest $app_name to $app_dest"
else
echo "⚠️ WARNING: Failed to copy $app_name to $app_dest" >&2
echo "💡 TIP: You may need to manually copy $app_bundle to /Applications/" >&2
fi

echo "🚪 Restarting app..."
echo " Starting new $app_name..."
open "$app_dest"

# Wait for new process to start and verify
sleep 2
if pgrep -x "$app_name" > /dev/null; then
local new_pid
new_pid=$(pgrep -x "$app_name")
echo " ✅ $app_name restarted successfully (PID: $new_pid)"
else
echo " ⚠️ WARNING: $app_name may not have started. Run manually: open $app_dest" >&2
fi
}
132 changes: 95 additions & 37 deletions Scripts/lib/signing.sh
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,62 @@ KP_VERIFY_CMD=${KP_VERIFY_CMD:-codesign}
KP_SIGN_DRY_RUN=${KP_SIGN_DRY_RUN:-0}
KP_NOTARY_WAIT_TIMEOUT=${KP_NOTARY_WAIT_TIMEOUT:-15m}

# Default App Store Connect API key used for notarization when present.
# notarytool keychain profiles live in the data-protection keychain, which
# becomes unreadable when the login session locks mid-build (2026-07-30 RC
# incident: release-doctor validated KeyPath-Profile, the screen locked during
# the ~9 minute build, and submit failed with "No Keychain password item
# found"). The file-based API key has no lock-state dependency. The key id and
# issuer are identifiers, not secrets.
KP_NOTARY_DEFAULT_KEY_PATH=${KP_NOTARY_DEFAULT_KEY_PATH:-"$HOME/.appstoreconnect/private_keys/AuthKey_XQ4565NYZ7.p8"}
KP_NOTARY_DEFAULT_KEY_ID=${KP_NOTARY_DEFAULT_KEY_ID:-XQ4565NYZ7}
KP_NOTARY_DEFAULT_ISSUER=${KP_NOTARY_DEFAULT_ISSUER:-60b8eb46-ca64-4580-a43b-850d92fcc7ab}

# Opt release scripts into the API key when the key file exists and no auth was
# configured explicitly. Set KP_NOTARY_AUTH=keychain-profile to force profile
# auth (tests and machines that intentionally use a keychain profile).
kp_notary_default_auth_from_environment() {
if [ -n "${KP_NOTARY_KEY_PATH:-}" ]; then
return 0
fi
if [ "${KP_NOTARY_AUTH:-auto}" = "keychain-profile" ]; then
return 0
Comment on lines +30 to +34

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Honor the explicit keychain-profile authentication override

When KP_NOTARY_AUTH=keychain-profile is set alongside an existing KP_NOTARY_KEY_PATH, this early return leaves the key path populated and kp_notary_resolve_auth consequently selects API-key authentication. Thus the documented force option cannot recover from a stale, inaccessible, or unwanted API key unless the caller also knows to unset the key variables; check the force setting before accepting KP_NOTARY_KEY_PATH, or make the resolver honor it directly.

Useful? React with 👍 / 👎.

fi
if [ -f "$KP_NOTARY_DEFAULT_KEY_PATH" ]; then
KP_NOTARY_KEY_PATH="$KP_NOTARY_DEFAULT_KEY_PATH"
KP_NOTARY_KEY_ID=${KP_NOTARY_KEY_ID:-"$KP_NOTARY_DEFAULT_KEY_ID"}
KP_NOTARY_ISSUER=${KP_NOTARY_ISSUER:-"$KP_NOTARY_DEFAULT_ISSUER"}
fi
return 0
}

# Resolve the notarytool auth flags once per operation into the globals
# KP_NOTARY_AUTH_ARGS (array) and KP_NOTARY_AUTH_DISPLAY (string for printed
# recovery commands). Explicit KP_NOTARY_KEY_PATH wins over the keychain
# profile — unless KP_NOTARY_AUTH=keychain-profile, which forces profile auth
# even when a key path is populated (recovers from a stale or unwanted key).
# The library never auto-detects on its own.
kp_notary_resolve_auth() {
local profile=$1
if [ -n "${KP_NOTARY_KEY_PATH:-}" ] && [ "${KP_NOTARY_AUTH:-auto}" != "keychain-profile" ]; then
if [ -z "${KP_NOTARY_KEY_ID:-}" ] || [ -z "${KP_NOTARY_ISSUER:-}" ]; then
echo "❌ KP_NOTARY_KEY_PATH is set but KP_NOTARY_KEY_ID or KP_NOTARY_ISSUER is missing." >&2
return 1
fi
if [ "$KP_SIGN_DRY_RUN" != "1" ] && [ ! -f "$KP_NOTARY_KEY_PATH" ]; then
echo "❌ App Store Connect API key not found: $KP_NOTARY_KEY_PATH" >&2
return 1
fi
KP_NOTARY_AUTH_ARGS=(--key "$KP_NOTARY_KEY_PATH" --key-id "$KP_NOTARY_KEY_ID" --issuer "$KP_NOTARY_ISSUER")
else
KP_NOTARY_AUTH_ARGS=(--keychain-profile "$profile")
if [ -n "${KP_NOTARY_KEYCHAIN:-}" ]; then
KP_NOTARY_AUTH_ARGS+=(--keychain "$KP_NOTARY_KEYCHAIN")
fi
fi
KP_NOTARY_AUTH_DISPLAY=${KP_NOTARY_AUTH_ARGS[*]}
}

kp_run() {
if [ "$KP_SIGN_DRY_RUN" = "1" ]; then
echo "[DRY RUN] $*"
Expand Down Expand Up @@ -244,20 +300,15 @@ kp_print_notary_recovery() {
echo " Recovery state: $state_file"
echo ""
echo " Check the existing submission before considering a retry:"
if [ -n "${KP_NOTARY_KEYCHAIN:-}" ]; then
echo " xcrun notarytool info \"$submission_id\" --keychain-profile \"$profile\" --keychain \"$KP_NOTARY_KEYCHAIN\""
else
echo " xcrun notarytool info \"$submission_id\" --keychain-profile \"$profile\""
fi
echo " xcrun notarytool info \"$submission_id\" $KP_NOTARY_AUTH_DISPLAY"
echo ""
echo " Do not submit another archive automatically. If one explicit retry is"
echo " necessary, first verify the archive still has this exact checksum:"
echo " shasum -a 256 \"$archive_path\""
if [ -n "${KP_NOTARY_KEYCHAIN:-}" ]; then
echo " xcrun notarytool submit \"$archive_path\" --keychain-profile \"$profile\" --keychain \"$KP_NOTARY_KEYCHAIN\" --no-wait --output-format json --no-progress"
else
echo " xcrun notarytool submit \"$archive_path\" --keychain-profile \"$profile\" --no-wait --output-format json --no-progress"
fi
echo " xcrun notarytool submit \"$archive_path\" $KP_NOTARY_AUTH_DISPLAY --no-wait --output-format json --no-progress"
echo ""
echo " Or resume the whole staple+deploy flow from the recovery state:"
echo " Scripts/release-candidate.sh --resume-notarization"
}

kp_notarize_zip() {
Expand All @@ -266,14 +317,11 @@ kp_notarize_zip() {
shift 2
local state_file=${KP_NOTARY_STATE_FILE:-"${zip_path%.zip}.notary-submission.json"}

kp_notary_resolve_auth "$profile" || return 1

if [ "$KP_SIGN_DRY_RUN" = "1" ]; then
if [ -n "${KP_NOTARY_KEYCHAIN:-}" ]; then
echo "[DRY RUN] $KP_NOTARY_CMD submit $zip_path --keychain-profile $profile --keychain $KP_NOTARY_KEYCHAIN --no-wait --output-format json --no-progress $*"
echo "[DRY RUN] $KP_NOTARY_CMD wait <submission-id> --keychain-profile $profile --keychain $KP_NOTARY_KEYCHAIN --timeout $KP_NOTARY_WAIT_TIMEOUT --output-format json --no-progress"
else
echo "[DRY RUN] $KP_NOTARY_CMD submit $zip_path --keychain-profile $profile --no-wait --output-format json --no-progress $*"
echo "[DRY RUN] $KP_NOTARY_CMD wait <submission-id> --keychain-profile $profile --timeout $KP_NOTARY_WAIT_TIMEOUT --output-format json --no-progress"
fi
echo "[DRY RUN] $KP_NOTARY_CMD submit $zip_path $KP_NOTARY_AUTH_DISPLAY --no-wait --output-format json --no-progress $*"
echo "[DRY RUN] $KP_NOTARY_CMD wait <submission-id> $KP_NOTARY_AUTH_DISPLAY --timeout $KP_NOTARY_WAIT_TIMEOUT --output-format json --no-progress"
echo "[DRY RUN] persist submission evidence to $state_file"
return 0
fi
Expand All @@ -298,11 +346,7 @@ kp_notarize_zip() {

local submit_output=""
local submit_exit=0
if [ -n "${KP_NOTARY_KEYCHAIN:-}" ]; then
submit_output=$($KP_NOTARY_CMD submit "$archive_path" --keychain-profile "$profile" --keychain "$KP_NOTARY_KEYCHAIN" --no-wait --output-format json --no-progress "$@" 2>"$submit_stderr_file") || submit_exit=$?
else
submit_output=$($KP_NOTARY_CMD submit "$archive_path" --keychain-profile "$profile" --no-wait --output-format json --no-progress "$@" 2>"$submit_stderr_file") || submit_exit=$?
fi
submit_output=$($KP_NOTARY_CMD submit "$archive_path" "${KP_NOTARY_AUTH_ARGS[@]}" --no-wait --output-format json --no-progress "$@" 2>"$submit_stderr_file") || submit_exit=$?
local submit_stderr
submit_stderr=$(<"$submit_stderr_file")
/bin/rm -f "$submit_stderr_file"
Expand All @@ -313,11 +357,7 @@ kp_notarize_zip() {
local candidates_json="[]"
local history_output=""
if [ -z "$submission_id" ]; then
if [ -n "${KP_NOTARY_KEYCHAIN:-}" ]; then
history_output=$($KP_NOTARY_CMD history --keychain-profile "$profile" --keychain "$KP_NOTARY_KEYCHAIN" --output-format json 2>/dev/null) || true
else
history_output=$($KP_NOTARY_CMD history --keychain-profile "$profile" --output-format json 2>/dev/null) || true
fi
history_output=$($KP_NOTARY_CMD history "${KP_NOTARY_AUTH_ARGS[@]}" --output-format json 2>/dev/null) || true
candidates_json=$(kp_notary_recent_history_candidates "$history_output" "$(basename "$archive_path")" "$submit_started_epoch") || candidates_json="[]"
fi
kp_write_notary_submit_failure_state "$state_file" "$submission_id" "$archive_path" "$archive_sha256" "$profile" "$submit_exit" "$submit_output" "$submit_stderr" "$candidates_json"
Expand All @@ -338,25 +378,33 @@ kp_notarize_zip() {
echo " Submission ID: $submission_id"
echo " Archive SHA-256: $archive_sha256"
echo " Recovery state: $state_file"

kp_notary_await_submission "$submission_id" "$profile" "$state_file" "$archive_path" "$archive_sha256"
}

# Wait for an already-uploaded submission to finish, updating the recovery
# state file. Also the resume entry point after a failed run: it can be called
# standalone with the submission id recorded in that state file.
# Returns 0 on Accepted, 75 while still In Progress, nonzero otherwise.
kp_notary_await_submission() {
local submission_id=$1
local profile=$2
local state_file=$3
local archive_path=$4
local archive_sha256=$5

kp_notary_resolve_auth "$profile" || return 1
echo "⏳ Waiting up to $KP_NOTARY_WAIT_TIMEOUT for Apple notarization..."

local wait_output=""
local wait_exit=0
if [ -n "${KP_NOTARY_KEYCHAIN:-}" ]; then
wait_output=$($KP_NOTARY_CMD wait "$submission_id" --keychain-profile "$profile" --keychain "$KP_NOTARY_KEYCHAIN" --timeout "$KP_NOTARY_WAIT_TIMEOUT" --output-format json --no-progress) || wait_exit=$?
else
wait_output=$($KP_NOTARY_CMD wait "$submission_id" --keychain-profile "$profile" --timeout "$KP_NOTARY_WAIT_TIMEOUT" --output-format json --no-progress) || wait_exit=$?
fi
wait_output=$($KP_NOTARY_CMD wait "$submission_id" "${KP_NOTARY_AUTH_ARGS[@]}" --timeout "$KP_NOTARY_WAIT_TIMEOUT" --output-format json --no-progress) || wait_exit=$?

local final_status=""
final_status=$(printf '%s' "$wait_output" | kp_notary_json_field status 2>/dev/null) || true
if [ "$wait_exit" -ne 0 ] || [ -z "$final_status" ]; then
local info_output=""
if [ -n "${KP_NOTARY_KEYCHAIN:-}" ]; then
info_output=$($KP_NOTARY_CMD info "$submission_id" --keychain-profile "$profile" --keychain "$KP_NOTARY_KEYCHAIN" --output-format json 2>/dev/null) || true
else
info_output=$($KP_NOTARY_CMD info "$submission_id" --keychain-profile "$profile" --output-format json 2>/dev/null) || true
fi
info_output=$($KP_NOTARY_CMD info "$submission_id" "${KP_NOTARY_AUTH_ARGS[@]}" --output-format json 2>/dev/null) || true
local info_status=""
info_status=$(printf '%s' "$info_output" | kp_notary_json_field status 2>/dev/null) || true
if [ -n "$info_status" ]; then
Expand Down Expand Up @@ -398,6 +446,16 @@ kp_staple() {
$KP_STAPLER_CMD staple "$target" "$@"
}

kp_staple_validate() {
local target=$1
shift
if [ "$KP_SIGN_DRY_RUN" = "1" ]; then
echo "[DRY RUN] $KP_STAPLER_CMD validate $target $*"
return 0
fi
$KP_STAPLER_CMD validate "$target" "$@"
}

kp_spctl_assess() {
local target=$1
shift
Expand Down
Loading
Loading