作者在 AI 辅助下一年写了 957 个 commit 大多无意义,转为单次 commit 加 signed tag 发布,探讨 AI 时代版本控制的四个假设是否仍成立。
今天我发布了 contenox 1.0.0。不是在一千个提交之上打 tag,而是作为一个单独的提交推入一个空仓库:整棵代码树,一个签名 tag,CI 从这个 tag 构建出的二进制文件。那 957 个帮我完成这件事的提交仍然存在,在旧仓库里,作为历史。它们不再是项目发布的方式。
这篇文章讲的是为什么,以及我在回读结果之前出了什么错。
GitHub 的工作流建立在四个假设之上,它们太过久远,以至于没人再提:
提交是人类意图的单位。有人做了决定,然后敲了出来。
Pull Request 是审查的单位。一个人读了 diff,因为一个人写了它。
历史是溯源。谁在什么时候改了什么,并通过消息说明为什么。
时间戳是劳动。你个人主页上的贡献图是一本日记。
这四条在 2008 年都是真的。对于一棵由 Agent 书写的树,没有一条能经得住考验。
一些数字,来自一棵你可以自己查看的树:
一年出头 957 个提交,大多数名叫 Checkpoint、Fix tests、Snapshot WiP。忙碌的一天能有几十个。
生产的 Go 代码从 17,267 行手写代码增长到 134,040 行 Agent 辅助代码。可测量,非估算。文件的中位数大小没变;但文件和包的数量变了。
一度有 530 个未提交路径躺在一个工作树里。在那堆东西里面,带有仓库自身约定的文件被删了。过了好几天都没人注意到,因为没人会去 review 一个 530 文件的 diff。
这样的提交流不是历史。它是日志。读它告诉不了你任何人做了什么决定——那些决定发生在 prompts 里、Agent 声明里、一个策略文件里——它精确地告诉你一件事:工作是什么时候做的。如果你还接客户项目,公开发布的提交流就是一份你从未同意公开的时间表。
审查也悄悄反转了。我不再 review 提交。我在 review 结果:能不能构建、测试过没过、二进制文件能不能干活。提交边界成了 Agent 什么时候停下来的偶然结果。
所以发布的单位移到了唯一还有意义的边界:release。
树活在私有 monorepo 里,和产品的商业部分放在一起。公开仓库 contenox/contenox 是一个 release mirror:
main 只通过 release 移动。其上的每个提交都是 Release vX.Y.Z,携带完整的树。两个提交之间的 diff 就是两个 release 之间的 diff——可以一口气读完,而一个月的 checkpoint 永远做不到。
每个 tag 都经过签名,它的注释就是 release notes。CI 从 tag 构建四个二进制文件,写入校验和,验证溯源,发布。
Issues 保持打开。Pull requests 欢迎,并被手动合入上游。作者在 release 提交上获得 Co-authored-by,并在 notes 里留一行。分支是变更的证据,不是交付的载体。
机制是一个比这篇文章还短的 shell script。关键部分:
# export exactly what git tracks under the OSS tree: no ignored files, no build outputs
git ls-files -z -- runtime | tar --null -T - -cf - | tar -xf - -C "$MIRROR" --strip-components=1
# refuse to publish anything that matches a list of private markers
grep -rIlE -f private-patterns.txt "$MIRROR" && exit 1
git -C "$MIRROR" add -A
git -C "$MIRROR" commit -F release-notes.md # "Release v1.0.0", signed
git -C "$MIRRIR" tag -a -F release-notes.md v1.0.0 # signed; the notes ride in the annotation
git -C "$MIRROR" push origin main v1.0.0 # release.yml takes it from here
这里没有任何新东西。Android 多年来一直以 source drops 的方式发布 AOSP,很多厂商也用同样方式发布。新的是原因:不是保密,而是中间的提交不再值得发布。
第一次 drop 随附了一个 CONTRIBUTING.md,详细描述了我刚刚构建的、用来把工作流挡在公开仓库之外的工作流。一个 Agent 写的——全面、正确、但完全跑偏了——而我没读,因为它是带来整个机制的二十九个文件之一。然后 Go module proxy 获取了 v1.0.0,从那一刻起那段文字就永久化了:你没法在不导致后续所有 go get 校验和不匹配的情况下重新打一个 Go module 的 tag。
两条教训,都跟工具无关。
第一:我设的每个关卡都是机械的——lint、测试、扫描 secrets 和私有路径——但没有一个是"有人读过即将永久化的内容"。扫描能捕获凭证。它捕获不了一段诚恳的散文。
第二,这条花了我一点时间:我的本能反应是十五分钟后发布 v1.0.1。这个本能反应恰恰是新仓库存在要终结的东西。让发布变得毫不费力,它就会被毫不费力地使用。所以规则不是脚本;而是日历。公开仓库一个月才动一次。 Regime 改变的第一个证明是 v1.0.0 之后一个月的沉默——CONTRIBUTING 写得不好也在内。
没有公开 CI。关卡在 drop 之前运行,在私有环境里。公开仓库提供的是签名 tag、一个你可以读的构建配方、校验和与溯源。如果绿色徽章和每次 push 是你信任的东西,这个模型要求你信任别的东西。
贡献者不会在分支上留下他们的提交。他们在 release 里获得积分。有些人会讨厌这个。我理解。
它看起来像专有厂商的发布方式。确实就是。许可证是 Apache-2.0,节奏已声明,旧历史是公开的。这就是全部差异,而且我认为这已经够了。
它没有让代码可审查。它让 release 可审查。两次 release 之间代码发生了什么——那是另一个问题——而且老实说,是更大的问题。
一个仓库模式。叫它 release drop:main 只通过 tag 移动;历史是每个 release 一个提交;pull requests 是提案,不是合并;溯源按 release 验证,而非按 push。平台已有每一块——不可变 release、构建验证、带必须审查者的环境。它只是仍然假设有人敲了提交。
在那之前:一个 shell script,一个日历,一条规则——在有人读过之前什么都不永久化。
contenox 1.0.0 在 github.com/contenox/contenox。把它送到的历史在 github.com/contenox/contenox-v0。测量数据在 Lab:contenox.com/docs/rnd/handwritten-vs-generated/。