@7n/rules-ci-github 1.5.0 → 1.6.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.6.1] - 2026-07-18
4
+
5
+ ### Changed
6
+
7
+ - fix-service_deploy_workflow: `parseNRulesCmd`/`relevantDomains` перенесено в спільний `@7n/rules/scripts/lib/lint-surface/ci-plan.mjs` (jscpd-дублікат із ci-azure), без зміни поведінки
8
+
9
+ ## [1.6.0] - 2026-07-18
10
+
11
+ ### Added
12
+
13
+ - GA-автоміграція: T0-фікс service_deploy_workflow переписує deploy-*.yml до канону — job plan з outputs-мапінгом, легасі `lint --path` → per-domain lint-джоби (needs+if по outputs), перешивка needs, Skipped-толерантний if термінальних джоб; yaml Document API зберігає коментарі
14
+
3
15
  ## [1.5.0] - 2026-07-18
4
16
 
5
17
  ### Added
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@7n/rules-ci-github",
3
- "version": "1.5.0",
4
- "description": "\u041f\u043b\u0430\u0433\u0456\u043d @7n/rules: \u043a\u0430\u043d\u043e\u043d GitHub Actions (.github/workflows) \u2014 \u043e\u0431\u043e\u0432'\u044f\u0437\u043a\u043e\u0432\u0456 workflow, zizmor, VS Code",
3
+ "version": "1.6.1",
4
+ "description": "Плагін @7n/rules: канон GitHub Actions (.github/workflows) обов'язкові workflow, zizmor, VS Code",
5
5
  "keywords": [
6
6
  "github-actions",
7
7
  "ci",
@@ -45,5 +45,8 @@
45
45
  "engines": {
46
46
  "bun": ">=1.3",
47
47
  "node": ">=24"
48
+ },
49
+ "dependencies": {
50
+ "yaml": "^2.9.0"
48
51
  }
49
52
  }
@@ -0,0 +1,30 @@
1
+ ---
2
+ type: JS Module
3
+ title: fix-service_deploy_workflow.mjs
4
+ resource: plugins/ci-github/rules/ga/service_deploy_workflow/fix-service_deploy_workflow.mjs
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
11
+ ---
12
+
13
+ ## Огляд
14
+
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: помилки не прокидаються назовні, а за окремих збоїв повертається порожнє значення.
16
+
17
+ ## Поведінка
18
+
19
+ - `migrateWorkflowFile` — мігрує один GitHub Actions deploy-workflow до сервісного канону: додає `plan`, розкладає legacy lint на per-domain jobs, перешиває `needs` і лишає файл без змін, якщо міграція не потрібна або не вдалася.
20
+ - `patterns` — описує T0-патерн fix-конвеєра: запускає міграцію лише для workflow з порушенням і повертає перелік змінених файлів та коротке повідомлення без винесення помилок назовні.
21
+
22
+ ## Публічний API
23
+
24
+ - migrateWorkflowFile — переводить один deploy-workflow у канонічний формат і повідомляє, чи були зміни
25
+ - patterns — задає шаблони для розпізнавання та обробки workflow-файлів
26
+
27
+ ## Гарантії поведінки
28
+
29
+ - Перехоплює помилки і не пропускає винятків назовні (fail-safe).
30
+ - За певних помилок повертає порожнє значення (напр. `null`) замість винятку.
@@ -0,0 +1,9 @@
1
+ ---
2
+ type: Directory Index
3
+ title: plugins/ci-github/rules/ga/service_deploy_workflow
4
+ resource: plugins/ci-github/rules/ga/service_deploy_workflow/
5
+ ---
6
+
7
+ | Файл | Тип |
8
+ | ----------------------------------------------------------------- | --------- |
9
+ | [fix-service_deploy_workflow.mjs](fix-service_deploy_workflow.md) | JS Module |
@@ -0,0 +1,354 @@
1
+ /**
2
+ * T0-автоміграція GA deploy-workflow до сервіс-канону (ADR 260718-0835) —
3
+ * дзеркало fix-service_deploy_pipeline (ci-azure) для GitHub Actions.
4
+ *
5
+ * Детермінований переписувач `.github/workflows/deploy-*.yml`, що НЕ
6
+ * відповідають формі plan → lint-<domain> → deploy (порушення rego-концерну
7
+ * service_deploy_workflow):
8
+ *
9
+ * - додає job `plan` (checkout fetch-depth: 0 + prep + `bunx n-rules ci plan
10
+ * --path <svc> --github` з `id: plan`) і **outputs-мапінг** доменів + `any`
11
+ * (без нього гейти `needs.plan.outputs.*` порожні в runtime);
12
+ * - легасі job із `n-rules lint --path <svc>` (без домену) замінюється на
13
+ * per-domain джоби lint-<domain> — домени по файлах піддерева сервісу, ті
14
+ * самі glob-и, що `ci plan` (computeActiveDomains/domainKey);
15
+ * - domain-style lint-джоби добираються wiring-ом (needs: plan, if по outputs,
16
+ * `--no-fix`, fetch-depth: 0, prep);
17
+ * - `needs` інших джоб перешивається з легасі-імені; джоби з прямими needs на
18
+ * умовні lint-джоби без власного `if` отримують Skipped-толерантний канон
19
+ * (`!cancelled()` + `!contains(needs.*.result, 'failure')`).
20
+ *
21
+ * Мутації — через `yaml` Document API (jobs у GA — мапа, не послідовність):
22
+ * коментарі та форматування незачеплених частин зберігаються. Наявний
23
+ * нетривіальний `if` не перезаписується (deny лишається — ручне рішення).
24
+ */
25
+ import { readFileSync, writeFileSync, existsSync } from 'node:fs'
26
+ import { join } from 'node:path'
27
+
28
+ import { parseDocument } from 'yaml'
29
+
30
+ import { domainKey, parseNRulesCmd, relevantDomains } from '@7n/rules/scripts/lib/lint-surface/ci-plan.mjs'
31
+
32
+ const GLOB_SUFFIX_RE = /\/\*+$/u
33
+
34
+ const CANONICAL_DEPLOY_IF = `\${{ !cancelled() && needs.plan.result == 'success' && !contains(needs.*.result, 'failure') && !contains(needs.*.result, 'cancelled') }}`
35
+
36
+ /** Канонічні prep-кроки нової джоби (checkout повної глибини + bun-залежності). */
37
+ const CANONICAL_PREP = [
38
+ { uses: 'actions/checkout@v6', with: { 'persist-credentials': false, 'fetch-depth': 0 } },
39
+ { uses: './.github/actions/setup-bun-deps' }
40
+ ]
41
+
42
+ /**
43
+ * Текст run-кроку ('' якщо крок не run).
44
+ * @param {unknown} step крок джоби (plain JS)
45
+ * @returns {string} команда або ''
46
+ */
47
+ function stepRun(step) {
48
+ if (!step || typeof step !== 'object') return ''
49
+ const r = /** @type {Record<string, unknown>} */ (step).run
50
+ return typeof r === 'string' ? r : ''
51
+ }
52
+
53
+ /**
54
+ * needs джоби як масив імен.
55
+ * @param {Record<string, unknown>} job джоба (plain JS)
56
+ * @returns {string[]} імена залежностей
57
+ */
58
+ function needsOf(job) {
59
+ const n = job.needs
60
+ if (typeof n === 'string') return [n]
61
+ if (Array.isArray(n)) return n.filter(x => typeof x === 'string')
62
+ return []
63
+ }
64
+
65
+ /**
66
+ * Розбір lint-кроку джоби.
67
+ * @param {Array<Record<string, unknown>>} steps кроки джоби (plain JS)
68
+ * @returns {{ legacy: boolean, domain: string|null, path: string|null, stepIndex: number }|null} розбір
69
+ */
70
+ function findLintStep(steps) {
71
+ for (const [i, step] of steps.entries()) {
72
+ const parsed = parseNRulesCmd(stepRun(step), 'n-rules lint')
73
+ if (!parsed || parsed.path === null) continue
74
+ return { legacy: parsed.domain === null, domain: parsed.domain, path: parsed.path, stepIndex: i }
75
+ }
76
+ return null
77
+ }
78
+
79
+ /**
80
+ * Prep-кроки для нових джоб: зразок із наявної джоби з `setup-bun-deps`
81
+ * (кроки до нього включно, checkout → fetch-depth: 0), інакше — канонічні.
82
+ * @param {Array<Record<string, unknown>>} jobs джоби (plain JS)
83
+ * @returns {Array<Record<string, unknown>>} prep-кроки (глибока копія)
84
+ */
85
+ function derivePrepSteps(jobs) {
86
+ for (const job of jobs) {
87
+ const steps = Array.isArray(job.steps) ? job.steps : []
88
+ const idx = steps.findIndex(s => s && typeof s === 'object' && s.uses === './.github/actions/setup-bun-deps')
89
+ if (idx === -1) continue
90
+ const prep = structuredClone(steps.slice(0, idx + 1))
91
+ const checkout = prep.find(s => typeof s.uses === 'string' && s.uses.startsWith('actions/checkout@'))
92
+ if (checkout) {
93
+ checkout.with = { ...checkout.with, 'fetch-depth': 0 }
94
+ } else {
95
+ prep.unshift(structuredClone(CANONICAL_PREP[0]))
96
+ }
97
+ return prep
98
+ }
99
+ return structuredClone(CANONICAL_PREP)
100
+ }
101
+
102
+ /**
103
+ * Джоби документа як [name, job] (plain JS); GA jobs — мапа.
104
+ * @param {import('yaml').Document} doc YAML-документ
105
+ * @returns {Array<[string, Record<string, unknown>]>} пари імʼя→джоба
106
+ */
107
+ function jobEntries(doc) {
108
+ const js = doc.toJS() ?? {}
109
+ const jobs = js.jobs
110
+ if (!jobs || typeof jobs !== 'object' || Array.isArray(jobs)) return []
111
+ return Object.entries(jobs).filter(([, j]) => j && typeof j === 'object')
112
+ }
113
+
114
+ /**
115
+ * Сервісний каталог workflow: із plan-джоби → з легасі lint-джоби →
116
+ * з `on.push.paths` (glob-суфікс зрізається).
117
+ * @param {import('yaml').Document} doc YAML-документ
118
+ * @param {Array<[string, Record<string, unknown>]>} entries пари імʼя→джоба
119
+ * @returns {string|null} каталог сервісу або null
120
+ */
121
+ function resolveServicePath(doc, entries) {
122
+ for (const [, job] of entries) {
123
+ for (const step of Array.isArray(job.steps) ? job.steps : []) {
124
+ const p = parseNRulesCmd(stepRun(step), 'n-rules ci plan')?.path
125
+ if (p) return p
126
+ }
127
+ }
128
+ for (const [, job] of entries) {
129
+ const found = findLintStep(Array.isArray(job.steps) ? job.steps : [])
130
+ if (found?.path) return found.path
131
+ }
132
+ const js = doc.toJS() ?? {}
133
+ const on = js.on ?? js.true ?? {}
134
+ const paths = on?.push?.paths
135
+ if (!Array.isArray(paths)) return null
136
+ return (
137
+ paths
138
+ .filter(p => typeof p === 'string')
139
+ .map(p => p.replace(GLOB_SUFFIX_RE, ''))
140
+ .find(p => p !== '' && !p.includes('*')) ?? null
141
+ )
142
+ }
143
+
144
+ /**
145
+ * Крок 1: вставляє job `plan` (з outputs-мапінгом доменів + any) першим у jobs.
146
+ * @param {import('yaml').Document} doc документ (мутується)
147
+ * @param {string[]} domains релевантні домени
148
+ * @param {Array<Record<string, unknown>>} prep prep-кроки
149
+ * @param {string} servicePath каталог сервісу
150
+ * @returns {void}
151
+ */
152
+ function insertPlanJob(doc, domains, prep, servicePath) {
153
+ /** @type {Record<string, string>} */
154
+ const outputs = {}
155
+ for (const d of domains) outputs[domainKey(d)] = `\${{ steps.plan.outputs.${domainKey(d)} }}`
156
+ outputs.any = `\${{ steps.plan.outputs.any }}`
157
+ const planJob = {
158
+ 'runs-on': 'ubuntu-latest',
159
+ permissions: { contents: 'read' },
160
+ outputs,
161
+ steps: [...structuredClone(prep), { id: 'plan', run: `bunx n-rules ci plan --path ${servicePath} --github` }]
162
+ }
163
+ const jobsMap = doc.getIn(['jobs'])
164
+ jobsMap.items.unshift(doc.createNode({ plan: planJob }).items[0])
165
+ }
166
+
167
+ /**
168
+ * Нова per-domain lint-джоба канону (пара [імʼя, джоба]).
169
+ * @param {string} domain rule-id домену
170
+ * @param {string} servicePath каталог сервісу
171
+ * @param {Array<Record<string, unknown>>} prep prep-кроки
172
+ * @returns {[string, Record<string, unknown>]} пара для jobs-мапи
173
+ */
174
+ function buildLintJob(domain, servicePath, prep) {
175
+ const key = domainKey(domain)
176
+ return [
177
+ `lint-${key}`,
178
+ {
179
+ needs: 'plan',
180
+ if: `needs.plan.outputs.${key} == 'true'`,
181
+ 'runs-on': 'ubuntu-latest',
182
+ permissions: { contents: 'read' },
183
+ steps: [...structuredClone(prep), { run: `bunx n-rules lint ${domain} --path ${servicePath} --no-fix` }]
184
+ }
185
+ ]
186
+ }
187
+
188
+ /**
189
+ * Крок 2: замінює легасі lint-джоби на per-domain джоби (in-place у мапі jobs).
190
+ * @param {import('yaml').Document} doc документ (мутується)
191
+ * @param {string[]} legacyNames імена легасі-джоб
192
+ * @param {string[]} domains релевантні домени
193
+ * @param {string} servicePath каталог сервісу
194
+ * @param {Array<Record<string, unknown>>} prep prep-кроки
195
+ * @returns {Map<string, string[]>} легасі-імʼя → нові імена
196
+ */
197
+ function replaceLegacyJobs(doc, legacyNames, domains, servicePath, prep) {
198
+ /** @type {Map<string, string[]>} */
199
+ const renames = new Map()
200
+ const jobsMap = doc.getIn(['jobs'])
201
+ for (const legacyName of legacyNames) {
202
+ const idx = jobsMap.items.findIndex(pair => pair?.key?.toString?.() === legacyName)
203
+ if (idx === -1) continue
204
+ const newPairs = domains.map(d => buildLintJob(d, servicePath, prep))
205
+ const nodes = newPairs.map(([name, job]) => doc.createNode({ [name]: job }).items[0])
206
+ jobsMap.items.splice(idx, 1, ...nodes)
207
+ renames.set(
208
+ legacyName,
209
+ newPairs.map(([name]) => name)
210
+ )
211
+ }
212
+ return renames
213
+ }
214
+
215
+ /**
216
+ * Крок 3 (одна джоба): wiring domain-style lint-джоби (needs/if/--no-fix/fetch-depth/prep).
217
+ * @param {import('yaml').Document} doc документ (мутується)
218
+ * @param {string} name імʼя джоби
219
+ * @param {Record<string, unknown>} j джоба (plain JS)
220
+ * @param {{ domain: string|null, stepIndex: number }} found розбір lint-кроку
221
+ * @returns {boolean} чи були зміни
222
+ */
223
+ function patchDomainLintJob(doc, name, j, found) {
224
+ let changed = false
225
+ const base = ['jobs', name]
226
+ const steps = Array.isArray(j.steps) ? j.steps : []
227
+ if (needsOf(j).length === 0) {
228
+ doc.setIn([...base, 'needs'], 'plan')
229
+ changed = true
230
+ }
231
+ if (typeof j.if !== 'string' && found.domain) {
232
+ doc.setIn([...base, 'if'], `needs.plan.outputs.${domainKey(found.domain)} == 'true'`)
233
+ changed = true
234
+ }
235
+ const cmd = stepRun(steps[found.stepIndex])
236
+ if (!cmd.includes('--no-fix')) {
237
+ doc.setIn([...base, 'steps', found.stepIndex, 'run'], `${cmd.trimEnd()} --no-fix`)
238
+ changed = true
239
+ }
240
+ const checkoutIdx = steps.findIndex(s => typeof s?.uses === 'string' && s.uses.startsWith('actions/checkout@'))
241
+ if (checkoutIdx !== -1 && steps[checkoutIdx]?.with?.['fetch-depth'] !== 0) {
242
+ doc.setIn([...base, 'steps', checkoutIdx, 'with', 'fetch-depth'], 0)
243
+ changed = true
244
+ }
245
+ return changed
246
+ }
247
+
248
+ /**
249
+ * Крок 4 (одна джоба): перешивка needs (легасі → нові) + Skipped-толерантний if.
250
+ * @param {import('yaml').Document} doc документ (мутується)
251
+ * @param {string} name імʼя джоби
252
+ * @param {Record<string, unknown>} j джоба (plain JS)
253
+ * @param {Map<string, string[]>} renames легасі-імʼя → нові lint-джоби
254
+ * @param {Set<string>} lintJobNames множина всіх lint-джоб
255
+ * @returns {boolean} чи були зміни
256
+ */
257
+ function rewireOneJob(doc, name, j, renames, lintJobNames) {
258
+ let deps = needsOf(j)
259
+ if (deps.length === 0) return false
260
+ let changed = false
261
+ const base = ['jobs', name]
262
+ const expanded = deps.flatMap(d => renames.get(d) ?? [d])
263
+ if (JSON.stringify(expanded) !== JSON.stringify(deps)) {
264
+ doc.setIn([...base, 'needs'], doc.createNode(expanded))
265
+ deps = expanded
266
+ changed = true
267
+ }
268
+ const touchesLint = deps.some(d => lintJobNames.has(d))
269
+ if (touchesLint && typeof j.if !== 'string') {
270
+ const withPlan = deps.includes('plan') ? deps : ['plan', ...deps]
271
+ if (!deps.includes('plan')) doc.setIn([...base, 'needs'], doc.createNode(withPlan))
272
+ doc.setIn([...base, 'if'], CANONICAL_DEPLOY_IF)
273
+ changed = true
274
+ }
275
+ return changed
276
+ }
277
+
278
+ /**
279
+ * Мігрує один deploy-workflow до канону. Повертає true, якщо файл змінено.
280
+ * @param {string} absPath абсолютний шлях workflow-файлу
281
+ * @param {string} cwd корінь consumer-репо
282
+ * @returns {Promise<boolean>} чи були зміни
283
+ */
284
+ export async function migrateWorkflowFile(absPath, cwd) {
285
+ const prevText = readFileSync(absPath, 'utf8')
286
+ let doc
287
+ try {
288
+ doc = parseDocument(prevText)
289
+ } catch {
290
+ return false
291
+ }
292
+ const entries = jobEntries(doc)
293
+ if (entries.length === 0) return false
294
+ const servicePath = resolveServicePath(doc, entries)
295
+ if (servicePath === null) return false
296
+
297
+ const hasPlan = entries.some(([name]) => name === 'plan')
298
+ const legacyNames = entries
299
+ .filter(([name, j]) => name !== 'plan' && findLintStep(Array.isArray(j.steps) ? j.steps : [])?.legacy)
300
+ .map(([name]) => name)
301
+
302
+ const jobsPlain = entries.map(([, j]) => j)
303
+ const prep = derivePrepSteps(jobsPlain)
304
+ const domains = await relevantDomains(cwd, servicePath)
305
+ let changed = false
306
+
307
+ if (!hasPlan) {
308
+ insertPlanJob(doc, domains, prep, servicePath)
309
+ changed = true
310
+ }
311
+ const renames = replaceLegacyJobs(doc, legacyNames, domains, servicePath, prep)
312
+ if (renames.size > 0) changed = true
313
+
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
+ }
326
+
327
+ if (!changed) return false
328
+ writeFileSync(absPath, doc.toString())
329
+ return true
330
+ }
331
+
332
+ export const patterns = [
333
+ {
334
+ id: 'ga-service-workflow-canon-migrate',
335
+ test: violations => violations.length > 0,
336
+ async apply(violations, ctx) {
337
+ const files = [...new Set(violations.map(v => v.file).filter(Boolean))]
338
+ const touched = []
339
+ for (const rel of files) {
340
+ const abs = join(ctx.cwd, rel)
341
+ if (!existsSync(abs)) continue
342
+ try {
343
+ if (await migrateWorkflowFile(abs, ctx.cwd)) touched.push(abs)
344
+ } catch {
345
+ // міграція конкретного файлу не вдалася — лишаємо deny детектору (fail-open до ручного фіксу)
346
+ }
347
+ }
348
+ return {
349
+ touchedFiles: touched,
350
+ message: touched.length > 0 ? `мігровано до сервіс-канону: ${touched.length} workflow(ів)` : null
351
+ }
352
+ }
353
+ }
354
+ ]
@@ -18,4 +18,6 @@ Rego-пакет: `ga.service_deploy_workflow`
18
18
 
19
19
  Repo-wide перевірки без path-підтримки (knip, jscpd, dep-policy) сюди НЕ входять — вони в окремому `lint-repo.yml` (концерн `lint_repo_yml`) і деплой не гейтять.
20
20
 
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
+
21
23
  Канон-snippet (документаційний приклад): [deploy-service.yml.snippet.yml](./template/deploy-service.yml.snippet.yml)