From 0e4f2de4d0938aa25ef10563c368ff25d23a7272 Mon Sep 17 00:00:00 2001 From: Yun Chan Date: Wed, 23 Sep 2026 16:39:38 +0900 Subject: [PATCH] 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. --- scripts/ci/publish-portable-release.mjs | 120 ++++++++++++++---------- 1 file changed, 69 insertions(+), 51 deletions(-) diff --git a/scripts/ci/publish-portable-release.mjs b/scripts/ci/publish-portable-release.mjs index b50027b..13c37dd 100644 --- a/scripts/ci/publish-portable-release.mjs +++ b/scripts/ci/publish-portable-release.mjs @@ -124,7 +124,6 @@ if (existsSync(runtimeIndexPath)) { } const authorization = forgejoAuthorization() -const bases = [`${FEED}/portable-${version}`, `${FEED}/portable-latest`] async function forgejoFetch(url, init = {}) { 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로 거부하고 해시도 주지 않으므로, - * Range GET으로 크기를 본 뒤 1MiB 이하는 실제 바이트까지 비교한다. - * (크기만 비교하면 버전 문자열만 바뀐 latest.yml 같은 메타데이터를 놓친다 — 실측 사고.) + * Forgejo generic registry는 HEAD를 405로 거부하고 파일 해시도 헤더로 주지 않는다. + * 패키지 버전의 파일 목록 API는 sha256을 주므로, 큰 볼륨을 내려받지 않고도 원격 + * 바이트가 로컬과 같은지 정확히 판단할 수 있다. + * (Range GET의 크기만 비교하면 90MiB 볼륨에서 크기가 우연히 같을 때 다른 바이트를 + * "동일"로 오판해 별칭이 일부만 새 바이트로 갱신된다 — 실측 사고.) */ -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 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 upload(url, body, contentType) { - if (check) { - console.log(`[portable] (check) PUT ${url} (${body.length} bytes)`) - return +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) } +} - // 메타데이터는 크기가 아니라 내용까지 비교해야 한다. - // 크기만 보면 버전 문자열만 바뀐 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) - } +async function put(url, body, contentType) { const response = await forgejoFetch(url, { method: 'PUT', headers: { 'Content-Type': contentType }, @@ -195,18 +174,57 @@ async function upload(url, body, contentType) { 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) +/** + * 한 버전 경로를 원자적으로 게시한다. + * 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) } } -for (const base of bases) { - for (const payload of payloads) { - await upload(`${base}/${encodeURIComponent(payload.name)}`, payload.bytes, payload.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( [