Easydict 发布流水线改造:以 changelog 版本文件统一 GitHub Release 与 Sparkle 应用内更新日志
【免费下载链接】Easydict一个简洁优雅的词典翻译 macOS App。开箱即用,支持离线 OCR 识别,支持有道词典,🍎 苹果系统词典,🍎 苹果系统翻译,OpenAI,Gemini,DeepL,Google,Bing,腾讯,百度,阿里,小牛,彩云和火山翻译。A concise and elegant Dictionary and Translator macOS App for looking up words and translating text.项目地址: https://gitcode.com/gh_mirrors/ea/Easydict
Easydict 的发布团队在 2026-09-07 完成了一次发布内容体系重构:让仓库内的changelog/<version>.md成为 Release Notes 的唯一正文源,GitHub Release 保存其原始 Markdown,Sparkle 应用内更新日志(appcast)保存其确定性渲染的 HTML,并配套了校验、快照、漂移检测与回归测试。阅读本文,你将掌握这套「单源多表面」发布方案的完整设计:changelog 文件规范、Python-Markdown 固定渲染器与 SHA-256 快照机制、GitHub Draft 冻结门禁、Sparkle appcast 一致性校验,以及已发布版本修订的安全流程。本次重构只迁移验证了 2.22.0,未发布任何新版本。
背景:多正文源导致的发布不一致
在本次重构之前,Easydict 的发布流程存在多个「正文来源」:临时 notes 文件、GitHub 自动生成正文(--generate-notes)、Sparkle appcast 从文件或线上 Release 抓取正文。多个来源叠加不同的生成时序,会导致三个发布表面——GitHub Release 正文、Sparkle 应用内更新日志、人工编辑内容——彼此漂移且难以察觉。
为此,发布团队在 2026-09-07-changelog-release-notes.md 中明确了目标:
changelog/<version>.md成为 Release Notes 唯一正文来源,GitHub Release 与 Sparkle appcast 均验证其派生结果,并用 2.22.0 完成迁移验证。
整个改动限定在changelog/、.agents/skills/release-easydict/(发布 Skill 与脚本)、scripts/、docs/范围内,明确禁止发布新版本、编辑线上 2.22.0 Release、push 或移动 Tag。
changelog 目录:按版本保存的唯一可编辑正文
新增的 changelog/README.md 定义了文件约定:
- 文件名必须为
<version>.md,例如2.22.0.md,版本使用x.y.z格式; - 正文使用 UTF-8 编码和 LF 换行,不允许YAML front matter,也不需要Release 标题(GitHub Release 标题独立维护);
- 正文内 PR 与比较范围链接使用简短 Markdown 标签(如
[#1285](https://github.com/tisfeng/Easydict/pull/1285)),不直接展示完整裸 URL; - 发布开始前可以直接编辑 Markdown,编辑后必须重新运行验证;发布状态冻结后发生的改动会触发哈希不一致,必须重新开始或明确重建 Draft,不能静默沿用旧 appcast。
迁移后的 changelog/2.22.0.md 与线上 2.22.0 Release 正文逐字一致,包含## What's Changed(28 条 PR 条目)、## New Contributors(10 位新贡献者)与**Full Changelog**: 2.21.0...2.22.0三个区块,是后续版本正文的格式范本。
单一正文源的数据流架构
changelog/README.md 中用一张关系图明确了发布表面的派生关系:
changelog/<version>.md ├── GitHub Release body(原始 Markdown) └── Sparkle appcast description(确定性渲染后的 HTML)设计意图(来自 history 文档)可以概括为三点:
- GitHub 保存原始 Markdown,与 changelog 文件逐字一致;
- Sparkle 保存固定渲染器产生的 HTML,保证应用内展示稳定;
- 发布状态只保存哈希与渲染器身份,不再保存另一份可编辑 notes,从结构上消灭第二正文源。
版本 Tag 仍指向版本构建快照,不因正文展示格式产生额外移动;正文必须在启动发布前提交到dev分支。
确定性渲染与哈希快照:release_notes.py 核心实现
统一的正文工具是 release_notes.py,它同时承担校验、渲染、快照和远端比对四类职责。
严格的文件校验
read_notes()对每个 changelog 文件执行严格门禁:版本号必须匹配^[0-9]+\.[0-9]+\.[0-9]+$;文件名必须等于<version>.md;正文必须是合法 UTF-8、不含 BOM、不含 CR/LF 混合换行、不含 NUL 字节、非空、不以---\n开头。任何一条不满足都会抛出ReleaseNotesError并中断发布。
固定渲染器身份
require_renderer()强制要求 Python-Markdown 版本等于EXPECTED_MARKDOWN_VERSION = "3.8.1",缺失或版本不匹配立即失败,避免不同发布机器渲染出不同 HTML。依赖清单见 requirements.txt(Markdown==3.8.1)。渲染器身份字符串为:
Python-Markdown/3.8.1:extra,sane_lists,easydict_bare_urlrender_markdown()使用extra、sane_lists扩展,并注册了自定义的BareUrlExtension。该扩展专门处理裸 URL:BareUrlInlineProcessor只作用于 Markdown 文本节点,跳过位于<a>、<code>、<pre>原始 HTML 内部的链接,并会修剪 URL 尾部标点(.,;:!?]})以及多余的右括号,随后生成带href的<a>元素。
SHA-256 快照
notes_metadata()输出包含schema_version、version、path、markdown_sha256、renderer、html_sha256六个字段。正文哈希只做「传输级」归一化——normalize_body()把 CRLF/CR 统一为 LF 并去掉末尾单个换行——其余空白与内容必须逐字节一致。快照通过snapshot子命令写入发布状态 JSON,verify-state子命令在 resume、Draft、publish 和验证阶段重新比对,防止人工编辑后继续使用旧 appcast。
release_notes.py暴露五个 CLI 子命令,全部通过--file与--version定位正文:
| 子命令 | 作用 |
|---|---|
validate | 严格校验文件并输出完整元数据 JSON |
render | 渲染 HTML,可指定--output落盘 |
snapshot | 首次写入或校验发布状态快照 |
verify-state | 重算元数据并与状态文件逐字段比对 |
verify-release | 通过gh release view读取线上 Release,比对正文并输出markdown_sha256 |
其中verify-release会同时检查tagName与版本一致、Release body 与 changelog 归一化后完全相等,并支持--input-json离线注入 Release JSON 便于测试。
GitHub Release 接入:Draft 强制冻结 Markdown
release_content.py 负责把 changelog 固化到 GitHub Release 表面,并提供capture、apply、validate-pr-policy三个子命令。
Release 标题规范
validate_release_title()强制标题格式:
<version> <emoji> <type>: <summary>其中type与 emoji 一一对应:feat→✨、fix→🐞、security→🔒、perf→🚀、chore→🔧;标题不得超过 120 字符且只能使用英文(含非拉丁字母即报错)。
PR 条目解析与去重
parse_change_entries()从正文解析形如- title by @author in [#123](https://.../pull/123)的条目,同时兼容裸 PR URL 引用,并做三重校验:Markdown 链接的 label 数字与 URL 中的 PR 号必须一致;同一 PR 号不得重复出现;正文中至少存在一条 PR 条目。validate-pr-policy子命令还会逐个检查 PR 是否属于应忽略的 bot PR,若正文包含被忽略的 bot 条目则直接阻断(对应测试中的「缺失/格式」门禁)。
Draft 冻结与远程验证
apply子命令先校验标题与正文,再通过gh release view读取 Draft 并调用validate_draft()比对 body 与 changelog;--execute才会真正调用gh release edit更新标题,随后重新抓取Draft 做二次远程验证,确保标题写入成功。发布脚本从此不再回退到--generate-notes。
Sparkle appcast:从 changelog 渲染 description
release-appcast.py 把 Sparkle 应用内更新日志完全收敛到 changelog:
set-link:将目标版本 item 的<description>替换为render_markdown(read_notes(...))渲染结果,移除旧的releaseNotesLink,并写入fullReleaseNotesLink;set-description:仅替换目标 item 的 description;find-previous-beta/promote-previous-beta:处理 beta 版本升级为稳定版时对前序 beta item 的 channel 清理;validate:对生成的 appcast 做全字段严格校验。
validate子命令的校验面相当完整,它要求:
- appcast 版本集合与顺序不变(新 item 置于首位,旧 item 除 beta 转正外字节级不变,通过
canonical_item()归一化空白后比对); - item
title等于版本号、存在pubDate; fullReleaseNotesLink或releaseNotesLink等于预期的 Release URL;<description>与render_markdown(read_notes(...))完全相等;minimumSystemVersion为13.0;channel 与目标(beta或稳定)一致;enclosure的 URL 等于下载地址、length等于归档文件真实字节数、type为application/octet-stream,且必须存在 Sparkle EdDSA 签名(sparkle:edSignature)。
这些约束可以在仓库 appcast.xml 中直接对照验证——例如 2.22.0 item 的<description>就是 2.22.0 changelog 渲染后的 HTML,fullReleaseNotesLink指向对应 Release Tag,enclosure携带签名与长度。
已发布版本的修订与同步:release-notes-sync.py
针对「已发布版本如何修订正文」的问题,release-notes-sync.py 提供了端到端的同步工具,核心约束是不允许单边修改。
sync-notes <version>命令默认读取changelog/<version>.md,流程为:
- 读取并渲染 changelog,校验 UTF-8/LF/文件名;
- 通过
gh api --include抓取线上 Release(拒绝仍为 Draft 的版本,并要求 ETag 用于条件更新); - 抓取
main、dev两个分支上的远端appcast.xml(读取前后各取一次分支 head,防止读取期间远端变更); - 生成预览 JSON:包含 Release body 的 unified diff、
release_update_required、两个分支 appcast 是否需更新、本地分支是否落后远端; --execute阶段:校验origin确为 GitHub 仓库、在临时 worktree 中生成 main 分支的 appcast 提交、合并进 dev、--force-with-lease原子推送两个分支、按 ETag 条件更新 Release body,最后重新拉取远端验证三个表面全部一致。
同步成功后,状态 JSON(默认写入.tmp/release/<version>/state/notes-sync.json)记录notes_sha256、html_sha256、status与各阶段结果;任一步骤失败都会把failed_stage与错误写入状态文件后中止。因此文档给出的修订路径是:先修改并提交对应 changelog,再在一次明确授权的维护任务中同步 GitHub Release 与 appcast——单边手动修改 GitHub 或 appcast 会被校验识别为「内容漂移」并停止发布。
测试与验证结果
本次重构的验证横跨发布脚本测试、Skill 测试与线上只读比对(数据来自 history 文档 与 exec-plan 文档):
- 发布脚本测试 26 个通过(
python3 -m unittest discover -s scripts/release/tests -p 'test_*.py'); - Release Skill 测试 23 个通过(
python3 -m unittest discover -s .agents/skills/release-easydict/tests -p 'test_*.py'),覆盖缺失/格式、快照漂移、远端正文漂移、复杂 Markdown、appcast 漂移和 Draft--notes-file行为; - 2.22.0 线上 Release 只读比对通过,正文哈希为
0f5dcd6d3cd492a2a484aec80687176638dc9a6d65bb636032d913182f958799; - 仓库
appcast.xml的 2.22.0 description 与 changelog 渲染结果完全一致,并使用公开条目的 build 65、ZIP 长度、URL、channel 与签名完成严格校验; bash -n、jq -e . scripts/release/asc-workflow.json、asc workflow validate、python3 -m py_compile、skill-creatorquick validation、git diff --check全部通过;- 独立 reviewer 三轮复核确认冻结门禁、Git index/工作树漂移和 Markdown/raw HTML 链接边界均已闭环;独立 tester 在最终快照上重复执行全部测试通过;
xcodebuild未运行——本次只修改发布脚本、测试与文档,不涉及 Xcode 编译源码。
变更影响与边界
本次改动影响的仓库范围(来自 history 文档):
changelog/:新增 README 与 2.22.0 迁移文件;.agents/skills/release-easydict/:发布 Skill 脚本、测试与文档;docs/exec-plans/、docs/histories/2026-09/:计划与执行记录;scripts/(依 exec-plan 允许修改路径,同时涉及相关脚本目录)。
远程状态保持零写入:未创建或发布新版本、未编辑 2.22.0 GitHub Release、未上传资产、未创建或移动 Tag、未 push。需要留意的是,本次迁移仅覆盖 2.22.0 及以后版本,2.22.0 之前的旧版本不在 changelog 迁移范围内。
如果你需要在本地复现这套机制的校验效果,可以基于 release_notes.py 对 changelog/2.22.0.md 运行validate与render子命令,再对照 appcast.xml 中 2.22.0 item 的<description>,即可直观理解「原始 Markdown 进、确定性 HTML 出、哈希锁状态」的完整链路。
【免费下载链接】Easydict一个简洁优雅的词典翻译 macOS App。开箱即用,支持离线 OCR 识别,支持有道词典,🍎 苹果系统词典,🍎 苹果系统翻译,OpenAI,Gemini,DeepL,Google,Bing,腾讯,百度,阿里,小牛,彩云和火山翻译。A concise and elegant Dictionary and Translator macOS App for looking up words and translating text.项目地址: https://gitcode.com/gh_mirrors/ea/Easydict
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考