#!/bin/bash
# frozen_string_literal: false
#
# Unified Release Script
# Builds, signs, notarizes, and packages a ZIP for any SaneApps project
#

set -eE

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SECRETS_ENV_CACHE_FILE="${SANE_ENV_CACHE_FILE:-$HOME/.config/nv/env}"
KEYCHAIN_FALLBACK_ENABLED="${SANE_KEYCHAIN_FALLBACK:-1}"
[ "${SANE_NO_KEYCHAIN:-0}" = "1" ] && KEYCHAIN_FALLBACK_ENABLED="0"

# Ensure Homebrew-installed tools resolve in non-interactive shells (CI/SSH).
export PATH="/opt/homebrew/bin:/usr/local/bin:${PATH}"

# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color

# Shared Sparkle public key for all SaneApps direct-distribution builds.
SHARED_SPARKLE_PUBLIC_KEY="7Pl/8cwfb2vm4Dm65AByslkMCScLJ9tbGlwGGx81qYU="
RELEASE_STARTED_AT_UTC="$(date -u +"%Y-%m-%dT%H:%M:%SZ")"
RELEASE_RUN_ID="$(date -u +"%Y%m%dT%H%M%SZ")-$$"
RELEASE_LEDGER_FILE="${RELEASE_LEDGER_FILE:-$(cd "${SCRIPT_DIR}/.." && pwd)/outputs/release_failure_ledger.jsonl}"
RELEASE_LAST_ERROR=""
RELEASE_LAST_ERR_LINE=""
RELEASE_LAST_ERR_COMMAND=""
CURRENT_GATE=""
RELEASE_ERRORS=()
RELEASE_ERR_GATE_RECORDED=""
RELEASE_TEMP_PATHS=()

log_info() {
    echo -e "${GREEN}[INFO]${NC} $1"
}

log_warn() {
    echo -e "${YELLOW}[WARN]${NC} $1"
}

log_error() {
    echo -e "${RED}[ERROR]${NC} $1"
    RELEASE_LAST_ERROR="$1"
    RELEASE_ERRORS+=("$1")
}

run_logged_command() {
    local log_path="$1"
    shift

    local pipe_status=()
    local command_status=0
    local tee_status=0

    set +e
    "$@" 2>&1 | tee -a "${log_path}"
    pipe_status=("${PIPESTATUS[@]}")
    set -e

    command_status="${pipe_status[0]:-0}"
    tee_status="${pipe_status[1]:-0}"

    if [ "${tee_status}" -ne 0 ] && [ "${tee_status}" -ne 141 ]; then
        log_warn "Log stream helper exited ${tee_status} while writing ${log_path}; using command exit ${command_status}."
    fi

    return "${command_status}"
}

running_on_mini_host() {
    case "${PROJECT_ROOT:-$PWD}" in
        /Users/stephansmac/*)
            return 0
            ;;
        *)
            return 1
            ;;
    esac
}

mini_gui_runner_path() {
    printf '%s/mini/mini-gui-run.sh' "${SCRIPT_DIR}"
}

appstore_gui_fallback_marker_present() {
    local log_path="$1"
    [ -f "${log_path}" ] || return 1
    grep -Eq \
        'errSecInternalComponent|doesn.t include signing certificate|No Accounts|No profiles for|conflicting provisioning settings' \
        "${log_path}"
}

run_mini_gui_logged_command() {
    local log_path="$1"
    local title="$2"
    local working_dir="$3"
    shift 3

    local runner
    local inner_command=""
    local command_string=""
    local gui_log_path="${log_path}.gui"
    local env_source=""

    runner="$(mini_gui_runner_path)"
    [ -x "${runner}" ] || return 1

    if [ -n "${SECRETS_ENV_CACHE_FILE:-}" ] && [ -f "${SECRETS_ENV_CACHE_FILE}" ]; then
        printf -v env_source '. %q >/dev/null 2>&1 || true; ' "${SECRETS_ENV_CACHE_FILE}"
    fi

    printf -v inner_command '%q ' "$@"
    inner_command="${inner_command% }"

    if [ -n "${working_dir}" ]; then
        printf -v command_string '%scd %q && %s' "${env_source}" "${working_dir}" "${inner_command}"
    else
        command_string="${env_source}${inner_command}"
    fi

    run_logged_command "${log_path}" \
        "${runner}" \
        --title "${title}" \
        --log-file "${gui_log_path}" \
        --close-window \
        -- "${command_string}"
}

release_mode_label() {
    if [ "${PREFLIGHT_ONLY:-false}" = true ]; then
        echo "preflight"
        return
    fi
    if [ "${WEBSITE_ONLY:-false}" = true ]; then
        echo "website-only"
        return
    fi
    if [ "${RUN_DEPLOY:-false}" = true ]; then
        if [ "${FULL_RELEASE:-false}" = true ]; then
            echo "full+deploy"
        else
            echo "deploy"
        fi
        return
    fi
    if [ "${FULL_RELEASE:-false}" = true ]; then
        echo "full"
        return
    fi
    echo "build"
}

mini_route_context_path() {
    printf '%s/.sanemaster/mini_route_context.json' "${PROJECT_ROOT}"
}

mini_route_workspace_field() {
    local field_path="$1"
    local context_path
    context_path="$(mini_route_context_path)"
    [ -f "${context_path}" ] || return 1

    python3 - "${context_path}" "${field_path}" <<'PY'
import json
import sys

path, field_path = sys.argv[1], sys.argv[2]
with open(path, encoding="utf-8") as handle:
    payload = json.load(handle)

value = payload.get("workspace", {})
for part in field_path.split("."):
    if not isinstance(value, dict) or part not in value:
        sys.exit(1)
    value = value[part]

if value is None:
    sys.exit(1)
if isinstance(value, bool):
    print("true" if value else "false")
elif isinstance(value, (dict, list)):
    print(json.dumps(value))
else:
    print(value)
PY
}

mini_route_webhook_field() {
    local field_path="$1"
    local context_path
    context_path="$(mini_route_context_path)"
    [ -f "${context_path}" ] || return 1

    python3 - "${context_path}" "${field_path}" <<'PY'
import json
import sys

path, field_path = sys.argv[1], sys.argv[2]
with open(path, encoding="utf-8") as handle:
    payload = json.load(handle)

value = payload.get("webhook", {})
for part in field_path.split("."):
    if not isinstance(value, dict) or part not in value:
        sys.exit(1)
    value = value[part]

if value is None:
    sys.exit(1)
if isinstance(value, bool):
    print("true" if value else "false")
elif isinstance(value, (dict, list)):
    print(json.dumps(value))
else:
    print(value)
PY
}

mini_route_context_active() {
    [ -f "$(mini_route_context_path)" ]
}

normalize_failure_signature() {
    local raw="$1"
    if [ -z "${raw}" ]; then
        echo ""
        return 0
    fi
    printf '%s' "${raw}" | \
        tr '[:upper:]' '[:lower:]' | \
        sed -E \
            -e 's#/[A-Za-z0-9._/-]+#<path>#g' \
            -e 's/[0-9]{8,}/<num>/g' \
            -e 's/v[0-9]+\.[0-9]+(\.[0-9]+)?/v<ver>/g' \
            -e 's/[[:space:]]+/ /g' \
            -e 's/^ //; s/ $//'
}

append_release_ledger_entry() {
    local event="$1"
    local status="$2"
    local gate="$3"
    local message="$4"
    local signature="$5"

    mkdir -p "$(dirname "${RELEASE_LEDGER_FILE}")" >/dev/null 2>&1 || true
    local max_lines="${RELEASE_LEDGER_MAX_LINES:-6000}"

    local branch=""
    if [ -n "${PROJECT_ROOT:-}" ]; then
        branch=$(git -C "${PROJECT_ROOT}" rev-parse --abbrev-ref HEAD 2>/dev/null || true)
    fi

    LEDGER_FILE="${RELEASE_LEDGER_FILE}" \
    LEDGER_EVENT="${event}" \
    LEDGER_STATUS="${status}" \
    LEDGER_GATE="${gate}" \
    LEDGER_MESSAGE="${message}" \
    LEDGER_SIGNATURE="${signature}" \
    LEDGER_TIMESTAMP="$(date -u +"%Y-%m-%dT%H:%M:%SZ")" \
    LEDGER_RUN_ID="${RELEASE_RUN_ID}" \
    LEDGER_STARTED_AT="${RELEASE_STARTED_AT_UTC}" \
    LEDGER_PROJECT="${PROJECT_ROOT:-}" \
    LEDGER_APP="${APP_NAME:-}" \
    LEDGER_MODE="$(release_mode_label)" \
    LEDGER_VERSION="${VERSION:-}" \
    LEDGER_BUILD="${BUILD_NUMBER:-}" \
    LEDGER_HOST="$(hostname)" \
    LEDGER_USER="${USER:-}" \
    LEDGER_BRANCH="${branch}" \
    LEDGER_COMMAND="${RELEASE_LAST_ERR_COMMAND:-}" \
    LEDGER_LINE="${RELEASE_LAST_ERR_LINE:-}" \
    LEDGER_MAX_LINES="${max_lines}" \
    python3 - <<'PY' >/dev/null 2>&1 || true
import fcntl
import json
import os

path = os.environ["LEDGER_FILE"]
max_lines = int(os.environ.get("LEDGER_MAX_LINES", "6000"))
os.makedirs(os.path.dirname(path), exist_ok=True)

entry = {
    "timestamp_utc": os.environ.get("LEDGER_TIMESTAMP", ""),
    "event": os.environ.get("LEDGER_EVENT", ""),
    "status": os.environ.get("LEDGER_STATUS", ""),
    "run_id": os.environ.get("LEDGER_RUN_ID", ""),
    "run_started_at_utc": os.environ.get("LEDGER_STARTED_AT", ""),
    "project_root": os.environ.get("LEDGER_PROJECT", ""),
    "app_name": os.environ.get("LEDGER_APP", ""),
    "mode": os.environ.get("LEDGER_MODE", ""),
    "gate": os.environ.get("LEDGER_GATE", ""),
    "message": os.environ.get("LEDGER_MESSAGE", ""),
    "failure_signature": os.environ.get("LEDGER_SIGNATURE", ""),
    "version": os.environ.get("LEDGER_VERSION", ""),
    "build_number": os.environ.get("LEDGER_BUILD", ""),
    "host": os.environ.get("LEDGER_HOST", ""),
    "user": os.environ.get("LEDGER_USER", ""),
    "branch": os.environ.get("LEDGER_BRANCH", ""),
    "last_failed_command": os.environ.get("LEDGER_COMMAND", ""),
    "last_failed_line": os.environ.get("LEDGER_LINE", ""),
}

with open(path, "a+", encoding="utf-8") as f:
    fcntl.flock(f.fileno(), fcntl.LOCK_EX)
    f.seek(0)
    lines = f.readlines()
    lines.append(json.dumps(entry, ensure_ascii=True) + "\n")
    if len(lines) > max_lines:
        lines = lines[-max_lines:]
    f.seek(0)
    f.truncate(0)
    f.writelines(lines)
    f.flush()
    os.fsync(f.fileno())
    fcntl.flock(f.fileno(), fcntl.LOCK_UN)
PY
}

track_gate_result() {
    local gate="$1"
    local status="$2"
    local message="$3"
    local signature=""
    if [ "${status}" = "failure" ]; then
        signature="$(normalize_failure_signature "${gate} :: ${message}")"
    fi
    append_release_ledger_entry "gate" "${status}" "${gate}" "${message}" "${signature}"
}

release_err_trap() {
    local exit_code="$1"
    local line="$2"
    local cmd="$3"
    set +e
    RELEASE_LAST_ERR_LINE="${line}"
    RELEASE_LAST_ERR_COMMAND="${cmd}"
    if [ -z "${RELEASE_LAST_ERROR}" ]; then
        RELEASE_LAST_ERROR="Command failed (exit ${exit_code}) at line ${line}: ${cmd}"
        RELEASE_ERRORS+=("${RELEASE_LAST_ERROR}")
    fi
    if [ -n "${CURRENT_GATE}" ] && [ "${RELEASE_ERR_GATE_RECORDED}" != "${CURRENT_GATE}" ]; then
        track_gate_result "${CURRENT_GATE}" "failure" "${RELEASE_LAST_ERROR}"
        RELEASE_ERR_GATE_RECORDED="${CURRENT_GATE}"
    fi
}

release_exit_trap() {
    local exit_code="$1"
    set +e

    if [ "${#RELEASE_TEMP_PATHS[@]}" -gt 0 ]; then
        local temp_path=""
        for temp_path in "${RELEASE_TEMP_PATHS[@]}"; do
            [ -n "${temp_path}" ] || continue
            remove_path "${temp_path}" >/dev/null 2>&1 || true
        done
    fi

    local status="success"
    local summary="release completed"
    local signature=""
    if [ "${exit_code}" -ne 0 ]; then
        status="failure"
        if [ -n "${RELEASE_LAST_ERROR}" ]; then
            summary="${RELEASE_LAST_ERROR}"
        else
            summary="release failed with exit code ${exit_code}"
        fi
        signature="$(normalize_failure_signature "${CURRENT_GATE} :: ${summary}")"
    fi

    append_release_ledger_entry "run" "${status}" "${CURRENT_GATE}" "${summary}" "${signature}"
}

trap 'release_err_trap $? ${LINENO} "$BASH_COMMAND"' ERR
trap 'release_exit_trap $?' EXIT

check_recent_unresolved_failures_gate() {
    if [ "${ALLOW_REPEAT_FAILURE}" = true ]; then
        log_warn "Repeat-failure guard bypassed (--allow-repeat-failure)."
        return 0
    fi

    if [ ! -f "${RELEASE_LEDGER_FILE}" ]; then
        return 0
    fi

    local threshold="${RELEASE_REPEAT_FAIL_THRESHOLD:-2}"
    local lookback_hours="${RELEASE_REPEAT_FAIL_LOOKBACK_HOURS:-72}"
    local unresolved
    unresolved=$(PROJECT_FILTER="${PROJECT_ROOT}" APP_FILTER="${APP_NAME}" THRESHOLD="${threshold}" LOOKBACK_HOURS="${lookback_hours}" LEDGER_FILE="${RELEASE_LEDGER_FILE}" python3 - <<'PY'
import datetime as dt
import json
import os

path = os.environ.get("LEDGER_FILE", "")
project = os.environ.get("PROJECT_FILTER", "")
app = os.environ.get("APP_FILTER", "")
threshold = int(os.environ.get("THRESHOLD", "2"))
lookback_hours = int(os.environ.get("LOOKBACK_HOURS", "72"))

if not path:
    raise SystemExit(0)

now = dt.datetime.now(dt.timezone.utc)
cutoff = now - dt.timedelta(hours=lookback_hours)

events = []
with open(path, "r", encoding="utf-8") as f:
    for line in f:
        line = line.strip()
        if not line:
            continue
        try:
            row = json.loads(line)
        except json.JSONDecodeError:
            continue
        if row.get("event") != "run":
            continue
        if project and row.get("project_root") != project:
            continue
        if app and row.get("app_name") and row.get("app_name") != app:
            continue
        ts = row.get("timestamp_utc", "")
        try:
            when = dt.datetime.fromisoformat(ts.replace("Z", "+00:00"))
        except Exception:
            continue
        if when < cutoff:
            continue
        events.append((when, row))

events.sort(key=lambda x: x[0])
if not events:
    raise SystemExit(0)

fail_after_success = []
for when, row in events:
    if row.get("status") == "success":
        fail_after_success = []
        continue
    fail_after_success.append(row)

if len(fail_after_success) >= threshold:
    for row in fail_after_success[-threshold:]:
        print(f"{row.get('timestamp_utc','')} :: {row.get('message','')}")
PY
)

    if [ -n "${unresolved}" ]; then
        log_error "Repeat-failure guard tripped (${threshold} failures without a successful run in the last ${lookback_hours}h)."
        while IFS= read -r row; do
            [ -n "${row}" ] && log_error "  ${row}"
        done <<< "${unresolved}"
        log_error "Fix root cause before retrying, or bypass once with --allow-repeat-failure."
        return 1
    fi

    return 0
}

run_required_step() {
    local gate="$1"
    shift
    CURRENT_GATE="${gate}"
    RELEASE_ERR_GATE_RECORDED=""
    if "$@"; then
        track_gate_result "${gate}" "pass" "ok"
        CURRENT_GATE=""
        return 0
    fi
    local step_error="${RELEASE_LAST_ERROR:-step failed}"
    track_gate_result "${gate}" "failure" "${step_error}"
    CURRENT_GATE=""
    return 1
}

extract_http_status() {
    local url="$1"
    curl --connect-timeout 10 --max-time 20 -sI -o /dev/null -w '%{http_code}' "${url}" 2>/dev/null || echo "000"
}

extract_http_status_with_user_agent() {
    local url="$1"
    local user_agent="$2"
    curl --connect-timeout 10 --max-time 20 -A "${user_agent}" -sI -o /dev/null -w '%{http_code}' "${url}" 2>/dev/null || echo "000"
}

extract_http_effective_url() {
    local url="$1"
    local user_agent="${2:-Mozilla/5.0}"
    curl -A "${user_agent}" -sSL -o /dev/null -w '%{url_effective}' "${url}" 2>/dev/null || true
}

extract_redirect_location() {
    local url="$1"
    local user_agent="${2:-Mozilla/5.0}"
    curl --connect-timeout 10 --max-time 20 -A "${user_agent}" -sSI "${url}" 2>/dev/null | awk 'tolower($1)=="location:" {sub(/\r$/, "", $2); print $2; exit}'
}

lookup_checkout_uuid_for_slug() {
    local slug="$1"
    ruby -ryaml -e '
      cfg = YAML.load_file(ARGV[0]) || {}
      product = cfg.fetch("products", {}).fetch(ARGV[1], {})
      puts(product.fetch("checkout_uuid", "").to_s)
    ' "${SCRIPT_DIR}/../config/products.yml" "${slug}" 2>/dev/null || true
}

extract_content_length() {
    local url="$1"
    curl --connect-timeout 10 --max-time 20 -sI "${url}" 2>/dev/null | awk 'tolower($1)=="content-length:" {gsub("\r","",$2); print $2}' | tail -1
}

extract_content_length_with_user_agent() {
    local url="$1"
    local user_agent="$2"
    curl --connect-timeout 10 --max-time 20 -A "${user_agent}" -sI "${url}" 2>/dev/null | awk 'tolower($1)=="content-length:" {gsub("\r","",$2); print $2}' | tail -1
}

lookup_live_app_store_url() {
    local app_id="$1"
    local country="${2:-us}"
    if [ -z "${app_id}" ] || ! [[ "${app_id}" =~ ^[0-9]+$ ]]; then
        echo ""
        return 0
    fi

    local lookup_url="https://itunes.apple.com/lookup?id=${app_id}&country=${country}&entity=software"
    local payload
    payload=$(curl -fsSL "${lookup_url}" 2>/dev/null || true)
    if [ -z "${payload}" ]; then
        echo ""
        return 0
    fi

    APPSTORE_LOOKUP_JSON="${payload}" python3 - <<'PY'
import json
import os
import sys

raw = os.environ.get("APPSTORE_LOOKUP_JSON", "")
if not raw:
    print("")
    sys.exit(0)

try:
    data = json.loads(raw)
except Exception:
    print("")
    sys.exit(0)

results = data.get("results")
if not isinstance(results, list):
    print("")
    sys.exit(0)

for item in results:
    if not isinstance(item, dict):
        continue
    url = item.get("trackViewUrl") or item.get("artistViewUrl") or ""
    if isinstance(url, str) and "apps.apple.com" in url:
        print(url.split("?")[0])
        sys.exit(0)

print("")
PY
}

update_app_store_link_markers_in_file() {
    local file_path="$1"
    local live_url="$2"
    APPSTORE_LINK_FILE="${file_path}" APPSTORE_LINK_URL="${live_url}" python3 - <<'PY'
import os
import re

path = os.environ["APPSTORE_LINK_FILE"]
url = os.environ["APPSTORE_LINK_URL"]

with open(path, "r", encoding="utf-8") as f:
    content = f.read()

original = content

anchor_re = re.compile(r'<a\b([^>]*)>', flags=re.IGNORECASE | re.DOTALL)
href_re = re.compile(r'href="[^"]*"', flags=re.IGNORECASE)
style_re = re.compile(r'\sstyle="display:\s*none;?"', flags=re.IGNORECASE)
data_url_re = re.compile(r'data-appstore-ios-url="[^"]*"', flags=re.IGNORECASE)

def patch_anchor(match):
    attrs = match.group(1)
    if "data-appstore-ios-link" not in attrs.lower():
        return match.group(0)

    if href_re.search(attrs):
        attrs = href_re.sub(f'href="{url}"', attrs, count=1)
    else:
        attrs += f' href="{url}"'

    attrs = style_re.sub("", attrs)

    if data_url_re.search(attrs):
        attrs = data_url_re.sub(f'data-appstore-ios-url="{url}"', attrs, count=1)
    else:
        attrs += f' data-appstore-ios-url="{url}"'

    return f"<a{attrs}>"

updated = anchor_re.sub(patch_anchor, content)
if updated != original:
    with open(path, "w", encoding="utf-8") as f:
        f.write(updated)
    print("updated")
else:
    print("noop")
PY
}

appstore_platform_tokens() {
    local raw_platforms=""
    local parsed_platforms=()
    if declare -p APPSTORE_PLATFORMS >/dev/null 2>&1; then
        raw_platforms="${APPSTORE_PLATFORMS[*]}"
    else
        raw_platforms="${APPSTORE_PLATFORMS:-macos}"
    fi

    raw_platforms="${raw_platforms//,/ }"
    read -r -a parsed_platforms <<< "${raw_platforms}"
    printf '%s\n' "${parsed_platforms[@]}"
}

appstore_platform_enabled() {
    local target="$1"
    appstore_platform_tokens | grep -qx "${target}"
}

update_website_app_store_links_if_live() {
    if [ "${APPSTORE_ENABLED}" != "true" ]; then
        return 0
    fi
    if ! appstore_platform_enabled "ios"; then
        return 0
    fi
    if [ -z "${APPSTORE_APP_ID}" ] || ! [[ "${APPSTORE_APP_ID}" =~ ^[0-9]+$ ]]; then
        log_warn "Skipping App Store website URL update: APPSTORE_APP_ID missing/invalid."
        return 0
    fi

    local live_url
    live_url=$(lookup_live_app_store_url "${APPSTORE_APP_ID}" "${APPSTORE_LOOKUP_COUNTRY:-us}")
    if [ -z "${live_url}" ]; then
        log_info "No live App Store URL found yet for app id ${APPSTORE_APP_ID}; website App Store links unchanged."
        return 0
    fi

    local markers_found=false
    for site_dir in "${PROJECT_ROOT}/docs" "${PROJECT_ROOT}/website"; do
        local index_html="${site_dir}/index.html"
        if [ ! -f "${index_html}" ]; then
            continue
        fi
        if ! grep -q "data-appstore-ios-link" "${index_html}" 2>/dev/null; then
            continue
        fi
        markers_found=true
        local patch_result
        patch_result=$(update_app_store_link_markers_in_file "${index_html}" "${live_url}")
        if [ "${patch_result}" = "updated" ]; then
            log_info "Updated App Store iOS link marker in $(basename "${site_dir}")/index.html → ${live_url}"
        else
            log_info "App Store iOS link marker already current in $(basename "${site_dir}")/index.html"
        fi
    done

    if [ "${markers_found}" = false ]; then
        log_warn "Live App Store URL found (${live_url}) but no data-appstore-ios-link markers exist in docs/website index.html."
    fi
}

# Format release notes as clean HTML bullet list for Sparkle appcast.
# Hard rules: plain English, never scare customers, beautifully formatted.
# Input: newline-separated notes (one bullet per line)
# Output: HTML <ul><li> list with "What's New" heading
release_notes_required_for_mode() {
    if [ "${PREFLIGHT_ONLY}" = true ] || [ "${FULL_RELEASE}" = true ] || [ "${RUN_DEPLOY}" = true ] || [ "${RUN_GH_RELEASE}" = true ]; then
        return 0
    fi
    return 1
}

validate_release_notes_text() {
    local raw_notes="$1"
    local require_notes="$2"
    local item_count=0

    if [ "${require_notes}" = "true" ] && [ -z "$(echo "${raw_notes}" | tr -d '[:space:]')" ]; then
        log_error "Release notes are required for this release mode."
        log_error "Pass --notes with plain-English customer-facing bullets."
        return 1
    fi

    if [ -z "$(echo "${raw_notes}" | tr -d '[:space:]')" ]; then
        return 0
    fi

    local normalized_notes
    normalized_notes="$(printf '%s' "${raw_notes}" | tr -d '\r' | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')"
    if [ "${normalized_notes}" = "Critical security update." ]; then
        return 0
    fi

    local bad_words="regression|hardens|corruption|backlog|reconcile|fallback|herestring|mutex|refactor|migration recovery|separator target|position seeding|critical|severe|broken|failure|catastrophic|fatal"
    if echo "${raw_notes}" | grep -qiE "${bad_words}"; then
        log_error "Release notes contain jargon or alarming language that customers should never see."
        log_error "Rejected words: $(echo "${raw_notes}" | grep -oiE "${bad_words}" | sort -u | tr '\n' ', ')"
        log_error "Rewrite in calm plain English. Answer: what does this do for me?"
        log_error "Focus on the user benefit, not the scary failure mode or technical cause."
        return 1
    fi

    if echo "${raw_notes}" | grep -qE '#[0-9]+'; then
        log_error "Release notes contain issue numbers (e.g. #79). Customers don't care about issue trackers."
        log_error "Describe what changed in plain English."
        return 1
    fi

    if echo "${raw_notes}" | grep -qF '\n'; then
        log_error "Release notes contain literal \\n characters. Use actual newlines to separate bullets."
        return 1
    fi

    while IFS= read -r line; do
        line=$(echo "${line}" | sed 's/^[[:space:]]*[-*•]*//' | sed 's/^[[:space:]]*//' | sed 's/[[:space:]]*$//')
        [ -z "${line}" ] && continue
        item_count=$((item_count + 1))
    done <<< "${raw_notes}"

    if [ "${item_count}" -eq 0 ]; then
        log_error "Release notes are empty after formatting cleanup."
        log_error "Provide at least one plain-English bullet."
        return 1
    fi

    return 0
}

check_release_notes_gate() {
    if ! release_notes_required_for_mode; then
        return 0
    fi
    validate_release_notes_text "${RELEASE_NOTES}" "true"
}

format_appcast_notes() {
    local raw_notes="$1"
    local item_count=0

    if ! validate_release_notes_text "${raw_notes}" "true"; then
        exit 1
    fi

    # Build HTML bullet list
    local html="<h2>What's New</h2>\n                <ul>"
    while IFS= read -r line; do
        line=$(echo "${line}" | sed 's/^[[:space:]]*[-*•]*//' | sed 's/^[[:space:]]*//' | sed 's/[[:space:]]*$//')
        [ -z "${line}" ] && continue
        html="${html}\n                <li>${line}</li>"
        item_count=$((item_count + 1))
    done <<< "${raw_notes}"
    if [ "${item_count}" -eq 0 ]; then
        log_error "Release notes produced zero bullets after formatting."
        exit 1
    fi
    html="${html}\n                </ul>"

    echo -e "${html}"
}

appcast_item_count_for_version() {
    local appcast_content="$1"
    APPCAST_CONTENT="${appcast_content}" python3 - "${VERSION}" "${BUILD_NUMBER}" <<'PY'
import os
import re
import sys

xml = os.environ.get("APPCAST_CONTENT", "")
version = sys.argv[1]
build = sys.argv[2]

def version_match(item: str) -> bool:
    if f'sparkle:shortVersionString="{version}"' in item:
        return True
    if re.search(rf"<sparkle:shortVersionString>\s*{re.escape(version)}\s*</sparkle:shortVersionString>", item):
        return True
    return False

def build_match(item: str) -> bool:
    if f'sparkle:version="{build}"' in item:
        return True
    if re.search(rf"<sparkle:version>\s*{re.escape(build)}\s*</sparkle:version>", item):
        return True
    return False

def matches_item(item: str) -> bool:
    has_version = bool(version)
    has_build = bool(build)
    vm = version_match(item) if has_version else True
    bm = build_match(item) if has_build else True
    if has_version and has_build:
        return vm and bm
    if has_version:
        return vm
    if has_build:
        return bm
    return False

count = 0
for match in re.finditer(r"<item>.*?</item>", xml, flags=re.S):
    item = match.group(0)
    if matches_item(item):
        count += 1

print(count)
PY
}

appcast_informational_constraint_version_mismatches() {
    local appcast_content="$1"
    APPCAST_CONTENT="${appcast_content}" python3 - <<'PY'
import os
import re

xml = os.environ.get("APPCAST_CONTENT", "")
hits = []

for match in re.finditer(r"<item>.*?</item>", xml, flags=re.S):
    item = match.group(0)
    if "<sparkle:informationalUpdate" not in item:
        continue

    build_match = re.search(r'sparkle:version="([^"]+)"', item) or re.search(r"<sparkle:version>\s*([^<\s]+)\s*</sparkle:version>", item)
    item_build = build_match.group(1).strip() if build_match else ""
    if not re.fullmatch(r"\d+", item_build):
        continue

    constraints = re.findall(r"<sparkle:(?:version|belowVersion)>\s*([^<\s]+)\s*</sparkle:(?:version|belowVersion)>", item)
    if not constraints or not any("." in constraint for constraint in constraints):
        continue

    version_match = re.search(r'sparkle:shortVersionString="([^"]+)"', item) or re.search(r"<sparkle:shortVersionString>\s*([^<\s]+)\s*</sparkle:shortVersionString>", item) or re.search(r"<title>\s*([^<]+)\s*</title>", item)
    hits.append((version_match.group(1).strip() if version_match else "<unknown version>"))

print(",".join(hits))
PY
}

appcast_length_for_version() {
    local appcast_content="$1"
    APPCAST_CONTENT="${appcast_content}" python3 - "${VERSION}" "${BUILD_NUMBER}" <<'PY'
import os
import re
import sys

xml = os.environ.get("APPCAST_CONTENT", "")
version = sys.argv[1]
build = sys.argv[2]

def version_match(item: str) -> bool:
    if f'sparkle:shortVersionString="{version}"' in item:
        return True
    if re.search(rf"<sparkle:shortVersionString>\s*{re.escape(version)}\s*</sparkle:shortVersionString>", item):
        return True
    return False

def build_match(item: str) -> bool:
    if f'sparkle:version="{build}"' in item:
        return True
    if re.search(rf"<sparkle:version>\s*{re.escape(build)}\s*</sparkle:version>", item):
        return True
    return False

def matches_item(item: str) -> bool:
    has_version = bool(version)
    has_build = bool(build)
    vm = version_match(item) if has_version else True
    bm = build_match(item) if has_build else True
    if has_version and has_build:
        return vm and bm
    if has_version:
        return vm
    if has_build:
        return bm
    return False

for match in re.finditer(r"<item>.*?</item>", xml, flags=re.S):
    item = match.group(0)
    if not matches_item(item):
        continue

    enclosure = re.search(r"<enclosure\b[^>]*>", item, flags=re.S)
    if not enclosure:
        continue

    length_match = re.search(r'length="([0-9]+)"', enclosure.group(0))
    if length_match:
        print(length_match.group(1))
        sys.exit(0)

print("")
PY
}

appcast_enclosure_url_for_version() {
    local appcast_content="$1"
    APPCAST_CONTENT="${appcast_content}" python3 - "${VERSION}" "${BUILD_NUMBER}" <<'PY'
import os
import re
import sys

xml = os.environ.get("APPCAST_CONTENT", "")
version = sys.argv[1]
build = sys.argv[2]

def version_match(item: str) -> bool:
    if f'sparkle:shortVersionString="{version}"' in item:
        return True
    if re.search(rf"<sparkle:shortVersionString>\s*{re.escape(version)}\s*</sparkle:shortVersionString>", item):
        return True
    return False

def build_match(item: str) -> bool:
    if f'sparkle:version="{build}"' in item:
        return True
    if re.search(rf"<sparkle:version>\s*{re.escape(build)}\s*</sparkle:version>", item):
        return True
    return False

def matches_item(item: str) -> bool:
    has_version = bool(version)
    has_build = bool(build)
    vm = version_match(item) if has_version else True
    bm = build_match(item) if has_build else True
    if has_version and has_build:
        return vm and bm
    if has_version:
        return vm
    if has_build:
        return bm
    return False

for match in re.finditer(r"<item>.*?</item>", xml, flags=re.S):
    item = match.group(0)
    if not matches_item(item):
        continue

    enclosure = re.search(r"<enclosure\b[^>]*>", item, flags=re.S)
    if not enclosure:
        continue

    url_match = re.search(r'url="([^"]+)"', enclosure.group(0))
    if url_match:
        print(url_match.group(1))
        sys.exit(0)

print("")
PY
}

prune_existing_appcast_entries() {
    local appcast_path="$1"
    local removed_count
    removed_count=$(python3 - "${appcast_path}" "${VERSION}" "${BUILD_NUMBER}" <<'PY'
import re
import sys

path, version, build = sys.argv[1], sys.argv[2], sys.argv[3]
with open(path, "r", encoding="utf-8") as f:
    xml = f.read()

def matches_item(item: str) -> bool:
    if f'sparkle:shortVersionString="{version}"' in item:
        return True
    if f'sparkle:version="{build}"' in item:
        return True
    if re.search(rf"<sparkle:shortVersionString>\s*{re.escape(version)}\s*</sparkle:shortVersionString>", item):
        return True
    if re.search(rf"<sparkle:version>\s*{re.escape(build)}\s*</sparkle:version>", item):
        return True
    return False

removed = 0
parts = []
last = 0
for match in re.finditer(r"<item>.*?</item>", xml, flags=re.S):
    item = match.group(0)
    if matches_item(item):
        parts.append(xml[last:match.start()])
        last = match.end()
        removed += 1

parts.append(xml[last:])
new_xml = "".join(parts)

if removed > 0:
    with open(path, "w", encoding="utf-8") as f:
        f.write(new_xml)

print(removed)
PY
)
    if [ "${removed_count}" -gt 0 ] 2>/dev/null; then
        log_warn "Removed ${removed_count} stale appcast entr$( [ "${removed_count}" = "1" ] && echo "y" || echo "ies" ) for v${VERSION} before insert"
    fi
}

wait_for_live_appcast_version() {
    local appcast_url="$1"
    local max_attempts="${APPCAST_VERIFY_ATTEMPTS:-15}"
    local sleep_seconds="${APPCAST_VERIFY_SLEEP_SECONDS:-8}"
    local attempt=1

    while [ "${attempt}" -le "${max_attempts}" ]; do
        local status
        status=$(extract_http_status "${appcast_url}")
        if [ "${status}" = "200" ]; then
            local appcast_content
            appcast_content=$(curl -fsSL "${appcast_url}" 2>/dev/null || true)
            if [ -n "${appcast_content}" ]; then
                local count
                count=$(appcast_item_count_for_version "${appcast_content}")
                if [ "${count}" = "1" ]; then
                    log_info "Appcast propagated with exactly one v${VERSION} entry (attempt ${attempt}/${max_attempts})"
                    return 0
                fi
                log_warn "Appcast propagation attempt ${attempt}/${max_attempts}: expected 1 matching item, found ${count}"
            fi
        else
            log_warn "Appcast propagation attempt ${attempt}/${max_attempts}: HTTP ${status}"
        fi

        if [ "${attempt}" -lt "${max_attempts}" ]; then
            sleep "${sleep_seconds}"
        fi
        attempt=$((attempt + 1))
    done

    return 1
}

appstore_duplicate_build_upload_detected() {
    local latest_log_dir
    latest_log_dir=$(ls -td /var/folders/*/*/*/T/${APP_NAME}_*.xcdistributionlogs 2>/dev/null | head -1 || true)
    if [ -z "${latest_log_dir}" ]; then
        return 1
    fi

    local content_log="${latest_log_dir}/ContentDelivery.log"
    if [ ! -f "${content_log}" ]; then
        return 1
    fi

    if grep -Eqi "ENTITY_ERROR\.ATTRIBUTE\.INVALID\.DUPLICATE|bundle version must be higher than the previously uploaded version" "${content_log}"; then
        return 0
    fi

    return 1
}

appcast_signature_for_version() {
    local appcast_content="$1"
    APPCAST_CONTENT="${appcast_content}" python3 - "${VERSION}" "${BUILD_NUMBER}" <<'PY'
import os
import re
import sys

xml = os.environ.get("APPCAST_CONTENT", "")
version = sys.argv[1]
build = sys.argv[2]

def version_match(item: str) -> bool:
    if f'sparkle:shortVersionString="{version}"' in item:
        return True
    if re.search(rf"<sparkle:shortVersionString>\s*{re.escape(version)}\s*</sparkle:shortVersionString>", item):
        return True
    return False

def build_match(item: str) -> bool:
    if f'sparkle:version="{build}"' in item:
        return True
    if re.search(rf"<sparkle:version>\s*{re.escape(build)}\s*</sparkle:version>", item):
        return True
    return False

def matches_item(item: str) -> bool:
    has_version = bool(version)
    has_build = bool(build)
    vm = version_match(item) if has_version else True
    bm = build_match(item) if has_build else True
    if has_version and has_build:
        return vm and bm
    if has_version:
        return vm
    if has_build:
        return bm
    return False

for match in re.finditer(r"<item>.*?</item>", xml, flags=re.S):
    item = match.group(0)
    if not matches_item(item):
        continue

    enclosure = re.search(r"<enclosure\b[^>]*>", item, flags=re.S)
    if not enclosure:
        continue

    sig_match = re.search(r'sparkle:edSignature="([^"]+)"', enclosure.group(0))
    if sig_match:
        print(sig_match.group(1))
        sys.exit(0)

print("")
PY
}

verify_sparkle_signature_for_dist_url() {
    local dist_url="$1"
    local ed_signature="$2"

    if [ -z "${ed_signature}" ]; then
        log_error "Appcast entry for v${VERSION} missing sparkle:edSignature"
        return 1
    fi

    if ! command -v swift >/dev/null 2>&1; then
        log_error "swift is required for Sparkle signature verification."
        return 1
    fi

    if ! DIST_URL="${dist_url}" ED_SIGNATURE="${ed_signature}" SPARKLE_PUBLIC_KEY="${SHARED_SPARKLE_PUBLIC_KEY}" swift -e '
import Foundation
import CryptoKit

let env = ProcessInfo.processInfo.environment
guard
    let distURL = env["DIST_URL"],
    let signatureB64 = env["ED_SIGNATURE"],
    let publicKeyB64 = env["SPARKLE_PUBLIC_KEY"],
    let url = URL(string: distURL),
    let signature = Data(base64Encoded: signatureB64),
    let publicKeyData = Data(base64Encoded: publicKeyB64)
else {
    exit(1)
}

do {
    let archive = try Data(contentsOf: url)
    let key = try Curve25519.Signing.PublicKey(rawRepresentation: publicKeyData)
    if key.isValidSignature(signature, for: archive) {
        exit(0)
    } else {
        exit(2)
    }
} catch {
    exit(3)
}
' >/dev/null 2>&1; then
        log_error "Sparkle signature mismatch: appcast signature for v${VERSION} does not validate against live archive bytes."
        return 1
    fi

    return 0
}

verify_archive_bundle_version() {
    local zip_path="$1"
    local expected_version="$2"
    local expected_build="${3:-}"

    if [ -z "${zip_path}" ] || [ ! -f "${zip_path}" ]; then
        log_error "Archive version check failed: zip not found at ${zip_path}"
        return 1
    fi

    local tmp_root
    tmp_root=$(mktemp -d "/tmp/${APP_NAME}-archive-check.XXXXXX")
    local unpack_dir="${tmp_root}/unpacked"
    mkdir -p "${unpack_dir}"

    if ! ditto -x -k "${zip_path}" "${unpack_dir}" >/dev/null 2>&1; then
        remove_path "${tmp_root}"
        log_error "Archive version check failed: could not unzip ${zip_path}"
        return 1
    fi

    local app_bundle_path
    app_bundle_path=$(find "${unpack_dir}" -maxdepth 6 -type d -name "${APP_NAME}.app" | head -1)
    if [ -z "${app_bundle_path}" ]; then
        app_bundle_path=$(find "${unpack_dir}" -maxdepth 6 -type d -name "*.app" | head -1)
    fi
    if [ -z "${app_bundle_path}" ]; then
        remove_path "${tmp_root}"
        log_error "Archive version check failed: no .app bundle found inside ${zip_path}"
        return 1
    fi

    local bundle_version
    bundle_version=$(/usr/libexec/PlistBuddy -c "Print :CFBundleShortVersionString" "${app_bundle_path}/Contents/Info.plist" 2>/dev/null || true)
    if [ -z "${bundle_version}" ]; then
        remove_path "${tmp_root}"
        log_error "Archive version check failed: missing CFBundleShortVersionString in ${app_bundle_path}"
        return 1
    fi
    if [ "${bundle_version}" != "${expected_version}" ]; then
        remove_path "${tmp_root}"
        log_error "Archive version mismatch: expected ${expected_version}, found ${bundle_version} in ${zip_path}"
        return 1
    fi

    if [ -n "${expected_build}" ]; then
        local bundle_build
        bundle_build=$(/usr/libexec/PlistBuddy -c "Print :CFBundleVersion" "${app_bundle_path}/Contents/Info.plist" 2>/dev/null || true)
        if [ -z "${bundle_build}" ]; then
            remove_path "${tmp_root}"
            log_error "Archive build check failed: missing CFBundleVersion in ${app_bundle_path}"
            return 1
        fi
        if [ "${bundle_build}" != "${expected_build}" ]; then
            remove_path "${tmp_root}"
            log_error "Archive build mismatch: expected ${expected_build}, found ${bundle_build} in ${zip_path}"
            return 1
        fi
    fi

    remove_path "${tmp_root}"
    return 0
}

verify_dist_archive_bundle_version() {
    local dist_url="$1"
    local expected_version="$2"
    local expected_build="${3:-}"

    local tmp_zip
    tmp_zip=$(mktemp "/tmp/${APP_NAME}-dist-check.XXXXXX.zip")

    if ! curl -fsSL "${dist_url}" -o "${tmp_zip}"; then
        remove_path "${tmp_zip}"
        log_error "Could not download dist archive for version check: ${dist_url}"
        return 1
    fi

    if ! verify_archive_bundle_version "${tmp_zip}" "${expected_version}" "${expected_build}"; then
        remove_path "${tmp_zip}"
        return 1
    fi

    remove_path "${tmp_zip}"
    return 0
}

sync_docs_appcast_to_website() {
    local docs_appcast="${PROJECT_ROOT}/docs/appcast.xml"
    local website_dir="${PROJECT_ROOT}/website"
    local website_appcast="${website_dir}/appcast.xml"

    if [ ! -f "${docs_appcast}" ] || [ ! -d "${website_dir}" ]; then
        return 0
    fi

    if [ ! -f "${website_appcast}" ] || ! cmp -s "${docs_appcast}" "${website_appcast}" 2>/dev/null; then
        cp "${docs_appcast}" "${website_appcast}"
        log_info "Synced website/appcast.xml from docs/appcast.xml"
    fi

    return 0
}

fetch_remote_email_webhook_js() {
    local github_api_path="repos/sane-apps/sane-email-automation/contents/src/handlers/webhook-lemonsqueezy.js?ref=main"
    local raw_url="https://raw.githubusercontent.com/sane-apps/sane-email-automation/main/src/handlers/webhook-lemonsqueezy.js"
    local body=""

    if command -v gh >/dev/null 2>&1; then
        body=$(gh api "${github_api_path}" --jq '.content' 2>/dev/null | tr -d '\n' | python3 -c 'import base64,sys; data=sys.stdin.read().strip(); print(base64.b64decode(data).decode("utf-8"), end="")' 2>/dev/null || true)
    fi

    if [ -z "${body}" ]; then
        body=$(curl -fsSL "${raw_url}" 2>/dev/null || true)
    fi

    printf '%s' "${body}"
}

resolve_email_api_key() {
    local token="${EMAIL_API_KEY:-${SANE_EMAIL_API_KEY:-}}"
    if [ -n "${token}" ]; then
        EMAIL_API_KEY="${token}"
        SANE_EMAIL_API_KEY="${token}"
        export EMAIL_API_KEY SANE_EMAIL_API_KEY
        printf '%s' "${token}"
        return 0
    fi

    token=$(keychain_secret "sane-email-automation" "api_key" "SANE_EMAIL_API_KEY" "EMAIL_API_KEY")
    if [ -n "${token}" ]; then
        EMAIL_API_KEY="${token}"
        SANE_EMAIL_API_KEY="${token}"
        export EMAIL_API_KEY SANE_EMAIL_API_KEY
        printf '%s' "${token}"
        return 0
    fi

    return 1
}

fetch_live_email_webhook_snapshot() {
    local product_name="${1:-}"
    local include_signed="${2:-0}"
    local api_key url response

    api_key=$(resolve_email_api_key 2>/dev/null || true)
    if [ -z "${api_key}" ]; then
        return 0
    fi

    url="https://email-api.saneapps.com/api/debug/download-config"
    if [ -n "${product_name}" ]; then
        url="${url}?product=${product_name}"
        if [ "${include_signed}" = "1" ]; then
            url="${url}&signed=1"
        fi
    elif [ "${include_signed}" = "1" ]; then
        url="${url}?signed=1"
    fi

    response=$(curl -fsSL "${url}" \
        -H "Authorization: Bearer ${api_key}" 2>/dev/null || true)
    printf '%s' "${response}"
}

extract_live_email_webhook_field() {
    local snapshot_json="$1"
    local app_name="${2:-${APP_NAME}}"
    local field_name="$3"

    [ -n "${snapshot_json}" ] || return 0
    [ -n "${app_name}" ] || return 0
    [ -n "${field_name}" ] || return 0

    SNAPSHOT_JSON="${snapshot_json}" python3 - "${app_name}" "${field_name}" <<'PY'
import json
import os
import sys

app_name = sys.argv[1]
field_name = sys.argv[2]

try:
    payload = json.loads(os.environ.get("SNAPSHOT_JSON", ""))
except Exception:
    raise SystemExit(0)

product = (payload.get("products") or {}).get(app_name) or {}
value = product.get(field_name, "")
if value is None:
    value = ""
sys.stdout.write(str(value))
PY
}

wait_for_live_email_webhook_version() {
    local expected_version="$1"
    local app_name="${2:-${APP_NAME}}"
    local include_signed="${3:-0}"
    local max_attempts="${4:-10}"
    local sleep_seconds="${5:-6}"
    local attempt=1
    local snapshot=""
    local actual_version=""

    while [ "${attempt}" -le "${max_attempts}" ]; do
        snapshot=$(fetch_live_email_webhook_snapshot "${app_name}" "${include_signed}")
        actual_version=$(extract_live_email_webhook_field "${snapshot}" "${app_name}" "version")
        if [ -n "${actual_version}" ] && [ "${actual_version}" = "${expected_version}" ]; then
            printf '%s' "${snapshot}"
            return 0
        fi
        if [ "${attempt}" -lt "${max_attempts}" ]; then
            sleep "${sleep_seconds}"
        fi
        attempt=$((attempt + 1))
    done

    printf '%s' "${snapshot}"
    return 1
}

extract_email_webhook_version() {
    local webhook_content="$1"
    local app_name="${2:-${APP_NAME}}"
    local webhook_entry=""

    if [ -z "${webhook_content}" ] || [ -z "${app_name}" ]; then
        return 0
    fi

    webhook_entry=$(grep "'${app_name}'" <<< "${webhook_content}" 2>/dev/null || true)
    if [ -z "${webhook_entry}" ]; then
        return 0
    fi

    echo "${webhook_entry}" | grep -oE "${app_name}-[0-9]+\.[0-9]+(\.[0-9]+)?" | head -1 | grep -oE '[0-9]+\.[0-9]+(\.[0-9]+)?'
}

run_post_release_checks() {
    local dist_url="https://${DIST_HOST}/updates/${APP_NAME}-${VERSION}.zip"
    local appcast_url="https://${SITE_HOST}/appcast.xml"
    local checkout_url="https://go.saneapps.com/buy/${LOWER_APP_NAME}"
    local strict="${STRICT_PUBLIC_CHANNEL_SYNC}"

    log_info "Running strict post-release checks..."

    local dist_status_browser
    dist_status_browser=$(extract_http_status "${dist_url}")
    if [ "${dist_status_browser}" != "200" ] && [ "${dist_status_browser}" != "206" ]; then
        log_error "Download URL failed for browser/web install flow: ${dist_url} returned HTTP ${dist_status_browser}"
        return 1
    fi

    local dist_status_sparkle
    dist_status_sparkle=$(extract_http_status_with_user_agent "${dist_url}" "Sparkle/2")
    if [ "${dist_status_sparkle}" != "200" ] && [ "${dist_status_sparkle}" != "206" ]; then
        log_error "Download URL failed for Sparkle flow: ${dist_url} returned HTTP ${dist_status_sparkle}"
        return 1
    fi

    local dist_length
    dist_length=$(extract_content_length_with_user_agent "${dist_url}" "Sparkle/2")
    if [ -z "${dist_length}" ]; then
        log_error "Download URL missing Content-Length: ${dist_url}"
        return 1
    fi

    if ! verify_dist_archive_bundle_version "${dist_url}" "${VERSION}" "${BUILD_NUMBER}"; then
        log_error "Download archive metadata check failed for v${VERSION} (${BUILD_NUMBER})."
        return 1
    fi

    if [ "${USE_SPARKLE}" = true ]; then
        local appcast_status
        appcast_status=$(extract_http_status "${appcast_url}")
        if [ "${appcast_status}" != "200" ]; then
            log_error "Appcast URL failed: ${appcast_url} returned HTTP ${appcast_status}"
            return 1
        fi

        local appcast_content
        appcast_content=$(curl -fsSL "${appcast_url}" 2>/dev/null || true)
        if [ -z "${appcast_content}" ]; then
            log_error "Could not fetch appcast content from ${appcast_url}"
            return 1
        fi

        if command -v xmllint >/dev/null 2>&1; then
            if ! xmllint --noout - <<< "${appcast_content}" >/dev/null 2>&1; then
                log_error "Appcast XML is invalid at ${appcast_url}"
                return 1
            fi
        else
            if ! APPCAST_CONTENT="${appcast_content}" python3 - <<'PY' >/dev/null 2>&1
import os
import xml.etree.ElementTree as ET

ET.fromstring(os.environ.get("APPCAST_CONTENT", ""))
PY
            then
                log_error "Appcast XML parse failed at ${appcast_url}"
                return 1
            fi
        fi

        local appcast_item_count
        appcast_item_count=$(appcast_item_count_for_version "${appcast_content}")
        if [ "${appcast_item_count}" != "1" ]; then
            log_error "Appcast has ${appcast_item_count} entries for v${VERSION} (expected exactly 1)"
            return 1
        fi

        local informational_constraint_mismatches
        informational_constraint_mismatches=$(appcast_informational_constraint_version_mismatches "${appcast_content}") || return 1
        if [ -n "${informational_constraint_mismatches}" ]; then
            log_error "Appcast informational update cutoffs must use CFBundleVersion/build values, not display versions: ${informational_constraint_mismatches}"
            return 1
        fi

        local appcast_enclosure_url
        appcast_enclosure_url=$(appcast_enclosure_url_for_version "${appcast_content}")
        if [ -z "${appcast_enclosure_url}" ]; then
            log_error "Appcast entry for v${VERSION} missing enclosure URL"
            return 1
        fi
        if [ "${appcast_enclosure_url}" != "${dist_url}" ]; then
            log_error "Appcast enclosure URL mismatch for v${VERSION}:"
            log_error "  appcast: ${appcast_enclosure_url}"
            log_error "  expected: ${dist_url}"
            return 1
        fi

        if grep -Eqi 'github\.com/[^/]+/[^/]+/releases/download' <<< "${appcast_content}"; then
            log_error "Appcast contains GitHub release download URLs (forbidden). Use dist.* direct-distribution URLs only."
            return 1
        fi

        local appcast_length
        appcast_length=$(appcast_length_for_version "${appcast_content}")
        if [ -z "${appcast_length}" ]; then
            log_error "Appcast entry for v${VERSION} missing enclosure length"
            return 1
        fi

        if [ "${appcast_length}" != "${dist_length}" ]; then
            log_error "Length mismatch: appcast=${appcast_length}, dist=${dist_length}"
            return 1
        fi

        local appcast_signature
        appcast_signature=$(appcast_signature_for_version "${appcast_content}")
        if ! verify_sparkle_signature_for_dist_url "${dist_url}" "${appcast_signature}"; then
            return 1
        fi
    fi

    if [ "${RUN_GH_RELEASE}" = true ]; then
        if ! command -v gh >/dev/null 2>&1; then
            if [ "${strict}" = true ]; then
                log_error "gh CLI is required for GitHub release post-release verification."
                return 1
            fi
            log_warn "gh CLI unavailable — skipping GitHub release post-release verification."
        elif [ -z "${GITHUB_REPO}" ]; then
            if [ "${strict}" = true ]; then
                log_error "GITHUB_REPO is empty during GitHub release post-release verification."
                return 1
            fi
            log_warn "GITHUB_REPO is empty — skipping GitHub release post-release verification."
        else
            local gh_tag="v${VERSION}"
            local gh_asset="${APP_NAME}-${VERSION}.zip"
            if ! gh release view "${gh_tag}" --repo "${GITHUB_REPO}" >/dev/null 2>&1; then
                if [ "${strict}" = true ]; then
                    log_error "GitHub release missing after deploy: ${GITHUB_REPO}@${gh_tag}"
                    return 1
                fi
                log_warn "GitHub release missing after deploy: ${GITHUB_REPO}@${gh_tag} (non-fatal)."
            elif ! gh release view "${gh_tag}" --repo "${GITHUB_REPO}" --json assets --jq '.assets[].name' 2>/dev/null | grep -Fxq "${gh_asset}"; then
                if [ "${strict}" = true ]; then
                    log_error "GitHub release ${GITHUB_REPO}@${gh_tag} missing required asset ${gh_asset}"
                    return 1
                fi
                log_warn "GitHub release ${GITHUB_REPO}@${gh_tag} missing asset ${gh_asset} (non-fatal)."
            fi
        fi
    fi

    local checkout_status
    checkout_status=$(extract_http_status "${checkout_url}")
    if [ "${checkout_status}" != "200" ] && [ "${checkout_status}" != "301" ] && [ "${checkout_status}" != "302" ]; then
        log_error "Checkout URL failed: ${checkout_url} returned HTTP ${checkout_status}"
        return 1
    fi

    # Follow redirects and verify the checkout lands on the expected Lemon Squeezy flow.
    if [ "${checkout_status}" = "301" ] || [ "${checkout_status}" = "302" ]; then
        local expected_checkout_uuid
        local expected_checkout_prefix=""
        local checkout_first_hop
        local checkout_first_hop_ok=false
        local checkout_final_status
        local checkout_final_url
        expected_checkout_uuid=$(lookup_checkout_uuid_for_slug "${LOWER_APP_NAME}")
        [ -n "${expected_checkout_uuid}" ] && expected_checkout_prefix="https://saneapps.lemonsqueezy.com/checkout/buy/${expected_checkout_uuid}"
        checkout_first_hop=$(extract_redirect_location "${checkout_url}" "Mozilla/5.0")
        if [ -n "${expected_checkout_prefix}" ] && [[ "${checkout_first_hop}" == "${expected_checkout_prefix}"* ]]; then
            checkout_first_hop_ok=true
        elif [ -n "${expected_checkout_prefix}" ]; then
            log_error "Checkout URL first hop mismatch for ${checkout_url}"
            log_error "  first hop: ${checkout_first_hop:-<none>}"
            log_error "  expected:  ${expected_checkout_prefix}"
            return 1
        fi

        checkout_final_status=$(curl -A 'Mozilla/5.0' -sSL -o /dev/null -w '%{http_code}' "${checkout_url}" 2>/dev/null || echo "000")
        checkout_final_url=$(extract_http_effective_url "${checkout_url}" "Mozilla/5.0")
        if [ -z "${checkout_final_url}" ]; then
            if [ "${checkout_first_hop_ok}" = true ]; then
                log_warn "Checkout final destination was blank after provider redirects, but first-hop UUID matched ${expected_checkout_uuid}. Treating first hop as authoritative."
            else
                log_error "Checkout URL redirect chain did not resolve a final destination: ${checkout_url}"
                return 1
            fi
        fi

        if [ -n "${checkout_final_url}" ] && ! [[ "${checkout_final_url}" =~ ^https://([A-Za-z0-9-]+\.)?lemonsqueezy\.com/checkout/buy/ ]]; then
            if [ "${checkout_first_hop_ok}" = true ] && [[ "${checkout_final_url}" =~ ^https://([A-Za-z0-9-]+\.)?lemonsqueezy\.com/checkout/?$ ]]; then
                log_warn "Checkout final URL resolved to a provider anti-bot landing page (${checkout_final_url}); first-hop UUID matched ${expected_checkout_uuid}, so checkout is treated as healthy."
            else
                log_error "Checkout URL redirect chain landed on unexpected destination: ${checkout_final_url}"
                return 1
            fi
        fi

        if [ -n "${checkout_final_url}" ] && [[ "${checkout_final_url}" =~ ^https://([A-Za-z0-9-]+\.)?lemonsqueezy\.com/checkout/buy/ ]]; then
            if [ "${checkout_final_status}" != "200" ] && [ "${checkout_final_status}" != "403" ]; then
                log_error "Checkout URL redirect chain ended with unexpected HTTP ${checkout_final_status}: ${checkout_final_url}"
                return 1
            fi
        fi
    fi

    if [ -n "${HOMEBREW_TAP_REPO}" ]; then
        local cask_file="Casks/${LOWER_APP_NAME}.rb"
        local cask_raw_url="https://raw.githubusercontent.com/${HOMEBREW_TAP_REPO}/main/${cask_file}"
        local cask_api_path="repos/${HOMEBREW_TAP_REPO}/contents/${cask_file}?ref=main"
        local cask_status
        cask_status=$(extract_http_status "${cask_raw_url}")
        if [ "${cask_status}" = "404" ]; then
            if [ "${STRICT_PUBLIC_CHANNEL_SYNC}" = true ]; then
                log_error "No Homebrew cask found for ${APP_NAME} (${cask_raw_url})."
                return 1
            fi
            log_warn "No Homebrew cask found for ${APP_NAME} (${cask_raw_url}); skipping Homebrew verification."
        elif [ "${cask_status}" != "200" ]; then
            log_error "Could not fetch Homebrew cask: ${cask_raw_url} (HTTP ${cask_status})"
            return 1
        else
        local cask_attempt=1
        local cask_max_attempts="${HOMEBREW_VERIFY_ATTEMPTS:-10}"
        local cask_sleep_seconds="${HOMEBREW_VERIFY_SLEEP_SECONDS:-6}"
        local cask_ok=false
        local cask_verified_source=""
        local cask_verified_body=""

        while [ "${cask_attempt}" -le "${cask_max_attempts}" ]; do
            local cask_body
            cask_body=$(curl -fsSL "${cask_raw_url}" 2>/dev/null || true)
            if [ -n "${cask_body}" ] && \
               grep -q "version \"${VERSION}\"" <<< "${cask_body}" && \
               grep -q "sha256 \"${SHA256}\"" <<< "${cask_body}"; then
                cask_ok=true
                cask_verified_source="raw"
                cask_verified_body="${cask_body}"
                break
            fi

            # raw.githubusercontent can lag behind Git refs after push; verify against GitHub API as fallback.
            if command -v gh >/dev/null 2>&1; then
                local cask_api_body
                cask_api_body=$(gh api "${cask_api_path}" --jq '.content' 2>/dev/null | tr -d '\n' | python3 -c 'import base64,sys; data=sys.stdin.read().strip(); print(base64.b64decode(data).decode("utf-8"), end="")' 2>/dev/null || true)
                if [ -n "${cask_api_body}" ] && \
                   grep -q "version \"${VERSION}\"" <<< "${cask_api_body}" && \
                   grep -q "sha256 \"${SHA256}\"" <<< "${cask_api_body}"; then
                    cask_ok=true
                    cask_verified_source="github-api"
                    cask_verified_body="${cask_api_body}"
                    break
                fi
            fi

            if [ "${cask_attempt}" -lt "${cask_max_attempts}" ]; then
                sleep "${cask_sleep_seconds}"
            fi
            cask_attempt=$((cask_attempt + 1))
        done

        if [ "${cask_ok}" != "true" ]; then
            log_error "Homebrew cask did not converge to v${VERSION} / ${SHA256:0:12}... at ${cask_raw_url}"
            return 1
        fi

        # Extract and assert exact version from cask content
        local cask_extracted_version
        cask_extracted_version=$(grep -oE 'version "[^"]+"' <<< "${cask_verified_body}" | head -1 | sed 's/version "//;s/"//')
        if [ -n "${cask_extracted_version}" ] && [ "${cask_extracted_version}" != "${VERSION}" ]; then
            log_error "Homebrew cask version mismatch: cask has v${cask_extracted_version}, expected v${VERSION}"
            return 1
        fi

        if [ "${cask_verified_source}" = "github-api" ]; then
            log_warn "Homebrew verification passed via GitHub API; raw.githubusercontent is still propagating."
            log_warn "Continuing because the tap repo already has the correct cask content and raw propagation is eventually consistent."
        fi
        fi
    fi

    # Verify website download flow matches the release version.
    # Some sites link directly to the ZIP from the homepage; others route the
    # homepage CTA to /download and keep the versioned ZIP there.
    local site_url="https://${SITE_HOST}/"
    local site_body
    site_body=$(curl -fsSL "${site_url}" 2>/dev/null || true)
    if [ -n "${site_body}" ]; then
        local expected_download_url="https://${DIST_HOST}/updates/${APP_NAME}-${VERSION}.zip"
        local homepage_download_ver=""
        homepage_download_ver=$(grep -oE "https://${DIST_HOST}/updates/${APP_NAME}-[0-9]+\.[0-9]+\.[0-9]+\.zip" <<< "${site_body}" | head -1 | grep -oE '[0-9]+\.[0-9]+\.[0-9]+')
        if grep -Fq "${expected_download_url}" <<< "${site_body}"; then
            log_info "Website download link verified: ${expected_download_url}"
        elif [ -n "${homepage_download_ver}" ]; then
            log_error "Website download link points to v${homepage_download_ver}, expected v${VERSION}: ${site_url}"
            return 1
        else
            local download_page_url="https://${SITE_HOST}/download"
            local download_page_body=""
            local found_download_ver=""
            if grep -Fq 'href="/download"' <<< "${site_body}" || grep -Fq "${download_page_url}" <<< "${site_body}"; then
                download_page_body=$(curl -fsSL "${download_page_url}" 2>/dev/null || true)
            fi

            if [ -n "${download_page_body}" ] && grep -Fq "${expected_download_url}" <<< "${download_page_body}"; then
                log_info "Website download flow verified via ${download_page_url}: ${expected_download_url}"
            else
                found_download_ver=$(printf '%s\n%s' "${site_body}" "${download_page_body}" | \
                    grep -oE "https://${DIST_HOST}/updates/${APP_NAME}-[0-9]+\.[0-9]+\.[0-9]+\.zip" | \
                    head -1 | grep -oE '[0-9]+\.[0-9]+\.[0-9]+')
                if [ -n "${found_download_ver}" ]; then
                    log_error "Website download flow points to v${found_download_ver}, expected v${VERSION}: ${download_page_url}"
                else
                    log_error "Website has no download link matching ${APP_NAME}-*.zip pattern on ${site_url} or ${download_page_url}"
                fi
                return 1
            fi
        fi
    else
        log_error "Could not fetch website for download link check: ${site_url}"
        return 1
    fi

    # Verify the deployed email worker serves the current build, not just repo state.
    local webhook_snapshot webhook_ver webhook_file webhook_download_url
    webhook_snapshot=$(fetch_live_email_webhook_snapshot "${APP_NAME}" "1")
    if [ -z "${webhook_snapshot}" ]; then
        log_error "Could not fetch live email webhook snapshot from email-api.saneapps.com"
        return 1
    fi

    webhook_ver=$(extract_live_email_webhook_field "${webhook_snapshot}" "${APP_NAME}" "version")
    webhook_file=$(extract_live_email_webhook_field "${webhook_snapshot}" "${APP_NAME}" "file")
    webhook_download_url=$(extract_live_email_webhook_field "${webhook_snapshot}" "${APP_NAME}" "downloadUrl")

    if [ -z "${webhook_ver}" ]; then
        log_error "Live email webhook snapshot has no '${APP_NAME}' entry."
        return 1
    fi

    if [ "${webhook_ver}" != "${VERSION}" ]; then
        log_error "Live email webhook drift: deployed worker serves v${webhook_ver}, expected v${VERSION}"
        log_error "New customers will download an old build until the Worker is updated."
        return 1
    fi

    if [ "${webhook_file}" != "${APP_NAME}-${VERSION}.zip" ]; then
        log_error "Live email webhook file mismatch: ${webhook_file:-missing}, expected ${APP_NAME}-${VERSION}.zip"
        return 1
    fi

    if [ -z "${webhook_download_url}" ]; then
        log_error "Live email webhook snapshot did not return a signed download URL for ${APP_NAME}."
        return 1
    fi

    local webhook_download_status
    webhook_download_status=$(curl -sL -o /dev/null -w '%{http_code}' "${webhook_download_url}" 2>/dev/null || echo "000")
    if [ "${webhook_download_status}" != "200" ] && [ "${webhook_download_status}" != "206" ]; then
        log_error "Live email webhook download URL failed: HTTP ${webhook_download_status}"
        return 1
    fi

    if ! verify_dist_archive_bundle_version "${webhook_download_url}" "${VERSION}" "${BUILD_NUMBER}"; then
        log_error "Live email webhook archive metadata check failed for v${VERSION} (${BUILD_NUMBER})."
        return 1
    fi

    log_info "Live email webhook verified: ${webhook_file}"

    # Verify source code version matches released version (prevents uncommitted bump drift)
    local source_version=""
    if [ -f "${PROJECT_ROOT}/project.yml" ]; then
        source_version=$(grep -m1 'MARKETING_VERSION:' "${PROJECT_ROOT}/project.yml" | sed 's/.*MARKETING_VERSION:[[:space:]]*"*//;s/".*//;s/[[:space:]]*$//')
    fi
    if [ -z "${source_version}" ]; then
        while IFS= read -r xcf; do
            [ -z "${xcf}" ] && continue
            local xcf_ver
            xcf_ver=$(grep -m1 'MARKETING_VERSION' "${xcf}" 2>/dev/null | sed 's/.*=[[:space:]]*//' | tr -d '[:space:]')
            if [ -n "${xcf_ver}" ]; then
                source_version="${xcf_ver}"
                break
            fi
        done < <(find "${PROJECT_ROOT}" -name "*.xcconfig" -not -path "*/DerivedData/*" -not -path "*/build/*" 2>/dev/null)
    fi
    if [ -n "${source_version}" ] && [ "${source_version}" != "${VERSION}" ]; then
        log_error "Source code version drift: project config has v${source_version}, released v${VERSION}"
        log_error "The version bump was not committed back to the repo. Next build from source will produce a downgrade."
        return 1
    fi
    if [ -n "${source_version}" ]; then
        log_info "Source code version verified: v${source_version}"
    fi

    # Verify website JSON-LD softwareVersion matches release version
    if [ -n "${site_body}" ]; then
        local jsonld_version
        jsonld_version=$(echo "${site_body}" | grep -oE '"softwareVersion"[[:space:]]*:[[:space:]]*"[^"]+"' | head -1 | grep -oE '"[0-9][^"]*"' | tr -d '"')
        if [ -n "${jsonld_version}" ] && [ "${jsonld_version}" != "${VERSION}" ]; then
            log_error "Website JSON-LD softwareVersion is v${jsonld_version}, expected v${VERSION}"
            return 1
        fi
        if [ -n "${jsonld_version}" ]; then
            log_info "Website JSON-LD version verified: v${jsonld_version}"
        fi
    fi

    # Verify download redirect (go.saneapps.com/download/{app})
    local download_redirect_url="https://go.saneapps.com/download/${LOWER_APP_NAME}"
    local download_redirect_status
    download_redirect_status=$(curl -sI -o /dev/null -w '%{http_code}' -L "${download_redirect_url}" 2>/dev/null || echo "000")
    if [ "${download_redirect_status}" = "200" ]; then
        log_info "Download redirect verified: ${download_redirect_url}"
    else
        log_error "Download redirect failed with HTTP ${download_redirect_status}: ${download_redirect_url}"
        return 1
    fi

    log_info "Post-release checks passed."
    return 0
}

print_help() {
    echo "Usage: $0 [options]"
    echo ""
    echo "Options:"
    echo "  --project PATH      Project root (defaults to current directory)"
    echo "  --config PATH       Config file (defaults to <project>/.saneprocess if present)"
    echo "  --full              Version bump, tests, git commit"
    echo "  --deploy            Upload to R2, update appcast, deploy website (run after build)"
    echo "  --github-release    Create/update GitHub release (uploads ZIP for approved apps only)"
    echo "  --critical-update   Mark appcast entry as critical update"
    echo "  --minimum-autoupdate-version BUILD"
    echo "                      Set <sparkle:minimumAutoupdateVersion> (CFBundleVersion)"
    echo "  --critical-below-version BUILD"
    echo "                      Only mark update critical for hosts below this CFBundleVersion"
    echo "  --purge-r2-binaries"
    echo "                      Delete older direct-download ZIPs from R2 after deploy"
    echo "  --purge-github-binaries"
    echo "                      Delete older GitHub release ZIP/DMG assets after deploy"
    echo "  --keep-github-binaries"
    echo "                      Legacy alias: do not purge old GitHub release binary assets"
    echo "  --skip-notarize      Skip notarization (requires typed override approval)"
    echo "  --skip-build         Skip build step (requires typed override approval)"
    echo "  --version X.Y.Z      Set version number"
    echo "  --notes \"...\"      Release notes for changelog/release metadata (required for preflight/full/deploy/GitHub release)"
    echo "  --allow-republish    Allow republishing an already-live version/build (requires typed override approval)"
    echo "  --allow-unsynced-peer  Bypass Air/mini reconcile gate (requires typed override approval)"
    echo "  --allow-repeat-failure  Bypass repeat-failure guard once"
    echo "  --allow-channel-sync-failures"
    echo "                      Allow GitHub/Homebrew sync failures (not recommended)"
    echo "  --skip-appstore      Skip App Store archive/export/upload (requires typed override approval)"
    echo "  --website-only       Deploy website + appcast only (no build/R2/signing)"
    echo "  --preflight-only     Run release gates only and exit (no build, no upload, no publish)"
    echo "  -h, --help           Show this help"
}

ensure_cmd() {
    if ! command -v "$1" >/dev/null 2>&1; then
        log_error "Required command not found: $1"
        exit 1
    fi
}

array_contains() {
    local needle="$1"
    shift
    local item
    for item in "$@"; do
        if [ "${item}" = "${needle}" ]; then
            return 0
        fi
    done
    return 1
}

ensure_git_clean() {
    if mini_route_context_active; then
        local routed_dirty_count
        routed_dirty_count="$(mini_route_workspace_field 'dirty_count' 2>/dev/null || true)"
        if [ "${routed_dirty_count}" = "0" ]; then
            log_info "Git clean verified from routed workspace context."
            return 0
        fi
    fi

    local status_output
    status_output="$(git -C "${PROJECT_ROOT}" status --porcelain)"
    if [ -z "${status_output}" ]; then
        return 0
    fi

    local -a expected_release_mutations unexpected_mutations
    expected_release_mutations=()
    unexpected_mutations=()

    while IFS= read -r line; do
        [ -z "${line}" ] && continue
        local path="${line#?? }"
        case "${path}" in
            project.yml|CHANGELOG.md|docs/appcast.xml|docs/index.html|website/index.html|*.xcodeproj/project.pbxproj)
                expected_release_mutations+=("${path}")
                ;;
            *)
                unexpected_mutations+=("${path}")
                ;;
        esac
    done <<< "${status_output}"

    log_error "Git working directory not clean."
    log_error "Dirty files:"
    while IFS= read -r line; do
        [ -n "${line}" ] && log_error "  ${line}"
    done <<< "${status_output}"

    if [ ${#unexpected_mutations[@]} -gt 0 ]; then
        log_error "Unexpected dirty files are present. Reconcile these changes before release."
        log_error "Review: git -C \"${PROJECT_ROOT}\" diff -- ${unexpected_mutations[*]}"
    elif [ ${#expected_release_mutations[@]} -gt 0 ]; then
        log_error "Only known release-mutated files are dirty."
        log_error "Finish reconciliation first: keep/commit good changes, restore bad ones."
        log_error "Review: git -C \"${PROJECT_ROOT}\" diff -- ${expected_release_mutations[*]}"
    fi
    exit 1
}

shell_true() {
    case "${1:-}" in
        true|TRUE|True|1|yes|YES|on|ON)
            return 0
            ;;
        *)
            return 1
            ;;
    esac
}

record_override_flag() {
    local flag="$1"
    local existing
    for existing in "${OVERRIDE_FLAGS_USED[@]}"; do
        if [ "${existing}" = "${flag}" ]; then
            return 0
        fi
    done
    OVERRIDE_FLAGS_USED+=("${flag}")
}

override_approval_phrase_for_flag() {
    case "$1" in
        --skip-notarize) echo "MR. SANE APPROVES SKIP NOTARIZATION" ;;
        --skip-build) echo "MR. SANE APPROVES SKIP BUILD" ;;
        --allow-republish) echo "MR. SANE APPROVES REPUBLISH OVERRIDE" ;;
        --allow-unsynced-peer) echo "MR. SANE APPROVES UNSYNCED PEER RELEASE" ;;
        --skip-appstore) echo "MR. SANE APPROVES SKIP APPSTORE" ;;
        *) echo "MR. SANE APPROVES RELEASE OVERRIDE" ;;
    esac
}

override_reason_for_flag() {
    case "$1" in
        --skip-notarize) echo "Notarization is being skipped." ;;
        --skip-build) echo "Build step is being skipped (reusing prior artifacts)." ;;
        --allow-republish) echo "Release is republishing an already-live version/build." ;;
        --allow-unsynced-peer) echo "Air/mini reconcile gate is being bypassed." ;;
        --skip-appstore) echo "App Store submission path is being skipped." ;;
        *) echo "A release safety override is being requested." ;;
    esac
}

request_override_approval_for_flag() {
    local flag="$1"
    local phrase reason response
    phrase="$(override_approval_phrase_for_flag "${flag}")"
    reason="$(override_reason_for_flag "${flag}")"

    if [ ! -t 0 ] || [ ! -t 1 ]; then
        log_error "Override ${flag} requires interactive typed approval."
        log_error "${reason}"
        log_error "Run this release command in an interactive terminal and type exactly:"
        log_error "${phrase}"
        return 1
    fi

    echo
    log_warn "Manual approval required for ${flag}"
    log_warn "${reason}"
    echo "Type exactly to continue:"
    echo "${phrase}"
    printf '> '
    IFS= read -r response

    if [ "${response}" != "${phrase}" ]; then
        log_error "Approval phrase mismatch for ${flag}. Override denied."
        return 1
    fi

    log_info "Override approved for ${flag}"
    return 0
}

enforce_release_override_approvals() {
    local flag
    if [ ${#OVERRIDE_FLAGS_USED[@]} -eq 0 ]; then
        return 0
    fi

    for flag in "${OVERRIDE_FLAGS_USED[@]}"; do
        if ! request_override_approval_for_flag "${flag}"; then
            return 1
        fi
    done
    return 0
}

release_guardrails_required_for_mode() {
    if [ "${PREFLIGHT_ONLY}" = true ] || [ "${FULL_RELEASE}" = true ] || [ "${RUN_DEPLOY}" = true ]; then
        return 0
    fi
    return 1
}

set_env_if_missing() {
    local key="$1"
    local value="$2"
    if [ -z "${key}" ] || [ -z "${value}" ]; then
        return 0
    fi
    if [ -n "${!key:-}" ]; then
        return 0
    fi
    export "${key}=${value}"
}

persist_secret_to_env_cache() {
    local value="$1"
    shift

    [ -n "${value}" ] || return 0
    [ "${SANE_ENV_CACHE_WRITE:-1}" != "0" ] || return 0
    [ ${#} -gt 0 ] || return 0

    local env_file="${SECRETS_ENV_CACHE_FILE}"
    local env_dir
    env_dir="$(dirname "${env_file}")"
    mkdir -p "${env_dir}"
    chmod 700 "${env_dir}" 2>/dev/null || true

    python3 - "${env_file}" "${value}" "$@" <<'PY'
import pathlib
import shlex
import sys

env_path = pathlib.Path(sys.argv[1]).expanduser()
value = sys.argv[2]
names = [name for name in sys.argv[3:] if name]
if not names:
    raise SystemExit(0)

lines = []
if env_path.exists():
    lines = env_path.read_text(encoding="utf-8").splitlines()

filtered = []
for line in lines:
    stripped = line.strip()
    if any(stripped.startswith(f"export {name}=") for name in names):
        continue
    filtered.append(line)

for name in names:
    filtered.append(f"export {name}={shlex.quote(value)}")

env_path.write_text("\n".join(filtered) + "\n", encoding="utf-8")
env_path.chmod(0o600)
PY
}

unquote_env_value() {
    local raw="$1"
    raw="${raw%"${raw##*[![:space:]]}"}"
    raw="${raw#"${raw%%[![:space:]]*}"}"
    if [[ "${raw}" == \"*\" && "${raw}" == *\" ]]; then
        raw="${raw:1:${#raw}-2}"
    elif [[ "${raw}" == \'*\' && "${raw}" == *\' ]]; then
        raw="${raw:1:${#raw}-2}"
    fi
    printf '%s' "${raw}"
}

load_secrets_env_file() {
    local env_file="$1"
    [ -f "${env_file}" ] || return 1

    while IFS= read -r line || [ -n "${line}" ]; do
        [ -z "${line}" ] && continue
        [[ "${line}" =~ ^[[:space:]]*# ]] && continue
        line="${line#export }"
        [[ "${line}" == *=* ]] || continue
        local key="${line%%=*}"
        local value="${line#*=}"
        key="$(echo "${key}" | tr -d '[:space:]')"
        [ -n "${key}" ] || continue
        value="$(unquote_env_value "${value}")"
        set_env_if_missing "${key}" "${value}"
    done < "${env_file}"

    return 0
}

keychain_secret() {
    local service="$1"
    local account="${2:-}"
    shift 2 || true
    local value=""
    [ "${KEYCHAIN_FALLBACK_ENABLED}" = "1" ] || return 0
    if [ -n "${account}" ]; then
        value=$(security find-generic-password -w -s "${service}" -a "${account}" 2>/dev/null || true)
    fi
    if [ -z "${value}" ]; then
        value=$(security find-generic-password -w -s "${service}" 2>/dev/null || true)
    fi
    if [ -n "${value}" ] && [ ${#} -gt 0 ]; then
        persist_secret_to_env_cache "${value}" "$@"
    fi
    printf '%s' "${value}"
}

resolve_cloudflare_api_token() {
    local token="${CLOUDFLARE_API_TOKEN:-}"
    if [ -n "${token}" ]; then
        printf '%s' "${token}"
        return 0
    fi

    token=$(keychain_secret "saneprocess.cloudflare.api_token" "cloudflare_api_token" "CLOUDFLARE_API_TOKEN")
    if [ -n "${token}" ]; then
        printf '%s' "${token}"
        return 0
    fi

    token=$(keychain_secret "saneprocess.cloudflare.api_token" "" "CLOUDFLARE_API_TOKEN")
    if [ -n "${token}" ]; then
        printf '%s' "${token}"
        return 0
    fi

    token=$(keychain_secret "cloudflare" "api_token" "CLOUDFLARE_API_TOKEN")
    if [ -n "${token}" ]; then
        printf '%s' "${token}"
        return 0
    fi

    token=$(keychain_secret "cloudflare" "" "CLOUDFLARE_API_TOKEN")
    if [ -n "${token}" ]; then
        printf '%s' "${token}"
        return 0
    fi

    return 1
}

hydrate_headless_release_env() {
    local secrets_files=(
        "${SECRETS_ENV_CACHE_FILE}"
        "${SANEPROCESS_SECRETS_FILE:-}"
        "${HOME}/.config/saneprocess/secrets.env"
        "${HOME}/.saneprocess/secrets.env"
    )
    local env_file
    for env_file in "${secrets_files[@]}"; do
        [ -n "${env_file}" ] || continue
        if load_secrets_env_file "${env_file}"; then
            log_info "Loaded headless secrets from ${env_file}"
            break
        fi
    done

    set_env_if_missing "SANEBAR_KEYCHAIN_PASSWORD" "$(keychain_secret "saneprocess.keychain.password" "keychain_password" "SANEBAR_KEYCHAIN_PASSWORD")"
    set_env_if_missing "SANEBAR_KEYCHAIN_PASSWORD" "$(keychain_secret "saneprocess.keychain.password" "" "SANEBAR_KEYCHAIN_PASSWORD")"
    set_env_if_missing "ASC_AUTH_KEY_ID" "$(keychain_secret "saneprocess.asc.key_id" "asc_key_id" "ASC_AUTH_KEY_ID")"
    set_env_if_missing "ASC_AUTH_KEY_ID" "$(keychain_secret "saneprocess.asc.key_id" "" "ASC_AUTH_KEY_ID")"
    set_env_if_missing "ASC_AUTH_ISSUER_ID" "$(keychain_secret "saneprocess.asc.issuer_id" "asc_issuer_id" "ASC_AUTH_ISSUER_ID")"
    set_env_if_missing "ASC_AUTH_ISSUER_ID" "$(keychain_secret "saneprocess.asc.issuer_id" "" "ASC_AUTH_ISSUER_ID")"
    set_env_if_missing "ASC_AUTH_KEY_PATH" "$(keychain_secret "saneprocess.asc.key_path" "asc_key_path" "ASC_AUTH_KEY_PATH")"
    set_env_if_missing "ASC_AUTH_KEY_PATH" "$(keychain_secret "saneprocess.asc.key_path" "" "ASC_AUTH_KEY_PATH")"
    set_env_if_missing "NOTARY_API_KEY_ID" "$(keychain_secret "saneprocess.notary.key_id" "notary_key_id" "NOTARY_API_KEY_ID")"
    set_env_if_missing "NOTARY_API_KEY_ID" "$(keychain_secret "saneprocess.notary.key_id" "" "NOTARY_API_KEY_ID")"
    set_env_if_missing "NOTARY_API_ISSUER_ID" "$(keychain_secret "saneprocess.notary.issuer_id" "notary_issuer_id" "NOTARY_API_ISSUER_ID")"
    set_env_if_missing "NOTARY_API_ISSUER_ID" "$(keychain_secret "saneprocess.notary.issuer_id" "" "NOTARY_API_ISSUER_ID")"
    set_env_if_missing "NOTARY_API_KEY_PATH" "$(keychain_secret "saneprocess.notary.key_path" "notary_key_path" "NOTARY_API_KEY_PATH")"
    set_env_if_missing "NOTARY_API_KEY_PATH" "$(keychain_secret "saneprocess.notary.key_path" "" "NOTARY_API_KEY_PATH")"
    # The shared Sparkle key lives at sparkle-project.org; prefer that exact entry
    # so the keychain guard does not burn a lookup on the legacy alias first.
    set_env_if_missing "SPARKLE_PRIVATE_KEY" "$(keychain_secret "https://sparkle-project.org" "EdDSA Private Key" "SPARKLE_PRIVATE_KEY")"
    set_env_if_missing "SPARKLE_PRIVATE_KEY" "$(keychain_secret "saneprocess.sparkle.private_key" "sparkle" "SPARKLE_PRIVATE_KEY")"
    set_env_if_missing "CLOUDFLARE_API_TOKEN" "$(resolve_cloudflare_api_token || true)"
}

default_peer_host_for_project() {
    case "${PROJECT_ROOT}" in
        /Users/sj/*)
            echo "mini"
            ;;
        /Users/stephansmac/*)
            # `air` alias is not guaranteed on the mini; .local hostname is reliable.
            echo "Stephans-MacBook-Air.local"
            ;;
        *)
            echo "mini"
            ;;
    esac
}

default_peer_repo_path_for_project() {
    case "${PROJECT_ROOT}" in
        /Users/sj/*)
            echo "/Users/stephansmac/${PROJECT_ROOT#/Users/sj/}"
            ;;
        /Users/stephansmac/*)
            echo "/Users/sj/${PROJECT_ROOT#/Users/stephansmac/}"
            ;;
        *)
            echo ""
            ;;
    esac
}

enforce_machine_reconcile() {
    if ! shell_true "${RELEASE_RECONCILE_ENABLED}"; then
        return 0
    fi

    if [ "${ALLOW_UNSYNCED_PEER}" = true ]; then
        log_warn "Machine reconcile gate bypassed (--allow-unsynced-peer)."
        return 0
    fi

    ensure_cmd ssh
    ensure_cmd git

    local peer_host peer_repo_path peer_branch
    local local_branch local_head local_dirty
    local peer_head="" peer_ref_branch="" peer_dirty="" peer_dirty_files=""
    local peer_report peer_path_escaped
    local local_dirty_files=""

    peer_host="${RELEASE_PEER_HOST:-$(default_peer_host_for_project)}"
    peer_repo_path="${RELEASE_PEER_REPO_PATH:-$(default_peer_repo_path_for_project)}"
    peer_branch="${RELEASE_PEER_BRANCH:-main}"

    if [ -z "${peer_host}" ] || [ -z "${peer_repo_path}" ]; then
        log_error "Machine reconcile gate is enabled but peer host/path could not be resolved."
        log_error "Set release.reconcile.peer_host and release.reconcile.peer_repo_path in .saneprocess, or use --allow-unsynced-peer."
        exit 1
    fi

    if mini_route_context_active; then
        local_branch="$(mini_route_workspace_field 'branch' 2>/dev/null || true)"
        local_head="$(mini_route_workspace_field 'head' 2>/dev/null || true)"
        local_dirty="$(mini_route_workspace_field 'dirty_count' 2>/dev/null || true)"
        local_dirty_files="$(mini_route_workspace_field 'dirty_files' 2>/dev/null || true)"
    else
        local_branch=$(git -C "${PROJECT_ROOT}" rev-parse --abbrev-ref HEAD 2>/dev/null || true)
        local_head=$(git -C "${PROJECT_ROOT}" rev-parse HEAD 2>/dev/null || true)
        local_dirty_files="$(git -C "${PROJECT_ROOT}" status --porcelain 2>/dev/null || true)"
        local_dirty=$(printf '%s\n' "${local_dirty_files}" | sed '/^[[:space:]]*$/d' | wc -l | tr -d ' ')
    fi

    if [ -z "${local_head}" ]; then
        log_error "Unable to read local git HEAD for machine reconcile gate."
        exit 1
    fi

    if [ "${local_dirty}" != "0" ]; then
        log_error "Local repo is not clean (${local_dirty} change(s)). Resolve before release."
        if [ -n "${local_dirty_files}" ]; then
            log_error "Local dirty files:"
            while IFS= read -r line; do
                [ -n "${line}" ] && log_error "  ${line}"
            done <<< "${local_dirty_files}"
        fi
        exit 1
    fi

    if [ -n "${peer_branch}" ] && [ "${local_branch}" != "${peer_branch}" ]; then
        log_error "Local branch is '${local_branch}', expected '${peer_branch}' for release."
        exit 1
    fi

    peer_path_escaped=$(printf '%q' "${peer_repo_path}")
    if ! peer_report=$(ssh -o BatchMode=yes -o ConnectTimeout=6 "${peer_host}" \
        "cd ${peer_path_escaped} >/dev/null 2>&1 && \
         printf 'HEAD=%s\nBRANCH=%s\nDIRTY=%s\n' \
           \"\$(git rev-parse HEAD 2>/dev/null || echo)\" \
           \"\$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo)\" \
           \"\$(git status --porcelain 2>/dev/null | wc -l | tr -d ' ')\""); then
        log_error "Could not query peer repo ${peer_host}:${peer_repo_path}."
        log_error "Fix connectivity/repo path first, or use --allow-unsynced-peer for emergencies."
        exit 1
    fi

    while IFS='=' read -r key value; do
        case "${key}" in
            HEAD) peer_head="${value}" ;;
            BRANCH) peer_ref_branch="${value}" ;;
            DIRTY) peer_dirty="${value}" ;;
        esac
    done <<< "${peer_report}"

    if [ -z "${peer_head}" ] || [ -z "${peer_ref_branch}" ] || [ -z "${peer_dirty}" ]; then
        log_error "Peer repo state parse failed for ${peer_host}:${peer_repo_path}."
        exit 1
    fi

    if [ "${peer_dirty}" != "0" ]; then
        log_error "Peer repo has ${peer_dirty} uncommitted change(s): ${peer_host}:${peer_repo_path}"
        log_error "Reconcile both machines before release, or use --allow-unsynced-peer for emergencies."
        peer_dirty_files=$(ssh -o BatchMode=yes -o ConnectTimeout=6 "${peer_host}" \
            "cd ${peer_path_escaped} >/dev/null 2>&1 && git status --porcelain 2>/dev/null | sed -n '1,50p'" 2>/dev/null || true)
        if [ -n "${peer_dirty_files}" ]; then
            log_error "Peer dirty files (first 50):"
            local peer_line
            while IFS= read -r peer_line; do
                [ -n "${peer_line}" ] && log_error "  ${peer_line}"
            done <<< "${peer_dirty_files}"
        fi
        exit 1
    fi

    if [ -n "${peer_branch}" ] && [ "${peer_ref_branch}" != "${peer_branch}" ]; then
        log_error "Peer branch is '${peer_ref_branch}', expected '${peer_branch}'."
        exit 1
    fi

    if [ "${local_head}" != "${peer_head}" ]; then
        log_error "Air/mini are not reconciled."
        log_error "Local (${local_branch}) HEAD: ${local_head}"
        log_error "Peer  (${peer_ref_branch}) HEAD: ${peer_head}"
        log_error "Review divergence locally:"
        log_error "  git -C \"${PROJECT_ROOT}\" log --left-right --oneline ${peer_head}...${local_head}"
        log_error "Then keep good changes, drop bad ones, sync both machines, and rerun."
        log_error "Emergency bypass remains --allow-unsynced-peer (typed approval)."
        exit 1
    fi

    log_info "Machine reconcile gate passed: local and ${peer_host} are synced at ${local_head:0:12}"
}

enforce_reconcile_policy() {
    # Do not allow silent env-based bypasses. Emergency bypass must be explicit and typed.
    if ! shell_true "${RELEASE_RECONCILE_ENABLED}"; then
        if [ "${ALLOW_UNSYNCED_PEER}" = true ]; then
            log_warn "RELEASE_RECONCILE_ENABLED=false supplied, but explicit --allow-unsynced-peer override is in effect."
            return 0
        fi
        log_error "RELEASE_RECONCILE_ENABLED=false is not allowed for release operations."
        log_error "Release reconcile is mandatory. Use --allow-unsynced-peer (typed approval) for emergency-only bypass."
        exit 1
    fi
}

resolve_path() {
    local path="$1"
    if [ -z "${path}" ]; then
        echo ""
        return 0
    fi
    if [[ "${path}" = /* ]]; then
        echo "${path}"
    else
        echo "${PROJECT_ROOT}/${path}"
    fi
}

remove_path() {
    local path="$1"
    if [ -e "${path}" ]; then
        if command -v trash >/dev/null 2>&1; then
            trash "${path}"
        else
            rm -rf "${path}"
        fi
    fi
}

register_release_temp_path() {
    local path="$1"
    [ -n "${path}" ] || return 0
    RELEASE_TEMP_PATHS+=("${path}")
}

webhook_checkout_branch() {
    local repo_dir="$1"
    local branch=""

    branch=$(git -C "${repo_dir}" rev-parse --abbrev-ref HEAD 2>/dev/null || true)
    if [ -z "${branch}" ] || [ "${branch}" = "HEAD" ]; then
        branch="main"
    fi
    printf '%s' "${branch}"
}

prepare_clean_webhook_checkout() {
    local source_dir="$1"
    local branch="$2"
    local remote_url=""
    local clean_root=""
    local clean_repo=""

    remote_url=$(git -C "${source_dir}" remote get-url origin 2>/dev/null || true)
    if [ -z "${remote_url}" ]; then
        log_error "Could not resolve sane-email-automation origin URL from ${source_dir}"
        return 1
    fi

    clean_root=$(mktemp -d "/tmp/sane-email-automation-release.XXXXXX")
    clean_repo="${clean_root}/repo"
    register_release_temp_path "${clean_root}"

    if ! GIT_TERMINAL_PROMPT=0 GCM_INTERACTIVE=never git clone --no-checkout "${remote_url}" "${clean_repo}" >/dev/null 2>&1; then
        log_error "Could not clone sane-email-automation from ${remote_url}"
        return 1
    fi

    if ! (
        cd "${clean_repo}" &&
        GIT_TERMINAL_PROMPT=0 GCM_INTERACTIVE=never git fetch --tags origin >/dev/null 2>&1 || true
        if git show-ref --verify --quiet "refs/remotes/origin/${branch}"; then
            git checkout -B "${branch}" "origin/${branch}" >/dev/null 2>&1
            git branch --set-upstream-to "origin/${branch}" "${branch}" >/dev/null 2>&1 || true
            git reset --hard "origin/${branch}" >/dev/null 2>&1
        else
            git checkout "$(git rev-parse HEAD)" >/dev/null 2>&1
        fi
        git clean -fdx >/dev/null 2>&1
    ); then
        log_error "Could not prepare a clean sane-email-automation checkout for release sync."
        return 1
    fi

    printf '%s' "${clean_repo}"
}

resolve_release_webhook_checkout() {
    local source_dir="$1"
    local source_js="$2"
    local branch="$3"
    local unrelated_changes=""
    local current_head=""
    local remote_head=""
    local clean_repo=""

    WEBHOOK_WORK_DIR="${source_dir}"
    WEBHOOK_WORK_JS="${source_js}"

    unrelated_changes=$(git -C "${source_dir}" status --porcelain --untracked-files=all 2>/dev/null | \
        grep -vE '^[ MARCUD?]{2} src/handlers/webhook-lemonsqueezy\.js$' || true)
    if [ -n "${unrelated_changes}" ]; then
        log_warn "sane-email-automation checkout has unrelated dirty changes; using a clean temporary clone for webhook release sync."
        clean_repo=$(prepare_clean_webhook_checkout "${source_dir}" "${branch}") || return 1
        WEBHOOK_WORK_DIR="${clean_repo}"
        WEBHOOK_WORK_JS="${clean_repo}/src/handlers/webhook-lemonsqueezy.js"
        return 0
    fi

    GIT_TERMINAL_PROMPT=0 GCM_INTERACTIVE=never git -C "${source_dir}" fetch origin "${branch}" >/dev/null 2>&1 || true
    current_head=$(git -C "${source_dir}" rev-parse HEAD 2>/dev/null || true)
    remote_head=$(git -C "${source_dir}" rev-parse "origin/${branch}" 2>/dev/null || true)
    if [ -n "${current_head}" ] && [ -n "${remote_head}" ] && [ "${current_head}" != "${remote_head}" ]; then
        log_warn "sane-email-automation checkout is not aligned with origin/${branch}; using a clean temporary clone for webhook release sync."
        clean_repo=$(prepare_clean_webhook_checkout "${source_dir}" "${branch}") || return 1
        WEBHOOK_WORK_DIR="${clean_repo}"
        WEBHOOK_WORK_JS="${clean_repo}/src/handlers/webhook-lemonsqueezy.js"
    fi
}

project_version_from_semver() {
    local semver="$1"
    local project_version
    # Parse major.minor.patch and compute major*1000 + minor*100 + patch
    # This ensures 2.0.0 (2000) > 1.0.23 (1023) for Sparkle version comparison
    local major minor patch
    major=$(echo "$semver" | cut -d. -f1)
    minor=$(echo "$semver" | cut -d. -f2)
    patch=$(echo "$semver" | cut -d. -f3)
    major=${major:-0}; minor=${minor:-0}; patch=${patch:-0}
    project_version=$(( major * 1000 + minor * 100 + patch ))
    if [ "${project_version}" -eq 0 ]; then
        project_version="1"
    fi
    echo "${project_version}"
}

restore_version_bump() {
    if [ -n "${VERSION_BUMP_RESTORE_CMD}" ]; then
        eval "${VERSION_BUMP_RESTORE_CMD}"
        return
    fi

    if [ -f "${PROJECT_ROOT}/project.yml" ]; then
        git -C "${PROJECT_ROOT}" restore --staged --worktree "project.yml" 2>/dev/null || \
            git -C "${PROJECT_ROOT}" checkout -- "project.yml" 2>/dev/null || true
    fi

    # Also restore any xcconfig files that were modified
    while IFS= read -r xcf; do
        [ -z "${xcf}" ] && continue
        local rel_path="${xcf#"${PROJECT_ROOT}/"}"
        git -C "${PROJECT_ROOT}" restore --staged --worktree "${rel_path}" 2>/dev/null || \
            git -C "${PROJECT_ROOT}" checkout -- "${rel_path}" 2>/dev/null || true
    done < <(git -C "${PROJECT_ROOT}" diff --name-only -- '*.xcconfig' 2>/dev/null)
}

bump_project_version() {
    local version="$1"
    local project_version
    project_version=$(project_version_from_semver "${version}")

    if [ -n "${VERSION_BUMP_CMD}" ]; then
        eval "${VERSION_BUMP_CMD}"
        return
    fi

    if [ ! -f "${PROJECT_ROOT}/project.yml" ]; then
        # Try xcconfig files before giving up
        local xcconfig_found=false
        while IFS= read -r xcf; do
            [ -z "${xcf}" ] && continue
            if grep -q 'MARKETING_VERSION' "${xcf}" 2>/dev/null; then
                sed -i '' "s/MARKETING_VERSION = .*/MARKETING_VERSION = ${version}/" "${xcf}"
                if grep -q 'CURRENT_PROJECT_VERSION' "${xcf}" 2>/dev/null; then
                    sed -i '' "s/CURRENT_PROJECT_VERSION = .*/CURRENT_PROJECT_VERSION = ${project_version}/" "${xcf}"
                fi
                log_info "Updated version in ${xcf}"
                xcconfig_found=true
            fi
        done < <(find "${PROJECT_ROOT}" -name "*.xcconfig" -not -path "*/DerivedData/*" -not -path "*/build/*" 2>/dev/null)

        if [ "${xcconfig_found}" != "true" ]; then
            log_error "No version bump method. Set VERSION_BUMP_CMD, add project.yml, or use xcconfig with MARKETING_VERSION."
            exit 1
        fi
        return
    fi

    sed -i '' "s/MARKETING_VERSION: \".*\"/MARKETING_VERSION: \"${version}\"/" "${PROJECT_ROOT}/project.yml"
    sed -i '' "s/CURRENT_PROJECT_VERSION: \".*\"/CURRENT_PROJECT_VERSION: \"${project_version}\"/" "${PROJECT_ROOT}/project.yml"

    # Also update any xcconfig files that define MARKETING_VERSION (some projects use both)
    while IFS= read -r xcf; do
        [ -z "${xcf}" ] && continue
        if grep -q 'MARKETING_VERSION' "${xcf}" 2>/dev/null; then
            sed -i '' "s/MARKETING_VERSION = .*/MARKETING_VERSION = ${version}/" "${xcf}"
            if grep -q 'CURRENT_PROJECT_VERSION' "${xcf}" 2>/dev/null; then
                sed -i '' "s/CURRENT_PROJECT_VERSION = .*/CURRENT_PROJECT_VERSION = ${project_version}/" "${xcf}"
            fi
            log_info "Also updated version in ${xcf}"
        fi
    done < <(find "${PROJECT_ROOT}" -name "*.xcconfig" -not -path "*/DerivedData/*" -not -path "*/build/*" 2>/dev/null)
}

run_tests() {
    log_info "Running tests..."

    local cache_root="${PROJECT_ROOT}/.build/cache"
    local clang_cache="${cache_root}/clang"
    local swiftpm_cache="${cache_root}/swiftpm"
    mkdir -p "${clang_cache}" "${swiftpm_cache}"

    if [ -d "${HOME}/Library/Caches/org.swift.swiftpm" ] && [ ! -d "${swiftpm_cache}/repositories" ]; then
        cp -R "${HOME}/Library/Caches/org.swift.swiftpm/." "${swiftpm_cache}/" 2>/dev/null || true
    fi

    local args=(test -scheme "${SCHEME}" -destination 'platform=macOS' -quiet)
    if [ -n "${WORKSPACE}" ]; then
        args=(-workspace "${WORKSPACE}" "${args[@]}")
    elif [ -n "${XCODEPROJ}" ]; then
        args=(-project "${XCODEPROJ}" "${args[@]}")
    fi

    local test_log="${PROJECT_ROOT}/.build/release_test.log"
    local unsigned_signing_args=(
        "CODE_SIGNING_ALLOWED=NO"
        "CODE_SIGNING_REQUIRED=NO"
        "CODE_SIGN_IDENTITY="
        "DEVELOPMENT_TEAM="
        "PROVISIONING_PROFILE_SPECIFIER="
        "PROVISIONING_PROFILE="
    )
    local sanemaster_verify="${PROJECT_ROOT}/scripts/SaneMaster.rb"

    test_log_indicates_success() {
        local log_path="$1"
        [ -f "${log_path}" ] || return 1

        if grep -q "✅ Tests passed!" "${log_path}"; then
            return 0
        fi
        if grep -Eq "Swift Testing:[[:space:]]+[0-9]+ tests .* passed" "${log_path}"; then
            return 0
        fi
        if grep -q "Test Suite 'All tests' passed" "${log_path}"; then
            return 0
        fi
        if grep -Eq "Executed [0-9]+ tests?, with 0 failures" "${log_path}"; then
            return 0
        fi

        return 1
    }

    if [ -x "${sanemaster_verify}" ]; then
        log_info "Using SaneMaster verify as the authoritative release test lane."
        if SANEMASTER_RELEASE_PREFLIGHT=1 "${sanemaster_verify}" verify --quiet >"${test_log}" 2>&1; then
            cat "${test_log}"
            log_info "All tests passed (SaneMaster verify)"
            return 0
        fi

        cat "${test_log}"
        if test_log_indicates_success "${test_log}"; then
            log_warn "SaneMaster verify returned non-zero despite a clean pass. Continuing with release."
            return 0
        fi

        log_error "SaneMaster verify failed. Aborting release."
        restore_version_bump
        exit 1
    fi

    if XDG_CACHE_HOME="${cache_root}" \
       CLANG_MODULE_CACHE_PATH="${clang_cache}" \
       SWIFTPM_CACHE_PATH="${swiftpm_cache}" \
       xcodebuild "${args[@]}" >"${test_log}" 2>&1; then
        cat "${test_log}"
        log_info "All tests passed"
        return 0
    fi

    cat "${test_log}"

    if test_log_indicates_success "${test_log}"; then
        log_warn "Test runner returned non-zero despite a clean pass. Continuing with release."
        return 0
    fi

    if grep -Eq 'Command CodeSign failed with a nonzero exit code|errSecInternalComponent|No profiles for .+ were found|Automatic signing is disabled and unable to generate a profile|requires a provisioning profile with the .+ feature' "${test_log}"; then
        log_warn "Signed test build failed due to signing/provisioning. Retrying unsigned tests..."
        if XDG_CACHE_HOME="${cache_root}" \
           CLANG_MODULE_CACHE_PATH="${clang_cache}" \
           SWIFTPM_CACHE_PATH="${swiftpm_cache}" \
           xcodebuild "${args[@]}" "${unsigned_signing_args[@]}" >"${test_log}" 2>&1; then
            cat "${test_log}"
            log_info "All tests passed (unsigned fallback)"
            return 0
        fi

        cat "${test_log}"
        if test_log_indicates_success "${test_log}"; then
            log_warn "Unsigned test runner returned non-zero despite a clean pass. Continuing with release."
            return 0
        fi
    fi

    log_error "Tests failed. Aborting release."
    restore_version_bump
    exit 1
}

update_changelog() {
    local changelog="${PROJECT_ROOT}/CHANGELOG.md"
    if [ ! -f "${changelog}" ]; then
        log_warn "No CHANGELOG.md found — skipping changelog update"
        return 0
    fi

    local date_str
    date_str=$(date +%Y-%m-%d)
    local tmp_changelog="${changelog}.tmp"
    local entry_file="${changelog}.entry"

    # Write entry to temp file (avoids awk -v backslash escaping issues)
    cat > "${entry_file}" <<CLEOF
## [${VERSION}] - ${date_str}

${RELEASE_NOTES}

---

CLEOF

    if grep -q '^## \[' "${changelog}"; then
        # Insert before the first existing version entry
        local first_entry_line
        first_entry_line=$(grep -n '^## \[' "${changelog}" | head -1 | cut -d: -f1)
        head -n $((first_entry_line - 1)) "${changelog}" > "${tmp_changelog}"
        cat "${entry_file}" >> "${tmp_changelog}"
        tail -n +${first_entry_line} "${changelog}" >> "${tmp_changelog}"
    else
        # No existing entries — append after header
        cp "${changelog}" "${tmp_changelog}"
        echo "" >> "${tmp_changelog}"
        cat "${entry_file}" >> "${tmp_changelog}"
    fi

    mv "${tmp_changelog}" "${changelog}"
    rm -f "${entry_file}"
    log_info "CHANGELOG.md updated with v${VERSION} entry"
}

commit_version_bump() {
    git -C "${PROJECT_ROOT}" add "${VERSION_BUMP_FILES[@]}"
    # Also stage CHANGELOG.md if it was updated
    if [ -f "${PROJECT_ROOT}/CHANGELOG.md" ]; then
        git -C "${PROJECT_ROOT}" add "CHANGELOG.md"
    fi
    if git -C "${PROJECT_ROOT}" commit -m "Bump version to ${VERSION}" >/dev/null 2>&1; then
        log_info "Version bump committed"
    else
        log_warn "No version bump commit created (maybe no changes)"
    fi
}

create_github_release() {
    local strict="${STRICT_PUBLIC_CHANNEL_SYNC}"
    if ! command -v gh >/dev/null 2>&1; then
        if [ "${strict}" = true ]; then
            log_error "gh CLI not installed — cannot create required GitHub release."
            return 1
        fi
        log_warn "gh CLI not installed — skipping GitHub release creation."
        return 0
    fi

    if [ -z "${GITHUB_REPO}" ]; then
        if [ "${strict}" = true ]; then
            log_error "GITHUB_REPO is empty — cannot create required GitHub release."
            return 1
        fi
        log_warn "GITHUB_REPO is empty — skipping GitHub release creation."
        return 0
    fi

    local tag="v${VERSION}"
    local title="${APP_NAME} ${VERSION}"
    local notes_file="${BUILD_DIR}/github-release-notes-${VERSION}.md"
    cat > "${notes_file}" <<EOF
## ${APP_NAME} ${VERSION}

${RELEASE_NOTES:-Release ${VERSION}}

Distribution:
- Sparkle appcast: https://${SITE_HOST}/appcast.xml
- Direct download: https://${DIST_HOST}/updates/${APP_NAME}-${VERSION}.zip
EOF

    local target_commit
    target_commit=$(git -C "${PROJECT_ROOT}" rev-parse HEAD 2>/dev/null || echo "")

    if [ -n "${target_commit}" ]; then
        local remote_tag_commit=""
        remote_tag_commit=$(git -C "${PROJECT_ROOT}" ls-remote --tags origin "refs/tags/${tag}" 2>/dev/null | awk '{print $1}' | head -1)
        if [ "${remote_tag_commit}" != "${target_commit}" ]; then
            git -C "${PROJECT_ROOT}" tag -f "${tag}" "${target_commit}" >/dev/null 2>&1 || {
                log_error "Failed to retarget local ${tag} tag to ${target_commit}."
                return 1
            }

            if [ -n "${remote_tag_commit}" ]; then
                log_info "Updating remote ${tag} tag to ${target_commit} before GitHub release sync..."
                local tag_push_output=""
                if ! tag_push_output=$(GIT_TERMINAL_PROMPT=0 GCM_INTERACTIVE=never git -C "${PROJECT_ROOT}" push --force origin "refs/tags/${tag}" 2>&1); then
                    log_error "Failed to push corrected remote ${tag} tag before GitHub release sync."
                    [ -n "${tag_push_output}" ] && log_error "git output: ${tag_push_output}"
                    return 1
                fi
            else
                log_info "Pushing new remote ${tag} tag at ${target_commit} before GitHub release sync..."
                local tag_push_output=""
                if ! tag_push_output=$(GIT_TERMINAL_PROMPT=0 GCM_INTERACTIVE=never git -C "${PROJECT_ROOT}" push origin "refs/tags/${tag}" 2>&1); then
                    log_error "Failed to push remote ${tag} tag before GitHub release sync."
                    [ -n "${tag_push_output}" ] && log_error "git output: ${tag_push_output}"
                    return 1
                fi
            fi
        fi
    fi

    if gh release view "${tag}" --repo "${GITHUB_REPO}" >/dev/null 2>&1; then
        local -a gh_edit_args
        gh_edit_args=(
            release
            edit
            "${tag}"
            --repo "${GITHUB_REPO}"
            --title "${title}"
            --notes-file "${notes_file}"
        )
        if [ -n "${target_commit}" ]; then
            gh_edit_args+=(--target "${target_commit}")
        fi

        if gh "${gh_edit_args[@]}" >/dev/null 2>&1; then
            log_info "GitHub release updated: ${GITHUB_REPO}@${tag}"
        else
            if [ "${strict}" = true ]; then
                log_error "Failed to edit required GitHub release ${GITHUB_REPO}@${tag}."
                return 1
            fi
            log_warn "Failed to edit GitHub release ${GITHUB_REPO}@${tag} (non-fatal)."
        fi
    else
        local -a gh_create_args
        gh_create_args=(
            release
            create
            "${tag}"
            --repo "${GITHUB_REPO}"
            --title "${title}"
            --notes-file "${notes_file}"
            --verify-tag
        )

        if gh_create_output=$(gh "${gh_create_args[@]}" 2>&1); then
            log_info "GitHub release created: ${GITHUB_REPO}@${tag}"
        else
            if [ "${strict}" = true ]; then
                log_error "Failed to create required GitHub release ${GITHUB_REPO}@${tag}."
                if [ -n "${gh_create_output}" ]; then
                    log_error "gh output: ${gh_create_output}"
                fi
                return 1
            fi
            log_warn "Failed to create GitHub release ${GITHUB_REPO}@${tag} (non-fatal)."
            if [ -n "${gh_create_output}" ]; then
                log_warn "gh output: ${gh_create_output}"
            fi
        fi
    fi

    if ! gh release view "${tag}" --repo "${GITHUB_REPO}" >/dev/null 2>&1; then
        if [ "${strict}" = true ]; then
            log_error "GitHub release ${GITHUB_REPO}@${tag} is not accessible after update."
            return 1
        fi
        log_warn "GitHub release ${GITHUB_REPO}@${tag} not accessible after update."
    fi

    return 0
}

upload_github_release_asset() {
    local strict="${STRICT_PUBLIC_CHANNEL_SYNC}"
    if [ "${PUBLIC_CHANNEL_APPROVED}" != true ]; then
        if [ "${strict}" = true ]; then
            log_error "GitHub binary upload is required but ${APP_NAME} is not in PUBLIC_CHANNEL_ALLOWLIST."
            return 1
        fi
        log_warn "Skipping GitHub binary upload: ${APP_NAME} is not in PUBLIC_CHANNEL_ALLOWLIST."
        return 0
    fi

    if ! command -v gh >/dev/null 2>&1; then
        if [ "${strict}" = true ]; then
            log_error "gh CLI not installed — cannot upload required GitHub binary."
            return 1
        fi
        log_warn "gh CLI not installed — skipping GitHub binary upload."
        return 0
    fi

    if [ -z "${GITHUB_REPO}" ]; then
        if [ "${strict}" = true ]; then
            log_error "GITHUB_REPO is empty — cannot upload required GitHub binary."
            return 1
        fi
        log_warn "GITHUB_REPO is empty — skipping GitHub binary upload."
        return 0
    fi

    if [ ! -f "${FINAL_ZIP}" ]; then
        if [ "${strict}" = true ]; then
            log_error "Release ZIP not found at ${FINAL_ZIP} — cannot upload required GitHub binary."
            return 1
        fi
        log_warn "Release ZIP not found at ${FINAL_ZIP} — skipping GitHub binary upload."
        return 0
    fi

    local tag="v${VERSION}"
    local asset_name
    asset_name="$(basename "${FINAL_ZIP}")"

    if ! gh release view "${tag}" --repo "${GITHUB_REPO}" >/dev/null 2>&1; then
        if [ "${strict}" = true ]; then
            log_error "GitHub release ${GITHUB_REPO}@${tag} does not exist yet."
            return 1
        fi
        log_warn "GitHub release ${GITHUB_REPO}@${tag} does not exist yet — skipping asset upload (non-fatal)."
        return 0
    fi

    if gh release upload "${tag}" "${FINAL_ZIP}" --repo "${GITHUB_REPO}" --clobber >/dev/null 2>&1; then
        log_info "Uploaded GitHub release asset: ${tag}/${asset_name}"
    else
        if [ "${strict}" = true ]; then
            log_error "Failed to upload required GitHub release asset ${tag}/${asset_name}."
            return 1
        fi
        log_warn "Failed to upload GitHub release asset ${tag}/${asset_name} (non-fatal)."
    fi

    if ! gh release view "${tag}" --repo "${GITHUB_REPO}" --json assets --jq '.assets[].name' 2>/dev/null | grep -Fxq "${asset_name}"; then
        if [ "${strict}" = true ]; then
            log_error "GitHub release ${GITHUB_REPO}@${tag} is missing required asset ${asset_name}."
            return 1
        fi
        log_warn "GitHub release ${GITHUB_REPO}@${tag} is missing asset ${asset_name}."
    fi

    return 0
}

purge_github_binary_assets() {
    if [ "${PURGE_GITHUB_BINARY_ASSETS}" != true ]; then
        return 0
    fi

    if ! command -v gh >/dev/null 2>&1; then
        log_warn "gh CLI not installed — skipping GitHub binary purge."
        return 0
    fi

    if [ -z "${GITHUB_REPO}" ]; then
        log_warn "GITHUB_REPO is empty — skipping GitHub binary purge."
        return 0
    fi

    local tags
    tags=$(gh release list --repo "${GITHUB_REPO}" --limit 200 --json tagName --jq '.[].tagName' 2>/dev/null || true)
    if [ -z "${tags}" ]; then
        log_info "No GitHub releases found for binary purge."
        return 0
    fi

    local deleted=0
    local failed=0
    local keep_zip="${APP_NAME}-${VERSION}.zip"
    local keep_dmg="${APP_NAME}-${VERSION}.dmg"
    while IFS= read -r tag; do
        [ -n "${tag}" ] || continue
        local assets
        assets=$(gh release view "${tag}" --repo "${GITHUB_REPO}" --json assets --jq '.assets[].name' 2>/dev/null || true)
        [ -n "${assets}" ] || continue

        while IFS= read -r asset; do
            [ -n "${asset}" ] || continue
            case "${asset}" in
                "${keep_zip}"|"${keep_dmg}")
                    continue
                    ;;
                "${APP_NAME}-"*.zip|"${APP_NAME}-"*.dmg)
                    if gh release delete-asset "${tag}" "${asset}" --repo "${GITHUB_REPO}" -y >/dev/null 2>&1; then
                        log_info "Removed GitHub binary asset ${tag}/${asset}"
                        deleted=$((deleted + 1))
                    else
                        log_warn "Failed to remove GitHub binary asset ${tag}/${asset}"
                        failed=$((failed + 1))
                    fi
                    ;;
            esac
        done <<< "${assets}"
    done <<< "${tags}"

    log_info "GitHub binary asset purge summary: deleted=${deleted}, failed=${failed}"
    return 0
}

build_optional_appcast_fields() {
    local lines=""

    if [ -n "${MIN_AUTOUPDATE_VERSION}" ]; then
        lines="${lines}            <sparkle:minimumAutoupdateVersion>${MIN_AUTOUPDATE_VERSION}</sparkle:minimumAutoupdateVersion>\n"
    fi

    if [ "${CRITICAL_UPDATE}" = true ]; then
        if [ -n "${CRITICAL_UPDATE_BELOW_VERSION}" ]; then
            lines="${lines}            <sparkle:criticalUpdate sparkle:version=\"${CRITICAL_UPDATE_BELOW_VERSION}\"/>\n"
        else
            lines="${lines}            <sparkle:criticalUpdate/>\n"
        fi
    fi

    printf '%b' "${lines}"
}

write_export_options_plist() {
    local plist_path="$1"

    if [ -n "${EXPORT_OPTIONS_PLIST}" ]; then
        cp "${EXPORT_OPTIONS_PLIST}" "${plist_path}"
        return
    fi

    cat > "${plist_path}" << OPT
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
    <key>method</key>
    <string>developer-id</string>
    <key>teamID</key>
    <string>${TEAM_ID}</string>
    <key>signingStyle</key>
    <string>manual</string>
    <key>signingCertificate</key>
    <string>Developer ID Application</string>
OPT

    if [ ${#EXPORT_OPTIONS_PROFILES[@]} -gt 0 ]; then
        echo "    <key>provisioningProfiles</key>" >> "${plist_path}"
        echo "    <dict>" >> "${plist_path}"
        for entry in "${EXPORT_OPTIONS_PROFILES[@]}"; do
            local bundle_id="${entry%%:*}"
            local profile_name="${entry#*:}"
            echo "        <key>${bundle_id}</key>" >> "${plist_path}"
            echo "        <string>${profile_name}</string>" >> "${plist_path}"
        done
        echo "    </dict>" >> "${plist_path}"
    fi

    if [ -n "${EXPORT_OPTIONS_EXTRA_XML}" ]; then
        echo "${EXPORT_OPTIONS_EXTRA_XML}" >> "${plist_path}"
    fi

    cat >> "${plist_path}" << OPT
</dict>
</plist>
OPT
}

preflight_check_dylibs() {
    # Verify all dynamic libraries referenced by the binary are present in the bundle.
    # Catches the Sparkle-missing-from-App-Store-build class of crash-on-launch.
    local app_path="$1"
    local label="$2"
    local main_exec
    main_exec=$(defaults read "${app_path}/Contents/Info" CFBundleExecutable 2>/dev/null || true)
    if [ -z "${main_exec}" ]; then
        log_warn "Could not read CFBundleExecutable for dylib check"
        return 0
    fi

    local binary="${app_path}/Contents/MacOS/${main_exec}"
    if [ ! -f "${binary}" ]; then
        log_warn "Binary not found for dylib check: ${binary}"
        return 0
    fi

    local missing=0
    while IFS= read -r line; do
        line=$(echo "${line}" | xargs) # trim whitespace
        [ -z "${line}" ] && continue

        # Extract the library path (first field before the parenthesized version info)
        local lib
        lib=$(echo "${line}" | awk '{print $1}')
        [ -z "${lib}" ] && continue

        # Skip weak references — they don't crash if missing (LC_LOAD_WEAK_DYLIB)
        # otool -L shows: "... (compatibility version X.Y.Z, current version X.Y.Z, weak)"
        if echo "${line}" | grep -q ", weak)"; then
            continue
        fi

        # Resolve @rpath references against the app's Frameworks dir
        if [[ "${lib}" == @rpath/* ]]; then
            local framework_name="${lib#@rpath/}"
            local resolved="${app_path}/Contents/Frameworks/${framework_name}"
            if [ ! -f "${resolved}" ]; then
                log_error "MISSING DYLIB (${label}): ${lib}"
                log_error "  Expected at: ${resolved}"
                missing=$((missing + 1))
            fi
        elif [[ "${lib}" == @executable_path/* ]]; then
            local rel_path="${lib#@executable_path/}"
            local resolved="${app_path}/Contents/MacOS/${rel_path}"
            if [ ! -f "${resolved}" ]; then
                log_error "MISSING DYLIB (${label}): ${lib}"
                log_error "  Expected at: ${resolved}"
                missing=$((missing + 1))
            fi
        fi
        # /usr/lib and /System paths are OS-provided, skip
    done < <(otool -L "${binary}" 2>/dev/null | tail -n +2)

    if [ "${missing}" -gt 0 ]; then
        log_error ""
        log_error "${missing} missing dynamic library reference(s) in ${label} build."
        log_error "The app WILL crash on launch (dyld: Library not loaded)."
        log_error ""
        log_error "Common cause: binary was compiled with a framework (e.g., Sparkle)"
        log_error "that isn't included in the App Store build. Use a separate build"
        log_error "configuration (e.g., Release-AppStore) with conditional compilation"
        log_error "flags (#if !APP_STORE) to exclude the framework at compile time."
        return 1
    fi

    log_info "Dylib preflight (${label}): all references resolved"
    return 0
}

write_export_options_appstore_plist() {
    local plist_path="$1"
    cat > "${plist_path}" << 'OPT'
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
    <key>method</key>
    <string>app-store-connect</string>
    <key>teamID</key>
OPT
    echo "    <string>${TEAM_ID}</string>" >> "${plist_path}"
    cat >> "${plist_path}" << 'OPT'
    <key>signingStyle</key>
    <string>automatic</string>
</dict>
</plist>
OPT
}

validate_appstore_pkg_preflight() {
    local pkg_path="$1"
    local tmp_root
    local expanded_root
    local app_path
    local entitlements

    tmp_root=$(/usr/bin/mktemp -d /tmp/appstore_pkg_preflight.XXXX)
    expanded_root="${tmp_root}/expanded"
    # pkgutil requires destination to NOT already exist.
    if ! pkgutil --expand-full "${pkg_path}" "${expanded_root}" >/dev/null 2>&1; then
        # Some macOS versions only support --expand for this artifact shape.
        if ! pkgutil --expand "${pkg_path}" "${expanded_root}" >/dev/null 2>&1; then
            log_error "App Store preflight failed: could not expand pkg ${pkg_path}"
            remove_path "${tmp_root}"
            return 1
        fi
    fi

    app_path=$(find "${expanded_root}" -type d -name "${APP_NAME}.app" | head -n 1)
    if [ -z "${app_path}" ] || [ ! -d "${app_path}" ]; then
        log_error "App Store preflight failed: app payload not found in pkg"
        remove_path "${tmp_root}"
        return 1
    fi

    if [ -d "${app_path}/Contents/Frameworks/Sparkle.framework" ]; then
        log_error "App Store preflight failed: Sparkle.framework is still present in pkg payload"
        remove_path "${tmp_root}"
        return 1
    fi

    entitlements=$(codesign -d --entitlements :- "${app_path}" 2>/dev/null || true)
    if [ -z "${entitlements}" ]; then
        log_error "App Store preflight failed: could not read app entitlements from pkg payload"
        remove_path "${tmp_root}"
        return 1
    fi

    if ! echo "${entitlements}" | tr -d '[:space:]' | grep -q "<key>com.apple.security.app-sandbox</key><true/>"; then
        log_error "App Store preflight failed: payload app is missing com.apple.security.app-sandbox=true"
        remove_path "${tmp_root}"
        return 1
    fi

    remove_path "${tmp_root}"
    return 0
}

create_empty_entitlements_plist() {
    local entitlements_path="$1"
    cat > "${entitlements_path}" << 'PLIST'
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict/>
</plist>
PLIST
}

binary_has_get_task_allow() {
    local binary_path="$1"
    if codesign -d --entitlements :- "${binary_path}" 2>/dev/null | grep -q "get-task-allow"; then
        return 0
    fi
    return 1
}

binary_has_strong_sparkle_load() {
    local binary_path="$1"
    python3 - "${binary_path}" <<'PY'
import subprocess
import sys

binary = sys.argv[1]
try:
    out = subprocess.check_output(["otool", "-l", binary], text=True, stderr=subprocess.DEVNULL)
except Exception:
    sys.exit(1)

lines = out.splitlines()
for i, line in enumerate(lines):
    if line.strip() != "cmd LC_LOAD_DYLIB":
        continue
    for j in range(i + 1, min(i + 12, len(lines))):
        s = lines[j].strip()
        if s.startswith("name ") and "Sparkle.framework" in s:
            sys.exit(0)

sys.exit(1)
PY
}

sign_app_bundle_developer_id() {
    local bundle_path="$1"
    local entitlements_path="$2"

    # --deep is intentional here because helper apps embedded in zips frequently
    # contain Swift runtime dylibs and frameworks which also need a Developer ID signature.
    codesign --force --sign "${SIGNING_IDENTITY}" --options runtime --timestamp \
        --entitlements "${entitlements_path}" --deep "${bundle_path}"
}

fix_and_verify_zipped_apps_in_app() {
    local host_app_path="$1"

    # Some libraries embed helper .app bundles inside .zip resources. Apple notarization
    # validates those payloads, but codesign --verify --deep does NOT inspect inside zips.
    local tmp_root
    tmp_root=$(/usr/bin/mktemp -d /tmp/release_notary_preflight.XXXX)
    local empty_entitlements="${tmp_root}/empty.entitlements"
    create_empty_entitlements_plist "${empty_entitlements}"

    # Only scan Resources for zips to keep runtime low.
    local resources_path="${host_app_path}/Contents/Resources"
    if [ ! -d "${resources_path}" ]; then
        remove_path "${tmp_root}"
        return 0
    fi

    local zip_found=false
    while IFS= read -r -d '' zip_path; do
        zip_found=true

        local unzip_dir="${tmp_root}/unzip"
        remove_path "${unzip_dir}"
        mkdir -p "${unzip_dir}"

        # If the zip doesn't contain an .app, skip it.
        if ! ditto -x -k "${zip_path}" "${unzip_dir}" 2>/dev/null; then
            log_warn "Could not unzip resource: ${zip_path} (skipping)"
            continue
        fi

        local apps_in_zip
        apps_in_zip=$(find "${unzip_dir}" -name "*.app" -maxdepth 6 2>/dev/null || true)
        if [ -z "${apps_in_zip}" ]; then
            continue
        fi

        log_info "Fixing embedded helper app(s) in: ${zip_path}"

        # Re-sign each embedded app bundle.
        while IFS= read -r embedded_app; do
            [ -n "${embedded_app}" ] || continue

            local embedded_exec
            embedded_exec=$(defaults read "${embedded_app}/Contents/Info" CFBundleExecutable 2>/dev/null || true)
            if [ -n "${embedded_exec}" ] && [ -f "${embedded_app}/Contents/MacOS/${embedded_exec}" ]; then
                if binary_has_get_task_allow "${embedded_app}/Contents/MacOS/${embedded_exec}"; then
                    log_warn "Removing get-task-allow by re-signing: ${embedded_app}"
                fi
            fi

            sign_app_bundle_developer_id "${embedded_app}" "${empty_entitlements}"

            if [ -n "${embedded_exec}" ] && [ -f "${embedded_app}/Contents/MacOS/${embedded_exec}" ]; then
                if binary_has_get_task_allow "${embedded_app}/Contents/MacOS/${embedded_exec}"; then
                    log_error "Embedded helper still has get-task-allow after signing: ${embedded_app}"
                    remove_path "${tmp_root}"
                    exit 1
                fi
            fi
        done <<< "${apps_in_zip}"

        # Recreate zip from the extracted directory.
        rm -f "${zip_path}"
        (cd "${unzip_dir}" && ditto -c -k --norsrc . "${zip_path}")
    done < <(find "${resources_path}" -type f -name "*.zip" -print0 2>/dev/null || true)

    if [ "${zip_found}" = true ]; then
        log_info "Embedded zip helper preflight complete."
    fi

    remove_path "${tmp_root}"
}

sanity_check_app_for_notarization() {
    local host_app_path="$1"
    local main_exec
    main_exec=$(defaults read "${host_app_path}/Contents/Info" CFBundleExecutable 2>/dev/null || true)
    if [ -z "${main_exec}" ] || [ ! -f "${host_app_path}/Contents/MacOS/${main_exec}" ]; then
        log_error "Could not determine main executable for: ${host_app_path}"
        exit 1
    fi

    if binary_has_get_task_allow "${host_app_path}/Contents/MacOS/${main_exec}"; then
        log_error "Main app executable has get-task-allow (Debug entitlement). Release builds must not include this."
        exit 1
    fi

    # Check all executables inside the bundle for debug entitlement.
    while IFS= read -r -d '' exec_path; do
        if binary_has_get_task_allow "${exec_path}"; then
            log_error "Found get-task-allow in embedded executable: ${exec_path}"
            exit 1
        fi
    done < <(find "${host_app_path}" -type f -path "*/Contents/MacOS/*" -print0 2>/dev/null || true)
}


check_required_commands() {
    local missing=()
    local cmd
    for cmd in git xcodebuild codesign xcrun hdiutil ditto; do
        if ! command -v "${cmd}" >/dev/null 2>&1; then
            missing+=("${cmd}")
        fi
    done

    if [ ${#missing[@]} -gt 0 ]; then
        log_error "Missing required commands: ${missing[*]}"
        return 1
    fi

    return 0
}

check_git_clean_gate() {
    ensure_git_clean
}

check_reconcile_gate() {
    if (enforce_machine_reconcile); then
        return 0
    fi
    return 1
}

check_signing_identity_gate() {
    if ! command -v security >/dev/null 2>&1; then
        log_error "security CLI not available."
        return 1
    fi

    if ! security find-identity -v -p codesigning 2>/dev/null | grep -q "${SIGNING_IDENTITY}"; then
        log_error "Signing identity not found: ${SIGNING_IDENTITY}"
        return 1
    fi

    return 0
}

ensure_skip_build_artifacts_present() {
    [ "${SKIP_BUILD}" = true ] || return 0

    if [ ! -d "${ARCHIVE_PATH}" ]; then
        log_error "--skip-build requires an existing release archive at ${ARCHIVE_PATH}."
        log_error "Rerun a full release build first, or use the routed resume path after artifact sync."
        return 1
    fi

    if [ "${APPSTORE_ENABLED}" = "true" ] && [ "${SKIP_APPSTORE}" = false ]; then
        local appstore_archive_path="${BUILD_DIR}/${APP_NAME}-AppStore.xcarchive"
        if [ ! -d "${appstore_archive_path}" ]; then
            log_error "--skip-build requires an existing App Store archive at ${appstore_archive_path}."
            log_error "Rerun a full release build first, or skip the App Store leg explicitly."
            return 1
        fi
    fi

    return 0
}

resolve_sparkle_private_key() {
    local sparkle_key="${SPARKLE_PRIVATE_KEY:-}"
    if [ -n "${sparkle_key}" ]; then
        printf '%s' "${sparkle_key}"
        return 0
    fi

    sparkle_key=$(security find-generic-password -w -s "https://sparkle-project.org" -a "EdDSA Private Key" 2>/dev/null || true)
    if [ -n "${sparkle_key}" ]; then
        printf '%s' "${sparkle_key}"
        return 0
    fi

    sparkle_key=$(security find-generic-password -w -s "saneprocess.sparkle.private_key" 2>/dev/null || true)
    if [ -n "${sparkle_key}" ]; then
        printf '%s' "${sparkle_key}"
        return 0
    fi

    return 1
}

derive_sparkle_public_key() {
    local sparkle_private_key="$1"
    if [ -z "${sparkle_private_key}" ]; then
        return 1
    fi

    SPARKLE_PRIVATE_KEY_INPUT="${sparkle_private_key}" swift -e '
import Foundation
import CryptoKit

guard let keyBase64 = ProcessInfo.processInfo.environment["SPARKLE_PRIVATE_KEY_INPUT"],
      let keyData = Data(base64Encoded: keyBase64) else {
    fputs("invalid private key\n", stderr)
    exit(2)
}

do {
    let key = try Curve25519.Signing.PrivateKey(rawRepresentation: keyData)
    print(key.publicKey.rawRepresentation.base64EncodedString())
} catch {
    fputs("unable to derive public key\n", stderr)
    exit(3)
}
' 2>/dev/null
}

check_sparkle_keypair_gate() {
    if [ "${USE_SPARKLE}" != "true" ]; then
        return 0
    fi

    if ! command -v swift >/dev/null 2>&1; then
        log_error "swift is required to validate Sparkle private/public keypair."
        return 1
    fi

    local sparkle_private_key
    sparkle_private_key=$(resolve_sparkle_private_key || true)
    if [ -z "${sparkle_private_key}" ]; then
        log_error "Sparkle private key not found."
        log_error "Set SPARKLE_PRIVATE_KEY or add Keychain item:"
        log_error "  service=saneprocess.sparkle.private_key"
        log_error "  or service=https://sparkle-project.org account=EdDSA Private Key"
        return 1
    fi

    local derived_public_key
    derived_public_key=$(derive_sparkle_public_key "${sparkle_private_key}" || true)
    if [ -z "${derived_public_key}" ]; then
        log_error "Unable to derive Sparkle public key from private key."
        return 1
    fi

    if [ "${derived_public_key}" != "${SHARED_SPARKLE_PUBLIC_KEY}" ]; then
        log_error "Sparkle keypair mismatch."
        log_error "  Expected public key: ${SHARED_SPARKLE_PUBLIC_KEY}"
        log_error "  Derived public key:  ${derived_public_key}"
        log_error "Wrong Sparkle private key would break customer auto-updates."
        return 1
    fi

    log_info "Sparkle private/public keypair matches shared key."
    return 0
}

list_codesign_identities() {
    local keychain="${1:-}"
    # `-p basic` includes installer identities such as
    # "3rd Party Mac Developer Installer", which productbuild needs during
    # headless Mac App Store exports.
    if [ -n "${keychain}" ] && [ -f "${keychain}" ]; then
        security find-identity -v -p basic "${keychain}" 2>/dev/null |
            sed -n 's/^[[:space:]]*[0-9][0-9]*) [0-9A-F]\{40\} "\(.*\)"$/\1/p'
    else
        security find-identity -v -p basic 2>/dev/null |
            sed -n 's/^[[:space:]]*[0-9][0-9]*) [0-9A-F]\{40\} "\(.*\)"$/\1/p'
    fi
}

grant_keychain_partition_access() {
    local keychain="$1"
    local keychain_password="$2"
    local identities identity

    [ -n "${keychain_password}" ] || return 0
    [ -f "${keychain}" ] || return 0

    identities="$(list_codesign_identities "${keychain}")"
    [ -n "${identities}" ] || return 0

    while IFS= read -r identity; do
        [ -n "${identity}" ] || continue
        security set-key-partition-list \
            -S apple-tool:,apple:,codesign: \
            -s \
            -k "${keychain_password}" \
            -D "${identity}" \
            -t private \
            "${keychain}" >/dev/null 2>&1 || true
    done <<< "${identities}"
}

probe_codesign_identity() {
    local identity="$1"
    local keychain="${2:-}"
    local probe_path=""

    [ -n "${identity}" ] || return 1

    probe_path=$(/usr/bin/mktemp /tmp/codesign_probe.XXXXXX)
    echo "sane" > "${probe_path}"

    if [ -n "${keychain}" ] && [ -f "${keychain}" ]; then
        if /usr/bin/codesign --force --sign "${identity}" --keychain "${keychain}" --timestamp=none "${probe_path}" >/dev/null 2>&1; then
            rm -f "${probe_path}"
            return 0
        fi
    elif /usr/bin/codesign --force --sign "${identity}" --timestamp=none "${probe_path}" >/dev/null 2>&1; then
        rm -f "${probe_path}"
        return 0
    fi

    rm -f "${probe_path}"
    return 1
}

resolve_ios_signing_probe_identity() {
    local keychain="${1:-}"
    local identity

    while IFS= read -r identity; do
        case "${identity}" in
            Apple\ Development:*|Apple\ Distribution:*|iPhone\ Distribution:*)
                printf '%s' "${identity}"
                return 0
                ;;
        esac
    done < <(list_codesign_identities "${keychain}")

    return 1
}

prepare_signing_session() {
    local login_keychain="${SANEBAR_KEYCHAIN_PATH:-${KEYCHAIN_PATH:-${HOME}/Library/Keychains/login.keychain-db}}"
    local keychain_password="${SANEBAR_KEYCHAIN_PASSWORD:-${KEYCHAIN_PASSWORD:-${KEYCHAIN_PASS:-}}}"
    local ios_probe_identity=""

    security default-keychain -d user -s "${login_keychain}" >/dev/null 2>&1 || true
    security list-keychains -d user -s "${login_keychain}" /Library/Keychains/System.keychain >/dev/null 2>&1 || true
    security set-keychain-settings -lut 21600 "${login_keychain}" >/dev/null 2>&1 || true

    if [ -f "${login_keychain}" ] && [[ "${OTHER_CODE_SIGN_FLAGS:-}" != *"--keychain ${login_keychain}"* ]]; then
        export OTHER_CODE_SIGN_FLAGS="--keychain ${login_keychain}${OTHER_CODE_SIGN_FLAGS:+ ${OTHER_CODE_SIGN_FLAGS}}"
    fi

    if [ -n "${keychain_password}" ]; then
        if ! security unlock-keychain -p "${keychain_password}" "${login_keychain}" >/dev/null 2>&1; then
            log_error "Keychain unlock failed with provided password env var."
            log_error "Check SANEBAR_KEYCHAIN_PASSWORD / KEYCHAIN_PASSWORD / KEYCHAIN_PASS."
            return 1
        fi
        grant_keychain_partition_access "${login_keychain}" "${keychain_password}"
    fi

    if ! probe_codesign_identity "${SIGNING_IDENTITY}" "${login_keychain}"; then
        log_error "Codesign cannot access signing key in this session."
        log_error "For headless releases, set SANEBAR_KEYCHAIN_PASSWORD (or KEYCHAIN_PASSWORD/KEYCHAIN_PASS)."
        return 1
    fi

    if [ "${APPSTORE_ENABLED}" = "true" ] && appstore_platform_enabled "ios"; then
        ios_probe_identity="$(resolve_ios_signing_probe_identity "${login_keychain}" || true)"
        if [ -z "${ios_probe_identity}" ]; then
            log_error "iOS App Store release is enabled, but no Apple Development/Distribution signing identity is visible."
            log_error "Open Xcode once on this machine and confirm the iOS signing identity is installed in the login keychain."
            return 1
        fi

        if ! probe_codesign_identity "${ios_probe_identity}" "${login_keychain}"; then
            log_error "iOS codesign cannot access signing key in this session: ${ios_probe_identity}"
            log_error "Headless iOS App Store releases need login-keychain unlock plus partition-list access."
            return 1
        fi
    fi

    return 0
}

resolve_homebrew_tap_git_url() {
    local repo="${HOMEBREW_TAP_REPO:-}"

    if [ -n "${HOMEBREW_TAP_GIT_URL:-}" ]; then
        printf '%s' "${HOMEBREW_TAP_GIT_URL}"
        return 0
    fi

    case "${repo}" in
        git@*|ssh://*|https://*|http://*|/*)
            printf '%s' "${repo}"
            ;;
        *)
            printf 'git@github.com:%s.git' "${repo}"
            ;;
    esac
}

resolve_notary_auth() {
    if [ "${SKIP_NOTARIZE}" = true ]; then
        NOTARY_AUTH_MODE="skipped"
        return 0
    fi

    if xcrun notarytool history --keychain-profile "${NOTARY_PROFILE}" >/dev/null 2>&1; then
        NOTARY_AUTH_MODE="keychain-profile"
        return 0
    fi

    local key_path="${NOTARY_API_KEY_PATH}"
    local key_id="${NOTARY_API_KEY_ID}"
    local issuer_id="${NOTARY_API_ISSUER_ID}"

    local any_set=0
    [ -n "${key_path}" ] && any_set=1
    [ -n "${key_id}" ] && any_set=1
    [ -n "${issuer_id}" ] && any_set=1

    local missing=()
    [ -n "${key_path}" ] || missing+=("NOTARY_API_KEY_PATH")
    [ -n "${key_id}" ] || missing+=("NOTARY_API_KEY_ID")
    [ -n "${issuer_id}" ] || missing+=("NOTARY_API_ISSUER_ID")

    if [ "${any_set}" -eq 1 ] && [ ${#missing[@]} -gt 0 ]; then
        log_error "Notary API fallback is partially configured. Missing: ${missing[*]}"
        return 1
    fi

    if [ ${#missing[@]} -eq 0 ]; then
        if [ ! -f "${key_path}" ]; then
            log_error "NOTARY_API_KEY_PATH does not exist: ${key_path}"
            return 1
        fi
        NOTARY_AUTH_MODE="api-key"
        return 0
    fi

    log_error "Notary auth unavailable. Keychain profile '${NOTARY_PROFILE}' is inaccessible and API fallback is unset."
    log_error "Set NOTARY_API_KEY_PATH, NOTARY_API_KEY_ID, NOTARY_API_ISSUER_ID or repair notary keychain profile."
    return 1
}

prepare_xcode_provisioning_auth() {
    XCODE_PROVISIONING_AUTH_ARGS=()

    local key_path="${ASC_AUTH_KEY_PATH}"
    local key_id="${ASC_AUTH_KEY_ID}"
    local issuer_id="${ASC_AUTH_ISSUER_ID}"

    if [ -z "${key_path}" ] || [ -z "${key_id}" ] || [ -z "${issuer_id}" ]; then
        if [ "${APPSTORE_ENABLED}" = "true" ] && [ "${SKIP_APPSTORE}" != true ]; then
            log_error "App Store releases require ASC API credentials."
            log_error "Set ASC_AUTH_KEY_PATH, ASC_AUTH_KEY_ID, ASC_AUTH_ISSUER_ID or keychain services saneprocess.asc.key_path, saneprocess.asc.key_id, saneprocess.asc.issuer_id."
            return 1
        fi
        log_warn "ASC auth key values incomplete; xcodebuild will use interactive account auth."
        return 0
    fi

    if [ ! -f "${key_path}" ]; then
        if [ "${APPSTORE_ENABLED}" = "true" ] && [ "${SKIP_APPSTORE}" != true ]; then
            log_error "ASC_AUTH_KEY_PATH does not exist: ${key_path}"
            return 1
        fi
        log_warn "ASC auth key not found at ${key_path}; xcodebuild will use interactive account auth."
        return 0
    fi

    XCODE_PROVISIONING_AUTH_ARGS=(
        -authenticationKeyPath "${key_path}"
        -authenticationKeyID "${key_id}"
        -authenticationKeyIssuerID "${issuer_id}"
    )

    log_info "Using ASC API key auth for provisioning updates."
    return 0
}

check_version_bump_gate() {
    if [ -n "${VERSION_BUMP_CMD}" ]; then
        if [ ${#VERSION_BUMP_FILES[@]} -eq 0 ]; then
            log_error "VERSION_BUMP_CMD is set but VERSION_BUMP_FILES is empty."
            return 1
        fi

        local vf
        local missing=()
        for vf in "${VERSION_BUMP_FILES[@]}"; do
            if [ ! -f "${PROJECT_ROOT}/${vf}" ]; then
                missing+=("${vf}")
            fi
        done
        if [ ${#missing[@]} -gt 0 ]; then
            log_error "VERSION_BUMP_FILES missing: ${missing[*]}"
            return 1
        fi

        if [ -z "${VERSION}" ]; then
            log_warn "No --version provided; skipping execution test of VERSION_BUMP_CMD."
            return 0
        fi

        if [ -n "$(git -C "${PROJECT_ROOT}" status --porcelain 2>/dev/null)" ]; then
            log_error "Repo must be clean to simulate VERSION_BUMP_CMD."
            return 1
        fi

        bump_project_version "${VERSION}"

        local changed=0
        for vf in "${VERSION_BUMP_FILES[@]}"; do
            if ! git -C "${PROJECT_ROOT}" diff --quiet -- "${vf}" 2>/dev/null; then
                changed=1
            fi
        done

        restore_version_bump

        if [ "${changed}" -ne 1 ]; then
            log_error "VERSION_BUMP_CMD ran but produced no file changes."
            return 1
        fi

        return 0
    fi

    if [ -f "${PROJECT_ROOT}/project.yml" ]; then
        return 0
    fi

    log_error "No version bump strategy found (no VERSION_BUMP_CMD and no project.yml)."
    return 1
}

app_appears_to_require_storekit_product_gate() {
    local out=""
    if command -v rg >/dev/null 2>&1; then
        out=$(rg -n \
            --glob '*.swift' \
            --glob '!build/**' \
            --glob '!DerivedData/**' \
            --glob '!**/SourcePackages/**' \
            "LicenseService\\s*\\(" \
            "${PROJECT_ROOT}" 2>/dev/null || true)
    else
        out=$(find "${PROJECT_ROOT}" \
            \( -path "*/build/*" -o -path "*/DerivedData/*" -o -path "*/SourcePackages/*" \) -prune -o \
            -name '*.swift' -print0 2>/dev/null | \
            xargs -0 grep -n -E "LicenseService[[:space:]]*\\(" 2>/dev/null || true)
    fi
    [ -n "${out}" ]
}

check_appstore_gate() {
    if [ "${APPSTORE_ENABLED}" != "true" ] || [ "${SKIP_APPSTORE}" = true ]; then
        return 0
    fi

    if [ -z "${APPSTORE_APP_ID}" ] || ! [[ "${APPSTORE_APP_ID}" =~ ^[0-9]+$ ]]; then
        log_error "APPSTORE_APP_ID is missing or invalid: '${APPSTORE_APP_ID}'"
        return 1
    fi

    if [ -z "${APPSTORE_PRODUCT_ID}" ]; then
        local product_id_required
        product_id_required="${APPSTORE_PRODUCT_ID_REQUIRED:-false}"

        if [ "${product_id_required}" = "true" ]; then
            log_error "APPSTORE_PRODUCT_ID is required for App Store builds to use StoreKit checkout."
            log_error "Set appstore.product_id in .saneprocess for this project before App Store deployment."
            log_error "Set appstore.require_product_id: false to force direct checkout fallback."
            return 1
        fi

        if app_appears_to_require_storekit_product_gate; then
            log_error "APPSTORE_PRODUCT_ID is missing, but StoreKit unlock flow was detected in source."
            log_error "This can route digital unlocks outside App Store billing in APP_STORE builds."
            log_error "Set appstore.product_id in .saneprocess before App Store deployment."
            return 1
        fi

        log_warn "APPSTORE_PRODUCT_ID is not set."
        log_warn "This build will use direct checkout fallback at runtime when APP_STORE is enabled and AppStoreProductID is missing."
        log_warn "Set appstore.product_id or appstore.require_product_id: true when StoreKit in-app checkout is mandatory."
    else
        if ! [[ "${APPSTORE_PRODUCT_ID}" =~ ^[A-Za-z0-9._-]+$ ]]; then
            log_error "APPSTORE_PRODUCT_ID has invalid characters: ${APPSTORE_PRODUCT_ID}"
            log_error "Allowed characters are A-Z a-z 0-9 . _ -"
            return 1
        fi

        log_info "Using App Store product id: ${APPSTORE_PRODUCT_ID}"
    fi

    if [ -z "${APPSTORE_CONTACT_EMAIL}" ]; then
        log_error "App Store contact email is missing (appstore.contact.email)."
        return 1
    fi

    if [ -n "${APPSTORE_ENTITLEMENTS}" ]; then
        if [ ! -f "${APPSTORE_ENTITLEMENTS}" ]; then
            log_error "APPSTORE_ENTITLEMENTS file not found: ${APPSTORE_ENTITLEMENTS}"
            return 1
        fi
        local sandbox_value
        sandbox_value=$(/usr/libexec/PlistBuddy -c "Print :com.apple.security.app-sandbox" "${APPSTORE_ENTITLEMENTS}" 2>/dev/null || echo "")
        if [ "${sandbox_value}" != "true" ]; then
            log_error "APPSTORE_ENTITLEMENTS must set app sandbox to true"
            return 1
        fi
    fi

    local bf
    for bf in "${APPSTORE_BUILD_FLAGS[@]}"; do
        if [[ "${bf}" == *"Developer ID Application"* ]] || [[ "${bf}" == CODE_SIGN_STYLE=Manual* ]]; then
            log_error "App Store build flags include direct-distribution signing override: ${bf}"
            log_error "Use automatic signing for App Store builds."
            return 1
        fi
    done

    if [ "${USE_SPARKLE}" = true ]; then
        if ! array_contains "Sparkle.framework" "${APPSTORE_STRIP_FRAMEWORKS[@]}"; then
            log_error "APPSTORE_STRIP_FRAMEWORKS must include Sparkle.framework when use_sparkle=true"
            return 1
        fi
    fi

    return 0
}

check_project_qa_guardrails() {
    if ! release_guardrails_required_for_mode; then
        return 0
    fi

    local qa_script=""
    if [ -f "${PROJECT_ROOT}/Scripts/qa.rb" ]; then
        qa_script="${PROJECT_ROOT}/Scripts/qa.rb"
    elif [ -f "${PROJECT_ROOT}/scripts/qa.rb" ]; then
        qa_script="${PROJECT_ROOT}/scripts/qa.rb"
    fi

    if [ -z "${qa_script}" ]; then
        log_warn "No project qa.rb found — skipping project QA guardrail."
        return 0
    fi

    if ! command -v ruby >/dev/null 2>&1; then
        log_error "Project QA guardrail requires ruby (missing from PATH)."
        return 1
    fi

    local app_env_prefix app_preflight_key app_stability_key
    app_env_prefix="$(echo "${APP_NAME}" | tr '[:lower:]-' '[:upper:]_' | tr -cd 'A-Z0-9_')"
    app_preflight_key="${app_env_prefix}_RELEASE_PREFLIGHT"
    app_stability_key="${app_env_prefix}_RUN_STABILITY_SUITE"

    if env SANEPROCESS_RELEASE_PREFLIGHT=1 SANEPROCESS_RUN_STABILITY_SUITE=1 \
        "${app_preflight_key}=1" "${app_stability_key}=1" \
        ruby "${qa_script}"; then
        return 0
    fi

    log_error "Project QA guardrails failed (${qa_script})."
    log_error "Stop release, fix the issue, verify, then rerun."
    return 1
}

check_public_source_build_guardrails() {
    if ! release_guardrails_required_for_mode; then
        return 0
    fi

    if [ -z "${PROJECT_ROOT}" ] || [ ! -d "${PROJECT_ROOT}" ]; then
        log_error "Public source-build guardrails require a valid PROJECT_ROOT (got '${PROJECT_ROOT:-}')"
        return 1
    fi

    local guard_script="${SCRIPT_DIR}/automation/public-source-build-guard.sh"
    if [ ! -f "${guard_script}" ]; then
        log_error "Public source-build guard script not found: ${guard_script}"
        return 1
    fi

    if [ ! -x "${guard_script}" ]; then
        log_error "Public source-build guard script is not executable: ${guard_script}"
        return 1
    fi

    if "${guard_script}" "${PROJECT_ROOT}"; then
        return 0
    fi

    log_error "Public source-build guardrails failed."
    return 1
}

check_appstore_connect_version_state_gate() {
    if [ "${APPSTORE_ENABLED}" != "true" ] || [ "${SKIP_APPSTORE}" = true ]; then
        return 0
    fi

    if [ -z "${VERSION}" ]; then
        log_error "App Store version-state preflight requires --version X.Y.Z"
        return 1
    fi

    local appstore_script="${SCRIPT_DIR}/appstore_submit.rb"
    if [ ! -f "${appstore_script}" ]; then
        log_error "appstore_submit.rb not found at ${appstore_script}"
        return 1
    fi

    local raw_platforms
    raw_platforms="$(appstore_platform_tokens)"
    local platform
    local checked=0

    for platform in ${raw_platforms}; do
        case "${platform}" in
            macos|ios)
                checked=$((checked + 1))
                if ! ruby "${appstore_script}" \
                    --preflight-version-state \
                    --app-id "${APPSTORE_APP_ID}" \
                    --version "${VERSION}" \
                    --platform "${platform}"; then
                    log_error "App Store Connect version-state preflight failed for platform '${platform}'."
                    return 1
                fi
                ;;
            "")
                ;;
            *)
                log_warn "Skipping unsupported App Store platform in preflight: ${platform}"
                ;;
        esac
    done

    if [ "${checked}" -eq 0 ]; then
        log_error "No valid App Store platforms configured for version-state preflight (APPSTORE_PLATFORMS='${raw_platforms}')."
        return 1
    fi

    return 0
}

check_appstore_submission_guardrails_gate() {
    if [ "${APPSTORE_ENABLED}" != "true" ] || [ "${SKIP_APPSTORE}" = true ]; then
        return 0
    fi

    local sanemaster_script="${SCRIPT_DIR}/SaneMaster.rb"
    if [ ! -f "${sanemaster_script}" ]; then
        log_error "SaneMaster.rb not found at ${sanemaster_script}"
        return 1
    fi

    if ! ruby "${sanemaster_script}" appstore_preflight; then
        log_error "App Store artifact/policy preflight failed."
        return 1
    fi

    return 0
}

check_live_appcast_republish_gate() {
    if [ "${ALLOW_REPUBLISH}" = true ] || [ "${USE_SPARKLE}" != "true" ]; then
        return 0
    fi

    if [ -z "${VERSION}" ]; then
        log_warn "No --version provided; skipping live appcast republish guard in preflight."
        return 0
    fi

    local appcast_url="https://${SITE_HOST}/appcast.xml"
    local appcast_content
    appcast_content=$(curl -fsSL "${appcast_url}" 2>/dev/null || true)
    if [ -z "${appcast_content}" ]; then
        log_warn "Could not fetch live appcast (${appcast_url}); skipping republish guard in preflight."
        return 0
    fi

    local live_count
    live_count=$(appcast_item_count_for_version "${appcast_content}")
    if [ "${live_count}" -ge 1 ]; then
        log_error "Live appcast republish guard: v${VERSION} already exists (${live_count} entr$( [ "${live_count}" = "1" ] && echo "y" || echo "ies" ))."
        log_error "Bump version/build before release, or use --allow-republish for emergencies."
        return 1
    fi

    return 0
}

check_cloudflare_auth_gate() {
    if [ "${RUN_DEPLOY}" != "true" ] && [ "${WEBSITE_ONLY}" != "true" ]; then
        return 0
    fi

    local token
    token="$(resolve_cloudflare_api_token || true)"
    if [ -z "${token}" ]; then
        log_error "Cloudflare auth unavailable for deploy."
        log_error "Set CLOUDFLARE_API_TOKEN or add keychain secret:"
        log_error "  service=saneprocess.cloudflare.api_token account=cloudflare_api_token"
        log_error "  (legacy supported: service=cloudflare account=api_token)"
        return 1
    fi

    export CLOUDFLARE_API_TOKEN="${token}"
    return 0
}

check_release_path_gate() {
    # Releasing from ad-hoc git worktrees has repeatedly caused branch/deploy drift.
    # Require canonical project path for publish operations.
    if [[ "${PROJECT_ROOT}" == *"/.worktrees/"* ]]; then
        log_error "Release must run from canonical project path, not a git worktree clone:"
        log_error "  ${PROJECT_ROOT}"
        log_error "Use the canonical repo directory (for example ~/SaneApps/apps/${APP_NAME}) after reconcile."
        return 1
    fi
    return 0
}

run_release_preflight_only() {
    local failures=0

    run_gate() {
        local name="$1"
        shift
        CURRENT_GATE="${name}"
        RELEASE_ERR_GATE_RECORDED=""
        if "$@"; then
            log_info "[PASS] ${name}"
            track_gate_result "${name}" "pass" "ok"
        else
            local gate_error="${RELEASE_LAST_ERROR:-gate failed}"
            log_error "[FAIL] ${name}"
            failures=$((failures + 1))
            track_gate_result "${name}" "failure" "${gate_error}"
        fi
        CURRENT_GATE=""
    }

    log_info "Running release preflight-only checks..."

    run_gate "Repeat-failure guard" check_recent_unresolved_failures_gate
    if [ "${failures}" -gt 0 ]; then
        log_error "Preflight-only aborted by repeat-failure guard."
        return 1
    fi
    run_gate "Required commands" check_required_commands
    run_gate "Project QA guardrails" check_project_qa_guardrails
    run_gate "Public source-build guardrails" check_public_source_build_guardrails
    run_gate "Git clean" check_git_clean_gate
    run_gate "Machine reconcile" check_reconcile_gate
    run_gate "Version bump configuration" check_version_bump_gate
    run_gate "Signing identity" check_signing_identity_gate
    run_gate "Sparkle keypair" check_sparkle_keypair_gate
    run_gate "Cloudflare deploy authentication" check_cloudflare_auth_gate
    run_gate "Release notes quality" check_release_notes_gate
    run_gate "Keychain/signing session" prepare_signing_session
    run_gate "Notarization authentication" resolve_notary_auth
    run_gate "App Store configuration" check_appstore_gate
    run_gate "App Store Connect draft version state" check_appstore_connect_version_state_gate
    run_gate "App Store artifact/policy preflight" check_appstore_submission_guardrails_gate
    run_gate "Live appcast republish guard" check_live_appcast_republish_gate

    if [ "${failures}" -gt 0 ]; then
        log_error "Preflight-only failed with ${failures} failing gate(s)."
        return 1
    fi

    log_info "Preflight-only checks passed."
    return 0
}

# Defaults/flags
PROJECT_ROOT=""
CONFIG_PATH=""
SKIP_NOTARIZE=false
SKIP_BUILD=false
FULL_RELEASE=false
VERSION=""
RELEASE_NOTES=""
XCODEGEN_DONE=false
RUN_GH_RELEASE=false
RUN_DEPLOY=false
WEBSITE_ONLY=false
ALLOW_REPUBLISH=false
CRITICAL_UPDATE=false
MIN_AUTOUPDATE_VERSION=""
CRITICAL_UPDATE_BELOW_VERSION=""
PURGE_R2_BINARY_ASSETS=false
PURGE_GITHUB_BINARY_ASSETS=false
ALLOW_UNSYNCED_PEER=false
SKIP_APPSTORE=false
PREFLIGHT_ONLY=false
OVERRIDE_FLAGS_USED=()
ALLOW_REPEAT_FAILURE=false
STRICT_PUBLIC_CHANNEL_SYNC=true

# Parse arguments
while [[ $# -gt 0 ]]; do
    case $1 in
        --project)
            PROJECT_ROOT="$2"
            shift 2
            ;;
        --config)
            CONFIG_PATH="$2"
            shift 2
            ;;
        --skip-notarize)
            SKIP_NOTARIZE=true
            record_override_flag "--skip-notarize"
            shift
            ;;
        --skip-build)
            SKIP_BUILD=true
            record_override_flag "--skip-build"
            shift
            ;;
        --version)
            VERSION="$2"
            shift 2
            ;;
        --notes)
            RELEASE_NOTES="$2"
            shift 2
            ;;
        --allow-republish)
            ALLOW_REPUBLISH=true
            record_override_flag "--allow-republish"
            shift
            ;;
        --allow-unsynced-peer)
            ALLOW_UNSYNCED_PEER=true
            record_override_flag "--allow-unsynced-peer"
            shift
            ;;
        --allow-repeat-failure)
            ALLOW_REPEAT_FAILURE=true
            shift
            ;;
        --allow-channel-sync-failures)
            STRICT_PUBLIC_CHANNEL_SYNC=false
            shift
            ;;
        --skip-appstore)
            SKIP_APPSTORE=true
            record_override_flag "--skip-appstore"
            shift
            ;;
        --full)
            FULL_RELEASE=true
            shift
            ;;
        --deploy)
            RUN_DEPLOY=true
            shift
            ;;
        --github-release)
            RUN_GH_RELEASE=true
            shift
            ;;
        --critical-update)
            CRITICAL_UPDATE=true
            shift
            ;;
        --minimum-autoupdate-version)
            MIN_AUTOUPDATE_VERSION="$2"
            shift 2
            ;;
        --critical-below-version)
            CRITICAL_UPDATE_BELOW_VERSION="$2"
            shift 2
            ;;
        --purge-r2-binaries)
            PURGE_R2_BINARY_ASSETS=true
            shift
            ;;
        --purge-github-binaries)
            PURGE_GITHUB_BINARY_ASSETS=true
            shift
            ;;
        --keep-github-binaries)
            PURGE_GITHUB_BINARY_ASSETS=false
            shift
            ;;
        --website-only)
            WEBSITE_ONLY=true
            shift
            ;;
        --preflight-only)
            PREFLIGHT_ONLY=true
            shift
            ;;
        -h|--help)
            print_help
            exit 0
            ;;
        *)
            log_error "Unknown option: $1"
            exit 1
            ;;
    esac
done

if [ -z "${PROJECT_ROOT}" ]; then
    PROJECT_ROOT="$(pwd)"
fi
PROJECT_ROOT="$(cd "${PROJECT_ROOT}" && pwd)"

EXPLICIT_APPSTORE_PLATFORMS_SET=0
EXPLICIT_APPSTORE_PLATFORMS=""
if [ "${APPSTORE_PLATFORMS+x}" = "x" ] && [ -n "${APPSTORE_PLATFORMS}" ]; then
    EXPLICIT_APPSTORE_PLATFORMS_SET=1
    EXPLICIT_APPSTORE_PLATFORMS="${APPSTORE_PLATFORMS}"
fi

if [ -n "${CRITICAL_UPDATE_BELOW_VERSION}" ] && [ "${CRITICAL_UPDATE}" != true ]; then
    CRITICAL_UPDATE=true
fi

if [ -z "${CONFIG_PATH}" ] && [ -f "${PROJECT_ROOT}/.saneprocess" ]; then
    CONFIG_PATH="${PROJECT_ROOT}/.saneprocess"
fi
if [ -z "${CONFIG_PATH}" ] && [ -f "${PROJECT_ROOT}/release.env" ]; then
    CONFIG_PATH="${PROJECT_ROOT}/release.env"
fi

if [ -n "${CONFIG_PATH}" ]; then
    if [ ! -f "${CONFIG_PATH}" ]; then
        log_error "Config file not found: ${CONFIG_PATH}"
        exit 1
    fi
    if [[ "${CONFIG_PATH}" = *.yml ]] || [[ "${CONFIG_PATH}" = *.yaml ]] || [[ "$(basename "${CONFIG_PATH}")" = ".saneprocess" ]]; then
        SANEPROCESS_ENV_LOADER="$(cd "$(dirname "$0")" && pwd)/saneprocess_env.rb"
        if [ ! -f "${SANEPROCESS_ENV_LOADER}" ]; then
            log_error "Missing saneprocess_env.rb (required to read YAML config)"
            exit 1
        fi
        # shellcheck disable=SC2046
        eval "$("${SANEPROCESS_ENV_LOADER}" "${CONFIG_PATH}")"
    else
        # shellcheck source=/dev/null
        set -a
        . "${CONFIG_PATH}"
        set +a
    fi
fi

hydrate_headless_release_env

if [ "${EXPLICIT_APPSTORE_PLATFORMS_SET}" -eq 1 ]; then
    unset APPSTORE_PLATFORMS
    APPSTORE_PLATFORMS=("${EXPLICIT_APPSTORE_PLATFORMS}")
    export APPSTORE_PLATFORMS
fi

APP_NAME="${APP_NAME:-$(basename "${PROJECT_ROOT}")}" 
SCHEME="${SCHEME:-${APP_NAME}}"
LOWER_APP_NAME="$(echo "${APP_NAME}" | tr '[:upper:]' '[:lower:]')"
BUNDLE_ID="${BUNDLE_ID:-com.${LOWER_APP_NAME}.app}"
TEAM_ID="${TEAM_ID:?Set TEAM_ID env var or pass --team-id}"
SIGNING_IDENTITY="${SIGNING_IDENTITY:-Developer ID Application}"
BUILD_DIR="${BUILD_DIR:-${PROJECT_ROOT}/build}"
ARCHIVE_PATH="${ARCHIVE_PATH:-${BUILD_DIR}/${APP_NAME}.xcarchive}"
EXPORT_PATH="${EXPORT_PATH:-${BUILD_DIR}/Export}"
RELEASE_DIR="${RELEASE_DIR:-${PROJECT_ROOT}/releases}"
DIST_HOST="${DIST_HOST:-dist.${LOWER_APP_NAME}.com}"
SITE_HOST="${SITE_HOST:-${LOWER_APP_NAME}.com}"
R2_BUCKET="${R2_BUCKET:-sanebar-downloads}"  # Shared bucket for ALL SaneApps
USE_SPARKLE="${USE_SPARKLE:-true}"
MIN_SYSTEM_VERSION="${MIN_SYSTEM_VERSION:-15.0}"
NOTARY_PROFILE="${NOTARY_PROFILE:-notarytool}"
NOTARY_API_KEY_PATH="${NOTARY_API_KEY_PATH:-}"
NOTARY_API_KEY_ID="${NOTARY_API_KEY_ID:-}"
NOTARY_API_ISSUER_ID="${NOTARY_API_ISSUER_ID:-}"
NOTARY_AUTH_MODE=""
ASC_AUTH_KEY_PATH="${ASC_AUTH_KEY_PATH:-}"
ASC_AUTH_KEY_ID="${ASC_AUTH_KEY_ID:-}"
ASC_AUTH_ISSUER_ID="${ASC_AUTH_ISSUER_ID:-}"
GITHUB_REPO="${GITHUB_REPO:-sane-apps/${APP_NAME}}"
HOMEBREW_TAP_REPO="${HOMEBREW_TAP_REPO:-sane-apps/homebrew-tap}"
# Public-channel apps must be actively sold and have verified Basic/Pro gating.
PUBLIC_CHANNEL_ALLOWLIST="${PUBLIC_CHANNEL_ALLOWLIST:-SaneBar,SaneClip,SaneHosts,SaneClick,SaneSales}"
XCODEGEN="${XCODEGEN:-false}"
DMG_WINDOW_POS="${DMG_WINDOW_POS:-200 120}"
DMG_WINDOW_SIZE="${DMG_WINDOW_SIZE:-800 400}"
DMG_ICON_SIZE="${DMG_ICON_SIZE:-100}"
DMG_APP_ICON_POS="${DMG_APP_ICON_POS:-200 185}"
DMG_DROP_POS="${DMG_DROP_POS:-600 185}"
DMG_HIDE_EXTENSION="${DMG_HIDE_EXTENSION:-true}"
DMG_NO_INTERNET_ENABLE="${DMG_NO_INTERNET_ENABLE:-false}"
VERIFY_STAPLE="${VERIFY_STAPLE:-false}"
RELEASE_RECONCILE_ENABLED="${RELEASE_RECONCILE_ENABLED:-true}"
RELEASE_PEER_HOST="${RELEASE_PEER_HOST:-}"
RELEASE_PEER_REPO_PATH="${RELEASE_PEER_REPO_PATH:-}"
RELEASE_PEER_BRANCH="${RELEASE_PEER_BRANCH:-main}"
PAGES_BRANCH="${PAGES_BRANCH:-main}"

if ! enforce_release_override_approvals; then
    exit 1
fi

enforce_reconcile_policy

PUBLIC_CHANNEL_APPROVED=false
IFS=',' read -r -a PUBLIC_CHANNEL_APPS <<< "${PUBLIC_CHANNEL_ALLOWLIST}"
for approved_app in "${PUBLIC_CHANNEL_APPS[@]}"; do
    approved_app="$(echo "${approved_app}" | tr -d '[:space:]')"
    if [ "${approved_app}" = "${APP_NAME}" ]; then
        PUBLIC_CHANNEL_APPROVED=true
        break
    fi
done

if [ "${PUBLIC_CHANNEL_APPROVED}" != true ]; then
    if [ "${RUN_GH_RELEASE}" = true ]; then
        log_warn "GitHub release metadata disabled: ${APP_NAME} is not in PUBLIC_CHANNEL_ALLOWLIST (${PUBLIC_CHANNEL_ALLOWLIST})"
        RUN_GH_RELEASE=false
    fi
    if [ -n "${HOMEBREW_TAP_REPO}" ]; then
        log_warn "Homebrew cask publish disabled: ${APP_NAME} is not in PUBLIC_CHANNEL_ALLOWLIST (${PUBLIC_CHANNEL_ALLOWLIST})"
        HOMEBREW_TAP_REPO=""
    fi
fi

if [ "${RUN_DEPLOY}" = true ] && [ "${PUBLIC_CHANNEL_APPROVED}" = true ] && [ "${RUN_GH_RELEASE}" != true ]; then
    RUN_GH_RELEASE=true
    log_info "Auto-enabling GitHub release sync for public-channel app ${APP_NAME}."
fi

if [ "${RUN_DEPLOY}" = true ] && [ "${PUBLIC_CHANNEL_APPROVED}" = true ] && [ "${STRICT_PUBLIC_CHANNEL_SYNC}" = true ] && [ -z "${HOMEBREW_TAP_REPO}" ]; then
    log_error "Homebrew tap sync is required for public-channel deploys, but HOMEBREW_TAP_REPO is empty."
    log_error "Set homebrew.tap_repo in .saneprocess or pass --allow-channel-sync-failures for emergency bypass."
    exit 1
fi

if [ "${CRITICAL_UPDATE}" = true ] && [ -z "${MIN_AUTOUPDATE_VERSION}" ]; then
    log_warn "CRITICAL_UPDATE enabled without MIN_AUTOUPDATE_VERSION."
    log_warn "Set --minimum-autoupdate-version to force major-upgrade prompts for old builds."
fi

WORKSPACE="${WORKSPACE:-}"
XCODEPROJ="${XCODEPROJ:-}"
EXPORT_OPTIONS_PLIST="${EXPORT_OPTIONS_PLIST:-}"
EXPORT_OPTIONS_EXTRA_XML="${EXPORT_OPTIONS_EXTRA_XML:-}"
VERSION_BUMP_CMD="${VERSION_BUMP_CMD:-}"
VERSION_BUMP_RESTORE_CMD="${VERSION_BUMP_RESTORE_CMD:-}"

if ! declare -p EXPORT_OPTIONS_PROFILES >/dev/null 2>&1; then
    EXPORT_OPTIONS_PROFILES=()
fi
if ! declare -p ARCHIVE_EXTRA_ARGS >/dev/null 2>&1; then
    ARCHIVE_EXTRA_ARGS=()
fi
if ! declare -p CREATE_DMG_EXTRA_ARGS >/dev/null 2>&1; then
    CREATE_DMG_EXTRA_ARGS=()
fi
if ! declare -p VERSION_BUMP_FILES >/dev/null 2>&1; then
    VERSION_BUMP_FILES=("project.yml")
fi
if ! declare -p APPSTORE_BUILD_FLAGS >/dev/null 2>&1; then
    APPSTORE_BUILD_FLAGS=()
fi
if ! declare -p APPSTORE_STRIP_FRAMEWORKS >/dev/null 2>&1; then
    APPSTORE_STRIP_FRAMEWORKS=()
fi
if ! declare -p APPSTORE_ARCHIVE_EXTRA_ARGS >/dev/null 2>&1; then
    APPSTORE_ARCHIVE_EXTRA_ARGS=()
fi
if ! declare -p XCODE_PROVISIONING_AUTH_ARGS >/dev/null 2>&1; then
    XCODE_PROVISIONING_AUTH_ARGS=()
fi

WORKSPACE="$(resolve_path "${WORKSPACE}")"
XCODEPROJ="$(resolve_path "${XCODEPROJ}")"
EXPORT_OPTIONS_PLIST="$(resolve_path "${EXPORT_OPTIONS_PLIST}")"
DMG_FILE_ICON="$(resolve_path "${DMG_FILE_ICON}")"
DMG_VOLUME_ICON="$(resolve_path "${DMG_VOLUME_ICON}")"
DMG_BACKGROUND="$(resolve_path "${DMG_BACKGROUND}")"
DMG_BACKGROUND_GENERATOR="$(resolve_path "${DMG_BACKGROUND_GENERATOR}")"
ASC_AUTH_KEY_PATH="$(resolve_path "${ASC_AUTH_KEY_PATH}")"
APPSTORE_ENTITLEMENTS="$(resolve_path "${APPSTORE_ENTITLEMENTS}")"
# Resolve helper scripts: check project first, fall back to SaneProcess scripts dir
SANEPROCESS_SCRIPTS_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
if [ -z "${SIGN_UPDATE_SCRIPT}" ]; then
    if [ -f "${PROJECT_ROOT}/scripts/sign_update.swift" ]; then
        SIGN_UPDATE_SCRIPT="${PROJECT_ROOT}/scripts/sign_update.swift"
    else
        SIGN_UPDATE_SCRIPT="${SANEPROCESS_SCRIPTS_DIR}/sign_update.swift"
    fi
fi
SIGN_UPDATE_SCRIPT="$(resolve_path "${SIGN_UPDATE_SCRIPT}")"

if [ -z "${SET_DMG_ICON_SCRIPT}" ]; then
    if [ -f "${PROJECT_ROOT}/scripts/set_dmg_icon.swift" ]; then
        SET_DMG_ICON_SCRIPT="${PROJECT_ROOT}/scripts/set_dmg_icon.swift"
    else
        SET_DMG_ICON_SCRIPT="${SANEPROCESS_SCRIPTS_DIR}/set_dmg_icon.swift"
    fi
fi
SET_DMG_ICON_SCRIPT="$(resolve_path "${SET_DMG_ICON_SCRIPT}")"

# Pre-flight validation: check that configured resources exist BEFORE building
if [ -n "${DMG_FILE_ICON}" ] && [ ! -f "${DMG_FILE_ICON}" ]; then
    log_error "DMG file icon not found: ${DMG_FILE_ICON}"
    log_error "Set release.dmg.file_icon in .saneprocess to a valid path, or remove it"
    exit 1
fi
if [ -n "${DMG_VOLUME_ICON}" ] && [ ! -f "${DMG_VOLUME_ICON}" ]; then
    log_error "DMG volume icon not found: ${DMG_VOLUME_ICON}"
    log_error "Set release.dmg.volume_icon in .saneprocess to a valid path, or remove it"
    exit 1
fi
if [ -n "${DMG_BACKGROUND}" ] && [ ! -f "${DMG_BACKGROUND}" ]; then
    log_error "DMG background not found: ${DMG_BACKGROUND}"
    log_error "Set release.dmg.background in .saneprocess to a valid path, or remove it"
    exit 1
fi

cd "${PROJECT_ROOT}"

# Website-only deploy (no build, no R2, no signing — just push website + appcast to Pages)
if [ "${WEBSITE_ONLY}" = true ]; then
    PAGES_PROJECT="${LOWER_APP_NAME}-site"
    # Prefer website/ dir (has full HTML site), fall back to docs/
    if [ -d "${PROJECT_ROOT}/website" ]; then
        DEPLOY_DIR="${PROJECT_ROOT}/website"
        sync_docs_appcast_to_website
    elif [ -d "${PROJECT_ROOT}/docs" ]; then
        DEPLOY_DIR="${PROJECT_ROOT}/docs"
    else
        log_error "No website/ or docs/ directory found"
        exit 1
    fi
    # Version drift guard: appcast version must match website download link version
    if [ -f "${DEPLOY_DIR}/appcast.xml" ] && [ -f "${DEPLOY_DIR}/index.html" ]; then
        appcast_ver=""
        # Handle both element (<sparkle:shortVersionString>X.Y.Z</...) and attribute (sparkle:shortVersionString="X.Y.Z") formats
        appcast_ver=$(grep -oE 'shortVersionString[^0-9]*[0-9]+\.[0-9]+\.[0-9]+' "${DEPLOY_DIR}/appcast.xml" | head -1 | grep -oE '[0-9]+\.[0-9]+\.[0-9]+')
        site_download_ver=""
        site_download_ver=$(grep -oE "${APP_NAME}-[0-9]+\.[0-9]+\.[0-9]+\.zip" "${DEPLOY_DIR}/index.html" | head -1 | grep -oE '[0-9]+\.[0-9]+\.[0-9]+')
        if [ -n "${appcast_ver}" ] && [ -n "${site_download_ver}" ]; then
            if [ "${appcast_ver}" != "${site_download_ver}" ]; then
                log_error "Version drift detected in website-only deploy:"
                log_error "  appcast.xml version: ${appcast_ver}"
                log_error "  index.html download link: ${site_download_ver}"
                log_error "Update both files to the same version before deploying."
                exit 1
            fi
            log_info "Version consistency check passed: appcast and website both at v${appcast_ver}"

            dist_archive_url="https://${DIST_HOST}/updates/${APP_NAME}-${appcast_ver}.zip"
            dist_archive_status=$(extract_http_status_with_user_agent "${dist_archive_url}" "Sparkle/2")
            if [ "${dist_archive_status}" != "200" ] && [ "${dist_archive_status}" != "206" ]; then
                log_error "Website-only deploy blocked: dist archive is not reachable at ${dist_archive_url} (HTTP ${dist_archive_status})"
                exit 1
            fi
            if ! verify_dist_archive_bundle_version "${dist_archive_url}" "${appcast_ver}" ""; then
                log_error "Website-only deploy blocked: dist archive bundle version does not match appcast/index version ${appcast_ver}."
                exit 1
            fi
            log_info "Dist archive metadata check passed for website-only deploy: ${dist_archive_url}"
        fi

        # Live webhook version drift check.
        # In strict public-channel mode this is blocking, because new customers would
        # otherwise receive an old build even if the website/appcast are correct.
        if [ -n "${appcast_ver}" ]; then
            webhook_snapshot=""
            webhook_ver=""
            webhook_snapshot=$(fetch_live_email_webhook_snapshot "${APP_NAME}" "0")
            webhook_ver=$(extract_live_email_webhook_field "${webhook_snapshot}" "${APP_NAME}" "version")
            if [ -n "${webhook_ver}" ] && [ "${webhook_ver}" != "${appcast_ver}" ]; then
                if [ "${STRICT_PUBLIC_CHANNEL_SYNC}" = true ]; then
                    log_error "Live email webhook drift: worker has v${webhook_ver} but appcast is v${appcast_ver}"
                    log_error "New customers will download an old build until the Worker is updated."
                    exit 1
                fi
                log_warn "Live email webhook drift: worker has v${webhook_ver} but appcast is v${appcast_ver}"
                log_warn "New customers will download an old build until the Worker is updated."
            elif [ -z "${webhook_ver}" ] && [ "${STRICT_PUBLIC_CHANNEL_SYNC}" = true ]; then
                log_error "Could not verify live email webhook state for ${APP_NAME} during website-only deploy."
                exit 1
            fi
        fi
    fi

    # Auto-wire live iOS App Store URL into website markers (if configured and available).
    update_website_app_store_links_if_live

    log_info "Deploying website to Cloudflare Pages (${PAGES_PROJECT}) from ${DEPLOY_DIR}..."
    npx wrangler pages deploy "${DEPLOY_DIR}" \
        --project-name="${PAGES_PROJECT}" \
        --branch="${PAGES_BRANCH}" \
        --commit-dirty=true \
        --commit-message="Website update $(date +%Y-%m-%d)"
    log_info "Website deploy complete."
    # Verify
    if [ -f "${DEPLOY_DIR}/appcast.xml" ]; then
        APPCAST_URL="https://${SITE_HOST}/appcast.xml"
        APPCAST_STATUS=$(extract_http_status "${APPCAST_URL}")
        if [ "${APPCAST_STATUS}" != "200" ]; then
            log_error "Appcast URL failed after deploy: ${APPCAST_URL} returned HTTP ${APPCAST_STATUS}"
            exit 1
        fi
        APPCAST_CONTENT=$(curl -fsSL "${APPCAST_URL}" 2>/dev/null || true)
        if [ -z "${APPCAST_CONTENT}" ]; then
            log_error "Failed to fetch appcast content after deploy: ${APPCAST_URL}"
            exit 1
        fi
        if command -v xmllint >/dev/null 2>&1; then
            if ! xmllint --noout - <<< "${APPCAST_CONTENT}" >/dev/null 2>&1; then
                log_error "Appcast XML invalid after website-only deploy: ${APPCAST_URL}"
                exit 1
            fi
        fi
        log_info "Appcast verified at ${APPCAST_URL}"
    fi
    exit 0
fi

if [ "${PREFLIGHT_ONLY}" = true ]; then
    if [ -z "${VERSION}" ]; then
        log_error "--preflight-only requires --version X.Y.Z"
        exit 1
    fi
    if run_release_preflight_only; then
        exit 0
    fi
    exit 1
fi

if [ "${FULL_RELEASE}" = true ] || [ "${RUN_DEPLOY}" = true ]; then
    if ! check_project_qa_guardrails; then
        exit 1
    fi
    if ! check_public_source_build_guardrails; then
        exit 1
    fi
fi

if [ "${FULL_RELEASE}" = true ] || [ "${RUN_DEPLOY}" = true ] || [ "${RUN_GH_RELEASE}" = true ]; then
    ensure_cmd git
    if ! check_release_path_gate; then
        exit 1
    fi
    ensure_git_clean
    enforce_machine_reconcile
fi

if ! check_recent_unresolved_failures_gate; then
    exit 1
fi

if ! check_release_notes_gate; then
    exit 1
fi

# Full release flow
if [ "${FULL_RELEASE}" = true ]; then
    if [ -z "${VERSION}" ]; then
        log_error "--full requires --version X.Y.Z"
        exit 1
    fi

    # ─── Pre-release safety gates (learned from 46 issues + 200 emails) ───

    # Gate 1: UserDefaults / migration change detection
    # Settings migration is #1 cause of customer bugs (50% of critical issues)
    DEFAULTS_CHANGED=$(git -C "${PROJECT_ROOT}" diff HEAD~5..HEAD --name-only -- '*.swift' | \
        xargs grep -l 'UserDefaults\|setDefaultsIfNeeded\|registerDefaults\|migration\|migrate' 2>/dev/null | head -5)
    if [ -n "${DEFAULTS_CHANGED}" ]; then
        log_warn "═══ UPGRADE SAFETY WARNING ═══"
        log_warn "These files touch UserDefaults/migration logic:"
        echo "${DEFAULTS_CHANGED}" | while read f; do log_warn "  - ${f}"; done
        log_warn "This is the #1 cause of customer regressions (v1.0.20 broke 5+ users)."
        log_warn "BEFORE SHIPPING: test upgrade path from previous version with existing user data."
        log_warn "════════════════════════════════"
    fi

    # Gate 2: Pending customer emails
    EMAIL_API_KEY="${SANE_EMAIL_API_KEY:-${EMAIL_API_KEY:-}}"
    if [ -z "${EMAIL_API_KEY}" ]; then
        EMAIL_API_KEY=$(keychain_secret "sane-email-automation" "api_key" "SANE_EMAIL_API_KEY" "EMAIL_API_KEY")
    fi
    if [ -n "${EMAIL_API_KEY}" ]; then
        PENDING_COUNT=$(curl -s "https://email-api.saneapps.com/api/emails/pending" \
            -H "Authorization: Bearer ${EMAIL_API_KEY}" 2>/dev/null | \
            python3 -c "import json,sys; print(len(json.load(sys.stdin)))" 2>/dev/null || echo "0")
        if [ "${PENDING_COUNT}" -gt 0 ] 2>/dev/null; then
            log_warn "${PENDING_COUNT} pending customer email(s) — review before shipping."
        fi
    fi

    # Gate 3: Open GitHub work queue (issues + PRs)
    if [ -n "${GITHUB_REPO}" ] && command -v gh >/dev/null 2>&1; then
        OPEN_ISSUES=$(gh issue list --repo "${GITHUB_REPO}" --state open --json number 2>/dev/null | \
            python3 -c "import json,sys; print(len(json.load(sys.stdin)))" 2>/dev/null || echo "0")
        if [ "${OPEN_ISSUES}" -gt 0 ] 2>/dev/null; then
            log_warn "${OPEN_ISSUES} open GitHub issue(s) — review before shipping."
        fi

        OPEN_PRS=$(gh pr list --repo "${GITHUB_REPO}" --state open --json number 2>/dev/null | \
            python3 -c "import json,sys; print(len(json.load(sys.stdin)))" 2>/dev/null || echo "0")
        if [ "${OPEN_PRS}" -gt 0 ] 2>/dev/null; then
            log_warn "${OPEN_PRS} open GitHub PR(s) — review before shipping."
            log_warn "Open PR list:"
            gh pr list --repo "${GITHUB_REPO}" --state open --limit 20 2>/dev/null | while IFS= read -r pr; do
                log_warn "  ${pr}"
            done
        fi
    fi

    # Gate 4: Evening release warning (8-18hr discovery window if broken)
    HOUR=$(date +%H)
    if [ "${HOUR}" -ge 17 ] || [ "${HOUR}" -lt 6 ]; then
        log_warn "Evening/night release detected ($(date +%H:%M))."
        log_warn "Bugs won't be discovered until morning. Prefer morning releases."
    fi

    # Gate 5: License validation endpoint reachable
    LICENSE_API="https://api.lemonsqueezy.com/v1/licenses/validate"
    LICENSE_STATUS=$(curl -sI -o /dev/null -w '%{http_code}' "${LICENSE_API}" 2>/dev/null || echo "000")
    if [ "${LICENSE_STATUS}" = "000" ]; then
        log_warn "LemonSqueezy license API unreachable (network error)."
        log_warn "Licensing features won't work for new activations until API is back."
    elif [ "${LICENSE_STATUS}" -ge 400 ] && [ "${LICENSE_STATUS}" -lt 500 ]; then
        # 4xx is expected for a bare POST with no body — means API is responding
        log_info "License API reachable (${LICENSE_STATUS} — expected without payload)"
    elif [ "${LICENSE_STATUS}" -ge 500 ]; then
        log_warn "LemonSqueezy API returned ${LICENSE_STATUS} — may be experiencing issues."
    fi

    # README sync check — warn if features aren't documented
    README_CHECK="${SCRIPT_DIR}/automation/nv-readme-check.sh"
    if [ -f "${README_CHECK}" ] && [ -x "${README_CHECK}" ]; then
        log_info "Checking README sync with shipped features..."
        if ! "${README_CHECK}" "${PROJECT_ROOT}" 2>/dev/null; then
            log_warn "README may be out of date with shipped features (see above)."
            log_warn "Consider updating README.md before release. Continuing..."
        fi
    fi

    run_tests

    log_info "Bumping version to ${VERSION}..."
    bump_project_version "${VERSION}"

    VERSION_BUMP_CHANGED=0
    for vf in "${VERSION_BUMP_FILES[@]}"; do
        if ! git -C "${PROJECT_ROOT}" diff --quiet -- "${vf}" 2>/dev/null; then
            VERSION_BUMP_CHANGED=1
        fi
    done
    if [ "${VERSION_BUMP_CHANGED}" -ne 1 ]; then
        log_warn "Version bump produced no tracked file changes. Continuing with existing versioned source state."
    fi

    update_changelog

    if [ "${XCODEGEN}" = true ]; then
        ensure_cmd xcodegen
        log_info "Regenerating Xcode project..."
        xcodegen generate
        XCODEGEN_DONE=true
    fi

    commit_version_bump
fi

# Clean up previous builds (skip if reusing existing archive)
if [ "${SKIP_BUILD}" = false ]; then
    log_info "Cleaning previous build artifacts..."
    remove_path "${BUILD_DIR}"
fi
mkdir -p "${BUILD_DIR}"
mkdir -p "${RELEASE_DIR}"

# Generate project if requested
if [ "${XCODEGEN}" = true ] && [ "${XCODEGEN_DONE}" = false ]; then
    ensure_cmd xcodegen
    log_info "Generating Xcode project..."
    xcodegen generate
fi

ensure_cmd xcodebuild
ensure_cmd codesign
ensure_cmd xcrun
ensure_cmd hdiutil
ensure_cmd ditto

if ! ensure_skip_build_artifacts_present; then
    exit 1
fi

# Fail early if signing/notarization prerequisites are missing.
if ! run_required_step "Keychain/signing session" prepare_signing_session; then
    exit 1
fi

if ! run_required_step "Notarization authentication" resolve_notary_auth; then
    exit 1
fi

if ! run_required_step "ASC provisioning authentication" prepare_xcode_provisioning_auth; then
    exit 1
fi

if [ "${NOTARY_AUTH_MODE}" = "api-key" ]; then
    log_info "Using notarytool API key fallback auth."
elif [ "${NOTARY_AUTH_MODE}" = "keychain-profile" ]; then
    log_info "Using notarytool keychain profile auth (${NOTARY_PROFILE})."
fi

# Build archive
if [ "${SKIP_BUILD}" = false ]; then
    log_info "Building release archive..."
    CURRENT_GATE="Build release archive"
    RELEASE_ERR_GATE_RECORDED=""

    archive_args=(archive -scheme "${SCHEME}" -configuration Release -archivePath "${ARCHIVE_PATH}" -destination "generic/platform=macOS" -allowProvisioningUpdates OTHER_CODE_SIGN_FLAGS="--timestamp")
    if [ ${#XCODE_PROVISIONING_AUTH_ARGS[@]} -gt 0 ]; then
        archive_args+=("${XCODE_PROVISIONING_AUTH_ARGS[@]}")
    fi
    if [ -n "${WORKSPACE}" ]; then
        archive_args=(-workspace "${WORKSPACE}" "${archive_args[@]}")
    elif [ -n "${XCODEPROJ}" ]; then
        archive_args=(-project "${XCODEPROJ}" "${archive_args[@]}")
    fi
    if [ ${#ARCHIVE_EXTRA_ARGS[@]} -gt 0 ]; then
        archive_args+=("${ARCHIVE_EXTRA_ARGS[@]}")
    fi

    xcodebuild "${archive_args[@]}" 2>&1 | tee "${BUILD_DIR}/build.log"

    if [ ${PIPESTATUS[0]} -ne 0 ]; then
        log_error "Archive build failed! Check ${BUILD_DIR}/build.log"
        track_gate_result "Build release archive" "failure" "${RELEASE_LAST_ERROR}"
        exit 1
    fi

    # Verify bundle ID inside archive
    archive_app_path="${ARCHIVE_PATH}/Products/Applications/${APP_NAME}.app"
    if [ ! -d "${archive_app_path}" ]; then
        log_error "Archive app not found at ${archive_app_path}"
        exit 1
    fi

    archive_bundle_id=$(defaults read "${archive_app_path}/Contents/Info" CFBundleIdentifier 2>/dev/null || true)
    if [ -z "${archive_bundle_id}" ]; then
        log_error "Unable to read CFBundleIdentifier from archive app"
        exit 1
    fi

    if [ "${archive_bundle_id}" != "${BUNDLE_ID}" ]; then
        log_error "Bundle ID mismatch: expected ${BUNDLE_ID}, got ${archive_bundle_id}"
        track_gate_result "Build release archive" "failure" "${RELEASE_LAST_ERROR}"
        exit 1
    fi
    track_gate_result "Build release archive" "pass" "ok"
    CURRENT_GATE=""
fi

# Export archive
log_info "Creating export options..."
EXPORT_OPTIONS_PATH="${BUILD_DIR}/ExportOptions.plist"
write_export_options_plist "${EXPORT_OPTIONS_PATH}"

log_info "Exporting signed app..."
CURRENT_GATE="Export signed app"
RELEASE_ERR_GATE_RECORDED=""
if ! run_logged_command "${BUILD_DIR}/build.log" \
    xcodebuild -exportArchive \
        -archivePath "${ARCHIVE_PATH}" \
        -exportPath "${EXPORT_PATH}" \
        -exportOptionsPlist "${EXPORT_OPTIONS_PATH}" \
        -allowProvisioningUpdates \
        "${XCODE_PROVISIONING_AUTH_ARGS[@]}"; then
    if [ "${USE_SPARKLE}" = true ] && \
       grep -q 'exportArchive codesign command failed' "${BUILD_DIR}/build.log" && \
       grep -q 'Sparkle.framework/Versions/B/' "${BUILD_DIR}/build.log" && \
       grep -q 'errSecInternalComponent' "${BUILD_DIR}/build.log"; then
        log_warn "Developer ID export hit the known Sparkle nested-bundle re-sign failure."
        log_warn "Reusing the already signed archive app for notarization and release packaging."
        if ! codesign --verify --deep --strict "${archive_app_path}"; then
            log_error "Archive app is not validly signed, so export fallback is unsafe."
            track_gate_result "Export signed app" "failure" "${RELEASE_LAST_ERROR}"
            exit 1
        fi
        mkdir -p "${EXPORT_PATH}"
        rm -rf "${EXPORT_PATH:?}/${APP_NAME}.app"
        if ! /usr/bin/ditto "${archive_app_path}" "${EXPORT_PATH}/${APP_NAME}.app"; then
            log_error "Failed to stage archive app into export path."
            track_gate_result "Export signed app" "failure" "${RELEASE_LAST_ERROR}"
            exit 1
        fi
        track_gate_result "Export signed app" "pass" "archive app fallback after Sparkle export resign failure"
    else
        log_error "Export failed! Check ${BUILD_DIR}/build.log"
        track_gate_result "Export signed app" "failure" "${RELEASE_LAST_ERROR}"
        exit 1
    fi
else
    track_gate_result "Export signed app" "pass" "ok"
fi
CURRENT_GATE=""

APP_PATH="${EXPORT_PATH}/${APP_NAME}.app"

# Notarization preflight
fix_and_verify_zipped_apps_in_app "${APP_PATH}"
sanity_check_app_for_notarization "${APP_PATH}"

# Verify code signature
log_info "Verifying code signature..."
codesign --verify --deep --strict "${APP_PATH}"
log_info "Code signature verified!"

# Verify Sparkle configuration
if [ "${USE_SPARKLE}" = true ]; then
    log_info "Verifying Sparkle configuration..."
    PLIST_FEED=$(/usr/libexec/PlistBuddy -c "Print :SUFeedURL" "${APP_PATH}/Contents/Info.plist" 2>/dev/null || echo "")
    PLIST_KEY=$(/usr/libexec/PlistBuddy -c "Print :SUPublicEDKey" "${APP_PATH}/Contents/Info.plist" 2>/dev/null || echo "")
    PLIST_INSTALLER_LAUNCHER=$(/usr/libexec/PlistBuddy -c "Print :SUEnableInstallerLauncherService" "${APP_PATH}/Contents/Info.plist" 2>/dev/null || echo "")
    if [ -z "${PLIST_FEED}" ]; then
        log_error "SUFeedURL missing from Info.plist!"
        exit 1
    fi
    if [ -z "${PLIST_KEY}" ]; then
        log_error "SUPublicEDKey missing from Info.plist!"
        exit 1
    fi
    # Verify key VALUE matches the shared SaneApps key (not just exists)
    # Per-project keys broke updates for ALL shipped customers
    if [ "${PLIST_KEY}" != "${SHARED_SPARKLE_PUBLIC_KEY}" ]; then
        log_error "SUPublicEDKey MISMATCH! Built app has wrong Sparkle key."
        log_error "  Expected: ${SHARED_SPARKLE_PUBLIC_KEY}"
        log_error "  Got:      ${PLIST_KEY}"
        log_error "This will break auto-update for ALL existing customers!"
        exit 1
    fi
    APP_ENTITLEMENTS_XML=$(codesign -d --entitlements :- "${APP_PATH}" 2>/dev/null | tr -d '\n\t ')
    if echo "${APP_ENTITLEMENTS_XML}" | grep -q "<key>com.apple.security.app-sandbox</key><true/>"; then
        APP_BUNDLE_ID=$(/usr/libexec/PlistBuddy -c "Print :CFBundleIdentifier" "${APP_PATH}/Contents/Info.plist" 2>/dev/null || echo "")
        if [ "${PLIST_INSTALLER_LAUNCHER}" != "true" ]; then
            log_error "SUEnableInstallerLauncherService missing from Info.plist for sandboxed Sparkle app."
            exit 1
        fi
        if [ -z "${APP_BUNDLE_ID}" ]; then
            log_error "CFBundleIdentifier missing from Info.plist; cannot verify Sparkle installer mach services."
            exit 1
        fi
        if ! echo "${APP_ENTITLEMENTS_XML}" | grep -q -- "${APP_BUNDLE_ID}-spki"; then
            log_error "Sandboxed Sparkle app is missing mach-lookup exception ${APP_BUNDLE_ID}-spki"
            exit 1
        fi
        if ! echo "${APP_ENTITLEMENTS_XML}" | grep -q -- "${APP_BUNDLE_ID}-spks"; then
            log_error "Sandboxed Sparkle app is missing mach-lookup exception ${APP_BUNDLE_ID}-spks"
            exit 1
        fi
        log_info "Sparkle installer launcher service verified for sandboxed app ${APP_BUNDLE_ID}"
    fi
    log_info "SUFeedURL: ${PLIST_FEED}"
    log_info "SUPublicEDKey: ${PLIST_KEY} (verified)"
fi

# App Store build + export (if enabled in .saneprocess)
# CRITICAL: App Store builds need a SEPARATE archive with a different configuration
# (e.g., Release-AppStore) to exclude direct-distribution frameworks like Sparkle.
# Reusing the Developer ID archive would produce a binary that links Sparkle at
# @rpath but doesn't include it — instant dyld crash on launch.
if [ "${APPSTORE_ENABLED}" = "true" ] && [ "${SKIP_APPSTORE}" = false ]; then
    APPSTORE_CONFIG="${APPSTORE_CONFIGURATION:-Release-AppStore}"
    APPSTORE_SCHEME_VALUE="${APPSTORE_SCHEME:-${SCHEME}}"
    APPSTORE_ARCHIVE="${BUILD_DIR}/${APP_NAME}-AppStore.xcarchive"
    APPSTORE_EXPORT_PATH="${BUILD_DIR}/Export-AppStore"
    mkdir -p "${APPSTORE_EXPORT_PATH}"
    if [ -n "${APPSTORE_PRODUCT_ID}" ]; then
        APPSTORE_HAS_PRODUCT_KEY="false"
        for bf in "${APPSTORE_BUILD_FLAGS[@]}"; do
            if [[ "${bf}" == INFOPLIST_KEY_AppStoreProductID=* ]]; then
                APPSTORE_HAS_PRODUCT_KEY="true"
                break
            fi
        done

        if [ "${APPSTORE_HAS_PRODUCT_KEY}" != "true" ]; then
            APPSTORE_BUILD_FLAGS+=("INFOPLIST_KEY_AppStoreProductID=${APPSTORE_PRODUCT_ID}")
        fi
    fi

    if [ "${SKIP_BUILD}" = false ]; then
        log_info ""
        log_info "Building App Store archive (configuration: ${APPSTORE_CONFIG})..."
        CURRENT_GATE="Build App Store archive"
        RELEASE_ERR_GATE_RECORDED=""

        appstore_archive_args=(archive -scheme "${APPSTORE_SCHEME_VALUE}" -configuration "${APPSTORE_CONFIG}" \
            -archivePath "${APPSTORE_ARCHIVE}" \
            -destination "generic/platform=macOS" OTHER_CODE_SIGN_FLAGS="--timestamp" \
            -allowProvisioningUpdates)
        if [ -n "${APPSTORE_ENTITLEMENTS}" ]; then
            has_entitlements_flag=false
            for build_flag in "${APPSTORE_BUILD_FLAGS[@]}"; do
                if [[ "${build_flag}" == CODE_SIGN_ENTITLEMENTS=* ]]; then
                    has_entitlements_flag=true
                    break
                fi
            done
            if [ "${has_entitlements_flag}" = false ]; then
                appstore_archive_args+=("CODE_SIGN_ENTITLEMENTS=${APPSTORE_ENTITLEMENTS}")
            fi
        fi
        if [ ${#APPSTORE_BUILD_FLAGS[@]} -gt 0 ]; then
            appstore_archive_args+=("${APPSTORE_BUILD_FLAGS[@]}")
        fi
        if [ ${#XCODE_PROVISIONING_AUTH_ARGS[@]} -gt 0 ]; then
            appstore_archive_args+=("${XCODE_PROVISIONING_AUTH_ARGS[@]}")
        fi
        if [ -n "${WORKSPACE}" ]; then
            appstore_archive_args=(-workspace "${WORKSPACE}" "${appstore_archive_args[@]}")
        elif [ -n "${XCODEPROJ}" ]; then
            appstore_archive_args=(-project "${XCODEPROJ}" "${appstore_archive_args[@]}")
        fi
        if [ ${#APPSTORE_ARCHIVE_EXTRA_ARGS[@]} -gt 0 ]; then
            appstore_archive_args+=("${APPSTORE_ARCHIVE_EXTRA_ARGS[@]}")
        fi

        if ! run_logged_command "${BUILD_DIR}/build.log" xcodebuild "${appstore_archive_args[@]}"; then
            if running_on_mini_host && appstore_gui_fallback_marker_present "${BUILD_DIR}/build.log"; then
                log_warn "Headless App Store archive failed on the Mini. Retrying in the Mini GUI session with automatic signing fallback..."
                gui_archive_args=(xcodebuild archive -scheme "${APPSTORE_SCHEME_VALUE}" -configuration "${APPSTORE_CONFIG}" \
                    -archivePath "${APPSTORE_ARCHIVE}" CODE_SIGN_STYLE=Automatic CODE_SIGN_IDENTITY= DEVELOPMENT_TEAM="${TEAM_ID}")
                if [ ${#XCODE_PROVISIONING_AUTH_ARGS[@]} -gt 0 ]; then
                    gui_archive_args+=("${XCODE_PROVISIONING_AUTH_ARGS[@]}")
                fi
                if ! run_mini_gui_logged_command "${BUILD_DIR}/build.log" "${APP_NAME} App Store archive" "${PROJECT_ROOT}" "${gui_archive_args[@]}"; then
                    log_error "App Store archive build failed! Check ${BUILD_DIR}/build.log"
                    track_gate_result "Build App Store archive" "failure" "${RELEASE_LAST_ERROR}"
                    exit 1
                fi
            else
                log_error "App Store archive build failed! Check ${BUILD_DIR}/build.log"
                track_gate_result "Build App Store archive" "failure" "${RELEASE_LAST_ERROR}"
                exit 1
            fi
        fi
        track_gate_result "Build App Store archive" "pass" "ok"
        CURRENT_GATE=""
    fi

    if [ ${#APPSTORE_STRIP_FRAMEWORKS[@]} -gt 0 ]; then
        for framework_name in "${APPSTORE_STRIP_FRAMEWORKS[@]}"; do
            framework_path="${APPSTORE_ARCHIVE}/Products/Applications/${APP_NAME}.app/Contents/Frameworks/${framework_name}"
            if [ -d "${framework_path}" ]; then
                log_info "Removing ${framework_name} from App Store archive..."
                remove_path "${framework_path}"
            fi
        done
    fi

    # Weaken Sparkle dylib reference in the archive binary.
    # SPM links Sparkle unconditionally; this changes LC_LOAD_DYLIB → LC_LOAD_WEAK_DYLIB
    # so dyld skips the missing framework instead of crashing on launch.
    APPSTORE_APP_IN_ARCHIVE="${APPSTORE_ARCHIVE}/Products/Applications/${APP_NAME}.app"
    if [ -d "${APPSTORE_APP_IN_ARCHIVE}" ]; then
        ARCHIVE_EXEC=$(defaults read "${APPSTORE_APP_IN_ARCHIVE}/Contents/Info" CFBundleExecutable 2>/dev/null || true)
        if [ -n "${ARCHIVE_EXEC}" ]; then
            ARCHIVE_BINARY="${APPSTORE_APP_IN_ARCHIVE}/Contents/MacOS/${ARCHIVE_EXEC}"
            if [ -f "${ARCHIVE_BINARY}" ]; then
                if otool -L "${ARCHIVE_BINARY}" 2>/dev/null | grep -q "Sparkle.framework"; then
                    if binary_has_strong_sparkle_load "${ARCHIVE_BINARY}"; then
                        log_info "Weakening Sparkle dylib reference in App Store binary..."
                        if ! ruby "${SCRIPT_DIR}/weaken_sparkle.rb" "${ARCHIVE_BINARY}"; then
                            log_error "Failed to weaken Sparkle reference. App Store build may crash on launch."
                            exit 1
                        fi
                    else
                        log_info "Sparkle dylib already weak-linked in App Store binary."
                    fi
                fi
            fi
        fi
    fi

    # Dylib preflight: verify no missing dynamic library references BEFORE uploading
    # This catches the Sparkle-not-stripped class of crash before we upload to ASC
    appstore_app="${APPSTORE_ARCHIVE}/Products/Applications/${APP_NAME}.app"
    if [ -d "${appstore_app}" ]; then
        if ! preflight_check_dylibs "${appstore_app}" "App Store"; then
            log_error "App Store dylib preflight FAILED — aborting before upload."
            log_error "The binary references frameworks not present in the bundle."
            log_error "Ensure ${APPSTORE_CONFIG} configuration excludes direct-distribution"
            log_error "frameworks (Sparkle, etc.) via compiler flags (#if !APP_STORE)."
            exit 1
        fi
    fi

    log_info "Exporting for App Store..."
    CURRENT_GATE="Export App Store package"
    RELEASE_ERR_GATE_RECORDED=""
    APPSTORE_PLIST="${BUILD_DIR}/ExportOptions-AppStore.plist"
    write_export_options_appstore_plist "${APPSTORE_PLIST}"

    appstore_export_args=(xcodebuild -exportArchive \
        -archivePath "${APPSTORE_ARCHIVE}" \
        -exportPath "${APPSTORE_EXPORT_PATH}" \
        -exportOptionsPlist "${APPSTORE_PLIST}" \
        -allowProvisioningUpdates)
    if [ ${#XCODE_PROVISIONING_AUTH_ARGS[@]} -gt 0 ]; then
        appstore_export_args+=("${XCODE_PROVISIONING_AUTH_ARGS[@]}")
    fi

    if ! run_logged_command "${BUILD_DIR}/build.log" "${appstore_export_args[@]}"; then
        if running_on_mini_host && appstore_gui_fallback_marker_present "${BUILD_DIR}/build.log"; then
            log_warn "Headless App Store export failed on the Mini. Retrying in the Mini GUI session..."
            if ! run_mini_gui_logged_command "${BUILD_DIR}/build.log" "${APP_NAME} App Store export" "${PROJECT_ROOT}" "${appstore_export_args[@]}"; then
                log_error "App Store export failed! Check ${BUILD_DIR}/build.log"
                track_gate_result "Export App Store package" "failure" "${RELEASE_LAST_ERROR}"
                exit 1
            fi
        else
            log_error "App Store export failed! Check ${BUILD_DIR}/build.log"
            track_gate_result "Export App Store package" "failure" "${RELEASE_LAST_ERROR}"
            exit 1
        fi
    fi
    track_gate_result "Export App Store package" "pass" "ok"
    CURRENT_GATE=""

    APPSTORE_PKG="${APPSTORE_EXPORT_PATH}/${APP_NAME}.pkg"
    if [ ! -f "${APPSTORE_PKG}" ]; then
        log_error "App Store export succeeded but no .pkg was produced at ${APPSTORE_PKG}"
        log_error "Headless release requires a local .pkg for API-key upload."
        exit 1
    else
        log_info "App Store artifact: ${APPSTORE_PKG}"
        if ! validate_appstore_pkg_preflight "${APPSTORE_PKG}"; then
            log_error "App Store package preflight failed. Fix entitlements/framework payload before upload."
            exit 1
        fi
        log_info "App Store package preflight passed (sandbox + framework checks)."
    fi
fi

# Get version/build from exported app and enforce requested-version consistency.
APP_VERSION_FROM_PLIST=$(/usr/libexec/PlistBuddy -c "Print :CFBundleShortVersionString" "${APP_PATH}/Contents/Info.plist" 2>/dev/null || echo "")
if [ -z "${APP_VERSION_FROM_PLIST}" ]; then
    log_error "Could not read CFBundleShortVersionString from exported app at ${APP_PATH}"
    exit 1
fi
if [ -z "${VERSION}" ]; then
    VERSION="${APP_VERSION_FROM_PLIST}"
elif [ "${VERSION}" != "${APP_VERSION_FROM_PLIST}" ]; then
    log_error "Requested --version (${VERSION}) does not match exported app version (${APP_VERSION_FROM_PLIST})."
    log_error "Fix version bump inputs first; refusing to publish mismatched artifact naming/metadata."
    exit 1
fi

BUILD_NUMBER=$(/usr/libexec/PlistBuddy -c "Print :CFBundleVersion" "${APP_PATH}/Contents/Info.plist" 2>/dev/null || echo "")
if [ -z "${BUILD_NUMBER}" ]; then
    log_error "Could not read CFBundleVersion from exported app at ${APP_PATH}"
    exit 1
fi
log_info "Version: ${VERSION} (${BUILD_NUMBER})"

# Package as ZIP (simpler, app icon survives HTTP download, no resource fork issues)
ZIP_NAME="${APP_NAME}-${VERSION}"
NOTARIZE_ZIP="${BUILD_DIR}/${APP_NAME}-notarize.zip"
ZIP_PATH="${BUILD_DIR}/${ZIP_NAME}.zip"

# Create temporary zip for notarization submission
log_info "Creating ZIP for notarization..."
ditto -c -k --keepParent --norsrc "${APP_PATH}" "${NOTARIZE_ZIP}"

# Notarize (if not skipped)
if [ "${SKIP_NOTARIZE}" = false ]; then
    log_info "Submitting for notarization..."
    log_warn "This may take several minutes..."
    CURRENT_GATE="Notarization submit+staple"
    RELEASE_ERR_GATE_RECORDED=""

    if [ "${NOTARY_AUTH_MODE}" = "api-key" ]; then
        xcrun notarytool submit "${NOTARIZE_ZIP}" \
            --key "${NOTARY_API_KEY_PATH}" \
            --key-id "${NOTARY_API_KEY_ID}" \
            --issuer "${NOTARY_API_ISSUER_ID}" \
            --wait
    else
        xcrun notarytool submit "${NOTARIZE_ZIP}" \
            --keychain-profile "${NOTARY_PROFILE}" \
            --wait
    fi

    log_info "Stapling notarization ticket to app..."
    xcrun stapler staple "${APP_PATH}"

    if [ "${VERIFY_STAPLE}" = true ]; then
        log_info "Verifying staple..."
        xcrun stapler validate "${APP_PATH}"
    fi

    log_info "Notarization complete!"
    track_gate_result "Notarization submit+staple" "pass" "ok"
    CURRENT_GATE=""
else
    log_warn "Skipping notarization (--skip-notarize flag set)"
fi

# Create final distribution ZIP (with stapled notarization ticket)
log_info "Creating distribution ZIP..."
rm -f "${ZIP_PATH}"
ditto -c -k --keepParent --norsrc "${APP_PATH}" "${ZIP_PATH}"

if ! verify_archive_bundle_version "${ZIP_PATH}" "${VERSION}" "${BUILD_NUMBER}"; then
    log_error "Distribution ZIP metadata check failed (bundle version/build mismatch)."
    log_error "Stop release, fix artifact inputs, verify, then rerun."
    exit 1
fi

# Guard: release ZIP must not contain AppleDouble metadata entries.
# Some extraction tools (notably plain `unzip`) materialize these as files
# inside app/framework bundles, which breaks code signing and Gatekeeper.
if unzip -l "${ZIP_PATH}" | awk 'NR > 3 {print $4}' | grep -Eq '(^|/)\._'; then
    log_error "Distribution ZIP contains AppleDouble (._*) entries, which can corrupt signatures after extraction."
    log_error "Rebuild failed intentionally to prevent shipping a Gatekeeper-rejected artifact."
    exit 1
fi

# Clean up temp notarization zip
rm -f "${NOTARIZE_ZIP}"

# Copy to releases folder
FINAL_ZIP="${RELEASE_DIR}/${ZIP_NAME}.zip"
remove_path "${FINAL_ZIP}"
ditto "${ZIP_PATH}" "${FINAL_ZIP}"

log_info "========================================"
log_info "Release build complete!"
log_info "========================================"
log_info "ZIP: ${FINAL_ZIP}"
log_info "Version: ${VERSION}"

# Metadata used by deploy and post-release checks (always compute).
SHA256=$(shasum -a 256 "${FINAL_ZIP}" | awk '{print $1}')
FILE_SIZE=$(stat -f%z "${FINAL_ZIP}")

# Generate Sparkle Signature
if [ "${USE_SPARKLE}" = true ] && command -v swift >/dev/null 2>&1; then
    log_info ""
    log_info "--- Generating Release Metadata ---"

    log_info "Fetching Sparkle Private Key from Keychain..."
    SPARKLE_KEY=$(resolve_sparkle_private_key || true)

    if [ -n "${SPARKLE_KEY}" ]; then
        DERIVED_SPARKLE_PUBLIC_KEY=$(derive_sparkle_public_key "${SPARKLE_KEY}" || true)
        if [ -z "${DERIVED_SPARKLE_PUBLIC_KEY}" ]; then
            log_error "Unable to derive Sparkle public key from private key."
            exit 1
        fi
        if [ "${DERIVED_SPARKLE_PUBLIC_KEY}" != "${SHARED_SPARKLE_PUBLIC_KEY}" ]; then
            log_error "Sparkle keypair mismatch during signing."
            log_error "  Expected public key: ${SHARED_SPARKLE_PUBLIC_KEY}"
            log_error "  Derived public key:  ${DERIVED_SPARKLE_PUBLIC_KEY}"
            exit 1
        fi

        log_info "Sparkle Key found. Generating signature..."

        if [ -f "${SIGN_UPDATE_SCRIPT}" ]; then
            SIGNATURE=$(swift "${SIGN_UPDATE_SCRIPT}" "${FINAL_ZIP}" "${SPARKLE_KEY}" 2>/dev/null || echo "")
        else
            SIGNATURE=""
        fi

        if [ -n "${SIGNATURE}" ]; then
            DATE=$(date +"%a, %d %b %Y %H:%M:%S %z")

            FORMATTED_NOTES=$(format_appcast_notes "${RELEASE_NOTES}" "${VERSION}")
            OPTIONAL_SPARKLE_FIELDS=$(build_optional_appcast_fields)
            echo -e "${GREEN}Sparkle AppCast Item:${NC}"
            cat <<EOF
        <item>
            <title>${VERSION}</title>
            <link>https://${SITE_HOST}/download</link>
            <pubDate>${DATE}</pubDate>
            <sparkle:minimumSystemVersion>${MIN_SYSTEM_VERSION}</sparkle:minimumSystemVersion>
${OPTIONAL_SPARKLE_FIELDS}            <description>
                <![CDATA[
                ${FORMATTED_NOTES}
                ]]>
            </description>
            <enclosure url="https://${DIST_HOST}/updates/${APP_NAME}-${VERSION}.zip"
                       sparkle:version="${BUILD_NUMBER}"
                       sparkle:shortVersionString="${VERSION}"
                       length="${FILE_SIZE}"
                       type="application/octet-stream"
                       sparkle:edSignature="${SIGNATURE}"/>
        </item>
EOF
        else
            log_error "Failed to generate Sparkle signature. Check Swift and key format."
        fi
    else
        log_warn "Sparkle Private Key not found in Keychain. Skipping signature generation."
    fi

    echo ""
    echo -e "${GREEN}Release Info:${NC}"
    echo "Version: ${VERSION} (Build ${BUILD_NUMBER})"
    echo "SHA256: ${SHA256}"
    echo "Size: ${FILE_SIZE} bytes"

    cat > "${BUILD_DIR}/${APP_NAME}-${VERSION}.meta" <<METAEOF
VERSION=${VERSION}
BUILD=${BUILD_NUMBER}
SHA256=${SHA256}
SIZE=${FILE_SIZE}
SIGNATURE=${SIGNATURE:-UNSIGNED}
METAEOF
    log_info "Saved release metadata to ${BUILD_DIR}/${APP_NAME}-${VERSION}.meta"
fi

if [ "${RUN_GH_RELEASE}" = true ]; then
    log_info ""
    log_info "Creating/updating GitHub release..."
    if ! create_github_release; then
        log_error "GitHub release sync failed."
        exit 1
    fi
    if ! upload_github_release_asset; then
        log_error "GitHub release asset sync failed."
        exit 1
    fi
fi

# Guardrail: by default do not republish an already-live Sparkle version/build.
ensure_not_republishing_live_version() {
    if [ "${ALLOW_REPUBLISH}" = true ]; then
        log_warn "Republish override enabled (--allow-republish)."
        return 0
    fi

    if [ "${USE_SPARKLE}" != "true" ]; then
        return 0
    fi

    local appcast_url="https://${SITE_HOST}/appcast.xml"
    local appcast_content
    appcast_content=$(curl -fsSL "${appcast_url}" 2>/dev/null || true)
    if [ -z "${appcast_content}" ]; then
        # If appcast is unavailable we cannot prove duplicate; continue and let later checks fail if needed.
        return 0
    fi

    local live_count
    live_count=$(appcast_item_count_for_version "${appcast_content}")
    if [ "${live_count}" -ge 1 ]; then
        log_error "Refusing to republish v${VERSION} (${BUILD_NUMBER}): live appcast already has ${live_count} entr$( [ "${live_count}" = "1" ] && echo "y" || echo "ies" )."
        log_error "Sparkle users on v${VERSION} will not receive an update without a version/build bump."
        log_error "Bump version (recommended) or rerun with --allow-republish for emergency replacement."
        exit 1
    fi
}

# ─── Deploy: R2 upload + appcast update + Pages deploy ───
if [ "${RUN_DEPLOY}" = true ]; then
    log_info ""
    log_info "═══════════════════════════════════════════"
    log_info "  DEPLOYING TO PRODUCTION"
    log_info "═══════════════════════════════════════════"

    ensure_not_republishing_live_version

    # Step 1: Upload ZIP to R2
    log_info "Uploading ZIP to R2 bucket ${R2_BUCKET}..."
    ensure_cmd npx
    CURRENT_GATE="Upload ZIP to R2"
    RELEASE_ERR_GATE_RECORDED=""
    # Dist worker maps /updates/<file> URLs to bare R2 object keys.
    # Keep the public URL path for clients, but store object without prefix.
    R2_OBJECT_KEY="${APP_NAME}-${VERSION}.zip"
    npx wrangler r2 object put "${R2_BUCKET}/${R2_OBJECT_KEY}" \
        --file="${FINAL_ZIP}" --remote
    log_info "R2 upload complete."
    track_gate_result "Upload ZIP to R2" "pass" "ok"
    CURRENT_GATE=""

    # Verify R2 upload
    log_info "Verifying download URL..."
    HTTP_STATUS_BROWSER=$(extract_http_status "https://${DIST_HOST}/updates/${APP_NAME}-${VERSION}.zip")
    HTTP_STATUS_SPARKLE=$(extract_http_status_with_user_agent "https://${DIST_HOST}/updates/${APP_NAME}-${VERSION}.zip" "Sparkle/2")
    if { [ "${HTTP_STATUS_BROWSER}" != "200" ] && [ "${HTTP_STATUS_BROWSER}" != "206" ]; } || \
       { [ "${HTTP_STATUS_SPARKLE}" != "200" ] && [ "${HTTP_STATUS_SPARKLE}" != "206" ]; }; then
        log_error "R2 verification FAILED! https://${DIST_HOST}/updates/${APP_NAME}-${VERSION}.zip returned browser=${HTTP_STATUS_BROWSER}, sparkle=${HTTP_STATUS_SPARKLE}"
        log_error "Check dist Worker routing and update/download channel rules."
        exit 1
    fi
    log_info "Download verified: https://${DIST_HOST}/updates/${APP_NAME}-${VERSION}.zip (browser=${HTTP_STATUS_BROWSER}, sparkle=${HTTP_STATUS_SPARKLE})"

    # Step 1b: Optionally clean up old versions from R2.
    # Historical binaries stay available by default so downgrade links and
    # appcast history do not silently rot after a new release.
    if [ "${PURGE_R2_BINARY_ASSETS}" = true ]; then
        log_info "Cleaning old ${APP_NAME} versions from R2..."
        CF_TOKEN="${CLOUDFLARE_API_TOKEN:-$(resolve_cloudflare_api_token || true)}"
        CF_ACCOUNT_ID="${CF_ACCOUNT_ID:-2c267ab06352ba2522114c3081a8c5fa}"

        if [ -n "${CF_TOKEN}" ]; then
            R2_API_URL="https://api.cloudflare.com/client/v4/accounts/${CF_ACCOUNT_ID}/r2/buckets/${R2_BUCKET}/objects"
            OLD_KEYS=$(CF_TOKEN="${CF_TOKEN}" R2_API_URL="${R2_API_URL}" APP_NAME="${APP_NAME}" VERSION="${VERSION}" python3 - <<'PY' 2>/dev/null || true
import json
import os
import urllib.parse
import urllib.request

token = os.environ.get("CF_TOKEN", "")
api_url = os.environ.get("R2_API_URL", "")
app_name = os.environ.get("APP_NAME", "")
version = os.environ.get("VERSION", "")

if not token or not api_url or not app_name or not version:
    raise SystemExit(0)

keep = {f"updates/{app_name}-{version}.zip", f"{app_name}-{version}.zip"}
prefixes = (f"updates/{app_name}-", f"{app_name}-")
cursor = None

while True:
    request_url = api_url
    if cursor:
        request_url += "?cursor=" + urllib.parse.quote(cursor, safe="")

    req = urllib.request.Request(
        request_url,
        headers={"Authorization": f"Bearer {token}"},
        method="GET",
    )
    with urllib.request.urlopen(req, timeout=20) as response:
        payload = json.loads(response.read().decode("utf-8"))

    for obj in payload.get("result", []):
        key = obj.get("key", "")
        if any(key.startswith(prefix) for prefix in prefixes) and key not in keep:
            print(key)

    cursor = (payload.get("result_info") or {}).get("cursor")
    if not cursor:
        break
PY
)

            OLD_COUNT=0
            if [ -n "${OLD_KEYS}" ]; then
                while IFS= read -r OLD_KEY; do
                    ENCODED_KEY=$(python3 -c 'import sys, urllib.parse; print(urllib.parse.quote(sys.argv[1], safe=""))' "${OLD_KEY}")
                    DEL_STATUS=$(curl -s -o /dev/null -w "%{http_code}" -X DELETE \
                        "${R2_API_URL}/${ENCODED_KEY}" \
                        -H "Authorization: Bearer ${CF_TOKEN}")
                    if [ "${DEL_STATUS}" = "200" ]; then
                        log_info "  Deleted old version: ${OLD_KEY}"
                        OLD_COUNT=$((OLD_COUNT + 1))
                    else
                        log_warn "  Failed to delete: ${OLD_KEY} (HTTP ${DEL_STATUS}, non-fatal)"
                    fi
                done <<< "${OLD_KEYS}"
                log_info "Cleaned ${OLD_COUNT} old version(s) from R2."
            else
                log_info "No old versions to clean up."
            fi
        else
            log_warn "Skipping R2 cleanup — no Cloudflare API token available."
            log_warn "Old versions may accumulate. Set 'cloudflare' keychain entry or CLOUDFLARE_API_TOKEN env var."
        fi
    else
        log_info "Keeping historical ${APP_NAME} versions in R2 (default)."
    fi

    # Step 1c: Optionally remove legacy GitHub binary assets.
    purge_github_binary_assets

    # Step 2: Update appcast.xml
    if [ "${USE_SPARKLE}" = true ] && [ -n "${SIGNATURE}" ] && [ "${SIGNATURE}" != "UNSIGNED" ]; then
        APPCAST_PATH="${PROJECT_ROOT}/docs/appcast.xml"
        if [ -f "${APPCAST_PATH}" ]; then
            log_info "Updating appcast.xml with v${VERSION}..."
            PUB_DATE=$(date -R)

            FORMATTED_NOTES=$(format_appcast_notes "${RELEASE_NOTES}" "${VERSION}")
            OPTIONAL_SPARKLE_FIELDS=$(build_optional_appcast_fields)
            NEW_ITEM=$(cat <<APPCASTEOF
        <item>
            <title>${VERSION}</title>
            <link>https://${SITE_HOST}/download</link>
            <pubDate>${PUB_DATE}</pubDate>
            <sparkle:minimumSystemVersion>${MIN_SYSTEM_VERSION}</sparkle:minimumSystemVersion>
${OPTIONAL_SPARKLE_FIELDS}            <description>
                <![CDATA[
                ${FORMATTED_NOTES}
                ]]>
            </description>
            <enclosure url="https://${DIST_HOST}/updates/${APP_NAME}-${VERSION}.zip"
                       sparkle:version="${BUILD_NUMBER}"
                       sparkle:shortVersionString="${VERSION}"
                       length="${FILE_SIZE}"
                       type="application/octet-stream"
                       sparkle:edSignature="${SIGNATURE}"/>
        </item>
APPCASTEOF
)
            # Remove stale entries for this version/build first to avoid duplicate release items.
            prune_existing_appcast_entries "${APPCAST_PATH}"

            # Insert new item before first existing <item>, or before </channel> if empty.
            APPCAST_NEW_ITEM="${NEW_ITEM}" python3 - "${APPCAST_PATH}" <<'PY'
import os
import sys
import tempfile

path = sys.argv[1]
new_item = os.environ.get("APPCAST_NEW_ITEM", "").rstrip() + "\n"

with open(path, "r", encoding="utf-8") as f:
    xml = f.read()

item_idx = xml.find("<item>")
if item_idx != -1:
    xml = xml[:item_idx] + new_item + xml[item_idx:]
elif "</channel>" in xml:
    xml = xml.replace("</channel>", f"{new_item}</channel>", 1)
else:
    raise SystemExit("appcast.xml missing </channel>")

path_dir = os.path.dirname(path) or "."
tmp_path = None
try:
    with tempfile.NamedTemporaryFile("w", encoding="utf-8", dir=path_dir, delete=False) as tmp:
        tmp.write(xml)
        tmp_path = tmp.name
    os.replace(tmp_path, path)
finally:
    if tmp_path and os.path.exists(tmp_path):
        os.remove(tmp_path)
PY

            # Validate local appcast before deploy.
            if command -v xmllint >/dev/null 2>&1; then
                if ! xmllint --noout "${APPCAST_PATH}" >/dev/null 2>&1; then
                    log_error "Local appcast is invalid XML after update: ${APPCAST_PATH}"
                    exit 1
                fi
            fi
            LOCAL_APPCAST_CONTENT=$(cat "${APPCAST_PATH}")
            LOCAL_COUNT=$(appcast_item_count_for_version "${LOCAL_APPCAST_CONTENT}")
            if [ "${LOCAL_COUNT}" != "1" ]; then
                log_error "Local appcast contains ${LOCAL_COUNT} entries for v${VERSION} (expected 1)"
                exit 1
            fi

            LOCAL_INFO_MISMATCHES=$(appcast_informational_constraint_version_mismatches "${LOCAL_APPCAST_CONTENT}") || exit 1
            if [ -n "${LOCAL_INFO_MISMATCHES}" ]; then
                log_error "Local appcast informational update cutoffs must use CFBundleVersion/build values, not display versions: ${LOCAL_INFO_MISMATCHES}"
                exit 1
            fi

            log_info "Appcast updated with v${VERSION} entry."
        else
            log_warn "No appcast.xml found at ${APPCAST_PATH}. Skipping appcast update."
        fi
    else
        log_warn "No Sparkle signature available. Skipping appcast update."
        log_warn "Run with USE_SPARKLE=true and ensure EdDSA key is in Keychain."
    fi

    # Keep website/appcast.xml aligned with docs/appcast.xml before deploy/commit checks.
    sync_docs_appcast_to_website

    # Step 2.5: Auto-update website download links to current version
    # Prevents stale download URLs (v1.5.0 link shipped with v2.1.0 — never again)
    DOCS_DIR="${PROJECT_ROOT}/docs"
    WEBSITE_DIR="${PROJECT_ROOT}/website"
    for SITE_DIR in "${DOCS_DIR}" "${WEBSITE_DIR}"; do
        for PAGE_NAME in index.html download.html; do
            INDEX_HTML="${SITE_DIR}/${PAGE_NAME}"
            if [ ! -f "${INDEX_HTML}" ]; then
                continue
            fi
            UPDATE_RESULT=$(APP_NAME="${APP_NAME}" VERSION="${VERSION}" INDEX_HTML="${INDEX_HTML}" python3 <<'PY'
import os
import pathlib
import re
import tempfile

path = pathlib.Path(os.environ["INDEX_HTML"])
app_name = re.escape(os.environ["APP_NAME"])
version = os.environ["VERSION"]
text = path.read_text(encoding="utf-8")

download_pattern = re.compile(rf"{app_name}-[0-9]+\.[0-9]+(?:\.[0-9]+)?\.(?:zip|dmg)")
software_pattern = re.compile(r'"softwareVersion": "[^"]*"')

new_text, download_count = download_pattern.subn(f'{os.environ["APP_NAME"]}-{version}.zip', text)
new_text, software_count = software_pattern.subn(f'"softwareVersion": "{version}"', new_text)

if new_text != text:
    tmp_path = None
    try:
        with tempfile.NamedTemporaryFile("w", encoding="utf-8", dir=path.parent, delete=False) as tmp:
            tmp.write(new_text)
            tmp_path = pathlib.Path(tmp.name)
        os.replace(tmp_path, path)
    finally:
        if tmp_path is not None and tmp_path.exists():
            tmp_path.unlink()

print(f"{download_count},{software_count}")
PY
)
            DOWNLOAD_COUNT="${UPDATE_RESULT%%,*}"
            SOFTWARE_COUNT="${UPDATE_RESULT##*,}"

            if [ "${DOWNLOAD_COUNT}" -gt 0 ]; then
                log_info "Updated ${DOWNLOAD_COUNT} download link(s) in $(basename "${SITE_DIR}")/${PAGE_NAME} → ${APP_NAME}-${VERSION}.zip"
            fi
            if [ "${SOFTWARE_COUNT}" -gt 0 ]; then
                log_info "Updated softwareVersion in $(basename "${SITE_DIR}")/${PAGE_NAME} → ${VERSION}"
            fi
        done
    done

    # Auto-wire live iOS App Store URL into website markers (if configured and available).
    update_website_app_store_links_if_live

    # Step 3: Deploy website + appcast to Cloudflare Pages
    PAGES_PROJECT="${LOWER_APP_NAME}-site"
    if [ -d "${WEBSITE_DIR}" ]; then
        DEPLOY_DIR="${WEBSITE_DIR}"
    elif [ -d "${DOCS_DIR}" ]; then
        DEPLOY_DIR="${DOCS_DIR}"
    else
        DEPLOY_DIR=""
    fi

    if [ -n "${DEPLOY_DIR}" ]; then
        log_info "Deploying website + appcast to Cloudflare Pages (${PAGES_PROJECT}) from ${DEPLOY_DIR}..."
        CURRENT_GATE="Deploy website to Cloudflare Pages"
        RELEASE_ERR_GATE_RECORDED=""
        npx wrangler pages deploy "${DEPLOY_DIR}" \
            --project-name="${PAGES_PROJECT}" \
            --branch="${PAGES_BRANCH}" \
            --commit-dirty=true \
            --commit-message="Release v${VERSION}"
        log_info "Pages deploy complete."
        track_gate_result "Deploy website to Cloudflare Pages" "pass" "ok"
        CURRENT_GATE=""

        # Verify appcast propagation and uniqueness (blocking) for Sparkle apps.
        if [ "${USE_SPARKLE}" = true ]; then
            log_info "Verifying appcast propagation..."
            if ! wait_for_live_appcast_version "https://${SITE_HOST}/appcast.xml"; then
                log_error "Appcast propagation failed for v${VERSION}. Aborting release."
                exit 1
            fi
        fi
    else
        log_warn "No website/ or docs/ directory found. Skipping Pages deploy."
    fi

    # Step 4: Verify checkout/purchase link still works
    # LemonSqueezy slug change broke 26 URLs for 44 hours ($40 lost)
    CHECKOUT_URL="https://go.saneapps.com/buy/${LOWER_APP_NAME}"
    CHECKOUT_STATUS=$(curl -sI -o /dev/null -w '%{http_code}' "${CHECKOUT_URL}" 2>/dev/null || echo "000")
    if [ "${CHECKOUT_STATUS}" = "200" ] || [ "${CHECKOUT_STATUS}" = "301" ] || [ "${CHECKOUT_STATUS}" = "302" ]; then
        log_info "Checkout link verified: ${CHECKOUT_URL} (${CHECKOUT_STATUS})"
    elif [ "${CHECKOUT_STATUS}" = "000" ]; then
        log_warn "Could not reach checkout URL: ${CHECKOUT_URL} (network error)"
    else
        log_warn "Checkout link may be broken: ${CHECKOUT_URL} returned ${CHECKOUT_STATUS}"
        log_warn "Test the purchase flow manually before announcing this release."
    fi

    APPSTORE_SUBMIT_FAILED=false

    # Step 5: App Store submission (if enabled)
    if [ "${APPSTORE_ENABLED}" = "true" ] && [ "${SKIP_APPSTORE}" = true ]; then
        log_warn "App Store submission skipped (--skip-appstore)."
    fi

    if [ "${APPSTORE_ENABLED}" = "true" ] && [ "${SKIP_APPSTORE}" = false ]; then
        log_info ""
        log_info "═══════════════════════════════════════════"
        log_info "  SUBMITTING TO APP STORE"
        log_info "═══════════════════════════════════════════"

        CURRENT_GATE="App Store artifact/policy preflight"
        RELEASE_ERR_GATE_RECORDED=""
        if ! check_appstore_submission_guardrails_gate; then
            track_gate_result "App Store artifact/policy preflight" "failure" "${RELEASE_LAST_ERROR}"
            CURRENT_GATE=""
            APPSTORE_SUBMIT_FAILED=true
        else
            track_gate_result "App Store artifact/policy preflight" "pass" "ok"
            CURRENT_GATE=""
        fi

        APPSTORE_SCRIPT="${SCRIPT_DIR}/appstore_submit.rb"

        if [ ! -f "${APPSTORE_SCRIPT}" ]; then
            log_warn "appstore_submit.rb not found at ${APPSTORE_SCRIPT}. Skipping App Store submit."
            APPSTORE_SUBMIT_FAILED=true
        fi

        if [ "${APPSTORE_SUBMIT_FAILED}" = false ] && { [ -z "${APPSTORE_PKG}" ] || [ ! -f "${APPSTORE_PKG}" ]; }; then
            log_warn "No App Store .pkg found. Skipping App Store submit for this release."
            APPSTORE_SUBMIT_FAILED=true
        fi

        if [ "${APPSTORE_SUBMIT_FAILED}" = false ]; then
            CURRENT_GATE="App Store submit (macOS)"
            RELEASE_ERR_GATE_RECORDED=""
            if ! ruby "${APPSTORE_SCRIPT}" \
                --pkg "${APPSTORE_PKG}" \
                --app-id "${APPSTORE_APP_ID}" \
                --version "${VERSION}" \
                --platform macos \
                --project-root "${PROJECT_ROOT}"; then
                log_warn "App Store submit failed (macOS). Continuing direct-channel release."
                track_gate_result "App Store submit (macOS)" "failure" "${RELEASE_LAST_ERROR}"
                APPSTORE_SUBMIT_FAILED=true
            else
                track_gate_result "App Store submit (macOS)" "pass" "ok"
            fi
            CURRENT_GATE=""
        fi

        # iOS build and submission (if configured)
        if [ "${APPSTORE_SUBMIT_FAILED}" = false ] && appstore_platform_enabled "ios"; then
            IOS_SCHEME="${APPSTORE_IOS_SCHEME:-${SCHEME}}"
            log_info ""
            log_info "Building iOS for App Store (scheme: ${IOS_SCHEME})..."

            IOS_ARCHIVE="${BUILD_DIR}/${APP_NAME}-iOS.xcarchive"
            ios_args=(archive -scheme "${IOS_SCHEME}" -configuration "${APPSTORE_CONFIG}" \
                      -archivePath "${IOS_ARCHIVE}" \
                      -destination "generic/platform=iOS" \
                      -allowProvisioningUpdates)
            if [ -n "${APPSTORE_PRODUCT_ID}" ]; then
                ios_args+=( "INFOPLIST_KEY_AppStoreProductID=${APPSTORE_PRODUCT_ID}" )
            fi
            if [ ${#XCODE_PROVISIONING_AUTH_ARGS[@]} -gt 0 ]; then
                ios_args+=("${XCODE_PROVISIONING_AUTH_ARGS[@]}")
            fi
            [ -n "${XCODEPROJ}" ] && ios_args=(-project "${XCODEPROJ}" "${ios_args[@]}")
            if ! run_logged_command "${BUILD_DIR}/build.log" xcodebuild "${ios_args[@]}"; then
                log_warn "iOS archive failed — skipping iOS App Store submission"
            else
                IOS_EXPORT="${BUILD_DIR}/Export-AppStore-iOS"
                mkdir -p "${IOS_EXPORT}"
                if ! run_logged_command "${BUILD_DIR}/build.log" \
                    xcodebuild -exportArchive \
                        -archivePath "${IOS_ARCHIVE}" \
                        -exportPath "${IOS_EXPORT}" \
                        -exportOptionsPlist "${APPSTORE_PLIST}" \
                        -allowProvisioningUpdates \
                        "${XCODE_PROVISIONING_AUTH_ARGS[@]}"; then
                    log_warn "iOS export failed — skipping iOS submission"
                fi

                IOS_IPA="${IOS_EXPORT}/${APP_NAME}.ipa"
                if [ -f "${IOS_IPA}" ]; then
                    CURRENT_GATE="App Store submit (iOS)"
                    RELEASE_ERR_GATE_RECORDED=""
                    if ! ruby "${APPSTORE_SCRIPT}" \
                        --pkg "${IOS_IPA}" \
                        --app-id "${APPSTORE_APP_ID}" \
                        --version "${VERSION}" \
                        --platform ios \
                        --project-root "${PROJECT_ROOT}"; then
                        log_warn "App Store submit failed (iOS). Continuing direct-channel release."
                        track_gate_result "App Store submit (iOS)" "failure" "${RELEASE_LAST_ERROR}"
                        APPSTORE_SUBMIT_FAILED=true
                    else
                        track_gate_result "App Store submit (iOS)" "pass" "ok"
                    fi
                    CURRENT_GATE=""
                else
                    log_warn "iOS export produced no .ipa — skipping iOS submission"
                fi
            fi
        fi
    fi

    POST_RELEASE_REPO_SYNC_FAILED=false
    POST_RELEASE_REPO_SYNC_DETAILS=()

    # Step 6: Commit version/link sync files that release updates indirectly
    PROJECT_PBXPROJ_REL="${APP_NAME}.xcodeproj/project.pbxproj"
    if [ -n "${XCODEPROJ}" ]; then
        if [[ "${XCODEPROJ}" == "${PROJECT_ROOT}/"* ]]; then
            PROJECT_PBXPROJ_REL="${XCODEPROJ#${PROJECT_ROOT}/}/project.pbxproj"
        else
            PROJECT_PBXPROJ_REL="$(basename "${XCODEPROJ}")/project.pbxproj"
        fi
    fi

    VERSION_SYNC_FILES=()
    for f in "project.yml" "${PROJECT_PBXPROJ_REL}" "docs/index.html"; do
        if [ -f "${PROJECT_ROOT}/${f}" ] && ! git -C "${PROJECT_ROOT}" diff --quiet -- "${f}" 2>/dev/null; then
            VERSION_SYNC_FILES+=("${f}")
        fi
    done

    if [ ${#VERSION_SYNC_FILES[@]} -gt 0 ]; then
        log_info "Committing version metadata/site link sync..."
        git -C "${PROJECT_ROOT}" add "${VERSION_SYNC_FILES[@]}"
        if ! git -C "${PROJECT_ROOT}" diff --cached --quiet; then
            git -C "${PROJECT_ROOT}" commit -m "chore: sync ${VERSION} version metadata and site download links"
            if ! GIT_TERMINAL_PROMPT=0 GCM_INTERACTIVE=never git -C "${PROJECT_ROOT}" push; then
                log_error "Failed to push version metadata/site link commit."
                POST_RELEASE_REPO_SYNC_FAILED=true
                POST_RELEASE_REPO_SYNC_DETAILS+=("Version metadata/site link commit push failed.")
            fi
            if [ "${POST_RELEASE_REPO_SYNC_FAILED}" != true ]; then
                log_info "Version metadata/site link commit pushed."
            fi
        fi
    fi

    # Step 7: Commit tracked release metadata changes (appcast + website download pages)
    RELEASE_METADATA_SYNC_FILES=()
    for f in \
        "docs/appcast.xml" \
        "website/appcast.xml" \
        "docs/index.html" \
        "docs/download.html" \
        "website/index.html" \
        "website/download.html"; do
        if [ -f "${PROJECT_ROOT}/${f}" ] && ! git -C "${PROJECT_ROOT}" diff --quiet -- "${f}" 2>/dev/null; then
            RELEASE_METADATA_SYNC_FILES+=("${f}")
        fi
    done
    if [ ${#RELEASE_METADATA_SYNC_FILES[@]} -gt 0 ]; then
        log_info "Committing release metadata update..."
        git -C "${PROJECT_ROOT}" add "${RELEASE_METADATA_SYNC_FILES[@]}"
        git -C "${PROJECT_ROOT}" commit -m "chore: sync release metadata for v${VERSION}"
        if ! GIT_TERMINAL_PROMPT=0 GCM_INTERACTIVE=never git -C "${PROJECT_ROOT}" push; then
            log_error "Failed to push release metadata commit for v${VERSION}."
            POST_RELEASE_REPO_SYNC_FAILED=true
            POST_RELEASE_REPO_SYNC_DETAILS+=("Release metadata commit push failed.")
        fi
        if [ "${POST_RELEASE_REPO_SYNC_FAILED}" != true ]; then
            log_info "Release metadata commit pushed."
        fi
    fi

    # Step 8: Update Homebrew cask (if tap repo configured)
    CASK_FILE="Casks/${LOWER_APP_NAME}.rb"
    HOMEBREW_TAP_DIR="/tmp/homebrew-tap-update-$$"
    if [ -n "${HOMEBREW_TAP_REPO}" ]; then
        HOMEBREW_TAP_GIT_URL_RESOLVED="$(resolve_homebrew_tap_git_url)"
        log_info "Updating Homebrew cask in ${HOMEBREW_TAP_REPO}..."

        # Clone the tap repo
        if GIT_TERMINAL_PROMPT=0 GCM_INTERACTIVE=never git clone --depth 1 "${HOMEBREW_TAP_GIT_URL_RESOLVED}" "${HOMEBREW_TAP_DIR}" 2>/dev/null; then
            if [ -f "${HOMEBREW_TAP_DIR}/${CASK_FILE}" ]; then
                # Update version and SHA256 in the cask formula
                sed -i '' "s/version \"[^\"]*\"/version \"${VERSION}\"/" "${HOMEBREW_TAP_DIR}/${CASK_FILE}"
                sed -i '' "s/sha256 \"[^\"]*\"/sha256 \"${SHA256}\"/" "${HOMEBREW_TAP_DIR}/${CASK_FILE}"

                # Commit and push
                cd "${HOMEBREW_TAP_DIR}"
                git add "${CASK_FILE}"
                if git diff --cached --quiet; then
                    log_info "Homebrew cask already up to date."
                else
                    git commit -m "chore: update ${LOWER_APP_NAME} to ${VERSION}"
                    if ! GIT_TERMINAL_PROMPT=0 GCM_INTERACTIVE=never git push; then
                        cd "${PROJECT_ROOT}"
                        remove_path "${HOMEBREW_TAP_DIR}"
                        if [ "${STRICT_PUBLIC_CHANNEL_SYNC}" = true ]; then
                            log_error "Homebrew tap push failed for v${VERSION}. Aborting."
                            exit 1
                        fi
                        log_warn "Homebrew tap push failed for v${VERSION}. Continuing."
                    else
                        log_info "Homebrew cask updated to v${VERSION} (SHA: ${SHA256:0:12}...)"
                    fi
                fi
                cd "${PROJECT_ROOT}"
            else
                if [ "${STRICT_PUBLIC_CHANNEL_SYNC}" = true ]; then
                    log_error "No cask found at ${CASK_FILE} in ${HOMEBREW_TAP_REPO}."
                    exit 1
                fi
                log_warn "No cask found at ${CASK_FILE} in ${HOMEBREW_TAP_REPO}. Skipping."
            fi
            remove_path "${HOMEBREW_TAP_DIR}"
        else
            if [ "${STRICT_PUBLIC_CHANNEL_SYNC}" = true ]; then
                log_error "Could not clone ${HOMEBREW_TAP_REPO}."
                exit 1
            fi
            log_warn "Could not clone ${HOMEBREW_TAP_REPO}. Skipping Homebrew update."
        fi
    fi

    # Step 9: Verify Homebrew cask is correct (post-push sanity check)
    if [ -n "${HOMEBREW_TAP_REPO}" ]; then
        CASK_RAW_URL="https://raw.githubusercontent.com/${HOMEBREW_TAP_REPO}/main/${CASK_FILE}"
        CASK_CHECK=$(curl -s "${CASK_RAW_URL}" 2>/dev/null)
        if echo "${CASK_CHECK}" | grep -q "version \"${VERSION}\"" && echo "${CASK_CHECK}" | grep -q "sha256 \"${SHA256}\""; then
            log_info "Homebrew cask verified: v${VERSION} live at ${CASK_RAW_URL}"
        else
            log_warn "Homebrew cask may not have propagated. Check: ${CASK_RAW_URL}"
        fi
    fi

    # Step 10: Auto-update email webhook product config
    # The email webhook has product→filename mappings for purchase download links.
    # Update it BEFORE strict post-release verification so the gate measures the
    # same customer-facing state the release just deployed.
    WEBHOOK_DIR="${HOME}/SaneApps/infra/sane-email-automation"
    WEBHOOK_JS="${WEBHOOK_DIR}/src/handlers/webhook-lemonsqueezy.js"
    if mini_route_context_active; then
        ROUTED_WEBHOOK_DIR="$(mini_route_webhook_field 'remote_path' 2>/dev/null || true)"
        ROUTED_WEBHOOK_JS="$(mini_route_webhook_field 'remote_file_path' 2>/dev/null || true)"
        if [ -n "${ROUTED_WEBHOOK_DIR}" ] && [ -d "${ROUTED_WEBHOOK_DIR}" ]; then
            WEBHOOK_DIR="${ROUTED_WEBHOOK_DIR}"
            WEBHOOK_JS="${ROUTED_WEBHOOK_JS:-${WEBHOOK_DIR}/src/handlers/webhook-lemonsqueezy.js}"
        fi
    fi
    if [ -f "${WEBHOOK_JS}" ]; then
        WEBHOOK_BRANCH="$(webhook_checkout_branch "${WEBHOOK_DIR}")"
        WEBHOOK_WORK_DIR="${WEBHOOK_DIR}"
        WEBHOOK_WORK_JS="${WEBHOOK_JS}"
        if ! resolve_release_webhook_checkout "${WEBHOOK_DIR}" "${WEBHOOK_JS}" "${WEBHOOK_BRANCH}"; then
            log_error "Could not prepare sane-email-automation for webhook release sync."
            exit 1
        fi

        # Match: 'AppName': { file: 'AppName-X.Y.Z.zip', ... } or .dmg
        OLD_ENTRY=$(grep "'${APP_NAME}'" "${WEBHOOK_WORK_JS}" 2>/dev/null || true)
        if [ -n "${OLD_ENTRY}" ]; then
            # Determine extension from current entry (zip or dmg)
            CURRENT_EXT=$(echo "${OLD_ENTRY}" | grep -o '\.\(zip\|dmg\)' | head -1)
            CURRENT_EXT="${CURRENT_EXT:-.zip}"
            sed -i '' "s|'${APP_NAME}': { file: '${APP_NAME}-[^']*'|'${APP_NAME}': { file: '${APP_NAME}-${VERSION}${CURRENT_EXT}'|" "${WEBHOOK_WORK_JS}"
            log_info "Updated email webhook: ${APP_NAME} → ${APP_NAME}-${VERSION}${CURRENT_EXT}"

            # Commit and deploy
            if cd "${WEBHOOK_WORK_DIR}" 2>/dev/null; then
                WEBHOOK_REL_PATH="src/handlers/webhook-lemonsqueezy.js"
                git add "${WEBHOOK_REL_PATH}"
                if git diff --cached --quiet; then
                    log_info "Webhook source already at ${APP_NAME}-${VERSION}${CURRENT_EXT}."
                else
                    if ! git commit -m "chore: update ${LOWER_APP_NAME} to ${VERSION}" --no-verify 2>/dev/null; then
                        log_error "Failed to commit email webhook update for ${APP_NAME}-${VERSION}."
                        cd "${PROJECT_ROOT}"
                        exit 1
                    fi
                    if ! GIT_TERMINAL_PROMPT=0 GCM_INTERACTIVE=never git push 2>/dev/null; then
                        log_error "Webhook push failed — remote main is not updated."
                        cd "${PROJECT_ROOT}"
                        exit 1
                    fi
                    log_info "Webhook committed and pushed."
                fi

                if [ -f "package-lock.json" ]; then
                    log_info "Installing email webhook dependencies with npm ci..."
                    WEBHOOK_INSTALL_OUTPUT=$(npm ci --no-audit --no-fund 2>&1)
                    WEBHOOK_INSTALL_STATUS=$?
                elif [ -f "package.json" ]; then
                    log_info "Installing email webhook dependencies with npm install..."
                    WEBHOOK_INSTALL_OUTPUT=$(npm install --no-audit --no-fund 2>&1)
                    WEBHOOK_INSTALL_STATUS=$?
                else
                    WEBHOOK_INSTALL_OUTPUT=""
                    WEBHOOK_INSTALL_STATUS=0
                fi

                if [ "${WEBHOOK_INSTALL_STATUS}" -ne 0 ]; then
                    log_error "Webhook dependency install failed."
                    printf '%s\n' "${WEBHOOK_INSTALL_OUTPUT}" >&2
                    cd "${PROJECT_ROOT}"
                    exit 1
                fi

                WEBHOOK_DEPLOY_OUTPUT=$(npx wrangler deploy --keep-vars 2>&1)
                WEBHOOK_DEPLOY_STATUS=$?
                if [ "${WEBHOOK_DEPLOY_STATUS}" -ne 0 ]; then
                    log_error "Webhook deploy failed."
                    printf '%s\n' "${WEBHOOK_DEPLOY_OUTPUT}" >&2
                    cd "${PROJECT_ROOT}"
                    exit 1
                fi
                log_info "Webhook deployed to Cloudflare Workers."
                cd "${PROJECT_ROOT}"
            else
                log_error "Could not enter sane-email-automation checkout at ${WEBHOOK_DIR}"
                exit 1
            fi

            # Verify local replacement, remote source, and the deployed worker.
            WEBHOOK_VERIFY=$(grep "'${APP_NAME}'" "${WEBHOOK_WORK_JS}" 2>/dev/null || true)
            if [ -n "${WEBHOOK_VERIFY}" ]; then
                if echo "${WEBHOOK_VERIFY}" | grep -q "${APP_NAME}-${VERSION}"; then
                    log_info "Webhook local version verified: ${APP_NAME}-${VERSION} in PRODUCT_CONFIG"
                else
                    ACTUAL_VER=$(echo "${WEBHOOK_VERIFY}" | grep -oE "${APP_NAME}-[0-9]+\.[0-9]+(\.[0-9]+)?" | head -1)
                    log_error "Webhook version mismatch after update: expected ${APP_NAME}-${VERSION} but found ${ACTUAL_VER:-unknown}"
                    log_error "The sed replacement may have failed silently. Edit ${WEBHOOK_JS} manually."
                    exit 1
                fi
            fi
            REMOTE_WEBHOOK_BODY=$(fetch_remote_email_webhook_js)
            REMOTE_WEBHOOK_VER=$(extract_email_webhook_version "${REMOTE_WEBHOOK_BODY}" "${APP_NAME}")
            if [ -n "${REMOTE_WEBHOOK_VER}" ]; then
                if [ "${REMOTE_WEBHOOK_VER}" = "${VERSION}" ]; then
                    log_info "Webhook remote version verified: ${APP_NAME}-${VERSION} in GitHub main"
                else
                    log_error "Webhook remote version mismatch: expected ${APP_NAME}-${VERSION} but GitHub main has ${APP_NAME}-${REMOTE_WEBHOOK_VER}"
                    exit 1
                fi
            else
                log_error "Could not verify webhook version on GitHub main."
                exit 1
            fi

            LIVE_WEBHOOK_SNAPSHOT=$(wait_for_live_email_webhook_version "${VERSION}" "${APP_NAME}" "1" "12" "5")
            LIVE_WEBHOOK_VER=$(extract_live_email_webhook_field "${LIVE_WEBHOOK_SNAPSHOT}" "${APP_NAME}" "version")
            if [ "${LIVE_WEBHOOK_VER}" != "${VERSION}" ]; then
                log_error "Live webhook version mismatch after deploy: expected ${APP_NAME}-${VERSION} but worker serves ${APP_NAME}-${LIVE_WEBHOOK_VER:-unknown}"
                exit 1
            fi
            LIVE_WEBHOOK_URL=$(extract_live_email_webhook_field "${LIVE_WEBHOOK_SNAPSHOT}" "${APP_NAME}" "downloadUrl")
            if [ -z "${LIVE_WEBHOOK_URL}" ]; then
                log_error "Live webhook deploy verification did not return a signed download URL for ${APP_NAME}."
                exit 1
            fi
            log_info "Webhook live version verified: ${APP_NAME}-${VERSION} on email-api.saneapps.com"
        else
            log_error "No '${APP_NAME}' entry found in webhook — update ${WEBHOOK_WORK_JS} before releasing."
            exit 1
        fi
    else
        log_error "Email webhook file not found at ${WEBHOOK_JS}"
        exit 1
    fi

    # Step 11: Strict post-release verification gate
    CURRENT_GATE="Post-release verification"
    RELEASE_ERR_GATE_RECORDED=""
    if ! run_post_release_checks; then
        log_error "Post-release verification failed. Release is NOT considered complete."
        track_gate_result "Post-release verification" "failure" "${RELEASE_LAST_ERROR}"
        exit 1
    fi
    track_gate_result "Post-release verification" "pass" "ok"
    CURRENT_GATE=""

    if [ "${APPSTORE_SUBMIT_FAILED}" = true ]; then
        log_info ""
        log_error "═══════════════════════════════════════════"
        log_error "  RELEASE v${VERSION} DIRECT CHANNELS LIVE"
        log_error "  APP STORE SUBMISSION STILL FAILED"
        log_error "═══════════════════════════════════════════"
        log_error "App Store submission did not complete in this run. Direct download/Homebrew channels are live, but the overall release is NOT complete."
        exit 1
    fi
    log_info ""
    log_info "═══════════════════════════════════════════"
    log_info "  RELEASE v${VERSION} DEPLOYED SUCCESSFULLY"
    log_info "═══════════════════════════════════════════"
    if [ "${POST_RELEASE_REPO_SYNC_FAILED}" = true ]; then
        log_error "Customer-facing release checks passed, but source repo sync failed."
        for detail in "${POST_RELEASE_REPO_SYNC_DETAILS[@]}"; do
            log_error "  - ${detail}"
        done
        exit 1
    fi
    log_info "  ZIP:      https://${DIST_HOST}/updates/${APP_NAME}-${VERSION}.zip"
    log_info "  Appcast:  https://${SITE_HOST}/appcast.xml"
    log_info "  Homebrew: brew install --cask sane-apps/tap/${LOWER_APP_NAME}"
    log_info "═══════════════════════════════════════════"
else
    log_info ""
    log_info "To test: open \"${FINAL_ZIP}\""
    log_info ""
    log_info "To deploy to production, re-run with --deploy flag:"
    log_info "  ./scripts/release.sh --version ${VERSION} --skip-build --deploy"
fi

open "${RELEASE_DIR}"
