从零搭建 Jenkins Android 自动发包体系

原文链接 yuuou.vercel.app

!summary\] **AI总结** 本文介绍了如何利用 **Jenkins** 构建一条完整的 Android 应用打包流水线,实现从本地触发到自动编译、上传蒲公英并通知团队的全流程自动化。核心步骤包括:安装 Jenkins 与所需插件,配置仓库凭据和环境变量,编写流水线(拉取代码、编译项目、上传安装包、通过钉钉机器人推送通知),并支持手动触发脚本和 Webhooks 自动发包。此外还展示了如何记录执行信息、检测包体积和集成单元测试等高阶功能。通过这些实践,可以减少重复工作、提升打包质量并降低人为失误。

前言

在传统的开发流程中,开发人员常常在本地完成代码修复后自行打包并上传安装包,但这个过程中可能忘记先 push 代码。这种做法不仅容易遗漏代码更新,还可能导致已修复的 bug 在测试或产品环节"复活",迫使团队重复确认版本,增加了不必要的沟通和时间浪费。

为了避免这些问题,我决定通过 Jenkins 搭建一条标准化的 CI/CD 流水线,将打包、上传和通知等环节全部自动化,消除人为操作的风险。通过自动化,我们能够确保每次构建都基于最新的代码提交,确保版本一致性,并及时通知团队成员,避免了版本错乱和重复劳动。此外,CI/CD 流程的引入还能提升开发效率、加快产品交付周期,并使得团队能够更早地发现和解决问题,从而显著提高软件质量和团队协作效率。

通过本教程,你将学习如何搭建这一流水线,进一步提升团队的工作效率,减少人为错误,让开发、测试和产品团队的合作更加流畅高效。

整体流程与效果展示

0 整体流程

开发者通过 bat 脚本或 Webhooks 触发 Jenkins 任务 → Jenkins 拉取代码 → 编译生成 apk → Python 脚本上传蒲公英 → 钉钉机器人通知团队。

1 手动触发bat脚本发送打包指令

参数说明:

  • 构建用户:发包人姓名。在后端维护可信人员列表,如果姓名不在列表内则不允许打包。在钉钉机器人发送的信息中也会在群内艾特此用户。
  • 更新说明:表达更新内容。告知团队本次包的主要修改内容,该文本同时作为蒲公英的更新说明。
  • 打包分支:支持git项目中的任意分支打包。此参数避免了修改分支后要修改Jenkinsg任务参数的麻烦。
  • 打包环境:debug/release,区分打包类型

2 Jenkins实际执行效果

3 发包成功的钉钉机器人信息

4 上传完成的蒲公英信息

5 运行效果总结

几个月的运行统计表明,115次执行全部成功,执行时长平均4分6秒,编译时长4分50秒,上传时长19秒

需要实现的目标

支持不同打包方式

自动打包
  • 目标:每次push代码后监测版本号修改情况,当版本号修改时自动打包。
  • 过程:在源码管理中使用Webhooks,每次push后自动触发Jenkins自动打包任务;在自动打包任务中判断当前版本号和上次版本号是否相同,如果不同则进行打包。
  • 细节:发包完成后需要通知团队其他成员,所以在项目中我们需要保存自动发包时的发包说明和其它信息
手动打包

本地bat脚本执行,只需要输入构建用户、更新说明、需要艾特的人员等参数后即可下发构建任务。把大部分参数放到脚本中,通过curl命令传到任务中,任务再根据对应的值进行打包。

安全措施

由于手动打包的方式是在bat脚本中发送curl命令,意味着任何有该链接的用户都可以直接通过访问接口的方式发起打包任务,而在bat脚本中放置该链接相当于对用户完全开放。为了限制让可信用户才能打包,刚开始是在bat脚本中使用base64维护一个列表,不在列表内的用户提示无权限。这种方式实际还是没有安全性,于是委托后端新开了一个接口,后端进行校验构建用户是否在可信列表 内,不在的话不允许打包。这种方式保证外部人员即便拿到bat脚本也无法直接触发任务,已经足以满足此项目的安全性。实际效果运行半年多没有发生过安全性问题

支持对开发以外团队开放

项目参与的测试和产品团队,通过手动打包脚本实现验证包和发包上架流程。but 因为我们公司规模小,用不上这点。

解构目标任务(★代表难度)

1 安装Jenkins ★

  1. 下载并安装Jenkins,参考教程
  2. 配置Jenkins
  3. 新建一个Pipeline流水线任务

2 创建打包流水线

教程采用 Declarative Pipeline 编写流水线脚本。以下每个 stage 负责一个功能模块,可根据需要增加或删除 stage。在构建任务中时用得到的基础说明

pipeline 复制代码
pipeline { // 脚本入口
	agent any // 在任意可用的 Agent/节点上运行,没有指定标签
	triggers { GenericTrigger(...) } // Generic Webhook Trigger 插件中的参数
	environment { ... } // 声明环境变量,对后续 `steps` 和子进程(如 `sh`)可见
	stages { // 任务集合,可以将不同的步骤划分成不同的任务,方便把控每个任务的工作
	    stage('任务名称') { // 单个任务
		    when { // 条件判断语句,正常可省略
			    expression { true/false } // 只有当表达式为 true 时才执行该 stage
			}
			steps { // 执行步骤
                script { // 声明式里使用脚本化 Groovy
	                // 工作代码
                }
            }
        }
    }
    post {
	    success { // 当stages全部成功时执行
            echo 'Pipeline executed successfully!'
        }
        failure { // 当stages未能全部成功时执行
            echo 'Pipeline failed!'
        }
        // 还有 always\changed\unstable\aborted 状态
    }
}

2.1 拉取git项目(Checkout) ★

此 stage 是将远程仓库的包拉取到Jenkins项目中,这正是CI流程中的关键思想:持续集成 。将各个工程师编写的代码持续集成进来,由一个统一的控制中心来处理代码交互给用户之前的任何问题。 这一任务完成的前提是要求所有工程师能及时将已修改代码push到远程仓库。
任务代码

pipeline 复制代码
pipeline {
	environment {
        // Git 仓库地址
        GIT_REPO = 'xxx.git'
        // Git 账号
        GIT_USER = ''
        // Git 密码
        GIT_PASSWORD = ''
    }
    stages {
	    stage('Checkout') {
            steps {
                script {
                    try {
                        // 拉取 Git 仓库代码
                        git url: "${GIT_REPO}",
                            credentialsId: "${GIT_USER}:${GIT_PASSWORD}"
                            // ,branch: "${env.BRANCH}"
                        echo "成功从 ${GIT_REPO} 的 ${env.BRANCH} 分支拉取代码"
                    } catch (Exception e) {
                        echo "拉取代码失败: ${e.message}"
                        error("拉取代码失败,请检查仓库地址、凭证和分支名")
                    }
                }
            }
        }
    }
}

以上的方式只是方便快速使用,实际使用时用全局配置的方式添加凭证,避免泄露。路径: Dashboard - 系统管理 - 凭据 - 系统 - 全局凭据 (unrestricted) - New credentials

2.2 编译(Build) ★★★★★

此 stage 是在拉取git代码后,在Ubuntu环境中编译项目输出apk包。进行此任务前必须先确保项目在Linux版的Android Studio中能打包成功。 以下是打包过程中遇到的一部分问题

  • Ubuntu服务器版问题:刚开始运维给的是无桌面的Ubuntu,由于Android打包需要配置环境(gradle/ndk/依赖包等),但是很多资源包压根没有在命令行编译的环境进行优化,导致千奇百怪的各种报错。尝试了很多天后得出结论:几乎不可能在命令行环境中下载完整的Android编译所需的依赖文件。通过切换成Ubuntu桌面版解决
  • 项目模块大小写问题:在Windows中include(":module")是不区分大小的,也就是你引入的模块目录大小写混着写是能编译过的。但是在Linux中不行,对于大小写是完全敏感的,必须保证引入模块和模块目录名称大小写一致
  • 编译问题:各种从所未见的编译问题,比如aidl文件不匹配,经过一顿摸索发现是 当前的 build-tools 和 aidl 工具不支持 ARM 64 位架构
  • 环境问题:哪怕Android Studio能成功打包了,但是在任务执行时还是可能出错。例如sdk路径问题,因为在Android Studio打包跟Jenkins任务中打包的环境不一致,需要强制在local.properties文件中写入sdk.dir=/root/Android/Sdk

任务代码

pipeline 复制代码
		stage('Build') {
            steps {
                script {
                    try {
                        def settingsGradleFile = "${WORKSPACE}/local.properties"
                        // 如果环境配置不存在必须先创建这个文件并写入
                        if (!fileExists(settingsGradleFile)) {
                            writeFile file: settingsGradleFile, text: 'sdk.dir="/root/Android/Sdk"'
                        }
                        sh "sudo chmod +x gradlew"
                        // 输出 Debug 或 Release
                        def capitalizedMatrix = env.MATRIX[0].toUpperCase() + env.MATRIX.substring(1)
                        // 最后的参数代表构建的类型包
                        sh "sudo ./gradlew clean assembleForeignbeta${capitalizedMatrix}"
                        echo "Gradle 构建任务执行成功  ${capitalizedMatrix}"
                    } catch (Exception e) {
                        FAILURE_REASON = "Gradle 构建失败: ${e.message}"
                        error(FAILURE_REASON)
                    }
                }
            }
        }

2.3 蒲公英发包(Upload APK to Pgyer) ★★★

蒲公英api文档

此 stage 用于将编译好的apk文件上传到发包平台,在测试阶段可以先屏蔽上面的编译任务。我刚开始使用的时候还是1.0版本,下载jenkins里的蒲公英插件后配置api信息,直接通过curl带文件上传就行。 后面蒲公英升级了2.0版本,上传速率更快了,但是不能在pipeline中直接使用curl上传,所以写了一个Python脚本upload_to_pgyer.py用于上传文件。
任务代码

pipeline 复制代码
		stage('Upload APK to Pgyer') {
            steps {
                script {
                    try {
                        env.START_UPLOAD_TIME = new Date().format('yyyy-MM-dd HH:mm:ss')
                        env.START_UPLOAD_TIMESTAMP = System.currentTimeMillis()
                        def apkDir = "app/build/outputs/apk/foreignbeta/${env.MATRIX}"
                        def apkFiles = sh(script: """
                                firstApk=\$(find ${apkDir} -name "*.apk" | head -n 1)
                                echo \$firstApk | xargs -n 1 basename
                            """, returnStdout: true).trim()
                        if (apkFiles.isEmpty()) {
                            error("未找到 APK 文件。")
                        }
                        def apkPath = "${apkDir}/${apkFiles}"
                        if (!fileExists(apkPath)) {
                            error("文件 ${apkPath} 不存在。")
                        }
                        env.INSTALL_TYPE = (env.INSTALL_TYPE && env.INSTALL_TYPE != 'null') ? env.INSTALL_TYPE : '1'
                        echo "📤 准备上传 APK 到蒲公英..."
                        def output = sh (
                            script: """
                                python3 -u upload_to_pgyer.py \
                                    --file ${apkPath} \
                                    --install_type ${env.INSTALL_TYPE} \
                                    --password '${env.PASSWORD}' \
                                    --update_description '${env.UPDATE}'
                            """,
                            returnStdout: true
                        ).trim()
                        echo "py: ${output}"

                        def result = readJSON file: 'upload_result.json'
                        if (result.code != 0) {
                            error "上传失败: ${result.message}"
                        }
                        def data = result['data']
                        env.appVersion = data?.buildVersion
                        env.appShortcutUrl = data?.buildShortcutUrl
                        env.appUpdated = data?.buildUpdated
                        env.appQRCodeURL = data?.buildQRCodeURL
                    } catch (Exception e) {
                        FAILURE_REASON = "上传APK失败: ${e.message}"
                        throw e
                    }
                }
            }
        }

上传脚本upload_to_pgyer.py主要流程:读取参数 → 调用 getCOSToken 获取上传地址 → 使用 COS Token 上传 APK → 调用 uploadComplete 完成上传 → 轮询发布结果并生成 upload_result.json 调用时仅需修改文件内的api,并按照要求传入文件路径即可

python 复制代码
import os
import time
import json
import requests
import argparse

# 🔐 固定 Key 配置(可配置在此处)
PGY_API_KEY = ""

parser = argparse.ArgumentParser()
parser.add_argument('--file', required=True, help='APK 文件路径')
parser.add_argument('--install_type', default='1', help='安装类型')
parser.add_argument('--password', default='', help='安装密码')
parser.add_argument('--update_description', default='更新~', help='更新说明')

args = parser.parse_args()

APK_PATH = args.file
installType = args.install_type
password = args.password
updateDescription = args.update_description

# 忽略证书警告
requests.packages.urllib3.disable_warnings()

def get_cos_token(install_type, password, update_description):
    url = "https://api.pgyer.com/apiv2/app/getCOSToken"
    data = {
        "_api_key": PGY_API_KEY,
        "buildType": "apk",
        "buildUpdateDescription": update_description,
        "buildInstallType": install_type,
        "buildPassword": password,
    }
    res = requests.post(url, data=data)
    res.raise_for_status()
    result = res.json()
    return result["data"]


def upload_to_cos(cos_data):
    files = {"file": open(APK_PATH, "rb"),}
    payload = cos_data["params"]
    payload["key"] = cos_data["key"]
    payload["x-cos-meta-file-name"] = os.path.basename(APK_PATH)
    response = requests.post(cos_data["endpoint"], data=payload, files=files, verify=False)
    response.raise_for_status()
    return cos_data["key"]


def complete_upload_form(build_key, install_type, password, update_description):
    url = "https://api.pgyer.com/apiv2/app/uploadComplete"
    payload = {
        "_api_key": PGY_API_KEY,
        "buildKey": build_key,
        "buildUpdateDescription": update_description,
        "buildInstallType": install_type,
        "buildPassword": password,
    }

    print(f"updateDescription: {updateDescription}  === {update_description}")
    # ✅ 打印实际上传参数
    print("📝 正在提交以下安装信息:")
    for k, v in payload.items():
        print(f"  {k}: {v}")

    response = requests.post(url, data=payload, verify=False)
    response.raise_for_status()
    return response.json()

def wait_for_publish(build_key, max_wait_seconds=300, interval=5):
    """
    等待应用发布成功,最大等待时间为 max_wait_seconds 秒,每 interval 秒轮询一次
    """
    url = "https://api.pgyer.com/apiv2/app/buildInfo"
    params = {
        "_api_key": PGY_API_KEY,
        "buildKey": build_key
    }
    max_retries = max_wait_seconds // interval

    for i in range(max_retries):
        response = requests.get(url, params=params, verify=False)
        data = response.json()

        if data.get("code") == 0:
            print("✅ 发布成功!")
            return data
        elif data.get("code") == 1216:
            print("❌ 发布失败:", data.get("message"))
            return data
        elif data.get("code") == 1247:
            print(f"⏳ 第 {i + 1}/{max_retries} 次轮询:App 正在发布中,请稍候...")
            time.sleep(interval)
        else:
            print(f"⚠️ 其他返回:{data}")
            time.sleep(interval)

    # 超时
    return {
        "code": -1,
        "message": "发布超时(5分钟未完成)"
    }

def upload_app_to_pgyer(install_type, password, update_description, output_json="upload_result.json"):
    print("🚀 获取上传地址...")
    cos_token = get_cos_token(install_type, password, update_description)
    print(f"{cos_token}")

    print("📦 上传 APK 文件... ")
    build_key = upload_to_cos(cos_token)

    print(f"{build_key}")
    print("🔄 等待发布结果...")
    result = wait_for_publish(build_key)

    print(f"✅ 完成,结果保存至:{output_json}")
    with open(output_json, "w", encoding="utf-8") as f:
        json.dump(result, f, ensure_ascii=False, indent=4)

if __name__ == "__main__":
   print("📦 环境变量读取结果:")
   print(f"APK_PATH: {APK_PATH}")
   print(f"installType: {installType}")
   print(f"password: {password}")
   print(f"updateDescription: {updateDescription}")
   upload_app_to_pgyer(installType, password, updateDescription)

上传输出示例upload_result.json,内容含义看此文档

json 复制代码
{
    "code": 0,
    "message": "",
    "data": {
        "buildKey": "oooo",
        "buildType": "2",
        "buildIsFirst": "0",
        "buildIsLastest": "1",
        "buildFileKey": "aabb.apk",
        "buildFileName": "xxx_v1.2.5_9-11-foreignbeta-release.apk",
        "buildFileSize": "123",
        "buildName": "xxx",
        "buildVersion": "1.2.5",
        "buildVersionNo": "134",
        "buildBuildVersion": "393",
        "buildIdentifier": "com.ooo",
        "buildIcon": "ooo",
        "buildDescription": "",
        "buildUpdateDescription": "更新说明",
        "buildScreenshots": "",
        "buildShortcutUrl": "ooo",
        "buildCreated": "2025-ooo 17:47:06",
        "buildUpdated": "2025-ooo 17:47:06",
        "buildQRCodeURL": "https://pgyer.com/app/qrcodeHistory/oooo"
    }
}

2.4 钉钉机器人(Post DingTalk) ★★

钉钉机器人api说明

此 stage 根据打包参数决定是否发送通知,并拼接链接信息。在发送通知时需要将钉钉群内的人员昵称映射为手机号,以便机器人正确 @ 用户。
任务代码

pipeline 复制代码
	stage('Post DingDing') {
            when {
	             // 考虑到不是每次发包都需要钉钉提醒,可以加个参数跳过发钉钉的任务
                 expression { env.TEST?.toLowerCase() == 'true' }
            }
            steps {
                script {
                    try {
                        def nameToPhoneMap = loadNameToPhoneMap()
                        def atList = []
                        if (env.NAME) {
                            atList.add(nameToPhoneMap.get(env.NAME) ?: env.NAME)
                        }
                        if (env.AT) {
                            def names = env.AT.replaceAll("[\\[\\] ]", "").split(",")
                            for (name in names) {
                                atList.add(nameToPhoneMap.get(name) ?: name)
                            }
                        }
                        // 这个id需要看上面的文档配置好
                        env.robot = '' 

                        env.outtext = "${env.UPDATE}; 分支:${env.BRANCH}; 类型:${env.MATRIX}; 版本:${env.appVersion};https://www.pgyer.com/${env.appShortcutUrl}"
                        
                        dingtalk(
                            robot: env.robot,
                            type: 'MARKDOWN',
                            title: env.UPDATE,
                            text: [
                            "### ${env.UPDATE}",
                            '---',
                            "- 构建分支: ${env.BRANCH}",
                            "- 构建类型: ${env.MATRIX}",
                            "- 构建版本: ${env.appVersion}",
                            '---',
                            "[https://www.pgyer.com/${env.appShortcutUrl}](https://www.pgyer.com/${env.appShortcutUrl})"
                            ],
                            at: atList
                        )
                    } catch (Exception e) {
                        echo "钉钉推送失败: ${e.message}"
                    }
                }
            }
        }
        
// 从外部文件读取姓名与电话映射
def loadNameToPhoneMap() {
    def mapFile = "${WORKSPACE}/name_to_phone.json"
    if (!fileExists(mapFile)) {
        error("缺少映射文件: ${mapFile}")
    }
    return readJSON(file: mapFile)
}

艾特列表name_to_phone.json,注意:手机号是钉钉的注册手机号,这样机器人才能识别到所艾特用户

json 复制代码
{
    "姓名":"手机号",
    "测试1":"2233"
}

2.5 接收curl数据(Print Parameters) ★★★★

此 stage 将curl的传过来的参数在pipeline脚本中打印出来,这样才能进行数据互通

  1. 进入配置页面:项目 - configure - Triggers
  2. 添加Token:用于直接通过网址直接触发任务,例如:yuu
  3. 添加变量:用于接收curl传过来的参数 示例:Generic Webhook Trigger - Name of variable:变量名(NAME); Expression:代码中的读取方式($.name); JSONPath; Default value:默认参数可不填
  4. 选择Pipeline script,编写脚本:

任务代码

pipeline 复制代码
pipeline {
    agent any
    triggers {
        GenericTrigger(
            token: 'yuu',
            genericVariables: [
                [key: 'NAME', value: '$.name']
            ],
            printContributedVariables: true,
            printPostContent: true
        )
    }
    stages {
        stage('Print Parameters') {
            steps {
                script {
	                echo "name: ${env.name}"
                }
            }
        }
    }
}

以上步骤完成后,在Windows 的cmd中执行这个命令:curl 127.0.0.1/generic-webhook-trigger/invoke?token=yuu -d name:姓名

3 bat脚本 ★★

通过上面的代码实现了在本地拉取项目代码并打包上传的操作,但是存在一个问题,触发脚本需要打开Jenkins后台,如果将Jenkins后台开放给全部人,将会导致严重的权限问题 。虽然Jenkins自带了用户权限管理,但是依然无法限制有权限用户对Jenkins任务的更改,为了尽可能的避免这个问题,决定使用Generic Webhook Trigger触发脚本,而不是通过Jenkins后台。当然,这个问题也可以通过使用Pipeline script from SCM来规避,这是在脚本能稳定运行之后需要做的。 这种前后端分离的做法也使得执行任务更为容易,只需要保证更健壮的执行稳定性

bat脚本代码设计思路:因为这是内部开放的脚本,需要区分构建用户,以及带上一定的保密措施。在构建的时候带上需要打包的类型(debug/release),打包分支等,以及告诉测试更新内容 ,和需要艾特的人员。这些全部参数都在bat脚本中填写,这样就不需要再额外修改pipeline脚本了。为了更佳的适配性,这个脚本本身需要执行的东西也是越少越好,对外仅执行一个curl命令,发送post信息。
任务代码

bat 复制代码
@echo off
chcp 65001 >nul
setlocal EnableDelayedExpansion
::  设计参数: name:姓名; update:更新说明; at:需要艾特的人员列表; branch:分支...
set "NAME="
if "%NAME%"=="" (
    :input_name
    set /p "NAME=请输入构建人姓名: "
    if "!NAME!"=="" (
        echo 姓名不能为空,请重新输入。
        goto input_name
    )
)

:: 打包环境 debug / release
set "MATRIX=debug"

:: 分支(master  main ,不允许为空)
set "BRANCH=main"  

:: true 在钉钉群发布, false 不发布
set "TEST=true"

:group_found
:: 初始化其他变量
set "PASSWORD="
set "INSTALL_TYPE=1"
set "update_info="
set "at_people="
set "aab_type="
:: 等待时间
set "wait_time=30"
:: 查询 Git 提交数量
set "query_git_commit=10"

set "shortcut=trtc"

:update_input
set /p "update_info=请输入更新说明: "
if "!update_info!"=="" (
    echo 更新说明不能为空,请重新输入
    goto update_input
)

set "at_people="
:: echo.
echo 请输入艾特人员(可为空):
echo 姓名,姓名
set /p "at_people="

:: 显示信息并确认
cls
echo.
echo ---------- 请确认以下信息 ----------
echo 构建用户: %NAME%
if not "!update_info!"=="" echo 更新说明: !update_info!
if not "!at_people!"=="" echo 艾特人员: !at_people!
echo 打包分支: %BRANCH%
echo 打包环境: %MATRIX%
if not "!aab_type!"=="" echo 打包类型 aab: !aab_type!
if not "!PASSWORD!"=="" echo 蒲公英密码: !PASSWORD!
echo -------------------------------------
echo.

echo 确认信息无误后,按任意键发送请求
pause > nul

echo 请求发送中...
echo.
:: 创建临时JSON文件
set temp_json="{\"branch\":\"%BRANCH%\", \"name\":\"%NAME%\",\"update\":\"%update_info%\",\"at\":\"%at_people%\",\"test\":\"%TEST%\",\"matrix\":\"%MATRIX%\",\"password\":\"%PASSWORD%\",\"aab\":\"%aab_type%\", \"query\":%query_git_commit%,\"shortcut\":\"%shortcut%\"}"

:: Jenkins 相关配置
set "JENKINS_URL=127.0.0.1/generic-webhook-trigger/invoke?token=yuu"

:: echo 发送数据 %temp_json%
:: 执行curl请求并获取状态码
set temp_response=%TEMP%\temp_response.txt
curl -X POST -H "Content-Type: application/json" -o "%temp_response%" -w "%%{http_code}" "%JENKINS_URL%" -d %temp_json% > %TEMP%\http_code.txt
set /p http_code=%TEMP%\http_code.txt

if not "%http_code%"=="200" (
    type "%temp_response%"
    echo.
    echo 请求失败,状态码: %http_code%,请截图联系运维
    set "wait_time=60"

    :: del %TEMP%\http_code.txt
    goto :exit_script
) else (
    :: 解析返回的JSON数据
    set "jobs_null=false"
    for /f "usebackq delims=" %%a in (`powershell -Command "(Get-Content '%temp_response%' -Raw) -match '\"jobs\":null'"`) do (
        if "%%a"=="True" (
            set "jobs_null=true"
        )
    )

    if "%jobs_null%"=="true" (
        type "%temp_response%"
        echo.
        echo 任务为空,请截图联系运维
        :: del %TEMP%\http_code.txt
        goto :exit_script
    )

    echo.
    echo 发送任务构建命令成功,稍后在群内查看
    goto :exit_script
)

:exit_script
echo.
echo 按任意键退出...
timeout /t %wait_time%
exit /b

4 自动发包任务 ★★

此 stage 是用来实现自动打包的,因为步骤跟手动打包差不多,所以就不另起一个任务来执行。

关键的点在于,git仓库调用webhook的时候加上一个参数代表自动打包;以及需要在源码仓库中保存打包过程所需的数据,如:更新说明、打包类型、艾特人员等等。还有一个难点在于区分分支,因为push任意代码后都会调用这里,但是push到哪个分支无法通过webhook传进来,所以这里需要去遍历全部分支,找出最近提交代码的分支,以此分支来进行打包流程。

由于这里是url调用不带参,那对于非自动发包任务的curl请求 中应该添加参数来表示,例如:auto:false

具体思路:

  1. 在[[#2.1 拉取git项目 ★|拉取git项目]]任务执行之前,创建一个任务用来校验是否为自动发包,使用when语句即可
  2. 由于Webhooks是不带参数的,所以无法带入分支参数,这时候就需要我们定义一个默认分支,但是如果以默认分支为打包分支,那么后续每次需要切换分支时都需要在任务中重新修改,这很明显是不够优雅
  3. 拉取默认分支,遍历所有分支,找出最新提交的分支,拉取此分支内容。git log --graph --all --oneline
  4. 读取仓库中的jenkins.json文件,获取到后面需要的参数,例如:更新说明、打包类型、艾特人员等
  5. 继续[[#2.2 编译 ★★★★★|编译]]后续工作

Webhooks可以用git Hooks替代

5 *高阶任务(可选)

打印git log信息(Query Git Commits) ★

此 stage 用户查看此次拉取的git仓库中最近的几次git log,如果代码出问题的话方便快速定位,也可以将最近一次log在钉钉机器人中发送出去。
yuu 2025-03-11T17:39:53+08:00 修复编译错误
任务代码

pipeline 复制代码
		stage('Query Git Commits') {
            steps {
                script {
                    def command = "git log -n ${env.query} --pretty=\"%cn %cI %s\""
                    def recentCommits = sh(script: command, returnStdout: true).trim()
                    env.recentCommits = recentCommits
                    def firstGitLine = recentCommits.tokenize('\n')[0]
                    env.firstGitLine = firstGitLine
                    echo "对外输出的: ${firstGitLine}"
                }
            }
        }

记录执行信息 ★★

此 stage 用来保存Jenkins任务的执行结果,方便分析原因和统计

思路是在always方法中,记录其它任务的执行情况,比如:执行总时长、编译时长、上传时长、开始时间戳、git log、钉钉推送信息等等。总之就是在其它任务的执行中保存你想要的数据,然后保存下来。为了能对时间进行一个平均计算,我是引入了Excel表格式csv,每次执行完成往后添加一条记录
任务代码

pipeline 复制代码
pipeline{
	post {
		always {
			script {
                try {
                    writeCsvLog(true, "")
                } catch (e) {
                    echo "CSV日志记录失败: ${e.message}"
                }
            }
        }
	}
}
// 统一 CSV 写入方法
def writeCsvLog(boolean success, String failureReason = "") {
    def endTimestamp = System.currentTimeMillis()
    def startTimestamp = Long.parseLong(env.START_BUILD_TIMESTAMP ?: "0")
    def uploadTimestamp = Long.parseLong(env.START_UPLOAD_TIMESTAMP ?: startTimestamp.toString())

    def totalDuration = formatDuration(endTimestamp - startTimestamp)
    def buildDuration = formatDuration(uploadTimestamp - startTimestamp)
    def uploadDuration = formatDuration(endTimestamp - uploadTimestamp)

    def transmissionData = [
        "branch"  : env.BRANCH ?: "",
        "name"    : env.NAME ?: "",
        "update"  : env.UPDATE ?: "",
        "at"      : env.AT ?: "",
        "test"    : env.TEST ?: "",
        "matrix"  : env.MATRIX ?: "",
        "password": env.PASSWORD ? "***" : "",
        "query"   : env.query ?: 5,
        "shortcut": env.SHORTCUT ?: ""
    ]
    def transmissionJson = groovy.json.JsonOutput.toJson(transmissionData)

    def pushInfo = ""
    if (success && env.TEST?.toLowerCase() == 'true' && env.outtext) {
        pushInfo = env.outtext.toString().replaceAll('\\[|\\]', '').replaceAll(', ', ' ')
    }

    def csvRecord = [
        totalDuration,
        buildDuration,
        uploadDuration,
        env.START_BUILD_TIME ?: "",
        success.toString(),
        (env.firstGitLine ?: "").replaceAll(',', ';'),
        pushInfo.replaceAll(',', ';'),
        failureReason.replaceAll(',', ';'),
        transmissionJson.replaceAll(',', ';')
    ].collect { "\"${it}\"" }.join(',')
    
    def csvRecordoutput = sh (
        script: """
            python3 -u save.py \
                --csv '${csvRecord}'
        """,
        returnStdout: true
    ).trim()
    echo "csvRecord: ${csvRecord}"
    echo "py output: ${csvRecordoutput}"
    echo ' out ${env.CSV_FILE}'
}

// 时间格式化
def formatDuration(long milliseconds) {
    long seconds = milliseconds / 1000
    long minutes = seconds / 60
    seconds = seconds % 60
    return String.format("%02d:%02d", minutes, seconds)
}

在写入csv信息的save.py脚本中,还可以进行邮件发送,这样方便在微信中收到执行信息

包体积管理(APK Size Check) ★★★

APK Checker

此 stage 用来记录apk的包问题,在[[#2.2 编译 ★★★★★|编译]]完成后执行,职责是包体积判断。每次执行完都会将包大小记录到apkTool.json文件中,key:分支_类型_size,例如:main_debug_size

包体积超过1MB时直接使用APK Checker进行包内容检查

包体积超过5MB时停止任务,不允许继续发包,并根据情况决定是否发邮件通知发包人和leader
任务代码

apkTool.json保存上次的文件大小信息

json 复制代码
{
	"分支_debug_size":2333,
	"分支_release_size":2233
}
pipeline 复制代码
stage('APK Size Check') {
  steps {
    script {
	    // 1. 确定 apk 路径目录与 apk 文件
	    def apkDir = "app/build/outputs/apk/foreignbeta/${env.MATRIX}"
	     def apkFiles = sh(script: """
            firstApk=\$(find ${apkDir} -name "*.apk" | head -n 1)
            echo \$firstApk | xargs -n 1 basename
            """, returnStdout: true).trim()
        if (apkFiles.isEmpty()) {
            error("未找到 APK 文件。")
        }
        def apkPath = "${apkDir}/${apkFiles}"
        if (!fileExists(apkPath)) {
            error("文件 ${apkPath} 不存在。")
        }
	  // apkFile.length 是字节数
      long currentSizeBytes = apkFile.length
      echo "Found APK: ${apkPath}, size = ${currentSizeBytes} bytes"

      // 2. 读取 apkTool.json 中上次记录的包大小
      def jsonPath = 'apkTool.json'
      def jsonExists = fileExists(jsonPath)
      def jsonMap = [:]
      if (jsonExists) {
        jsonMap = readJSON file: jsonPath
      } else {
        echo "apkTool.json not found; will create a new one"
      }
      // 构造 key  "main_debug_size"
      def branchName = env.BRANCH ?: env.GIT_BRANCH ?: 'unknown_branch'
      // 保证 key 与 env.MATRIX 一致
      def key = "${branchName}_${env.MATRIX}_size"
      echo "Using key: ${key}"
      long previousSizeBytes = 0
      if (jsonMap.containsKey(key)) {
        previousSizeBytes = (jsonMap[key] as Long)
        echo "Previous size for ${key}: ${previousSizeBytes} bytes"
      } else {
        echo "No previous size for ${key}; assuming 0"
      }

      // 3. 比较当前 vs 以前
      long diffBytes = currentSizeBytes - previousSizeBytes
      long oneMB = 1 * 1024 * 1024
      long fiveMB = 5 * 1024 * 1024

      if (diffBytes > fiveMB) {
        error "APK size increased by more than 5 MB: +${(diffBytes / (1024*1024)).round(2)} MB"
      } else if (diffBytes > oneMB) {
        echo "APK size increased by more than 1 MB (+${(diffBytes / (1024*1024)).round(2)} MB), running ApkChecker"
        // 4. 运行 ApkChecker
        sh "java -jar ApkChecker.jar --apk ${apkPath} --config apkChecker.json"
      } else {
        echo "APK size increase within acceptable range (+${(diffBytes / (1024*1024)).round(2)} MB)"
      }

      // 5. 写回最新包大小到 apkTool.json
      jsonMap[key] = currentSizeBytes
      // 可以保留其他 key 不变
      writeJSON file: jsonPath, json: jsonMap, pretty: 2
      echo "Updated ${jsonPath} with ${key}: ${currentSizeBytes} bytes"
    }
  }
}

自动化测试 ★★★

根据项目实际需要,接入不同的测试任务,常见的有单元测试、Lint代码检查,结合上面的包体积管理输出一份自动化测试报告。

参考:
delme-bat.github.io/2018/06/26/...
tech.meituan.com/2015/12/24/...

6 总结

pipeline是非常容易理解的胶水语言,结合Python、JSON外部文件可以完成各种事情,这个过程中只需要聚焦在每个任务中,经过简单尝试就能在项目中完成想要的功能

在应用上架过程,Jenkins完全可以做到由产品团队直接调用任务任务完成,将打包/分发/上传流程统一,这需要严格的测试回归、分支管理、权限分配等流程

实践

Linux环境 项目:github.com/yuuouu/Deep...

1 创建流水线

配置GitHub项目,填入项目URL

2 配置流水线

  • Pipeline script from SCM
  • Repository URL:github.com/yuuouu/Deep...
  • 指定分支(为空时代表any):*/master
  • 脚本路径:jenkins/pipeline

3 构建任务

稍后便能看到执行成功

总结

🎉彻底掌握Jenkins自动发包全流程,具体想做什么由你定义,fork项目后修改jenkins/pipeline中的stage

后语

我小时候第一次接触电脑,是在黑网吧里,玩一些单机游戏。印象很深的是玩《恐龙快打》,我记得有个选项叫做配置命令,应该是类似《侠盗猎车手》中输入panzer 掉坦克一样的作弊码,我当时并不会用这个功能,只是在脑海里想着要用这个功能来实现无限生命无限武器这样的操作。这个想法在我脑海中持续了很长时间,连实现之后怎么玩都想了很多遍,但是由于我并没有稳定接触电脑的机会,加上不能够完全理解游戏内容,这样的想法一直没有实现。

当我在现在的工作中需要实现类似操作的时候:通过编写脚本实现全自动发包流程,我发现我已经有能力做到这点。

相关推荐
libraG1 天前
Jenkins打包问题
前端·npm·jenkins
全栈工程师修炼指南3 天前
告别手动构建!Jenkins 与 Gitlab 完美协作,根据参数自动化触发CI/CD流水线实践
运维·ci/cd·自动化·gitlab·jenkins
一勺菠萝丶3 天前
Jenkins 构建 Node 项目报错解析与解决——pnpm lockfile 问题实战
elasticsearch·servlet·jenkins
xixingzhe24 天前
jenkins脚本触发部署
运维·jenkins
躲在云朵里`4 天前
ElasticSearch复习指南:从零搭建一个商品搜索案例
运维·jenkins
Cloud Traveler5 天前
第3天-Jenkins详解-3
运维·分布式·jenkins
u0104058365 天前
电商导购平台的搜索引擎优化:基于Elasticsearch的商品精准推荐系统
elasticsearch·搜索引擎·jenkins
*老工具人了*5 天前
Terraform整合到GitLab+Jenkins工具链
gitlab·jenkins·terraform