fix(release): keep portable/runtime version packages immutable and guard latest aliases against rollback
This commit is contained in:
parent
88f3dbcd69
commit
5f12ab4772
4 changed files with 658 additions and 88 deletions
|
|
@ -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(
|
||||
[
|
||||
'',
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue