@zairakai/js-dev-tools 3.8.0 → 3.10.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.
@@ -454,6 +454,41 @@ js:release:
454
454
  - release_notes.md
455
455
  expire_in: 1 year
456
456
 
457
+ # ================================
458
+ # DOCUMENTATION
459
+ # ================================
460
+ # The documentation site (GitLab Pages): every released version, each built from its own tag, and next, with a version
461
+ # selector. See docs/scripts/publish.sh in the project. It comes with the release: a tag is always a release of main, and
462
+ # nothing is published if the publication failed. The dependencies are the ones installed once by js:install.
463
+ # A project without docs/scripts/publish.sh has no site.
464
+ pages:
465
+ <<: [*base_job, *cache_node]
466
+ stage: release
467
+ image: ${NODE_IMAGE}
468
+ needs:
469
+ - job: js:install
470
+ - job: js:publish
471
+ variables:
472
+ GIT_DEPTH: 0
473
+ before_script:
474
+ - node --version
475
+ script:
476
+ - bash docs/scripts/publish.sh
477
+ # The next jobs of the runner must not find (and lint) the docs dependencies or what they generate.
478
+ after_script:
479
+ - rm -rf docs/node_modules docs/.vitepress/dist docs/.vitepress/cache
480
+ - git clean -fdXq docs || true
481
+ artifacts:
482
+ paths:
483
+ - public
484
+ expire_in: 1 week
485
+ rules:
486
+ - if: $UPDATE_DEPENDENCIES == "true"
487
+ when: never
488
+ - if: $CI_COMMIT_TAG =~ $VERSION_TAG_REGEX
489
+ exists:
490
+ - docs/scripts/publish.sh
491
+
457
492
  # ================================
458
493
  # AUTOMATIC UPDATES
459
494
  # ================================
@@ -473,7 +508,23 @@ deps:update:
473
508
  - '[ "${JS_DEV_TOOLS_SCRIPTS}" = "scripts" ] || npm install --no-save --no-audit --no-fund "@zairakai/js-dev-tools@latest"'
474
509
  - node "${JS_DEV_TOOLS_SCRIPTS}/update-dependencies.mjs" update
475
510
  rules:
476
- - if: $UPDATE_DEPENDENCIES == "true" && $CI_COMMIT_BRANCH == "develop"
511
+ - if: $UPDATE_DEPENDENCIES == "true" && $AUDIT_DEPENDENCIES != "true" && $CI_COMMIT_BRANCH == "develop"
512
+
513
+ # A daily schedule with UPDATE_DEPENDENCIES=true and AUDIT_DEPENDENCIES=true looks for security advisories: one issue per
514
+ # advisory (ntfy alert, closed when it is gone) and, with the group variable AUTO_FIX=true, an update started to fix it.
515
+ # The token FRESHNESS_TOKEN of the group (else GITLAB_TOKEN) opens the issues and starts the update; NTFY_URL is the alert.
516
+ deps:audit:
517
+ <<: *base_job
518
+ stage: install
519
+ image: ${NODE_UPDATE_IMAGE}
520
+ needs: []
521
+ script:
522
+ - node --version
523
+ - "[ -d node_modules ] || npm ci --no-audit --no-fund"
524
+ - '[ "${JS_DEV_TOOLS_SCRIPTS}" = "scripts" ] || npm install --no-save --no-audit --no-fund "@zairakai/js-dev-tools@latest"'
525
+ - node "${JS_DEV_TOOLS_SCRIPTS}/update-dependencies.mjs" audit
526
+ rules:
527
+ - if: $UPDATE_DEPENDENCIES == "true" && $AUDIT_DEPENDENCIES == "true" && $CI_COMMIT_BRANCH == "develop"
477
528
 
478
529
  # After the merge of an update into develop, bring develop into main.
479
530
  deps:release:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zairakai/js-dev-tools",
3
- "version": "3.8.0",
3
+ "version": "3.10.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",
@@ -88,15 +88,15 @@
88
88
  "validate": "make quality"
89
89
  },
90
90
  "dependencies": {
91
- "@stanislas-poisson/js-dev-tools": "^1.0.0",
91
+ "@stanislas-poisson/js-dev-tools": "~1.0.0",
92
92
  "eslint": "^10.13.0",
93
- "markdownlint-cli2": "^0.23.3",
93
+ "markdownlint-cli2": "^0.24.0",
94
94
  "prettier": "^3.9.10",
95
95
  "stylelint": "^17.16.0"
96
96
  },
97
97
  "devDependencies": {
98
98
  "@vitest/coverage-v8": "^5.0.5",
99
- "knip": "^6.42.0",
99
+ "knip": "^6.43.0",
100
100
  "sort-package-json": "^4.1.0",
101
101
  "typescript": "^6.0.3",
102
102
  "vitest": "^5.0.5",
@@ -2,13 +2,15 @@
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
5
+ * Usage: node update-dependencies.mjs update | release | tag | cascade | audit
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.
9
9
  * - release: open the merge request of develop into main after an update, and merge it when its pipeline succeeds.
10
10
  * - tag: tag main when a runtime range changed since the last tag (see `tag` in .dependency-update.json).
11
11
  * - cascade: after the publication of this package, start the update of the projects that use it.
12
+ * - audit: look for security advisories, open one issue per advisory and, with AUTO_FIX=true, start an update to fix them
13
+ * (UPDATE_URGENT=true: the new versions are taken without waiting, and `npm audit fix` is run).
12
14
  *
13
15
  * It reads `.dependency-update.json` at the root of the project (everything is optional):
14
16
  *
@@ -19,7 +21,8 @@
19
21
  * "cascade": { "groups": ["zairakai/npm-packages"] } projects to start (opt-in: they have the same file)
20
22
  * }
21
23
  *
22
- * Environment: GITLAB_TOKEN (group variable), and the variables of GitLab CI.
24
+ * Environment: GITLAB_TOKEN (group variable), and the variables of GitLab CI. The audit also reads FRESHNESS_TOKEN (else
25
+ * GITLAB_TOKEN), NTFY_URL and AUTO_FIX.
23
26
  */
24
27
  import { execFileSync } from 'node:child_process'
25
28
  import { existsSync, readFileSync } from 'node:fs'
@@ -29,6 +32,8 @@ const CONFIG_FILE = '.dependency-update.json'
29
32
  const PIPELINE_FILE = '.gitlab-ci.yml'
30
33
  const COMMIT_SUBJECT = 'chore(deps): update the dependencies'
31
34
  const BRANCH_PREFIX = 'chore/update-dependencies'
35
+ const ADVISORY_TITLE_PREFIX = 'Security advisory: '
36
+ const ISSUE_LABELS = 'Kind::Security,Area::Security,Automated'
32
37
 
33
38
  const DEFAULTS = {
34
39
  reject: ['typescript'],
@@ -163,14 +168,75 @@ export function isRefOnlyChange(diff) {
163
168
  return 2 === changed.length && changed.every((line) => /^[+-]\s*ref:\s*\S+\s*$/.test(line))
164
169
  }
165
170
 
171
+ /** The id of an advisory: the last part of its URL (GHSA-xxxx-xxxx-xxxx), else the number npm gives it. */
172
+ function advisoryId(via) {
173
+ const fromUrl = 'string' === typeof via.url ? via.url.split('/').filter(Boolean).pop() : ''
174
+
175
+ return fromUrl || String(via.source ?? '')
176
+ }
177
+
178
+ /** The advisories of the JSON report of `npm audit`, one row per advisory and package, sorted. */
179
+ export function parseNpmAudit(report) {
180
+ const rows = new Map()
181
+
182
+ for (const entry of Object.values(report?.vulnerabilities ?? {})) {
183
+ for (const via of entry.via ?? []) {
184
+ // A string only names another vulnerable package: the advisory itself is on that one.
185
+ if ('object' !== typeof via || null === via) continue
186
+
187
+ const row = {
188
+ id: advisoryId(via),
189
+ package: via.name ?? entry.name,
190
+ title: via.title ?? '',
191
+ link: via.url ?? '',
192
+ severity: String(via.severity ?? entry.severity ?? '').toLowerCase(),
193
+ range: via.range ?? '',
194
+ }
195
+
196
+ rows.set(`${row.package}|${row.id}`, row)
197
+ }
198
+ }
199
+
200
+ return [...rows.values()].sort((a, b) => `${a.package}|${a.id}`.localeCompare(`${b.package}|${b.id}`))
201
+ }
202
+
203
+ /** The title of the issue of an advisory. It is also the key that keeps the issue from being opened twice. */
204
+ export function advisoryTitle(row) {
205
+ return `${ADVISORY_TITLE_PREFIX}${row.package} (${row.id})`
206
+ }
207
+
208
+ /** Whether an advisory is fixed without waiting for the next weekly update. An unknown severity counts as urgent. */
209
+ export function isUrgent(severity) {
210
+ return ['', 'high', 'critical'].includes(severity)
211
+ }
212
+
213
+ /** The body of the issue opened for an advisory. */
214
+ export function advisoryIssueBody(row, pipelineUrl, autoFix) {
215
+ return [
216
+ `${row.title}.`,
217
+ '',
218
+ `- Package: \`${row.package}\``,
219
+ `- Affected versions: \`${row.range || 'unknown'}\``,
220
+ `- Severity: ${row.severity || 'not given'}`,
221
+ `- Advisory: ${row.link || row.id}`,
222
+ `- Automatic fix: ${autoFix && isUrgent(row.severity) ? 'started (an update of the dependencies, taken without waiting)' : 'off for this advisory: update by hand'}`,
223
+ '',
224
+ `Pipeline: ${pipelineUrl}`,
225
+ '',
226
+ 'The issue is closed by the next audit that no longer finds this advisory.',
227
+ ].join('\n')
228
+ }
229
+
166
230
  // ---------------------------------------------------------------------------------------------------------------------
167
231
 
168
232
  const log = (message) => console.info(message)
169
233
 
170
234
  function secret(text) {
171
- const token = process.env.GITLAB_TOKEN
235
+ return ['GITLAB_TOKEN', 'FRESHNESS_TOKEN'].reduce((masked, name) => {
236
+ const token = process.env[name]
172
237
 
173
- return token ? String(text).split(token).join('***') : String(text)
238
+ return token ? masked.split(token).join('***') : masked
239
+ }, String(text))
174
240
  }
175
241
 
176
242
  function run(command, args = [], options = {}) {
@@ -181,10 +247,25 @@ function run(command, args = [], options = {}) {
181
247
  }
182
248
  }
183
249
 
184
- async function api(method, path, body) {
250
+ /** The output of a command that exits with 1 when it finds something (npm audit): only a missing output is a failure. */
251
+ function capture(command, args = []) {
252
+ try {
253
+ return execFileSync(command, args, {
254
+ encoding: 'utf8',
255
+ stdio: ['ignore', 'pipe', 'pipe'],
256
+ maxBuffer: 64 * 1024 * 1024,
257
+ })
258
+ } catch (error) {
259
+ if (error.stdout) return error.stdout
260
+
261
+ throw new Error(secret(`${command} ${args.join(' ')} failed: ${error.stderr || error.message}`), { cause: error })
262
+ }
263
+ }
264
+
265
+ async function api(method, path, body, token = process.env.GITLAB_TOKEN) {
185
266
  const response = await fetch(`${process.env.CI_API_V4_URL ?? 'https://gitlab.com/api/v4'}${path}`, {
186
267
  method,
187
- headers: { 'PRIVATE-TOKEN': process.env.GITLAB_TOKEN ?? '', 'Content-Type': 'application/json' },
268
+ headers: { 'PRIVATE-TOKEN': token ?? '', 'Content-Type': 'application/json' },
188
269
  body: body === undefined ? undefined : JSON.stringify(body),
189
270
  })
190
271
  const text = await response.text()
@@ -336,9 +417,19 @@ async function update(config) {
336
417
  if ('package.json' === ecosystem.file) {
337
418
  // The new versions wait for the cooling period. The packages of the group are trusted: no waiting for them.
338
419
  const reject = 0 < config.reject.length ? ['--reject', config.reject.join(',')] : []
339
- ncu(['--cooldown', config.cooldown, ...reject])
420
+ // An update started by the audit (UPDATE_URGENT=true) fixes an advisory: the fix is not made to wait.
421
+ const urgent = 'true' === process.env.UPDATE_URGENT
422
+ ncu([...(urgent ? [] : ['--cooldown', config.cooldown]), ...reject])
340
423
  ncu(['--filter', '@zairakai/*', ...reject])
341
424
  run('npm', ['install', '--no-audit', '--no-fund'])
425
+
426
+ if (urgent) {
427
+ try {
428
+ run('npm', ['audit', 'fix', '--no-fund'])
429
+ } catch (error) {
430
+ log(`npm audit fix did not apply: ${secret(error.message).split('\n')[0]}`)
431
+ }
432
+ }
342
433
  }
343
434
 
344
435
  const after = JSON.parse(readFileSync(ecosystem.file, 'utf8'))
@@ -510,11 +601,133 @@ async function cascade(config) {
510
601
  }
511
602
  }
512
603
 
513
- const modes = { update, release, tag, cascade }
604
+ // ---------------------------------------------------------------------------------------------------------------------
605
+
606
+ async function issueExists(title, token) {
607
+ const query = new URLSearchParams({ state: 'opened', search: title, in: 'title' })
608
+ const result = await api('GET', `/projects/${projectId()}/issues?${query}`, undefined, token)
609
+
610
+ return (Array.isArray(result.data) ? result.data : []).some((issue) => issue.title === title)
611
+ }
612
+
613
+ async function createIssue(title, description, token) {
614
+ // The assignee is given by the variable ISSUE_ASSIGNEE_ID; without it, the user of the token.
615
+ let assignee = Number(process.env.ISSUE_ASSIGNEE_ID) || 0
616
+
617
+ if (0 === assignee) assignee = (await api('GET', '/user', undefined, token)).data?.id ?? 0
618
+
619
+ const issue = await api(
620
+ 'POST',
621
+ `/projects/${projectId()}/issues`,
622
+ { title, description, labels: ISSUE_LABELS, assignee_ids: 0 < assignee ? [assignee] : [] },
623
+ token
624
+ )
625
+
626
+ if (!issue.ok) throw new Error(`The issue could not be created: ${secret(JSON.stringify(issue.data))}`)
627
+
628
+ return issue.data.web_url
629
+ }
630
+
631
+ /** Close the open issues of the audit whose advisory is not found any more. */
632
+ async function closeResolved(titles, token) {
633
+ const query = new URLSearchParams({
634
+ state: 'opened',
635
+ labels: 'Automated',
636
+ search: ADVISORY_TITLE_PREFIX,
637
+ in: 'title',
638
+ per_page: '100',
639
+ })
640
+ const result = await api('GET', `/projects/${projectId()}/issues?${query}`, undefined, token)
641
+
642
+ for (const issue of Array.isArray(result.data) ? result.data : []) {
643
+ if (!issue.title.startsWith(ADVISORY_TITLE_PREFIX) || titles.includes(issue.title)) continue
644
+
645
+ const path = `/projects/${projectId()}/issues/${issue.iid}`
646
+ await api(
647
+ 'POST',
648
+ `${path}/notes`,
649
+ { body: `The audit of ${process.env.CI_PIPELINE_URL} no longer finds this advisory.` },
650
+ token
651
+ )
652
+ const closed = await api('PUT', path, { state_event: 'close' }, token)
653
+ log(closed.ok ? `Issue closed: ${issue.title}` : `Issue not closed (${closed.status}): ${issue.title}`)
654
+ }
655
+ }
656
+
657
+ /** A push notification through ntfy, when NTFY_URL is set. A failure never stops the audit. */
658
+ async function notify(title, message) {
659
+ if (!process.env.NTFY_URL) return
660
+
661
+ try {
662
+ await fetch(process.env.NTFY_URL, {
663
+ method: 'POST',
664
+ headers: { Title: title },
665
+ body: message,
666
+ signal: AbortSignal.timeout(15000),
667
+ })
668
+ } catch {
669
+ log('The ntfy alert could not be sent.')
670
+ }
671
+ }
672
+
673
+ async function audit() {
674
+ assertEnvironment()
675
+
676
+ const found = parseNpmAudit(JSON.parse(capture('npm', ['audit', '--json'])))
677
+ const auto = 'true' === process.env.AUTO_FIX
678
+ const token = process.env.FRESHNESS_TOKEN || process.env.GITLAB_TOKEN
679
+ const titles = []
680
+ let urgent = false
681
+
682
+ log(`${found.length} security advisory(ies) found.`)
683
+
684
+ for (const row of found) {
685
+ const title = advisoryTitle(row)
686
+ titles.push(title)
687
+ urgent ||= isUrgent(row.severity)
688
+
689
+ if (await issueExists(title, token)) {
690
+ log(`Issue already open: ${title}`)
691
+ continue
692
+ }
693
+
694
+ const url = await createIssue(title, advisoryIssueBody(row, process.env.CI_PIPELINE_URL ?? '', auto), token)
695
+ log(`Issue created: ${url}`)
696
+ await notify(title, `${process.env.CI_PROJECT_PATH}: ${url}`)
697
+ }
698
+
699
+ await closeResolved(titles, token)
700
+
701
+ if (!auto || !urgent) {
702
+ log(auto ? 'Nothing urgent: the weekly update takes the rest.' : 'AUTO_FIX is off: update by hand.')
703
+ return
704
+ }
705
+
706
+ if ((await openMergeRequests()).some((request) => request.source_branch.startsWith(BRANCH_PREFIX))) {
707
+ log('An update is already open: nothing to start.')
708
+ return
709
+ }
710
+
711
+ const started = await api(
712
+ 'POST',
713
+ `/projects/${projectId()}/pipeline`,
714
+ {
715
+ ref: 'develop',
716
+ variables: [
717
+ { key: 'UPDATE_DEPENDENCIES', value: 'true' },
718
+ { key: 'UPDATE_URGENT', value: 'true' },
719
+ ],
720
+ },
721
+ token
722
+ )
723
+ log(started.ok ? 'Update started.' : `The update could not be started (${started.status}).`)
724
+ }
725
+
726
+ const modes = { update, release, tag, cascade, audit }
514
727
 
515
728
  async function main(mode) {
516
729
  if (!modes[mode]) {
517
- console.error('Usage: node update-dependencies.mjs update | release | tag | cascade')
730
+ console.error('Usage: node update-dependencies.mjs update | release | tag | cascade | audit')
518
731
  process.exitCode = 1
519
732
  return
520
733
  }