feat: 支持跨平台前端镜像部署
This commit is contained in:
@@ -13,3 +13,6 @@ yarn-error.log*
|
||||
.env.local
|
||||
.env.*.local
|
||||
openspec
|
||||
.codex
|
||||
.claude
|
||||
.agents
|
||||
|
||||
@@ -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
1
.husky/pre-push
Executable file
@@ -0,0 +1 @@
|
||||
node scripts/deploy-frontend.mjs --pre-push
|
||||
@@ -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
152
scripts/deploy-frontend.mjs
Normal 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
|
||||
})
|
||||
}
|
||||
41
scripts/tests/deployFrontend.test.mjs
Normal file
41
scripts/tests/deployFrontend.test.mjs
Normal 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'
|
||||
)
|
||||
})
|
||||
Reference in New Issue
Block a user