229 lines
7.3 KiB
JavaScript
229 lines
7.3 KiB
JavaScript
import { randomUUID } from 'node:crypto'
|
|
import { open, stat } from 'node:fs/promises'
|
|
import path from 'node:path'
|
|
import { pathToFileURL } from 'node:url'
|
|
|
|
export async function publishSkinSite({
|
|
archivePath,
|
|
tag,
|
|
token,
|
|
baseUrl = 'https://skin.starlight.cool',
|
|
fetchImpl = fetch,
|
|
sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
|
now = Date.now,
|
|
timeoutMs = 30 * 60 * 1000,
|
|
pollIntervalMs = 2000,
|
|
log = console.log,
|
|
}) {
|
|
if (!token) throw new Error('SKIN_SITE_CI_UPLOAD_TOKEN is required to publish to the skin site')
|
|
if (!archivePath || !/^v?\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(tag ?? '')) {
|
|
throw new Error('Expected a complete update ZIP and a semantic version tag')
|
|
}
|
|
const version = tag.replace(/^v/, '')
|
|
const url = new URL(baseUrl)
|
|
if (url.protocol !== 'https:' || url.username || url.password) {
|
|
throw new Error('SKIN_SITE_URL must be an HTTPS URL without credentials')
|
|
}
|
|
const { size } = await stat(archivePath)
|
|
if (!archivePath.toLowerCase().endsWith('.zip') || size < 1 || size > 4 * 1024 ** 3) {
|
|
throw new Error('Expected a nonempty complete update ZIP no larger than 4 GiB')
|
|
}
|
|
const deadline = now() + timeoutMs
|
|
function remainingTime() {
|
|
const remaining = deadline - now()
|
|
if (remaining <= 0) throw new Error('Skin site publication timed out')
|
|
return remaining
|
|
}
|
|
async function request(route, { method = 'GET', body, chunk = false } = {}) {
|
|
const attempts = chunk ? 3 : 1
|
|
for (let attempt = 0; attempt < attempts; attempt++) {
|
|
const requestTimeout = Math.min(120_000, remainingTime())
|
|
let response
|
|
try {
|
|
response = await fetchImpl(`${baseUrl.replace(/\/$/, '')}/starlight/${route}`, {
|
|
method,
|
|
headers: {
|
|
Authorization: `Bearer ${token}`,
|
|
...(body && !chunk ? { 'Content-Type': 'application/json' } : {}),
|
|
},
|
|
body: body && !chunk ? JSON.stringify(body) : body,
|
|
signal: AbortSignal.timeout(requestTimeout),
|
|
redirect: 'error',
|
|
})
|
|
} catch {
|
|
if (attempt + 1 === attempts)
|
|
throw new Error(`Skin site ${route}: network request failed or timed out`)
|
|
await sleep(500 * (attempt + 1))
|
|
continue
|
|
}
|
|
if (!response.ok) {
|
|
const details = await response.json().catch(() => null)
|
|
if ((response.status === 429 || response.status >= 500) && attempt + 1 < attempts) {
|
|
await sleep(500 * (attempt + 1))
|
|
continue
|
|
}
|
|
throw new Error(
|
|
`Skin site ${route}: HTTP ${response.status}${typeof details?.errorMessage === 'string' ? ` - ${details.errorMessage.slice(0, 1000)}` : ''}`,
|
|
)
|
|
}
|
|
let result
|
|
try {
|
|
result = await response.json()
|
|
} catch {
|
|
throw new Error(`Skin site ${route}: invalid JSON response`)
|
|
}
|
|
if (result?.success !== true || !result.payload) {
|
|
throw new Error(
|
|
`Skin site ${route}: ${result?.errorMessage || 'request was rejected or payload is missing'}`,
|
|
)
|
|
}
|
|
return result.payload
|
|
}
|
|
}
|
|
|
|
let status = await request('launcher/admin/uploads', {
|
|
method: 'POST',
|
|
body: {
|
|
uploadId: randomUUID(),
|
|
filename: path.basename(archivePath),
|
|
size,
|
|
forceUpdate: false,
|
|
},
|
|
})
|
|
if (typeof status.id !== 'string' || !status.id)
|
|
throw new Error('Skin site returned an invalid upload ID')
|
|
const uploadRoute = `launcher/admin/uploads/${encodeURIComponent(status.id)}`
|
|
log(`Skin site upload ${status.id}: ${size} bytes`)
|
|
const file = await open(archivePath, 'r')
|
|
try {
|
|
for (;;) {
|
|
remainingTime()
|
|
if (status.state === 'ready') {
|
|
const release = status.release
|
|
if (!Number.isSafeInteger(release?.id) || release.id <= 0 || release.version !== version) {
|
|
throw new Error(
|
|
`Skin site returned an invalid release or a version other than ${version}`,
|
|
)
|
|
}
|
|
const activated = await request(`launcher/admin/${release.id}/activate`, { method: 'POST' })
|
|
if (
|
|
activated.id !== release.id ||
|
|
activated.version !== version ||
|
|
activated.active !== true
|
|
) {
|
|
throw new Error(`Skin site did not confirm activation of ${version}`)
|
|
}
|
|
log(`Skin site release ${version} published (id ${release.id})`)
|
|
return activated
|
|
}
|
|
if (status.state === 'failed' || status.state === 'cancelled') {
|
|
throw new Error(
|
|
`Skin site archive processing ${status.state}: ${status.errorMessage || 'no error details'}`,
|
|
)
|
|
}
|
|
if (status.state === 'processing') {
|
|
await sleep(Math.min(pollIntervalMs, remainingTime()))
|
|
status = await request(uploadRoute)
|
|
continue
|
|
}
|
|
if (
|
|
status.state !== 'uploading' ||
|
|
status.size !== size ||
|
|
!Number.isSafeInteger(status.chunkSize) ||
|
|
status.chunkSize < 1 ||
|
|
status.chunkSize > 64 * 1024 ** 2 ||
|
|
!Number.isSafeInteger(status.received) ||
|
|
status.received < 0 ||
|
|
status.received > size ||
|
|
!Array.isArray(status.receivedChunks)
|
|
) {
|
|
throw new Error('Skin site returned invalid upload status or chunk size')
|
|
}
|
|
const count = Math.ceil(size / status.chunkSize)
|
|
if (
|
|
status.receivedChunks.some(
|
|
(index) => !Number.isSafeInteger(index) || index < 0 || index >= count,
|
|
)
|
|
) {
|
|
throw new Error('Skin site returned invalid received chunk indices')
|
|
}
|
|
const received = new Set(status.receivedChunks)
|
|
const missing = Array.from({ length: count }, (_, index) => index).filter(
|
|
(index) => !received.has(index),
|
|
)
|
|
if (!missing.length) {
|
|
if (status.received !== size)
|
|
throw new Error('Skin site received byte count does not match the archive')
|
|
status = await request(`${uploadRoute}/complete`, { method: 'POST' })
|
|
continue
|
|
}
|
|
const chunkSize = status.chunkSize
|
|
const concurrency = Math.min(
|
|
Number.isSafeInteger(status.uploadConcurrency) && status.uploadConcurrency > 0
|
|
? status.uploadConcurrency
|
|
: 1,
|
|
6,
|
|
missing.length,
|
|
)
|
|
let cursor = 0
|
|
let uploadError
|
|
async function worker() {
|
|
try {
|
|
while (!uploadError && cursor < missing.length) {
|
|
remainingTime()
|
|
const index = missing[cursor++]
|
|
const start = index * chunkSize
|
|
const bytes = Buffer.alloc(Math.min(chunkSize, size - start))
|
|
let offset = 0
|
|
while (offset < bytes.length) {
|
|
const { bytesRead } = await file.read(
|
|
bytes,
|
|
offset,
|
|
bytes.length - offset,
|
|
start + offset,
|
|
)
|
|
if (!bytesRead) throw new Error('Update archive changed or ended during upload')
|
|
offset += bytesRead
|
|
}
|
|
const form = new FormData()
|
|
form.append('file', new Blob([bytes]), 'chunk.bin')
|
|
await request(`${uploadRoute}/chunks/${index}`, {
|
|
method: 'POST',
|
|
body: form,
|
|
chunk: true,
|
|
})
|
|
}
|
|
} catch (error) {
|
|
uploadError ??= error
|
|
}
|
|
}
|
|
await Promise.all(Array.from({ length: concurrency }, worker))
|
|
if (uploadError) throw uploadError
|
|
status = await request(uploadRoute)
|
|
log(`Skin site uploaded ${status.received}/${size} bytes`)
|
|
}
|
|
} finally {
|
|
await file.close()
|
|
}
|
|
}
|
|
|
|
if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) {
|
|
try {
|
|
await publishSkinSite({
|
|
archivePath: process.argv[2],
|
|
tag: process.argv[3],
|
|
token: process.env.SKIN_SITE_TOKEN,
|
|
baseUrl: process.env.SKIN_SITE_URL || 'https://skin.starlight.cool',
|
|
})
|
|
} catch (error) {
|
|
const message = String(error.message).replaceAll(
|
|
process.env.SKIN_SITE_TOKEN || '\0',
|
|
'[redacted]',
|
|
)
|
|
console.error(
|
|
`::error::${message.replaceAll('%', '%25').replaceAll('\r', '%0D').replaceAll('\n', '%0A')}`,
|
|
)
|
|
process.exitCode = 1
|
|
}
|
|
}
|