Some checks failed
deploy-site / deploy (push) Failing after 14m16s
The released installer could not start: it carried a better-sqlite3 build for the host Node runtime instead of Electron, so the app died immediately with a module version mismatch when it opened its database. Packaging now proves the Electron build of every runtime-sensitive native module before an installer or archive exists, and installers are produced only from that verified tree, so the mistake cannot pass silently. The release pipelines run the same check. The default local model also pointed at a retired model: a *.gguf name that Ollama cannot serve, while the settings, onboarding, and guide screens recommended an older model. All of them now use the model the service code already preferred.
433 lines
No EOL
15 KiB
JavaScript
433 lines
No EOL
15 KiB
JavaScript
// scripts/ci/build-portable.mjs
|
|
// 서명 없이 배포할 수 있는 휴대용 Windows 배포본을 만든다(7z 분할 볼륨 + Scoop 매니페스트).
|
|
//
|
|
// 왜 분할인가: canonical feed(git.chanpaca.net)는 Cloudflare 뒤에 있고 업로드 본문이
|
|
// ~100MiB(104,857,600 bytes)를 넘으면 413으로 거부한다(실측: 60MiB 201 / 110MiB 413).
|
|
// 사이드카(faster-whisper)를 포함한 앱은 그 한도를 넘으므로, 95MiB 단위 7z 볼륨으로
|
|
// 나눠 올리고 Scoop이 볼륨을 이어서 해제하도록 한다(Scoop은 .7z.001 볼을 공식 지원).
|
|
//
|
|
// 산출물:
|
|
// apps/desktop/release/<version>/D3RO-Voice-<version>-x64-portable.7z.001/.002/...
|
|
// apps/desktop/release/<version>/portable.json (볼륨 인덱스: 이름/크기/sha256)
|
|
// bucket/d3ro-voice.json (Scoop 매니페스트, 커밋 대상)
|
|
//
|
|
// zip 분할 부품(수동 설치용)도 함께 만든다 — Windows 내장 Expand-Archive로 해제할 수 있어
|
|
// 사용자에게 7-Zip 설치를 요구하지 않는다. 7z 볼륨은 Scoop 전용으로 더 작다(162MiB vs 243MiB).
|
|
//
|
|
// 사용:
|
|
// npm run build --workspace=@d3ro/desktop
|
|
// node scripts/ci/build-portable.mjs # 7z 분할 볼륨
|
|
// node scripts/ci/build-portable.mjs --zip # + 로컬 배포용 단일 zip(게시용 아님)
|
|
|
|
import { spawnSync } from 'node:child_process'
|
|
import { createHash } from 'node:crypto'
|
|
import {
|
|
existsSync,
|
|
readFileSync,
|
|
readdirSync,
|
|
mkdirSync,
|
|
renameSync,
|
|
rmSync,
|
|
statSync,
|
|
writeFileSync,
|
|
} from 'node:fs'
|
|
import { createRequire } from 'node:module'
|
|
import { createGzip } from 'node:zlib'
|
|
import { pipeline } from 'node:stream/promises'
|
|
import { createReadStream, createWriteStream } from 'node:fs'
|
|
import * as tar from 'tar'
|
|
import { dirname, join } from 'node:path'
|
|
import { fileURLToPath } from 'node:url'
|
|
|
|
const require = createRequire(import.meta.url)
|
|
const root = join(dirname(fileURLToPath(import.meta.url)), '..', '..')
|
|
const desktopDir = join(root, 'apps', 'desktop')
|
|
const version = JSON.parse(
|
|
readFileSync(join(root, 'release', 'product-version.json'), 'utf8'),
|
|
).version
|
|
|
|
const FEED = 'https://git.chanpaca.net/api/packages/yunchan/generic/d3ro-voice'
|
|
const PORTABLE_VERSION_PATH = `${FEED}/portable-${version}`
|
|
const PORTABLE_LATEST_PATH = `${FEED}/portable-latest`
|
|
const ARCHIVE_BASE = `D3RO-Voice-${version}-x64-portable`
|
|
/** Cloudflare 본문 한도(100MiB)보다 여유를 둔 7z 볼륨 크기 (Scoop 경로) */
|
|
const VOLUME_SIZE = '95m'
|
|
const MAX_VOLUME_BYTES = 95 * 1024 * 1024
|
|
/** 수동 설치 스크립트용 zip 분할 부품 크기 */
|
|
const ZIP_PART_SIZE = '90m'
|
|
const MAX_ZIP_PART_BYTES = 95 * 1024 * 1024
|
|
|
|
const wantZip = process.argv.includes('--zip')
|
|
const releaseDir = join(desktopDir, 'release', version)
|
|
const appDir = join(releaseDir, 'win-unpacked')
|
|
|
|
if (!existsSync(join(desktopDir, 'out', 'main', 'index.js'))) {
|
|
console.error(
|
|
'out/main/index.js 가 없습니다. 먼저 데스크톱 번들을 빌드하세요:\n' +
|
|
' npm run build --workspace=@d3ro/desktop',
|
|
)
|
|
process.exit(1)
|
|
}
|
|
|
|
if (!existsSync(join(desktopDir, 'sidecar-dist', 'sidecar'))) {
|
|
console.error(
|
|
'STT 사이드카 번들이 없습니다. 서명 없이 배포해도 로컬 전사에는 사이드카가 필요합니다:\n' +
|
|
' npm --prefix apps/desktop run sidecar:setup\n' +
|
|
' npm --prefix apps/desktop run sidecar:build',
|
|
)
|
|
process.exit(1)
|
|
}
|
|
|
|
/** 현재 스크립트와 같은 Node로 CI 스크립트를 실행한다 */
|
|
function runNodeScript(relativePath, scriptArgs) {
|
|
console.log(`[portable] $ node ${relativePath} ${scriptArgs.join(' ')}`)
|
|
const result = spawnSync(
|
|
process.execPath,
|
|
[join(root, relativePath), ...scriptArgs],
|
|
{ cwd: root, stdio: 'inherit' },
|
|
)
|
|
if (result.status !== 0) {
|
|
console.error(`[portable] ${relativePath} 실패 (exit ${result.status ?? 'null'})`)
|
|
process.exit(result.status ?? 1)
|
|
}
|
|
}
|
|
|
|
function resolve7za() {
|
|
// electron-builder가 의존하는 7zip-bin이 플랫폼별 7za 실행 파일을 제공한다.
|
|
const platformDir =
|
|
process.platform === 'win32'
|
|
? join('win', process.arch === 'arm64' ? 'arm64' : 'x64')
|
|
: process.platform === 'darwin'
|
|
? join('mac', process.arch === 'arm64' ? 'arm64' : 'x64')
|
|
: join('linux', process.arch === 'arm64' ? 'arm64' : 'x64')
|
|
const binary = process.platform === 'win32' ? '7za.exe' : '7za'
|
|
const candidate = join(root, 'node_modules', '7zip-bin', platformDir, binary)
|
|
if (!existsSync(candidate)) {
|
|
console.error(`7za를 찾을 수 없습니다: ${candidate}\n npm ci 후 다시 실행하세요.`)
|
|
process.exit(1)
|
|
}
|
|
return candidate
|
|
}
|
|
|
|
const target = process.argv.includes('--zip') ? 'zip' : 'dir'
|
|
console.log(
|
|
`[portable] electron-builder 빌드 (version ${version}, target=${target}, 서명 없음)`,
|
|
)
|
|
const targetArgs = target === 'zip' ? ['--win', 'zip'] : ['--win', 'dir']
|
|
const build = spawnSync(
|
|
'npx',
|
|
[
|
|
'electron-builder',
|
|
...targetArgs,
|
|
'--x64',
|
|
'--config',
|
|
'electron-builder.yml',
|
|
'--publish',
|
|
'never',
|
|
// 서명이 없으므로 NSIS 경로의 fail-closed 게이트를 이 채널에서만 명시적으로 해제한다.
|
|
// (자동 업데이트 피드가 아니라 별도 portable 경로로만 게시한다 — publish 스크립트 참조)
|
|
'-c.win.forceCodeSigning=false',
|
|
'-c.npmRebuild=false',
|
|
],
|
|
{ cwd: desktopDir, stdio: 'inherit', shell: process.platform === 'win32' },
|
|
)
|
|
|
|
if (build.status !== 0) {
|
|
console.error(`[portable] electron-builder 실패 (exit ${build.status ?? 'null'})`)
|
|
process.exit(build.status ?? 1)
|
|
}
|
|
|
|
if (!existsSync(appDir)) {
|
|
console.error(`[portable] win-unpacked가 없습니다: ${appDir}`)
|
|
process.exit(1)
|
|
}
|
|
|
|
// 네이티브 모듈 ABI 사고 방지: 호스트 Node ABI로 빌드된 모듈이 섞이면 설치본이 시작조차 못 한다.
|
|
// (실측 사고: better_sqlite3.node가 NODE_MODULE_VERSION 131 → Electron 130 요구)
|
|
runNodeScript('scripts/ci/fix-native-abi.mjs', ['--dir', appDir])
|
|
runNodeScript('scripts/ci/verify-native-abi.mjs', ['--dir', appDir])
|
|
|
|
// 이전 볼륨 정리 (같은 버전 재드 시 잔여 볼륨이 섞이지 않도록)
|
|
for (const name of readdirSync(releaseDir)) {
|
|
if (name.startsWith(ARCHIVE_BASE)) {
|
|
rmSync(join(releaseDir, name), { force: true })
|
|
}
|
|
}
|
|
|
|
const sevenZip = resolve7za()
|
|
const archivePath = join(releaseDir, `${ARCHIVE_BASE}.7z`)
|
|
|
|
console.log(`[portable] 7z 분할 볼륨 생성 (볼륨 ${VOLUME_SIZE})`)
|
|
const compress = spawnSync(
|
|
sevenZip,
|
|
[
|
|
'a',
|
|
'-t7z',
|
|
'-m0=lzma2',
|
|
'-mx=9',
|
|
'-mmt=on',
|
|
'-ms=on',
|
|
`-v${VOLUME_SIZE}`,
|
|
'-bsp0',
|
|
'-bso0',
|
|
'-y',
|
|
archivePath,
|
|
join(appDir, '*'),
|
|
],
|
|
{ stdio: 'inherit' },
|
|
)
|
|
if (compress.status !== 0) {
|
|
console.error(`[portable] 7z 압축 실패 (exit ${compress.status ?? 'null'})`)
|
|
process.exit(compress.status ?? 1)
|
|
}
|
|
|
|
const volumes = readdirSync(releaseDir)
|
|
.filter((name) => name.startsWith(`${ARCHIVE_BASE}.7z.`))
|
|
.sort()
|
|
|
|
if (volumes.length === 0) {
|
|
console.error('[portable] 7z 볼륨을 찾을 수 없습니다.')
|
|
process.exit(1)
|
|
}
|
|
|
|
const volumeEntries = []
|
|
for (const name of volumes) {
|
|
const path = join(releaseDir, name)
|
|
const size = statSync(path).size
|
|
if (size > MAX_VOLUME_BYTES) {
|
|
console.error(
|
|
`[portable] 볼륨이 너무 큽니다(${name}: ${(size / 1048576).toFixed(1)}MiB). ` +
|
|
'VOLUME_SIZE를 줄이세요 — Cloudflare가 100MiB 초과 업로드를 413으로 거부합니다.',
|
|
)
|
|
process.exit(1)
|
|
}
|
|
const sha256 = createHash('sha256').update(readFileSync(path)).digest('hex')
|
|
volumeEntries.push({ name, size, sha256, url: `${PORTABLE_VERSION_PATH}/${name}` })
|
|
}
|
|
|
|
const totalBytes = volumeEntries.reduce((sum, entry) => sum + entry.size, 0)
|
|
|
|
const portableIndex = {
|
|
channel: 'portable-unsigned',
|
|
version,
|
|
archive: `${ARCHIVE_BASE}.7z`,
|
|
volumes: volumeEntries,
|
|
volumeCount: volumeEntries.length,
|
|
totalSize: totalBytes,
|
|
releasedAt: new Date().toISOString(),
|
|
latestIndexUrl: `${PORTABLE_LATEST_PATH}/portable.json`,
|
|
installScriptUrl: `${PORTABLE_LATEST_PATH}/install-d3ro-voice.ps1`,
|
|
notes: [
|
|
'서명 없는 휴대용 배포본입니다. 자동 업데이트 피드(latest.yml)는 갱신하지 않습니다.',
|
|
'Cloudflare 업로드 한도(100MiB) 때문에 7z 볼륨으로 나뉘어 있습니다. Scoop이 이어서 해제합니다.',
|
|
'수동 설치: install-d3ro-voice.ps1 (7-Zip 필요) 또는 Scoop 사용을 권장합니다.',
|
|
],
|
|
}
|
|
|
|
writeFileSync(
|
|
join(releaseDir, 'portable.json'),
|
|
`${JSON.stringify(portableIndex, null, 2)}\n`,
|
|
'utf8',
|
|
)
|
|
|
|
const scoopManifest = {
|
|
version,
|
|
description: '로컬 AI 음성 어시스턴트 (faster-whisper + Ollama, 100% 오프라인 지원)',
|
|
homepage: 'https://d3ro.chanpaca.net',
|
|
license: 'MIT',
|
|
architecture: {
|
|
'64bit': {
|
|
url: volumeEntries.map((entry) => entry.url),
|
|
hash: volumeEntries.map((entry) => entry.sha256),
|
|
},
|
|
},
|
|
shortcuts: [['D3RO Voice.exe', 'D3RO Voice']],
|
|
checkver: {
|
|
url: `${PORTABLE_LATEST_PATH}/portable.json`,
|
|
jsonpath: '$.version',
|
|
},
|
|
autoupdate: {
|
|
architecture: {
|
|
'64bit': {
|
|
url: volumeEntries.map((entry) =>
|
|
entry.url.replace(`portable-${version}`, 'portable-$version'),
|
|
),
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
writeFileSync(
|
|
join(root, 'bucket', 'd3ro-voice.json'),
|
|
`${JSON.stringify(scoopManifest, null, 2)}\n`,
|
|
'utf8',
|
|
)
|
|
|
|
// ── 수동 설치용 zip 분할 부품 ─────────────────────────────
|
|
const zipTarget = join(releaseDir, `${ARCHIVE_BASE}.zip`)
|
|
console.log(`[portable] 수동 설치용 zip 생성 (분할 ${ZIP_PART_SIZE})`)
|
|
const zipBuild = spawnSync(
|
|
'npx',
|
|
[
|
|
'electron-builder',
|
|
// 검증된 트리에서 바로 패키징한다(ne ABI가 확실한 디렉토리만 사용).
|
|
'--prepackaged',
|
|
appDir,
|
|
'--win',
|
|
'zip',
|
|
'--x64',
|
|
'--config',
|
|
'electron-builder.yml',
|
|
'--publish',
|
|
'never',
|
|
'-c.win.forceCodeSigning=false',
|
|
'-c.npmRebuild=false',
|
|
],
|
|
{ cwd: desktopDir, stdio: 'inherit', shell: process.platform === 'win32' },
|
|
)
|
|
if (zipBuild.status !== 0) {
|
|
console.error(`[portable] zip 빌드 실패 (exit ${zipBuild.status ?? 'null'})`)
|
|
process.exit(zipBuild.status ?? 1)
|
|
}
|
|
|
|
const producedZip = readdirSync(releaseDir).find((name) =>
|
|
name.endsWith('.zip') && name.includes(version) && !name.includes('.part'),
|
|
)
|
|
if (!producedZip) {
|
|
console.error('[portable] zip 산출물을 찾을 수 없습니다.')
|
|
process.exit(1)
|
|
}
|
|
if (join(releaseDir, producedZip) !== zipTarget) {
|
|
renameSync(join(releaseDir, producedZip), zipTarget)
|
|
}
|
|
|
|
// zip을 90MiB 단위로 바이트 분할한다 (사용자가 이어 붙여 Expand-Archive로 해제)
|
|
const zipBytes = readFileSync(zipTarget)
|
|
const partSize = 90 * 1024 * 1024
|
|
const zipParts = []
|
|
for (let offset = 0, index = 1; offset < zipBytes.length; offset += partSize, index += 1) {
|
|
const slice = zipBytes.subarray(offset, Math.min(offset + partSize, zipBytes.length))
|
|
const name = `${ARCHIVE_BASE}.zip.${String(index).padStart(3, '0')}`
|
|
if (slice.length > MAX_ZIP_PART_BYTES) {
|
|
console.error(`[portable] zip 부품이 너무 큽니다: ${name}`)
|
|
process.exit(1)
|
|
}
|
|
writeFileSync(join(releaseDir, name), slice)
|
|
zipParts.push({
|
|
name,
|
|
size: slice.length,
|
|
sha256: createHash('sha256').update(slice).digest('hex'),
|
|
url: `${PORTABLE_VERSION_PATH}/${name}`,
|
|
})
|
|
}
|
|
const zipSha256 = createHash('sha256').update(zipBytes).digest('hex')
|
|
|
|
// 인덱스에 zip 부품 정보를 추가한다 (설치 스크립트가 사용)
|
|
const indexJson = JSON.parse(readFileSync(join(releaseDir, 'portable.json'), 'utf8'))
|
|
indexJson.zipArchive = `${ARCHIVE_BASE}.zip`
|
|
indexJson.zipSize = zipBytes.length
|
|
indexJson.zipSha256 = zipSha256
|
|
indexJson.zipParts = zipParts
|
|
writeFileSync(
|
|
join(releaseDir, 'portable.json'),
|
|
`${JSON.stringify(indexJson, null, 2)}\n`,
|
|
'utf8',
|
|
)
|
|
// ── 로컬 AI 런타임 번들 (설치본에 넣지 않고 처음 필요할 때 내려받는다) ──────
|
|
const RUNTIME_DIR = join(releaseDir, 'runtime')
|
|
rmSync(RUNTIME_DIR, { recursive: true, force: true })
|
|
mkdirSync(RUNTIME_DIR, { recursive: true })
|
|
|
|
/** 디렉터리를 tar.gz으로 묶어 90MiB 부품으로 나누고 인덱스 항목을 돌려준다 */
|
|
async function packRuntime(component, sourceDir, archiveBase) {
|
|
const archivePath = join(RUNTIME_DIR, `${archiveBase}.tar.gz`)
|
|
await pipeline(
|
|
tar.c({ cwd: sourceDir, portable: true, gzip: false }, ['.']),
|
|
createGzip({ level: 6 }),
|
|
createWriteStream(archivePath),
|
|
)
|
|
|
|
const archiveBytes = readFileSync(archivePath)
|
|
const sha256 = createHash('sha256').update(archiveBytes).digest('hex')
|
|
const partSize = 90 * 1024 * 1024
|
|
const parts = []
|
|
for (let offset = 0, index = 1; offset < archiveBytes.length; offset += partSize, index += 1) {
|
|
const slice = archiveBytes.subarray(offset, Math.min(offset + partSize, archiveBytes.length))
|
|
const name = `${archiveBase}.tar.gz.${String(index).padStart(3, '0')}`
|
|
if (slice.length > MAX_ZIP_PART_BYTES) {
|
|
throw new Error(`런타임 부품이 너무 큽니다: ${name}`)
|
|
}
|
|
writeFileSync(join(RUNTIME_DIR, name), slice)
|
|
parts.push({
|
|
name,
|
|
size: slice.length,
|
|
sha256: createHash('sha256').update(slice).digest('hex'),
|
|
})
|
|
}
|
|
|
|
return {
|
|
component,
|
|
archive: `${archiveBase}.tar.gz`,
|
|
sha256,
|
|
totalSize: archiveBytes.length,
|
|
parts,
|
|
}
|
|
}
|
|
const runtimeComponents = {}
|
|
const sidecarSource = join(desktopDir, 'sidecar-dist', 'sidecar')
|
|
// @ffmpeg-installer가 플랫폼별로 제공하는 실행 파일 경로를 그대로 사용한다
|
|
const ffmpegInstaller = (() => {
|
|
try {
|
|
return require('@ffmpeg-installer/ffmpeg')
|
|
} catch {
|
|
return null
|
|
}
|
|
})()
|
|
const ffmpegSource = ffmpegInstaller?.path ? dirname(ffmpegInstaller.path) : null
|
|
|
|
if (existsSync(sidecarSource)) {
|
|
console.log('[portable] 런타임 번들 생성: sidecar (faster-whisper 진)')
|
|
runtimeComponents.sidecar = await packRuntime('sidecar', sidecarSource, 'd3ro-runtime-sidecar')
|
|
} else {
|
|
console.error('[portable] 경고: sidecar-dist가 없어 런타임 번들을 만들 수 없습니다')
|
|
}
|
|
|
|
if (ffmpegSource && existsSync(ffmpegSource)) {
|
|
console.log('[portable] 런타임 번들 생성: ffmpeg')
|
|
runtimeComponents.ffmpeg = await packRuntime('ffmpeg', ffmpegSource, 'd3ro-runtime-ffmpeg')
|
|
} else {
|
|
console.error('[portable] 경고: @ffmpeg-installer가 없어 ffmpeg 런타임을 만들 수 없습니다')
|
|
}
|
|
|
|
const runtimeIndex = {
|
|
schemaVersion: 1,
|
|
version,
|
|
generatedAt: new Date().toISOString(),
|
|
components: Object.fromEntries(
|
|
Object.entries(runtimeComponents).map(([name, entry]) => [
|
|
name,
|
|
{
|
|
...entry,
|
|
parts: entry.parts.map((part) => ({
|
|
...part,
|
|
url: `${FEED}/runtime-${version}/${part.name}`,
|
|
})),
|
|
},
|
|
]),
|
|
),
|
|
}
|
|
writeFileSync(join(RUNTIME_DIR, 'runtime.json'), `${JSON.stringify(runtimeIndex, null, 2)}\n`, 'utf8')
|
|
console.log(
|
|
[
|
|
'[portable] 완료',
|
|
` 볼륨 : ${volumeEntries.length}개 / 합계 ${(totalBytes / 1048576).toFixed(1)}MiB`,
|
|
...volumeEntries.map(
|
|
(entry) => ` ${entry.name} (${(entry.size / 1048576).toFixed(1)}MiB)`,
|
|
),
|
|
` zip : ${zipParts.length}개 부품 / 합계 ${(zipBytes.length / 1048576).toFixed(1)}MiB`,
|
|
` 인덱스 : ${join(releaseDir, 'portable.json')}`,
|
|
` scoop : ${join(root, 'bucket', 'd3ro-voice.json')}`,
|
|
` 런타임 : ${Object.keys(runtimeComponents).join(', ') || '(없음)'} → ${join(RUNTIME_DIR, 'runtime.json')}`,
|
|
` 게시 : node scripts/ci/publish-portable-release.mjs`,
|
|
].join('\n'),
|
|
) |