拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

告别手动点上传:miniprogram-ci 把小程序提审打包成一条命令

告别手动点上传:miniprogram-ci 把小程序提审打包成一条命令

告别手动点上传:miniprogram-ci 把小程序提审打包成一条命令

适用读者:负责微信小程序日常迭代与发版的前端工程师、维护小程序 CI/CD 流水线的 DevOps 同学。假设你已经能独立跑通微信开发者工具的预览与上传,对 Node.js 脚本和 Git 打 tag 有基本操作经验。

上周三晚上十点半,测试在群里说「这个 bug 修复版帮我传一下」。值班同事打开微信开发者工具,点了上传,版本号随手填了个 1.2.3——和两个小时前另一个人传的版本号撞了。微信后台「版本管理」里两条记录同名,谁也不知道哪份代码对应哪个提交,最后只能靠后台显示的上传时间和打包 md5 反推。这种场景你一定不陌生:版本号靠记忆、描述靠手打、谁传的靠翻聊天记录,三个人协作一个小程序,后台堆了一排同名版本,出问题根本没法回溯。那天晚上我下定决心把发版脚本化,前后花了两个晚上,现在整个流程收在一条命令里:npm run release——版本号强制取自 git tag,描述自动取 commit message,人只需要打一个 tag,剩下的交给流水线。

TL;DR 速览

  • 一条命令:用 miniprogram-ci 把上传打包成npm run release。
  • 版本可回溯:版本号强制取自 git tag,描述取 commit message。
  • 密钥当生产凭据:按生产凭据保管,IP 白名单按场景取舍。
  • 提审发布靠人:提审与发布仍需人工在 mp 后台操作。

手动上传到底有多疼

先量化一下问题。我们团队 4 个人共用一个小程序项目,迭代周期一周两版。我对过去一个月的发版记录做了次盘点,把手动上传和接入 miniprogram-ci 之后的表现放在一起对比:

对比项手动开发者工具上传miniprogram-ci 脚本上传
单次操作耗时90~150 秒(含开工具、编译、手填版本)25~40 秒(纯上传打包)
版本号来源人工记忆,随手填强制取自 git tag,不填 tag 直接失败
版本描述「修复了一些问题」自动取 commit message,精确到提交
多人覆盖常见,后台同名版本堆积robot 号隔离,互相不干扰
谁都能传是,任何装了工具的人否,密钥只有 CI 环境持有
出问题回溯翻聊天记录找谁传的git log + CI 构建记录一一对应

最要命的不是慢,是版本号和代码之间没有约束关系。版本描述栏里写「fix bug」,三个月后没人知道修的是哪个 bug。脚本化之后这些问题全部消掉,因为信息只能从 Git 仓库里来,人没有填错的机会。

miniprogram-ci 能做什么

miniprogram-ci 是微信官方提供的 Node.js 模块(npm i miniprogram-ci),把开发者工具里「预览」「上传」「代码依赖分析」这几个动作做成了可编程接口。常用的三个能力:

  • ci.upload:把本地项目编译打包后上传到微信后台「开发版本」,等价于工具里的上传按钮,参数里可以指定版本号、描述、编译设置和 robot 编号。
  • ci.preview:生成预览版,产物是一张预览码图(可存成文件),扫码即可在真机上打开临时版本,不占用正式版本位。
  • ci.analyse:跑代码依赖分析和体积分析,输出主包/分包大小、依赖关系,适合放在流水线里做体积门禁。

它不需要安装微信开发者工具,纯命令行运行,这是它和开发者工具 CLI(cli命令行调用本机工具)最本质的区别——CI 容器里通常装不了 GUI 工具,miniprogram-ci 就是为此设计的。

上传密钥和 IP 白名单:机制先搞清楚再动手

这是整个方案里最容易踩坑的一块,值得单独拆开讲。miniprogram-ci 的鉴权不走个人微信账号,而是走「小程序代码上传密钥」——在 mp.weixin.qq.com 后台「开发管理 → 开发设置」里生成,下载得到一个.key结尾的私钥文件。上传时用这个私钥对请求签名,微信服务端验证后才放行。

这里有个高频混淆点:代码上传密钥(IP 白名单)和「开发者工具的安全域名」是两套独立体系。开发者工具里上传代码走的是登录账号的票据,不校验出口 IP;而代码上传密钥受「IP 白名单」开关保护——你可以在后台开启「仅允许白名单内 IP 调用」,此时只有指定出口 IP 的机器能用这把密钥上传。我们第一次接 CI 就栽在这里:本地脚本跑得好好的,丢到 GitHub Actions 上直接报错40125 invalid ip,因为 Actions 的 runner 出口 IP 不固定,不在白名单里。

解法有两条路:

  1. 后台不开启 IP 白名单强校验(默认就是关的),只靠密钥文件本身保密;
  2. 用带固定出口 IP 的自建 Runner 或云主机跑 Jenkins / self-hosted runner,把出口 IP 配进白名单。

我们的选择是折中:GitHub Actions 上不启用白名单,但密钥只放在 Actions Secrets 里、日志里绝不打印;公司内网 Jenkins 的机器走白名单强校验。密钥文件的权限等级等同于小程序的发布权限,谁拿到谁就能传代码,保管规格按生产凭据对待。

另外注意 robot 编号:后台允许配置多个机器人(1~30),不同 robot 上传的版本在后台是分区展示的。我们给 CI 固定用 robot 3,本地应急手传用 robot 1,这样后台一眼就能分清哪条是流水线产物。

封装 upload 脚本:version 从 tag 取,desc 取 commit

核心思路是让脚本自己从 Git 里挖元数据,人不参与填写。下面是我们仓库里scripts/upload.js的完整实现,Node 14 以上可跑,依赖只有 miniprogram-ci 本体:

// scripts/upload.js —— 小程序上传脚本,发版入口// 依赖:npm i miniprogram-ci@latest(Node >= 14)// 环境变量:MP_APPID(小程序 AppID)、MP_KEY_PATH(私钥文件路径)// 使用方式:npm run upload,且当前分支必须已打 vX.Y.Z 的 tagconstci=require('miniprogram-ci')const{execSync}=require('child_process')// 取当前分支最近的 tag 作为版本号// 没有 tag 时直接抛错,杜绝「随手填版本号」的可能functiongetVersion(){// git describe 会找到当前分支可到达的最近一个 tag// --abbrev=0 表示只要 tag 本身,不带 commit 后缀consttag=execSync('git describe --tags --abbrev=0').toString().trim()// 校验语义化版本格式,防止打成 v1 或 build-2024 这类自由 tagif(!/^v\d+\.\d+\.\d+$/.test(tag)){thrownewError(`tag${tag}不符合 vX.Y.Z 格式,请先打规范版本 tag`)}// 去掉前缀 v,微信后台只要数字部分returntag.slice(1)}// 版本描述取最近一条 commit message// 后台「版本描述」栏会原文展示,回溯时直接定位到提交functiongetDesc(){// %s 只取标题行,不带正文,长度可控constmsg=execSync('git log -1 --pretty=%s').toString().trim()// 微信对描述长度有限制,截断到 60 字符保险// 超长截断比报错友好,描述不参与完整性校验returnmsg.length>60?msg.slice(0,60):msg}asyncfunctionmain(){// 元数据全部来自 Git,脚本不提供任何手动传参入口constversion=getVersion()constdesc=getDesc()console.log(`开始上传:version=${version}desc=${desc}`)// Project 实例封装了项目信息与私钥,后续 upload/preview 复用constproject=newci.Project({appid:process.env.MP_APPID,// AppID 从环境变量读,不硬编码type:'miniProgram',projectPath:process.cwd(),// 小程序项目根目录(含 project.config.json)privateKeyPath:process.env.MP_KEY_PATH,// 上传密钥私钥文件路径ignores:['node_modules/**/*'],// 打包时排除依赖目录})constt0=Date.now()constresult=awaitci.upload({project,version,desc,setting:{es6:true,// 开启 ES6 转 ES5,和工具里设置保持一致minify:true,// 压缩代码,主包体积能小 15% 左右autoPrefixWXSS:true,// 样式自动补前缀},robot:3,// CI 专用机器人编号,和本地手传区分})// 上传完成,打印耗时和后台包信息便于留档console.log(`上传完成,耗时${Math.round((Date.now()-t0)/1000)}s`)console.log(`分包信息:${JSON.stringify(result.subPackageInfo||[])}`)}// 统一入口,任何异常都转成非零退出码main().catch((e)=>{console.error('上传失败:',e.message)process.exit(1)// 非零退出码让 CI 正确判定失败})

配套的package.json里加两条 script:

{"scripts":{"upload":"node scripts/upload.js","preview":"node scripts/preview.js"}}

实际跑起来:我们一个主包 1.6MB + 两个分包合计 2.8MB 的项目,ci.upload全程 28 秒左右(公司 200M 带宽内网),比开发者工具里的「上传」按钮快不少,因为省掉了 GUI 编译面板的初始化。tag 校验那行曾救过我们一次——有人打成v2.1就想发布,脚本直接拦下来了。

接进 CI:密钥保管与流水线编排

脚本能跑只是第一步,关键是让它在流水线里安全地跑。以 GitHub Actions 为例,两个密钥:MP_APPID放 repo 的 Variables,MP_PRIVATE_KEY把私钥文件内容(不是路径)存进 Actions Secrets,工作流里现场落盘成临时文件再传给脚本:

# .github/workflows/release.yml —— 打 tag 触发小程序上传# 触发条件刻意收紧在 tag,防止日常 push 误触发上传name:miniapp-releaseon:push:tags:['v*']# 只有 vX.Y.Z 的 tag 才触发jobs:upload:runs-on:ubuntu-latest# 单 job 串行执行,上传失败后续步骤不跑steps:# 拉全量历史,git describe 才能找到 tag-uses:actions/checkout@v4with:fetch-depth:0# Node 版本与本地开发保持一致,避免编译行为漂移-uses:actions/setup-node@v4with:node-version:18-name:还原上传密钥run:|# 私钥内容从 Secrets 写入临时文件,用完即弃 echo "${{ secrets.MP_PRIVATE_KEY }}" > /tmp/private.wx.key-run:npm ci# 体积门禁:主包超限直接让流水线失败# analyse 脚本内部也用非零退出码上报失败-name:依赖分析体积门禁run:node scripts/analyse.js-name:上传到微信后台env:# AppID 放 Variables,私钥放 Secrets,权限分级管理# 私钥只存内容不存路径,路径在运行时生成MP_APPID:${{vars.MP_APPID}}MP_KEY_PATH:/tmp/private.wx.keyrun:npm run upload# 无论成败都删掉私钥文件,不留痕在 runner 磁盘# if: always() 保证失败分支也执行清理-name:清理密钥文件if:always()run:rm-f /tmp/private.wx.key

流水线编排成这样一条链:

主包超限

通过

打 tag v1.3.0

Actions 触发

npm ci 安装依赖

analyse 体积门禁

流水线失败
阻塞发布

ci.upload 上传

ci.preview 生成预览

预览码推测试群

测试验收通过

后台手动提审

Jenkins 侧的差别主要在密钥:私钥文件用 Credentials 管理成 Secret file 类型,流水线里通过withCredentials挂载,机器出口 IP 配进后台白名单。原理相通,就不贴第二份配置了。

把 upload 的鉴权与上传时序画出来,方便理解私钥到底在哪一步起作用:

preview 推群验收:流水线的最后一环

上传成功不等于可以提审,中间还差一道测试验收。我们把ci.preview接在 upload 之后,生成的预览码图直接存到构建产物目录:

// scripts/preview.js —— 生成预览版本供真机验收// 验收流程:构建产物下载预览码图 → 真机打开 → 群里回验收结论constci=require('miniprogram-ci')// Project 初始化逻辑与 upload.js 相同,此处省略// 依赖环境变量与 upload.js 完全一致,复用同一把私钥asyncfunctionmain(){constproject=awaitrequire('./makeProject')()// 复用初始化// preview 与 upload 参数结构几乎一致,只是产物不同constresult=awaitci.preview({project,// 描述里带上版本号,群里对版本时不用翻构建日志desc:`preview@${require('./upload').getVersion()}`,setting:{es6:true,minify:true},// qrcodeFormat 支持 base64 / image / terminal 三种qrcodeFormat:'image',// 输出为图片文件qrcodeOutputDest:'./dist/preview.jpg',// 存到构建产物目录robot:3,// 与 upload 同一 robot,版本可对应onProgressUpdate:console.log,// 打印编译进度便于排查})// preview 结果里带真机调试相关配置,可按需存档console.log('预览版已生成:dist/preview.jpg')}// 失败同样以非零退出码上抛给 CImain().catch((e)=>{console.error(e);process.exit(1)})

Actions 里再加一步,用现成的上传构建产物的 action 把dist/preview.jpg存成 artifact,通知机器人把下载链接甩进测试群。测试同学扫码进预览版,验完在群里回「1.3.0 OK」,负责发布的同学再去后台点提审。预览码本身只指向临时体验版本,不经过群文件流转也不产生安全问题,但截图里若带了项目名信息,对外群还是要留意。

提审的边界:ci 到此为止,这一步还在人手里

提审后的验证与发布

提审通过后,发布这一步同样在 mp 后台由人完成,但发布前的验证和发布后的回滚,值得单独梳理成一套固定动作,避免「审核过了就以为万事大吉」。

发布前的核对清单

审核通过后,后台「版本管理」里会出现「审核通过」状态的版本。点「发布」之前,先过一遍这份清单:

  • 版本号:确认后台显示的版本号与 git tag 一致(如1.3.0),别把审核中的旧版本当成最新版发布。
  • 版本描述:核对描述是否对应本次迭代的 commit message,避免「修复了一些问题」这类无法回溯的描述上线。
  • 分包大小:确认主包/分包体积在微信限制内(主包 2MB、总包 20MB),超限版本即使审核通过也可能在线上被降级或拦截。
  • 线上功能冒烟测试:发布后立即在真机上跑一遍核心链路——登录、首页加载、关键页面跳转、支付(如有),确认没有白屏或接口报错。

发布与验证

点「发布」后,微信后台会有一个短暂的发布生效过程(通常几十秒到几分钟)。我们的做法是发布后立刻在测试群同步一条消息,附上版本号和冒烟结论,让测试同学在真机上再确认一遍。发布不等于上线完成,线上版本以「版本管理」里最新一条「已发布」状态为准。

回滚(如有)

微信后台支持把线上版本回退到历史「已发布」版本。回滚的触发条件一般是:线上出现严重 bug、接口大面积报错、或数据异常。操作路径是「版本管理 → 选择历史已发布版本 → 设为线上版本」。

回滚有两个注意点:

  • 回滚只切版本,不切代码:线上回退到旧版本后,代码仓库里仍是新版本,需要尽快修复并重新走一遍「打 tag → 上传 → 提审 → 发布」流程,否则下次发布又会把问题版本带上去。
  • 回滚有延迟:微信后台切版本不是瞬时的,切完后要等生效再冒烟验证,别切完就以为立刻恢复了。

小结

提审通过只是发版流程的中点,发布、验证、回滚这三步仍然需要人盯着。把「核对清单 + 冒烟测试 + 回滚预案」固化成团队约定,发版这件事才算真正闭环。

必须说清楚一个能力边界:miniprogram-ci 不包含提审和发布的 API。上传(upload)生成的是「开发版本」,把开发版本提交审核、审核通过后发布,这两步官方只开放给了第三方平台代开发的场景(submitAudit属于开放平台第三方接口,普通自研小程序用不了)。所以自研小程序的流水线终点是「开发版本就绪 + 预览验收通过」,提审按钮仍然在 mp 后台由人按下。

这个边界设计其实合理:提审涉及审核规则判断——类目资质是否齐、有没有违规内容,机器不好兜底。我们的实践是让流水线把「该准备的都准备好」:版本号规范、描述可回溯、体积达标、预览验收留痕,人只做最后一次判断。提审高峰期(比如赶大版本)后台审核排队 2~6 小时不等,提审后到通过前开发版本不能被覆盖上传,这也是为什么 robot 分区很重要——CI 继续用 robot 3 传下一个日常版本,不会动 robot 区里正在审核的那个。

原理侧:ci.upload 在本地到底做了什么

先看一张鉴权时序图,私钥在整条链路里只出现一次,但每一步校验都不能少:

微信上传网关miniprogram-ciNode 脚本微信上传网关miniprogram-ciNode 脚本本地完成编译压缩计算整包 md5验签 + IP 白名单校验传入 appid 与私钥路径签名(appid+版本+md5)40001/40125 或放行返回上传结果与分包信息

把ci.upload当黑盒用没问题,但排查构建差异时得知道它和开发者工具的差异在哪。ci.upload在本地完成了完整的前端编译链:Babel 转译(es6 选项)、代码压缩(minify)、WXSS 前缀补全、WXML 编译,然后按project.config.json里的packOptions规则收集文件,计算整包 md5,最后用私钥对「appid + 版本 + 包 md5」做签名,连同代码包一起 POST 到微信上传网关。服务端验签通过才落库。

这意味着两个结论:编译行为由脚本参数和 project.config.json 共同决定,两边 setting 不一致就会出现「我本地工具里好好的,CI 传上去就不对」——我们把工具里setting的每一项都对齐到脚本参数后才稳定下来。第二,签名机制决定了私钥文件损坏或格式不对(比如从 Secrets 还原时多了换行符)会报code 40001这类签名错误,排查时优先检查密钥文件内容是否被流水线污染,我们踩过的这几个坑集中列一下:

坑现象报错解法
密钥文件带 BOM/多余换行40001 invalid signatureSecrets 写入后sed -i 's/\r$//'清理,或 base64 转存还原
Actions runner IP 不在白名单40125 invalid ip not in whitelist关闭强校验,或换固定出口 IP 的 self-hosted runner
checkout 没拉全量历史git describe报fatal: No tags foundfetch-depth: 0拉全量
projectPath 指错层Error: 项目未找到 app.json指向含 project.config.json 的根目录
robot 用了别人的编号后台版本区混乱、覆盖团队约定编号表,写进 README

常见报错速查表

上面表格里列的是我们踩过的坑,这里再补一张更通用的速查表,覆盖 miniprogram-ci 上传时的高频报错,方便你遇到报错时按错误码快速定位:

错误码典型场景排查步骤解决方案
40001 invalid signature密钥文件被流水线污染(BOM、多余换行、base64 还原出错)1. 检查私钥文件内容是否与后台下载的原始文件一致;2. 用xxd或cat -A查看文件头尾是否有异常字符Secrets 写入后执行sed -i 's/\r$//' /tmp/private.wx.key清理换行;或改用 base64 编码存储、运行时base64 -d还原
40125 invalid ip not in whitelistGitHub Actions / 云函数等出口 IP 不固定的环境,且后台开启了 IP 白名单强校验1. 确认后台「IP 白名单」开关状态;2. 查看 runner 出口 IP(curl ifconfig.me)是否在白名单内关闭白名单强校验(仅靠密钥保密),或改用固定出口 IP 的 self-hosted runner / 内网 Jenkins,把出口 IP 配进白名单
600001或系统繁忙上传频率过高、并发上传同一 robot、或微信服务端临时抖动1. 检查是否同一 robot 短时间内多次上传;2. 查看 CI 日志确认是否并发触发多个 upload job给流水线加concurrency限制,同一 robot 串行上传;重试一次(微信服务端偶发抖动,重试通常能过)
Error: 项目未找到 app.jsonprojectPath指向了错误目录,或项目根目录缺少project.config.json1. 确认projectPath指向含project.config.json的根目录;2. 检查project.config.json里miniprogramRoot是否指向了小程序代码子目录把projectPath改为项目根目录;若代码在子目录,在project.config.json里正确配置miniprogramRoot
fatal: No tags foundCI 里git describe找不到 tag,通常是 checkout 没拉全量历史1. 确认当前分支是否已打 tag;2. 检查 CI 的 checkout 步骤是否只拉了浅克隆在 Actions 的actions/checkout@v4里加fetch-depth: 0拉全量历史;本地确认git tag已推送
version 格式不正确版本号不符合微信后台要求(如v1.3、1.3带前缀、或含非法字符)1. 检查脚本里getVersion()的返回值;2. 确认 tag 是否符合vX.Y.Z格式统一 tag 规范为vX.Y.Z,脚本里用正则/^v\d+\.\d+\.\d+$/校验,不合法直接抛错拦截

排查时建议在upload.js的catch里把e.message完整打印出来,错误码通常就在消息开头;再结合上面的表格按「错误码 → 场景 → 排查 → 解决」的顺序走,大部分问题都能在十分钟内定位。

误区澄清

两点常见误解值得摆正。一是「有了 ci 就能全自动发版」——不对,提审和发布环节官方没开放给自研小程序,全自动只到上传为止,刻意绕过人工提审的思路(找非官方接口)有账号风控风险,不要碰。二是「不开 IP 白名单就不安全」——白名单只是纵深防御的一层,密钥文件本身的保管(Secrets 加密、日志脱敏、离职回收)才是核心,白名单解决的是密钥泄露后被异地滥用的场景,两者不互斥。小程序工程化这条路微信官方还在持续补能力,代码依赖分析、体积告警这些点值得盯着 ci 的版本更新日志跟进,工具链每前进一步,人就少点一次按钮。

有问题欢迎评论区交流,尤其是 Actions 上传微信小程序踩过的别的坑。

参考与延伸

  • miniprogram-ci 官方文档 — upload/preview/analyse 全部参数说明
  • 微信小程序开发框架文档 — project.config.json 配置项与编译选项
  • 开发者工具 CLI 说明 — 与 miniprogram-ci 的能力边界对照
  • GitHub Actions 文档 — Secrets 管理与工作流语法

微信小程序 · miniprogram-ci · CI/CD · 自动化构建 · 代码上传密钥 · 小程序上传 · 持续集成

返回列表