📰 IT Дайджест

← К выпуску 14.07.2026

Как собрать и залить macOS/iOS-приложение, не открывая Xcode

scottwillsey.com · #tool #build #ios #macos #xcode

Автор описывает рабочий процесс сборки и отправки macOS и iOS приложений вообще без ручного захода в Xcode. Для тех, кто уже устал от тяжеловесного GUI-ритуала Apple, это выглядит как попытка вернуть сборки в мир командной строки, скриптов и хоть какой-то воспроизводимости.

Открыть оригинал →

Недавно я слышал, как несколько подкастеров, связанных с Apple, говорили о том, насколько плох Xcode, и о том, что Apple нужно сделать vibe-coding приложений для Mac и iOS лучше, сделав Xcode менее непонятным. Они не ошибаются, но я также не понимаю, зачем они вообще открывают Xcode в первую очередь. Немного подготовительной работы — и вы можете vibe code приложения для Mac и iOS сколько душе угодно, больше даже не глядя на Xcode.

И если у вас когда-нибудь возникнут сомнения, как заставить что-то из следующего работать, направьте Claude Code или выбранный вами LLM-инструмент для кодинга на этот пост и дайте ему разобраться. Это буквально его работа — разбираться в том, с чем вы не хотите возиться сами.

TL;DR

  • Xcode.app должен быть установлен, но его никогда не нужно открывать. xcodebuild, notarytool, stapler и devicectl находятся внутри Xcode и нормально запускаются из shell.
  • Несколько разовых шагов действительно требуют GUI (или интерактивного terminal): войти в Apple ID, создать Developer ID certificate, сохранить пароль для notarization. После этого сборки и деплой полностью headless.
  • Mac app поставляется через один script — scripts/release.sh — который вы пишете один раз. Он выполняет всю цепочку: archive → Developer ID sign → notarize → staple → install to /Applications.
  • Signing основан на certificate и keychain. Signing key хранится в login keychain; xcodebuild находит его автоматически. Никаких secrets в repo.

Разовая настройка — единственная часть с какой-либо friction, так что давайте сначала покончим с ней.

Install Xcode

Xcode действительно должен быть установлен, тут не обойтись без него, потому что build зависит от tools, находящихся внутри Xcode.app.

После установки Xcode убедитесь, что выбран именно его command line toolchain, а не /Library/Developer/CommandLineTools. Если вывод проверки — /Applications/Xcode.app/Contents/Developer, всё в порядке:

❯ xcode-select -p
/Applications/Xcode.app/Contents/Developer

Если же вместо этого он возвращает путь к standalone CommandLineTools, укажите ему на Xcode:

sudo xcode-select -s /Applications/Xcode.app/Contents/Developer

NOTE: Название “Command Line Tools” может сбивать с толку.

Это потому, что существует отдельный package Command Line Tools, доступный через xcode-select --install, который соответствует версии /Library/Developer/CommandLineTools. Он содержит clang и git, но не содержит iOS SDK, notarytool, devicectl и других компонентов, необходимых для полноценной разработки приложений.

Полный toolchain находится внутри Xcode.app, в /Applications/Xcode.app/Developer, и там есть всё необходимое. Если Xcode уже установлен, standalone Command Line Tools вам не нужны.

Install XcodeGen

Xcode и его command line tools недостаточны, чтобы автоматически генерировать и управлять Xcode projects. Для этого вам понадобится XcodeGen. Его можно скачать с Github или установить через homebrew:

Вкратце: Xcode projects на самом деле представляют собой folders, которые macOS заставляет выглядеть как files, и они содержат всё, что нужно для создания и компиляции вашего app. Xcode постоянно изменяет files и file references, и это создаёт проблемы для git repositories.

Xcodegen создаёт project.yml (YAML) file со всеми настройками вашего project, а затем при каждом build заново воссоздаёт всю .xcodeproj folder на основе этого project.yml file. В git нужно коммитить только YAML file, а всю .xcodeproj с точки зрения git можно игнорировать.

Configure Xcode, Once

Вам нужно один раз настроить Xcode, чтобы потом больше никогда не смотреть на него снова.

Xcode License and Additional Components

Сначала либо примите его license и установите дополнительные components, либо сделайте это через command line:

sudo xcodebuild -license accept
sudo xcodebuild -runFirstLaunch

Setup Your Apple Developer Account in Xcode

Затем откройте Xcode, нажмите Settings → Accounts и нажмите +, чтобы добавить account.

NOTE: Чтобы распространять и notarize свои app, у вас должен быть платный Apple Developer account.

И вам понадобится notarize их, чтобы устанавливать их на ваши Mac и iOS devices и не дать OS решить, что это malware, и удалить их.

Create a Developer ID Application Certificate

После этого создайте Developer ID Application certificate (Settings → Accounts → your Apple ID → Manage Certificates… → + → Developer ID Application), который создаёт cert для signing поставляемого .app bundle.

Обратите внимание, что Developer ID Application certificate и ваш Apple Development certificate — это две разные вещи.

Apple Development identity предназначена для build и запуска на ваших собственных devices — отправки на iPhone, локальной отладки. Developer ID Application identity предназначена для notarized .app, который проходит Gatekeeper и запускается на чужом Mac. Release script нужен именно второй вариант.

Создание certificate в Xcode устанавливает и certificate, и его private key в ваш login keychain. Именно этот private key фактически выполняет signing, и его нельзя скачать заново — так что не удаляйте его и сделайте backup вашего keychain.

Если сомневаетесь, спросите про это ваш LLM по выбору и попросите помочь с настройкой. Всё равно именно он будет использовать Xcode за вас.

И наконец,

Store a Notarization Credential – Once, in Terminal

Notarization загружает ваш signed app в Apple для malware scan. notarytool проходит authentication с помощью сохранённого keychain profile, который вы один раз создаёте интерактивно — он запросит app-specific password, и обойти этот prompt нельзя:

xcrun notarytool store-credentials App-Name \
  --apple-id "[email protected]" --team-id YOUR-TEAM-ID
# paste an app-specific password when prompted

Вот несколько важных моментов:

  • Назовите profile в честь app. Не берите profile от другого app — на вашей машине он будет работать, а потом тихо сломается у кого-то другого.
  • App-specific password — это не ваш Apple ID password. Сгенерируйте его на appleid.apple.com → Sign-In & Security → App-Specific Passwords.
  • Эти passwords тихо устаревают, когда вы меняете Apple ID password. 401 invalid credentials из notarization почти всегда означает “идите и создайте свежий app-specific password”, а не “ваша настройка сломана”.

Проверьте, что он сохранён:

xcrun notarytool history --keychain-profile App-Name

Небольшое отступление: я храню свои app-specific passwords в vault 1Password, к которому есть доступ у Claude Code. Так каждый раз, когда я создаю новое app, я могу сказать IT, чтобы оно создало notarization credential за меня, а оно уже знает, что надо проверить свой vault 1Password на наличие password. Смысл использования LLM с самого начала — избежать ручных действий, которые вы не хотите выполнять.

Setup a Local.xconfig File and Add It to .gitignore

Для настоящего signing нужны ваш team ID и bundle prefix, и я храню их в Local.xconfig file:

cp Local.xcconfig.example Local.xcconfig
# then edit Local.xcconfig to set:
#   BUNDLE_PREFIX     = your.real.prefix
#   DEVELOPMENT_TEAM  = YOUR-TEAM-ID

И снова: если сомневаетесь, попросите Claude Code или выбранный вами LLM создать это для вас.

Create the Deploy Script

Деплой моих app выполняется через script под названием release.sh, который лежит в folder scripts внутри repo. Без него у меня нет автоматизированного build pipeline.

Я попросил Claude Code создать мой: я сказал Claude примерно так — мне нужно архивировать, подписывать Developer ID, notarize, staple и устанавливать этот app в /Applications, не открывая Xcode вообще. Напиши мне script, который делает всю цепочку и громко падает, если какой-то шаг ломается.

Ему не нужно было, чтобы я объяснял pipeline, потому что pipeline не секрет — archive с xcodebuild, export с -exportArchive и ExportOptions.plist, submit с notarytool --wait, attach ticket с stapler, check с spctl. Это документированный, стандартный способ выпускать Developer ID Mac app, и модель его знает. От меня ей понадобились специфичные для project вещи: имя scheme, team ID, как назвать notary profile, куда ставить результат.

Затем он написал первый draft, мы его запустили, он сломался, и мы его исправили. Такой цикл — не failure mode, а просто процесс. Я всегда смотрю на AI workflows как на work in progress, но довольно быстро наступает момент, когда можно перестать что-то подстраивать и просто начать работать.

Это реальный script из одного из моих app repos:

#!/usr/bin/env bash
# scripts/release.sh — produce a Developer ID-signed, notarized MY-APP-NAME.app and
# install it to /Applications.
#
# Requires (one-time): Xcode signed into your Apple ID, the paid Developer
# Program, and a notarytool credential profile. Defaults to the "MY-APP-NAME"
# profile; override with MY-APP-NAME_NOTARY_PROFILE=<name>.
#
# Usage: ./scripts/release.sh

set -euo pipefail

PROJECT="MY-APP-NAME.xcodeproj"
SCHEME="MY-APP-NAME-macOS"
APP_NAME="MY-APP-NAME"
TEAM_ID="YOURTEAMID"
NOTARY_PROFILE="${MY-APP-NAME_NOTARY_PROFILE:-MY-APP-NAME}"
BUILD_DIR="build"
ARCHIVE_PATH="$BUILD_DIR/$APP_NAME.xcarchive"
EXPORT_PATH="$BUILD_DIR/Export"
APP_PATH="$EXPORT_PATH/$APP_NAME.app"
INSTALL_DIR="/Applications"
LSREGISTER="/System/Library/Frameworks/CoreServices.framework/Versions/A/Frameworks/LaunchServices.framework/Versions/A/Support/lsregister"

cd "$(dirname "$0")/.."

step() { printf "\n\033[1;36m▸ %s\033[0m\n" "$*"; }
fail() { printf "\n\033[1;31m✗ %s\033[0m\n" "$*" >&2; exit 1; }

step "Pre-flight"
command -v xcodegen >/dev/null || fail "xcodegen not installed (brew install xcodegen)."
if ! xcrun notarytool history --keychain-profile "$NOTARY_PROFILE" >/dev/null 2>&1; then
  fail "notarytool profile '$NOTARY_PROFILE' missing. Create it with xcrun notarytool store-credentials."
fi

step "Regenerating project"
xcodegen generate
rm -rf "$BUILD_DIR"; mkdir -p "$BUILD_DIR"

step "Archiving (Release)"
xcodebuild -project "$PROJECT" -scheme "$SCHEME" -configuration Release \
  -derivedDataPath "$BUILD_DIR/derived" -archivePath "$ARCHIVE_PATH" \
  -allowProvisioningUpdates archive

step "Exporting Developer ID-signed app"
cat > "$BUILD_DIR/ExportOptions.plist" <<EOF
<?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>automatic</string>
</dict>
</plist>
EOF
xcodebuild -exportArchive -archivePath "$ARCHIVE_PATH" -exportPath "$EXPORT_PATH" \
  -exportOptionsPlist "$BUILD_DIR/ExportOptions.plist" -allowProvisioningUpdates
[ -d "$APP_PATH" ] || fail "Exported app not found at $APP_PATH."

step "Notarizing (submitting to Apple, may take a few minutes)"
ditto -c -k --keepParent "$APP_PATH" "$BUILD_DIR/notarize.zip"
xcrun notarytool submit "$BUILD_DIR/notarize.zip" --keychain-profile "$NOTARY_PROFILE" --wait

step "Stapling ticket"
xcrun stapler staple "$APP_PATH"

step "Verifying Gatekeeper acceptance"
spctl -a -vvv -t exec "$APP_PATH"

step "Installing to $INSTALL_DIR/$APP_NAME.app"
pkill -x "$APP_NAME" 2>/dev/null || true
rm -rf "$INSTALL_DIR/$APP_NAME.app"
cp -R "$APP_PATH" "$INSTALL_DIR/"
[ -x "$LSREGISTER" ] && "$LSREGISTER" -f "$INSTALL_DIR/$APP_NAME.app" >/dev/null 2>&1 || true

step "Verifying installed bundle"
xcrun stapler validate "$INSTALL_DIR/$APP_NAME.app"
spctl -a -vvv -t exec "$INSTALL_DIR/$APP_NAME.app"

printf "\n\033[1;32m✓ %s notarized, stapled, installed.\033[0m\n" "$APP_NAME"

Это выглядит сложнее, чем есть на самом деле, но это серия шагов, которые нужно знать, что вообще надо выполнить. Снова: именно поэтому вы говорите с вашим LLM, говорите ему, чего хотите, и просите помочь построить workflow.

Некоторые вещи, на которые стоит обратить внимание:

  • set -euo pipefail немедленно останавливает script при любой ошибке команды, вместо того чтобы слепо идти дальше. Никакого наполовину завершённого состояния, которое выглядит как успех.
  • cd "$(dirname "$0")/.." означает, что script переходит в root repo независимо от того, откуда вы его вызвали, так что ./scripts/release.sh работает, даже если вы в корне repo или на три directory ниже.
  • Блок pre-flight проверяет, что существует xcodegen, и что notary profile действительно сохранён, прежде чем тратить пять минут на archive, который обречён провалиться на шаге пять.
  • И последние два шага повторно проверяют установленный bundle, а не только экспортированный. Осторожность не помешает, а у меня уже бывало, что copy step тихо портил bundle, и я предпочёл бы узнать об этом из script, а не от Gatekeeper через три дня, когда он удалит моё app за меня.

Create CLAUDE.md or AGENTS.md

release.sh даёт вам deploy одной командой. CLAUDE.md (или AGENTS.md для практически любой другой модели) — это то, что заставляет agent реально использовать его, не объясняя всё каждый раз заново.

Я попросил Claude создать мой CLAUDE.md после длительных обсуждений процесса build. Теперь всякий раз, когда я создаю новое app, я прошу его опираться на repo одного из моих других app и использовать ту же методологию.

Build commands

# Regenerate the Xcode project after changing project.yml or adding source files
xcodegen generate

# Unit tests (YOUR-APP-NAMEKit only; fast, no Xcode build required)
swift test

# macOS app
xcodebuild -project YOUR-APP-NAME.xcodeproj -scheme YOUR-APP-NAME-macOS \
    -destination 'platform=macOS' CODE_SIGNING_ALLOWED=NO build

Release (Developer ID + notarization)

./scripts/release.sh   # archive → Developer ID export → notarize → staple → install

xcodebuild команды выше используют CODE_SIGNING_ALLOWED=NO, что даёт ad-hoc build: он подходит для CI и быстрых локальных проверок, но Gatekeeper его отвергает, а entitlements iCloud KVS / App Group не привязываются (нет team prefix). Для настоящего menu-bar build, который переживает quarantine и позволяет работать iCloud sync, используйте scripts/release.sh. Он подписывает через Developer ID, notarize через YOUR-APP-NAME notarytool keychain profile, stapler прикрепляет ticket, и всё устанавливается в /Applications/YOUR-APP-NAME.app.

Вот и всё

И это вся разовая настройка. Дальше мышь больше не нужна.

Как на самом деле выполняется build — без GUI в loop

Ниже всё — обычный command-line invocation. Xcode.app никогда не запускается; эти tools живут внутри него, но работают standalone. Именно это выполняет Claude Code через свой shell.

Быстрые unsigned проверки

Для проверки “компилируется ли / проходят ли tests” signing вообще не нужен:

# Unit tests — pure SPM, no Xcode build at all
swift test

# Compile the macOS app (ad-hoc, unsigned — fine for CI/local sanity)
xcodebuild -project TZed.xcodeproj -scheme TZed-macOS \
    -destination 'platform=macOS' CODE_SIGNING_ALLOWED=NO build

# Compile the iOS app + widget extension for the simulator
xcodebuild -project TZed.xcodeproj -scheme TZed-iOS \
    -destination 'generic/platform=iOS Simulator' CODE_SIGNING_ALLOWED=NO build

CODE_SIGNING_ALLOWED=NO даёт вам ad-hoc build: он компилируется и запускается в simulator, но Gatekeeper его отвергает, и такие entitlements, как iCloud KVS и App Group, не привязываются. Это быстрый inner loop.

The Mac Release Pipeline

Одна команда выполняет всю цепочку, пригодную для поставки — script из Part two:

Archive, Developer ID export, notarize, staple, verify, install. Если какой-то шаг ломается, он останавливается и сообщает, какой именно. Нужен другой notary profile? Переопределите его: TZED_NOTARY_PROFILE=<name> ./scripts/release.sh.

Deploying to a Real iPhone, Headless

У iOS нет шага notarization — это концепция для распространения на Mac. Чтобы установить build на подключённый iPhone, используются xcodebuild и devicectl, оба входят в toolchain Xcode:

# Build & sign for a real device (uses the Apple Development cert + provisioning)
xcodebuild -project TZed.xcodeproj -scheme TZed-iOS \
    -destination 'generic/platform=iOS' \
    -allowProvisioningUpdates \
    -derivedDataPath build/ios archive -archivePath build/TZed-iOS.xcarchive

# Install the built .app onto the connected device by its UDID
xcrun devicectl device install app \
    --device <DEVICE-UDID> build/ios/…/TZed.app

devicectl list devices показывает подключённые и сопряжённые devices с их UDID. Device builds подписываются Apple Development identity (не Developer ID) плюс development provisioning profile, который -allowProvisioningUpdates получает за вас.

Как работает code signing, когда нет GUI

Если вы когда-либо подписывали apps, просто отмечая checkbox в Xcode, стоит понять, что именно там происходит, потому что во время build GUI не нужен вообще.

Private key выполняет signing. Когда вы создали Developer ID Application certificate, Apple выдала certificate, а ваш Mac сгенерировал соответствующий private key, и оба оказались в вашем login keychain. codesign (который вызывает xcodebuild) использует private key для signing binary; certificate — который цепочкой уходит к root Apple — встраивается внутрь, чтобы любой мог всё проверить.

Automatic signing сам выбирает identity. Release script использует signingStyle: automatic, так что xcodebuild выбирает нужную identity по team ID и при необходимости подгружает provisioning profile от Apple на лету. Никаких profiles в repo.

Entitlements привязываются в момент signing. У каждого target есть file .entitlements (sandbox, network client, iCloud KVS, App Group). Они начинают действовать только когда app подписан настоящей team identity, и это ещё одна причина, почему ad-hoc build нельзя выпускать: entitlements iCloud и App-Group тихо не привязываются без team prefix, и вы потом часами гадаете, почему ваш key-value store пуст.

Notarization — это не signing. Signing доказывает, кто собрал app. Notarization — отдельный шаг, где Apple сканирует signed app на malware и выдаёт ticket; stapling прикрепляет этот ticket, чтобы Gatekeeper доверял app офлайн. Для hidden-UI menu bar app (LSUIElement) именно notarization не даёт XProtect его пометить.

Secrets никогда не попадают в git. Signing private key лежит в login keychain. App-specific password для notarization лежит в notarytool keychain profile. Ни одно из них никогда не записывается в repo.

Любую signed build можно проверить вручную:

codesign -dv --verbose=4 /Applications/TZed.app   # who signed it, with what cert
spctl -a -vvv -t exec   /Applications/TZed.app     # would Gatekeeper allow it?
stapler validate        /Applications/TZed.app     # is the notarization ticket attached?

What the agent actually uses

Здесь нет никакой магии. Claude Code управляет всем этим через обычный non-interactive shell — никакой специальный MCP server или plugin для “build” не делает что-то хитрое. Это xcodebuild, xcrun notarytool, xcrun stapler, spctl, codesign, devicectl, xcodegen и swift. Стандартные CLI tools, те же самые, которыми пользовались бы и мы сами.

Связующим звеном служит CLAUDE.md из Part two. Он сообщает agent соглашение о notary profile, два пути build и то, что shipping означает release.sh. В результате Claude просто запускает всё это, без моего повторного объяснения на каждой session.

Единственный шаг, который остаётся интерактивным, — это notarytool store-credentials, и это скорее выбор, чем ограничение: можно передать --password и автоматизировать это, но тогда app-specific password окажется в вашей shell history. Введите его один раз вручную, пусть keychain или 1Password хранит его, а дальше всё по цепочке уже автоматизировано.

Why Xcode never needs to be open

Если поставить GUI workflow рядом с headless one, всё просто совпадает:

JOB GUI WAY HEADLESS WAY
Generate project Xcode manages .xcodeproj xcodegen generate from project.yml
Build ⌘B / Run button xcodebuild … build
Archive Product → Archive xcodebuild … archive
Export signed app Organizer → Distribute xcodebuild -exportArchive
Notarize Organizer upload xcrun notarytool submit --wait
Staple (automatic in Organizer) xcrun stapler staple
Install to /Applications drag-and-drop cp -R + lsregister
Deploy to iPhone Run on device xcodebuild archive + devicectl device install

GUI нужна только для этой разовой настройки credentials. После этого весь lifecycle можно script-овать, и именно это завершает release.sh, и именно поэтому agent может владеть этим end to end, пока я занимаюсь чем-то более интересным, чем смотреть на progress bar.

Если хотите попробовать сами, порядок такой: установите Xcode и xcodegen, выполните настройку credentials, а затем сядьте с Claude и соберите свой собственный release.sh и CLAUDE.md. Именно эта последняя часть и есть настоящая работа, и на неё уйдёт максимум час-два. После этого “выпустить новую build” становится одним предложением. После первой настройки app Claude Code может копировать ту же конфигурацию для будущих app.