docs: keep notices fresh at commit time instead of a new CI gate
Regenerate THIRD_PARTY_NOTICES.md from a pre-commit job whenever a manifest, lock file, vendor manifest, or pyproject is staged, and assert the committed bytes inside the generator spec the test lane already runs. Drops the separate doc-sync gate: no extra CI process, and a dependency edit no longer bounces back from CI to rerun a generator.
This commit is contained in:
+2
-2
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-07-30-generated-third-party-notices.md
|
||||
2026-07-30-generated-third-party-notices.md: 5b85abf5716d79213f6937b938d3f0267f7627f3
|
||||
2026-07-30-generated-third-party-notices.zh.md: 26b3e6e88ec038d9107c9b78c85a8c45a1916805
|
||||
2026-07-30-generated-third-party-notices.md: 2a669be4c8dcf92beb08c1305098e1baa7626ef4
|
||||
2026-07-30-generated-third-party-notices.zh.md: 990d15238a2939c54b4242016f9986e3f58a260b
|
||||
@@ -8,11 +8,13 @@ English | [中文](2026-07-30-generated-third-party-notices.zh.md)
|
||||
|
||||
Open-sourcing this repository requires disclosing the third-party software it depends on, with each project's license. The disclosure has to be complete, has to stay true as dependencies change, and has to say something a reader can act on — which of these packages end up on a user's machine, and which only build and test the repository.
|
||||
|
||||
A hand-written inventory answers none of those durably. Roughly a hundred rows of names and license strings derived from manifests drift silently the moment a package is added, removed, or relicensed, and nothing in `doc-sync` would notice.
|
||||
A hand-written inventory answers none of those durably. Roughly a hundred rows of names and license strings derived from manifests drift silently the moment a package is added, removed, or relicensed, and nothing would notice.
|
||||
|
||||
## Decision
|
||||
|
||||
[`THIRD_PARTY_NOTICES.md`](../../../../THIRD_PARTY_NOTICES.md) is generated by [`scripts/gen-third-party-notices.ts`](../../../../scripts/gen-third-party-notices.ts) from the workspace manifests, `vendor/README.md`, the `pyproject.toml` files, and `pnpm-workspace.yaml`. `pnpm run verify-third-party-notices` runs the generator with `--check` as a `doc-sync` leaf gate, so a dependency change that skips regeneration fails the same way a stale catalog does. The root README pair links the file from its License section.
|
||||
[`THIRD_PARTY_NOTICES.md`](../../../../THIRD_PARTY_NOTICES.md) is generated by [`scripts/gen-third-party-notices.ts`](../../../../scripts/gen-third-party-notices.ts) from the workspace manifests, `vendor/README.md`, the `pyproject.toml` files, and `pnpm-workspace.yaml`. The root README pair links the file from its License section.
|
||||
|
||||
**Freshness is maintained, not merely enforced.** A pre-commit job regenerates the file and stages it whenever a manifest, lock file, `vendor/README.md`, or `pyproject.toml` is staged, so an unrelated dependency edit never has to come back and rerun a generator. The committed bytes are then asserted inside [`scripts/gen-third-party-notices.spec.ts`](../../../../scripts/gen-third-party-notices.spec.ts), which the test lane already runs — the check adds no gate process, no scheduler slot, and no separate CI step. `pnpm run verify-third-party-notices` remains available for a standalone check.
|
||||
|
||||
The file discloses **direct** dependencies only. The complete npm closure with pinned versions already lives in `pnpm-lock.yaml` (`pnpm licenses list` renders it) and the Python closure in `python/sdk/uv.lock`; re-materializing either as prose would be a second, worse copy.
|
||||
|
||||
@@ -24,12 +26,14 @@ License and repository metadata come from the installed pnpm store, so the gener
|
||||
|
||||
## Testing
|
||||
|
||||
[`scripts/gen-third-party-notices.spec.ts`](../../../../scripts/gen-third-party-notices.spec.ts) pins the tiering rule against fixture manifests — including the two cases that motivate it, a `dependencies` entry of a test-support package and a plugin package no app mounts — and pins that the vendored-table parser reads the committed manifest and yields nothing when the table shape changes, which is what makes the generator fail loud rather than emit an empty section.
|
||||
The same spec that asserts freshness pins the tiering rule against fixture manifests — including the two cases that motivate it, a `dependencies` entry of a test-support package and a plugin package no app mounts — and pins that the vendored-table parser reads the committed manifest and yields nothing when the table shape changes, which is what makes the generator fail loud rather than emit an empty section.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Keep the hand-written file and review it at release time.** Reviewing a hundred derived rows by eye is exactly the work a generator does correctly, and the file's own claim — that it lists every direct dependency — would be unverified between releases.
|
||||
|
||||
**Verify through a dedicated `doc-sync` gate.** That is how every other generated artifact here is checked, and it was the first shape of this change. It costs a gate process and a scheduler slot in a matrix that is already long, and — worse — its only failure mode is telling a contributor, minutes after they pushed an unrelated dependency bump, to go rerun a generator. Regenerating at commit time removes the interruption, and the assertion inside a spec the test lane already runs keeps the guarantee at no additional CI cost.
|
||||
|
||||
**Enumerate the full transitive closure.** The closure is thousands of packages, already recorded in the lock files with exact versions, and would bury the direct dependencies that a reader actually evaluates. The file points at the lock files and the `pnpm licenses list` renderer instead.
|
||||
|
||||
**Tier by manifest section (`dependencies` vs `devDependencies`).** Mechanically simple and wrong on real data in both directions, as the tiering paragraph above records.
|
||||
@@ -40,7 +44,7 @@ License and repository metadata come from the installed pnpm store, so the gener
|
||||
|
||||
## Consequences
|
||||
|
||||
Adding or removing a dependency now requires running `pnpm run gen-third-party-notices` and committing the result; `doc-sync` fails otherwise. That is the intended cost — the disclosure cannot silently go stale.
|
||||
A dependency edit now carries a regenerated notices file into the same commit. Contributors pay one generator run — about a second — on commits that touch a manifest, and nothing on any other commit. Committing with hooks disabled defers the cost to a test-lane failure that names the command.
|
||||
|
||||
The generator needs an installed tree, which makes it heavier than a pure-source generator, and a new package with unusable published metadata needs an `OVERRIDES` entry rather than silently rendering a blank license. Both failures are loud and name the remedy.
|
||||
|
||||
|
||||
@@ -8,11 +8,13 @@ Status: implemented
|
||||
|
||||
本仓库开源需要披露所依赖的第三方软件及各自的许可证。这份披露必须完整,必须随依赖变化保持为真,还必须给出读者用得上的信息:哪些包最终会进到用户机器上,哪些只用于构建和测试。
|
||||
|
||||
手写清单无法长期满足其中任何一条。约一百行从各清单文件推导出来的包名与许可证标识,只要有依赖新增、移除或换用许可证就会悄悄失真,而 `doc-sync` 不会察觉。
|
||||
手写清单无法长期满足其中任何一条。约一百行从各清单文件推导出来的包名与许可证标识,只要有依赖新增、移除或换用许可证就会悄悄失真,而没有任何检查会察觉。
|
||||
|
||||
## Decision
|
||||
|
||||
[`THIRD_PARTY_NOTICES.md`](../../../../THIRD_PARTY_NOTICES.md) 由 [`scripts/gen-third-party-notices.ts`](../../../../scripts/gen-third-party-notices.ts) 依据各工作区清单、`vendor/README.md`、`pyproject.toml` 与 `pnpm-workspace.yaml` 生成。`pnpm run verify-third-party-notices` 以 `--check` 运行生成器,作为 `doc-sync` 的叶子门禁;依赖变了却没重新生成,会像目录过期一样直接失败。根 README 双语两侧都从「许可证」一节链到该文件。
|
||||
[`THIRD_PARTY_NOTICES.md`](../../../../THIRD_PARTY_NOTICES.md) 由 [`scripts/gen-third-party-notices.ts`](../../../../scripts/gen-third-party-notices.ts) 依据各工作区清单、`vendor/README.md`、`pyproject.toml` 与 `pnpm-workspace.yaml` 生成。根 README 双语两侧都从「许可证」一节链到该文件。
|
||||
|
||||
**新鲜度靠维护而非拦截。** 只要暂存了清单文件、锁文件、`vendor/README.md` 或 `pyproject.toml`,pre-commit 任务就会重新生成并一并入库,改依赖的人不必事后再折返跑一次生成器。已提交的字节随后由 [`scripts/gen-third-party-notices.spec.ts`](../../../../scripts/gen-third-party-notices.spec.ts) 断言,而测试 lane 本就会跑这个文件——这项校验不增加门禁进程、不占调度位、也不新增 CI 步骤。需要单独校验时,`pnpm run verify-third-party-notices` 仍然可用。
|
||||
|
||||
文件只披露**直接**依赖。完整的 npm 闭包连同锁定版本已记录在 `pnpm-lock.yaml`(`pnpm licenses list` 可渲染),Python 闭包记录在 `python/sdk/uv.lock`;再用散文誊一遍只会得到一份更差的副本。
|
||||
|
||||
@@ -24,12 +26,14 @@ Status: implemented
|
||||
|
||||
## Testing
|
||||
|
||||
[`scripts/gen-third-party-notices.spec.ts`](../../../../scripts/gen-third-party-notices.spec.ts) 用夹具清单钉住分层规则,覆盖促成该规则的两个场景:测试支撑包的 `dependencies` 条目,以及没有任何应用挂载的插件包。它同时钉住被收编包的表格解析器能读出已提交的清单表,且表格形态一变就解析为空——正是这一点让生成器直接失败,而不是产出一个空章节。
|
||||
断言新鲜度的同一个 spec 也用夹具清单钉住分层规则,覆盖促成该规则的两个场景:测试支撑包的 `dependencies` 条目,以及没有任何应用挂载的插件包。它同时钉住被收编包的表格解析器能读出已提交的清单表,且表格形态一变就解析为空——正是这一点让生成器直接失败,而不是产出一个空章节。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**保留手写文件,发版时人工过一遍。** 用肉眼审阅上百行推导数据,恰恰是生成器能做对的活;而且在两次发版之间,文件自称「列出全部直接依赖」这句话无人验证。
|
||||
|
||||
**用专门的 `doc-sync` 门禁校验。** 仓库里其他生成产物都是这么把关的,本次改动最初也是这个形态。但它要在本已冗长的矩阵里再占一个门禁进程和一个调度位;更糟的是,它唯一的失败方式,就是在别人推完一个无关的依赖升级几分钟后,通知对方回去重跑一次生成器。改为提交时重新生成消除了这次打断,而把断言放进测试 lane 本就会跑的 spec 里,则以零额外 CI 成本保住了这项保证。
|
||||
|
||||
**列出完整传递闭包。** 闭包有数千个包,锁文件里已带精确版本,铺开只会淹没读者真正要评估的直接依赖。文件转而指向锁文件与 `pnpm licenses list`。
|
||||
|
||||
**按清单字段分层(`dependencies` 与 `devDependencies`)。** 机械上最省事,但在真实数据上两个方向都会出错,理由见上文分层段落。
|
||||
@@ -40,7 +44,7 @@ Status: implemented
|
||||
|
||||
## Consequences
|
||||
|
||||
此后增删依赖都需要运行 `pnpm run gen-third-party-notices` 并提交结果,否则 `doc-sync` 失败。这正是预期成本——披露不可能再悄悄过期。
|
||||
此后改动依赖时,重新生成的披露文件会随同一个提交入库。触及清单文件的提交多付一次生成器运行——约一秒;其余提交不受影响。若禁用钩子提交,代价推迟为一次测试 lane 失败,其报错会指明补救命令。
|
||||
|
||||
生成器需要已安装的工作树,因此比纯源码生成器更重;发布元数据不可用的新包需要补一条 `OVERRIDES`,而不是默默渲染出空白许可证。这两类失败都会明确报错并指出补救方式。
|
||||
|
||||
|
||||
@@ -18,6 +18,12 @@ pre-commit:
|
||||
run: node_modules/.bin/tsx scripts/run-oxlint.ts --fix --no-error-on-unmatched-pattern {staged_files}
|
||||
stage_fixed: true
|
||||
|
||||
# Regenerate rather than reject: a dependency edit that forgot the notices
|
||||
# would otherwise fail the test lane long after the commit.
|
||||
- name: third-party notices (staged)
|
||||
glob: '{package.json,*/package.json,*/*/package.json,*/*/*/package.json,pnpm-workspace.yaml,pnpm-lock.yaml,vendor/README.md,python/*/pyproject.toml}'
|
||||
run: node_modules/.bin/tsx scripts/gen-third-party-notices.ts && git add THIRD_PARTY_NOTICES.md
|
||||
|
||||
- name: whitespace (staged)
|
||||
run: git diff --cached --check
|
||||
|
||||
|
||||
@@ -1,10 +1,20 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { type Manifest, parseVendoredRows, tierExternalDeps } from './gen-third-party-notices.ts'
|
||||
import { type Manifest, parseVendoredRows, render, tierExternalDeps } from './gen-third-party-notices.ts'
|
||||
|
||||
const root = resolve(import.meta.dirname, '..')
|
||||
|
||||
describe('THIRD_PARTY_NOTICES.md', () => {
|
||||
// Freshness lives here rather than in its own doc-sync gate: this spec file
|
||||
// already runs in the test lane, so the check costs no extra CI process.
|
||||
// Pre-commit regenerates the file whenever a manifest is staged, so reaching
|
||||
// this assertion means the notices were committed without that hook.
|
||||
it('matches what the generator produces from the current manifests', () => {
|
||||
expect(readFileSync(resolve(root, 'THIRD_PARTY_NOTICES.md'), 'utf8'), 'stale notices — run `pnpm run gen-third-party-notices`').toBe(render())
|
||||
})
|
||||
})
|
||||
|
||||
/** Build the (manifests, names) pair `tierExternalDeps` consumes. */
|
||||
function workspace(entries: Record<string, Manifest>): { manifests: Map<string, Manifest>; names: Set<string> } {
|
||||
const manifests = new Map(Object.entries(entries))
|
||||
|
||||
@@ -283,8 +283,11 @@ function renderNpmTable(deps: ExternalDep[]): string {
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
/** Render the complete notices document. */
|
||||
function render(): string {
|
||||
/**
|
||||
* Render the complete notices document.
|
||||
* @returns the exact bytes `THIRD_PARTY_NOTICES.md` must hold.
|
||||
*/
|
||||
export function render(): string {
|
||||
verifyBuildTimePins()
|
||||
const npm = collectNpmDeps()
|
||||
const runtimeDeps = npm.filter(dep => dep.runtime)
|
||||
|
||||
@@ -472,7 +472,6 @@ function docSyncLeafGates(options: {
|
||||
pnpmScript('tool-catalog', 'verify-tool-catalog', { label: 'tool catalog' }),
|
||||
pnpmScript('config-catalog', 'verify-config-catalog', { label: 'config catalog' }),
|
||||
pnpmScript('persistence-catalog', 'verify-persistence-catalog', { label: 'persistence catalog' }),
|
||||
pnpmScript('third-party-notices', 'verify-third-party-notices', { label: 'third-party notices' }),
|
||||
pnpmScript('doc-graphs', 'verify-doc-graphs', { label: 'doc graphs' }),
|
||||
pnpmScript('scoped-events', 'verify-scoped-events', { label: 'scoped events' }),
|
||||
pnpmScript('markdown-wrap', 'verify-md-wrap', { label: 'markdown wrap' }),
|
||||
|
||||
Reference in New Issue
Block a user