@7n/rules-ci-github 1.7.1 → 1.9.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 +12 -0
- package/package.json +1 -1
- package/rules/ga/service_deploy_workflow/docs/fix-service_deploy_workflow.md +13 -12
- package/rules/ga/service_deploy_workflow/fix-service_deploy_workflow.mjs +132 -13
- package/rules/ga/service_deploy_workflow/service_deploy_workflow.mdc +3 -1
- package/rules/ga/service_deploy_workflow/service_deploy_workflow.rego +46 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [1.9.0] - 2026-07-20
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- service_deploy_workflow: bootstrap-режим міграції (`migrateWorkflowFile(..., { bootstrap: true })`) — для deploy-workflow без жодної lint-джоби (валідний as-is за rego) створює lint-джоби доменів з нуля і підключає вхідну джобу до plan + усіх lint-джоб; опційний опт-ін, не частина звичайного lint --fix
|
|
8
|
+
|
|
9
|
+
## [1.8.0] - 2026-07-20
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- service_deploy_workflow: перевірка outputs-мапінгу plan-джоби — кожен ключ гейта `needs.plan.outputs.<key>` має бути задекларований у `jobs.plan.outputs` і вказувати на `steps.<id>.outputs.<key>` реального кроку (інакше гейт тихо порожній і джоба скіпається завжди)
|
|
14
|
+
|
|
3
15
|
## [1.7.1] - 2026-07-19
|
|
4
16
|
|
|
5
17
|
### Changed
|
package/package.json
CHANGED
|
@@ -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:
|
|
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
|
-
|
|
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` — мігрує один
|
|
20
|
-
- `patterns` —
|
|
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 —
|
|
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
|
-
- Перехоплює помилки
|
|
30
|
-
-
|
|
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
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
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())
|
|
@@ -12,7 +12,7 @@ Rego-пакет: `ga.service_deploy_workflow`
|
|
|
12
12
|
4. **`test`-джоба** — `needs: plan`, `if: needs.plan.outputs.any == 'true'`; тести лише сервісу (`bun test run/<service>`; python-стек — `astral-sh/setup-uv` + `uv sync --locked` + `pytest run/<service>`).
|
|
13
13
|
5. **Деплой** — needs-ланцюг (можна через проміжний `build`) **транзитивно досягає** `plan` і всі lint/test-джоби; кожна джоба з `needs` на умовні lint-джоби має `if` з `!cancelled()` та `!contains(needs.*.result, 'failure')` — skipped-лінт не блокує деплой, failed — блокує.
|
|
14
14
|
|
|
15
|
-
Що перевіряє rego: наявність plan-джоби з `ci plan --path … --github`; збіг `on.push.paths` ↔ `--path`; для кожної lint-джоби — `needs: plan`, гейт по outputs, той самий `--path`, `--no-fix`, prep-крок, `fetch-depth: 0`; для термінальних джоб — транзитивну досяжність plan і всіх перевірок та skip-толерантний `if`.
|
|
15
|
+
Що перевіряє rego: наявність plan-джоби з `ci plan --path … --github`; збіг `on.push.paths` ↔ `--path`; outputs-мапінг plan-джоби (кожен ключ, на який посилається гейт `needs.plan.outputs.<key>`, задекларований у `jobs.plan.outputs` і вказує на `steps.<id>.outputs.<key>` реального кроку — інакше гейт тихо порожній); для кожної lint-джоби — `needs: plan`, гейт по outputs, той самий `--path`, `--no-fix`, prep-крок, `fetch-depth: 0`; для термінальних джоб — транзитивну досяжність plan і всіх перевірок та skip-толерантний `if`.
|
|
16
16
|
|
|
17
17
|
Що НЕ перевіряється (справа автора): добір доменів під вміст сервісу, зміст test/deploy-кроків, секрети/environment, uv-prep. Спільні workflow-інваріанти (persist-credentials, min-версії uses, існування paths-глобів) покривають `workflow_common` і `workflows`.
|
|
18
18
|
|
|
@@ -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)
|
|
@@ -21,8 +21,7 @@
|
|
|
21
21
|
# Ланцюги deploy (build → deploy) законні: досяжність — graph.reachable.
|
|
22
22
|
#
|
|
23
23
|
# Не перевіряється тут (лишається .mdc-каноном): вибір набору доменів під
|
|
24
|
-
# сервіс, зміст test/deploy-кроків,
|
|
25
|
-
# python-стеку. Спільні workflow-перевірки (persist-credentials, min-версії
|
|
24
|
+
# сервіс, зміст test/deploy-кроків, uv-prep для python-стеку. Спільні workflow-перевірки (persist-credentials, min-версії
|
|
26
25
|
# uses) — у ga.workflow_common; існування paths-глобів — у ga/workflows.
|
|
27
26
|
package ga.service_deploy_workflow
|
|
28
27
|
|
|
@@ -65,6 +64,26 @@ service_paths contains p if {
|
|
|
65
64
|
p := m[0][1]
|
|
66
65
|
}
|
|
67
66
|
|
|
67
|
+
# Outputs-мапінг plan-джоби: гейт `needs.plan.outputs.<key>` працює лише коли
|
|
68
|
+
# jobs.plan.outputs декларує `<key>` з посиланням на `steps.<id>.outputs.<key>`
|
|
69
|
+
# кроку з таким id — інакше значення тихо порожнє і джоба скіпається завжди.
|
|
70
|
+
plan_outputs := object.get(object.get(jobs, "plan", {}), "outputs", {})
|
|
71
|
+
|
|
72
|
+
plan_step_ids contains id if {
|
|
73
|
+
some step in object.get(object.get(jobs, "plan", {}), "steps", [])
|
|
74
|
+
id := object.get(step, "id", "")
|
|
75
|
+
id != ""
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
# Ключі plan-outputs, на які посилаються гейти інших джоб.
|
|
79
|
+
gated_output_keys contains k if {
|
|
80
|
+
some name, job in jobs
|
|
81
|
+
name != "plan"
|
|
82
|
+
cond := object.get(job, "if", "")
|
|
83
|
+
some m in regex.find_all_string_submatch_n(`needs\.plan\.outputs\.([A-Za-z0-9_]+)`, cond, -1)
|
|
84
|
+
k := m[1]
|
|
85
|
+
}
|
|
86
|
+
|
|
68
87
|
# Lint-джоби (крок `n-rules lint <domain> … --path …`) з розібраною командою.
|
|
69
88
|
lint_cmds contains cmd if {
|
|
70
89
|
some name, job in jobs
|
|
@@ -135,6 +154,24 @@ deny contains msg if {
|
|
|
135
154
|
not contains(r, "--github")
|
|
136
155
|
}
|
|
137
156
|
|
|
157
|
+
# ── deny: outputs-мапінг plan-джоби ─────────────────────────────────────
|
|
158
|
+
|
|
159
|
+
deny contains msg if {
|
|
160
|
+
is_service_workflow
|
|
161
|
+
jobs.plan
|
|
162
|
+
some k in gated_output_keys
|
|
163
|
+
not plan_outputs[k]
|
|
164
|
+
msg := sprintf("plan: jobs.plan.outputs не мапить `%s` (канон: `%s: ${{ steps.plan.outputs.%s }}`) — гейт `needs.plan.outputs.%s` тихо порожній і джоба скіпається завжди (ga.mdc)", [k, k, k, k])
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
deny contains msg if {
|
|
168
|
+
is_service_workflow
|
|
169
|
+
some k in gated_output_keys
|
|
170
|
+
v := plan_outputs[k]
|
|
171
|
+
not output_maps_plan_step(v, k)
|
|
172
|
+
msg := sprintf("plan: outputs.%s не посилається на `steps.<id>.outputs.%s` кроку з таким id — гейт тихо порожній (ga.mdc)", [k, k])
|
|
173
|
+
}
|
|
174
|
+
|
|
138
175
|
# ── deny: тригер paths ↔ сервісний каталог ─────────────────────────────
|
|
139
176
|
|
|
140
177
|
deny contains msg if {
|
|
@@ -245,6 +282,13 @@ glob_covers(sp) if {
|
|
|
245
282
|
startswith(g, sp)
|
|
246
283
|
}
|
|
247
284
|
|
|
285
|
+
# Значення output-а посилається на `steps.<id>.outputs.<key>` реального кроку.
|
|
286
|
+
output_maps_plan_step(v, k) if {
|
|
287
|
+
m := regex.find_all_string_submatch_n(sprintf(`steps\.([A-Za-z0-9_-]+)\.outputs\.%s`, [k]), v, 1)
|
|
288
|
+
count(m) > 0
|
|
289
|
+
m[0][1] in plan_step_ids
|
|
290
|
+
}
|
|
291
|
+
|
|
248
292
|
job_has_prep(job) if {
|
|
249
293
|
some step in object.get(job, "steps", [])
|
|
250
294
|
step.uses == "./.github/actions/setup-bun-deps"
|