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:
parent
8f7d300b89
commit
3beba99668
11 changed files with 776 additions and 2291 deletions
|
|
@ -86,6 +86,8 @@ package-windows:
|
|||
- python --version
|
||||
- npm ci
|
||||
script:
|
||||
# 0) 태그 → package.json 버전 동기화 (설치파일명/latest.yml 일치)
|
||||
- node scripts/ci/sync-version.mjs
|
||||
# 1) Python sidecar 의존성 + PyInstaller 빌드
|
||||
- python -m pip install --upgrade pip
|
||||
- python -m pip install pyinstaller
|
||||
|
|
@ -97,7 +99,7 @@ package-windows:
|
|||
- powershell -ExecutionPolicy Bypass -File apps/desktop/scripts/download-ollama.ps1
|
||||
# 4) electron-vite 빌드 + electron-builder NSIS 패키징
|
||||
- npm run build --workspace=@d3ro/desktop
|
||||
- npm run dist --workspace=@d3ro/desktop
|
||||
- npm run dist:win --workspace=@d3ro/desktop
|
||||
artifacts:
|
||||
name: "d3ro-voice-windows-${CI_COMMIT_SHORT_SHA}"
|
||||
# win-unpacked/ (수GB)은 제외하고 최종 .exe 설치파일 + 메타만 업로드.
|
||||
|
|
@ -114,8 +116,61 @@ package-windows:
|
|||
allow_failure: true
|
||||
|
||||
# ────────────────────────────────────────────────────────────────────
|
||||
# Release — 같은 Windows runner에서 GitLab API를 curl로 호출해 Release 생성.
|
||||
# release-cli Docker 이미지 대신 shell+curl로 처리하여 Docker runner 불필요.
|
||||
# Package macOS — arm64 무서명 dmg+zip. (agent-switchboard 파이프라인 패턴 이식)
|
||||
# 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:
|
||||
stage: release
|
||||
|
|
@ -124,20 +179,12 @@ release-create:
|
|||
- shell
|
||||
needs:
|
||||
- job: package-windows
|
||||
artifacts: false
|
||||
artifacts: true
|
||||
- job: package-macos
|
||||
artifacts: true
|
||||
optional: true
|
||||
resource_group: d3ro-voice-release
|
||||
script:
|
||||
- |
|
||||
$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
|
||||
- node scripts/ci/publish-gitlab-release.mjs
|
||||
rules:
|
||||
- if: $CI_COMMIT_TAG =~ /^v\d+\.\d+\.\d+/
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
173
apps/desktop/src/main/services/UpdateService.ts
Normal file
173
apps/desktop/src/main/services/UpdateService.ts
Normal 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 }
|
||||
15
apps/desktop/src/main/update-feed.ts
Normal file
15
apps/desktop/src/main/update-feed.ts
Normal 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 = ''
|
||||
95
docs/deployment/release-guide.md
Normal file
95
docs/deployment/release-guide.md
Normal 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)
|
||||
|
|
@ -2,11 +2,27 @@
|
|||
|
||||
> 마지막 갱신: 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 백엔드)
|
||||
- 병렬 트랙: `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단계 부트스트랩 ✅
|
||||
- **기본 STT 모델**: `base` → `large-v3-turbo` (CONFIG_DEFAULTS). 6배 빠름, 정확도 손실 1~2%, 1.6GB
|
||||
|
|
|
|||
2385
package-lock.json
generated
2385
package-lock.json
generated
File diff suppressed because it is too large
Load diff
230
scripts/ci/publish-gitlab-release.mjs
Normal file
230
scripts/ci/publish-gitlab-release.mjs
Normal 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()}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
34
scripts/ci/sync-version.mjs
Normal file
34
scripts/ci/sync-version.mjs
Normal 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}`)
|
||||
Loading…
Add table
Add a link
Reference in a new issue