fix(release): stop portable aliases from mixing old and new bytes

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.
This commit is contained in:
Yun Chan 2026-09-23 16:39:38 +09:00
parent 5c11ee2fde
commit 0e4f2de4d0

View file

@ -124,7 +124,6 @@ if (existsSync(runtimeIndexPath)) {
} }
const authorization = forgejoAuthorization() const authorization = forgejoAuthorization()
const bases = [`${FEED}/portable-${version}`, `${FEED}/portable-latest`]
async function forgejoFetch(url, init = {}) { async function forgejoFetch(url, init = {}) {
return fetch(url, { return fetch(url, {
@ -133,53 +132,33 @@ async function forgejoFetch(url, init = {}) {
}) })
} }
const PACKAGE_API = 'https://git.chanpaca.net/api/v1/packages/yunchan/generic/d3ro-voice'
/** /**
* 원격 파일이 로컬 바이트와 같은지 판단한다. * Forgejo generic registry는 HEAD를 405로 거부하고 파일 해시도 헤더로 주지 않는다.
* Forgejo generic registry는 HEAD를 405로 거부하고 해시도 주지 않으므로, * 패키지 버전의 파일 목록 API는 sha256을 주므로, 큰 볼륨을 내려받지 않고도 원격
* Range GET으로 크기를 본 뒤 1MiB 이하는 실제 바이트까지 비교한다. * 바이트가 로컬과 같은지 정확히 판단할 수 있다.
* (크기만 비교하면 버전 문자열만 바뀐 latest.yml 같은 메타데이터를 놓친다 — 실측 사고.) * (Range GET의 크기만 비교하면 90MiB 볼륨에서 크기가 우연히 같을 때 다른 바이트를
* "동일"로 오판해 별칭이 일부만 새 바이트로 갱신된다 — 실측 사고.)
*/ */
async function remoteIsIdentical(url, body, fetchImpl) { async function remoteFileHashes(versionPath) {
const probe = await fetchImpl(url, { headers: { Range: 'bytes=0-0' } }).catch(() => null) const response = await forgejoFetch(`${PACKAGE_API}/${encodeURIComponent(versionPath)}/files`)
if (!probe?.ok) return false if (!response.ok) return new Map()
const contentRange = probe.headers.get('content-range') const files = await response.json()
const remoteSize = contentRange ? Number(contentRange.split('/')[1]) : NaN return new Map(files.map((file) => [file.name, file.sha256]))
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) { async function deletePackageVersion(versionPath) {
if (check) { const response = await forgejoFetch(`${PACKAGE_API}/${encodeURIComponent(versionPath)}`, {
console.log(`[portable] (check) PUT ${url} (${body.length} bytes)`) method: 'DELETE',
return })
if (!response.ok && response.status !== 404) {
console.error(`[portable] 버전 삭제 실패 (HTTP ${response.status}): ${versionPath}`)
process.exit(1)
} }
}
// 메타데이터는 크기가 아니라 내용까지 비교해야 한다. async function put(url, body, contentType) {
// 크기만 보면 버전 문자열만 바뀐 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, { const response = await forgejoFetch(url, {
method: 'PUT', method: 'PUT',
headers: { 'Content-Type': contentType }, headers: { 'Content-Type': contentType },
@ -195,18 +174,57 @@ async function upload(url, body, contentType) {
console.log(`[portable] uploaded ${url}`) console.log(`[portable] uploaded ${url}`)
} }
const runtimeBases = [`${FEED}/runtime-${version}`, `${FEED}/runtime-latest`] /**
for (const base of runtimeBases) { * 한 버전 경로를 원자적으로 게시한다.
for (const payload of runtimePayloads) { * Forgejo generic registry는 파일 단위 덮어쓰기를 거부(409)하므로, 내용이 다른 파일이
await upload(`${base}/${encodeURIComponent(payload.name)}`, payload.bytes, payload.contentType) * 하나라도 있으면 버전 전체를 지우고 모든 파일을 다시 올린다. 이렇게 해야
* `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)
} }
} }
for (const base of bases) { await publishBase(`${FEED}/runtime-${version}`, runtimePayloads)
for (const payload of payloads) { await publishBase(`${FEED}/runtime-latest`, runtimePayloads)
await upload(`${base}/${encodeURIComponent(payload.name)}`, payload.bytes, payload.contentType) await publishBase(`${FEED}/portable-${version}`, payloads)
} await publishBase(`${FEED}/portable-latest`, payloads)
}
console.log( console.log(
[ [