From 55eec78a90215621bd6ed0a4b536bf527cbea880 Mon Sep 17 00:00:00 2001 From: break Date: Sun, 20 Sep 2026 15:25:04 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=94=AF=E6=8C=81=E8=B7=A8=E5=B9=B3?= =?UTF-8?q?=E5=8F=B0=E5=89=8D=E7=AB=AF=E9=95=9C=E5=83=8F=E9=83=A8=E7=BD=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .dockerignore | 3 + .gitea/workflows/deploy.yaml | 86 ++------------- .husky/pre-push | 1 + package.json | 2 + scripts/deploy-frontend.mjs | 152 ++++++++++++++++++++++++++ scripts/tests/deployFrontend.test.mjs | 41 +++++++ 6 files changed, 210 insertions(+), 75 deletions(-) create mode 100755 .husky/pre-push create mode 100644 scripts/deploy-frontend.mjs create mode 100644 scripts/tests/deployFrontend.test.mjs diff --git a/.dockerignore b/.dockerignore index 26d0eaf..5803e88 100644 --- a/.dockerignore +++ b/.dockerignore @@ -13,3 +13,6 @@ yarn-error.log* .env.local .env.*.local openspec +.codex +.claude +.agents diff --git a/.gitea/workflows/deploy.yaml b/.gitea/workflows/deploy.yaml index 15e2d51..1617fe7 100644 --- a/.gitea/workflows/deploy.yaml +++ b/.gitea/workflows/deploy.yaml @@ -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 diff --git a/.husky/pre-push b/.husky/pre-push new file mode 100755 index 0000000..670dbea --- /dev/null +++ b/.husky/pre-push @@ -0,0 +1 @@ +node scripts/deploy-frontend.mjs --pre-push diff --git a/package.json b/package.json index 8d3923b..d8f106a 100644 --- a/package.json +++ b/package.json @@ -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}\"", diff --git a/scripts/deploy-frontend.mjs b/scripts/deploy-frontend.mjs new file mode 100644 index 0000000..313764a --- /dev/null +++ b/scripts/deploy-frontend.mjs @@ -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 + }) +} diff --git a/scripts/tests/deployFrontend.test.mjs b/scripts/tests/deployFrontend.test.mjs new file mode 100644 index 0000000..2d7fce5 --- /dev/null +++ b/scripts/tests/deployFrontend.test.mjs @@ -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' + ) +})