@nitra/cursor 1.8.125 → 1.8.127
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/mdc/abie.mdc +1 -1
- package/mdc/docker.mdc +10 -1
- package/mdc/ga.mdc +28 -0
- package/mdc/k8s.mdc +2 -2
- package/package.json +1 -1
- package/scripts/check-docker.mjs +80 -0
- package/scripts/check-ga.mjs +72 -0
- package/scripts/check-k8s.mjs +98 -117
- package/scripts/utils/docker-mirror.mjs +156 -0
- package/scripts/utils/gha-workflow.mjs +32 -0
package/mdc/abie.mdc
CHANGED
package/mdc/docker.mdc
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
description: Dockerfile — lint-docker / hadolint; перевірка check-docker
|
|
3
|
-
version: '1.
|
|
3
|
+
version: '1.8'
|
|
4
4
|
globs: "**/Dockerfile*"
|
|
5
5
|
alwaysApply: false
|
|
6
6
|
---
|
|
@@ -9,6 +9,15 @@ alwaysApply: false
|
|
|
9
9
|
|
|
10
10
|
[hadolint](https://github.com/hadolint/hadolint) перевіряє Dockerfile на типові помилки та рекомендації (`FROM`, `RUN`, `COPY`, shell form тощо).
|
|
11
11
|
|
|
12
|
+
Для образів з Docker Hub — **`oven/bun`**, **`alpine`**, **`nginx`**, **`node`** — у **`FROM`** треба вказувати дзеркало GCR, а не pull напряму з Hub: **`mirror.gcr.io/oven/bun`**, **`mirror.gcr.io/library/alpine`**, **`mirror.gcr.io/library/nginx`**, **`mirror.gcr.io/library/node`**. Перевіряє **`check-docker.mjs`**, деталі в **`npm/scripts/utils/docker-mirror.mjs`**.
|
|
13
|
+
|
|
14
|
+
Також Dockerfile/Containerfile **має бути multistage build**: окремий build stage (залежності/компіляція) і окремий runtime stage. У фінальному stage дозволені лише мінімальні базові образи:
|
|
15
|
+
|
|
16
|
+
- **backend**: `mirror.gcr.io/library/alpine:*`
|
|
17
|
+
- **frontend**: `mirror.gcr.io/library/nginx:*`
|
|
18
|
+
|
|
19
|
+
Це гарантує, що результуючий образ містить лише runtime (alpine) або nginx, без build tooling і node_modules.
|
|
20
|
+
|
|
12
21
|
## Область
|
|
13
22
|
|
|
14
23
|
- Усі файли з іменем **`Dockerfile`** або **`Dockerfile.*`** (наприклад `Dockerfile.prod`) у репозиторії, крім ігнорованих каталогів (`node_modules`, `.git`, `dist`, …) — як у **`check-docker.mjs`**.
|
package/mdc/ga.mdc
CHANGED
|
@@ -149,8 +149,36 @@ jobs:
|
|
|
149
149
|
}
|
|
150
150
|
```
|
|
151
151
|
|
|
152
|
+
У **`.vscode/settings.json`** для мови **`github-actions-workflow`** (workflow з розширення GitHub Actions) задай **oxc** як formatter:
|
|
153
|
+
|
|
154
|
+
```json
|
|
155
|
+
"[github-actions-workflow]": {
|
|
156
|
+
"editor.defaultFormatter": "oxc.oxc-vscode"
|
|
157
|
+
}
|
|
158
|
+
```
|
|
159
|
+
|
|
152
160
|
**ЗАБОРОНЕНО** дублювати кроки встановлення Bun та кешування безпосередньо у workflow файлах. Завжди використовуй локальний composite action.
|
|
153
161
|
|
|
162
|
+
**Кроки `run`:** не розбивай команду shell-продовженням через зворотний сліш у кінці рядка (`… \` у `run: |`). Замість багаторядкового буквального блока з `\\` оформ довгу одну shell-команду як **folded block** `>-` (рядки з’єднаються в один рядок із пробілами).
|
|
163
|
+
|
|
164
|
+
### Приклад run (НЕПРАВИЛЬНО — `\\` на кінцях)
|
|
165
|
+
|
|
166
|
+
```yaml
|
|
167
|
+
- run: |
|
|
168
|
+
docker build \
|
|
169
|
+
--push \
|
|
170
|
+
--build-arg BRANCH=${{ github.ref_name }}
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
### Приклад run (ПРАВИЛЬНО — `>-`)
|
|
174
|
+
|
|
175
|
+
```yaml
|
|
176
|
+
- run: >-
|
|
177
|
+
docker build
|
|
178
|
+
--push
|
|
179
|
+
--build-arg BRANCH=${{ github.ref_name }}
|
|
180
|
+
```
|
|
181
|
+
|
|
154
182
|
### Приклад (НЕПРАВИЛЬНО)
|
|
155
183
|
|
|
156
184
|
```yaml
|
package/mdc/k8s.mdc
CHANGED
|
@@ -129,7 +129,7 @@ resources:
|
|
|
129
129
|
|
|
130
130
|
```yaml
|
|
131
131
|
# yaml-language-server: $schema=https://datreeio.github.io/CRDs-catalog/gateway.networking.k8s.io/httproute_v1beta1.json
|
|
132
|
-
apiVersion: gateway.networking.k8s.io/
|
|
132
|
+
apiVersion: gateway.networking.k8s.io/v1
|
|
133
133
|
kind: HTTPRoute
|
|
134
134
|
metadata:
|
|
135
135
|
name: db-h
|
|
@@ -490,7 +490,7 @@ patches:
|
|
|
490
490
|
# yaml-language-server: $schema=https://raw.githubusercontent.com/datreeio/CRDs-catalog/main/secrets.infisical.com/infisicalsecret_v1alpha1.json
|
|
491
491
|
```
|
|
492
492
|
|
|
493
|
-
**Приклад (Gateway API):** `apiVersion: gateway.networking.k8s.io/
|
|
493
|
+
**Приклад (Gateway API):** `apiVersion: gateway.networking.k8s.io/v1`, `kind: HTTPRoute`:
|
|
494
494
|
|
|
495
495
|
```yaml
|
|
496
496
|
# yaml-language-server: $schema=https://datreeio.github.io/CRDs-catalog/gateway.networking.k8s.io/httproute_v1beta1.json
|
package/package.json
CHANGED
package/scripts/check-docker.mjs
CHANGED
|
@@ -1,16 +1,36 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Запускає hadolint для Dockerfile / Containerfile у всьому репозиторії (див. docker.mdc).
|
|
3
3
|
*
|
|
4
|
+
* Додатково переконуються, що образи `oven/bun`, `alpine`, `nginx`, `node` з Docker Hub
|
|
5
|
+
* вказуються через `mirror.gcr.io` (див. `utils/docker-mirror.mjs`).
|
|
6
|
+
*
|
|
7
|
+
* Також перевіряє, що Dockerfile/Containerfile має **multistage build** і що фінальний stage
|
|
8
|
+
* використовує мінімальний runtime-образ:
|
|
9
|
+
* - backend: `mirror.gcr.io/library/alpine:*`
|
|
10
|
+
* - frontend: `mirror.gcr.io/library/nginx:*`
|
|
11
|
+
*
|
|
12
|
+
* Мета — щоб у фінальному образі не було build tooling (Bun/Node та залежностей), а лише
|
|
13
|
+
* runtime (alpine) або nginx.
|
|
14
|
+
*
|
|
4
15
|
* Знаходить Dockerfile, Dockerfile.*, Containerfile, Containerfile.*; пропускає node_modules, .git
|
|
5
16
|
* тощо. Спочатку hadolint з PATH, інакше docker run з образом hadolint/hadolint.
|
|
6
17
|
* Кореневий .hadolint.yaml підхоплюється hadolint автоматично.
|
|
7
18
|
*/
|
|
19
|
+
import { readFile } from 'node:fs/promises'
|
|
8
20
|
import { basename } from 'node:path'
|
|
9
21
|
|
|
22
|
+
import { getMirrorGcrHint, getFromImageToken } from './utils/docker-mirror.mjs'
|
|
10
23
|
import { lintDockerfileWithHadolint, posixRel } from './utils/docker-hadolint.mjs'
|
|
11
24
|
import { createCheckReporter } from './utils/check-reporter.mjs'
|
|
12
25
|
import { walkDir } from './utils/walkDir.mjs'
|
|
13
26
|
|
|
27
|
+
/**
|
|
28
|
+
* @typedef {{
|
|
29
|
+
* line: number
|
|
30
|
+
* image: string
|
|
31
|
+
* }} FromStage
|
|
32
|
+
*/
|
|
33
|
+
|
|
14
34
|
/**
|
|
15
35
|
* Чи є basename Dockerfile / Containerfile (у т.ч. Dockerfile.prod).
|
|
16
36
|
* @param {string} name basename шляху
|
|
@@ -37,6 +57,55 @@ export async function findDockerfilePaths(root) {
|
|
|
37
57
|
return out.toSorted((a, b) => a.localeCompare(b))
|
|
38
58
|
}
|
|
39
59
|
|
|
60
|
+
/**
|
|
61
|
+
* Витягує всі `FROM <image>` зі вмісту Dockerfile/Containerfile.
|
|
62
|
+
*
|
|
63
|
+
* @param {string} fileContent вміст Dockerfile/Containerfile
|
|
64
|
+
* @returns {FromStage[]} список знайдених FROM-інструкцій
|
|
65
|
+
*/
|
|
66
|
+
export function parseFromStages(fileContent) {
|
|
67
|
+
const out = []
|
|
68
|
+
const lines = fileContent.split(/\r?\n/)
|
|
69
|
+
for (let i = 0; i < lines.length; i++) {
|
|
70
|
+
const image = getFromImageToken(lines[i])
|
|
71
|
+
if (image) out.push({ line: i + 1, image })
|
|
72
|
+
}
|
|
73
|
+
return out
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const RUNTIME_IMAGES = /** @type {const} */ ([
|
|
77
|
+
'mirror.gcr.io/library/alpine',
|
|
78
|
+
'mirror.gcr.io/library/nginx'
|
|
79
|
+
])
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Перевіряє базові вимоги до структури Dockerfile:
|
|
83
|
+
* - multistage: мінімум 2 FROM
|
|
84
|
+
* - фінальний FROM: alpine або nginx з mirror.gcr.io
|
|
85
|
+
*
|
|
86
|
+
* @param {string} fileContent вміст Dockerfile/Containerfile
|
|
87
|
+
* @returns {string | null} повідомлення помилки або null
|
|
88
|
+
*/
|
|
89
|
+
export function getMultistageAndRuntimeHint(fileContent) {
|
|
90
|
+
const stages = parseFromStages(fileContent)
|
|
91
|
+
if (stages.length === 0) return null
|
|
92
|
+
|
|
93
|
+
if (stages.length < 2) {
|
|
94
|
+
return 'має бути multistage build: мінімум 2 інструкції FROM (build stage + runtime stage)'
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const last = stages.at(-1)
|
|
98
|
+
const lastImage = (last?.image || '').split('@')[0] || ''
|
|
99
|
+
const lastLower = lastImage.toLowerCase()
|
|
100
|
+
|
|
101
|
+
const okRuntime = RUNTIME_IMAGES.some(img => lastLower.startsWith(`${img}:`) || lastLower === img)
|
|
102
|
+
if (!okRuntime) {
|
|
103
|
+
return `фінальний FROM має бути ${RUNTIME_IMAGES.join(' або ')} (runtime stage), зараз: ${last?.image} (рядок ${last?.line})`
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return null
|
|
107
|
+
}
|
|
108
|
+
|
|
40
109
|
/**
|
|
41
110
|
* Перевіряє Dockerfile / Containerfile через hadolint (docker.mdc).
|
|
42
111
|
* @returns {Promise<number>} 0 — все OK, 1 — є зауваження або помилка запуску
|
|
@@ -57,6 +126,17 @@ export async function check() {
|
|
|
57
126
|
|
|
58
127
|
for (const abs of files) {
|
|
59
128
|
const rel = posixRel(root, abs) || basename(abs)
|
|
129
|
+
const content = await readFile(abs, 'utf8')
|
|
130
|
+
const hint = getMirrorGcrHint(content)
|
|
131
|
+
if (hint) {
|
|
132
|
+
fail(`${rel} (mirror.gcr.io): ${hint}`)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const multistageHint = getMultistageAndRuntimeHint(content)
|
|
136
|
+
if (multistageHint) {
|
|
137
|
+
fail(`${rel} (multistage): ${multistageHint}`)
|
|
138
|
+
}
|
|
139
|
+
|
|
60
140
|
const { ok, stdout, stderr, via } = lintDockerfileWithHadolint(root, abs)
|
|
61
141
|
const tail = (stdout + stderr).trim()
|
|
62
142
|
if (ok) {
|
package/scripts/check-ga.mjs
CHANGED
|
@@ -4,11 +4,14 @@
|
|
|
4
4
|
* Workflows лише з розширенням `.yml`, наявність clean/lint workflow, конфіг zizmor з ref-pin,
|
|
5
5
|
* відсутність MegaLinter, коректний скрипт `lint-ga` у `package.json`, виклик у `lint-ga.yml`,
|
|
6
6
|
* наявність composite `.github/actions/setup-bun-deps/action.yml` (його записує npx `\@nitra/cursor`),
|
|
7
|
+
* `\.vscode/settings.json` — `editor.defaultFormatter` **oxc** для `[github-actions-workflow]`,
|
|
7
8
|
* перед `uses: ./…/setup-bun-deps` у workflow — `actions/checkout` (runner інакше не бачить локальний action).
|
|
8
9
|
*
|
|
9
10
|
* Заборонено дублювати кроки встановлення Bun та кешування безпосередньо у workflow файлах
|
|
10
11
|
* (oven-sh/setup-bun, actions/cache, bun install). Перевірки `uses`/`run` виконуються після **YAML parse**
|
|
11
12
|
* (`yaml`), щоб не спрацьовувати на випадкові збіги в коментарях або поза кроками.
|
|
13
|
+
*
|
|
14
|
+
* У `run:` заборонено shell-продовження рядків через `\\` перед переносом; довгі команди — через folded block `>-`.
|
|
12
15
|
*/
|
|
13
16
|
import { existsSync } from 'node:fs'
|
|
14
17
|
import { readdir, readFile } from 'node:fs/promises'
|
|
@@ -19,6 +22,7 @@ import {
|
|
|
19
22
|
anyRunStepIncludes,
|
|
20
23
|
eventPathsIncludeExact,
|
|
21
24
|
findForbiddenUsesOrRunPatterns,
|
|
25
|
+
findRunStepsWithShellLineContinuationBackslash,
|
|
22
26
|
hasAnyStepUsesContaining,
|
|
23
27
|
hasCheckoutBeforeLocalSetupBunDeps,
|
|
24
28
|
parseWorkflowYaml
|
|
@@ -117,6 +121,31 @@ function verifyNoDirectBunOrCache(relPath, content, failFn, passFn) {
|
|
|
117
121
|
}
|
|
118
122
|
}
|
|
119
123
|
|
|
124
|
+
/**
|
|
125
|
+
* У кроках `run` заборонено shell-продовження через `\\` перед переносом; замість `run: |` з `\\` використовуй `run: >-`.
|
|
126
|
+
* @param {string} relPath шлях для повідомлень
|
|
127
|
+
* @param {string} content вміст YAML
|
|
128
|
+
* @param {(msg: string) => void} failFn реєструє порушення (exit 1)
|
|
129
|
+
* @param {(msg: string) => void} passFn реєструє успішну перевірку
|
|
130
|
+
* @returns {void}
|
|
131
|
+
*/
|
|
132
|
+
function verifyNoRunShellLineContinuationBackslash(relPath, content, failFn, passFn) {
|
|
133
|
+
const root = parseWorkflowYaml(content)
|
|
134
|
+
if (!root) {
|
|
135
|
+
return
|
|
136
|
+
}
|
|
137
|
+
const hits = findRunStepsWithShellLineContinuationBackslash(root)
|
|
138
|
+
if (hits.length === 0) {
|
|
139
|
+
passFn(`${relPath}: run без shell-продовження через \\ (ga.mdc)`)
|
|
140
|
+
return
|
|
141
|
+
}
|
|
142
|
+
for (const h of hits) {
|
|
143
|
+
failFn(
|
|
144
|
+
`${relPath}: job ${h.jobId}, крок ${h.stepIndex + 1}: у run заборонено продовження рядків через зворотний сліш; довгі команди оформи як folded block (run: >-) без \\ на кінцях рядків (ga.mdc)`
|
|
145
|
+
)
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
120
149
|
/**
|
|
121
150
|
* Перевіряє apply-workflow на наявність paths trigger.
|
|
122
151
|
* @param {string} wfDir директорія workflows
|
|
@@ -183,6 +212,46 @@ async function checkZizmor(passFn, failFn) {
|
|
|
183
212
|
}
|
|
184
213
|
}
|
|
185
214
|
|
|
215
|
+
/**
|
|
216
|
+
* Перевіряє `.vscode/settings.json`: oxfmt/oxc як default formatter для GitHub Actions workflow (мова
|
|
217
|
+
* `github-actions-workflow` з розширення github.vscode-github-actions), узгоджено з oxc для yaml/workflow.
|
|
218
|
+
* @param {(msg: string) => void} passFn callback при успішній перевірці
|
|
219
|
+
* @param {(msg: string) => void} failFn callback при помилці
|
|
220
|
+
*/
|
|
221
|
+
async function checkVscodeSettingsForGa(passFn, failFn) {
|
|
222
|
+
const rel = '.vscode/settings.json'
|
|
223
|
+
if (!existsSync(rel)) {
|
|
224
|
+
failFn(`${rel} не існує — додай [github-actions-workflow].editor.defaultFormatter = oxc.oxc-vscode (ga.mdc)`)
|
|
225
|
+
return
|
|
226
|
+
}
|
|
227
|
+
let settings
|
|
228
|
+
try {
|
|
229
|
+
settings = JSON.parse(await readFile(rel, 'utf8'))
|
|
230
|
+
} catch {
|
|
231
|
+
failFn(`${rel}: невалідний JSON (ga.mdc)`)
|
|
232
|
+
return
|
|
233
|
+
}
|
|
234
|
+
if (!settings || typeof settings !== 'object') {
|
|
235
|
+
failFn(`${rel}: очікується об’єкт налаштувань (ga.mdc)`)
|
|
236
|
+
return
|
|
237
|
+
}
|
|
238
|
+
const block = /** @type {Record<string, unknown>} */ (settings)['[github-actions-workflow]']
|
|
239
|
+
if (!block || typeof block !== 'object' || block === null || Array.isArray(block)) {
|
|
240
|
+
failFn(
|
|
241
|
+
`${rel}: додай "[github-actions-workflow]": { "editor.defaultFormatter": "oxc.oxc-vscode" } (ga.mdc)`
|
|
242
|
+
)
|
|
243
|
+
return
|
|
244
|
+
}
|
|
245
|
+
const df = String(/** @type {Record<string, unknown>} */ (block)['editor.defaultFormatter'] ?? '')
|
|
246
|
+
if (df !== 'oxc.oxc-vscode') {
|
|
247
|
+
failFn(
|
|
248
|
+
`${rel}: [github-actions-workflow].editor.defaultFormatter має бути "oxc.oxc-vscode" (зараз: ${df || '∅'}) (ga.mdc)`
|
|
249
|
+
)
|
|
250
|
+
return
|
|
251
|
+
}
|
|
252
|
+
passFn(`${rel}: [github-actions-workflow] → oxc.oxc-vscode`)
|
|
253
|
+
}
|
|
254
|
+
|
|
186
255
|
/**
|
|
187
256
|
* Перевіряє скрипт lint-ga в package.json.
|
|
188
257
|
* @param {(msg: string) => void} passFn callback при успішній перевірці
|
|
@@ -379,6 +448,8 @@ export async function check() {
|
|
|
379
448
|
fail('.vscode/extensions.json не існує')
|
|
380
449
|
}
|
|
381
450
|
|
|
451
|
+
await checkVscodeSettingsForGa(pass, fail)
|
|
452
|
+
|
|
382
453
|
const ymlWorkflows = files.filter(f => f.endsWith('.yml'))
|
|
383
454
|
await checkMegalinter(wfDir, ymlWorkflows, pass, fail)
|
|
384
455
|
|
|
@@ -386,6 +457,7 @@ export async function check() {
|
|
|
386
457
|
const content = await readFile(join(wfDir, f), 'utf8')
|
|
387
458
|
verifyCheckoutBeforeLocalSetupBunDeps(`${wfDir}/${f}`, content, fail, pass)
|
|
388
459
|
verifyNoDirectBunOrCache(`${wfDir}/${f}`, content, fail, pass)
|
|
460
|
+
verifyNoRunShellLineContinuationBackslash(`${wfDir}/${f}`, content, fail, pass)
|
|
389
461
|
}
|
|
390
462
|
|
|
391
463
|
await checkZizmor(pass, fail)
|
package/scripts/check-k8s.mjs
CHANGED
|
@@ -402,7 +402,7 @@ function pathsFromKustomizationObject(obj) {
|
|
|
402
402
|
* як у `pathsFromKustomizationObject`, плюс **`patchesJson6902[].path`**, плюс **`configurations[]`**
|
|
403
403
|
* (рядки-шляхи) і **`replacements[].path`**, якщо задано.
|
|
404
404
|
* @param {unknown} obj корінь першого документа
|
|
405
|
-
* @returns {string[]}
|
|
405
|
+
* @returns {string[]} масив локальних шляхів для перевірки існування на диску
|
|
406
406
|
*/
|
|
407
407
|
export function kustomizePathRefsForExistenceCheck(obj) {
|
|
408
408
|
if (obj === null || typeof obj !== 'object' || Array.isArray(obj)) {
|
|
@@ -463,47 +463,44 @@ async function validateOneKustomizationPathRefsExist(root, kustAbs, rootNorm, fa
|
|
|
463
463
|
const refs = kustomizePathRefsForExistenceCheck(kust)
|
|
464
464
|
const kustDir = dirname(resolve(kustAbs))
|
|
465
465
|
for (const r of refs) {
|
|
466
|
-
if (typeof r
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
}
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
if (st.isFile()) {
|
|
491
|
-
if (!YAML_EXTENSION_RE.test(target)) {
|
|
466
|
+
if (typeof r === 'string' && !r.includes('://') && r.trim() !== '') {
|
|
467
|
+
const target = resolve(kustDir, r.trim())
|
|
468
|
+
if (resolvedFilePathIsUnderRoot(rootNorm, target)) {
|
|
469
|
+
/** @type {import('node:fs').Stats | undefined} */
|
|
470
|
+
let st
|
|
471
|
+
try {
|
|
472
|
+
st = await stat(target)
|
|
473
|
+
} catch {
|
|
474
|
+
st = undefined
|
|
475
|
+
}
|
|
476
|
+
if (st === undefined) {
|
|
477
|
+
fail(
|
|
478
|
+
`${rel}: посилання «${r}» вказує на неіснуючий ресурс (очікувано файл або каталог; k8s.mdc)`
|
|
479
|
+
)
|
|
480
|
+
} else if (st.isFile()) {
|
|
481
|
+
if (!YAML_EXTENSION_RE.test(target)) {
|
|
482
|
+
fail(
|
|
483
|
+
`${rel}: «${r}» — за правилами k8s у kustomization для файлів дозволені лише розширення .yaml / .yml (k8s.mdc)`
|
|
484
|
+
)
|
|
485
|
+
}
|
|
486
|
+
} else if (!st.isDirectory()) {
|
|
487
|
+
fail(`${rel}: «${r}» — ні файл, ні каталог (k8s.mdc)`)
|
|
488
|
+
}
|
|
489
|
+
} else {
|
|
492
490
|
fail(
|
|
493
|
-
`${rel}: «${r}»
|
|
491
|
+
`${rel}: посилання «${r}» виходить за межі репозиторію (resolve: ${(relative(rootNorm, target) || target).replaceAll('\\', '/')
|
|
492
|
+
}) (k8s.mdc)`
|
|
494
493
|
)
|
|
495
494
|
}
|
|
496
|
-
} else if (!st.isDirectory()) {
|
|
497
|
-
fail(`${rel}: «${r}» — ні файл, ні каталог (k8s.mdc)`)
|
|
498
495
|
}
|
|
499
496
|
}
|
|
500
497
|
}
|
|
501
498
|
|
|
502
499
|
/**
|
|
503
500
|
* Усі `kustomization.yaml` під `k8s`: локальні `path` / ресурси мають існувати.
|
|
504
|
-
* @param {string} root
|
|
505
|
-
* @param {string[]} yamlFilesAbs
|
|
506
|
-
* @param {(msg: string) => void} fail
|
|
501
|
+
* @param {string} root корінь репозиторію
|
|
502
|
+
* @param {string[]} yamlFilesAbs абсолютні шляхи YAML-файлів у k8s
|
|
503
|
+
* @param {(msg: string) => void} fail callback для повідомлень про помилки
|
|
507
504
|
* @returns {Promise<void>}
|
|
508
505
|
*/
|
|
509
506
|
async function validateKustomizationPathRefsExistOnDisk(root, yamlFilesAbs, fail) {
|
|
@@ -4293,26 +4290,26 @@ async function k8sBaseDirsFromKustomizeResourcePathRefs(kustDir, pathRefs, rootN
|
|
|
4293
4290
|
/** @type {string[]} */
|
|
4294
4291
|
const out = []
|
|
4295
4292
|
for (const ref of pathRefs) {
|
|
4296
|
-
if (typeof ref
|
|
4297
|
-
|
|
4298
|
-
|
|
4299
|
-
|
|
4300
|
-
|
|
4301
|
-
|
|
4302
|
-
|
|
4303
|
-
|
|
4304
|
-
|
|
4305
|
-
|
|
4306
|
-
|
|
4307
|
-
|
|
4308
|
-
|
|
4309
|
-
|
|
4310
|
-
|
|
4311
|
-
|
|
4312
|
-
|
|
4313
|
-
|
|
4293
|
+
if (typeof ref === 'string' && !ref.includes('://') && ref.trim() !== '') {
|
|
4294
|
+
const resolved = resolve(kustDir, ref.trim())
|
|
4295
|
+
if (resolvedFilePathIsUnderRoot(rootNorm, resolved)) {
|
|
4296
|
+
let st
|
|
4297
|
+
try {
|
|
4298
|
+
st = await stat(resolved)
|
|
4299
|
+
} catch {
|
|
4300
|
+
st = undefined
|
|
4301
|
+
}
|
|
4302
|
+
if (
|
|
4303
|
+
st !== undefined
|
|
4304
|
+
&& st.isDirectory()
|
|
4305
|
+
&& basename(resolved) === 'base'
|
|
4306
|
+
&& existsSync(join(resolved, 'kustomization.yaml'))
|
|
4307
|
+
&& isUnderK8sPathRelToRoot(rootNorm, resolved)
|
|
4308
|
+
) {
|
|
4309
|
+
out.push(resolved)
|
|
4310
|
+
}
|
|
4311
|
+
}
|
|
4314
4312
|
}
|
|
4315
|
-
out.push(resolved)
|
|
4316
4313
|
}
|
|
4317
4314
|
return out
|
|
4318
4315
|
}
|
|
@@ -4349,20 +4346,13 @@ async function yamlFileContainsHpaOrPdbDocument(fileAbs) {
|
|
|
4349
4346
|
if (docs === undefined) {
|
|
4350
4347
|
return false
|
|
4351
4348
|
}
|
|
4352
|
-
|
|
4353
|
-
if (doc.errors.length > 0)
|
|
4354
|
-
continue
|
|
4355
|
-
}
|
|
4349
|
+
return docs.some(doc => {
|
|
4350
|
+
if (doc.errors.length > 0) return false
|
|
4356
4351
|
const o = doc.toJSON()
|
|
4357
|
-
if (o === null || typeof o !== 'object' || Array.isArray(o))
|
|
4358
|
-
continue
|
|
4359
|
-
}
|
|
4352
|
+
if (o === null || typeof o !== 'object' || Array.isArray(o)) return false
|
|
4360
4353
|
const k = /** @type {Record<string, unknown>} */ (o).kind
|
|
4361
|
-
|
|
4362
|
-
|
|
4363
|
-
}
|
|
4364
|
-
}
|
|
4365
|
-
return false
|
|
4354
|
+
return k === 'HorizontalPodAutoscaler' || k === 'PodDisruptionBudget'
|
|
4355
|
+
})
|
|
4366
4356
|
}
|
|
4367
4357
|
|
|
4368
4358
|
/**
|
|
@@ -4398,7 +4388,7 @@ async function verifyK8sBaseKustomizeHpaPdbNeedDeployment(kustAbs, rel, fail, pa
|
|
|
4398
4388
|
* @param {Record<string, unknown>} kustObj перший документ
|
|
4399
4389
|
* @param {(msg: string) => void} fail callback
|
|
4400
4390
|
* @param {(msg: string) => void} passFn success
|
|
4401
|
-
* @param {(kust: string) => Promise<{ hasDeployment: boolean, hasHpa: boolean, hasPdb: boolean }>} getTreeFlags
|
|
4391
|
+
* @param {(kust: string) => Promise<{ hasDeployment: boolean, hasHpa: boolean, hasPdb: boolean }>} getTreeFlags функція отримання прапорців дерева kustomize
|
|
4402
4392
|
* @returns {Promise<void>}
|
|
4403
4393
|
*/
|
|
4404
4394
|
async function verifyOverlayHpaPdbFileRefsRespectBaseDeployment(
|
|
@@ -4427,41 +4417,33 @@ async function verifyOverlayHpaPdbFileRefsRespectBaseDeployment(
|
|
|
4427
4417
|
return false
|
|
4428
4418
|
})()
|
|
4429
4419
|
for (const ref of pathRefs) {
|
|
4430
|
-
if (typeof ref
|
|
4431
|
-
|
|
4432
|
-
|
|
4433
|
-
|
|
4434
|
-
|
|
4435
|
-
|
|
4436
|
-
|
|
4437
|
-
|
|
4438
|
-
|
|
4439
|
-
|
|
4440
|
-
|
|
4441
|
-
|
|
4442
|
-
|
|
4443
|
-
|
|
4444
|
-
|
|
4445
|
-
|
|
4446
|
-
|
|
4447
|
-
|
|
4448
|
-
|
|
4449
|
-
|
|
4450
|
-
|
|
4451
|
-
|
|
4452
|
-
|
|
4453
|
-
|
|
4454
|
-
|
|
4455
|
-
|
|
4456
|
-
|
|
4457
|
-
if (!anyBaseHasDep) {
|
|
4458
|
-
fail(
|
|
4459
|
-
`${rel}: посилання «${ref}» містить HorizontalPodAutoscaler і/або PodDisruptionBudget, а наслідуваний k8s/base не дає у дереві Deployment — прибери HPA/PDB або додай Deployment у base (k8s.mdc)`
|
|
4460
|
-
)
|
|
4461
|
-
} else {
|
|
4462
|
-
passFn(
|
|
4463
|
-
`${rel}: overlay-файл «${(relative(root, fAbs) || ref).replaceAll('\\', '/')}» з HPA/PDB, base містить Deployment (k8s.mdc)`
|
|
4464
|
-
)
|
|
4420
|
+
if (typeof ref === 'string' && !ref.includes('://') && ref.trim() !== '') {
|
|
4421
|
+
const fAbs = resolve(kustDir, ref.trim())
|
|
4422
|
+
if (resolvedFilePathIsUnderRoot(root, fAbs) && existsSync(fAbs)) {
|
|
4423
|
+
let st
|
|
4424
|
+
try {
|
|
4425
|
+
st = await stat(fAbs)
|
|
4426
|
+
} catch {
|
|
4427
|
+
st = undefined
|
|
4428
|
+
}
|
|
4429
|
+
if (st !== undefined && st.isFile() && YAML_EXTENSION_RE.test(fAbs)) {
|
|
4430
|
+
const fUnderSomeBase = baseDirs.some(bd => isResolvedFileUnderDirectory(bd, fAbs))
|
|
4431
|
+
if (!fUnderSomeBase) {
|
|
4432
|
+
const hpaPdb = await yamlFileContainsHpaOrPdbDocument(fAbs)
|
|
4433
|
+
if (hpaPdb) {
|
|
4434
|
+
if (anyBaseHasDep) {
|
|
4435
|
+
passFn(
|
|
4436
|
+
`${rel}: overlay-файл «${(relative(root, fAbs) || ref).replaceAll('\\', '/')}» з HPA/PDB, base містить Deployment (k8s.mdc)`
|
|
4437
|
+
)
|
|
4438
|
+
} else {
|
|
4439
|
+
fail(
|
|
4440
|
+
`${rel}: посилання «${ref}» містить HorizontalPodAutoscaler і/або PodDisruptionBudget, а наслідуваний k8s/base не дає у дереві Deployment — прибери HPA/PDB або додай Deployment у base (k8s.mdc)`
|
|
4441
|
+
)
|
|
4442
|
+
}
|
|
4443
|
+
}
|
|
4444
|
+
}
|
|
4445
|
+
}
|
|
4446
|
+
}
|
|
4465
4447
|
}
|
|
4466
4448
|
}
|
|
4467
4449
|
}
|
|
@@ -4480,8 +4462,8 @@ async function validateKustomizeHpaPdbOnlyWithBaseDeployment(root, yamlFilesAbs,
|
|
|
4480
4462
|
/** @type {Map<string, Promise<{ hasDeployment: boolean, hasHpa: boolean, hasPdb: boolean }>>} */
|
|
4481
4463
|
const treeFlagsMemo = new Map()
|
|
4482
4464
|
/**
|
|
4483
|
-
* @param {string} kustPath
|
|
4484
|
-
* @returns {Promise<{ hasDeployment: boolean, hasHpa: boolean, hasPdb: boolean }>}
|
|
4465
|
+
* @param {string} kustPath абсолютний шлях до kustomization.yaml
|
|
4466
|
+
* @returns {Promise<{ hasDeployment: boolean, hasHpa: boolean, hasPdb: boolean }>} прапорці наявності ресурсів у дереві
|
|
4485
4467
|
*/
|
|
4486
4468
|
const getTreeFlags = kustPath => {
|
|
4487
4469
|
const k = resolve(kustPath)
|
|
@@ -4496,21 +4478,20 @@ async function validateKustomizeHpaPdbOnlyWithBaseDeployment(root, yamlFilesAbs,
|
|
|
4496
4478
|
for (const kustAbs of kustFiles) {
|
|
4497
4479
|
const rel = (relative(rootNorm, kustAbs) || kustAbs).replaceAll('\\', '/')
|
|
4498
4480
|
const kust = await readFirstYamlObject(kustAbs)
|
|
4499
|
-
if (kust
|
|
4500
|
-
|
|
4501
|
-
|
|
4502
|
-
|
|
4503
|
-
|
|
4504
|
-
|
|
4505
|
-
|
|
4506
|
-
|
|
4507
|
-
|
|
4508
|
-
|
|
4509
|
-
|
|
4510
|
-
|
|
4511
|
-
|
|
4512
|
-
|
|
4513
|
-
)
|
|
4481
|
+
if (kust !== null) {
|
|
4482
|
+
if (isK8sBaseKustomizationRelPath(rel)) {
|
|
4483
|
+
await verifyK8sBaseKustomizeHpaPdbNeedDeployment(kustAbs, rel, fail, passFn, getTreeFlags)
|
|
4484
|
+
} else {
|
|
4485
|
+
await verifyOverlayHpaPdbFileRefsRespectBaseDeployment(
|
|
4486
|
+
rootNorm,
|
|
4487
|
+
kustAbs,
|
|
4488
|
+
rel,
|
|
4489
|
+
kust,
|
|
4490
|
+
fail,
|
|
4491
|
+
passFn,
|
|
4492
|
+
getTreeFlags
|
|
4493
|
+
)
|
|
4494
|
+
}
|
|
4514
4495
|
}
|
|
4515
4496
|
}
|
|
4516
4497
|
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Визначає, чи рядок `FROM` у Dockerfile використовує образи
|
|
3
|
+
* `oven/bun`, `alpine`, `nginx`, `node` з Docker Hub без дзеркала
|
|
4
|
+
* `mirror.gcr.io` (GCR mirror).
|
|
5
|
+
*
|
|
6
|
+
* Правило застосовується лише до тих самих звернень, що виглядають як
|
|
7
|
+
* pull з Docker Hub (короткі імена, `docker.io/…`); приватні реєстри
|
|
8
|
+
* (hostname у першому сегменті) не оцінюються.
|
|
9
|
+
*
|
|
10
|
+
* Канонічні заміни: `mirror.gcr.io/oven/bun` та
|
|
11
|
+
* `mirror.gcr.io/library/{alpine,nginx,node}`.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* @param {string} t — токен образу в лапках або без
|
|
16
|
+
* @returns {string} токен без зовнішніх лапок
|
|
17
|
+
*/
|
|
18
|
+
function stripFromImageQuotes(t) {
|
|
19
|
+
if (t.length >= 2 && (t[0] === '"' || t[0] === "'")) {
|
|
20
|
+
return t.slice(1, -1)
|
|
21
|
+
}
|
|
22
|
+
return t
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Виділяє токен образу з рядка `FROM` (після зняття inline-коментаря, без AS).
|
|
27
|
+
* Підтримує прапорець `--platform=…` і форму `--platform` + значення.
|
|
28
|
+
*
|
|
29
|
+
* @param {string} line — рядок Dockerfile
|
|
30
|
+
* @returns {string | null} токен образу або null, якщо рядок не `FROM`
|
|
31
|
+
*/
|
|
32
|
+
export function getFromImageToken(line) {
|
|
33
|
+
const withoutComment = line.split('#')[0].trim()
|
|
34
|
+
if (!withoutComment) return null
|
|
35
|
+
const m = withoutComment.match(/^\s*FROM\s+(.+)$/i)
|
|
36
|
+
if (!m) return null
|
|
37
|
+
const raw = m[1].trim()
|
|
38
|
+
const tokenRe = /(?:[^\s"]+|"[^"]*")+/g
|
|
39
|
+
const tokens = raw.match(tokenRe) || []
|
|
40
|
+
let i = 0
|
|
41
|
+
while (i < tokens.length) {
|
|
42
|
+
const t = tokens[i]
|
|
43
|
+
if (t === '--platform' || t.startsWith('--platform=')) {
|
|
44
|
+
if (t.startsWith('--platform=')) {
|
|
45
|
+
i += 1
|
|
46
|
+
} else if (tokens[i + 1] === undefined) {
|
|
47
|
+
i += 1
|
|
48
|
+
} else {
|
|
49
|
+
i += 2
|
|
50
|
+
}
|
|
51
|
+
} else if (t === '--' || t.toUpperCase() === 'AS') {
|
|
52
|
+
break
|
|
53
|
+
} else if (t.startsWith('--') && t.includes('=')) {
|
|
54
|
+
i += 1
|
|
55
|
+
} else if (t.startsWith('--')) {
|
|
56
|
+
i += 1
|
|
57
|
+
} else {
|
|
58
|
+
return stripFromImageQuotes(t)
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return null
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Схоже на звернення до Docker Hub (коротке ім’я, `docker.io/…`, не mirror.gcr.io).
|
|
66
|
+
* Не вважати Hub: явний чужий реєстр (`gcr.io/…`, `reg.example.com:5000/…`).
|
|
67
|
+
*
|
|
68
|
+
* @param {string} imageToken — ref образу (FROM)
|
|
69
|
+
* @returns {boolean} true, якщо схоже на pull з Docker Hub
|
|
70
|
+
*/
|
|
71
|
+
export function isDockerHubStyleImageRef(imageToken) {
|
|
72
|
+
if (!imageToken) return false
|
|
73
|
+
if (/^mirror\.gcr\.io\//i.test(imageToken)) return false
|
|
74
|
+
const noDigest = imageToken.split('@')[0] || ''
|
|
75
|
+
if (!noDigest.includes('/')) {
|
|
76
|
+
return true
|
|
77
|
+
}
|
|
78
|
+
const first = noDigest.split('/')[0] || ''
|
|
79
|
+
if (first === 'docker.io' || first === 'index.docker.io') return true
|
|
80
|
+
if (first.includes('.')) return false
|
|
81
|
+
if (first === 'localhost' || /^\d+\.\d+/.test(first)) return false
|
|
82
|
+
if (first.includes(':') && /^\S+:\d+$/.test(first)) {
|
|
83
|
+
return false
|
|
84
|
+
}
|
|
85
|
+
return true
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Нормалізує шлях репозиторію (без тега/digest) для порівняння: `library/node`, `oven/bun`, …
|
|
90
|
+
*
|
|
91
|
+
* @param {string} imageToken — ref образу
|
|
92
|
+
* @returns {string} нормалізований шлях репозиторію без тега
|
|
93
|
+
*/
|
|
94
|
+
export function normalizeHubRepoPath(imageToken) {
|
|
95
|
+
let s = (imageToken.split('@')[0] || '').toLowerCase()
|
|
96
|
+
s = s.replace(/^(docker\.io|index\.docker\.io)\//, '')
|
|
97
|
+
if (!s.includes('/')) {
|
|
98
|
+
return `library/${s.split(':')[0]}`
|
|
99
|
+
}
|
|
100
|
+
const lastSl = s.lastIndexOf('/')
|
|
101
|
+
const lastCol = s.lastIndexOf(':')
|
|
102
|
+
if (lastCol > lastSl) {
|
|
103
|
+
s = s.slice(0, lastCol)
|
|
104
|
+
}
|
|
105
|
+
return s
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const HUB_REPOS_REQUIRING_MIRROR = /** @type {const} */ ([
|
|
109
|
+
'oven/bun',
|
|
110
|
+
'library/alpine',
|
|
111
|
+
'library/nginx',
|
|
112
|
+
'library/node'
|
|
113
|
+
])
|
|
114
|
+
|
|
115
|
+
const EXPECTED_MIRROR = /** @type {const} */ ({
|
|
116
|
+
'oven/bun': 'mirror.gcr.io/oven/bun',
|
|
117
|
+
'library/alpine': 'mirror.gcr.io/library/alpine',
|
|
118
|
+
'library/nginx': 'mirror.gcr.io/library/nginx',
|
|
119
|
+
'library/node': 'mirror.gcr.io/library/node'
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Якщо образ тягнеть з Hub і підлягає дзеркалу — повертає рекомендовану заміну, інакше `null`.
|
|
124
|
+
*
|
|
125
|
+
* @param {string} imageToken — ref після `FROM`
|
|
126
|
+
* @returns {string | null} рекомендований `mirror.gcr.io/...` (без тега) або null
|
|
127
|
+
*/
|
|
128
|
+
export function getRequiredMirrorGcrImage(imageToken) {
|
|
129
|
+
if (!imageToken) return null
|
|
130
|
+
if (/^mirror\.gcr\.io\//i.test(imageToken)) return null
|
|
131
|
+
if (!isDockerHubStyleImageRef(imageToken)) return null
|
|
132
|
+
const norm = normalizeHubRepoPath(imageToken)
|
|
133
|
+
if (!HUB_REPOS_REQUIRING_MIRROR.includes(/** @type {any} */ (norm))) {
|
|
134
|
+
return null
|
|
135
|
+
}
|
|
136
|
+
return EXPECTED_MIRROR[/** @type {keyof typeof EXPECTED_MIRROR} */ (norm)]
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Сканує вміст Dockerfile / Containerfile — повертає рядок помилки або `null`.
|
|
141
|
+
*
|
|
142
|
+
* @param {string} fileContent — повний вміст Dockerfile
|
|
143
|
+
* @returns {string | null} повідомлення з номером рядка або null
|
|
144
|
+
*/
|
|
145
|
+
export function getMirrorGcrHint(fileContent) {
|
|
146
|
+
const lines = fileContent.split(/\r?\n/)
|
|
147
|
+
for (let n = 0; n < lines.length; n++) {
|
|
148
|
+
const line = lines[n]
|
|
149
|
+
const image = getFromImageToken(line)
|
|
150
|
+
const expected = getRequiredMirrorGcrImage(image)
|
|
151
|
+
if (expected) {
|
|
152
|
+
return `рядок ${n + 1}: FROM має тягнути ${expected} (замість ${image})`
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return null
|
|
156
|
+
}
|
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Використовується в check-ga, check-js-lint, check-text, check-style-lint, check-npm-module замість
|
|
5
5
|
* пошуку підрядків у сирому тексті там, де важливі лише значення `uses:` та `run:` кроків.
|
|
6
|
+
*
|
|
7
|
+
* Для `run:` також виявляється shell-продовження рядка через `\\` перед переносом (антипатерн у ga.mdc).
|
|
6
8
|
*/
|
|
7
9
|
import { parse } from 'yaml'
|
|
8
10
|
|
|
@@ -67,6 +69,36 @@ export function getStepRun(step) {
|
|
|
67
69
|
return ''
|
|
68
70
|
}
|
|
69
71
|
|
|
72
|
+
/** У тексті `run:` зіставляє `\\` одразу перед переносом рядка (типове shell-продовження в bash). */
|
|
73
|
+
const RUN_SHELL_LINE_CONTINUATION_BACKSLASH_RE = /\\\r?\n/
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Чи містить значення `run:` shell-продовження рядка через зворотний сліш перед переносом (`… \\` + NL).
|
|
77
|
+
* У workflow такі конструкції замінюють на folded block `>-` без зворотних слішів (ga.mdc).
|
|
78
|
+
* @param {string} runText текст з `getStepRun`
|
|
79
|
+
* @returns {boolean} `true`, якщо знайдено `\\` перед новим рядком
|
|
80
|
+
*/
|
|
81
|
+
export function runTextHasShellLineContinuationBackslash(runText) {
|
|
82
|
+
return typeof runText === 'string' && runText.length > 0 && RUN_SHELL_LINE_CONTINUATION_BACKSLASH_RE.test(runText)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Повертає кроки, у яких `run:` містить заборонене shell-продовження через `\\`.
|
|
87
|
+
* @param {Record<string, unknown>} root корінь workflow
|
|
88
|
+
* @returns {{ jobId: string, stepIndex: number }[]} список кроків із порушенням
|
|
89
|
+
*/
|
|
90
|
+
export function findRunStepsWithShellLineContinuationBackslash(root) {
|
|
91
|
+
/** @type {{ jobId: string, stepIndex: number }[]} */
|
|
92
|
+
const out = []
|
|
93
|
+
for (const { jobId, stepIndex, step } of flattenWorkflowSteps(root)) {
|
|
94
|
+
const run = getStepRun(step)
|
|
95
|
+
if (runTextHasShellLineContinuationBackslash(run)) {
|
|
96
|
+
out.push({ jobId, stepIndex })
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return out
|
|
100
|
+
}
|
|
101
|
+
|
|
70
102
|
/**
|
|
71
103
|
* Чи є крок, у якого `uses` містить будь-який з підрядків.
|
|
72
104
|
* @param {Record<string, unknown>} root корінь workflow
|