@7n/rules-ci-github 1.8.0 → 1.9.1

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/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.9.1] - 2026-07-22
4
+
5
+ ### Fixed
6
+
7
+ - GH Actions canon: заквотувати $GITHUB_PATH у lint-k8s.yml і git-ai.yml.snippet.yml (SC2086 deadlock з ga/workflows-лінтом); гейт-тест template/*.yml.snippet.yml через actionlint
8
+
9
+ ## [1.9.0] - 2026-07-20
10
+
11
+ ### Added
12
+
13
+ - service_deploy_workflow: bootstrap-режим міграції (`migrateWorkflowFile(..., { bootstrap: true })`) — для deploy-workflow без жодної lint-джоби (валідний as-is за rego) створює lint-джоби доменів з нуля і підключає вхідну джобу до plan + усіх lint-джоб; опційний опт-ін, не частина звичайного lint --fix
14
+
3
15
  ## [1.8.0] - 2026-07-20
4
16
 
5
17
  ### Added
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@7n/rules-ci-github",
3
- "version": "1.8.0",
3
+ "version": "1.9.1",
4
4
  "description": "Плагін @7n/rules: канон GitHub Actions (.github/workflows) — обов'язкові workflow, zizmor, VS Code",
5
5
  "keywords": [
6
6
  "github-actions",
@@ -19,7 +19,7 @@ jobs:
19
19
  - name: Install git-ai
20
20
  run: |
21
21
  curl -fsSL https://usegitai.com/install.sh | bash
22
- echo "$HOME/.git-ai/bin" >> $GITHUB_PATH
22
+ echo "$HOME/.git-ai/bin" >> "$GITHUB_PATH"
23
23
  - name: Run git-ai
24
24
  id: run-git-ai
25
25
  env:
@@ -3,28 +3,29 @@ type: JS Module
3
3
  title: fix-service_deploy_workflow.mjs
4
4
  resource: plugins/ci-github/rules/ga/service_deploy_workflow/fix-service_deploy_workflow.mjs
5
5
  docgen:
6
- crc: c6a64f66
7
- model: openai-codex/gpt-5.4-mini
8
- score: 100
9
- issues: judge:inaccurate:0.99
10
- judgeModel: openai-codex/gpt-5.4-mini
6
+ crc: bb163530
11
7
  ---
12
8
 
13
9
  ## Огляд
14
10
 
15
- Файл виконує T0-автоміграцію `.github/workflows/deploy-*.yml` до сервісного канону для `service_deploy_workflow`, як у `fix-service_deploy_pipeline` для `ci-azure`: детерміновано переписує лише workflow, що не відповідають формі `plan lint-<domain> deploy`. Додає job `plan` із `fetch-depth: 0`, prep і `bunx n-rules ci plan --path <svc> --github`, створює `id: plan` та outputs-мапінг доменів і `any`, щоб `needs.plan.outputs.*` були доступні в runtime. Legacy job із `n-rules lint --path <svc>` без домену замінює на per-domain jobs `lint-<domain>` за тими самими glob-ами, що й `ci plan` через `computeActiveDomains/domainKey`; domain-style lint-джоби отримують wiring із `needs: plan`, умовами по outputs, `--no-fix`, `fetch-depth: 0` і prep. `needs` інших jobs перешивається з legacy-імені, а jobs із прямими `needs` на умовні lint-джоби без власного `if` отримують Skipped-толерантний канон `!cancelled` + `!contains`. Мутації виконуються через YAML Document API, тому `jobs` у GA лишається map, а коментарі та форматування незачеплених частин зберігаються; наявний нетривіальний `if` не перезаписується. Публічні функції: `migrateWorkflowFile`, `patterns`. Fail-safe: помилки не прокидаються назовні, а за окремих збоїв повертається порожнє значення.
11
+ Модуль виконує T0-автоміграцію GitHub Actions deploy-workflow до сервіс-канону `plan lint-<domain> → deploy` (ADR 260718-0835), дзеркало `fix-service_deploy_pipeline` для `ci-azure`. Для workflow з легасі-джобою `n-rules lint --path <svc>` (без домену) додає `plan` (checkout `fetch-depth: 0` + prep + `bunx n-rules ci plan --path <svc> --github` з `id: plan` і outputs-мапінгом доменів + `any`), розкладає легасі lint на per-domain `lint-<domain>`-джоби (домени й glob — ті самі, що в `ci plan`), перепідключає `needs` залежних джоб і додає Skipped-толерантний `if` там, де його бракує. Наявний нетривіальний `if` не перезаписується.
12
+
13
+ `bootstrap: true` — окремий опт-ін, який `n-rules lint --fix` не викликає автоматично: для deploy-workflow БЕЗ жодної lint-джоби (rego вважає такий workflow валідним as-is — публікація без гейта може бути свідомим рішенням) створює lint-<domain>-джоби з нуля за `relevantDomains` піддерева сервісу і підключає вхідну джобу без власного `needs` до `plan` + усіх нових lint-джоб зі Skipped-толерантним `if`.
14
+
15
+ Мутації виконуються через YAML Document API — коментарі та форматування незачеплених частин файлу зберігаються. Помилки парсингу чи міграції окремого файлу не прокидаються назовні: функція повертає `false`, файл лишається без змін.
16
16
 
17
17
  ## Поведінка
18
18
 
19
- - `migrateWorkflowFile` — мігрує один GitHub Actions deploy-workflow до сервісного канону: додає `plan`, розкладає legacy lint на per-domain jobs, перешиває `needs` і лишає файл без змін, якщо міграція не потрібна або не вдалася.
20
- - `patterns` — описує T0-патерн fix-конвеєра: запускає міграцію лише для workflow з порушенням і повертає перелік змінених файлів та коротке повідомлення без винесення помилок назовні.
19
+ - `migrateWorkflowFile(absPath, cwd, { bootstrap? })` — мігрує один deploy-workflow до канону; повертає `true`, якщо файл змінено, `false` якщо міграція не потрібна, шлях сервісу не визначити, чи (без `bootstrap`) у workflow немає ні `plan`, ні жодного lint-кроку.
20
+ - `patterns` — T0-патерн fix-конвеєра: спрацьовує лише коли rego-концерн уже знайшов порушення у файлі (без `bootstrap`), мігрує кожен зачеплений workflow і збирає перелік змінених файлів; помилки окремих файлів не переривають обробку решти.
21
21
 
22
22
  ## Публічний API
23
23
 
24
- - migrateWorkflowFile — переводить один deploy-workflow у канонічний формат і повідомляє, чи були зміни
25
- - patterns — задає шаблони для розпізнавання та обробки workflow-файлів
24
+ - `migrateWorkflowFile` — переводить один deploy-workflow у канонічний формат, опційно (`bootstrap: true`) добудовуючи lint-джоби з нуля для workflow, що їх ще не мали.
25
+ - `patterns`T0-фікс-патерн для fix-конвеєра `n-rules lint`: розпізнає порушення `service_deploy_workflow` і застосовує `migrateWorkflowFile` (без `bootstrap`) до кожного знайденого файлу.
26
26
 
27
27
  ## Гарантії поведінки
28
28
 
29
- - Перехоплює помилки і не пропускає винятків назовні (fail-safe).
30
- - За певних помилок повертає порожнє значення (напр. `null`) замість винятку.
29
+ - Перехоплює помилки парсингу й міграції — не пропускає винятків назовні (fail-safe), повертає `false`.
30
+ - Не перезаписує наявний нетривіальний `if` термінальної джоби.
31
+ - `bootstrap`-логіка не активується неявно через звичайний `n-rules lint --fix` — лише за прямого виклику з `{ bootstrap: true }`.
@@ -18,6 +18,13 @@
18
18
  * умовні lint-джоби без власного `if` отримують Skipped-толерантний канон
19
19
  * (`!cancelled()` + `!contains(needs.*.result, 'failure')`).
20
20
  *
21
+ * `bootstrap: true` (окремий опт-ін, НЕ частина звичайного fix-режиму) —
22
+ * для deploy-workflow без жодної lint-джоби створює lint-<domain> джоби з
23
+ * нуля (за `relevantDomains` піддерева сервісу) і підключає безумовну
24
+ * вхідну джобу (без `needs`) до plan + усіх нових lint-джоб. Концерн явно
25
+ * не вимагає цього автоматично (публікація без лінту валідна as-is) — це
26
+ * механічне виконання свідомого рішення перейти на гейт.
27
+ *
21
28
  * Мутації — через `yaml` Document API (jobs у GA — мапа, не послідовність):
22
29
  * коментарі та форматування незачеплених частин зберігаються. Наявний
23
30
  * нетривіальний `if` не перезаписується (deny лишається — ручне рішення).
@@ -245,6 +252,107 @@ function patchDomainLintJob(doc, name, j, found) {
245
252
  return changed
246
253
  }
247
254
 
255
+ /**
256
+ * Вставляє нові lint-<domain> джоби одразу після `plan` у мапі jobs.
257
+ * @param {import('yaml').Document} doc документ (мутується)
258
+ * @param {Array<[string, Record<string, unknown>]>} newPairs пари [імʼя, джоба]
259
+ * @returns {void}
260
+ */
261
+ function insertLintJobsAfterPlan(doc, newPairs) {
262
+ const jobsMap = doc.getIn(['jobs'])
263
+ const nodes = newPairs.map(([name, job]) => doc.createNode({ [name]: job }).items[0])
264
+ const planIdx = jobsMap.items.findIndex(pair => pair?.key?.toString?.() === 'plan')
265
+ jobsMap.items.splice(planIdx === -1 ? 0 : planIdx + 1, 0, ...nodes)
266
+ }
267
+
268
+ /**
269
+ * Bootstrap-крок (лише коли `bootstrap: true`): джоба без жодного `needs` —
270
+ * кандидат «вхідної/термінальної» джоби деплою без вбудованого лінту (напр.
271
+ * `run-auth.yml`, де один job одразу збирає образ і виконує деплой). Підключає її до
272
+ * plan + усіх lint-джоб зі Skipped-толерантним `if` (свідомий перехід на
273
+ * гейт — концерн НЕ вимагає цього автоматично, bootstrap лише виконує рішення,
274
+ * вже ухвалене людиною командою запуску).
275
+ * @param {import('yaml').Document} doc документ (мутується)
276
+ * @param {string} name імʼя джоби
277
+ * @param {Record<string, unknown>} j джоба (plain JS)
278
+ * @param {Set<string>} lintJobNames множина всіх lint-джоб
279
+ * @returns {boolean} чи були зміни
280
+ */
281
+ function wireEntryJob(doc, name, j, lintJobNames) {
282
+ if (needsOf(j).length > 0) return false
283
+ if (lintJobNames.size === 0) return false
284
+ const base = ['jobs', name]
285
+ const deps = ['plan', ...[...lintJobNames].toSorted()]
286
+ doc.setIn([...base, 'needs'], doc.createNode(deps))
287
+ doc.setIn([...base, 'if'], CANONICAL_DEPLOY_IF)
288
+ return true
289
+ }
290
+
291
+ /**
292
+ * Bootstrap-крок: домени сервісу, для яких ще немає per-domain lint-джоби,
293
+ * отримують нову джобу з нуля (одразу після `plan`).
294
+ * @param {import('yaml').Document} doc документ (мутується)
295
+ * @param {string[]} domains релевантні домени сервісу
296
+ * @param {string} servicePath каталог сервісу
297
+ * @param {Array<Record<string, unknown>>} prep prep-кроки
298
+ * @returns {boolean} чи були зміни
299
+ */
300
+ function bootstrapMissingLintJobs(doc, domains, servicePath, prep) {
301
+ const covered = new Set()
302
+ for (const [name, j] of jobEntries(doc)) {
303
+ if (name === 'plan') continue
304
+ const found = findLintStep(Array.isArray(j.steps) ? j.steps : [])
305
+ if (found && !found.legacy && found.domain) covered.add(found.domain)
306
+ }
307
+ const missing = domains.filter(d => !covered.has(d))
308
+ if (missing.length === 0) return false
309
+ insertLintJobsAfterPlan(
310
+ doc,
311
+ missing.map(d => buildLintJob(d, servicePath, prep))
312
+ )
313
+ return true
314
+ }
315
+
316
+ /**
317
+ * Дописує wiring для всіх наявних domain-style lint-джоб (needs/if/no-fix/fetch-depth)
318
+ * і збирає повну множину імен lint-джоб (renames + вже канонічні + bootstrap-нові).
319
+ * @param {import('yaml').Document} doc документ (мутується)
320
+ * @param {Map<string, string[]>} renames легасі-імʼя → нові lint-джоби
321
+ * @returns {{ lintJobNames: Set<string>, changed: boolean }} множина lint-джоб і прапорець змін
322
+ */
323
+ function patchAllDomainLintJobs(doc, renames) {
324
+ const lintJobNames = new Set(renames.values().toArray().flat())
325
+ let changed = false
326
+ for (const [name, j] of jobEntries(doc)) {
327
+ if (name === 'plan') continue
328
+ const found = findLintStep(Array.isArray(j.steps) ? j.steps : [])
329
+ if (!found || found.legacy) continue
330
+ lintJobNames.add(name)
331
+ if (patchDomainLintJob(doc, name, j, found)) changed = true
332
+ }
333
+ return { lintJobNames, changed }
334
+ }
335
+
336
+ /**
337
+ * Перешиває needs/if усіх джоб, що не є lint: легасі-перейменування (завжди) і,
338
+ * за bootstrap, підключення безумовних вхідних джоб до plan + lint.
339
+ * @param {import('yaml').Document} doc документ (мутується)
340
+ * @param {Map<string, string[]>} renames легасі-імʼя → нові lint-джоби
341
+ * @param {Set<string>} lintJobNames множина всіх lint-джоб
342
+ * @param {boolean} bootstrap чи дозволено підключати безумовні вхідні джоби
343
+ * @returns {boolean} чи були зміни
344
+ */
345
+ function rewireAllJobs(doc, renames, lintJobNames, bootstrap) {
346
+ let changed = false
347
+ for (const [name, j] of jobEntries(doc)) {
348
+ if (name === 'plan' || lintJobNames.has(name)) continue
349
+ const rewired = rewireOneJob(doc, name, j, renames, lintJobNames)
350
+ const bootstrapped = !rewired && bootstrap && wireEntryJob(doc, name, j, lintJobNames)
351
+ if (rewired || bootstrapped) changed = true
352
+ }
353
+ return changed
354
+ }
355
+
248
356
  /**
249
357
  * Крок 4 (одна джоба): перешивка needs (легасі → нові) + Skipped-толерантний if.
250
358
  * @param {import('yaml').Document} doc документ (мутується)
@@ -277,11 +385,23 @@ function rewireOneJob(doc, name, j, renames, lintJobNames) {
277
385
 
278
386
  /**
279
387
  * Мігрує один deploy-workflow до канону. Повертає true, якщо файл змінено.
388
+ *
389
+ * `bootstrap: true` — свідоме розширення поза звичайним fix-режимом: для
390
+ * deploy-workflow БЕЗ жодної lint-джоби (валідний as-is за рего-концерном,
391
+ * деталі — service_deploy_workflow.rego) створює lint-<domain> джоби з нуля
392
+ * (за `relevantDomains` піддерева сервісу) і підключає вхідну/термінальну
393
+ * джобу без `needs` до plan + усіх lint-джоб. Це саме «свідоме рішення
394
+ * перейти на гейт», про яке говорить коментар концерну — bootstrap лише
395
+ * виконує його механічно, а не ухвалює автоматично (звичайний
396
+ * `n-rules lint --fix` bootstrap не викликає: patterns[0].apply завжди
397
+ * викликається без bootstrap).
280
398
  * @param {string} absPath абсолютний шлях workflow-файлу
281
399
  * @param {string} cwd корінь consumer-репо
400
+ * @param {{ bootstrap?: boolean }} [opts] `bootstrap: true` — додати lint-джоби з нуля
282
401
  * @returns {Promise<boolean>} чи були зміни
283
402
  */
284
- export async function migrateWorkflowFile(absPath, cwd) {
403
+ export async function migrateWorkflowFile(absPath, cwd, opts = {}) {
404
+ const { bootstrap = false } = opts
285
405
  const prevText = readFileSync(absPath, 'utf8')
286
406
  let doc
287
407
  try {
@@ -298,6 +418,12 @@ export async function migrateWorkflowFile(absPath, cwd) {
298
418
  const legacyNames = entries
299
419
  .filter(([name, j]) => name !== 'plan' && findLintStep(Array.isArray(j.steps) ? j.steps : [])?.legacy)
300
420
  .map(([name]) => name)
421
+ const hasAnyLintStep = entries.some(
422
+ ([name, j]) => name !== 'plan' && findLintStep(Array.isArray(j.steps) ? j.steps : []) !== null
423
+ )
424
+ // Workflow без plan і без жодного lint-кроку — концерн вважає його valid
425
+ // as-is (публікація без гейта — свідоме рішення). Чіпати лише за bootstrap.
426
+ if (!hasPlan && !hasAnyLintStep && !bootstrap) return false
301
427
 
302
428
  const jobsPlain = entries.map(([, j]) => j)
303
429
  const prep = derivePrepSteps(jobsPlain)
@@ -311,18 +437,11 @@ export async function migrateWorkflowFile(absPath, cwd) {
311
437
  const renames = replaceLegacyJobs(doc, legacyNames, domains, servicePath, prep)
312
438
  if (renames.size > 0) changed = true
313
439
 
314
- const lintJobNames = new Set(renames.values().toArray().flat())
315
- for (const [name, j] of jobEntries(doc)) {
316
- if (name === 'plan') continue
317
- const found = findLintStep(Array.isArray(j.steps) ? j.steps : [])
318
- if (!found || found.legacy) continue
319
- lintJobNames.add(name)
320
- if (patchDomainLintJob(doc, name, j, found)) changed = true
321
- }
322
- for (const [name, j] of jobEntries(doc)) {
323
- if (name === 'plan' || lintJobNames.has(name)) continue
324
- if (rewireOneJob(doc, name, j, renames, lintJobNames)) changed = true
325
- }
440
+ if (bootstrap && bootstrapMissingLintJobs(doc, domains, servicePath, prep)) changed = true
441
+
442
+ const { lintJobNames, changed: patched } = patchAllDomainLintJobs(doc, renames)
443
+ if (patched) changed = true
444
+ if (rewireAllJobs(doc, renames, lintJobNames, bootstrap)) changed = true
326
445
 
327
446
  if (!changed) return false
328
447
  writeFileSync(absPath, doc.toString())
@@ -20,4 +20,6 @@ Repo-wide перевірки без path-підтримки (knip, jscpd, dep-po
20
20
 
21
21
  **Автоміграція (T0-фікс, `n-rules lint` у fix-режимі):** легасі deploy-workflow (job із `n-rules lint --path <svc>` без домену, без plan-джоби) детерміновано переписується до канону — додається `plan` з outputs-мапінгом доменів + `any`, легасі lint-джоба замінюється на per-domain `lint-<domain>` (домени по файлах піддерева, ті самі glob-и, що `ci plan`), `needs` залежних джоб перешивається, джоби з прямими needs на умовні lint-джоби без власного `if` отримують Skipped-толерантний канон. Нетривіальний наявний `if` не перезаписується.
22
22
 
23
+ **Bootstrap-режим (опційний, окремий опт-ін):** `migrateWorkflowFile(absPath, cwd, { bootstrap: true })` — для deploy-workflow **без жодної lint-джоби** (rego вважає його valid as-is — публікація без гейта може бути свідомим рішенням) створює lint-`<domain>` джоби з нуля (`relevantDomains` по всьому піддереву сервісу) і підключає вхідну/термінальну джобу без власного `needs` до `plan` + усіх нових lint-джоб зі Skipped-толерантним `if`. Це не частина звичайного `n-rules lint --fix` (`patterns[0].apply` викликає `migrateWorkflowFile` без bootstrap — інакше кожен publish-workflow без лінту тихо отримав би гейт при звичайному lint) — bootstrap механічно виконує рішення, уже ухвалене людиною, яка явно його викликала.
24
+
23
25
  Канон-snippet (документаційний приклад): [deploy-service.yml.snippet.yml](./template/deploy-service.yml.snippet.yml)
@@ -37,7 +37,7 @@ jobs:
37
37
  - name: Install kubescape
38
38
  run: |
39
39
  curl -s https://raw.githubusercontent.com/kubescape/kubescape/master/install.sh | /bin/bash
40
- echo "$HOME/.kubescape/bin" >> $GITHUB_PATH
40
+ echo "$HOME/.kubescape/bin" >> "$GITHUB_PATH"
41
41
 
42
42
  - name: Lint K8s
43
43
  run: bunx n-rules lint k8s --no-fix