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.
231 lines
No EOL
9.2 KiB
JavaScript
231 lines
No EOL
9.2 KiB
JavaScript
// scripts/ci/publish-portable-release.mjs
|
|
// 서명 없는 휴대용 배포본(7z 분할 볼륨) + Scoop 매니페스트 + 수동 설치 스크립트를
|
|
// Forgejo Generic Registry에 게시한다.
|
|
//
|
|
// 이 채널은 자동 업데이트 피드(latest.yml / update-policy.json)를 건드리지 않는다.
|
|
// 서명이 없어도 게시할 수 있으므로 인증서 발급 전에도 사용자가 설치할 수 있는 경로다.
|
|
//
|
|
// 경로:
|
|
// .../generic/d3ro-voice/portable-<version>/<륨>.7z.00N
|
|
// .../generic/d3ro-voice/portable-<version>/portable.json
|
|
// .../generic/d3ro-voice/portable-<version>/install-d3ro-voice.ps1
|
|
// .../generic/d3ro-voice/portable-latest/... (동일 파일 alias)
|
|
//
|
|
// 사용:
|
|
// node scripts/ci/build-portable.mjs
|
|
// node --env-file-if-exists=.env scripts/ci/publish-portable-release.mjs [--check]
|
|
|
|
import credentialHelpers from '../lib/credentials.cjs'
|
|
import { createHash } from 'node:crypto'
|
|
import { existsSync, readFileSync } from 'node:fs'
|
|
import { readFile } from 'node:fs/promises'
|
|
import { dirname, join } from 'node:path'
|
|
import { fileURLToPath } from 'node:url'
|
|
|
|
const { forgejoAuthorization } = credentialHelpers
|
|
|
|
const root = join(dirname(fileURLToPath(import.meta.url)), '..', '..')
|
|
const check = process.argv.includes('--check') || process.env.PORTABLE_PUBLISH_DRY_RUN === '1'
|
|
|
|
const FEED = 'https://git.chanpaca.net/api/packages/yunchan/generic/d3ro-voice'
|
|
const version = JSON.parse(
|
|
readFileSync(join(root, 'release', 'product-version.json'), 'utf8'),
|
|
).version
|
|
|
|
const releaseDir = join(root, 'apps', 'desktop', 'release', version)
|
|
const index = JSON.parse(readFileSync(join(releaseDir, 'portable.json'), 'utf8'))
|
|
const installerPath = join(root, 'scripts', 'install', 'install-d3ro-voice.ps1')
|
|
|
|
if (index.version !== version) {
|
|
console.error(
|
|
`[portable] portable.json 버전(${index.version})이 product-version.json(${version})과 다릅니다. build-portable.mjs를 다시 실행하세요.`,
|
|
)
|
|
process.exit(1)
|
|
}
|
|
|
|
if (!existsSync(installerPath)) {
|
|
console.error(`[portable] 설치 스크립트가 없습니다: ${installerPath}`)
|
|
process.exit(1)
|
|
}
|
|
|
|
// 게시 전 해시 재검증 — 파일이 바뀌었는데 인덱스가 낡으면 불일치 배포가 된다.
|
|
const payloads = []
|
|
for (const volume of index.volumes) {
|
|
const path = join(releaseDir, volume.name)
|
|
if (!existsSync(path)) {
|
|
console.error(`[portable] 볼륨이 없습니다: ${path}`)
|
|
process.exit(1)
|
|
}
|
|
const bytes = await readFile(path)
|
|
const sha256 = createHash('sha256').update(bytes).digest('hex')
|
|
if (sha256 !== volume.sha256) {
|
|
console.error(
|
|
`[portable] sha256 불일치 (${volume.name}): index=${volume.sha256} actual=${sha256}`,
|
|
)
|
|
process.exit(1)
|
|
}
|
|
payloads.push({ name: volume.name, bytes, contentType: 'application/octet-stream' })
|
|
}
|
|
|
|
// 수동 설치용 zip 분할 부품 (Windows 내장 Expand-Archive로 해제 — 7-Zip 불필요)
|
|
for (const part of index.zipParts ?? []) {
|
|
const partPath = join(releaseDir, part.name)
|
|
if (!existsSync(partPath)) {
|
|
console.error(`[portable] zip 부품이 없습니다: ${partPath}`)
|
|
process.exit(1)
|
|
}
|
|
const partBytes = await readFile(partPath)
|
|
const partSha = createHash('sha256').update(partBytes).digest('hex')
|
|
if (partSha !== part.sha256) {
|
|
console.error(`[portable] zip 부품 sha256 불일치 (${part.name})`)
|
|
process.exit(1)
|
|
}
|
|
payloads.push({ name: part.name, bytes: partBytes, contentType: 'application/octet-stream' })
|
|
}
|
|
payloads.push({
|
|
name: 'portable.json',
|
|
bytes: Buffer.from(`${JSON.stringify(index, null, 2)}\n`, 'utf8'),
|
|
contentType: 'application/json',
|
|
})
|
|
payloads.push({
|
|
name: 'install-d3ro-voice.ps1',
|
|
bytes: await readFile(installerPath),
|
|
contentType: 'text/plain',
|
|
})
|
|
|
|
// ── 로컬 AI 런타임 번들 게시 (설치본에는 없고, 앱이 처음 필요할 때 내려받는다) ──
|
|
const runtimeDir = join(releaseDir, 'runtime')
|
|
const runtimePayloads = []
|
|
const runtimeIndexPath = join(runtimeDir, 'runtime.json')
|
|
if (existsSync(runtimeIndexPath)) {
|
|
const runtimeIndex = JSON.parse(readFileSync(runtimeIndexPath, 'utf8'))
|
|
for (const [component, entry] of Object.entries(runtimeIndex.components ?? {})) {
|
|
for (const part of entry.parts) {
|
|
const partPath = join(runtimeDir, part.name)
|
|
if (!existsSync(partPath)) {
|
|
console.error(`[portable] 런타임 부품이 없습니다: ${partPath}`)
|
|
process.exit(1)
|
|
}
|
|
const bytes = await readFile(partPath)
|
|
const sha = createHash('sha256').update(bytes).digest('hex')
|
|
if (sha !== part.sha256) {
|
|
console.error(`[portable] 런타임 부품 sha256 불일치: ${part.name}`)
|
|
process.exit(1)
|
|
}
|
|
runtimePayloads.push({ name: part.name, bytes, contentType: 'application/octet-stream' })
|
|
}
|
|
void component
|
|
}
|
|
runtimePayloads.push({
|
|
name: 'runtime.json',
|
|
bytes: Buffer.from(`${JSON.stringify(runtimeIndex, null, 2)}\n`, 'utf8'),
|
|
contentType: 'application/json',
|
|
})
|
|
}
|
|
|
|
const authorization = forgejoAuthorization()
|
|
const bases = [`${FEED}/portable-${version}`, `${FEED}/portable-latest`]
|
|
|
|
async function forgejoFetch(url, init = {}) {
|
|
return fetch(url, {
|
|
...init,
|
|
headers: { Authorization: authorization, ...(init.headers ?? {}) },
|
|
})
|
|
}
|
|
|
|
/**
|
|
* 원격 파일이 로컬 바이트와 같은지 판단한다.
|
|
* Forgejo generic registry는 HEAD를 405로 거부하고 해시도 주지 않으므로,
|
|
* Range GET으로 크기를 본 뒤 1MiB 이하는 실제 바이트까지 비교한다.
|
|
* (크기만 비교하면 버전 문자열만 바뀐 latest.yml 같은 메타데이터를 놓친다 — 실측 사고.)
|
|
*/
|
|
async function remoteIsIdentical(url, body, fetchImpl) {
|
|
const probe = await fetchImpl(url, { headers: { Range: 'bytes=0-0' } }).catch(() => null)
|
|
if (!probe?.ok) return false
|
|
const contentRange = probe.headers.get('content-range')
|
|
const remoteSize = contentRange ? Number(contentRange.split('/')[1]) : NaN
|
|
if (!Number.isFinite(remoteSize) || remoteSize !== body.length) return false
|
|
if (body.length > 1024 * 1024) return true
|
|
const full = await fetchImpl(url).catch(() => null)
|
|
if (!full?.ok) return false
|
|
const remoteBytes = Buffer.from(await full.arrayBuffer())
|
|
return remoteBytes.length === body.length && remoteBytes.equals(body)
|
|
}
|
|
|
|
async function upload(url, body, contentType) {
|
|
if (check) {
|
|
console.log(`[portable] (check) PUT ${url} (${body.length} bytes)`)
|
|
return
|
|
}
|
|
|
|
// 메타데이터는 크기가 아니라 내용까지 비교해야 한다.
|
|
// 크기만 보면 버전 문자열만 바뀐 latest.yml/json을 "동일"로 오판한다 — 실측 사고.
|
|
if (await remoteIsIdentical(url, body, forgejoFetch)) {
|
|
console.log(`[portable] 이미 동일한 파일이 있습니다(건너): ${url}`)
|
|
return
|
|
}
|
|
|
|
const existing = await forgejoFetch(url, { headers: { Range: 'bytes=0-0' } }).catch(() => null)
|
|
if (existing?.ok) {
|
|
// 볼륨/부품은 불변 자산이다 — 같은 버전 경로에 다른 바이트가 있으면 덮어쓰지 않고 중단한다.
|
|
const isImmutableAsset =
|
|
url.includes(`/portable-${version}/`) && (url.includes('.7z.') || url.includes('.zip.'))
|
|
if (isImmutableAsset) {
|
|
console.error(
|
|
`[portable] ${version} 자산에 다른 바이트가 이미 있습니다: ${url}\n` +
|
|
' 이미 게시된 버전은 어쓰지 않습니다(불변). 새 버전으로 게시하세요.',
|
|
)
|
|
process.exit(1)
|
|
}
|
|
// 메타데이터와 latest 별칭은 최신을 반영해야 하므로 지우고 쓴다.
|
|
await forgejoFetch(url, { method: 'DELETE' }).catch(() => null)
|
|
}
|
|
const response = await forgejoFetch(url, {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': contentType },
|
|
body,
|
|
})
|
|
if (!response.ok) {
|
|
console.error(
|
|
`[portable] 업로드 실패 (HTTP ${response.status}): ${url}\n` +
|
|
' HTTP 413이면 Cloudflare 본문 한도(100MiB) 초과입니다. 볼륨 크기를 줄이세요.',
|
|
)
|
|
process.exit(1)
|
|
}
|
|
console.log(`[portable] uploaded ${url}`)
|
|
}
|
|
|
|
const runtimeBases = [`${FEED}/runtime-${version}`, `${FEED}/runtime-latest`]
|
|
for (const base of runtimeBases) {
|
|
for (const payload of runtimePayloads) {
|
|
await upload(`${base}/${encodeURIComponent(payload.name)}`, payload.bytes, payload.contentType)
|
|
}
|
|
}
|
|
|
|
for (const base of bases) {
|
|
for (const payload of payloads) {
|
|
await upload(`${base}/${encodeURIComponent(payload.name)}`, payload.bytes, payload.contentType)
|
|
}
|
|
}
|
|
|
|
console.log(
|
|
[
|
|
'',
|
|
`[portable] 게시 ${check ? '(check 모드 — 실제 업로드 없음)' : '완료'}: ${version}`,
|
|
` 볼륨 : ${index.volumeCount}개 / 합계 ${(index.totalSize / 1048576).toFixed(1)}MiB`,
|
|
` 인덱스 : ${FEED}/portable-latest/portable.json`,
|
|
` 스크립트: ${FEED}/portable-latest/install-d3ro-voice.ps1`,
|
|
runtimePayloads.length
|
|
? ` 런타임 : ${FEED}/runtime-latest/runtime.json (${runtimePayloads.length}개 파일)`
|
|
: ' 런타임 : (없음)',
|
|
'',
|
|
' 설치(Scoop, 권장):',
|
|
' scoop bucket add d3ro https://git.chanpaca.net/yunchan/d3ro-voice.git',
|
|
' scoop install d3ro/d3ro-voice',
|
|
'',
|
|
' 수동 설치(추가 도구 불필요):',
|
|
` irm ${FEED}/portable-latest/install-d3ro-voice.ps1 | iex`,
|
|
'',
|
|
' 참고: 이 채널은 서명이 없어 자동 업데이트 피드를 갱신하지 않습니다.',
|
|
].join('\n'),
|
|
) |