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