---
name: nix-flake-update-check
description: 检查任意 nix flake 仓库中软件包的上游版本更新并升级——按包型（npm / cmake / Rust / fetchurl / python）分流的 hash 更新流程与自托管 forge（Gitea）取源、开工前的交互式澄清（支持提问的智能体须主动使用以确保一次跑完）、同账户子项目的链式并行检查（含回环与依赖冲突防护）、版本语义变化时文档须重写而非机械替换、文档外部链接的失效审计、GitHub Actions 的 SHA 固定更新检查、外部自动化 PR 的 hash 修补、flake.lock 处置、补丁内版本检查与 nixpkgs 漂移陷阱。仓库特有的文档与日志环节经「仓库适配层」注入。
---

# nix flake 软件包更新检查（通用）

检查 nix flake 仓库中各软件包的上游发布更新并执行版本升级。

本技能只包含**与仓库无关**的通用方法。仓库特有的环节（文档同步、维护日志、
内置插件清单等）由**仓库适配层**提供——见文末「仓库适配层」。

## 本技能的文件构成

**主流程在本文档**；两份配套参考按需查阅，不必通读：

| 文件 | 内容 | 何时读 |
|---|---|---|
| **`SKILL.md`**（本文） | 交互式澄清 + 第 1~10 步主流程 + 适配层契约 | 始终 |
| [`builders.md`](builders.md) | 按 builder 的 hash 更新流程、`flake.lock` 处置 | 第 4 步 |
| [`traps.md`](traps.md) | nixpkgs 漂移陷阱、fail-closed 校验、外链审计、Actions 更新、补丁内版本 | 第 2 步末尾（Actions）与第 7 步自检命中时 |




## 交互式澄清：一次流程内解决所有待定项

**在开始前先判断所运行的智能体是否支持交互式提问**（如 DSH 提供
`ask_user_question`，可一次提出多个问题并等待用户选择）。**支持就积极使用**
——凡是不确定、有多种合理做法、或必须由用户拍板的事项，**当场问**，不要
先按自己的猜测执行完、再让用户在下一条消息里纠正。

**为什么这条是强要求**：软件更新是**多决策点**流程（升级策略、跨大版本取舍、
依赖冲突的解法、通道选择、是否部署）。若每个决策点都退化成「猜一次 → 被纠正
→ 重来」，一次更新会膨胀成多轮往返，且**每次重来都要重跑构建**——而构建是
本流程最贵的环节（Rust/python 包动辄数十分钟）。把决策点前置到开始之前，
整个流程才可能**一次跑完**。

### 必须提问的情形

| 情形 | 为什么必须问 |
|---|---|
| **跨大版本升级**（3.x → 4.x） | 可能含 breaking change、新的硬性要求，或需要用户接受行为变化 |
| **依赖冲突有两种以上合理解法** | 如「抬依赖版本」vs「跳过该包」vs「打补丁放宽校验」——代价与风险差别很大，且有的会破坏上游安全契约 |
| **需要新增 overlay / 补丁 / 额外 vendored 包** | 增加维护面，属结构性改动 |
| **构建时长或体积显著增加** | 用户可能宁愿暂缓 |
| **通道选择**（stable / next / alpha） | 取决于用户想跟哪条线，技能无法推断 |
| **是否部署到运行中的系统** | 影响生产环境 |
| **发现的信息与文档记载冲突** | 可能是文档过时，也可能是现状异常，需用户判断 |

### 提问的写法

- **一次问全**：把本轮所有待定项**合并成一批**提问，不要挤牙膏式地一问一等。
- **给出可选项与代价**：每个选项写清影响（尤其「破坏上游契约」这类后果）。
- **给出推荐项并说明理由**：把推荐项放第一位，标注「推荐」。
- **能自己查证的不要问**：先查 release notes / 上游源码 / 构建报错，
  把问题收敛到真正需要人决策的那几个。

> **不支持交互式提问时**（如纯命令行 agent）：把上述待定项**汇总成一份清单**
> 一次性输出，并说明每项的影响与推荐做法，然后**停下等待**——同样避免
> 「猜完再被纠正」的多轮往返。宁可一次问完，不要边做边猜。

### 与「先做完再汇报」的取舍

默认行为是自主推进，本技能是**例外**：因为更新流程的返工成本极高（重建代价），
**提问的成本远低于猜测的错误成本**。但**只问真正需要决策的**——机械步骤
（取 hash、改版本号、同步文档、记日志）自行完成，不要拿流程细节去打扰用户。

## 适用前提

- 仓库是 nix flake（根目录存在 `flake.nix`）。
- 包定义以独立 `.nix` 文件形式存在，通常位于 `packages/`（或其他目录），
  由 `flake.nix` 经 `callPackage` 引用。
- 若仓库布局不同（如所有包写在单个 `flake.nix`、或位于 `nix/` 等目录），
  以第 2 步的自动发现结果为准，不必强行套用目录名。

## 第 1 步：确认本地仓库

```bash
test -f flake.nix && echo "OK: $(grep -oP 'description\s*=\s*"\K[^"]+' flake.nix | head -1)" || echo "ERROR: not in a flake project"
```

## 第 2 步：发现待检查的软件包

从 `flake.nix` 自动提取被 `callPackage` 引用的包文件，再按排除规则过滤。
**不要硬编码包列表**——从仓库现状动态发现：

```bash
# 自动发现所有被引用的包文件（按仓库实际路径调整正则）
grep -oP '\./[a-zA-Z0-9_/-]+\.nix' flake.nix | sort -u > /tmp/all_pkgs.txt

# 排除自建包（src 指向本地路径）
while read -r f; do
  grep -q 'src\s*=\s*\./\.\.\|src\s*=\s*\.\/' "$f" 2>/dev/null && echo "SKIP self-hosted: $f"
done < /tmp/all_pkgs.txt

# ⚠️ 提取版本号时必须同时匹配 `=` 与 `?`（参数默认值），否则漏包
grep -oP '^\s*version\s*[?=]\s*"\K[^"]+' "$f"
```

### ⚠️ 用 `version\s*=` 提版本会**静默漏掉**参数化默认值的包

参数化包把版本写成**函数参数默认值**，而不是普通赋值：

```nix
# packages/dsh.nix —— 主定义，通道包装靠传参覆盖它
{ version ? "0.1.5-rc.2", hash ? "sha256-...", ... }:
```

`version\s*=` **匹配不到** `version ? "..."`：该包会被当成"没有版本"而**从检查
范围里消失**——不报错、不出现在报告里，与"已是最新"无法区分。

```bash
# 判定：同时匹配两种形态，并区分「默认值」与「覆盖值」
grep -nP '^\s*version\s*[?=]\s*"' $(grep -oP '\./[a-zA-Z0-9_/-]+\.nix' flake.nix | sort -u)
```

| 形态 | 含义 | 检查对象 |
|---|---|---|
| `version ? "x"` | **参数默认值** —— 主定义，stable 通道的实际版本 | **主定义的默认值** |
| `version = "x"` | **覆盖值** —— 厚封装调用主定义时传入（如 `dsh-alpha`、`ruyi-alpha`） | 该通道自身 |

**覆盖值不是重复项**：主定义 + 各通道覆盖**都要逐个检查**，因为通道跟的是不同
上游线（stable / beta / alpha 各自的发布节奏不同）。

> 判据：**主定义里带 `?` 的那一行，就是 stable 通道的真版本**。文件顶部常有一句
> `# version/hash overridden for other channels` 的注释——它正是识别此类包的标志。

`flake.nix` 中 packages 段的其余包均纳入检查。

### ⚠️ 软件包之外还有一类**必须检查**的更新：固定 SHA 的 GitHub Actions

**只扫 `flake.nix` 的 `./*.nix` 会漏掉整整一类可更新项**——CI 工作流里固定到
提交 SHA 的第三方 action。它们同样有上游版本、同样会过期，却**不在包定义的
发现范围内**：发现脚本读的是 `flake.nix`，而 action 写在 `.github/workflows/`。

```bash
# 固定 SHA 的 action 有几处？为 0 才可跳过本节
grep -rhoE 'uses: [^ ]+@[0-9a-f]{40}' .github/workflows/*.yml | sort -u
```

**不要凭"这仓库没有"跳过**——先跑上面这条命令看实际输出。完整检查流程
（查最新 tag → 取 tag 指向的 commit → 回写 SHA 与注释版本号）见配套文件
[`traps.md`](traps.md) 的「[检查 GitHub Actions 的更新](traps.md#检查-github-actions-的更新)」。

> **为何强调这一条**：把 action 固定到 SHA 之后**就收不到更新通知了**
> （这是固定 SHA 的固有代价）。若同时仓库还有"不引入外部自动化"的边界
> （如不用 Dependabot），那么**本技能就是唯一会发现它过期的途径**——
> 漏掉本节，这类更新就彻底无人过问。

## 排除的软件包

以下类别**不检查**（无固定上游发布版本）。通过 `.nix` 文件特征自动分类：

- **自建软件包**：`src` 指向本地路径（`./` 或 `../` 开头），如
  `rustPlatform.buildRustPackage { src = ./src; }`
- **动态版本追踪**：`version` 从外部输入动态读取，如 `builtins.readFile`、URL 抓取、flake input
- **跟随 nixpkgs 版本**：使用 `overrideAttrs` 仅追加 patch，不定义独立 version
- **补丁内硬编码版本**：`.patch` 文件中直接包含 `${version}` 或 wheel URL 及 hash
  （见配套文件 [`traps.md`](traps.md) 的「检查补丁内版本」节）

## 第 3 步：检查上游版本

对每个外部包，从其 `.nix` 文件确定上游仓库，然后比对：

```bash
check() {
  local pkg="$1" current="$2" repo="$3"
  latest=$(gh api "repos/$repo/releases/latest" --jq .tag_name 2>/dev/null)
  # 回退到 tag；仍取不到就显式报错，不要当成"最新"
  [ -z "$latest" ] && latest=$(gh api "repos/$repo/tags" --jq '.[0].name' 2>/dev/null)
  if [ -z "$latest" ]; then
    echo "ERROR: $pkg  无法取得上游版本（网络/限流/仓库路径）—— 必须人工确认"
  elif [ "${current#v}" = "${latest#v}" ]; then
    echo "OK: $pkg  $current"
  else
    echo "UPDATE: $pkg  $current → $latest"
  fi
}
```

### ⚠️ 用 `gh api` 而不是裸 `curl`

**匿名 `curl` 到 `api.github.com` 可能返回空响应**，而管道下游的 `grep` 同样
静默返回空——于是**每个包都被判成"已是最新"**，整轮检查给出虚假的"全部正常"。
这是本流程最危险的失败形态：**不报错、只撒谎**。

| 做法 | 结果 |
|---|---|
| ✅ `gh api repos/<owner>/<repo>/...` | 走已认证身份（5000 次/小时），失败时**非零退出** |
| ❌ `curl -s https://api.github.com/...` | 未认证（60 次/小时，极易耗尽）；耗尽时**静默返回空** |

**两条硬性纪律**：

1. **空结果必须视为错误**，绝不能当作"已是最新"——上面 `check()` 的
   `ERROR:` 分支就是为此存在。**整轮没有任何 `UPDATE:` 时，先怀疑取数失败**，
   用一两个已知有更新的包回归验证取数链路是否真的通。
2. **首次运行先自检取数链路**：

```bash
# 这条必须返回真实仓库信息；返回空或报错就说明取数链路不通，先修它
gh api repos/<owner>/<known-repo> --jq .full_name || echo "取数链路异常 —— 停止检查"
```

> 非 GitHub 来源（PyPI、crates.io、自托管 forge）同理：**用能明确报错的取数方式，
> 并把"空/异常响应"当作失败处理**，不要用 `curl -s | grep` 这种失败即静默的组合。

上游不一定用 GitHub Release。按实际来源选择：GitHub tag（`/tags`）、PyPI
（`/pypi/<pkg>/json`）、crates.io（`/api/v1/crates/<pkg>`）、上游 CHANGELOG 等。

## 第 4 步：更新构建配置

**按包型选择流程**——包型由 `.nix` 文件中的 builder 决定，不是由包名决定。

> **📖 详见配套文件 [`builders.md`](builders.md)**，其中按 builder 列出：
>
> | builder | 流程要点 |
> |---|---|
> | `buildNpmPackage` | 两次构建取 source hash + `npmDepsHash`；vendored lock 须含 peer 条目 |
> | `stdenv.mkDerivation` + cmake | 单次构建取 hash |
> | `buildRustPackage` | **必须同步 `Cargo.lock`**（最易遗漏） |
> | `fetchurl`（预编译二进制） | 逐个 URL 取 hash，可能需多次构建 |
> | `fetchzip` / `fetchFromGitea` | 自托管 forge：**取源路径可能整体 403**，见下 |
>
> 同文件还包含 `flake.lock` 的三路处置（已 gitignore → 跳过；有动态版本 →
> 必须排除；其他 → 随 hash 一并提交）。

**通用 hash 注意**（无论包型）：SRI 用标准 base64、`fetchFromGitHub` 的
hash 不能从 archive tarball 预算、`npmDepsHash` 用 `lib.fakeHash` 占位。
完整说明见 `builders.md` 开头。

## 第 5 步：更新文档

**文档布局因仓库而异。** 按仓库实际结构选择：

- 若仓库有 `docs/<lang>/<pkg>.md` 多语体系 → 在各语言文档中同步版本号
- 若仓库只有单语 README / 包内注释 → 更新对应位置
- 若仓库无文档 → 跳过本步

```bash
# 多语示例（语言列表从仓库实际目录发现，不要硬编码）
for lang in $(ls docs/ | grep -v '\.md$'); do
  [ -f "docs/$lang/<pkg>.md" ] && sed -i "s/$OLD_VER/$NEW_VER/g" "docs/$lang/<pkg>.md"
done
```

同时检查并更新 `.nix` 文件中的 `meta.changelog` URL。

> 仓库若有**额外的文档同步要求**（如内置插件清单、特定章节），由仓库适配层补充。

### ⚠️ 何时必须**重写**文档而非机械替换版本号

版本号替换（`sed s/OLD/NEW/`）只处理「数字变了」。**当上游改变了版本语义时，
文档里的旧表述会变成事实错误**——此时必须重写，而不是让新版本号挂在旧描述上。

**触发判据**（命中任一条就要人读一遍文档，不能只跑 sed）：

| 触发条件 | 为什么旧表述会失效 |
|---|---|
| **依赖从范围变为精确锁**（`>=x` → `==x.y.z`） | 「≥ 3.4.0」会让读者以为版本可自由浮动，而实际已是 fail-closed 校验 |
| **新增启动期 / 构建期硬校验** | 文档若没提，用户升级后才会在运行时撞上 `RuntimeError` |
| **依赖项增删** | 表格条目数就不对了 |
| **构建方式变化**（fetcher 换型、新增 overlay / patch） | 「来源」列已不准确 |
| **平台 / 版本要求变化**（如 `requires-python` 收窄） | 兼容性描述失效 |

```bash
# 快速发现「文档所列依赖」与「包定义实际依赖」的差异
grep -A15 '^  dependencies' packages/<pkg>.nix          # 实际
sed -n '/依赖\|Dependencies/,/^## /p' docs/zh/<pkg>.md   # 文档所写
```

> **判据的边界**：本节只规定**何时必须重写**（更新流程的职责），
> **不规定文档该怎么写**（那是文档编写技能的职责）。命中触发条件时，
> 交给文档技能处理，或至少按上表逐项核对——**不要**在这一步临时发明
> 一套文档风格。

**本仓库的实例**（可用上面两条命令复现）：`packages/godot-ai.nix` 列出
**9** 个运行时依赖，而 `docs/zh/godot-ai.md` 的依赖表只列 **6** 个、且版本
要求全部写成 `≥` 范围；4.0 起上游改为 fail-closed 精确锁后，该表已成事实
错误——**它正是本节要防的那类漏改：版本号升了，表述却没跟着变语义**。

## 第 6 步：检查本地安装版本

```bash
nix eval --raw .#<pkg>.version 2>/dev/null
which <binary> 2>/dev/null && <binary> --version 2>/dev/null
```

## 第 7 步：输出汇总报告

以表格呈现：包名、旧版本 → 新版本、构建状态、本地安装版本。

### 提交前的八问自检

**构建通过 ≠ 升级完成**。下面八条都是从「已通过构建、却仍然出错」的实际
事故中提炼的，逐条问过再提交——它们比通读全部陷阱章节更快命中要害：

| # | 自问 | 若不查会怎样 |
|---|---|---|
| 1 | **这个包有几个变体？** `ref`/换行搜索包名，看 `flake.nix` 是否按架构或通道分流到多个文件 | 只改一个变体 → 本机构建通过，**另一架构部署后版本没变**（实测发生） |
| 2 | **包定义里的依赖列表，与文档依赖表一致吗？** 数目与语义都要对 | 文档写「≥ 范围」而实际是精确锁 → 读者照做撞 `RuntimeError`（实测发生） |
| 3 | **取源方式还有效吗？** 对 `fetchFromGitea` 等非 GitHub 源，逐 tag 探测 `archive` 与 `api` 两条路径 | 上游改了路径 → 缓存掩盖问题，**干净环境才失败**（实测发生） |
| 4 | **真的运行过产物吗？** `nix build` 成功只说明能构建 | 上游 fail-closed 校验会在**运行时**拒绝启动（实测发生） |
| 5 | **版本号之外的文档表述还成立吗？** 见「何时必须重写文档……」的触发判据 | 版本号升了、描述没跟着变语义 → 文档成为事实错误（实测发生） |
| 6 | **`flake.lock` 该不该提交？** 按本文档「flake.lock 同步」的前置检测 | 该提交而未提交 → 复现不一致；不该提交而提交 → 锁死浮动输入（见该节） |
| 7 | **取数链路真的通吗？** 本轮**没有任何 `UPDATE:`** 时，先怀疑是取数失败而非"全都最新"（见第 3 步的 `gh api` 纪律） | 匿名 `curl` 静默返回空 → 报告虚假的"全部最新"，**漏掉全部真实更新**（实测发生） |
| 8 | **`.github/workflows/` 里固定 SHA 的 action 查过了吗？** 见第 2 步末节与 `traps.md` | 固定 SHA 后收不到通知，**这类更新彻底无人过问**（本轮实测：3 个 action 共 6 处从未被检查） |

> **这八问的来源**：全部是 2026-09-17 ~ 09-20 在同一仓库实际踩到的坑，
> 每一问都对应一次真实返工或缺陷。**不是假想的检查表。**
>
> 第 1、3、5 问尤其值得单独跑一条命令确认——它们的共同特征是
> **构建与 CI 都不会失败**，只有主动核对才看得见。
>
> 第 7、8 问共享同一个特征：**它们的失败是"什么都没发现"**。前六问的疏漏
> 会在构建或运行时报错，而后两问的疏漏**只会让报告看起来一片正常**——
> 这也是它们最容易长期潜伏的原因。

## 第 8 步：记录变更

按仓库约定记录本次更新（维护日志 / CHANGELOG / 无）。NixKits 仓库的对应
技能为 `write-maintenance-log`，由仓库适配层指定。

## 第 9 步：同账户子项目链式检查

当仓库引用了**同一账户下的其他仓库**（典型形态是薄封装：本仓只固定子仓的
`rev` + hash，源码与文档都在子仓），则**子仓自身的上游版本也要检查**——
否则子仓会长期停在旧版本，而主仓的薄封装只是忠实地钉住那个旧版本。

```bash
# 本仓属于哪个账户
OWNER=$(git remote get-url origin | sed -E 's#.*[:/]([^/]+)/[^/]+(\.git)?$#\1#')

# 本仓引用了同账户的哪些仓库（薄封装坐标 = owner + repo）
# 注意用 -h 去掉 "文件名:行号:" 前缀，否则 awk 的字段号会错位
grep -rh -B2 -A4 'fetchFromGitHub {' packages/*.nix 2>/dev/null |
  awk -v o="$OWNER" '
    /owner[[:space:]]*=/ {owner=$3; gsub(/[";]/,"",owner)}
    /repo[[:space:]]*=/  {repo=$3;  gsub(/[";]/,"",repo);
      if (owner==o) print repo}' | sort -u
```

> ⚠️ **必须 `-h`**：带 `-n` 时输出为 `path:line:content`，`content` 会落到
> 第 3 个字段之后，`$3` 取到的是行号而非值——症状是**匹配结果为空**（而非报错），
> 极易误判为「本仓没有子项目」。

**引用关系不止 `fetchFromGitHub`**：flake input（`github:<owner>/<repo>`）、
git submodule、vendored 源码目录都算。以实际引用方式为准，逐个核对。

### 前提：确认不会引发回环或依赖冲突

链式检查**先验证再执行**。以下任何一条不成立，就**不链式**，退化为在报告里
提示「子仓 X 存在更新，请单独在其仓库内运行」：

| 前提 | 判据 |
|---|---|
| **无回环** | 沿引用关系展开形成的是 **DAG**，不是环。子仓不得（直接或间接）引用回本仓 |
| **无版本冲突** | 子仓的升级目标版本与主仓的其他依赖不冲突（如两包共享同一 peer 版本约束） |
| **可独立升级** | 子仓有独立的上游发布，且升级不需要主仓同步改代码（否则属主仓的普通升级） |
| **账户一致** | 沿链展开时维持同一账户，不越界到第三方仓（第三方的升级由第 3 步照常处理，不属本条） |

#### 回环检测

```bash
# 收集链条；每进入一层前，检查该仓是否已在访问集合中
declare -A VISITED
MAX_DEPTH=3

chain_check() {
  local repo="$1" depth="$2"
  if [ -n "${VISITED[$repo]}" ]; then
    echo "ABORT: 回环 — $repo 已在访问集合中（链条: ${!VISITED[*]}）"; return 1
  fi
  if [ "$depth" -gt "$MAX_DEPTH" ]; then
    echo "ABORT: 超出深度上限 $MAX_DEPTH — 疑似意外的长链"; return 1
  fi
  VISITED[$repo]=1
}
```

> **深度上限是安全网，不是配额**：真正的薄封装链通常只有 1~2 层。命中上限
> 往往意味着引用关系被误读（如把 vendored 的第三方源码当成子仓）。

**回环的实际形态**（子仓可能不是 flake，所以不能只查 flake input）：

```bash
# 子仓以任何方式引用回本仓，都构成回环——逐个检查子仓的引用面
SUB=<sub-repo>
gh api "repos/$OWNER/$SUB/contents" --jq '.[].name' | grep -E '^(flake\.nix|\.gitmodules)$' \
  && echo "有 flake.nix / submodule → 必须展开核对"
gh api "repos/$OWNER/$SUB/contents/package.json" --jq '.content' 2>/dev/null |
  base64 -d | grep -E '"(@[^"]*/)?'"$MAIN_REPO"'[^"]*":'   # 依赖名里出现主仓名
```

> 子仓**没有** `flake.nix` 也**不等于**不可能回环——它仍可能以 submodule、
> 依赖名、CI 脚本等方式拉回主仓。判据是「子仓的任何引用面是否指向主仓」，
> 不是「子仓是否是我认识的形态」。

#### 依赖冲突检测

子仓与主仓共享同一依赖时，**比对两边的目标版本**：

```bash
# 主仓侧该依赖的版本约束
grep -rn '<dep-name>' packages/*.nix | grep -oP 'version\s*=\s*"\K[^"]+'
# 子仓侧的目标版本
gh api "repos/$OWNER/<sub-repo>/contents/package.json" --jq '.content' |
  base64 -d | grep -oP '"<dep-name>":\s*"\K[^"]+'
```

两侧指向同一大版本 → 可并行升级。若子仓升级会把主仓的 peer 约束顶到
不兼容区间，**先升主仓侧**（或同步升），不要并行。

> ⚠️ **运行时由宿主解析的依赖不构成冲突**——这是最容易误报的一类。
> 当子仓声明的是 `peerDependencies`（或实际表现为 peer：声明版本低于宿主、
> 由宿主树在运行时解析，如 dsh 生态的 `@deepseek-ai/dsh-*`），则
>
> | 现象 | 判定 |
> |---|---|
> | 子仓声明 `0.1.1-rc.2`，宿主提供 `0.1.5-rc.2` | **不冲突**——运行时取宿主树 |
> | 子仓声明 `0.2.0`，宿主提供 `0.1.5-rc.2`（子仓要求**更高**） | 冲突——宿主满足不了 |
>
> 判据：**只查「子仓要求的版本是否高于宿主能提供的版本」**，而不是「两侧是否相等」。
> 宿主版本更高是正常状态，不是漂移。主仓侧薄封装一律用 `--legacy-peer-deps`
> 跳过 peer 解析，正因如此它才能正常工作。

### 执行：链式并行

前提全部成立时，各子仓的检查**并行**执行，互不阻塞：

| 规则 | 说明 |
|---|---|
| **并行** | 多个子仓（及其各自的子仓）同时展开，无依赖的检查不必串行等待 |
| **各自独立** | 子仓的检查在**子仓自己的工作副本**中进行，不得在主仓目录内改子仓文件 |
| **失败隔离** | 某个子仓失败不阻断其他子仓；失败项进入报告，其余照常推进 |
| **不越权提交** | 子仓的 commit / push 在子仓仓库内进行；主仓只提交自己的薄封装更新 |

```bash
# 并行展开（每个子仓一个工作副本）
for sub in $SUBPROJECTS; do
  ( git -C "/tmp/$sub" fetch -q origin &&
    cd "/tmp/$sub" && <本技能第 1~8 步> ) &
done
wait
```

> **子项目不一定是 nix flake**：被引用的子仓常常是普通 npm / python / rust
> 项目（主仓的薄封装只负责用 `buildNpmPackage` 等包装它）。此时**不要**套用
> 本技能的 flake 专属步骤（`nix flake check`、flake.lock 处置）——按子仓
> 自身的构建体系升级（`npm version` / `pyproject.toml` / `Cargo.toml`），
> 只在主仓侧重新计算薄封装的 `rev` + hash。

### 判据：子仓何时需要在主仓侧跟进

子仓有新提交 ≠ 主仓薄封装要动。按子仓变更的**性质**分流：

| 子仓变更 | 主仓侧动作 |
|---|---|
| **版本号变更**（`version` 字段变了） | **必须跟进**——更新薄封装的 `version` + `rev` + 两侧 hash |
| **仅源码变更、版本未变**（同为 docs/修复提交） | 分情况：<br>• 变更影响**运行时行为** → 跟进 `rev` + src hash（`npmDepsHash` 可能不变）<br>• 仅改文档/README/注释 → **不跟进**，主仓无需重新钉住文档 |
| **仅依赖变更** | 跟进，且需重算 `npmDepsHash`（依赖集变了） |
| **仅清单文件的发布元数据变更**（`package.json` 的 `publishConfig` / `repository` / `keywords` 等） | **不跟进**——文件字节变了但语义输入未变，详细字段分界见下 |

> ⚠️ **别把「子仓 HEAD ≠ 主仓 rev」直接当成待办**。薄封装钉的是**版本坐标**，
> 不是「子仓最新提交」。子仓的 README 改动对主仓构建产物毫无影响，为它重钉
> rev 只会制造无意义的构建与缓存失效。
>
> 判据：**先 diff 判断变更性质，再决定是否跟进**——而不是 rev 不等就升级。

```bash
# 判断子仓在 pin 之后改了什么
gh api "repos/$OWNER/$SUB/compare/$PINNED_REV...$SUB_HEAD" \
  --jq '.files[] | "\(.status)  \(.filename)"'
# 版本号是否变了（最硬的判据）
gh api "repos/$OWNER/$SUB/contents/package.json" --jq '.content' |
  base64 -d | grep -oP '"version":\s*"\K[^"]+'
```

#### ⚠️ 判据的真实分界：不是「有没有变」，而是「变的是不是构建输入」

版本号未变时，**不能只看「是否只有文档」**——`package.json` 这类**清单文件**
的改动尤其容易误导。必须进一步判断改动是否落在**构建输入**上：

| 子仓改动 | 是否构建输入 | 主仓动作 |
|---|---|---|
| `README` / `docs/` / 注释 | 否 | **不跟进** |
| `.github/workflows/`、`.gitignore` | 否（主仓薄封装不消费子仓 CI） | **不跟进** |
| **清单文件中的发布元数据**（`publishConfig`、`repository`、`keywords`、`description`、`bugs`、`homepage`） | **否** | **不跟进** |
| `dependencies` / `devDependencies` / `peerDependencies` / 锁文件 | **是** → 影响 `npmDepsHash` | **跟进** |
| `files` / `main` / `exports` / `scripts` | **是** → 影响打包产物 | **跟进** |
| `version` | **是**（最硬的信号） | **必须跟进** |
| `lib/`、`src/` 等源码 | **是** → 影响产物 | **跟进** |

**关键区分**：`package.json` **整个文件**都是构建输入的一部分（它进 tarball，
src hash 会变），但**它的诸多字段中只有一部分是语义输入**。判据落在**字段级**：

```bash
# 提取 package.json 的实际 diff，逐字段判定
gh api "repos/$OWNER/$SUB/compare/$PINNED_REV...$SUB_HEAD" \
  --jq '.files[] | select(.filename=="package.json") | .patch'
```

> **两种结论的取舍**：
>
> - **不跟进**（仅发布元数据/文档变化）：主仓薄封装确实与子仓 HEAD 不再逐字节
>   一致，但**构建产物与运行时行为完全相同**。重钉只会制造一次无意义的全架构
>   重建与缓存失效。这是**正确的**选择。
> - **跟进**（任何语义字段变化）：必须重钉 `rev`；依赖相关字段变了还要重算
>   `npmDepsHash`；仅源码/元数据变了则 `npmDepsHash` 通常不变。
>
> 若无法确定某字段是否影响产物，**按「跟进」处理**——多一次构建远好过漏掉
> 一次真实变更。

### 结果归属

| 场合 | 归属 |
|---|---|
| 汇总报告 | 子仓结果**视为主仓结果**，一并呈现（报告读者关心的是主仓最终钉住的版本） |
| 主仓维护日志 | 记录**薄封装坐标变更**（`rev` / hash），并提供**指向子仓维护条目章节的链接** |
| 子仓维护日志 | 记录**子仓自身的版本变更**（它是该项目的完整记录所在） |

主仓条目里的链接必须**锚定到具体章节**，而不是只给日志文件：

```markdown
**摘要**：dsh-api-balance 0.1.0 → 0.2.0 — 薄封装 rev/hash 同步（子仓变更见
[子仓维护日志](https://github.com/<owner>/<sub>/blob/main/MAINTENANCE.md#<timestamp-anchor>)）
```

> 锚点用子仓条目的**时间戳**（如 `#2026-09-17t11-21-34-09-00`）——它在该仓库内
> 唯一，且子仓与主仓都用同一套标题格式时可直接推导。**推导规则与易错点见
> `write-maintenance-log` 技能「主仓条目的链接写法」**（逐字符替换，不是删除
> 分隔符）。给不出锚点就退化为给日志文件链接 + 条目标题文本。

## 仓库适配层

通用流程覆盖到第 8 步为止。**仓库特有的环节不由本技能定义**，而由该仓库的
适配层技能补充。若当前仓库存在适配层技能，先加载它，再执行本技能；两者冲突时
以适配层为准（适配层了解本仓库的真实约定）。

适配层通常补充以下内容：

| 环节 | 适配层需说明的内容 |
|---|---|
| 文档同步（第 5 步） | 文档路径、语言清单、需同步的特殊章节 |
| 变更记录（第 8 步） | 该仓库使用的记录技能或文件（如维护日志） |
| 动态版本输入 | 仓库是否有不可锁定的浮动 input，及其影响 |
| 已知事故教训 | 该仓库历史上因更新导致的故障与规避方式 |
| 额外同步项 | 内置清单、生成文件等需随版本一并更新的内容 |
| 子项目清单（第 9 步） | 本仓引用了哪些同账户子仓、各自的构建体系、子仓日志的路径与语言约定 |
