@zairakai/js-dev-tools 3.11.0 → 3.12.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zairakai/js-dev-tools",
3
- "version": "3.11.0",
3
+ "version": "3.12.1",
4
4
  "description": "The unified and centralized development toolkit for JavaScript/TypeScript projects - ESLint, Prettier, Stylelint, Knip, Vitest, Git Hooks, and GitLab CI pipeline templates",
5
5
  "keywords": [
6
6
  "dev-tools",
@@ -89,9 +89,10 @@
89
89
  },
90
90
  "dependencies": {
91
91
  "@stanislas-poisson/js-dev-tools": "^1.1.1",
92
- "eslint": "^10.13.0",
92
+ "eslint": "^10.0.0",
93
93
  "markdownlint-cli2": "^0.24.0",
94
94
  "prettier": "^3.9.10",
95
+ "semver": "^7.8.5",
95
96
  "stylelint": "^17.16.0"
96
97
  },
97
98
  "devDependencies": {
@@ -2,7 +2,7 @@
2
2
  /**
3
3
  * Automatic updates of the dependencies of a package of the zairakai group.
4
4
  *
5
- * Usage: node update-dependencies.mjs update | release | tag | cascade | audit
5
+ * Usage: node update-dependencies.mjs update | release | tag | cascade | audit | policy | policy-validate
6
6
  *
7
7
  * - update: prepare a branch with the minor and patch updates, open a merge request into develop and merge it when its
8
8
  * pipeline succeeds. The pipeline of the merge request is the gate: nothing is tested here.
@@ -11,11 +11,16 @@
11
11
  * - cascade: after the publication of this package, start the update of the projects that use it.
12
12
  * - audit: look for security advisories, open one issue per advisory and, with AUTO_FIX=true, start an update to fix them
13
13
  * (UPDATE_URGENT=true: the new versions are taken without waiting, and `npm audit fix` is run).
14
+ * - policy: in the repository dependency-policy, lift the entries whose condition is met (merge request).
15
+ * - policy-validate: in the repository dependency-policy, check the shape of the files.
16
+ *
17
+ * The packages listed in the dependency policy (https://gitlab.com/zairakai/dependency-policy) are never updated, whatever
18
+ * the project. DEPENDENCY_POLICY_URL replaces the address of the files.
14
19
  *
15
20
  * It reads `.dependency-update.json` at the root of the project (everything is optional):
16
21
  *
17
22
  * {
18
- * "reject": ["typescript"], packages that are never updated
23
+ * "reject": ["some-package"], packages of this project that are never updated
19
24
  * "cooldown": "3d", minimum age of a new version
20
25
  * "tag": { "when": "runtime", "level": "patch" }, when: runtime | always | never, level: patch | minor
21
26
  * "cascade": { "groups": ["zairakai/npm-packages"] } projects to start (opt-in: they have the same file)
@@ -25,18 +30,25 @@
25
30
  * GITLAB_TOKEN), NTFY_URL and AUTO_FIX.
26
31
  */
27
32
  import { execFileSync } from 'node:child_process'
28
- import { existsSync, readFileSync } from 'node:fs'
33
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs'
29
34
  import { pathToFileURL } from 'node:url'
30
35
 
36
+ import semver from 'semver'
37
+
31
38
  const CONFIG_FILE = '.dependency-update.json'
32
39
  const PIPELINE_FILE = '.gitlab-ci.yml'
33
40
  const COMMIT_SUBJECT = 'chore(deps): update the dependencies'
34
41
  const BRANCH_PREFIX = 'chore/update-dependencies'
35
42
  const ADVISORY_TITLE_PREFIX = 'Security advisory: '
36
- const ISSUE_LABELS = 'Kind::Security,Area::Security,Automated'
43
+ const ISSUE_LABELS = ['Kind::Security', 'Area::Security', 'Automated']
44
+ const POLICY_URL = 'https://gitlab.com/zairakai/dependency-policy/-/raw/main'
45
+ const POLICY_FILES = { npm: 'node.json', composer: 'php.json' }
46
+ const POLICY_BRANCH_PREFIX = 'chore/unblock'
47
+ const GROUP_SCOPE = '@zairakai/'
48
+ const TRUSTED_SCOPES = [GROUP_SCOPE, '@stanislas-poisson/']
37
49
 
38
50
  const DEFAULTS = {
39
- reject: ['typescript'],
51
+ reject: [],
40
52
  cooldown: '3d',
41
53
  tag: { when: 'runtime', level: 'patch' },
42
54
  cascade: null,
@@ -168,6 +180,148 @@ export function isRefOnlyChange(diff) {
168
180
  return 2 === changed.length && changed.every((line) => /^[+-]\s*ref:\s*\S+\s*$/.test(line))
169
181
  }
170
182
 
183
+ /** The problems of a policy document, as a list of sentences (empty when it is valid). */
184
+ export function validatePolicy(doc) {
185
+ if (!Array.isArray(doc?.entries)) return ['"entries" must be a list.']
186
+
187
+ const errors = []
188
+ const seen = new Set()
189
+
190
+ doc.entries.forEach((entry, index) => {
191
+ const where = `entries[${index}]`
192
+ const name = 'string' === typeof entry?.package ? entry.package : ''
193
+
194
+ if ('' === name) errors.push(`${where}: "package" is missing.`)
195
+ else if (seen.has(name)) errors.push(`${where}: "${name}" is listed twice.`)
196
+ seen.add(name)
197
+
198
+ if ('string' !== typeof entry?.reason || '' === entry.reason.trim()) errors.push(`${where}: "reason" is missing.`)
199
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(entry?.since ?? '')) errors.push(`${where}: "since" must be a date (YYYY-MM-DD).`)
200
+
201
+ if (undefined !== entry?.unblock) {
202
+ const { version, peers } = entry.unblock ?? {}
203
+
204
+ if (null === semver.valid(version)) errors.push(`${where}: "unblock.version" must be a version (X.Y.Z).`)
205
+ if (!Array.isArray(peers) || 0 === peers.length || !peers.every((peer) => 'string' === typeof peer && peer)) {
206
+ errors.push(`${where}: "unblock.peers" must be a list of packages.`)
207
+ }
208
+ }
209
+ })
210
+
211
+ return errors
212
+ }
213
+
214
+ /** The policy of an ecosystem, read from the repository dependency-policy. Anything wrong stops the update. */
215
+ export async function fetchPolicy(
216
+ ecosystem,
217
+ fetchImpl = fetch,
218
+ base = process.env.DEPENDENCY_POLICY_URL || POLICY_URL
219
+ ) {
220
+ const url = `${base.replace(/\/$/, '')}/${POLICY_FILES[ecosystem]}`
221
+ let doc
222
+
223
+ try {
224
+ const response = await fetchImpl(url, { signal: AbortSignal.timeout(20000) })
225
+ if (!response.ok) throw new Error(`HTTP ${response.status}`)
226
+ doc = await response.json()
227
+ } catch (error) {
228
+ throw new Error(`The dependency policy could not be read (${url}): ${error.message}`, { cause: error })
229
+ }
230
+
231
+ const errors = validatePolicy(doc)
232
+ if (0 < errors.length) throw new Error(`The dependency policy is not valid (${url}): ${errors.join(' ')}`)
233
+
234
+ return doc
235
+ }
236
+
237
+ /** The packages that the project never updates: its own `reject` and the packages of the policy. */
238
+ export function rejectedPackages(config, policy) {
239
+ return [...new Set([...config.reject, ...policy.entries.map((entry) => entry.package)])]
240
+ }
241
+
242
+ /** Whether the condition of an entry is met: every package of `peers` accepts the version in its peer range. */
243
+ export function isLifted(entry, ranges) {
244
+ if (!entry.unblock) return false
245
+
246
+ return entry.unblock.peers.every((peer) => {
247
+ const range = ranges[peer]
248
+
249
+ return (
250
+ 'string' === typeof range && null !== semver.validRange(range) && semver.satisfies(entry.unblock.version, range)
251
+ )
252
+ })
253
+ }
254
+
255
+ /**
256
+ * The packages of a lock file that the refresh may update: the ones of the group are left out, because they are trusted
257
+ * and not made to wait, and the cooling date would refuse a version that was just published.
258
+ */
259
+ export function refreshableNames(lock, scopes = TRUSTED_SCOPES) {
260
+ const names = Object.keys(lock?.packages ?? {})
261
+ .filter((path) => '' !== path)
262
+ .map((path) => path.slice(path.lastIndexOf('node_modules/') + 'node_modules/'.length))
263
+ .filter((name) => !scopes.some((scope) => name.startsWith(scope)))
264
+
265
+ return [...new Set(names)].sort()
266
+ }
267
+
268
+ /** The age, as a date, from which a version is taken: now minus the cooling period ("3d", "12h"). */
269
+ export function cooldownDate(cooldown, now = new Date()) {
270
+ const match = /^(\d+)\s*([dhm])$/.exec(cooldown)
271
+ if (!match) return null
272
+
273
+ const unit = { d: 86_400_000, h: 3_600_000, m: 60_000 }[match[2]]
274
+
275
+ return new Date(now.getTime() - Number(match[1]) * unit)
276
+ }
277
+
278
+ /**
279
+ * Whether a package is only reached through the packages of the group (@zairakai/*), in a lock file of version 2 or 3.
280
+ * The graph is read by name, which is enough here: a package that is also reached through a dependency of the project
281
+ * itself is not inherited.
282
+ */
283
+ export function isInherited(lock, name, scope = GROUP_SCOPE) {
284
+ const packages = lock?.packages ?? {}
285
+ const edges = new Map()
286
+
287
+ for (const [path, node] of Object.entries(packages)) {
288
+ if ('' === path) continue
289
+
290
+ const key = path.slice(path.lastIndexOf('node_modules/') + 'node_modules/'.length)
291
+ const next = edges.get(key) ?? new Set()
292
+
293
+ for (const section of ['dependencies', 'optionalDependencies', 'peerDependencies']) {
294
+ Object.keys(node[section] ?? {}).forEach((dependency) => next.add(dependency))
295
+ }
296
+ edges.set(key, next)
297
+ }
298
+
299
+ const root = packages['']
300
+ const direct = ['dependencies', 'devDependencies', 'optionalDependencies', 'peerDependencies'].flatMap((section) =>
301
+ Object.keys(root?.[section] ?? {})
302
+ )
303
+ const reaches = (starts) => {
304
+ const seen = new Set(starts)
305
+ const queue = [...starts]
306
+
307
+ while (0 < queue.length) {
308
+ for (const next of edges.get(queue.shift()) ?? []) {
309
+ if (!seen.has(next)) {
310
+ seen.add(next)
311
+ queue.push(next)
312
+ }
313
+ }
314
+ }
315
+
316
+ return seen
317
+ }
318
+
319
+ const ours = reaches(direct.filter((dependency) => !dependency.startsWith(scope)))
320
+ const theirs = reaches(direct.filter((dependency) => dependency.startsWith(scope)))
321
+
322
+ return theirs.has(name) && !ours.has(name)
323
+ }
324
+
171
325
  /** The id of an advisory: the last part of its URL (GHSA-xxxx-xxxx-xxxx), else the number npm gives it. */
172
326
  function advisoryId(via) {
173
327
  const fromUrl = 'string' === typeof via.url ? via.url.split('/').filter(Boolean).pop() : ''
@@ -210,6 +364,33 @@ export function isUrgent(severity) {
210
364
  return ['', 'high', 'critical'].includes(severity)
211
365
  }
212
366
 
367
+ /** Whether an advisory is worth a push notification: moderate and above. An unknown severity counts as notable. */
368
+ export function isNotable(severity) {
369
+ return ['', 'moderate', 'high', 'critical'].includes(severity)
370
+ }
371
+
372
+ /**
373
+ * The one notification of an audit, or null when nothing notable was opened. The advisories of a low severity only
374
+ * get their issue: they are counted, not listed.
375
+ */
376
+ export function auditAlert(opened, project) {
377
+ const notable = opened.filter(({ row }) => isNotable(row.severity))
378
+ if (0 === notable.length) return null
379
+
380
+ const silent = opened.length - notable.length
381
+ const lines = notable.map(({ row, url }) => `${row.package} (${row.id}, ${row.severity || 'unknown'}): ${url}`)
382
+ if (0 < silent) lines.push(`${silent} more of a low severity, issue only.`)
383
+
384
+ return { title: `Security advisories: ${notable.length} new in ${project}`, message: lines.join('\n') }
385
+ }
386
+
387
+ /** The labels of the issue of an advisory: the kind and the area, the priority from the severity, and Status::Ready. */
388
+ export function advisoryLabels(row) {
389
+ const priority = { critical: 'Priority::Critical', high: 'Priority::High' }[row.severity] ?? 'Priority::Low'
390
+
391
+ return [...ISSUE_LABELS, priority, 'Status::Ready'].join(',')
392
+ }
393
+
213
394
  /** The body of the issue opened for an advisory. */
214
395
  export function advisoryIssueBody(row, pipelineUrl, autoFix) {
215
396
  return [
@@ -416,13 +597,25 @@ async function update(config) {
416
597
  // A PHP project was prepared by the job before this one, in its own image: only npm is updated here.
417
598
  if ('package.json' === ecosystem.file) {
418
599
  // The new versions wait for the cooling period. The packages of the group are trusted: no waiting for them.
419
- const reject = 0 < config.reject.length ? ['--reject', config.reject.join(',')] : []
600
+ const rejected = rejectedPackages(config, await readPolicy('npm'))
601
+ const reject = 0 < rejected.length ? ['--reject', rejected.join(',')] : []
420
602
  // An update started by the audit (UPDATE_URGENT=true) fixes an advisory: the fix is not made to wait.
421
603
  const urgent = 'true' === process.env.UPDATE_URGENT
422
604
  ncu([...(urgent ? [] : ['--cooldown', config.cooldown]), ...reject])
423
605
  ncu(['--filter', '@zairakai/*', ...reject])
424
606
  run('npm', ['install', '--no-audit', '--no-fund'])
425
607
 
608
+ // The lock file is refreshed inside the ranges, so that a fix of a package that this project only inherits arrives
609
+ // (overrides of a dependency do not apply here). The new versions wait for the cooling period, except for a fix.
610
+ const before = cooldownDate(config.cooldown)
611
+ const names = refreshableNames(JSON.parse(readFileSync('package-lock.json', 'utf8')))
612
+
613
+ if (urgent || null === before) {
614
+ run('npm', ['update', '--no-audit', '--no-fund'])
615
+ } else if (0 < names.length) {
616
+ run('npm', ['update', '--no-audit', '--no-fund', `--before=${before.toISOString()}`, ...names])
617
+ }
618
+
426
619
  if (urgent) {
427
620
  try {
428
621
  run('npm', ['audit', 'fix', '--no-fund'])
@@ -474,9 +667,23 @@ async function update(config) {
474
667
  await mergeWhenGreen(request.data.iid)
475
668
  }
476
669
 
670
+ /** Close the issues whose advisory is gone from the merged tree. It never stops a release. */
671
+ async function closeObsolete() {
672
+ try {
673
+ const found = parseNpmAudit(JSON.parse(capture('npm', ['audit', '--json'])))
674
+
675
+ await closeResolved(found.map(advisoryTitle), process.env.FRESHNESS_TOKEN || process.env.GITLAB_TOKEN)
676
+ } catch (error) {
677
+ log(`The obsolete issues could not be checked: ${secret(error.message).split('\n')[0]}`)
678
+ }
679
+ }
680
+
477
681
  async function release() {
478
682
  assertEnvironment()
479
683
 
684
+ // The update that was just merged into develop may fix advisories: their issues have no reason to stay open.
685
+ await closeObsolete()
686
+
480
687
  const existing = await openMergeRequests('&source_branch=develop&target_branch=main')
481
688
 
482
689
  if (0 < existing.length) {
@@ -610,7 +817,7 @@ async function issueExists(title, token) {
610
817
  return (Array.isArray(result.data) ? result.data : []).some((issue) => issue.title === title)
611
818
  }
612
819
 
613
- async function createIssue(title, description, token) {
820
+ async function createIssue(title, description, labels, token) {
614
821
  // The assignee is given by the variable ISSUE_ASSIGNEE_ID; without it, the user of the token.
615
822
  let assignee = Number(process.env.ISSUE_ASSIGNEE_ID) || 0
616
823
 
@@ -619,7 +826,7 @@ async function createIssue(title, description, token) {
619
826
  const issue = await api(
620
827
  'POST',
621
828
  `/projects/${projectId()}/issues`,
622
- { title, description, labels: ISSUE_LABELS, assignee_ids: 0 < assignee ? [assignee] : [] },
829
+ { title, description, labels, assignee_ids: 0 < assignee ? [assignee] : [] },
623
830
  token
624
831
  )
625
832
 
@@ -670,13 +877,29 @@ async function notify(title, message) {
670
877
  }
671
878
  }
672
879
 
880
+ /** The policy of the ecosystem. When it cannot be read, the update stops (with an alert) instead of going blind. */
881
+ async function readPolicy(ecosystem) {
882
+ try {
883
+ const policy = await fetchPolicy(ecosystem)
884
+ log(`Dependency policy: ${policy.entries.map((entry) => entry.package).join(', ') || 'nothing is held back'}.`)
885
+
886
+ return policy
887
+ } catch (error) {
888
+ await notify('Dependency policy unreachable', `${process.env.CI_PROJECT_PATH}: ${secret(error.message)}`)
889
+ throw error
890
+ }
891
+ }
892
+
893
+ /** Open one issue per advisory that does not have one, and close the issues whose advisory is gone. */
673
894
  async function audit() {
674
895
  assertEnvironment()
675
896
 
676
897
  const found = parseNpmAudit(JSON.parse(capture('npm', ['audit', '--json'])))
898
+ const lock = existsSync('package-lock.json') ? JSON.parse(readFileSync('package-lock.json', 'utf8')) : null
677
899
  const auto = 'true' === process.env.AUTO_FIX
678
900
  const token = process.env.FRESHNESS_TOKEN || process.env.GITLAB_TOKEN
679
901
  const titles = []
902
+ const opened = []
680
903
  let urgent = false
681
904
 
682
905
  log(`${found.length} security advisory(ies) found.`)
@@ -691,11 +914,23 @@ async function audit() {
691
914
  continue
692
915
  }
693
916
 
694
- const url = await createIssue(title, advisoryIssueBody(row, process.env.CI_PIPELINE_URL ?? '', auto), token)
917
+ // Inherited from a package of the group: the issue is in the package that brings it, and the update of this project
918
+ // takes the fix when there is one.
919
+ if (isInherited(lock, row.package)) {
920
+ log(`Inherited from the group, followed in the package that brings it: ${title}`)
921
+ continue
922
+ }
923
+
924
+ const body = advisoryIssueBody(row, process.env.CI_PIPELINE_URL ?? '', auto)
925
+ const url = await createIssue(title, body, advisoryLabels(row), token)
695
926
  log(`Issue created: ${url}`)
696
- await notify(title, `${process.env.CI_PROJECT_PATH}: ${url}`)
927
+ opened.push({ row, url })
697
928
  }
698
929
 
930
+ // One message per audit, whatever the number of new advisories.
931
+ const alert = auditAlert(opened, process.env.CI_PROJECT_PATH)
932
+ if (alert) await notify(alert.title, alert.message)
933
+
699
934
  await closeResolved(titles, token)
700
935
 
701
936
  if (!auto || !urgent) {
@@ -723,11 +958,101 @@ async function audit() {
723
958
  log(started.ok ? 'Update started.' : `The update could not be started (${started.status}).`)
724
959
  }
725
960
 
726
- const modes = { update, release, tag, cascade, audit }
961
+ /** The peer range of `name` in the latest release of `peer`, or null. */
962
+ function peerRange(peer, name) {
963
+ try {
964
+ return JSON.parse(run('npm', ['view', peer, 'peerDependencies', '--json']) || '{}')?.[name] ?? null
965
+ } catch {
966
+ return null
967
+ }
968
+ }
969
+
970
+ /** In the repository dependency-policy: check the shape of the files. */
971
+ async function policyValidate() {
972
+ let failed = false
973
+
974
+ for (const file of Object.values(POLICY_FILES)) {
975
+ if (!existsSync(file)) continue
976
+
977
+ let errors
978
+ try {
979
+ errors = validatePolicy(JSON.parse(readFileSync(file, 'utf8')))
980
+ } catch (error) {
981
+ errors = [`not valid JSON (${error.message}).`]
982
+ }
983
+
984
+ for (const message of errors) console.error(`${file}: ${message}`)
985
+ failed ||= 0 < errors.length
986
+ }
987
+
988
+ if (failed) throw new Error('The dependency policy is not valid.')
989
+ log('The dependency policy is valid.')
990
+ }
991
+
992
+ /** In the repository dependency-policy: lift the entries whose condition is met, with a merge request into main. */
993
+ async function policyWatch() {
994
+ assertEnvironment()
995
+
996
+ const file = POLICY_FILES.npm
997
+ const doc = JSON.parse(readFileSync(file, 'utf8'))
998
+ const errors = validatePolicy(doc)
999
+ if (0 < errors.length) throw new Error(`${file} is not valid: ${errors.join(' ')}`)
1000
+
1001
+ const lifted = doc.entries.filter((entry) => {
1002
+ if (!entry.unblock) return false
1003
+
1004
+ const ranges = Object.fromEntries(entry.unblock.peers.map((peer) => [peer, peerRange(peer, entry.package)]))
1005
+
1006
+ return isLifted(entry, ranges)
1007
+ })
1008
+
1009
+ if (0 === lifted.length) {
1010
+ log('No entry can be lifted.')
1011
+ return
1012
+ }
1013
+
1014
+ if ((await openMergeRequests()).some((request) => request.source_branch.startsWith(POLICY_BRANCH_PREFIX))) {
1015
+ log('A merge request that lifts an entry is already open.')
1016
+ return
1017
+ }
1018
+
1019
+ const names = lifted.map((entry) => entry.package).join(', ')
1020
+ const branch = `${POLICY_BRANCH_PREFIX}-${new Date().toISOString().slice(0, 10).replaceAll('-', '')}`
1021
+ const subject = `chore(policy): unblock ${names}`
1022
+
1023
+ configureSigning()
1024
+ run('git', ['switch', '-C', branch])
1025
+ writeFileSync(
1026
+ file,
1027
+ `${JSON.stringify({ ...doc, entries: doc.entries.filter((entry) => !lifted.includes(entry)) }, null, 2)}\n`
1028
+ )
1029
+ run('git', ['add', file])
1030
+ run('git', ['commit', '-m', subject, '-m', 'The peer ranges of the packages that held it back now accept it.'])
1031
+ run('git', ['push', remoteUrl(), `HEAD:refs/heads/${branch}`])
1032
+
1033
+ const request = await api('POST', `/projects/${projectId()}/merge_requests`, {
1034
+ source_branch: branch,
1035
+ target_branch: 'main',
1036
+ title: subject,
1037
+ description: `The condition of the entry is met: ${lifted.map((entry) => `${entry.package} ${entry.unblock.version} (${entry.unblock.peers.join(', ')})`).join('; ')}. It is merged when its pipeline succeeds, and the update tools take the package at their next run.`,
1038
+ remove_source_branch: true,
1039
+ ...(await mergeRequestOptions()),
1040
+ })
1041
+
1042
+ if (!request.ok) throw new Error(`The merge request could not be created: ${secret(JSON.stringify(request.data))}`)
1043
+
1044
+ log(`Merge request !${request.data.iid} created.`)
1045
+ await notify('Dependency policy: block lifted', `${names}: ${request.data.web_url}`)
1046
+ await mergeWhenGreen(request.data.iid)
1047
+ }
1048
+
1049
+ const modes = { update, release, tag, cascade, audit, policy: policyWatch, 'policy-validate': policyValidate }
727
1050
 
728
1051
  async function main(mode) {
729
1052
  if (!modes[mode]) {
730
- console.error('Usage: node update-dependencies.mjs update | release | tag | cascade | audit')
1053
+ console.error(
1054
+ 'Usage: node update-dependencies.mjs update | release | tag | cascade | audit | policy | policy-validate'
1055
+ )
731
1056
  process.exitCode = 1
732
1057
  return
733
1058
  }