@zairakai/js-dev-tools 3.3.1 → 3.4.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.
@@ -77,6 +77,9 @@ variables:
77
77
  NODE_VERSION: "24"
78
78
  NODE_IMAGE: "zairakai/node:latest-dev"
79
79
  NODE_TEST_IMAGE: "zairakai/node:latest-test"
80
+ # The jobs of the automatic updates sign with gpg: the image has it since 1.3.0. It is an exact version, published on
81
+ # Docker Hub like our other images: the runner pulls it, whatever the old image it keeps locally.
82
+ NODE_UPDATE_IMAGE: "zairakai/node:1.3.0-dev"
80
83
  SHELLCHECK_IMAGE: "koalaman/shellcheck-alpine:v0.10.0"
81
84
  RELEASE_CLI_IMAGE: "registry.gitlab.com/gitlab-org/release-cli:v0.18.0"
82
85
  ALPINE_IMAGE: "alpine:3.18"
@@ -134,6 +137,8 @@ variables:
134
137
  secret_detection:
135
138
  stage: security
136
139
  rules:
140
+ - if: $UPDATE_DEPENDENCIES == "true"
141
+ when: never
137
142
  - if: $CI_PIPELINE_SOURCE == "merge_request_event" || $CI_MERGE_REQUEST_ID
138
143
  - if: $CI_COMMIT_BRANCH == "main" || $CI_COMMIT_BRANCH == "develop"
139
144
 
@@ -144,6 +149,8 @@ js:tag-format:
144
149
  stage: security
145
150
  image: ${ALPINE_IMAGE}
146
151
  rules:
152
+ - if: $UPDATE_DEPENDENCIES == "true"
153
+ when: never
147
154
  - if: $CI_COMMIT_TAG
148
155
  script:
149
156
  - |
@@ -166,6 +173,10 @@ js:tag-format:
166
173
  js:install:
167
174
  <<: [*base_job, *cache_node_push]
168
175
  stage: install
176
+ rules:
177
+ - if: $UPDATE_DEPENDENCIES == "true"
178
+ when: never
179
+ - when: on_success
169
180
  image: ${NODE_IMAGE}
170
181
  script:
171
182
  - npm ci --cache .npm --prefer-offline
@@ -184,6 +195,10 @@ js:install:
184
195
  js:validate:markdownlint:
185
196
  <<: [*base_job, *cache_node]
186
197
  stage: validate
198
+ rules:
199
+ - if: $UPDATE_DEPENDENCIES == "true"
200
+ when: never
201
+ - when: on_success
187
202
  image: ${NODE_IMAGE}
188
203
  script:
189
204
  - bash "$JS_DEV_TOOLS_SCRIPTS/markdownlint.sh"
@@ -191,6 +206,10 @@ js:validate:markdownlint:
191
206
  js:validate:shellcheck:
192
207
  <<: *base_job
193
208
  stage: validate
209
+ rules:
210
+ - if: $UPDATE_DEPENDENCIES == "true"
211
+ when: never
212
+ - when: on_success
194
213
  image: ${SHELLCHECK_IMAGE}
195
214
  before_script:
196
215
  - apk add --no-cache bash
@@ -208,6 +227,10 @@ js:validate:shellcheck:
208
227
  js:quality:lint:
209
228
  <<: *job_node
210
229
  stage: quality
230
+ rules:
231
+ - if: $UPDATE_DEPENDENCIES == "true"
232
+ when: never
233
+ - when: on_success
211
234
  script:
212
235
  - bash "$JS_DEV_TOOLS_SCRIPTS/eslint.sh"
213
236
  artifacts:
@@ -221,18 +244,30 @@ js:quality:lint:
221
244
  js:quality:format:
222
245
  <<: *job_node
223
246
  stage: quality
247
+ rules:
248
+ - if: $UPDATE_DEPENDENCIES == "true"
249
+ when: never
250
+ - when: on_success
224
251
  script:
225
252
  - bash "$JS_DEV_TOOLS_SCRIPTS/prettier.sh"
226
253
 
227
254
  js:quality:stylelint:
228
255
  <<: *job_node
229
256
  stage: quality
257
+ rules:
258
+ - if: $UPDATE_DEPENDENCIES == "true"
259
+ when: never
260
+ - when: on_success
230
261
  script:
231
262
  - bash "$JS_DEV_TOOLS_SCRIPTS/stylelint.sh"
232
263
 
233
264
  js:quality:typecheck:
234
265
  <<: *job_node
235
266
  stage: quality
267
+ rules:
268
+ - if: $UPDATE_DEPENDENCIES == "true"
269
+ when: never
270
+ - when: on_success
236
271
  script:
237
272
  - |
238
273
  if [ -f tsconfig.json ] && [ -f node_modules/.bin/tsc ]; then
@@ -244,12 +279,20 @@ js:quality:typecheck:
244
279
  js:quality:knip:
245
280
  <<: *job_node
246
281
  stage: quality
282
+ rules:
283
+ - if: $UPDATE_DEPENDENCIES == "true"
284
+ when: never
285
+ - when: on_success
247
286
  script:
248
287
  - bash "$JS_DEV_TOOLS_SCRIPTS/knip.sh"
249
288
 
250
289
  js:quality:bats:
251
290
  <<: [*base_job, *cache_node]
252
291
  stage: quality
292
+ rules:
293
+ - if: $UPDATE_DEPENDENCIES == "true"
294
+ when: never
295
+ - when: on_success
253
296
  image: ${ALPINE_IMAGE}
254
297
  before_script:
255
298
  - apk add --no-cache bash bats git
@@ -265,6 +308,8 @@ js:quality:outdated:
265
308
  <<: *job_node
266
309
  stage: quality
267
310
  rules:
311
+ - if: $UPDATE_DEPENDENCIES == "true"
312
+ when: never
268
313
  - if: $CI_PIPELINE_SOURCE == "schedule"
269
314
  script:
270
315
  - bash "$JS_DEV_TOOLS_SCRIPTS/outdated.sh"
@@ -280,6 +325,10 @@ js:quality:outdated:
280
325
  js:test:
281
326
  <<: [*base_job, *cache_node]
282
327
  stage: test
328
+ rules:
329
+ - if: $UPDATE_DEPENDENCIES == "true"
330
+ when: never
331
+ - when: on_success
283
332
  image: ${NODE_TEST_IMAGE}
284
333
  script:
285
334
  - CI=true bash "$JS_DEV_TOOLS_SCRIPTS/test.sh"
@@ -314,6 +363,8 @@ js:build:
314
363
  expire_in: 1 week
315
364
  when: on_success
316
365
  rules:
366
+ - if: $UPDATE_DEPENDENCIES == "true"
367
+ when: never
317
368
  - if: $CI_COMMIT_TAG =~ $VERSION_TAG_REGEX
318
369
  - if: $CI_COMMIT_BRANCH == "main" || $CI_COMMIT_BRANCH == "develop"
319
370
  - if: $CI_PIPELINE_SOURCE == "merge_request_event" || $CI_MERGE_REQUEST_ID
@@ -326,6 +377,8 @@ js:publish:
326
377
  stage: publish
327
378
  image: ${NODE_IMAGE}
328
379
  rules:
380
+ - if: $UPDATE_DEPENDENCIES == "true"
381
+ when: never
329
382
  - if: $CI_COMMIT_TAG =~ $VERSION_TAG_REGEX
330
383
  script:
331
384
  - |
@@ -356,6 +409,8 @@ js:release:
356
409
  stage: release
357
410
  image: ${RELEASE_CLI_IMAGE}
358
411
  rules:
412
+ - if: $UPDATE_DEPENDENCIES == "true"
413
+ when: never
359
414
  - if: $CI_COMMIT_TAG =~ $VERSION_TAG_REGEX
360
415
  before_script:
361
416
  - apk add --no-cache bash git jq
@@ -397,11 +452,12 @@ js:release:
397
452
  deps:update:
398
453
  <<: *base_job
399
454
  stage: install
400
- image: ${NODE_IMAGE}
455
+ image: ${NODE_UPDATE_IMAGE}
401
456
  needs: []
402
457
  script:
403
458
  - node --version
404
459
  - "[ -d node_modules ] || npm ci --no-audit --no-fund"
460
+ - '[ "${JS_DEV_TOOLS_SCRIPTS}" = "scripts" ] || npm install --no-save --no-audit --no-fund "@zairakai/js-dev-tools@latest"'
405
461
  - node "${JS_DEV_TOOLS_SCRIPTS}/update-dependencies.mjs" update
406
462
  rules:
407
463
  - if: $UPDATE_DEPENDENCIES == "true" && $CI_COMMIT_BRANCH == "develop"
@@ -421,7 +477,7 @@ deps:release:
421
477
  deps:tag:
422
478
  <<: *base_job
423
479
  stage: release
424
- image: ${NODE_IMAGE}
480
+ image: ${NODE_UPDATE_IMAGE}
425
481
  variables:
426
482
  GIT_DEPTH: 0
427
483
  script:
package/README.md CHANGED
@@ -178,6 +178,8 @@ The pipeline template updates the dependencies of a project by itself, and this
178
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
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
180
 
181
+ The same script serves the PHP projects (`composer.json`): a job in the PHP image runs `composer update`, then this script commits, signs and opens the merge request, and `deps:tag` looks at `require` only. The jobs that sign (`deps:update`, `deps:tag`) run on an exact version of the node image (`NODE_UPDATE_IMAGE`, published on Docker Hub), because they need `gpg` and the image of the runner is not updated by itself.
182
+
181
183
  The exceptions of a project live in `.dependency-update.json` at its root (everything is optional):
182
184
 
183
185
  ```json
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zairakai/js-dev-tools",
3
- "version": "3.3.1",
3
+ "version": "3.4.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",
@@ -39,6 +39,17 @@ const DEFAULTS = {
39
39
  const RUNTIME_SECTIONS = ['dependencies', 'peerDependencies', 'optionalDependencies']
40
40
  const ALL_SECTIONS = ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies']
41
41
 
42
+ /** The manifest of each ecosystem, with the sections that are published and all of them. */
43
+ const ECOSYSTEMS = {
44
+ npm: { file: 'package.json', runtime: RUNTIME_SECTIONS, all: ALL_SECTIONS },
45
+ composer: { file: 'composer.json', runtime: ['require'], all: ['require', 'require-dev'] },
46
+ }
47
+
48
+ /** The ecosystem of the project: a package.json at its root, otherwise a composer.json. */
49
+ export function detectEcosystem(exists = existsSync) {
50
+ return exists('package.json') ? 'npm' : 'composer'
51
+ }
52
+
42
53
  /** The configuration of the project, completed with the defaults. */
43
54
  export function loadConfig(path = CONFIG_FILE) {
44
55
  const user = existsSync(path) ? JSON.parse(readFileSync(path, 'utf8')) : {}
@@ -78,15 +89,15 @@ function sorted(section) {
78
89
  }
79
90
 
80
91
  /** 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]))
92
+ export function runtimeChanged(before, after, sections = RUNTIME_SECTIONS) {
93
+ return sections.some((section) => sorted(before?.[section]) !== sorted(after?.[section]))
83
94
  }
84
95
 
85
96
  /** The dependencies whose range changed, with the section, the old range and the new one. */
86
- export function diffDependencies(before, after) {
97
+ export function diffDependencies(before, after, sections = ALL_SECTIONS) {
87
98
  const rows = []
88
99
 
89
- for (const section of ALL_SECTIONS) {
100
+ for (const section of sections) {
90
101
  const old = before?.[section] ?? {}
91
102
  const current = after?.[section] ?? {}
92
103
 
@@ -129,12 +140,12 @@ export function signerIdentity(env) {
129
140
  }
130
141
 
131
142
  /** What `tag` has to do: the version to create, or null (and why). */
132
- export function planTag({ config, last, commits, before, after }) {
143
+ export function planTag({ config, last, commits, before, after, sections = RUNTIME_SECTIONS }) {
133
144
  if ('never' === config.tag.when) return { version: null, reason: 'the project is never tagged by the update' }
134
145
  if (!last) return { version: null, reason: 'there is no tag yet' }
135
146
  if (0 === commits.length) return { version: null, reason: 'no update since the last tag' }
136
147
 
137
- if ('runtime' === config.tag.when && !runtimeChanged(before, after)) {
148
+ if ('runtime' === config.tag.when && !runtimeChanged(before, after, sections)) {
138
149
  return { version: null, reason: 'only the development dependencies changed: the published archive is the same' }
139
150
  }
140
151
 
@@ -196,16 +207,40 @@ function assertEnvironment() {
196
207
  run('git', ['config', '--global', '--add', 'safe.directory', process.cwd()])
197
208
  }
198
209
 
210
+ /** GPG is not in the image of the CI: add it with the package manager of the image (Alpine or Debian). */
211
+ function ensureGpg() {
212
+ const available = () => {
213
+ try {
214
+ run('gpg', ['--version'])
215
+ return true
216
+ } catch {
217
+ return false
218
+ }
219
+ }
220
+
221
+ if (available()) return
222
+
223
+ for (const [command, args] of [
224
+ ['apk', ['add', '--no-cache', 'gnupg']],
225
+ ['apt-get', ['install', '-y', '--no-install-recommends', 'gnupg']],
226
+ ]) {
227
+ try {
228
+ run(command, args)
229
+ } catch {
230
+ continue
231
+ }
232
+
233
+ if (available()) return
234
+ }
235
+
236
+ throw new Error('gpg is not installed and could not be added: add gnupg to the image of the job.')
237
+ }
238
+
199
239
  /** Import the signing key and make Git sign the commits and the tags with it. */
200
240
  function configureSigning() {
201
241
  const signer = signerIdentity(process.env)
202
242
 
203
- try {
204
- run('gpg', ['--version'])
205
- } catch {
206
- run('apt-get', ['update'])
207
- run('apt-get', ['install', '-y', '--no-install-recommends', 'gnupg'])
208
- }
243
+ ensureGpg()
209
244
 
210
245
  run('gpg', ['--batch', '--import'], { input: signer.key, stdio: ['pipe', 'pipe', 'pipe'] })
211
246
  const fingerprint = run('gpg', ['--list-secret-keys', '--with-colons'])
@@ -281,18 +316,22 @@ async function update(config) {
281
316
  }
282
317
 
283
318
  configureSigning()
284
- const before = JSON.parse(readFileSync('package.json', 'utf8'))
319
+ const ecosystem = ECOSYSTEMS[detectEcosystem()]
320
+ const before = JSON.parse(run('git', ['show', `HEAD:${ecosystem.file}`]))
285
321
  const branch = `${BRANCH_PREFIX}-${new Date().toISOString().slice(0, 10).replaceAll('-', '')}`
286
322
  run('git', ['switch', '-c', branch])
287
323
 
288
- // The new versions wait for the cooling period. The packages of the group are trusted: no waiting for them.
289
- const reject = 0 < config.reject.length ? ['--reject', config.reject.join(',')] : []
290
- ncu(['--cooldown', config.cooldown, ...reject])
291
- ncu(['--filter', '@zairakai/*', ...reject])
292
- run('npm', ['install', '--no-audit', '--no-fund'])
324
+ // A PHP project was prepared by the job before this one, in its own image: only npm is updated here.
325
+ if ('package.json' === ecosystem.file) {
326
+ // The new versions wait for the cooling period. The packages of the group are trusted: no waiting for them.
327
+ const reject = 0 < config.reject.length ? ['--reject', config.reject.join(',')] : []
328
+ ncu(['--cooldown', config.cooldown, ...reject])
329
+ ncu(['--filter', '@zairakai/*', ...reject])
330
+ run('npm', ['install', '--no-audit', '--no-fund'])
331
+ }
293
332
 
294
- const after = JSON.parse(readFileSync('package.json', 'utf8'))
295
- const rows = diffDependencies(before, after)
333
+ const after = JSON.parse(readFileSync(ecosystem.file, 'utf8'))
334
+ const rows = diffDependencies(before, after, ecosystem.all)
296
335
 
297
336
  if ('' === run('git', ['status', '--porcelain'])) {
298
337
  log('Everything is up to date.')
@@ -359,10 +398,11 @@ async function tag(config) {
359
398
  .split('\n')
360
399
  .filter(Boolean)
361
400
  : []
362
- const before = last ? JSON.parse(run('git', ['show', `${last}:package.json`])) : null
363
- const after = JSON.parse(readFileSync('package.json', 'utf8'))
401
+ const ecosystem = ECOSYSTEMS[detectEcosystem()]
402
+ const before = last ? JSON.parse(run('git', ['show', `${last}:${ecosystem.file}`])) : null
403
+ const after = JSON.parse(readFileSync(ecosystem.file, 'utf8'))
364
404
 
365
- const plan = planTag({ config, last, commits, before, after })
405
+ const plan = planTag({ config, last, commits, before, after, sections: ecosystem.runtime })
366
406
 
367
407
  if (!plan.version) {
368
408
  log(`No tag: ${plan.reason}.`)
@@ -376,6 +416,21 @@ async function tag(config) {
376
416
  log(`Tag ${plan.version} signed and pushed (last tag ${last}): ${plan.reason}. Its pipeline publishes the package.`)
377
417
  }
378
418
 
419
+ /** True when the registry of the ecosystem (npm or Packagist) lists the version. */
420
+ async function isAvailable(ecosystem, name, version) {
421
+ if ('npm' === ecosystem) {
422
+ return run('npm', ['view', `${name}@${version}`, 'version', '--prefer-online']) === version
423
+ }
424
+
425
+ const response = await fetch(`https://repo.packagist.org/p2/${name}.json`)
426
+
427
+ if (!response.ok) return false
428
+
429
+ const data = await response.json()
430
+
431
+ return (data.packages?.[name] ?? []).some((release) => release.version === version)
432
+ }
433
+
379
434
  async function cascade(config) {
380
435
  assertEnvironment()
381
436
 
@@ -384,7 +439,8 @@ async function cascade(config) {
384
439
  return
385
440
  }
386
441
 
387
- const name = JSON.parse(readFileSync('package.json', 'utf8')).name
442
+ const ecosystem = detectEcosystem()
443
+ const name = JSON.parse(readFileSync(ECOSYSTEMS[ecosystem].file, 'utf8')).name
388
444
  const version = process.env.CI_COMMIT_TAG
389
445
 
390
446
  if (!version) throw new Error('The cascade runs on the pipeline of a tag.')
@@ -394,7 +450,7 @@ async function cascade(config) {
394
450
 
395
451
  for (let attempt = 0; 60 > attempt && !available; attempt++) {
396
452
  try {
397
- available = run('npm', ['view', `${name}@${version}`, 'version', '--prefer-online']) === version
453
+ available = await isAvailable(ecosystem, name, version)
398
454
  } catch {
399
455
  available = false
400
456
  }