@zairakai/js-dev-tools 3.1.0 → 3.3.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/.gitlab/ci/pipeline-js-package.yml +57 -0
- package/README.md +31 -1
- package/package.json +18 -18
- package/scripts/update-dependencies.mjs +425 -0
|
@@ -385,3 +385,60 @@ js:release:
|
|
|
385
385
|
paths:
|
|
386
386
|
- release_notes.md
|
|
387
387
|
expire_in: 1 year
|
|
388
|
+
|
|
389
|
+
# ================================
|
|
390
|
+
# AUTOMATIC UPDATES
|
|
391
|
+
# ================================
|
|
392
|
+
# See the handbook, policies/dependency-management.md. The exceptions of a project live in .dependency-update.json.
|
|
393
|
+
# The group variable GITLAB_TOKEN (protected, so these jobs run on main, develop and the tags) opens the merge requests,
|
|
394
|
+
# merges them and creates the tags. The pipeline of each merge request is the gate: nothing is tested by these jobs.
|
|
395
|
+
|
|
396
|
+
# A weekly schedule (or a pipeline started by the cascade) with UPDATE_DEPENDENCIES=true prepares the update.
|
|
397
|
+
deps:update:
|
|
398
|
+
<<: *base_job
|
|
399
|
+
stage: install
|
|
400
|
+
image: ${NODE_IMAGE}
|
|
401
|
+
needs: []
|
|
402
|
+
script:
|
|
403
|
+
- node --version
|
|
404
|
+
- "[ -d node_modules ] || npm ci --no-audit --no-fund"
|
|
405
|
+
- node "${JS_DEV_TOOLS_SCRIPTS}/update-dependencies.mjs" update
|
|
406
|
+
rules:
|
|
407
|
+
- if: $UPDATE_DEPENDENCIES == "true" && $CI_COMMIT_BRANCH == "develop"
|
|
408
|
+
|
|
409
|
+
# After the merge of an update into develop, bring develop into main.
|
|
410
|
+
deps:release:
|
|
411
|
+
<<: *base_job
|
|
412
|
+
stage: release
|
|
413
|
+
image: ${NODE_IMAGE}
|
|
414
|
+
script:
|
|
415
|
+
- "[ -d node_modules ] || npm ci --no-audit --no-fund"
|
|
416
|
+
- node "${JS_DEV_TOOLS_SCRIPTS}/update-dependencies.mjs" release
|
|
417
|
+
rules:
|
|
418
|
+
- if: $CI_COMMIT_BRANCH == "develop" && $CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_MESSAGE =~ /chore\/update-dependencies/
|
|
419
|
+
|
|
420
|
+
# After the merge of an update into main, tag it when a published range changed. The tag starts the publication.
|
|
421
|
+
deps:tag:
|
|
422
|
+
<<: *base_job
|
|
423
|
+
stage: release
|
|
424
|
+
image: ${NODE_IMAGE}
|
|
425
|
+
variables:
|
|
426
|
+
GIT_DEPTH: 0
|
|
427
|
+
script:
|
|
428
|
+
- "[ -d node_modules ] || npm ci --no-audit --no-fund"
|
|
429
|
+
- node "${JS_DEV_TOOLS_SCRIPTS}/update-dependencies.mjs" tag
|
|
430
|
+
rules:
|
|
431
|
+
- if: $CI_COMMIT_BRANCH == "main" && $CI_PIPELINE_SOURCE == "push"
|
|
432
|
+
|
|
433
|
+
# After the publication, start the update of the projects that use this package (only if the project has a cascade).
|
|
434
|
+
deps:cascade:
|
|
435
|
+
<<: *base_job
|
|
436
|
+
stage: release
|
|
437
|
+
image: ${NODE_IMAGE}
|
|
438
|
+
needs:
|
|
439
|
+
- job: js:publish
|
|
440
|
+
script:
|
|
441
|
+
- "[ -d node_modules ] || npm ci --no-audit --no-fund"
|
|
442
|
+
- node "${JS_DEV_TOOLS_SCRIPTS}/update-dependencies.mjs" cascade
|
|
443
|
+
rules:
|
|
444
|
+
- if: $CI_COMMIT_TAG =~ $VERSION_TAG_REGEX
|
package/README.md
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
[![Develop][pipeline-develop-badge]][pipeline-develop-link]
|
|
5
5
|
|
|
6
6
|
[![npm][npm-badge]][npm-link]
|
|
7
|
+
[![Docs][docs-badge]][docs]
|
|
7
8
|
[![GitLab Release][gitlab-release-badge]][gitlab-release]
|
|
8
9
|
[![License][license-badge]][license]
|
|
9
10
|
|
|
@@ -14,7 +15,7 @@
|
|
|
14
15
|
|
|
15
16
|
One unified toolkit to set up JavaScript/TypeScript quality tooling. Context-aware by default — it adapts to both standalone packages and full-stack applications.
|
|
16
17
|
|
|
17
|
-
**Documentation:
|
|
18
|
+
**Documentation: [js-dev-tools-3fa149.gitlab.io][docs]**
|
|
18
19
|
|
|
19
20
|
---
|
|
20
21
|
|
|
@@ -169,6 +170,27 @@ Available templates:
|
|
|
169
170
|
|
|
170
171
|
---
|
|
171
172
|
|
|
173
|
+
## Automatic updates
|
|
174
|
+
|
|
175
|
+
The pipeline template updates the dependencies of a project by itself, and this package starts the update of the projects that use it. The rules are in the handbook (`policies/dependency-management.md` and `policies/versioning.md`).
|
|
176
|
+
|
|
177
|
+
- A weekly schedule with the variable `UPDATE_DEPENDENCIES=true`, or a pipeline started by the cascade, runs `deps:update`: it takes the **minor and patch** updates after a cooling period, opens a merge request into `develop` and merges it **when its pipeline succeeds**. Major versions are never applied.
|
|
178
|
+
- `deps:release` brings `develop` into `main` after such an update, and `deps:tag` tags `main` when a published range (`dependencies`, `peerDependencies`) changed. A project that changed only its development dependencies is not tagged: its published archive is the same.
|
|
179
|
+
- After the publication of a tag, `deps:cascade` waits for the version to be on the registry, then starts the update of the projects that have a `.dependency-update.json`.
|
|
180
|
+
|
|
181
|
+
The exceptions of a project live in `.dependency-update.json` at its root (everything is optional):
|
|
182
|
+
|
|
183
|
+
```json
|
|
184
|
+
{
|
|
185
|
+
"reject": ["typescript", "vue-tsc"],
|
|
186
|
+
"cooldown": "3d",
|
|
187
|
+
"tag": { "when": "runtime", "level": "patch" },
|
|
188
|
+
"cascade": { "groups": ["zairakai/npm-packages"] }
|
|
189
|
+
}
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
`tag.when` is `runtime` (default), `always` or `never`, and `tag.level` is `patch` (default) or `minor`. Only the projects that others depend on need a `cascade`. The group variable `GITLAB_TOKEN` is used to open the merge requests and to tag.
|
|
193
|
+
|
|
172
194
|
## Development
|
|
173
195
|
|
|
174
196
|
```bash
|
|
@@ -189,6 +211,11 @@ make doctor # environment diagnostics
|
|
|
189
211
|
**Made with ❤️ by [Zairakai][ecosystem]**
|
|
190
212
|
|
|
191
213
|
<!-- Reference Links -->
|
|
214
|
+
|
|
215
|
+
## Statistics
|
|
216
|
+
|
|
217
|
+
![Statistics of js-dev-tools][stats-card]
|
|
218
|
+
|
|
192
219
|
[pipeline-main-badge]: https://gitlab.com/zairakai/npm-packages/js-dev-tools/badges/main/pipeline.svg?ignore_skipped=true&key_text=Main
|
|
193
220
|
[pipeline-main-link]: https://gitlab.com/zairakai/npm-packages/js-dev-tools/-/commits/main
|
|
194
221
|
[pipeline-develop-badge]: https://gitlab.com/zairakai/npm-packages/js-dev-tools/badges/develop/pipeline.svg?ignore_skipped=true&key_text=Develop
|
|
@@ -212,3 +239,6 @@ make doctor # environment diagnostics
|
|
|
212
239
|
[stylelint-badge]: https://img.shields.io/badge/css-stylelint-263238.svg?logo=stylelint
|
|
213
240
|
[stylelint]: https://stylelint.io
|
|
214
241
|
[ecosystem]: https://gitlab.com/zairakai
|
|
242
|
+
[docs]: https://js-dev-tools-3fa149.gitlab.io
|
|
243
|
+
[docs-badge]: https://img.shields.io/badge/docs-online-blue
|
|
244
|
+
[stats-card]: https://gitlab.com/zairakai/gitlab-profile/-/raw/main/assets/projects/js-dev-tools.svg
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zairakai/js-dev-tools",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.3.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,35 +88,35 @@
|
|
|
88
88
|
"validate": "make quality"
|
|
89
89
|
},
|
|
90
90
|
"dependencies": {
|
|
91
|
-
"@eslint/compat": "^2.
|
|
92
|
-
"@eslint/js": "^10.0.
|
|
91
|
+
"@eslint/compat": "^2.1.1",
|
|
92
|
+
"@eslint/js": "^10.0.1",
|
|
93
93
|
"@prettier/plugin-php": "^0.25.0",
|
|
94
|
-
"@typescript-eslint/eslint-plugin": "^8.
|
|
95
|
-
"@typescript-eslint/parser": "^8.
|
|
96
|
-
"eslint": "^10.
|
|
97
|
-
"eslint-config-prettier": "^10.
|
|
98
|
-
"eslint-plugin-vue": "^10.
|
|
99
|
-
"globals": "^17.
|
|
100
|
-
"markdownlint-cli2": "^0.23.
|
|
101
|
-
"prettier": "^3.
|
|
94
|
+
"@typescript-eslint/eslint-plugin": "^8.71.1",
|
|
95
|
+
"@typescript-eslint/parser": "^8.71.1",
|
|
96
|
+
"eslint": "^10.12.0",
|
|
97
|
+
"eslint-config-prettier": "^10.1.8",
|
|
98
|
+
"eslint-plugin-vue": "^10.11.1",
|
|
99
|
+
"globals": "^17.13.0",
|
|
100
|
+
"markdownlint-cli2": "^0.23.3",
|
|
101
|
+
"prettier": "^3.9.9",
|
|
102
102
|
"prettier-plugin-blade": "^3.3.3",
|
|
103
|
-
"prettier-plugin-organize-imports": "^4.
|
|
104
|
-
"stylelint": "^17.
|
|
103
|
+
"prettier-plugin-organize-imports": "^4.3.0",
|
|
104
|
+
"stylelint": "^17.16.0",
|
|
105
105
|
"stylelint-config-html": "^2.0.0",
|
|
106
106
|
"stylelint-config-recommended-vue": "^2.0.0",
|
|
107
107
|
"stylelint-config-standard": "^40.0.0",
|
|
108
108
|
"stylelint-config-standard-scss": "^17.0.0",
|
|
109
|
-
"stylelint-order": "^8.
|
|
110
|
-
"stylelint-scss": "^7.
|
|
111
|
-
"vue-eslint-parser": "^10.
|
|
109
|
+
"stylelint-order": "^8.1.1",
|
|
110
|
+
"stylelint-scss": "^7.3.0",
|
|
111
|
+
"vue-eslint-parser": "^10.4.1"
|
|
112
112
|
},
|
|
113
113
|
"devDependencies": {
|
|
114
114
|
"@vitest/coverage-v8": "^5.0.3",
|
|
115
|
-
"knip": "^6.
|
|
115
|
+
"knip": "^6.41.0",
|
|
116
116
|
"sort-package-json": "^4.0.0",
|
|
117
117
|
"typescript": "^6.0.3",
|
|
118
118
|
"vitest": "^5.0.3",
|
|
119
|
-
"zod": "^4.
|
|
119
|
+
"zod": "^4.6.5"
|
|
120
120
|
},
|
|
121
121
|
"peerDependencies": {
|
|
122
122
|
"eslint": "^10.0.0",
|
|
@@ -0,0 +1,425 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Automatic updates of the dependencies of a package of the zairakai group.
|
|
4
|
+
*
|
|
5
|
+
* Usage: node update-dependencies.mjs update | release | tag | cascade
|
|
6
|
+
*
|
|
7
|
+
* - update: prepare a branch with the minor and patch updates, open a merge request into develop and merge it when its
|
|
8
|
+
* pipeline succeeds. The pipeline of the merge request is the gate: nothing is tested here.
|
|
9
|
+
* - release: open the merge request of develop into main after an update, and merge it when its pipeline succeeds.
|
|
10
|
+
* - tag: tag main when a runtime range changed since the last tag (see `tag` in .dependency-update.json).
|
|
11
|
+
* - cascade: after the publication of this package, start the update of the projects that use it.
|
|
12
|
+
*
|
|
13
|
+
* It reads `.dependency-update.json` at the root of the project (everything is optional):
|
|
14
|
+
*
|
|
15
|
+
* {
|
|
16
|
+
* "reject": ["typescript"], packages that are never updated
|
|
17
|
+
* "cooldown": "3d", minimum age of a new version
|
|
18
|
+
* "tag": { "when": "runtime", "level": "patch" }, when: runtime | always | never, level: patch | minor
|
|
19
|
+
* "cascade": { "groups": ["zairakai/npm-packages"] } projects to start (opt-in: they have the same file)
|
|
20
|
+
* }
|
|
21
|
+
*
|
|
22
|
+
* Environment: GITLAB_TOKEN (group variable), and the variables of GitLab CI.
|
|
23
|
+
*/
|
|
24
|
+
import { execFileSync } from 'node:child_process'
|
|
25
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
26
|
+
import { pathToFileURL } from 'node:url'
|
|
27
|
+
|
|
28
|
+
const CONFIG_FILE = '.dependency-update.json'
|
|
29
|
+
const COMMIT_SUBJECT = 'chore(deps): update the dependencies'
|
|
30
|
+
const BRANCH_PREFIX = 'chore/update-dependencies'
|
|
31
|
+
|
|
32
|
+
const DEFAULTS = {
|
|
33
|
+
reject: ['typescript'],
|
|
34
|
+
cooldown: '3d',
|
|
35
|
+
tag: { when: 'runtime', level: 'patch' },
|
|
36
|
+
cascade: null,
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const RUNTIME_SECTIONS = ['dependencies', 'peerDependencies', 'optionalDependencies']
|
|
40
|
+
const ALL_SECTIONS = ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies']
|
|
41
|
+
|
|
42
|
+
/** The configuration of the project, completed with the defaults. */
|
|
43
|
+
export function loadConfig(path = CONFIG_FILE) {
|
|
44
|
+
const user = existsSync(path) ? JSON.parse(readFileSync(path, 'utf8')) : {}
|
|
45
|
+
|
|
46
|
+
return { ...DEFAULTS, ...user, tag: { ...DEFAULTS.tag, ...user.tag } }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function compareVersions(a, b) {
|
|
50
|
+
const left = a.split('.').map(Number)
|
|
51
|
+
const right = b.split('.').map(Number)
|
|
52
|
+
|
|
53
|
+
return left[0] - right[0] || left[1] - right[1] || left[2] - right[2]
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** The highest tag of the form X.Y.Z (a tag with a prefix or a suffix is ignored), or null. */
|
|
57
|
+
export function latestTag(tags) {
|
|
58
|
+
return (
|
|
59
|
+
tags
|
|
60
|
+
.filter((tag) => /^\d+\.\d+\.\d+$/.test(tag))
|
|
61
|
+
.sort(compareVersions)
|
|
62
|
+
.at(-1) ?? null
|
|
63
|
+
)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** The version after `last` for a level of change (major, minor or patch). */
|
|
67
|
+
export function nextVersion(last, level) {
|
|
68
|
+
const [major, minor, patch] = last.split('.').map(Number)
|
|
69
|
+
|
|
70
|
+
if ('major' === level) return `${major + 1}.0.0`
|
|
71
|
+
if ('minor' === level) return `${major}.${minor + 1}.0`
|
|
72
|
+
|
|
73
|
+
return `${major}.${minor}.${patch + 1}`
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function sorted(section) {
|
|
77
|
+
return JSON.stringify(Object.entries(section ?? {}).sort(([a], [b]) => a.localeCompare(b)))
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** True when a range that is published (dependencies, peerDependencies, optionalDependencies) is not the same. */
|
|
81
|
+
export function runtimeChanged(before, after) {
|
|
82
|
+
return RUNTIME_SECTIONS.some((section) => sorted(before?.[section]) !== sorted(after?.[section]))
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** The dependencies whose range changed, with the section, the old range and the new one. */
|
|
86
|
+
export function diffDependencies(before, after) {
|
|
87
|
+
const rows = []
|
|
88
|
+
|
|
89
|
+
for (const section of ALL_SECTIONS) {
|
|
90
|
+
const old = before?.[section] ?? {}
|
|
91
|
+
const current = after?.[section] ?? {}
|
|
92
|
+
|
|
93
|
+
for (const name of new Set([...Object.keys(old), ...Object.keys(current)])) {
|
|
94
|
+
if (old[name] !== current[name]) {
|
|
95
|
+
rows.push({ section, name, from: old[name] ?? null, to: current[name] ?? null })
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return rows.sort((a, b) => a.section.localeCompare(b.section) || a.name.localeCompare(b.name))
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** The changes as a Markdown table, for the merge request and the commit. */
|
|
104
|
+
export function formatChanges(rows) {
|
|
105
|
+
const lines = ['| Section | Package | From | To |', '| --- | --- | --- | --- |']
|
|
106
|
+
|
|
107
|
+
for (const row of rows) {
|
|
108
|
+
lines.push(`| ${row.section} | ${row.name} | ${row.from ?? '-'} | ${row.to ?? '-'} |`)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return lines.join('\n')
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** What `tag` has to do: the version to create, or null (and why). */
|
|
115
|
+
export function planTag({ config, last, commits, before, after }) {
|
|
116
|
+
if ('never' === config.tag.when) return { version: null, reason: 'the project is never tagged by the update' }
|
|
117
|
+
if (!last) return { version: null, reason: 'there is no tag yet' }
|
|
118
|
+
if (0 === commits.length) return { version: null, reason: 'no update since the last tag' }
|
|
119
|
+
|
|
120
|
+
if ('runtime' === config.tag.when && !runtimeChanged(before, after)) {
|
|
121
|
+
return { version: null, reason: 'only the development dependencies changed: the published archive is the same' }
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return { version: nextVersion(last, config.tag.level), reason: 'a published range changed' }
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// ---------------------------------------------------------------------------------------------------------------------
|
|
128
|
+
|
|
129
|
+
const log = (message) => console.info(message)
|
|
130
|
+
|
|
131
|
+
function secret(text) {
|
|
132
|
+
const token = process.env.GITLAB_TOKEN
|
|
133
|
+
|
|
134
|
+
return token ? String(text).split(token).join('***') : String(text)
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function run(command, args = [], options = {}) {
|
|
138
|
+
try {
|
|
139
|
+
return execFileSync(command, args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], ...options }).trim()
|
|
140
|
+
} catch (error) {
|
|
141
|
+
throw new Error(secret(`${command} ${args.join(' ')} failed: ${error.stderr || error.message}`), { cause: error })
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async function api(method, path, body) {
|
|
146
|
+
const response = await fetch(`${process.env.CI_API_V4_URL ?? 'https://gitlab.com/api/v4'}${path}`, {
|
|
147
|
+
method,
|
|
148
|
+
headers: { 'PRIVATE-TOKEN': process.env.GITLAB_TOKEN ?? '', 'Content-Type': 'application/json' },
|
|
149
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
150
|
+
})
|
|
151
|
+
const text = await response.text()
|
|
152
|
+
|
|
153
|
+
return { status: response.status, ok: response.ok, data: parseBody(text) }
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function parseBody(text) {
|
|
157
|
+
if (!text) return null
|
|
158
|
+
|
|
159
|
+
try {
|
|
160
|
+
return JSON.parse(text)
|
|
161
|
+
} catch {
|
|
162
|
+
return text
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const sleep = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds))
|
|
167
|
+
|
|
168
|
+
function projectId() {
|
|
169
|
+
return process.env.CI_PROJECT_ID
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function assertEnvironment() {
|
|
173
|
+
for (const name of ['GITLAB_TOKEN', 'CI_PROJECT_ID', 'CI_PROJECT_PATH', 'CI_SERVER_HOST']) {
|
|
174
|
+
if (!process.env[name])
|
|
175
|
+
throw new Error(`${name} is not set: run this in GitLab CI, with the group variable GITLAB_TOKEN.`)
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// The runner clones the project with another user than the one of the job: Git refuses it otherwise.
|
|
179
|
+
run('git', ['config', '--global', '--add', 'safe.directory', process.cwd()])
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function configureGit() {
|
|
183
|
+
run('git', ['config', 'user.name', 'Stanislas Poisson (autoupdate)'])
|
|
184
|
+
run('git', ['config', 'user.email', 'contact@stanislas-poisson.fr'])
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function remoteUrl() {
|
|
188
|
+
return `https://oauth2:${process.env.GITLAB_TOKEN}@${process.env.CI_SERVER_HOST}/${process.env.CI_PROJECT_PATH}.git`
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
async function mergeRequestOptions() {
|
|
192
|
+
const user = await api('GET', '/user')
|
|
193
|
+
const milestones = await api('GET', `/projects/${projectId()}/milestones?state=active`)
|
|
194
|
+
const milestone = Array.isArray(milestones.data)
|
|
195
|
+
? (milestones.data.find((item) => 'Maintenance' === item.title) ?? milestones.data[0])
|
|
196
|
+
: null
|
|
197
|
+
|
|
198
|
+
return {
|
|
199
|
+
assignee_id: user.data.id,
|
|
200
|
+
reviewer_ids: [user.data.id],
|
|
201
|
+
labels: 'Kind::Chore',
|
|
202
|
+
...(milestone ? { milestone_id: milestone.id } : {}),
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** Merge the request when its pipeline succeeds. GitLab needs a moment to create the pipeline of a new request. */
|
|
207
|
+
async function mergeWhenGreen(iid) {
|
|
208
|
+
for (let attempt = 0; 18 > attempt; attempt++) {
|
|
209
|
+
const result = await api('PUT', `/projects/${projectId()}/merge_requests/${iid}/merge`, {
|
|
210
|
+
merge_when_pipeline_succeeds: true,
|
|
211
|
+
should_remove_source_branch: true,
|
|
212
|
+
})
|
|
213
|
+
|
|
214
|
+
if (result.ok) {
|
|
215
|
+
log(`Merge request !${iid}: it is merged when its pipeline succeeds.`)
|
|
216
|
+
return true
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
await sleep(10_000)
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
log(`Merge request !${iid}: it could not be set to merge by itself, it stays open.`)
|
|
223
|
+
|
|
224
|
+
return false
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
async function openMergeRequests(query = '') {
|
|
228
|
+
const result = await api('GET', `/projects/${projectId()}/merge_requests?state=opened&per_page=100${query}`)
|
|
229
|
+
|
|
230
|
+
return Array.isArray(result.data) ? result.data : []
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function ncu(args) {
|
|
234
|
+
return run('npx', ['--yes', 'npm-check-updates', '--target', 'minor', ...args, '-u'])
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// ---------------------------------------------------------------------------------------------------------------------
|
|
238
|
+
|
|
239
|
+
async function update(config) {
|
|
240
|
+
assertEnvironment()
|
|
241
|
+
|
|
242
|
+
if ((await openMergeRequests()).some((request) => request.source_branch.startsWith(BRANCH_PREFIX))) {
|
|
243
|
+
log('An update is already open: nothing to do.')
|
|
244
|
+
return
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
configureGit()
|
|
248
|
+
const before = JSON.parse(readFileSync('package.json', 'utf8'))
|
|
249
|
+
const branch = `${BRANCH_PREFIX}-${new Date().toISOString().slice(0, 10).replaceAll('-', '')}`
|
|
250
|
+
run('git', ['switch', '-c', branch])
|
|
251
|
+
|
|
252
|
+
// The new versions wait for the cooling period. The packages of the group are trusted: no waiting for them.
|
|
253
|
+
const reject = 0 < config.reject.length ? ['--reject', config.reject.join(',')] : []
|
|
254
|
+
ncu(['--cooldown', config.cooldown, ...reject])
|
|
255
|
+
ncu(['--filter', '@zairakai/*', ...reject])
|
|
256
|
+
run('npm', ['install', '--no-audit', '--no-fund'])
|
|
257
|
+
|
|
258
|
+
const after = JSON.parse(readFileSync('package.json', 'utf8'))
|
|
259
|
+
const rows = diffDependencies(before, after)
|
|
260
|
+
|
|
261
|
+
if ('' === run('git', ['status', '--porcelain'])) {
|
|
262
|
+
log('Everything is up to date.')
|
|
263
|
+
return
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const table = formatChanges(rows)
|
|
267
|
+
run('git', ['add', '-A'])
|
|
268
|
+
run('git', [
|
|
269
|
+
'commit',
|
|
270
|
+
'-m',
|
|
271
|
+
COMMIT_SUBJECT,
|
|
272
|
+
'-m',
|
|
273
|
+
`Minor and patch updates, taken after ${config.cooldown} of age.\n\n${table}`,
|
|
274
|
+
])
|
|
275
|
+
run('git', ['push', remoteUrl(), `HEAD:refs/heads/${branch}`])
|
|
276
|
+
|
|
277
|
+
const request = await api('POST', `/projects/${projectId()}/merge_requests`, {
|
|
278
|
+
source_branch: branch,
|
|
279
|
+
target_branch: 'develop',
|
|
280
|
+
title: COMMIT_SUBJECT,
|
|
281
|
+
description: `Automatic update. The pipeline of this merge request is the gate: it is merged when it succeeds, and stays open otherwise. Major versions are never applied here.\n\n${table}`,
|
|
282
|
+
remove_source_branch: true,
|
|
283
|
+
...(await mergeRequestOptions()),
|
|
284
|
+
})
|
|
285
|
+
|
|
286
|
+
if (!request.ok) throw new Error(`The merge request could not be created: ${secret(JSON.stringify(request.data))}`)
|
|
287
|
+
|
|
288
|
+
log(`Merge request !${request.data.iid} created.`)
|
|
289
|
+
await mergeWhenGreen(request.data.iid)
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
async function release() {
|
|
293
|
+
assertEnvironment()
|
|
294
|
+
|
|
295
|
+
const existing = await openMergeRequests('&source_branch=develop&target_branch=main')
|
|
296
|
+
|
|
297
|
+
if (0 < existing.length) {
|
|
298
|
+
await mergeWhenGreen(existing[0].iid)
|
|
299
|
+
return
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const request = await api('POST', `/projects/${projectId()}/merge_requests`, {
|
|
303
|
+
source_branch: 'develop',
|
|
304
|
+
target_branch: 'main',
|
|
305
|
+
title: COMMIT_SUBJECT,
|
|
306
|
+
description: 'Brings the automatic update of the dependencies into main. It is merged when its pipeline succeeds.',
|
|
307
|
+
...(await mergeRequestOptions()),
|
|
308
|
+
})
|
|
309
|
+
|
|
310
|
+
if (!request.ok) throw new Error(`The merge request could not be created: ${secret(JSON.stringify(request.data))}`)
|
|
311
|
+
|
|
312
|
+
log(`Merge request !${request.data.iid} created.`)
|
|
313
|
+
await mergeWhenGreen(request.data.iid)
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
async function tag(config) {
|
|
317
|
+
assertEnvironment()
|
|
318
|
+
|
|
319
|
+
run('git', ['fetch', '--tags', '--quiet'])
|
|
320
|
+
const last = latestTag(run('git', ['tag', '--list']).split('\n'))
|
|
321
|
+
const commits = last
|
|
322
|
+
? run('git', ['log', `${last}..HEAD`, '--format=%s', `--grep=^${COMMIT_SUBJECT.replace(/[()]/g, '\\$&')}`])
|
|
323
|
+
.split('\n')
|
|
324
|
+
.filter(Boolean)
|
|
325
|
+
: []
|
|
326
|
+
const before = last ? JSON.parse(run('git', ['show', `${last}:package.json`])) : null
|
|
327
|
+
const after = JSON.parse(readFileSync('package.json', 'utf8'))
|
|
328
|
+
|
|
329
|
+
const plan = planTag({ config, last, commits, before, after })
|
|
330
|
+
|
|
331
|
+
if (!plan.version) {
|
|
332
|
+
log(`No tag: ${plan.reason}.`)
|
|
333
|
+
return
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
const result = await api('POST', `/projects/${projectId()}/repository/tags`, {
|
|
337
|
+
tag_name: plan.version,
|
|
338
|
+
ref: process.env.CI_COMMIT_SHA,
|
|
339
|
+
message: plan.version,
|
|
340
|
+
})
|
|
341
|
+
|
|
342
|
+
if (!result.ok)
|
|
343
|
+
throw new Error(`The tag ${plan.version} could not be created: ${secret(JSON.stringify(result.data))}`)
|
|
344
|
+
|
|
345
|
+
log(`Tag ${plan.version} created (last tag ${last}): ${plan.reason}. Its pipeline publishes the package.`)
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
async function cascade(config) {
|
|
349
|
+
assertEnvironment()
|
|
350
|
+
|
|
351
|
+
if (!config.cascade) {
|
|
352
|
+
log('No cascade configured.')
|
|
353
|
+
return
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
const name = JSON.parse(readFileSync('package.json', 'utf8')).name
|
|
357
|
+
const version = process.env.CI_COMMIT_TAG
|
|
358
|
+
|
|
359
|
+
if (!version) throw new Error('The cascade runs on the pipeline of a tag.')
|
|
360
|
+
|
|
361
|
+
// The registry needs a few minutes to list a new version: wait for it instead of waiting for a fixed hour.
|
|
362
|
+
let available = false
|
|
363
|
+
|
|
364
|
+
for (let attempt = 0; 60 > attempt && !available; attempt++) {
|
|
365
|
+
try {
|
|
366
|
+
available = run('npm', ['view', `${name}@${version}`, 'version', '--prefer-online']) === version
|
|
367
|
+
} catch {
|
|
368
|
+
available = false
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
if (!available) await sleep(30_000)
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
if (!available) throw new Error(`${name}@${version} is not on the registry after 30 minutes.`)
|
|
375
|
+
|
|
376
|
+
for (const group of config.cascade.groups ?? []) {
|
|
377
|
+
const projects = await api(
|
|
378
|
+
'GET',
|
|
379
|
+
`/groups/${encodeURIComponent(group)}/projects?include_subgroups=true&archived=false&per_page=100`
|
|
380
|
+
)
|
|
381
|
+
|
|
382
|
+
for (const project of Array.isArray(projects.data) ? projects.data : []) {
|
|
383
|
+
if (String(project.id) === String(projectId())) continue
|
|
384
|
+
|
|
385
|
+
// Opt-in: a project is updated only if it has the same file.
|
|
386
|
+
const file = await api(
|
|
387
|
+
'GET',
|
|
388
|
+
`/projects/${project.id}/repository/files/${encodeURIComponent(CONFIG_FILE)}?ref=develop`
|
|
389
|
+
)
|
|
390
|
+
|
|
391
|
+
if (!file.ok) {
|
|
392
|
+
log(`${project.path_with_namespace}: no ${CONFIG_FILE}, skipped.`)
|
|
393
|
+
continue
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
const started = await api('POST', `/projects/${project.id}/pipeline`, {
|
|
397
|
+
ref: 'develop',
|
|
398
|
+
variables: [{ key: 'UPDATE_DEPENDENCIES', value: 'true' }],
|
|
399
|
+
})
|
|
400
|
+
|
|
401
|
+
log(`${project.path_with_namespace}: ${started.ok ? 'update started' : `not started (${started.status})`}.`)
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
const modes = { update, release, tag, cascade }
|
|
407
|
+
|
|
408
|
+
async function main(mode) {
|
|
409
|
+
if (!modes[mode]) {
|
|
410
|
+
console.error('Usage: node update-dependencies.mjs update | release | tag | cascade')
|
|
411
|
+
process.exitCode = 1
|
|
412
|
+
return
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
try {
|
|
416
|
+
await modes[mode](loadConfig())
|
|
417
|
+
} catch (error) {
|
|
418
|
+
console.error(secret(error.message))
|
|
419
|
+
process.exitCode = 1
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
424
|
+
await main(process.argv[2])
|
|
425
|
+
}
|