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

@ -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 = ''