【指南】uni-app微信小程序多环境CI-CD完全指南

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 这个明确范围内,把环境选择、构建和上传三件事捋顺。一套流水线管三套环境,出了问题也知道该揪谁的耳朵。

最终链路如下:

flowchart LR A[选择 DEPLOY_ENV] --> B[检出代码] B --> C[读取 .env 对应 AppID] C --> D[安装依赖] D --> E[执行对应环境构建] E --> F[校验小程序产物] F --> G[注入上传私钥] G --> H[miniprogram-ci 上传] H --> I[恢复 manifest.json]

先说清楚适用边界

狗哥先把丑话说在前面:CI/CD 最怕脱离环境谈通用。本文示例基于以下条件:

项目 本文约定
项目类型 uni-app Vue2 CLI 项目
构建节点 Linux Jenkins Agent
Node.js 由项目锁定版本;示例使用 14.19.1
环境 devbetaprod
上传工具 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.jsonapp.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 怎么办?

整体结构不变,但 shsedtest 要替换为 powershellbat。本文 Jenkinsfile 明确面向 Linux Agent,不建议用一堆条件分支强行兼容两种系统;维护两份很薄的节点适配层通常更清楚。

为什么不直接在 Jenkinsfile 里维护 AppID 映射?

当然可以,但这会让 AppID 同时存在于环境文件和流水线。本文选择"环境文件是唯一来源",换来的约束是:环境文件必须接受代码审查,流水线也必须校验读取结果。

总结

折腾到这里,扳手可以先放下了。多环境 CI/CD 真正难的,从来不是把 Jenkinsfile 写得像火车时刻表一样长,而是划清四条边界:

  1. 环境文件保存非敏感业务配置;
  2. Jenkins Credentials 保存上传私钥;
  3. 构建脚本只负责生成确定的产物;
  4. 上传脚本只接收已经校验过的 AppID、产物路径和机器人编号。

把这四件事拆开,一套流水线管理 dev、beta、prod,才不会演变成"一处改动、三处猜测、五个人背锅"。

狗哥最后再唠叨一句:第一次接入,先拿 dev 把成功和失败用例都遛一遍,确认产物、账号和 robot 能对上号,再去碰那个看起来就很贵的生产按钮。

能一路跑绿不算最厉害;配置错了能及时翻脸、凭据不对能当场刹车、构建失败还能把现场收拾干净,这才是一条有职业素养的流水线。

参考资料

本文核对的是配置边界与代码逻辑,没有连接真实 Jenkins、微信后台和上传私钥执行生产上传。miniprogram-ci 的参数能力可能随版本变化,落地时请以项目锁定版本的文档为准。

相关推荐
PedroQue991 小时前
uni-router v2.1.0 升级:导航守卫全面支持返回值模式
前端·uni-app
Patrick_Wilson19 小时前
sccache 用在 Rust 上为什么常「不省编译」:原理、限制与 Windows 接入
ci/cd·rust·编译器
2501_915909061 天前
iOS test 测试怎么做?功能、性能、兼容、稳定与安全五类测试指南
android·ios·小程序·https·uni-app·iphone·webview
北风toto1 天前
研发效能与后端核心技术全景指南:从CI/CD到共性组件实战
java·开发语言·ci/cd
heimeiyingwang2 天前
【GitOps·入门篇】工具生态:ArgoCD、Flux、Jenkins X 对比选型
jenkins·flux·argocd·gitops
顿哥GPT2 天前
ChatGPT Plus/Pro 解锁 Codex 后这样玩:云端任务、本地 CLI 与 CI/CD 的进阶实战手册
ci/cd·chatgpt
游戏开发爱好者82 天前
App Store 上传 IPA 自动化,.p8 密钥认证与 CI/CD 接入实战
android·运维·ci/cd·小程序·uni-app·自动化·iphone
骇客野人2 天前
基于Nginx+Eureka+Apollo+Jenkins+SpringBoot Web系统分布式部署方案
nginx·eureka·jenkins
游戏开发爱好者82 天前
iOS 推送怎么配置,APNs 推送证书、设备库与群发
android·ios·小程序·https·uni-app·iphone·webview