import { digest } from './releaseArtifacts.mjs' import { validateFeedUrl } from '../build/config.mjs' import { randomBytes } from 'node:crypto' export const UPLOAD_CHUNK_SIZE = 512 * 1024 /** Publish immutable assets first and the feed pointer last, verifying public bytes. */ export async function uploadRelease(release, { uploadUrl, token, fetchImpl = fetch, log = console.log }) { uploadUrl = validateFeedUrl(uploadUrl) if (!token || /[\r\n]/.test(token)) throw new Error('Set CAPSULE_UPLOAD_TOKEN in the release environment.') for (const file of release.files) { const metadata = file.name === 'latest.yml' const uploadId = randomBytes(16).toString('hex') const chunked = !metadata && file.data.length > UPLOAD_CHUNK_SIZE const step = chunked ? UPLOAD_CHUNK_SIZE : file.data.length if (!step) throw new Error(`Cannot upload empty artifact ${file.name}`) for (let offset = 0; offset < file.data.length; offset += step) { const body = file.data.subarray(offset, offset + step) const response = await fetchImpl(new URL(file.name, uploadUrl), { method: 'PUT', redirect: 'error', signal: AbortSignal.timeout(10 * 60 * 1000), headers: { Authorization: `Bearer ${token}`, 'Content-Type': metadata ? 'application/yaml' : 'application/octet-stream', 'Cache-Control': metadata ? 'no-cache' : 'public, max-age=31536000, immutable', ...(!metadata ? { 'If-None-Match': '*' } : {}), ...(chunked ? { 'Content-Range': `bytes ${offset}-${offset + body.length - 1}/${file.data.length}`, 'X-Capsule-Upload-Id': uploadId } : {}) }, body }) // Existing immutable files are safe to reuse only after verifying their public bytes. if (!response.ok && !(response.status === 412 && !metadata)) { throw new Error(`Upload failed for ${file.name}: HTTP ${response.status}. Publication stopped.`) } if (response.status === 412) break } const publicResponse = await fetchImpl(new URL(file.name, release.feedUrl), { redirect: 'error', signal: AbortSignal.timeout(10 * 60 * 1000), cache: 'no-store' }) if (!publicResponse.ok || digest(Buffer.from(await publicResponse.arrayBuffer())) !== digest(file.data)) { throw new Error(`Public verification failed for ${file.name}. Publication stopped.`) } log(`Published and verified ${file.name}`) } }