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
7 changes: 7 additions & 0 deletions .changeset/lockfile-integrity-gate.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
---

Internal tooling only: a new CI lockfile-integrity gate (`scripts/check-lockfile-integrity.mjs`),
its workflow, its test suite and its `ci-cd-pipeline.md` section. No published package source, no
publish-contract field and no `apps/console` executable source is touched, so nothing here changes
any released artifact — declared explicitly rather than left to inference (objectui#8326).
89 changes: 89 additions & 0 deletions .github/workflows/lockfile-integrity.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
name: Lockfile Integrity

# objectui#8326 — a pull request's `pnpm-lock.yaml` may not silently DUPLICATE a
# dependency the workspace declares, or move an `@objectstack/*` identity
# BACKWARD. The mechanism, both incident causes and every boundary this gate
# keeps are documented at length in `scripts/check-lockfile-integrity.mjs`.
#
# ── Why it is its own workflow ───────────────────────────────────────────────
#
# It is the only gate in this repository that needs TWO revisions of a file: the
# pull request's lockfile and its merge base's. That is why it checks out with
# `fetch-depth: 0` and fetches the base branch, which no other gate here pays
# for. Keeping it separate keeps that cost off every other job.
#
# ── Why it IS path-filtered, unlike shell-escape-residue.yml and friends ─────
#
# Those gates read the whole tree, so any change can trip them and a path filter
# would build the hole they exist to close. This one reads exactly one file and
# compares it with its own base: a pull request that does not touch
# `pnpm-lock.yaml` cannot produce a finding, by construction — the head and base
# blobs are then the same bytes. So the filter costs no coverage.
#
# ⚠️ The filter is also what makes it UNREQUIRABLE: `#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.
#
# ── ⛔ Deliberately NOT enrolled as a blocking context ────────────────────────
#
# `scripts/dependabot-merge-gate.mjs` classifies `Lockfile Integrity Check` in
# `NOT_A_GATE`, so a red here does not stop a Dependabot auto-merge and does not
# block the merge queue. That is a MAINTAINER decision with its own cost, and
# objectui#8326's dispatch reserved it: what enrolling would take, and what it
# would have cost over the last 40 lockfile-changing commits on `main`, is
# written up in that card's pull request as input. ⛔ Do not promote this to
# `REQUIRED_CONTEXTS` or `OPTIONAL_CONTEXTS` without that decision.
#
# Until then it is an ALARM: it names the packages, in one line, on the pull
# request where the change is. That alone is the whole of what the incident
# needed — two seats each spent a full diagnosis discovering that a
# `Bundle Analysis` red was not about the dependency being bumped.

on:
pull_request:
branches: [main, develop]
paths:
- 'pnpm-lock.yaml'
- 'scripts/check-lockfile-integrity.mjs'
- '.github/workflows/lockfile-integrity.yml'
workflow_dispatch:

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

permissions:
contents: read

jobs:
lockfile-integrity:
name: Lockfile Integrity Check
runs-on: ubuntu-latest
timeout-minutes: 5

steps:
# `fetch-depth: 0` because the gate's subject is a DELTA. A shallow
# checkout has no merge base, and the script refuses to guess one — it
# exits 2 (could not take a reading), which is never a pass.
- name: Checkout code
uses: actions/checkout@v7
with:
fetch-depth: 0

- name: Setup Node.js
uses: actions/setup-node@v7
with:
node-version: '22.x'

# No install and no build: the gate is one `node` call over two text
# files, builtins plus one repo-relative module only, which
# `pre-install-import-graph.yml` enforces (objectui#6148).
- name: Fetch the base branch so the merge base exists
env:
BASE_REF: ${{ github.base_ref || 'main' }}
run: git fetch --no-tags origin "+refs/heads/${BASE_REF}:refs/remotes/origin/${BASE_REF}"

- name: Compare this lockfile with its merge base
env:
BASE_REF: ${{ github.base_ref || 'main' }}
run: node scripts/check-lockfile-integrity.mjs --base-ref "origin/${BASE_REF}"
49 changes: 49 additions & 0 deletions content/docs/guide/ci-cd-pipeline.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ one has its own section below.
| `docs-route-eager-closure.yml` | Docs Route Eager Closure Check | Push / PR to `main`, `develop` — **no path filter**; merge-queue builds; manual | **Yes** — when a package named in `apps/site/app/components/registerCatalogBlocks.ts` is not already reachable from the docs route's module graph (exit 1), or when the gate's own gauge cannot be trusted (exit 2) |
| `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; 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` |
| `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 @@ -1472,6 +1473,54 @@ workflow runs as its own first step because a rotted predicate must redden rathe
governed diff through, and the wiring is pinned by
`scripts/__tests__/check-governed-queue-guard.test.ts`.

## Lockfile Integrity (`lockfile-integrity.yml`)

**Triggers:** Pull requests to `main`/`develop` that touch `pnpm-lock.yaml`,
`scripts/check-lockfile-integrity.mjs` or the workflow file itself, plus manual dispatch. It appears
in the checks list as **Lockfile Integrity Check**.

Runs `scripts/check-lockfile-integrity.mjs`, which compares the pull request's `pnpm-lock.yaml`
against the **merge base's** and reports two things:

1. an `@objectstack/*` identity resolving to a version **lower** than the base resolved;
2. a package the workspace declares at runtime (`dependencies`, `peerDependencies`,
`optionalDependencies`) resolving to **more physical copies** than the base did — where a copy is
a `snapshots:` key, peer suffix included, because that is the directory pnpm creates.

It is the only gate here that needs two revisions of a file, which is why it checks out with
`fetch-depth: 0`. Exit `0` clean, `1` findings, `2` **could not take a reading** — a shallow checkout
or a lockfile whose format moved exits 2 rather than reporting a clean lockfile it never read.

**Why it needed a gate.** In [#8326](https://github.com/objectstack-ai/objectui/issues/8326)
Dependabot's regenerated lockfile on three pull requests at once downgraded the whole
`@objectstack/*` family 17.3.0 → 17.2.0 and forked zod, 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. What that produced was a **red `Bundle Analysis` on a dependency bump
for a reason that had nothing to do with the dependency** — and the obvious reading, "this bump bloats
the bundle", was wrong. Two seats each spent a full diagnosis reaching the same answer independently.
Every workspace range on the family is a floating `^17.x` against a registry whose latest is 17.3.0,
so no fresh resolve can produce 17.2.0: it was a stale resolution carried across a rebase.

**Why one rule was not enough.** [#8333](https://github.com/objectstack-ai/objectui/issues/8333)
measured the other cause: floating `better-auth` to 1.7.3 from a **clean** base forks zod while moving
**no `@objectstack/*` identity at all**, and re-measuring it on `da5e4f69e` shows `@objectstack/spec`
staying at one version and still landing as **two directories**, one per peer resolution. A gate that
only asked "did an identity move backward", or that counted versions rather than snapshot keys, is
green on that.

**⛔ What this gate does not do.** It does not pin, dedupe or re-resolve anything — the remedy for a
finding is a decision the pull request makes. It is a **ratchet, not an audit**: a lockfile that is
already duplicated stays green while the change does not make it worse. It does not judge backward
moves outside `@objectstack/*` (third-party downgrades are legitimate) or duplications of packages
this workspace does not itself declare at runtime — measured, scoping it wider reds on ordinary merged
build-tooling churn. And it says nothing about bytes; `Bundle Analysis` measures those.

**⛔ It cannot block anything today**, by design. `scripts/dependabot-merge-gate.mjs` classifies it in
`NOT_A_GATE`, and its `pull_request` trigger is path-filtered, which under
[#3523](https://github.com/objectstack-ai/objectui/issues/3523)'s rule makes it unrequirable while
that filter stands. Enrolling it is a maintainer decision with its own cost, written up on #8326's
pull request as input.

## 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 @@ -78,6 +78,7 @@
"check:governed-queue-guard": "node scripts/check-governed-queue-guard.mjs --self-test",
"governed": "node scripts/check-governed-queue-guard.mjs --test",
"check:merge-queue-head": "node scripts/check-merge-queue-head.mjs --self-test",
"check:lockfile-integrity": "node scripts/check-lockfile-integrity.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:vi-mock-specifiers": "node scripts/check-vi-mock-specifiers.mjs",
Expand Down
219 changes: 219 additions & 0 deletions scripts/__tests__/check-lockfile-integrity.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,219 @@
/**
* objectui#8326 — the lockfile-integrity gate, pinned against the two causes it
* was built for and against the three ways it could be worthless.
*
* The gate's own logic is exercised by `--self-test` inside the script (29
* cases, synthetic lockfiles, no git). This file adds the three things a
* synthetic corpus cannot say:
*
* 1. it is GREEN on this repository's real `pnpm-lock.yaml`;
* 2. it is RED on the two real incident samples, built from that same real
* lockfile by a transformation this file asserts actually landed;
* 3. it is wired — the workflow runs it, and the Dependabot merge gate
* classifies the check it produces.
*
* ⚠️ (2) matters more than it looks. A gate that is red on everything passes
* both samples and is worthless, so every red assertion here is paired with a
* green one over the same corpus.
*/
import { execFileSync } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import { describe, expect, it } from 'vitest';

import { readWorkflows, repoRoot } from './workflow-checks';
import { NOT_A_GATE, OPTIONAL_CONTEXTS, REQUIRED_CONTEXTS } from '../dependabot-merge-gate.mjs';
import {
compareLockfiles,
indexLockfile,
readDeclaredNames,
readSnapshotKeys,
} from '../check-lockfile-integrity.mjs';

const GATE = path.join(repoRoot, 'scripts/check-lockfile-integrity.mjs');
const LOCKFILE = path.join(repoRoot, 'pnpm-lock.yaml');
const realLock = fs.readFileSync(LOCKFILE, 'utf8');

/** The check-run name the workflow's job produces. */
const CHECK_NAME = 'Lockfile Integrity Check';

describe('the gate runs its own self-test', () => {
it('exits 0 with every case passing', () => {
const out = execFileSync('node', [GATE, '--self-test'], { encoding: 'utf8' });
expect(out).toMatch(/^✓ check-lockfile-integrity self-test: \d+ cases pass/m);
});
});

describe('the real lockfile parses, and parses to something', () => {
// Anti-vacuity, the failure mode this repository keeps re-teaching: every
// assertion below is a comparison over these sets, and an empty set compares
// clean no matter what the change did.
const keys = readSnapshotKeys(realLock);
const declared = readDeclaredNames(realLock);

it('reads a four-figure resolution set out of pnpm-lock.yaml', () => {
expect(keys.length).toBeGreaterThan(1000);
});

it('reads the workspace-declared runtime names out of the importers section', () => {
expect(declared.size).toBeGreaterThan(50);
// The two packages the incident is about, both declared by real packages:
// `zod` by packages/types, app-shell and plugin-timeline; the spec by most
// of the workspace.
expect(declared).toContain('zod');
expect(declared).toContain('@objectstack/spec');
// ⛔ And a devDependency-only name is NOT in scope — the exclusion that
// keeps the gate off ordinary build-tooling churn (`90bc5d16a`).
expect(declared).not.toContain('vitest');
});
});

describe('green on main — the negative control', () => {
it('is clean against itself', () => {
const idx = indexLockfile(realLock, 'main');
expect(compareLockfiles(idx, idx).findings).toEqual([]);
});

it('is clean against its own merge base in a real run', () => {
// Exercises the git path (`--base-ref`), not just the pure functions.
const out = execFileSync('node', [GATE, '--base-ref', 'origin/main'], {
encoding: 'utf8',
cwd: repoRoot,
});
expect(out).toContain('VERDICT clean');
});
});

/**
* Sample (a) — objectui#8326's own scenario.
*
* ⛔ It cannot be produced by running the resolver, and that impossibility is
* the card's central claim: every workspace range on `@objectstack/*` is a
* floating `^17.x` and the registry's latest is 17.3.0, so no fresh resolve can
* pick 17.2.0. The sample is therefore a transformation of the real lockfile
* reproducing the identity facts measured on PRs #7053 / #7058 — the family
* down to 17.2.0, and `@objectstack/spec` resolved at BOTH versions.
*/
function sampleA(): string {
let out = realLock;
for (const pkg of ['client', 'core', 'formula', 'lint', 'sdui-parser']) {
out = out.replace(new RegExp(`(@objectstack/${pkg}@)17\\.3\\.0`, 'g'), '$117.2.0');
}
const specKey = " '@objectstack/spec@17.3.0(ai@7.0.65(zod@4.4.3))':";
expect(out, 'the spec snapshot key moved — rebuild this sample before trusting it').toContain(
specKey,
);
return out.replace(
specKey,
" '@objectstack/spec@17.2.0(ai@7.0.65(zod@4.4.3))':\n dependencies:\n zod: 4.4.3\n" +
specKey,
);
}

/**
* Sample (b) — objectui#8333, re-measured on `da5e4f69e` on 2026-09-08 by
* `pnpm update better-auth --lockfile-only --recursive`, which resolved 1.7.3
* and produced exactly this: the better-auth family forward, `zod@4.5.4` added,
* and every `@objectstack/*` package that sits under `ai` gaining a SECOND peer
* context at the SAME version.
*
* ⭐ That last part is the discriminator. No `@objectstack/*` version moves, so
* a gate that only asks "did an identity move backward" is green here.
*/
function sampleB(): string {
let out = realLock;
const forked = [
" '@objectstack/spec@17.3.0(ai@7.0.65(zod@4.4.3))':",
" '@objectstack/formula@17.3.0(ai@7.0.65(zod@4.4.3))':",
" ai@7.0.65(zod@4.4.3):",
];
for (const key of forked) {
expect(out, `snapshot key moved: ${key}`).toContain(key);
out = out.replace(key, `${key.replace('zod@4.4.3', 'zod@4.5.4')}\n dependencies: {}\n${key}`);
}
const zodKey = ' zod@4.4.3: {}';
expect(out).toContain(zodKey);
return out.replace(zodKey, `${zodKey}\n zod@4.5.4: {}`);
}

describe('cause 1 — an @objectstack/* identity moves backward (objectui#8326)', () => {
const reading = compareLockfiles(indexLockfile(realLock, 'base'), indexLockfile(sampleA(), 'a'));

it('the sample really differs from the base (the mutation landed)', () => {
expect(sampleA()).not.toEqual(realLock);
expect(sampleA()).toContain('@objectstack/client@17.2.0');
});

it('goes red, naming every family member that moved', () => {
const names = reading.backward.map((f) => f.name);
expect(names).toEqual(
expect.arrayContaining([
'@objectstack/client',
'@objectstack/core',
'@objectstack/formula',
'@objectstack/lint',
'@objectstack/sdui-parser',
]),
);
});

it('also names the duplication the downgrade created', () => {
expect(reading.duplicated.map((f) => f.name)).toContain('@objectstack/spec');
});
});

describe('cause 2 — a declared dependency forks with no version moving (objectui#8333)', () => {
const reading = compareLockfiles(indexLockfile(realLock, 'base'), indexLockfile(sampleB(), 'b'));

it('the sample really differs from the base (the mutation landed)', () => {
expect(sampleB()).not.toEqual(realLock);
expect(sampleB()).toContain('zod@4.5.4');
});

it('⭐ moves no @objectstack/* identity at all — so rule 1 is blind to it', () => {
expect(reading.backward).toEqual([]);
});

it('⭐ still goes red: zod gained a copy, though it was never single-copy', () => {
const zod = reading.duplicated.find((f) => f.name === 'zod');
expect(zod, 'the gate must not be blind to objectui#8333').toBeTruthy();
// ⛔ 2 -> 3, not 1 -> 2. Both cards say "forks a single-copy dependency";
// measured on `main`, zod is ALREADY two copies (3.25.76 + 4.4.3), so a
// rule keyed on "was one, is now two" would be green here.
expect(zod?.baseCount).toBe(2);
expect(zod?.headCount).toBe(3);
});

it('⭐ names the second physical @objectstack/spec, at one unchanged version', () => {
const spec = reading.duplicated.find((f) => f.name === '@objectstack/spec');
expect(spec?.headCount).toBe(2);
// This is #8326's exact bundling mechanism — two real paths, nothing
// dedupes them — arriving with no version change anywhere.
expect(spec?.head.every((k) => k.includes('@17.3.0'))).toBe(true);
});
});

describe('the gate is wired, and wired the way the dispatch reserved', () => {
const workflow = readWorkflows().find((w) => w.file === 'lockfile-integrity.yml');

it('exists and runs the gate', () => {
expect(workflow, '.github/workflows/lockfile-integrity.yml is missing').toBeTruthy();
expect(workflow?.text).toContain('node scripts/check-lockfile-integrity.mjs');
});

it('checks out deep enough to have a merge base', () => {
// A shallow checkout has no merge base and the script exits 2 rather than
// guessing — which would be a check that reports nothing and looks fine.
expect(workflow?.text).toContain('fetch-depth: 0');
});

it('⛔ is classified as NOT a blocking context (the maintainer floor)', () => {
expect(Object.keys(NOT_A_GATE)).toContain(CHECK_NAME);
expect(REQUIRED_CONTEXTS).not.toContain(CHECK_NAME);
expect(Object.keys(OPTIONAL_CONTEXTS)).not.toContain(CHECK_NAME);
});

it('states in its own classification why enrolling it is not this card', () => {
expect(NOT_A_GATE[CHECK_NAME]).toMatch(/maintainer decision/);
});
});
Loading
Loading