📋 目录
项目概述
本项目为 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
// ... 根据需要添加更多人员
获取方式:
- 钉钉群 → 群设置 → 智能群助手 → 添加机器人 → 自定义
- 选择"加签"安全设置,获取
SECRET - 复制 Webhook URL(包含 access_token)
- 收集需要 @ 的人员手机号
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)
配置步骤:
- Jenkins Job → 配置 → 参数化构建过程
- 添加参数:
Active Choices Reactive Parameter - 名称:
UNITY_FOLDER - 脚本类型:
Groovy Script - 粘贴以下代码并修改配置区域
// ========== 配置区域 ==========
// 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)
配置步骤:
- Jenkins Job → 配置 → 参数化构建过程
- 添加参数:
Active Choices Reactive Parameter - 名称:
UNITY_FILE - Choice Type:
Check Boxes(支持多选) - Referenced parameters:
UNITY_FOLDER - 脚本类型:
Groovy Script - 粘贴以下代码并修改配置区域
// ========== 配置区域 ==========
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
配置步骤:
- Jenkins Job → 配置 → 参数化构建过程
- 添加参数:
Boolean Parameter - 名称:
FORCE_UPDATE - 默认值:
false(不勾选) - 描述:
强制更新(即使版本号相同也执行更新)
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}×tamp=\${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 凭据:
- Jenkins → Manage Jenkins → Manage Credentials
- 添加 → Username with password
- 输入 SMB 用户名和密码
- ID 自动生成或自定义(如:
smb-unity-files) - 保存并记录凭据 ID
Git 凭据:
- 同上,创建 Git 账号的用户名密码凭据
- 记录凭据 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
-
新建任务
- 名称:
UnityFramework_YourProject - 类型:Pipeline
- 名称:
-
配置参数化构建
添加以下参数(按顺序):
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
- 描述:强制更新(即使版本号相同也执行更新)
- 名称:
-
配置 Pipeline
- Pipeline script:粘贴Pipeline 完整代码
- 修改所有标记为"需要修改"的配置项
保存配置
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 |
使用指南
首次使用
- 进入 Jenkins Job 页面
- 点击"Build with Parameters"
- 首次加载可能较慢(需要挂载 SMB)
- 选择版本文件夹(UNITY_FOLDER)
- 选择要上传的文件(UNITY_FILE)
- 选择 @ 人员
- 点击"Build"
日常使用
- 新版本发布后,Unity 团队会将文件上传到 SMB 共享目录
- 进入 Jenkins Job,点击"Build with Parameters"
- 选择最新版本文件夹
- 默认已选中所有文件(Framework + dSYM)
- 选择钉钉通知对象
- 点击"Build"
- 等待构建完成(通常 2-5 分钟)
- 查看钉钉通知确认结果
强制更新
如果版本号相同但需要重新上传:
- 勾选"FORCE_UPDATE"参数
- 执行构建
故障排查
常见问题
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 提交历史
- 使用强制更新模式
调试技巧
-
查看详细日志
- 进入构建页面
- 点击"Console Output"
- 搜索
❌或ERROR
-
手动测试 SMB
# 在 Agent 上执行 ls -la ~/TuwanMounts/ -
手动测试 Git
# 在 Agent 上执行 cd ~/jenkins/workspace/YourJob/ git status git log -1 -
测试钉钉通知
# 使用 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 (相同)