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