@7n/rules-ci-azure 1.2.1 → 1.3.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 CHANGED
@@ -1,5 +1,11 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.3.0] - 2026-07-18
4
+
5
+ ### Added
6
+
7
+ - Сервіс-орієнтований CI-канон: концерн azure-pipelines/service_deploy_pipeline — форма per-service pipeline у .azurepipelines/ (plan-гейт `ci plan --azure`, per-domain lint-джоби з dependsOn+condition, термінальні джоби зі Skipped-толерантним condition)
8
+
3
9
  ## [1.2.1] - 2026-07-16
4
10
 
5
11
  ### Fixed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@7n/rules-ci-azure",
3
- "version": "1.2.1",
3
+ "version": "1.3.0",
4
4
  "description": "\u041f\u043b\u0430\u0433\u0456\u043d @7n/rules: \u043a\u0430\u043d\u043e\u043d Azure Pipelines (azure-pipelines.yml) \u2014 \u0441\u0442\u0440\u0443\u043a\u0442\u0443\u0440\u0430, lint-\u0441\u0442\u0435\u043f, VS Code",
5
5
  "keywords": [
6
6
  "azure-pipelines",
@@ -1,11 +1,13 @@
1
1
  ---
2
- description: Канон Azure Pipelines — структура azure-pipelines.yml, обов'язковий lint-степ n-rules, розширення VS Code
3
- version: '1.0'
4
- globs: "azure-pipelines.yml"
2
+ description: Канон Azure Pipelines — структура azure-pipelines.yml, обов'язковий lint-степ n-rules, сервіс-орієнтовані deploy-pipelines, розширення VS Code
3
+ version: '1.1'
4
+ globs: "azure-pipelines.yml,.azurepipelines/**/*.yml"
5
5
  alwaysApply: false
6
6
  ---
7
7
 
8
- Правило **azure-pipelines** (плагін `@7n/rules-ci-azure`) перевіряє канонічну структуру `azure-pipelines.yml` у корені репозиторію та налаштування VS Code для роботи з Azure Pipelines. Аналог правила `ga` (плагін `@7n/rules-ci-github`) для репозиторіїв на Azure DevOps.
8
+ Правило **azure-pipelines** (плагін `@7n/rules-ci-azure`) перевіряє канонічну структуру `azure-pipelines.yml` у корені репозиторію, форму сервіс-орієнтованих deploy-pipelines у `.azurepipelines/` та налаштування VS Code для роботи з Azure Pipelines. Аналог правила `ga` (плагін `@7n/rules-ci-github`) для репозиторіїв на Azure DevOps.
9
+
10
+ Ролі pipeline-ів: **кореневий `azure-pipelines.yml`** — repo-wide лінт (safety-net `--full`), деплой **не гейтить**; **сервісні pipelines** (`.azurepipelines/**/*.yml` із `trigger.paths.include`) — гейтять деплой свого сервісу через plan → lint/test → deploy (концерн `service_deploy_pipeline`).
9
11
 
10
12
  - `azure-pipelines.yml`:
11
13
 
@@ -67,6 +69,14 @@ Substring-перевірки замість exact-match — Azure YAML допу
67
69
 
68
70
  Канон: [azure-pipelines-lint.yml.snippet.yml](./lint_pipeline/template/azure-pipelines-lint.yml.snippet.yml)
69
71
 
72
+ ## Сервіс-орієнтовані deploy-pipelines (`.azurepipelines/**/*.yml`, опційно)
73
+
74
+ Rego-пакет: `azure_pipelines.service_deploy_pipeline`
75
+
76
+ **Цільові файли:** `.azurepipelines/**/*.yml` крім `templates/**`; сервісним вважається файл із `trigger.paths.include`.
77
+
78
+ Форма: джоба `plan` (`bunx n-rules ci plan --path <svc> --azure`, крок з `name: plan`) → паралельні `lint_<domain>`-джоби (`dependsOn: plan`, `condition` по `dependencies.plan.outputs['plan.<domain>']`, `lint <domain> --path <svc> --no-fix`) і `run_tests` → термінальні джоби з dependsOn-ланцюгом на всі перевірки та Skipped-толерантним `condition`. Деталі й template-розкладка: [service_deploy_pipeline.mdc](./service_deploy_pipeline/service_deploy_pipeline.mdc); приклад — [deploy-service-pipeline.yml.snippet.yml](./service_deploy_pipeline/template/deploy-service-pipeline.yml.snippet.yml).
79
+
70
80
  ## `.vscode/extensions.json` — розширення для Azure Pipelines
71
81
 
72
82
  Rego-пакет: `azure_pipelines.vscode_extensions`
@@ -0,0 +1,9 @@
1
+ {
2
+ "$schema": "https://unpkg.com/@7n/rules/schemas/concern.json",
3
+ "fixability": "config",
4
+ "policy": {
5
+ "files": {
6
+ "walkGlob": [".azurepipelines/**/*.yml", "!.azurepipelines/templates/**"]
7
+ }
8
+ }
9
+ }
@@ -0,0 +1,19 @@
1
+ ## Rego-gate сервіс-орієнтованих deploy-pipeline (`.azurepipelines/**/*.yml`)
2
+
3
+ Rego-пакет: `azure_pipelines.service_deploy_pipeline`
4
+
5
+ Цільові файли: усі `.azurepipelines/**/*.yml`, крім `templates/**` (walkGlob). Дискримінатор — **`trigger.paths.include`**: файл із paths-фільтром вважається сервісним pipeline і мусить відповідати формі; без нього — концерн мовчить. Кореневий `azure-pipelines.yml` (repo-wide лінт, канон `pipeline_common`) поза глобом — він деплой не гейтить.
6
+
7
+ Канонічна форма per-service pipeline (каталог сервісу — напр. `run/nexus`):
8
+
9
+ 1. **Тригер** — `trigger.paths.include` з каталогом сервісу.
10
+ 2. **`plan`-джоба** — `checkout: self` із `fetchDepth: 0` → prep (install bun + `bun install --frozen-lockfile`) → крок `bunx n-rules ci plan --path run/<service> --azure` **з `name: plan`** (downstream читає `dependencies.plan.outputs['plan.<domain>']`).
11
+ 3. **`lint_<domain>`-джоби** (паралельно) — `dependsOn: plan`, `condition: eq(dependencies.plan.outputs['plan.<domain>'], 'true')` (ключ = домен із `-`→`_`), крок `bunx n-rules lint <domain> --path run/<service> --no-fix` з `--path` ∈ `trigger.paths.include`, prep у кожній джобі (джоби не шарять ФС).
12
+ 4. **`run_tests`** — `dependsOn: plan`, `condition` по `plan.any`; тести лише сервісу (python-стек — uv: `curl … astral.sh/uv/install.sh | sh` + `uv sync --locked`).
13
+ 5. **Термінальні джоби** (`build_and_push`/`deploy_*`) — dependsOn-ланцюг **транзитивно досягає** `plan` і всі перевірки; джоба з `dependsOn` на умовні lint-джоби має `condition` з `not(canceled())` та `in(dependencies.<j>.result, 'Succeeded', 'Skipped')` — skipped-лінт не блокує деплой, failed — блокує.
14
+
15
+ **Template-розкладка** (efes-стиль `- template:` з параметром-каталогом): законна, rego тоді перевіряє лише видиме у файлі — що параметр template-посилання дорівнює одному з `paths.include`; повнота графа всередині шаблону — відповідальність автора за цим каноном (rego не бачить розгорнутий YAML).
16
+
17
+ Що НЕ перевіряється: добір доменів під вміст сервісу, зміст test/deploy-кроків, uv-prep, шаблони в `templates/**`.
18
+
19
+ Канон-snippet (документаційний приклад): [deploy-service-pipeline.yml.snippet.yml](./template/deploy-service-pipeline.yml.snippet.yml)
@@ -0,0 +1,281 @@
1
+ # Форма сервіс-орієнтованого deploy-pipeline (`.azurepipelines/**/*.yml`,
2
+ # крім `templates/**`). Дзеркало ga.service_deploy_workflow для Azure Pipelines.
3
+ #
4
+ # Дискримінатор застосовності — НЕ імʼя файлу, а `trigger.paths.include`:
5
+ # файл із paths-фільтром = сервісний pipeline і мусить відповідати формі;
6
+ # файл без нього (repo-wide) — жодних deny. Кореневий azure-pipelines.yml
7
+ # поза walkGlob узагалі (канон pipeline_common).
8
+ #
9
+ # Канон (azure-pipelines.mdc, сніпет template/deploy-service-pipeline.yml.snippet.yml):
10
+ # plan (checkout fetchDepth:0 + bun install + `bunx n-rules ci plan --path <svc> --azure`,
11
+ # крок з `name: plan`)
12
+ # ├─ lint_<domain> × N (dependsOn: plan; condition по dependencies.plan.outputs['plan.<domain>'];
13
+ # │ `bunx n-rules lint <domain> --path <svc> --no-fix`)
14
+ # ├─ run_tests (dependsOn: plan)
15
+ # └─ build_and_push/deploy (dependsOn-ланцюг досягає plan і всі перевірки;
16
+ # condition: not(canceled()) + 'Skipped'-толерантність)
17
+ #
18
+ # Template-розкладка (`- template:` з параметром-каталогом) законна: rego тоді
19
+ # перевіряє лише видиме у файлі — наявність template-посилання з параметром,
20
+ # що дорівнює одному з paths.include; повна перевірка графа — .mdc-канон.
21
+ package azure_pipelines.service_deploy_pipeline
22
+
23
+ import rego.v1
24
+
25
+ # ── extraction ──────────────────────────────────────────────────────────
26
+
27
+ service_paths contains p if {
28
+ some p in input.trigger.paths.include
29
+ is_string(p)
30
+ }
31
+
32
+ is_service_pipeline if count(service_paths) > 0
33
+
34
+ # Джоби на будь-якій глибині (root jobs / stages→jobs).
35
+ all_jobs contains j if {
36
+ walk(input, [_, j])
37
+ is_object(j)
38
+ is_string(object.get(j, "job", 0))
39
+ }
40
+
41
+ # `- template:`-посилання з параметрами (efes-стиль modulePath).
42
+ template_refs contains t if {
43
+ walk(input, [_, t])
44
+ is_object(t)
45
+ is_string(object.get(t, "template", 0))
46
+ }
47
+
48
+ template_covers_service if {
49
+ some t in template_refs
50
+ some v in object.get(t, "parameters", {})
51
+ v in service_paths
52
+ }
53
+
54
+ plan_jobs contains j if {
55
+ some j in all_jobs
56
+ j.job == "plan"
57
+ }
58
+
59
+ plan_runs contains r if {
60
+ some j in plan_jobs
61
+ some r in job_cmds(j)
62
+ contains(r, "n-rules ci plan")
63
+ }
64
+
65
+ # Lint-джоби (`n-rules lint <domain> … --path …`) з розібраною командою.
66
+ lint_cmds contains cmd if {
67
+ some j in all_jobs
68
+ j.job != "plan"
69
+ some r in job_cmds(j)
70
+ m := regex.find_all_string_submatch_n(`n-rules lint\s+([a-z0-9-]+)\s+[^\n]*--path\s+(\S+)`, r, 1)
71
+ count(m) > 0
72
+ cmd := {"job": j.job, "spec": j, "domain": m[0][1], "path": m[0][2], "run": r}
73
+ }
74
+
75
+ check_jobs contains cmd.job if some cmd in lint_cmds
76
+
77
+ # Граф dependsOn і транзитивна досяжність (ланцюг deploy → build → перевірки).
78
+ needs_graph[name] := depends_of(j) if {
79
+ some j in all_jobs
80
+ name := j.job
81
+ }
82
+
83
+ all_needed contains n if {
84
+ some j in all_jobs
85
+ some n in depends_of(j)
86
+ }
87
+
88
+ terminal_jobs contains j.job if {
89
+ some j in all_jobs
90
+ j.job != "plan"
91
+ not j.job in check_jobs
92
+ not j.job in all_needed
93
+ }
94
+
95
+ job_by_name[name] := j if {
96
+ some j in all_jobs
97
+ name := j.job
98
+ }
99
+
100
+ # ── deny: plan-джоба ────────────────────────────────────────────────────
101
+
102
+ deny contains msg if {
103
+ is_service_pipeline
104
+ count(all_jobs) > 0
105
+ count(plan_jobs) == 0
106
+ not template_covers_service
107
+ msg := "service-pipeline: немає job `plan` з кроком `bunx n-rules ci plan --path <svc> --azure` (azure-pipelines.mdc: сервіс-канон)"
108
+ }
109
+
110
+ deny contains msg if {
111
+ is_service_pipeline
112
+ count(all_jobs) == 0
113
+ count(template_refs) > 0
114
+ not template_covers_service
115
+ msg := "service-pipeline: `- template:` без параметра-каталогу з trigger.paths.include — гейт не привʼязаний до сервісу (azure-pipelines.mdc)"
116
+ }
117
+
118
+ deny contains msg if {
119
+ msg := "service-pipeline: job `plan` без кроку `bunx n-rules ci plan …` (azure-pipelines.mdc: сервіс-канон)"
120
+ is_service_pipeline
121
+ some j in plan_jobs
122
+ count([r | some r in job_cmds(j); contains(r, "n-rules ci plan")]) == 0
123
+ }
124
+
125
+ deny contains msg if {
126
+ msg := "service-pipeline: `ci plan` без `--azure` — outputs не стануть змінними джоби (azure-pipelines.mdc)"
127
+ is_service_pipeline
128
+ some r in plan_runs
129
+ not contains(r, "--azure")
130
+ }
131
+
132
+ deny contains msg if {
133
+ is_service_pipeline
134
+ some r in plan_runs
135
+ m := regex.find_all_string_submatch_n(`n-rules ci plan\s+--path\s+(\S+)`, r, 1)
136
+ count(m) > 0
137
+ not m[0][1] in service_paths
138
+ msg := sprintf("service-pipeline: `ci plan --path %s` ∉ trigger.paths.include — план рахується не для того каталогу (azure-pipelines.mdc)", [m[0][1]])
139
+ }
140
+
141
+ # Outputs адресуються як dependencies.plan.outputs['plan.<key>'] — крок мусить мати name: plan.
142
+ deny contains msg if {
143
+ msg := "service-pipeline: крок `ci plan` без `name: plan` — downstream-condition не знайде outputs (azure-pipelines.mdc)"
144
+ is_service_pipeline
145
+ some j in plan_jobs
146
+ count([s | some s in object.get(j, "steps", []); contains(step_cmd(s), "n-rules ci plan"); s.name == "plan"]) == 0
147
+ }
148
+
149
+ # ── deny: lint-джоби ────────────────────────────────────────────────────
150
+
151
+ deny contains msg if {
152
+ is_service_pipeline
153
+ some cmd in lint_cmds
154
+ not "plan" in depends_of(cmd.spec)
155
+ msg := sprintf("%s: lint-джоба мусить мати dependsOn: plan (azure-pipelines.mdc: сервіс-канон)", [cmd.job])
156
+ }
157
+
158
+ deny contains msg if {
159
+ is_service_pipeline
160
+ some cmd in lint_cmds
161
+ cond := object.get(cmd.spec, "condition", "")
162
+ not contains(cond, sprintf("dependencies.plan.outputs['plan.%s']", [replace(cmd.domain, "-", "_")]))
163
+ msg := sprintf("%s: lint-джоба без condition по dependencies.plan.outputs['plan.%s'] — крок не скіпатиметься (azure-pipelines.mdc)", [cmd.job, replace(cmd.domain, "-", "_")])
164
+ }
165
+
166
+ deny contains msg if {
167
+ is_service_pipeline
168
+ some cmd in lint_cmds
169
+ not cmd.path in service_paths
170
+ msg := sprintf("%s: `lint %s --path %s` ∉ trigger.paths.include — лінтиться інший каталог (azure-pipelines.mdc)", [cmd.job, cmd.domain, cmd.path])
171
+ }
172
+
173
+ deny contains msg if {
174
+ is_service_pipeline
175
+ some cmd in lint_cmds
176
+ not contains(cmd.run, "--no-fix")
177
+ msg := sprintf("%s: `n-rules lint` у CI мусить мати `--no-fix` (azure-pipelines.mdc)", [cmd.job])
178
+ }
179
+
180
+ deny contains msg if {
181
+ is_service_pipeline
182
+ some cmd in lint_cmds
183
+ not job_has_prep(cmd.spec)
184
+ msg := sprintf("%s: lint-джоба без prep-кроку `bun install --frozen-lockfile` (джоби не шарять ФС) (azure-pipelines.mdc)", [cmd.job])
185
+ }
186
+
187
+ # ── deny: fetchDepth для git-дельти ─────────────────────────────────────
188
+
189
+ deny contains msg if {
190
+ msg := "plan: checkout мусить мати fetchDepth: 0 — без історії git-дельта `ci plan` не рахується (azure-pipelines.mdc)"
191
+ is_service_pipeline
192
+ some j in plan_jobs
193
+ count([r | some r in job_cmds(j); contains(r, "n-rules ci plan")]) > 0
194
+ not job_has_full_checkout(j)
195
+ }
196
+
197
+ deny contains msg if {
198
+ is_service_pipeline
199
+ some cmd in lint_cmds
200
+ not job_has_full_checkout(cmd.spec)
201
+ msg := sprintf("%s: checkout мусить мати fetchDepth: 0 — без історії `lint --path` не бачить дельти (azure-pipelines.mdc)", [cmd.job])
202
+ }
203
+
204
+ # ── deny: термінальні (deploy) джоби ────────────────────────────────────
205
+
206
+ deny contains msg if {
207
+ is_service_pipeline
208
+ count(check_jobs) > 0
209
+ some name in terminal_jobs
210
+ not "plan" in graph.reachable(needs_graph, {name})
211
+ msg := sprintf("%s: термінальна джоба не досягає `plan` через dependsOn-ланцюг (azure-pipelines.mdc: сервіс-канон)", [name])
212
+ }
213
+
214
+ deny contains msg if {
215
+ is_service_pipeline
216
+ some name in terminal_jobs
217
+ some cj in check_jobs
218
+ not cj in graph.reachable(needs_graph, {name})
219
+ msg := sprintf("%s: термінальна джоба не досягає перевірки `%s` через dependsOn-ланцюг — деплой не гейтиться нею (azure-pipelines.mdc)", [name, cj])
220
+ }
221
+
222
+ # Skipped-лінт (умовний гейт спрацював) не мусить блокувати деплой, fail — мусить:
223
+ # канон `and(not(canceled()), in(dependencies.<j>.result, 'Succeeded', 'Skipped'), …)`.
224
+ deny contains msg if {
225
+ is_service_pipeline
226
+ some name in terminal_jobs
227
+ needed := depends_of(job_by_name[name])
228
+ count([cj | some cj in check_jobs; cj in needed]) > 0
229
+ cond := object.get(job_by_name[name], "condition", "")
230
+ not skip_tolerant(cond)
231
+ msg := sprintf("%s: джоба з dependsOn на умовні lint-джоби мусить мати condition із not(canceled()) і 'Skipped'-толерантністю — інакше skipped-лінт назавжди блокує деплой (azure-pipelines.mdc)", [name])
232
+ }
233
+
234
+ # ── helpers ─────────────────────────────────────────────────────────────
235
+
236
+ # dependsOn: string | array → array.
237
+ depends_of(job) := [n] if {
238
+ n := object.get(job, "dependsOn", [])
239
+ is_string(n)
240
+ }
241
+
242
+ depends_of(job) := object.get(job, "dependsOn", []) if is_array(object.get(job, "dependsOn", []))
243
+
244
+ # Команда кроку: `script:` або `bash:`.
245
+ step_cmd(step) := s if {
246
+ s := object.get(step, "script", "")
247
+ s != ""
248
+ }
249
+
250
+ step_cmd(step) := s if {
251
+ object.get(step, "script", "") == ""
252
+ s := object.get(step, "bash", "")
253
+ s != ""
254
+ }
255
+
256
+ step_cmd(step) := "" if {
257
+ object.get(step, "script", "") == ""
258
+ object.get(step, "bash", "") == ""
259
+ }
260
+
261
+ job_cmds(job) := [c |
262
+ some step in object.get(job, "steps", [])
263
+ c := step_cmd(step)
264
+ c != ""
265
+ ]
266
+
267
+ job_has_prep(job) if {
268
+ some c in job_cmds(job)
269
+ contains(c, "bun install --frozen-lockfile")
270
+ }
271
+
272
+ job_has_full_checkout(job) if {
273
+ some step in object.get(job, "steps", [])
274
+ object.get(step, "checkout", "") != ""
275
+ step.fetchDepth == 0
276
+ }
277
+
278
+ skip_tolerant(cond) if {
279
+ contains(cond, "not(canceled())")
280
+ contains(cond, "'Skipped'")
281
+ }
@@ -0,0 +1,77 @@
1
+ # Документаційний приклад сервіс-орієнтованого deploy-pipeline (azure-pipelines.mdc).
2
+ # Це НЕ deep-subset-канон (перелік сервісів консюмер-специфічний) — форму
3
+ # перевіряє service_deploy_pipeline.rego по всіх `.azurepipelines/**/*.yml`
4
+ # з trigger.paths.include. Плейсхолдер run/<service> заміни на каталог сервісу.
5
+ trigger:
6
+ branches:
7
+ include:
8
+ - dev
9
+ - main
10
+ paths:
11
+ include:
12
+ - run/<service>
13
+
14
+ pool:
15
+ vmImage: ubuntu-latest
16
+
17
+ jobs:
18
+ - job: plan
19
+ steps:
20
+ - checkout: self
21
+ fetchDepth: 0
22
+ - script: |
23
+ curl -fsSL https://bun.sh/install | bash
24
+ echo "##vso[task.prependpath]$HOME/.bun/bin"
25
+ displayName: Install bun
26
+ - script: bun install --frozen-lockfile
27
+ displayName: Install deps
28
+ - script: bunx n-rules ci plan --path run/<service> --azure
29
+ name: plan # ОБОВʼЯЗКОВО: outputs адресуються як dependencies.plan.outputs['plan.<domain>']
30
+
31
+ - job: lint_js
32
+ dependsOn: plan
33
+ condition: eq(dependencies.plan.outputs['plan.js'], 'true')
34
+ steps:
35
+ - checkout: self
36
+ fetchDepth: 0
37
+ - script: |
38
+ curl -fsSL https://bun.sh/install | bash
39
+ echo "##vso[task.prependpath]$HOME/.bun/bin"
40
+ displayName: Install bun
41
+ - script: bun install --frozen-lockfile
42
+ displayName: Install deps
43
+ - script: bunx n-rules lint js --path run/<service> --no-fix
44
+ displayName: Lint js
45
+
46
+ # …lint_<domain> для кожного домену сервісу (python/docker/k8s/security/…)
47
+ # python-стек: перед тестами ще uv (curl -LsSf https://astral.sh/uv/install.sh | sh + uv sync --locked)
48
+
49
+ - job: run_tests
50
+ dependsOn: plan
51
+ condition: eq(dependencies.plan.outputs['plan.any'], 'true')
52
+ steps:
53
+ - checkout: self
54
+ fetchDepth: 0
55
+ - script: |
56
+ curl -fsSL https://bun.sh/install | bash
57
+ echo "##vso[task.prependpath]$HOME/.bun/bin"
58
+ displayName: Install bun
59
+ - script: bun install --frozen-lockfile
60
+ displayName: Install deps
61
+ - script: bun test run/<service>
62
+ displayName: Tests
63
+
64
+ - job: build_and_push
65
+ dependsOn:
66
+ - plan
67
+ - lint_js
68
+ - run_tests
69
+ # Skipped-лінт (гейт ci plan) НЕ блокує деплой; failed — блокує.
70
+ condition: >-
71
+ and(not(canceled()),
72
+ eq(dependencies.plan.result, 'Succeeded'),
73
+ in(dependencies.lint_js.result, 'Succeeded', 'Skipped'),
74
+ in(dependencies.run_tests.result, 'Succeeded', 'Skipped'))
75
+ steps:
76
+ - checkout: self
77
+ # …кроки build/push/deploy сервісу