Files
qingjian/docs/notes/release.md
T
yenharvey 9b2acacae0 ci(android): CI 编 Android,发版出签名的 APK
ci.yml 加 android job 编 debug APK,Kotlin 与 Gradle 以前没有任何检查。
release.yml 加 android job:带产品数据出 arm64 APK,签名后 apksigner 验过才上传;v 标签连 Android 一起发,单发用 android-v。
APK 的 versionName / versionCode 改从 core/Cargo.toml 算,发版门禁同样核对它;0.3.0 起 Android 与桌面同号,成熟度用「预览版」标注。
.qj 不再设 noCompress,APK 从 225 MB 降到 95 MB,只多在首次启动解压时花几秒。
README、译本与用户文档把 Android 从「需从源码构建」改成下载 APK;发版文档补签名密钥的生成与 Secrets。
2026-10-22 10:01:35 +08:00

180 lines
18 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
| `.github/workflows/ci.yml` | push main、PR | `core`(Linux)fmt / clippy / 全 workspace 测试(排除 IMK 壳);`macos` 编 IMK 壳并跑它的测试;`windows` 编 Server / TSF DLL / Settings 并跑测试;`android` 用样例数据编 debug APK。仓库公开,Actions 不计费 |
| `.github/workflows/audit.yml` | 每周一、Cargo.lock 变动 | `cargo audit`(RustSec 已知漏洞) |
| `.github/dependabot.yml` | 每周一 | Cargo 依赖与钉 commit 的 actions 的更新 PR |# 发版流程
2026-09-07 搭起来的:GitHub Actions 按标签打包、建 Release、生成官网下载页用的 `releases.json`。
这里记怎么发一版、各环节的依赖,以及官网怎么消费产物。
## 一次发版做什么
0.1.4 起各平台共用版本号、共用一个 GitHub Release(标签 `v<版本>`):同一个版本号、同一份更新日志只出现一次,各平台的安装包都挂在上面。0.3.0 起 Android 也在里面(之前没发过包,版本号从 0.1.0-dev 直接跳到 0.3.0)。
1. 改版本号,把 `-dev` 去掉:`apps/macos/Cargo.toml`(Info.plist 与 pkg 文件名从这里取)、`apps/windows/{server,tsf,settings}/Cargo.toml`(三个一起改,打包读 `server`)、
`apps/linux/server/Cargo.toml`、`apps/android/core/Cargo.toml`(APK 的 versionName 与 versionCode 从这里算),然后 `cargo update --workspace --offline` 同步 `Cargo.lock`。
**发版之间版本号一直带 `-dev`**(Rust nightly / Firefox Nightly 那套):Cargo.toml 写 `0.1.5-dev`,打包脚本再接上 git 短哈希,
本地装的、CI 中间构建的都显示 `0.1.5-dev-1a2b3c4`(工作区有改动加 `+`),测试时一眼知道装的是哪个提交;版本号干净的一定是线上包;带 `-dev` 的标签 CI 直接拒绝。
pkg 的 `--version`、Inno 的 `VersionInfoVersion` 只认数字点号,`bundle.sh` / `build.ps1` 去掉后缀再传,文件名保留完整版本。
2. `CHANGELOG.md` 顶上那一节把「未发布」换成日期:`## <版本> · <日期> · <渠道>`,一行一条、面向用户的措辞;只属于一个平台的条目加「macOS:」「Windows:」「Linux:」「Android:」前缀。
渠道是**更新渠道**(2026-09-17 定):定期发的版本标 `stable`、版本号干净(`0.1.4`);中间放给测试者的版本标 `alpha` / `beta` / `rc`,
版本号带同名预发布后缀(`0.1.5-beta.1`),`release.yml` 见到后缀就把 GitHub Release 标成预发布、不抢 latest。
「产品还在测试期」由 0.x 的版本号表达,不占渠道字段;0.1.2 及更早的条目标的 `beta` 是旧含义,不回改。
**更新日志手写,不由提交自动生成**:发版前按上个标签以来的 `git log` 起草几条,人审一遍再定稿。
标题下可以写一行 `网盘:[夸克网盘](链接)`(GitHub 下载不方便的用户用):它不算更新条目,Release 说明把它放在最前面,`releases.json` 单独输出成 `mirrors`,官网显示成按钮。
网盘文件在 CI 跑完后手动上传。
3. 提交,打注释标签并推:`git tag -a v0.1.4 -m "青简 0.1.4" && git push origin main v0.1.4`。
4. 标签推出去之后,给历代配置回归测试补一份这一版的模板:`python3 crates/qingjian-platform/tests/config_history/extract.py v<版本> v<版本>`,
新文件随下一个提交进库(`tests/config_history.rs` 要求以后的版本都读得进它)。
5. 紧接一个普通提交把版本号改成下一个开发版(`0.1.5-dev`),CHANGELOG 顶上加 `## 0.1.5 · 未发布 · stable`;-dev 版本永远没有标签与 Release。
`release.yml` 的流程:
- `prepare`(`ubuntu-26.04`):门禁(要打的平台版本号都与标签一致、不带 `-dev`、标签在 main 上、配了索引签名密钥)→ 用 CHANGELOG 那一节加各平台未签名提示建**草稿** Release。
- `macos`(`macos-26`)、`windows`(`windows-latest`)、`linux`(`ubuntu-26.04`)、`android`(`ubuntu-26.04`)并行:下载产品数据 → 打包 → `tools/release/upload-build.sh` 把安装包和
这个平台的 `SHA256SUMS-<平台>`、`build-info-<平台>.json` 片段传到草稿上。
- `publish`:要打的平台都成功才跑,`tools/release/finish-release.sh` 把片段合成一份 `SHA256SUMS` 与 `build-info.json`(各平台构建机记在 `platforms` 里)、删掉片段、草稿转正
→ `publish-releases-json.sh` 生成并签名 `releases.json` → `bump-website.sh` 触发官网构建。
任何平台失败,Release 就停在草稿、用户看不到;修好后删掉草稿与标签重推,或在 Actions 页重跑失败的 job。
**单平台补丁**(只修一个平台的急事):只改那个平台的版本号,打 `macos-v<版本>` / `windows-v<版本>` / `linux-v<版本>` / `android-v<版本>`,同一套流程只打那一个平台,Release 标题带平台名。
发版后 GitHub Release 上有:`qingjian-<版本>-macos-arm64.pkg`、`-macos-x86_64.pkg`、`-windows-x86_64-setup.exe`、`-linux-x86_64.tar.gz`、`-android-arm64.apk`、`SHA256SUMS`、`build-info.json`、`releases.json` 与 `.sig`。
官网由 Cloudflare Workers Builds 按官网仓库的提交自动构建,没有可调用的构建钩子,所以主仓库靠**往官网仓库推一个小提交**来触发:
`tools/release/bump-website.sh` 把版本标签与文档提交号写进官网的 `src/content/upstream.json` 并提交推送(提交者 qingjian-ci)。
配了 `QINGJIAN_WEB_TOKEN`(对 qingjian-web 有 Contents: read and write 的 fine-grained PAT)release.yml 末尾自动做;
官网文档只随发版更新(`docs/user/` 平时改动不推官网,免得文档领先于用户装到的版本)。没配就在官网仓库随便提交一次(或本地跑这个脚本)。
官网构建时才拉最新 Release 的 `releases.json` 与主仓库 `docs/user`,所以提交内容本身不重要,`upstream.json` 只是留个记录、顺便让文档按记下的提交号拉(版本对得上)。
Rust 工具链由 `rust-toolchain.toml` 钉版本(现在 1.96.0),两个 workflow 里 `dtolnay/rust-toolchain` 的 `toolchain:` 输入写同一个号;升级 Rust 时三处一起改。
## 各平台的特别之处
- **Windows**:没有代码签名证书时 workflow 设 `QINGJIAN_UIACCESS=0`:没签名的 exe 带 uiAccess=true 起不来。
代价是候选窗在任务栏搜索 / 设置这类 UWP 宿主里可能被盖住,用户文档与 CHANGELOG 已列为已知问题。
Certum 开源证书办下来后:在 `build.ps1` 加 signtool 一步(`sign-local.ps1` 是本机自签的参考),workflow 去掉那个环境变量;SmartScreen 对无签名安装包的拦截也一并消失。
Inno Setup 钉 7.1.0(与开发机同版本、钉死 GitHub Release 上的安装程序与哈希)。
- **Linux**:`apps/linux/scripts/package.sh` 打的包里是编好的 Server、设置程序与 Fcitx5 插件、产品数据压缩包、`install.sh` / `uninstall.sh` / `files.py`,按仓库相对路径放,
安装与源码安装走同一份清单与校验;打完先装进临时目录冒烟测试(文件齐、校验过、`ldd` 无缺库)再上传。
插件链接构建机的 Fcitx5(`build-info.json` 的 `platforms.linux.runner` 记了版本),所以只承诺 Ubuntu 26.04,其他发行版走源码安装。
- **Android**:runner 自带 SDK 与 JDK,job 里按 `app/build.gradle.kts` 的 `ndkVersion` 装 NDK、装 cargo-ndk,`./gradlew assembleRelease` 带产品数据出 arm64 APK,
签名用 Secrets 里的密钥库(见下文「签名与公证」),`apksigner verify` 过了才上传。只出 APK,不进应用商店;官网下载页与软件内检查更新现在都不认 `android`,
`releases.json` 里有这条但它们会跳过,用户从 GitHub Release 下载。
- **官网**:安装包按文件名识别平台(`ASSET_KINDS`),下载页按访问者平台取「有该平台安装包的最新版本」(`latestFor`);0.1.3 及更早按平台分开的 Release 照样识别,同版本号合在一条里。
## 提交前检查与 CI
本地 `git config core.hooksPath .githooks` 启用一次后,每次提交前 `.githooks/pre-commit` 先拒绝装饰性分隔注释(`// ====` / `// ────`,只做视觉分组不带「为什么」),再跑 `cargo fmt --check` 与 `cargo clippy -D warnings`(含 IMK 外壳,增量几十秒);
`.githooks/pre-push` 在推之前跑全 workspace 测试。外部 PR 走同一套 `ci.yml`,不过不合。
供应链:workflow 里的 actions 一律钉到 commit(注释写对应标签),`.github/dependabot.yml` 每周一提 Cargo 与 actions 的更新 PR;`audit.yml` 每周与 Cargo.lock 变动时跑 `cargo audit`;
cargo 命令全 `--locked`(含 `bundle.sh` 与 `build.ps1`)。普通 CI 只有 `contents: read`,checkout 不留凭据;release 的 secrets 不放顶层 env,只注入用它的那一步。
发版门禁(`release.yml` 第一步):版本号与标签一致且不带 `-dev`;标签指向的提交必须在 `main` 上(`git merge-base --is-ancestor`);产品数据下载后按 `data` Release 的 `SHA256SUMS` 校验,摘要写进 `build-info.json` 的 `data_sha256`。
**正式版前还欠**:产品数据改成不可变 tag 并在仓库里锁定版本(现在滚动覆盖,同一源码 tag 重跑可能拿到不同数据)、安装包内容验证(词库 / 模型 / 许可齐不齐、签名校验)。
## 两个 workflow
| 文件 | 触发 | 做什么 |
|---|---|---|
| `.github/workflows/ci.yml` | push main、PR | Linux 上 `cargo fmt --check` / clippy / test,排除 `qingjian-macos`(IMK 外壳只能在 macOS 编译,macOS runner 计费是 Linux 的 10 倍) |
| `.github/workflows/release.yml` | 推 `v*`(全部平台)或 `macos-v*` / `windows-v*` / `linux-v*` / `android-v*`(单平台)标签 | `prepare` 门禁并建草稿 Release → `macos` / `windows` / `linux` / `android` 并行打包上传 → `publish` 合并摘要、转正、生成 `releases.json`、触发官网构建(见上文) |
## 产品数据从哪来
词库、语言模型、释义表(`data/generated/*.qj`、`dicts/*.qj`、英文词表)不在 git 里,体积约 90 MB 且由本机数据管道生成。
它们发在仓库里一个个**不可变**的预发布 Release 上:`data-v1`、`data-v2`……每次数据重生成发一个新号、从不覆盖
(预发布不会成为 GitHub 的 latest,官网取 latest 时不会拿到它)。仓库里 `tools/release/data.lock` 钉住当前要用的标签与资产的 SHA-256,
跟用到新数据的代码同一个提交进去:checkout 哪个提交就拿到它对应的那版数据,离线自编译的人不会因为我们改了数据而编出坏包。
- `tools/release/data-bundle.sh`:把 `data/generated/`、含章·知微 `hanzhang-zhiwei-small.qjm` 与含章·通变 `hanzhang-tongbian-small.qjm` 装进唯一附件 `qingjian-data.tar.gz`
(导出的三件套放各自模型目录,`tools/release/pack-model.sh` 打成 `.qjm` 容器)。训练中间产物不进入公开包;发到下一个 `data-vN`(`--tag` 可指定,已存在就拒绝),然后改写 `data.lock`。
- `tools/release/data-fetch.sh`:按 `data.lock` 下载(有 gh 用 gh,没有就 curl 直连)、按锁文件里的哈希校验,
数据包解到 `data/generated/` 与两份模型各自的 `data/models/<标识>/`。旧锁仍可读取分离附件。`release.yml` 各平台的 job 和离线自编译走同一个脚本;
`bundle.sh`、`qingjian.iss` 与 Linux 打包脚本将两份模型分别装入对应目录,产品端优先使用通变。
标签与各资产哈希记进 `build-info.json`(`data_tag` / `data_sha256` / `model_sha256` / `p2c_model_sha256`)。
数据重生成之后(重跑 lexicon / bigram / gloss-gen export)或模型重训之后跑一次 `data-bundle.sh`(三件套比 `.qjm` 新会自动重打),
把锁文件的改动提交(`chore(data): 数据 data-vN`),否则 CI 打的包还是锁文件指的旧数据。模型文件缺失或哈希不符时 CI 会失败,不会静默地发出错数据的包。
2026-09-16 之前用的是滚动覆盖的 `data` Release,已冻结不再更新。
## 版本索引的签名
软件内「检查更新」只认签过名的 `releases.json`(设计见 `docs/design/update.md`)。`publish-releases-json.sh` 生成索引后用 `tools/release-sign` 签出 `releases.json.sig`,
两个文件一起挂到本次 Release 与 GitHub latest;官网构建时原样拷到 `https://qingjian.app/releases.json` 与 `.sig`。
- 私钥是仓库 Secret `QINGJIAN_INDEX_SIGNING_KEY`(base64 的 32 字节);没配时 `release.yml` 的门禁直接失败。维护者本机留一份在 `~/.config/qingjian/index-signing.key`(不进仓库)。
- 换钥:`cargo run -p qingjian-release-sign -- keygen --out <新文件>`,把打印的公钥加进 `crates/qingjian-update/src/index/signature.rs` 的 `PUBLIC_KEYS`(新旧并列),
发一两个版本后再换 Secret、去掉旧公钥。直接换 Secret 会让所有已装版本收不到更新。
- 改了 CHANGELOG 后用 `publish-releases-json.sh` 重刷索引同样要带这个环境变量。
## 签名与公证
没有证书时 CI 照样出包(ad-hoc 签名,Release 说明里自动加一句「首次打开要在隐私与安全性里放行」)。
Apple Developer 账号有了以后,在仓库 Secrets 里配齐 `release.yml` 头部注释列的七个值(.p12 与 .p8 都 base64),
下一次发版就是签名 + 公证 + 钉票据的包,用户下载双击即装。`bundle.sh` 本身通过 `QINGJIAN_SIGN_IDENTITY` /
`QINGJIAN_INSTALLER_IDENTITY` / `QINGJIAN_NOTARY_PROFILE` 三个环境变量工作,本机有证书也能这样打。
**Android**:APK 必须签名才装得上,`release.yml` 的 android job 没配密钥库就失败。密钥库只生成一次,**丢了或换了,已装的用户就不能覆盖升级**(只能卸载重装、丢掉学习数据),
本机与密码管理器各留一份备份。生成与配置:
```sh
keytool -genkeypair -v -keystore qingjian-release.jks -alias qingjian -keyalg RSA -keysize 4096 -validity 36500 -dname "CN=Qingjian"
base64 -i qingjian-release.jks | gh secret set QINGJIAN_ANDROID_KEYSTORE
gh secret set QINGJIAN_ANDROID_KEYSTORE_PASSWORD # 下面三个按提示粘贴
gh secret set QINGJIAN_ANDROID_KEY_ALIAS # qingjian
gh secret set QINGJIAN_ANDROID_KEY_PASSWORD
```
本机打签名包:把这四个值设成同名环境变量(`QINGJIAN_ANDROID_KEYSTORE` 写 .jks 的路径)再 `./gradlew assembleRelease`;没设时出未签名的 `app-release-unsigned.apk`。
## releases.json:官网下载页的数据源
`tools/release/releases_json.py` 从 `CHANGELOG.md`(日期、渠道、更新日志)、GitHub Releases API(附件、地址、大小)
与每次发布的 `SHA256SUMS` / `build-info.json`(每个包的 sha256、提交哈希、构建时间、工具链)生成,挂在每个版本的 Release 上;官网固定取
`https://github.com/<repo>/releases/latest/download/releases.json`(仓库私有期间要带令牌走 API 下载附件)。
结构对应官网 `src/lib/releases.ts` 里的 `Release` / `Asset` 类型:
```json
{
"schema_version": 1,
"generated": "2026-09-07T12:00:00Z",
"repository": "owner/qingjian",
"latest": "0.1.3",
"releases": [
{
"version": "0.1.3",
"date": "2026-09-18",
"channel": "stable",
"notes": ["整句输入:……", "候选旁有词性和译词……"],
"mirrors": [{ "name": "夸克网盘", "url": "https://pan.quark.cn/s/…" }],
"commit": "869ad00…(40 位)",
"built_at": "2026-09-07T08:38:12Z",
"toolchain": "rustc 1.96.0 (ac68faa20 2026-05-25)",
"assets": [
{ "platform": "macos", "arch": "Apple Silicon", "cpu": "arm64", "file": "qingjian-0.1.3-macos-arm64.pkg",
"url": "https://github.com/owner/qingjian/releases/download/macos-v0.1.3/qingjian-0.1.3-macos-arm64.pkg",
"size": 35989277, "sha256": "…" },
{ "platform": "macos", "arch": "Intel", "cpu": "x86_64", "file": "qingjian-0.1.3-macos-x86_64.pkg", "url": "…", "size": 36172871, "sha256": "…" }
]
}
]
}
```
- `releases` 从新到旧,`latest` 是第一条的版本号;官网「当前版本」取它,历史版本列表就是整个数组。
- `channel` 是 `alpha` / `beta` / `rc` / `stable`,显示成什么字由官网定;`commit` / `built_at` / `sha256` 给用户核对下载的包,下载页应显示 sha256 与提交短哈希。
- 安装包文件名固定为 `qingjian-<版本>-<平台>-<cpu>[-setup].<扩展名>`(全小写;平台 `macos` / `windows` / `linux` / `android`,cpu `arm64` / `x86_64`,2026-09-17 定,
包管理器的地址模板与检查更新都靠它稳定)。平台与架构由文件名判定(`ASSET_KINDS`,同时认 0.1.2 及更早的 `Qingjian-<版本>-arm64.pkg` / `-Setup.exe` 旧名),
`arch` 给人看(Apple Silicon / Intel / x64),`cpu` 给程序比对。
- `schema_version` 现在是 1:只加字段不用动,改了已有字段的含义或结构才加一。
- `mirrors` 来自 CHANGELOG 那一节的「网盘:」行,没有就是空数组;官网在下载按钮旁单独显示。
- `SHA256SUMS` 与 `releases.json` 自己不列进 `assets`。
- 官网侧要做的:构建时下载这个文件替代手写的 `releases` 数组(与拉 `docs/user` 的 `sync-docs.mjs` 同一处、同一个令牌),
`downloadsOpen` 开关仍由官网自己控制。
## 本机打包
`apps/macos/scripts/bundle.sh --pkg` 打本机架构;`QINGJIAN_TARGET=x86_64-apple-darwin` 交叉编译 Intel 包(要先 `rustup target add`,
本机不需要时不必装,CI 上两个都打)。成品在 `target/pkg/qingjian-<版本>-macos-<arch>.pkg`,每个架构一个工作目录,连着打互不覆盖。