@zairakai/js-dev-tools 3.11.0 → 3.12.0

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.0",
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,24 @@
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/'
37
48
 
38
49
  const DEFAULTS = {
39
- reject: ['typescript'],
50
+ reject: [],
40
51
  cooldown: '3d',
41
52
  tag: { when: 'runtime', level: 'patch' },
42
53
  cascade: null,
@@ -168,6 +179,135 @@ export function isRefOnlyChange(diff) {
168
179
  return 2 === changed.length && changed.every((line) => /^[+-]\s*ref:\s*\S+\s*$/.test(line))
169
180
  }
170
181
 
182
+ /** The problems of a policy document, as a list of sentences (empty when it is valid). */
183
+ export function validatePolicy(doc) {
184
+ if (!Array.isArray(doc?.entries)) return ['"entries" must be a list.']
185
+
186
+ const errors = []
187
+ const seen = new Set()
188
+
189
+ doc.entries.forEach((entry, index) => {
190
+ const where = `entries[${index}]`
191
+ const name = 'string' === typeof entry?.package ? entry.package : ''
192
+
193
+ if ('' === name) errors.push(`${where}: "package" is missing.`)
194
+ else if (seen.has(name)) errors.push(`${where}: "${name}" is listed twice.`)
195
+ seen.add(name)
196
+
197
+ if ('string' !== typeof entry?.reason || '' === entry.reason.trim()) errors.push(`${where}: "reason" is missing.`)
198
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(entry?.since ?? '')) errors.push(`${where}: "since" must be a date (YYYY-MM-DD).`)
199
+
200
+ if (undefined !== entry?.unblock) {
201
+ const { version, peers } = entry.unblock ?? {}
202
+
203
+ if (null === semver.valid(version)) errors.push(`${where}: "unblock.version" must be a version (X.Y.Z).`)
204
+ if (!Array.isArray(peers) || 0 === peers.length || !peers.every((peer) => 'string' === typeof peer && peer)) {
205
+ errors.push(`${where}: "unblock.peers" must be a list of packages.`)
206
+ }
207
+ }
208
+ })
209
+
210
+ return errors
211
+ }
212
+
213
+ /** The policy of an ecosystem, read from the repository dependency-policy. Anything wrong stops the update. */
214
+ export async function fetchPolicy(
215
+ ecosystem,
216
+ fetchImpl = fetch,
217
+ base = process.env.DEPENDENCY_POLICY_URL || POLICY_URL
218
+ ) {
219
+ const url = `${base.replace(/\/$/, '')}/${POLICY_FILES[ecosystem]}`
220
+ let doc
221
+
222
+ try {
223
+ const response = await fetchImpl(url, { signal: AbortSignal.timeout(20000) })
224
+ if (!response.ok) throw new Error(`HTTP ${response.status}`)
225
+ doc = await response.json()
226
+ } catch (error) {
227
+ throw new Error(`The dependency policy could not be read (${url}): ${error.message}`, { cause: error })
228
+ }
229
+
230
+ const errors = validatePolicy(doc)
231
+ if (0 < errors.length) throw new Error(`The dependency policy is not valid (${url}): ${errors.join(' ')}`)
232
+
233
+ return doc
234
+ }
235
+
236
+ /** The packages that the project never updates: its own `reject` and the packages of the policy. */
237
+ export function rejectedPackages(config, policy) {
238
+ return [...new Set([...config.reject, ...policy.entries.map((entry) => entry.package)])]
239
+ }
240
+
241
+ /** Whether the condition of an entry is met: every package of `peers` accepts the version in its peer range. */
242
+ export function isLifted(entry, ranges) {
243
+ if (!entry.unblock) return false
244
+
245
+ return entry.unblock.peers.every((peer) => {
246
+ const range = ranges[peer]
247
+
248
+ return (
249
+ 'string' === typeof range && null !== semver.validRange(range) && semver.satisfies(entry.unblock.version, range)
250
+ )
251
+ })
252
+ }
253
+
254
+ /** The age, as a date, from which a version is taken: now minus the cooling period ("3d", "12h"). */
255
+ export function cooldownDate(cooldown, now = new Date()) {
256
+ const match = /^(\d+)\s*([dhm])$/.exec(cooldown)
257
+ if (!match) return null
258
+
259
+ const unit = { d: 86_400_000, h: 3_600_000, m: 60_000 }[match[2]]
260
+
261
+ return new Date(now.getTime() - Number(match[1]) * unit)
262
+ }
263
+
264
+ /**
265
+ * Whether a package is only reached through the packages of the group (@zairakai/*), in a lock file of version 2 or 3.
266
+ * The graph is read by name, which is enough here: a package that is also reached through a dependency of the project
267
+ * itself is not inherited.
268
+ */
269
+ export function isInherited(lock, name, scope = GROUP_SCOPE) {
270
+ const packages = lock?.packages ?? {}
271
+ const edges = new Map()
272
+
273
+ for (const [path, node] of Object.entries(packages)) {
274
+ if ('' === path) continue
275
+
276
+ const key = path.slice(path.lastIndexOf('node_modules/') + 'node_modules/'.length)
277
+ const next = edges.get(key) ?? new Set()
278
+
279
+ for (const section of ['dependencies', 'optionalDependencies', 'peerDependencies']) {
280
+ Object.keys(node[section] ?? {}).forEach((dependency) => next.add(dependency))
281
+ }
282
+ edges.set(key, next)
283
+ }
284
+
285
+ const root = packages['']
286
+ const direct = ['dependencies', 'devDependencies', 'optionalDependencies', 'peerDependencies'].flatMap((section) =>
287
+ Object.keys(root?.[section] ?? {})
288
+ )
289
+ const reaches = (starts) => {
290
+ const seen = new Set(starts)
291
+ const queue = [...starts]
292
+
293
+ while (0 < queue.length) {
294
+ for (const next of edges.get(queue.shift()) ?? []) {
295
+ if (!seen.has(next)) {
296
+ seen.add(next)
297
+ queue.push(next)
298
+ }
299
+ }
300
+ }
301
+
302
+ return seen
303
+ }
304
+
305
+ const ours = reaches(direct.filter((dependency) => !dependency.startsWith(scope)))
306
+ const theirs = reaches(direct.filter((dependency) => dependency.startsWith(scope)))
307
+
308
+ return theirs.has(name) && !ours.has(name)
309
+ }
310
+
171
311
  /** The id of an advisory: the last part of its URL (GHSA-xxxx-xxxx-xxxx), else the number npm gives it. */
172
312
  function advisoryId(via) {
173
313
  const fromUrl = 'string' === typeof via.url ? via.url.split('/').filter(Boolean).pop() : ''
@@ -210,6 +350,33 @@ export function isUrgent(severity) {
210
350
  return ['', 'high', 'critical'].includes(severity)
211
351
  }
212
352
 
353
+ /** Whether an advisory is worth a push notification: moderate and above. An unknown severity counts as notable. */
354
+ export function isNotable(severity) {
355
+ return ['', 'moderate', 'high', 'critical'].includes(severity)
356
+ }
357
+
358
+ /**
359
+ * The one notification of an audit, or null when nothing notable was opened. The advisories of a low severity only
360
+ * get their issue: they are counted, not listed.
361
+ */
362
+ export function auditAlert(opened, project) {
363
+ const notable = opened.filter(({ row }) => isNotable(row.severity))
364
+ if (0 === notable.length) return null
365
+
366
+ const silent = opened.length - notable.length
367
+ const lines = notable.map(({ row, url }) => `${row.package} (${row.id}, ${row.severity || 'unknown'}): ${url}`)
368
+ if (0 < silent) lines.push(`${silent} more of a low severity, issue only.`)
369
+
370
+ return { title: `Security advisories: ${notable.length} new in ${project}`, message: lines.join('\n') }
371
+ }
372
+
373
+ /** The labels of the issue of an advisory: the kind and the area, the priority from the severity, and Status::Ready. */
374
+ export function advisoryLabels(row) {
375
+ const priority = { critical: 'Priority::Critical', high: 'Priority::High' }[row.severity] ?? 'Priority::Low'
376
+
377
+ return [...ISSUE_LABELS, priority, 'Status::Ready'].join(',')
378
+ }
379
+
213
380
  /** The body of the issue opened for an advisory. */
214
381
  export function advisoryIssueBody(row, pipelineUrl, autoFix) {
215
382
  return [
@@ -416,13 +583,24 @@ async function update(config) {
416
583
  // A PHP project was prepared by the job before this one, in its own image: only npm is updated here.
417
584
  if ('package.json' === ecosystem.file) {
418
585
  // 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(',')] : []
586
+ const rejected = rejectedPackages(config, await readPolicy('npm'))
587
+ const reject = 0 < rejected.length ? ['--reject', rejected.join(',')] : []
420
588
  // An update started by the audit (UPDATE_URGENT=true) fixes an advisory: the fix is not made to wait.
421
589
  const urgent = 'true' === process.env.UPDATE_URGENT
422
590
  ncu([...(urgent ? [] : ['--cooldown', config.cooldown]), ...reject])
423
591
  ncu(['--filter', '@zairakai/*', ...reject])
424
592
  run('npm', ['install', '--no-audit', '--no-fund'])
425
593
 
594
+ // The lock file is refreshed inside the ranges, so that a fix of a package that this project only inherits arrives
595
+ // (overrides of a dependency do not apply here). The new versions wait for the cooling period, except for a fix.
596
+ const before = cooldownDate(config.cooldown)
597
+ run('npm', [
598
+ 'update',
599
+ '--no-audit',
600
+ '--no-fund',
601
+ ...(urgent || null === before ? [] : [`--before=${before.toISOString()}`]),
602
+ ])
603
+
426
604
  if (urgent) {
427
605
  try {
428
606
  run('npm', ['audit', 'fix', '--no-fund'])
@@ -474,9 +652,23 @@ async function update(config) {
474
652
  await mergeWhenGreen(request.data.iid)
475
653
  }
476
654
 
655
+ /** Close the issues whose advisory is gone from the merged tree. It never stops a release. */
656
+ async function closeObsolete() {
657
+ try {
658
+ const found = parseNpmAudit(JSON.parse(capture('npm', ['audit', '--json'])))
659
+
660
+ await closeResolved(found.map(advisoryTitle), process.env.FRESHNESS_TOKEN || process.env.GITLAB_TOKEN)
661
+ } catch (error) {
662
+ log(`The obsolete issues could not be checked: ${secret(error.message).split('\n')[0]}`)
663
+ }
664
+ }
665
+
477
666
  async function release() {
478
667
  assertEnvironment()
479
668
 
669
+ // The update that was just merged into develop may fix advisories: their issues have no reason to stay open.
670
+ await closeObsolete()
671
+
480
672
  const existing = await openMergeRequests('&source_branch=develop&target_branch=main')
481
673
 
482
674
  if (0 < existing.length) {
@@ -610,7 +802,7 @@ async function issueExists(title, token) {
610
802
  return (Array.isArray(result.data) ? result.data : []).some((issue) => issue.title === title)
611
803
  }
612
804
 
613
- async function createIssue(title, description, token) {
805
+ async function createIssue(title, description, labels, token) {
614
806
  // The assignee is given by the variable ISSUE_ASSIGNEE_ID; without it, the user of the token.
615
807
  let assignee = Number(process.env.ISSUE_ASSIGNEE_ID) || 0
616
808
 
@@ -619,7 +811,7 @@ async function createIssue(title, description, token) {
619
811
  const issue = await api(
620
812
  'POST',
621
813
  `/projects/${projectId()}/issues`,
622
- { title, description, labels: ISSUE_LABELS, assignee_ids: 0 < assignee ? [assignee] : [] },
814
+ { title, description, labels, assignee_ids: 0 < assignee ? [assignee] : [] },
623
815
  token
624
816
  )
625
817
 
@@ -670,13 +862,29 @@ async function notify(title, message) {
670
862
  }
671
863
  }
672
864
 
865
+ /** The policy of the ecosystem. When it cannot be read, the update stops (with an alert) instead of going blind. */
866
+ async function readPolicy(ecosystem) {
867
+ try {
868
+ const policy = await fetchPolicy(ecosystem)
869
+ log(`Dependency policy: ${policy.entries.map((entry) => entry.package).join(', ') || 'nothing is held back'}.`)
870
+
871
+ return policy
872
+ } catch (error) {
873
+ await notify('Dependency policy unreachable', `${process.env.CI_PROJECT_PATH}: ${secret(error.message)}`)
874
+ throw error
875
+ }
876
+ }
877
+
878
+ /** Open one issue per advisory that does not have one, and close the issues whose advisory is gone. */
673
879
  async function audit() {
674
880
  assertEnvironment()
675
881
 
676
882
  const found = parseNpmAudit(JSON.parse(capture('npm', ['audit', '--json'])))
883
+ const lock = existsSync('package-lock.json') ? JSON.parse(readFileSync('package-lock.json', 'utf8')) : null
677
884
  const auto = 'true' === process.env.AUTO_FIX
678
885
  const token = process.env.FRESHNESS_TOKEN || process.env.GITLAB_TOKEN
679
886
  const titles = []
887
+ const opened = []
680
888
  let urgent = false
681
889
 
682
890
  log(`${found.length} security advisory(ies) found.`)
@@ -691,11 +899,23 @@ async function audit() {
691
899
  continue
692
900
  }
693
901
 
694
- const url = await createIssue(title, advisoryIssueBody(row, process.env.CI_PIPELINE_URL ?? '', auto), token)
902
+ // Inherited from a package of the group: the issue is in the package that brings it, and the update of this project
903
+ // takes the fix when there is one.
904
+ if (isInherited(lock, row.package)) {
905
+ log(`Inherited from the group, followed in the package that brings it: ${title}`)
906
+ continue
907
+ }
908
+
909
+ const body = advisoryIssueBody(row, process.env.CI_PIPELINE_URL ?? '', auto)
910
+ const url = await createIssue(title, body, advisoryLabels(row), token)
695
911
  log(`Issue created: ${url}`)
696
- await notify(title, `${process.env.CI_PROJECT_PATH}: ${url}`)
912
+ opened.push({ row, url })
697
913
  }
698
914
 
915
+ // One message per audit, whatever the number of new advisories.
916
+ const alert = auditAlert(opened, process.env.CI_PROJECT_PATH)
917
+ if (alert) await notify(alert.title, alert.message)
918
+
699
919
  await closeResolved(titles, token)
700
920
 
701
921
  if (!auto || !urgent) {
@@ -723,11 +943,101 @@ async function audit() {
723
943
  log(started.ok ? 'Update started.' : `The update could not be started (${started.status}).`)
724
944
  }
725
945
 
726
- const modes = { update, release, tag, cascade, audit }
946
+ /** The peer range of `name` in the latest release of `peer`, or null. */
947
+ function peerRange(peer, name) {
948
+ try {
949
+ return JSON.parse(run('npm', ['view', peer, 'peerDependencies', '--json']) || '{}')?.[name] ?? null
950
+ } catch {
951
+ return null
952
+ }
953
+ }
954
+
955
+ /** In the repository dependency-policy: check the shape of the files. */
956
+ async function policyValidate() {
957
+ let failed = false
958
+
959
+ for (const file of Object.values(POLICY_FILES)) {
960
+ if (!existsSync(file)) continue
961
+
962
+ let errors
963
+ try {
964
+ errors = validatePolicy(JSON.parse(readFileSync(file, 'utf8')))
965
+ } catch (error) {
966
+ errors = [`not valid JSON (${error.message}).`]
967
+ }
968
+
969
+ for (const message of errors) console.error(`${file}: ${message}`)
970
+ failed ||= 0 < errors.length
971
+ }
972
+
973
+ if (failed) throw new Error('The dependency policy is not valid.')
974
+ log('The dependency policy is valid.')
975
+ }
976
+
977
+ /** In the repository dependency-policy: lift the entries whose condition is met, with a merge request into main. */
978
+ async function policyWatch() {
979
+ assertEnvironment()
980
+
981
+ const file = POLICY_FILES.npm
982
+ const doc = JSON.parse(readFileSync(file, 'utf8'))
983
+ const errors = validatePolicy(doc)
984
+ if (0 < errors.length) throw new Error(`${file} is not valid: ${errors.join(' ')}`)
985
+
986
+ const lifted = doc.entries.filter((entry) => {
987
+ if (!entry.unblock) return false
988
+
989
+ const ranges = Object.fromEntries(entry.unblock.peers.map((peer) => [peer, peerRange(peer, entry.package)]))
990
+
991
+ return isLifted(entry, ranges)
992
+ })
993
+
994
+ if (0 === lifted.length) {
995
+ log('No entry can be lifted.')
996
+ return
997
+ }
998
+
999
+ if ((await openMergeRequests()).some((request) => request.source_branch.startsWith(POLICY_BRANCH_PREFIX))) {
1000
+ log('A merge request that lifts an entry is already open.')
1001
+ return
1002
+ }
1003
+
1004
+ const names = lifted.map((entry) => entry.package).join(', ')
1005
+ const branch = `${POLICY_BRANCH_PREFIX}-${new Date().toISOString().slice(0, 10).replaceAll('-', '')}`
1006
+ const subject = `chore(policy): unblock ${names}`
1007
+
1008
+ configureSigning()
1009
+ run('git', ['switch', '-C', branch])
1010
+ writeFileSync(
1011
+ file,
1012
+ `${JSON.stringify({ ...doc, entries: doc.entries.filter((entry) => !lifted.includes(entry)) }, null, 2)}\n`
1013
+ )
1014
+ run('git', ['add', file])
1015
+ run('git', ['commit', '-m', subject, '-m', 'The peer ranges of the packages that held it back now accept it.'])
1016
+ run('git', ['push', remoteUrl(), `HEAD:refs/heads/${branch}`])
1017
+
1018
+ const request = await api('POST', `/projects/${projectId()}/merge_requests`, {
1019
+ source_branch: branch,
1020
+ target_branch: 'main',
1021
+ title: subject,
1022
+ 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.`,
1023
+ remove_source_branch: true,
1024
+ ...(await mergeRequestOptions()),
1025
+ })
1026
+
1027
+ if (!request.ok) throw new Error(`The merge request could not be created: ${secret(JSON.stringify(request.data))}`)
1028
+
1029
+ log(`Merge request !${request.data.iid} created.`)
1030
+ await notify('Dependency policy: block lifted', `${names}: ${request.data.web_url}`)
1031
+ await mergeWhenGreen(request.data.iid)
1032
+ }
1033
+
1034
+ const modes = { update, release, tag, cascade, audit, policy: policyWatch, 'policy-validate': policyValidate }
727
1035
 
728
1036
  async function main(mode) {
729
1037
  if (!modes[mode]) {
730
- console.error('Usage: node update-dependencies.mjs update | release | tag | cascade | audit')
1038
+ console.error(
1039
+ 'Usage: node update-dependencies.mjs update | release | tag | cascade | audit | policy | policy-validate'
1040
+ )
731
1041
  process.exitCode = 1
732
1042
  return
733
1043
  }