Files
kra-new/web/deploy/release.mjs
T

155 lines
8.5 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 imageRepository = 'harbor.fuhua91.com/game/sw2'
export const frontendImage = tag => `${imageRepository}:${validTag(tag)}`
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'
}