团队维护的 UI 组件库最初依赖开发者手动执行 npm publish
,这是一个脆弱且混乱的流程。版本号管理不规范、changelog 忘记生成、发布过程中的本地环境差异,都曾引发过线上问题。更关键的是,这个过程完全是个黑盒,除了发布者自己,没人知道当前哪个版本正在构建,以及发布的具体进度。为了解决这个痛点,我们决定构建一个内部自服务平台,将整个发布流程标准化、自动化。
这个平台的核心诉求很简单:提供一个简洁的 UI 界面,让任何授权的开发者选择要发布的版本类型(patch
, minor
, major
),一键触发,并能实时看到发布的完整流程。整个过程无需开发者接触命令行,也无需关心 Jenkins 的具体实现。
初步构想与技术选型
整个系统的架构被设计为三层解耦的模式:
- 执行层 (Execution Layer): Jenkins。作为公司内部成熟的 CI/CD 工具,它无疑是最佳选择。我们利用 Pipeline as Code (
Jenkinsfile
) 来定义整个发布流程,确保环境的一致性和流程的固化。 - 服务层 (Service Layer): 一个轻量级的 Web API。直接暴露 Jenkins 的 UI 或 API 给前端并不合适,不仅有安全风险,而且 Jenkins API 的复杂性对于前端来说是个负担。因此,我们需要一个中间层 API,它负责接收前端请求,然后与 Jenkins API 交互,同时处理认证、参数校验和状态管理。我们选择了 FastAPI,因为它开发效率高、性能出色,且自带文档。
- 表现层 (Presentation Layer): 一个内部 Web UI。这个 UI 将使用我们自己的组件库来构建,实现“狗食文化”(Dogfooding)。它提供一个简单的表单来触发发布,并用一个状态组件实时展示发布进度。
三者之间的交互流程如下:
sequenceDiagram participant User as 用户 participant Frontend as 平台前端 (Vue.js) participant BackendAPI as 服务端 API (FastAPI) participant Jenkins as Jenkins Server User->>Frontend: 选择版本类型 (patch), 点击 "发布" Frontend->>BackendAPI: POST /api/v1/components/publish (body: { version_type: "patch" }) BackendAPI->>Jenkins: 调用 Jenkins API 触发一个参数化构建 Jenkins-->>BackendAPI: 返回 Job Queue Item ID BackendAPI->>BackendAPI: 存根: 将 Queue Item ID 与 Build 任务关联 BackendAPI-->>Frontend: 返回 { "task_id": "uuid-12345", "message": "已加入构建队列" } loop 轮询状态 Frontend->>BackendAPI: GET /api/v1/components/publish/status/uuid-12345 BackendAPI->>Jenkins: 根据 Task ID 查询 Jenkins Build 状态和日志 Jenkins-->>BackendAPI: 返回当前执行阶段、状态 (SUCCESS/RUNNING/FAILED) BackendAPI-->>Frontend: 返回 { "task_id": "uuid-12345", "status": "RUNNING", "current_stage": "NPM Publish" } end Frontend->>User: 实时更新 UI 状态步骤条
核心实现:参数化的 Jenkins Pipeline
Jenkinsfile
是整个自动化流程的基石。它必须是参数化的,以便接收来自 API 的指令,例如要发布的版本类型。同时,它需要将流程划分为清晰的阶段(Stage),这样 API 才能准确地获取和上报进度。
一个生产级的 Jenkinsfile
应该包含错误处理、凭证管理和状态通知。
// Jenkinsfile
// 使用公司的标准 Node.js 构建环境
properties([
parameters([
string(name: 'VERSION_TYPE', defaultValue: 'patch', description: '发布的版本类型 (patch, minor, major)')
])
])
pipeline {
agent {
// 使用带有 Node.js, Git, Jq 的预定义 Pod 模板
kubernetes {
yaml '''
apiVersion: v1
kind: Pod
spec:
containers:
- name: nodejs
image: node:18-alpine
command:
- cat
tty: true
'''
}
}
environment {
// 从 Jenkins Credentials 中获取 NPM Token,避免硬编码
NPM_TOKEN = credentials('corp-npm-token-id')
GIT_CREDENTIALS = credentials('corp-git-ssh-key')
}
stages {
stage('1. Checkout Code') {
steps {
container('nodejs') {
script {
echo "Checking out the source code..."
// 清理工作区,确保环境干净
cleanWs()
checkout([
$class: 'GitSCM',
branches: [[name: 'main']],
userRemoteConfigs: [[
credentialsId: 'corp-git-ssh-key', // 使用 SSH 密钥进行认证
url: 'git@githost:frontend/ui-component-library.git'
]]
])
}
}
}
}
stage('2. Environment Setup') {
steps {
container('nodejs') {
script {
echo "Setting up Git and NPM configuration..."
// 配置 Git 用户信息,用于后续的 tag 和 commit
sh 'git config --global user.email "[email protected]"'
sh 'git config --global user.name "Jenkins CI"'
// 配置 NPM registry 和认证 token
sh 'echo "//registry.npmjs.org/:_authToken=${NPM_TOKEN}" > .npmrc'
echo "Installing dependencies..."
// 使用 ci 命令确保依赖版本一致性
sh 'npm ci'
}
}
}
}
stage('3. Version Bump & Changelog') {
steps {
container('nodejs') {
script {
echo "Bumping version with type: ${params.VERSION_TYPE}"
// npm version 会自动创建 git commit 和 tag
// --no-git-tag-version 让我们手动控制 tag,以防后续步骤失败
def newVersion = sh(script: "npm version ${params.VERSION_TYPE} --no-git-tag-version | sed 's/v//'", returnStdout: true).trim()
// 将新版本号存储为环境变量,供后续阶段使用
env.NEW_VERSION = newVersion
echo "New version will be: ${env.NEW_VERSION}"
// 在真实项目中,这里会用 conventional-changelog-cli 等工具生成 changelog
sh 'echo "## v${env.NEW_VERSION}\n- Automated release by Jenkins." >> CHANGELOG.md'
sh "git add package.json package-lock.json CHANGELOG.md"
sh "git commit -m 'chore(release): bump version to ${env.NEW_VERSION}'"
}
}
}
}
stage('4. Build & Test') {
steps {
container('nodejs') {
script {
echo "Running unit tests and building the library..."
sh 'npm run test:unit'
sh 'npm run build'
}
}
}
}
stage('5. Publish to NPM') {
steps {
container('nodejs') {
script {
echo "Publishing v${env.NEW_VERSION} to NPM registry..."
// --tag latest 是默认行为,但显式声明更清晰
sh 'npm publish --tag latest'
}
}
}
}
stage('6. Git Tag & Push') {
steps {
container('nodejs') {
script {
echo "Tagging and pushing to Git repository..."
// 在成功发布到 NPM 后,才打 tag 并推送到远程仓库
sh "git tag -a v${env.NEW_VERSION} -m 'Release version ${env.NEW_VERSION}'"
// 推送 commit 和 tag
sh 'git push origin main'
sh 'git push origin --tags'
}
}
}
}
}
post {
// 无论成功还是失败,都清理工作区
always {
echo "Cleaning up workspace..."
cleanWs()
// 清理 npmrc 文件,防止 token 泄露
sh 'rm -f .npmrc'
}
success {
echo "Pipeline finished successfully."
// 这里可以集成 Slack 或其他通知
}
failure {
echo "Pipeline failed."
// 可以在这里添加失败回滚逻辑,例如 git reset
}
}
}
这个 Jenkinsfile
的关键设计点在于:
- 原子性:只有在
npm publish
成功后,才会执行git tag
和git push
。这避免了代码仓库中存在一个已打上 tag 但并未成功发布的“幽灵版本”。 - 幂等性:每次构建都在一个干净的 Pod 中运行,避免了环境污染。
- 安全性:敏感信息(如 NPM Token 和 Git 凭证)通过 Jenkins Credentials 注入,而不是硬编码在代码中。
服务层:FastAPI 粘合剂
服务层是解耦的关键。它封装了与 Jenkins 交互的复杂逻辑,为前端提供了一套干净、简单的 RESTful API。
# main.py
import os
import uuid
import jenkins
from fastapi import FastAPI, HTTPException, BackgroundTasks
from pydantic import BaseModel, Field
from typing import Literal
# --- 配置 ---
# 在生产环境中,这些应该来自环境变量或配置文件
JENKINS_URL = os.getenv("JENKINS_URL", "http://jenkins.example.com")
JENKINS_USER = os.getenv("JENKINS_USER", "api-user")
JENKINS_TOKEN = os.getenv("JENKINS_API_TOKEN", "your_jenkins_api_token")
JENKINS_JOB_NAME = "ui-component-library-release"
# --- 模拟一个简单的内存数据库来存储任务状态 ---
# 在真实项目中,应该使用 Redis 或数据库
tasks_db = {}
# --- Pydantic 模型 ---
class PublishRequest(BaseModel):
version_type: Literal['patch', 'minor', 'major'] = Field(..., description="版本更新类型")
class TaskStatus(BaseModel):
task_id: str
status: str
build_number: int | None = None
current_stage: str | None = None
build_url: str | None = None
# --- FastAPI 应用实例 ---
app = FastAPI(title="Component Publisher API")
# --- Jenkins 连接 ---
try:
server = jenkins.Jenkins(JENKINS_URL, username=JENKINS_USER, password=JENKINS_TOKEN)
server.get_version()
except jenkins.JenkinsException as e:
print(f"Error connecting to Jenkins: {e}")
# 在应用启动失败时,这应该是一个致命错误
# 这里为了示例简单,仅打印
server = None
# --- API Endpoints ---
@app.post("/api/v1/components/publish", response_model=TaskStatus, status_code=202)
async def trigger_publish(request: PublishRequest):
if not server:
raise HTTPException(status_code=503, detail="Jenkins server is unavailable.")
task_id = str(uuid.uuid4())
try:
# Jenkins API 的 build_job 是非阻塞的
# 它返回一个 queue item number,需要稍后用它来查询 build number
queue_item_id = server.build_job(
JENKINS_JOB_NAME,
parameters={'VERSION_TYPE': request.version_type}
)
tasks_db[task_id] = {"status": "QUEUED", "queue_id": queue_item_id}
return TaskStatus(task_id=task_id, status="QUEUED")
except jenkins.JenkinsException as e:
raise HTTPException(status_code=500, detail=f"Failed to trigger Jenkins job: {str(e)}")
@app.get("/api/v1/components/publish/status/{task_id}", response_model=TaskStatus)
async def get_publish_status(task_id: str):
if task_id not in tasks_db:
raise HTTPException(status_code=404, detail="Task not found.")
task_info = tasks_db[task_id]
if not server:
raise HTTPException(status_code=503, detail="Jenkins server is unavailable.")
try:
# --- 核心逻辑:从队列ID到构建信息的转换 ---
queue_item_info = server.get_queue_item(task_info['queue_id'])
if 'executable' in queue_item_info and queue_item_info['executable']:
build_number = queue_item_info['executable']['number']
# 使用深度为1的API来获取构建的阶段信息
build_info = server.get_build_info(JENKINS_JOB_NAME, build_number, depth=1)
# 解析 Blue Ocean API 返回的阶段信息
current_stage = "Unknown"
stages = build_info.get('stages', [])
# 找到最后一个正在运行或已完成的阶段
for stage in reversed(stages):
if stage.get('status') != 'NOT_EXECUTED':
current_stage = stage.get('name', 'Unknown')
break
# 更新数据库
tasks_db[task_id]['build_number'] = build_number
tasks_db[task_id]['status'] = build_info['result'] if build_info['result'] else 'RUNNING'
return TaskStatus(
task_id=task_id,
status=tasks_db[task_id]['status'],
build_number=build_number,
current_stage=current_stage,
build_url=build_info.get('url')
)
else:
# 任务仍在队列中,尚未开始执行
return TaskStatus(task_id=task_id, status="QUEUED")
except jenkins.NotFoundException:
# 可能是队列项已过期或构建已开始但信息尚未同步
return TaskStatus(task_id=task_id, status="PENDING", current_stage="Waiting for executor")
except jenkins.JenkinsException as e:
raise HTTPException(status_code=500, detail=f"Error fetching status from Jenkins: {str(e)}")
这个 API 服务的关键点:
- 解耦:前端完全不知道 Jenkins 的存在。未来如果我们将 CI/CD 工具从 Jenkins 迁移到 GitLab CI 或其他平台,只需要重写这个 API 服务的逻辑,前端代码无需任何改动。
- 状态转换:处理了从
QUEUED
->RUNNING
->SUCCESS/FAILED
的完整状态机。它通过 Jenkins 的queue item ID
来跟踪一个构建从排队到正式执行的全过程。 - 信息聚合:通过
depth=1
的 API 请求,可以从 Jenkins 获取到结构化的 Stage 信息,而不是去解析原始的控制台日志。这是提供清晰进度的关键。
表现层:一个自服务的发布面板
前端是整个平台的入口。我们将使用组件库自身来构建这个界面。核心是一个“发布状态步骤条”组件,它封装了与后端 API 的所有交互。
<!-- PublishStepper.vue -->
<template>
<div class="publish-panel">
<div v-if="!taskId" class="control-section">
<select v-model="selectedVersionType">
<option value="patch">Patch (修复 Bug)</option>
<option value="minor">Minor (新增功能)</option>
<option value="major">Major (破坏性改动)</option>
</select>
<button @click="triggerBuild" :disabled="isLoading">
{{ isLoading ? '正在请求...' : '开始发布' }}
</button>
</div>
<div v-if="taskId" class="status-section">
<h3>发布任务 ID: {{ taskId }}</h3>
<a v-if="buildUrl" :href="buildUrl" target="_blank">查看 Jenkins 日志</a>
<div class="steps">
<div v-for="stage in stages" :key="stage.id" :class="getStepClass(stage)">
<div class="step-icon">{{ stage.icon }}</div>
<div class="step-name">{{ stage.name }}</div>
</div>
</div>
<p v-if="error" class="error-message">错误: {{ error }}</p>
</div>
</div>
</template>
<script setup>
import { ref, onUnmounted, computed } from 'vue';
const selectedVersionType = ref('patch');
const taskId = ref(null);
const currentStageName = ref('');
const status = ref('');
const buildUrl = ref('');
const isLoading = ref(false);
const error = ref(null);
let pollInterval = null;
const stages = ref([
{ id: 1, name: 'Checkout Code', icon: '📦', jenkinsName: '1. Checkout Code' },
{ id: 2, name: 'Environment Setup', icon: '🔧', jenkinsName: '2. Environment Setup' },
{ id: 3, name: 'Version Bump & Changelog', icon: '📝', jenkinsName: '3. Version Bump & Changelog' },
{ id: 4, name: 'Build & Test', icon: '⚙️', jenkinsName: '4. Build & Test' },
{ id: 5, name: 'Publish to NPM', icon: '🚀', jenkinsName: '5. Publish to NPM' },
{ id: 6, name: 'Git Tag & Push', icon: '🏷️', jenkinsName: '6. Git Tag & Push' },
]);
const currentStageIndex = computed(() => {
const index = stages.value.findIndex(s => s.jenkinsName === currentStageName.value);
return index === -1 ? -1 : index;
});
function getStepClass(stage) {
const stageIndex = stages.value.findIndex(s => s.id === stage.id);
if (status.value === 'SUCCESS' && stageIndex <= currentStageIndex.value) {
return 'step completed';
}
if (status.value === 'FAILED' && stageIndex === currentStageIndex.value) {
return 'step failed';
}
if (stageIndex < currentStageIndex.value) {
return 'step completed';
}
if (stageIndex === currentStageIndex.value && status.value === 'RUNNING') {
return 'step active';
}
return 'step pending';
}
async function triggerBuild() {
isLoading.value = true;
error.value = null;
try {
const response = await fetch('/api/v1/components/publish', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ version_type: selectedVersionType.value }),
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
taskId.value = data.task_id;
startPolling();
} catch (e) {
error.value = e.message;
} finally {
isLoading.value = false;
}
}
async function pollStatus() {
if (!taskId.value) return;
try {
const response = await fetch(`/api/v1/components/publish/status/${taskId.value}`);
if (!response.ok) {
throw new Error(`Failed to fetch status: ${response.status}`);
}
const data = await response.json();
status.value = data.status;
currentStageName.value = data.current_stage;
buildUrl.value = data.build_url;
if (data.status === 'SUCCESS' || data.status === 'FAILED' || data.status === 'ABORTED') {
stopPolling();
}
} catch (e) {
error.value = e.message;
stopPolling();
}
}
function startPolling() {
if (pollInterval) return;
// 立即执行一次,然后开始轮询
pollStatus();
pollInterval = setInterval(pollStatus, 3000);
}
function stopPolling() {
if (pollInterval) {
clearInterval(pollInterval);
pollInterval = null;
}
}
onUnmounted(() => {
stopPolling();
});
</script>
<style scoped>
/* 此处省略具体样式,但应包含 .step, .completed, .active, .failed, .pending 等状态的视觉反馈 */
.steps { display: flex; gap: 10px; margin-top: 20px; }
.step { text-align: center; }
.step.completed .step-icon { border: 2px solid green; }
.step.active .step-icon { border: 2px solid blue; animation: pulse 1.5s infinite; }
.step.failed .step-icon { border: 2px solid red; }
.step.pending .step-icon { border: 2px solid grey; }
.error-message { color: red; }
</style>
遗留问题与未来迭代方向
这套系统解决了最初的痛点,但它并非完美。在真实生产环境中,还有几个方面值得优化:
- 轮询与实时性:当前前端采用 HTTP 轮询来获取状态,这会带来不必要的网络开销和延迟。更优雅的方案是使用 WebSocket。当 Jenkins 任务状态变更时,通过 Jenkins 的 Webhook 或消息队列(如 RabbitMQ)通知后端 API,再由 API 通过 WebSocket 将状态实时推送到前端。
- API 服务的持久化:目前 API 服务的任务状态存储在内存中,服务重启会导致任务信息丢失。在生产环境中,应使用 Redis 或小型数据库来持久化
task_id
与 Jenkinsqueue_id
/build_number
的映射关系。 - 权限控制:当前 API 是开放的,任何人都可以触发。需要集成公司的统一认证系统(如 OAuth2/OIDC),实现基于角色的访问控制(RBAC),例如只有组件库的维护者才能执行
major
版本发布。 - 并发与锁:目前没有机制阻止多个开发者同时发布。这可能导致版本冲突。一个简单的改进是在触发构建前,API 服务层增加一个分布式锁(例如基于 Redis 的 Redlock),确保同一时间只有一个发布任务在进行。