@nitra/cursor 1.8.129 → 1.8.130
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/ga.mdc +31 -0
- package/package.json +1 -1
- package/scripts/check-ga.mjs +384 -1
package/mdc/ga.mdc
CHANGED
|
@@ -161,6 +161,37 @@ jobs:
|
|
|
161
161
|
|
|
162
162
|
**Кроки `run`:** не розбивай команду shell-продовженням через зворотний сліш у кінці рядка (`… \` у `run: |`). Замість багаторядкового буквального блока з `\\` оформ довгу одну shell-команду як **folded block** `>-` (рядки з’єднаються в один рядок із пробілами).
|
|
163
163
|
|
|
164
|
+
**Читабельність `run: >-` з циклам/умовами:** оскільки `>-` **згортає рядки в один shell-рядок**, відступи потрібні **лише для читабельності** й не впливають на виконання. Але для конструкцій bash на кшталт `while …; do … done` та `if …; then … fi` **обовʼязково**:
|
|
165
|
+
|
|
166
|
+
- став явні роздільники команд **`;`** або **`&&`** там, де при згортанні рядків інакше “злипнуться” токени (`do`/`then`/`fi`/`done` з наступною командою);
|
|
167
|
+
- тримай `do` і `then` в одному логічному рядку як `…; do` / `…; then`, щоб після згортання це гарантовано лишалось валідним bash;
|
|
168
|
+
- додавай відступи всередині `do/then/else` блоків, навіть якщо це один рядок після згортання — так workflow лишається читабельним у diff.
|
|
169
|
+
|
|
170
|
+
### Приклад (ПРАВИЛЬНО — читабельно, `run: >-`, з `while`/`if`)
|
|
171
|
+
|
|
172
|
+
```yaml
|
|
173
|
+
- name: Apply changes
|
|
174
|
+
shell: bash
|
|
175
|
+
run: >-
|
|
176
|
+
echo "$FILES" |
|
|
177
|
+
while read -r FILE; do
|
|
178
|
+
[ -z "$FILE" ] && continue;
|
|
179
|
+
dirname "$FILE";
|
|
180
|
+
done |
|
|
181
|
+
sort -u |
|
|
182
|
+
while read -r DIR; do
|
|
183
|
+
(
|
|
184
|
+
if [ -f "$DIR/kustomization.yaml" ]; then
|
|
185
|
+
printf 'Applying %s\n' "$DIR" &&
|
|
186
|
+
cd "$DIR" &&
|
|
187
|
+
kubectl apply -k .;
|
|
188
|
+
else
|
|
189
|
+
echo "Skip $DIR - no kustomization.yaml";
|
|
190
|
+
fi
|
|
191
|
+
);
|
|
192
|
+
done
|
|
193
|
+
```
|
|
194
|
+
|
|
164
195
|
### Приклад run (НЕПРАВИЛЬНО — `\\` на кінцях)
|
|
165
196
|
|
|
166
197
|
```yaml
|
package/package.json
CHANGED
package/scripts/check-ga.mjs
CHANGED
|
@@ -7,6 +7,10 @@
|
|
|
7
7
|
* `\.vscode/settings.json` — `editor.defaultFormatter` **oxc** для `[github-actions-workflow]`,
|
|
8
8
|
* перед `uses: ./…/setup-bun-deps` у workflow — `actions/checkout` (runner інакше не бачить локальний action).
|
|
9
9
|
*
|
|
10
|
+
* Також перевіряє, що ключові workflow (`clean-ga-workflows.yml`, `clean-merged-branch.yml`, `lint-ga.yml`, `git-ai.yml`)
|
|
11
|
+
* мають структуру й значення, узгоджені з `npm/mdc/ga.mdc`. Для цих файлів перевірка виконується структурно
|
|
12
|
+
* (після YAML parse), щоб не залежати від форматування/відступів.
|
|
13
|
+
*
|
|
10
14
|
* Заборонено дублювати кроки встановлення Bun та кешування безпосередньо у workflow файлах
|
|
11
15
|
* (oven-sh/setup-bun, actions/cache, bun install). Перевірки `uses`/`run` виконуються після **YAML parse**
|
|
12
16
|
* (`yaml`), щоб не спрацьовувати на випадкові збіги в коментарях або поза кроками.
|
|
@@ -25,6 +29,9 @@ import {
|
|
|
25
29
|
findRunStepsWithShellLineContinuationBackslash,
|
|
26
30
|
hasAnyStepUsesContaining,
|
|
27
31
|
hasCheckoutBeforeLocalSetupBunDeps,
|
|
32
|
+
flattenWorkflowSteps,
|
|
33
|
+
getStepRun,
|
|
34
|
+
getStepUses,
|
|
28
35
|
parseWorkflowYaml
|
|
29
36
|
} from './utils/gha-workflow.mjs'
|
|
30
37
|
|
|
@@ -44,6 +51,341 @@ const FORBIDDEN_BUN_PATTERNS = [
|
|
|
44
51
|
{ pattern: 'bun install', msg: 'використовуй .github/actions/setup-bun-deps замість bun install' }
|
|
45
52
|
]
|
|
46
53
|
|
|
54
|
+
/** Обовʼязкові workflow-файли (ga.mdc). */
|
|
55
|
+
const REQUIRED_WORKFLOWS = ['clean-ga-workflows.yml', 'clean-merged-branch.yml', 'lint-ga.yml', 'git-ai.yml']
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Безпечний доступ до вкладеного поля (лише для обʼєктів).
|
|
59
|
+
* @param {unknown} obj значення-кандидат на обʼєкт
|
|
60
|
+
* @param {string} key ключ
|
|
61
|
+
* @returns {unknown} значення поля або undefined
|
|
62
|
+
*/
|
|
63
|
+
function getObjKey(obj, key) {
|
|
64
|
+
if (!obj || typeof obj !== 'object' || Array.isArray(obj)) return undefined
|
|
65
|
+
return /** @type {Record<string, unknown>} */ (obj)[key]
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Очікує, що значення є рядком рівно `expected`.
|
|
70
|
+
* @param {unknown} v значення
|
|
71
|
+
* @param {string} expected очікуваний рядок
|
|
72
|
+
* @returns {boolean} true, якщо збігається
|
|
73
|
+
*/
|
|
74
|
+
function isExactString(v, expected) {
|
|
75
|
+
return typeof v === 'string' && v === expected
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Перевіряє структуру workflow `clean-ga-workflows.yml` (ga.mdc).
|
|
80
|
+
* @param {Record<string, unknown> | null} root parsed YAML
|
|
81
|
+
* @param {(msg: string) => void} passFn pass
|
|
82
|
+
* @param {(msg: string) => void} failFn fail
|
|
83
|
+
*/
|
|
84
|
+
function validateCleanGaWorkflows(root, passFn, failFn) {
|
|
85
|
+
if (!root) {
|
|
86
|
+
failFn('clean-ga-workflows.yml: YAML не вдалося розібрати (ga.mdc)')
|
|
87
|
+
return
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (!isExactString(root.name, 'Clean action for removing completed workflow runs')) {
|
|
91
|
+
failFn('clean-ga-workflows.yml: name має бути "Clean action for removing completed workflow runs" (ga.mdc)')
|
|
92
|
+
} else {
|
|
93
|
+
passFn('clean-ga-workflows.yml: name OK')
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const on = root.on
|
|
97
|
+
const schedule = getObjKey(on, 'schedule')
|
|
98
|
+
const wfDispatch = getObjKey(on, 'workflow_dispatch')
|
|
99
|
+
|
|
100
|
+
const hasCron =
|
|
101
|
+
Array.isArray(schedule) &&
|
|
102
|
+
schedule.some(v => v && typeof v === 'object' && /** @type {Record<string, unknown>} */ (v).cron === '0 1 16 * *')
|
|
103
|
+
|
|
104
|
+
if (!hasCron) {
|
|
105
|
+
failFn("clean-ga-workflows.yml: on.schedule має містити cron: '0 1 16 * *' (ga.mdc)")
|
|
106
|
+
} else {
|
|
107
|
+
passFn('clean-ga-workflows.yml: cron OK')
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (!wfDispatch || typeof wfDispatch !== 'object') {
|
|
111
|
+
failFn('clean-ga-workflows.yml: має бути workflow_dispatch: {} (ga.mdc)')
|
|
112
|
+
} else {
|
|
113
|
+
passFn('clean-ga-workflows.yml: workflow_dispatch OK')
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const jobs = getObjKey(root, 'jobs')
|
|
117
|
+
const job = getObjKey(jobs, 'cleanup_old_workflows')
|
|
118
|
+
if (!job) {
|
|
119
|
+
failFn('clean-ga-workflows.yml: jobs.cleanup_old_workflows відсутній (ga.mdc)')
|
|
120
|
+
return
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (!isExactString(getObjKey(job, 'runs-on'), 'ubuntu-latest')) {
|
|
124
|
+
failFn('clean-ga-workflows.yml: runs-on має бути ubuntu-latest (ga.mdc)')
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const perm = getObjKey(job, 'permissions')
|
|
128
|
+
if (!(getObjKey(perm, 'actions') === 'write' && getObjKey(perm, 'contents') === 'read')) {
|
|
129
|
+
failFn('clean-ga-workflows.yml: permissions мають бути actions: write, contents: read (ga.mdc)')
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const steps = getObjKey(job, 'steps')
|
|
133
|
+
const step0 = Array.isArray(steps) ? steps[0] : null
|
|
134
|
+
if (!step0 || typeof step0 !== 'object') {
|
|
135
|
+
failFn('clean-ga-workflows.yml: steps має містити крок з dmvict/clean-workflow-runs@v1 (ga.mdc)')
|
|
136
|
+
return
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (!isExactString(getObjKey(step0, 'name'), 'Delete workflow runs')) {
|
|
140
|
+
failFn('clean-ga-workflows.yml: перший крок має мати name: Delete workflow runs (ga.mdc)')
|
|
141
|
+
}
|
|
142
|
+
if (!isExactString(getObjKey(step0, 'uses'), 'dmvict/clean-workflow-runs@v1')) {
|
|
143
|
+
failFn('clean-ga-workflows.yml: перший крок має uses: dmvict/clean-workflow-runs@v1 (ga.mdc)')
|
|
144
|
+
}
|
|
145
|
+
const withObj = getObjKey(step0, 'with')
|
|
146
|
+
if (
|
|
147
|
+
!(getObjKey(withObj, 'token') === '${{ github.token }}' &&
|
|
148
|
+
getObjKey(withObj, 'save_period') === 31 &&
|
|
149
|
+
getObjKey(withObj, 'save_min_runs_number') === 0)
|
|
150
|
+
) {
|
|
151
|
+
failFn('clean-ga-workflows.yml: with має містити token/save_period/save_min_runs_number як у ga.mdc')
|
|
152
|
+
} else {
|
|
153
|
+
passFn('clean-ga-workflows.yml: jobs/steps OK')
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Перевіряє структуру workflow `clean-merged-branch.yml` (ga.mdc).
|
|
159
|
+
* @param {Record<string, unknown> | null} root parsed YAML
|
|
160
|
+
* @param {(msg: string) => void} passFn pass
|
|
161
|
+
* @param {(msg: string) => void} failFn fail
|
|
162
|
+
*/
|
|
163
|
+
function validateCleanMergedBranch(root, passFn, failFn) {
|
|
164
|
+
if (!root) {
|
|
165
|
+
failFn('clean-merged-branch.yml: YAML не вдалося розібрати (ga.mdc)')
|
|
166
|
+
return
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (!isExactString(root.name, 'Clean abandoned branches')) {
|
|
170
|
+
failFn('clean-merged-branch.yml: name має бути "Clean abandoned branches" (ga.mdc)')
|
|
171
|
+
} else {
|
|
172
|
+
passFn('clean-merged-branch.yml: name OK')
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const on = root.on
|
|
176
|
+
const schedule = getObjKey(on, 'schedule')
|
|
177
|
+
const wfDispatch = getObjKey(on, 'workflow_dispatch')
|
|
178
|
+
const hasCron =
|
|
179
|
+
Array.isArray(schedule) &&
|
|
180
|
+
schedule.some(v => v && typeof v === 'object' && /** @type {Record<string, unknown>} */ (v).cron === '0 1 15 * *')
|
|
181
|
+
|
|
182
|
+
if (!hasCron) {
|
|
183
|
+
failFn("clean-merged-branch.yml: on.schedule має містити cron: '0 1 15 * *' (ga.mdc)")
|
|
184
|
+
} else {
|
|
185
|
+
passFn('clean-merged-branch.yml: cron OK')
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if (!wfDispatch || typeof wfDispatch !== 'object') {
|
|
189
|
+
failFn('clean-merged-branch.yml: має бути workflow_dispatch: {} (ga.mdc)')
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const jobs = getObjKey(root, 'jobs')
|
|
193
|
+
const job = getObjKey(jobs, 'cleanup_old_branches')
|
|
194
|
+
if (!job) {
|
|
195
|
+
failFn('clean-merged-branch.yml: jobs.cleanup_old_branches відсутній (ga.mdc)')
|
|
196
|
+
return
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const perm = getObjKey(job, 'permissions')
|
|
200
|
+
if (!(getObjKey(perm, 'contents') === 'write')) {
|
|
201
|
+
failFn('clean-merged-branch.yml: permissions мають бути contents: write (ga.mdc)')
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const steps = getObjKey(job, 'steps')
|
|
205
|
+
if (!Array.isArray(steps) || steps.length < 2) {
|
|
206
|
+
failFn('clean-merged-branch.yml: steps має містити 2 кроки як у ga.mdc')
|
|
207
|
+
return
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const step0 = steps[0]
|
|
211
|
+
if (!step0 || typeof step0 !== 'object') {
|
|
212
|
+
failFn('clean-merged-branch.yml: перший крок невалідний (ga.mdc)')
|
|
213
|
+
return
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
if (!isExactString(getObjKey(step0, 'id'), 'delete_stuff')) {
|
|
217
|
+
failFn('clean-merged-branch.yml: перший крок має id: delete_stuff (ga.mdc)')
|
|
218
|
+
}
|
|
219
|
+
if (!isExactString(getObjKey(step0, 'uses'), 'phpdocker-io/github-actions-delete-abandoned-branches@v2.0.3')) {
|
|
220
|
+
failFn('clean-merged-branch.yml: перший крок має uses як у ga.mdc')
|
|
221
|
+
}
|
|
222
|
+
const withObj = getObjKey(step0, 'with')
|
|
223
|
+
if (getObjKey(withObj, 'github_token') !== '${{ github.token }}') {
|
|
224
|
+
failFn('clean-merged-branch.yml: with.github_token має бути ${{ github.token }} (ga.mdc)')
|
|
225
|
+
}
|
|
226
|
+
if (getObjKey(withObj, 'last_commit_age_days') !== 90) {
|
|
227
|
+
failFn('clean-merged-branch.yml: with.last_commit_age_days має бути 90 (ga.mdc)')
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const ignoreBranches = String(getObjKey(withObj, 'ignore_branches') ?? '')
|
|
231
|
+
if (!(ignoreBranches.includes('main') && ignoreBranches.includes('dev'))) {
|
|
232
|
+
failFn('clean-merged-branch.yml: with.ignore_branches має містити main,dev (ga.mdc)')
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
if (getObjKey(withObj, 'dry_run') !== 'no') {
|
|
236
|
+
failFn('clean-merged-branch.yml: with.dry_run має бути no (ga.mdc)')
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const step1 = steps[1]
|
|
240
|
+
if (!step1 || typeof step1 !== 'object') {
|
|
241
|
+
failFn('clean-merged-branch.yml: другий крок невалідний (ga.mdc)')
|
|
242
|
+
return
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
if (!isExactString(getObjKey(step1, 'name'), 'Get output')) {
|
|
246
|
+
failFn('clean-merged-branch.yml: другий крок має name: Get output (ga.mdc)')
|
|
247
|
+
}
|
|
248
|
+
const env = getObjKey(step1, 'env')
|
|
249
|
+
if (getObjKey(env, 'DELETED_BRANCHES') !== '${{ steps.delete_stuff.outputs.deleted_branches }}') {
|
|
250
|
+
failFn('clean-merged-branch.yml: env.DELETED_BRANCHES має бути як у ga.mdc')
|
|
251
|
+
}
|
|
252
|
+
if (!String(getObjKey(step1, 'run') ?? '').includes('echo "Deleted branches: ${DELETED_BRANCHES}"')) {
|
|
253
|
+
failFn('clean-merged-branch.yml: run має echo Deleted branches як у ga.mdc')
|
|
254
|
+
} else {
|
|
255
|
+
passFn('clean-merged-branch.yml: jobs/steps OK')
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Перевіряє структуру workflow `lint-ga.yml` (ga.mdc).
|
|
261
|
+
* @param {Record<string, unknown> | null} root parsed YAML
|
|
262
|
+
* @param {(msg: string) => void} passFn pass
|
|
263
|
+
* @param {(msg: string) => void} failFn fail
|
|
264
|
+
*/
|
|
265
|
+
function validateLintGaWorkflowStructure(root, passFn, failFn) {
|
|
266
|
+
if (!root) {
|
|
267
|
+
failFn('lint-ga.yml: YAML не вдалося розібрати (ga.mdc)')
|
|
268
|
+
return
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
if (!isExactString(root.name, 'Lint GA')) {
|
|
272
|
+
failFn('lint-ga.yml: name має бути "Lint GA" (ga.mdc)')
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const on = root.on
|
|
276
|
+
const push = getObjKey(on, 'push')
|
|
277
|
+
const pr = getObjKey(on, 'pull_request')
|
|
278
|
+
const pushBranches = getObjKey(push, 'branches')
|
|
279
|
+
const pushPaths = getObjKey(push, 'paths')
|
|
280
|
+
const prBranches = getObjKey(pr, 'branches')
|
|
281
|
+
|
|
282
|
+
if (!Array.isArray(pushBranches) || !(pushBranches.includes('dev') && pushBranches.includes('main'))) {
|
|
283
|
+
failFn('lint-ga.yml: on.push.branches має містити dev і main (ga.mdc)')
|
|
284
|
+
}
|
|
285
|
+
if (!Array.isArray(prBranches) || !(prBranches.includes('dev') && prBranches.includes('main'))) {
|
|
286
|
+
failFn('lint-ga.yml: on.pull_request.branches має містити dev і main (ga.mdc)')
|
|
287
|
+
}
|
|
288
|
+
if (!Array.isArray(pushPaths) || !(pushPaths.includes('.github/actions/**') && pushPaths.includes('.github/workflows/**'))) {
|
|
289
|
+
failFn('lint-ga.yml: on.push.paths має містити .github/actions/** і .github/workflows/** (ga.mdc)')
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
const conc = getObjKey(root, 'concurrency')
|
|
293
|
+
if (!(getObjKey(conc, 'cancel-in-progress') === true)) {
|
|
294
|
+
failFn('lint-ga.yml: concurrency.cancel-in-progress має бути true (ga.mdc)')
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
const jobs = getObjKey(root, 'jobs')
|
|
298
|
+
const job = getObjKey(jobs, 'lint-ga')
|
|
299
|
+
if (!job) {
|
|
300
|
+
failFn('lint-ga.yml: jobs.lint-ga відсутній (ga.mdc)')
|
|
301
|
+
return
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
if (!isExactString(getObjKey(job, 'runs-on'), 'ubuntu-latest')) {
|
|
305
|
+
failFn('lint-ga.yml: runs-on має бути ubuntu-latest (ga.mdc)')
|
|
306
|
+
}
|
|
307
|
+
const perm = getObjKey(job, 'permissions')
|
|
308
|
+
if (!(getObjKey(perm, 'contents') === 'read')) {
|
|
309
|
+
failFn('lint-ga.yml: permissions мають бути contents: read (ga.mdc)')
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
const steps = getObjKey(job, 'steps')
|
|
313
|
+
if (!Array.isArray(steps) || steps.length === 0) {
|
|
314
|
+
failFn('lint-ga.yml: jobs.lint-ga.steps відсутні (ga.mdc)')
|
|
315
|
+
return
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const flat = flattenWorkflowSteps(root)
|
|
319
|
+
const usesList = flat.map(s => getStepUses(s.step))
|
|
320
|
+
const runBlob = flat.map(s => getStepRun(s.step)).join('\n')
|
|
321
|
+
|
|
322
|
+
if (!usesList.includes('actions/checkout@v6')) {
|
|
323
|
+
failFn('lint-ga.yml: має бути uses: actions/checkout@v6 (ga.mdc)')
|
|
324
|
+
}
|
|
325
|
+
if (!usesList.includes('./.github/actions/setup-bun-deps')) {
|
|
326
|
+
failFn('lint-ga.yml: має бути uses: ./.github/actions/setup-bun-deps (ga.mdc)')
|
|
327
|
+
}
|
|
328
|
+
if (!usesList.includes('astral-sh/setup-uv@v8.0.0')) {
|
|
329
|
+
failFn('lint-ga.yml: має бути uses: astral-sh/setup-uv@v8.0.0 (ga.mdc)')
|
|
330
|
+
}
|
|
331
|
+
if (!runBlob.includes('bun run lint-ga')) {
|
|
332
|
+
failFn('lint-ga.yml: має бути крок run: bun run lint-ga (ga.mdc)')
|
|
333
|
+
} else {
|
|
334
|
+
passFn('lint-ga.yml: структура jobs/steps OK')
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* Перевіряє структуру workflow `git-ai.yml` (ga.mdc).
|
|
340
|
+
* @param {Record<string, unknown> | null} root parsed YAML
|
|
341
|
+
* @param {(msg: string) => void} passFn pass
|
|
342
|
+
* @param {(msg: string) => void} failFn fail
|
|
343
|
+
*/
|
|
344
|
+
function validateGitAiWorkflowStructure(root, passFn, failFn) {
|
|
345
|
+
if (!root) {
|
|
346
|
+
failFn('git-ai.yml: YAML не вдалося розібрати (ga.mdc)')
|
|
347
|
+
return
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
if (!isExactString(root.name, 'Git AI')) {
|
|
351
|
+
failFn('git-ai.yml: name має бути "Git AI" (ga.mdc)')
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
const on = root.on
|
|
355
|
+
const pr = getObjKey(on, 'pull_request')
|
|
356
|
+
const types = getObjKey(pr, 'types')
|
|
357
|
+
if (!Array.isArray(types) || !types.includes('closed')) {
|
|
358
|
+
failFn('git-ai.yml: on.pull_request.types має містити closed (ga.mdc)')
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
const jobs = getObjKey(root, 'jobs')
|
|
362
|
+
const job = getObjKey(jobs, 'git-ai')
|
|
363
|
+
if (!job) {
|
|
364
|
+
failFn('git-ai.yml: jobs.git-ai відсутній (ga.mdc)')
|
|
365
|
+
return
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
if (!String(getObjKey(job, 'if') ?? '').includes('github.event.pull_request.merged == true')) {
|
|
369
|
+
failFn('git-ai.yml: job має містити if: github.event.pull_request.merged == true (ga.mdc)')
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
const perm = getObjKey(job, 'permissions')
|
|
373
|
+
if (!(getObjKey(perm, 'contents') === 'write')) {
|
|
374
|
+
failFn('git-ai.yml: permissions мають бути contents: write (ga.mdc)')
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
const flat = flattenWorkflowSteps(root)
|
|
378
|
+
const runBlob = flat.map(s => getStepRun(s.step)).join('\n')
|
|
379
|
+
if (!runBlob.includes('curl -fsSL https://usegitai.com/install.sh | bash')) {
|
|
380
|
+
failFn('git-ai.yml: має встановлювати git-ai через curl | bash (ga.mdc)')
|
|
381
|
+
}
|
|
382
|
+
if (!runBlob.includes('git-ai ci github run')) {
|
|
383
|
+
failFn('git-ai.yml: має виконувати git-ai ci github run (ga.mdc)')
|
|
384
|
+
} else {
|
|
385
|
+
passFn('git-ai.yml: структура jobs/steps OK')
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
47
389
|
/**
|
|
48
390
|
* Якщо workflow викликає локальний setup-bun-deps, раніше у файлі має бути `actions/checkout@v…` (ga.mdc).
|
|
49
391
|
* Fallback: сирий текст, якщо YAML не вдається розібрати.
|
|
@@ -323,7 +665,14 @@ function checkGaWorkflowFiles(wfDir, files, pass, fail) {
|
|
|
323
665
|
pass('Всі workflows мають розширення .yml')
|
|
324
666
|
}
|
|
325
667
|
|
|
326
|
-
|
|
668
|
+
const notYmlFiles = files.filter(f => !f.endsWith('.yml'))
|
|
669
|
+
if (notYmlFiles.length > 0) {
|
|
670
|
+
for (const f of notYmlFiles) {
|
|
671
|
+
fail(`Workflow має бути з розширенням .yml: ${wfDir}/${f} (ga.mdc)`)
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
for (const f of REQUIRED_WORKFLOWS) {
|
|
327
676
|
if (files.includes(f)) {
|
|
328
677
|
pass(`${f} існує`)
|
|
329
678
|
} else {
|
|
@@ -405,6 +754,38 @@ async function checkGitAiWorkflow(wfDir, passFn, failFn) {
|
|
|
405
754
|
}
|
|
406
755
|
}
|
|
407
756
|
|
|
757
|
+
/**
|
|
758
|
+
* Перевіряє, що “канонічні” workflows відповідають ga.mdc (структура і значення).
|
|
759
|
+
* @param {string} wfDir директорія workflows
|
|
760
|
+
* @param {(msg: string) => void} passFn pass
|
|
761
|
+
* @param {(msg: string) => void} failFn fail
|
|
762
|
+
*/
|
|
763
|
+
async function checkCanonicalWorkflowsMatchRule(wfDir, passFn, failFn) {
|
|
764
|
+
const paths = {
|
|
765
|
+
cleanGa: join(wfDir, 'clean-ga-workflows.yml'),
|
|
766
|
+
cleanMerged: join(wfDir, 'clean-merged-branch.yml'),
|
|
767
|
+
lintGa: join(wfDir, 'lint-ga.yml'),
|
|
768
|
+
gitAi: join(wfDir, 'git-ai.yml')
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
if (existsSync(paths.cleanGa)) {
|
|
772
|
+
const c = await readFile(paths.cleanGa, 'utf8')
|
|
773
|
+
validateCleanGaWorkflows(parseWorkflowYaml(c), passFn, failFn)
|
|
774
|
+
}
|
|
775
|
+
if (existsSync(paths.cleanMerged)) {
|
|
776
|
+
const c = await readFile(paths.cleanMerged, 'utf8')
|
|
777
|
+
validateCleanMergedBranch(parseWorkflowYaml(c), passFn, failFn)
|
|
778
|
+
}
|
|
779
|
+
if (existsSync(paths.lintGa)) {
|
|
780
|
+
const c = await readFile(paths.lintGa, 'utf8')
|
|
781
|
+
validateLintGaWorkflowStructure(parseWorkflowYaml(c), passFn, failFn)
|
|
782
|
+
}
|
|
783
|
+
if (existsSync(paths.gitAi)) {
|
|
784
|
+
const c = await readFile(paths.gitAi, 'utf8')
|
|
785
|
+
validateGitAiWorkflowStructure(parseWorkflowYaml(c), passFn, failFn)
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
|
|
408
789
|
/**
|
|
409
790
|
* Перевіряє відповідність проєкту правилам ga.mdc
|
|
410
791
|
* @returns {Promise<number>} 0 — все OK, 1 — є проблеми
|
|
@@ -458,6 +839,8 @@ export async function check() {
|
|
|
458
839
|
verifyNoRunShellLineContinuationBackslash(`${wfDir}/${f}`, content, fail, pass)
|
|
459
840
|
}
|
|
460
841
|
|
|
842
|
+
await checkCanonicalWorkflowsMatchRule(wfDir, pass, fail)
|
|
843
|
+
|
|
461
844
|
await checkZizmor(pass, fail)
|
|
462
845
|
await checkLintGaScript(pass, fail)
|
|
463
846
|
await checkLintGaWorkflow(wfDir, pass, fail)
|