XuqmGroup-RNSDK/packages/update/scripts/xuqm_release.mjs

207 行
8.0 KiB
JavaScript

#!/usr/bin/env node
import { existsSync, readFileSync } from 'node:fs'
import { createHash } from 'node:crypto'
import path from 'node:path'
import { createInterface } from 'node:readline'
import process from 'node:process'
import {
SUPPORTED_PLATFORMS,
bundleOutputDirectory,
parseModuleOptions,
readConfig,
resolveHostPackageId,
resolveVersionedModules,
selectModules,
} from './xuqm-config.mjs'
import {
BUGCOLLECT_ARTIFACT_UPLOAD_PATH,
createArtifactUploadRequest,
createRnSourceMapForm,
} from './artifact-upload.mjs'
import { validateReleaseConfig } from './release-config.mjs'
import { readReleasePackageIdentity } from './release-package.mjs'
import { parseBugCollectOption } from './package-options.mjs'
const root = process.cwd()
const rawArgs = process.argv.slice(2)
function takeOption(name) {
const index = rawArgs.indexOf(name)
if (index < 0) return undefined
const value = rawArgs[index + 1]
if (!value || value.startsWith('--')) throw new Error(`${name} requires a value`)
rawArgs.splice(index, 2)
return value
}
const platform = takeOption('--platform') ?? 'android'
const noteOption = takeOption('--note')
const parsedBugCollect = parseBugCollectOption(
rawArgs,
process.env.XUQM_BUGCOLLECT_MODE ?? 'platform',
)
const bugCollectMode = parsedBugCollect.bugCollectMode
rawArgs.splice(0, rawArgs.length, ...parsedBugCollect.remaining)
const publishOptionIndex = rawArgs.indexOf('--publish')
const publishOption = publishOptionIndex >= 0
if (publishOption) rawArgs.splice(publishOptionIndex, 1)
const { moduleIds, remaining } = parseModuleOptions(rawArgs)
if (remaining.length) throw new Error(`unknown option(s): ${remaining.join(' ')}`)
if (!SUPPORTED_PLATFORMS.has(platform)) throw new Error('platform must be android or ios')
if (!['platform', 'disabled'].includes(bugCollectMode)) {
throw new Error('--bugcollect must be platform or disabled')
}
const config = readConfig(root)
const hostPackageId = resolveHostPackageId(config, platform)
const versioned = resolveVersionedModules(config, root, process.env.XUQM_APP_VERSION)
const selectedModules = selectModules({ ...config, modules: versioned.modules }, moduleIds)
if (moduleIds.length > 0 && selectedModules.some(module => module.type === 'startup')) {
throw new Error('startup is embedded in the host APK and cannot be published independently')
}
const modules = selectedModules.filter(module => module.type !== 'startup')
if (modules.length === 0) throw new Error('no publishable common/app/buz module selected')
const serverUrl = process.env.XUQM_SERVER_URL ?? config.release?.serverUrl
const appKey = process.env.XUQM_APP_KEY ?? config.release?.appKey
// API Token 只允许由 CI/当前进程注入,禁止把可用凭据写进会进入版本库的插件清单。
const apiToken = process.env.XUQM_API_TOKEN
const bugCollectUrl = process.env.XUQM_BUGCOLLECT_URL
if (!serverUrl || !appKey || !apiToken) {
throw new Error(
'publish requires XUQM_API_TOKEN and XUQM_SERVER_URL/XUQM_APP_KEY (environment or non-secret release config)',
)
}
const rl = createInterface({ input: process.stdin, output: process.stdout })
const ask = question => new Promise(resolve => rl.question(`\x1b[36m${question}\x1b[0m`, resolve))
const confirm = async question => /^y/i.test(((await ask(`${question} [y/N]: `)) ?? '').trim())
function apiHeaders() {
return { Authorization: `Bearer ${apiToken}` }
}
async function validateReleaseConfigOnline() {
await validateReleaseConfig({
projectRoot: root,
packageName: hostPackageId,
})
}
function releasePackage(module) {
const file = path.join(
bundleOutputDirectory(root, config, platform, module.id),
`${module.id}.${platform}.xuqm.zip`,
)
if (!existsSync(file)) throw new Error(`plugin package is missing: ${file}`)
const identity = readReleasePackageIdentity(file, {
packageName: hostPackageId,
moduleId: module.id,
platform,
moduleVersion: module.moduleVersion,
appVersion: versioned.appVersion,
})
return { file, identity }
}
async function uploadBundle(module, release, note) {
const { file, identity } = release
const form = new FormData()
2026-05-07 19:39:41 +08:00
form.append('appKey', appKey)
form.append('moduleId', module.id)
form.append('platform', platform.toUpperCase())
form.append('version', identity.moduleVersion)
form.append('appVersionRange', identity.appVersionRange)
form.append('builtAgainstNativeBaselineId', identity.nativeBaselineId)
form.append('buildId', identity.buildId)
form.append('bundleFormat', identity.bundleFormat)
form.append('commonVersionRange', identity.commonVersionRange ?? '')
form.append('minNativeApiLevel', String(identity.minNativeApiLevel))
form.append('note', note)
form.append('packageName', identity.packageName)
form.append('bundle', new Blob([readFileSync(file)]), `${module.id}.${platform}.xuqm.zip`)
const response = await fetch(`${serverUrl}/api/v1/rn/upload`, {
method: 'POST',
headers: apiHeaders(),
body: form,
})
if (!response.ok) throw new Error(`upload failed for ${module.id}: ${await response.text()}`)
return response.json()
}
async function uploadSourceMap(module, release) {
const bundleDirectory = bundleOutputDirectory(root, config, platform, module.id)
const bundleFile = path.join(bundleDirectory, `${module.id}.${platform}.bundle`)
const sourceMapFile = `${bundleFile}.map`
if (!existsSync(sourceMapFile)) throw new Error(`Source Map is missing: ${sourceMapFile}`)
const sourceMapBundleHash = createHash('sha256').update(readFileSync(bundleFile)).digest('hex')
if (sourceMapBundleHash !== release.identity.bundleHash) {
throw new Error(
`Source Map Bundle identity mismatch for ${module.id}; rebuild before publishing`,
)
}
if (bugCollectMode === 'disabled') {
console.log(` ${module.id}/${platform} Source Map archived locally (BugCollect disabled)`)
return
}
if (!bugCollectUrl) {
throw new Error('XUQM_BUGCOLLECT_URL is required when BugCollect Source Map upload is enabled')
}
const form = createRnSourceMapForm({
appKey,
platform,
appVersion: versioned.appVersion,
buildId: release.identity.buildId,
moduleId: module.id,
moduleVersion: module.moduleVersion,
bundleHash: release.identity.bundleHash,
fileBytes: readFileSync(sourceMapFile),
fileName: `${module.id}.${platform}.map`,
})
const response = await fetch(
`${bugCollectUrl.replace(/\/$/, '')}${BUGCOLLECT_ARTIFACT_UPLOAD_PATH}`,
createArtifactUploadRequest(apiToken, form),
)
if (!response.ok) {
throw new Error(`Source Map upload failed for ${module.id}: ${await response.text()}`)
}
}
async function main() {
await validateReleaseConfigOnline()
const note = noteOption ?? (await ask('Release notes: '))
const publishImmediately = publishOption || config.release?.publishImmediately === true
console.log('\n\x1b[36m--- Xuqm RN plugin publish ---\x1b[0m')
console.log(` Platform: ${platform}`)
console.log(
` Modules: ${modules.map(module => `${module.id}@${module.moduleVersion}`).join(', ')}`,
)
console.log(` Publish immediately: ${publishImmediately}`)
console.log(` BugCollect: ${bugCollectMode}`)
if (!(await confirm('Proceed?'))) return
for (const module of modules) {
const release = releasePackage(module)
const result = await uploadBundle(module, release, note)
const bundleId = result.data?.id
console.log(`\x1b[32m✓ ${module.id}/${platform} uploaded, ID: ${bundleId}\x1b[0m`)
// Source Map 是正式发布硬门禁,必须在插件进入 published 状态前完成。
await uploadSourceMap(module, release)
if (publishImmediately) {
const response = await fetch(`${serverUrl}/api/v1/rn/${bundleId}/publish`, {
method: 'POST',
headers: apiHeaders(),
})
if (!response.ok) throw new Error(`publish failed for ${module.id}: ${await response.text()}`)
console.log(` ${module.id}/${platform} published`)
}
}
}
main()
.catch(error => {
console.error(`\n\x1b[31m${error.message}\x1b[0m`)
process.exitCode = 1
})
.finally(() => rl.close())