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 ; 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()