Electron macOS 코드서명과 CI 검증
TL;DR
electron-builder.yml에mac.identity가 있어도 CI 러너 키체인에 인증서가 없으면 macOS 앱은 서명되지 않는다.- electron-builder 24.13.3의 macOS 서명은 기본적으로 실패 조건이 아니라 선택 동작이다. 인증서를 찾지 못해도 빌드는 성공할 수 있다.
- 빌드 성공, 코드 서명 성공, 공증 성공은 서로 다른 판정이다. CI에서 산출물을 직접 검사해야 한다.
afterSign훅에서 공증 실패를catch로 삼키면 로그는 성공처럼 보이고 배포 파일은 검증되지 않은 상태로 올라간다.
Electron 앱 배포 파이프라인은 보통 세 단계를 한 번에 묶는다.
- 앱을 패키징한다.
- macOS 앱 번들에 코드 서명을 붙인다.
- Apple 공증을 통과시킨 뒤 산출물을 업로드한다.
문제는 세 단계가 같은 성공 조건을 공유하지 않는다는 점이다. yarn build가 0으로 끝났다고 해서 .app이 서명됐다는 뜻은 아니고, .dmg가 만들어졌다고 해서 Gatekeeper가 통과시킨다는 뜻도 아니다.
설정 파일과 러너 환경
설정 파일에는 macOS 서명에 필요한 값이 들어 있었다.
afterSign: "build/mac/notarize.js"
mac:
hardenedRuntime: true
entitlements: "build/mac/entitlements.mac.plist"
identity: "Example Inc. (TEAMID1234)"
notarize: true
mac.identity는 키체인에서 찾을 인증서 이름이다. 로컬 맥에는 인증서가 있으니 로컬 빌드는 정상적으로 보인다. GitHub Actions의 macOS 러너는 매 실행마다 새 환경이고, 인증서를 직접 import하지 않으면 키체인에 해당 identity가 없다. 즉 설정 파일의 identity는 “이 인증서로 서명하라”는 참조일 뿐이다. 인증서 자체를 CI에 넣어 주는 동작은 별도 단계다.
echo "$CERTIFICATE_BASE64" | base64 --decode > certificate.p12
security create-keychain -p "$KEYCHAIN_PASSWORD" build.keychain
security default-keychain -s build.keychain
security unlock-keychain -p "$KEYCHAIN_PASSWORD" build.keychain
security import certificate.p12 \
-k build.keychain \
-P "$CERTIFICATE_PASSWORD" \
-T /usr/bin/codesign
security set-key-partition-list \
-S apple-tool:,apple:,codesign: \
-s \
-k "$KEYCHAIN_PASSWORD" \
build.keychain
마지막 set-key-partition-list는 헤드리스 러너에서 중요하다. 이 설정이 없으면 codesign이 private key를 사용할 때 암호 입력 프롬프트를 요구할 수 있고, CI에서는 그 프롬프트에 응답할 수 없다.
빌드 성공과 서명 성공
electron-builder 24.13.3의 macOS 서명 로직은 인증서를 찾지 못했을 때 항상 실패로 처리하지 않는다.
if (isMas || isForceCodeSigning) {
throw new Error("skipped macOS application code signing");
} else {
log.warn(logFields, "skipped macOS application code signing");
}
Mac App Store 빌드이거나 forceCodeSigning이 켜져 있으면 예외를 던진다. 그렇지 않으면 경고만 남기고 서명 단계를 건너뛴다. 이 기본값은 의도된 동작에 가깝다. PR 빌드, 오픈소스 빌드, 로컬 확인 빌드처럼 인증서를 갖지 않은 환경에서도 패키징 자체는 가능해야 한다. 그래서 “서명되지 않으면 실패해야 한다”는 요구가 있다면 설정과 CI 검증에서 별도로 강제해야 한다.
mac:
forceCodeSigning: true
다만 이 값만으로 충분하다고 보면 안 된다. 서명 강제는 빌드 중단 조건을 만드는 설정이고, 배포 산출물이 실제로 어떤 상태인지 확인하는 일은 여전히 별도의 검증 단계가 담당해야 한다.
afterSign 훅의 실패 삼킴
공증은 afterSign 훅으로 직접 호출하고 있었다.
const appleId = process.env.APPLE_ID;
const applePassword = process.env.APPLE_PASSWORD;
const teamId = process.env.APPLE_TEAM_ID;
try { await notarize({ appBundleId, appPath, appleId, appleIdPassword: applePassword, teamId, }); } catch (error) { console.error(error); }
console.log(`Done notarizing ${appId}`);
이 코드는 실패를 배포 실패로 만들지 않는다. notarize()가 예외를 던져도 catch에서 출력만 하고 다시 던지지 않기 때문이다. 마지막 Done notarizing 로그도 항상 찍힌다. CI 로그만 보면 공증 훅이 호출됐고 마지막 로그까지 찍힌다. 하지만 종료 코드는 0이고, electron-builder 입장에서는 훅이 정상 종료된 것처럼 보인다. 공증 실패는 배포 실패여야 한다. 훅에서는 예외를 삼키지 말고 다시 던져야 한다.
try {
await notarize(options);
} catch (error) {
console.error(error);
throw error;
}
더 단순하게는 try/catch 자체를 제거하고, 실패가 그대로 상위 프로세스까지 올라가게 두는 편이 낫다.
환경변수 이름의 이중화
내장 공증과 커스텀 훅이 서로 다른 환경변수 이름을 보고 있었다.
// electron-builder 내장 공증 경로
process.env.APPLE_ID;
process.env.APPLE_APP_SPECIFIC_PASSWORD;
process.env.APPLE_TEAM_ID;
// 커스텀 afterSign 훅
process.env.APPLE_ID;
process.env.APPLE_PASSWORD;
process.env.APPLE_TEAM_ID;
APPLE_PASSWORD는 커스텀 훅이 직접 읽는 이름이라 훅 안에서는 동작할 수 있다. 하지만 electron-builder의 내장 공증 경로는 APPLE_APP_SPECIFIC_PASSWORD를 읽는다. 같은 값을 두 경로에 기대고 있다면 이름이 갈리는 순간 한쪽은 자격 증명을 받지 못한다. 서명과 공증은 민감한 배포 조건이므로 환경변수 이름을 한 벌로 고정하는 편이 낫다. 내장 기능을 쓸 거면 electron-builder가 기대하는 이름을 그대로 쓰고, 커스텀 훅을 유지할 거면 훅에서도 같은 이름을 읽게 맞춘다.
const applePassword = process.env.APPLE_APP_SPECIFIC_PASSWORD;
검증 명령의 위치
CI에 필요한 검증은 빌드 명령 뒤, 업로드 명령 앞에 있어야 한다.
codesign --verify --deep --strict --verbose=2 "dist/mac-universal/App.app"
codesign -dvv "dist/mac-universal/App.app"
spctl --assess --type execute --verbose=2 "dist/mac-universal/App.app"
각 명령이 확인하는 범위는 다르다.
| 명령 | 확인하는 것 |
|---|---|
codesign --verify |
앱 번들의 코드 서명 유효성 |
codesign -dvv |
어떤 identity로 서명됐는지 |
spctl --assess |
Gatekeeper 평가 결과 |
배포 파이프라인에서 중요한 순서는 이렇다.
build
-> codesign 검증
-> spctl 검증
-> notarization 검증
-> upload
업로드가 먼저 오면 CI의 초록불은 “파일을 만들고 올렸다”까지만 보장한다. 사용자가 받는 파일이 서명됐는지는 산출물 검사 없이는 알 수 없다.
배포 파이프라인의 판정 분리
이 문제의 핵심은 electron-builder의 설정 문법이 아니다. 배포 파이프라인에서 서로 다른 판정을 하나의 초록불로 합쳐 버린 것이다.
| 판정 | 실패 조건 | CI에서 필요한 확인 |
|---|---|---|
| 패키징 | 앱 번들 생성 실패 | electron-builder 종료 코드 |
| 코드 서명 | identity 누락, 인증서 import 실패 | codesign --verify, codesign -dvv |
| 공증 | Apple 자격 증명 누락, notarize 실패 | 공증 명령의 non-zero exit |
| 배포 | 산출물 업로드 실패 | 업로드 명령 종료 코드 |
설정 파일에 identity가 있고, 공증 훅이 있고, CI가 성공했다는 사실만으로는 배포 가능한 macOS 앱을 만들었다고 볼 수 없다. 각 단계의 산출물 속성을 직접 검사해야 한다.
정리
Electron macOS 배포에서 build succeeded는 최종 판정이 아니다. 패키징은 성공했지만 서명이 빠질 수 있고, 공증 훅은 호출됐지만 실패를 삼킬 수 있다. 배포 전에 최소한 세 가지를 분리해 확인해야 한다.
- CI 러너 키체인에 인증서가 import됐는가
- 만들어진
.app이 기대한 identity로 서명됐는가 - 공증 실패가 빌드 실패로 전파되는가
이 셋을 확인하지 않으면 CI의 초록불은 사용자에게 전달되는 파일의 상태를 설명하지 못한다.