@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 +3 -2
- package/scripts/update-dependencies.mjs +337 -12
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zairakai/js-dev-tools",
|
|
3
|
-
"version": "3.
|
|
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.
|
|
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": ["
|
|
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: [
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
}
|