feat(release): switchboard 릴리스 체계 이식 — Mac 배포 + Registry 게시 + 자동 업데이트

- package-macos CI job: arm64 무서명(ad-hoc) dmg+zip, D3RO_MAC_RUNNER 변수 게이트
- sync-version.mjs: 태그 → package.json 버전 동기화
- publish-gitlab-release.mjs: Generic Package Registry(버전별+latest) 업로드 + Release 생성
- electron-builder: publish generic(latest.yml 생성), 공백 없는 artifactName, mac arm64 단일화
- UpdateService: electron-updater 4h 주기 체크 + 재시작 다이얼로그 (update-feed.ts SSOT)
- @rollup/rollup-win32-x64-msvc → optionalDependencies (mac npm ci EBADPLATFORM 해소)
- docs/deployment/release-guide.md: runner 등록·feed 설정·파일명 규칙 가이드

리뷰 워크플로(3관점 적대적 검증)로 확정된 결함 8건 반영:
publish 부재로 latest.yml 미생성, 파일명 공백 vs 레지스트리 404,
mac EBADPLATFORM, needs optional의 실패 미커버, DELETE 권한 의존,
CHANGELOG regex 경계, --arm64 무시, 다이얼로그 parent 부재
This commit is contained in:
Yun Chan 2026-07-21 12:43:16 +09:00
parent 8f7d300b89
commit 3beba99668
11 changed files with 776 additions and 2291 deletions

View file

@ -15,6 +15,18 @@ files:
- out/**/*
- "!out/**/*.map"
# ────────────────────────────────────────────────────────────────────
# 자동 업데이트 feed — 이 설정이 있어야 electron-builder가
# latest.yml / latest-mac.yml (update info)를 생성한다.
# generic provider는 파일 생성만 트리거하고 직접 업로드는 하지 않음
# (업로드는 scripts/ci/publish-gitlab-release.mjs 담당).
# 첫 릴리스 후 projects/0을 실제 프로젝트 ID로 교체할 것 —
# src/main/update-feed.ts의 UPDATE_FEED_URL과 반드시 동일 값 유지.
# ────────────────────────────────────────────────────────────────────
publish:
provider: generic
url: "https://gitlab.twentyoz.kr:8443/api/v4/projects/0/packages/generic/d3ro-voice/latest"
# native 모듈은 asar 외부에 배치
asarUnpack:
- "node_modules/better-sqlite3/**"
@ -37,15 +49,18 @@ win:
# ────────────────────────────────────────────────────────────────────
mac:
category: public.app-category.productivity
# 파일명 공백 금지 — GitLab Generic Package Registry가 공백을 불허해
# latest-mac.yml의 url과 레지스트리 업로드명이 어긋나면 업데이트 다운로드 404.
artifactName: "D3RO-Voice-${version}-${arch}.${ext}"
# x64 제외 — 사이드카(PyInstaller)가 arm64 전용이라 x64 산출물은 깨진 빌드가 됨.
# Intel Mac 수요 발생 시 x64 runner에서 별도 빌드.
target:
- target: dmg
arch:
- arm64
- x64
- target: zip
arch:
- arm64
- x64
hardenedRuntime: true
gatekeeperAssess: false
entitlements: build/entitlements.mac.plist
@ -104,6 +119,8 @@ extraResources:
- "**/*"
nsis:
# 파일명 공백 금지 — mac.artifactName 주석과 동일한 이유 (latest.yml url 정합)
artifactName: "D3RO-Voice-Setup-${version}-${arch}.${ext}"
# oneClick=false + perMachine=false 조합이 System.dll 0xc0000005 크래시 사례(#7921).
# 사내 테스트 단계에선 oneClick=true로 전환하여 크래시 경로 회피.
oneClick: true

View file

@ -16,6 +16,8 @@
"test:watch": "vitest --config vitest.config.ts",
"pack": "electron-vite build && electron-builder --dir",
"dist": "electron-vite build && electron-builder --config electron-builder.yml",
"dist:win": "electron-vite build && electron-builder --win --config electron-builder.yml --publish never",
"dist:mac": "electron-vite build && electron-builder --mac --config electron-builder.yml --publish never -c.mac.identity=- -c.mac.hardenedRuntime=false",
"build:sidecar": "python scripts/build-sidecar.py",
"setup:sox": "powershell -ExecutionPolicy Bypass -File scripts/download-sox.ps1"
},
@ -49,13 +51,13 @@
"@mui/icons-material": "^7.0.0",
"@mui/material": "^7.0.0",
"@nut-tree-fork/nut-js": "^4.2.6",
"@rollup/rollup-win32-x64-msvc": "^4.60.1",
"better-sqlite3": "^12.8.0",
"docx": "^9.6.1",
"drizzle-orm": "^0.45.2",
"electron-audio-loopback": "^1.0.6",
"electron-log": "^5.2.0",
"electron-store": "^10.0.0",
"electron-updater": "^6.3.9",
"fluent-ffmpeg": "^2.1.3",
"node-record-lpcm16": "^1.0.1",
"pdf-parse": "^2.4.5",
@ -64,5 +66,8 @@
"react-markdown": "^10.1.0",
"remark-gfm": "^4.0.1",
"uiohook-napi": "^1.5.5"
},
"optionalDependencies": {
"@rollup/rollup-win32-x64-msvc": "^4.60.1"
}
}

View file

@ -66,6 +66,7 @@ export async function bootstrap(): Promise<void> {
{ name: 'meeting-mode', critical: false, fn: initMeetingMode },
// cloud-sync는 가장 마지막에 — 세션 복원 성공 시 DB를 열고 auth-changed emit.
{ name: 'cloud-sync', critical: false, fn: initCloudSync },
{ name: 'auto-update', critical: false, fn: initAutoUpdate },
]
for (const step of steps) {
@ -139,6 +140,11 @@ async function initAutoLaunch(): Promise<void> {
getAutoLaunchService().syncWithConfig()
}
async function initAutoUpdate(): Promise<void> {
const { getUpdateService } = await import('./services/UpdateService')
getUpdateService().initialize()
}
async function initPopupWindows(): Promise<void> {
preloadPopupWindows()
setupHistoryPopupIPC()

View file

@ -0,0 +1,173 @@
// src/main/services/UpdateService.ts
// electron-updater 기반 자동 업데이트. 싱글톤 + EventEmitter.
//
// feed: GitLab Generic Package Registry `d3ro-voice/latest` (update-feed.ts SSOT,
// release-create CI 잡이 갱신). 다음 조건이면 조용히 비활성:
// - dev 실행 (app.isPackaged=false)
// - Windows가 아닌 플랫폼 (macOS 무서명 빌드는 Squirrel.Mac 서명 요구로 미지원)
// - UPDATE_FEED_URL 미설정
import { EventEmitter } from 'events'
import { app, dialog } from 'electron'
import { getLogger } from './LoggerService'
import { configGet } from './ConfigService'
import { getMainWindow } from '../windows/WindowManager'
import { UPDATE_FEED_URL } from '../update-feed'
const logger = getLogger('UpdateService')
/** 앱 시작 후 첫 체크까지 지연 — 초기화 경합(모델 로딩 등) 회피 */
const INITIAL_CHECK_DELAY_MS = 30_000
/** 주기 체크 간격 */
const CHECK_INTERVAL_MS = 4 * 60 * 60 * 1000
export interface UpdateServiceEvents {
'update-available': { version: string }
'update-downloaded': { version: string }
'update-error': { message: string }
}
class UpdateService extends EventEmitter {
private _initialized = false
private _initialTimer: NodeJS.Timeout | null = null
private _intervalTimer: NodeJS.Timeout | null = null
private _promptShown = false
/** 자동 업데이트 시작. 비활성 조건이면 로그만 남기고 no-op. */
initialize(): void {
if (this._initialized) return
this._initialized = true
if (!app.isPackaged) {
logger.info('dev 실행 — 자동 업데이트 비활성')
return
}
if (process.platform !== 'win32') {
logger.info(`플랫폼 ${process.platform} — 자동 업데이트 미지원 (서명 필요)`)
return
}
if (!UPDATE_FEED_URL) {
logger.info('UPDATE_FEED_URL 미설정 — 자동 업데이트 비활성')
return
}
// electron-updater는 CommonJS — 동적 require로 로드 (미설치/로드 실패 시 비활성)
let autoUpdater: import('electron-updater').AppUpdater
try {
// eslint-disable-next-line @typescript-eslint/no-require-imports
autoUpdater = (require('electron-updater') as typeof import('electron-updater')).autoUpdater
} catch (err) {
logger.warn(
`electron-updater 로드 실패 — 자동 업데이트 비활성: ${err instanceof Error ? err.message : String(err)}`,
)
return
}
autoUpdater.setFeedURL({ provider: 'generic', url: UPDATE_FEED_URL })
autoUpdater.autoDownload = true
autoUpdater.autoInstallOnAppQuit = true
autoUpdater.logger = {
info: (msg: unknown) => logger.info(String(msg)),
warn: (msg: unknown) => logger.warn(String(msg)),
error: (msg: unknown) => logger.error(String(msg)),
debug: (msg: unknown) => logger.debug(String(msg)),
}
autoUpdater.on('update-available', (info) => {
logger.info(`업데이트 발견: ${info.version}`)
this.emit('update-available', { version: info.version })
})
autoUpdater.on('update-downloaded', (info) => {
logger.info(`업데이트 다운로드 완료: ${info.version}`)
this.emit('update-downloaded', { version: info.version })
void this._promptRestart(autoUpdater, info.version)
})
autoUpdater.on('error', (err) => {
// 네트워크 오류/401 등은 조용히 로그만 (다음 주기에 재시도)
logger.warn(`업데이트 체크 실패: ${err.message}`)
this.emit('update-error', { message: err.message })
})
const check = (): void => {
autoUpdater.checkForUpdates().catch((err: unknown) => {
logger.warn(
`checkForUpdates 실패: ${err instanceof Error ? err.message : String(err)}`,
)
})
}
this._initialTimer = setTimeout(check, INITIAL_CHECK_DELAY_MS)
this._intervalTimer = setInterval(check, CHECK_INTERVAL_MS)
logger.info(`자동 업데이트 활성 — feed: ${UPDATE_FEED_URL}`)
}
dispose(): void {
if (this._initialTimer) clearTimeout(this._initialTimer)
if (this._intervalTimer) clearInterval(this._intervalTimer)
this._initialTimer = null
this._intervalTimer = null
}
/** 다운로드 완료 시 재시작 여부 다이얼로그 (세션당 1회) */
private async _promptRestart(
autoUpdater: import('electron-updater').AppUpdater,
version: string,
): Promise<void> {
if (this._promptShown) return
this._promptShown = true
const isKo = (configGet('language') as string | undefined)?.startsWith('ko') ?? true
const options = {
type: 'info' as const,
title: isKo ? '업데이트 준비 완료' : 'Update Ready',
message: isKo
? `D3RO Voice ${version} 업데이트가 다운로드되었습니다. 지금 재시작하여 적용할까요?`
: `D3RO Voice ${version} has been downloaded. Restart now to apply?`,
buttons: isKo ? ['지금 재시작', '나중에'] : ['Restart Now', 'Later'],
defaultId: 0,
cancelId: 1,
}
// 메인 윈도우가 살아있으면 parent로 붙여 포커스 스틸 방지 (트레이 상태면 독립 표시)
const win = getMainWindow()
const { response } =
win && !win.isDestroyed()
? await dialog.showMessageBox(win, options)
: await dialog.showMessageBox(options)
if (response === 0) {
autoUpdater.quitAndInstall()
}
// '나중에' — autoInstallOnAppQuit=true라 종료 시 자동 적용
}
// ── 타입 안전한 이벤트 메서드 오버라이드 ──
override emit<K extends keyof UpdateServiceEvents>(
event: K,
payload: UpdateServiceEvents[K],
): boolean {
return super.emit(event, payload)
}
override on<K extends keyof UpdateServiceEvents>(
event: K,
listener: (payload: UpdateServiceEvents[K]) => void,
): this {
return super.on(event, listener)
}
}
// ── 싱글톤 ──
let instance: UpdateService | null = null
export function getUpdateService(): UpdateService {
if (!instance) {
instance = new UpdateService()
}
return instance
}
export { UpdateService }

View file

@ -0,0 +1,15 @@
// src/main/update-feed.ts
// 자동 업데이트 feed URL SSOT.
//
// release-create CI 잡이 GitLab Generic Package Registry의
// `d3ro-voice/latest` 패키지에 latest.yml + 설치파일을 게시하며,
// electron-updater가 이 URL에서 latest.yml을 읽어 업데이트를 감지한다.
//
// 첫 릴리스 후 GitLab 프로젝트 ID를 확인해 기입할 것:
// https://gitlab.twentyoz.kr:8443/api/v4/projects/<PROJECT_ID>/packages/generic/d3ro-voice/latest
//
// 전제: 프로젝트 설정에서 "Allow anyone to pull from Package Registry" 활성화
// (비활성 시 무인증 다운로드가 401 — 앱은 조용히 업데이트 체크를 건너뜀).
//
// 빈 문자열이면 UpdateService가 비활성 상태로 동작한다.
export const UPDATE_FEED_URL = ''