본문 바로가기
C.W.K.
Lesson 04 of 06 · published

한 번 빌드하고, 검증한 사본을 모든 Mac에 보내

~17 min · proof-and-fleet, deployment, fleet, rsync, codesign

Level 0번들 열어본 사람
0 XP0/84 lessons0/17 achievements
0/100 XP to next level100 XP to go0% complete
"어제 바이너리를 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 여섯 대를 돌아다니게 되거든. "다시 켜야 함"은 배포하면서 돌고 있던 앱을 종료했는데 그걸 다시 켜줄 게 아무것도 없을 때만 적어.

Code

빌드하는 Mac에서: 사전 점검, 그리고 동료마다 검증한 사본 하나·bash
#!/bin/bash
# Office builds once; peers receive copies. This script never builds.
set -euo pipefail
app=Spark quit_policy=signal
installed="/Applications/$app.app"
peers=(${PEERS:-air mini})

# Preflight, before any peer is touched: a stale build must never reach Mac number one.
stale=$(find Sources Resources Package.swift -newer "$installed" -print -quit 2>/dev/null || true)
[[ -z "$stale" ]] || { echo "HARD_FAIL $stale is newer than $installed: build and install here first"; exit 1; }
codesign --verify --deep --strict "$installed"
codesign -d -r- "$installed" 2>&1 | grep -q 'certificate leaf' \
  || { echo "HARD_FAIL $installed is not signed with the shared identity; its grants would not survive a copy"; exit 1; }

for peer in "${peers[@]}"; do
  staged="/Applications/.spark-deploy.$$"                     # no spaces: peer rsync splits on them
  if ! rsync -aH --delete -e "ssh -o BatchMode=yes" "$installed/" "$peer:$staged/"; then
    echo "$app@$peer FAILED (rsync)"; continue
  fi
  result=$(ssh -o BatchMode=yes "$peer" "bash -s -- '$app' '$staged' '$quit_policy'" < peer-install.sh) \
    || true
  echo "$app@$peer $result"
done
peer-install.sh: 검증, 정책대로 멈춤, 교체, 재검증, 되돌리기·bash
#!/bin/bash
# Runs ON the peer:  ssh peer 'bash -s -- Spark /Applications/.spark-deploy.123 polite' < peer-install.sh
# Verify what arrived, stop the running copy the right way, swap, verify again, roll back on failure.
set -euo pipefail
app=$1 staged=$2 quit_policy=$3            # quit_policy: polite (document app) | signal (utility)
root=${APPLICATIONS:-/Applications}
dest="$root/$app.app" previous="$root/.$app.previous.$$"
info="$staged/Contents/Info.plist"
exe=$(/usr/libexec/PlistBuddy -c 'Print :CFBundleExecutable' "$info")
bundle_id=$(/usr/libexec/PlistBuddy -c 'Print :CFBundleIdentifier' "$info")

xattr -cr "$staged"                                          # copied metadata breaks signatures
codesign --verify --deep --strict "$staged" || { echo "FAILED app=$app reason=staged-signature"; exit 1; }

if pgrep -x "$exe" >/dev/null; then
  if [[ $quit_policy == polite ]]; then
    # A signal never reaches applicationShouldTerminate, so unsaved work would be skipped.
    # A direct Apple Event works from ssh; it can block on a dialog, so it gets a deadline.
    osascript -e "tell application id \"$bundle_id\" to quit" >/dev/null 2>&1 &
    for _ in $(seq 1 100); do pgrep -x "$exe" >/dev/null || break; sleep 0.1; done
    if pgrep -x "$exe" >/dev/null; then echo "SKIP app=$app reason=quit-refused (unsaved work?)"; exit 0; fi
  else
    pkill -x "$exe" || true
  fi
fi

[[ -d "$dest" ]] && mv "$dest" "$previous"
if mv "$staged" "$dest" && codesign --verify --deep --strict "$dest" 2>/dev/null; then
  rm -rf "$previous"
  echo "OK app=$app build=$(/usr/libexec/PlistBuddy -c 'Print :CFBundleVersion' "$dest/Contents/Info.plist" 2>/dev/null || echo unknown)"
else
  rm -rf "$dest"
  [[ -d "$previous" ]] && mv "$previous" "$dest"
  echo "ROLLED_BACK app=$app reason=installed-signature"
  exit 1
fi

External links

Exercise

APPLICATIONS가 연습용 디렉터리를 가리키게 하고 peer-install.sh를 로컬에서 돌려. 빌드 1을 설치하고, 빌드 2를 대기시켰다가 설치해. 그러고 나서 실행 파일에 바이트 하나를 덧붙인 사본을 대기시키고, 아무것도 교체되기 전에 거부되는지 확인해. 이어서 ship.sh에 빌드하는 Mac의 설치된 번들이 소스보다 오래됐으면 실패하는 검사를 넣고, 소스 파일 하나를 touch해서 제대로 걸리는지 증명해. 마지막으로 배포가 앱과 동료 Mac마다 찍을 한 줄짜리 결과 형식을, 에이전트와 권한 상태까지 넣어서 설계해.
Hint
실행 파일에 바이트를 덧붙이면 서명의 코드 해시가 깨지니까, codesign --verify --deep --strict가 엄격 검증 에러를 내며 대기 사본을 거부해. find <경로들> -newer <번들> -print -quit는 더 새로운 경로를 처음 찾으면 그걸 찍고 멈춰.

Progress

Progress is local-only — sign in to sync across devices.
이 페이지에서 버그를 발견하셨거나 피드백이 있으세요?문제 신고

댓글 0

🔔 답글 알림 (로그인 필요)
로그인 — 댓글을 남기려면 로그인해 주세요.

아직 댓글이 없어요. 첫 댓글을 남겨보세요.