fix(release): keep portable/runtime version packages immutable and guard latest aliases against rollback

This commit is contained in:
Yun Chan 2026-09-28 02:16:18 +09:00
parent 88f3dbcd69
commit 5f12ab4772
4 changed files with 658 additions and 88 deletions

View file

@ -10,6 +10,11 @@
// .../generic/d3ro-voice/portable-<version>/portable.json
// .../generic/d3ro-voice/portable-<version>/install-d3ro-voice.ps1
// .../generic/d3ro-voice/portable-latest/... (동일 파일 alias)
// .../generic/d3ro-voice/runtime-<version>/... (로컬 AI 런타임 부품 + runtime.json)
// .../generic/d3ro-voice/runtime-latest/... (동일 파일 alias)
//
// 버전 경로는 불변(다른 바이트면 중단, 부분 업로드는 이어 올림), *-latest 별칭은 버전 경로가
// 모두 완성된 뒤 더 오래된 버전으로 되돌리지 않을 때만 교체한다 — lib/portable-publish-policy.mjs.
//
// 사용:
// node scripts/ci/build-portable.mjs
@ -21,6 +26,8 @@ import { existsSync, readFileSync } from 'node:fs'
import { readFile } from 'node:fs/promises'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { createForgejoGenericRegistry } from './lib/forgejo-generic-registry.mjs'
import { publishPortablePackages } from './lib/portable-publish-policy.mjs'
const { forgejoAuthorization } = credentialHelpers
@ -134,98 +141,32 @@ async function forgejoFetch(url, init = {}) {
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]))
}
const registry = createForgejoGenericRegistry({
feedUrl: FEED,
packageApiUrl: PACKAGE_API,
fetchImpl: forgejoFetch,
onUploaded: (url) => console.log(`[portable] uploaded ${url}`),
})
async function deletePackageVersion(versionPath) {
const response = await forgejoFetch(`${PACKAGE_API}/${encodeURIComponent(versionPath)}`, {
method: 'DELETE',
// 정책(불변 버전 경로 · 별칭 롤백 방지)은 lib/portable-publish-policy.mjs 에 있다.
// 버전 경로(runtime-<v>, portable-<v>)를 모두 완성한 뒤에만 *-latest 별칭을 갱신한다.
try {
await publishPortablePackages({
version,
packages: [
{ kind: 'runtime', indexName: 'runtime.json', payloads: runtimePayloads },
{ kind: 'portable', indexName: 'portable.json', payloads },
],
registry,
dryRun: check,
describeUrl: registry.fileUrl,
log: (message) => console.log(`[portable] ${message}`),
})
if (!response.ok && response.status !== 404) {
console.error(`[portable] 버전 삭제 실패 (HTTP ${response.status}): ${versionPath}`)
process.exit(1)
}
} catch (error) {
console.error(`[portable] ${error instanceof Error ? error.message : String(error)}`)
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(
[
'',