uni-app 微信小程序多环境 CI/CD 实战:一套 Jenkinsfile 管理 dev、beta、prod
大家好,我是 JavaDog 程序狗。今天不聊花活,来收拾一个很容易"养着养着就失控"的东西:uni-app 微信小程序多环境 CI/CD。
同一个小程序,dev 连测试接口,beta 给产品验收,prod 再上传正式版本。最省脑子的办法是什么?复制三份 Jenkins 任务,一套环境一份,主打一个简单粗暴。
刚开始确实很爽。等三份任务各自改过几轮再回头看:dev 有自己的想法,beta 偷偷换过参数,prod 则像祖传代码一样,路过的人都要先拜一拜才敢点构建。
狗哥这次不造"万能 Jenkinsfile",也不画一张包治百病的大饼。咱们只在 uni-app Vue2 CLI 项目 + Linux Jenkins Agent + miniprogram-ci 这个明确范围内,把环境选择、构建和上传三件事捋顺。一套流水线管三套环境,出了问题也知道该揪谁的耳朵。
最终链路如下:

先说清楚适用边界
狗哥先把丑话说在前面:CI/CD 最怕脱离环境谈通用。本文示例基于以下条件:
| 项目 | 本文约定 |
|---|---|
| 项目类型 | uni-app Vue2 CLI 项目 |
| 构建节点 | Linux Jenkins Agent |
| Node.js | 由项目锁定版本;示例使用 14.19.1 |
| 环境 | dev、beta、prod |
| 上传工具 | miniprogram-ci |
| 产物目录 | 默认 dist/build/mp-weixin,以实际项目为准 |
Vue3、HBuilderX 项目和 Windows Agent 也能沿用这个思路,但构建命令、产物目录和 shell 写法不能直接照搬。狗哥先把护栏焊死:边界对不上就别硬抄,不然控制台一片绿色,上传的却不是你以为的那个包,这种绿看着多少有点晦气。
版本提醒 :Node.js 14 已停止维护。这里保留 14.19.1,是因为它属于本文存量项目已经验证过的构建环境,不是给新项目的版本推荐。新项目应结合 uni-app、Vue CLI、webpack 与
miniprogram-ci的兼容范围选择仍受维护的 Node.js 版本,并先在非生产环境完成回归。
一、配置只保留一个来源
项目根目录维护三份环境文件:
text
.env.dev
.env.beta
.env.prod
每份文件至少包含当前环境的小程序 AppID 和接口地址:
dotenv
# .env.dev
VUE_APP_API_BASE=https://api-dev.example.com
VUE_APP_WECHAT_APPID=wx_dev_appid
dotenv
# .env.beta
VUE_APP_API_BASE=https://api-beta.example.com
VUE_APP_WECHAT_APPID=wx_prod_appid
dotenv
# .env.prod
VUE_APP_API_BASE=https://api.example.com
VUE_APP_WECHAT_APPID=wx_prod_appid
狗哥翻译一下:AppID 只认一个账本。别在 Jenkinsfile、上传脚本和 .env 里各藏一份,今天改这里,明天忘那里,最后全团队一起玩"大家来找茬"。环境文件负责记账,其他脚本老老实实来读就行。
.env不是秘密仓库。AppID 可以进入源码,但上传私钥、密码和令牌必须留在 Jenkins Credentials 中。
二、构建前写入 AppID,结束后恢复
部分旧版 uni-app 项目需要在构建前把 AppID 写入 manifest.json。脚本可以使用项目已经安装的 dotenv 解析环境文件,避免自己实现一套残缺的 .env 语法。
javascript
// scripts/build-env.js
const fs = require('fs')
const path = require('path')
const dotenv = require('dotenv')
const allowedEnvs = new Set(['dev', 'beta', 'prod'])
const deployEnv = process.argv[2]
if (!allowedEnvs.has(deployEnv)) {
throw new Error(`非法环境:${deployEnv}`)
}
const rootDir = path.resolve(__dirname, '..')
const envPath = path.join(rootDir, `.env.${deployEnv}`)
const manifestPath = path.join(rootDir, 'manifest.json')
const backupPath = path.join(rootDir, 'manifest.json.ci-backup')
const parsed = dotenv.parse(fs.readFileSync(envPath))
const appid = parsed.VUE_APP_WECHAT_APPID
if (!/^wx[a-zA-Z0-9]+$/.test(appid || '')) {
throw new Error(`${envPath} 中缺少合法的 VUE_APP_WECHAT_APPID`)
}
const source = fs.readFileSync(manifestPath, 'utf8')
const appidPattern = /("mp-weixin"\s*:\s*\{[^}]*?"appid"\s*:\s*")([^"]*)(")/s
if (!appidPattern.test(source)) {
throw new Error('manifest.json 中未找到 mp-weixin.appid')
}
if (!fs.existsSync(backupPath)) {
fs.copyFileSync(manifestPath, backupPath)
}
fs.writeFileSync(manifestPath, source.replace(appidPattern, `$1${appid}$3`), 'utf8')
console.log(`[build-env] env=${deployEnv} appid=${appid}`)
配套恢复脚本只做一件事:有备份就还原。
javascript
// scripts/build-restore.js
const fs = require('fs')
const path = require('path')
const rootDir = path.resolve(__dirname, '..')
const manifestPath = path.join(rootDir, 'manifest.json')
const backupPath = path.join(rootDir, 'manifest.json.ci-backup')
if (fs.existsSync(backupPath)) {
fs.copyFileSync(backupPath, manifestPath)
fs.unlinkSync(backupPath)
console.log('[build-restore] manifest.json 已恢复')
}
为什么一定要恢复?因为 Jenkins 工作区可能被复用。上一次构建遗留的生产 AppID,完全可能混进下一次开发构建。构建机没有失忆症,你不收拾,它就真敢把上次的东西留给下次。
狗哥翻译一下:借来的东西,用完得放回去;哪怕构建半路趴窝,也得让清理动作出来收尸。因此恢复必须放进 Pipeline 的 post { always { ... } },成功失败都要执行,不能看心情。
配置怎么从
.env一路流到manifest.json再被还原,画成图更直观:

三、上传脚本不要猜产物目录
miniprogram-ci 要求 projectPath 指向 包含 project.config.json 的小程序目录 。对于本文的 CLI 项目,默认使用 dist/build/mp-weixin,并允许通过环境变量覆盖。
这里本狗得敲两下黑板:仓库根目录、dist 目录和"小程序产物目录"不是一回事。路径一旦猜错,上传脚本写得再漂亮,也只是对着空气打了一套组合拳。
javascript
// scripts/upload.js
const fs = require('fs')
const path = require('path')
const ci = require('miniprogram-ci')
function readArg(name, fallback) {
const index = process.argv.indexOf(`--${name}`)
return index >= 0 && process.argv[index + 1] ? process.argv[index + 1] : fallback
}
const deployEnv = readArg('env', 'dev')
const version = readArg('version', '1.0.0')
const desc = readArg('desc', `CI upload ${deployEnv}`)
const robot = Number(readArg('robot', '1'))
const appid = process.env.WECHAT_APPID
const privateKeyPath = process.env.MP_UPLOAD_KEY
const projectPath = path.resolve(
process.env.MP_PROJECT_PATH || 'dist/build/mp-weixin'
)
if (!Number.isInteger(robot) || robot < 1 || robot > 30) {
throw new Error('robot 必须是 1~30 的整数')
}
if (!appid || !privateKeyPath) {
throw new Error('缺少 WECHAT_APPID 或 MP_UPLOAD_KEY')
}
if (!fs.existsSync(path.join(projectPath, 'project.config.json'))) {
throw new Error(`构建产物无效:${projectPath} 下没有 project.config.json`)
}
const project = new ci.Project({
appid,
type: 'miniProgram',
projectPath,
privateKeyPath,
ignores: ['node_modules/**/*']
})
ci.upload({
project,
version,
desc,
robot,
setting: { useProjectConfig: true },
onProgressUpdate: console.log
}).then(() => {
console.log(`[upload] 成功:env=${deployEnv} robot=${robot}`)
}).catch(error => {
console.error('[upload] 失败:', error)
process.exitCode = 1
})
这里直接使用 project.config.json 中的编译设置。狗哥还是那个原则:能认一个账本,就别再手抄第二份 setting。
四、package.json 固化构建入口
流水线负责喊人干活,package.json 负责告诉大家活该怎么干。别把整段构建命令塞进 Jenkinsfile 的犄角旮旯里,否则哪天 CI 挂了,本地想复现还得先请一位 Jenkins 考古专家。
json
{
"scripts": {
"build:dev": "node scripts/build-env.js dev && cross-env NODE_ENV=production UNI_PLATFORM=mp-weixin vue-cli-service uni-build --mode dev",
"build:beta": "node scripts/build-env.js beta && cross-env NODE_ENV=production UNI_PLATFORM=mp-weixin vue-cli-service uni-build --mode beta",
"build:prod": "node scripts/build-env.js prod && cross-env NODE_ENV=production UNI_PLATFORM=mp-weixin vue-cli-service uni-build --mode prod",
"restore": "node scripts/build-restore.js"
}
}
--mode dev 会让 Vue CLI 加载对应模式的环境文件;实际变量合并规则仍应以项目所用 Vue CLI 和 uni-app 版本为准。
Node 版本也不应该在 Jenkinsfile 中"猜"。旧项目如果已经验证使用 14.19.1,就通过 .nvmrc 或 Jenkins Tool 配置锁定它;新项目按依赖兼容矩阵选择受支持版本。--openssl-legacy-provider 只能作为旧 webpack 项目的过渡兼容项,不是 Node 17+ 的通用开关。
五、Jenkins 凭据约定
在 Jenkins 中为每个小程序账号创建一条 Secret file 凭据,内容为微信公众平台下载的上传私钥。

本文采用一个简单约定:凭据 ID 等于 AppID。
| 环境 | AppID | Jenkins 凭据 ID | robot |
|---|---|---|---|
| dev | 测试小程序 AppID | 同 AppID | 1 |
| beta | 正式小程序 AppID | 同 AppID | 2 |
| prod | 正式小程序 AppID | 同 AppID | 3 |
这样 beta 和 prod 可以共用正式账号及私钥,再通过机器人编号区分上传记录。miniprogram-ci 当前支持的机器人编号范围是 1~30。
狗哥翻译一下:AppID 是门牌号,别人看见问题不大;上传私钥才是开门钥匙。门牌号可以写在配置里,钥匙必须锁进 Jenkins Credentials。谁把钥匙顺手提交到 Git,谁就喜提一次全员密钥轮换。
同时在微信公众平台配置 Jenkins Agent 的出口 IP 白名单。若 Agent 经 NAT 或代理访问外网,填写的是实际出口 IP,不一定是机器的内网地址。
六、可直接落地的 Jenkinsfile
下面上正菜。这个 Pipeline 假定 Jenkins Job 已经从 SCM 加载 Jenkinsfile,所以不再重复拼 Git 地址和凭据。若你的 Job 不是 Pipeline from SCM,就把仓库参数老老实实补全。别凭空扔进去几个变量,然后期待 Jenkins 半夜顿悟。
groovy
pipeline {
agent { label 'linux-node14' }
parameters {
choice(name: 'DEPLOY_ENV', choices: ['dev', 'beta', 'prod'], description: '部署环境')
string(name: 'MP_VERSION', defaultValue: '1.0.0', description: '小程序版本号')
string(name: 'MP_DESC', defaultValue: '', description: '上传说明,留空自动生成')
booleanParam(name: 'CLEAN_WORKSPACE', defaultValue: false, description: '检出前清理工作区')
}
environment {
NPM_CONFIG_REGISTRY = 'https://registry.npmmirror.com'
NODE_OPTIONS = '--max-old-space-size=4096'
MP_PROJECT_PATH = 'dist/build/mp-weixin'
}
options {
disableConcurrentBuilds()
timestamps()
timeout(time: 30, unit: 'MINUTES')
buildDiscarder(logRotator(numToKeepStr: '10'))
skipDefaultCheckout(true)
}
stages {
stage('Checkout') {
steps {
script {
if (params.CLEAN_WORKSPACE) {
deleteDir()
}
}
checkout scm
}
}
stage('Resolve Environment') {
steps {
script {
def envFile = ".env.${params.DEPLOY_ENV}"
if (!fileExists(envFile)) {
error "找不到环境文件:${envFile}"
}
def appid = sh(
script: "sed -n 's/^VUE_APP_WECHAT_APPID=//p' '${envFile}' | head -n 1 | tr -d '\\r\\n'",
returnStdout: true
).trim()
if (!(appid ==~ /wx[A-Za-z0-9]+/)) {
error "${envFile} 中的 AppID 不合法"
}
env.WECHAT_APPID = appid
echo "环境=${params.DEPLOY_ENV},AppID=${appid}"
}
}
}
stage('Install') {
steps {
sh 'node --version && npm --version'
sh 'npm ci --no-audit --no-fund'
}
}
stage('Build') {
steps {
sh "npm run build:${params.DEPLOY_ENV}"
sh "test -f '${env.MP_PROJECT_PATH}/project.config.json'"
}
}
stage('Upload') {
steps {
script {
def robots = [dev: 1, beta: 2, prod: 3]
def description = params.MP_DESC?.trim()
? params.MP_DESC.trim()
: "Jenkins #${env.BUILD_NUMBER} ${env.GIT_COMMIT?.take(8) ?: ''}".trim()
withCredentials([
file(credentialsId: env.WECHAT_APPID, variable: 'MP_UPLOAD_KEY')
]) {
withEnv([
"UPLOAD_ENV=${params.DEPLOY_ENV}",
"UPLOAD_VERSION=${params.MP_VERSION}",
"UPLOAD_DESC=${description}",
"UPLOAD_ROBOT=${robots[params.DEPLOY_ENV]}"
]) {
sh '''
node scripts/upload.js \\
--env "$UPLOAD_ENV" \\
--version "$UPLOAD_VERSION" \\
--robot "$UPLOAD_ROBOT" \\
--desc "$UPLOAD_DESC"
'''
}
}
}
}
}
}
post {
always {
sh 'npm run restore --if-present'
}
success {
echo "上传成功:${params.DEPLOY_ENV} / ${params.MP_VERSION}"
}
failure {
echo '构建或上传失败,请查看 Console Output'
}
}
}
这版有几个刻意的取舍:
- 使用 Jenkins 原生
deleteDir(),不在 shell 中删除$WORKSPACE。 - 使用
checkout scm,避免引用没有声明的 Git 参数。 - 使用
npm ci保证依赖锁文件可复现;如果项目没有有效的package-lock.json,应先修复锁文件,而不是在 CI 中长期使用npm install兜底。 - 上传描述通过环境变量进入 shell,减少 Groovy 插值和 shell 引号互相打架。
- 不打印私钥内容,Secret file 只在
withCredentials作用域内使用。
七、验证不能只看"绿色"
看到一片绿色先把香槟放下。流水线跑通,只能证明命令没有报错,不代表 AppID、接口地址和上传账号真的对上了。CI 最会制造的一种错觉,就是"它绿了,所以它一定没问题"。狗哥建议按下面三组测试逐项验,专治这种盲目乐观。

构建测试
- dev、beta、prod 分别执行一次。
- 日志中的 AppID 与环境文件一致。
- 构建产物中存在
project.config.json、app.json。 - 编译后的接口地址与目标环境一致。
- 构建结束后
manifest.json已恢复。
上传测试
- dev 上传到测试小程序账号,并显示机器人 1。
- beta 上传到正式账号,并显示机器人 2。
- prod 上传到正式账号,并显示机器人 3。
- 上传版本号和描述符合微信后台要求。
失败测试
- 删除一个
.env文件,流水线应在环境解析阶段停止。 - 写入非法 AppID,流水线应在绑定凭据前停止。
- 修改产物目录,构建阶段应明确提示缺少
project.config.json。 - 使用不存在的凭据 ID,上传阶段应失败且不泄露私钥。
- 将 robot 改为 31,上传脚本应在本地校验阶段停止。
八、几个常见问题
下面这些问题,基本都是落地时最容易踩的坑。狗哥提前把坑边插上牌子,省得大家钻进 Console Output,一铲子一铲子考古。
beta 和 prod 能否共用一个小程序账号?
可以。两份环境文件填写同一个 AppID,Jenkins 只配置一份上传私钥,再用不同 robot 编号区分开发版本。需要注意:robot 只是上传记录的区分方式,不等于独立的运行环境或权限隔离。
能否根据分支自动推断环境?
可以,但不要把分支和环境强绑定得过早。feature 分支也可能需要部署到 beta。更稳妥的方式是保留显式环境参数,再根据团队规则给它设置默认值。
Windows Jenkins Agent 怎么办?
整体结构不变,但 sh、sed、test 要替换为 powershell 或 bat。本文 Jenkinsfile 明确面向 Linux Agent,不建议用一堆条件分支强行兼容两种系统;维护两份很薄的节点适配层通常更清楚。
为什么不直接在 Jenkinsfile 里维护 AppID 映射?
当然可以,但这会让 AppID 同时存在于环境文件和流水线。本文选择"环境文件是唯一来源",换来的约束是:环境文件必须接受代码审查,流水线也必须校验读取结果。
总结
折腾到这里,扳手可以先放下了。多环境 CI/CD 真正难的,从来不是把 Jenkinsfile 写得像火车时刻表一样长,而是划清四条边界:
- 环境文件保存非敏感业务配置;
- Jenkins Credentials 保存上传私钥;
- 构建脚本只负责生成确定的产物;
- 上传脚本只接收已经校验过的 AppID、产物路径和机器人编号。
把这四件事拆开,一套流水线管理 dev、beta、prod,才不会演变成"一处改动、三处猜测、五个人背锅"。
狗哥最后再唠叨一句:第一次接入,先拿 dev 把成功和失败用例都遛一遍,确认产物、账号和 robot 能对上号,再去碰那个看起来就很贵的生产按钮。
能一路跑绿不算最厉害;配置错了能及时翻脸、凭据不对能当场刹车、构建失败还能把现场收拾干净,这才是一条有职业素养的流水线。
参考资料
本文核对的是配置边界与代码逻辑,没有连接真实 Jenkins、微信后台和上传私钥执行生产上传。miniprogram-ci 的参数能力可能随版本变化,落地时请以项目锁定版本的文档为准。