@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.
@@ -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: <https://js-dev-tools-3fa149.gitlab.io>**
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.1.0",
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.0.3",
92
- "@eslint/js": "^10.0.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.40.0",
95
- "@typescript-eslint/parser": "^8.40.0",
96
- "eslint": "^10.0.0",
97
- "eslint-config-prettier": "^10.0.0",
98
- "eslint-plugin-vue": "^10.0.0",
99
- "globals": "^17.5.0",
100
- "markdownlint-cli2": "^0.23.0",
101
- "prettier": "^3.8.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.0.0",
104
- "stylelint": "^17.8.0",
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.0.0",
110
- "stylelint-scss": "^7.0.0",
111
- "vue-eslint-parser": "^10.0.0"
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.4.1",
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.0.0"
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
+ }