"어제 바이너리를 Mac 여덟 대에 밀어 넣는 게 이 도구가 낼 수 있는 최악의 결과야."
사본이면 충분한 이유
가족 Mac은 앱을 각자 빌드하지 않아. Mac 한 대가 빌드하고, 서명하고, 설치하고, 나머지는 그 서명된 번들의 사본을 받아. 이게 되는 건 번들 서명 트랙 덕분이야. 모든 앱이 자체 서명 신원 하나로 서명되니까 designated requirement가 해시가 아니라 인증서를 가리켜. 그래서 동료 Mac에서 한 번 허용한 권한이 이후의 모든 사본에도 계속 유효해. 동료 Mac엔 Swift 도구도 키체인 가져오기도 필요 없어. codesign --verify는 번들에 들어 있는 인증서로 서명을 확인하고, 개인 키는 서명할 때만 필요하거든. 그래서 배포 스크립트는 절대 아무것도 빌드하지 않아. 빌드하는 Mac에서 설치된 번들보다 새로운 소스를 찾으면 거부해. 배포를 안 하는 것보다 나쁜 건 딱 하나, 틀린 바이너리를 모든 곳에 자신 있게 배포하는 거니까.
첫 동료 Mac에 닿기 전에 사전 점검
배포를 막아야 할 조건은 전부 동료 Mac을 건드리기 전에 확인해. 설치된 번들이 소스보다 새로운지, 서명이 엄격하게 검증되는지, requirement가 인증서에 묶였는지, 선언한 LaunchAgent plist가 있는지 봐. 나쁜 빌드는 빌드하는 Mac에서 실패해야지, 1번 Mac엔 이미 깔리고 8번 Mac은 확인도 안 된 상태에서 실패하면 안 돼.
동료마다: 대기, 검증, 멈춤, 교체, 검증
번들은 먼저 /Applications 옆에 숨김 처리된, 공백 없는 대기용 이름으로 복사돼. 공백을 피하는 데는 이유가 있어. 동료 Mac의 openrsync가 공백이 든 원격 목적지를 인자 두 개로 쪼개는 바람에 죽었거든. 그래서 함대 배포는 Homebrew rsync로 고정하고, TestFlight export는 직접 재서 확인한 정반대의 이유로 애플 rsync에 고정해. 중단된 실행이 남긴 대기 흔적은 셸 glob이 아니라 find로 치워. 동료 Mac의 zsh에선 맞는 파일이 없는 glob이 명령 전체를 죽이는 에러가 돼. 그래서 치울 게 없는 평소 상황에선 정리 단계가 조용히 통째로 건너뛰어졌거든.
동료 Mac에선 대기 번들을 검증하고, 돌고 있는 앱을 그 앱의 종료 정책대로 멈춰. 상주 유틸리티는 시그널을 보내도 돼. 문서 앱은 안 돼. SIGTERM은 절대 applicationShouldTerminate까지 가지 않아. 그래서 글쓰기 편집기가 한글 입력 조합이나 동료 동기화, 내보내기 도중이면 종료를 거부하는 판단도, 저장할지 초안으로 둘지 묻는 결정도 전부 건너뛰어져. 그러니 ssh에서도 되는 직접 Apple Event로 정중하게 종료를 요청하고 마감을 걸어. 거부되면 건너뛴 걸로 처리해. 터미널 앱은 열려 있으면 아예 건너뛰어. 종료하면 돌고 있는 작업이 끝나버릴 수 있으니까. 그 뒤에 옛 번들을 옆으로 치우고 새 번들을 넣은 다음, 최종 경로에서 검증해. 하나라도 실패하면 옛 번들을 되돌려놔.
앱마다, Mac마다 참인 걸 보고해
보고는 동료 Mac의 앱마다 한 줄이면 돼. LaunchAgent는 그 Mac에 누가 로그인해 있으면 올라감, 아무도 없으면 대기 중이야. 권한 상태는 서비스마다 그 권한을 관리하는 데이터베이스에서 읽어. 물어본 적 없음, 물어봤는데 허용 안 함(다시 안 물어봄), 허용됨, 못 읽음. 수동 단계는 실제로 권한이 필요한 앱에만 적어. 안 그러면 주인이 보고만 믿고, 뜨지도 않을 권한 창을 허용하러 Mac 여섯 대를 돌아다니게 되거든. "다시 켜야 함"은 배포하면서 돌고 있던 앱을 종료했는데 그걸 다시 켜줄 게 아무것도 없을 때만 적어.