From be8fecbc397a2bf1c1b1496861957f0f5b738685 Mon Sep 17 00:00:00 2001 From: Micah Alpern Date: Thu, 30 Jul 2026 17:16:43 -0700 Subject: [PATCH 1/2] fix: survive locked-keychain notarization and add RC resume path The 2026-07-30 release candidate failed at notarytool submit with "No Keychain password item found for profile: KeyPath-Profile" nine minutes after release-doctor validated that same profile: notarytool stores profiles in the data-protection keychain, which becomes unreadable when the login session locks mid-build. Recovery then required hand-stapling the extracted zip because a dev deploy had re-signed the installed app, detaching it from its eventual ticket. - Prefer the file-based App Store Connect API key for notarytool when present (no lock-state dependency); keychain-profile auth remains the fallback and can be forced with KP_NOTARY_AUTH=keychain-profile. release-doctor now validates whichever auth path the build will use. - Add Scripts/release-candidate.sh --resume-notarization (Scripts/notarize-resume.sh): finish the recorded submission or resubmit the checksum-verified archive, staple dist/KeyPath.app, and deploy it verbatim - no rebuild, no re-sign. - build-and-sign.sh refuses to deploy a bundle without a stapled ticket whenever notarization was requested, and both deploy paths share Scripts/lib/deploy-app.sh (ditto copy, never re-signs). - Extract kp_notary_await_submission so resume can wait on a recorded submission id; enforce the new invariants in verify-release-signing-contract.sh; extend SigningPipelineTests. Incident write-up: docs/bugs/2026-07-30-rc-notarization-keychain-lock.md Co-Authored-By: Claude Fable 5 --- Scripts/build-and-sign.sh | 76 ++-------- Scripts/lib/deploy-app.sh | 81 +++++++++++ Scripts/lib/signing.sh | 130 +++++++++++++----- Scripts/notarize-resume.sh | 100 ++++++++++++++ Scripts/release-candidate.sh | 40 +++++- Scripts/release-doctor.sh | 32 +++-- Scripts/verify-release-signing-contract.sh | 3 + .../BuildScripts/SigningPipelineTests.swift | 88 ++++++++++++ .../Lint/DeploymentScriptContractTests.swift | 19 ++- ...026-07-30-rc-notarization-keychain-lock.md | 57 ++++++++ docs/process/release-process.md | 23 ++++ 11 files changed, 538 insertions(+), 111 deletions(-) create mode 100644 Scripts/lib/deploy-app.sh create mode 100755 Scripts/notarize-resume.sh create mode 100644 docs/bugs/2026-07-30-rc-notarization-keychain-lock.md diff --git a/Scripts/build-and-sign.sh b/Scripts/build-and-sign.sh index 535372f73..2fad58c7a 100755 --- a/Scripts/build-and-sign.sh +++ b/Scripts/build-and-sign.sh @@ -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/.." @@ -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..." @@ -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 diff --git a/Scripts/lib/deploy-app.sh b/Scripts/lib/deploy-app.sh new file mode 100644 index 000000000..ce9fae8bd --- /dev/null +++ b/Scripts/lib/deploy-app.sh @@ -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 +} diff --git a/Scripts/lib/signing.sh b/Scripts/lib/signing.sh index 1c78a02d3..a8bc9c167 100644 --- a/Scripts/lib/signing.sh +++ b/Scripts/lib/signing.sh @@ -12,6 +12,60 @@ 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 + 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; the library never auto-detects on its own. +kp_notary_resolve_auth() { + local profile=$1 + if [ -n "${KP_NOTARY_KEY_PATH:-}" ]; 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] $*" @@ -244,20 +298,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() { @@ -266,14 +315,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 --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 --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 $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 @@ -298,11 +344,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" @@ -313,11 +355,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" @@ -338,25 +376,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 @@ -398,6 +444,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 diff --git a/Scripts/notarize-resume.sh b/Scripts/notarize-resume.sh new file mode 100755 index 000000000..43f048818 --- /dev/null +++ b/Scripts/notarize-resume.sh @@ -0,0 +1,100 @@ +#!/usr/bin/env bash + +# Resume a release-candidate whose notarization failed after dist/ was built. +# +# Reads the recovery state written by kp_notarize_zip, finishes or re-runs the +# Apple submission for the exact dist archive, staples dist/KeyPath.app, and +# deploys that stapled bundle to /Applications unchanged (no rebuild, no +# re-sign). Use via Scripts/release-candidate.sh --resume-notarization. +# +# Environment: +# KP_NOTARY_STATE_FILE Recovery state override (default: +# dist/KeyPath.notary-submission.json). +# KP_NOTARY_SUBMISSION_ID Wait on this submission id instead of the +# recorded one (use when the state file lists +# candidateSubmissions). +# SKIP_DEPLOY=1 Staple and verify dist/ without touching +# /Applications. + +set -euo pipefail + +SCRIPT_DIR=$(cd "$(dirname "$0")" >/dev/null && pwd) +PROJECT_DIR="$SCRIPT_DIR/.." +cd "$PROJECT_DIR" + +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" +keypath_use_stable_xcode +keypath_acquire_deploy_lock "notarize-resume ($PROJECT_DIR)" "${KEYPATH_RELEASE_DEPLOY_LOCK_TIMEOUT_SECONDS:-600}" +trap keypath_release_deploy_lock EXIT + +APP_NAME="KeyPath" +DIST_DIR="dist" +APP_BUNDLE="${DIST_DIR}/${APP_NAME}.app" +ZIP_PATH="${DIST_DIR}/${APP_NAME}.zip" +STATE_FILE=${KP_NOTARY_STATE_FILE:-"${ZIP_PATH%.zip}.notary-submission.json"} +NOTARY_PROFILE="${NOTARY_PROFILE:-KeyPath-Profile}" + +for required in "$APP_BUNDLE" "$ZIP_PATH" "$STATE_FILE"; do + if [ ! -e "$required" ]; then + echo "โŒ Missing $required โ€” nothing to resume. Run Scripts/release-candidate.sh instead." >&2 + exit 1 + fi +done + +recorded_sha=$(kp_notary_json_field archiveSHA256 < "$STATE_FILE" 2>/dev/null) || recorded_sha="" +recorded_status=$(kp_notary_json_field status < "$STATE_FILE" 2>/dev/null) || recorded_status="" +recorded_submission_id=$(kp_notary_json_field submissionId < "$STATE_FILE" 2>/dev/null) || recorded_submission_id="" + +current_sha=$(/usr/bin/shasum -a 256 "$ZIP_PATH" | /usr/bin/awk '{print $1}') +if [ -z "$recorded_sha" ] || [ "$current_sha" != "$recorded_sha" ]; then + echo "โŒ $ZIP_PATH no longer matches the recovery state (recorded ${recorded_sha:-}, found $current_sha)." >&2 + echo " dist/ was rebuilt or modified; run the full Scripts/release-candidate.sh instead." >&2 + exit 1 +fi + +kp_notary_default_auth_from_environment + +submission_id="${KP_NOTARY_SUBMISSION_ID:-$recorded_submission_id}" + +if [ "$recorded_status" = "accepted" ]; then + echo "โœ… Recovery state already shows an accepted submission; continuing to staple." +elif [ -n "$submission_id" ]; then + echo "๐Ÿ” Resuming submission $submission_id (recorded status: ${recorded_status:-unknown})..." + kp_notary_await_submission "$submission_id" "$NOTARY_PROFILE" "$STATE_FILE" "$ZIP_PATH" "$current_sha" +else + candidate_count=$(/usr/bin/python3 -c 'import json,sys; print(len(json.load(sys.stdin).get("candidateSubmissions") or []))' < "$STATE_FILE" 2>/dev/null) || candidate_count=0 + if [ "$candidate_count" -gt 0 ]; then + echo "โŒ The failed submit may have reached Apple; refusing to resubmit automatically." >&2 + echo " Candidate submissions in $STATE_FILE:" >&2 + /usr/bin/python3 -c ' +import json, sys +for candidate in json.load(sys.stdin).get("candidateSubmissions") or []: + print(" {} {} {}".format(candidate.get("id"), candidate.get("status"), candidate.get("createdDate"))) +' < "$STATE_FILE" >&2 + echo " Re-run with KP_NOTARY_SUBMISSION_ID= to wait on one of them." >&2 + exit 1 + fi + echo "๐Ÿ” No submission reached Apple; resubmitting the recorded archive..." + kp_notarize_zip "$ZIP_PATH" "$NOTARY_PROFILE" +fi + +echo "๐Ÿ”– Stapling notarization..." +kp_staple "$APP_BUNDLE" +kp_staple_validate "$APP_BUNDLE" + +echo "๐Ÿ” Final verification..." +kp_spctl_assess "$APP_BUNDLE" + +if [ "${SKIP_DEPLOY:-0}" = "1" ]; then + echo "โญ๏ธ Skipping deployment (SKIP_DEPLOY=1)" + echo "๐Ÿ“ Stapled candidate retained at: $APP_BUNDLE" + exit 0 +fi + +kp_deploy_bundle_to_applications "$APP_BUNDLE" "$APP_NAME" + +echo "๐ŸŽ‰ Notarization resume complete." +echo "๐Ÿ“ Deployed app: /Applications/${APP_NAME}.app" diff --git a/Scripts/release-candidate.sh b/Scripts/release-candidate.sh index e5281aac8..f439b2f6f 100755 --- a/Scripts/release-candidate.sh +++ b/Scripts/release-candidate.sh @@ -6,6 +6,7 @@ PROJECT_DIR="$SCRIPT_DIR/.." VERIFY=1 DOCTOR=1 +RESUME=0 usage() { cat <<'EOF' @@ -29,11 +30,21 @@ Options: --with-peekaboo Allow Peekaboo screenshot generation during snapshot regeneration. --no-doctor Skip Scripts/release-doctor.sh preflight. --no-verify Skip Scripts/verify-installed-app.sh after deploy. + --resume-notarization + Do not rebuild. Finish the notarization recorded in the + recovery state, staple dist/KeyPath.app, deploy it + unchanged, and verify. Use after a run that stopped at + notarization. -h, --help Show this help. Environment: CODESIGN_IDENTITY Developer ID Application identity override. NOTARY_PROFILE notarytool keychain profile override. + KP_NOTARY_KEY_PATH / KP_NOTARY_KEY_ID / KP_NOTARY_ISSUER + App Store Connect API key for notarytool. Defaults to + ~/.appstoreconnect/private_keys/AuthKey_XQ4565NYZ7.p8 + when that file exists; set KP_NOTARY_AUTH=keychain-profile + to force keychain-profile auth instead. KP_NOTARY_WAIT_TIMEOUT Maximum Apple processing wait before preserving recovery evidence and stopping (default: 15m). @@ -67,6 +78,9 @@ while [[ $# -gt 0 ]]; do --no-verify) VERIFY=0 ;; + --resume-notarization) + RESUME=1 + ;; -h|--help) usage exit 0 @@ -82,6 +96,17 @@ done cd "$PROJECT_DIR" +if [[ "$RESUME" == "1" ]]; then + echo "๐Ÿ” Resuming release-candidate notarization (no rebuild)" + "$SCRIPT_DIR/notarize-resume.sh" + if [[ "$VERIFY" == "1" ]]; then + "$SCRIPT_DIR/verify-installed-app.sh" + else + echo "โญ๏ธ Skipping installed-app verification (--no-verify)" + fi + exit 0 +fi + echo "๐Ÿšข Building release candidate" echo " SKIP_SNAPSHOTS=$SKIP_SNAPSHOTS" echo " SKIP_PEEKABOO=$SKIP_PEEKABOO" @@ -94,7 +119,20 @@ else echo "โญ๏ธ Skipping release preflight (--no-doctor or SKIP_RELEASE_DOCTOR=1)" fi -"$SCRIPT_DIR/build-and-sign.sh" +build_status=0 +"$SCRIPT_DIR/build-and-sign.sh" || build_status=$? +if [[ "$build_status" -ne 0 ]]; then + echo "" >&2 + echo "โŒ Release candidate failed (build-and-sign exit $build_status)." >&2 + state_file="${KP_NOTARY_STATE_FILE:-dist/KeyPath.notary-submission.json}" + if [[ -f "$state_file" ]] && ! grep -q '"status": "accepted"' "$state_file"; then + echo " Notarization did not complete; the app was NOT deployed to /Applications." >&2 + echo " Recovery state: $state_file" >&2 + echo " Resume without rebuilding once credentials/Apple are sorted:" >&2 + echo " Scripts/release-candidate.sh --resume-notarization" >&2 + fi + exit "$build_status" +fi if [[ "$VERIFY" == "1" ]]; then "$SCRIPT_DIR/verify-installed-app.sh" diff --git a/Scripts/release-doctor.sh b/Scripts/release-doctor.sh index e166922e0..a08833767 100755 --- a/Scripts/release-doctor.sh +++ b/Scripts/release-doctor.sh @@ -4,6 +4,7 @@ set -euo pipefail SCRIPT_DIR=$(cd "$(dirname "$0")" >/dev/null && pwd) PROJECT_DIR=$(cd "$SCRIPT_DIR/.." >/dev/null && pwd) source "$SCRIPT_DIR/lib/xcode.sh" +source "$SCRIPT_DIR/lib/signing.sh" source "$SCRIPT_DIR/lib/sparkle.sh" export KEYPATH_PROJECT_DIR="$PROJECT_DIR" keypath_use_stable_xcode @@ -31,6 +32,9 @@ Environment checked: CODESIGN_IDENTITY Developer ID Application identity override. NOTARY_PROFILE notarytool keychain profile override. KP_NOTARY_KEYCHAIN Optional notarytool keychain override. + KP_NOTARY_KEY_PATH / KP_NOTARY_KEY_ID / KP_NOTARY_ISSUER + App Store Connect API key for notarytool (defaults to + the AuthKey under ~/.appstoreconnect when present). SKIP_SPARKLE If 1, skip Sparkle checks. SKIP_WEBSITE If 1, skip gh-pages website checks. EOF @@ -190,16 +194,26 @@ else echo " Set CODESIGN_IDENTITY or install the Developer ID Application certificate." fi -notary_args=(history --keychain-profile "$notary_profile" --output-format json) -if [[ -n "${KP_NOTARY_KEYCHAIN:-}" ]]; then - notary_args+=(--keychain "$KP_NOTARY_KEYCHAIN") -fi - -if notarytool "${notary_args[@]}" >/dev/null 2>&1; then - pass "notarytool profile validated: $notary_profile" +# Validate the same auth path build-and-sign.sh will use. The API key is +# preferred because keychain profiles become unreadable if the session locks +# mid-build (data-protection keychain). +kp_notary_default_auth_from_environment +if ! kp_notary_resolve_auth "$notary_profile"; then + fail "notarytool auth configuration is invalid (KP_NOTARY_KEY_* incomplete or key file missing)" +elif notarytool history "${KP_NOTARY_AUTH_ARGS[@]}" --output-format json >/dev/null 2>&1; then + if [[ -n "${KP_NOTARY_KEY_PATH:-}" ]]; then + pass "notarytool App Store Connect API key validated: ${KP_NOTARY_KEY_ID:-?} (no keychain dependency)" + else + pass "notarytool profile validated: $notary_profile" + echo " Note: keychain-profile auth fails if the session locks mid-build; prefer an App Store Connect API key (KP_NOTARY_KEY_PATH)." + fi else - fail "notarytool profile failed validation: $notary_profile" - echo " Set NOTARY_PROFILE or run: xcrun notarytool store-credentials" + if [[ -n "${KP_NOTARY_KEY_PATH:-}" ]]; then + fail "notarytool App Store Connect API key failed validation: $KP_NOTARY_KEY_PATH" + else + fail "notarytool profile failed validation: $notary_profile" + echo " Set NOTARY_PROFILE or run: xcrun notarytool store-credentials" + fi fi print_section "Identity Contract" diff --git a/Scripts/verify-release-signing-contract.sh b/Scripts/verify-release-signing-contract.sh index 226aac921..470daa9ae 100755 --- a/Scripts/verify-release-signing-contract.sh +++ b/Scripts/verify-release-signing-contract.sh @@ -138,6 +138,9 @@ require_contains "$build_script" 'kp_sign "$CONTENTS/Library/KeyPath/libkeypath_ require_contains "$build_script" 'kp_sign "$CONTENTS/Library/KeyPath/kanata-simulator" --force --options=runtime --sign "$SIGNING_IDENTITY"' "Kanata simulator is hardened-runtime signed" require_contains "$build_script" '"$SCRIPT_DIR/verify-identity-contract.sh" --app "$APP_BUNDLE"' "build-and-sign runs installed-app identity verification" require_contains "$build_script" '"$SCRIPT_DIR/verify-release-signing-contract.sh" --source' "build-and-sign runs source signing-contract verification" +require_contains "$build_script" 'kp_staple_validate "$APP_BUNDLE"' "build-and-sign refuses to deploy without a stapled notarization ticket" +require_contains "$build_script" 'kp_deploy_bundle_to_applications' "build-and-sign deploys the stapled bundle verbatim (no re-sign on deploy)" +require_contains "Scripts/notarize-resume.sh" 'kp_deploy_bundle_to_applications' "notarize-resume deploys the stapled bundle verbatim (no re-sign on deploy)" require_contains "$doctor_script" 'verify-release-signing-contract.sh" --source' "release-doctor runs source signing-contract verification" if (( failures > 0 )); then diff --git a/Tests/KeyPathTests/BuildScripts/SigningPipelineTests.swift b/Tests/KeyPathTests/BuildScripts/SigningPipelineTests.swift index bf973479d..88840e643 100644 --- a/Tests/KeyPathTests/BuildScripts/SigningPipelineTests.swift +++ b/Tests/KeyPathTests/BuildScripts/SigningPipelineTests.swift @@ -361,6 +361,94 @@ final class SigningPipelineTests: XCTestCase { XCTAssertTrue(result.stdout.contains("persist submission evidence to /tmp/KeyPath.notary-submission.json")) } + func testNotaryWrapperUsesAPIKeyAuthWhenConfigured() throws { + let fixture = try makeNotaryFixture(waitExit: 0, waitStatus: "Accepted", infoStatus: nil) + let keyFile = fixture.archive.deletingLastPathComponent().appendingPathComponent("AuthKey_TESTKEY.p8") + try Data("test key".utf8).write(to: keyFile) + + let script = """ + unset KP_SIGN_DRY_RUN + source \(signingLibPath) + KP_NOTARY_CMD="\(fixture.stub.path)" + KP_NOTARY_STATE_FILE="\(fixture.state.path)" + KP_NOTARY_KEY_PATH="\(keyFile.path)" + KP_NOTARY_KEY_ID="TESTKEY" + KP_NOTARY_ISSUER="00000000-0000-0000-0000-000000000000" + kp_notarize_zip "\(fixture.archive.path)" "KeyPath-Profile" + """ + let result = runScript(script, env: ["KP_NOTARY_TEST_LOG": fixture.log.path]) + + XCTAssertEqual(result.code, 0, result.stderr) + let invocations = try String(contentsOf: fixture.log, encoding: .utf8) + XCTAssertTrue(invocations.contains("--key \(keyFile.path) --key-id TESTKEY --issuer 00000000-0000-0000-0000-000000000000")) + XCTAssertFalse(invocations.contains("--keychain-profile")) + let state = try notaryState(at: fixture.state) + XCTAssertEqual(state["status"] as? String, "accepted") + } + + func testNotaryWrapperRejectsIncompleteAPIKeyConfiguration() throws { + let fixture = try makeNotaryFixture(waitExit: 0, waitStatus: "Accepted", infoStatus: nil) + let script = """ + unset KP_SIGN_DRY_RUN + source \(signingLibPath) + KP_NOTARY_CMD="\(fixture.stub.path)" + KP_NOTARY_STATE_FILE="\(fixture.state.path)" + KP_NOTARY_KEY_PATH="/tmp/AuthKey_MISSINGBITS.p8" + kp_notarize_zip "\(fixture.archive.path)" "KeyPath-Profile" + """ + let result = runScript(script, env: ["KP_NOTARY_TEST_LOG": fixture.log.path]) + + XCTAssertNotEqual(result.code, 0) + XCTAssertTrue(result.stderr.contains("KP_NOTARY_KEY_ID or KP_NOTARY_ISSUER")) + XCTAssertFalse(FileManager.default.fileExists(atPath: fixture.log.path), "no notarytool call should be made with incomplete key auth") + } + + func testNotaryAwaitSubmissionResumesRecordedSubmission() throws { + let fixture = try makeNotaryFixture(waitExit: 0, waitStatus: "Accepted", infoStatus: nil) + let script = """ + unset KP_SIGN_DRY_RUN + source \(signingLibPath) + KP_NOTARY_CMD="\(fixture.stub.path)" + kp_notary_await_submission "\(submissionID)" "KeyPath-Profile" "\(fixture.state.path)" "\(fixture.archive.path)" "0000000000000000000000000000000000000000000000000000000000000000" + """ + let result = runScript(script, env: ["KP_NOTARY_TEST_LOG": fixture.log.path]) + + XCTAssertEqual(result.code, 0, result.stderr) + XCTAssertTrue(result.stdout.contains("Apple notarization accepted submission \(submissionID)")) + let invocations = try String(contentsOf: fixture.log, encoding: .utf8) + XCTAssertTrue(invocations.contains("wait \(submissionID) --keychain-profile KeyPath-Profile")) + XCTAssertFalse(invocations.contains("submit "), "resume must not re-upload the archive") + let state = try notaryState(at: fixture.state) + XCTAssertEqual(state["status"] as? String, "accepted") + XCTAssertEqual(state["submissionId"] as? String, submissionID) + } + + func testNotaryDefaultAuthPrefersApiKeyFileAndRespectsOptOut() throws { + let directory = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + addTeardownBlock { try? FileManager.default.removeItem(at: directory) } + let keyFile = directory.appendingPathComponent("AuthKey_DEFAULT.p8") + try Data("test key".utf8).write(to: keyFile) + + let script = """ + source \(signingLibPath) + unset KP_NOTARY_KEY_PATH KP_NOTARY_AUTH + KP_NOTARY_DEFAULT_KEY_PATH="\(keyFile.path)" + kp_notary_default_auth_from_environment + echo "auto:${KP_NOTARY_KEY_PATH:-none}" + unset KP_NOTARY_KEY_PATH + KP_NOTARY_AUTH=keychain-profile + kp_notary_default_auth_from_environment + echo "forced:${KP_NOTARY_KEY_PATH:-none}" + """ + let result = runScript(script) + + XCTAssertEqual(result.code, 0, result.stderr) + XCTAssertTrue(result.stdout.contains("auto:\(keyFile.path)")) + XCTAssertTrue(result.stdout.contains("forced:none")) + } + func testPinnedNotarytoolSupportsExplicitNoWait() { let script = """ source Scripts/lib/xcode.sh diff --git a/Tests/KeyPathTests/Lint/DeploymentScriptContractTests.swift b/Tests/KeyPathTests/Lint/DeploymentScriptContractTests.swift index ab8095b81..15bc1d3fd 100644 --- a/Tests/KeyPathTests/Lint/DeploymentScriptContractTests.swift +++ b/Tests/KeyPathTests/Lint/DeploymentScriptContractTests.swift @@ -93,21 +93,34 @@ final class DeploymentScriptContractTests: XCTestCase { } func testBuildAndSignSupportsNonDeployingCandidateBuilds() throws { - let buildAndSign = try contents(of: repositoryRoot().appendingPathComponent("Scripts/build-and-sign.sh")) + let root = repositoryRoot() + let buildAndSign = try contents(of: root.appendingPathComponent("Scripts/build-and-sign.sh")) let guardText = #"if [ "${SKIP_DEPLOY:-0}" = "1" ]; then"# guard let guardRange = buildAndSign.range(of: guardText), - let runtimeStopRange = buildAndSign.range(of: #"pgrep -x "KeyPath""#) + let deployCallRange = buildAndSign.range(of: "kp_deploy_bundle_to_applications") else { return XCTFail("build-and-sign must expose SKIP_DEPLOY before touching the installed runtime") } XCTAssertLessThan( buildAndSign.distance(from: buildAndSign.startIndex, to: guardRange.lowerBound), - buildAndSign.distance(from: buildAndSign.startIndex, to: runtimeStopRange.lowerBound), + buildAndSign.distance(from: buildAndSign.startIndex, to: deployCallRange.lowerBound), "SKIP_DEPLOY must exit before stopping or replacing the installed app." ) XCTAssertTrue(buildAndSign.contains("Candidate retained at: $APP_BUNDLE")) + + // The runtime stop/replace sequence lives in the shared deploy lib that + // runs only after the SKIP_DEPLOY guard. + let deployLib = try contents(of: root.appendingPathComponent("Scripts/lib/deploy-app.sh")) + XCTAssertTrue( + deployLib.contains(#"pgrep -x "$app_name""#), + "deploy lib must stop the running app before replacing the bundle." + ) + XCTAssertFalse( + deployLib.contains("codesign"), + "deploy lib must never re-sign; that detaches the app from its notarization ticket." + ) } func testReleaseBuildReadsVersionMetadataFromPlistPaths() throws { diff --git a/docs/bugs/2026-07-30-rc-notarization-keychain-lock.md b/docs/bugs/2026-07-30-rc-notarization-keychain-lock.md new file mode 100644 index 000000000..a4f1b8295 --- /dev/null +++ b/docs/bugs/2026-07-30-rc-notarization-keychain-lock.md @@ -0,0 +1,57 @@ +# RC notarization failed mid-build on locked data-protection keychain; recovery left an unstapleable install + +**Date:** 2026-07-30 ยท **Area:** release scripts (notarization, deploy) + +## What happened + +A `Scripts/release-candidate.sh` run started at 23:38 local. `release-doctor.sh` +validated the `KeyPath-Profile` notarytool profile at the start of the run, but +nine minutes later โ€” after the build finished โ€” `notarytool submit` failed with +exit 69: `No Keychain password item found for profile: KeyPath-Profile` +(recorded in `dist/KeyPath.notary-submission.json` as `submit-client-failed`). +The RC script aborted correctly at that point (`set -euo pipefail`); nothing +was deployed by the RC run. + +The installed app went bad through a different path: a later `ui-deploy.sh` +run replaced `/Applications/KeyPath.app` with a locally re-signed build. When +the notarization was manually resubmitted and accepted the next day, the +ticket could not be stapled onto the installed copy because the local re-sign +had changed the cdhash. Recovery required extracting `dist/KeyPath.zip`, +stapling the extracted app, and swapping it into `/Applications`. + +## Root causes + +1. **Keychain credential is lock-state dependent.** notarytool (Xcode 26.x) + stores keychain profiles in the *data-protection* keychain โ€” the item is not + in `login.keychain-db` at all. Data-protection items are only readable while + the login session is unlocked, so the profile "disappears" when the screen + locks mid-build. `security unlock-keychain` cannot help (it only unlocks + file keychains), and the doctor preflight passes because the user is still + present when it runs. +2. **No resume path.** After a notarization failure there was no scripted way + to finish the submission, staple `dist/`, and deploy; ad-hoc recovery mixed + in dev-deploy scripts that re-sign, which detaches the app from its ticket. + +## Fixes (PR) + +- Notarization now prefers the file-based App Store Connect API key + (`kp_notary_default_auth_from_environment` in `Scripts/lib/signing.sh`), + which has no keychain/lock-state dependency. `release-doctor.sh` validates + whichever auth path the build will actually use. +- `Scripts/release-candidate.sh --resume-notarization` / + `Scripts/notarize-resume.sh`: finish the recorded submission (or resubmit the + identical archive), staple `dist/KeyPath.app`, and deploy it verbatim. +- `build-and-sign.sh` refuses to deploy a bundle without a stapled ticket when + notarization was requested, and both deploy paths go through + `Scripts/lib/deploy-app.sh`, which copies with `ditto` and never re-signs. + +## Lessons + +- Registration-time validation is not runtime validation: a credential check at + the start of a long build says nothing about availability at submit time. + Prefer credentials with no session/lock dependency for anything that runs + unattended (same lesson as the gws keychain saga). +- Never re-sign a notarized artifact. The stapled `dist/` bundle is the only + thing that may be copied to `/Applications` for a release candidate; if a dev + deploy (`quick-deploy.sh`/`ui-deploy.sh`) overwrote it, re-deploy the stapled + bundle rather than trying to staple the installed copy. diff --git a/docs/process/release-process.md b/docs/process/release-process.md index d15fecd4b..6949f4c82 100644 --- a/docs/process/release-process.md +++ b/docs/process/release-process.md @@ -95,6 +95,29 @@ when Apple still reports `In Progress`, reserving a distinct transient result for automation even though the current release entry points stop on any nonzero result. Permanent statuses such as `Invalid` return exit 1. +To finish an interrupted run without rebuilding, use the resume path: + +```bash +./Scripts/release-candidate.sh --resume-notarization +``` + +It re-reads the recovery state, verifies `dist/KeyPath.zip` still has the +recorded checksum, waits on the recorded submission (or resubmits that exact +archive when nothing reached Apple; set `KP_NOTARY_SUBMISSION_ID` to pick one +of several recorded candidates), staples `dist/KeyPath.app`, and deploys that +stapled bundle verbatim. Deploys never re-sign: a post-notarization re-sign +changes the cdhash and permanently detaches the installed app from its ticket. +`build-and-sign.sh` also refuses to deploy a bundle without a stapled ticket +whenever notarization was requested. + +Notarization credentials: the scripts prefer the file-based App Store Connect +API key (`~/.appstoreconnect/private_keys/AuthKey_XQ4565NYZ7.p8`, overridable +via `KP_NOTARY_KEY_PATH`/`KP_NOTARY_KEY_ID`/`KP_NOTARY_ISSUER`) over the +`KeyPath-Profile` keychain profile. notarytool stores profiles in the +data-protection keychain, which becomes unreadable when the login session +locks mid-build; the API key file has no lock-state dependency. Set +`KP_NOTARY_AUTH=keychain-profile` to force profile auth. + Opt into slower work only when needed: ```bash From 02e1309a55981fb282069d5d2508a592e38bf6b8 Mon Sep 17 00:00:00 2001 From: Micah Alpern Date: Thu, 30 Jul 2026 18:12:21 -0700 Subject: [PATCH 2/2] fix: address review findings on resume safeguards and auth override - KP_NOTARY_AUTH=keychain-profile now beats a populated KP_NOTARY_KEY_PATH in kp_notary_resolve_auth, so a stale API key can be bypassed without unsetting variables (new SigningPipelineTests coverage). - notarize-resume falls back to the profile recorded in the recovery state when NOTARY_PROFILE is not overridden. - notarize-resume no longer treats an empty recorded candidate list as proof nothing reached Apple: it requires a successful notarytool history lookup now and refuses to resubmit when history is unavailable or shows fresh submissions of the archive (KP_NOTARY_SUBMISSION_ID overrides). - release-candidate.sh reads the recovery status via kp_notary_json_field instead of grepping the JSON formatting. Co-Authored-By: Claude Fable 5 --- Scripts/lib/signing.sh | 6 ++- Scripts/notarize-resume.sh | 43 ++++++++++++++++++- Scripts/release-candidate.sh | 7 ++- .../BuildScripts/SigningPipelineTests.swift | 24 +++++++++++ 4 files changed, 76 insertions(+), 4 deletions(-) diff --git a/Scripts/lib/signing.sh b/Scripts/lib/signing.sh index a8bc9c167..9d1457844 100644 --- a/Scripts/lib/signing.sh +++ b/Scripts/lib/signing.sh @@ -44,10 +44,12 @@ kp_notary_default_auth_from_environment() { # 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; the library never auto-detects on its own. +# 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:-}" ]; then + 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 diff --git a/Scripts/notarize-resume.sh b/Scripts/notarize-resume.sh index 43f048818..57bc15304 100755 --- a/Scripts/notarize-resume.sh +++ b/Scripts/notarize-resume.sh @@ -35,7 +35,6 @@ DIST_DIR="dist" APP_BUNDLE="${DIST_DIR}/${APP_NAME}.app" ZIP_PATH="${DIST_DIR}/${APP_NAME}.zip" STATE_FILE=${KP_NOTARY_STATE_FILE:-"${ZIP_PATH%.zip}.notary-submission.json"} -NOTARY_PROFILE="${NOTARY_PROFILE:-KeyPath-Profile}" for required in "$APP_BUNDLE" "$ZIP_PATH" "$STATE_FILE"; do if [ ! -e "$required" ]; then @@ -47,6 +46,11 @@ done recorded_sha=$(kp_notary_json_field archiveSHA256 < "$STATE_FILE" 2>/dev/null) || recorded_sha="" recorded_status=$(kp_notary_json_field status < "$STATE_FILE" 2>/dev/null) || recorded_status="" recorded_submission_id=$(kp_notary_json_field submissionId < "$STATE_FILE" 2>/dev/null) || recorded_submission_id="" +recorded_profile=$(kp_notary_json_field profile < "$STATE_FILE" 2>/dev/null) || recorded_profile="" + +# Fall back to the profile the failed release actually used, so a resume +# without the original NOTARY_PROFILE override still authenticates. +NOTARY_PROFILE="${NOTARY_PROFILE:-${recorded_profile:-KeyPath-Profile}}" current_sha=$(/usr/bin/shasum -a 256 "$ZIP_PATH" | /usr/bin/awk '{print $1}') if [ -z "$recorded_sha" ] || [ "$current_sha" != "$recorded_sha" ]; then @@ -77,6 +81,43 @@ for candidate in json.load(sys.stdin).get("candidateSubmissions") or []: echo " Re-run with KP_NOTARY_SUBMISSION_ID= to wait on one of them." >&2 exit 1 fi + # An empty recorded candidate list is not proof nothing reached Apple: the + # original history lookup may itself have failed or lagged the upload. + # Require a successful, authoritative history check now before resubmitting. + echo "๐Ÿ”Ž Checking Apple submission history before resubmitting..." + kp_notary_resolve_auth "$NOTARY_PROFILE" + history_output="" + if ! history_output=$($KP_NOTARY_CMD history "${KP_NOTARY_AUTH_ARGS[@]}" --output-format json 2>/dev/null); then + echo "โŒ notarytool history is unavailable; refusing to resubmit without positive evidence" >&2 + echo " that the failed upload never reached Apple (a blind retry can create duplicates)." >&2 + echo " Fix credentials/connectivity and re-run, or wait on a known submission with" >&2 + echo " KP_NOTARY_SUBMISSION_ID=." >&2 + exit 1 + fi + failure_epoch=$(/usr/bin/python3 - "$STATE_FILE" <<'PY' +import datetime +import json +import sys + +try: + updated_at = json.load(open(sys.argv[1])).get("updatedAt") + print(int(datetime.datetime.fromisoformat(updated_at.replace("Z", "+00:00")).timestamp())) +except Exception: + print(0) # unparseable -> widest window, which can only add caution +PY +) + fresh_candidates=$(kp_notary_recent_history_candidates "$history_output" "$(basename "$ZIP_PATH")" "$failure_epoch") || fresh_candidates="[]" + fresh_count=$(printf '%s' "$fresh_candidates" | /usr/bin/python3 -c 'import json, sys; print(len(json.load(sys.stdin)))') || fresh_count=0 + if [ "$fresh_count" -gt 0 ]; then + echo "โŒ Apple history shows submissions of $(basename "$ZIP_PATH") around the failed run; refusing to resubmit." >&2 + printf '%s' "$fresh_candidates" | /usr/bin/python3 -c ' +import json, sys +for candidate in json.load(sys.stdin): + print(" {} {} {}".format(candidate.get("id"), candidate.get("status"), candidate.get("createdDate"))) +' >&2 + echo " Re-run with KP_NOTARY_SUBMISSION_ID= to wait on one of them." >&2 + exit 1 + fi echo "๐Ÿ” No submission reached Apple; resubmitting the recorded archive..." kp_notarize_zip "$ZIP_PATH" "$NOTARY_PROFILE" fi diff --git a/Scripts/release-candidate.sh b/Scripts/release-candidate.sh index f439b2f6f..0a3cb5eaa 100755 --- a/Scripts/release-candidate.sh +++ b/Scripts/release-candidate.sh @@ -3,6 +3,7 @@ set -euo pipefail SCRIPT_DIR=$(cd "$(dirname "$0")" >/dev/null && pwd) PROJECT_DIR="$SCRIPT_DIR/.." +source "$SCRIPT_DIR/lib/signing.sh" VERIFY=1 DOCTOR=1 @@ -125,7 +126,11 @@ if [[ "$build_status" -ne 0 ]]; then echo "" >&2 echo "โŒ Release candidate failed (build-and-sign exit $build_status)." >&2 state_file="${KP_NOTARY_STATE_FILE:-dist/KeyPath.notary-submission.json}" - if [[ -f "$state_file" ]] && ! grep -q '"status": "accepted"' "$state_file"; then + notary_status="" + if [[ -f "$state_file" ]]; then + notary_status=$(kp_notary_json_field status < "$state_file" 2>/dev/null) || notary_status="" + fi + if [[ -f "$state_file" && "$notary_status" != "accepted" ]]; then echo " Notarization did not complete; the app was NOT deployed to /Applications." >&2 echo " Recovery state: $state_file" >&2 echo " Resume without rebuilding once credentials/Apple are sorted:" >&2 diff --git a/Tests/KeyPathTests/BuildScripts/SigningPipelineTests.swift b/Tests/KeyPathTests/BuildScripts/SigningPipelineTests.swift index 88840e643..a01ed8e06 100644 --- a/Tests/KeyPathTests/BuildScripts/SigningPipelineTests.swift +++ b/Tests/KeyPathTests/BuildScripts/SigningPipelineTests.swift @@ -423,6 +423,30 @@ final class SigningPipelineTests: XCTestCase { XCTAssertEqual(state["submissionId"] as? String, submissionID) } + func testNotaryForcedKeychainProfileBeatsPopulatedKeyPath() throws { + let fixture = try makeNotaryFixture(waitExit: 0, waitStatus: "Accepted", infoStatus: nil) + let keyFile = fixture.archive.deletingLastPathComponent().appendingPathComponent("AuthKey_STALE.p8") + try Data("stale key".utf8).write(to: keyFile) + + let script = """ + unset KP_SIGN_DRY_RUN + source \(signingLibPath) + KP_NOTARY_CMD="\(fixture.stub.path)" + KP_NOTARY_STATE_FILE="\(fixture.state.path)" + KP_NOTARY_KEY_PATH="\(keyFile.path)" + KP_NOTARY_KEY_ID="STALE" + KP_NOTARY_ISSUER="00000000-0000-0000-0000-000000000000" + KP_NOTARY_AUTH=keychain-profile + kp_notarize_zip "\(fixture.archive.path)" "KeyPath-Profile" + """ + let result = runScript(script, env: ["KP_NOTARY_TEST_LOG": fixture.log.path]) + + XCTAssertEqual(result.code, 0, result.stderr) + let invocations = try String(contentsOf: fixture.log, encoding: .utf8) + XCTAssertTrue(invocations.contains("--keychain-profile KeyPath-Profile")) + XCTAssertFalse(invocations.contains("--key "), "forced keychain-profile auth must ignore a populated key path") + } + func testNotaryDefaultAuthPrefersApiKeyFileAndRespectsOptOut() throws { let directory = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) .appendingPathComponent(UUID().uuidString, isDirectory: true)