Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .changeset/8333-lockfile-dedupe-gate.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
---

Require the committed `pnpm-lock.yaml` to be deduped (objectui#8333).

New gate `scripts/check-lockfile-dedupe.mjs` + `.github/workflows/lockfile-dedupe.yml` runs
`pnpm dedupe --check` on pull requests that touch the lockfile, and the Dependabot merge gate
classifies the resulting `Lockfile Dedupe Check` as a blocking context. A dependency bump can
re-resolve part of the peer graph and fork a single-copy package without changing any declaration,
range or override; `Bundle Analysis` then reads the growth as the bump's. This makes
`pnpm dedupe` a requirement of the bump pull request instead, so that reading is attributable.

CI, scripts and docs only; no package is released by this change.
158 changes: 158 additions & 0 deletions .github/workflows/lockfile-dedupe.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
name: Lockfile Dedupe

# objectui#8333 — the committed `pnpm-lock.yaml` must already be DEDUPED, i.e.
# `pnpm dedupe` on it may not collapse anything. The mechanism, what it catches
# and what its green does NOT mean are documented at length in
# `scripts/check-lockfile-dedupe.mjs`.
#
# ── Why this exists next to `lockfile-integrity.yml` rather than inside it ────
#
# They answer different questions. objectui#8326's gate reports a DELTA against
# the merge base — "did THIS change duplicate something?" — and needs two
# revisions of one file to do it. This one reports a PROPERTY of a single tree —
# "would `pnpm dedupe` still have work to do here?" — and needs no base at all,
# but does need pnpm and the registry. Different subject, different cost,
# different failure modes, so they are separate jobs and each stays readable.
#
# ── Why it is path-filtered ──────────────────────────────────────────────────
#
# Its subject is one file. A pull request that does not touch `pnpm-lock.yaml`
# cannot change whether that lockfile is deduped, so the filter costs no
# coverage. A manifest edit that WOULD change the resolution cannot reach `main`
# without moving the lockfile either, because `ci.yml` installs with
# `--frozen-lockfile` and fails first. Same argument shape as its neighbour.
#
# The filter also lists this gate's RUNTIME CLOSURE — the files the job actually
# executes — which is this repository's convention for a path-filtered gate
# (`performance-budget.yml` states it at length): without that, a change to the
# checker itself ships having never run once, and the wiring bug then surfaces on
# somebody else's next lockfile PR, reading to them as a problem of their own.
#
# ⚠️ The filter is also what makes it UNREQUIRABLE: objectui#3523's rule,
# enforced by `scripts/__tests__/dependabot-merge-gate.test.ts`, is that a
# REQUIRED context must come from a workflow whose `pull_request` trigger has no
# path filter. It is enrolled as an OPTIONAL context instead — blocking when it
# runs, not waited for when the filter keeps it out. `Bundle Analysis` is
# enrolled on exactly those terms and its filter also includes `pnpm-lock.yaml`.
#
# ── ⚠️ It IS a blocking context, and that is a decision, not a default ────────
#
# `scripts/dependabot-merge-gate.mjs` classifies `Lockfile Dedupe Check` in
# `OPTIONAL_CONTEXTS`, so a red here stops a Dependabot auto-merge. Its
# neighbour `Lockfile Integrity Check` is deliberately NOT enrolled, and that
# difference is intentional rather than an inconsistency to tidy away:
#
# * #8326's gate reports a duplication and leaves the ANSWER open — re-lock,
# pin, or accept the split — so enrolling it would block pull requests on a
# judgement call with no mechanical remedy. Its header reserves that for the
# maintainer, and ⛔ this file does not touch that reservation.
# * This gate has exactly ONE remedy and pnpm prints it: run `pnpm dedupe` and
# commit the lockfile. No declaration, range or override changes. There is no
# judgement call to reserve.
#
# The cost of enrolling it was measured before it was taken, and it is near zero
# TODAY only because objectui#9215 landed first: on `main` after that collapse
# this gate is green, so nothing currently-mergeable is blocked by it. On `main`
# BEFORE it, the same command reds. ⇒ what this gate really defends is that
# objectui#9215's paydown does not silently accrue again — which is the whole
# reason the bump PR's `pnpm dedupe` is cheap enough to require at all.
#
# ⇒ ⚠️ If this ever needs to stop blocking, the flip is one line: move
# `Lockfile Dedupe Check` from `OPTIONAL_CONTEXTS` to `NOT_A_GATE` in
# `scripts/dependabot-merge-gate.mjs`. The partition test keeps either state
# honest; ⛔ deleting the name from both buckets fails that test rather than
# quietly shrinking the gate.
#
# ── No `merge_group` trigger ─────────────────────────────────────────────────
#
# Deliberate, and the same shape as `lockfile-integrity.yml` and
# `performance-budget.yml`: under objectui#3523 a required context that does not
# report on a queue build stalls the queue until the ruleset timeout fails it, so
# a path-filtered, non-required gate must not subscribe one.

on:
pull_request:
branches: [main, develop]
paths:
- 'pnpm-lock.yaml'
# This gate's runtime closure — the files this job executes. An edit to any
# of them must be able to red this gate on its OWN pull request.
- '.github/workflows/lockfile-dedupe.yml'
- 'scripts/check-lockfile-dedupe.mjs'
# `isEntrypoint` decides whether `main()` runs at all, so a regression in it
# makes this checker exit 0 having measured nothing — the exact silent-green
# failure the rest of this gate exists to prevent. Same reason
# `performance-budget.yml` and `half-state-patrol.yml` list it.
- 'scripts/invoked-as.mjs'
# The toolchain step this job runs before pnpm is on PATH.
- 'scripts/ci-setup-pnpm.sh'
workflow_dispatch:

concurrency:
group: lockfile-dedupe-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true

permissions:
contents: read

jobs:
lockfile-dedupe:
name: Lockfile Dedupe Check
runs-on: ubuntu-latest

# ── The job ceiling, DERIVED FOR THIS JOB ────────────────────────────────
#
# ⛔ NOT inherited from `lockfile-integrity.yml`'s 5 or `ci.yml`'s ladder —
# objectui#7048 fences exactly that. The arithmetic below is this job's own,
# and its weakness is stated rather than hidden.
#
# - Population: `pnpm dedupe --check` on a clean `origin/main` worktree,
# n=3 consecutive runs, pnpm 10.31.0 / Node 22.22.2, WARM pnpm metadata
# cache, on a developer box and not a runner: 22.8s / 22.9s / 23.2s, all
# exit 0. Spread 0.4s, so the command itself is not variable.
# - ⚠️ That population is the wrong one for a ceiling and is known to be:
# this workflow has no run history yet, and a runner starts with a COLD
# metadata cache, which is the dominant term. So the max is not measured
# but BOUNDED, pessimistically, at 5 minutes for checkout + Node + the
# Corepack fetch + a cold resolve of ~1,767 identities.
#
# Ceiling = the smallest round number that is both >= 3x that bound (15min)
# and >= bound + 15min (20min); the second leg binds => 20.
#
# ⚠️ Re-derive this from real runs once this workflow has a history — the
# instrument is the Actions jobs endpoint (`completed_at` minus
# `started_at`, per job, never the run total), the same one
# `performance-budget.yml` names. ⛔ Do not copy the number above forward as
# if it were a measurement of this job in CI; it is a bound on one.
timeout-minutes: 20

steps:
# No `fetch-depth: 0` here, unlike its neighbour: this gate's subject is a
# single tree, not a delta, so it needs no merge base and no history.
- name: Checkout code
uses: actions/checkout@v7

- name: Setup Node.js
uses: actions/setup-node@v7
with:
node-version: '22.x'
# No `cache: 'pnpm'` — that action's store probe shells out to pnpm
# before the step below has put it on PATH (objectui#8099 documents
# that exact ordering trap), and this job runs no `pnpm install`, so
# there is no node_modules for a cache to pay for.

- name: Enable Corepack and download the pinned pnpm
run: bash scripts/ci-setup-pnpm.sh

- name: Verify pnpm version
run: pnpm --version

# The checker's own cases, before the checker is trusted to judge anything.
- name: Self-test the checker
run: node scripts/check-lockfile-dedupe.mjs --self-test

# No `pnpm install`: `pnpm dedupe --check` resolves and reports without
# writing a lockfile or a node_modules tree (verified on objectui#8333 —
# the lockfile's sha256 is unchanged across repeated runs).
- name: Check the committed lockfile is deduped
run: node scripts/check-lockfile-dedupe.mjs
55 changes: 55 additions & 0 deletions content/docs/guide/ci-cd-pipeline.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ one has its own section below.
| `governed-surface-guard.yml` | Governed Surface Queue Guard | PR to `main`, `develop` (incl. `ready_for_review`) — **no path filter**; merge-queue builds | **Yes on a queue build only** — a governed-surface diff with no authorized approval record (on any commit) is refused there, and so is any merge group whose queued pull requests still carry `needs:contract-review`; on the pull request itself it is deliberately green and prints an early warning |
| `performance-budget.yml` | Bundle Analysis | Push / PR touching `packages/**`, `apps/console/**`, `pnpm-lock.yaml` | **Yes** — the console entry gzip budget |
| `lockfile-integrity.yml` | Lockfile Integrity Check | PR to `main`, `develop` touching `pnpm-lock.yaml` or the gate's own two files; manual | No — **deliberately not a blocking context** ([#8326](https://github.com/objectstack-ai/objectui/issues/8326)); it names the packages and the Dependabot merge gate classifies it `NOT_A_GATE` |
| `lockfile-dedupe.yml` | Lockfile Dedupe Check | PR to `main`, `develop` touching `pnpm-lock.yaml` or the gate's own runtime closure; manual | **Yes, when it runs** — classified `OPTIONAL_CONTEXTS`, so a red stops a Dependabot auto-merge ([#8333](https://github.com/objectstack-ai/objectui/issues/8333)) |
| `live-e2e.yml` | Live E2E (informational) | PR to `main`, `develop` (code paths); nightly cron `30 6 * * *`; manual | No — informational lane: not in the required-check set, and it declares no `merge_group` trigger |
| `labeler.yml` | Auto Label PRs | PR `opened`, `synchronize`, `reopened` | No |
| `dependabot-auto-merge.yml` | Dependabot Auto-merge | PR to `main`/`develop` authored by `dependabot[bot]` | No — but it gates *its own* merge, and goes red instead of merging when the check set is not green |
Expand Down Expand Up @@ -1877,6 +1878,60 @@ build-tooling churn. And it says nothing about bytes; `Bundle Analysis` measures
that filter stands. Enrolling it is a maintainer decision with its own cost, written up on #8326's
pull request as input.

## Lockfile Dedupe (`lockfile-dedupe.yml`)

**Trigger:** Pull requests to `main` and `develop` that touch `pnpm-lock.yaml`, this gate's own
runtime closure (`scripts/check-lockfile-dedupe.mjs`, `scripts/invoked-as.mjs`,
`scripts/ci-setup-pnpm.sh`) or the workflow file itself, plus manual dispatch. It appears in the
checks list as **Lockfile Dedupe Check**.

Runs `scripts/check-lockfile-dedupe.mjs`, which shells out to `pnpm dedupe --check` and requires the
committed lockfile to be **already deduped** — that is, `pnpm dedupe` must have nothing left to
collapse.

**How it differs from its neighbour.** `Lockfile Integrity Check` reports a **delta** against the
merge base ("did *this* change duplicate something?") and needs two revisions of one file.
This gate reports a **property of one tree** ("would `pnpm dedupe` still have work to do here?") and
needs no base — but it does need pnpm and the registry. The two are independent: a tree can be
delta-clean and still carry years of accumulated duplication, which is the state `main` was in until
[#9215](https://github.com/objectstack-ai/objectui/issues/9215) collapsed 47 identities out of it.

**Why it exists.** [#8333](https://github.com/objectstack-ai/objectui/issues/8333) measured the
sequence: a dependency bump re-resolves part of the peer graph and forks a package that was
single-copy — no declaration, range or override anywhere in the workspace changes — and
`Bundle Analysis` then reads a bundle that grew and attributes the growth to the bump. The remedy is
`pnpm dedupe`, but `pnpm dedupe` is **not surgical**: run on a tree carrying old duplication it
collapses all of it, and the reviewer reads the combined delta as the bump's — the same
misattribution, aimed at a different pull request. So the remedy is only honest once the tree is
already deduped, which is why #8333's ruling was **B then A**: pay the accumulated debt down in its
own dedicated pull request first (#9215), then require the remedy in bump pull requests. This gate is
that second half, and it is also what keeps the first half true.

**It IS a blocking context**, and that is a decision rather than a default.
`scripts/dependabot-merge-gate.mjs` classifies it in `OPTIONAL_CONTEXTS`, so a red stops a Dependabot
auto-merge; its path filter makes it unrequirable under
[#3523](https://github.com/objectstack-ai/objectui/issues/3523)'s rule, the same terms
`Bundle Analysis` is enrolled on. It blocks where `Lockfile Integrity Check` deliberately does not
because the two differ in **remedy**: #8326's gate names a duplication and leaves the answer open
(re-lock, pin, or accept), a judgement call its header reserves for the maintainer, while this gate
has exactly one mechanical remedy and pnpm prints it — run `pnpm dedupe` and commit the lockfile,
changing no declaration, range or override. To stop it blocking, move the name to `NOT_A_GATE`;
`scripts/__tests__/check-lockfile-dedupe.test.ts` fails on that demotion so it has to be taken
deliberately.

**⛔ What its green does not mean.** It does not say the lockfile is *minimal* — packages whose
ranges genuinely do not overlap keep their separate copies and are not findings. It does not measure
bytes; `Bundle Analysis` does. And it is not a check that drifts with the registry: `pnpm dedupe`
collapses copies already in the tree rather than chasing the newest version a range admits, which
#8333 measured directly (`better-auth` declared `^1.7.2`, locked at `1.7.2`, with `1.7.4` published
and admitted by that range — and this gate green on that tree).

**⛔ What it does not do.** It does not run `pnpm dedupe` for you, and it never edits the lockfile —
`--check` reports without writing. A finding is fixed in the pull request that caused it, by running
the command and committing the result. ⛔ Pinning a version, adding a `pnpm.overrides` entry or
widening a range are **not** acceptable ways to satisfy it: #8333 rejected all three, on the grounds
that they spend a declaration to fix a resolution artefact.

## Link Checking (`check-links.yml`)

**Trigger:** Weekly cron (`17 4 * * 0` — Sundays, off the top of the hour, when the scheduled-run
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@
"check:merge-queue-head": "node scripts/check-merge-queue-head.mjs --self-test",
"check:required-check-set": "node scripts/check-required-check-set.mjs --self-test",
"check:lockfile-integrity": "node scripts/check-lockfile-integrity.mjs",
"check:lockfile-dedupe": "node scripts/check-lockfile-dedupe.mjs",
"merge-queue-head": "node scripts/check-merge-queue-head.mjs",
"check:pre-install-import-graph": "node scripts/check-pre-install-import-graph.mjs",
"check:test-path-roots": "node scripts/check-test-path-roots.mjs",
Expand Down
Loading
Loading