#!/usr/bin/env bash set -euo pipefail # # ZaliVATorr — сборка и заливка в App Store Connect одной командой. # # Работает через API-ключ (.p8): без Xcode-аккаунта, не ломается на VM. # Всё находит сам: проект, схему, Team ID, ключ (даже в ~/Downloads). # Чего не хватает — спросит один раз и запомнит (release.config + private_keys/). # Заливка только с виртуалки; при ошибке покажет строки с ошибками из лога. # Перед заливкой предложит обновить title/subtitle и release notes (языки из ASC). # # Команды: # release.sh меню (без аргументов в терминале) # release.sh go собрать и залить сразу, без меню # release.sh doctor проверить тачку (не собирает) # release.sh setup только настроить # release.sh --ipa-only .ipa без заливки # release.sh --from-archive <путь> дозалить готовый архив # release.sh --issuer issuer без вопросов # release.sh install-button Product → Archive будет заливать сам # release.sh install-global поставить как команду `release` # release.sh logs [errors|full|-f] лог сборки / ошибки / live # release.sh health health-check App Store: что не пустит в стор # release.sh meta меню: release notes / title / subtitle # release.sh meta notes "текст" один текст сразу во все языки (\n — перенос) # release.sh meta push залить папку metadata/ (как fastlane) # release.sh meta prompt промт для GPT (языки возьмёт из ASC) # release.sh meta json вставить JSON-ответ GPT в терминал # release.sh meta notes.json notes из JSON-файла # ==== ХАРДКОД (вбей сюда — и скрипт-одиночка сам всё знает) ==== ISSUER_HARDCODE="" KEY_ID_HARDCODE="" P8_HARDCODE="" VM_GUARD="YES" # ========================================================================== # цвета — только в живом терминале if [ -t 1 ]; then C_G=$'\033[32m'; C_Y=$'\033[33m'; C_R=$'\033[31m'; C_C=$'\033[36m'; C_D=$'\033[2m'; C_X=$'\033[0m' HAVE_TTY=1 else C_G=""; C_Y=""; C_R=""; C_C=""; C_D=""; C_X="" HAVE_TTY=0 fi log() { echo "${C_D}[i]${C_X} $*"; } ok() { echo "${C_G}[+]${C_X} $*"; } warn() { echo "${C_Y}[!]${C_X} $*" >&2; } err() { echo "${C_R}[x]${C_X} $*" >&2; } die() { err "$*"; exit 1; } # --- где проект (скрипт может лежать в scripts/ или быть командой в PATH) --- SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" PARENT_DIR="$(cd "$SCRIPT_DIR/.." 2>/dev/null && pwd || echo "$SCRIPT_DIR")" # самолечение: после первого «bash release.sh» дальше можно просто ./release.sh if [ -f "${BASH_SOURCE[0]}" ] && [ ! -x "${BASH_SOURCE[0]}" ]; then chmod +x "${BASH_SOURCE[0]}" 2>/dev/null || true fi has_project() { find "$1" -maxdepth 1 \( -name "*.xcodeproj" -o -name "*.xcworkspace" \) -print 2>/dev/null | grep -q . } if has_project "$PWD"; then REPO_ROOT="$PWD" elif has_project "$PARENT_DIR"; then REPO_ROOT="$PARENT_DIR" elif has_project "$SCRIPT_DIR"; then REPO_ROOT="$SCRIPT_DIR" else REPO_ROOT="$PWD" fi BUILD_DIR="$REPO_ROOT/build" KEYS_DIR="$REPO_ROOT/private_keys" # --- разбор аргументов --- MODE="full" EXISTING_ARCHIVE="" ISSUER_ARG="" LOGS_ARG="" META_ARG="" NOTES_TEXT="" ARG_COUNT=$# while [ $# -gt 0 ]; do case "$1" in go|release) MODE="full" ;; --ipa-only) MODE="ipa-only" ;; --from-archive) MODE="from-archive"; shift; EXISTING_ARCHIVE="${1:-}" [ -n "$EXISTING_ARCHIVE" ] || die "Использование: $0 --from-archive <путь.xcarchive>" ;; doctor) MODE="doctor" ;; setup) MODE="setup" ;; logs|log) MODE="logs" case "${2:-}" in errors|full|-f) shift; LOGS_ARG="$1" ;; esac ;; health|check) MODE="meta"; META_ARG="health" ;; meta) MODE="meta" case "${2:-}" in push|prompt|json|health|*.json) shift; META_ARG="$1" ;; notes) shift; META_ARG="notes" if [ -n "${2:-}" ]; then shift; NOTES_TEXT="$1"; fi ;; esac ;; install-button) MODE="install-button" ;; install-global) MODE="install-global" ;; --issuer) shift; ISSUER_ARG="${1:-}" [ -n "$ISSUER_ARG" ] || die "Использование: $0 --issuer " ;; -h|--help) awk 'NR<3{next} /^# ====/{exit} /^#/{sub(/^# ?/,""); print; next} /^$/{print ""; next} {exit}' "$0"; exit 0 ;; *) die "Не знаю такого аргумента: $1 (см. $0 --help)" ;; esac shift done # --- конфиг (создаётся сам, живёт в проекте) --- CONFIG_FILE="" for c in "$REPO_ROOT/scripts/release.config" "$REPO_ROOT/release.config" "$SCRIPT_DIR/release.config"; do if [ -f "$c" ]; then CONFIG_FILE="$c"; break; fi done if [ -n "$CONFIG_FILE" ]; then # shellcheck source=/dev/null . "$CONFIG_FILE" else case "$SCRIPT_DIR" in "$REPO_ROOT"*) CONFIG_FILE="$SCRIPT_DIR/release.config" ;; *) CONFIG_FILE="$REPO_ROOT/release.config" ;; esac fi SCHEME="${SCHEME:-}" CONFIGURATION="${CONFIGURATION:-Release}" TEAM_ID="${TEAM_ID:-}" API_KEY_ID="${API_KEY_ID:-}" API_ISSUER_ID="${API_ISSUER_ID:-}" WORKSPACE="${WORKSPACE:-}" PROJECT="${PROJECT:-}" AUTO_BUMP_BUILD="${AUTO_BUMP_BUILD:-YES}" UPLOAD_SYMBOLS="${UPLOAD_SYMBOLS:-YES}" BUNDLE_ID="${BUNDLE_ID:-}" if [ -n "$ISSUER_ARG" ]; then API_ISSUER_ID="$ISSUER_ARG" elif [ -n "${ASC_ISSUER_ID:-}" ]; then API_ISSUER_ID="$ASC_ISSUER_ID" elif [ -n "$ISSUER_HARDCODE" ]; then API_ISSUER_ID="$ISSUER_HARDCODE" fi # --- мелкие помощники --- mtime_of() { if [ "$(uname)" = "Darwin" ]; then stat -f %m "$1" 2>/dev/null || echo 0 else stat -c %Y "$1" 2>/dev/null || echo 0; fi } abs_path() { echo "$(cd "$(dirname "$1")" && pwd)/$(basename "$1")"; } # путь из drag&drop терминала: кавычки, экранированные пробелы, ~ clean_path() { local p p="$(printf '%s' "$1" | sed -e "s/^[\"']//" -e "s/[\"']\$//" -e 's/\\ / /g')" case "$p" in "~/"*) p="$HOME/${p#\~/}" ;; esac printf '%s' "$p" } is_uuid() { case "$1" in [0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F]-[0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F]-[0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F]-[0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F]-[0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F]) return 0 ;; *) return 1 ;; esac } # --- VM или нет (верим только положительным доказательствам) --- sysctl_n() { sysctl -n "$1" 2>/dev/null || /usr/sbin/sysctl -n "$1" 2>/dev/null || true; } is_vm() { [ "$(sysctl_n kern.hv_vmm_present)" = "1" ] && return 0 sysctl_n machdep.cpu.features | grep -q VMM && return 0 case "$(sysctl_n hw.model)" in VirtualMac*|VMware*|Parallels*|VirtualBox*|QEMU*) return 0 ;; esac return 1 } # --- проект/workspace --- detect_container() { CONTAINER_FLAG=""; CONTAINER_PATH="" if [ -n "$WORKSPACE" ]; then CONTAINER_FLAG="-workspace"; CONTAINER_PATH="$REPO_ROOT/$WORKSPACE" elif [ -n "$PROJECT" ]; then CONTAINER_FLAG="-project"; CONTAINER_PATH="$REPO_ROOT/$PROJECT" else local ws pj ws="$(find "$REPO_ROOT" -maxdepth 1 -name "*.xcworkspace" -print 2>/dev/null | head -n 1)" if [ -n "$ws" ]; then CONTAINER_FLAG="-workspace"; CONTAINER_PATH="$ws" else pj="$(find "$REPO_ROOT" -maxdepth 1 -name "*.xcodeproj" -print 2>/dev/null | head -n 1)" [ -n "$pj" ] || return 1 CONTAINER_FLAG="-project"; CONTAINER_PATH="$pj" fi fi [ -e "$CONTAINER_PATH" ] || die "Не нашёл $CONTAINER_PATH — проверь WORKSPACE/PROJECT в конфиге" } # --- схема (из xcodebuild -list) --- detect_scheme() { [ -n "$SCHEME" ] && return 0 local schemes base picked count schemes="$(xcodebuild -list "$CONTAINER_FLAG" "$CONTAINER_PATH" 2>/dev/null \ | sed -n '/Schemes:/,$p' | tail -n +2 \ | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//' -e '/^$/d')" [ -n "$schemes" ] || return 1 count="$(echo "$schemes" | wc -l | tr -d ' ')" base="$(basename "$CONTAINER_PATH")"; base="${base%.*}" picked="" while IFS= read -r s; do if [ "$s" = "$base" ]; then picked="$s"; break; fi done </dev/null | head -n 1)" [ -n "$pj" ] && pbx="$pj/project.pbxproj" fi [ -f "$pbx" ] || return 1 TEAM_ID="$(grep -o 'DEVELOPMENT_TEAM = "\{0,1\}[A-Z0-9]\{10\}' "$pbx" 2>/dev/null | head -n 1 | awk '{print $3}' | tr -d '"')" [ -n "$TEAM_ID" ] } # --- Bundle ID (из pbxproj) --- detect_bundle_id() { [ -n "$BUNDLE_ID" ] && return 0 local pbx pbx="$(find "$REPO_ROOT" -maxdepth 2 -name "project.pbxproj" -print 2>/dev/null | head -n 1)" [ -f "$pbx" ] || return 1 BUNDLE_ID="$(grep -o 'PRODUCT_BUNDLE_IDENTIFIER = "\{0,1\}[A-Za-z0-9._-]*' "$pbx" 2>/dev/null \ | awk '{print $3}' | tr -d '"' | grep -v 'Tests$' | grep -v '^\$' | grep . | head -n 1)" [ -n "$BUNDLE_ID" ] } # --- поиск .p8 (Key ID — из имени файла) --- find_key() { KEY_PATH="" if [ -n "$P8_HARDCODE" ]; then [ -n "$KEY_ID_HARDCODE" ] || die "P8_HARDCODE задан, а KEY_ID_HARDCODE пуст — впиши Key ID в хардкод-блок." if [ ! -f "$KEYS_DIR/AuthKey_${KEY_ID_HARDCODE}.p8" ]; then mkdir -p "$KEYS_DIR" printf '%s\n' "$P8_HARDCODE" > "$KEYS_DIR/AuthKey_${KEY_ID_HARDCODE}.p8" fi API_KEY_ID="$KEY_ID_HARDCODE" elif [ -n "$KEY_ID_HARDCODE" ] && [ -z "$API_KEY_ID" ]; then API_KEY_ID="$KEY_ID_HARDCODE" fi local search_dirs d f best best_m m search_dirs="$KEYS_DIR $REPO_ROOT $SCRIPT_DIR $HOME/.private_keys $HOME/private_keys $HOME/.appstoreconnect/private_keys $HOME/Downloads" if [ -n "$API_KEY_ID" ]; then while IFS= read -r d; do f="$d/AuthKey_${API_KEY_ID}.p8" if [ -f "$f" ]; then KEY_PATH="$(abs_path "$f")"; return 0; fi done </dev/null) done < " local first line key_content="" IFS= read -r first || true first="$(printf '%s' "${first:-}" | tr -d '\r')" [ -n "$first" ] || { err "Пусто — отменяю."; return 1; } # вариант: перетащили файл local path path="$(clean_path "$first")" if [ -f "$path" ]; then local base base="$(basename "$path" .p8)" case "$base" in AuthKey_*) API_KEY_ID="${base#AuthKey_}" ;; *) printf "В имени файла нет Key ID (ждал AuthKey_.p8). Вставь Key ID со страницы ASC: " read -r API_KEY_ID || true API_KEY_ID="$(echo "${API_KEY_ID:-}" | tr -d '[:space:]')" [ -n "$API_KEY_ID" ] || { err "Key ID пустой."; return 1; } ;; esac mkdir -p "$KEYS_DIR" cp "$path" "$KEYS_DIR/AuthKey_${API_KEY_ID}.p8" KEY_PATH="$KEYS_DIR/AuthKey_${API_KEY_ID}.p8" ok "Ключ сохранён в проект (Key ID: $API_KEY_ID — из имени файла). Больше не спрошу." return 0 fi # вариант: вставили текст case "$first" in *"BEGIN PRIVATE KEY"*) key_content="${first} " ;; *) err "Это не путь к файлу и не содержимое .p8 (нет BEGIN PRIVATE KEY)."; return 1 ;; esac while IFS= read -r line; do line="$(printf '%s' "$line" | tr -d '\r')" [ -z "$line" ] && continue key_content="${key_content}${line} " case "$line" in *"END PRIVATE KEY"*) break ;; esac done echo "" echo "Принял. В самом ключе Key ID не хранится — он в имени файла или на странице ASC." printf "Вставь Key ID (10 символов): " read -r API_KEY_ID || true API_KEY_ID="$(echo "${API_KEY_ID:-}" | tr -d '[:space:]')" [ -n "$API_KEY_ID" ] || { err "Key ID пустой."; return 1; } case "$API_KEY_ID" in [A-Z0-9][A-Z0-9][A-Z0-9][A-Z0-9][A-Z0-9][A-Z0-9][A-Z0-9][A-Z0-9][A-Z0-9][A-Z0-9]) : ;; *) warn "Key ID обычно 10 заглавных букв/цифр — проверь, то ли вставил." ;; esac mkdir -p "$KEYS_DIR" printf '%s' "$key_content" > "$KEYS_DIR/AuthKey_${API_KEY_ID}.p8" KEY_PATH="$KEYS_DIR/AuthKey_${API_KEY_ID}.p8" ok "Ключ сохранён в проект. Больше не спрошу." } # --- ключ копируем в проект (поедет вместе с ним) --- adopt_key() { case "$KEY_PATH" in "$REPO_ROOT"/*) return 0 ;; esac mkdir -p "$KEYS_DIR" cp "$KEY_PATH" "$KEYS_DIR/AuthKey_${API_KEY_ID}.p8" KEY_PATH="$KEYS_DIR/AuthKey_${API_KEY_ID}.p8" ok "Ключ скопирован в проект: private_keys/AuthKey_${API_KEY_ID}.p8 (поедет вместе с ним)" } # --- Issuer ID: спросить один раз --- ensure_issuer() { API_ISSUER_ID="$(echo "$API_ISSUER_ID" | tr -d '[:space:]')" if [ -n "$API_ISSUER_ID" ]; then is_uuid "$API_ISSUER_ID" || warn "Issuer ID не похож на UUID: '$API_ISSUER_ID' — проверь." return 0 fi if [ -t 0 ] || [ -n "${RELEASE_FORCE_INTERACTIVE:-}" ]; then echo "" echo "Нужен Issuer ID (один раз — запомню)." echo "Он на той же странице ASC, где создавал ключ: UUID сверху, рядом кнопка Copy." printf "Вставь Issuer ID → Enter: " read -r API_ISSUER_ID || true API_ISSUER_ID="$(echo "${API_ISSUER_ID:-}" | tr -d '[:space:]')" [ -n "$API_ISSUER_ID" ] || die "Issuer ID пустой." is_uuid "$API_ISSUER_ID" || warn "Не похоже на UUID — если упадёт авторизация, проверь это первым." else die "Не задан Issuer ID. Запусти: $0 --issuer (или export ASC_ISSUER_ID=)" fi } # --- запомнить настройки --- save_config() { cat > "$CONFIG_FILE" <&2 err "Ошибки из лога ($f):" { grep -nE "error:|fatal error|xcodebuild: error|FAILED|Error Domain|Unable to|failed with" "$f" 2>/dev/null \ | grep -v "warning:" | grep -vE "^[0-9]+:[[:space:]]" \ | tail -n 20 | sed 's/^/ /' >&2; } \ || echo " (строк с ошибками не нашлось — смотри полный лог: $0 logs full)" >&2 } # --- logs --- run_logs() { local newest newest="$(ls -t "$BUILD_DIR"/release-*.log 2>/dev/null | head -n 1 || true)" if [ -f /tmp/asc_upload.log ]; then if [ -z "$newest" ] || [ "$(mtime_of /tmp/asc_upload.log)" -gt "$(mtime_of "$newest")" ]; then newest="/tmp/asc_upload.log" fi fi [ -n "$newest" ] || die "Логов пока нет (смотрел $BUILD_DIR/release-*.log и /tmp/asc_upload.log)" log "Лог: $newest" case "$LOGS_ARG" in errors) print_errors "$newest" ;; full) cat "$newest" ;; -f) tail -n 20 -f "$newest" ;; *) echo "--- последние 60 строк | весь: logs full | ошибки: logs errors | live: logs -f ---" tail -n 60 "$newest" ;; esac exit 0 } # --- doctor --- run_doctor() { local fail=0 echo "=== release.sh doctor ===" if is_vm; then ok "Машина: виртуалка ($(sysctl -n hw.model 2>/dev/null || echo '?')) — заливать можно" else warn "Машина: НЕ похожа на виртуалку ($(sysctl -n hw.model 2>/dev/null || echo '?')) — заливка будет отменена" fi if xcode-select -p >/dev/null 2>&1; then ok "xcode-select: $(xcode-select -p)" else err "Xcode не выбран. Выполни: sudo xcode-select -s /Applications/Xcode.app"; fail=1 fi if xcodebuild -version >/dev/null 2>&1; then ok "xcodebuild: $(xcodebuild -version | tr '\n' ' ')" else err "xcodebuild не отвечает — скорее всего не принята лицензия: sudo xcodebuild -license accept"; fail=1 fi if detect_container; then ok "Проект: $CONTAINER_FLAG $CONTAINER_PATH" if detect_scheme; then ok "Scheme: $SCHEME"; else err "Схему определить не удалось (xcodebuild -list)"; fail=1; fi if detect_team_id; then ok "Team ID: $TEAM_ID"; else warn "Team ID не найден — выбери команду в Xcode (Signing & Capabilities)"; fail=1; fi else err "В $REPO_ROOT нет .xcodeproj/.xcworkspace — запусти из папки проекта"; fail=1 fi if find_key; then ok "Ключ: $KEY_PATH (Key ID: $API_KEY_ID)" else err "Ключ AuthKey_*.p8 не найден. Скачай в ASC: Users and Access → Integrations."; fail=1 fi if [ -n "$API_ISSUER_ID" ]; then ok "Issuer ID: $API_ISSUER_ID"; else warn "Issuer ID пока нет — спрошу при первом релизе"; fi if command -v python3 >/dev/null 2>&1; then ok "python3: $(python3 -V 2>&1 | tr -d '\n')" else warn "python3 не найден — meta (title/notes) и install-button работать не будут" fi if command -v openssl >/dev/null 2>&1; then ok "openssl: есть"; else warn "openssl не найден — meta работать не будет"; fi if [ "$VM_GUARD" = "YES" ]; then ok "VM-защита: включена (заливка только с виртуалки)" else warn "VM-защита: ВЫКЛЮЧЕНА (VM_GUARD=\"NO\" в скрипте)" fi echo if [ "$fail" -eq 0 ]; then ok "Всё готово. Запускай: $0"; else err "Есть проблемы, см. выше."; fi exit "$fail" } # --- install-global: команда `release` в PATH --- install_global() { local dest="/usr/local/bin/release" mkdir -p /usr/local/bin 2>/dev/null || true if cp "${BASH_SOURCE[0]}" "$dest" 2>/dev/null && chmod +x "$dest" 2>/dev/null; then ok "Готово: теперь в любом проекте просто cd <папка> && release" else warn "Не хватило прав. Выполни руками:" echo " sudo cp \"${BASH_SOURCE[0]}\" $dest && sudo chmod +x $dest" exit 1 fi exit 0 } # --- install-button: post-action в схему, Archive будет заливать сам --- install_button() { command -v python3 >/dev/null 2>&1 || die "Нужен python3 (идёт с Xcode). Или добавь post-action руками: Edit Scheme → Archive → Post-actions." detect_container local scheme_files picked scheme_files="$(find "$REPO_ROOT" -maxdepth 6 -path "*/xcschemes/*.xcscheme" -print 2>/dev/null)" [ -n "$scheme_files" ] || die "Нет ни одного .xcscheme. В Xcode: Product → Scheme → Manage Schemes → галка Shared, и повтори." picked="" if detect_scheme 2>/dev/null && [ -n "$SCHEME" ]; then picked="$(echo "$scheme_files" | grep "xcshareddata.*/${SCHEME}\.xcscheme$" | head -n 1 || true)" [ -n "$picked" ] || picked="$(echo "$scheme_files" | grep "/${SCHEME}\.xcscheme$" | head -n 1)" fi [ -n "$picked" ] || picked="$(echo "$scheme_files" | grep "xcshareddata" | head -n 1)" [ -n "$picked" ] || picked="$(echo "$scheme_files" | head -n 1)" case "$picked" in *xcshareddata*) : ;; *) warn "Схема не shared — кнопка будет только на этой машине." warn "Manage Schemes → галка Shared, потом повтори install-button." ;; esac log "Схема: $picked" local pa_script pa_script='RS="${PROJECT_DIR}/scripts/release.sh"; [ -f "$RS" ] || RS="${PROJECT_DIR}/release.sh"; [ -f "$RS" ] || RS="$(command -v release)" bash "$RS" --from-archive "${ARCHIVE_PATH}" > /tmp/asc_upload.log 2>&1 \ && osascript -e '\''display notification "Билд ушёл в App Store Connect" with title "Release ✅"'\'' \ || osascript -e '\''display notification "Ошибка — смотри /tmp/asc_upload.log" with title "Release ❌"'\''' RESULT="$(python3 - "$picked" "$pa_script" <<'PYEOF' import sys, copy, xml.etree.ElementTree as ET path, script_text = sys.argv[1], sys.argv[2] tree = ET.parse(path) root = tree.getroot() aa = root.find('ArchiveAction') if aa is None: aa = ET.SubElement(root, 'ArchiveAction', {'buildConfiguration': 'Release', 'revealArchiveInOrganizer': 'YES'}) pa = aa.find('PostActions') if pa is None: pa = ET.Element('PostActions') aa.insert(0, pa) for ea in pa.findall('ExecutionAction'): ac = ea.find('ActionContent') if ac is not None and 'ARCHIVE_PATH' in (ac.get('scriptText') or ''): print('ALREADY') sys.exit(0) ea = ET.SubElement(pa, 'ExecutionAction', {'ActionType': 'Xcode.IDEStandardExecutionActionsCore.ExecutionActionType.ShellScriptAction'}) ac = ET.SubElement(ea, 'ActionContent', {'title': 'Upload to App Store Connect', 'scriptText': script_text}) br = root.find('.//BuildAction//BuildableReference') if br is not None: eb = ET.SubElement(ac, 'EnvironmentBuildable') eb.append(copy.deepcopy(br)) try: ET.indent(tree) except AttributeError: pass tree.write(path, xml_declaration=True, encoding='UTF-8') print('OK') PYEOF )" || RESULT="python3 упал — схема не изменена (битый .xcscheme?)" case "$RESULT" in OK) ok "Готово: Product → Archive теперь заливает сам (лог: /tmp/asc_upload.log)." log "Перезапусти Xcode, чтобы он перечитал схему." log "Issuer ID должен быть уже сохранён (один обычный запуск или --issuer)." ;; ALREADY) ok "Кнопка уже стоит — ничего не менял." ;; *) die "Не получилось изменить схему: $RESULT" ;; esac exit 0 } # --- meta-движок: notes/title/subtitle через ASC API (JWT этим же ключом) --- write_meta_py() { META_PY="$(mktemp "${TMPDIR:-/tmp}/release_meta.XXXXXX")" || die "mktemp не сработал" cat > "$META_PY" <<'PYEOF' import sys, os, re, json, time, base64, subprocess, urllib.request, urllib.error, urllib.parse KEY_PATH, KID, ISS, BUNDLE_ID, META_DIR, MODE = sys.argv[1:7] BASE = os.environ.get("ASC_API_BASE", "https://api.appstoreconnect.apple.com") SK_BASE = os.environ.get("ASC_STOREKIT_BASE", "https://api.storekit.itunes.apple.com") NAME_MAX, SUB_MAX, NOTES_MAX = 30, 30, 4000 EDITABLE = {"PREPARE_FOR_SUBMISSION", "METADATA_REJECTED", "DEVELOPER_REJECTED", "REJECTED", "INVALID_BINARY"} TTY = sys.stdout.isatty() def _c(code, s): return ("\033[" + code + "m" + s + "\033[0m") if TTY else s def i(m): print(_c("2", "[i]") + " " + m) def okm(m): print(_c("32", "[+]") + " " + m) def warn(m): print(_c("33", "[!]") + " " + m) def fail(m): print(_c("31", "[x]") + " " + m); sys.exit(1) def b64u(b): return base64.urlsafe_b64encode(b).decode().rstrip("=") def token(extra=None): now = int(time.time()) pl = {"iss": ISS, "iat": now - 30, "exp": now + 1140, "aud": "appstoreconnect-v1"} if extra: pl.update(extra) h = b64u(json.dumps({"alg": "ES256", "kid": KID, "typ": "JWT"}).encode()) p = b64u(json.dumps(pl).encode()) si = (h + "." + p).encode() try: der = subprocess.run(["openssl", "dgst", "-sha256", "-sign", KEY_PATH], input=si, stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=True).stdout except subprocess.CalledProcessError as e: fail("openssl не смог подписать JWT ключом " + KEY_PATH + ": " + e.stderr.decode().strip()) idx = 2 if der[1] & 0x80: idx = 2 + (der[1] & 0x7F) parts = [] for _ in range(2): if der[idx] != 2: fail("не разобрал подпись openssl (DER)") ln = der[idx + 1] parts.append(der[idx + 2:idx + 2 + ln].lstrip(b"\x00")) idx += 2 + ln sig = parts[0].rjust(32, b"\x00") + parts[1].rjust(32, b"\x00") return si.decode() + "." + b64u(sig) TOK, TOK_AT = None, 0.0 def api(method, path, body=None, soft=False): global TOK, TOK_AT # токен живёт ~19 мин — обновляем заранее, чтобы долгое меню не падало с 401 if TOK is None or time.time() - TOK_AT > 900: TOK = token() TOK_AT = time.time() req = urllib.request.Request(BASE + path, method=method) req.add_header("Authorization", "Bearer " + TOK) data = None if body is not None: req.add_header("Content-Type", "application/json") data = json.dumps(body).encode() try: with urllib.request.urlopen(req, data, timeout=60) as r: t = r.read() return json.loads(t) if t else {} except urllib.error.HTTPError as e: if soft: return None try: msgs = "; ".join((x.get("detail") or x.get("title") or "") for x in json.loads(e.read()).get("errors", [])) except Exception: msgs = "" fail("ASC API " + method + " " + path.split("?")[0] + " -> HTTP " + str(e.code) + ((": " + msgs) if msgs else "")) except urllib.error.URLError as e: if soft: return None fail("нет связи с ASC API: " + str(e.reason)) def ask(prompt): try: return input(prompt) except EOFError: return "" def short(s): if not s: return "(пусто)" s = s.replace("\n", " ") return s[:48] + ("…" if len(s) > 48 else "") def state_of(a): return a.get("appVersionState") or a.get("appStoreState") or a.get("state") or "?" def read_multiline(limit): print("Вводи текст (можно несколько строк). Закончи строкой с одной точкой '.'") lines = [] while True: try: ln = input() except EOFError: break if ln.strip() == ".": break lines.append(ln) text = "\n".join(lines).strip() if not text: fail("пустой текст — отменено") if len(text) > limit: warn("длина %d, лимит Apple %d — сократи" % (len(text), limit)) return read_multiline(limit) okm("принято: %d символов (лимит %d)" % (len(text), limit)) return text def pick_locales(rows, allow_new=True): print("") for n, (lc, pv) in enumerate(rows, 1): print(" %2d) %-10s %s" % (n, lc, pv)) extra = " n) новый язык" if allow_new else "" print(" a) все" + extra + " Enter) отмена") ans = ask("Выбор (номера через запятую): ").strip().lower() if not ans: return [] if ans == "a": return [lc for lc, _ in rows] if ans == "n" and allow_new: lc = ask("Код локали (en-US, ru, de-DE…): ").strip() return [lc] if lc else [] out = [] for t in ans.replace(" ", "").split(","): if t.isdigit() and 1 <= int(t) <= len(rows): out.append(rows[int(t) - 1][0]) return out def save_meta(locale, fname, text): d = os.path.join(META_DIR, locale) os.makedirs(d, exist_ok=True) with open(os.path.join(d, fname), "w") as f: f.write(text + "\n") def get_app(): d = api("GET", "/v1/apps?filter[bundleId]=" + BUNDLE_ID).get("data", []) if not d: fail("в этой учётке нет приложения с Bundle ID " + BUNDLE_ID + " — создай App в ASC") return d[0]["id"], (d[0]["attributes"].get("name") or "") def get_versions(app_id): return api("GET", "/v1/apps/" + app_id + "/appStoreVersions?filter[platform]=IOS&limit=20").get("data", []) def editable_version(app_id, interactive): for v in get_versions(app_id): if state_of(v["attributes"]) in EDITABLE: return v if not interactive: fail("нет редактируемой версии (Prepare for Submission) — создай её в меню или в ASC") ans = ask("Редактируемой версии нет. Создать? Введи номер (например 1.2.0), Enter — отмена: ").strip() if not ans: fail("отменено") if not all(p.isdigit() for p in ans.split(".") if p != ""): warn("номер версии выглядит странно — пробую как есть") v = api("POST", "/v1/appStoreVersions", {"data": {"type": "appStoreVersions", "attributes": {"platform": "IOS", "versionString": ans}, "relationships": {"app": {"data": {"type": "apps", "id": app_id}}}}})["data"] okm("Версия " + ans + " создана") return v def ver_locs(vid): return api("GET", "/v1/appStoreVersions/" + vid + "/appStoreVersionLocalizations?limit=50").get("data", []) def info_locs(iid): return api("GET", "/v1/appInfos/" + iid + "/appInfoLocalizations?limit=50").get("data", []) def get_appinfo(app_id): infos = api("GET", "/v1/apps/" + app_id + "/appInfos?limit=10").get("data", []) if not infos: fail("у приложения нет appInfo — очень странно") for x in infos: if state_of(x["attributes"]) in EDITABLE: return x return infos[-1] def apply_notes(app_id, per_locale, interactive): v = editable_version(app_id, interactive) vid = v["id"] i("Версия: " + (v["attributes"].get("versionString") or "?") + " (" + state_of(v["attributes"]) + ")") byloc = {l["attributes"]["locale"]: l["id"] for l in ver_locs(vid)} for lc, text in per_locale.items(): if len(text) > NOTES_MAX: fail(lc + ": release notes длиннее %d символов" % NOTES_MAX) if lc in byloc: api("PATCH", "/v1/appStoreVersionLocalizations/" + byloc[lc], {"data": {"type": "appStoreVersionLocalizations", "id": byloc[lc], "attributes": {"whatsNew": text}}}) else: api("POST", "/v1/appStoreVersionLocalizations", {"data": {"type": "appStoreVersionLocalizations", "attributes": {"locale": lc, "whatsNew": text}, "relationships": {"appStoreVersion": {"data": {"type": "appStoreVersions", "id": vid}}}}}) okm(lc + ": release notes обновлены") def apply_titles(app_id, per_locale): info = get_appinfo(app_id) byloc = {l["attributes"]["locale"]: l["id"] for l in info_locs(info["id"])} for lc, at in per_locale.items(): if "name" in at and len(at["name"]) > NAME_MAX: fail(lc + ": title длиннее %d символов" % NAME_MAX) if "subtitle" in at and len(at["subtitle"]) > SUB_MAX: fail(lc + ": subtitle длиннее %d символов" % SUB_MAX) if lc in byloc: api("PATCH", "/v1/appInfoLocalizations/" + byloc[lc], {"data": {"type": "appInfoLocalizations", "id": byloc[lc], "attributes": at}}) else: a = dict(at); a["locale"] = lc api("POST", "/v1/appInfoLocalizations", {"data": {"type": "appInfoLocalizations", "attributes": a, "relationships": {"appInfo": {"data": {"type": "appInfos", "id": info["id"]}}}}}) okm(lc + ": " + ", ".join(sorted(at.keys())) + " обновлено") def act_notes(app_id): v = editable_version(app_id, True) rows = [(l["attributes"]["locale"], short(l["attributes"].get("whatsNew"))) for l in ver_locs(v["id"])] sel = pick_locales(rows) if rows else [ask("Локализаций пока нет. Код локали (en-US, ru…): ").strip()] sel = [s for s in sel if s] if not sel: warn("отменено"); return print("Release notes для: " + ", ".join(sel)) text = read_multiline(NOTES_MAX) if ask("Применить к %d локал. [y/N]: " % len(sel)).strip().lower() not in ("y", "yes", "да", "д"): warn("отменено"); return apply_notes(app_id, {lc: text for lc in sel}, True) for lc in sel: save_meta(lc, "release_notes.txt", text) i("Сохранил и в metadata/<локаль>/release_notes.txt — поедет в репе") def act_title(app_id): info = get_appinfo(app_id) rows = [(l["attributes"]["locale"], (l["attributes"].get("name") or "—") + " / " + (l["attributes"].get("subtitle") or "—")) for l in info_locs(info["id"])] sel = pick_locales(rows) if not sel: warn("отменено"); return name = ask("Новый title (Enter — не менять, лимит %d): " % NAME_MAX).strip() while len(name) > NAME_MAX: warn("длина %d — лимит %d" % (len(name), NAME_MAX)) name = ask("Новый title: ").strip() sub = ask("Новый subtitle (Enter — не менять, '-' — очистить, лимит %d): " % SUB_MAX).strip() while sub != "-" and len(sub) > SUB_MAX: warn("длина %d — лимит %d" % (len(sub), SUB_MAX)) sub = ask("Новый subtitle: ").strip() at = {} if name: at["name"] = name if sub == "-": at["subtitle"] = "" elif sub: at["subtitle"] = sub if not at: warn("нечего менять"); return plan = " и ".join(sorted(at.keys())) if ask("Меняем %s у %d локал. [y/N]: " % (plan, len(sel))).strip().lower() not in ("y", "yes", "да", "д"): warn("отменено"); return apply_titles(app_id, {lc: dict(at) for lc in sel}) for lc in sel: if "name" in at: save_meta(lc, "name.txt", at["name"]) if "subtitle" in at: save_meta(lc, "subtitle.txt", at["subtitle"]) def act_show(app_id, app_name): print("") print("Версии:") for v in get_versions(app_id): a = v["attributes"] print(" %-8s %s" % (a.get("versionString") or "?", state_of(a))) info = get_appinfo(app_id) print("Title / Subtitle:") for l in info_locs(info["id"]): a = l["attributes"] print(" %-10s %s / %s" % (a["locale"], a.get("name") or "—", a.get("subtitle") or "—")) vs = [v for v in get_versions(app_id) if state_of(v["attributes"]) in EDITABLE] if vs: print("Release notes (%s):" % (vs[0]["attributes"].get("versionString") or "?")) for l in ver_locs(vs[0]["id"]): print(" %-10s %s" % (l["attributes"]["locale"], short(l["attributes"].get("whatsNew")))) def act_push(app_id): if not os.path.isdir(META_DIR): fail("папки metadata/ нет. Структура: metadata/<локаль>/release_notes.txt|name.txt|subtitle.txt") notes, titles = {}, {} for lc in sorted(os.listdir(META_DIR)): d = os.path.join(META_DIR, lc) if not os.path.isdir(d) or lc.startswith("."): continue p = os.path.join(d, "release_notes.txt") if os.path.isfile(p): notes[lc] = open(p).read().strip() at = {} for f, k in (("name.txt", "name"), ("subtitle.txt", "subtitle")): fp = os.path.join(d, f) if os.path.isfile(fp): at[k] = open(fp).read().strip() if at: titles[lc] = at if not notes and not titles: fail("в metadata/ не нашлось ни release_notes.txt, ни name/subtitle.txt") print("План:") for lc, t in notes.items(): print(" %-10s release notes, %d симв." % (lc, len(t))) for lc, at in titles.items(): print(" %-10s %s" % (lc, ", ".join(sorted(at.keys())))) if sys.stdin.isatty(): if ask("Заливаем? [y/N]: ").strip().lower() not in ("y", "yes", "да", "д"): fail("отменено") if notes: apply_notes(app_id, notes, sys.stdin.isatty()) if titles: apply_titles(app_id, titles) okm("Метаданные залиты") # --- JSON: {"en-US": "..."} или {"release_notes": {...}, "title": {...}, "subtitle": {...}} --- def strip_fences(s): lines = [l for l in s.splitlines() if not l.strip().startswith("```")] return "\n".join(lines).strip() def parse_meta_json(obj): if not isinstance(obj, dict): fail('ожидал JSON-объект вида {"en-US": "текст", "ru": "текст"}') notes, titles = {}, {} special = {"release_notes", "notes", "whatsNew", "title", "name", "subtitle"} if special & set(obj.keys()): for k in ("release_notes", "notes", "whatsNew"): if isinstance(obj.get(k), dict): notes.update(obj[k]) for k in ("title", "name"): if isinstance(obj.get(k), dict): for lc, v in obj[k].items(): titles.setdefault(lc, {})["name"] = str(v).strip() if isinstance(obj.get("subtitle"), dict): for lc, v in obj["subtitle"].items(): titles.setdefault(lc, {})["subtitle"] = str(v).strip() else: notes = dict(obj) clean = {} for lc, v in notes.items(): if not isinstance(v, str) or not v.strip(): warn(lc + ": пустой текст — пропускаю") continue v = v.strip() if not re.match(r"^[a-z]{2,3}(-[A-Za-z0-9]{2,6})?$", lc): warn("странный код локали: '" + lc + "' (жду вида en-US, ru, de-DE)") if len(v) > NOTES_MAX: fail(lc + ": release notes %d символов — лимит Apple %d" % (len(v), NOTES_MAX)) clean[lc] = v for lc, at in titles.items(): if "name" in at and len(at["name"]) > NAME_MAX: fail(lc + ": title '%s' — %d символов, лимит %d" % (at["name"], len(at["name"]), NAME_MAX)) if "subtitle" in at and len(at["subtitle"]) > SUB_MAX: fail(lc + ": subtitle — %d символов, лимит %d" % (len(at["subtitle"]), SUB_MAX)) return clean, titles def act_json_apply(app_id, obj, interactive): notes, titles = parse_meta_json(obj) if not notes and not titles: fail("в JSON не нашлось ни release notes, ни title/subtitle") print("План:") for lc, t in sorted(notes.items()): print(" %-10s notes %4d симв. %s" % (lc, len(t), short(t))) for lc, at in sorted(titles.items()): print(" %-10s " % lc + "; ".join("%s: %s" % (k, v) for k, v in sorted(at.items()))) if interactive and sys.stdin.isatty(): if ask("Заливаем? [y/N]: ").strip().lower() not in ("y", "yes", "да", "д"): fail("отменено") if notes: apply_notes(app_id, notes, interactive) if titles: apply_titles(app_id, titles) for lc, t in notes.items(): save_meta(lc, "release_notes.txt", t) for lc, at in titles.items(): if "name" in at: save_meta(lc, "name.txt", at["name"]) if "subtitle" in at: save_meta(lc, "subtitle.txt", at["subtitle"]) okm("Метаданные залиты (копия — в metadata/, поедет в репе)") def act_json_paste(optional=False): if optional: print("Вставь JSON (ответ GPT). Пустая строка сразу — пропустить:") else: print("Вставь JSON (ответ GPT) — как только он станет валидным, сам продолжу:") buf = "" while True: try: ln = input() except EOFError: break if optional and not buf.strip() and not ln.strip(): return None buf += ln + "\n" s = strip_fences(buf) if s.startswith("{") and s.rstrip().endswith("}"): try: return json.loads(s) except ValueError: pass if len(buf) > 300000: fail("слишком длинно — это точно JSON?") try: return json.loads(strip_fences(buf)) except ValueError: fail("не разобрал JSON. Нужен чистый объект { ... } — без пояснений вокруг") def act_prompt(app_id): locs = [] for v in get_versions(app_id): if state_of(v["attributes"]) in EDITABLE: locs = [l["attributes"]["locale"] for l in ver_locs(v["id"])] break if not locs: locs = [l["attributes"]["locale"] for l in info_locs(get_appinfo(app_id)["id"])] if not locs: locs = ["en-US"] sample = ", ".join('"%s": "..."' % l for l in locs) print("") print("──── промт для GPT: скопируй всё между линиями, впиши свою фразу в конце ────") print("") print("Напиши release notes (раздел \"What's New\" в App Store) для мобильного") print("приложения на каждом из языков: " + ", ".join(locs) + ".") print("Для каждого языка — живой естественный текст на этом языке (адаптация,") print("не дословный перевод), 2-4 коротких пункта с новой строки, без markdown,") print("без эмодзи, до 500 символов на язык.") print("Ответь ТОЛЬКО валидным JSON без пояснений и без ``` — ровно такой структуры:") print("{" + sample + "}") print("") print("Суть обновления: <<< СЮДА СВОЮ ФРАЗУ >>>") print("") print("──────────────────────────── конец промта ────────────────────────────") print("") i("Ответ GPT потом вставь сюда: bash scripts/release.sh meta json") i("или сохрани в файл и запусти: bash scripts/release.sh meta notes.json") # --- health-check: что не пустит в стор --- def act_health(app_id): n = {"bad": 0, "warn": 0} def hb(m): n["bad"] += 1; print(_c("31", " ✖ ") + m) def hw(m): n["warn"] += 1; print(_c("33", " ⚠ ") + m) def hok(m): print(_c("32", " ✓ ") + m) def sect(t): print(""); print(_c("1", "── " + t + " ──")) sect("Версия") vs = get_versions(app_id) ed = None for v in vs: if state_of(v["attributes"]) in EDITABLE: ed = v break prev_released = any(state_of(v["attributes"]) in ("READY_FOR_SALE", "READY_FOR_DISTRIBUTION", "REPLACED_WITH_NEW_VERSION", "PENDING_APPLE_RELEASE", "PROCESSING_FOR_APP_STORE") for v in vs) if ed: hok("редактируемая версия: %s (%s)" % (ed["attributes"].get("versionString") or "?", state_of(ed["attributes"]))) else: hw("нет редактируемой версии — создай в ASC или через меню notes") vlocs = ver_locs(ed["id"]) if ed else [] if vlocs: hok("локали версии: " + ", ".join(sorted(l["attributes"]["locale"] for l in vlocs))) elif ed: hw("у версии нет ни одной локализации") sect("Билд") b = api("GET", "/v1/builds?filter[app]=" + app_id + "&sort=-uploadedDate&limit=1", soft=True) bd = (b or {}).get("data") or [] if not bd: hw("билдов не видно — если только что залил, подожди processing (5–30 мин)") else: a = bd[0]["attributes"] st = a.get("processingState") or "?" (hok if st == "VALID" else hw)("последний билд: %s (%s)" % (a.get("version") or "?", st)) if a.get("expired"): hw("билд протух (expired) — залей свежий") if a.get("usesNonExemptEncryption") is None: hw("encryption не указан — билд повиснет в Missing Compliance (ITSAppUsesNonExemptEncryption в Info.plist)") if ed: vb = api("GET", "/v1/appStoreVersions/" + ed["id"] + "/build", soft=True) if (vb or {}).get("data"): hok("билд прикреплён к версии") else: hw("к версии не прикреплён билд — после processing выбери его в версии") sect("Метаданные версии (по локалям)") for l in vlocs: a = l["attributes"] lc = a["locale"] blockers, probs = [], [] if len((a.get("description") or "").strip()) < 10: blockers.append("description") if not (a.get("keywords") or "").strip(): blockers.append("keywords") if not (a.get("supportUrl") or "").strip(): blockers.append("support URL") shots = 0 sets = api("GET", "/v1/appStoreVersionLocalizations/" + l["id"] + "/appScreenshotSets?limit=50", soft=True) for s in (sets or {}).get("data") or []: ss = api("GET", "/v1/appScreenshotSets/" + s["id"] + "/appScreenshots?limit=1", soft=True) shots += len((ss or {}).get("data") or []) if shots == 0: blockers.append("скриншотов") if not (a.get("whatsNew") or "").strip(): probs.append("release notes") if not blockers and not probs: hok(lc + ": описание, keywords, support URL, скриншоты, notes — всё есть") for x in blockers: hb(lc + ": нет " + x) for x in probs: (hb if prev_released else hw)(lc + ": нет " + x + ("" if prev_released else " (для первой версии не обязательно)")) sect("App Info") info = get_appinfo(app_id) cat = api("GET", "/v1/appInfos/" + info["id"] + "/primaryCategory", soft=True) if (cat or {}).get("data"): hok("категория: " + str(cat["data"].get("id"))) else: hb("основная категория не выбрана (App Information)") no_pp = [l["attributes"]["locale"] for l in info_locs(info["id"]) if not (l["attributes"].get("privacyPolicyUrl") or "").strip()] if no_pp: hw("нет privacy policy URL: " + ", ".join(sorted(no_pp))) else: hok("privacy policy URL на всех локалях") app = api("GET", "/v1/apps/" + app_id, soft=True) crd = (((app or {}).get("data") or {}).get("attributes") or {}).get("contentRightsDeclaration") if crd: hok("content rights: " + crd) else: hw("content rights declaration не указана (App Information)") sect("App Privacy (декларация о данных)") pub = api("GET", "/v1/apps/" + app_id + "/dataUsagePublishState", soft=True) at = ((pub or {}).get("data") or {}).get("attributes") or {} if at.get("published"): hok("опубликована — вопросов про данные не будет") elif pub is None: hw("не удалось прочитать через API — проверь вкладку App Privacy руками") else: hb("НЕ опубликована — стор не пустит сабмит (App Privacy → Get Started)") sect("Подписки и покупки") total = 0 groups = api("GET", "/v1/apps/" + app_id + "/subscriptionGroups?limit=50", soft=True) for g in (groups or {}).get("data") or []: subs = api("GET", "/v1/subscriptionGroups/" + g["id"] + "/subscriptions?limit=50", soft=True) for s in (subs or {}).get("data") or []: total += 1 a = s["attributes"] st = a.get("state") or "?" shot = api("GET", "/v1/subscriptions/" + s["id"] + "/appStoreReviewScreenshot", soft=True) has = bool((shot or {}).get("data")) msg = "%s (%s): %s, %s" % (a.get("name") or "?", a.get("productId") or "?", st, "скриншот есть" if has else "скриншота НЕТ") if st == "APPROVED" and has: hok(msg) elif st in ("MISSING_METADATA", "DEVELOPER_ACTION_NEEDED", "REJECTED") or not has: hw(msg) else: hok(msg) iaps = api("GET", "/v1/apps/" + app_id + "/inAppPurchasesV2?limit=50", soft=True) for p_ in (iaps or {}).get("data") or []: total += 1 a = p_["attributes"] st = a.get("state") or "?" shot = api("GET", "/v2/inAppPurchases/" + p_["id"] + "/appStoreReviewScreenshot", soft=True) has = bool((shot or {}).get("data")) msg = "%s (IAP): %s, %s" % (a.get("name") or a.get("productId") or "?", st, "скриншот есть" if has else "скриншота НЕТ") if st == "APPROVED" and has: hok(msg) elif st in ("MISSING_METADATA", "DEVELOPER_ACTION_NEEDED", "REJECTED") or not has: hw(msg) else: hok(msg) if total == 0: i("подписок/покупок не найдено") sect("Цена") pr = api("GET", "/v1/apps/" + app_id + "/appPriceSchedule/manualPrices?limit=1", soft=True) if (pr or {}).get("data"): hok("цена настроена") else: hw("цена не читается — проверь Pricing and Availability") sect("App Store Server Notifications") resp, err_txt = None, "" try: req = urllib.request.Request(SK_BASE + "/inApps/v1/notifications/test", method="POST") req.add_header("Authorization", "Bearer " + token({"bid": BUNDLE_ID})) with urllib.request.urlopen(req, b"", timeout=20) as r: resp = json.loads(r.read() or b"{}") except urllib.error.HTTPError as e: try: err_txt = e.read().decode("utf-8", "replace")[:200] except Exception: err_txt = str(e.code) except Exception as e: err_txt = str(e)[:100] if resp is not None and resp.get("testNotificationToken"): hok("настроены (отправил тестовое уведомление на твой сервер)") elif "4040007" in err_txt or "url" in err_txt.lower(): hw("похоже, URL уведомлений не настроен (App Information → App Store Server Notifications)") else: i("через API не проверилось — глянь App Information → App Store Server Notifications") sect("Только глазами (API это не отдаёт)") i("Business → Agreements / Tax / Banking: жёлтые баннеры видны только в вебе ASC") i("Возрастной рейтинг (Age Rating) — App Information") print("") if n["bad"]: print(_c("1;31", "Итог: блокеров %d, предупреждений %d — в таком виде в стор НЕ пустит." % (n["bad"], n["warn"]))) elif n["warn"]: print(_c("1;33", "Итог: блокеров нет, предупреждений %d — сабмит скорее всего пройдёт." % n["warn"])) else: print(_c("1;32", "Итог: всё чисто — можно сабмитить 🎉")) return n["bad"] # --- обёртка: одна упавшая операция не убивает меню/визард --- def _safe(fn, *a): try: fn(*a) except SystemExit as e: if not e.code: raise warn("шаг не выполнен (см. выше) — остальное продолжаю") def act_menu_json(app_id): act_json_apply(app_id, act_json_paste(), True) # --- one-shot: `meta notes "текст"` — сразу во все языки --- def act_notes_oneshot(app_id, text): text = text.replace("\\n", "\n").strip() if not text: fail("пустой текст") if len(text) > NOTES_MAX: fail("текст %d символов — лимит Apple %d" % (len(text), NOTES_MAX)) v = editable_version(app_id, sys.stdin.isatty()) locs = [l["attributes"]["locale"] for l in ver_locs(v["id"])] if not locs: locs = ["en-US"] print("Текст (%d симв.) встанет во все языки: %s" % (len(text), ", ".join(locs))) for ln in text.splitlines(): print(" " + ln) if sys.stdin.isatty(): if ask("Заливаем? [Y/n]: ").strip().lower() in ("n", "no", "нет"): fail("отменено — ничего не залито") apply_notes(app_id, dict((lc, text) for lc in locs), sys.stdin.isatty()) for lc in locs: save_meta(lc, "release_notes.txt", text) okm("Улетело 🚀 (копия — в metadata/)") # --- перед заливкой: один текст на все языки --- def act_notes_all(app_id): v = editable_version(app_id, True) locs = [l["attributes"]["locale"] for l in ver_locs(v["id"])] if not locs: locs = ["en-US"] print("Один текст (обычно английский) встанет во все языки: " + ", ".join(locs)) text = read_multiline(NOTES_MAX) if ask("Применить к %d локал. [y/N]: " % len(locs)).strip().lower() not in ("y", "yes", "да", "д"): warn("отменено"); return apply_notes(app_id, dict((lc, text) for lc in locs), True) for lc in locs: save_meta(lc, "release_notes.txt", text) i("Копия — в metadata/<локаль>/release_notes.txt") # --- автоперевод: бесплатные переводчики без ключей (не GPT) --- def iso_of(lc): if lc == "zh-Hans": return "zh-CN" if lc == "zh-Hant": return "zh-TW" return lc.split("-")[0] def _http_json(url): req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0 (release.sh)"}) with urllib.request.urlopen(req, timeout=20) as r: return json.loads(r.read().decode("utf-8", "replace")) def translate_text(text, tgt): # 1) Google translate (публичный эндпоинт, без ключа) try: u = ("https://translate.googleapis.com/translate_a/single?client=gtx&sl=en&tl=" + urllib.parse.quote(tgt) + "&dt=t&q=" + urllib.parse.quote(text)) data = _http_json(u) out = "".join(seg[0] for seg in data[0] if seg and seg[0]) if out.strip(): return out.strip() except Exception: pass # 2) MyMemory (запасной, построчно) try: outs = [] for line in text.splitlines(): if not line.strip(): outs.append(line) continue u = ("https://api.mymemory.translated.net/get?q=" + urllib.parse.quote(line) + "&langpair=en%7C" + urllib.parse.quote(tgt)) data = _http_json(u) t = ((data.get("responseData") or {}).get("translatedText") or "").strip() if not t or "MYMEMORY" in t.upper(): raise ValueError("empty") outs.append(t) out = "\n".join(outs).strip() if out: return out except Exception: pass return None # --- автоперевод notes: пишешь по-английски, переводится на все языки --- def act_notes_translate(app_id): v = editable_version(app_id, True) locs = [l["attributes"]["locale"] for l in ver_locs(v["id"])] if not locs: locs = ["en-US"] print("Автоперевод без GPT (Google → запасной MyMemory). Языки: " + ", ".join(locs)) print("Пиши release notes ПО-АНГЛИЙСКИ:") text = read_multiline(NOTES_MAX) per, failed = {}, [] for lc in locs: tgt = iso_of(lc) if tgt == "en": per[lc] = text continue sys.stdout.write(" перевожу %-8s… " % lc) sys.stdout.flush() t = translate_text(text, tgt) if t is None or len(t) > NOTES_MAX: failed.append(lc) per[lc] = text print("не вышло — поставлю английский") else: per[lc] = t print("ок") print("") print("Проверь переводы перед заливкой:") for lc in locs: mark = " (перевод не удался — английский)" if lc in failed else "" print("── " + lc + mark) for ln in per[lc].splitlines(): print(" " + ln) if failed: warn("не перевелись: " + ", ".join(failed) + " — туда встанет английский текст") if ask("Заливаем на %d локал.? [y/N]: " % len(locs)).strip().lower() not in ("y", "yes", "да", "д"): warn("отменено — ничего не залито") return apply_notes(app_id, per, True) for lc in locs: save_meta(lc, "release_notes.txt", per[lc]) i("Копия — в metadata/<локаль>/release_notes.txt") # --- перед заливкой: промт для GPT → сразу вставить JSON --- def act_notes_gpt(app_id): act_prompt(app_id) print("Когда GPT ответит — вставь JSON прямо сюда.") obj = act_json_paste(optional=True) if obj is None: i("Ок, без notes. Вставить позже: bash release.sh meta json") return act_json_apply(app_id, obj, True) # --- визард перед заливкой --- def act_prerelease(want): if "title" in want: print("") i("── Title / Subtitle ──") _safe(act_title, app_id) if "notes" in want: print("") print("Release notes (What's New):") print(" 1) один текст (обычно английский) — во все языки") print(" 2) автоперевод: пишешь по-английски — переведу на все языки (без GPT)") print(" 3) через GPT: промт со всеми языками → вставишь JSON с переводами") print(" Enter) пропустить") c = ask("> ").strip() if c == "1": _safe(act_notes_all, app_id) elif c == "2": _safe(act_notes_translate, app_id) elif c == "3": _safe(act_notes_gpt, app_id) else: i("release notes не трогаю") try: app_id, app_name = get_app() print("") print(_c("1;36", "══════ 🚀 ZaliVATorr · метаданные ══════")) i("Приложение: " + (app_name or "?") + " · " + BUNDLE_ID) if MODE == "push": act_push(app_id) sys.exit(0) if MODE == "prompt": act_prompt(app_id) sys.exit(0) if MODE == "json": act_json_apply(app_id, act_json_paste(), True) sys.exit(0) if MODE.startswith("prerelease"): act_prerelease(MODE.split(":", 1)[1] if ":" in MODE else "title,notes") sys.exit(0) if MODE == "health": sys.exit(1 if act_health(app_id) else 0) if MODE == "title": act_title(app_id) sys.exit(0) if MODE == "notes": txt = os.environ.get("RELEASE_NOTES_TEXT", "").strip() if txt: act_notes_oneshot(app_id, txt) else: act_notes_all(app_id) sys.exit(0) if MODE.endswith(".json"): if not os.path.isfile(MODE): fail("файла нет: " + MODE) try: obj = json.loads(strip_fences(open(MODE).read())) except ValueError as e: fail("битый JSON в " + MODE + ": " + str(e)) act_json_apply(app_id, obj, sys.stdin.isatty()) sys.exit(0) while True: print("") print(" 1) 📝 Release notes (What's New)") print(" 2) 🏷 Title / Subtitle") print(" 3) 👀 Показать текущее в ASC") print(" 4) 📤 Залить папку metadata/ целиком") print(" 5) 📋 Вставить JSON-ответ GPT") print(" 6) 🤖 Промт для GPT (notes на все языки)") print(" 7) 🌍 Автоперевод notes: англ → все языки (без GPT)") print(" q) Выход") c = ask("> ").strip().lower() if c == "1": _safe(act_notes, app_id) elif c == "2": _safe(act_title, app_id) elif c == "3": _safe(act_show, app_id, app_name) elif c == "4": _safe(act_push, app_id) elif c == "5": _safe(act_menu_json, app_id) elif c == "6": _safe(act_prompt, app_id) elif c == "7": _safe(act_notes_translate, app_id) elif c in ("q", "й", ""): print("Пока! 👋") break except KeyboardInterrupt: print("") warn("прервано (Ctrl-C) — ничего не сломано") sys.exit(130) PYEOF } # --- meta: подготовить ключ/issuer/bundle и запустить нужное действие --- run_meta_action() { command -v python3 >/dev/null 2>&1 || { err "Нужен python3 (идёт с Xcode)"; return 1; } command -v openssl >/dev/null 2>&1 || { err "Нужен openssl (есть на любом маке)"; return 1; } find_key || paste_key_wizard || { err "Ключ не найден. Скачай .p8 в ASC — подхвачу даже из ~/Downloads."; return 1; } adopt_key ensure_issuer if ! detect_bundle_id; then if [ -t 0 ] || [ -n "${RELEASE_FORCE_INTERACTIVE:-}" ]; then printf "Bundle ID приложения (com.company.app): " read -r BUNDLE_ID || true BUNDLE_ID="$(echo "${BUNDLE_ID:-}" | tr -d '[:space:]')" [ -n "$BUNDLE_ID" ] || { err "Bundle ID пустой."; return 1; } else err "Bundle ID не определился — задай BUNDLE_ID в $CONFIG_FILE"; return 1 fi fi save_config write_meta_py local rc=0 RELEASE_NOTES_TEXT="${NOTES_TEXT:-}" python3 -u "$META_PY" \ "$KEY_PATH" "$API_KEY_ID" "$API_ISSUER_ID" "$BUNDLE_ID" "$REPO_ROOT/metadata" "$1" || rc=$? rm -f "$META_PY" return "$rc" } # --- meta: команда `release.sh meta ...` --- run_meta() { local rc=0 run_meta_action "${META_ARG:-menu}" || rc=$? exit "$rc" } # --- перед заливкой: спросить про title/subtitle и release notes --- maybe_meta_wizard() { { [ "$MODE" = "full" ] || [ "$MODE" = "from-archive" ]; } || return 0 local a want="" printf "Поменять title/subtitle в App Store? [y/N]: " read -r a || a="" case "$a" in y|Y|yes|да|Да|д|Д) want="title" ;; esac printf "Обновить release notes (What's New)? [y/N]: " read -r a || a="" case "$a" in y|Y|yes|да|Да|д|Д) want="${want:+$want,}notes" ;; esac [ -n "$want" ] || return 0 command -v python3 >/dev/null 2>&1 || { warn "python3 не найден — метаданные пропускаю"; return 0; } command -v openssl >/dev/null 2>&1 || { warn "openssl не найден — метаданные пропускаю"; return 0; } if ! detect_bundle_id; then printf "Bundle ID приложения (com.company.app): " read -r BUNDLE_ID || true BUNDLE_ID="$(echo "${BUNDLE_ID:-}" | tr -d '[:space:]')" if [ -z "$BUNDLE_ID" ]; then warn "Bundle ID пустой — метаданные пропускаю"; return 0; fi save_config fi write_meta_py local rc=0 python3 -u "$META_PY" "$KEY_PATH" "$API_KEY_ID" "$API_ISSUER_ID" "$BUNDLE_ID" "$REPO_ROOT/metadata" "prerelease:$want" || rc=$? rm -f "$META_PY" if [ "$rc" -ne 0 ]; then warn "Метаданные не обновились (код $rc). Их можно залить и после: bash $0 meta" printf "Продолжить сборку и заливку без них? [Y/n]: " read -r a || a="" case "$a" in n|N|no|нет|Нет) die "Отменено — ничего не залито." ;; esac fi return 0 } # --- прогресс: что сейчас делает xcodebuild (по хвосту лога) --- phase_label() { local last last="$(tail -c 120000 "$LOG_FILE" 2>/dev/null \ | grep -E '^(SwiftDriver|SwiftCompile|SwiftEmitModule|CompileC|CompileSwiftSources|CompileAssetCatalog|CompileStoryboard|CompileXIB|CpResource|CopySwiftLibs|Ld|CreateUniversalBinary|GenerateDSYMFile|Strip|CodeSign|ProcessProductPackaging|ProcessInfoPlistFile|PhaseScriptExecution|Touch) ' \ | tail -n 1 | awk '{print $1}' || true)" case "$last" in SwiftDriver|SwiftCompile|SwiftEmitModule|CompileC|CompileSwiftSources) echo "компилирую код" ;; CompileAssetCatalog|CompileStoryboard|CompileXIB|CpResource|CopySwiftLibs) echo "собираю ресурсы" ;; Ld|CreateUniversalBinary) echo "линкую" ;; GenerateDSYMFile|Strip) echo "готовлю dSYM" ;; CodeSign|ProcessProductPackaging) echo "подписываю" ;; ProcessInfoPlistFile|PhaseScriptExecution|Touch) echo "финальные шаги" ;; *) echo "" ;; esac } # --- спиннер: команда пишет в лог, терминал видит живой прогресс --- SPIN_ELAPSED="00:00" run_with_spinner() { local label="$1"; shift "$@" >> "$LOG_FILE" 2>&1 & local pid=$! trap 'kill "$pid" 2>/dev/null' INT TERM local start now el ph start="$(date +%s)" if [ "$HAVE_TTY" = "1" ]; then local frames=('⠋' '⠙' '⠹' '⠸' '⠼' '⠴' '⠦' '⠧' '⠇' '⠏') n=0 while kill -0 "$pid" 2>/dev/null; do now="$(date +%s)"; el=$((now - start)) ph="$(phase_label)" printf '\r\033[K \033[36m%s\033[0m \033[2m%02d:%02d\033[0m %s%s' \ "${frames[$((n % 10))]}" "$((el / 60))" "$((el % 60))" "$label" "${ph:+ · $ph}" > /dev/tty n=$((n + 1)) sleep 0.5 done printf '\r\033[K' > /dev/tty fi local rc=0 wait "$pid" || rc=$? trap - INT TERM now="$(date +%s)"; el=$((now - start)) SPIN_ELAPSED="$(printf '%02d:%02d' "$((el / 60))" "$((el % 60))")" return "$rc" } # --- главное меню (запуск без аргументов в терминале) --- main_menu() { local c p while true; do echo "" echo "${C_C}════════ 🚀 ZaliVATorr ════════${C_X}" echo " 1) 🚀 Собрать и залить (полный цикл; title/notes предложит по пути)" echo " 2) 🏷 Title / Subtitle (Enter — оставить текущий)" echo " 3) 📝 Release notes (один текст / автоперевод / переводы через GPT)" echo " 4) 🩺 Doctor — проверить машину" echo " 5) 📦 Собрать .ipa без заливки" echo " 6) ☁️ Дозалить готовый .xcarchive" echo " 7) 📜 Логи последней сборки" echo " 8) 🏥 Health-check App Store — что не пустит в стор" echo " 9) 🧰 Метаданные ASC: всё меню (текущее, JSON от GPT, metadata/)" echo " q) Выход" printf "> " read -r c || c="q" case "$(echo "$c" | tr -d '[:space:]')" in 1) MODE="full"; return 0 ;; 2) run_meta_action "title" || true ;; 3) run_meta_action "prerelease:notes" || true ;; 4) ( run_doctor ) || true ;; 5) MODE="ipa-only"; return 0 ;; 6) printf "Путь к .xcarchive (можно перетащить папку сюда): " read -r p || p="" p="$(clean_path "$p")" if [ -d "$p" ]; then MODE="from-archive"; EXISTING_ARCHIVE="$p"; return 0 else err "Архив не найден: ${p:-—}" fi ;; 7) ( run_logs ) || true ;; 8) run_meta_action "health" || true ;; 9) run_meta_action "menu" || true ;; q|Q|й|"") echo "Пока! 👋"; exit 0 ;; *) warn "Не понял «$c» — введи 1-9 или q" ;; esac done } # ================== поехали ================== # без аргументов в живом терминале — меню; в автоматике — сразу полный цикл if [ "$ARG_COUNT" -eq 0 ] && { [ -t 0 ] || [ -n "${RELEASE_FORCE_INTERACTIVE:-}" ]; }; then main_menu fi if [ "$MODE" = "logs" ]; then run_logs; fi if [ "$MODE" = "doctor" ]; then run_doctor; fi if [ "$MODE" = "install-global" ]; then install_global; fi if [ "$MODE" = "install-button" ]; then install_button; fi if [ "$MODE" = "meta" ]; then run_meta; fi # заливка только с виртуалки if [ "$VM_GUARD" = "YES" ] && { [ "$MODE" = "full" ] || [ "$MODE" = "from-archive" ]; } && ! is_vm; then die "Похоже, это НЕ виртуалка (hv_vmm_present: $(sysctl_n kern.hv_vmm_present | grep . || echo '-'), hw.model: $(sysctl_n hw.model | grep . || echo '-')). Заливку отменяю. (Осознанно с этой машины — VM_GUARD=\"NO\" в скрипте)" fi # проверка Xcode case "$(xcode-select -p 2>/dev/null)" in *CommandLineTools*) die "Стоят только Command Line Tools — для сборки нужен полный Xcode: sudo xcode-select -s /Applications/Xcode.app" ;; esac xcodebuild -version >/dev/null 2>&1 || die "xcodebuild не отвечает. Обычно лечится так: sudo xcodebuild -license accept (и xcodebuild -runFirstLaunch на свежей машине)" detect_container || die "Тут нет .xcodeproj/.xcworkspace — запусти из папки проекта (сейчас: $REPO_ROOT)" detect_scheme || die "Схему определить не удалось — впиши SCHEME в $CONFIG_FILE (список: xcodebuild -list)" detect_team_id || die "В проекте не выбрана команда подписи — Xcode → Signing & Capabilities → Team (или впиши TEAM_ID в $CONFIG_FILE)" find_key || paste_key_wizard || die "Ключ не найден. Скачай .p8 в ASC (Users and Access → Integrations) — подхвачу даже из ~/Downloads." adopt_key ensure_issuer save_config # setup: настроить и выйти if [ "$MODE" = "setup" ]; then ok "Готово. Чтобы другие машины не спрашивали ничего, закоммить в приватную репу:" echo " git add private_keys ${CONFIG_FILE#$REPO_ROOT/} && git commit -m 'release kit'" exit 0 fi detect_export_method mkdir -p "$BUILD_DIR" || die "Не могу создать $BUILD_DIR — нет прав на запись в папке проекта" TS="$(date +%Y%m%d-%H%M%S)" LOG_FILE="$BUILD_DIR/release-$TS.log" # сводка — до лога, в цвете: видно, ЧТО и КУДА поедет, пока не поздно echo "" echo "${C_C}════════ 🚀 ZaliVATorr ════════${C_X}" log "Проект: $(basename "$CONTAINER_PATH")" log "Схема: $SCHEME ($CONFIGURATION), Team: $TEAM_ID" log "Ключ: $KEY_PATH (Key ID: $API_KEY_ID)" log "Issuer: $API_ISSUER_ID" log "Машина: $(is_vm && echo "виртуалка ✓" || echo "НЕ виртуалка") ($(sysctl_n hw.model | grep . || echo '?'))" log "Лог: $LOG_FILE" echo "${C_C}═══════════════════════════════${C_X}" # подтверждение + метаданные (title/subtitle/release notes) — до сборки if [ "$MODE" != "ipa-only" ]; then if [ -t 0 ] || [ -n "${RELEASE_FORCE_INTERACTIVE:-}" ]; then printf "Продолжить залив? [Enter/y — да, n — отмена]: " read -r CONFIRM || CONFIRM="" case "$CONFIRM" in ""|y|Y|yes|Yes|да|Да) echo "" ;; *) die "Отменено — ничего не залито." ;; esac maybe_meta_wizard fi fi exec > >(tee -a "$LOG_FILE") 2>&1 # дальше всё дублируется в лог C_G=""; C_Y=""; C_R=""; C_C=""; C_D=""; C_X="" # в лог — без ANSI { echo "════════ ZaliVATorr $TS ════════" echo "Проект: $CONTAINER_PATH" echo "Схема: $SCHEME ($CONFIGURATION), Team: $TEAM_ID" echo "Ключ: $KEY_PATH (Key ID: $API_KEY_ID), Issuer: $API_ISSUER_ID" } >> "$LOG_FILE" # шаг 1: архив if [ "$MODE" = "from-archive" ]; then ARCHIVE_PATH="$EXISTING_ARCHIVE" [ -d "$ARCHIVE_PATH" ] || die "Архив не найден: $ARCHIVE_PATH" log "Беру готовый архив: $ARCHIVE_PATH" else ARCHIVE_PATH="$BUILD_DIR/${SCHEME}-${TS}.xcarchive" log "Шаг 1/2: собираю архив 🔨 (обычно 3–10 мин · подробности: $0 logs -f)" set +e run_with_spinner "собираю архив" xcodebuild archive \ "$CONTAINER_FLAG" "$CONTAINER_PATH" \ -scheme "$SCHEME" \ -configuration "$CONFIGURATION" \ -destination 'generic/platform=iOS' \ -archivePath "$ARCHIVE_PATH" \ -allowProvisioningUpdates \ -authenticationKeyPath "$KEY_PATH" \ -authenticationKeyID "$API_KEY_ID" \ -authenticationKeyIssuerID "$API_ISSUER_ID" ARCHIVE_EXIT=$? set -e if [ $ARCHIVE_EXIT -ne 0 ]; then err "Сборка архива упала (код $ARCHIVE_EXIT)." print_errors "$LOG_FILE" if grep -q "downloadPlatform\|is not installed\|iOS.*platform" "$LOG_FILE" 2>/dev/null; then err "Похоже, не докачаны компоненты iOS. Выполни: xcodebuild -downloadPlatform iOS" fi err "Полный лог: $LOG_FILE (быстро: $0 logs errors)" exit "$ARCHIVE_EXIT" fi ok "Архив собран за $SPIN_ELAPSED." fi # шаг 2: экспорт и заливка to_plist_bool() { if [ "$1" = "YES" ] || [ "$1" = "yes" ] || [ "$1" = "true" ] || [ "$1" = "1" ]; then echo ""; else echo ""; fi; } if [ "$MODE" = "ipa-only" ]; then DESTINATION="export" EXPORT_PATH="$BUILD_DIR/output" log "Шаг 2/2: делаю .ipa в $EXPORT_PATH (без заливки)" else DESTINATION="upload" EXPORT_PATH="" log "Шаг 2/2: заливаю в App Store Connect… ☁️" fi EXPORT_PLIST="$BUILD_DIR/ExportOptions-$TS.plist" cat > "$EXPORT_PLIST" < method ${EXPORT_METHOD} destination ${DESTINATION} teamID ${TEAM_ID} signingStyle automatic uploadSymbols $(to_plist_bool "$UPLOAD_SYMBOLS") manageAppVersionAndBuildNumber $(to_plist_bool "$AUTO_BUMP_BUILD") PLIST set +e if [ "$DESTINATION" = "export" ]; then run_with_spinner "делаю .ipa" xcodebuild -exportArchive \ -archivePath "$ARCHIVE_PATH" \ -exportOptionsPlist "$EXPORT_PLIST" \ -exportPath "$EXPORT_PATH" \ -allowProvisioningUpdates \ -authenticationKeyPath "$KEY_PATH" \ -authenticationKeyID "$API_KEY_ID" \ -authenticationKeyIssuerID "$API_ISSUER_ID" else run_with_spinner "заливаю в App Store Connect" xcodebuild -exportArchive \ -archivePath "$ARCHIVE_PATH" \ -exportOptionsPlist "$EXPORT_PLIST" \ -allowProvisioningUpdates \ -authenticationKeyPath "$KEY_PATH" \ -authenticationKeyID "$API_KEY_ID" \ -authenticationKeyIssuerID "$API_ISSUER_ID" fi EXPORT_EXIT=$? set -e if [ $EXPORT_EXIT -ne 0 ]; then err "Заливка не прошла (код $EXPORT_EXIT)." print_errors "$LOG_FILE" err "Архив цел: $ARCHIVE_PATH" err "Повторить только заливку: $0 --from-archive \"$ARCHIVE_PATH\"" exit "$EXPORT_EXIT" fi echo "" if [ "$MODE" = "ipa-only" ]; then IPA_FILE="$(find "$EXPORT_PATH" -maxdepth 1 -name '*.ipa' -print 2>/dev/null | head -n 1 || true)" ok "Готово за $SPIN_ELAPSED! .ipa: ${IPA_FILE:-$EXPORT_PATH}" else ok "Готово! Билд ушёл в App Store Connect 🎉 (заливка $SPIN_ELAPSED)" log "Processing занимает 5–30 мин — потом билд появится в TestFlight / для выбора в версии." # после пуша — предложить проверку готовности стора if [ -t 0 ] || [ -n "${RELEASE_FORCE_INTERACTIVE:-}" ]; then printf "Прогнать health-check App Store (что не пустит в стор)? [Y/n]: " read -r HC || HC="" case "$HC" in n|N|no|нет|Нет) : ;; *) run_meta_action "health" || true ;; esac fi fi