pi 团队把"npm 依赖变更"当成需要评审的代码变更来对待:从版本号怎么升、发布怎么触发,到锁文件谁说了算、CI 怎么审计依赖,每一环都有明确、可验证的机制,而不是靠人肉记忆。
学习目标
- 理解 lockstep 版本管理是什么、为什么所有包共享一个版本号。
- 走一遍
npm run release:patch/release:minor背后完整的发布流程。 - 理解 GitHub Actions OIDC trusted publishing 如何让发布不再需要本地
npm publish/OTP。 - 知道
.npmrc里save-exact和min-release-age分别防的是什么风险。 - 理解
package-lock.json、npm-shrinkwrap.json、生命周期脚本白名单三者如何共同构成依赖锁定治理。 - 知道 CI 里
npm audit检查了什么、多久跑一次。
Lockstep 版本管理
发布流程原来直接写在 AGENTS.md 的 "Releasing" 一节里,现在这一节已经收缩成一句话,指向一个独立的技能文档 .pi/skills/release.md------把可复用的操作流程从 AGENTS.md 里搬出去、单独维护,是这个仓库最近的一个整理动作(后面会再遇到一次,见"从源码运行 pi"相关章节)。release.md 开门见山:
Lockstep versioning : all packages share one version; every release updates all together.
patch= fixes + additions,minor= breaking changes. No major releases.
实际打开各包的 package.json 也能验证这一点:packages/agent、packages/ai、packages/chord、packages/coding-agent、packages/tui、packages/telemetry、packages/protocol、packages/client、packages/server、packages/session-backends/sqlite-node 的 version 字段目前全部是同一个值(例如都是 0.85.1)。新加入的 chord 包也被纳入了这条 lockstep 规则,并没有因为它"可以脱离 pi 独立使用"而单独走自己的版本号------这与很多 monorepo 采用的"每个包独立语义化版本"策略不同,pi 选择让所有可发布包永远保持版本号一致,简化了"这个功能到底在哪个版本引入"的心智负担。
需要特别注意版本语义的错位用法:minor 版本号在 pi 里被用来表示破坏性变更(breaking changes) ,patch 才对应"修复 + 新增功能",而且明确不做 major 发布 (0.x.y 会一直停留在 0 大版本)。这与严格语义化版本(SemVer)里"minor 表示向后兼容的新特性,major 才表示破坏性变更"的惯例不同,属于这个项目自定义的版本规则,阅读或参与贡献时不能想当然套用标准 SemVer 理解。
对应的根目录脚本:
"version:patch": "npm version patch --workspaces --no-git-tag-version --no-workspaces-update && node scripts/sync-versions.js && npm install --package-lock-only --ignore-scripts",
"version:minor": "npm version minor --workspaces --no-git-tag-version --no-workspaces-update && node scripts/sync-versions.js && npm install --package-lock-only --ignore-scripts",
"version:major": "npm version major --workspaces --no-git-tag-version --no-workspaces-update && node scripts/sync-versions.js && npm install --package-lock-only --ignore-scripts"
npm version <bump> --workspaces 让每个 workspace 包各自完成一次版本号递增,scripts/sync-versions.js 再把所有包的版本号收敛成同一个值(并同步内部工作区依赖的版本约束),最后用 --package-lock-only --ignore-scripts 重新生成 package-lock.json(只更新锁文件,不做真正的 npm install 副作用,也不跑生命周期脚本)。
发布流程:npm run release:patch / release:minor
真正触发发布的入口:
"release:patch": "node scripts/release.mjs patch",
"release:minor": "node scripts/release.mjs minor",
"release:major": "node scripts/release.mjs major"
.pi/skills/release.md 描述了完整的人工 + 自动化流程,按顺序是:
- 更新 CHANGELOG :发布前要确认是否已经在
main最新提交上跑过/cl提示词,审计并更新每个包CHANGELOG.md里的[Unreleased]小节。 - 本地冒烟测试 :用
npm run release:local -- --out /tmp/pi-local-release --force(对应脚本scripts/local-release.mjs)在仓库之外的目录构建一次未发布的产物,分别对 Node 安装版和 Bun 二进制版跑--help、--version、--list-models、-p "Say exactly: ok"以及一次真正的交互式会话,验证启动、鉴权、真实一次问答都没问题。之所以要"在仓库之外"跑,是为了确保它不能解析到 workspace 本地文件,模拟的是终端用户拿到已发布包之后的真实体验。 - 执行发布脚本 :PI_ALLOW_LOCKFILE_CHANGE=1 npm_config_min_release_age=0 npm run release:patch
PI_ALLOW_LOCKFILE_CHANGE=1 npm_config_min_release_age=0 npm run release:minor
这里出现的两个环境变量都只应该在发布这条命令里临时设置:PI_ALLOW_LOCKFILE_CHANGE=1放行发布过程中必然发生的锁文件变更提交(见下一节的 pre-commit 锁文件守卫);npm_config_min_release_age=0临时关闭.npmrc里的min-release-age年龄限制,因为发布时可能需要立即解析刚发布不久的内部包版本,正常开发环境下这道限制应保持开启。
release.mjs会依次完成:递增所有包版本号、更新 changelog、重新生成发布相关产物、跑一次npm run check、提交Release vX.Y.Z、打 tagvX.Y.Z、在各 changelog 里补一个新的空[Unreleased]小节、提交Add [Unreleased] section for next cycle,最后 pushmain分支和标签。标签一旦 push 出去就不能重跑发布脚本。 - CI 校验并公告 npm 发布结果 :push
vX.Y.Z标签会触发.github/workflows/build-binaries.yml。 - 发布或公告失败时 :检查失败的 job;发布脚本本身是幂等的(会跳过 npm 上已经存在的包版本),公告 job 会在更新标记前重新核实包的可用性;修复问题后重跑失败的 job/workflow 即可,不要为同一个版本重跑
release:patch/release:minor。
GitHub Actions OIDC Trusted Publishing
build-binaries.yml 里的 publish-npm job 是发布链路的关键一步:
publish-npm:
runs-on: ubuntu-latest
needs: stage-github-release
environment: npm-publish
permissions:
contents: read
id-token: write
steps:
...
- name: Upgrade npm for trusted publishing
run: |
npm install -g npm@11.16.0 --ignore-scripts
npm --version
- name: Publish npm packages
run: node scripts/publish.mjs
几个关键点:
permissions.id-token: write是 GitHub Actions OIDC(OpenID Connect)的开关:CI job 会向 GitHub 的 OIDC 提供方申请一个短期身份令牌,npm 注册表验证这个令牌来确认"这次发布确实来自你声明的这个仓库、这个工作流",而不需要在 CI 里配置任何长期存活的 npm token。environment: npm-publish把发布这一步绑定到一个受保护的 GitHub Environment,可以配合仓库设置对这个 Environment 加审批、限制可触发的分支/标签等治理措施。- 升级到
npm@11.16.0是因为 trusted publishing 依赖较新版本 npm CLI 的支持。 - 整个过程"不需要本地
npm publish、npm whoami、OTP(一次性密码)或 WebAuthn 流程"------.pi/skills/release.md原话如此,也就是说没有任何人能在自己电脑上手动npm publish出一个官方版本,发布权限完全收敛在这一条 CI 流水线里。 - 发布成功后,
announce-pi-dev-releasejob(依赖publish-npm成功)会重新核实每一个公开 workspace 包在 npm 上确实能解析到这次发布的精确版本号、且对应 tarball 可下载,然后把"已验证的发布标记"写入 Cloudflare R2。pi.dev/api/latest-version只读取这个已验证标记,规则明确规定它"绝不能在这个 job 成功之前,就从 npm 侧提前宣布一个发布"------避免用户端看到"最新版本号"已经更新、但实际 npm 包还没完全就绪或校验失败的竞态。
.npmrc:save-exact 与 min-release-age
仓库根目录 .npmrc 只有两行:
save-exact=true
min-release-age=2
save-exact=true:让npm install <pkg>默认写入精确版本号(1.2.3)而不是范围版本号(^1.2.3)到package.json。这与前面《构建测试与开发流程》提到的check:pinned-deps检查是配套的------.npmrc保证"新增依赖时默认就是精确版本",check:pinned-deps.mjs再兜底检查"是否有人手改成了范围版本"。min-release-age=2:要求 npm 在解析依赖版本时,只考虑发布时间超过 2 天的版本。这是防御供应链投毒的一个具体机制:如果某个依赖的维护者账号被盗,攻击者发布一个恶意版本后,通常会在短时间内被社区或自动化工具发现并从注册表下架;min-release-age相当于给"发现窗口"留出缓冲时间,避免在依赖发布的当天就把这个版本引入到锁文件里。发布流程里之所以要临时把它设为0(npm_config_min_release_age=0),是因为发布时经常需要立刻解析刚刚发布的、来自本项目自己的内部包版本,此时这条年龄限制反而会挡住正常发布。
README 的 "Supply-chain hardening" 一节把这条规则概括为:
Direct external dependencies are pinned to exact versions. Internal workspace packages remain version-ranged.
也就是说精确锁版本只针对外部 依赖,@earendil-works/pi-* 这类内部工作区依赖仍然使用类似 ^0.84.1 的范围版本------因为内部包版本始终 lockstep 递增,用范围版本反而方便同一次发布内互相引用。
依赖锁文件治理:谁是唯一真相源
README 明确定义:
package-lock.jsonis the dependency ground truth.
围绕这句话,实际落地了三层机制:
- 提交守卫 :
.husky/pre-commit钩子会先执行node scripts/check-lockfile-commit.mjs。这个脚本会对比HEAD:package-lock.json和暂存区里的package-lock.json,如果检测到锁文件被改动且没有设置PI_ALLOW_LOCKFILE_CHANGE=1(或true/yes),就会阻止这次提交,逼着开发者显式确认"我是真的想改锁文件",而不是不小心在某次npm install后顺手把锁文件变更也提交进去。 - coding-agent 的 shrinkwrap :发布出去的 CLI 包
@earendil-works/pi-coding-agent会额外附带packages/coding-agent/npm-shrinkwrap.json(files字段里显式包含这个文件),由scripts/generate-coding-agent-shrinkwrap.mjs从根package-lock.json生成,目的是让通过 npm 直接安装这个 CLI 包的最终用户,其传递依赖 版本也被精确锁定,不会因为上游某个间接依赖发新版本而在安装时产生漂移。npm run check里的check:shrinkwrap(带--check参数)负责校验这个 shrinkwrap 文件与根锁文件是否保持同步。 - 生命周期脚本白名单 :
generate-coding-agent-shrinkwrap.mjs内部维护了一个显式的allowedInstallScriptPackages白名单,例如:const allowedInstallScriptPackages = new Map( \["@google/genai@2.21.0", "preinstall is a no-op in the published package",
"esbuild@0.28.2", "postinstall selects and verifies the platform-specific esbuild binary",
"protobufjs@7.6.6", "postinstall only warns about protobufjs version scheme mismatches",
]);
这张表会随着依赖版本升级和新依赖加入持续变化------比如esbuild这一条就是新加入 monorepo 的chord包引入esbuild作为依赖之后,才第一次出现在这张白名单里的,具体版本号和条目数量不必死记,重点是理解这个机制本身。README 对这一机制的描述是:"Shrinkwrap generation has an explicit allowlist for dependency lifecycle scripts; new lifecycle-script deps fail checks until reviewed." 也就是说:只要新增/更新的某个依赖带有生命周期脚本(hasInstallScript),而它没有出现在这张白名单里,check:shrinkwrap就会失败------逼着人工去读这个脚本到底做了什么、判断是否安全,然后显式加一条带理由的白名单记录,而不是默默放行任何带安装脚本的新依赖。
CI 里的 npm audit
.github/workflows/npm-audit.yml 是一个独立的、定时触发的工作流:
on:
schedule:
- cron: '37 7 * * *'
workflow_dispatch:
jobs:
audit:
steps:
- run: npm ci --ignore-scripts --no-audit --no-fund
- name: Audit production vulnerabilities
run: npm audit --omit=dev --audit-level=moderate
- name: Verify registry signatures
run: npm audit signatures --omit=dev
三个细节值得注意:
- 每天定时跑一次(UTC 07:37),而不是只在 PR 或 push 时跑------这样即使代码完全没变,只要 npm 注册表新披露了某个已安装依赖的漏洞,也能在最多一天内被发现。
- 先用
npm ci --ignore-scripts --no-audit --no-fund装依赖(--no-audit是为了避免npm ci自带的审计和这里手动跑的npm audit重复),再用两条独立命令分别做:npm audit --omit=dev --audit-level=moderate检查生产依赖(跳过 devDependencies)里中等及以上严重程度的已知漏洞;npm audit signatures --omit=dev校验这些包的注册表签名,确认包内容没有被篡改。 - README 里把这两条命令合称为"a scheduled GitHub workflow runs
npm audit --omit=devplusnpm audit signatures --omit=dev",与 CI 文件内容完全对应。
从 Release 源码包构建 standalone 二进制
README 的另一段面向"想自己打包 pi 二进制"的用户或发行版维护者:
VERSION="<release-version>"
tar -xzf "pi-${VERSION}-source.tar.gz"
cd "pi-${VERSION}"
./scripts/build-binaries.sh --offline-model-data --platform linux-x64 --out "$PWD/out"
GitHub Release 附带的版本化源码归档由该 release 的 SHA256SUMS 文件校验完整性;--offline-model-data 让构建使用归档里自带的模型数据快照而不是联网刷新(对应上一篇提到的 build:offline);这个脚本本身仍然会执行"装依赖、构建整个 monorepo、编译 Bun 可执行文件、拷贝运行期资源"的完整流程。脚本的参数集最近被精简过:如果包维护者希望自己单独管理依赖,现在只需要传 --skip-install 跳过 npm ci 这一步(旧版本里同时存在的 --skip-deps 参数已经不再出现在脚本里);如果只想重新打包已经构建好的产物、跳过整个包构建阶段,可以传 --skip-build。本地安装、文档中演示的 npm 安装步骤,以及 pi update --self 自更新命令,在支持的地方也都统一使用 --ignore-scripts。
动手练习
- 只读地跑一次
node scripts/check-pinned-deps.mjs(在仓库根目录),观察它如何遍历所有package.json并报告不符合精确版本号规则的依赖(正常情况下应该没有输出,因为仓库自身是通过这项检查的)。 - 阅读
scripts/generate-coding-agent-shrinkwrap.mjs里allowedInstallScriptPackages这个 Map,思考:如果你要新增一个带postinstall脚本的依赖,你需要做哪几件事才能让npm run check通过? - 对照
.github/workflows/build-binaries.yml中publish-npmjob 的permissions字段,解释一下为什么它只需要id-token: write而不需要在仓库 secrets 里配置任何NPM_TOKEN。
小结
pi 的发布与供应链安全体系可以概括为"把每一个可能被人为疏忽绕过的环节都变成自动化的强制检查":lockstep 版本号消除了"这个包到底该发哪个版本"的判断成本;发布权限完全收敛到 GitHub Actions 的 OIDC trusted publishing,本地无法手动发布;.npmrc 的两条配置分别管住了"依赖版本精度"和"依赖发布新鲜度"两个风险维度;package-lock.json 作为唯一真相源,配合 pre-commit 守卫、coding-agent 专属 shrinkwrap、生命周期脚本白名单三层机制,防止依赖状态被静默篡改;每日定时的 npm audit 则是持续运行的最后一道监控网。这些机制单独看都不复杂,但组合在一起,构成了一套相当扎实的供应链防护。