// scripts/ci/publish-portable-release.mjs // 서명 없는 휴대용 배포본(7z 분할 볼륨) + Scoop 매니페스트 + 수동 설치 스크립트를 // Forgejo Generic Registry에 게시한다. // // 이 채널은 자동 업데이트 피드(latest.yml / update-policy.json)를 건드리지 않는다. // 서명이 없어도 게시할 수 있으므로 인증서 발급 전에도 사용자가 설치할 수 있는 경로다. // // 경로: // .../generic/d3ro-voice/portable-/<륨>.7z.00N // .../generic/d3ro-voice/portable-/portable.json // .../generic/d3ro-voice/portable-/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'), )