fix(version): 补全开发快照记录与同步交付闭环
build / build (push) Successful in 1m3s

收录 main、注释开发快照和完整运行 SHA;开发说明绑定原提交,补记 0.1.1–0.1.25。
进行中同步按仓库合并,完成后重新执行;启动入队,部署脚本读回版本证据。
开发快照独立于正式标签与 Release,既有冲突标签不移动。

已运行:整仓 make verify,Go 673 通过、前端 274 通过,92 个已声明可选跳过,必需跳过 0。
隔离 PostgreSQL 重复同步队列用例及 -race 通过;未更新现有 8088、未修改生产问答。
This commit is contained in:
姚章浩
2026-10-18 18:11:49 +08:00
parent 90901a4458
commit 013ce4a24e
19 changed files with 860 additions and 32 deletions
+282 -1
View File
@@ -73,9 +73,290 @@
> **版本怎么走**(两行就够):
> · **正式版本** = 下面那条 `## [x.y.z] - 日期`,是**发版事实**,由人批准后打注释 tag `vX.Y.Z`;
> · **开发版本自动递增** = `<正式版本的主.次>.<自那个 tag 以来的提交数>`(正式 `0.1.0` 之后第 12 个提交 =
> `0.1.12`),由 `scripts/version.py` 从 git **算出来**,**不写进本文件** ⇒ 每个提交都有自己的版本号,
> `0.1.12`),由 `scripts/version.py` 从 git **算出来**;开发交付的独立说明写入本文件“开发快照”章节,并打 `snapshot/vX.Y.Z` 注释标签 ⇒ 每次开发交付均可追溯,
> 但"发布版本"仍然只能由人定(CI 不会自动升版本、不会打 tag)。
## 开发快照 snapshot/v0.1.26 - 2026-10-18
### Fixed
- 版本记录收录已交付开发快照与当前运行提交,不再只同步 main;开发版本显示对应的独立变更说明。
- 修复同步重复返回旧已完成任务:进行中同步合并,完成后允许新一轮同步。
- 构建启动后自动请求版本同步;部署后独立读回当前完整 SHA、开发标签与变更说明,缺失时不宣称交付完成。
### Added
- 增加开发快照计划、显式发布及远端读回工具;历史说明按原提交补记,正式 tag 与 Release 保持独立授权。
## 开发快照 snapshot/v0.1.25 - 2026-10-18
目标提交:90901a4458054aca721047c9a2896f2130d46b5c
历史开发快照补记,不改变原提交、原提交时间或正式发布状态。
### Added
- 问答记录新增“本周”快捷查询,默认展示北京时间本周数据;所选范围自动读取全部记录,校验完整数量后展示,不再需要分页或加载更多。固定展示完整字段,空列不再隐藏,中文原文直接展示、外语未翻译明确标记;保留源端有界传输、取消和失败保护。
## 开发快照 snapshot/v0.1.24 - 2026-10-18
目标提交:3566f208e0e15fdec2cc98c29f93c46f5f7c91ca
历史开发快照补记,不改变原提交、原提交时间或正式发布状态。
### Added
- 问答记录新增年份与自然周选择,周次展示起止日期,支持跨年及每年实际周数;按北京时间周一至周日填入查询范围,默认本周且仅查询截至当前的数据。保留自定义时间与原查询按钮,浏览器时区不改变查询边界。
## 开发快照 snapshot/v0.1.23 - 2026-10-18
目标提交:2ea2111420e501fec37e0ea0c4bcd3cd8c866403
历史开发快照补记,不改变原提交、原提交时间或正式发布状态。
### Fixed
- 修复通过带端口的 HTTP 地址访问问答运营时,正常同源操作被误报“拒绝跨站配置请求”:网页代理保留原始 Host 及端口,使缺少 Fetch Metadata 的浏览器也能正确执行 Origin 校验。保留后端跨站、异端口、伪造转发头与缺失操作意图的拒绝保护。
## 开发快照 snapshot/v0.1.22 - 2026-10-18
目标提交:828c2c5fa0d4e3e0eee3987085a4d782872f7e95
历史开发快照补记,不改变原提交、原提交时间或正式发布状态。
### Added
- 问答运营按已确认口径显示知识覆盖率、回答正确率及原始分子/分母、排除数量与构成。使用完整筛选范围和当前有效人工优先结论,精确规则命中的未覆盖可进入覆盖分母;部分覆盖不算完全覆盖,待确认/未评估/失效/不计入按对应指标排除,零分母显示“暂无可计算数据”。返回独立规则版本,AI 模型与绩效范围仍未接入。
- 新增覆盖复检历史、已采集输入快照和完整筛选范围质量数量。批量统计只传指纹与规则命中元数据,原文在读取问答/复核时采集,未采集版本明确提示而非伪造空快照;源事务时点阻止延迟旧扫描回退新输入。基础数量与本期经确认新增的质量比例分开显示;不接 AI 模型、不定义绩效分母、不回写 DobotAI 原问答。
## 开发快照 snapshot/v0.1.21 - 2026-10-17
目标提交:4b3aaff3219e78ae3dfd5d12ba36f82e323a21ba
历史开发快照补记,不改变原提交、原提交时间或正式发布状态。
### Added
- 问答详情支持治理侧人工复核及不可变审计:覆盖、正确性、产品分类、问题类型、根因与理由由管理员明确填写,操作人取登录身份;保存后独立读回复核历史。保留源端判断和自动规则,输入变化使旧人工结论失效,已观测的内容回退不自动恢复旧结论;并发修改冲突、重复请求不重复留档。
- 新增覆盖复检历史、已采集输入快照和完整筛选范围质量数量。批量统计只传指纹与规则命中元数据,原文在读取问答/复核时采集,未采集版本明确提示而非伪造空快照;源事务时点阻止延迟旧扫描回退新输入。仅提供数量,不接 AI 模型、不定义质量率或绩效分母、不回写 DobotAI 原问答。
## 开发快照 snapshot/v0.1.20 - 2026-10-17
目标提交:9556db1ef01add21064553f64be6a7e25f838e9e
历史开发快照补记,不改变原提交、原提交时间或正式发布状态。
### Added
- 「使用统计 → 个人月明细」按北京时间自然月及截止时点展示当前应用可见名册的人员用量(包含本源零记录),支持部门含子部门、区域/待核对状态、姓名/完整部门路径和已有提问/零记录筛选。稳定身份关联去重、不可关联问答单列、过期组织版本阻断翻页;源端姓名可缺失并提示重新同步,不按姓名匹配身份。姓名筛选通过有界只读 POST 请求体传递、不进入 URL,原始员工标识不返回浏览器;名册不作为在职/考核分母,当月进度不作为绩效结论。
## 开发快照 snapshot/v0.1.19 - 2026-10-17
目标提交:56b8cabed9213911b6beb4aeaac9baa22e872a00
历史开发快照补记,不改变原提交、原提交时间或正式发布状态。
### Added
- 问答运营按稳定身份关联当前治理通讯录,缺失部门/区域补全并保留源快照及目录/区域版本;空列判断包含完整筛选结果,不混合配置已变化的分页。新增完整时间/关键词范围的使用统计,问答计次、名册内人员去重,跨区/未匹配/身份缺失分列,不输出绩效结论;不回写 DobotAI 原表。
- 新增治理侧通讯录实时桥接,复用既有周报读取能力,向源端读取真实缺失祖先以补齐部门树;不改旧周报脚本、配置或输出。私有 JSON 与受控子进程执行器拒绝不完整数据、模拟同步、公开文件和异常输出,超时清理临时人员文件。同步默认关闭,容器启用仍需批准的既有周报 SDK/配置及兼容 Python 运行时挂载。
- **「问答运营 → 区域使用」区域归属配置切片**:按稳定部门 ID 搜索和勾选完整路径,预览去重归属人数,保存版本后重新读取;父部门包含子部门、子部门显式配置优先,多部门跨区域人员单独待核对。预览不写入,过期版本和不完整部门树被阻断。基础使用统计见本期新增,不代表绩效验收;翻译及 AI 评估模型接入按用户要求暂缓。
- **「问答运营 → 问答记录」首个开发切片**:在现有导航中查看 DobotAI 2.0 钉钉问答,支持半开时间范围、原问题关键词、稳定游标分页和完整详情;按整个筛选结果隐藏空列,反馈聚合不放大问答条数。外语翻译、AI 评估尚未接入,不标为第一阶段完成。
## 开发快照 snapshot/v0.1.18 - 2026-10-17
目标提交:cc4906b6a9f33683a5c0017d83b24d96d5c997b1
历史开发快照补记,不改变原提交、原提交时间或正式发布状态。
### Added
- 「问答运营 → 区域使用」支持管理员从网页发起持久化通讯录同步,重复请求重放、进行中操作合并;按服务器固定范围注册后台执行器,未接线或未检测到心跳时不创建新任务。界面显示真实任务状态、不编造百分比;失败保留旧快照与区域规则,同步完成不覆盖未保存草稿。旧执行器丢失租约后不能激活结果,重启接管和已提交快照的续跑使用原任务。
- 新增治理侧通讯录实时桥接,复用既有周报读取能力,向源端读取真实缺失祖先以补齐部门树;不改旧周报脚本、配置或输出。私有 JSON 与受控子进程执行器拒绝不完整数据、模拟同步、公开文件和异常输出,超时清理临时人员文件;问答组织关联尚未接入。同步默认关闭,容器启用仍需批准的既有周报 SDK/配置及兼容 Python 运行时挂载。
- **「问答运营 → 区域使用」区域归属配置切片**:按稳定部门 ID 搜索和勾选完整路径,预览去重归属人数,保存版本后重新读取;父部门包含子部门、子部门显式配置优先,多部门跨区域人员单独待核对。预览不写入,过期版本和不完整部门树被阻断。当前不是使用/绩效统计验收,翻译及 AI 评估模型接入按用户要求暂缓。
## 开发快照 snapshot/v0.1.17 - 2026-10-17
目标提交:6adfdcdd4fe0e5c35018bf0e4857c203dd24e5d1
历史开发快照补记,不改变原提交、原提交时间或正式发布状态。
### Added
- 区域归属表支持长部门路径换行,避免继承全局不换行规则后将生效区域与归属依据列挤出桌面面板。
## 开发快照 snapshot/v0.1.16 - 2026-10-17
目标提交:5874134e049b0896a202b41d9d400d1be5386f89
历史开发快照补记,不改变原提交、原提交时间或正式发布状态。
### Added
- 新增治理侧通讯录实时桥接,复用既有周报读取能力,向源端读取真实缺失祖先以补齐部门树;不改旧周报脚本、配置或输出。私有 JSON 与受控子进程执行器拒绝不完整数据、模拟同步、公开文件和异常输出,超时清理临时人员文件;网页触发的后台同步队列及问答组织关联尚未接入。
## 开发快照 snapshot/v0.1.15 - 2026-10-17
目标提交:dab74b706b5b87da3ecb71a8e0f6b6caa5fba4ad
历史开发快照补记,不改变原提交、原提交时间或正式发布状态。
### Added
- **「问答运营 → 区域使用」区域归属配置切片**:按稳定部门 ID 搜索和勾选完整路径,预览去重归属人数,保存版本后重新读取;父部门包含子部门、子部门显式配置优先,多部门跨区域人员单独待核对。预览不写入,过期版本和不完整部门树被阻断。当前不是实时通讯录同步或使用/绩效统计验收,翻译及 AI 评估模型接入按用户要求暂缓。
- 新增治理侧组织快照与区域修订迁移、受确认保护的后台导入命令;不修改 DobotAI 原表或钉钉组织结构。
## 开发快照 snapshot/v0.1.14 - 2026-10-17
目标提交:fd04bf7886aa4eba68b247dd7c1092894a5282da
历史开发快照补记,不改变原提交、原提交时间或正式发布状态。
### Added
- **「问答运营 → 问答记录」首个开发切片**:在现有导航中查看 DobotAI 2.0 钉钉问答,支持半开时间范围、原问题关键词、稳定游标分页和完整详情;按整个筛选结果隐藏空列,反馈聚合不放大问答条数。外语翻译、AI 评估、人员区域映射尚未接入,不标为第一阶段完成。
- 治理侧重新检查答案:包含 `暂无已发布` 即未覆盖,引用和源端 covered 不能抵消;未命中仍待评估,源端规则单独保留作对照。独立只读源默认关闭、租户后端固定、管理员会话鉴权,源故障不回退治理主库;安全告警明确保留 TEMP/TLS 尚未整改状态。
- **「版本记录」页**(一级入口):只读时间线追踪**远端 main 可达的全部提交**(含合并提交与已并入 main 的侧分支提交),按提交时间倒序显示标题/正文、`[正式版本] / [开发版本] / [历史提交]` 徽标、短 SHA、提交内变更记录与 `[查看提交]`/`[查看 tag]`;支持「全部 / 正式 / 开发 / 历史」筛选与游标翻页,窄屏单列。
## 开发快照 snapshot/v0.1.13 - 2026-10-14
目标提交:c1d49adb622f3bf18e51bddb415795a894ed227e
历史开发快照补记,不改变原提交、原提交时间或正式发布状态。
### Changed
- 补三Agent交接闭环工作流(状态机、快照tag与验收模板)。
## 开发快照 snapshot/v0.1.12 - 2026-10-14
目标提交:9a8a3e77956149d116360bbb561efd67e20fb062
历史开发快照补记,不改变原提交、原提交时间或正式发布状态。
### Fixed
- **登记钉钉知识库失败(HTTP 409 `space_registration_conflict`)的真实原因与修复**:`source_connections.name` 有唯一约束,而早期路径登记的钉钉来源(`base_url` 为空、id 与 dws 现在推导的确定性 id 不同)被**归档后仍占着名字槽位** ⇒ 重新登记时 INSERT 撞唯一约束;处理器又把**所有**存储层错误都翻译成同一个 409,真实原因被吞掉(线上只能看到一个无细节的错误码)。现在:① 同名来源**已归档**时,把它改名腾位(`…(已下架 · <短ID>)`,归档行不显示、数据与审计一行不动);② 同名来源**仍在使用**时返回 `space_name_conflict` + 可行动提示(先「去掉来源」);③ 同一登记键指向别的空间且该行仍在用时返回 `space_identity_conflict`(已归档的行则允许就地改写 —— 这就是"去掉后再用 dws 重新获取");④ 其余内部故障改回 **500** `space_registration_failed`,不再伪装成冲突;前端优先显示后端 `hint`,并为这两个新码补了中文说明。
## 开发快照 snapshot/v0.1.11 - 2026-10-14
目标提交:2c7265efc47a2cd6b324bf278fa5ddb0c4a9fe57
历史开发快照补记,不改变原提交、原提交时间或正式发布状态。
### Changed
- 「系统设置 → 数据源配置」**收敛成两个入口**:**dws 导入**(按当前钉钉账号列出可访问的知识库与钉盘空间)与 **PP 导入**。AList(已由钉盘文件替代)、静态登记的钉钉知识库、本地手工导入与 PP legacy 都**不再出现在这一页**;已接入的这类来源用**归档**下架(`POST /api/v1/source-connections/{id}/archive`,管理员、幂等,带写意图头与幂等键):从列表、来源与目录、计划刷新与手动扫描里消失,**目录快照、发布记录与审计数据一行不删**。恢复用 `…/restore`(保持停用、不自动带回调度),或让 dws 重新登记同一来源 —— 钉钉空间登记是确定性 ID + `ON CONFLICT`,**原地复活同一行、不产生重复**。卡片上新增「去掉来源」按钮(管理员,带确认弹窗)。
- 迁移 `000047_source_connection_archive`:新增 `archived_at` / `archived_by` 与只覆盖未归档行的部分索引;列表、目录、计划刷新、手动扫描四类查询一律排除归档行(漏一条就等于入口没去掉)。
## 开发快照 snapshot/v0.1.10 - 2026-10-14
目标提交:64b32c2da64881b3c9bf6de6b8f9af3f833128fd
历史开发快照补记,不改变原提交、原提交时间或正式发布状态。
### Changed
- 「系统设置 → 数据源配置」默认只列 PP 资料与「可加入的来源」:AList 来源(已由钉盘文件替代)与静态登记的钉钉知识库来源(改为按当前钉钉账号发现)**不再默认列出**,收进「显示 AList/钉钉知识库来源(N)」折叠开关 —— **来源一个都没删**,展开后照旧可改刷新周期与启停,「来源与目录」里的内容不变。手动导入来源(本地手工验收)不在这次范围内,照旧显示。
## 开发快照 snapshot/v0.1.9 - 2026-10-14
目标提交:b7f7e4f12101569db5bb76af9d836043eefc342b
历史开发快照补记,不改变原提交、原提交时间或正式发布状态。
### Fixed
- **上线前护栏 + 事故复盘**:`scripts/deploy-api-web.sh` 现在先校验 `.env` 的键值形状(值里再出现 `KEY=` 或键名不合法 ⇒ **拒绝上线**),配置校验失败时也会给出"两行粘成一行"的线索。事故:`.env` 最后一行没有结尾换行、又被追加新键 ⇒ 两行粘成一行 ⇒ `VERSION_HISTORY_GITEA_BASE_URL` 变成非法值 ⇒ api/worker 按设计拒绝启动、崩溃重启(平台整体不可用)。已修数据、加护栏、加回归用例。
## 开发快照 snapshot/v0.1.8 - 2026-10-14
目标提交:4cb51108454c3559e8a6305ad3316f74ed4e2daf
历史开发快照补记,不改变原提交、原提交时间或正式发布状态。
### Fixed
- 「版本记录」页的链接改为**抓取与链接分离**:抓取仍走配置的(内网)地址,页面上的「查看提交 / 查看 tag」用 `VERSION_HISTORY_LINK_ORIGIN`(公网 origin)拼 —— 原先两者共用内网地址,内网外的浏览器点开是空白;另外 tag 记录原先不带链接(空 `href` 点下去没反应),现在补上链接、没有链接就不渲染该按钮。
## 开发快照 snapshot/v0.1.7 - 2026-10-14
目标提交:90db243a68a5d16d643dd0273614b93666d65ca7
历史开发快照补记,不改变原提交、原提交时间或正式发布状态。
### Fixed
- 前端入口与静态资源的缓存语义("新功能看起来没上线"与"页面偶尔裸奔/空白"的根因):
## 开发快照 snapshot/v0.1.6 - 2026-10-14
目标提交:062a3b3cb60ede5ebcca64a0f14df53fffa1f359
历史开发快照补记,不改变原提交、原提交时间或正式发布状态。
### Fixed
- 前端入口的缓存策略:`index.html` 此前没有缓存头,CDN/浏览器会启发式缓存它,于是**页面会一直停在旧构建上**(实测被腾讯云 EdgeOne 卡住:源站已是新包,公网入口仍发旧包,看起来像"新菜单/新页面没上线")。现在 `index.html` 每次回源校验(`no-cache, must-revalidate`)、带内容哈希的 `/assets/` 长缓存(`immutable`),旧哈希资源明确 404 而不是回退成 HTML。
## 开发快照 snapshot/v0.1.5 - 2026-10-14
目标提交:4d43a9386a8a97969a1568956213f6f17b13edfb
历史开发快照补记,不改变原提交、原提交时间或正式发布状态。
### Changed
- tag 之前的提交不再被编号 + 部署脚本注入构建身份。
## 开发快照 snapshot/v0.1.4 - 2026-10-13
目标提交:0974d8dfa2aad5650eefbcee3edb365a8afd4868
历史开发快照补记,不改变原提交、原提交时间或正式发布状态。
### Added
- **「版本记录」页**(一级菜单第 5 项):只读时间线追踪**远端 main 可达的全部提交**(含合并提交与已并入 main 的侧分支提交),按提交时间倒序显示标题/正文、`[正式版本] / [开发版本] / [历史提交]` 徽标、短 SHA、提交内变更记录与 `[查看提交]`/`[查看 tag]`;支持「全部 / 正式 / 开发 / 历史」筛选与游标翻页,窄屏单列。
- 新增只读接口 `GET /api/v1/build-info`(当前运行产物的构建身份)、`GET /api/v1/version-history`(快照游标分页,不返回伪造的 total)、`POST /api/v1/version-history/sync`(仅管理员手动同步,并发同步复用同一作业)。
- 新增迁移 `000046_version_history`(提交记录 + 同步状态):`commit_sha` 是唯一身份,**版本号不是唯一键** —— 两个提交算出同一个开发号时各占一行,用 SHA 区分。
- 「当前运行」只认运行产物注入的**完整 SHA 精确匹配**:匹配到就标该条;开发构建显式标注"开发构建";拿不到标识就显示"当前运行版本未知",**不拿最新 main 或最新 tag 顶替**;CI 成功不等于已部署。
### Security
- 版本记录同步**匿名只读**远端仓库(**不使用任何凭据**),来源限配置里批准的 origin 与固定仓库:拒绝任意 URL、拒绝跨主机重定向,单个响应设大小上限与超时、5xx/429 才有退避重试。
- 提交正文与变更记录一律按**纯文本**渲染(不产生 HTML/脚本注入面);页面不含打 tag / 发布 / 部署入口;同步动作仅管理员可用,服务端再次判角色。
## 开发快照 snapshot/v0.1.3 - 2026-10-13
目标提交:ad9c5d20d467a077e88bbfe63b1a56355d263e51
历史开发快照补记,不改变原提交、原提交时间或正式发布状态。
### Changed
- Merge remote-tracking branch 'origin/main'。
## 开发快照 snapshot/v0.1.1 - 2026-10-13
目标提交:3a1d32da8fba90eb5081865b1f3ad424a46639ee
历史开发快照补记,不改变原提交、原提交时间或正式发布状态。
### Changed
- 重写平台定位与能力边界并归档原说明。
## 开发快照 snapshot/v0.1.1 - 2026-10-13
目标提交:950c1b429bc814d9fa7c2c1449f999a05537af21
历史开发快照补记,不改变原提交、原提交时间或正式发布状态。
### Changed
- 抬档门禁(破坏性 ⇒ 0.x 抬次 / ≥1.0.0 抬主)+ 1.0.0 前置(稳定契约承诺)。
## [0.1.0] - 2026-10-13
### Added
+10
View File
@@ -23,6 +23,7 @@ import (
"knowledge-governance/internal/qaopsorg"
"knowledge-governance/internal/qaopsquality"
"knowledge-governance/internal/serviceverify"
"knowledge-governance/internal/version"
)
// dingtalkSourceLister 是「系统设置 → 数据源配置」的数据源发现实现:只读列举钉钉知识库与
@@ -37,6 +38,9 @@ func (dingtalkSourceLister) ListAvailableDingtalkSources(ctx context.Context) (d
func main() {
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
if len(os.Args) == 2 && os.Args[1] == "--version-record-status" {
os.Exit(versionRecordStatus(ctx, os.Getenv))
}
if err := run(ctx, os.Getenv); err != nil {
log.Fatal(err)
}
@@ -126,6 +130,12 @@ func run(ctx context.Context, getenv func(string) string) error {
versionHistoryStore, versionHistoryRepo := httpapi.VersionHistoryStore(nil), ""
if config.VersionHistoryGiteaBaseURL != "" {
versionHistoryStore, versionHistoryRepo = postgresstore.NewStore(pool), config.VersionHistoryRepo
info := version.Snapshot()
if info.IdentityAvailable && info.CommitSHA != nil {
if _, e := actionStore.EnqueueVersionHistorySync(ctx, config.VersionHistoryRepo, "system:deployment", *info.CommitSHA, time.Now()); e != nil {
log.Print("version history deployment synchronization could not be queued")
}
}
}
qaReader, qaSettings := openQAOperations(ctx, getenv, config.DatabaseURL)
if closer, ok := qaReader.(interface{ Close() }); ok {
+56
View File
@@ -0,0 +1,56 @@
package main
import (
"context"
"encoding/json"
"fmt"
"github.com/jackc/pgx/v5/pgxpool"
"knowledge-governance/internal/appconfig"
"knowledge-governance/internal/version"
"os"
"time"
)
// Operator-only read-back in the existing API binary; no new public endpoint,
// no session/credential creation and no migrations or remote forge access.
func versionRecordStatus(ctx context.Context, getenv func(string) string) int {
info := version.Snapshot()
if !info.IdentityAvailable || info.CommitSHA == nil {
return printVersionStatus(false, "build_identity_missing", "")
}
config, e := appconfig.Load(getenv)
if e != nil {
return printVersionStatus(false, "configuration_invalid", *info.CommitSHA)
}
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
pool, e := pgxpool.New(ctx, config.DatabaseURL)
if e != nil {
return printVersionStatus(false, "database_unavailable", *info.CommitSHA)
}
defer pool.Close()
tag := "snapshot/v" + info.DisplayVersion
kind := "development"
if info.BuildKind == "release" {
tag = "v" + info.DisplayVersion
kind = "release"
}
evidence, _ := json.Marshal([]map[string]any{{"name": tag, "annotated": true}})
var ready bool
e = pool.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM version_history_commits c WHERE repo_key=$1 AND commit_sha=$2 AND version_label=$3 AND record_kind=$4 AND btrim(release_notes)<>'' AND release_tags @> $5::jsonb AND snapshot_id=(SELECT max(id) FROM version_history_sync_runs WHERE repo_key=$1 AND status='completed' AND complete))`, config.VersionHistoryRepo, *info.CommitSHA, info.DisplayVersion, kind, evidence).Scan(&ready)
if e != nil {
return printVersionStatus(false, "database_unavailable", *info.CommitSHA)
}
if !ready {
return printVersionStatus(false, "version_record_pending", *info.CommitSHA)
}
return printVersionStatus(true, "verified", *info.CommitSHA)
}
func printVersionStatus(ready bool, code, sha string) int {
raw, _ := json.Marshal(map[string]any{"ready": ready, "code": code, "commit_sha": sha})
fmt.Fprintln(os.Stdout, string(raw))
if ready {
return 0
}
return 1
}
+10 -1
View File
@@ -137,11 +137,20 @@ func workerHandlers(store *postgresstore.Store, config appconfig.Config) (map[st
triggeredBy := ""
var payload struct {
TriggeredBy string `json:"triggered_by"`
TargetSHA string `json:"target_sha"`
}
if err := json.Unmarshal(job.Payload, &payload); err == nil {
triggeredBy = payload.TriggeredBy
}
run, err := syncer.Sync(ctx, job.ID, triggeredBy)
activeSyncer := syncer
if payload.TargetSHA != "" {
var e error
activeSyncer, e = versionhistory.NewSyncer(versionhistory.SyncerConfig{Source: client, Store: store, RepoKey: config.VersionHistoryRepo, MaxPages: config.VersionHistoryMaxPages, TargetSHAs: []string{payload.TargetSHA}})
if e != nil {
return e
}
}
run, err := activeSyncer.Sync(ctx, job.ID, triggeredBy)
if err != nil {
log.Printf("version history sync failed job=%s run=%d code=%s: %v", job.ID, run.ID, run.ErrorCode, err)
return err
+27
View File
@@ -0,0 +1,27 @@
package governance
import (
"strings"
)
// Development headings cannot replace the formal release baseline.
func ExtractDevelopmentNotes(changelog, version string) string {
if !SemverValid(version) {
return ""
}
lines := strings.Split(changelog, "\n")
prefix := "## 开发快照 snapshot/v" + version + " - "
for i, line := range lines {
if strings.HasPrefix(line, prefix) {
end := len(lines)
for j := i + 1; j < len(lines); j++ {
if strings.HasPrefix(lines[j], "## ") {
end = j
break
}
}
return strings.TrimSpace(strings.Join(lines[i+1:end], "\n"))
}
}
return ""
}
+17 -1
View File
@@ -225,10 +225,26 @@ func versionHistorySync(w http.ResponseWriter, r *http.Request, config Config) {
versionHistoryError(w, http.StatusInternalServerError, "version_history_unavailable", "构造同步任务失败")
return
}
if queue, ok := config.Store.(interface {
EnqueueVersionHistorySync(context.Context, string, string, string, time.Time) (*jobs.Job, error)
}); ok {
target := ""
info := version.Snapshot()
if info.IdentityAvailable && info.CommitSHA != nil {
target = *info.CommitSHA
}
job, e := queue.EnqueueVersionHistorySync(r.Context(), config.VersionHistoryRepo, triggeredBy, target, now)
if e != nil || job == nil {
versionHistoryError(w, 500, "version_history_unavailable", "入队同步任务失败")
return
}
writeJSON(w, 202, map[string]any{"job_id": job.ID, "status": job.State})
return
}
requestedID := fmt.Sprintf("version-history-sync-%d", now.UnixNano())
job, err := config.Store.Enqueue(r.Context(), jobs.EnqueueRequest{ID: requestedID,
Type: VersionHistoryJobType, Payload: payload, AvailableAt: now, MaxAttempts: 3,
IdempotencyKey: "version-history.sync:" + config.VersionHistoryRepo}, now)
IdempotencyKey: "version-history.sync:" + config.VersionHistoryRepo + ":" + requestedID}, now)
if err != nil || job == nil {
versionHistoryError(w, http.StatusInternalServerError, "version_history_unavailable", "入队同步任务失败")
return
+1 -1
View File
@@ -237,7 +237,7 @@ func TestVersionHistorySyncRequiresAdminAndEnqueuesOnce(t *testing.T) {
if recorder.Code != http.StatusAccepted {
t.Fatalf("首次同步应 202,得到 %d(%s)", recorder.Code, recorder.Body.String())
}
if queue.enqueued.Type != VersionHistoryJobType || queue.enqueued.IdempotencyKey != "version-history.sync:Weknora/knowledge-governance" {
if queue.enqueued.Type != VersionHistoryJobType || queue.enqueued.IdempotencyKey != "version-history.sync:Weknora/knowledge-governance:"+queue.enqueued.ID {
t.Fatalf("入队参数不对:%+v", queue.enqueued)
}
if queue.enqueued.MaxAttempts != 3 || queue.enqueued.AvailableAt != now {
@@ -0,0 +1,63 @@
package postgres
import (
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"github.com/jackc/pgx/v5"
"knowledge-governance/internal/jobs"
"knowledge-governance/internal/versionhistory"
"regexp"
"strings"
"time"
)
func (s *Store) EnqueueVersionHistorySync(ctx context.Context, repo, actor, target string, now time.Time) (*jobs.Job, error) {
if strings.Count(repo, "/") != 1 || strings.TrimSpace(repo) != repo || len(repo) > 200 || actor == "" || len(actor) > 256 || target != "" && !regexp.MustCompile(`^[a-f0-9]{40}$`).MatchString(target) {
return nil, errors.New("invalid version synchronization request")
}
tx, e := s.db.Begin(ctx)
if e != nil {
return nil, e
}
defer tx.Rollback(context.Background())
if _, e = tx.Exec(ctx, `SELECT pg_advisory_xact_lock(hashtextextended($1,0))`, "version-history:"+repo); e != nil {
return nil, e
}
// Retire exhausted expired owners. Terminal jobs are history, not a perpetual
// idempotency key: a later refresh must be allowed to execute again.
if _, e = tx.Exec(ctx, `UPDATE jobs SET state='failed',lease_owner=NULL,lease_until=NULL,updated_at=$3 WHERE type=$1 AND payload->>'repo_key'=$2 AND state='running' AND lease_until<clock_timestamp() AND attempt>=max_attempts`, versionhistory.JobType, repo, now); e != nil {
return nil, e
}
var active string
e = tx.QueryRow(ctx, `SELECT id FROM jobs WHERE type=$1 AND payload->>'repo_key'=$2 AND state IN ('pending','running','retry') ORDER BY created_at DESC,id DESC LIMIT 1`, versionhistory.JobType, repo).Scan(&active)
if e != nil && !errors.Is(e, pgx.ErrNoRows) {
return nil, e
}
if active != "" {
job, e := scanJob(tx.QueryRow(ctx, `SELECT id,type,payload,state,priority,available_at,lease_owner,lease_until,attempt,max_attempts,last_error,idempotency_key,created_at,updated_at FROM jobs WHERE id=$1`, active))
if e != nil {
return nil, e
}
if e = tx.Commit(ctx); e != nil {
return nil, e
}
return job, nil
}
var random [16]byte
if _, e = rand.Read(random[:]); e != nil {
return nil, e
}
id := "vh-" + hex.EncodeToString(random[:])
payload, _ := json.Marshal(map[string]string{"repo_key": repo, "triggered_by": actor, "target_sha": target})
job, e := NewStore(tx).Enqueue(ctx, jobs.EnqueueRequest{ID: id, Type: versionhistory.JobType, Payload: payload, AvailableAt: now, MaxAttempts: 3, IdempotencyKey: "version-history:" + repo + ":" + id}, now)
if e != nil {
return nil, e
}
if e = tx.Commit(ctx); e != nil {
return nil, e
}
return job, nil
}
@@ -0,0 +1,52 @@
package postgres
import (
"context"
"github.com/jackc/pgx/v5/pgxpool"
"knowledge-governance/internal/versionhistory"
"net/url"
"os"
"strings"
"testing"
"time"
)
func TestVersionHistorySyncQueueCanRefreshAfterCompletion(t *testing.T) {
dsn := os.Getenv("KG_VERSION_HISTORY_TEST_DSN")
if dsn == "" {
t.Skip("explicit disposable version-history DB required")
}
u, e := url.Parse(dsn)
if e != nil || u.Hostname() != "127.0.0.1" || !strings.HasPrefix(u.Path, "/kg_version_history_test_") {
t.Fatal("refusing shared database")
}
ctx := context.Background()
p, e := pgxpool.New(ctx, dsn)
if e != nil {
t.Fatal(e)
}
defer p.Close()
if e = ApplyMigrations(ctx, p); e != nil {
t.Fatal(e)
}
s := NewStore(p)
one, e := s.EnqueueVersionHistorySync(ctx, "fixture/repo", "fixture-admin", strings.Repeat("a", 40), time.Now())
if e != nil || one == nil {
t.Fatal("first sync missing", e)
}
same, e := s.EnqueueVersionHistorySync(ctx, "fixture/repo", "fixture-admin", strings.Repeat("a", 40), time.Now())
if e != nil || same.ID != one.ID {
t.Fatal("pending sync not coalesced", e)
}
claimed, e := s.Claim(ctx, "fixture-worker", []string{versionhistory.JobType}, time.Now(), time.Minute)
if e != nil || claimed == nil {
t.Fatal(e)
}
if e = s.Complete(ctx, claimed.ID, claimed.LeaseOwner, time.Now()); e != nil {
t.Fatal(e)
}
next, e := s.EnqueueVersionHistorySync(ctx, "fixture/repo", "fixture-admin", strings.Repeat("b", 40), time.Now())
if e != nil || next == nil || next.ID == one.ID || next.State != "pending" {
t.Fatal("completed request was replayed forever instead of starting a new refresh", e)
}
}
+58
View File
@@ -0,0 +1,58 @@
package versionhistory
import (
"context"
"fmt"
"knowledge-governance/internal/governance"
"sort"
"strings"
)
func (s *Syncer) includeDeliveredCommits(ctx context.Context, commits []RawCommit, tags []RawTag) ([]RawCommit, error) {
seen := map[string]bool{}
for _, c := range commits {
seen[c.SHA] = true
}
refs := append([]string{}, s.targetSHAs...)
snapshots := []RawTag{}
for _, tag := range tags {
if tag.Annotated && strings.HasPrefix(tag.Name, "snapshot/v") && governance.SemverValid(strings.TrimPrefix(tag.Name, "snapshot/v")) {
snapshots = append(snapshots, tag)
}
}
sort.Slice(snapshots, func(i, j int) bool {
return governance.CompareSemver(strings.TrimPrefix(snapshots[i].Name, "snapshot/v"), strings.TrimPrefix(snapshots[j].Name, "snapshot/v")) > 0
})
for _, tag := range snapshots {
refs = append(refs, tag.TargetSHA)
}
for _, ref := range refs {
if seen[ref] {
continue
}
if !fullSHA.MatchString(ref) {
return nil, fmt.Errorf("开发快照缺少完整提交身份")
}
extra, total, e := s.fetchCommitsAt(ctx, ref)
if e != nil {
return nil, e
}
if total > 0 && len(extra) != total {
return nil, codeError{code: ErrorCodeCountMismatch, message: "开发快照提交历史不完整"}
}
found := false
for _, c := range extra {
if c.SHA == ref {
found = true
}
if !seen[c.SHA] {
seen[c.SHA] = true
commits = append(commits, c)
}
}
if !found {
return nil, codeError{code: ErrorCodeIncompletePages, message: "开发快照目标没有被取回"}
}
}
return commits, nil
}
@@ -0,0 +1,87 @@
package versionhistory
import (
"context"
"fmt"
"knowledge-governance/internal/governance"
"net/http"
"strings"
"testing"
"time"
)
func TestGiteaSnapshotRefIsExplicitAndNotesArePreserved(t *testing.T) {
server := fakeGitea(t, func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/api/v1/repos/Weknora/knowledge-governance/commits" {
if r.URL.Query().Get("sha") != shaB {
t.Error("snapshot commit history silently used default branch")
}
fmt.Fprintf(w, `[{"sha":%q,"parents":[],"commit":{"message":"fixture"}}]`, shaB)
return
}
if r.URL.Path == "/api/v1/repos/Weknora/knowledge-governance/git/refs/tags" {
fmt.Fprintf(w, `[{"ref":"refs/tags/snapshot/v0.1.1","object":{"type":"tag","sha":%q}}]`, shaT)
return
}
fmt.Fprintf(w, `{"message":"development notes","object":{"type":"commit","sha":%q}}`, shaB)
})
c, _ := NewGiteaClient(validConfig(server.URL))
if _, _, e := c.ListCommitsAtRef(context.Background(), shaB, 1, 50); e != nil {
t.Fatal(e)
}
tags, e := c.ListTags(context.Background())
if e != nil || len(tags) != 1 || tags[0].Message != "development notes" {
t.Fatal("annotated snapshot notes lost", e)
}
}
func TestDevelopmentVersionsHaveTheirOwnChangelogNotes(t *testing.T) {
a, b := shaA, shaB
now := time.Now()
text := "# Changelog\n\n## [Unreleased]\n\n### Added\n\n- unrelated future work\n\n## 开发快照 snapshot/v0.1.1 - 2026-10-18\n\n### Fixed\n\n- fix specifically delivered here\n\n## [0.1.0] - 2026-10-13\n\n### Added\n\n- baseline\n"
result := DeriveVersions(DeriveInput{Commits: []RawCommit{{SHA: a, CommittedAt: now}, {SHA: b, Parents: []string{a}, CommittedAt: now}}, Tags: []RawTag{{Name: "v0.1.0", TargetSHA: a, Annotated: true}}, Changelog: func(string) (string, error) { return text, nil }})
for _, v := range result.Records {
if v.CommitSHA == b && (v.ReleaseNotes != "### Fixed\n\n- fix specifically delivered here" || v.RecordKind != governance.RecordKindDevelopment) {
t.Fatalf("development version lacks its own notes: %q", v.ReleaseNotes)
}
}
}
func TestHistoricDevelopmentArchiveBindsExactCommit(t *testing.T) {
archive := "## 开发快照 snapshot/v0.1.1 - 2026-10-18\n\n目标提交:" + shaB + "\n\n### Fixed\n\n- historic delivery notes\n"
input := DeriveInput{DeliveryArchive: archive, Commits: []RawCommit{{SHA: shaA}, {SHA: shaB, Parents: []string{shaA}}}, Tags: []RawTag{{Name: "v0.1.0", TargetSHA: shaA, Annotated: true}}, Changelog: func(string) (string, error) { return "## [0.1.0] - 2026-10-13\n\n### Added\n\n- baseline", nil }}
result := DeriveVersions(input)
for _, v := range result.Records {
if v.CommitSHA == shaB && !strings.Contains(v.ReleaseNotes, "historic delivery notes") {
t.Fatal("archived notes missing")
}
}
input.DeliveryArchive = strings.ReplaceAll(archive, shaB, strings.Repeat("c", 40))
result = DeriveVersions(input)
for _, v := range result.Records {
if v.CommitSHA == shaB && v.ReleaseNotes != "" {
t.Fatal("different commit reused same-number archive notes")
}
}
}
type snapshotRefSource struct {
fakeSource
other []RawCommit
}
func (s *snapshotRefSource) ListCommitsAtRef(_ context.Context, _ string, page, limit int) ([]RawCommit, int, error) {
if page == 1 {
return s.other, len(s.other), nil
}
return nil, len(s.other), nil
}
func TestSyncIncludesDeliveredSnapshotOutsideMain(t *testing.T) {
a := RawCommit{SHA: shaA}
b := RawCommit{SHA: shaB, Parents: []string{shaA}}
src := &snapshotRefSource{fakeSource: fakeSource{commits: []RawCommit{a}, total: 1, pageSize: 50, head: shaA, tags: []RawTag{{Name: "snapshot/v0.1.1", TargetSHA: shaB, Annotated: true}}}, other: []RawCommit{b, a}}
store := &fakeStore{}
run, e := newTestSyncer(t, src, store).Sync(context.Background(), "fixture", "admin")
if e != nil || len(store.saved) != 1 || len(store.saved[0].records) != 2 || !run.Complete {
t.Fatal("delivered non-main snapshot omitted", e, run.FetchedCount)
}
}
+10 -1
View File
@@ -188,6 +188,12 @@ var errGiteaNotFound = newGiteaError(KindNotFound, http.StatusNotFound, "版本
// ListCommits 读一页提交;同时返回 X-Total-Count(用于完整性对账,不做假 total)。
func (c *GiteaClient) ListCommits(ctx context.Context, page, limit int) ([]RawCommit, int, error) {
return c.ListCommitsAtRef(ctx, "main", page, limit)
}
func (c *GiteaClient) ListCommitsAtRef(ctx context.Context, ref string, page, limit int) ([]RawCommit, int, error) {
if ref != "main" && !fullSHA.MatchString(ref) {
return nil, 0, newGiteaError(KindParse, 0, "版本记录:只接受 main 或完整提交 SHA", nil)
}
if limit <= 0 || limit > giteaDefaultPageSize {
limit = giteaDefaultPageSize
}
@@ -196,6 +202,7 @@ func (c *GiteaClient) ListCommits(ctx context.Context, page, limit int) ([]RawCo
query.Set("page", fmt.Sprintf("%d", page))
query.Set("stat", "false")
query.Set("verification", "false")
query.Set("sha", ref)
body, header, err := c.get(ctx, c.apiURL("/commits", query))
if err != nil {
return nil, 0, err
@@ -322,7 +329,8 @@ func (c *GiteaClient) ListTags(ctx context.Context) ([]RawTag, error) {
return nil, err
}
var annotated struct {
Object struct {
Message string `json:"message"`
Object struct {
SHA string `json:"sha"`
} `json:"object"`
}
@@ -333,6 +341,7 @@ func (c *GiteaClient) ListTags(ctx context.Context) ([]RawTag, error) {
continue
}
tag.TargetSHA = annotated.Object.SHA
tag.Message = annotated.Message
}
tags = append(tags, tag)
}
+29 -7
View File
@@ -8,6 +8,7 @@ package versionhistory
import (
"sort"
"strings"
"time"
"knowledge-governance/internal/governance"
@@ -25,6 +26,7 @@ type RawCommit struct {
// RawTag 是一个 tag 及其可核验属性;Annotated=false 表示轻量 tag(不算正式版本的证据)。
type RawTag struct {
Message string
Name string
TagSHA string
TargetSHA string
@@ -36,13 +38,14 @@ type ChangelogSource func(sha string) (string, error)
// DeriveInput 是一次换算的全部输入。
type DeriveInput struct {
RepoKey string
CommitURLBase string // 形如 "<origin>/<owner>/<repo>/commit/",由配置拼好,不接受外部传入
TagURLBase string // 形如 "<origin>/<owner>/<repo>/src/tag/";空则不下发 tag 链接
Commits []RawCommit
Tags []RawTag
Changelog ChangelogSource
SnapshotID int64
DeliveryArchive string
RepoKey string
CommitURLBase string // 形如 "<origin>/<owner>/<repo>/commit/",由配置拼好,不接受外部传入
TagURLBase string // 形如 "<origin>/<owner>/<repo>/src/tag/";空则不下发 tag 链接
Commits []RawCommit
Tags []RawTag
Changelog ChangelogSource
SnapshotID int64
}
// DeriveResult 是换算结果。
@@ -192,6 +195,25 @@ func DeriveVersions(in DeriveInput) DeriveResult {
}
if kind == governance.RecordKindRelease {
record.ReleaseNotes = governance.ExtractReleaseNotes(changelog, label)
} else if kind == governance.RecordKindDevelopment {
record.ReleaseNotes = governance.ExtractDevelopmentNotes(changelog, label)
if record.ReleaseNotes == "" {
archived := governance.ExtractDevelopmentNotes(in.DeliveryArchive, label)
if strings.Contains(archived, "目标提交:"+commit.SHA) {
record.ReleaseNotes = archived
}
}
if record.ReleaseNotes == "" {
for _, tag := range tagsByTarget[commit.SHA] {
if tag.Annotated && tag.Name == "snapshot/v"+label {
_, body, found := strings.Cut(tag.Message, "\n\n")
if found {
record.ReleaseNotes = strings.TrimSpace(body)
}
break
}
}
}
}
records = append(records, record)
}
+53 -16
View File
@@ -130,22 +130,24 @@ type SyncOutcome struct {
// SyncerConfig 装配同步器。
type SyncerConfig struct {
Source Source
Store SyncStore
RepoKey string
Changelog string // 提交内的变更记录文件路径,默认 CHANGELOG.md
Now func() time.Time
MaxPages int
Source Source
Store SyncStore
RepoKey string
Changelog string // 提交内的变更记录文件路径,默认 CHANGELOG.md
Now func() time.Time
MaxPages int
TargetSHAs []string
}
// Syncer 抓取远端 main 可达的全部提交并换算版本记录。
type Syncer struct {
source Source
store SyncStore
repoKey string
changelog string
now func() time.Time
maxPages int
source Source
store SyncStore
repoKey string
changelog string
now func() time.Time
maxPages int
targetSHAs []string
}
const syncerDefaultMaxPages = 200
@@ -170,7 +172,12 @@ func NewSyncer(config SyncerConfig) (*Syncer, error) {
if maxPages <= 0 {
maxPages = syncerDefaultMaxPages
}
return &Syncer{source: config.Source, store: config.Store, repoKey: config.RepoKey,
for _, sha := range config.TargetSHAs {
if !fullSHA.MatchString(sha) {
return nil, errors.New("版本记录:运行身份必须是完整 SHA")
}
}
return &Syncer{targetSHAs: append([]string{}, config.TargetSHAs...), source: config.Source, store: config.Store, repoKey: config.RepoKey,
changelog: changelog, now: now, maxPages: maxPages}, nil
}
@@ -204,9 +211,22 @@ func (s *Syncer) Sync(ctx context.Context, jobID, triggeredBy string) (governanc
return fail(err)
}
commits, err = s.includeDeliveredCommits(ctx, commits, tags)
if err != nil {
return fail(err)
}
total = len(commits)
deliveryArchive := ""
if len(s.targetSHAs) > 0 {
deliveryArchive, err = s.source.ReadFile(ctx, s.targetSHAs[0], s.changelog)
if err != nil {
return fail(err)
}
}
derived := DeriveVersions(DeriveInput{RepoKey: s.repoKey, CommitURLBase: s.source.CommitURLBase(),
TagURLBase: s.source.TagURLBase(),
Commits: commits, Tags: tags, SnapshotID: runID,
TagURLBase: s.source.TagURLBase(),
DeliveryArchive: deliveryArchive,
Commits: commits, Tags: tags, SnapshotID: runID,
Changelog: func(sha string) (string, error) {
return s.source.ReadFile(ctx, sha, s.changelog)
}})
@@ -220,6 +240,9 @@ func (s *Syncer) Sync(ctx context.Context, jobID, triggeredBy string) (governanc
} else {
sourceHead = headSHA(commits) // 读分支失败不致命:退回提交列表最新一条
}
if len(s.targetSHAs) > 0 {
sourceHead = s.targetSHAs[0]
}
outcome := SyncOutcome{SourceHeadSHA: sourceHead, FetchedCount: len(commits),
ExpectedCount: total, Complete: derived.GraphComplete && (total == 0 || len(commits) == total)}
if err := s.store.FinishVersionHistorySync(ctx, runID, outcome, s.now().UTC()); err != nil {
@@ -232,6 +255,9 @@ func (s *Syncer) Sync(ctx context.Context, jobID, triggeredBy string) (governanc
// fetchAllCommits 逐页抓取并按 SHA 去重;短页即终止(也防住服务端忽略 page 参数)。
func (s *Syncer) fetchAllCommits(ctx context.Context) ([]RawCommit, int, error) {
return s.fetchCommitsAt(ctx, "")
}
func (s *Syncer) fetchCommitsAt(ctx context.Context, ref string) ([]RawCommit, int, error) {
pageSize := s.source.PageSize()
if pageSize <= 0 {
pageSize = giteaDefaultPageSize
@@ -240,7 +266,18 @@ func (s *Syncer) fetchAllCommits(ctx context.Context) ([]RawCommit, int, error)
commits := make([]RawCommit, 0, pageSize)
total := 0
for page := 1; page <= s.maxPages; page++ {
pageCommits, pageTotal, err := s.source.ListCommits(ctx, page, pageSize)
var pageCommits []RawCommit
var pageTotal int
var err error
if ref == "" {
pageCommits, pageTotal, err = s.source.ListCommits(ctx, page, pageSize)
} else if source, ok := s.source.(interface {
ListCommitsAtRef(context.Context, string, int, int) ([]RawCommit, int, error)
}); ok {
pageCommits, pageTotal, err = source.ListCommitsAtRef(ctx, ref, page, pageSize)
} else {
return nil, 0, errors.New("版本来源不支持开发快照历史")
}
if err != nil {
return nil, 0, err
}
+3
View File
@@ -356,6 +356,9 @@ def cmd_internal_web_test() -> int:
def cmd_internal_contracts(go: str) -> int:
"""版本契约:结构 + 注入身份必须**真 PASS**(SKIP 判红),
并且**开发构建不许自称正式版**(负例:标成 release ⇒ 必须红)。"""
snapshot_rc = subprocess.run(py("scripts/development_snapshot.py","--self-test"),cwd=ROOT).returncode
if snapshot_rc:
return 1
rc = subprocess.run(py("scripts/check_changelog.py"), cwd=ROOT).returncode
if rc:
return 1
+10 -1
View File
@@ -1,7 +1,8 @@
#!/usr/bin/env bash
# 重建并上线 governance api + web(本机 docker build 需要宿主代理 + --network host)
set -u
cd /home/zheromiku/knowledge-governance || exit 1
cd "$(dirname "${BASH_SOURCE[0]}")/.." || exit 1
[ -z "$(git status --porcelain --untracked-files=normal)" ] || { printf "%s\n" "REFUSED_DIRTY_SOURCE"; exit 1; }
TS=$(date +%Y%m%d-%H%M%S)
PROXY_ARGS=(--build-arg HTTP_PROXY=http://127.0.0.1:7890 --build-arg HTTPS_PROXY=http://127.0.0.1:7890 --build-arg NO_PROXY=localhost,127.0.0.1 --build-arg GOPROXY=https://goproxy.cn,direct)
@@ -123,4 +124,12 @@ if docker exec knowledge-governance-p1-api-1 sh -lc "strings /app/api | grep -q
else
echo "INJECTION_MISSING(产物里没有 $INJECTED —— 页面会显示当前运行未知)"
fi
echo "=== 10. 版本记录同步读回(当前 SHA + 注释快照 + 独立变更说明)"
# Startup queues the read-only history job. A healthy API alone is not proof.
VERIFIED=0
for ((attempt=0;attempt<90;attempt++)); do
if docker exec knowledge-governance-p1-api-1 /app/api --version-record-status; then VERIFIED=1; break; fi
sleep 2
done
[ "$VERIFIED" = "1" ] || { printf '%s\n' 'VERSION_RECORD_NOT_VERIFIED'; exit 1; }
echo "DONE"
+82
View File
@@ -0,0 +1,82 @@
#!/usr/bin/env python3
"""Explicit development delivery tags; never creates formal tags or releases."""
import argparse,json,re,subprocess,tempfile
from pathlib import Path
ROOT=Path(__file__).resolve().parent.parent
SHA=re.compile(r'^[a-f0-9]{40}$')
def git(root,*args):
p=subprocess.run(['git',*args],cwd=root,text=True,capture_output=True)
if p.returncode:raise RuntimeError('git operation failed: '+args[0])
return p.stdout.strip()
def notes(text,version):
heading=re.search(r'^## 开发快照 snapshot/v'+re.escape(version)+r' - \d{4}-\d{2}-\d{2}\s*$',text,re.M)
if not heading:return ''
tail=text[heading.end():];return re.split(r'^##\s',tail,maxsplit=1,flags=re.M)[0].strip()
def plan(root,sha,backfill=False):
if not sha or not SHA.fullmatch(sha):raise ValueError('exact 40-character commit SHA required')
if git(root,'cat-file','-t',sha)!='commit':raise ValueError('snapshot target must be a commit')
text=git(root,'show',sha+':CHANGELOG.md');m=re.search(r'^##\s*\[(\d+\.\d+\.\d+)\]',text,re.M)
if not m:raise ValueError('formal baseline missing')
baseline=m.group(1);base='v'+baseline
if git(root,'cat-file','-t','refs/tags/'+base)!='tag':raise ValueError('annotated formal baseline missing')
git(root,'merge-base','--is-ancestor',base,sha)
n=int(git(root,'rev-list','--count',base+'..'+sha));version='.'.join(baseline.split('.')[:2]+[str(n)])
if n<=0:raise ValueError('formal baseline cannot be tagged as a development delivery')
body=notes(text,version)
if not body and backfill:
body=notes(git(root,'show','HEAD:CHANGELOG.md'),version)
if '目标提交:'+sha not in body:raise ValueError('backfilled notes must bind the exact historic SHA')
if not body:raise ValueError('this development version has no independent CHANGELOG entry')
if not re.search(r'^### (Added|Changed|Fixed|Security)\s*$',body,re.M):raise ValueError('snapshot notes have no change category')
return {'commit_sha':sha,'display_version':version,'tag':'snapshot/v'+version,'message':'Development snapshot '+version+'\n\n'+body,'backfilled':bool(backfill)}
def publish(root,p):
# Only explicitly named development refs are written. Existing refs, including
# legacy misnumbered snapshots, are never moved or overwritten.
tag=p['tag'];sha=p['commit_sha'];ref='refs/tags/'+tag
remote=git(root,'ls-remote','origin');pairs=[line.split() for line in remote.splitlines() if line.strip()];refs={name:obj for obj,name in pairs}
if ref in refs:
if refs.get(ref+'^{}')!=sha:raise ValueError('remote snapshot already exists with different or non-annotated identity')
return {'tag':tag,'commit_sha':sha,'state':'already_published'}
reachable=False
for obj,name in pairs:
if name.startswith('refs/heads/') and subprocess.run(['git','merge-base','--is-ancestor',sha,obj],cwd=root,stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL).returncode==0:reachable=True;break
if not reachable:raise ValueError('snapshot target has not been pushed to a remote branch')
exists=subprocess.run(['git','show-ref','--verify','--quiet',ref],cwd=root).returncode==0
if exists:
if git(root,'cat-file','-t',ref)!='tag' or git(root,'rev-parse',ref+'^{}')!=sha:raise ValueError('local snapshot conflicts with requested identity')
if git(root,'for-each-ref','--format=%(contents)',ref).strip()!=p['message'].strip():raise ValueError('existing snapshot annotation differs; will not overwrite')
else:
with tempfile.NamedTemporaryFile(mode='w',prefix='kg-snapshot-note-',encoding='utf-8') as f:
f.write(p['message']+'\n');f.flush();git(root,'tag','-a',tag,sha,'-F',f.name)
git(root,'push','origin',ref+':'+ref)
readback={line.split()[1]:line.split()[0] for line in git(root,'ls-remote','--tags','origin',ref,ref+'^{}').splitlines()}
if readback.get(ref+'^{}')!=sha or readback.get(ref)!=git(root,'rev-parse',ref):raise RuntimeError('remote snapshot read-back failed')
return {'tag':tag,'commit_sha':sha,'state':'published_and_read_back'}
def self_test():
with tempfile.TemporaryDirectory(prefix='kg-snapshot-test-') as d:
r=Path(d);git(r,'init','-b','main');git(r,'config','user.name','Fixture');git(r,'config','user.email','fixture@example.invalid')
base='# Changelog\n\n## [0.1.0] - 2026-10-13\n\n### Added\n\n- fixture baseline\n'
(r/'CHANGELOG.md').write_text(base);git(r,'add','CHANGELOG.md');git(r,'commit','-m','feat: fixture baseline');git(r,'tag','-a','v0.1.0','-m','fixture')
(r/'CHANGELOG.md').write_text('## 开发快照 snapshot/v0.1.1 - 2026-10-18\n\n### Fixed\n\n- specific fixture fix\n\n'+base);git(r,'add','CHANGELOG.md');git(r,'commit','-m','fix: fixture change')
sha=git(r,'rev-parse','HEAD');before=git(r,'tag');p=plan(r,sha)
assert p['tag']=='snapshot/v0.1.1' and 'specific fixture fix' in p['message']
assert before==git(r,'tag'),'planning wrote a tag'
try:plan(r,'a'*7)
except ValueError:pass
else:raise AssertionError('short SHA accepted')
bare=r/'remote.git';git(r,'init','--bare',str(bare));git(r,'remote','add','origin',str(bare));git(r,'push','origin','main')
out=publish(r,p);assert out['state']=='published_and_read_back'
assert git(r,'rev-parse',p['tag']+'^{}')==sha
assert publish(r,p)['state']=='already_published'
wrong=dict(p,commit_sha='a'*40)
try:publish(r,wrong)
except ValueError:pass
else:raise AssertionError('conflicting remote snapshot overwritten')
assert git(r,'tag','--list','v*').splitlines()==['v0.1.0'],'formal tags changed'
print('development snapshot fixture passed; real repository tags untouched')
def main():
a=argparse.ArgumentParser();a.add_argument('--sha');a.add_argument('--backfill',action='store_true');a.add_argument('--publish',action='store_true');a.add_argument('--self-test',action='store_true');o=a.parse_args()
if o.self_test:return self_test()
p=plan(ROOT,o.sha,o.backfill)
print(json.dumps(publish(ROOT,p) if o.publish else p,ensure_ascii=False))
if __name__=='__main__':main()
+7
View File
@@ -98,6 +98,13 @@ const render = (over: Partial<Parameters<typeof VersionHistoryView>[0]> = {}) =>
)
describe('VersionHistoryView', () => {
it('renders the exact development changelog instead of hiding all non-release notes', () => {
const html = render({
expanded: developmentItem.commit_sha,
page: { ...page, items: [{ ...developmentItem, release_notes: '这次开发独有的变更说明' }] },
})
expect(html).toContain('这次开发独有的变更说明')
})
it('lists commits newest-first with kind badges, short sha, times and links', () => {
const html = render()
expect(html).toContain('版本记录')
+3 -3
View File
@@ -11,7 +11,7 @@ import {
import { useOptionalAuth } from './auth'
import './versionHistory.css'
// 「版本记录」页:追踪**所有已进入远端 main 的提交**,按提交时间倒序。
// 「版本记录」页:追踪远端 main、已交付开发快照及当前运行 SHA 的提交,按提交时间倒序。
// 只读:不提供打 tag / 发布 / 部署按钮;版本号只按证据展示,证据不足就说"历史提交"。
const FILTERS: ReadonlyArray<{ id: string; label: string }> = [
{ id: 'all', label: '全部' },
@@ -158,7 +158,7 @@ function TimelineEntry({
}) {
const isRelease = item.record_kind === 'release' && item.version_state === 'verified'
const heading = isRelease ? item.version_label : item.version_label || `历史提交 · ${item.short_sha}`
const note = isRelease ? item.release_notes : ''
const note = item.version_state === 'verified' ? item.release_notes : ''
const hasDetail = Boolean(item.body) || Boolean(note)
return (
<li className={`version-history-entry ${item.record_kind}`}>
@@ -185,7 +185,7 @@ function TimelineEntry({
</button>
{expanded ? (
<div className="version-history-detail">
{item.record_kind === 'release' && note ? <pre>{note}</pre> : null}
{note ? <pre>{note}</pre> : null}
{item.body ? <pre>{item.body}</pre> : null}
</div>
) : null}