项目背景
从2024年开始,我和团队开发了零课表微信小程序,为全校师生提供课表查询、空教室查询、校园社区等服务。这是BFU历史上第一个连接本科生、研究生、教师三大群体的平台。直到今天,这个项目仍然在维护,总用户超过2.8W,DAU超1W,提供的日均请求超23W次,全校98%的本科生都在使用零课表。这意味着,全校每10个本科生同学中,就至少有9位在使用零课表提供的服务。


本项目主要由多端组成,包括基于Python Fast API提供统一HTTP API服务的后端;基于微信原生小程序框架,提供主要教务、论坛服务的小程序端;基于Vue3实现,面向团队内部运营管理的Web中台Stargazer;以及基于Vue3,用于临时营销活动的H5端。此外,还衍生出了一系列的零课表生态产品,例如“零课问问”,基于AI LLM与RAG的智能问答助手;“零课文档”,基于Vue3的文档、资料分享平台等。本文主要讨论的是小程序端。
小程序端的代码托管在GitHub仓库中,主要代码分支如下:
- main:主要分支,质量稳定,随时可以发版
- dev:开发的分支,同步TEST(测试)环境,未经全面测试,可能会有bug
- 其他:团队成员用于本地开发、测试的分支
团队成员通过其他分支(如:feat/xxx,fix/xxx)开启PR向dev分支合并。dev分支稳定后向main分支开PR合并。main分支稳定后,可根据需要随时发版(Release)。
从传统的SOP来看,我们发版包括以下步骤:
- 合并PR
- 撰写changelog
- 发布版本(Release)
- 从GitHub拉取Release代码
- 安装NPM包
- 本地打开微信开发者工具,构建NPM
- 手动进行打包,标记版本号后上传
- 微信后台提交版本审核
但是随着项目的持续迭代,前端功能不断新增,发版频率也随之升高。手动发版效率过低,于是团队决定引入CICD流水线来完成这些重复劳动,解放人力资源的同时,也减少出错的概率。(懒狗也有春天)
背景知识
DevOps
DevOps 是一个结合了开发(Development)和运维(Operations)的实践,旨在通过自动化流程和更好的协作来提高软件开发和运维的效率。其核心目标是消除开发与运维团队之间的壁垒,实现快速部署和高可靠性。DevOps 强调敏捷开发、持续集成与交付,并使用工具链如 Jenkins、GitLab 和 Docker 来支持这些实践。随着 DevOps 的普及,越来越多的组织开始采用这一方法,以提高软件交付的速度和质量。
简单来说,就是Dev(开发)人员希望产品快速迭代,但是可能破坏软件的稳定性;Ops(运维)人员的首要目的则是保持线上环境的稳定性,毕竟谁也不想大半夜的接到SRE的Oncall,对吧?这两类工程师的职责其实是相悖的,快速迭代必然会导致稳定下降。于是,我们引入DevOps制度,将每个Dev的代码分支进行自动化检查,确保始终只合并正确的代码,提高工程的健壮性;同时,Ops同学让自动化流程完成部署,确保软件的可持续交付。
CI/CD
事实上,我们刚刚在介绍DevOps的时候已经涉及到CI/CD的概念了!
CI/CD 是一种通过在应用开发阶段引入自动化来频繁向客户交付应用的方法。CI/CD 的核心概念是持续集成、持续交付和持续部署。它是作为一个面向开发和运营团队的解决方案,主要针对在集成新代码时所引发的问题(也称为:“集成地狱”)。CI/CD 可让持续自动化和持续监控贯穿于应用的整个生命周期(从集成和测试阶段,到交付和部署)。这些关联的事务通常被统称为 CI/CD 管道,由开发和运维团队以敏捷方式协同支持。
在上述描述中,Dev人员随时通过自动化流程检查代码(如Lint或单元测试等),这个流程就是CI(Continuous Integration,持续集成);Ops人员可以随时通过自动化流程将稳定的代码交付给生产环境,这个流程就是CD(Continuous Deployment,持续部署;或者叫Continuous Delivery,持续交付)。
GitHub Actions
GitHub Actions 工作流(Workflow) 是一种可配置的自动化过程,用于在仓库中执行构建、测试、部署等任务。工作流由 YAML 文件 定义,存放在仓库的 .github/workflows 目录中,可以由事件、手动或定时触发。你可以查阅GitHub Actions的官网文档了解更多:https://docs.github.com/zh/actions。
也就是说,通过GitHub Actions,我们就能完成CI/CD流水线,进而达到我们DevOps的开发、运维愿景。除了GitHub Actions,还有很多其他的工具可以帮助我们实现自动化流水线,如Jenkins、GitLab CI/CD、Gitea Actions、Semaphore 和 Harness 等,它们都支持自动化构建、测试和部署工作流。因为本项目托管在GitHub仓库,所以使用GitHub Actions实现起来最为简便。同时GitHub Actions还提供了很多社区共享的工作流,极大提高了效率;最重要的是,GitHub Actions提供一定的免费额度,对白嫖党非常友好!
自动发版
首先,我们需要一个工作流来帮助我们自动发版。也就是说,PR从dev合并至main分支的时候,我们需要自动化地生成一个Release Draft,这里使用的是release-drafter:https://github.com/release-drafter/release-drafter。
.github/workflows文件夹下面,这样才能生效。废话不多说,我们上代码:
# Release.yml
name: Release A New Version
on:
pull_request:
types: [closed]
branches:
- main
permissions: write-all
jobs:
release:
if: github.event.pull_request.merged == true && github.event.pull_request.base.ref == 'main'
runs-on: ubuntu-latest
steps:
- name: Create a release draft
uses: release-drafter/release-drafter@v6
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Comment PR to notify
uses: actions/github-script@v8
with:
script: |
await github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: `🎉🎉🎉 Congratulations on your new release! 🎉🎉🎉
We have created a release draft of the new version, please go to [Release](https://github.com/0class/0class-front/releases) to publish it! ✨`
})
这段GitHub Actions配置的作用很简单:当一个 PR 被成功合并到 main 分支后,自动帮你创建一个Release草稿,并在该PR下留言提醒你去发布。
最开始的 on: pull_request 表示监听 PR 相关事件,types: [closed] 表示只在 PR 被关闭时触发,而 branches: - main 限制了目标分支必须是 main。不过 PR 被关闭不一定代表被合并,所以后面又通过 if: github.event.pull_request.merged == true && github.event.pull_request.base.ref == 'main' 做了二次判断,确保只有“真正合并进 main 分支”的 PR 才会执行这个流程。
permissions: write-all 是给这个工作流写权限,否则它没法创建 release 或发表评论。
接下来定义了一个名为 release 的任务,运行在 ubuntu-latest 环境上。这个任务里有两个步骤。
第一个步骤使用 release-drafter/release-drafter@v6,它的作用是根据已经合并的 PR 自动生成一个 Release 草稿(Draft),还会顺便整理好变更日志。这里通过 GITHUB_TOKEN 来授权,让它有权限操作仓库。
GITHUB_TOKEN 是GitHub默认就有的Token变量,你无须手动添加。第二个步骤使用 actions/github-script@v8 执行一段 JavaScript 脚本,这段脚本调用 GitHub 的 API,在当前 PR 下发一条评论。评论内容就是一段提示,大意是告诉你:“新版本的 Release 草稿已经帮你建好了,去 Releases 页面点一下发布就行”。

整体流程就是:PR合并进main → 触发工作流 → 自动生成Release草稿 → 在PR里提醒你去发布。这样,当你每次向main分支合并代码时,就会生成好对应的changelog和Release Draft。

通过这个Workflow生成的版本号遵循语义化版本号规则,当然你也可以在Release Draft创建完成后手动更改。
打包与上传
通过上述步骤,我们就有了一个可供发布的Release Draft。现在我们需要Publish这个Draft,同时在Publish的时候,打包、编译这个版本的代码,并上传到服务器。
对于需要NPM包的微信小程序项目,是需要在微信开发者工具内手动NPM包的。关于微信小程序对NPM的支持,请参见:https://developers.weixin.qq.com/miniprogram/dev/devtools/npm.html。同样的,上传的操作也要通过微信开发者工具操作。


为了直接在CI/CD流水线中操作,我们需要使用js脚本和miniprogram-ci来完成,这里我把它们放在了.CICD文件夹下面。
// .CICD/packnpm.js
// npm build script for miniprogram
// author: Nero978
import colors from 'colors'; // eslint-disable-line no-unused-vars
import mpConfig from '../project.config.json' with { type: 'json' };
import ci from 'miniprogram-ci';
const { Project, packNpm } = ci;
(async () => {
const project = new Project({
appid: Your_AppId,
type: 'miniProgram',
projectPath: process.cwd(),
privateKeyPath: process.env.PRIVATE_KEY,
ignores: [
'node_modules/**/*',
'.git/**',
'.github/**',
'.CICD/**',
'.vscode/**',
'.gitignore',
'.prettierignore',
'.prettierrc.yml',
'eslint.config.js',
'package-lock.json',
'**/.DS_Store',
'readme.md',
'LICENSE',
],
});
console.log('Start to build npm packages...'.blue);
const warning = await packNpm(project, {
ignores: [],
reporter: (infos) => {
Object.entries(infos).forEach(([key, value]) => {
console.log('· ' + `${key}: ` + `${value}`.yellow);
});
},
});
if (warning.length > 0) {
warning
.map((it, index) => {
return `${index + 1}. ${it.msg}
\t> code: ${it.code}
\t@ ${it.jsPath}:${it.startLine}-${it.endLine}`;
})
.join('---------------\n');
console.log(('Warning found: ' + warning.length).red);
console.warn(warning);
} else {
console.log('No warning found.'.green);
}
console.log('Build npm packages successfully!'.green);
})();
// .CICD/upload.js
// miniprogram upload
// author: Nero978
import colors from 'colors'; // eslint-disable-line no-unused-vars
import mpConfig from '../project.config.json' with { type: 'json' };
import { execSync } from 'child_process';
import ci from 'miniprogram-ci';
const { Project, upload } = ci;
import { exit } from 'process';
const version = execSync('git tag --points-at HEAD').toString().trim();
const sha = execSync('git rev-parse HEAD').toString().trim();
function formatSize(size) {
return (size / 1024).toFixed(2) + ' KB';
}
(async () => {
const project = new Project({
appid: Your_AppId,
type: 'miniProgram',
projectPath: process.cwd(),
privateKeyPath: process.env.PRIVATE_KEY,
ignores: [
'node_modules/**/*',
'.git/**',
'.github/**',
'.CICD/**',
'.vscode/**',
'.gitignore',
'.prettierignore',
'.prettierrc.yml',
'eslint.config.js',
'package-lock.json',
'**/.DS_Store',
'readme.md',
'LICENSE',
],
});
console.log('Start to upload...'.blue);
const uploadResult = await upload({
project,
version: version,
desc: '[PROD] commit sha: ' + sha,
robot: 1,
setting: {
useProjectConfig: true,
},
onProgressUpdate: (log) => {
if (typeof log === 'object') {
console.log(log.status, log.message);
} else {
console.log(log);
}
},
});
console.log('------------------------------------');
console.log('Upload successfully!'.green);
console.log('version: ' + version.yellow);
console.log('[PROD]'.blue + ' commit sha: ' + sha.yellow);
console.log('------------------------------------');
const { subPackageInfo, pluginInfo, devPluginId } = uploadResult;
console.log('subPackageInfo'.blue);
subPackageInfo.forEach((it) => {
console.log('name: ' + it.name.blue, '\tsize: ' + formatSize(it.size).bgYellow);
});
console.log('------------------------------------');
console.log('pluginInfo'.blue);
pluginInfo.forEach((it) => {
console.log(
'pluginProviderAppid: ' + it.pluginProviderAppid.blue,
'\tversion: ' + it.version.blue,
'\tsize: ' + formatSize(it.size).bgYellow,
);
});
console.log('------------------------------------');
console.log('devPlugin'.blue);
console.log('devPluginId: ', (devPluginId ? devPluginId : 'null').blue);
exit(0);
})();
上面这两个脚本分别是用来构建小程序的NPM,以及打包上传小程序。请将上述代码中的Your_AppId替换为你的小程序的AppId。为了让打印出来的log更加美观,我使用了colors包来给log标记颜色,你也可以根据需要删掉。
现在,我们需要在package.json中定义相关命令,指向这两个脚本,方便在CI/CD流水线中进行调用。
{
"scripts": {
"packnpm": "node ./.CICD/packnpm.js",
"upload": "node ./.CICD/upload.js",
},
}
准备工作完成!接下来,我们需要定义上传的GitHub Action工作流文件。我们希望当Release发布一个新的版本时,可以自动帮我们把当前版本的代码进行打包,并上传到微信后台。
# upload.yml
name: Upload to Wechat Server
on:
release:
types: [published]
permissions: write-all
jobs:
upload:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: '20.x'
- name: Cache node modules
uses: actions/cache@v5
with:
path: node_modules
key: ${{ runner.os }}-npm-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
${{ runner.os }}-npm-
- name: Install dependencies
run: npm install
- name: Build npm packages
run: npm run packnpm
env:
PRIVATE_KEY: {{ secrets.PRIVATE_KEY }}
- name: Upload to Wechat Server
run: npm run upload
env:
PRIVATE_KEY: {{ secrets.PRIVATE_KEY }}
你肯定发现了,在上述YML文件中,我们使用了PRIVATE_KEY的密钥,这就是微信要求的小程序上传密钥。我们需要从微信后台下载后,再通过GitHub项目「Settings」-左侧「Secrets and variables」-「Actions」为项目添加密钥。我们选择「Secrets」,然后New一个新的,把刚从微信下载的密钥粘贴到这里。
即使是私有仓库,也不建议这样做。


提交审核
好了,到这里就全部配置完成了!现在,当你每次发布一个新的版本,就会自动运行CI/CD。也就是说现在的发版、上传SOP变成了:
- 合并PR
- 发布自动生成的Release Draft
- 微信后台提交审核
当这个工作流完成之后,你就能在微信后台看到你刚刚发布的新版本了。你可以看到该版本的版本号和commit hash,方便你识别最新的版本。

但是由于微信的限制,目前提交审核的步骤还是无法通过自动化工具完成,只能手动进入Web端提交审核。
恭喜你!到目前为止,你已经完成了一个标准的CI/CD发版流程。只要简单几步,你就可以通过自动化流程将小程序快速同步到微信后台,进而实现快速地版本发布。


