From 20e454c2d463e2b6bcfdf300c67768a8e5973b37 Mon Sep 17 00:00:00 2001 From: Yiming Li Date: Thu, 14 May 2026 11:20:14 +0800 Subject: [PATCH 1/3] ci: detect package.json dep/peerDep changes without a matching changeset MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add `.github/scripts/check-dep-changes-have-changeset.cjs` that compares the changeset status report against the set of package.json files whose `dependencies` or `peerDependencies` actually changed in the PR. Any non-private package with a dep/peer-dep diff but no changeset bump is reported with an actionable message and the script exits non-zero. Hook it into the existing `code-style-check` job after the other changeset checks, and run the script's own unit tests in the same step. Verified locally: re-running this check against the state of PR #2423 (where `react-refresh-webpack-plugin/package.json` had its peer range widened but the changeset bumped only three other packages) reproduces the catch — the script flags `@lynx-js/react-refresh-webpack-plugin` and fails. With this check in place, the omission would have been surfaced at review time. --- .../check-dep-changes-have-changeset.cjs | 132 +++++++++++++++ .../check-dep-changes-have-changeset.test.cjs | 158 ++++++++++++++++++ .github/workflows/test.yml | 4 + 3 files changed, 294 insertions(+) create mode 100644 .github/scripts/check-dep-changes-have-changeset.cjs create mode 100644 .github/scripts/check-dep-changes-have-changeset.test.cjs diff --git a/.github/scripts/check-dep-changes-have-changeset.cjs b/.github/scripts/check-dep-changes-have-changeset.cjs new file mode 100644 index 0000000000..92df3330bf --- /dev/null +++ b/.github/scripts/check-dep-changes-have-changeset.cjs @@ -0,0 +1,132 @@ +#!/usr/bin/env node + +// Copyright 2026 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. +const { execFileSync } = require('node:child_process'); +const { readFileSync } = require('node:fs'); + +function gitShow(ref, path) { + try { + return execFileSync('git', ['show', `${ref}:${path}`], { + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'pipe'], + }); + } catch { + return null; + } +} + +function changedPackageJsons(baseRef) { + const out = execFileSync( + 'git', + ['diff', '--name-only', `${baseRef}...HEAD`], + { encoding: 'utf8' }, + ); + return out + .split('\n') + .filter((p) => p === 'package.json' || p.endsWith('/package.json')); +} + +function isShallowEqual(a, b) { + const ka = Object.keys(a ?? {}); + const kb = Object.keys(b ?? {}); + if (ka.length !== kb.length) return false; + for (const k of ka) { + if (a?.[k] !== b?.[k]) return false; + } + return true; +} + +function findMissingChangesets({ + releases, + changedFiles, + readCurrent, + readBase, +}) { + const willBump = new Set(releases.map((r) => r.name)); + const missing = []; + for (const file of changedFiles) { + const cur = readCurrent(file); + const base = readBase(file); + if (cur === null || base === null) continue; + let curPkg; + let basePkg; + try { + curPkg = JSON.parse(cur); + basePkg = JSON.parse(base); + } catch { + continue; + } + if (curPkg.private) continue; + if (!curPkg.name) continue; + const depsChanged = !isShallowEqual( + curPkg.dependencies, + basePkg.dependencies, + ); + const peerChanged = !isShallowEqual( + curPkg.peerDependencies, + basePkg.peerDependencies, + ); + if ((depsChanged || peerChanged) && !willBump.has(curPkg.name)) { + missing.push({ + name: curPkg.name, + path: file, + deps: depsChanged, + peer: peerChanged, + }); + } + } + return missing; +} + +module.exports = { findMissingChangesets, isShallowEqual }; + +function main() { + const statusFile = process.argv[2] || '.changeset-status.json'; + const baseRef = process.argv[3] || process.env.BASE_REF || 'origin/main'; + + const status = JSON.parse(readFileSync(statusFile, 'utf8')); + const releases = Array.isArray(status.releases) ? status.releases : []; + + const missing = findMissingChangesets({ + releases, + changedFiles: changedPackageJsons(baseRef), + readCurrent: (f) => { + try { + return readFileSync(f, 'utf8'); + } catch { + return null; + } + }, + readBase: (f) => gitShow(baseRef, f), + }); + + if (missing.length === 0) { + process.stdout.write( + 'All package.json dependency/peerDependency changes have a matching changeset.\n', + ); + return; + } + + process.stderr.write( + '\nThe following packages changed `dependencies` or `peerDependencies` but no changeset bumps them:\n\n', + ); + for (const m of missing) { + const what = [m.deps && 'dependencies', m.peer && 'peerDependencies'] + .filter(Boolean) + .join(' + '); + process.stderr.write(` - ${m.name} [${what}]\n ${m.path}\n`); + } + process.stderr.write( + '\nAdd a changeset (e.g. `pnpm changeset add`) bumping each affected package so the dependency change actually ships in the next release.\n', + ); + + throw new Error( + `${missing.length} package(s) with dep changes lack a changeset.`, + ); +} + +if (require.main === module) { + main(); +} diff --git a/.github/scripts/check-dep-changes-have-changeset.test.cjs b/.github/scripts/check-dep-changes-have-changeset.test.cjs new file mode 100644 index 0000000000..556bfa3224 --- /dev/null +++ b/.github/scripts/check-dep-changes-have-changeset.test.cjs @@ -0,0 +1,158 @@ +#!/usr/bin/env node + +// Copyright 2026 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. +const assert = require('node:assert/strict'); +const test = require('node:test'); + +const { findMissingChangesets, isShallowEqual } = require( + './check-dep-changes-have-changeset.cjs', +); + +function fakeReader(files) { + return (path) => (path in files ? files[path] : null); +} + +test('passes when no package.json changed', () => { + const out = findMissingChangesets({ + releases: [], + changedFiles: [], + readCurrent: () => null, + readBase: () => null, + }); + assert.deepEqual(out, []); +}); + +test('flags `dependencies` change with no matching changeset', () => { + const cur = JSON.stringify({ name: 'foo', dependencies: { a: '1.0.0' } }); + const base = JSON.stringify({ name: 'foo', dependencies: {} }); + const out = findMissingChangesets({ + releases: [], + changedFiles: ['packages/foo/package.json'], + readCurrent: fakeReader({ 'packages/foo/package.json': cur }), + readBase: fakeReader({ 'packages/foo/package.json': base }), + }); + assert.equal(out.length, 1); + assert.equal(out[0].name, 'foo'); + assert.equal(out[0].deps, true); + assert.equal(out[0].peer, false); +}); + +test('flags `peerDependencies` change with no matching changeset', () => { + const cur = JSON.stringify({ + name: 'foo', + peerDependencies: { p: '^1.0.0 || ^2.0.0' }, + }); + const base = JSON.stringify({ + name: 'foo', + peerDependencies: { p: '^1.0.0' }, + }); + const out = findMissingChangesets({ + releases: [], + changedFiles: ['packages/foo/package.json'], + readCurrent: fakeReader({ 'packages/foo/package.json': cur }), + readBase: fakeReader({ 'packages/foo/package.json': base }), + }); + assert.equal(out.length, 1); + assert.equal(out[0].peer, true); + assert.equal(out[0].deps, false); +}); + +test('passes when dep change is covered by a changeset', () => { + const cur = JSON.stringify({ name: 'foo', dependencies: { a: '1.0.0' } }); + const base = JSON.stringify({ name: 'foo', dependencies: {} }); + const out = findMissingChangesets({ + releases: [{ name: 'foo', type: 'patch' }], + changedFiles: ['packages/foo/package.json'], + readCurrent: fakeReader({ 'packages/foo/package.json': cur }), + readBase: fakeReader({ 'packages/foo/package.json': base }), + }); + assert.deepEqual(out, []); +}); + +test('ignores private packages', () => { + const cur = JSON.stringify({ + name: 'foo', + private: true, + dependencies: { a: '1' }, + }); + const base = JSON.stringify({ + name: 'foo', + private: true, + dependencies: {}, + }); + const out = findMissingChangesets({ + releases: [], + changedFiles: ['packages/foo/package.json'], + readCurrent: fakeReader({ 'packages/foo/package.json': cur }), + readBase: fakeReader({ 'packages/foo/package.json': base }), + }); + assert.deepEqual(out, []); +}); + +test('ignores `devDependencies`-only changes', () => { + const cur = JSON.stringify({ name: 'foo', devDependencies: { a: '2' } }); + const base = JSON.stringify({ name: 'foo', devDependencies: { a: '1' } }); + const out = findMissingChangesets({ + releases: [], + changedFiles: ['packages/foo/package.json'], + readCurrent: fakeReader({ 'packages/foo/package.json': cur }), + readBase: fakeReader({ 'packages/foo/package.json': base }), + }); + assert.deepEqual(out, []); +}); + +test('ignores newly added package.json files', () => { + const cur = JSON.stringify({ name: 'foo', dependencies: { a: '1' } }); + const out = findMissingChangesets({ + releases: [], + changedFiles: ['packages/foo/package.json'], + readCurrent: fakeReader({ 'packages/foo/package.json': cur }), + readBase: fakeReader({}), + }); + assert.deepEqual(out, []); +}); + +test('flags multiple packages independently', () => { + const out = findMissingChangesets({ + releases: [{ name: 'foo', type: 'patch' }], + changedFiles: ['packages/foo/package.json', 'packages/bar/package.json'], + readCurrent: fakeReader({ + 'packages/foo/package.json': JSON.stringify({ + name: 'foo', + dependencies: { a: '1' }, + }), + 'packages/bar/package.json': JSON.stringify({ + name: 'bar', + peerDependencies: { p: '^2' }, + }), + }), + readBase: fakeReader({ + 'packages/foo/package.json': JSON.stringify({ + name: 'foo', + dependencies: {}, + }), + 'packages/bar/package.json': JSON.stringify({ + name: 'bar', + peerDependencies: { p: '^1' }, + }), + }), + }); + assert.equal(out.length, 1); + assert.equal(out[0].name, 'bar'); +}); + +test('isShallowEqual treats reordered keys as equal', () => { + assert.equal( + isShallowEqual({ a: '1', b: '2' }, { b: '2', a: '1' }), + true, + ); +}); + +test('isShallowEqual treats different version ranges as unequal', () => { + assert.equal( + isShallowEqual({ a: '^1.0.0' }, { a: '^2.0.0' }), + false, + ); +}); diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index ecc4a9a83d..c5e4251e3a 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -61,6 +61,10 @@ jobs: run: node .github/scripts/check-no-major-changeset.cjs .changeset-status.json - name: Changeset Heading Check run: node .github/scripts/check-no-heading-changeset.cjs .changeset-status.json + - name: Dependency Changeset Check + run: node .github/scripts/check-dep-changes-have-changeset.cjs .changeset-status.json + - name: Dependency Changeset Script Tests + run: node --test .github/scripts/check-dep-changes-have-changeset.test.cjs eslint: needs: build From 89fee0d85c42baf2233c0e474621eca0e87aa63b Mon Sep 17 00:00:00 2001 From: Yiming Li Date: Thu, 14 May 2026 11:21:51 +0800 Subject: [PATCH 2/3] ci: drop standalone unit-test step for dep-changeset script --- .github/workflows/test.yml | 2 -- 1 file changed, 2 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index c5e4251e3a..f23e07fcbb 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -63,8 +63,6 @@ jobs: run: node .github/scripts/check-no-heading-changeset.cjs .changeset-status.json - name: Dependency Changeset Check run: node .github/scripts/check-dep-changes-have-changeset.cjs .changeset-status.json - - name: Dependency Changeset Script Tests - run: node --test .github/scripts/check-dep-changes-have-changeset.test.cjs eslint: needs: build From e43771262f14993f94c81d367330c387530ff07e Mon Sep 17 00:00:00 2001 From: Yiming Li Date: Thu, 14 May 2026 11:22:24 +0800 Subject: [PATCH 3/3] chore: remove dep-changeset-check unit test file --- .../check-dep-changes-have-changeset.test.cjs | 158 ------------------ 1 file changed, 158 deletions(-) delete mode 100644 .github/scripts/check-dep-changes-have-changeset.test.cjs diff --git a/.github/scripts/check-dep-changes-have-changeset.test.cjs b/.github/scripts/check-dep-changes-have-changeset.test.cjs deleted file mode 100644 index 556bfa3224..0000000000 --- a/.github/scripts/check-dep-changes-have-changeset.test.cjs +++ /dev/null @@ -1,158 +0,0 @@ -#!/usr/bin/env node - -// Copyright 2026 The Lynx Authors. All rights reserved. -// Licensed under the Apache License Version 2.0 that can be found in the -// LICENSE file in the root directory of this source tree. -const assert = require('node:assert/strict'); -const test = require('node:test'); - -const { findMissingChangesets, isShallowEqual } = require( - './check-dep-changes-have-changeset.cjs', -); - -function fakeReader(files) { - return (path) => (path in files ? files[path] : null); -} - -test('passes when no package.json changed', () => { - const out = findMissingChangesets({ - releases: [], - changedFiles: [], - readCurrent: () => null, - readBase: () => null, - }); - assert.deepEqual(out, []); -}); - -test('flags `dependencies` change with no matching changeset', () => { - const cur = JSON.stringify({ name: 'foo', dependencies: { a: '1.0.0' } }); - const base = JSON.stringify({ name: 'foo', dependencies: {} }); - const out = findMissingChangesets({ - releases: [], - changedFiles: ['packages/foo/package.json'], - readCurrent: fakeReader({ 'packages/foo/package.json': cur }), - readBase: fakeReader({ 'packages/foo/package.json': base }), - }); - assert.equal(out.length, 1); - assert.equal(out[0].name, 'foo'); - assert.equal(out[0].deps, true); - assert.equal(out[0].peer, false); -}); - -test('flags `peerDependencies` change with no matching changeset', () => { - const cur = JSON.stringify({ - name: 'foo', - peerDependencies: { p: '^1.0.0 || ^2.0.0' }, - }); - const base = JSON.stringify({ - name: 'foo', - peerDependencies: { p: '^1.0.0' }, - }); - const out = findMissingChangesets({ - releases: [], - changedFiles: ['packages/foo/package.json'], - readCurrent: fakeReader({ 'packages/foo/package.json': cur }), - readBase: fakeReader({ 'packages/foo/package.json': base }), - }); - assert.equal(out.length, 1); - assert.equal(out[0].peer, true); - assert.equal(out[0].deps, false); -}); - -test('passes when dep change is covered by a changeset', () => { - const cur = JSON.stringify({ name: 'foo', dependencies: { a: '1.0.0' } }); - const base = JSON.stringify({ name: 'foo', dependencies: {} }); - const out = findMissingChangesets({ - releases: [{ name: 'foo', type: 'patch' }], - changedFiles: ['packages/foo/package.json'], - readCurrent: fakeReader({ 'packages/foo/package.json': cur }), - readBase: fakeReader({ 'packages/foo/package.json': base }), - }); - assert.deepEqual(out, []); -}); - -test('ignores private packages', () => { - const cur = JSON.stringify({ - name: 'foo', - private: true, - dependencies: { a: '1' }, - }); - const base = JSON.stringify({ - name: 'foo', - private: true, - dependencies: {}, - }); - const out = findMissingChangesets({ - releases: [], - changedFiles: ['packages/foo/package.json'], - readCurrent: fakeReader({ 'packages/foo/package.json': cur }), - readBase: fakeReader({ 'packages/foo/package.json': base }), - }); - assert.deepEqual(out, []); -}); - -test('ignores `devDependencies`-only changes', () => { - const cur = JSON.stringify({ name: 'foo', devDependencies: { a: '2' } }); - const base = JSON.stringify({ name: 'foo', devDependencies: { a: '1' } }); - const out = findMissingChangesets({ - releases: [], - changedFiles: ['packages/foo/package.json'], - readCurrent: fakeReader({ 'packages/foo/package.json': cur }), - readBase: fakeReader({ 'packages/foo/package.json': base }), - }); - assert.deepEqual(out, []); -}); - -test('ignores newly added package.json files', () => { - const cur = JSON.stringify({ name: 'foo', dependencies: { a: '1' } }); - const out = findMissingChangesets({ - releases: [], - changedFiles: ['packages/foo/package.json'], - readCurrent: fakeReader({ 'packages/foo/package.json': cur }), - readBase: fakeReader({}), - }); - assert.deepEqual(out, []); -}); - -test('flags multiple packages independently', () => { - const out = findMissingChangesets({ - releases: [{ name: 'foo', type: 'patch' }], - changedFiles: ['packages/foo/package.json', 'packages/bar/package.json'], - readCurrent: fakeReader({ - 'packages/foo/package.json': JSON.stringify({ - name: 'foo', - dependencies: { a: '1' }, - }), - 'packages/bar/package.json': JSON.stringify({ - name: 'bar', - peerDependencies: { p: '^2' }, - }), - }), - readBase: fakeReader({ - 'packages/foo/package.json': JSON.stringify({ - name: 'foo', - dependencies: {}, - }), - 'packages/bar/package.json': JSON.stringify({ - name: 'bar', - peerDependencies: { p: '^1' }, - }), - }), - }); - assert.equal(out.length, 1); - assert.equal(out[0].name, 'bar'); -}); - -test('isShallowEqual treats reordered keys as equal', () => { - assert.equal( - isShallowEqual({ a: '1', b: '2' }, { b: '2', a: '1' }), - true, - ); -}); - -test('isShallowEqual treats different version ranges as unequal', () => { - assert.equal( - isShallowEqual({ a: '^1.0.0' }, { a: '^2.0.0' }), - false, - ); -});