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

@ -86,6 +86,8 @@ package-windows:
- python --version - python --version
- npm ci - npm ci
script: script:
# 0) 태그 → package.json 버전 동기화 (설치파일명/latest.yml 일치)
- node scripts/ci/sync-version.mjs
# 1) Python sidecar 의존성 + PyInstaller 빌드 # 1) Python sidecar 의존성 + PyInstaller 빌드
- python -m pip install --upgrade pip - python -m pip install --upgrade pip
- python -m pip install pyinstaller - python -m pip install pyinstaller
@ -97,7 +99,7 @@ package-windows:
- powershell -ExecutionPolicy Bypass -File apps/desktop/scripts/download-ollama.ps1 - powershell -ExecutionPolicy Bypass -File apps/desktop/scripts/download-ollama.ps1
# 4) electron-vite 빌드 + electron-builder NSIS 패키징 # 4) electron-vite 빌드 + electron-builder NSIS 패키징
- npm run build --workspace=@d3ro/desktop - npm run build --workspace=@d3ro/desktop
- npm run dist --workspace=@d3ro/desktop - npm run dist:win --workspace=@d3ro/desktop
artifacts: artifacts:
name: "d3ro-voice-windows-${CI_COMMIT_SHORT_SHA}" name: "d3ro-voice-windows-${CI_COMMIT_SHORT_SHA}"
# win-unpacked/ (수GB)은 제외하고 최종 .exe 설치파일 + 메타만 업로드. # win-unpacked/ (수GB)은 제외하고 최종 .exe 설치파일 + 메타만 업로드.
@ -114,8 +116,61 @@ package-windows:
allow_failure: true allow_failure: true
# ──────────────────────────────────────────────────────────────────── # ────────────────────────────────────────────────────────────────────
# Release — 같은 Windows runner에서 GitLab API를 curl로 호출해 Release 생성. # Package macOS — arm64 무서명 dmg+zip. (agent-switchboard 파이프라인 패턴 이식)
# release-cli Docker 이미지 대신 shell+curl로 처리하여 Docker runner 불필요. # self-hosted mac runner (shell executor, tags: macos+arm64) 필요.
# 전제: brew install sox, Xcode CLT, Python 3.11+.
# 서명/공증은 후속 단계 (현재 notarize:false — 사용자는 우클릭→열기로 실행).
# ────────────────────────────────────────────────────────────────────
package-macos:
stage: package
tags:
- macos
- arm64
before_script:
# 무서명(ad-hoc) 빌드 강제 — runner에 인증서가 있어도 집어쓰지 않게 차단
# (agent-switchboard의 dist:mac:unsigned 검증된 조합)
- export CSC_IDENTITY_AUTO_DISCOVERY=false
- node --version
- python3 --version
- npm ci
script:
# 0) 태그 → package.json 버전 동기화
- node scripts/ci/sync-version.mjs
# 1) Python sidecar — PEP 668(externally-managed) 회피를 위해 venv 사용
- python3 -m venv apps/desktop/sidecar/.venv-ci
- apps/desktop/sidecar/.venv-ci/bin/pip install --upgrade pip
- apps/desktop/sidecar/.venv-ci/bin/pip install pyinstaller
- apps/desktop/sidecar/.venv-ci/bin/pip install -r apps/desktop/sidecar/requirements.txt
- apps/desktop/sidecar/.venv-ci/bin/python apps/desktop/scripts/build-sidecar.py
# 2) SoX 번들 (brew sox → resources/sox/ 복사 + dylib rpath 재배치)
- bash apps/desktop/scripts/install-sox.sh
# 3) Ollama 다운로드 (darwin tgz)
- bash apps/desktop/scripts/download-ollama.sh
# 4) electron-vite 빌드 + electron-builder dmg/zip (arm64)
- npm run dist:mac --workspace=@d3ro/desktop
artifacts:
name: "d3ro-voice-macos-${CI_COMMIT_SHORT_SHA}"
paths:
- apps/desktop/release/*/*.dmg
- apps/desktop/release/*/*.zip
- apps/desktop/release/*/latest-mac.yml
- apps/desktop/release/*/*.blockmap
expire_in: 90 days
rules:
# mac runner 등록 후 프로젝트 CI/CD 변수 D3RO_MAC_RUNNER="true" 설정 시에만 실행.
# 변수 미설정 시 잡이 생성되지 않아(release-create의 optional needs) 릴리스가 막히지 않는다.
# allow_failure: mac 잡이 실패해도(needs가 성공 취급) Windows 단독 릴리스로 진행.
- if: $CI_COMMIT_TAG =~ /^v\d+\.\d+\.\d+/ && $D3RO_MAC_RUNNER == "true"
allow_failure: true
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH && $D3RO_MAC_RUNNER == "true"
when: manual
allow_failure: true
# ────────────────────────────────────────────────────────────────────
# Release — artifacts를 Generic Package Registry(버전별 + latest)에 업로드하고
# GitLab Release + asset 링크를 생성. (agent-switchboard 패턴 이식)
# latest 패키지 경로는 electron-updater feed로 사용된다.
# macOS artifacts는 optional — mac runner 미등록/실패 시 Windows 단독 릴리스.
# ──────────────────────────────────────────────────────────────────── # ────────────────────────────────────────────────────────────────────
release-create: release-create:
stage: release stage: release
@ -124,20 +179,12 @@ release-create:
- shell - shell
needs: needs:
- job: package-windows - job: package-windows
artifacts: false artifacts: true
- job: package-macos
artifacts: true
optional: true
resource_group: d3ro-voice-release
script: script:
- | - node scripts/ci/publish-gitlab-release.mjs
$artifactUrl = "$env:CI_PROJECT_URL/-/jobs/artifacts/$env:CI_COMMIT_TAG/download?job=package-windows"
$description = "## D3RO Voice $env:CI_COMMIT_TAG`n`nWindows 설치 파일은 [package-windows 잡 artifacts]($artifactUrl)에서 받을 수 있습니다.`n`n- 기본 LLM 모델: ``gemma4:e4b`` (첫 실행 시 자동 다운로드, ~9.6GB)`n- Whisper 모델: ``large-v3`` (첫 전사 시 자동 다운로드, ~3GB)`n- 설치 마법사가 Visual C++ 재배포 패키지를 자동 다운로드/설치"
$body = @{
name = "D3RO Voice $env:CI_COMMIT_TAG"
tag_name = "$env:CI_COMMIT_TAG"
description = $description
} | ConvertTo-Json -Compress
Invoke-RestMethod `
-Uri "$env:CI_API_V4_URL/projects/$env:CI_PROJECT_ID/releases" `
-Method Post `
-Headers @{ "JOB-TOKEN" = "$env:CI_JOB_TOKEN" ; "Content-Type" = "application/json" } `
-Body $body
rules: rules:
- if: $CI_COMMIT_TAG =~ /^v\d+\.\d+\.\d+/ - if: $CI_COMMIT_TAG =~ /^v\d+\.\d+\.\d+/

View file

@ -15,6 +15,18 @@ files:
- out/**/* - out/**/*
- "!out/**/*.map" - "!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 외부에 배치 # native 모듈은 asar 외부에 배치
asarUnpack: asarUnpack:
- "node_modules/better-sqlite3/**" - "node_modules/better-sqlite3/**"
@ -37,15 +49,18 @@ win:
# ──────────────────────────────────────────────────────────────────── # ────────────────────────────────────────────────────────────────────
mac: mac:
category: public.app-category.productivity 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:
- target: dmg - target: dmg
arch: arch:
- arm64 - arm64
- x64
- target: zip - target: zip
arch: arch:
- arm64 - arm64
- x64
hardenedRuntime: true hardenedRuntime: true
gatekeeperAssess: false gatekeeperAssess: false
entitlements: build/entitlements.mac.plist entitlements: build/entitlements.mac.plist
@ -104,6 +119,8 @@ extraResources:
- "**/*" - "**/*"
nsis: nsis:
# 파일명 공백 금지 — mac.artifactName 주석과 동일한 이유 (latest.yml url 정합)
artifactName: "D3RO-Voice-Setup-${version}-${arch}.${ext}"
# oneClick=false + perMachine=false 조합이 System.dll 0xc0000005 크래시 사례(#7921). # oneClick=false + perMachine=false 조합이 System.dll 0xc0000005 크래시 사례(#7921).
# 사내 테스트 단계에선 oneClick=true로 전환하여 크래시 경로 회피. # 사내 테스트 단계에선 oneClick=true로 전환하여 크래시 경로 회피.
oneClick: true oneClick: true

View file

@ -16,6 +16,8 @@
"test:watch": "vitest --config vitest.config.ts", "test:watch": "vitest --config vitest.config.ts",
"pack": "electron-vite build && electron-builder --dir", "pack": "electron-vite build && electron-builder --dir",
"dist": "electron-vite build && electron-builder --config electron-builder.yml", "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", "build:sidecar": "python scripts/build-sidecar.py",
"setup:sox": "powershell -ExecutionPolicy Bypass -File scripts/download-sox.ps1" "setup:sox": "powershell -ExecutionPolicy Bypass -File scripts/download-sox.ps1"
}, },
@ -49,13 +51,13 @@
"@mui/icons-material": "^7.0.0", "@mui/icons-material": "^7.0.0",
"@mui/material": "^7.0.0", "@mui/material": "^7.0.0",
"@nut-tree-fork/nut-js": "^4.2.6", "@nut-tree-fork/nut-js": "^4.2.6",
"@rollup/rollup-win32-x64-msvc": "^4.60.1",
"better-sqlite3": "^12.8.0", "better-sqlite3": "^12.8.0",
"docx": "^9.6.1", "docx": "^9.6.1",
"drizzle-orm": "^0.45.2", "drizzle-orm": "^0.45.2",
"electron-audio-loopback": "^1.0.6", "electron-audio-loopback": "^1.0.6",
"electron-log": "^5.2.0", "electron-log": "^5.2.0",
"electron-store": "^10.0.0", "electron-store": "^10.0.0",
"electron-updater": "^6.3.9",
"fluent-ffmpeg": "^2.1.3", "fluent-ffmpeg": "^2.1.3",
"node-record-lpcm16": "^1.0.1", "node-record-lpcm16": "^1.0.1",
"pdf-parse": "^2.4.5", "pdf-parse": "^2.4.5",
@ -64,5 +66,8 @@
"react-markdown": "^10.1.0", "react-markdown": "^10.1.0",
"remark-gfm": "^4.0.1", "remark-gfm": "^4.0.1",
"uiohook-napi": "^1.5.5" "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 }, { name: 'meeting-mode', critical: false, fn: initMeetingMode },
// cloud-sync는 가장 마지막에 — 세션 복원 성공 시 DB를 열고 auth-changed emit. // cloud-sync는 가장 마지막에 — 세션 복원 성공 시 DB를 열고 auth-changed emit.
{ name: 'cloud-sync', critical: false, fn: initCloudSync }, { name: 'cloud-sync', critical: false, fn: initCloudSync },
{ name: 'auto-update', critical: false, fn: initAutoUpdate },
] ]
for (const step of steps) { for (const step of steps) {
@ -139,6 +140,11 @@ async function initAutoLaunch(): Promise<void> {
getAutoLaunchService().syncWithConfig() getAutoLaunchService().syncWithConfig()
} }
async function initAutoUpdate(): Promise<void> {
const { getUpdateService } = await import('./services/UpdateService')
getUpdateService().initialize()
}
async function initPopupWindows(): Promise<void> { async function initPopupWindows(): Promise<void> {
preloadPopupWindows() preloadPopupWindows()
setupHistoryPopupIPC() 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 = ''

View file

@ -0,0 +1,95 @@
# D3RO Voice 릴리스 가이드
agent-switchboard-client의 릴리스 체계를 이식한 멀티플랫폼 배포 파이프라인.
## 파이프라인 개요
```
git tag vX.Y.Z → push
├─ package-windows (runner: windows+shell) → NSIS exe + latest.yml
├─ package-macos (runner: macos+arm64) → dmg + zip + latest-mac.yml [D3RO_MAC_RUNNER="true"일 때만]
└─ release-create (runner: windows+shell)
├─ Generic Package Registry 업로드
│ ├─ d3ro-voice/<version>/ (버전별 보관)
│ └─ d3ro-voice/latest/ (electron-updater feed — 매 릴리스 갱신)
└─ GitLab Release 생성 + asset 링크
```
- 버전은 태그에서 자동 동기화된다 (`scripts/ci/sync-version.mjs``v0.2.0` → package.json `0.2.0`).
- macOS artifacts가 없으면 Windows 단독으로 릴리스가 생성된다 (경고 로그만).
## 릴리스 절차
```bash
git tag v0.2.0
git push origin v0.2.0
```
끝. 파이프라인이 패키징 → 레지스트리 업로드 → Release 생성까지 자동 수행한다.
## 사용자가 1회 설정해야 하는 것
### 1. macOS runner 등록 (Mac 빌드 활성화)
MacBook에서:
```bash
brew install gitlab-runner
gitlab-runner register \
--url https://gitlab.twentyoz.kr:8443 \
--executor shell \
--tag-list "macos,arm64"
# 등록 토큰: GitLab → d3r0/voice → Settings → CI/CD → Runners
brew services start gitlab-runner
```
Mac 환경 전제조건:
- Xcode Command Line Tools (`xcode-select --install`)
- Node 22+ (시스템 PATH)
- Python 3.11+ (`python3`)
- `brew install sox` — install-sox.sh가 이 바이너리를 번들함
등록 후 **GitLab → Settings → CI/CD → Variables**에 `D3RO_MAC_RUNNER` = `"true"` 추가.
(이 변수가 없으면 package-macos 잡이 생성되지 않아 릴리스가 Windows 단독으로 진행)
### 2. 자동 업데이트 활성화 (Windows)
1. GitLab → d3r0/voice → 프로젝트 홈에서 **Project ID** 확인
2. **두 곳**에 같은 feed URL 기입 (어긋나면 안 됨):
- `apps/desktop/src/main/update-feed.ts``UPDATE_FEED_URL`
- `apps/desktop/electron-builder.yml``publish.url` (`projects/0` → 실제 ID)
```
https://gitlab.twentyoz.kr:8443/api/v4/projects/<PROJECT_ID>/packages/generic/d3ro-voice/latest
```
`publish` 섹션은 latest.yml/latest-mac.yml **생성**을 트리거하는 필수 설정이다
(없으면 electron-builder가 update info 파일을 아예 만들지 않음).
3. GitLab → Settings → General → Visibility에서 **"Allow anyone to pull from Package Registry"** 활성화
(미설정 시 앱의 무인증 다운로드가 401 — UpdateService는 조용히 건너뜀)
4. 다음 태그부터 배포된 앱이 4시간 주기로 업데이트를 체크한다
### 산출물 파일명 규칙
파일명에 공백 금지 — GitLab Generic Package Registry가 공백을 불허해
latest.yml의 url과 레지스트리 파일명이 어긋나면 업데이트 다운로드가 404 난다.
- Windows: `D3RO-Voice-Setup-<version>-x64.exe`
- macOS: `D3RO-Voice-<version>-arm64.dmg` / `.zip`
## 알려진 제약 / 후속 단계
| 항목 | 현재 상태 | 후속 |
|---|---|---|
| macOS 서명/공증 | ❌ ad-hoc 서명 (`identity=-`, switchboard 검증 조합) — Gatekeeper가 차단하면 우클릭→열기 또는 `xattr -dr com.apple.quarantine "/Applications/D3RO Voice.app"` | Apple Developer 계정($99/년) 확보 시 switchboard의 prepare/cleanup-macos-keychain.mjs + 공증 패턴 이식. 주의: D3RO는 asarUnpack native 모듈 + ollama/sox 바이너리 다중 서명 필요 |
| macOS 자동 업데이트 | ❌ 미지원 (Squirrel.Mac이 서명 요구) | 서명 도입 후 latest-mac.yml feed 연결 |
| 업데이트 무결성 서명 | latest.yml SHA-512 (electron-builder 기본) | switchboard의 Ed25519 update-policy 매니페스트 체계 이식 검토 |
| CHANGELOG | 없음 — Release 노트는 기본 안내문 | `CHANGELOG.md` 작성 시 publish 스크립트가 해당 버전 섹션을 자동 추출 |
| mac x64 (Intel) | 빌드 안 함 (사이드카가 arm64 전용) | 수요 발생 시 Intel Mac runner 추가 |
## 참고 파일
- `.gitlab-ci.yml` — 파이프라인 정의
- `scripts/ci/sync-version.mjs` — 태그 → package.json 버전 동기화
- `scripts/ci/publish-gitlab-release.mjs` — 레지스트리 업로드 + Release 생성
- `apps/desktop/src/main/services/UpdateService.ts` — 자동 업데이트 (electron-updater)
- `apps/desktop/src/main/update-feed.ts` — feed URL SSOT
- 원본 패턴: `D:\workspace\agent-switchboard-client` (.gitlab-ci.yml, scripts/publish-gitlab-release.mjs)

View file

@ -2,11 +2,27 @@
> 마지막 갱신: 2026-07-21 (Whisper large-v3-turbo 전환 + 온보딩 2단계 부트스트랩) > 마지막 갱신: 2026-07-21 (Whisper large-v3-turbo 전환 + 온보딩 2단계 부트스트랩)
## 모델 현대화 트랙 (2026-07-21) — B 완료, A 진행 예정 ## 모델 현대화 트랙 (2026-07-21) — B 완료, A 완료(코드), C 완료(코드)
### 방향 결정 (사용자 컨펌) ### 방향 결정 (사용자 컨펌)
- **A+B 병행**: B(로컬 Whisper turbo 전환 + 부트스트랩 진행률 UI) 먼저 → A(OpenAI gpt-realtime-2.1 라이브 음성 대화 Premium 백엔드) - **A+B 병행**: B(로컬 Whisper turbo 전환 + 부트스트랩 진행률 UI) 먼저 → A(OpenAI gpt-realtime-2.1 라이브 음성 대화 Premium 백엔드)
- 병렬 트랙: `D:\workspace\agent-switchboard-client`에 D3ROVoice와 동일한 GitLab CI 배포 체계 구축 (백그라운드 에이전트 진행) - **C (방향 반전 확인됨)**: agent-switchboard-client의 릴리스 체계를 **D3RO로 이식** (원래 지시가 반대로 전달됐었음 — 대상은 이미 우수한 파이프라인 보유). Mac 배포 + Release 자동화 + 자동 업데이트.
### C 완료(코드): switchboard 릴리스 체계 이식 ✅
- **package-macos CI job**: arm64 무서명(ad-hoc, `identity=-` + hardenedRuntime=false + CSC_IDENTITY_AUTO_DISCOVERY=false — switchboard 검증 조합). 사이드카 venv PyInstaller + brew sox 번들 + Ollama darwin. `D3RO_MAC_RUNNER="true"` CI 변수 게이트 + allow_failure(mac 실패 시 Windows 단독 릴리스).
- **sync-version.mjs**: 태그 → apps/desktop/package.json 버전 동기화 (기존 "Setup 1.0.0.exe" 불일치 해소)
- **publish-gitlab-release.mjs**: 산출물을 Generic Package Registry 버전별+latest 업로드 + GitLab Release asset 링크 (기존 PowerShell curl 잡 대체). latest.yml 필수 검증, latest 삭제는 best-effort.
- **electron-builder.yml**: `publish: generic` 추가(latest.yml 생성 트리거 — 없으면 update info 미생성!), 공백 없는 artifactName(`D3RO-Voice-Setup-*`), mac arm64 단일화(x64 사이드카 불가)
- **UpdateService** (신규, 싱글톤+EventEmitter): electron-updater generic provider, 4h 주기, 다운로드 완료 시 재시작 다이얼로그(mainWindow parent). `update-feed.ts` SSOT — URL 미기입 시 비활성. Windows 전용(mac 무서명은 Squirrel.Mac 제약).
- **의존성**: electron-updater 추가, `@rollup/rollup-win32-x64-msvc`를 optionalDependencies로 이동(mac npm ci EBADPLATFORM 해소), lockfile에서 workspace 밖 RN 의존성 163개 정리
- **가이드**: `docs/deployment/release-guide.md` — mac runner 등록, feed URL 2곳 SSOT, 파일명 규칙, 후속(서명/공증/Ed25519) 정리
- **검증**: ultracode 리뷰 워크플로(16 에이전트, 3관점 → 적대적 검증)로 결함 8건(중복 제외) 확정 → 전부 수정. tsc 0 / vitest 41 / YAML·mjs OK.
#### C 남은 사용자 액션
1. MacBook에 gitlab-runner 등록 (tags: macos+arm64, brew sox/Xcode CLT/Python 3.11+) → CI 변수 `D3RO_MAC_RUNNER="true"`
2. 첫 릴리스 후 프로젝트 ID 확인 → `update-feed.ts` + `electron-builder.yml publish.url` 기입 (2곳 동일)
3. GitLab 설정 "Allow anyone to pull from Package Registry" 활성화
4. 다음 태그(v0.1.1-alpha 권장)로 전체 파이프라인 실측
### B 완료: Whisper large-v3-turbo 전환 + 온보딩 2단계 부트스트랩 ✅ ### B 완료: Whisper large-v3-turbo 전환 + 온보딩 2단계 부트스트랩 ✅
- **기본 STT 모델**: `base``large-v3-turbo` (CONFIG_DEFAULTS). 6배 빠름, 정확도 손실 1~2%, 1.6GB - **기본 STT 모델**: `base``large-v3-turbo` (CONFIG_DEFAULTS). 6배 빠름, 정확도 손실 1~2%, 1.6GB

2385
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,230 @@
// scripts/ci/publish-gitlab-release.mjs
// 태그 파이프라인의 release 스테이지에서 실행:
// 1) package-windows / package-macos artifacts를 GitLab Generic Package Registry에 업로드
// - 버전별 경로: packages/generic/d3ro-voice/<version>/<file>
// - latest 경로: packages/generic/d3ro-voice/latest/<file> (electron-updater feed)
// 2) GitLab Release 생성 + asset 링크 연결
//
// agent-switchboard-client의 publish-gitlab-release.mjs 패턴을 D3RO에 맞게 단순화.
// (Ed25519 update-policy 서명 체계는 후속 단계 — 현재는 latest.yml 기반)
//
// 필요 env: CI_API_V4_URL, CI_PROJECT_ID, CI_JOB_TOKEN, CI_COMMIT_TAG
import { createReadStream } from "node:fs";
import { readFile, readdir, stat } from "node:fs/promises";
import { basename, join } from "node:path";
import process from "node:process";
import { fileURLToPath, URL } from "node:url";
const apiUrl = required("CI_API_V4_URL").replace(/\/+$/, "");
const projectId = required("CI_PROJECT_ID");
const jobToken = required("CI_JOB_TOKEN");
const tag = required("CI_COMMIT_TAG");
const version = tag.replace(/^v/, "");
const packageName = "d3ro-voice";
if (!/^\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?$/.test(version)) {
throw new Error(`Unsupported release tag: ${tag}`);
}
// electron-builder output: apps/desktop/release/<version>/
const releaseDirectory = fileURLToPath(new URL(`../../apps/desktop/release/${version}/`, import.meta.url));
// 업로드 대상 수집 — 설치파일 + 업데이터 메타데이터만 (unpacked 디렉토리 제외)
const ASSET_PATTERN = /(\.exe|\.dmg|\.zip|\.blockmap|^latest(-mac|-linux)?\.yml)$/;
const files = [];
for (const name of await readdir(releaseDirectory)) {
if (!ASSET_PATTERN.test(name)) continue;
const path = join(releaseDirectory, name);
if ((await stat(path)).isFile()) files.push({ name, path });
}
if (files.length === 0) {
throw new Error(`No release assets found in ${releaseDirectory}`);
}
const hasWindows = files.some((file) => file.name.endsWith(".exe"));
if (!hasWindows) {
throw new Error(`Windows installer is missing for ${tag}.`);
}
// latest.yml은 자동 업데이트 feed의 핵심 — 없으면 electron-builder publish 설정 회귀
if (!files.some((file) => file.name === "latest.yml")) {
throw new Error(
`latest.yml is missing for ${tag} — check electron-builder.yml publish config.`,
);
}
const hasMac = files.some((file) => file.name.endsWith(".dmg"));
if (!hasMac) {
// mac runner 미등록/실패 시에도 Windows 단독 릴리스는 진행 (경고만)
process.stdout.write(`WARNING: macOS artifacts missing for ${tag} — Windows-only release.\n`);
}
const packageBaseUrl = `${apiUrl}/projects/${encodeURIComponent(projectId)}/packages/generic/${packageName}/${version}`;
const latestPackageBaseUrl = `${apiUrl}/projects/${encodeURIComponent(projectId)}/packages/generic/${packageName}/latest`;
const links = [];
const sortedFiles = files.sort((a, b) => a.name.localeCompare(b.name));
// 1) 버전별 패키지 업로드
for (const file of sortedFiles) {
const registryName = safeAssetName(file.name);
const uploadUrl = `${packageBaseUrl}/${encodeURIComponent(registryName)}`;
await uploadFile(file, uploadUrl, "versioned package");
links.push({
name: file.name,
url: uploadUrl,
direct_asset_path: `/${registryName}`,
link_type: isUpdateMetadata(file.name) ? "other" : "package",
});
}
// 2) latest 패키지 갱신 — 이전 latest 삭제 후 현재 버전 재업로드.
// 삭제는 best-effort — JOB-TOKEN에 패키지 삭제 권한이 없는 GitLab 설정에서도
// 릴리스가 막히지 않게 한다 (동일 파일명 재업로드 시 다운로드는 최신 파일 우선).
try {
await deletePackagesForVersion("latest");
} catch (err) {
process.stdout.write(
`WARNING: stale latest package cleanup failed (continuing): ${err instanceof Error ? err.message : String(err)}\n`,
);
}
for (const file of sortedFiles) {
const registryName = safeAssetName(file.name);
await uploadFile(file, `${latestPackageBaseUrl}/${encodeURIComponent(registryName)}`, "latest package");
}
// 3) GitLab Release 생성
const description = await buildDescription();
const releaseUrl = `${apiUrl}/projects/${encodeURIComponent(projectId)}/releases`;
const releaseResponse = await globalThis.fetch(releaseUrl, {
method: "POST",
headers: {
"JOB-TOKEN": jobToken,
"Content-Type": "application/json",
},
body: JSON.stringify({
name: `D3RO Voice ${tag}`,
tag_name: tag,
description,
assets: { links },
}),
});
if (!releaseResponse.ok) {
throw new Error(`Release creation failed: HTTP ${releaseResponse.status} ${await releaseResponse.text()}`);
}
process.stdout.write(`Created ${tag} with ${links.length} release assets.\n`);
// ── 헬퍼 ────────────────────────────────────────────────────
function required(name) {
const value = process.env[name]?.trim();
if (!value) throw new Error(`${name} is required.`);
return value;
}
function safeAssetName(name) {
return basename(name).replace(/[^A-Za-z0-9._-]+/g, "-");
}
function isUpdateMetadata(name) {
return /(?:\.blockmap$|^latest(?:-mac|-linux)?\.yml$)/.test(name);
}
async function buildDescription() {
// CHANGELOG.md의 해당 버전 섹션이 있으면 사용, 없으면 기본 안내문
try {
const changelog = await readFile(fileURLToPath(new URL("../../CHANGELOG.md", import.meta.url)), "utf8");
const section = extractChangelogSection(changelog, version);
if (section) return section;
} catch {
// CHANGELOG 없음 — 기본 안내문 사용
}
const lines = [
`## D3RO Voice ${tag}`,
"",
"아래 Assets에서 플랫폼별 설치 파일을 받으세요.",
"",
"- Windows: `D3RO Voice Setup *.exe`",
hasMac ? "- macOS (Apple Silicon): `*.dmg` — 무서명 빌드는 우클릭 → 열기로 실행" : null,
"",
"첫 실행 시 온보딩에서 AI 모델을 자동 다운로드합니다:",
"- LLM: `gemma4:e4b` (~9.6GB)",
"- Whisper: `large-v3-turbo` (~1.6GB)",
].filter((line) => line !== null);
return lines.join("\n");
}
function extractChangelogSection(changelog, targetVersion) {
const lines = changelog.split("\n");
// 버전 뒤 경계 필수 — "0.1.0"이 "0.1.0-alpha" 섹션에 오매칭되지 않게
const escaped = targetVersion.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const headerPattern = new RegExp(`^##\\s+\\[?v?${escaped}(?:\\]|\\s|$)`);
const start = lines.findIndex((line) => headerPattern.test(line));
if (start === -1) return null;
let end = lines.length;
for (let i = start + 1; i < lines.length; i++) {
if (/^##\s+/.test(lines[i])) {
end = i;
break;
}
}
return lines.slice(start, end).join("\n").trim();
}
async function uploadFile(file, uploadUrl, target) {
process.stdout.write(`Uploading ${file.name} to ${target}...\n`);
const response = await globalThis.fetch(uploadUrl, {
method: "PUT",
headers: { "JOB-TOKEN": jobToken },
body: createReadStream(file.path),
duplex: "half",
});
if (!response.ok) {
throw new Error(`${target} upload failed for ${file.name}: HTTP ${response.status} ${await response.text()}`);
}
}
async function listPackagesForVersion(packageVersion) {
const packages = [];
let page = "1";
while (page) {
const listUrl = new URL(`${apiUrl}/projects/${encodeURIComponent(projectId)}/packages`);
listUrl.searchParams.set("package_type", "generic");
listUrl.searchParams.set("package_name", packageName);
listUrl.searchParams.set("package_version", packageVersion);
listUrl.searchParams.set("per_page", "100");
listUrl.searchParams.set("page", page);
const response = await globalThis.fetch(listUrl, { headers: { "JOB-TOKEN": jobToken } });
if (!response.ok) {
throw new Error(`Package lookup failed for ${packageVersion}: HTTP ${response.status} ${await response.text()}`);
}
const rows = await response.json();
if (!Array.isArray(rows)) throw new Error(`Package lookup returned an invalid response for ${packageVersion}.`);
packages.push(
...rows.filter(
(row) => row?.package_type === "generic" && row?.name === packageName && row?.version === packageVersion,
),
);
page = response.headers.get("x-next-page")?.trim() ?? "";
}
return packages;
}
async function deletePackagesForVersion(packageVersion) {
const packages = await listPackagesForVersion(packageVersion);
for (const packageEntry of packages) {
process.stdout.write(`Deleting stale ${packageName}/${packageVersion} package ${packageEntry.id}...\n`);
const response = await globalThis.fetch(
`${apiUrl}/projects/${encodeURIComponent(projectId)}/packages/${encodeURIComponent(packageEntry.id)}`,
{ method: "DELETE", headers: { "JOB-TOKEN": jobToken } },
);
if (!response.ok) {
throw new Error(`Package deletion failed for ${packageVersion}: HTTP ${response.status} ${await response.text()}`);
}
}
}

View file

@ -0,0 +1,34 @@
// scripts/ci/sync-version.mjs
// CI_COMMIT_TAG(v0.1.0-alpha 등)에서 버전을 추출해 apps/desktop/package.json에 기록한다.
// electron-builder가 package.json version으로 설치파일명/latest.yml을 만들기 때문에
// 태그와 산출물 버전이 어긋나지 않게 패키징 전에 실행한다.
import { readFileSync, writeFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import { dirname, join } from 'node:path'
const tag = process.env.CI_COMMIT_TAG ?? process.argv[2]
if (!tag) {
console.log('[sync-version] CI_COMMIT_TAG 없음 — 버전 동기화 건너뜀')
process.exit(0)
}
const version = tag.replace(/^v/, '')
if (!/^\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?$/.test(version)) {
console.error(`[sync-version] 유효하지 않은 semver: ${version} (tag: ${tag})`)
process.exit(1)
}
const root = join(dirname(fileURLToPath(import.meta.url)), '..', '..')
const pkgPath = join(root, 'apps', 'desktop', 'package.json')
const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'))
if (pkg.version === version) {
console.log(`[sync-version] 이미 동기화됨: ${version}`)
process.exit(0)
}
const prev = pkg.version
pkg.version = version
writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n', 'utf8')
console.log(`[sync-version] apps/desktop version: ${prev}${version}`)