-
Notifications
You must be signed in to change notification settings - Fork 21
389 lines (356 loc) · 17.9 KB
/
Copy pathrelease-macos.yml
File metadata and controls
389 lines (356 loc) · 17.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
name: Release macOS
# macOS 유니버설 DMG 빌드 → 서명 → 공증 → draft 릴리즈 첨부
# - main의 npm version 커밋: draft 릴리즈 생성/갱신. Windows workflow 완료 후 사람이 publish
# - workflow_dispatch(dry_run=true): 릴리즈 없이 빌드·검증만, 산출물은 workflow artifact
# - workflow_dispatch(dry_run=false): 현재 버전을 수동 릴리즈
# - 인증서는 이 워크플로가 직접 임시 키체인에 import (Tauri의 APPLE_CERTIFICATE 사용 금지 —
# beforeBundleCommand 훅 scripts/sign-macos-helper.sh가 번들 전에 키체인에서 identity를 찾음)
on:
push:
branches:
- main
paths:
- package.json
workflow_dispatch:
inputs:
dry_run:
description: "릴리즈 생성 없이 빌드·서명·공증·검증만 수행 (산출물은 workflow artifact)"
type: boolean
default: true
permissions:
contents: read
jobs:
preflight:
name: 실행 모드·버전 검사
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
contents: write # 빌드 전에 원격 릴리스 태그 생성
outputs:
version: ${{ steps.check.outputs.version }}
release: ${{ steps.check.outputs.release }}
should_build: ${{ steps.check.outputs.should_build }}
release_sha: ${{ steps.check.outputs.release_sha }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
persist-credentials: false
# 누락 시 맥 러너에서 빌드를 한참 돌린 뒤에야 실패하므로 여기서 먼저 검사 (값은 출력하지 않음)
- name: 필수 시크릿 확인
env:
MACOS_CERT_P12_BASE64: ${{ secrets.MACOS_CERT_P12_BASE64 }}
MACOS_CERT_PASSWORD: ${{ secrets.MACOS_CERT_PASSWORD }}
APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }}
APPLE_API_ISSUER_ID: ${{ secrets.APPLE_API_ISSUER_ID }}
APPLE_API_KEY_P8_BASE64: ${{ secrets.APPLE_API_KEY_P8_BASE64 }}
run: |
set -euo pipefail
MISSING=""
for name in MACOS_CERT_P12_BASE64 MACOS_CERT_PASSWORD APPLE_API_KEY_ID APPLE_API_ISSUER_ID APPLE_API_KEY_P8_BASE64; do
[ -n "${!name}" ] || MISSING="$MISSING $name"
done
if [ -n "$MISSING" ]; then
echo "::error::시크릿 누락:$MISSING — docs/release_macos.md 1절 참고"
exit 1
fi
echo "필수 시크릿 5종 확인"
- id: check
env:
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
REF_TYPE: ${{ github.ref_type }}
REF_NAME: ${{ github.ref_name }}
EVENT_NAME: ${{ github.event_name }}
DRY_RUN: ${{ inputs.dry_run }}
BEFORE_SHA: ${{ github.event.before }}
CURRENT_SHA: ${{ github.sha }}
run: |
set -euo pipefail
PKG="$(node -p "require('./package.json').version")"
CONF="$(node -p "require('./src-tauri/tauri.conf.json').version")"
CARGO="$(grep -m1 '^version = ' src-tauri/Cargo.toml | sed -E 's/version = "(.*)"/\1/')"
echo "package.json=$PKG tauri.conf.json=$CONF Cargo.toml=$CARGO"
if [ "$PKG" != "$CONF" ] || [ "$PKG" != "$CARGO" ]; then
echo "::error::버전 불일치 — npm run sync-version 실행 후 다시 커밋"
exit 1
fi
RELEASE=false
SHOULD_BUILD=false
RELEASE_SHA="$CURRENT_SHA"
if [ "$EVENT_NAME" = "push" ]; then
if [ "$REF_TYPE" != "branch" ] || [ "$REF_NAME" != "$DEFAULT_BRANCH" ]; then
echo "::error::버전 릴리즈는 기본 브랜치($DEFAULT_BRANCH) push에서만 허용"
exit 1
fi
SUBJECT="$(git log -1 --format=%s "$CURRENT_SHA")"
PREVIOUS_PKG="$(git show "$BEFORE_SHA:package.json" 2>/dev/null | node -e "let s='';process.stdin.on('data',d=>s+=d).on('end',()=>process.stdout.write(JSON.parse(s).version))" || true)"
if [ "$SUBJECT" = "$PKG" ] && [ -n "$PREVIOUS_PKG" ] && [ "$PREVIOUS_PKG" != "$PKG" ]; then
RELEASE=true
SHOULD_BUILD=true
echo "npm version 커밋 감지: $PREVIOUS_PKG -> $PKG"
else
echo "package.json 변경이 npm version 커밋이 아님 — 릴리즈 빌드 생략 (subject=$SUBJECT previous=$PREVIOUS_PKG current=$PKG)"
fi
elif [ "$EVENT_NAME" = "workflow_dispatch" ]; then
if [ "$REF_TYPE" != "branch" ] || [ "$REF_NAME" != "$DEFAULT_BRANCH" ]; then
echo "::error::수동 실행은 기본 브랜치($DEFAULT_BRANCH)에서만 가능"
exit 1
fi
SHOULD_BUILD=true
if [ "$DRY_RUN" != "true" ]; then
RELEASE=true
RELEASE_SHA="$(git log --format='%H%x09%s' -- package.json | awk -F '\t' -v expected="$PKG" '$2 == expected && !found { print $1; found=1 }')"
if [ -z "$RELEASE_SHA" ]; then
echo "::error::버전 커밋 '$PKG'를 찾을 수 없음"
exit 1
fi
RELEASE_PKG="$(git show "$RELEASE_SHA:package.json" | node -e "let s='';process.stdin.on('data',d=>s+=d).on('end',()=>process.stdout.write(JSON.parse(s).version))")"
if [ "$RELEASE_PKG" != "$PKG" ]; then
echo "::error::버전 커밋 검증 실패: sha=$RELEASE_SHA package=$RELEASE_PKG expected=$PKG"
exit 1
fi
fi
else
echo "::error::지원하지 않는 이벤트: $EVENT_NAME"
exit 1
fi
echo "version=$PKG" >> "$GITHUB_OUTPUT"
echo "release=$RELEASE" >> "$GITHUB_OUTPUT"
echo "should_build=$SHOULD_BUILD" >> "$GITHUB_OUTPUT"
echo "release_sha=$RELEASE_SHA" >> "$GITHUB_OUTPUT"
echo "release=$RELEASE should_build=$SHOULD_BUILD release_sha=$RELEASE_SHA"
- name: 릴리스 태그 확인 / 생성
if: steps.check.outputs.release == 'true'
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ steps.check.outputs.version }}
TARGET: ${{ steps.check.outputs.release_sha }}
run: |
set -euo pipefail
resolve_tag_commit() {
local commit
if commit="$(gh api "repos/${GITHUB_REPOSITORY}/commits/${TAG}" --jq .sha 2>/dev/null)"; then
printf '%s\n' "$commit"
fi
}
EXISTING="$(resolve_tag_commit)"
if [ -n "$EXISTING" ]; then
if [ "$EXISTING" != "$TARGET" ]; then
echo "::error::원격 태그 $TAG 대상 불일치: actual=$EXISTING expected=$TARGET"
exit 1
fi
echo "기존 원격 태그 확인: $TAG -> $TARGET"
exit 0
fi
if ! gh api --method POST "repos/${GITHUB_REPOSITORY}/git/refs" \
-f "ref=refs/tags/${TAG}" -f "sha=${TARGET}" >/dev/null; then
echo "태그 생성 경합 감지 — 원격 태그 재확인"
EXISTING="$(resolve_tag_commit)"
if [ "$EXISTING" != "$TARGET" ]; then
echo "::error::원격 태그 생성 실패: tag=$TAG actual=${EXISTING:-missing} expected=$TARGET"
exit 1
fi
fi
echo "원격 태그 생성 완료: $TAG -> $TARGET"
build-macos:
name: 유니버설 빌드·서명·공증
needs: preflight
if: needs.preflight.outputs.should_build == 'true'
# 실제 릴리즈만 직렬화. 일반 package.json 변경의 사전 검사는 대기시키지 않음
concurrency:
group: release-macos
cancel-in-progress: false
runs-on: macos-15
# 콜드 빌드(~45분) + 앱 공증(Tauri 내부, 타임아웃 없음) + DMG 공증(60분)이 직렬로 쌓임
timeout-minutes: 180
permissions:
contents: write # draft 릴리즈 생성·자산 업로드
env:
VERSION: ${{ needs.preflight.outputs.version }}
BUNDLE_DIR: src-tauri/target/universal-apple-darwin/release/bundle
steps:
# 이 job은 서명 키 + contents:write를 보유 — npm/cargo 스크립트에 토큰을 남기지 않음
- uses: actions/checkout@v4
with:
ref: ${{ needs.preflight.outputs.release_sha }}
persist-credentials: false
# build.rs의 헬퍼 빌드(xcrun swiftc/lipo)는 실패해도 cargo:warning만 내므로 먼저 명시 확인
- name: Xcode 도구 확인
run: |
set -euo pipefail
xcode-select -p
xcodebuild -version
xcrun --sdk macosx swiftc --version
xcrun notarytool --version
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
# @stable은 가변 브랜치라 SHA 핀 (서명 키를 쥔 job에서 서드파티 액션 탈취 방지). SHA 갱신 시 toolchain 입력 유지
- uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable, 2026-08-05
with:
toolchain: stable
targets: aarch64-apple-darwin,x86_64-apple-darwin
- uses: Swatinem/rust-cache@v2
with:
workspaces: src-tauri -> target
cache-on-failure: true
- run: npm ci
# set -x 금지 — security 인자에 암호가 노출됨
- name: 서명용 임시 키체인 구성
env:
MACOS_CERT_P12_BASE64: ${{ secrets.MACOS_CERT_P12_BASE64 }}
MACOS_CERT_PASSWORD: ${{ secrets.MACOS_CERT_PASSWORD }}
EXPECTED_IDENTITY: ${{ secrets.MACOS_SIGNING_IDENTITY }}
run: |
set -euo pipefail
KEYCHAIN_PATH="$RUNNER_TEMP/dmnote-signing.keychain-db"
KEYCHAIN_PASSWORD="$(openssl rand -base64 24)"
echo "::add-mask::$KEYCHAIN_PASSWORD"
CERT_PATH="$RUNNER_TEMP/dmnote-cert.p12"
printf '%s' "$MACOS_CERT_P12_BASE64" | base64 --decode > "$CERT_PATH"
chmod 600 "$CERT_PATH"
security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
security set-keychain-settings "$KEYCHAIN_PATH" # 인자 없음 = 자동 잠금 없음
security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
security import "$CERT_PATH" -k "$KEYCHAIN_PATH" -f pkcs12 \
-P "$MACOS_CERT_PASSWORD" -T /usr/bin/codesign -T /usr/bin/security
# 키체인 항목 덤프가 로그에 남지 않도록 stdout만 숨김
security set-key-partition-list -S apple-tool:,apple:,codesign: \
-s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" >/dev/null
rm -f "$CERT_PATH"
# -s는 검색 목록을 치환하므로 기존 목록(login.keychain-db 등)을 보존해 합침
EXISTING=()
while IFS= read -r line; do
line="$(printf '%s' "$line" | sed -E 's/^[[:space:]]*"?//; s/"?[[:space:]]*$//')"
[ -n "$line" ] && EXISTING+=("$line")
done < <(security list-keychains -d user)
# bash 3.2에서 빈 배열 + set -u 조합 방어
security list-keychains -d user -s "$KEYCHAIN_PATH" "${EXISTING[@]+"${EXISTING[@]}"}"
# identity는 시크릿이 아니라 키체인에서 파생 — 인증서 갱신 시 드리프트 방지
IDENTITY="$(security find-identity -v -p codesigning "$KEYCHAIN_PATH" \
| sed -n 's/.*"\(Developer ID Application: .*\)"$/\1/p' | head -1)"
if [ -z "$IDENTITY" ]; then
echo "::error::임시 키체인에 Developer ID Application identity 없음 (.p12에 개인 키 포함 여부 확인)"
exit 1
fi
if [ -n "${EXPECTED_IDENTITY:-}" ] && [ "$IDENTITY" != "$EXPECTED_IDENTITY" ]; then
echo "::error::signing identity 불일치 — 키체인='$IDENTITY'"
exit 1
fi
echo "signing identity: $IDENTITY"
echo "APPLE_SIGNING_IDENTITY=$IDENTITY" >> "$GITHUB_ENV"
- name: 공증 API 키 배치
env:
APPLE_API_KEY_P8_BASE64: ${{ secrets.APPLE_API_KEY_P8_BASE64 }}
run: |
set -euo pipefail
KEY_PATH="$RUNNER_TEMP/AuthKey.p8"
printf '%s' "$APPLE_API_KEY_P8_BASE64" | base64 --decode > "$KEY_PATH"
chmod 600 "$KEY_PATH"
grep -q "BEGIN PRIVATE KEY" "$KEY_PATH" || { echo "::error::.p8 디코딩 결과가 개인 키가 아님"; exit 1; }
echo "APPLE_API_KEY_PATH=$KEY_PATH" >> "$GITHUB_ENV"
# APPLE_SIGNING_IDENTITY / APPLE_API_KEY_PATH는 앞 스텝의 GITHUB_ENV로 주입됨
# APPLE_API_ISSUER는 App Store Connect "팀 키"에만 존재 — 개인 키는 사용 불가
# TAURI_BUNDLER_DMG_IGNORE_CI: CI=true면 Tauri가 DMG 스크립트에 --skip-jenkins를 넘겨
# Finder 아이콘 배치(.DS_Store)를 건너뜀 → 로컬과 동일한 DMG 외형을 위해 해제
- name: 유니버설 빌드 (서명 + 앱 공증 + staple)
env:
APPLE_API_KEY: ${{ secrets.APPLE_API_KEY_ID }}
APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER_ID }}
TAURI_BUNDLER_DMG_IGNORE_CI: "1"
run: npm run tauri:build:mac:universal
# spctl은 파이프에 물리면 종료 코드가 가려지므로 단독 실행
- name: 앱 번들 검증
run: |
set -euo pipefail
APP="$BUNDLE_DIR/macos/DM NOTE.app"
bash scripts/verify-macos-bundle.sh "$APP"
codesign --verify --deep --strict --verbose=2 "$APP"
xcrun stapler validate "$APP"
spctl -a -vvv -t exec "$APP"
# Tauri는 DMG를 서명만 하므로 DMG 자체 공증·staple은 여기서 수행
- name: DMG 공증 + staple
env:
APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }}
APPLE_API_ISSUER_ID: ${{ secrets.APPLE_API_ISSUER_ID }}
run: |
set -euo pipefail
DMG="$BUNDLE_DIR/dmg/DM NOTE_${VERSION}_universal.dmg"
if [ ! -f "$DMG" ]; then
echo "::error::DMG 없음: $DMG"
ls -la "$BUNDLE_DIR/dmg" || true
exit 1
fi
# notarytool은 Invalid/타임아웃 시 비정상 종료 — set -e에 죽지 않게 종료 코드를 잡아 진단 로그를 남김
set +e
RESULT="$(xcrun notarytool submit "$DMG" \
--key "$APPLE_API_KEY_PATH" --key-id "$APPLE_API_KEY_ID" --issuer "$APPLE_API_ISSUER_ID" \
--wait --timeout 60m --output-format json)"
SUBMIT_RC=$?
set -e
SUBMISSION_ID="$(jq -r '.id // empty' <<<"$RESULT" 2>/dev/null || true)"
STATUS="$(jq -r '.status // "Unknown"' <<<"$RESULT" 2>/dev/null || echo Unknown)"
echo "submission=${SUBMISSION_ID:-?} status=$STATUS rc=$SUBMIT_RC"
if [ "$SUBMIT_RC" -ne 0 ] || [ "$STATUS" != "Accepted" ]; then
[ -n "$SUBMISSION_ID" ] && xcrun notarytool log "$SUBMISSION_ID" \
--key "$APPLE_API_KEY_PATH" --key-id "$APPLE_API_KEY_ID" --issuer "$APPLE_API_ISSUER_ID" || true
echo "::error::DMG 공증 실패: status=$STATUS rc=$SUBMIT_RC"
exit 1
fi
xcrun stapler staple "$DMG"
xcrun stapler validate "$DMG"
spctl -a -vvv -t open --context context:primary-signature "$DMG"
# 자산 이름은 GitHub의 공백→점 치환에 맡기지 않고 고정
# — 앱 내 자동 업데이트(update_macos.rs asset_candidates)가 이 이름에 의존
ASSET_PATH="$RUNNER_TEMP/DM.NOTE_${VERSION}_universal.dmg"
cp "$DMG" "$ASSET_PATH"
shasum -a 256 "$ASSET_PATH"
echo "ASSET_PATH=$ASSET_PATH" >> "$GITHUB_ENV"
- name: 산출물 업로드 (드라이런)
if: needs.preflight.outputs.release != 'true'
uses: actions/upload-artifact@v4
with:
name: dmnote-macos-universal-${{ needs.preflight.outputs.version }}
path: ${{ env.ASSET_PATH }}
if-no-files-found: error
retention-days: 7
# 기존 릴리즈가 있으면 DMG만 덮어쓰기 — 노트·draft 상태·Windows workflow 자산 보존
- name: draft 릴리즈 생성 / DMG 업로드
if: needs.preflight.outputs.release == 'true'
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ needs.preflight.outputs.version }}
run: |
set -euo pipefail
upload_to_existing_release() {
# 이미 publish된 릴리즈의 자산 교체는 수동으로만 (사용자가 받아간 파일이 바뀌는 것 방지)
if [ "$(gh release view "$TAG" --json isDraft --jq .isDraft)" != "true" ]; then
echo "::error::릴리즈 $TAG 는 이미 publish됨 — 자동 덮어쓰기 거부. 필요하면 gh release upload --clobber 로 수동 교체"
exit 1
fi
echo "기존 draft 릴리즈 발견 — DMG 덮어쓰기"
gh release upload "$TAG" "$ASSET_PATH" --clobber
}
if gh release view "$TAG" >/dev/null 2>&1; then
upload_to_existing_release
else
# 본문은 버전만으로 생성 — Windows workflow와 같은 스크립트를 써야 경합에서 어느 쪽이
# 이기든 같은 본문이 남는다. 자산만 덮어쓰는 경로에서는 실행하지 않는다
# (그 경로의 checkout에는 이 스크립트가 없을 수 있음)
NOTES_PATH="$RUNNER_TEMP/release-notes-${VERSION}.md"
node scripts/build-release-notes.js "$VERSION" > "$NOTES_PATH"
if ! gh release create "$TAG" "$ASSET_PATH" \
--verify-tag --draft --title "DM NOTE v $VERSION" --notes-file "$NOTES_PATH"; then
echo "draft 생성 경합 감지 — 기존 릴리즈 재확인"
gh release view "$TAG" >/dev/null
upload_to_existing_release
fi
fi
gh release view "$TAG" --json isDraft,name,assets \
--jq '"draft=\(.isDraft) name=\(.name) assets=\([.assets[].name] | join(", "))"'
- name: 임시 키체인·키 정리
if: always()
run: |
security delete-keychain "$RUNNER_TEMP/dmnote-signing.keychain-db" 2>/dev/null || true
rm -f "$RUNNER_TEMP/AuthKey.p8" "$RUNNER_TEMP/dmnote-cert.p12"