@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 +3 -2
- package/scripts/update-dependencies.mjs +322 -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.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.
|
|
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,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: [
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
}
|