Files
Ncurator/scripts/release/shared/windows.mjs
T
Yoan Huang d4974286ef refactor: update test cases to improve structure and clarity
- Remove obsolete end-to-end test for system notification session navigation.
- Enhance workspace scan progress tests to verify UI state restoration.
- Update web prepare progress tests to ensure correct UI behavior after navigation.
- Refactor unit tests for AI persistence and model configuration to improve naming consistency.
- Modify release tests to use localized product names and ensure correct artifact naming.
- Adjust vector and workspace backend tests to reflect unit test categorization.
- Improve error handling in web crawler test utilities.
- Update Electron app launch environment to support additional environment variables.
- Refactor end-to-end test cases for workbench to improve readability and maintainability.
2026-07-25 11:22:23 +08:00

303 lines
10 KiB
JavaScript

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 './environment.mjs'
import { assertReleaseFragment } from '../r2/fragment-contract.mjs'
import { fetchWithRetry } from './http.mjs'
import { artifactKey, statusKey } from '../r2/storage-layout.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()}|{sys.executable}")'
])
if (!result.ok) continue
const [version, architecture, executable] = result.stdout.split('|')
const [major, minor] = version.split('.').map(Number)
if (major === 3 && minor === 12) {
return {
command: candidate.command,
args: candidate.args,
version,
architecture,
executable
}
}
}
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, { requirePublish = true } = {}) => {
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 = requirePublish ? await resolveAwsExecutable() : ''
if (requirePublish && !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`)
}
if (requirePublish) {
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,
{ mode, candidateRunId = '', regression = {} }
) => {
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 fetchWithRetry(
`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,
mode,
candidate_run_id: candidateRunId,
test_domain: String(regression.domain || ''),
test_suite: String(regression.suite || ''),
test_groups: Array.isArray(regression.groups) ? regression.groups.join(',') : '',
test_case: String(regression.caseId || ''),
fresh_knowledge_cache: regression.freshKnowledgeCache ? 'true' : 'false'
}
})
}
)
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}/${statusKey(releaseTag, platform)}?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 fetchWithRetry(`${publicBaseUrl}/${artifactKey(fragment, file.storageName)}`, {
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 = artifactKey(fragment, file.storageName)
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 }