ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

用 git describe 生成可追溯且语义化的版本号

用 git describe 生成可追溯且语义化的版本号 1. 为什么版本号要用 git describe 来生成1.1 手动版本号为什么撑不住先讲一个几乎每个团队都经历过的场景项目跑起来了产品要发版打包的人随手在命名里打上v1.0.0下个月改点 UI手一抖改成v1.0.1再下个月发了个修 bug 的包直接在文件名后面缀了个final。等运维那边看到的是一排v1.0.1-final、v1.0.1-final2、v1.0.1-fixed的时候你基本已经分不清哪个包对应哪次代码提交了。我待过的几个团队里版本号的维护方式大致分三类一类是完全靠人肉在构建脚本里写死一个字符串一类是做成配置文件每次发版之前专门开个 issue 让人去改还有一类更野直接拿日期当版本号20250113这种后果是一天打了十几个包的时候根本没法区分先后顺序。这些做法最致命的问题不是“不优雅”而是版本号和代码之间没有可追溯的关系。哪怕你把像1.2.3这样的版本号写进构建产物出了问题也只能靠猜——这个包到底是基于哪一次 commit 打的分支上有哪些代码有没有未提交的改动全凭记忆不靠谱。1.2 git describe 到底在做什么git describe解决的就是上面这一整串问题。它做的事情说简单也简单从当前 HEAD 指向的 commit 出发沿着提交历史往回找最近的 tag然后用一句话把这个位置描述出来。比如你现在处于v1.2.3这个 tag 之后又提交了 5 次代码的位置git describe会给出类似这样的结果v1.2.3-5-g3f4a2b1看不懂没关系拆开看就几段v1.2.3是离当前 commit 最近的 tag5表示当前 commit 与那个 tag 之间隔着 5 次提交g是 git 的惯用前缀就一个字母约定俗成3f4a2b1是当前 commit 的短哈希用来精确定位。这串输出可以说是“两头兼顾”v1.2.3让人一眼就能看出这是哪个版本线出来的5告诉你在那之后有多少新提交g3f4a2b1则让机器能精确锁定到具体的代码状态。版本号第一次做到了既给人看、又给机器看。可以类比成快递单号货物从广州发到北京中间经过 5 个中转站每一站都有扫描记录。git describe输出里的“v1.2.3-5”相当于告诉你“这是从广州站发出来的过了 5 站”而g3f4a2b1就是包裹上的唯一追踪码顺着它能找到任何一个环节。1.3 为什么说它是版本号生成的基本盘在我个人维护和参与过的开源项目里git describe几乎成了一个默认选项。无论是 Linux 内核社区、很多知名中间件项目还是一些大型游戏引擎工具的构建系统都在直接用或者二次封装它的输出。原因不复杂它有 git 的仓库历史作背书任何一次构建只要知道 git 元数据存在就能生成唯一且有序的版本标识不需要额外维护任何状态文件。要说它的局限唯一比较明显的是“必须有 tag”。仓库里一个 tag 都没有的时候git describe会报错。但换个角度看这也倒逼团队把 tag 管理纳入正规流程——版本号这件事本来就该跟发布节奏绑定而不是想起来才打一个。这一套东西适合谁来参考我的答案是所有需要把代码变成可交付产物的开发者和运维。前端要发 npm 包、Java 后端要出 jar/war、移动端要打安装包、桌面软件要出安装程序、嵌入式固件要烧录全都离不开版本标识。哪怕是个人项目学会了这一套至少不会再出现“这个文件到底是不是最新”的迷思。2. 拆解 git describe 的输出格式从一段字符串看懂来龙去脉2.1 默认输出长什么样先用一个干净的测试仓库把默认行为摸清楚。建一个新目录初始化 git 仓库打一个 tagmkdir demo cd demo git init git commit --allow-empty -m initial git tag -a v1.0.0 -m release v1.0.0 echo add feature A feature.txt git add feature.txt git commit -m feat: add feature A echo add feature B feature.txt git commit -m feat: add feature B仓库里现在有 1 个 tag 和 2 个新提交。此时执行git describe输出类似v1.0.0-2-gd123abc这意味着当前 HEAD 位于v1.0.0之后 2 个提交处gd123abc是当前 commit 的短哈希。但这里有一个非常容易踩的默认行为默认情况下git describe只认带注释的 tagannotated tag。什么叫带注释的 tag就是用git tag -a打的那种它会额外生成一个 tag 对象记录打标签的人、时间、附注信息。而直接用git tag v1.0.0打的是“轻量 tag”只单纯指向某个 commit。所以如果你的仓库历史里全是轻量 taggit describe默认可能搜不到任何可用 tag然后报错fatal: No names found, cannot describe anything.这个报错我第一次遇到时愣了半天因为明明git tag输出里能看到一堆 tag。后来查了文档才明白不是没有 tag而是没有“合条件”的 annotated tag。解决办法要么改用-a打 tag要么在命令里加--tags参数告诉 git 把轻量 tag 也算进去。2.2 常用参数怎么选git describe的参数不算多但每个在实际场景里都有讲究。--abbrevn控制短哈希的长度。默认是 7 位但有些大仓库在几百次提交后 7 位可能撞上重复你可以改成--abbrev12保险一些。需要注意如果省略了哈希完全没有-gxxxxxxx部分说明你用了--abbrev0的组合要小心覆盖 tag 上恰好为零距离的情况。--always是一个“兜底开关”。仓库里一个 tag 都找不到时默认会报错加上--always后它会退而求其次只输出当前 commit 的短哈希。对于刚从空仓库起步、还没正经打 tag 的项目来说这个参数能保证构建脚本不至于因为找不到 tag 直接挂掉。--long是“补齐格式”的开关。前面提到如果当前 commit 恰好就在 tag 上默认输出只有 tag 名本身比如v1.0.0没有后面那串-0-gxxxxxxx。在某些构建系统里这种格式的“不稳定”会造成麻烦——同一套解析脚本既要处理带距离的又要处理不带距离的。加上--long之后即使当前提交就是 tag也会强制输出成v1.0.0-0-gxxxxxxx格式统一解析逻辑简化为一种。--dirty则用来检测工作区状态。如果当前仓库里有未提交的修改输出末尾会追加-dirty标记v1.0.0-2-gd123abc-dirty这对于“这个包到底是用干净代码打的还是夹带私货打的”有非常重要的判断价值。下面专门用一个小节来说它。2.3 输出不稳定用 --match 过滤你想要的 tag还有一个容易被忽视的场景仓库里可能同时存在v1.0.0、v2.0.0-rc.1、以及一堆build-xxx之类的 tag。git describe默认找的是“最近的”但如果发布脚本恰恰只关心某个前缀的 tag就需要用--match来过滤。举个例子。假设仓库里既有v1.8.0又有新版的功能 tagnext-20250101而你只想基于v*格式的 tag 生成描述可以这样git describe --matchv[0-9]*这样next-20250101就被忽略了输出只会基于v1.8.0或更近的 v 系列 tag 来计算距离。实际项目中--match特别适合处理“预发布 tag 污染主线版本号”的问题。如果你在主干上打了v2.0.0-rc.0紧接着又提交了一批代码默认的git describe很可能会用v2.0.0-rc.0作为基准导致正式版构建的版本号变成了v2.0.0-rc.0-12-gxxxxxxx既长又误导人。配合--match过滤掉 rc 后缀的 tag就能让正式版和预发布版各走各的版本线。3. 把 git describe 组装成可交付的版本号3.1 从 describe 结果到正式版本号git describe的原始输出适合做内部标识但真要拿去当展示给用户看的版本号比如应用设置页里那个“版本 1.2.3 (build 20250101)”还是差点意思。所以工程上常见做法是把它的输出再做一层加工。直接上一段我在构建脚本里用了很久的函数function compute_version() { local describe$1 # 去掉 v 前缀将 -2-gd123abc 替换为 .dev2gd123abc echo $describe | sed -E s/^v//; s/-([0-9])-g([0-9a-f])\.?/.dev\1\2/ }输入v1.2.3-5-g3f4a2b1输出1.2.3.dev53f4a2b1这套格式很接近 Python 生态里 PEP 440 的本地版本标识规范1.2.3是语义化版本.dev5表示这是正式版之后的第 5 个开发构建3f4a2b1是本地版本标识。既符合“语义化版本”的习惯又能被很多包管理工具接受。如果你用的是 npm可以把它转换成类似的 pre-release 标识git describe --tags --long | sed -E s/^v//; s/-([0-9])-g([0-9a-f])/-beta.\1\2/输出类似1.2.3-beta.53f4a2b1放进 package.json 的 version 字段也基本能通过 npm 的版本校验。还有一点值得做把git describe里的短哈希统一转成小写。默认输出里的哈希就是小写但如果你在脚本里拿它跟其他系统比如 GitLab CI 里暴露的CI_COMMIT_SHORT_SHA比对要注意对方可能是大写。统一转小写能避免不少比较逻辑里莫名其妙的“不相等”。3.2 开发版和发布版要分开处理这里有个工程上的关键选择不是所有构建都应该用同一套版本号逻辑。我见过有的团队把git describe的原始输出直接当成正式版本号用结果1.2.3-5-g3f4a2b1出现在用户端的“关于”页面非技术用户看不懂运维同事也嫌麻烦。我的建议是分两条线发布模式下打 tag 那次构建直接取 tag 名去掉v前缀得到干净的1.2.3。开发/测试模式下任意非 tag 构建用 describe 的结果转成.devNhash这样的带构建元数据的版本号。判断当前构建是否打在 tag 上很简单if git describe --tags --exact-match /dev/null 21; then VERSION$(git describe --tags --abbrev0 | sed s/^v//) else VERSION$(git describe --tags --long | sed -E s/^v//; s/-([0-9])-g([0-9a-f])/.dev\1\2/) fi这样发布包是干净的1.2.3而内测、预发布、自动构建出来的包则带.devNhash通过版本号一眼就能分辨构建来源和代码位置。3.3 在 CI 里落地这套逻辑本地跑几次没问题但版本号真正的价值体现在自动化构建环节。最核心的一点是CI 环境里默认拉下来的代码往往不带完整的 tag 历史尤其是在用浅克隆shallow clone的时候——很多持续集成系统的默认 checkout 行为只拉最近一次提交或者只拉分支最近的若干条记录历史 tag 一并缺失。此时直接跑git describe结果可能是 fatal error或者只拿到一个裸哈希。解决方式也很直接。以常见 CI 为例# 先补全历史与 tag git fetch --tags --unshallow --prune # 或者至少确保 tag 被拉取 git fetch --tags --force讲几个我在 CI 配置里踩过的坑都很有代表性。第一如果流水线里有多步构建尽量只在一个环节生成版本号然后把结果以 artifact 或环境变量的形式传给后续环节避免每个环节各自跑一次git describe产生不一致的结果。第二release 分支和主干并行时不要在同一个 CI 任务里对多个分支的构建产物做统一编号分支间的能力完全不同。第三git fetch --tags --force要慎用它会覆盖本地 tag 引用如果服务器上前一个 tag 被删除过这句话会让本地仓库“忘记”那个 tag——默认行为反而不会这样。最后一次提醒CI 里跑这串命令的账号特别容易忽略身份认证尤其是私有仓库。如果git fetch --tags一直报权限错误优先检查流水线里配置的仓库访问令牌是否对这个项目有读取权限。3.4 额外一个小技巧把描述信息塞进构建产物版本号只是第一步真正排查问题上价值的是把 git 描述信息写进产物可访问的位置。做法很朴素构建时将git describe输出写入一个文本文件或 JSON 片段随产物一起发布。以任意一个 Web 项目为例在构建前生成version.jsoncat dist/version.json EOF { version: $(git describe --tags --long), commitSha: $(git rev-parse HEAD), buildTime: $(date -u %Y-%m-%dT%H:%M:%SZ) } EOF这样线上出了问题随便打开/version.json就能看到“这是哪个版本、哪个 commit、什么时候构建的”截图发给开发者2 分钟就能定位到代码位置。我参与维护的不少系统都靠这个小文件扛过了无数次“线上版本对不上”的扯皮。4. 实战中绕不开的坑git describe 的疑难杂症4.1 分支并发时的“看走眼”现象git describe找的是“从当前 commit 可达的历史里最近的 tag”。这句话大家都理解但落到真实的多分支开发模型里就有歧义。举个例子。主干上从v1.0.0之后提交了 100 次代码已经到v1.1.0了与此同时产品要从v1.0.0拉出一个hotfix-1.0.x分支修线上问题在分支上提交了 2 次。在 hotfix 分支上跑git describe因为最新的 tagv1.1.0并不在它的提交历史上它只从v1.0.0分出来git 只能向上找到v1.0.0输出会是v1.0.0-2-gxxxxxxx。这个结果其实是正确的它的意思就是“这是基于 v1.0.0 之后又改了两笔的产物”但如果你下意识以为是“主干上的 v1.1.0 之后又改了 2 笔”就会误判版本。处理办法不是改命令而是在发布流程里明确当前构建所基于的分支。CI 里通常都能拿到分支名比如CI_COMMIT_BRANCH或GITHUB_REF_NAME把分支名和git describe输出拼接成完整标识比如hotfix-1.0.x-v1.0.0-2-g3f4a2b1就完全不会再产生歧义了。4.2 一个 tag 引发的“commit 数量失真”错觉另一个让我印象深刻的坑是同一个 commit 被多个 tag 指向。比如说你打了v1.0.0之后发现漏了一个注释没重新提交代码而是直接删掉这个 tag 又打了一个v1.0.1两个 tag 指向同一个 commit。此时git describe会选哪个答案是它按一定规则在多个候选里挑选通常是 find 描述深度最短的那个实际上会随机返回候选里的一个。更常见的情况是 tag 打在了合并节点而不是提交节点上。一个 PR 合并进来节点上的 tag 名跟分支名对不上git describe距离永远显示 0 或 1版本号看起来“少了几笔”。遇到这种情况先看一看git log --oneline -10确认 tag 和实际提交的层级关系再判断。4.3 dirty 标记与工作区干扰--dirty参数是个好东西但用了它之后要理解它的脾气。它检测的不是“有没有未提交的文件”而是“已跟踪文件的索引和内容是否有差异”。如果你在构建前生成过临时文件而这些文件没有被 git 跟踪git describe --dirty是看不到它们的但只要你动了任何已跟踪文件没提交哪怕是改了一个空格也会被标记成 dirty。我在实际使用中遇到过一种恼人的情况构建脚本本身会修改工程里的某些文件比如往配置里写构建时间然后再调用git describe --dirty结果每次构建产物都带上了-dirty后缀。输出本身没错但会让版本号看起来“永远不干净”。解决办法是在构建流程中先快照版本号再做任何文件写入。打个比方先给人拍照再让化妆师上妆这样照片上的人永远是素颜状态。顺序错了后面全是脏数据。4.4 版本回退与 tag 重建还有一种稍微隐蔽的场景发布后发现变更有问题团队会把代码回到上一个 tag然后重新打一个补丁 tag。这时仓库的 tag 引用会变化如果构建脚本使用的 tag 还停留在旧引用上会出现“版本号倒挂”新构建的版本号看起来比旧版本还小。担心这个问题的团队建议在 CI 的获取 tag 环节加上git fetch --tags --force --prune这句话确保本地 tag 引用与远端一致避免继续引用已被删除或移动的旧 tag。但请一定注意它跟上一节说的--force的差异--prune会把本地已删除的 tag 同步删掉保留下来的都是远端还存在且有意义的。二者配合使用在发布环境中几乎是必须的。4.5 轻量 tag 的“隐形”问题与常规 check轻量 tag 的问题前面已经提过默认情况下git describe不认它们。但这里再补一个实际项目里更常见的坑有些团队习惯于“发完包后补一个轻量 tag”觉得省事。第二天构建系统输出的版本号却跟昨天一模一样或者干脆报错排查半天才发现 tag 没有注释不被识别。我把这列为“发布流程检查清单”里的一项代码提交前统一约定所有发布 tag 必须用git tag -a打附带版本信息说明。如果你是某个系统的维护者尽量在 CI 里显式加上--tags或者使用git describe --tags --long对轻量 tag 更宽容。定期用git tag -n抽查 tag 列表发现只有 tag 名没有附注的就是轻量 tag趁早补上。5. 关于版本号的一点个人体会跑了几年构建流水线踩过各种版本号相关的坑之后我的体会是版本号这个事看起来小撑起来难。团队的工程化水平到不到位不看口号看三样东西能不能追溯到代码、能不能比较新旧、能不能让人和人之间不产生歧义。git describe恰好在这三个维度上都有解而且解得很干净。再多说一句不要等到产品上线前一刻才想起要改版本号版本号是每次提交流程的自然产出不是发布当天的临时动作。用git describe把它固化到脚本里每个开发者本地 build 拿到的版本和 CI 上 build 拿到的版本能对得上问题响应速度自然就快了。这也是我最推荐每个项目尽早把这一套机制落地的原因。
返回列表