构建基于 Jenkins API 的 UI 组件库自动化发布平台


团队维护的 UI 组件库最初依赖开发者手动执行 npm publish,这是一个脆弱且混乱的流程。版本号管理不规范、changelog 忘记生成、发布过程中的本地环境差异,都曾引发过线上问题。更关键的是,这个过程完全是个黑盒,除了发布者自己,没人知道当前哪个版本正在构建,以及发布的具体进度。为了解决这个痛点,我们决定构建一个内部自服务平台,将整个发布流程标准化、自动化。

这个平台的核心诉求很简单:提供一个简洁的 UI 界面,让任何授权的开发者选择要发布的版本类型(patch, minor, major),一键触发,并能实时看到发布的完整流程。整个过程无需开发者接触命令行,也无需关心 Jenkins 的具体实现。

初步构想与技术选型

整个系统的架构被设计为三层解耦的模式:

  1. 执行层 (Execution Layer): Jenkins。作为公司内部成熟的 CI/CD 工具,它无疑是最佳选择。我们利用 Pipeline as Code (Jenkinsfile) 来定义整个发布流程,确保环境的一致性和流程的固化。
  2. 服务层 (Service Layer): 一个轻量级的 Web API。直接暴露 Jenkins 的 UI 或 API 给前端并不合适,不仅有安全风险,而且 Jenkins API 的复杂性对于前端来说是个负担。因此,我们需要一个中间层 API,它负责接收前端请求,然后与 Jenkins API 交互,同时处理认证、参数校验和状态管理。我们选择了 FastAPI,因为它开发效率高、性能出色,且自带文档。
  3. 表现层 (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 taggit 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>

遗留问题与未来迭代方向

这套系统解决了最初的痛点,但它并非完美。在真实生产环境中,还有几个方面值得优化:

  1. 轮询与实时性:当前前端采用 HTTP 轮询来获取状态,这会带来不必要的网络开销和延迟。更优雅的方案是使用 WebSocket。当 Jenkins 任务状态变更时,通过 Jenkins 的 Webhook 或消息队列(如 RabbitMQ)通知后端 API,再由 API 通过 WebSocket 将状态实时推送到前端。
  2. API 服务的持久化:目前 API 服务的任务状态存储在内存中,服务重启会导致任务信息丢失。在生产环境中,应使用 Redis 或小型数据库来持久化 task_id 与 Jenkins queue_id / build_number 的映射关系。
  3. 权限控制:当前 API 是开放的,任何人都可以触发。需要集成公司的统一认证系统(如 OAuth2/OIDC),实现基于角色的访问控制(RBAC),例如只有组件库的维护者才能执行 major 版本发布。
  4. 并发与锁:目前没有机制阻止多个开发者同时发布。这可能导致版本冲突。一个简单的改进是在触发构建前,API 服务层增加一个分布式锁(例如基于 Redis 的 Redlock),确保同一时间只有一个发布任务在进行。

  目录