328 lines
9.9 KiB
JavaScript
328 lines
9.9 KiB
JavaScript
import { execFileSync } from 'node:child_process'
|
|
import fs from 'node:fs'
|
|
import os from 'node:os'
|
|
import path from 'node:path'
|
|
|
|
const [command, tag, outputDirectory] = process.argv.slice(2)
|
|
const apiEndpoint = (process.env.CNB_API_ENDPOINT || 'https://api.cnb.cool').replace(/\/$/, '')
|
|
const repo = process.env.CNB_REPO_SLUG || 'axlmc/Axolotl'
|
|
const token = process.env.CNB_TOKEN
|
|
const tokenUser = process.env.CNB_TOKEN_USER_NAME || 'cnb'
|
|
const repoUrl = process.env.CNB_REPO_URL_HTTPS || `https://cnb.cool/${repo}.git`
|
|
const githubReleaseBaseUrl = (
|
|
process.env.GITHUB_RELEASE_BASE_URL || 'https://github.com/Mystic-Stars/Axolotl/releases/download'
|
|
).replace(/\/$/, '')
|
|
const githubApiBaseUrl = (
|
|
process.env.GITHUB_API_BASE_URL || 'https://api.github.com/repos/Mystic-Stars/Axolotl'
|
|
).replace(/\/$/, '')
|
|
const configuredAssetMirrorConcurrency = Number(process.env.ASSET_MIRROR_CONCURRENCY || 4)
|
|
const assetMirrorConcurrency =
|
|
Number.isSafeInteger(configuredAssetMirrorConcurrency) && configuredAssetMirrorConcurrency > 0
|
|
? configuredAssetMirrorConcurrency
|
|
: 4
|
|
|
|
if (command !== 'finalize' || !tag || !outputDirectory || !token) {
|
|
throw new Error('Usage: node cnb-release.mjs finalize <tag> <output-dir>; CNB_TOKEN is required')
|
|
}
|
|
|
|
const apiHeaders = {
|
|
Accept: 'application/vnd.cnb.api+json',
|
|
Authorization: `Bearer ${token}`,
|
|
}
|
|
const githubApiHeaders = {
|
|
Accept: 'application/vnd.github+json',
|
|
'User-Agent': 'Axolotl-CNB-Release',
|
|
...(process.env.GITHUB_TOKEN ? { Authorization: `Bearer ${process.env.GITHUB_TOKEN}` } : {}),
|
|
}
|
|
|
|
async function apiRequest(url, options = {}, allowedStatuses = []) {
|
|
const response = await fetch(url, {
|
|
...options,
|
|
headers: {
|
|
...apiHeaders,
|
|
...options.headers,
|
|
},
|
|
})
|
|
if (!response.ok && !allowedStatuses.includes(response.status)) {
|
|
throw new Error(
|
|
`${options.method || 'GET'} ${url} failed (${response.status}): ${await response.text()}`,
|
|
)
|
|
}
|
|
return response
|
|
}
|
|
|
|
async function getRelease() {
|
|
const response = await apiRequest(
|
|
`${apiEndpoint}/${repo}/-/releases/tags/${encodeURIComponent(tag)}`,
|
|
{},
|
|
[404],
|
|
)
|
|
return response.status === 404 ? null : await response.json()
|
|
}
|
|
|
|
async function ensureRelease(githubRelease) {
|
|
const existing = await getRelease()
|
|
if (existing) {
|
|
return existing
|
|
}
|
|
|
|
const prerelease = tag.includes('-')
|
|
const response = await apiRequest(
|
|
`${apiEndpoint}/${repo}/-/releases`,
|
|
{
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
tag_name: tag,
|
|
target_commitish: process.env.CNB_COMMIT || tag,
|
|
name:
|
|
process.env.CNB_TAG_RELEASE_TITLE ||
|
|
githubRelease.name ||
|
|
`Axolotl Launcher ${tag}`,
|
|
body: process.env.CNB_TAG_RELEASE_DESC || githubRelease.body || '',
|
|
draft: true,
|
|
prerelease,
|
|
make_latest: 'false',
|
|
}),
|
|
},
|
|
[409],
|
|
)
|
|
if (response.status !== 409) {
|
|
return await response.json()
|
|
}
|
|
|
|
for (let attempt = 0; attempt < 10; attempt++) {
|
|
await new Promise((resolve) => setTimeout(resolve, 1000))
|
|
const release = await getRelease()
|
|
if (release) {
|
|
return release
|
|
}
|
|
}
|
|
throw new Error(`Release ${tag} was created concurrently but could not be loaded`)
|
|
}
|
|
|
|
async function createAssetUpload(release, assetName, size) {
|
|
const uploadResponse = await apiRequest(
|
|
`${apiEndpoint}/${repo}/-/releases/${release.id}/asset-upload-url`,
|
|
{
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ asset_name: assetName, size, overwrite: true, ttl: 0 }),
|
|
},
|
|
)
|
|
return await uploadResponse.json()
|
|
}
|
|
|
|
async function verifyAssetUpload(upload) {
|
|
const verifyUrl = new URL(upload.verify_url, apiEndpoint).toString()
|
|
await apiRequest(`${verifyUrl}${verifyUrl.includes('?') ? '&' : '?'}ttl=0`, { method: 'POST' })
|
|
}
|
|
|
|
async function uploadAsset(release, filePath) {
|
|
const assetName = path.basename(filePath)
|
|
const size = fs.statSync(filePath).size
|
|
const upload = await createAssetUpload(release, assetName, size)
|
|
const fileResponse = await fetch(upload.upload_url, {
|
|
method: 'PUT',
|
|
body: fs.createReadStream(filePath),
|
|
duplex: 'half',
|
|
headers: {
|
|
'Content-Length': String(size),
|
|
'Content-Type': 'application/octet-stream',
|
|
},
|
|
})
|
|
if (!fileResponse.ok) {
|
|
throw new Error(
|
|
`Uploading ${assetName} failed (${fileResponse.status}): ${await fileResponse.text()}`,
|
|
)
|
|
}
|
|
await verifyAssetUpload(upload)
|
|
console.log(`Uploaded ${assetName}`)
|
|
}
|
|
|
|
async function loadGithubManifest() {
|
|
const manifestUrl = `${githubReleaseBaseUrl}/${encodeURIComponent(tag)}/latest.json`
|
|
const response = await fetch(manifestUrl)
|
|
if (!response.ok) {
|
|
throw new Error(
|
|
`Downloading GitHub manifest failed (${response.status}): ${await response.text()}`,
|
|
)
|
|
}
|
|
const manifest = await response.json()
|
|
if (manifest.version !== tag.replace(/^v/, '')) {
|
|
throw new Error(`GitHub manifest version ${manifest.version} does not match ${tag}`)
|
|
}
|
|
return manifest
|
|
}
|
|
|
|
async function loadGithubRelease(url) {
|
|
const response = await fetch(url, {
|
|
headers: githubApiHeaders,
|
|
})
|
|
if (!response.ok) {
|
|
throw new Error(`Loading GitHub release failed (${response.status}): ${await response.text()}`)
|
|
}
|
|
return await response.json()
|
|
}
|
|
|
|
async function getGithubRelease() {
|
|
return await loadGithubRelease(
|
|
`${githubApiBaseUrl}/releases/tags/${encodeURIComponent(tag)}`,
|
|
)
|
|
}
|
|
|
|
async function getLatestGithubRelease() {
|
|
return await loadGithubRelease(`${githubApiBaseUrl}/releases/latest`)
|
|
}
|
|
|
|
async function mirrorGithubAsset(release, asset) {
|
|
const upload = await createAssetUpload(release, asset.name, asset.size)
|
|
const response = await fetch(asset.browser_download_url)
|
|
if (!response.ok || !response.body) {
|
|
throw new Error(
|
|
`Downloading ${asset.name} failed (${response.status}): ${await response.text()}`,
|
|
)
|
|
}
|
|
const uploadResponse = await fetch(upload.upload_url, {
|
|
method: 'PUT',
|
|
body: response.body,
|
|
duplex: 'half',
|
|
headers: {
|
|
'Content-Length': String(asset.size),
|
|
'Content-Type': 'application/octet-stream',
|
|
},
|
|
})
|
|
if (!uploadResponse.ok) {
|
|
throw new Error(
|
|
`Uploading ${asset.name} failed (${uploadResponse.status}): ${await uploadResponse.text()}`,
|
|
)
|
|
}
|
|
await verifyAssetUpload(upload)
|
|
console.log(`Mirrored ${asset.name}`)
|
|
}
|
|
|
|
async function mirrorGithubAssets(release, githubRelease) {
|
|
const assets = (githubRelease.assets || []).filter((asset) => asset.name !== 'latest.json')
|
|
const mirroredNames = new Set(assets.map((asset) => asset.name))
|
|
if (mirroredNames.size !== assets.length) {
|
|
throw new Error('GitHub release contains duplicate asset names')
|
|
}
|
|
for (const asset of assets) {
|
|
if (
|
|
!asset.name ||
|
|
!asset.browser_download_url ||
|
|
!Number.isSafeInteger(asset.size) ||
|
|
asset.size < 0
|
|
) {
|
|
throw new Error(`Invalid or duplicate GitHub release asset: ${asset.name}`)
|
|
}
|
|
}
|
|
|
|
let nextAsset = 0
|
|
const workers = Array.from(
|
|
{ length: Math.min(assetMirrorConcurrency, assets.length) },
|
|
async () => {
|
|
while (nextAsset < assets.length) {
|
|
const asset = assets[nextAsset++]
|
|
await mirrorGithubAsset(release, asset)
|
|
}
|
|
},
|
|
)
|
|
await Promise.all(workers)
|
|
return mirroredNames
|
|
}
|
|
|
|
function createCnbManifest(githubManifest, mirroredNames) {
|
|
const requiredPlatforms = [
|
|
'darwin-aarch64',
|
|
'darwin-x86_64',
|
|
'linux-aarch64',
|
|
'linux-x86_64',
|
|
'windows-x86_64',
|
|
]
|
|
const platforms = {}
|
|
for (const platform of requiredPlatforms) {
|
|
const update = githubManifest.platforms?.[platform]
|
|
if (!update || typeof update.signature !== 'string' || update.signature.trim().length < 32) {
|
|
throw new Error(`Missing signed GitHub update for ${platform}`)
|
|
}
|
|
const filename = decodeURIComponent(path.posix.basename(new URL(update.url).pathname))
|
|
if (!mirroredNames.has(filename)) {
|
|
throw new Error(`GitHub release is missing updater artifact ${filename} for ${platform}`)
|
|
}
|
|
platforms[platform] = {
|
|
...update,
|
|
url: `https://cnb.cool/${repo}/-/releases/download/${encodeURIComponent(tag)}/${encodeURIComponent(filename)}`,
|
|
}
|
|
}
|
|
|
|
return {
|
|
...githubManifest,
|
|
version: tag.replace(/^v/, ''),
|
|
platforms,
|
|
}
|
|
}
|
|
|
|
function publishUpdateBranch(manifestPath) {
|
|
if (tag.includes('-')) {
|
|
return
|
|
}
|
|
|
|
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'axolotl-cnb-update-'))
|
|
const auth = Buffer.from(`${tokenUser}:${token}`).toString('base64')
|
|
const git = (...args) => execFileSync('git', args, { cwd: directory, stdio: 'inherit' })
|
|
git('init')
|
|
git('config', 'user.name', 'Axolotl CNB Release')
|
|
git('config', 'user.email', 'build@cnb.cool')
|
|
git('checkout', '--orphan', 'update')
|
|
fs.copyFileSync(manifestPath, path.join(directory, 'latest.json'))
|
|
git('add', 'latest.json')
|
|
git('commit', '-m', `Publish ${tag}`)
|
|
git('remote', 'add', 'origin', repoUrl)
|
|
git(
|
|
'-c',
|
|
`http.extraHeader=Authorization: Basic ${auth}`,
|
|
'push',
|
|
'--force',
|
|
'origin',
|
|
'HEAD:update',
|
|
)
|
|
}
|
|
|
|
async function finalizeRelease() {
|
|
fs.mkdirSync(outputDirectory, { recursive: true })
|
|
const [githubManifest, githubRelease, latestGithubRelease] = await Promise.all([
|
|
loadGithubManifest(),
|
|
getGithubRelease(),
|
|
getLatestGithubRelease(),
|
|
])
|
|
const isLatestRelease = githubRelease.tag_name === latestGithubRelease.tag_name
|
|
const release = await ensureRelease(githubRelease)
|
|
const mirroredNames = await mirrorGithubAssets(release, githubRelease)
|
|
const manifest = createCnbManifest(githubManifest, mirroredNames)
|
|
const manifestPath = path.join(outputDirectory, 'latest.json')
|
|
fs.writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`)
|
|
await uploadAsset(release, manifestPath)
|
|
|
|
const prerelease = tag.includes('-')
|
|
await apiRequest(`${apiEndpoint}/${repo}/-/releases/${release.id}`, {
|
|
method: 'PATCH',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
name: githubRelease.name || `Axolotl Launcher ${tag}`,
|
|
body: githubRelease.body || '',
|
|
draft: false,
|
|
prerelease,
|
|
make_latest: isLatestRelease ? 'true' : 'false',
|
|
}),
|
|
})
|
|
if (isLatestRelease) {
|
|
publishUpdateBranch(manifestPath)
|
|
} else {
|
|
console.log(`Skipped update branch for non-latest GitHub release ${tag}`)
|
|
}
|
|
console.log(`Published CNB release ${tag}`)
|
|
}
|
|
|
|
await finalizeRelease()
|