Цель: пройти полную iOS-сборку с приёмкой
Перед установкой Runner определим критерии «подключение завершено» — иначе Runner будет работать, а Archive застрянет на подписи. Конечная точка этого гайда: push в целевую ветку → GitHub Actions запускается на выделенном M4-узле → checkout → xcodebuild archive успешно создаёт .xcarchive. Загрузка в TestFlight вне scope; когда Archive стабилен, fastlane или altool — лишь дополнительные шаги.
Тестовый репозиторий — SwiftUI-приложение среднего размера: около 60 Swift-файлов, CocoaPods для зависимостей, Release с Manual Signing. Базовая среда: JexMac Mac mini M4 на узле Сингапур (16 GB Unified Memory, 256 GB NVMe), Xcode 16.2 установлен и зафиксирован через xcodes.
Контрольная группа: тот же репозиторий на GitHub-hosted runs-on: macos-14. UTC 13:00–18:00 (апак-послеполуденное время): медиана очереди job — 11 минут, максимальное ожидание — 19 минут; предустановленный Xcode на macos-14 отличался от локальных dev-машин и вызывал проблемы совместимости со Swift 6. Польза self-hosting здесь очевидна — ожидание сокращается с минут до секунд, а версию Xcode вы фиксируете на узле.
Три скрытых издержки GitHub-hosted macOS Runner
Многие команды выбирают GitHub-hosted Runner из-за «нулевого ops», но в iOS-сценариях скрытые издержки часто болезненнее счёта.
Первое — время. Пул macOS у GitHub ограничен; бесплатный аккаунт даёт 2000 минут в месяц, macOS считается с весом ×10 — фактически около 200 минут. Проект с 8 запусками в день по 6 минут расходует ~1440 взвешенных минут — почти весь лимит; добавите nightly-сборку — и лимит превышен.
Второе — дрейф окружения. Метка macos-latest меняет базовый Xcode при обновлении инфраструктуры GitHub — не раз бывало «вчера зелёный, сегодня красный». Временный workaround — sudo xcode-select в workflow, но каждое переключение версии добавляет 1–2 минуты и не гарантирует идентичность с локальной dev-машиной.
Третье — отладка. Hosted Runner уничтожается после каждого job — SSH для воспроизведения недоступен. Ошибки Keychain, просроченные provisioning profile — всё решается только повторными push и логами. Мы 7 раз push'или из-за User interaction is not allowed, пока не нашли отсутствующий partition list.
Все команды и замеры времени выполнены на выделенном физическом JexMac Mac mini M4 в дата-центре Сингапур, тестовое окно — конец июля 2026 г. Железо: Apple M4 · 10-ядерный CPU · 16 GB Unified Memory · 1 Gbps выделенный канал.
После доставки узла: первое SSH-подключение и базовая настройка
Консоль JexMac обычно выдаёт SSH-учётные данные через 1–5 минут после оплаты. Получив публичный IP, сначала настройте Ed25519 и отключите вход по паролю — Runner работает от имени текущего пользователя macOS, базовая безопасность SSH должна быть готова заранее.
-
01
Добавить SSH-публичный ключ
ssh-copy-id -i ~/.ssh/id_ed25519.pub jexmac@<IP-узла>После успешного входа без пароля отредактируйте
/etc/ssh/sshd_config:PasswordAuthentication no, затем перезапустите sshd. -
02
Установить Homebrew и xcodes
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"brew install xcodesorg/made/xcodesxcodes install 16.2 --experimental-fast-pass— установка и фиксация Xcode 16.2 (номер версии под ваш проект). -
03
Проверить цепочку сборки
xcodebuild -versionдолжен вывестиXcode 16.2и номер Build.xcodebuild -showsdks | grep iphoneos— убедитесь, что iOS SDK доступен. -
04
Установить CocoaPods (если проект использует)
sudo gem install cocoapods -n /usr/local/binЗаранее выполните
pod installна узле, чтобы кэш Specs-репозитория был готов — иначе первая CI-сборка зависнет на repo update.
После базовой настройки на узле должна быть выделенная директория (мы используем ~/ci-runner) для Runner и артефактов сборки, изолированная от повседневных файлов. Внутри — подкаталог DerivedData; в workflow явно укажите -derivedDataPath, чтобы избежать конфликтов при параллельных сборках.
На стороне GitHub: токен Runner и дизайн маршрутизации по меткам
Откройте репозиторий → Settings → Actions → Runners → New self-hosted runner, платформа macOS ARM64. Страница выдаст одноразовый токен регистрации (действует 1 час) и ссылку для скачивания.
Дизайн меток напрямую влияет на маршрутизацию workflow к нужной машине. Наши правила именования:
mac: общая метка для всех self-hosted macOS-узловm4: Apple Silicon M4, чтобы отличать от старых Intel-узловxcode-16-2: фиксация минорной версии Xcode; при обновлении меняйте метку, а не логику workflowsg: регион дата-центра (Сингапур); при мультирегиональном развёртывании — маршрутизация по близости
Параметр --labels в команде регистрации записывает все метки сразу. В workflow используйте массив:
jobs:
ios-archive:
runs-on: [self-hosted, mac, m4, xcode-16-2]
concurrency:
group: ios-build-${{ github.ref }}
cancel-in-progress: true
Группа concurrency гарантирует, что одна ветка не запустит два Archive параллельно — на M4 с 16 GB RAM двойной Clean Build возможен, но конкуренция за DerivedData увеличивает время на 30%+. При нескольких продуктовых линиях лучше разделить метки (product-a, product-b) и выделить узлы, чем форсировать параллелизм на одной машине.
Self-hosted Runner в публичном репозитории может быть вызван fork PR — вредоносный workflow выполнит произвольный код на вашем Mac. Используйте только в private-репозиториях или на уровне Organization; процесс Runner — под неадминистративным аккаунтом. В production добавьте GitHub Environment protection rules, ограничив secrets указанными ветками.
Установка Runner на M4-узле и настройка launchd
Шаги ниже выполняются в SSH-сессии; версия Runner — по странице регистрации GitHub (в примере v2.321.0).
-
01
Скачать и распаковать
mkdir -p ~/ci-runner/actions-runner && cd ~/ci-runner/actions-runnercurl -o actions-runner-osx-arm64-2.321.0.tar.gz -L \ https://github.com/actions/runner/releases/download/v2.321.0/actions-runner-osx-arm64-2.321.0.tar.gztar xzf ./actions-runner-osx-arm64-2.321.0.tar.gz -
02
Интерактивная регистрация
./config.sh --url https://github.com/YOUR_ORG/YOUR_REPO \ --token YOUR_ONE_TIME_TOKEN \ --name jexmac-m4-sg-01 \ --labels mac,m4,xcode-16-2,sg \ --unattended--unattendedпропускает интерактивное подтверждение — удобно для скриптового развёртывания. Work folder по умолчанию_workподходит. -
03
Установить launchd-сервис
./svc.sh install./svc.sh startПроверка:
./svc.sh statusдолжен показатьactive (running). Зелёный Online на странице Runners репозитория — регистрация успешна.
launchd поднимет Runner после перезагрузки, но есть нюанс: Runner работает от пользователя macOS, под которым установлен — этот пользователь должен хотя бы раз войти в систему (или включить автовход), иначе launchd может не получить доступ к Keychain. Мы создали аккаунт ci-bot, первый SSH-вход инициализировал Keychain, затем установили svc.sh — после перезагрузки вмешательство не требуется.
Логи Runner: ~/ci-runner/actions-runner/_diag/; после каждого job — Worker_*.log. При проблемах «job назначен узлу, но шаги не выполняются» сначала смотрите RunnerListener — там больше информации, чем в Annotations GitHub UI.
Минимальный workflow: от checkout до Archive
После регистрации создайте .github/workflows/ios-archive.yml. Ниже — минимальная проверенная версия без загрузки в TestFlight, только Archive:
name: iOS Archive
on:
push:
branches: [main, release/*]
pull_request:
branches: [main]
jobs:
archive:
runs-on: [self-hosted, mac, m4, xcode-16-2]
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
- name: Select Xcode
run: xcodes select 16.2
- name: Install pods
run: pod install --deployment
working-directory: ios
- name: Build archive
run: |
xcodebuild archive \
-workspace ios/MyApp.xcworkspace \
-scheme MyApp \
-sdk iphoneos \
-configuration Release \
-archivePath ./build/MyApp.xcarchive \
-derivedDataPath ~/ci-runner/DerivedData \
CODE_SIGN_STYLE=Manual \
CODE_SIGN_IDENTITY="Apple Distribution: Your Team (TEAMID)" \
PROVISIONING_PROFILE_SPECIFIER="MyApp AppStore"
env:
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
- name: Upload xcarchive artifact
uses: actions/upload-artifact@v4
with:
name: MyApp-xcarchive
path: ./build/MyApp.xcarchive
retention-days: 7
Несколько осознанных решений:
pod install --deployment фиксирует версии по Podfile.lock — CI и локальная среда совпадают. -derivedDataPath указывает на постоянную директорию на узле — кэш компиляции переиспользуется, инкрементальная сборка падает с 4 минут до ~1 мин 40 сек. upload-artifact загружает xcarchive в GitHub — коллеги без SSH могут скачать для QA; artifact хранится 7 дней.
Headless-среда: Keychain и импорт Distribution-сертификата
Успех Archive зависит от доступа CI к приватному ключу Distribution без GUI. Hosted Runner преднастраивает системный Keychain; на self-hosted узле всё на вас — частая причина «Runner работает, Archive падает на подписи».
Рекомендуемый подход: создавать временный Keychain на каждый job, импортировать p12, подписать, после job — уничтожить, чтобы приватный ключ не оставался на диске.
# Добавить перед шагом "Build archive"
- name: Import signing certificate
run: |
security create-keychain -p "$KEYCHAIN_PASSWORD" build.keychain
security default-keychain -s build.keychain
security unlock-keychain -p "$KEYCHAIN_PASSWORD" build.keychain
security set-keychain-settings -t 3600 -u build.keychain
echo "${{ secrets.CERTIFICATE_P12_BASE64 }}" | base64 --decode > cert.p12
security import cert.p12 \
-k build.keychain \
-P "${{ secrets.P12_PASSWORD }}" \
-T /usr/bin/codesign \
-T /usr/bin/xcodebuild
security set-key-partition-list \
-S apple-tool:,apple: \
-s -k "$KEYCHAIN_PASSWORD" build.keychain
rm -f cert.p12
env:
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
В GitHub Repository Secrets нужно заранее настроить три переменные: KEYCHAIN_PASSWORD (пароль временного Keychain — достаточно случайной строки), CERTIFICATE_P12_BASE64 (Distribution-сертификат в Base64), P12_PASSWORD (пароль при экспорте p12).
| Ключевое слово ошибки | Частая причина | Решение |
|---|---|---|
User interaction is not allowed |
Partition list приватного ключа не задан — codesign пытается открыть UI | Повторно выполнить set-key-partition-list (см. скрипт выше) |
errSecItemNotFound |
Сертификат импортирован в неверный Keychain или default-keychain не переключён | Убедитесь, что security default-keychain -s build.keychain выполняется до import |
| could not find signing certificate | Строка CODE_SIGN_IDENTITY не совпадает с Identity в Keychain |
Выполните security find-identity -v -p codesigning build.keychain и скопируйте полное имя |
| Provisioning profile doesn't match | Provisioning profile просрочен или Bundle ID / Capability не совпадают | Пересоздать в Apple Developer, скачать и синхронизировать через secrets или match |
После успешного импорта быстро проверьте одной командой в логе job:
security find-identity -v -p codesigning build.keychain | grep Distribution
Переходите к Archive только после 1 valid identities found — сэкономите время на «подпись упала, но непонятно где».
Отладка на практике: три проблемы, из-за которых мы сделали лишний push
Даже следуя шагам выше, при первом подключении возможны эти ситуации — реальные ловушки с признаками в логах и решениями.
Несовпадение меток: job вечно в очереди
Симптом: GitHub Actions UI показывает Queued, на странице Runners узел Online. Причина: метки в runs-on workflow не совпадают с регистрацией — например workflow xcode-16.2 (точка), регистрация xcode-16-2 (дефис). Сопоставление меток GitHub — точное сравнение строк; один символ — и маршрутизация не сработает.
Решение: на странице Runners репозитория кликните имя узла, скопируйте реальный список меток в YAML — не набирайте вручную.
Runner Offline после перезапуска launchd
Симптом: после reboot узла или обслуживания JexMac Runner Offline, нужен ручной SSH и ./svc.sh start. Причина: UserName в launchd plist не совпадает с SSH-пользователем или пользователь не завершил первый графический/session-вход.
Решение: ./svc.sh install и ./svc.sh start под одним пользователем; после reboot проверьте ./svc.sh status. Если не помогло — stderr в /Library/Logs/GitHubActionsRunner/.
Конфликт прав DerivedData
Симптом: второй build — Unable to write to DerivedData или permission denied на .o-файлах. Причина: первый job создал DerivedData от root или другого пользователя; следующие job без прав записи.
Решение: единый путь DerivedData и очистка в начале workflow: rm -rf ~/ci-runner/DerivedData && mkdir -p ~/ci-runner/DerivedData. Или перед каждым Archive удаляйте только hash-подкаталог проекта, сохраняя кэш компиляции.
Нет постоянного Mac: арендуйте выделенный узел по ритму сборок
К этому моменту ясно: технический порог self-hosted GitHub Actions Runner невысок — узкое место в круглосуточно доступной физической macOS-машине с контролируемой версией Xcode. Локальный MacBook плох как CI-хост — на 8 GB RAM Archive гонит вентилятор и swap; покупка Mac mini в компании — согласование CAPEX, colocation и выездная ротация сертификатов.
Наш подход: в спринте перед релизом (например две недели) арендуем выделенный JexMac Mac mini M4 как Runner-хост; после стабилизации — продление по неделям или освобождение. Стандарт: 16 GB Unified Memory, 256 GB NVMe, 1 Gbps выделенный канал, от $21.5/день, SSH/VNC через 1–5 минут после оплаты, без контрактной привязки. Пять узлов — Сингапур, Япония (Токио), Корея (Сеул), Гонконг, Восток США — по географии команды, чтобы снизить задержку git fetch и синхронизации CocoaPods Specs.
Сравнение с GitHub-hosted macOS Runner: пиковая очередь 10–20 мин vs self-hosted старт за ~25 сек; macOS-минуты с весом ×10 vs предсказуемая дневная аренда. Сравнение с покупкой: без upfront; обновление — смена метки или переустановка Xcode, без цикла закупки.
Тот же узел может быть remote dev desktop — браузерный VNC для UI, Instruments для профилирования; простой CI не пропадает. Для команды из 2–3 человек «одна M4 = Runner + remote Mac dev» часто выгоднее, чем hosted-минуты плюс апгрейд локальной RAM.
Подключите Runner к вашему M4-узлу
Все команды в статье проверены на выделенном физическом JexMac Mac mini M4. Активация → SSH → регистрация Runner — Archive часто за час. Аренда от $21.5/день, после спринта освобождаете узел, без годовой привязки.