feat(ci): gate a lockfile that duplicates a dependency or moves @objectstack/* backward - #8700
Conversation
…ctstack/* backward objectui#8326: dependabot's regenerated lockfile downgraded the whole @objectstack/* family 17.3.0 -> 17.2.0 and forked zod on three PRs at once, splitting the workspace across two physical @objectstack/spec copies. Nothing dedupes two real paths, so vendor-objectstack went 4,030,557 -> 8,423,436 raw bytes and `Bundle Analysis` went red on a dependency bump for a reason that had nothing to do with the dependency. Two seats each spent a full diagnosis. The gate compares a PR's pnpm-lock.yaml against its merge base and reports two things: an @objectstack/* identity resolving lower than the base resolved, and a workspace-declared runtime dependency resolving to more physical copies (pnpm snapshot keys, peer suffix included) than the base did. Both rules are needed. objectui#8333 measured the second cause: floating better-auth to 1.7.3 from a clean base moves no @objectstack/* identity at all and still lands @objectstack/spec as two directories, one per peer resolution — so a rule that only asks "did an identity move backward", or that counts versions instead of snapshot keys, is green on it. Scope is measured, not assumed. Over the 40 most recent lockfile-changing commits on main, a repo-wide rule 2 reds on three; excluding devDependencies drops the build-tooling churn and keeps the runtime duplication. ⛔ Deliberately not enrolled as a blocking context: dependabot-merge-gate.mjs classifies it NOT_A_GATE. Enrolling changes what stops the merge queue, which is a maintainer decision, and the cost is written up on the pull request as input. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FhBNJcLRZLe8M87VcUgpKr
The fourth reading — triage's named control sample, measured after the factTriage's acceptance criterion on objectui#8326 names a third sample the gate must stay green ⭐ Note this control is stronger than the card's, not weaker: today's re-resolve floats ⇒ all four samples now measured: red on (a), red on (b), green on the lucide-only control, The worktree was restored afterwards and the restore proven, not assumed: Generated by Claude Code |
ACCEPT — objectui#8326 / PR objectui#8700,
|
| sample | reading |
|---|---|
main |
exit 0, and reinforced against real history: over the 40 most recent lockfile-changing commits, 37 exit 0 / 3 exit 1, all three real duplications |
| ⭐ triage's lucide control | exit 0 — produced for real (pnpm update lucide-react --lockfile-only), and the gate correctly does not flag lucide's 2→1 repair |
| sample (a) | exit 1, 7 findings; mutation proven on disk (17.2.0 occurrences 0→11, spec snapshot keys 2→3) |
| ⭐ sample (b) | exit 1, 5 findings, 0 backward moves — the real resolver output, restored afterwards with the lockfile proven byte-identical to HEAD |
⭐ And the decisive ablation: delete rule 2 and the cause-①-only gate reads sample (b) as VERDICT clean, exit 0. ⇒ measured proof that the gate the two cards' wording describes is blind to objectui#8333 — which is the whole reason triage split the work this way.
⚠️ Maintainer floor held, and the cost is measured rather than guessed
⛔ Not enrolled. It sits in NOT_A_GATE, REQUIRED_CONTEXTS untouched, with a pin test asserting that. The enrolment cost is stated as input: three changes (drop the paths: filter per objectui#3523's rule; subscribe merge_group or the queue stalls; move the name into REQUIRED_CONTEXTS plus branch protection, which lives in repository settings, not in this repo) — and, measured over 40 commits, it would have blocked 3 of 40, all real duplications.
⭐ The dev then framed the maintainer's actual question, which is not the one the card asks: "the maintainer's question is not 'is the gate accurate' but 'should a bump that adds a second copy of a bundled dependency be unmergeable'."
⭐ Two design choices that are load-bearing and were surfaced, not buried
- Rule 1's second clause — absent from the base — without it "an already-split base reds on every later PR". Measured, not anticipated.
- The gate is a ratchet, not an audit: an already-duplicated lockfile stays green while the change does not worsen it. ⇒ stated plainly under not covered, alongside the fact that it never pins, dedupes or re-resolves and does not explain why pnpm picked 17.2.0.
Also verified by this seat
7 files, +999 −0, draft, Fixes #8326. ⭐ The docs section is not decoration — ci-cd-pipeline-doc.test.ts went red without it, which is that gate doing its job on a new gate's own inventory row.
Next
ci-wait on 489be9552, then flip, post-flip guard, arm SQUASH, enqueue from the timeline event.
Landing probe, scoped now: on a re-fetched origin/main, scripts/check-lockfile-integrity.mjs and its workflow exist; the name is in NOT_A_GATE and ⛔ absent from REQUIRED_CONTEXTS — ⭐ that absence is the maintainer-floor check, and it gets a firing control: REQUIRED_CONTEXTS must still list a name known to be in it, or the "absent" reading is a dead pattern; ci-cd-pipeline.md carries the inventory row; nonsense control exits 1.
Generated by Claude Code
Armed — objectui#8700, 2026-09-08T23:31Z
Landing probe, restated before the merge:
Generated by Claude Code |
Queue status at 2026-09-09T00:35Z — ⛔ this PR's delay is not its own diffEnqueued at 23:31Z, still unmerged 64 minutes later. Establishing why, so the next reader does not re-derive it:
Control: this PR's 7 files touch nothing under ⇒ nothing to fix here and ⛔ nothing to re-run — a re-run buys one more coin flip and another ~50 minutes of shared queue time. This PR stays enqueued. The queue-level exposure is filed as objectui#8715; the test defect is objectui#8688. Generated by Claude Code |
Base drift only: PR 8700 left the merge queue as dirty after seven PRs
landed on main while it waited. One conflicted file,
content/docs/guide/ci-cd-pipeline.md, in the workflow inventory table.
The two sides are semantically independent and both are kept:
- this branch INSERTS the `lockfile-integrity.yml` row above `live-e2e.yml`
- main (8692) REWRITES the `live-e2e.yml` row's trailing cell, from
"informational lane, `continue-on-error`" to "informational lane: not in
the required-check set, and it declares no `merge_group` trigger"
Resolution keeps main's rewritten `live-e2e.yml` row with this branch's new
`lockfile-integrity.yml` row above it. Verified: main's version of the file
loses zero lines, and the lines the merge adds to it are byte-identical to
the set this branch added over the merge base.
No behaviour change and no diff widening: the tree against main is still the
same seven files, 999 insertions, 0 deletions. The gate stays classified
NOT_A_GATE and is still absent from REQUIRED_CONTEXTS and OPTIONAL_CONTEXTS.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FhBNJcLRZLe8M87VcUgpKr
⛔ Correction to my queue-status note above — the mechanism I gave was wrong
PR #8683 ( The ⭐ My method error, since it is the reusable part: GraphQL Full account on objectui#7010 ( Generated by Claude Code |
⭐ LANDED — merged
|
Fixes #8326
A CI check that reads a pull request's
pnpm-lock.yamlagainst its merge base and names, in oneline, the two things objectui#8326 and objectui#8333 measured. It reports; it pins nothing.
The gate
scripts/check-lockfile-integrity.mjs— two rules, both deltas against the merge base:@objectstack/*onlydependencies/peerDependencies/optionalDependencies, plus@objectstack/*A "physical copy" is a
snapshots:key with its peer suffix, because that is the directory pnpmcreates. Exit
0clean ·1findings ·2could not take a reading (shallow checkout, or alockfile format that stopped parsing) — 2 is never a pass.
Verdict line, on a clean run:
The three required readings
All taken on
489be9552, against the real 701 KB lockfiles. Sample (b) is not a reconstruction: itis the output of
pnpm update better-auth --lockfile-only --recursiverun onda5e4f69etoday.mainda5e4f69e→ this branch's lockfile0VERDICT cleanmain→ the family at 17.2.0, spec at both1main→ real better-auth 1.7.3 resolve1Sample (b), in full — this is the discriminating one:
⭐
@objectstack/specstays at ONE version and still lands as TWO directories. That isobjectui#8326's exact bundling mechanism — two real paths, nothing dedupes them — arriving with no
version change anywhere. Sample (a) cannot be produced by running the resolver at all: every
workspace range on the family is a floating
^17.xand the registry's latest is 17.3.0, which is thecard's impossibility argument, so it is a documented transformation of the real lockfile reproducing
#7053 / #7058's measured identities.
Ablations — each restored, each restore proven by blob hash
Run on the committed tree;
git hash-objectreturned7512dd8c…(=HEAD:scripts/check-lockfile-integrity.mjs) after every restore.VERDICT clean, exit 0 — the gate the two cards' wording would have produced is blind to #8333. Test suite: 4 failed / 12 passed.@objectstack/spec1→2 disappearszod2→3 disappears — zod is already two copies onmain(3.25.76+4.4.3), so that wording is green on it⛔ What the gate does not cover
change does not make it worse. The absolute invariant "exactly one
@objectstack/specversion" isalready owned by
scripts/__tests__/ci-cd-pipeline-doc.test.tsand is deliberately not restatedhere — two definitions of one rule drift.
@objectstack/*are not judged. Third-party downgrades are legitimate(a revert, a yank, a security pin). The first-party scope is justified by the impossibility
argument, which does not transfer.
1bae75bb8(@vitejs/plugin-react),90bc5d16a(vite,vitest) andbde6483b1(
lucide-react); excludingdevDependenciesdropsvitestand keeps the rest, sincepackages/clideclaresviteand@vitejs/plugin-reactas runtime dependencies.Bundle Analysismeasures those. This names the cause so thereading is not attributed to the dependency.
^17.0.0range is stillobserved, not explained (director-seat note
5570724965), and this gate does not need it.pnpm dedupemade chore(deps): Bump better-auth from 1.6.28 to 1.7.2 #7053 worse (16@objectstackcopiesagainst 11) and is recorded on the card so nobody reaches for it.
⛔ Enrolling it as a required context — input, NOT done
scripts/dependabot-merge-gate.mjsclassifiesLockfile Integrity CheckinNOT_A_GATE, with thereason inline. Today a red here blocks nothing.
What enrolling would take — three changes, not one:
paths:filter fromlockfile-integrity.yml. Under ⛔ P0:合并队列已强制但零 merge_group 订阅——队列必需集为空、不校验任何东西;今日已兑现三例带红 Type Check 合入(#3503/#3510/#3516) #3523's rule, enforced bydependabot-merge-gate.test.ts, a required context must come from a workflow whosepull_requesttrigger has no path filter; a missing required context leaves a PR pending until the ruleset
timeout. (
OPTIONAL_CONTEXTSis the alternative — path filter kept, blocking when it runs — and isequally a decision about what stops a merge.)
merge_group, as every requirable gate here does, or the queue stalls on it.NOT_A_GATEtoREQUIRED_CONTEXTS, and add the check to branch protection —which lives in repository settings, not in this repository's files.
What it would cost — measured over the 40 most recent lockfile-changing commits on
main(
489be9552): 37 clean, 3 with findings, i.e. it would have blocked1bae75bb8fumadocs-core 16.14.4→16.15.4@vitejs/plugin-react1 → 2bde6483b1fumadocs-ui 16.14.4→16.15.4lucide-react1 → 290bc5d16amermaid 11.16.1→11.17.0vite2 → 3All three are real duplications of runtime-declared packages, not parse errors —
lucide-reactisa dependency of nine packages and is in the console bundle. So the question the maintainer is being
handed is not "is the gate accurate", it is "should a bump that adds a second copy of a bundled
dependency be unmergeable", at a measured rate of roughly 1 in 13 lockfile changes. ⛔ Not answered
here.
Checks
Run locally on
489be9552:node scripts/check-lockfile-integrity.mjs --self-test— 29 cases, exit 0pnpm exec vitest run scripts/— 128 files, 3713 tests passed, 2 skipped (includes the newsuite's 16,
dependabot-merge-gate.test.ts's bucket partition, andci-cd-pipeline-doc.test.ts)pnpm run type-check:scripts— exit 0pnpm exec eslinton the three changed scripts — exit 0check-changeset-presence,-no-major,-fixed,check-control-bytes,check-pre-install-import-graph,check-doc-fence-languages,check-doc-links— all exit 0Not verified locally: the repo-wide
pnpm lint,pnpm test(full four shards) andBuild & E2E;and the workflow's own execution — its
git fetch+--base-refpath is exercised here only againsta local
origin/main, never against a realpull_requestcheckout. CI is the first real run of theYAML.
维护者速读(草稿)
改了什么 — 新增一道 CI 检查:把 PR 的
pnpm-lock.yaml和它的合并基做比较,发现两种情况就报出来并点名具体包 —— ①
@objectstack/*的版本往回退了;② 本仓自己声明的运行时依赖多出了一份物理副本。另加它的工作流、测试、文档章节和一份「不发版」声明的 changeset。
为什么改 — objectui#8326:dependabot 重生成的锁文件把
@objectstack/*全族从 17.3.0 退回17.2.0 并把 zod 分叉,导致
packages/spec在 console 包里被打包了两份,Bundle Analysis变红。红的原因和被升级的那个依赖毫无关系,而最自然的读法(「这次升级把包撑大了」)是错的 ——
两个席位各花了一整天诊断才各自到达同一个答案。这道门要做的就是把那一天变成一行。
风险与代价(含回滚) — 风险很低:它今天挡不住任何东西(在
dependabot-merge-gate.mjs里被归类为
NOT_A_GATE),只是在 PR 上多一个红/绿标记。实测在main最近 40 次锁文件变更上,37 次绿、3 次红,三次都是真的重复副本。回滚成本 = 删掉两个新文件 + 还原三处小改动;没有任何
运行期代码被触碰。
席位意见 — (留空,交维护者)
你要做的 — 一个决定,本 PR ⛔ 没有替你做:要不要把它升成必需检查(阻断合并)?
升的话需要三步(去掉路径过滤、订阅
merge_group、加进REQUIRED_CONTEXTS和分支保护),代价就是上面那 3/40 —— 也就是「一次给捆绑依赖增加第二份副本的升级,该不该不许合」。
按最近的节奏大约每 13 次锁文件变更挡一次。
🤖 Generated with Claude Code
https://claude.ai/code/session_01FhBNJcLRZLe8M87VcUgpKr
Generated by Claude Code