Jenkins CI/CD 从SMB共享目录到发布UnityFramework到远程仓库流程

📋 目录


项目概述

本项目为 Unity Framework 提供自动化 CI/CD 流程,实现从 SMB 共享目录自动拉取 Unity Framework 文件,更新 Git 仓库,并上传符号表到 APMPlus 的完整流程。

核心功能

  • 📦 自动从 SMB 挂载点获取 Unity Framework 和 dSYM 文件
  • 🔄 智能版本比较,自动跳过相同版本(可强制更新)
  • 🚀 Git 浅克隆策略,快速拉取代码
  • 📝 自动更新 Podspec 版本号
  • 🏷️ 自动创建带注释的 Git Tag(带时间戳)
  • 📤 自动上传 dSYM 符号表到 APMPlus
  • 📢 钉钉通知支持(可 @ 指定人员)

系统架构

┌─────────────────┐
│   SMB 共享目录   │ (存放 Unity Framework 文件)
└────────┬────────┘
         │
         ▼
┌─────────────────┐
│  Jenkins Agent  │ (macOS 构建机)
│  - 挂载 SMB     │
│  - 执行 Pipeline│
└────────┬────────┘
         │
         ├─────────────────┐
         ▼                 ▼
┌─────────────────┐  ┌─────────────────┐
│  Git 仓库       │  │  APMPlus        │
│  (代码托管平台) │  │  (符号表管理)   │
└─────────────────┘  └─────────────────┘
         │
         ▼
┌─────────────────┐
│  钉钉通知       │
└─────────────────┘

环境要求

Jenkins 环境

  • Jenkins 版本: 2.x+
  • 必需插件:
    • Git Plugin
    • Pipeline Plugin
    • Active Choices Plugin (用于动态参数)
    • Credentials Plugin
    • Timestamper Plugin
    • AnsiColor Plugin

macOS Agent 要求

  • 操作系统: macOS 10.14+
  • 必需工具:
    • Git
    • curl
    • openssl
    • Python 3.x
    • unzip
    • sed, awk, grep, md5

网络要求

  • 访问 Git 代码托管平台
  • 访问 APMPlus API
  • 访问钉钉 Webhook
  • 访问内网 SMB 共享

配置说明

项目配置模板

项目信息

配置项 说明 示例
项目名称 项目显示名称 UnityFramework_Project1
Git 仓库 Git 仓库 HTTPS 地址 https://your-git.com/org/repo.git
Git 仓库 Host 仓库地址(不含 https://) your-git.com/org/repo.git
分支 目标分支名 master / main
Podspec 文件 CocoaPods 规格文件名 YourProject.podspec
挂载点前缀 SMB 挂载点唯一前缀 Project1_
Git 凭据 ID Jenkins 中的凭据 ID xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx

APMPlus 配置模板

// APMPlus 符号表上传配置
APMPlus_Upload_DSYM_Domain = "https://your-apmplus-domain.com"  // APMPlus API 域名
APMPlus_APPID = "YOUR_APP_ID"                                   // 应用ID
APMPlus_Upload_DSYM_API_Key = "YOUR_API_KEY"                    // API Key
APMPlus_Upload_DSYM_API_Token = "YOUR_API_TOKEN"                // API Token(用于签名)

获取方式

  • 登录 APMPlus 管理后台
  • 进入应用设置 → API管理
  • 查看或生成 API Key 和 Token

钉钉通知配置模板

// Jenkins 配置
JENKINS_BASE_URL = 'http://your-jenkins-host:port/'  // Jenkins 访问地址

// 钉钉机器人配置
DINGTALK_WEBHOOK_URL = 'https://oapi.dingtalk.com/robot/send?access_token=YOUR_ACCESS_TOKEN'
DINGTALK_SECRET = 'YOUR_DINGTALK_SECRET'  // 钉钉机器人签名密钥

// 人员手机号配置(用于 @ 功能)
DINGTALK_AT_ALL = '手机号1,手机号2,手机号3'           // 所有人
DINGTALK_AT_PERSON1 = '手机号1'                        // 人员1
DINGTALK_AT_PERSON2 = '手机号2'                        // 人员2
DINGTALK_AT_PERSON3 = '手机号3'                        // 人员3
// ... 根据需要添加更多人员

获取方式

  1. 钉钉群 → 群设置 → 智能群助手 → 添加机器人 → 自定义
  2. 选择"加签"安全设置,获取 SECRET
  3. 复制 Webhook URL(包含 access_token)
  4. 收集需要 @ 的人员手机号

SMB 共享配置模板

// SMB 服务器配置
SMB_HOST = "smb.server.ip.address"              // SMB 服务器 IP
SMB_SHARE_PATH = "ShareName/Path/To/UnityFiles" // 共享路径
SMB_CREDENTIALS_ID = "your-smb-credentials-id"  // Jenkins 凭据 ID

// 挂载点配置
MOUNT_PREFIX = "YourProject_"                   // 挂载点前缀(唯一标识)
MOUNT_TIMEOUT_SECONDS = 30                      // 挂载超时时间(秒)
SMB_READ_TIMEOUT_SECONDS = 10                   // 读取超时时间(秒)

// 编码配置
IOCHARSET = "utf-8"                             // 字符编码

参数化构建脚本

1. UNITY_FOLDER 参数脚本(文件夹选择器)

参数类型Active Choices Reactive Parameter (Groovy Script)

配置步骤

  1. Jenkins Job → 配置 → 参数化构建过程
  2. 添加参数:Active Choices Reactive Parameter
  3. 名称:UNITY_FOLDER
  4. 脚本类型:Groovy Script
  5. 粘贴以下代码并修改配置区域
// ========== 配置区域 ==========
// SMB 配置
def SMB_HOST = "192.168.x.x"                        // 替换为你的 SMB 服务器 IP
def SMB_SHARE_PATH = "ShareName/Path/To/Files"      // 替换为你的共享路径
def SMB_CREDENTIALS_ID = "your-credentials-id"      // 替换为你的 Jenkins 凭据 ID

// 挂载点配置
def MOUNT_PREFIX = "YourProject_"                   // 替换为你的项目前缀(唯一标识)
def MOUNT_TIMEOUT_SECONDS = 30                      // 挂载超时时间
def SMB_READ_TIMEOUT_SECONDS = 10                   // 读取超时时间

// 编码配置
def IOCHARSET = "utf-8"                             // 字符编码

// ========== 主逻辑(无需修改)==========
try {
    // 1. 获取凭据
    def credentials = com.cloudbees.plugins.credentials.CredentialsProvider.lookupCredentials(
        com.cloudbees.plugins.credentials.common.StandardUsernamePasswordCredentials.class,
        Jenkins.instance,
        null,
        null
    ).find { it.id == SMB_CREDENTIALS_ID }
    
    if (!credentials) {
        return ["[错误] 未找到 SMB 凭据"]
    }
    
    def username = credentials.username
    def password = credentials.password.plainText
    
    // 2. 检查挂载目录
    def userHome = System.getProperty("user.home")
    def mountsDir = new File("${userHome}/TuwanMounts/")
    
    if (!mountsDir.exists()) {
        mountsDir.mkdirs()
    }
    
    // 3. 查找或创建挂载点
    def smbVolume = mountsDir.listFiles()?.find { volume ->
        volume.name.startsWith(MOUNT_PREFIX)
    }
    
    if (!smbVolume) {
        def timestamp = new Date().format('yyyyMMdd_HHmmss')
        def mountPointName = "${MOUNT_PREFIX}${timestamp}"
        smbVolume = new File(mountsDir, mountPointName)
        
        if (!smbVolume.mkdirs()) {
            return ["[错误] 无法创建挂载点目录"]
        }
        
        def mountCommand = [
            'mount_smbfs',
            '-o',
            "nobrowse,soft,iocharset=${IOCHARSET}",
            "//${username}:${password}@${SMB_HOST}/${SMB_SHARE_PATH}",
            smbVolume.absolutePath
        ]
        
        def process = mountCommand.execute()
        process.waitForOrKill(MOUNT_TIMEOUT_SECONDS * 1000)
        
        if (process.exitValue() != 0) {
            def error = process.err.text
            return ["[错误] SMB 挂载失败: ${error}"]
        }
        
        Thread.sleep(2000)
    }
    
    // 4. 检查挂载点状态
    if (!smbVolume.exists() || !smbVolume.canRead()) {
        return ["[错误] 挂载点不可访问"]
    }
    
    // 5. 获取文件夹列表
    def folders = []
    def startTime = System.currentTimeMillis()
    
    while (folders.isEmpty() && (System.currentTimeMillis() - startTime) < (SMB_READ_TIMEOUT_SECONDS * 1000)) {
        folders = smbVolume.listFiles()?.findAll { 
            it.isDirectory() && !it.name.startsWith(".") 
        }
        
        if (folders.isEmpty()) {
            Thread.sleep(500)
        }
    }
    
    if (folders.isEmpty()) {
        return ["[提示] SMB 目录为空或读取超时"]
    }
    
    // 6. 排序并返回(按修改时间倒序)
    return folders
        .sort { -it.lastModified() }
        .collect { it.name }
        
} catch (Exception e) {
    return ["[异常] ${e.message}"]
}

2. UNITY_FILE 参数脚本(文件选择器)

参数类型Active Choices Reactive Parameter (Groovy Script)

配置步骤

  1. Jenkins Job → 配置 → 参数化构建过程
  2. 添加参数:Active Choices Reactive Parameter
  3. 名称:UNITY_FILE
  4. Choice Type:Check Boxes (支持多选)
  5. Referenced parameters:UNITY_FOLDER
  6. 脚本类型:Groovy Script
  7. 粘贴以下代码并修改配置区域
// ========== 配置区域 ==========
def MOUNT_PREFIX = "YourProject_"      // 替换为你的项目前缀(与 UNITY_FOLDER 保持一致)

// 默认选中配置
def DEFAULT_SELECT_ALL = true          // true: 默认选中所有文件; false: 不选中

// ========== 主逻辑(无需修改)==========
try {
    // 1. 获取参数
    def folderName = UNITY_FOLDER
    
    if (!folderName || folderName.toString().trim().isEmpty()) {
        return ["请先选择文件夹"]
    }
    
    def folder = folderName.toString().trim()
    
    // 2. 使用用户目录查找挂载点
    def userHome = System.getProperty("user.home")
    def mountsDir = new File("${userHome}/TuwanMounts/")
    
    if (!mountsDir.exists()) {
        return ["未找到挂载目录"]
    }
    
    def smbVolume = mountsDir.listFiles()?.find { volume ->
        volume.name.startsWith(MOUNT_PREFIX)
    }

    if (!smbVolume) {
        return ["未找到挂载点"]
    }

    // 3. 检查文件夹
    def selectedFolder = new File(smbVolume, folder)
    
    if (!selectedFolder.exists()) {
        return ["文件夹不存在"]
    }

    // 4. 获取文件
    def files = selectedFolder.listFiles()?.findAll { 
        it.isFile() && !it.name.startsWith(".") 
    }

    if (!files || files.isEmpty()) {
        return ["该文件夹下没有文件"]
    }

    // 5. 构建文件列表(带默认选中标记)
    def result = []
    
    files.sort { -it.lastModified() }.each { file ->
        def sizeKB = file.length() / 1024
        def sizeStr = sizeKB < 1024 ? 
            String.format("%.1f KB", sizeKB) : 
            String.format("%.1f MB", sizeKB / 1024)
        
        def displayName = file.name + " (" + sizeStr + ")"
        
        // 根据配置添加默认选中标记
        if (DEFAULT_SELECT_ALL) {
            displayName = displayName + ":selected"
        }
        
        result << displayName
    }
    
    return result

} catch (MissingPropertyException e) {
    return ["参数错误"]
} catch (Exception e) {
    return ["发生异常: ${e.message}"]
}

3. FORCE_UPDATE 参数(强制更新开关)

参数类型Boolean Parameter

配置步骤

  1. Jenkins Job → 配置 → 参数化构建过程
  2. 添加参数:Boolean Parameter
  3. 名称:FORCE_UPDATE
  4. 默认值:false(不勾选)
  5. 描述:强制更新(即使版本号相同也执行更新)

Pipeline 完整代码

Pipeline 配置模板

pipeline {
    agent any
    
    parameters {
        // 钉钉通知参数(UNITY_FOLDER、UNITY_FILE、FORCE_UPDATE 已在 Jenkins 界面配置)
        choice(
            name: 'DINGTALK_AT_WHO', 
            choices: [
                '所有人',
                '人员1',       // 根据实际情况修改
                '人员2',
                '人员3',
                '自定义手机号'
            ], 
            description: '选择钉钉机器人要@的人员'
        )
        string(
            name: 'CUSTOM_AT_MOBILES', 
            defaultValue: '', 
            description: '自定义@手机号(当选择"自定义手机号"时必填,多个手机号用逗号分隔)'
        )
    }
    
    environment {
        // ========== Git 配置 ==========
        GIT_CREDENTIALS_ID = 'your-git-credentials-id'  // 替换为你的 Git 凭据 ID
        
        // ========== Jenkins 配置 ==========
        JENKINS_BASE_URL = 'http://your-jenkins-host:port/'  // 替换为你的 Jenkins 地址
        
        // ========== 钉钉通知配置 ==========
        DINGTALK_WEBHOOK_URL = 'https://oapi.dingtalk.com/robot/send?access_token=YOUR_TOKEN'  // 替换
        DINGTALK_SECRET = 'YOUR_SECRET'  // 替换为你的钉钉机器人密钥
        
        // 钉钉 @ 人员配置(根据实际情况修改)
        DINGTALK_AT_ALL = '手机号1,手机号2,手机号3'
        DINGTALK_AT_PERSON1 = '手机号1'
        DINGTALK_AT_PERSON2 = '手机号2'
        DINGTALK_AT_PERSON3 = '手机号3'
    }
    
    options {
        timestamps()
        ansiColor('xterm')
        buildDiscarder(logRotator(numToKeepStr: '10'))
    }
    
    stages {
        stage('参数验证') {
            steps {
                script {
                    echo "=========================================="
                    echo "📋 参数信息"
                    echo "=========================================="
                    
                    def folder = params.UNITY_FOLDER
                    def files = params.UNITY_FILE
                    def forceUpdate = params.FORCE_UPDATE ?: false
                    
                    echo "选择的版本: ${folder}"
                    echo "选择的文件: ${files ?: '未选择'}"
                    echo "强制更新: ${forceUpdate ? '✅ 是' : '❌ 否'}"
                    echo "钉钉通知@: ${params.DINGTALK_AT_WHO}"
                    
                    if (!folder || folder.toString().trim().isEmpty()) {
                        error "❌ 错误: 未选择文件夹"
                    }
                    
                    if (!files || files.toString().trim().isEmpty()) {
                        error "❌ 错误: 未选择文件"
                    }
                    
                    // 验证自定义手机号
                    if (params.DINGTALK_AT_WHO == '自定义手机号' && !params.CUSTOM_AT_MOBILES?.trim()) {
                        echo "⚠️ 警告:选择了'自定义手机号'但未填写,将使用默认@所有人"
                    }
                    
                    if (forceUpdate) {
                        echo ""
                        echo "⚠️  强制更新模式已启用"
                        echo "   即使版本相同也会执行更新"
                    }
                    
                    echo "=========================================="
                }
            }
        }
        
        stage('检查 Git 仓库版本') {
            steps {
                script {
                    echo "=========================================="
                    echo "🔍 检查 Git 仓库版本"
                    echo "=========================================="
                    
                    // ========== 项目配置(需要修改)==========
                    def gitRepo = "https://your-git-host.com/org/repo.git"      // 替换为你的仓库地址
                    def gitRepoHost = "your-git-host.com/org/repo.git"          // 替换(不含 https://)
                    def gitBranch = "master"                                     // 目标分支
                    def gitDir = "${WORKSPACE}/YourRepoName"                     // 本地目录名
                    def podspecFileName = "YourProject.podspec"                  // Podspec 文件名
                    
                    echo "仓库地址: ${gitRepo}"
                    echo "分支: ${gitBranch}"
                    echo "目标目录: ${gitDir}"
                    echo ""
                    
                    echo "📥 使用浅克隆策略(--depth 1),每次都重新克隆..."
                    echo ""
                    
                    // 删除旧仓库
                    sh """
                        if [ -d '${gitDir}' ]; then
                            echo "清理旧仓库目录: ${gitDir}"
                            rm -rf '${gitDir}'
                            
                            if [ \$? -ne 0 ]; then
                                echo "❌ 清理失败"
                                exit 1
                            fi
                            
                            echo "✅ 旧目录已清理"
                        else
                            echo "目录不存在,无需清理"
                        fi
                    """
                    
                    echo ""
                    echo "开始浅克隆仓库(--depth 1 --single-branch)..."
                    
                    withCredentials([usernamePassword(
                        credentialsId: env.GIT_CREDENTIALS_ID,
                        usernameVariable: 'GIT_USER',
                        passwordVariable: 'GIT_PASS'
                    )]) {
                        def cloneResult = sh(
                            script: """
                                unset GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE
                                
                                git clone --depth 1 --single-branch --branch ${gitBranch} \
                                    https://\${GIT_USER}:\${GIT_PASS}@${gitRepoHost} \
                                    '${gitDir}'
                                
                                if [ \$? -ne 0 ]; then
                                    echo "❌ Clone 失败"
                                    exit 1
                                fi
                                
                                echo "✅ Clone 完成"
                            """,
                            returnStatus: true
                        )
                        
                        if (cloneResult != 0) {
                            error """
                                ❌ Git 克隆失败
                                
                                可能原因:
                                1. 凭据无效或权限不足
                                2. 仓库地址错误
                                3. 分支 '${gitBranch}' 不存在
                                4. 网络连接问题
                            """.stripIndent()
                        }
                    }
                    
                    echo "✅ 克隆完成"
                    echo ""
                    echo "🔍 验证 Git 仓库..."
                    
                    def validationResult = sh(
                        script: """
                            if [ ! -d '${gitDir}/.git' ]; then
                                echo "❌ .git 目录不存在"
                                exit 1
                            fi
                            
                            cd '${gitDir}'
                            
                            unset GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE
                            
                            if ! git status >/dev/null 2>&1; then
                                echo "❌ 无法执行 git status"
                                exit 1
                            fi
                            
                            echo "✅ Git 仓库验证通过"
                            echo "  分支: \$(git branch --show-current)"
                            echo "  提交: \$(git rev-parse --short HEAD)"
                        """,
                        returnStatus: true
                    )
                    
                    if (validationResult != 0) {
                        error "❌ Git 仓库验证失败"
                    }
                    
                    echo ""
                    echo "📄 当前分支和提交信息:"
                    sh """
                        cd '${gitDir}'
                        
                        unset GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE
                        
                        echo "  分支: \$(git branch --show-current)"
                        echo "  提交: \$(git log -1 --pretty=format:'%h - %s (%an, %ar)')"
                    """
                    
                    echo ""
                    
                    def podspecPath = "${gitDir}/${podspecFileName}"
                    
                    def currentVersion = sh(
                        script: "grep '^[[:space:]]*s\\.version' '${podspecPath}' | head -1 | cut -d \"'\" -f 2",
                        returnStdout: true
                    ).trim()
                    
                    if (!currentVersion) {
                        error "❌ 无法读取 podspec 版本号"
                    }
                    
                    echo ""
                    echo "=========================================="
                    echo "📊 版本信息"
                    echo "=========================================="
                    echo "仓库当前版本: ${currentVersion}"
                    echo "选择的版本:   ${params.UNITY_FOLDER}"
                    echo ""
                    
                    env.CURRENT_VERSION = currentVersion
                    env.NEW_VERSION = params.UNITY_FOLDER
                    env.GIT_REPO_DIR = gitDir
                    env.GIT_REPO_HOST = gitRepoHost
                }
            }
        }
        
        stage('版本比较') {
            steps {
                script {
                    echo "=========================================="
                    echo "🔄 版本比较"
                    echo "=========================================="
                    
                    def currentVersion = env.CURRENT_VERSION
                    def newVersion = env.NEW_VERSION
                    def forceUpdate = params.FORCE_UPDATE ?: false
                    
                    echo "当前版本: ${currentVersion}"
                    echo "新版本:   ${newVersion}"
                    echo "强制更新: ${forceUpdate ? '✅ 是' : '❌ 否'}"
                    echo ""
                    
                    def comparisonResult = compareVersions(currentVersion, newVersion)
                    
                    if (comparisonResult > 0) {
                        echo "📊 比较结果: 新版本低于当前版本"
                        echo "   这是版本回退操作"
                    } else if (comparisonResult == 0) {
                        echo "📊 比较结果: 版本相同"
                        if (forceUpdate) {
                            echo "   但强制更新模式已启用,将继续执行"
                        } else {
                            echo "   无需更新"
                        }
                    } else {
                        echo "📊 比较结果: 新版本高于当前版本"
                        echo "   这是正常的版本升级"
                    }
                    
                    env.VERSION_COMPARISON = comparisonResult.toString()
                    echo "=========================================="
                }
            }
        }
        
        stage('决定是否更新') {
            steps {
                script {
                    echo "=========================================="
                    echo "🎯 决定是否更新"
                    echo "=========================================="
                    
                    def currentVersion = env.CURRENT_VERSION
                    def newVersion = env.NEW_VERSION
                    def comparisonResult = env.VERSION_COMPARISON as Integer
                    def forceUpdate = params.FORCE_UPDATE ?: false
                    
                    def shouldUpdate = false
                    def reason = ""
                    
                    if (forceUpdate) {
                        shouldUpdate = true
                        if (comparisonResult == 0) {
                            reason = "强制更新模式(版本相同:${newVersion})"
                        } else {
                            reason = "强制更新模式(版本:${currentVersion} → ${newVersion})"
                        }
                    } else if (comparisonResult == 0) {
                        shouldUpdate = false
                        reason = "版本相同 (${currentVersion}),无需更新"
                    } else if (comparisonResult > 0) {
                        shouldUpdate = true
                        reason = "版本回退操作:${currentVersion} → ${newVersion}"
                    } else {
                        shouldUpdate = true
                        reason = "版本升级:${currentVersion} → ${newVersion}"
                    }
                    
                    echo "当前版本: ${currentVersion}"
                    echo "目标版本: ${newVersion}"
                    echo "强制更新: ${forceUpdate ? '✅ 是' : '❌ 否'}"
                    echo ""
                    echo "结论: ${reason}"
                    echo "是否更新: ${shouldUpdate ? '✅ 是' : '⚠️  否'}"
                    echo "=========================================="
                    
                    env.SHOULD_UPDATE = shouldUpdate.toString()
                    env.FORCE_UPDATE = forceUpdate.toString()
                }
            }
        }
        
        stage('查找挂载点') {
            when {
                expression { env.SHOULD_UPDATE == "true" }
            }
            steps {
                script {
                    echo "=========================================="
                    echo "🔍 查找 SMB 挂载点"
                    echo "=========================================="
                    
                    // ========== 挂载点配置(需要修改)==========
                    def mountPrefix = "YourProject_"  // 替换为你的项目前缀(与参数脚本保持一致)
                    
                    def smbVolume = sh(
                        script: """
                            USER_HOME=\$(eval echo ~\${USER})
                            MOUNTS_DIR="\${USER_HOME}/TuwanMounts"
                            
                            if [ ! -d "\${MOUNTS_DIR}" ]; then
                                echo "❌ 挂载目录不存在: \${MOUNTS_DIR}" >&2
                                exit 1
                            fi
                            
                            for vol in "\${MOUNTS_DIR}"/*; do
                                if [ -d "\$vol" ]; then
                                    case "\$(basename "\$vol")" in
                                        ${mountPrefix}*)
                                            echo "\$vol"
                                            exit 0
                                            ;;
                                    esac
                                fi
                            done
                            
                            echo "❌ 未找到前缀为 '${mountPrefix}' 的挂载点" >&2
                            exit 1
                        """,
                        returnStdout: true
                    ).trim()
                    
                    if (!smbVolume) {
                        error """
                            ❌ 未找到 SMB 挂载点
                            
                            请检查:
                            1. Jenkins 参数脚本是否正常运行
                            2. SMB 是否成功挂载
                            3. 挂载点前缀是否为 '${mountPrefix}'
                        """.stripIndent()
                    }
                    
                    echo "找到挂载点: ${smbVolume}"
                    
                    env.SMB_VOLUME = smbVolume
                    env.SOURCE_FOLDER = "${smbVolume}/${params.UNITY_FOLDER}"
                    
                    echo "源文件夹: ${env.SOURCE_FOLDER}"
                    echo "=========================================="
                }
            }
        }
        
        stage('准备目标目录') {
            when {
                expression { env.SHOULD_UPDATE == "true" }
            }
            steps {
                script {
                    echo "=========================================="
                    echo "📁 准备目标目录"
                    echo "=========================================="
                    
                    def folder = params.UNITY_FOLDER
                    def targetDir = "${WORKSPACE}/${folder}"
                    
                    echo "目标目录: ${targetDir}"
                    
                    sh """
                        if [ -d '${targetDir}' ]; then
                            echo "清理旧目录..."
                            rm -rf '${targetDir}'
                        fi
                        
                        echo "创建目录..."
                        mkdir -p '${targetDir}'
                        
                        if [ ! -d '${targetDir}' ]; then
                            echo "❌ 创建目录失败"
                            exit 1
                        fi
                        
                        echo "✅ 目录准备完成"
                    """
                    
                    env.TARGET_DIR = targetDir
                    echo "=========================================="
                }
            }
        }
        
        stage('下载文件') {
            when {
                expression { env.SHOULD_UPDATE == "true" }
            }
            steps {
                script {
                    echo "=========================================="
                    echo "⬇️  下载文件"
                    echo "=========================================="
                    
                    def sourceFolder = env.SOURCE_FOLDER
                    def targetDir = env.TARGET_DIR
                    def filesParam = params.UNITY_FILE
                    
                    echo "源文件夹: ${sourceFolder}"
                    echo "目标目录: ${targetDir}"
                    echo "参数值: ${filesParam}"
                    echo ""
                    
                    // 解析多选文件
                    def filesList = []
                    if (filesParam instanceof String) {
                        filesList = filesParam.split(',').collect { it.trim() }
                    } else if (filesParam instanceof List) {
                        filesList = filesParam
                    } else {
                        filesList = [filesParam.toString()]
                    }
                    
                    echo "解析到 ${filesList.size()} 个文件"
                    echo ""
                    
                    def frameworkFile = null
                    def dsymFile = null
                    
                    filesList.each { file ->
                        def realFileName = file.replaceAll(/\s*\(.*?\)\s*$/, '').trim()
                        
                        echo "处理文件: ${realFileName}"
                        
                        if (realFileName.endsWith('.framework.zip')) {
                            frameworkFile = realFileName
                            echo "  识别为: Framework 文件"
                        } else if (realFileName.endsWith('.framework.dSYM.zip')) {
                            dsymFile = realFileName
                            echo "  识别为: dSYM 符号表文件"
                        } else {
                            echo "  ⚠️ 未知文件类型,跳过"
                            return
                        }
                        
                        sh """
                            if [ ! -d '${sourceFolder}' ]; then
                                echo "❌ 源文件夹不存在: ${sourceFolder}"
                                exit 1
                            fi
                            
                            SOURCE_FILE='${sourceFolder}/${realFileName}'
                            
                            if [ ! -f "\${SOURCE_FILE}" ]; then
                                echo "❌ 文件不存在: \${SOURCE_FILE}"
                                exit 1
                            fi
                            
                            echo "  开始复制..."
                            cp -v "\${SOURCE_FILE}" '${targetDir}/'
                            
                            if [ \$? -ne 0 ]; then
                                echo "  ❌ 复制失败"
                                exit 1
                            fi
                            
                            echo "  ✅ 复制完成"
                            ls -lh '${targetDir}/${realFileName}'
                        """
                        
                        echo ""
                    }
                    
                    if (!frameworkFile) {
                        error "❌ 未找到 .framework.zip 文件"
                    }
                    
                    echo "=========================================="
                    echo "📦 文件下载汇总"
                    echo "=========================================="
                    echo "Framework 文件: ${frameworkFile}"
                    echo "dSYM 文件: ${dsymFile ?: '无'}"
                    echo "=========================================="
                    
                    env.FRAMEWORK_FILE = frameworkFile
                    env.REAL_FILE_NAME = frameworkFile
                    if (dsymFile) {
                        env.DSYM_FILE = dsymFile
                    }
                }
            }
        }
        
        stage('解压 Framework 文件') {
            when {
                expression { env.SHOULD_UPDATE == "true" }
            }
            steps {
                script {
                    echo "=========================================="
                    echo "📦 解压 Framework 文件"
                    echo "=========================================="
                    
                    def targetDir = env.TARGET_DIR
                    def frameworkFile = env.FRAMEWORK_FILE
                    def zipFile = "${targetDir}/${frameworkFile}"
                    
                    echo "压缩文件: ${zipFile}"
                    echo "解压目录: ${targetDir}"
                    echo ""
                    echo "ℹ️  注意:dSYM 文件不会在此阶段解压"
                    echo ""
                    
                    sh """
                        if [ ! -f '${zipFile}' ]; then
                            echo "❌ 压缩文件不存在: ${zipFile}"
                            exit 1
                        fi
                        
                        echo "开始解压 Framework..."
                        cd '${targetDir}'
                        unzip -o '${zipFile}'
                        
                        if [ \$? -ne 0 ]; then
                            echo "❌ 解压失败"
                            exit 1
                        fi
                        
                        echo ""
                        echo "✅ Framework 解压完成"
                        echo "解压后的内容:"
                        ls -lh '${targetDir}'
                    """
                    
                    echo "=========================================="
                }
            }
        }
        
        stage('拷贝 Framework 并更新版本号') {
            when {
                expression { env.SHOULD_UPDATE == "true" }
            }
            steps {
                script {
                    echo "=========================================="
                    echo "📋 拷贝 Framework 并更新版本号"
                    echo "=========================================="
                    
                    def targetDir = env.TARGET_DIR
                    def gitDir = env.GIT_REPO_DIR
                    def newVersion = env.NEW_VERSION
                    
                    // ========== 项目配置(需要修改)==========
                    def podspecFileName = "YourProject.podspec"  // 替换为你的 Podspec 文件名
                    def targetFrameworkParentDir = "YourRepoSubDir"  // Framework 所在的子目录名
                    
                    echo "下载目录: ${targetDir}"
                    echo "Git 目录: ${gitDir}"
                    echo "新版本: ${newVersion}"
                    echo ""
                    
                    // 步骤0: 检查当前 podspec 版本
                    echo "🔍 步骤0: 检查当前 podspec 版本..."
                    
                    def podspecPath = "${gitDir}/${podspecFileName}"
                    
                    def actualCurrentVersion = sh(
                        script: "grep -E '^[[:space:]]*s\\.version' '${podspecPath}' | head -1 | sed -E \"s/.*['\\\"]([^'\\\"]*)['\\\"].*/\\1/\"",
                        returnStdout: true
                    ).trim()
                    
                    echo "  podspec 当前版本: ${actualCurrentVersion}"
                    echo "  目标版本: ${newVersion}"
                    
                    def forceUpdate = env.FORCE_UPDATE == "true"
                    
                    if (actualCurrentVersion == newVersion && !forceUpdate) {
                        echo ""
                        echo "  ⚠️  podspec 版本已经是目标版本!"
                        echo "  💡 如需强制更新,请勾选 'FORCE_UPDATE' 参数重新运行"
                        echo "  ✅ 跳过 Framework 更新和版本修改"
                        echo "=========================================="
                        
                        env.SKIP_GIT_PUSH = "true"
                        return
                    } else if (actualCurrentVersion == newVersion && forceUpdate) {
                        echo ""
                        echo "  ⚠️  版本相同,但强制更新模式已启用"
                        echo "  ✅ 继续执行更新流程"
                        echo ""
                    } else {
                        echo "  ✅ 版本不同,继续更新流程"
                        echo ""
                    }
                    
                    // 步骤1: 验证 Git 仓库状态
                    echo "🔍 步骤1: 验证 Git 仓库状态..."
                    
                    sh """
                        unset GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE
                        
                        if [ ! -d '${gitDir}/.git' ]; then
                            echo "  ❌ .git 目录不存在"
                            exit 1
                        fi
                        
                        cd '${gitDir}'
                        
                        if ! git status >/dev/null 2>&1; then
                            echo "  ❌ Git 仓库状态异常"
                            exit 1
                        fi
                        
                        echo "  ✅ Git 仓库状态正常"
                    """
                    
                    echo ""
                    
                    // 步骤1.5: 清理错误位置的 Framework
                    echo "🗑️  步骤1.5: 清理错误位置的 Framework..."
                    
                    def incorrectFramework = "${gitDir}/UnityFramework.framework"
                    
                    def incorrectExists = sh(
                        script: "[ -d '${incorrectFramework}' ] && echo 'true' || echo 'false'",
                        returnStdout: true
                    ).trim()
                    
                    if (incorrectExists == "true") {
                        echo "  ⚠️  发现根目录下的错误 Framework: ${incorrectFramework}"
                        echo "  🗑️  删除中..."
                        
                        sh """
                            rm -rf '${incorrectFramework}'
                            
                            if [ \$? -ne 0 ]; then
                                echo "  ❌ 删除失败"
                                exit 1
                            fi
                            
                            echo "  ✅ 已删除错误位置的 Framework"
                        """
                    } else {
                        echo "  ✅ 未发现错误位置的 Framework"
                    }
                    
                    echo ""
                    
                    // 步骤2: 查找解压后的 Framework
                    echo "📂 步骤2: 查找解压后的 UnityFramework.framework..."
                    
                    def sourceFramework = sh(
                        script: """
                            FOUND=\$(find '${targetDir}' -type d -name 'UnityFramework.framework' 2>/dev/null | head -1)
                            if [ -z "\$FOUND" ]; then
                                echo "❌ 未找到 UnityFramework.framework" >&2
                                exit 1
                            fi
                            echo "\$FOUND"
                        """,
                        returnStdout: true
                    ).trim()
                    
                    echo "  找到: ${sourceFramework}"
                    echo ""
                    
                    // 步骤3: 确定 Framework 目标路径
                    echo "🔍 步骤3: 确定 Framework 目标路径..."
                    
                    def targetFrameworkParent = "${gitDir}/${targetFrameworkParentDir}"
                    def targetFramework = "${targetFrameworkParent}/UnityFramework.framework"
                    
                    echo "  目标父目录: ${targetFrameworkParent}"
                    echo "  目标路径: ${targetFramework}"
                    
                    def frameworkExists = sh(
                        script: "[ -d '${targetFramework}' ] && echo 'true' || echo 'false'",
                        returnStdout: true
                    ).trim()
                    
                    if (frameworkExists == "true") {
                        echo "  ✅ 找到现有 Framework,将替换"
                    } else {
                        echo "  ⚠️  Framework 不存在,将创建新的"
                    }
                    
                    echo ""
                    
                    // 步骤4: 替换 Framework
                    echo "🔄 步骤4: 替换 Framework..."
                    
                    sh """
                        if [ ! -d '${targetFrameworkParent}' ]; then
                            echo "  创建目录: ${targetFrameworkParent}"
                            mkdir -p '${targetFrameworkParent}'
                            
                            if [ \$? -ne 0 ]; then
                                echo "  ❌ 创建目录失败"
                                exit 1
                            fi
                        fi
                        
                        if [ -d '${targetFramework}' ]; then
                            echo "  删除旧 Framework: ${targetFramework}"
                            rm -rf '${targetFramework}'
                            
                            if [ \$? -ne 0 ]; then
                                echo "  ❌ 删除失败"
                                exit 1
                            fi
                        fi
                        
                        echo "  拷贝新 Framework..."
                        echo "    从: ${sourceFramework}"
                        echo "    到: ${targetFrameworkParent}/"
                        
                        cp -R '${sourceFramework}' '${targetFrameworkParent}/'
                        
                        if [ \$? -ne 0 ]; then
                            echo "  ❌ 拷贝失败"
                            exit 1
                        fi
                        
                        if [ ! -d '${targetFramework}' ]; then
                            echo "  ❌ 拷贝后文件不存在: ${targetFramework}"
                            exit 1
                        fi
                        
                        echo "  ✅ Framework 替换成功"
                        echo ""
                        echo "  验证结果:"
                        ls -ld '${targetFramework}'
                    """
                    
                    echo ""
                    
                    // 步骤5: 更新 podspec 版本号
                    echo "✏️  步骤5: 更新 podspec 版本号..."
                    
                    sh """
                        if [ ! -f '${podspecPath}' ]; then
                            echo "  ❌ podspec 文件不存在: ${podspecPath}"
                            exit 1
                        fi
                        
                        echo "  当前版本: \$(grep -E '^[[:space:]]*s\\.version' '${podspecPath}' | head -1 | sed -E "s/.*['\\\"]([^'\\\"]*)['\\\"].*/\\1/")"
                        
                        sed -i '' -E "s/(s\\.version[[:space:]]*=[[:space:]]*['\\\"])[^'\\\"]*(['\\\"])/\\1${newVersion}\\2/" '${podspecPath}'
                        
                        if [ \$? -ne 0 ]; then
                            echo "  ❌ 更新版本号失败"
                            exit 1
                        fi
                        
                        NEW_VER=\$(grep -E '^[[:space:]]*s\\.version' '${podspecPath}' | head -1 | sed -E "s/.*['\\\"]([^'\\\"]*)['\\\"].*/\\1/")
                        
                        if [ "\$NEW_VER" != "${newVersion}" ]; then
                            echo "  ❌ 版本号更新验证失败"
                            echo "     期望: ${newVersion}"
                            echo "     实际: \$NEW_VER"
                            exit 1
                        fi
                        
                        echo "  新版本: \$NEW_VER"
                        echo "  ✅ 版本号更新成功"
                    """
                    
                    echo ""
                    echo "✅ Framework 拷贝和版本更新完成"
                    echo "=========================================="
                }
            }
        }
        
        stage('Git 提交并推送') {
            when {
                expression { 
                    env.SHOULD_UPDATE == "true" && env.SKIP_GIT_PUSH != "true"
                }
            }
            steps {
                script {
                    echo "=========================================="
                    echo "🚀 Git 提交并推送"
                    echo "=========================================="
                    
                    def gitDir = env.GIT_REPO_DIR
                    def gitRepoHost = env.GIT_REPO_HOST
                    def newVersion = env.NEW_VERSION
                    def tagName = newVersion
                    
                    // ========== 项目配置(需要修改)==========
                    def projectName = "YourProject"  // 替换为你的项目名称
                    
                    echo "Git 目录: ${gitDir}"
                    echo "版本号: ${newVersion}"
                    echo "Tag 名称: ${tagName}"
                    echo ""
                    
                    // 步骤1: 检查是否有变更
                    echo "🔍 步骤1: 检查变更..."
                    
                    def hasChanges = sh(
                        script: """
                            unset GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE
                            cd '${gitDir}'
                            
                            if [ -n "\$(git status --porcelain)" ]; then
                                echo 'true'
                            else
                                echo 'false'
                            fi
                        """,
                        returnStdout: true
                    ).trim()
                    
                    if (hasChanges == 'false') {
                        echo "  ⚠️  没有变更,跳过提交"
                        echo "=========================================="
                        return
                    }
                    
                    echo "  ✅ 有变更需要提交"
                    echo ""
                    
                    // 步骤2: 添加并提交变更
                    echo "📝 步骤2: 提交变更..."
                    
                    withCredentials([usernamePassword(
                        credentialsId: env.GIT_CREDENTIALS_ID,
                        usernameVariable: 'GIT_USER',
                        passwordVariable: 'GIT_PASS'
                    )]) {
                        sh """
                            unset GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE
                            cd '${gitDir}'
                            
                            git config user.name "Jenkins CI"
                            git config user.email "ci@jenkins.local"
                            
                            # 强制设置提交时间为当前时间
                            export GIT_AUTHOR_DATE="\$(date)"
                            export GIT_COMMITTER_DATE="\$(date)"
                            
                            git add .
                            
                            if [ \$? -ne 0 ]; then
                                echo "  ❌ git add 失败"
                                exit 1
                            fi
                            
                            git commit -m "chore: update ${projectName} to version ${newVersion} - Jenkins Build #${env.BUILD_NUMBER}"
                            
                            if [ \$? -ne 0 ]; then
                                echo "  ❌ git commit 失败"
                                exit 1
                            fi
                            
                            echo "  ✅ 提交成功"
                        """
                        
                        echo ""
                        
                        // 步骤3: 创建带注释的 Tag
                        echo "🏷️  步骤3: 创建 Tag..."
                        
                        sh """
                            unset GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE
                            cd '${gitDir}'
                            
                            if git tag -l | grep -q "^${tagName}\$"; then
                                echo "  删除本地旧 tag..."
                                git tag -d "${tagName}"
                            fi
                            
                            # 创建带注释的 tag(确保 tag 有自己的时间戳)
                            git tag -a "${tagName}" -m "Release version ${tagName} - Jenkins Build #${env.BUILD_NUMBER}"
                            
                            if [ \$? -ne 0 ]; then
                                echo "  ❌ 创建 tag 失败"
                                exit 1
                            fi
                            
                            echo "  ✅ Tag '${tagName}' 创建成功(annotated tag)"
                        """
                        
                        echo ""
                        
                        // 步骤4: 强制推送
                        echo "📤 步骤4: 推送到远程仓库..."
                        
                        sh """
                            unset GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE
                            cd '${gitDir}'
                            
                            git config --unset credential.helper 2>/dev/null || true
                            
                            echo "  📤 推送代码到 origin/master(使用 --force-with-lease)..."
                            
                            git push --force-with-lease https://\${GIT_USER}:\${GIT_PASS}@${gitRepoHost} master
                            
                            if [ \$? -ne 0 ]; then
                                echo "  ❌ 代码推送失败"
                                exit 1
                            fi
                            
                            echo "  ✅ 代码推送成功"
                            echo ""
                            
                            echo "  🏷️  推送 Tag(强制)..."
                            
                            git push --force-with-lease https://\${GIT_USER}:\${GIT_PASS}@${gitRepoHost} "${tagName}"
                            
                            if [ \$? -ne 0 ]; then
                                echo "  ❌ Tag 推送失败"
                                exit 1
                            fi
                            
                            echo "  ✅ Tag 推送成功"
                        """
                    }
                    
                    echo ""
                    
                    // 步骤5: 验证推送结果
                    echo "✅ 步骤5: 验证推送结果..."
                    
                    sh """
                        unset GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE
                        cd '${gitDir}'
                        
                        echo "  📋 最新提交信息:"
                        git log -1 --pretty=format:'    提交: %h%n    作者: %an <%ae>%n    时间: %ad%n    消息: %s%n' --date=format:'%Y-%m-%d %H:%M:%S'
                        
                        echo ""
                        echo "  🏷️  Tag 详细信息:"
                        if git tag -l "${tagName}" | grep -q "${tagName}"; then
                            TAG_TYPE=\$(git cat-file -t "${tagName}")
                            echo "    Tag 名称: ${tagName}"
                            echo "    Tag 类型: \${TAG_TYPE}"
                            
                            if [ "\${TAG_TYPE}" = "tag" ]; then
                                echo "    创建时间: \$(git log -1 --format=%ai "${tagName}")"
                            else
                                echo "    指向提交: \$(git rev-parse --short "${tagName}")"
                            fi
                        else
                            echo "    ⚠️  Tag 未找到"
                        fi
                    """
                    
                    echo ""
                    echo "✅ Git 提交、Tag 创建和推送全部完成"
                    echo "=========================================="
                }
            }
        }
        
        stage('上传符号表') {
            when {
                expression { 
                    env.SHOULD_UPDATE == "true" && 
                    env.SKIP_GIT_PUSH != "true" && 
                    env.DSYM_FILE != null 
                }
            }
            steps {
                script {
                    echo "=========================================="
                    echo "📤 上传符号表到 APMPlus"
                    echo "=========================================="
                    
                    def targetDir = env.TARGET_DIR
                    def dsymFile = env.DSYM_FILE
                    def dsymFilePath = "${targetDir}/${dsymFile}"
                    
                    echo "dSYM 文件: ${dsymFile}"
                    echo "文件路径: ${dsymFilePath}"
                    echo ""
                    
                    // 验证文件存在
                    def fileExists = sh(
                        script: "[ -f '${dsymFilePath}' ] && echo 'true' || echo 'false'",
                        returnStdout: true
                    ).trim()
                    
                    if (fileExists != 'true') {
                        error "❌ dSYM 文件不存在: ${dsymFilePath}"
                    }
                    
                    echo "✅ dSYM 文件存在"
                    echo ""
                    echo "开始上传符号表..."
                    echo ""
                    
                    def uploadResult = sh(
                        script: """
                            # ========== APMPlus 配置(需要修改)==========
                            APMPlus_Upload_DSYM_Domain="https://your-apmplus-domain.com"  # APMPlus API 域名
                            APMPlus_APPID="YOUR_APP_ID"                                    # 应用 ID
                            APMPlus_Upload_DSYM_API_Key="YOUR_API_KEY"                     # API Key
                            APMPlus_Upload_DSYM_API_Token="YOUR_API_TOKEN"                 # API Token
                            
                            # dSYM 文件路径
                            APMPlus_DSYM_File_Path="${dsymFilePath}"
                            
                            # ========== 生成时间戳和签名 ==========
                            APMPlus_CURRENT_TIMESTAMP=\$(date +%s)000
                            
                            # MD5 签名计算(拼接规则:APPID + Timestamp + Token)
                            APMPlus_String_To_Sign="\${APMPlus_APPID}\${APMPlus_CURRENT_TIMESTAMP}\${APMPlus_Upload_DSYM_API_Token}"
                            APMPlus_Signature=\$(echo -n "\${APMPlus_String_To_Sign}" | md5)
                            
                            echo "📊 上传配置:"
                            echo "  Domain: \${APMPlus_Upload_DSYM_Domain}"
                            echo "  APPID: \${APMPlus_APPID}"
                            echo "  API Key: \${APMPlus_Upload_DSYM_API_Key}"
                            echo "  Timestamp: \${APMPlus_CURRENT_TIMESTAMP}"
                            echo "  Signature: \${APMPlus_Signature}"
                            echo "  File: \${APMPlus_DSYM_File_Path}"
                            echo ""
                            
                            # ========== 执行上传 ==========
                            echo "🚀 开始上传..."
                            
                            HTTP_RESPONSE=\$(curl -w "\\n%{http_code}" -s \\
                                "\${APMPlus_Upload_DSYM_Domain}/apmplus_api/eue/guest/app/mapping/upload" \\
                                -F "file=@\${APMPlus_DSYM_File_Path}" \\
                                -F "type=Dwarf" \\
                                -F "os=iOS" \\
                                -F "aid=\${APMPlus_APPID}" \\
                                -F "script-version=3.0" \\
                                -H "Content-Type: multipart/form-data" \\
                                -H "X-OpenApi-Timestamp: \${APMPlus_CURRENT_TIMESTAMP}" \\
                                -H "X-OpenApi-AppID: \${APMPlus_Upload_DSYM_API_Key}" \\
                                -H "X-OpenApi-Token: \${APMPlus_Signature}" \\
                                -H "x-app-ids: \${APMPlus_APPID}")
                            
                            # 分离响应体和状态码
                            RESPONSE_BODY=\$(echo "\${HTTP_RESPONSE}" | sed '\$d')
                            STATUS_CODE=\$(echo "\${HTTP_RESPONSE}" | tail -n 1)
                            
                            echo ""
                            echo "📥 上传响应:"
                            echo "  HTTP 状态码: \${STATUS_CODE}"
                            echo "  响应内容: \${RESPONSE_BODY}"
                            echo ""
                            
                            # 检查上传是否成功
                            if [ "\${STATUS_CODE}" = "200" ]; then
                                # 检查响应体中的 error_no
                                ERROR_NO=\$(echo "\${RESPONSE_BODY}" | grep -o '"error_no":[0-9]*' | cut -d':' -f2)
                                
                                if [ "\${ERROR_NO}" = "0" ]; then
                                    echo "✅ 符号表上传成功!"
                                    exit 0
                                else
                                    ERROR_MSG=\$(echo "\${RESPONSE_BODY}" | grep -o '"error_msg":"[^"]*"' | cut -d'"' -f4)
                                    echo "❌ 符号表上传失败!"
                                    echo "   错误码: \${ERROR_NO}"
                                    echo "   错误信息: \${ERROR_MSG}"
                                    exit 1
                                fi
                            else
                                echo "❌ 符号表上传失败!HTTP 状态码: \${STATUS_CODE}"
                                exit 1
                            fi
                        """,
                        returnStatus: true
                    )
                    
                    if (uploadResult != 0) {
                        error "❌ 符号表上传失败,请检查日志"
                    }
                    
                    echo ""
                    echo "✅ 符号表上传完成"
                    echo "=========================================="
                }
            }
        }
        
        stage('查看结果') {
            when {
                expression { env.SHOULD_UPDATE == "true" }
            }
            steps {
                script {
                    echo "=========================================="
                    echo "📊 查看结果"
                    echo "=========================================="
                    
                    if (env.SKIP_GIT_PUSH == "true") {
                        echo "⚠️  版本已经是最新,跳过了更新"
                        echo "当前版本: ${env.CURRENT_VERSION}"
                    } else {
                        def targetDir = env.TARGET_DIR
                        
                        sh """
                            echo "目标目录内容:"
                            ls -lh '${targetDir}'
                        """
                        
                        echo ""
                        echo "✅ 更新完成"
                        echo "新版本: ${env.NEW_VERSION}"
                        
                        if (env.FRAMEWORK_FILE) {
                            echo "Framework 文件: ${env.FRAMEWORK_FILE}"
                        }
                        
                        if (env.DSYM_FILE) {
                            echo "dSYM 文件: ${env.DSYM_FILE}"
                            echo "符号表上传: ✅ 已上传"
                        } else {
                            echo "符号表上传: ⚠️  未选择 dSYM 文件"
                        }
                    }
                    
                    echo "=========================================="
                }
            }
        }
    }
    
    post {
        always {
            script {
                // 清理临时目录
                def podspecCheckDir = "${WORKSPACE}/podspec_check"
                sh "rm -rf '${podspecCheckDir}'"
            }
        }
        
        success {
            script {
                // 钉钉通知逻辑
                def atMobiles = ""
                switch(params.DINGTALK_AT_WHO) {
                    case '所有人':
                        atMobiles = env.DINGTALK_AT_ALL
                        break
                    case '人员1':
                        atMobiles = env.DINGTALK_AT_PERSON1
                        break
                    case '人员2':
                        atMobiles = env.DINGTALK_AT_PERSON2
                        break
                    case '人员3':
                        atMobiles = env.DINGTALK_AT_PERSON3
                        break
                    case '自定义手机号':
                        atMobiles = params.CUSTOM_AT_MOBILES ?: ""
                        break
                    default:
                        atMobiles = env.DINGTALK_AT_ALL
                }
                
                def buildUrl = "${env.JENKINS_BASE_URL}job/${env.JOB_NAME}/${env.BUILD_NUMBER}/"
                def updateStatus = env.SHOULD_UPDATE == 'true' ? (env.SKIP_GIT_PUSH == 'true' ? '跳过更新(版本相同)' : '已更新') : '跳过更新(版本相同)'
                def versionInfo = env.SHOULD_UPDATE == 'true' ? env.NEW_VERSION : env.CURRENT_VERSION
                def forceUpdate = params.FORCE_UPDATE ?: false
                def dsymUploaded = (env.DSYM_FILE != null && env.SKIP_GIT_PUSH != 'true') ? '✅ 已上传' : '⏭️  未上传'
                
                // ========== 项目配置(需要修改)==========
                def projectName = "YourProject"  // 替换为你的项目名称
                
                def message = """
**构建成功** 🎉

**项目信息:**
- 项目名称: ${projectName}
- 版本号: ${versionInfo}
- 更新状态: ${updateStatus}
- 符号表: ${dsymUploaded}
- 构建编号: #${env.BUILD_NUMBER}
- 构建时间: ${new Date().format("yyyy-MM-dd HH:mm:ss")}
${forceUpdate ? '- 更新模式: 🔄 强制更新' : ''}

**构建步骤:**
✅ 参数验证
✅ Git 仓库检查
✅ 版本比较
${env.SHOULD_UPDATE == 'true' && env.SKIP_GIT_PUSH != 'true' ? '✅ 挂载点查找\n✅ 文件下载\n✅ Framework 解压\n✅ Framework 更新\n✅ 版本号更新\n✅ Git 提交推送\n✅ Tag 创建' : '⏭️  跳过更新(版本相同)'}
${env.DSYM_FILE != null && env.SKIP_GIT_PUSH != 'true' ? '✅ 符号表上传' : ''}

**构建链接:** [查看详情](${buildUrl})
                """
                
                sendDingTalkNotification(
                    message: message,
                    status: 'SUCCESS',
                    atMobiles: atMobiles,
                    buildUrl: buildUrl
                )
                
                echo ""
                echo "=========================================="
                echo "✅ Pipeline 执行成功"
                echo "=========================================="
                
                if (env.SHOULD_UPDATE == "true") {
                    if (env.SKIP_GIT_PUSH == "true") {
                        echo "版本已经是 ${env.CURRENT_VERSION},无需更新"
                        if (!forceUpdate) {
                            echo "提示:如需强制更新,请勾选 'FORCE_UPDATE' 参数"
                        }
                    } else {
                        echo "版本: ${env.CURRENT_VERSION} → ${env.NEW_VERSION}"
                        echo "Git 仓库已更新并推送"
                        echo "Tag '${env.NEW_VERSION}' 已创建并推送"
                        if (env.DSYM_FILE) {
                            echo "符号表已上传到 APMPlus"
                        }
                        if (forceUpdate) {
                            echo "更新模式: 强制更新"
                        }
                    }
                } else {
                    echo "版本已是最新,无需更新"
                }
                
                echo "=========================================="
            }
        }
        
        failure {
            script {
                // 钉钉通知逻辑
                def atMobiles = ""
                switch(params.DINGTALK_AT_WHO) {
                    case '所有人':
                        atMobiles = env.DINGTALK_AT_ALL
                        break
                    case '人员1':
                        atMobiles = env.DINGTALK_AT_PERSON1
                        break
                    case '人员2':
                        atMobiles = env.DINGTALK_AT_PERSON2
                        break
                    case '人员3':
                        atMobiles = env.DINGTALK_AT_PERSON3
                        break
                    case '自定义手机号':
                        atMobiles = params.CUSTOM_AT_MOBILES ?: ""
                        break
                    default:
                        atMobiles = env.DINGTALK_AT_ALL
                }
                
                def buildUrl = "${env.JENKINS_BASE_URL}job/${env.JOB_NAME}/${env.BUILD_NUMBER}/"
                
                // ========== 项目配置(需要修改)==========
                def projectName = "YourProject"  // 替换为你的项目名称
                
                def message = """
**构建失败** 💥

**项目信息:**
- 项目名称: ${projectName}
- 目标版本: ${env.NEW_VERSION ?: '未知'}
- 当前版本: ${env.CURRENT_VERSION ?: '未知'}
- 构建编号: #${env.BUILD_NUMBER}
- 构建时间: ${new Date().format("yyyy-MM-dd HH:mm:ss")}

**失败原因:**
请查看构建日志获取详细错误信息

**构建链接:** [查看详情](${buildUrl})

**需要相关人员及时处理!**
                """
                
                sendDingTalkNotification(
                    message: message,
                    status: 'FAILURE',
                    atMobiles: atMobiles,
                    buildUrl: buildUrl
                )
                
                echo ""
                echo "=========================================="
                echo "❌ Pipeline 执行失败"
                echo "=========================================="
                echo "请检查上面的错误信息"
                echo "=========================================="
            }
        }
        
        aborted {
            script {
                // 钉钉通知逻辑(同 failure,只是消息不同)
                def atMobiles = ""
                switch(params.DINGTALK_AT_WHO) {
                    case '所有人':
                        atMobiles = env.DINGTALK_AT_ALL
                        break
                    case '人员1':
                        atMobiles = env.DINGTALK_AT_PERSON1
                        break
                    case '人员2':
                        atMobiles = env.DINGTALK_AT_PERSON2
                        break
                    case '人员3':
                        atMobiles = env.DINGTALK_AT_PERSON3
                        break
                    case '自定义手机号':
                        atMobiles = params.CUSTOM_AT_MOBILES ?: ""
                        break
                    default:
                        atMobiles = env.DINGTALK_AT_ALL
                }
                
                def buildUrl = "${env.JENKINS_BASE_URL}job/${env.JOB_NAME}/${env.BUILD_NUMBER}/"
                
                // ========== 项目配置(需要修改)==========
                def projectName = "YourProject"  // 替换为你的项目名称
                
                def message = """
**构建被中止** ⏸️

**项目信息:**
- 项目名称: ${projectName}
- 目标版本: ${env.NEW_VERSION ?: '未知'}
- 当前版本: ${env.CURRENT_VERSION ?: '未知'}
- 构建编号: #${env.BUILD_NUMBER}
- 构建时间: ${new Date().format("yyyy-MM-dd HH:mm:ss")}

**构建链接:** [查看详情](${buildUrl})
                """
                
                sendDingTalkNotification(
                    message: message,
                    status: 'ABORTED',
                    atMobiles: atMobiles,
                    buildUrl: buildUrl
                )
            }
        }
    }
}

// ========== 辅助函数 ==========

// 版本比较函数
def compareVersions(String version1, String version2) {
    try {
        def v1Parts = version1.tokenize('.')
        def v2Parts = version2.tokenize('.')
        
        def maxLength = Math.max(v1Parts.size(), v2Parts.size())
        
        for (int i = 0; i < maxLength; i++) {
            def v1Part = i < v1Parts.size() ? (v1Parts[i] as Integer) : 0
            def v2Part = i < v2Parts.size() ? (v2Parts[i] as Integer) : 0
            
            if (v1Part > v2Part) {
                return 1
            } else if (v1Part < v2Part) {
                return -1
            }
        }
        
        return 0
    } catch (Exception e) {
        echo "⚠️  版本比较失败: ${e.message}"
        return -1
    }
}

// 钉钉通知函数
def sendDingTalkNotification(Map config) {
    def message = config.message
    def status = config.status
    def atMobiles = config.atMobiles
    def buildUrl = config.buildUrl
    
    // 构建 at 手机号列表(JSON 数组格式)
    def atMobilesArray = atMobiles.split(',').collect { "\"${it.trim()}\"" }.join(',')
    
    // 构建 at 文本(在消息末尾添加 @手机号)
    def atText = ""
    if (atMobiles && atMobiles.trim()) {
        def mobileList = atMobiles.split(',').collect { "@${it.trim()}" }.join(' ')
        atText = "\\n\\n---\\n${mobileList}"
    }
    
    sh """
        # 使用 Python 生成毫秒级时间戳(macOS 兼容)
        TIMESTAMP=\$(python3 -c "import time; print(int(time.time() * 1000))")
        
        # 计算签名
        SIGN=\$(printf "%s\\n%s" "\${TIMESTAMP}" "${env.DINGTALK_SECRET}" | openssl dgst -sha256 -hmac "${env.DINGTALK_SECRET}" -binary | base64)
        
        # URL 编码签名
        SIGN_ENCODED=\$(python3 -c "import sys, urllib.parse; print(urllib.parse.quote('\${SIGN}'.strip()))")
        
        # 构建完整的 webhook URL
        FULL_URL="${env.DINGTALK_WEBHOOK_URL}&timestamp=\${TIMESTAMP}&sign=\${SIGN_ENCODED}"
        
        # 构建消息体
        cat > /tmp/dingtalk_payload_${env.BUILD_NUMBER}.json <<'EOFMARK'
{
  "msgtype": "markdown",
  "markdown": {
    "title": "${status == 'SUCCESS' ? '✅ 构建成功' : (status == 'FAILURE' ? '❌ 构建失败' : '⏸️ 构建中止')}",
    "text": "${message.replaceAll('"', '\\\\"').replaceAll('\n', '\\\\n')}${atText}"
  },
  "at": {
    "atMobiles": [${atMobilesArray}],
    "isAtAll": false
  }
}
EOFMARK
        
        # 发送请求
        HTTP_CODE=\$(curl -s -w "%{http_code}" -o /tmp/dingtalk_response_${env.BUILD_NUMBER}.txt \\
          -X POST "\${FULL_URL}" \\
          -H 'Content-Type: application/json' \\
          -d @/tmp/dingtalk_payload_${env.BUILD_NUMBER}.json)
        
        echo "HTTP 响应码: \${HTTP_CODE}"
        
        # 清理临时文件
        rm -f /tmp/dingtalk_payload_${env.BUILD_NUMBER}.json
        rm -f /tmp/dingtalk_response_${env.BUILD_NUMBER}.txt
        
        if [ "\${HTTP_CODE}" = "200" ]; then
            echo "✅ 钉钉通知已成功发送: ${status}"
        else
            echo "⚠️  钉钉通知发送可能失败,HTTP 状态码: \${HTTP_CODE}"
        fi
    """
}

部署步骤

1. 前置准备

1.1 创建 Jenkins 凭据

SMB 凭据

  1. Jenkins → Manage Jenkins → Manage Credentials
  2. 添加 → Username with password
  3. 输入 SMB 用户名和密码
  4. ID 自动生成或自定义(如:smb-unity-files
  5. 保存并记录凭据 ID

Git 凭据

  1. 同上,创建 Git 账号的用户名密码凭据
  2. 记录凭据 ID(如:git-codeup-push

1.2 配置 macOS Agent

确保 Agent 已安装所需工具:

# 检查工具
git --version
python3 --version
curl --version
unzip -v
md5 -s "test"

# 创建挂载目录
mkdir -p ~/TuwanMounts

2. 创建 Jenkins Job

  1. 新建任务

    • 名称:UnityFramework_YourProject
    • 类型:Pipeline
  2. 配置参数化构建

    添加以下参数(按顺序):

    a. UNITY_FOLDER - Active Choices Reactive Parameter

    • 名称:UNITY_FOLDER
    • Groovy Script:粘贴文件夹选择器脚本
    • Choice Type: Radio Buttons
    • 修改配置区域的值

    b. UNITY_FILE - Active Choices Reactive Parameter

    • 名称:UNITY_FILE
    • Referenced parameters: UNITY_FOLDER
    • Groovy Script:粘贴文件选择器脚本
    • Choice Type: Check Boxes
    • 修改配置区域的值

    c. FORCE_UPDATE - Boolean Parameter

    • 名称:FORCE_UPDATE
    • 默认值:false
    • 描述:强制更新(即使版本号相同也执行更新)
  3. 配置 Pipeline

  4. 保存配置

3. 配置修改清单

在 Pipeline 代码中搜索并替换以下内容:

搜索关键字 替换为 位置
your-git-credentials-id 你的 Git 凭据 ID environment 区域
http://your-jenkins-host:port/ Jenkins 访问地址 environment 区域
YOUR_TOKEN 钉钉 Access Token environment 区域
YOUR_SECRET 钉钉机器人密钥 environment 区域
手机号1,手机号2 实际手机号 environment 区域
YourProject 项目名称 多处
https://your-git-host.com/org/repo.git Git 仓库地址 检查 Git 仓库版本 stage
YourProject.podspec Podspec 文件名 检查 Git 仓库版本 stage
YourRepoSubDir Framework 父目录 拷贝 Framework stage
YOUR_APP_ID APMPlus 应用 ID 上传符号表 stage
YOUR_API_KEY APMPlus API Key 上传符号表 stage
YOUR_API_TOKEN APMPlus API Token 上传符号表 stage

使用指南

首次使用

  1. 进入 Jenkins Job 页面
  2. 点击"Build with Parameters"
  3. 首次加载可能较慢(需要挂载 SMB)
  4. 选择版本文件夹(UNITY_FOLDER)
  5. 选择要上传的文件(UNITY_FILE)
  6. 选择 @ 人员
  7. 点击"Build"

日常使用

  1. 新版本发布后,Unity 团队会将文件上传到 SMB 共享目录
  2. 进入 Jenkins Job,点击"Build with Parameters"
  3. 选择最新版本文件夹
  4. 默认已选中所有文件(Framework + dSYM)
  5. 选择钉钉通知对象
  6. 点击"Build"
  7. 等待构建完成(通常 2-5 分钟)
  8. 查看钉钉通知确认结果

强制更新

如果版本号相同但需要重新上传:

  1. 勾选"FORCE_UPDATE"参数
  2. 执行构建

故障排查

常见问题

1. SMB 挂载失败

错误信息[错误] SMB 挂载失败

解决方案

  • 检查 SMB 服务器是否可访问
  • 验证凭据是否正确
  • 检查网络连接
  • 手动测试挂载:
    mount_smbfs //user:pass@host/share ~/test_mount
    

2. Git 克隆失败

错误信息❌ Git 克隆失败

解决方案

  • 验证 Git 凭据
  • 检查仓库地址是否正确
  • 确认分支名称
  • 检查网络连接

3. 符号表上传失败

错误信息❌ 符号表上传失败

解决方案

  • 检查 APMPlus 配置
  • 验证 API Key 和 Token
  • 确认 APPID 正确
  • 检查文件大小限制
  • 查看 APMPlus 后台日志

4. 钉钉通知未收到

解决方案

  • 检查 Webhook URL
  • 验证 SECRET
  • 确认手机号格式正确
  • 查看 Jenkins 控制台日志中的钉钉响应

5. 版本号未更新

解决方案

  • 检查 Podspec 文件路径
  • 确认版本号格式
  • 查看 Git 提交历史
  • 使用强制更新模式

调试技巧

  1. 查看详细日志

    • 进入构建页面
    • 点击"Console Output"
    • 搜索 ERROR
  2. 手动测试 SMB

    # 在 Agent 上执行
    ls -la ~/TuwanMounts/
    
  3. 手动测试 Git

    # 在 Agent 上执行
    cd ~/jenkins/workspace/YourJob/
    git status
    git log -1
    
  4. 测试钉钉通知

    # 使用 curl 手动发送测试消息
    curl -X POST "YOUR_WEBHOOK_URL" \
      -H 'Content-Type: application/json' \
      -d '{"msgtype":"text","text":{"content":"测试消息"}}'
    

最佳实践

1. 版本管理

  • 使用语义化版本号(如 1.2.3
  • 主版本号变更:重大架构调整
  • 次版本号变更:新功能添加
  • 修订号变更:Bug 修复

2. 构建频率

  • 开发阶段:每天 1-2 次
  • 测试阶段:按需构建
  • 生产发布:严格审核后构建

3. 权限管理

  • 限制 Jenkins Job 执行权限
  • 定期轮换 API Token
  • 使用只读 Git 账号(如果不需要推送)

4. 监控告警

  • 设置构建失败邮件通知
  • 配置钉钉机器人 @ 相关人员
  • 定期检查 Jenkins 日志

5. 备份策略

  • 定期备份 Git 仓库
  • 保留历史构建记录(至少 10 次)
  • 备份 Jenkins Job 配置

附录

A. Podspec 版本号格式

Pod::Spec.new do |s|
  s.name             = 'YourProject'
  s.version          = '1.2.3'  # 版本号位置
  s.summary          = 'Unity Framework'
  # ...
end

B. APMPlus 签名算法

# 拼接字符串:APPID + Timestamp + Token
string_to_sign="${APPID}${TIMESTAMP}${TOKEN}"

# 计算 MD5
signature=$(echo -n "${string_to_sign}" | md5)

C. 钉钉签名算法

# 拼接字符串:Timestamp\nSecret
string_to_sign="${TIMESTAMP}\n${SECRET}"

# 计算 HMAC-SHA256 并 Base64 编码
signature=$(echo -n "${string_to_sign}" | openssl dgst -sha256 -hmac "${SECRET}" -binary | base64)

# URL 编码
signature_encoded=$(python3 -c "import urllib.parse; print(urllib.parse.quote('${signature}'))")

D. 版本比较逻辑

1.2.3 vs 1.2.4 → -1 (需要升级)
1.2.4 vs 1.2.3 → 1  (回退)
1.2.3 vs 1.2.3 → 0  (相同)
©著作权归作者所有,转载或内容合作请联系作者
【社区内容提示】社区部分内容疑似由AI辅助生成,浏览时请结合常识与多方信息审慎甄别。
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。

相关阅读更多精彩内容

友情链接更多精彩内容