예전에 Alacritty를 포크하고, 유용하지만 업스트림 메인테이너가 받아들이지 않으려는 패치 몇 가지를 병합했습니다. 상하좌우를 각각 설정할 수 있는 padding, 선택 영역이 어긋나는 문제 수정, Big Sur 스타일 아이콘 등이었습니다. 여기에 제 변경 사항도 몇 가지 더했습니다.
기존 CI는 빌드 결과물만 만들었고, 서명이나 공증은 하지 않았습니다. 친구들에게 앱을 보내면 Mac에 Apple이 악성 코드 포함 여부를 확인할 수 없어 열 수 없다는 경고가 떴습니다. 매번 수동으로 허용해야 하니 불편했습니다.
마침 유료 개발자 계정이 있어서 빌드한 앱의 서명과 공증을 CI 안에서 모두 처리하기로 했습니다. 그 과정에서 시행착오를 꽤 겪어 글로 남깁니다. 구체적인 코드는 PR에 있습니다.
서명과 공증
서명은 개발자 인증서로 앱에 도장을 찍는 것과 같습니다. 누가 배포했는지 나타내고, 서명 후 앱이 바뀌었는지도 확인할 수 있게 합니다. 공증은 서명한 앱을 Apple에 제출하는 절차입니다. Apple이 자동 검사한 뒤 문제가 없으면 그 결과를 기록합니다. macOS의 Gatekeeper는 인터넷에서 다운로드한 앱을 열 때 서명과 공증 여부를 확인합니다. 이 둘을 갖추면 앞서 겪었던 경고를 피할 수 있습니다.
CI에서 빌드, 서명, 공증을 차례로 처리한 다음, DMG로 패키징해 업로드하면 됩니다.
CI에서 인증서 가져오기
서명에는 Developer ID Application 인증서가 필요합니다. 먼저 로컬 키체인에서 인증서와 개인 키를 함께 .p12로 내보내고, base64로 인코딩해 저장소의 Secrets에 넣습니다. 인증서와 관련해 저장한 값은 인증서 자체, .p12의 비밀번호, 임시 키체인의 비밀번호까지 세 가지입니다.
CI를 실행할 때마다 임시 키체인을 만들고 인증서를 가져옵니다. 사용이 끝나면 그 키체인을 삭제합니다.
여기서 set-key-partition-list 줄은 빠뜨리면 안 됩니다. 이 줄이 없으면 codesign이 개인 키를 사용할 때 macOS가 허용 여부를 묻는 대화상자를 띄웁니다. CI에는 버튼을 눌러 줄 사람이 없어 빌드가 계속 멈춰 있게 됩니다. (뼈아픈 교훈😭)
작업 마지막에는 정리 단계도 넣었습니다. if: always()를 지정해 앞 단계의 성공 여부와 관계없이 임시 키체인을 삭제합니다.
빌드 전에는 security find-identity -v -p codesigning 명령도 추가했습니다. 사용 가능한 서명 ID를 로그에 출력해 디버깅하기 편합니다.
Makefile 편집
Alacritty의 Makefile은 .app을 패키징할 때 codesign --sign - 명령으로 ad hoc 서명을 합니다. 인증서를 쓰지 않는 서명이므로 Gatekeeper가 신뢰하는 Developer ID 서명으로 인정되지 않습니다. 그래서 제 인증서를 사용하도록 바꿨습니다.
MAKE
@codesign --force --deep --entitlements $(APP_ENTITLEMENTS) --options runtime --timestamp --sign "Developer ID Application: Bojin Li (TEAMID)" -v "$(APP_DIR)/$(APP_NAME)"
여기에는 공증과 관련된 옵션 세 가지가 있습니다. --options runtime은 Hardened Runtime을 활성화하고, --timestamp는 Apple 타임스탬프 서비스의 타임스탬프를 붙입니다. 둘 다 공증에 필수이므로 하나라도 빠지면 공증이 거부됩니다. --entitlements는 권한 선언 파일을 지정합니다.
또한 Info.plist의 CFBundleIdentifier를 업스트림의 org.alacritty에서 제가 쓰는 xxxbrian.alacritty로 바꿨습니다.
Entitlements
Hardened Runtime을 활성화한 앱이 카메라, 마이크, 연락처, 캘린더, 위치 정보, 사진 등 보호된 리소스에 접근하려면 서명 시 해당 entitlement 선언이 필요합니다. 선언하지 않으면 접근이 거부됩니다.
터미널은 조금 특별합니다. 터미널 자체는 이런 리소스를 쓰지 않아도, 안에서 실행되는 프로그램은 쓸 수 있습니다. 카메라에 접근하는 명령줄 도구가 한 예입니다. 이때 권한은 터미널을 기준으로 처리됩니다. 따라서 권한을 선언하지 않으면 해당 도구가 제 Alacritty 안에서 리소스에 접근할 수 없습니다. 그래서 macos-entitlement.plist를 추가해 자주 쓰는 권한을 선언했습니다.
서명이 끝난 유니버설 .app을 공증에 제출합니다. notarytool 호출을 직접 작성하는 대신 lando/notarize-action을 사용했습니다. Apple ID, 앱 전용 비밀번호, Team ID를 제공하면 됩니다. 앱 전용 비밀번호는 appleid.apple.com에서 별도로 생성해야 하며, Apple ID 자체의 비밀번호는 사용할 수 없습니다.
공증을 통과하면 공증 티켓을 앱에 첨부합니다. 이 과정을 staple이라고 하며, 저는 BoundfoxStudios/action-xcode-staple을 사용했습니다. 티켓을 첨부해 두면 사용자가 오프라인에서도 앱의 공증 여부를 검증할 수 있습니다.
Alacritty의 Makefile에서 DMG 타깃은 .app 타깃에 의존합니다. 따라서 make dmg-universal을 실행하면 .app을 다시 빌드하며, 파일 복사와 서명도 다시 수행합니다. 하지만 서명, 공증, staple은 이미 앞 단계에서 끝난 상태입니다.
처음에는 .app에 의존하지 않는 dmg-universal-exist 타깃을 추가해 기존 .app을 그대로 패키징하려고 했습니다. 그런데 작성한 규칙이 기존 규칙과 거의 같았고 의존 관계도 남아 있어 아무 효과가 없었습니다. 결국 이 타깃을 삭제하고 make dmg-universal로 되돌렸습니다. PR의 마지막 fix: corrected DMG creation process 커밋이 바로 이 변경입니다.
Important
여기서는 당시 PR의 최종 상태를 기록했을 뿐, 이 패키징 순서를 권장하는 것은 아닙니다. make dmg-universal은 앱 빌드와 서명을 다시 수행하므로, 앞 단계에서 첨부한 공증 티켓이 그대로 남아 있다고 가정하면 안 됩니다. 서명, 공증, staple을 마친 앱을 다시 빌드하지 않고 그대로 패키징하는 편이 낫습니다. 배포 전에는 최종 DMG에서 실제 배포할 앱을 꺼내 글 끝의 검증 명령을 실행해야 합니다.
마무리
최종 릴리스 흐름은 이렇습니다. 태그를 푸시하면 CI가 macOS에서 x86과 ARM 버전을 빌드해 유니버설 바이너리로 합치고, 제 인증서로 서명합니다. 이어서 공증과 staple을 거친 뒤 DMG로 패키징해 GitHub Release에 업로드합니다.
예전에 Alacritty를 포크하고, 유용하지만 업스트림 메인테이너가 받아들이지 않으려는 패치 몇 가지를 병합했습니다. 상하좌우를 각각 설정할 수 있는 padding, 선택 영역이 어긋나는 문제 수정, Big Sur 스타일 아이콘 등이었습니다. 여기에 제 변경 사항도 몇 가지 더했습니다.
기존 CI는 빌드 결과물만 만들었고, 서명이나 공증은 하지 않았습니다. 친구들에게 앱을 보내면 Mac에 Apple이 악성 코드 포함 여부를 확인할 수 없어 열 수 없다는 경고가 떴습니다. 매번 수동으로 허용해야 하니 불편했습니다.
마침 유료 개발자 계정이 있어서 빌드한 앱의 서명과 공증을 CI 안에서 모두 처리하기로 했습니다. 그 과정에서 시행착오를 꽤 겪어 글로 남깁니다. 구체적인 코드는 PR에 있습니다.
서명과 공증
서명은 개발자 인증서로 앱에 도장을 찍는 것과 같습니다. 누가 배포했는지 나타내고, 서명 후 앱이 바뀌었는지도 확인할 수 있게 합니다. 공증은 서명한 앱을 Apple에 제출하는 절차입니다. Apple이 자동 검사한 뒤 문제가 없으면 그 결과를 기록합니다. macOS의 Gatekeeper는 인터넷에서 다운로드한 앱을 열 때 서명과 공증 여부를 확인합니다. 이 둘을 갖추면 앞서 겪었던 경고를 피할 수 있습니다.
CI에서 빌드, 서명, 공증을 차례로 처리한 다음, DMG로 패키징해 업로드하면 됩니다.
CI에서 인증서 가져오기
서명에는 Developer ID Application 인증서가 필요합니다. 먼저 로컬 키체인에서 인증서와 개인 키를 함께 .p12로 내보내고, base64로 인코딩해 저장소의 Secrets에 넣습니다. 인증서와 관련해 저장한 값은 인증서 자체, .p12의 비밀번호, 임시 키체인의 비밀번호까지 세 가지입니다.
CI를 실행할 때마다 임시 키체인을 만들고 인증서를 가져옵니다. 사용이 끝나면 그 키체인을 삭제합니다.
여기서 set-key-partition-list 줄은 빠뜨리면 안 됩니다. 이 줄이 없으면 codesign이 개인 키를 사용할 때 macOS가 허용 여부를 묻는 대화상자를 띄웁니다. CI에는 버튼을 눌러 줄 사람이 없어 빌드가 계속 멈춰 있게 됩니다. (뼈아픈 교훈😭)
작업 마지막에는 정리 단계도 넣었습니다. if: always()를 지정해 앞 단계의 성공 여부와 관계없이 임시 키체인을 삭제합니다.
빌드 전에는 security find-identity -v -p codesigning 명령도 추가했습니다. 사용 가능한 서명 ID를 로그에 출력해 디버깅하기 편합니다.
Makefile 편집
Alacritty의 Makefile은 .app을 패키징할 때 codesign --sign - 명령으로 ad hoc 서명을 합니다. 인증서를 쓰지 않는 서명이므로 Gatekeeper가 신뢰하는 Developer ID 서명으로 인정되지 않습니다. 그래서 제 인증서를 사용하도록 바꿨습니다.
여기에는 공증과 관련된 옵션 세 가지가 있습니다. --options runtime은 Hardened Runtime을 활성화하고, --timestamp는 Apple 타임스탬프 서비스의 타임스탬프를 붙입니다. 둘 다 공증에 필수이므로 하나라도 빠지면 공증이 거부됩니다. --entitlements는 권한 선언 파일을 지정합니다.
또한 Info.plist의 CFBundleIdentifier를 업스트림의 org.alacritty에서 제가 쓰는 xxxbrian.alacritty로 바꿨습니다.
Entitlements
Hardened Runtime을 활성화한 앱이 카메라, 마이크, 연락처, 캘린더, 위치 정보, 사진 등 보호된 리소스에 접근하려면 서명 시 해당 entitlement 선언이 필요합니다. 선언하지 않으면 접근이 거부됩니다.
터미널은 조금 특별합니다. 터미널 자체는 이런 리소스를 쓰지 않아도, 안에서 실행되는 프로그램은 쓸 수 있습니다. 카메라에 접근하는 명령줄 도구가 한 예입니다. 이때 권한은 터미널을 기준으로 처리됩니다. 따라서 권한을 선언하지 않으면 해당 도구가 제 Alacritty 안에서 리소스에 접근할 수 없습니다. 그래서 macos-entitlement.plist를 추가해 자주 쓰는 권한을 선언했습니다.
공증과 staple
서명이 끝난 유니버설 .app을 공증에 제출합니다. notarytool 호출을 직접 작성하는 대신 lando/notarize-action을 사용했습니다. Apple ID, 앱 전용 비밀번호, Team ID를 제공하면 됩니다. 앱 전용 비밀번호는 appleid.apple.com에서 별도로 생성해야 하며, Apple ID 자체의 비밀번호는 사용할 수 없습니다.
공증을 통과하면 공증 티켓을 앱에 첨부합니다. 이 과정을 staple이라고 하며, 저는 BoundfoxStudios/action-xcode-staple을 사용했습니다. 티켓을 첨부해 두면 사용자가 오프라인에서도 앱의 공증 여부를 검증할 수 있습니다.
DMG 생성
가장 까다로웠던 부분은 마지막 DMG였습니다.
Alacritty의 Makefile에서 DMG 타깃은 .app 타깃에 의존합니다. 따라서 make dmg-universal을 실행하면 .app을 다시 빌드하며, 파일 복사와 서명도 다시 수행합니다. 하지만 서명, 공증, staple은 이미 앞 단계에서 끝난 상태입니다.
처음에는 .app에 의존하지 않는 dmg-universal-exist 타깃을 추가해 기존 .app을 그대로 패키징하려고 했습니다. 그런데 작성한 규칙이 기존 규칙과 거의 같았고 의존 관계도 남아 있어 아무 효과가 없었습니다. 결국 이 타깃을 삭제하고 make dmg-universal로 되돌렸습니다. PR의 마지막 fix: corrected DMG creation process 커밋이 바로 이 변경입니다.
여기서는 당시 PR의 최종 상태를 기록했을 뿐, 이 패키징 순서를 권장하는 것은 아닙니다. make dmg-universal은 앱 빌드와 서명을 다시 수행하므로, 앞 단계에서 첨부한 공증 티켓이 그대로 남아 있다고 가정하면 안 됩니다. 서명, 공증, staple을 마친 앱을 다시 빌드하지 않고 그대로 패키징하는 편이 낫습니다. 배포 전에는 최종 DMG에서 실제 배포할 앱을 꺼내 글 끝의 검증 명령을 실행해야 합니다.
마무리
최종 릴리스 흐름은 이렇습니다. 태그를 푸시하면 CI가 macOS에서 x86과 ARM 버전을 빌드해 유니버설 바이너리로 합치고, 제 인증서로 서명합니다. 이어서 공증과 staple을 거친 뒤 DMG로 패키징해 GitHub Release에 업로드합니다.
빌드한 앱은 로컬에서 검증할 수 있습니다.
명령 spctl이 accepted를 출력하고 source=Notarized Developer ID를 표시하면 Gatekeeper 검사를 통과한 것입니다.