Some checks failed
deploy-site / deploy (push) Failing after 1m15s
Auto-update could not work at all: the installer was 189 MB because it carried the local speech engine and ffmpeg, and the download feed rejects uploads over about 100 MiB, so update metadata could never be published. The installer now leaves those components out and the app fetches them the first time they are needed, verifying every part and the joined archive before installing. The installer is 90.6 MiB, the update feed is published again, and updates stay small because the engine is not re-sent on every release. The fetch is visible and recoverable: the download runs with progress, a failed install cleans up after itself, and Settings > STT shows the runtime status with a manual download action for when the automatic one cannot run.
208 lines
No EOL
8.1 KiB
JavaScript
208 lines
No EOL
8.1 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 ?? {}) },
|
|
})
|
|
}
|
|
|
|
async function upload(url, body, contentType) {
|
|
if (check) {
|
|
console.log(`[portable] (check) PUT ${url} (${body.length} bytes)`)
|
|
return
|
|
}
|
|
// Forgejo의 generic registry는 HEAD를 405로 거부한다(실측) → Range GET으로 크기만 읽는다.
|
|
const probe = await forgejoFetch(url, { headers: { Range: 'bytes=0-0' } }).catch(() => null)
|
|
const contentRange = probe?.headers.get('content-range')
|
|
const remoteLength = contentRange ? Number(contentRange.split('/')[1]) : NaN
|
|
if (probe?.ok && Number.isFinite(remoteLength)) {
|
|
if (remoteLength === body.length) {
|
|
console.log(`[portable] 이미 동일한 파일이 있습니다(건너뜀): ${url}`)
|
|
return
|
|
}
|
|
// 볼륨은 불변 자산이다 — 같은 버전 경로에 다른 바이트가 있으면 덮어쓰지 않고 중단한다.
|
|
if (url.includes(`/portable-${version}/`) && url.includes('.7z.')) {
|
|
console.error(
|
|
`[portable] ${version} 볼륨에 다른 바이트가 이미 있습니다: ${url}\n` +
|
|
' 이미 게시된 버전은 덮어쓰지 않습니다(불변). 새 버전으로 게시하세요.',
|
|
)
|
|
process.exit(1)
|
|
}
|
|
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'),
|
|
) |