277 lines
9.4 KiB
JavaScript
277 lines
9.4 KiB
JavaScript
/* eslint-disable @typescript-eslint/explicit-function-return-type */
|
|
import { execFile } from 'node:child_process'
|
|
import { access, readFile } from 'node:fs/promises'
|
|
import os from 'node:os'
|
|
import path from 'node:path'
|
|
import process from 'node:process'
|
|
import { promisify } from 'node:util'
|
|
import { loadReleaseEnvironment } from './release-environment.mjs'
|
|
import { assertReleaseFragment } from './release-fragment-contract.mjs'
|
|
|
|
const execFileAsync = promisify(execFile)
|
|
const WINDOWS_PLATFORM = 'windows-x64'
|
|
const PUBLIC_RELEASE_BASE_URL = 'https://download.ncurator.com'
|
|
|
|
const commandResult = async (command, args, options = {}) => {
|
|
try {
|
|
const result = await execFileAsync(command, args, {
|
|
maxBuffer: 10 * 1024 * 1024,
|
|
shell: process.platform === 'win32' && /\.(cmd|bat)$/iu.test(command),
|
|
...options
|
|
})
|
|
return { ok: true, stdout: result.stdout.trim(), stderr: result.stderr.trim() }
|
|
} catch (error) {
|
|
return {
|
|
ok: false,
|
|
stdout: String(error?.stdout || '').trim(),
|
|
stderr: String(error?.stderr || error?.message || '').trim()
|
|
}
|
|
}
|
|
}
|
|
|
|
const resolveAwsCandidates = () => {
|
|
const configured = String(process.env.NCURATOR_AWS_CLI || '').trim()
|
|
if (configured) return [configured]
|
|
const candidates = []
|
|
if (process.platform === 'win32') {
|
|
if (process.env.ProgramFiles) {
|
|
candidates.push(path.join(process.env.ProgramFiles, 'Amazon', 'AWSCLIV2', 'aws.exe'))
|
|
}
|
|
candidates.push(path.join(os.homedir(), '.ncurator', 'release-tools', 'awscli', 'aws.exe'))
|
|
} else {
|
|
candidates.push(path.join(os.homedir(), '.ncurator', 'release-tools', 'awscli', 'bin', 'aws'))
|
|
}
|
|
candidates.push('aws')
|
|
return [...new Set(candidates)]
|
|
}
|
|
|
|
export const resolveAwsExecutable = async () => {
|
|
for (const candidate of resolveAwsCandidates()) {
|
|
if (path.isAbsolute(candidate)) {
|
|
const exists = await access(candidate)
|
|
.then(() => true)
|
|
.catch(() => false)
|
|
if (!exists) continue
|
|
}
|
|
const result = await commandResult(candidate, ['--version'])
|
|
if (result.ok) return candidate
|
|
}
|
|
return ''
|
|
}
|
|
|
|
const resolvePython = async () => {
|
|
const override = String(process.env.NCURATOR_SIDECAR_BUILD_PYTHON || '').trim()
|
|
const candidates = override
|
|
? [{ command: override, args: [] }]
|
|
: process.platform === 'win32'
|
|
? [
|
|
{ command: 'python', args: [] },
|
|
{ command: 'python3', args: [] },
|
|
{ command: 'py', args: ['-3.12'] }
|
|
]
|
|
: [
|
|
{ command: 'python3.12', args: [] },
|
|
{ command: 'python3', args: [] },
|
|
{ command: 'python', args: [] }
|
|
]
|
|
for (const candidate of candidates) {
|
|
const result = await commandResult(candidate.command, [
|
|
...candidate.args,
|
|
'-c',
|
|
'import platform, sys; print(f"{sys.version_info.major}.{sys.version_info.minor}|{platform.machine()}")'
|
|
])
|
|
if (!result.ok) continue
|
|
const [version, architecture] = result.stdout.split('|')
|
|
const [major, minor] = version.split('.').map(Number)
|
|
if (major === 3 && minor === 12) {
|
|
return { command: candidate.command, args: candidate.args, version, architecture }
|
|
}
|
|
}
|
|
return undefined
|
|
}
|
|
|
|
const expectedPnpmVersion = async (root) => {
|
|
const packageJson = JSON.parse(await readFile(path.join(root, 'package.json'), 'utf8'))
|
|
return String(packageJson.packageManager || '').match(/^pnpm@([^+]+)/u)?.[1] || ''
|
|
}
|
|
|
|
export const inspectLocalWindowsRelease = async (root) => {
|
|
await loadReleaseEnvironment(root)
|
|
const issues = []
|
|
if (process.platform !== 'win32')
|
|
issues.push(`operating system is ${process.platform}, not win32`)
|
|
if (process.arch !== 'x64') issues.push(`Node architecture is ${process.arch}, not x64`)
|
|
if (issues.length > 0) return { ready: false, issues }
|
|
|
|
const nodeMajor = Number(process.versions.node.split('.')[0])
|
|
if (nodeMajor !== 22)
|
|
issues.push(`Node ${process.versions.node} is installed; Node 22 is required`)
|
|
|
|
const pnpmExecutable = 'pnpm.cmd'
|
|
const pnpm = await commandResult(pnpmExecutable, ['--version'], { cwd: root })
|
|
const expectedPnpm = await expectedPnpmVersion(root)
|
|
if (!pnpm.ok) issues.push('pnpm is not available')
|
|
else if (expectedPnpm && pnpm.stdout !== expectedPnpm) {
|
|
issues.push(`pnpm ${pnpm.stdout} is installed; pnpm ${expectedPnpm} is required`)
|
|
}
|
|
|
|
const python = await resolvePython()
|
|
if (!python) issues.push('Python 3.12 x64 is not available')
|
|
else if (!/^(AMD64|x86_64)$/iu.test(python.architecture)) {
|
|
issues.push(`Python architecture is ${python.architecture}, not x64`)
|
|
}
|
|
|
|
const awsExecutable = await resolveAwsExecutable()
|
|
if (!awsExecutable) issues.push('AWS CLI is not available')
|
|
|
|
for (const [command, label] of [
|
|
['git', 'Git'],
|
|
['tar', 'tar']
|
|
]) {
|
|
if (!(await commandResult(command, ['--version'])).ok) issues.push(`${label} is not available`)
|
|
}
|
|
|
|
for (const name of [
|
|
'AWS_ACCESS_KEY_ID',
|
|
'AWS_SECRET_ACCESS_KEY',
|
|
'R2_ACCOUNT_ID',
|
|
'R2_BUCKET_NAME',
|
|
'R2_PUBLIC_BASE_URL'
|
|
]) {
|
|
if (!String(process.env[name] || '').trim())
|
|
issues.push(`missing environment variable: ${name}`)
|
|
}
|
|
const publicBaseUrl = String(process.env.R2_PUBLIC_BASE_URL || '').replace(/\/$/u, '')
|
|
if (publicBaseUrl && publicBaseUrl !== PUBLIC_RELEASE_BASE_URL) {
|
|
issues.push(`R2_PUBLIC_BASE_URL must be ${PUBLIC_RELEASE_BASE_URL}`)
|
|
}
|
|
|
|
return {
|
|
ready: issues.length === 0,
|
|
issues,
|
|
awsExecutable,
|
|
pnpmExecutable,
|
|
python
|
|
}
|
|
}
|
|
|
|
export const assertWindowsReleaseRouteAvailable = async ({
|
|
root,
|
|
forceLocal,
|
|
forceActions,
|
|
localInspection
|
|
}) => {
|
|
await loadReleaseEnvironment(root)
|
|
const inspection =
|
|
localInspection || (forceActions ? undefined : await inspectLocalWindowsRelease(root))
|
|
if (forceLocal) {
|
|
if (!inspection?.ready) {
|
|
throw new Error(`local Windows release is unavailable:\n- ${inspection?.issues.join('\n- ')}`)
|
|
}
|
|
return 'local'
|
|
}
|
|
if (!forceActions && inspection?.ready) return 'local'
|
|
if (!String(process.env.NCURATOR_ACTIONS_TOKEN || '').trim()) {
|
|
const reason = inspection?.issues?.length ? `\n- ${inspection.issues.join('\n- ')}` : ''
|
|
throw new Error(
|
|
`Windows cannot be released locally and NCURATOR_ACTIONS_TOKEN is not configured for the Actions fallback${reason}`
|
|
)
|
|
}
|
|
return 'actions'
|
|
}
|
|
|
|
export const dispatchWindowsRelease = async (root, releaseTag) => {
|
|
await loadReleaseEnvironment(root)
|
|
const token = String(process.env.NCURATOR_ACTIONS_TOKEN || '').trim()
|
|
if (!token) throw new Error('NCURATOR_ACTIONS_TOKEN is required to dispatch GitHub Actions')
|
|
const repository = String(process.env.RELEASE_REPOSITORY || 'Yoan98/Ncurator')
|
|
const response = await fetch(
|
|
`https://api.github.com/repos/${repository}/actions/workflows/release.yml/dispatches`,
|
|
{
|
|
method: 'POST',
|
|
headers: {
|
|
Accept: 'application/vnd.github+json',
|
|
Authorization: `Bearer ${token}`,
|
|
'X-GitHub-Api-Version': '2022-11-28',
|
|
'Content-Type': 'application/json'
|
|
},
|
|
body: JSON.stringify({ ref: 'main', inputs: { tag: releaseTag } })
|
|
}
|
|
)
|
|
if (!response.ok) {
|
|
throw new Error(
|
|
`GitHub Actions dispatch failed: HTTP ${response.status} ${await response.text()}`
|
|
)
|
|
}
|
|
process.stdout.write(`[release] dispatched GitHub Actions for Windows ${releaseTag}\n`)
|
|
}
|
|
|
|
export const readPublicReleaseFragment = async (
|
|
releaseTag,
|
|
platform,
|
|
publicBaseUrl = PUBLIC_RELEASE_BASE_URL
|
|
) => {
|
|
const response = await fetch(
|
|
`${publicBaseUrl}/release-status/${releaseTag}/${platform}.json?check=${Date.now()}`,
|
|
{ cache: 'no-store' }
|
|
)
|
|
if (response.status === 404) return undefined
|
|
if (!response.ok) {
|
|
throw new Error(`release status lookup failed for ${platform}: HTTP ${response.status}`)
|
|
}
|
|
const fragment = await response.json()
|
|
assertReleaseFragment(fragment, {
|
|
tag: releaseTag,
|
|
platform,
|
|
label: `remote release fragment ${platform}`
|
|
})
|
|
return fragment
|
|
}
|
|
|
|
export const verifyPublicReleaseFragment = async (
|
|
fragment,
|
|
publicBaseUrl = PUBLIC_RELEASE_BASE_URL
|
|
) => {
|
|
for (const file of fragment.files) {
|
|
const response = await fetch(`${publicBaseUrl}/artifacts/${fragment.tag}/${file.name}`, {
|
|
method: 'HEAD',
|
|
cache: 'no-store'
|
|
})
|
|
if (!response.ok || Number(response.headers.get('content-length')) !== file.size) {
|
|
throw new Error(`remote release asset verification failed: ${file.name}`)
|
|
}
|
|
}
|
|
}
|
|
|
|
export const verifyR2ReleaseFragment = async (fragment, awsExecutable) => {
|
|
const endpoint = `https://${process.env.R2_ACCOUNT_ID}.r2.cloudflarestorage.com`
|
|
const bucket = String(process.env.R2_BUCKET_NAME || '').replace(/^\/+|\/+$/gu, '')
|
|
if (!awsExecutable || !bucket || !process.env.R2_ACCOUNT_ID) {
|
|
throw new Error('R2 verification configuration is incomplete')
|
|
}
|
|
for (const file of fragment.files) {
|
|
const key = `artifacts/${fragment.tag}/${file.name}`
|
|
const result = await commandResult(awsExecutable, [
|
|
's3api',
|
|
'head-object',
|
|
'--bucket',
|
|
bucket,
|
|
'--key',
|
|
key,
|
|
'--output',
|
|
'json',
|
|
'--endpoint-url',
|
|
endpoint,
|
|
'--region',
|
|
'auto'
|
|
])
|
|
if (!result.ok) throw new Error(`R2 release asset lookup failed: ${key}`)
|
|
const remote = JSON.parse(result.stdout)
|
|
if (Number(remote.ContentLength) !== file.size || remote.Metadata?.sha512 !== file.sha512) {
|
|
throw new Error(`R2 release asset does not match fragment: ${key}`)
|
|
}
|
|
}
|
|
}
|
|
|
|
export { PUBLIC_RELEASE_BASE_URL, WINDOWS_PLATFORM }
|