feat: 支持跨平台前端镜像部署

This commit is contained in:
2026-09-20 15:25:04 +08:00
parent cb1561a317
commit 55eec78a90
6 changed files with 210 additions and 75 deletions

View File

@@ -13,3 +13,6 @@ yarn-error.log*
.env.local
.env.*.local
openspec
.codex
.claude
.agents

View File

@@ -10,20 +10,13 @@ on:
# 如果只是想保留构建(不部署),可以留着,反正部署条件已经只认 iteration/august
- main
concurrency:
group: one-pipe-system-${{ github.ref }}
cancel-in-progress: true
env:
REGISTRY: registry.boss160.cn
IMAGE_NAME: registry.boss160.cn/junhong/cmp-admin-web
DEPLOY_DIR: /opt/junhong_cmp_web
NODE_OPTIONS: --max-old-space-size=1536
MIN_AVAILABLE_MEMORY_MB: 2800
MIN_AVAILABLE_DISK_GB: 12
jobs:
build-and-deploy:
deploy:
runs-on: ubuntu-latest
steps:
@@ -34,81 +27,26 @@ jobs:
git clone https://git.boss160.cn/luo/one-pipe-system.git .
git checkout ${{ github.sha }}
- name: 设置镜像标签
id: tag
run: |
if [ "${{ github.ref }}" = "refs/heads/iteration/august" ]; then
echo "tag=latest" >> $GITHUB_OUTPUT
elif [ "${{ github.ref }}" = "refs/heads/dev" ]; then
echo "tag=dev" >> $GITHUB_OUTPUT
elif [ "${{ github.ref }}" = "refs/heads/test" ]; then
echo "tag=test" >> $GITHUB_OUTPUT
else
echo "tag=unknown" >> $GITHUB_OUTPUT
fi
- name: 登录 Docker Registry
run: |
echo "${{ secrets.REGISTRY_PASSWORD }}" | docker login "${{ env.REGISTRY }}" --username "${{ secrets.REGISTRY_USERNAME }}" --password-stdin
- name: 构建前端镜像
- name: 校验镜像存在
run: |
command -v flock >/dev/null || {
echo "服务器缺少 flock,无法保证构建互斥"
exit 1
}
exec 9>/tmp/one-pipe-system-build.lock
if ! flock -n 9; then
echo "已有 one-pipe-system 构建正在运行,本次任务拒绝并发构建"
if ! docker manifest inspect "${{ env.IMAGE_NAME }}:${{ github.sha }}" >/dev/null 2>&1; then
echo "镜像不存在:${{ env.IMAGE_NAME }}:${{ github.sha }}"
echo "请先在开发机完成 pre-push 镜像构建和推送"
exit 1
fi
AVAILABLE_MEMORY_MB=$(awk '/MemAvailable:/ { printf "%d", $2 / 1024 }' /proc/meminfo)
DOCKER_ROOT_DIR=$(docker info --format '{{.DockerRootDir}}')
AVAILABLE_DISK_GB=$(df -Pk "$DOCKER_ROOT_DIR" | awk 'NR == 2 { printf "%d", $4 / 1024 / 1024 }')
echo "构建前可用内存: ${AVAILABLE_MEMORY_MB} MiB"
echo "Docker 所在磁盘可用空间: ${AVAILABLE_DISK_GB} GiB"
if [ "$AVAILABLE_MEMORY_MB" -lt "${{ env.MIN_AVAILABLE_MEMORY_MB }}" ]; then
echo "可用内存不足,拒绝启动前端构建,避免测试服务器失去响应"
exit 1
fi
if [ "$AVAILABLE_DISK_GB" -lt "${{ env.MIN_AVAILABLE_DISK_GB }}" ]; then
echo "Docker 所在磁盘空间不足,拒绝启动前端构建"
exit 1
fi
docker build --build-arg NODE_OPTIONS="${{ env.NODE_OPTIONS }}" -t ${{ env.IMAGE_NAME }}:${{ github.sha }} .
docker tag ${{ env.IMAGE_NAME }}:${{ github.sha }} ${{ env.IMAGE_NAME }}:${{ steps.tag.outputs.tag }}
- name: 推送镜像到 Registry
run: |
docker push ${{ env.IMAGE_NAME }}:${{ steps.tag.outputs.tag }}
docker push ${{ env.IMAGE_NAME }}:${{ github.sha }}
- name: 部署到本地(仅 iteration/august 分支)
- name: 部署到测试环境
if: github.ref == 'refs/heads/iteration/august'
run: |
# 确保部署目录存在
mkdir -p ${{ env.DEPLOY_DIR }}
mkdir -p "${{ env.DEPLOY_DIR }}"
cp -v docker-compose.prod.yml "${{ env.DEPLOY_DIR }}/"
cd "${{ env.DEPLOY_DIR }}"
echo "当前工作目录: $(pwd)"
echo "当前目录内容:"
ls -la
# 更新 docker-compose.prod.yml
echo "更新部署配置文件..."
cp -v docker-compose.prod.yml ${{ env.DEPLOY_DIR }}/
cd ${{ env.DEPLOY_DIR }}
echo "拉取最新镜像..."
IMAGE_TAG=${{ github.sha }} docker compose -f docker-compose.prod.yml pull
echo "重启服务..."
IMAGE_TAG=${{ github.sha }} docker compose -f docker-compose.prod.yml up -d
echo "等待服务健康检查通过..."
@@ -125,15 +63,13 @@ jobs:
sleep 2
done
echo "部署完成!"
IMAGE_TAG=${{ github.sha }} docker compose -f docker-compose.prod.yml ps
- name: 构建结果通知
if: always()
run: |
if [ "${{ job.status }}" = "success" ]; then
echo "构建成功: ${{ steps.tag.outputs.tag }}"
echo "镜像标签: ${{ github.sha }}"
echo "部署成功: ${{ github.sha }}"
else
echo "构建失败"
echo "部署失败"
fi

1
.husky/pre-push Executable file
View File

@@ -0,0 +1 @@
node scripts/deploy-frontend.mjs --pre-push

View File

@@ -15,6 +15,8 @@
"np": "vite build --mode production",
"nd": "vite build --mode development",
"serve": "vite preview",
"deploy:frontend": "node scripts/deploy-frontend.mjs --branch iteration/august",
"deploy:frontend:branch": "node scripts/deploy-frontend.mjs --branch",
"lint": "eslint",
"fix": "eslint --fix",
"lint:prettier": "prettier --write \"**/*.{js,cjs,ts,json,tsx,css,less,scss,vue,html,md}\"",

152
scripts/deploy-frontend.mjs Normal file
View File

@@ -0,0 +1,152 @@
import { execFile } from 'node:child_process'
import { mkdtemp, rm } from 'node:fs/promises'
import { fileURLToPath } from 'node:url'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
import { promisify } from 'node:util'
const execFileAsync = promisify(execFile)
export const REGISTRY = process.env.FRONTEND_REGISTRY ?? 'registry.boss160.cn'
export const IMAGE_NAME = process.env.FRONTEND_IMAGE ?? `${REGISTRY}/junhong/cmp-admin-web`
export const NODE_OPTIONS = process.env.FRONTEND_NODE_OPTIONS ?? '--max-old-space-size=1536'
const ZERO_SHA = '0'.repeat(40)
export function parseArguments(args) {
if (args.length === 1 && args[0] === '--pre-push') {
return { mode: 'pre-push' }
}
if (args[0] === '--branch' && args.length === 2 && args[1]) {
return { mode: 'branch', branch: args[1] }
}
if (args.length === 0) {
throw new Error('缺少参数:Hook 使用 --pre-push,手动构建使用 --branch <分支>')
}
throw new Error(`未知参数:${args.join(' ')}`)
}
export function parsePrePushLines(input) {
return input
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean)
.map((line) => {
const fields = line.split(/\s+/)
if (fields.length !== 4) {
throw new Error(`无法解析 pre-push 数据:${line}`)
}
const [localRef, localSha, remoteRef, remoteSha] = fields
return { localRef, localSha, remoteRef, remoteSha }
})
}
export function resolveImageTag(sha) {
if (!/^[0-9a-f]{40}$/i.test(sha)) {
throw new Error(`不是完整 commit SHA:${sha}`)
}
return sha.toLowerCase()
}
function branchFromRef(ref) {
if (!ref.startsWith('refs/heads/')) {
throw new Error(`只允许推送分支,收到:${ref}`)
}
const branch = ref.slice('refs/heads/'.length)
if (!/^[A-Za-z0-9._/-]+$/.test(branch) || branch.includes('..') || branch.startsWith('-')) {
throw new Error(`非法分支名:${branch}`)
}
return branch
}
async function run(command, args, options = {}) {
const display = [command, ...args].join(' ')
console.log(`$ ${display}`)
return execFileAsync(command, args, {
cwd: options.cwd,
env: options.env,
maxBuffer: 10 * 1024 * 1024
})
}
async function readStdin() {
const chunks = []
for await (const chunk of process.stdin) chunks.push(chunk)
return Buffer.concat(chunks).toString('utf8')
}
async function resolveBranchCommit(branch) {
branchFromRef(`refs/heads/${branch}`)
await run('git', [
'fetch',
'--quiet',
'origin',
`refs/heads/${branch}:refs/remotes/origin/${branch}`
])
const { stdout } = await run('git', ['rev-parse', `refs/remotes/origin/${branch}`])
return { branch, sha: resolveImageTag(stdout.trim()) }
}
async function buildAndPush({ branch, sha, repoRoot }) {
const imageTag = resolveImageTag(sha)
const tempDir = await mkdtemp(join(tmpdir(), 'one-pipe-frontend-'))
try {
await run('git', ['worktree', 'add', '--detach', tempDir, imageTag], { cwd: repoRoot })
await run(
'docker',
[
'buildx',
'build',
'--platform',
'linux/amd64',
'--build-arg',
`NODE_OPTIONS=${NODE_OPTIONS}`,
'--tag',
`${IMAGE_NAME}:${imageTag}`,
'--push',
tempDir
],
{ cwd: repoRoot }
)
console.log(`镜像已推送:${IMAGE_NAME}:${imageTag}`)
console.log(`分支:${branch},提交:${imageTag}`)
} finally {
await run('git', ['worktree', 'remove', '--force', tempDir], { cwd: repoRoot }).catch(() => {})
await rm(tempDir, { recursive: true, force: true })
}
}
async function repoRoot() {
const { stdout } = await run('git', ['rev-parse', '--show-toplevel'])
return resolve(stdout.trim())
}
async function main() {
const options = parseArguments(process.argv.slice(2))
const root = await repoRoot()
if (options.mode === 'pre-push') {
const refs = parsePrePushLines(await readStdin())
for (const ref of refs) {
if (ref.remoteSha === ZERO_SHA || ref.localSha === ZERO_SHA) continue
const branch = branchFromRef(ref.remoteRef)
await buildAndPush({ branch, sha: ref.localSha, repoRoot: root })
}
return
}
const target = await resolveBranchCommit(options.branch)
await buildAndPush({ ...target, repoRoot: root })
console.log('手动构建完成。要触发 Gitea 自动部署,请将该分支对应提交推送到远程。')
}
if (process.argv[1] && resolve(process.argv[1]) === resolve(fileURLToPath(import.meta.url))) {
main().catch((error) => {
console.error(`前端镜像部署失败:${error.message}`)
process.exitCode = 1
})
}

View File

@@ -0,0 +1,41 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { parseArguments, parsePrePushLines, resolveImageTag } from '../deploy-frontend.mjs'
test('pre-push input resolves the exact commit being pushed', () => {
const refs = parsePrePushLines(
'refs/heads/iteration/august 94764b0e0a950fb841fd6c1e88578af34461f24c refs/heads/iteration/august 0000000000000000000000000000000000000000\n'
)
assert.deepEqual(refs, [
{
localRef: 'refs/heads/iteration/august',
localSha: '94764b0e0a950fb841fd6c1e88578af34461f24c',
remoteRef: 'refs/heads/iteration/august',
remoteSha: '0000000000000000000000000000000000000000'
}
])
})
test('deleting a remote branch does not resolve a zero commit', () => {
const refs = parsePrePushLines(
'refs/heads/iteration/august 0000000000000000000000000000000000000000 refs/heads/iteration/august 94764b0e0a950fb841fd6c1e88578af34461f24c\n'
)
assert.equal(refs[0].localSha, '0000000000000000000000000000000000000000')
})
test('manual branch mode accepts a branch and rejects unknown options', () => {
assert.deepEqual(parseArguments(['--branch', 'iteration/august']), {
mode: 'branch',
branch: 'iteration/august'
})
assert.throws(() => parseArguments(['--unknown']), /未知参数/)
})
test('image tag is the commit SHA rather than a mutable branch tag', () => {
assert.equal(
resolveImageTag('94764b0e0a950fb841fd6c1e88578af34461f24c'),
'94764b0e0a950fb841fd6c1e88578af34461f24c'
)
})