The portable publisher decided a remote file was already up to date by comparing size alone for anything over 1 MiB. A rebuilt runtime sidecar produced a first split volume with exactly the same byte length as the previous one, so the stale volume was skipped and the `runtime-latest` alias ended up as an old first part next to a new second part. Downloading that alias would produce a corrupt archive. The remote hash now comes from the package file-list API, and when any file in a version differs the whole alias version is deleted and republished, so an alias can never hold a mix of old and new bytes.
249 lines
No EOL
9.7 KiB
JavaScript
249 lines
No EOL
9.7 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()
|
|
|
|
async function forgejoFetch(url, init = {}) {
|
|
return fetch(url, {
|
|
...init,
|
|
headers: { Authorization: authorization, ...(init.headers ?? {}) },
|
|
})
|
|
}
|
|
|
|
const PACKAGE_API = 'https://git.chanpaca.net/api/v1/packages/yunchan/generic/d3ro-voice'
|
|
|
|
/**
|
|
* Forgejo generic registry는 HEAD를 405로 거부하고 파일 해시도 헤더로 주지 않는다.
|
|
* 패키지 버전의 파일 목록 API는 sha256을 주므로, 큰 볼륨을 내려받지 않고도 원격
|
|
* 바이트가 로컬과 같은지 정확히 판단할 수 있다.
|
|
* (Range GET의 크기만 비교하면 90MiB 볼륨에서 크기가 우연히 같을 때 다른 바이트를
|
|
* "동일"로 오판해 별칭이 일부만 새 바이트로 갱신된다 — 실측 사고.)
|
|
*/
|
|
async function remoteFileHashes(versionPath) {
|
|
const response = await forgejoFetch(`${PACKAGE_API}/${encodeURIComponent(versionPath)}/files`)
|
|
if (!response.ok) return new Map()
|
|
const files = await response.json()
|
|
return new Map(files.map((file) => [file.name, file.sha256]))
|
|
}
|
|
|
|
async function deletePackageVersion(versionPath) {
|
|
const response = await forgejoFetch(`${PACKAGE_API}/${encodeURIComponent(versionPath)}`, {
|
|
method: 'DELETE',
|
|
})
|
|
if (!response.ok && response.status !== 404) {
|
|
console.error(`[portable] 버전 삭제 실패 (HTTP ${response.status}): ${versionPath}`)
|
|
process.exit(1)
|
|
}
|
|
}
|
|
|
|
async function put(url, body, contentType) {
|
|
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}`)
|
|
}
|
|
|
|
/**
|
|
* 한 버전 경로를 원자적으로 게시한다.
|
|
* Forgejo generic registry는 파일 단위 덮어쓰기를 거부(409)하므로, 내용이 다른 파일이
|
|
* 하나라도 있으면 버전 전체를 지우고 모든 파일을 다시 올린다. 이렇게 해야
|
|
* `runtime-latest`/`portable-latest` 같은 별칭이 낡은 바이트와 새 바이트가 섞이지 않는다.
|
|
*/
|
|
async function publishBase(base, basePayloads) {
|
|
const versionPath = base.split('/').pop()
|
|
const items = basePayloads.map((payload) => ({
|
|
...payload,
|
|
sha256: createHash('sha256').update(payload.bytes).digest('hex'),
|
|
}))
|
|
|
|
if (check) {
|
|
for (const item of items) {
|
|
console.log(`[portable] (check) PUT ${base}/${item.name} (${item.bytes.length} bytes)`)
|
|
}
|
|
return
|
|
}
|
|
|
|
const remoteHashes = await remoteFileHashes(versionPath)
|
|
const differing = items.filter((item) => remoteHashes.get(item.name) !== item.sha256)
|
|
if (differing.length === 0) {
|
|
console.log(`[portable] 변경 없음(건너): ${base}`)
|
|
return
|
|
}
|
|
|
|
// 볼륨/부품은 불변 자산이다 — 같은 버전 경로에 다른 바이트가 있으면 덮어쓰지 않고 중단한다.
|
|
const isImmutableAsset =
|
|
base.includes(`/portable-${version}/`) &&
|
|
differing.some((item) => item.name.includes('.7z.') || item.name.includes('.zip.'))
|
|
if (isImmutableAsset) {
|
|
console.error(
|
|
`[portable] ${version} 자산에 다른 바이트가 이미 있습니다: ${base}\n` +
|
|
' 이미 게시된 버전은 덮어쓰지 않습니다(불변). 새 버전으로 게시하세요.',
|
|
)
|
|
process.exit(1)
|
|
}
|
|
|
|
if (remoteHashes.size > 0) {
|
|
await deletePackageVersion(versionPath)
|
|
}
|
|
for (const item of items) {
|
|
await put(`${base}/${encodeURIComponent(item.name)}`, item.bytes, item.contentType)
|
|
}
|
|
}
|
|
|
|
await publishBase(`${FEED}/runtime-${version}`, runtimePayloads)
|
|
await publishBase(`${FEED}/runtime-latest`, runtimePayloads)
|
|
await publishBase(`${FEED}/portable-${version}`, payloads)
|
|
await publishBase(`${FEED}/portable-latest`, payloads)
|
|
|
|
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'),
|
|
) |