ci(nx): enforce synchronized dependency updates (#1343)

* ci(nx): enforce lockstep dependency versions

* ci(deps): group Nx updates explicitly

* docs(nx): document coordinated dependency updates

* fix(nx): validate peer dependency versions

* fix(nx): validate duplicate root declarations
This commit is contained in:
4gray authored and GitHub committed 2026-08-02 12:52:37 +02:00
1 parent dac0dfdb6c
commit 011f322807
8 files changed
+366 -3

No files matched your search

+21 -3
View File
@@ -1,7 +1,8 @@
# Every Dependabot PR triggers the full pipeline (~15 jobs), so version
# updates are batched: weekly cadence, minor+patch bumps grouped into one PR
# per ecosystem, majors as individual PRs so CI gates them one by one.
# Security updates are separate and are not limited by this schedule.
# updates are batched weekly. Nx minor+patch updates use a dedicated group so
# official packages move together; other minor+patch updates are grouped per
# ecosystem, and majors remain individual. Security updates are separate and
# are not limited by this schedule; CI enforces complete Nx lockstep.
version: 2
updates:
- package-ecosystem: npm
@@ -12,7 +13,24 @@ updates:
time: '06:00'
open-pull-requests-limit: 5
groups:
nx-version-updates:
applies-to: version-updates
patterns:
- nx
- '@nx/*'
update-types:
- minor
- patch
nx-security-updates:
applies-to: security-updates
patterns:
- nx
- '@nx/*'
npm-minor-patch:
applies-to: version-updates
exclude-patterns:
- nx
- '@nx/*'
update-types:
- minor
- patch
+3
View File
@@ -147,6 +147,9 @@ jobs:
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Validate Nx dependency version policy
run: pnpm run deps:nx:validate
- name: Typecheck web and Electron entry points
run: pnpm run typecheck:ci
+6
View File
@@ -16,6 +16,12 @@ This file provides guidance to coding agents working in this repository.
- Use scoped path aliases from `tsconfig.base.json` such as `@iptvnator/services`, `@iptvnator/shared/interfaces`, and `@iptvnator/ui/components`. Do not add new imports from legacy bare aliases such as `services`, `shared-interfaces`, `components`, `m3u-state`, or `database`.
- Every Nx project should keep `scope:*`, `domain:*`, and `type:*` tags in `project.json` so `@nx/enforce-module-boundaries` remains useful for humans and agents.
- See `docs/architecture/nx-workspace-boundaries.md` for the current Nx tag and alias policy.
- Keep `nx` and every official `@nx/*` package on the same exact version; run
`pnpm run deps:nx:validate` after dependency updates.
- Update Nx with `pnpm nx migrate nx@<target> --skipInstall`, regenerate the
lockfile, run generated migrations when present, and validate before opening
a PR. Major updates are always manual. Replace incomplete Dependabot security
PRs with a coordinated update instead of editing the bot branch.
- ESLint enforces `max-lines` on TypeScript files: production code targets under 300 with a hard maximum of 400, while tests (`**/*.spec.ts`, `**/*.e2e.ts`, `apps/*-e2e/**`) are held to 1200 — a long spec signals coverage, not the design debt the production limit catches. Blank lines and comments are not counted, so a docblock never forces a split. Limits live in `tools/eslint/max-lines-config.mjs`, imported by both `eslint.config.mjs` and the generator so the rule and the baseline cannot drift. Files that predate the rule are baselined in `tools/eslint/max-lines-baseline.mjs`; after splitting a file, regenerate it with `node tools/eslint/generate-max-lines-baseline.mjs` (it runs ESLint's own rule rather than counting lines itself). Never add new files to the baseline — the list must only shrink. A new file that genuinely cannot be split (for example a function serialized into another process) instead carries its own file-wide `/* eslint-disable max-lines -- <why> */`; the generator skips those files, so a justified exemption never lands in the baseline. Remove such a directive once ESLint reports it as unused.
- Project `lint` targets that shell out to eslint must quote the glob, e.g. `eslint "apps/<project>/**/*.ts"`. An unquoted `**` is expanded by the POSIX shell on Linux and macOS (which has no `globstar`, so it matches only a shallow subset of files) while Windows passes the literal pattern to ESLint, which expands it recursively — the two hosts then lint different file sets. The target still reports success either way, so a broken glob hides missing coverage instead of failing. After changing such a target, compare the linted file count against `find <project> -name '*.ts' | wc -l`.
- Repository-specific skills live under `.codex/skills/`.
+6
View File
@@ -74,6 +74,12 @@ pnpm nx show projects
- Do not add new imports from legacy bare aliases such as `services`, `shared-interfaces`, `components`, `m3u-state`, or `database`.
- Every Nx project should keep `scope:*`, `domain:*`, and `type:*` tags in `project.json`.
- See `docs/architecture/nx-workspace-boundaries.md` for the current Nx tag and alias policy.
- Keep `nx` and every official `@nx/*` package on the same exact version; run
`pnpm run deps:nx:validate` after dependency updates.
- Update Nx with `pnpm nx migrate nx@<target> --skipInstall`, regenerate the
lockfile, run generated migrations when present, and validate before opening
a PR. Major updates are always manual. Replace incomplete Dependabot security
PRs with a coordinated update instead of editing the bot branch.
- Repository-specific skills live under `.codex/skills/`.
- Frontmatter descriptions are trigger-only and begin with `Use when`; keep
each skill at or below 500 words.
@@ -27,6 +27,29 @@ Do not invent a `test`, `build`, or `e2e` target because a similarly named
project has one. Run affected lint/test/build targets that exist and the closest
available E2E target for the changed behavior.
## Nx Dependency Updates
Keep `nx` and every official `@nx/*` package on the same exact version. Run
`pnpm run deps:nx:validate` after any manifest or lockfile update; CI runs the
same policy check and rejects both direct specifier drift and multiple resolved
Nx versions.
Dependabot groups routine minor and patch Nx updates when possible. A security
update may still contain only the vulnerable package, so replace an incomplete
Dependabot PR with a coordinated maintainer update instead of editing the bot
branch:
```bash
pnpm nx migrate nx@<target> --skipInstall
pnpm install --no-frozen-lockfile
pnpm nx migrate --run-migrations
pnpm run deps:nx:validate
```
Omit `pnpm nx migrate --run-migrations` when the first command reports that no
migrations exist. Major Nx updates always use this manual workflow and the
resulting PR runs the full CI pipeline.
## Placement Decision
- `apps/` owns runtime applications, development servers, E2E applications,
+3
View File
@@ -33,6 +33,9 @@
"test:backend": "nx test electron-backend",
"test:unit:all": "nx run-many --target=test --all --parallel=3",
"test:unit:ci": "nx run-many --target=test --all --parallel=3 --output-style=static",
"deps:nx:test": "node --test tools/dependencies/check-nx-version-sync.test.mjs",
"deps:nx:check": "node tools/dependencies/check-nx-version-sync.mjs",
"deps:nx:validate": "pnpm run deps:nx:test && pnpm run deps:nx:check",
"coverage:tools:test": "node --test tools/coverage/coverage-integrity.test.mjs",
"coverage:unit:ci": "node tools/coverage/run-tier-a-coverage.mjs",
"coverage:merge": "node tools/coverage/merge-coverage.mjs",
@@ -0,0 +1,105 @@
import { readFile } from 'node:fs/promises';
import { resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { parse } from 'yaml';
const EXACT_SEMVER = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
export function validateNxVersionSync({ packageJson, lockfile }) {
const diagnostics = [];
const expectedVersion = packageJson.devDependencies?.nx;
if (!expectedVersion) {
diagnostics.push('package.json: devDependencies.nx is required');
} else if (!EXACT_SEMVER.test(expectedVersion)) {
diagnostics.push(
`package.json: nx must use an exact semantic version, found "${expectedVersion}"`
);
}
if (!expectedVersion || !EXACT_SEMVER.test(expectedVersion)) {
return { diagnostics, expectedVersion };
}
for (const sectionName of [
'dependencies',
'devDependencies',
'optionalDependencies',
'peerDependencies',
]) {
const section = packageJson[sectionName] ?? {};
for (const [packageName, specifier] of Object.entries(section)) {
if (
(packageName === 'nx' || packageName.startsWith('@nx/')) &&
specifier !== expectedVersion
) {
diagnostics.push(
`package.json: ${sectionName}["${packageName}"] must match nx@${expectedVersion}, found "${specifier}"`
);
}
}
}
let foundExpectedNx = false;
for (const packageKey of Object.keys(lockfile.packages ?? {}).sort()) {
const nxMatch = /^nx@(.+)$/.exec(packageKey);
if (nxMatch) {
const resolvedVersion = nxMatch[1];
if (resolvedVersion === expectedVersion) {
foundExpectedNx = true;
} else {
diagnostics.push(
`pnpm-lock.yaml: resolved nx@${resolvedVersion} must match nx@${expectedVersion}`
);
}
continue;
}
const officialPackageMatch = /^(@nx\/[^@]+)@(.+)$/.exec(packageKey);
if (
officialPackageMatch &&
officialPackageMatch[2] !== expectedVersion
) {
diagnostics.push(
`pnpm-lock.yaml: resolved ${officialPackageMatch[1]}@${officialPackageMatch[2]} must match nx@${expectedVersion}`
);
}
}
if (!foundExpectedNx) {
diagnostics.push(
`pnpm-lock.yaml: expected nx@${expectedVersion} to be resolved`
);
}
return { diagnostics, expectedVersion };
}
const isMain =
process.argv[1] &&
resolve(process.argv[1]) === resolve(fileURLToPath(import.meta.url));
if (isMain) {
const rootDir = process.cwd();
const packageJson = JSON.parse(
await readFile(resolve(rootDir, 'package.json'), 'utf8')
);
const lockfile = parse(
await readFile(resolve(rootDir, 'pnpm-lock.yaml'), 'utf8')
);
const { diagnostics, expectedVersion } = validateNxVersionSync({
packageJson,
lockfile,
});
if (diagnostics.length > 0) {
console.error('Nx dependency version policy failed:');
for (const diagnostic of diagnostics) console.error(`- ${diagnostic}`);
process.exitCode = 1;
} else {
console.log(
`Nx dependency versions are synchronized at ${expectedVersion}.`
);
}
}
@@ -0,0 +1,199 @@
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import test from 'node:test';
import { fileURLToPath } from 'node:url';
import { stringify } from 'yaml';
const validatorPath = fileURLToPath(
new URL('./check-nx-version-sync.mjs', import.meta.url)
);
const alignedPackageJson = {
devDependencies: {
'@nx/angular': '22.7.1',
nx: '22.7.1',
'nx-electron': '22.0.0',
},
};
const alignedPackages = {
'@nx/angular@22.7.1': {},
'nx@22.7.1': {},
'nx-electron@22.0.0': {},
};
async function createFixture(t, { packageJson, packages }) {
const rootDir = await mkdtemp(join(tmpdir(), 'nx-version-sync-'));
t.after(() => rm(rootDir, { recursive: true, force: true }));
await writeFile(
join(rootDir, 'package.json'),
`${JSON.stringify(packageJson, null, 2)}\n`
);
await writeFile(
join(rootDir, 'pnpm-lock.yaml'),
stringify({ lockfileVersion: '9.0', packages })
);
return rootDir;
}
function runValidator(rootDir) {
return spawnSync(process.execPath, [validatorPath], {
cwd: rootDir,
encoding: 'utf8',
});
}
test('accepts aligned official Nx packages and ignores nx-electron', async (t) => {
const rootDir = await createFixture(t, {
packageJson: alignedPackageJson,
packages: alignedPackages,
});
const result = runValidator(rootDir);
assert.equal(result.status, 0, result.stderr);
assert.match(
result.stdout,
/Nx dependency versions are synchronized at 22\.7\.1\./
);
});
test('rejects a direct official Nx package mismatch', async (t) => {
const rootDir = await createFixture(t, {
packageJson: {
devDependencies: {
'@nx/angular': '22.7.1',
nx: '22.7.2',
},
},
packages: {
'@nx/angular@22.7.1': {},
'nx@22.7.2': {},
},
});
const result = runValidator(rootDir);
assert.equal(result.status, 1);
assert.match(
result.stderr,
/devDependencies\["@nx\/angular"\] must match nx@22\.7\.2, found "22\.7\.1"/
);
});
test('rejects a direct official Nx peer dependency mismatch', async (t) => {
const rootDir = await createFixture(t, {
packageJson: {
devDependencies: {
nx: '22.7.2',
},
peerDependencies: {
'@nx/angular': '22.7.1',
},
},
packages: {
'@nx/angular@22.7.2': {},
'nx@22.7.2': {},
},
});
const result = runValidator(rootDir);
assert.equal(result.status, 1);
assert.match(
result.stderr,
/peerDependencies\["@nx\/angular"\] must match nx@22\.7\.2, found "22\.7\.1"/
);
});
test('rejects an extra direct Nx declaration mismatch', async (t) => {
const rootDir = await createFixture(t, {
packageJson: {
devDependencies: {
nx: '22.7.2',
},
optionalDependencies: {
nx: '22.7.1',
},
},
packages: {
'nx@22.7.2': {},
},
});
const result = runValidator(rootDir);
assert.equal(result.status, 1);
assert.match(
result.stderr,
/optionalDependencies\["nx"\] must match nx@22\.7\.2, found "22\.7\.1"/
);
});
test('rejects a non-exact root Nx version', async (t) => {
const rootDir = await createFixture(t, {
packageJson: {
devDependencies: {
'@nx/angular': '^22.7.1',
nx: '^22.7.1',
},
},
packages: alignedPackages,
});
const result = runValidator(rootDir);
assert.equal(result.status, 1);
assert.match(
result.stderr,
/nx must use an exact semantic version, found "\^22\.7\.1"/
);
});
test('rejects an extra resolved Nx version', async (t) => {
const rootDir = await createFixture(t, {
packageJson: {
devDependencies: {
'@nx/angular': '22.7.2',
nx: '22.7.2',
},
},
packages: {
'@nx/angular@22.7.2': {},
'nx@22.7.1': {},
'nx@22.7.2': {},
},
});
const result = runValidator(rootDir);
assert.equal(result.status, 1);
assert.match(result.stderr, /resolved nx@22\.7\.1 must match nx@22\.7\.2/);
});
test('rejects a transitive official Nx lockfile mismatch', async (t) => {
const rootDir = await createFixture(t, {
packageJson: {
devDependencies: {
'@nx/angular': '22.7.2',
nx: '22.7.2',
},
},
packages: {
'@nx/angular@22.7.1': {},
'nx@22.7.2': {},
},
});
const result = runValidator(rootDir);
assert.equal(result.status, 1);
assert.match(
result.stderr,
/resolved @nx\/angular@22\.7\.1 must match nx@22\.7\.2/
);
});