feat: 支持前端独立打包与测试服Docker发布

This commit is contained in:
Yvan
2026-11-05 11:54:02 +08:00
parent c7d4ffbd9c
commit c4acb91c27
16 changed files with 702 additions and 1 deletions
+152
View File
@@ -0,0 +1,152 @@
import fs from 'node:fs'
import path from 'node:path'
import os from 'node:os'
import crypto from 'node:crypto'
import { fileURLToPath } from 'node:url'
import { execFileSync, spawnSync } from 'node:child_process'
import { parseArgs } from 'node:util'
export const frontendRoot = fileURLToPath(new URL('../', import.meta.url))
export const options = (definitions) => parseArgs({
options: { help: { type: 'boolean' }, ...definitions },
args: process.argv.slice(2).filter((arg, i) => !(i === 0 && arg === '--'))
}).values
export function run(command, args, settings = {}) {
return execFileSync(command, args, { cwd: frontendRoot, stdio: 'inherit', ...settings })
}
const capture = (command, args, settings = {}) => run(command, args, { stdio: ['pipe', 'pipe', 'pipe'], encoding: 'utf8', maxBuffer: 16 * 1024 * 1024, ...settings }).trim()
const git = (...args) => capture('git', args)
export function validTag(value) {
if (!/^[A-Za-z0-9][A-Za-z0-9_.-]{0,79}$/.test(value || '')) throw new Error('版本号只能包含字母、数字、点、下划线和短横线,最长 80 个字符')
return value
}
export const shellQuote = value => `'${String(value).replaceAll("'", "'\\''")}'`
export function assertClean() {
for (const args of [['diff', '--quiet', '--', '.'], ['diff', '--cached', '--quiet', '--', '.']]) {
const result = spawnSync('git', args, { cwd: frontendRoot, stdio: 'pipe' })
if (result.error) throw result.error
if (result.status !== 0) throw new Error('请先提交前端源代码及生成的 src/pathInfo.json,再打包')
}
if (git('ls-files', '--others', '--exclude-standard', '--', '.')) throw new Error('前端目录存在未提交文件,请提交或配置忽略规则后再打包')
}
export function sourceInfo(args = {}) {
assertClean()
const prefix = git('rev-parse', '--show-prefix').replace(/\/$/, '')
const tree = git('rev-parse', prefix ? `HEAD:${prefix}` : 'HEAD^{tree}')
const revision = git('rev-parse', '--verify', '--end-of-options', `${args['source-ref'] || 'HEAD'}^{commit}`)
if (args['source-ref'] && git('rev-parse', `${revision}^{tree}`) !== tree) throw new Error('--source-ref 的根目录必须与当前前端源码树一致')
return { formatVersion: 1, version: validTag(args.tag || revision.slice(0, 12)), revision, tree, sourceSubdirectory: args['source-ref'] ? '' : prefix, builtAt: new Date().toISOString() }
}
function pnpm(args) {
const executable = process.env.npm_execpath
if (executable && /\.(?:c?js|mjs)$/.test(executable)) return run(process.execPath, [executable, ...args])
if (process.platform !== 'win32') return run('pnpm', args)
// Only these fixed commands enter cmd.exe; no user argument is interpolated.
const commands = { build: 'pnpm build', install: 'pnpm install --frozen-lockfile' }
return run(process.env.ComSpec || 'cmd.exe', ['/d', '/s', '/c', commands[args[0]]])
}
function regularTree(directory) {
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
const filename = path.join(directory, entry.name)
if (entry.isDirectory()) regularTree(filename)
else if (!entry.isFile()) throw new Error(`构建产物不允许符号链接或特殊文件:${filename}`)
}
}
export function writeBuildInfo(directory, info) {
fs.writeFileSync(path.join(directory, 'build-info.json'), JSON.stringify(info, null, 2) + '\n')
fs.writeFileSync(path.join(directory, 'version.txt'), info.version + '\n')
}
export function packageRelease(args = {}) {
const info = sourceInfo(args)
const output = path.resolve(frontendRoot, args.output || '.cache/deploy')
fs.mkdirSync(output, { recursive: true })
const archive = path.join(output, `sw2-frontend-${info.version}.tar.gz`)
if (fs.existsSync(archive)) throw new Error(`发布包已存在,请复用该包或指定新的 --tag:${archive}`)
const lock = `${archive}.lock`
const descriptor = fs.openSync(lock, 'wx')
fs.closeSync(descriptor)
let stage, created = false, complete = false
try {
if (!args['skip-install']) pnpm(['install', '--frozen-lockfile'])
pnpm(['build'])
const after = sourceInfo(args)
if (after.tree !== info.tree || after.revision !== info.revision) throw new Error('构建期间源码发生变化,请重新打包')
const dist = path.join(frontendRoot, 'dist')
if (!fs.statSync(path.join(dist, 'index.html')).isFile()) throw new Error('缺少 dist/index.html')
regularTree(dist)
stage = fs.mkdtempSync(path.join(output, '.frontend-stage-'))
fs.cpSync(dist, path.join(stage, 'dist'), { recursive: true })
writeBuildInfo(path.join(stage, 'dist'), info)
for (const [from, to] of [['runtime.Dockerfile', 'Dockerfile'], ['nginx.conf', 'nginx.conf'], ['deploy.sh', 'deploy.sh']]) {
fs.writeFileSync(path.join(stage, to), fs.readFileSync(path.join(frontendRoot, 'deploy', from), 'utf8').replaceAll('\r\n', '\n'))
}
fs.writeFileSync(path.join(stage, 'release.json'), JSON.stringify(info, null, 2) + '\n')
const fd = fs.openSync(archive, 'wx'); fs.closeSync(fd); created = true
run('tar', ['-czf', archive, '-C', stage, '.'])
const checksum = sha256(archive)
fs.writeFileSync(`${archive}.sha256`, `${checksum} ${path.basename(archive)}\n`)
complete = true
console.log(`发布包:${archive}\nSHA256:${checksum}`)
return { archive, checksum, info }
} finally {
if (stage && path.dirname(stage) === output && path.basename(stage).startsWith('.frontend-stage-')) fs.rmSync(stage, { recursive: true })
if (created && !complete) fs.rmSync(archive)
fs.rmSync(lock)
}
}
export const sha256 = filename => crypto.createHash('sha256').update(fs.readFileSync(filename)).digest('hex')
export function validateEntries(names, types) {
for (const name of names) {
const clean = name.replace(/^\.\//, '').replace(/\/$/, '')
if (clean === '' || clean === '.') continue
if (name.includes('\\') || name.includes('\r') || name.startsWith('/') || clean.split('/').includes('..')) throw new Error(`不安全的包内路径:${name}`)
if (!['Dockerfile', 'nginx.conf', 'deploy.sh', 'release.json', 'dist'].includes(clean) && !clean.startsWith('dist/')) throw new Error(`发布包包含非前端产物:${name}`)
}
if (types.some(type => type !== '-' && type !== 'd')) throw new Error('发布包不能包含符号链接、硬链接或特殊文件')
for (const required of ['Dockerfile', 'nginx.conf', 'deploy.sh', 'release.json', 'dist/index.html', 'dist/version.txt']) {
if (!names.some(name => name.replace(/^\.\//, '') === required)) throw new Error(`发布包缺少 ${required}`)
}
}
export function inspectPackage(filename) {
const archive = path.resolve(filename)
const names = capture('tar', ['-tzf', archive]).split(/\r?\n/)
const types = capture('tar', ['-tvzf', archive]).split(/\r?\n/).map(line => line[0])
validateEntries(names, types)
const entry = name => names.find(item => item.replace(/^\.\//, '') === name)
const info = JSON.parse(capture('tar', ['-xOf', archive, entry('release.json')]))
validTag(info.version)
if (info.formatVersion !== 1 || !/^[a-f0-9]{40,64}$/.test(info.revision || '') || !/^[a-f0-9]{40,64}$/.test(info.tree || '')) throw new Error('发布包版本信息无效')
if (capture('tar', ['-xOf', archive, entry('dist/version.txt')]) !== info.version) throw new Error('发布包与前端版本标记不一致')
return { archive, info, checksum: sha256(archive) }
}
export function deploymentTarget(args = {}) {
const host = args.host || '192.168.2.226', user = args.user || 'root', port = String(args.port || 22)
const base = args.directory || '/www/wwwroot/sw2.fuhua91.com'
if (!/^[A-Za-z0-9][A-Za-z0-9.-]*$/.test(host) || !/^[A-Za-z_][A-Za-z0-9_-]*$/.test(user)) throw new Error('SSH 主机或用户名无效')
if (!/^\d+$/.test(port) || Number(port) < 1 || Number(port) > 65535) throw new Error('SSH 端口无效')
if (!/^\/(?:[A-Za-z0-9_.-]+\/)*[A-Za-z0-9_.-]+$/.test(base) || base.split('/').some(part => part === '..' || part === '.')) throw new Error('部署目录须为不含空格及相对路径段的绝对路径')
return { host, user, port, base, remote: `${user}@${host}` }
}
export function fail(error) {
console.error(error.message || String(error))
process.exitCode = 1
}
export function bashExecutable() {
if (process.env.BASH) return process.env.BASH
const gitBash = path.join(process.env.ProgramFiles || 'C:/Program Files', 'Git', 'bin', 'bash.exe')
return os.platform() === 'win32' && fs.existsSync(gitBash) ? gitBash : 'bash'
}