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

2026-09-28 01:19:14 0 次浏览
微信小程序小程序开发miniprogram-ciCI/CD自动化部署

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

上周三晚上十点半,测试在群里说「这个 bug 修复版帮我传一下」,值班同事打开微信开发者工具,点了上传,版本号随手填了个 1.2.3——和两个小时前另一个人传的版本号撞了。微信后台「版本管理」里两条记录同名,谁也不知道哪份代码对应哪个提交,最后只能靠后台显示的上传时间和打包 md5 反推。这件事之后我下决心把发版脚本化了,前后花了两个晚上,现在整个流程收在一条命令里:npm run release。

手动上传到底有多疼

先量化一下问题。我们团队 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 的 tag
const ci = require('miniprogram-ci')
const { execSync } = require('child_process')

// 取当前分支最近的 tag 作为版本号
// 没有 tag 时直接抛错,杜绝「随手填版本号」的可能
function getVersion() {
  // git describe 会找到当前分支可到达的最近一个 tag
  // --abbrev=0 表示只要 tag 本身,不带 commit 后缀
  const tag = execSync('git describe --tags --abbrev=0').toString().trim()
  // 校验语义化版本格式,防止打成 v1 或 build-2024 这类自由 tag
  if (!/^v\d+\.\d+\.\d+$/.test(tag)) {
    throw new Error(`tag ${tag} 不符合 vX.Y.Z 格式,请先打规范版本 tag`)
  }
  // 去掉前缀 v,微信后台只要数字部分
  return tag.slice(1)
}

// 版本描述取最近一条 commit message
// 后台「版本描述」栏会原文展示,回溯时直接定位到提交
function getDesc() {
  // %s 只取标题行,不带正文,长度可控
  const msg = execSync('git log -1 --pretty=%s').toString().trim()
  // 微信对描述长度有限制,截断到 60 字符保险
  // 超长截断比报错友好,描述不参与完整性校验
  return msg.length > 60 ? msg.slice(0, 60) : msg
}

async function main() {
  // 元数据全部来自 Git,脚本不提供任何手动传参入口
  const version = getVersion()
  const desc = getDesc()
  console.log(`开始上传:version=${version} desc=${desc}`)

  // Project 实例封装了项目信息与私钥,后续 upload/preview 复用
  const project = new ci.Project({
    appid: process.env.MP_APPID,        // AppID 从环境变量读,不硬编码
    type: 'miniProgram',
    projectPath: process.cwd(),         // 小程序项目根目录(含 project.config.json)
    privateKeyPath: process.env.MP_KEY_PATH, // 上传密钥私钥文件路径
    ignores: ['node_modules/**/*'],     // 打包时排除依赖目录
  })

  const t0 = Date.now()
  const result = await ci.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-release
on:
  push:
    tags: ['v*']            # 只有 vX.Y.Z 的 tag 才触发
jobs:
  upload:
    runs-on: ubuntu-latest
    # 单 job 串行执行,上传失败后续步骤不跑
    steps:
      # 拉全量历史,git describe 才能找到 tag
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0
      # Node 版本与本地开发保持一致,避免编译行为漂移
      - uses: actions/setup-node@v4
        with:
          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.key
        run: npm run upload
      # 无论成败都删掉私钥文件,不留痕在 runner 磁盘
      # if: always() 保证失败分支也执行清理
      - name: 清理密钥文件
        if: always()
        run: rm -f /tmp/private.wx.key

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

flowchart LR
    %% 全链路:人只参与打 tag 和最后的提审
    A[打 tag v1.3.0] --> B[Actions 触发]
    %% 中间环节全部由流水线完成,无人工参与
    B --> C[npm ci 安装依赖]
    C --> D{analyse 体积门禁}
    %% 超限即失败,防止越改越大
    D -- 主包超限 --> E[流水线失败\n阻塞发布]
    D -- 通过 --> F[ci.upload 上传]
    F --> G[ci.preview 生成预览]
    %% 预览码推群验收,测试通过才提审
    G --> H[预览码推测试群]
    H --> I[测试验收通过]
    %% 流水线终点:开发版本就绪,提审仍由人完成
    I --> J[后台手动提审]

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

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

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

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

// scripts/preview.js —— 生成预览版本供真机验收
// 验收流程:构建产物下载预览码图 → 真机打开 → 群里回验收结论
const ci = require('miniprogram-ci')
// Project 初始化逻辑与 upload.js 相同,此处省略
// 依赖环境变量与 upload.js 完全一致,复用同一把私钥

async function main() {
  const project = await require('./makeProject')() // 复用初始化
  // preview 与 upload 参数结构几乎一致,只是产物不同
  const result = await ci.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')
}

// 失败同样以非零退出码上抛给 CI
main().catch((e) => { console.error(e); process.exit(1) })

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

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

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

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

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

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

sequenceDiagram
    participant L as Node 脚本
    participant C as miniprogram-ci
    participant W as 微信上传网关
    L->>C: 传入 appid 与私钥路径
    Note over C: 本地完成编译压缩<br>计算整包 md5
    C->>W: 签名(appid+版本+md5)
    Note over W: 验签 + IP 白名单校验
    W-->>C: 40001/40125 或放行
    C-->>L: 返回上传结果与分包信息

把 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 signature Secrets 写入后 sed -i 's/\r$//' 清理,或 base64 转存还原
Actions runner IP 不在白名单 40125 invalid ip not in whitelist 关闭强校验,或换固定出口 IP 的 self-hosted runner
checkout 没拉全量历史 git describe 报 fatal: No tags found fetch-depth: 0 拉全量
projectPath 指错层 Error: 项目未找到 app.json 指向含 project.config.json 的根目录
robot 用了别人的编号 后台版本区混乱、覆盖 团队约定编号表,写进 README

误区澄清

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

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

参考与延伸

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

🤖
本内容由 AI 辅助生成,经人工校对审核;部分素材、资料来源于公开网络,仅作个人观点分享与交流使用,无任何商业侵权意图。若内容、图片、文字涉及您的合法著作权、版权权益,请联系本人,核实后将第一时间删除、修改相关内容。