fg-devkit 1.21.0 → 1.21.2

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/README.md CHANGED
@@ -72,8 +72,7 @@ pnpm exec fgd sync-dependency-check-suppressions-commit --all # every entry in
72
72
 
73
73
  The command runs `pnpm audit` and keeps only the active (not expired) `<suppress>` entries from fg-devkit's shared
74
74
  `configs/dependency-check-suppressions.xml` whose GHSA id is reported in the repo, so each repo accepts exactly the
75
- risks it has. Jenkins `owaspDependencyCheck` reads `dependency-check-suppressions.xml` from the repo root when the
76
- file exists. Add another `<suppress>` element in the shared fg-devkit file for each additional accepted finding.
75
+ risks it has. OWASP Dependency-Check reads the file when it is configured as a suppression file. Add another `<suppress>` element in the shared fg-devkit file for each additional accepted finding.
77
76
  The command overwrites the consumer copy; if nothing applies and the repo has no copy, no file is created.
78
77
 
79
78
  To sync standard consumer scripts to `fgd` commands:
@@ -174,15 +173,18 @@ pnpm exec fgd audit-override-commit --no-commit # leave the changes in the work
174
173
  younger than `minimumReleaseAge` (re-run later), when the package is a direct dependency (bump it with
175
174
  `policy-upgrade-commit`), or when no patch exists (candidate for `auditConfig.ignoreGhsas`).
176
175
  4. Prunes overrides that no longer change the audit.
177
- 5. If findings that are still reported have active (not expired) `<suppress>` entries in fg-devkit's shared
176
+ 5. Removes `minimumReleaseAgeExclude` entries that are no longer needed (see `prune-release-age-exclude` below).
177
+ This step also runs when `pnpm audit` is clean.
178
+ 6. If findings that are still reported have active (not expired) `<suppress>` entries in fg-devkit's shared
178
179
  `configs/dependency-check-suppressions.xml`, writes the repo's `dependency-check-suppressions.xml` with only
179
- those entries, so the Jenkins OWASP scan accepts exactly the risks this repo has. Findings with no patch and
180
+ those entries, so OWASP Dependency-Check accepts exactly the risks this repo has. Findings with no patch and
180
181
  no suppression are listed so you can add one.
181
- 6. Installs and runs the repo's `lint-check`, `lint:ts`, `test`, `test:unit` and `build` scripts when present.
182
+ 7. Installs and runs the repo's `lint-check`, `lint:ts`, `test`, `test:unit` and `build` scripts when present.
182
183
  Nothing is committed if a check fails.
183
- 7. Commits the lockfile refresh (`vuln(package): refresh lockfile` / `vuln(): refresh N packages in lockfile`),
184
- the override changes (`vuln(package): override version`, `vuln(): override N packages`) and the
185
- suppressions file (`chore(repo): sync dependency-check suppressions`) separately.
184
+ 8. Commits the lockfile refresh (`vuln(package): refresh lockfile` / `vuln(): refresh N packages in lockfile`),
185
+ the override changes (`vuln(package): override version`, `vuln(): override N packages`), the release-age
186
+ excludes (`chore(repo): prune minimumReleaseAgeExclude`) and the suppressions file
187
+ (`chore(repo): sync dependency-check suppressions`) separately.
186
188
 
187
189
  Report, remove, or remove and auto-commit workspace overrides that no longer change `pnpm audit`:
188
190
 
@@ -192,6 +194,20 @@ pnpm exec fgd prune-overrides --fix
192
194
  pnpm exec fgd prune-overrides --commit
193
195
  ```
194
196
 
197
+ Report, remove, or remove and auto-commit `minimumReleaseAgeExclude` entries that are no longer needed:
198
+
199
+ ```bash
200
+ pnpm exec fgd prune-release-age-exclude
201
+ pnpm exec fgd prune-release-age-exclude --fix
202
+ pnpm exec fgd prune-release-age-exclude --commit
203
+ ```
204
+
205
+ A pinned version (`name@1.2.3` or `name@1.2.3 || 1.2.4`) is only needed while it is in the lockfile and younger than
206
+ `minimumReleaseAge` (1440 minutes unless configured). Versions that are old enough, or no longer installed, are
207
+ removed, and an entry with no versions left is removed entirely. Name rules such as `my-package` or `@scope/*` allow
208
+ every future release on purpose and are always kept. With `--fix` the lockfile is re-verified against the release-age
209
+ policy, and `pnpm-workspace.yaml` is restored if pnpm rejects it.
210
+
195
211
  ## tsconfig in consuming repos
196
212
 
197
213
  Use `fgd sync-project-configs` (or `fgd sync-tooling-configs`) to write the local `tsconfig.json` template from `fg-devkit`.
@@ -217,9 +233,9 @@ export default {
217
233
 
218
234
  ## Piwik PRO custom dimensions
219
235
 
220
- Compare the local slot map with a Piwik PRO site, then create missing dimensions or rename existing ones. Run it from a consuming repo (`bokningsverktyget-ui`, `webbportalen-ui`, `feelgood-app`, and the same layout in other apps) or from the Electron maintainer UI under **Piwik**.
236
+ Compare the local slot map with a Piwik PRO site, then create missing dimensions or rename existing ones. Run it from a consuming repo.
221
237
 
222
- The local map is `src/analytics/dimensions.ts` (`customDimensions` = event, `sessionDimensions` = session) or `src/util/piwikDimensions.ts` (`CustomDimensions` / `SessionCustomDimensions`). The site defaults to the first non-production entry in `src/config/env.ts`, such as preprod. Pass `--env production` to target production. In the Electron app, Piwik actions show one radio per distinct `piwikId`. Environments that share an id, such as localhost, development, and preprod, appear once as **preprod**.
238
+ The local map is `src/analytics/dimensions.ts` (`customDimensions` = event, `sessionDimensions` = session) or `src/util/piwikDimensions.ts` (`CustomDimensions` / `SessionCustomDimensions`). The site defaults to the first non-production entry in `src/config/env.ts`, such as preprod. Pass `--env production` to target production.
223
239
 
224
240
  ```bash
225
241
  pnpm exec fgd piwik list
@@ -232,7 +248,7 @@ pnpm exec fgd piwik compare --from preprod --to production
232
248
  pnpm exec fgd piwik migrate --from preprod --to production --dry-run
233
249
  ```
234
250
 
235
- `sync` copies the local map up to the selected Piwik site: it creates missing dimensions, then renames existing ones. It does not edit the repo. `pull` copies tracking ids from that site down into the local map for keys with the same name. In the Electron app, **Sync** has From and To radios. **Local map** is the repo. A site as To writes up to Piwik. **Local map** as To writes down into the repo. Two sites migrate between environments.
251
+ `sync` copies the local map up to the selected Piwik site: it creates missing dimensions, then renames existing ones. It does not edit the repo. `pull` copies tracking ids from that site down into the local map for keys with the same name.
236
252
 
237
253
  `compare` checks two sites from `src/config/env.ts` against each other. Event and session dimensions match on `tracking_id`, so a different slot on the other site is still in sync. `migrate` copies names, descriptions, and missing dimensions from `--from` onto `--to`, one create at a time. It stops if Piwik assigns a different `tracking_id`. Dimensions that exist only on the target stay there.
238
254
 
@@ -0,0 +1,16 @@
1
+ import type { Linter } from 'eslint'
2
+
3
+ export interface CreateBaseConfigOptions {
4
+ tsconfigRootDir?: string
5
+ tsconfigFiles?: string[]
6
+ additionalIgnores?: string[]
7
+ extraNonTypeAwareFiles?: string[]
8
+ enablePrettier?: boolean
9
+ enableI18n?: boolean
10
+ i18nLocaleDir?: string
11
+ additionalRules?: Linter.RulesRecord
12
+ additionalPlugins?: NonNullable<Linter.Config['plugins']>
13
+ additionalSettings?: NonNullable<Linter.Config['settings']>
14
+ }
15
+
16
+ export default function createBaseConfig(options?: CreateBaseConfigOptions): Linter.Config[]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fg-devkit",
3
- "version": "1.21.0",
3
+ "version": "1.21.2",
4
4
  "description": "Shared configs, scripts, and DX tooling for Vue / TypeScript frontend repos",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
@@ -13,7 +13,10 @@
13
13
  "exports": {
14
14
  "./tsconfig.base.json": "./configs/tsconfig.base.json",
15
15
  "./tsconfig.app.sync.json": "./configs/tsconfig.app.sync.json",
16
- "./eslint.config.base.js": "./configs/eslint.config.base.js",
16
+ "./eslint.config.base.js": {
17
+ "types": "./configs/eslint.config.base.d.ts",
18
+ "default": "./configs/eslint.config.base.js"
19
+ },
17
20
  "./prettier.config.js": "./configs/prettier.config.js",
18
21
  "./lefthook.yml": "./configs/lefthook.yml",
19
22
  "./vscode-settings.json": "./configs/vscode-settings.json",
@@ -32,7 +32,7 @@
32
32
  "@intlify/unplugin-vue-i18n": "11.2.5",
33
33
  "@tanstack/vue-query-devtools": "6.3.1",
34
34
  "@types/node": "22.16.5",
35
- "fg-devkit": "1.21.0",
35
+ "fg-devkit": "1.21.2",
36
36
  "@vitejs/plugin-vue": "6.0.9",
37
37
  "tailwindcss": "4.3.3",
38
38
  "tsx": "4.23.15",
@@ -434,3 +434,137 @@ export function filterSuppressions(xmlText, ghsas, now = Date.now()) {
434
434
  }
435
435
  return result
436
436
  }
437
+
438
+ // ---------------------------------------------------------------------------
439
+ // minimumReleaseAgeExclude pruning
440
+ // ---------------------------------------------------------------------------
441
+
442
+ /**
443
+ * Split a minimumReleaseAgeExclude entry into its package name and exact versions.
444
+ * `versions` is null for name rules (`fg-devkit`, `@scope/*`), which allow every
445
+ * future release of that package on purpose and are never pruned.
446
+ */
447
+ export function parseReleaseAgeExcludeEntry(entry) {
448
+ const text = String(entry).trim()
449
+ const at = text.lastIndexOf('@')
450
+ if (at <= 0) return { name: text, versions: null }
451
+ return {
452
+ name: text.slice(0, at),
453
+ versions: text
454
+ .slice(at + 1)
455
+ .split('||')
456
+ .map((version) => version.trim())
457
+ .filter(Boolean),
458
+ }
459
+ }
460
+
461
+ /**
462
+ * Decide which version-pinned minimumReleaseAgeExclude entries are still needed.
463
+ *
464
+ * A version is still needed only while it is resolved in the lockfile *and* younger than
465
+ * minimumReleaseAge. Anything else (old enough now, or no longer installed) is dropped.
466
+ * Name rules, patterns, non-exact versions and versions without a known publish time are
467
+ * kept unchanged, so nothing is removed on a guess.
468
+ *
469
+ * @param {string[]} entries
470
+ * @param {{
471
+ * lockfileVersions: Map<string, Set<string>>,
472
+ * publishTime: (name: string, version: string) => number | undefined,
473
+ * minimumReleaseAgeMinutes: number,
474
+ * now?: number,
475
+ * }} context
476
+ * @returns {{ entry: string, next: string | null, dropped: { version: string, reason: 'old-enough' | 'not-installed' }[] }[]}
477
+ * `next` is the entry to keep (`null` = remove the entry, same string = unchanged).
478
+ */
479
+ export function planReleaseAgeExcludePrune(entries, context) {
480
+ const now = context.now ?? Date.now()
481
+ const maxAgeMs = context.minimumReleaseAgeMinutes * 60000
482
+ return entries.map((entry) => {
483
+ const { name, versions } = parseReleaseAgeExcludeEntry(entry)
484
+ if (versions === null || name.includes('*') || versions.length === 0) {
485
+ return { entry, next: entry, dropped: [] }
486
+ }
487
+ if (versions.some((version) => !parseVersion(version))) return { entry, next: entry, dropped: [] }
488
+
489
+ const kept = []
490
+ const dropped = []
491
+ for (const version of versions) {
492
+ if (!context.lockfileVersions.get(name)?.has(version)) {
493
+ dropped.push({ version, reason: 'not-installed' })
494
+ continue
495
+ }
496
+ const published = context.publishTime(name, version)
497
+ if (published === undefined || !Number.isFinite(published)) {
498
+ kept.push(version)
499
+ } else if (now - published >= maxAgeMs) {
500
+ dropped.push({ version, reason: 'old-enough' })
501
+ } else {
502
+ kept.push(version)
503
+ }
504
+ }
505
+ if (dropped.length === 0) return { entry, next: entry, dropped }
506
+ return { entry, next: kept.length > 0 ? `${name}@${kept.join(' || ')}` : null, dropped }
507
+ })
508
+ }
509
+
510
+ /** Items of a top-level YAML block list (`key:` followed by ` - item` lines). */
511
+ export function readWorkspaceList(text, key) {
512
+ const lines = String(text ?? '').split('\n')
513
+ const start = lines.findIndex((line) => line === `${key}:` || line.startsWith(`${key}: #`))
514
+ if (start === -1) return []
515
+ const items = []
516
+ for (let i = start + 1; i < lines.length; i += 1) {
517
+ const line = lines[i]
518
+ if (line.trim() === '' || line.trim().startsWith('#')) continue
519
+ const item = line.match(/^\s+-\s+(.*?)\s*$/)
520
+ if (!item) break
521
+ items.push(unquoteYaml(item[1]))
522
+ }
523
+ return items
524
+ }
525
+
526
+ function renderYamlListItem(value) {
527
+ return /^[\w@./-][\w@./ |<>=*^~-]*$/.test(value) && !/[:#]/.test(value) && !value.startsWith('@')
528
+ ? value
529
+ : `'${value.replace(/'/g, "''")}'`
530
+ }
531
+
532
+ /**
533
+ * Apply `{ entry -> next }` replacements to a top-level YAML block list, keeping
534
+ * indentation, quoting style and comments. `next === null` removes the item; the key is
535
+ * removed too when its list becomes empty.
536
+ */
537
+ export function rewriteWorkspaceList(text, key, replacements) {
538
+ const lines = String(text ?? '').split('\n')
539
+ const start = lines.findIndex((line) => line === `${key}:` || line.startsWith(`${key}: #`))
540
+ if (start === -1) return text
541
+ let remaining = 0
542
+ let end = start + 1
543
+ const output = lines.slice(0, start + 1)
544
+ for (; end < lines.length; end += 1) {
545
+ const line = lines[end]
546
+ if (line.trim() === '' || line.trim().startsWith('#')) {
547
+ output.push(line)
548
+ continue
549
+ }
550
+ const item = line.match(/^(\s+-\s+)(.*?)\s*$/)
551
+ if (!item) break
552
+ const value = unquoteYaml(item[2])
553
+ if (!replacements.has(value)) {
554
+ output.push(line)
555
+ remaining += 1
556
+ continue
557
+ }
558
+ const next = replacements.get(value)
559
+ if (next === null) continue
560
+ const quoted = /^['"]/.test(item[2]) ? `'${next.replace(/'/g, "''")}'` : renderYamlListItem(next)
561
+ output.push(`${item[1]}${quoted}`)
562
+ remaining += 1
563
+ }
564
+ if (remaining === 0) {
565
+ output.splice(start, output.length - start)
566
+ // Drop blank lines left where the section was.
567
+ while (output.length > 0 && output.at(-1) === '' && lines[end] === '') output.pop()
568
+ }
569
+ return [...output, ...lines.slice(end)].join('\n')
570
+ }
@@ -12,6 +12,9 @@ import {
12
12
  lowestPatchedVersion,
13
13
  normalizeAdvisories,
14
14
  planOverrides,
15
+ planReleaseAgeExcludePrune,
16
+ readWorkspaceList,
17
+ rewriteWorkspaceList,
15
18
  upsertWorkspaceOverrides,
16
19
  } from './audit-fix-plan.mjs'
17
20
 
@@ -285,3 +288,107 @@ test('filterSuppressions keeps only active entries that match the repo findings'
285
288
  // Expired entries are dropped even when they match.
286
289
  assert.doesNotMatch(filterSuppressions(xml, ['GHSA-vfj7-8cjw-p6xm'], Date.parse('2026-12-02T00:00:00Z')), /GHSA-vfj7/)
287
290
  })
291
+
292
+ test('planReleaseAgeExcludePrune drops versions that are old enough or no longer installed', () => {
293
+ const now = Date.parse('2026-10-06T12:00:00Z')
294
+ const hoursAgo = (hours) => now - hours * 3600000
295
+ const times = {
296
+ 'lefthook@2.1.15': hoursAgo(24 * 30),
297
+ 'lefthook@2.1.17': hoursAgo(2),
298
+ 'tsx@4.23.9': hoursAgo(24 * 10),
299
+ 'fallow@3.31.0': hoursAgo(5),
300
+ }
301
+ const plan = planReleaseAgeExcludePrune(
302
+ [
303
+ 'fg-devkit',
304
+ '@fallow-cli/*',
305
+ 'lefthook@2.1.15 || 2.1.17',
306
+ 'tsx@4.23.9',
307
+ 'fallow@3.31.0',
308
+ 'gone@1.0.0',
309
+ 'x@^1.0.0',
310
+ ],
311
+ {
312
+ lockfileVersions: new Map([
313
+ ['lefthook', new Set(['2.1.15', '2.1.17'])],
314
+ ['tsx', new Set(['4.23.9'])],
315
+ ['fallow', new Set(['3.31.0'])],
316
+ ['x', new Set(['1.2.0'])],
317
+ ]),
318
+ publishTime: (name, version) => times[`${name}@${version}`],
319
+ minimumReleaseAgeMinutes: 1440,
320
+ now,
321
+ },
322
+ )
323
+ assert.deepEqual(
324
+ plan.map(({ entry, next }) => [entry, next]),
325
+ [
326
+ ['fg-devkit', 'fg-devkit'],
327
+ ['@fallow-cli/*', '@fallow-cli/*'],
328
+ ['lefthook@2.1.15 || 2.1.17', 'lefthook@2.1.17'],
329
+ ['tsx@4.23.9', null],
330
+ ['fallow@3.31.0', 'fallow@3.31.0'],
331
+ ['gone@1.0.0', null],
332
+ ['x@^1.0.0', 'x@^1.0.0'],
333
+ ],
334
+ )
335
+ assert.deepEqual(plan[5].dropped, [{ version: '1.0.0', reason: 'not-installed' }])
336
+ })
337
+
338
+ test('planReleaseAgeExcludePrune keeps versions with unknown publish time', () => {
339
+ const plan = planReleaseAgeExcludePrune(['private@1.0.0'], {
340
+ lockfileVersions: new Map([['private', new Set(['1.0.0'])]]),
341
+ publishTime: () => undefined,
342
+ minimumReleaseAgeMinutes: 1440,
343
+ })
344
+ assert.equal(plan[0].next, 'private@1.0.0')
345
+ })
346
+
347
+ test('readWorkspaceList and rewriteWorkspaceList edit only the given list', () => {
348
+ const text = [
349
+ 'allowBuilds:',
350
+ ' esbuild: true',
351
+ 'minimumReleaseAgeExclude:',
352
+ ' - fg-devkit',
353
+ ' - tsx@4.23.9',
354
+ ' - lefthook@2.1.15 || 2.1.17',
355
+ " - '@fallow-cli/darwin-arm64@3.31.0'",
356
+ 'overrides:',
357
+ " 'a@<1.0.1': '1.0.1'",
358
+ '',
359
+ ].join('\n')
360
+ assert.deepEqual(readWorkspaceList(text, 'minimumReleaseAgeExclude'), [
361
+ 'fg-devkit',
362
+ 'tsx@4.23.9',
363
+ 'lefthook@2.1.15 || 2.1.17',
364
+ '@fallow-cli/darwin-arm64@3.31.0',
365
+ ])
366
+ const result = rewriteWorkspaceList(
367
+ text,
368
+ 'minimumReleaseAgeExclude',
369
+ new Map([
370
+ ['tsx@4.23.9', null],
371
+ ['lefthook@2.1.15 || 2.1.17', 'lefthook@2.1.17'],
372
+ ['@fallow-cli/darwin-arm64@3.31.0', null],
373
+ ]),
374
+ )
375
+ assert.equal(
376
+ result,
377
+ [
378
+ 'allowBuilds:',
379
+ ' esbuild: true',
380
+ 'minimumReleaseAgeExclude:',
381
+ ' - fg-devkit',
382
+ ' - lefthook@2.1.17',
383
+ 'overrides:',
384
+ " 'a@<1.0.1': '1.0.1'",
385
+ '',
386
+ ].join('\n'),
387
+ )
388
+ const emptied = rewriteWorkspaceList(
389
+ 'minimumReleaseAgeExclude:\n - tsx@4.23.9\nallowBuilds:\n esbuild: true\n',
390
+ 'minimumReleaseAgeExclude',
391
+ new Map([['tsx@4.23.9', null]]),
392
+ )
393
+ assert.equal(emptied, 'allowBuilds:\n esbuild: true\n')
394
+ })
@@ -10,11 +10,14 @@
10
10
  * pnpm-workspace.yaml. Findings with no patch, a patch in another major, a patch
11
11
  * younger than minimumReleaseAge, or on a direct dependency are reported, not overridden.
12
12
  * 4. Prune overrides that no longer change the audit (prune-overrides --fix).
13
- * 5. If findings that are still reported have active <suppress> entries in fg-devkit's
13
+ * 5. Prune minimumReleaseAgeExclude entries that are no longer needed
14
+ * (prune-release-age-exclude --fix). Runs even when the audit is clean.
15
+ * 6. If findings that are still reported have active <suppress> entries in fg-devkit's
14
16
  * shared dependency-check-suppressions.xml, write the repo's copy with only those
15
- * entries, so the Jenkins OWASP scan accepts exactly the risks this repo has.
16
- * 6. Install and run the repo's checks (lint-check, lint:ts, test, test:unit, build).
17
- * 7. Commit the refresh, the override changes and the suppressions file separately.
17
+ * entries, so OWASP Dependency-Check accepts exactly the risks this repo has.
18
+ * 7. Install and run the repo's checks (lint-check, lint:ts, test, test:unit, build).
19
+ * 8. Commit the refresh, the overrides, the release-age excludes and the suppressions
20
+ * file separately.
18
21
  *
19
22
  * Usage:
20
23
  * pnpm exec fg-devkit audit-override-commit [--no-verify] [--no-commit]
@@ -34,7 +37,9 @@ import {
34
37
  isReleaseAgeExcluded,
35
38
  normalizeAdvisories,
36
39
  parseAuditJson,
40
+ parseReleaseAgeExcludeEntry,
37
41
  planOverrides,
42
+ readWorkspaceList,
38
43
  upsertWorkspaceOverrides,
39
44
  } from './audit-fix-plan.mjs'
40
45
  import {
@@ -246,6 +251,44 @@ function writeOverrides(overrides) {
246
251
  runOrFail('pnpm', ['install', '--lockfile-only', '--ignore-scripts', '--no-frozen-lockfile'])
247
252
  }
248
253
 
254
+ /**
255
+ * Remove minimumReleaseAgeExclude entries that are no longer needed. Returns the
256
+ * entries removed and the narrowed entries added, for the commit message.
257
+ */
258
+ function pruneReleaseAgeExclude() {
259
+ const before = readWorkspaceList(readFile('pnpm-workspace.yaml') ?? '', 'minimumReleaseAgeExclude')
260
+ if (before.length === 0) return { removed: [], added: [] }
261
+ console.log(`${TAG} Checking minimumReleaseAgeExclude…`)
262
+ const result = run(process.execPath, [path.join(scriptDir, 'prune-release-age-exclude.mjs'), '--fix'])
263
+ if (result.status !== 0) {
264
+ console.warn(`${TAG} Could not prune minimumReleaseAgeExclude; left unchanged.`)
265
+ return { removed: [], added: [] }
266
+ }
267
+ const after = readWorkspaceList(readFile('pnpm-workspace.yaml') ?? '', 'minimumReleaseAgeExclude')
268
+ return {
269
+ removed: before.filter((entry) => !after.includes(entry)),
270
+ added: after.filter((entry) => !before.includes(entry)),
271
+ }
272
+ }
273
+
274
+ /** `removed entry` or `old entry -> narrowed entry` lines. */
275
+ function describeExcludeChanges({ removed, added }) {
276
+ const nameOf = (entry) => parseReleaseAgeExcludeEntry(entry).name
277
+ return removed.map((entry) => {
278
+ const narrowed = added.find((item) => nameOf(item) === nameOf(entry))
279
+ return narrowed ? `${entry} -> ${narrowed}` : `${entry}`
280
+ })
281
+ }
282
+
283
+ function buildReleaseAgeExcludeCommitMessage(changes) {
284
+ return {
285
+ subject: 'chore(repo): prune minimumReleaseAgeExclude',
286
+ body: describeExcludeChanges(changes)
287
+ .map((line) => `- ${line}`)
288
+ .join('\n'),
289
+ }
290
+ }
291
+
249
292
  function pruneOverrides() {
250
293
  if (!/^overrides:/m.test(readFile('pnpm-workspace.yaml') ?? '')) return
251
294
  console.log(`${TAG} Pruning overrides that no longer affect pnpm audit…`)
@@ -294,7 +337,7 @@ function verify() {
294
337
  }
295
338
  }
296
339
 
297
- function printSummary({ fixedByRefresh, plan, heldBack, applied, remaining, suppressions }) {
340
+ function printSummary({ fixedByRefresh, plan, heldBack, applied, remaining, suppressions, excludeChanges }) {
298
341
  const suppressed = new Set(suppressions.covered.map((advisory) => advisory.ghsa))
299
342
  const unique = (items) => [...new Set(items)]
300
343
  const lines = []
@@ -337,6 +380,7 @@ function printSummary({ fixedByRefresh, plan, heldBack, applied, remaining, supp
337
380
  'Could not read the patched range, check manually:',
338
381
  plan.unparseable.map((item) => `${item.name}: ${item.patched} (${item.ghsas.join(', ')})`),
339
382
  )
383
+ section('Removed from minimumReleaseAgeExclude (no longer needed):', describeExcludeChanges(excludeChanges))
340
384
  if (remaining.length > 0) {
341
385
  lines.push(`Still reported by pnpm audit: ${remaining.length} advisory range(s).`)
342
386
  } else {
@@ -360,60 +404,79 @@ function main() {
360
404
 
361
405
  console.log(`${TAG} Running pnpm audit…`)
362
406
  const initial = audit()
407
+ let refreshed = original
408
+ let refreshChanges = []
409
+ let fixedByRefresh = []
410
+ let plan = { overrides: [], noPatch: [], crossMajor: [], direct: [], unparseable: [] }
411
+ let heldBack = []
412
+ let allowed = []
413
+ let remaining = []
414
+
363
415
  if (initial.length === 0) {
364
- console.log(`${TAG} pnpm audit is clean. Nothing to do.`)
365
- return 0
416
+ console.log(`${TAG} pnpm audit is clean.`)
417
+ } else {
418
+ console.log(`${TAG} ${initial.length} advisory range(s) in ${new Set(initial.map((a) => a.name)).size} package(s).`)
419
+
420
+ // 2. Refresh within ranges.
421
+ refreshLockfile(initial)
422
+ refreshed = snapshot()
423
+ refreshChanges = diffLockfileVersions(original['pnpm-lock.yaml'], refreshed['pnpm-lock.yaml'])
424
+
425
+ // 3. Plan overrides for what is left.
426
+ const afterRefresh = audit()
427
+ const remainingIds = new Set(afterRefresh.map((advisory) => advisory.id))
428
+ fixedByRefresh = [
429
+ ...new Set(initial.filter((advisory) => !remainingIds.has(advisory.id)).map((a) => `${a.name} (${a.ghsa})`)),
430
+ ]
431
+ plan = planOverrides(afterRefresh, { directDependencies: directDependencies() })
432
+ ;({ allowed, heldBack } = applyReleaseAgeGate(plan.overrides))
433
+ if (allowed.length > 0) writeOverrides(allowed)
434
+
435
+ // 4. Prune overrides.
436
+ pruneOverrides()
437
+ remaining = allowed.length > 0 ? audit() : afterRefresh
366
438
  }
367
- console.log(`${TAG} ${initial.length} advisory range(s) in ${new Set(initial.map((a) => a.name)).size} package(s).`)
368
-
369
- // 2. Refresh within ranges.
370
- refreshLockfile(initial)
371
- const refreshed = snapshot()
372
- const refreshChanges = diffLockfileVersions(original['pnpm-lock.yaml'], refreshed['pnpm-lock.yaml'])
373
-
374
- // 3. Plan overrides for what is left.
375
- const afterRefresh = audit()
376
- const remainingIds = new Set(afterRefresh.map((advisory) => advisory.id))
377
- const fixedByRefresh = [
378
- ...new Set(initial.filter((advisory) => !remainingIds.has(advisory.id)).map((a) => `${a.name} (${a.ghsa})`)),
379
- ]
380
- const plan = planOverrides(afterRefresh, { directDependencies: directDependencies() })
381
- const { allowed, heldBack } = applyReleaseAgeGate(plan.overrides)
382
- if (allowed.length > 0) writeOverrides(allowed)
383
-
384
- // 4. Prune, 5. sync suppressions, 6. verify.
385
- pruneOverrides()
386
- const remaining = allowed.length > 0 ? audit() : afterRefresh
439
+ const afterOverrides = snapshot()
440
+
441
+ // 5. Release-age excludes, 6. suppressions, 7. verify.
442
+ const excludeChanges = pruneReleaseAgeExclude()
387
443
  const suppressions = syncSuppressions(remaining)
388
- printSummary({ fixedByRefresh, plan, heldBack, applied: allowed, remaining, suppressions })
389
- verify()
444
+ printSummary({ fixedByRefresh, plan, heldBack, applied: allowed, remaining, suppressions, excludeChanges })
390
445
 
391
- // 7. Commit.
392
446
  const final = snapshot()
393
- const overrideChanges = collectOverrideChanges(overridesBefore, collectOverrides(cwd))
394
447
  const nothingChanged = !suppressionsWritten && TRACKED_FILES.every((file) => final[file] === original[file])
395
448
  if (nothingChanged) {
396
449
  console.log(`${TAG} No file changes.`)
397
450
  return 0
398
451
  }
452
+ verify()
453
+
454
+ // 8. Commit.
399
455
  if (!shouldCommit) {
400
456
  console.log(`${TAG} --no-commit: changes left in the working tree.`)
401
457
  return 0
402
458
  }
403
-
459
+ const overrideChanges = collectOverrideChanges(overridesBefore, collectOverrides(cwd))
404
460
  if (refreshChanges.length > 0 && stageContents(refreshed) > 0) {
405
461
  commit(buildRefreshCommitMessage(refreshChanges))
406
462
  }
407
- if (overrideChanges.length > 0) {
408
- for (const item of overrideChanges) {
409
- console.log(` - ${item.name}: ${item.from ?? '(missing)'} -> ${formatOverrideValue(item.to)}`)
410
- }
463
+ for (const item of overrideChanges) {
464
+ console.log(` - ${item.name}: ${item.from ?? '(missing)'} -> ${formatOverrideValue(item.to)}`)
411
465
  }
412
- if (stageContents(final) > 0) {
466
+ if (stageContents(afterOverrides) > 0) {
413
467
  commit(
414
468
  overrideChanges.length > 0
415
469
  ? buildOverrideCommitMessage(overrideChanges)
416
- : buildRefreshCommitMessage(diffLockfileVersions(refreshed['pnpm-lock.yaml'], final['pnpm-lock.yaml'])),
470
+ : buildRefreshCommitMessage(
471
+ diffLockfileVersions(refreshed['pnpm-lock.yaml'], afterOverrides['pnpm-lock.yaml']),
472
+ ),
473
+ )
474
+ }
475
+ if (stageContents(snapshot()) > 0) {
476
+ commit(
477
+ excludeChanges.removed.length > 0 || excludeChanges.added.length > 0
478
+ ? buildReleaseAgeExcludeCommitMessage(excludeChanges)
479
+ : buildRefreshCommitMessage(diffLockfileVersions(afterOverrides['pnpm-lock.yaml'], final['pnpm-lock.yaml'])),
417
480
  )
418
481
  }
419
482
  if (suppressionsWritten) {
@@ -33,10 +33,7 @@ import fs from 'node:fs'
33
33
  import path from 'node:path'
34
34
  import { fileURLToPath } from 'node:url'
35
35
 
36
- import {
37
- DEFAULT_NODE_TOOLCHAIN_EXCLUDE,
38
- collectFleetProjectRepos,
39
- } from './fleet-project-repos.mjs'
36
+ import { collectFleetProjectRepos, toolchainExclude } from './fleet-project-repos.mjs'
40
37
  import { parseFleetSelectionFlags, resolveFleetRepos } from './fleet-repo-picker.mjs'
41
38
  import {
42
39
  formatNodeNvmrc,
@@ -213,7 +210,7 @@ async function main() {
213
210
  process.exit(1)
214
211
  }
215
212
 
216
- const allRepos = collectFleetProjectRepos(projectsRoot, { exclude: DEFAULT_NODE_TOOLCHAIN_EXCLUDE })
213
+ const allRepos = collectFleetProjectRepos(projectsRoot, { exclude: toolchainExclude(projectsRoot) })
217
214
  if (allRepos.length === 0) {
218
215
  console.log(`[bump-node-toolchain] No project repositories under ${projectsRoot}`)
219
216
  process.exit(0)
@@ -18,6 +18,7 @@
18
18
  * pnpm exec fg-devkit policy-upgrade-commit [--prune-overrides] [ncu-args...]
19
19
  * pnpm exec fg-devkit audit-override-commit [--no-verify] [--no-commit]
20
20
  * pnpm exec fg-devkit prune-overrides [--fix] [--commit]
21
+ * pnpm exec fg-devkit prune-release-age-exclude [--fix] [--commit]
21
22
  * pnpm exec fg-devkit sync-project-configs (VS Code + tooling sync; overwrites config files)
22
23
  * pnpm exec fg-devkit sync-tooling-configs (tooling sync only; overwrites config files)
23
24
  * pnpm exec fg-devkit sync-dependency-check-suppressions-commit [--all]
@@ -479,6 +480,7 @@ const DIRECT_COMMANDS = {
479
480
  'policy-upgrade-commit': 'policy-upgrade-commit.mjs',
480
481
  'audit-override-commit': 'audit-override-commit.mjs',
481
482
  'prune-overrides': 'prune-overrides.mjs',
483
+ 'prune-release-age-exclude': 'prune-release-age-exclude.mjs',
482
484
  'sync-project-configs': 'sync-project-configs.mjs',
483
485
  'sync-tooling-configs': 'sync-tooling-configs.mjs',
484
486
  'sync-dependency-check-suppressions-commit': 'sync-dependency-check-suppressions-commit.mjs',
@@ -546,6 +548,8 @@ Commands:
546
548
  Fix pnpm audit findings: refresh lockfile first, override only when a range blocks the fix, verify, auto-commit
547
549
  prune-overrides [--fix] [--commit]
548
550
  Report unused workspace overrides; --fix removes them; --commit also auto-commits
551
+ prune-release-age-exclude [--fix] [--commit]
552
+ Report minimumReleaseAgeExclude entries that are no longer needed; --fix removes them; --commit also auto-commits
549
553
  sync-project-configs VS Code + tooling sync (overwrites config files)
550
554
  sync-tooling-configs Tooling sync only (overwrites config files)
551
555
  sync-dependency-check-suppressions-commit [--all]
@@ -975,8 +979,15 @@ async function runInteractiveMenu() {
975
979
  { name: 'Sync and auto-commit', value: 'sync-commit' },
976
980
  { name: 'Upgrade package.json packages and auto-commit', value: 'upgrade-commit' },
977
981
  { name: 'Upgrade package.json packages, prune overrides, and auto-commit', value: 'upgrade-commit-prune' },
978
- { name: 'Fix vulnerability overrides and auto-commit', value: 'audit-override-commit' },
982
+ {
983
+ name: 'Fix vulnerabilities (refresh, override, prune) and auto-commit',
984
+ value: 'audit-override-commit',
985
+ },
979
986
  { name: 'Prune unused vulnerability overrides and auto-commit', value: 'prune-overrides' },
987
+ {
988
+ name: 'Prune unneeded minimumReleaseAgeExclude entries and auto-commit',
989
+ value: 'prune-release-age-exclude',
990
+ },
980
991
  { name: 'Cancel', value: 'cancel' },
981
992
  ],
982
993
  default: 'check',
@@ -999,6 +1010,9 @@ async function runInteractiveMenu() {
999
1010
  if (choice === 'prune-overrides') {
1000
1011
  return runSelectedAction(() => runNode('prune-overrides.mjs', ['--commit']))
1001
1012
  }
1013
+ if (choice === 'prune-release-age-exclude') {
1014
+ return runSelectedAction(() => runNode('prune-release-age-exclude.mjs', ['--commit']))
1015
+ }
1002
1016
  return 1
1003
1017
  }
1004
1018
 
@@ -80,11 +80,31 @@ export function collectFleetGitRepos(projectsRoot) {
80
80
  return repos.sort((a, b) => a.localeCompare(b))
81
81
  }
82
82
 
83
- /** Repos that intentionally maintain multiple Node versions or non-pnpm tooling. */
84
- export const DEFAULT_NODE_TOOLCHAIN_EXCLUDE = new Set(['jenkins-dockerfile', 'vulnerability-test'])
83
+ /** Local config file in the projects root (not part of fg-devkit). */
84
+ export const FLEET_CONFIG_FILE = '.fg-devkit-fleet.json'
85
85
 
86
- /** Repos excluded from fleet-wide pnpm toolchain bumps (non-pnpm or special tooling). */
87
- export const DEFAULT_PNPM_TOOLCHAIN_EXCLUDE = new Set(['jenkins-dockerfile', 'vulnerability-test'])
86
+ /**
87
+ * Repos excluded from fleet-wide Node and pnpm toolchain bumps (for example repos that
88
+ * maintain several Node versions or do not use pnpm). Read from `toolchainExclude` in
89
+ * `<projectsRoot>/.fg-devkit-fleet.json` and the comma-separated `FGD_TOOLCHAIN_EXCLUDE`
90
+ * environment variable.
91
+ *
92
+ * @param {string} projectsRoot
93
+ * @returns {Set<string>}
94
+ */
95
+ export function toolchainExclude(projectsRoot) {
96
+ const names = new Set()
97
+ try {
98
+ const config = JSON.parse(fs.readFileSync(path.join(projectsRoot, FLEET_CONFIG_FILE), 'utf8'))
99
+ for (const name of config?.toolchainExclude ?? []) names.add(String(name))
100
+ } catch {
101
+ // No local config: nothing excluded.
102
+ }
103
+ for (const name of (process.env.FGD_TOOLCHAIN_EXCLUDE ?? '').split(',')) {
104
+ if (name.trim()) names.add(name.trim())
105
+ }
106
+ return names
107
+ }
88
108
 
89
109
  /**
90
110
  * @param {string} dir
@@ -108,7 +128,7 @@ export function isPnpmPackageJsonDir(dir, pkg) {
108
128
  export function collectFleetPnpmRepos(projectsRoot, options = {}) {
109
129
  const exclude = new Set([
110
130
  ...DEFAULT_FLEET_REPO_EXCLUDE,
111
- ...DEFAULT_PNPM_TOOLCHAIN_EXCLUDE,
131
+ ...toolchainExclude(projectsRoot),
112
132
  ...(options.exclude ?? []),
113
133
  ])
114
134
  const { skipScaffoldVue = true } = options
@@ -13,7 +13,7 @@ import {
13
13
  } from './dependency-policy-toolchain.mjs'
14
14
 
15
15
  const DOCKER_FROM_NODE_PATTERN = /FROM node:(v)?(\d+(?:\.\d+)?)(-[a-z0-9-]+)?/gi
16
- const JENKINS_NODE_IMAGE_PATTERN = /(feelgoodsvenska\/jenkins-build:)node\d+/g
16
+ const JENKINS_NODE_IMAGE_PATTERN = /([\w.-]+\/jenkins-build:)node\d+/g
17
17
  const GITHUB_NODE_VERSION_PATTERN = /node-version:\s*['"]?\d+(?:\.\d+)?['"]?/g
18
18
 
19
19
  /**
@@ -323,7 +323,7 @@ export function collectNodeToolchainViolations(repoRoot, policy, fgDevkitRoot) {
323
323
  const expectedJenkins = jenkinsImage ?? deriveJenkinsImageTag(nvmrc)
324
324
  if (fs.existsSync(jenkinsfilePath)) {
325
325
  const jenkinsfile = fs.readFileSync(jenkinsfilePath, 'utf8')
326
- const matches = [...jenkinsfile.matchAll(/feelgoodsvenska\/jenkins-build:(node\d+)/g)]
326
+ const matches = [...jenkinsfile.matchAll(/[\w.-]+\/jenkins-build:(node\d+)/g)]
327
327
  for (const match of matches) {
328
328
  if (match[1] !== expectedJenkins) {
329
329
  violations.push(`Jenkinsfile: expected jenkins-build:${expectedJenkins}, got jenkins-build:${match[1]}`)
@@ -381,20 +381,3 @@ test('pull plans tracking id updates from Piwik into the local map', () => {
381
381
  'export const customDimensions = { active: 46, step: 30 } as const\n',
382
382
  )
383
383
  })
384
-
385
- test('parses the real consumer dimension files when they are checked out beside fg-devkit', () => {
386
- const files = [
387
- ['../../bokningsverktyget-ui/src/analytics/dimensions.ts', 'event', 'rowsBooked', 7],
388
- ['../../webbportalen-ui/src/analytics/dimensions.ts', 'session', 'organizationName', 26],
389
- ['../../feelgood-app/src/util/piwikDimensions.ts', 'event', 'eventTimestamp', 78],
390
- ]
391
- for (const [relative, scope, key, slot] of files) {
392
- const file = path.join(scriptDir, relative)
393
- if (!fs.existsSync(file)) continue
394
- const dimensions = parseDimensionSource(fs.readFileSync(file, 'utf8'), relative)
395
- const found = dimensions.find((item) => item.key === key)
396
- assert.ok(found, `${key} missing in ${relative}`)
397
- assert.equal(found.scope, scope)
398
- assert.equal(found.slot, slot)
399
- }
400
- })
@@ -0,0 +1,161 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Find minimumReleaseAgeExclude entries in pnpm-workspace.yaml that are no longer needed.
4
+ *
5
+ * A pinned version (`name@1.2.3 || 1.2.4`) is only needed while it is resolved in the
6
+ * lockfile and still younger than minimumReleaseAge. Versions that are old enough now, or
7
+ * no longer installed, are removed; an entry with no versions left is removed entirely.
8
+ * Name rules (`fg-devkit`, `@scope/*`) allow every future release on purpose and are kept.
9
+ *
10
+ * Usage:
11
+ * pnpm exec fg-devkit prune-release-age-exclude # report only
12
+ * pnpm exec fg-devkit prune-release-age-exclude --fix # remove what is not needed
13
+ * pnpm exec fg-devkit prune-release-age-exclude --commit # remove and auto-commit
14
+ */
15
+
16
+ import fs from 'node:fs'
17
+ import path from 'node:path'
18
+ import { spawnSync } from 'node:child_process'
19
+ import { parseArgs } from 'node:util'
20
+ import {
21
+ DEFAULT_MINIMUM_RELEASE_AGE_MINUTES,
22
+ lockfileVersions,
23
+ parseAuditJson,
24
+ planReleaseAgeExcludePrune,
25
+ readWorkspaceList,
26
+ rewriteWorkspaceList,
27
+ } from './audit-fix-plan.mjs'
28
+
29
+ const TAG = '[prune-release-age-exclude]'
30
+ const KEY = 'minimumReleaseAgeExclude'
31
+ const WORKSPACE_FILE = 'pnpm-workspace.yaml'
32
+ const COMMIT_SUBJECT = 'chore(repo): prune minimumReleaseAgeExclude'
33
+ const cwd = process.cwd()
34
+
35
+ const { values: args } = parseArgs({
36
+ options: {
37
+ fix: { type: 'boolean', default: false },
38
+ commit: { type: 'boolean', default: false },
39
+ help: { type: 'boolean', short: 'h', default: false },
40
+ },
41
+ strict: true,
42
+ })
43
+
44
+ if (args.help) {
45
+ console.log(`Usage: pnpm exec fg-devkit prune-release-age-exclude [--fix] [--commit]
46
+
47
+ Reports ${KEY} entries in ${WORKSPACE_FILE} that are no longer needed: pinned
48
+ versions that are older than minimumReleaseAge, or no longer in the lockfile.
49
+ Name rules such as "fg-devkit" or "@scope/*" are always kept.
50
+
51
+ --fix Remove them and re-verify the lockfile
52
+ --commit Remove them and auto-commit`)
53
+ process.exit(0)
54
+ }
55
+
56
+ function run(command, commandArgs, { capture = true } = {}) {
57
+ const result = spawnSync(command, commandArgs, {
58
+ cwd,
59
+ env: process.env,
60
+ encoding: 'utf8',
61
+ stdio: capture ? ['ignore', 'pipe', 'pipe'] : 'inherit',
62
+ maxBuffer: 50 * 1024 * 1024,
63
+ })
64
+ if (result.error) throw result.error
65
+ return result
66
+ }
67
+
68
+ function readJsonOutput(command, commandArgs) {
69
+ const result = run(command, commandArgs)
70
+ if (result.status !== 0) return undefined
71
+ try {
72
+ return parseAuditJson(result.stdout)
73
+ } catch {
74
+ return undefined
75
+ }
76
+ }
77
+
78
+ function describe(item) {
79
+ const reasons = item.dropped.map(
80
+ ({ version, reason }) =>
81
+ `${version} (${reason === 'old-enough' ? 'older than minimumReleaseAge' : 'not in lockfile'})`,
82
+ )
83
+ return item.next === null
84
+ ? `${item.entry}: remove, ${reasons.join(', ')}`
85
+ : `${item.entry} -> ${item.next}: drop ${reasons.join(', ')}`
86
+ }
87
+
88
+ function main() {
89
+ const workspacePath = path.join(cwd, WORKSPACE_FILE)
90
+ const lockfilePath = path.join(cwd, 'pnpm-lock.yaml')
91
+ if (!fs.existsSync(workspacePath) || !fs.existsSync(lockfilePath)) {
92
+ console.log(`${TAG} No ${WORKSPACE_FILE} or pnpm-lock.yaml. Nothing to check.`)
93
+ return 0
94
+ }
95
+
96
+ const original = fs.readFileSync(workspacePath, 'utf8')
97
+ const entries = readWorkspaceList(original, KEY)
98
+ if (entries.length === 0) {
99
+ console.log(`${TAG} No ${KEY} entries.`)
100
+ return 0
101
+ }
102
+
103
+ const config = readJsonOutput('pnpm', ['config', 'list', '--json']) ?? {}
104
+ const minutes = Number(config.minimumReleaseAge ?? DEFAULT_MINIMUM_RELEASE_AGE_MINUTES)
105
+ const timesByName = new Map()
106
+ const publishTime = (name, version) => {
107
+ if (!timesByName.has(name)) timesByName.set(name, readJsonOutput('pnpm', ['view', name, 'time', '--json']) ?? {})
108
+ const time = Date.parse(timesByName.get(name)[version] ?? '')
109
+ return Number.isFinite(time) ? time : undefined
110
+ }
111
+
112
+ console.log(
113
+ `${TAG} Checking ${entries.length} ${KEY} entr${entries.length === 1 ? 'y' : 'ies'} (minimumReleaseAge ${minutes} min)…`,
114
+ )
115
+ const plan = planReleaseAgeExcludePrune(entries, {
116
+ lockfileVersions: lockfileVersions(fs.readFileSync(lockfilePath, 'utf8')),
117
+ publishTime,
118
+ minimumReleaseAgeMinutes: minutes,
119
+ })
120
+ const changes = plan.filter((item) => item.next !== item.entry)
121
+ if (changes.length === 0) {
122
+ console.log(`${TAG} Every entry is still needed.`)
123
+ return 0
124
+ }
125
+
126
+ console.log(`${TAG} Not needed (${changes.length}):`)
127
+ for (const item of changes) console.log(` - ${describe(item)}`)
128
+
129
+ if (!args.fix && !args.commit) {
130
+ console.log(`${TAG} Re-run with --fix to remove them, or --commit to remove and auto-commit.`)
131
+ return 0
132
+ }
133
+
134
+ fs.writeFileSync(
135
+ workspacePath,
136
+ rewriteWorkspaceList(original, KEY, new Map(changes.map((item) => [item.entry, item.next]))),
137
+ )
138
+ // pnpm verifies the lockfile against the release-age policy here; restore on failure.
139
+ const verify = run('pnpm', ['install', '--lockfile-only', '--ignore-scripts', '--no-frozen-lockfile'])
140
+ if (verify.status !== 0) {
141
+ fs.writeFileSync(workspacePath, original)
142
+ console.error(`${TAG} pnpm rejected the lockfile without these entries, restored ${WORKSPACE_FILE}.`)
143
+ console.error((verify.stderr || verify.stdout || '').trim())
144
+ return 1
145
+ }
146
+ console.log(`${TAG} Updated ${WORKSPACE_FILE}.`)
147
+
148
+ if (!args.commit) return 0
149
+ const files = [WORKSPACE_FILE, 'pnpm-lock.yaml'].filter((file) => fs.existsSync(path.join(cwd, file)))
150
+ if (run('git', ['add', '--', ...files], { capture: false }).status !== 0) return 1
151
+ const body = changes.map((item) => `- ${describe(item)}`).join('\n')
152
+ console.log(`${TAG} Auto-committing: ${COMMIT_SUBJECT}`)
153
+ return run('git', ['commit', '-m', COMMIT_SUBJECT, '-m', body], { capture: false }).status ?? 1
154
+ }
155
+
156
+ try {
157
+ process.exit(main())
158
+ } catch (error) {
159
+ console.error(`${TAG} ${error instanceof Error ? error.message : error}`)
160
+ process.exit(2)
161
+ }
@@ -3,7 +3,7 @@
3
3
  * Write dependency-check-suppressions.xml into the consumer repo and auto-commit.
4
4
  *
5
5
  * Only the active <suppress> entries from fg-devkit's shared file that match a finding
6
- * `pnpm audit` still reports in this repo are written, so the Jenkins OWASP scan accepts
6
+ * `pnpm audit` still reports in this repo are written, so OWASP Dependency-Check accepts
7
7
  * exactly the risks this repo has. Pass --all to copy the whole shared file instead.
8
8
  *
9
9
  * Usage: