2026年6月12日星期五

为什么 VOO 不会因为市场交易而长期偏离 S&P 500

我最近在思考 S&P 500 指数 ETF 时遇到一个问题:VOO 一方面说自己追踪 S&P 500,另一方面又像普通股票一样在交易所买卖。既然 VOO 的价格由市场供求决定,如果很多人突然买入 VOO,它的价格为什么不会被推高,从此偏离 S&P 500?

我在跟 ChatGPT 的对话中最初是这样问的:

“Will active trading of the ETF drive the price away from the index?”

要回答这个问题,我们先要定义三个数字:

  1. S&P 500 指数:按照一套规则计算出来的数字。
  2. VOO 的净资产价值(Net Asset Value,NAV):VOO 实际持有的股票减去负债后,每一份 ETF 对应多少资产。
  3. VOO 的市场价格:投资者在交易所买卖 VOO 时实际成交的价格。

VOO 的美元价格本来就不会等于 S&P 500 的指数点位。「追踪指数」指的是 VOO 的投资回报应该接近指数的回报,而不是两个数字应该相等。VOO 要追踪 S&P 500,实际上需要解决两个不同的问题:首先,基金持有的资产要追踪指数;其次,ETF 的市场价格要追踪基金持有的资产。

基金持有的不是指数,而是一篮子股票

S&P 500 本身不是一种可以买入的资产,只是一个按照规则计算出来的指数。

VOO 才是真正持有资产的基金。Vanguard 要让 VOO 追踪 S&P 500,就按照接近指数权重的比例持有指数成分股。假设一家公司占 S&P 500 的 7%,它也应该占 VOO 资产的大约 7%。指数调整成分股或权重时,VOO 也要相应调整持仓。所以 VOO 的 NAV 会跟着 S&P 500 内股票的价格一起变化。

不过两者不会完全一致,因为 VOO 还要承担管理费用、交易成本,也可能因为现金、股息到账时间和调仓时点等原因产生偏差。这种基金回报与目标指数之间的差异叫做 tracking error。

这解释了 VOO 的资产如何追踪指数,我们还需要解释 VOO 在交易所的成交价为什么追踪 NAV。

我买入 VOO 时,Vanguard 通常什么都不用做

普通投资者买卖 VOO,发生在二级市场。买家从卖家手上买到已经存在的 VOO,Vanguard 不需要因为每一笔买单都立即发行新的 VOO,也不需要同时买入 500 家公司的股票。这跟普通股票很像。只要市场上有人愿意卖,买卖双方就可以成交。

但如果买家明显多于卖家,VOO 的市场价格就可能高于 NAV,也就是出现溢价。反过来,如果很多人都想卖,市场价格也可能低于 NAV,也就是出现折价。ETF 并不是保证永远没有折溢价。它真正特殊的地方,是存在一套让折溢价难以长期扩大的机制。

ETF 的份额可以增加,也可以减少

理解这套机制的关键,是 ETF 同时存在一级市场和二级市场。

普通投资者在二级市场互相买卖 VOO。另一方面,一些叫做 Authorized Participant(AP,授权参与者)的大型金融机构可以在一级市场直接跟基金进行交易。它们不能一股一股地申购和赎回,而是以很大的一组份额,也就是 Creation Unit 为单位操作。

Vanguard 不会看到散户按下「买入」按钮就主动印出更多 VOO,但当 VOO 的价格相对 NAV 出现足够大的差异时,AP 就有动力通过申购或赎回赚取差价。假设一篮子对应的 S&P 500 成分股价值 100 美元,但 VOO 在市场上能够卖到 101 美元。AP 可以:

  1. 用 100 美元买入相应的一篮子股票。
  2. 把这篮子股票交给 Vanguard。
  3. 从 Vanguard 换回新发行的 VOO。
  4. 在市场上以接近 101 美元的价格卖出 VOO。

AP 赚取差价的同时做了两件事情:它买入成分股,并增加 VOO 的市场供应。前者可能轻微推高成分股价格和 NAV,后者会压低 VOO 的市场价格。两边的价格因此重新靠近。

如果 VOO 只卖 99 美元,而对应资产价值 100 美元,操作就会反过来。这会减少市场上的 VOO 份额,同时增加对 VOO 的买入需求,把 VOO 的价格向 NAV 推回去。

美国证交会的 ETF 投资者公告把这套过程称为 ETF 结构内置的套利功能。AP 不是出于维护市场稳定的公益目的来工作,而是因为偏差本身代表可以赚钱的机会。正是逐利动机,让 VOO 的供应量能够随着市场情况扩张和收缩。

发行更多 VOO 不会摊薄原有投资者

这里还有一个容易产生误解的地方:如果 Vanguard 可以不断发行 VOO,那原有份额会不会被摊薄?答案是不会,因为 AP 不是空手换走新的 VOO。它必须同时交给基金一篮子等值的股票。

假设 VOO 原本有 100 亿美元资产和 2000 万份 ETF,每份 NAV 是 500 美元。AP 交进价值 1 亿美元的股票,同时换走 20 万份新的 VOO 后,基金有 101 亿美元资产和 2020 万份 ETF,每份 NAV 仍然是 500 美元。

基金总资产和份额数量一起按相同比例增加,NAV 不会因为申购本身改变。真正可能改变 NAV 的,是 AP 在市场上买入成分股时对股价造成的影响。

反过来赎回也是一样。基金交出一篮子股票,同时注销对应的 VOO 份额,总资产和份额一起减少,不会凭空把价值转移给任何一方。

ETF 的买卖也会传导到成分股

当 VOO 溢价时,AP 一边卖出 VOO,一边买入成分股,所以 VOO 的价格可能下降,成分股的价格和 NAV 也可能上升。折价时则相反:AP 买入 VOO,并可能卖出赎回得到的成分股。套利机制不是单向地让 ETF 服从 NAV,而是把 ETF 的供求传导到成分股市场,让两边的价格向中间靠拢。

不过对于 VOO 来说,S&P 500 成分股的交易非常活跃,一次 ETF 申购分散到数百家公司后,对每家公司股价的影响通常很小。AP 还可以使用已有库存、期货和其他工具进行对冲,不一定要在同一瞬间机械地买齐所有股票。因此在正常市场环境下,我们看到的主要效果仍然是 VOO 的市场价格被迅速拉回 NAV 附近,而不是整个 S&P 500 被 VOO 拉着走。

「不会偏离」不是绝对保证

准确来说,VOO 不是不会偏离 S&P 500,而是有两套机制分别限制两种偏差:

  1. Vanguard 按照指数成分和权重管理资产,限制 NAV 相对 S&P 500 的 tracking error。
  2. AP 通过申购、赎回和套利,限制 VOO 市场价格相对 NAV 的折溢价。

市场价格仍然可能在短时间内偏离 NAV。套利需要时间和成本;如果市场波动剧烈、成分股缺乏流动性、不同市场的交易时间不一致,或者 AP 不愿意承担风险,偏差都可能扩大。

VOO 的特殊优势不在于这套机制永远不会失败,而在于它追踪的是流动性很高的美国大型公司股票,同时 VOO 本身的交易也很活跃。套利交易容易执行,稍小的偏差就可能值得竞争者出手,所以偏差通常难以长期存在。

2026年6月2日星期二

用 Whipser 模型生成播客文字稿

上一篇文章里,我说到我在做自己的 LLM Wiki,那我肯定是要把我参与主持的《牛油果烤面包》播客收纳进去的。问题是,播客的内容主要是音频,而我写的所有 skills 都是用来处理基于 Markdown 的文本的,那我要如何收纳音频呢?

跟 ChatGPT 和 Claude 讨论之后,得到的方案是:用 OpenAI 开源的 Whipser 模型,输入音频生成文字稿。在 macOS 上面,只需要用 brew install whisper-cpp 安装 Whipser,然后就可以调用 whisper-cli 来处理音频文件。听起来很简单,但实际操作肯定是要踩坑的。

large-v3 模型会产生 loop

Whisper 当下有两个主流模型,large-v3large-v3-turbo。前者大一些,跑起来慢一些,但效果更好一些;后者小一些,速度快两三倍,但输出精度差一些。我让 Claude Code 帮我写实际调用 whisper-cli 的代码,让它分别测试这两个模型的效果,它发现 large-v3 确实好一些,所以我们先选择了它,尽管处理每一期播客都要花 30 分钟。

处理了几期播客后,Claude Code 发现输出的文字稿存在 loop 的问题。意思是,同一句话在音频中只出现了一次,但模型听到了多次,所以同一行文字在文字稿中重复了多次。如果不是 Claude Code 在帮我看着文字稿输出的结果,我自己肯定不会留意到这样的问题(几百上千行的文字稿没办法看)。它留意到这个问题后,跟我解释说更大的模型更容易出现 loop 的问题,建议降级到 large-v3-turbo 试试,降级后问题确实消失了。

最后我跟 Claude Code 做了一个决定:每一期的音频先用 large-v3 处理,等处理完了再分析文字稿,如果同一行内容连续出现 5 次或以上,那就算做 loop,然后用 large-v3-turbo 重新生成文字稿。我们用这个方法把剩余的音频都处理了,大概有 1/3 的文字稿因为存在 loop 而需要重新生成。

Whipser 把我的名字听成了「Kat」

我参与录制的节目,开头肯定会有一句「大家好,我是 Cat」,然后 Whipser 会把「Cat」听成了「Kat」。我问 Claude Code 怎么办,它发现 Whipser 可以输入一个文本的 prompt,于是我就把播客的核心信息写到 prompt 里面去,包括我名字的正确拼写:

《牛油果烤面包》播客聊科技发展趋势,聊各行业来龙去脉。我们坐标硅谷,邀请第一线的资深专家分享给大家听!主持人:Cat、斯图亚特、Sean、Vindy、David。

在这段 prompt 的帮助下,Whipser 识别我名字的正确率提高了,虽然还是不完美。

既然可以用文本 prompt,我就把每一期节目的文字描述也追加进 prompt 里,这样 Whipser 听到有关信息能够更好地识别出来。我粗略地看了一下,效果还不错。

2026年5月19日星期二

写 LLM skills 踩过的坑

我最近开始构造自己的 LLM Wiki,在这个过程中我需要编写自己的 LLM skills。(一方面我的文档架构跟别人不一样,只能参考别人的 skills 写自己的;另一方面,我就喜欢折腾。)在这个过程中踩了几个坑,在此记录下来。将来不一定有用,因为 LLM 发展太快了,今时今日的局限,6 个月之后可能就不是问题。

抄写无语义文本会抄错

我需要跟踪我的哪些博客文章更新了,然后重新生成对应的总结文档,为此我用 JavaScript 计算了每一篇博客文章的 SHA256 hash,然后写到总结文档的头部,只有 hash 对不上时才重新生成总结文档。在 skill 里面我把算好的 hash 提供给 LLM,让它在编写总结文档时把这写到头部,它有时候竟然会抄错整个 hash 中的一个字符。

我问 Claude Code 为什么会发生这样的事情,它说 LLM 擅长顺着 token 吐字符,而 hash 是没有任何语义的随机字符串,LLM 不擅长输出这样的 token,所以会偶尔输错。Claude Code 帮我改了一下 skill,在里面强调了 hash 必须一字不差地抄写到总结文档里。将来我会改用 JavaScript 生成好带 hash 头部的总结文档,再让 LLM 去填充内容,这样才能保证不出错。

在时间轴上刻舟求剑

我在 skill 里让 LLM 把当前时间的 ISO UTC 时间戳写到文档头部。我跑了一次这个 skill,过了一个小时又跑了一次同一个 skill,发现它写入的时间戳还是一小时前的。我问 Claude Code 为什么会这样子,它说 LLM 会记住「当前时间」这个概念,在同一个对话中不再尝试查时间。

原来 LLM 对时间流逝完全没有概念!这简直就是在时间河流中的刻舟求剑,一旦记下了当前时间就认定它永远不变。为了解决这个问题,我让 skill 调用脚本获取当前的时间戳,不再是自行输出时间戳。因为我已经在用 JavaScript,所以我就让 LLM 调用 Node 执行一行 JavaScript 获取时间戳:

node -e 'console.log(new Date().toISOString())'

前后自我矛盾和自作聪明

我写了一个需要跟我交互的 skill。这个 skill 让 LLM 审阅 Wiki 当中已有概念文档,然后找出可以合并的概念(因为它们是同义词、相关概念或者从属关系),接着问我是否要合并,以及按照哪个方向合并(把哪个概念合并进另一个概念)。我进行是否合并的最终决定。

这个 skill 写着,在 LLM 建议我合并概念 A 和概念 B 时,它应该提供一个有 4 个选项的菜单:

  • 把 A 合并进 B
  • 把 B 合并进 A
  • 不合并(以后也不要再提出合并 A 和 B)
  • 跳过(再次使用这个 skill 时可以再提出合并 A 和 B)

此外我还让 LLM 帮我判断应该往哪个方向进行合并,如果它能决定哪个方向更好的话它就应该把哪个选项放在首位,旁边注明「推荐」,例如说:

  • 把 B 合并进 A(推荐)
  • 把 A 合并进 B
  • 不合并
  • 跳过

实际执行这个 skill 时,它竟然给我出了这样的选项:

  • 不合并(推荐)
  • 把 B 合并进 A
  • 把 A 合并进 B
  • 跳过

这就很自我矛盾了。因为 skill 里面写着,LLM 必须找出值得合并的概念。如果 LLM 推荐我不要合并这两个概念,那就违背了它上文「值得合并」的筛选条件。

更搞笑的是,有时候 LLM 会自作聪明地把两对概念同时拿出来让我审阅,于是给出这样的选项:

  • 都不合并(既不合并 A 和 B,也不合并 C 和 D)
  • 分别审阅这两对概念

遇到这样的问题,我只好跑回去让 Claude Code 帮我改 skill。它往 skill 里面加入了更多约束的 prompt,上述现象确实消失了。但为什么多加约束就能用,约束增加到多少就会影响 skill 执行的质量,这一切我都不知道。

总结

上述问题全部出现在 Sonnet 4.6,换成 Opus 4.7 有没有同样的问题,我不知道。这就是 LLM 踩坑的局限性,你永远不知道这些坑是不是特定模型特定版本导致的。今时今日需要在 harness 层面进行修复的问题,或许 6 个月之后的 LLM 就不再依赖这类 harness 操作。这篇文章 6 个月之后再看,是否还有价值,还真的很难说。

2026年5月6日星期三

Reverse Centaur

Reverse Centaur(逆向半人马,也就是马的大脑驾驭人的躯体)是一个很少人讨论的概念,我甚至没见过有人用中文提及。

1997 年 Garry Kasparov 被深蓝击败后提出了 Centaur Chess(半人马象棋)的概念:业余选手搭配普通算法,既可以击败大师选手,又可以击败顶级算法。2000 年到 2010 年期间,这个概念被证实可行。(之后神经网络彻底超越人类,人类的介入只会让结果更差。)

Centaur 成了一个泛指「人类利用和指挥机器」的概念,随后 Cory Doctorow 提出了 Reverse Centaur 的概念,指代「人类被机器指挥和控制」。零工经济(例如外卖骑手)就是典型例子,算法决定如何调度人,被调度的人缺乏对大局的了解,只是充当机器临时的四肢(跑腿),等待被无人驾驶和机器人取代。

AI 时代,鼓吹者当然说未来是半人马模式,人类利用 AI 能做更多原本想做而不能做的事情。但现实也可能发展为更加阴暗的逆向半人马模式,AI 把人类当作外设来用,人类被迫去做自己不想做的事情。

2025年4月18日星期五

DEI 是不应该有配额的

我还记得在 Facebook 参加「反歧视」培训时,我举手提了一个问题:「如果我们不是歧视少数的一方,而是歧视多数的一方,这是否合法?」我得到的答案是「逆向歧视也是一种歧视」。这意味着不歧视的话,招聘招到什么样的人就是什么样的人,不可能给少数群体安排一个配额。(除非招聘总人数没有上限,在少数群体满足配额之前无论招到什么人都继续招聘。)

Facebook 在这方面是说到也做到的,不存在任何针对少数群体的配额。针对少数群体的弱势地位,有几件事情可以做:

  1. 可以放宽招聘的入口。如果招聘职位招到的都是男性,那考虑一下是不是招聘广告在女性面前的暴光率就不够,让更多的女性进入招聘的漏斗是可以的,但进入了漏斗之后的层层筛选就不能有歧视了。
  2. 可以在人远远没到招聘之前提供帮助。例如说那些针对中学生的编程课程,有女生想要加入就应该欢迎她们加入。如果有女生因为社会刻板印象而怀疑自己是否应该加入,可以鼓励她们撇开社会刻板印象从自身的兴趣出发考虑要不要深入学习编程。
  3. 可以对员工提供「无意识歧视(unconscious bias)」的培训。有时候员工并没有主动歧视某个群体,但是社会刻板印象会导致他们无意识地就在歧视某个群体,例如说有出差的机会时就默认怀孕的员工不会想要参加。这种培训可以让大家留意到自己日常工作中的一些无意识行为,从而避免因此引入的歧视。

我之前听说 Google 的招聘存在针对少数群体的配额,如果属实的话这其实也是一种歧视。


为什么有些政府或企业会设立 DEI 配额?因为这样子才能显示出自己真的有贡献,产生了影响(impact)。我上面所说的 Facebook 的做法,往往是无法在数字上体现成果的,一测量就会发现数值没产生足够可信的变化,数值浮动可能纯粹是噪声。这样很容易引起别人的质疑,「投入这么多资源去改善少数群体的处境,到底有没有效果?」

设立少数群体的招聘配额或者是晋升配额,通过逆向歧视完成配额,至少避免了上述的质疑,因为少数群体的实际招聘和晋升人数或比例每一年都在涨。这时候政府和企业就可以大谈自己在 DEI 上面的贡献有多少,成果有多出色。然而正是这种急功近利的做法,导致了最近反 DEI 的现象。

Donald Trump 反对 DEI,并且说要创造一个「colorblind and merit-based」的社会,然而真正符合 DEI 精神的做法本身就应该是「colorblind and merit-based」的。他和他的支持者实际反对的是逆向歧视,这是一个合理的诉求,毕竟逆向歧视也是歧视,只不过受到伤害的是多数群体而已。但是这种做法把 DEI 的名字搞臭了,现在不再有一个能代表真正「colorblind and merit-based」的词汇。


真正做 DEI 必须要沉得住气,在数据长期不动的前提下坚持做自己认为正确的事情。我在 Facebook 内部接触到各种帮助员工提升的群体,大多数帮助的结果都无法量化,但如果你相信你帮到了别人你就应该继续做下去。

我曾经跟负责 Facebook 女工程师成长的资深工程师聊过,她说她们组织资深的女工程师对其他女工程师提供帮助,结果就是无法量化。无论你如何分析晋升和绩效数据,都没有可量化的结果。有时候做好事和面子工程是不可兼得的。她最后的结论是,「如果你认定事情是对的,你就应该坚持去做,无论最好是否有可量化的结果」。

2024年7月28日星期日

做给 GitHub Actions 开发者用的 Actions(Part 2)

在开发 GitHub Actions 时,有时候会遇到这样的问题:如果这个 Action 接受来自用户的 GitHub token,那该如何以这个 token 背后的身份完成所需的 git 操作?

我在上一篇文章里介绍了我做的 config-git-with-token-action,专门用来解决这个问题的,但这个 Action 在配置 git 的时候又是怎么获取对应的用户名和邮箱的呢?这是通过另外一个叫做 token-who-am-i-action 来解决的。

这个 Action 可以接受用户生成的 PAT (Personal Access Token),也可以接受 GitHub App 的 token。(默认的 GitHub Actions token 当然是接受的。)它会使用 Action 调用 GitHub API 查询关于 token 自己身份相关的信息,因为类似于 Linux 的 whoami 命令所以我把它叫做 token-who-am-i-action


这个 Action 能返回的信息当中,首先要看 typeUser 还是 Bot

  • 如果是 User 的话,它所返回的其他信息包括 nameemail。这两项都可能是 undefined,因为用户可以选择隐藏自己的名称和邮箱。
  • 如果是 Bot 的话,它必须返回 nameemailappSlug,全部都是字符串,不可能是 undefined。每一个 GitHub App 必须有一个用于显示的名字,然后由此生成对外公开的地址(格式为 https://github.com/apps/${appSlug})。GitHub App 其实并不存在邮箱,但使用特定格式(${id}+${login}@users.noreply.github.com)生成的邮箱会被正确识别并显示正确的头像,例如说 GitHub Actions 默认 token 的身份使用的邮箱是 41898282+github-actions[bot]@users.noreply.github.com

除此之外,无论是哪种类型的身份,这个 Action 还能返回 loginidglobalIdlogin 对于 User 来说,就是他的个人页面地址(https://github.com/${login})中的路径,有些文档也把这个叫做 username;对于 Bot 来说,这可以由 appSlug 通过特定格式(${appSlug}[bot])生成,例如 GitHub Actions 的就是 github-actions[bot]

至于 idglobalId 分别用于 REST API 和 GraphQL。这是 GitHub 数据存储很有意思的一个地方。对于每一种 REST API 的类型(如 user),它背后都是一张独立的关系型数据表,都有一个这种类型内部唯一的 id,但这个 id 可以跟其他类型冲突。在 GraphQL 里面,因为 id 可以用来查询任何类型,所以引入了 globalId 的概念,保证即使跨类型依然唯一。GraphQL 的某些 query/mutation 的 id 默认就是 globalId;但某些必须加上 X-GitHub-Next-Global-ID: 1 的 header 进行请求才是 globalId,否则就是跨类型不唯一的 id


那我们如何在编写自己的 Action 时调用 token-who-am-i-action 呢?如果你在编写的是 composite action,可以这样写:

runs:
  using: 'composite'
  steps:
    - uses: CatChen/token-who-am-i-action@v1
      id: token-who-am-i
      with:
        github-token: ${{ inputs.github-token }}

    - shell: bash
      env:
        LOGIN: ${{ steps.token-who-am-i.outputs.login }}
        GLOBAL_ID: ${{ steps.token-who-am-i.outputs.global-id }}
        ID: ${{ steps.token-who-am-i.outputs.id }}
        NAME: ${{ steps.token-who-am-i.outputs.name }}
        EMAIL: ${{ steps.token-who-am-i.outputs.email }}
        TYPE: ${{ steps.token-who-am-i.outputs.type }}
        APP_SLUG: ${{ steps.token-who-am-i.outputs.app-slug }}
      run: |
        echo "Login is $LOGIN"
        echo "Global id is $GLOBAL_ID"
        echo "Id is $ID"
        echo "Name is $NAME"
        echo "Email is $EMAIL"
        echo "Type is $TYPE"
        echo "App slug is $APP_SLUG"

如果你编写的是 JavaScript action 可以先从 NPM 安装同名的 token-who-am-i-action 包,然后再进行调用:

import { tokenWhoAmI } from 'token-who-am-i-action';

const me = await tokenWhoAmI(githubToken);

const {
  login,
  globalId,
  type,
} = me;

if (me.type === 'User') {
  const {
    id,
    name,
    email,
  } = me;
} else if (me.type === 'Bot') {
  const {
    appSlug,
    id,
    name,
    email,
  } = me;
}

希望这个 Action 对各位 GitHub Actions 开发者有用。非 Actions 开发者也可以直接在 Workflow 里面使用这个 Action,如果你的 Workflow 使用非默认的 GitHub Actions(机器人)身份进行 git 操作的话。大家在使用过程中遇到什么问题,或者是希望增加什么新功能,欢迎到项目的 GitHub 开 issue。

2024年6月30日星期日

做给 GitHub Actions 开发者用的 Actions(Part 1)

在开发 GitHub Actions 时,有时候会遇到这样的问题:如果这个 Action 接受来自用户的 GitHub token,那该如何以这个 token 背后的身份完成所需的 git 操作?

使用 token 操作 GitHub API 是很容易的,通过 @actions/github(或 @octokit/core)创建一个 Octokit 实例时把 token 传进去就可以了,之后通过这个实例进行的所有 API 调用(包括 REST 和 GraphQL)都会以这个 token 的身份进行。但命令行的 git 操作怎么办呢?如何让 git commit 的作者变成 token 背后的身份?如何让 git push 以 token 背后的身份进行提交?(这两者并不一定要用同一个身份。)我做了 config-git-with-token-action 就是用来解决这个问题的。

这个 Action 会对 ghgit 进行配置,让它们的身份信息变成 GitHub token 背后的身份信息。gh 的配置相对简单一些,把 GH_TOKEN 这一环境变量配置好就行了,然后执行 go auth status 就能打印出 gh 认为自己在使用的身份信息。对 git 进行配置稍微麻烦一些,gh auth setup-git 只能保证 git 在跟 GitHub 交互时从 gh 获得身份信息,但并不指明具体是哪一个身份。为了保证 git commit 使用正确的身份,需要通过 git config 来设置正确的用户名和邮箱。此外,为了保证 git push 使用正确的身份,需要通过 git remote set-url origin 来更新上游地址,在 https://github.com/… 的地址中注入用户名和 token,让它变成 https://username:token@github.com/…

这个 Action 假设用户在执行它之前已经执行过了 @actions/checkout,所以它不会尝试自行建立项目目录。大家最好使用同一个 token 进行 @actions/checkout,这样项目目录从一开始就是以 token 背后的身份创建的。以下是一个完整的用例:

runs:
  using: ‘composite’
  steps:
    - uses: actions/checkout@v4

    - uses: CatChen/config-git-with-token-action@v1
      with:
        github-token: ${{ inputs.github-token }}

    - shell: bash
      run: |
        echo “Set up git user name: $(git config —get user.name)”
        echo “Set up git user email: $(git config —get user.email)”
        echo “Set up git remote origin with login and token: $(git remote get-url origin)”

    - shell: bash
      run: |
        touch test_file
        git commit test_file -m ‘Created test file’
        git push

做为 GitHub Actions 开发者,如果你利用 JavaScript 而非 bash 进行开发,那上述 composite action 的用例并不适用,我们需要一个针对 JavaScript action 的用例。我自己有同样的需求,所以 config-git-with-token-action 同时还是一个 NPM 包,可以在 JavaScript 中进行调用获得同样的功能。(这个包具备完整的 TypeScript 类型信息。)安装好之后,通过 JavaScript 调用的用例如下:

import { configGitWithToken } from ‘config-git-with-token-action’;

await configGitWithToken(githubToken);

希望这个 Action 对各位 GitHub Actions 开发者有用。非 Actions 开发者也可以直接在 Workflow 里面使用这个 Action,如果你的 Workflow 使用非默认的 GitHub Actions(机器人)身份进行 git 操作的话。大家在使用过程中遇到什么问题,或者是希望增加什么新功能,欢迎到项目的 GitHub 开 issue。

至于这个 Action 是如何获取到 token 背后的身份信息的,那是这个系列的下一篇文章要介绍的下一个 Action 负责的。