From 97dfa457a4f86c1fc3e846732b3445af70bcf5e7 Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 9 Sep 2026 17:53:05 +0300 Subject: [PATCH] ci: diff mutants only on request; the review pipeline proves them on the material before reviewing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Validate ran the three "Мутанты по диффу" shards on every push of every branch: 48 of 56 job-hours on 08–09.09, most of them cancelled by the next push. Mutants now run when asked — pull requests, the nightly schedule, a push carrying a `Release:` trailer, or a dispatch with `mutants=true` (classify-changes.mjs → `mutants_requested`); an ordinary push runs the light checks only. The proof moves to where it is consumed. process.yml gets a gate after the #499 reuse step: on the code stage it looks for a dispatch Validate run on the exact material SHA whose mutant jobs executed and passed (scripts/validate-gate.mjs); none → it dispatches one and waits; red or missing → the task goes back S7→S6 with the run link and the review cycle is not spent. Spec stage and the reuse fast-path skip the gate (`proceed=true`); all later steps branch on `proceed` in place of the old conflict conjunct only. merge-candidate.mjs dispatches Validate on the pushed candidate and waits for that dispatch run. PROCESS.md/AGENTS.md: review does not start on red code; one handoff — one push. Mutants: mutants-run-on-every-push, review-starts-on-red-validate, review-trusts-push-run-without-mutants, merge-waits-push-run-without-mutants. Issue: #510 User-Visible: no --- .github/workflows/process.yml | 80 ++++++++++++++++++--- .github/workflows/validate.yml | 24 +++++-- AGENTS.md | 12 ++++ PROCESS.md | 22 +++++- docs/TESTING.md | 10 ++- scripts/classify-changes.mjs | 21 ++++++ scripts/merge-candidate.mjs | 20 ++++-- scripts/mutation-gate.mjs | 46 +++++++++++- scripts/validate-gate.mjs | 120 ++++++++++++++++++++++++++++++++ test/classify-changes.test.mjs | 21 ++++-- test/merge-candidate.test.mjs | 12 +++- test/review-doc-guard.test.mjs | 32 +++++++++ test/validate-gate.test.mjs | 107 ++++++++++++++++++++++++++++ test/validate-workflow.test.mjs | 11 ++- 14 files changed, 504 insertions(+), 34 deletions(-) create mode 100755 scripts/validate-gate.mjs create mode 100755 test/validate-gate.test.mjs diff --git a/.github/workflows/process.yml b/.github/workflows/process.yml index c6f3fed0..1a8e7927 100644 --- a/.github/workflows/process.yml +++ b/.github/workflows/process.yml @@ -474,6 +474,64 @@ jobs: --add-label S6-in-progress --remove-label S7-code-review echo "S7-code-review -> S6-in-progress (ревью не запускалось)" + # Мутанты по диффу бегут только по запросу (#510): до ревью конвейер + # запускает Validate с мутантами на материале и ждёт его. Красный или + # пропавший прогон возвращает задачу автору без ревью — цикл не + # тратится на код, который CI уже отверг (08.09: #437 дважды ушёл в S6 + # после запущенного 15-минутного ревью). Этап spec кода не несёт и + # гейт не проходит; повторное применение вердикта (#499) — тоже: там + # слияние само дожидается Validate на кандидате. + - name: Validate с мутантами на материале + id: gate + if: steps.rebase.outputs.conflict != 'true' + env: + GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }} + STAGE: ${{ needs.guard.outputs.stage }} + REUSE: ${{ steps.reuse.outputs.reuse }} + BRANCH: ${{ steps.branch.outputs.name }} + SHA: ${{ steps.material.outputs.sha }} + run: | + if [ "$STAGE" != "code" ] || [ "$REUSE" = "true" ] || [ -z "$BRANCH" ]; then + echo "гейт не применяется: этап $STAGE, reuse=${REUSE:-false}, ветка ${BRANCH:-dev}" + { echo 'proceed=true'; echo 'result=skipped'; } >> "$GITHUB_OUTPUT" + exit 0 + fi + if node scripts/validate-gate.mjs --repo="${{ github.repository }}" --ref="$BRANCH" --sha="$SHA"; then + echo 'proceed=true' >> "$GITHUB_OUTPUT" + else + echo 'proceed=false' >> "$GITHUB_OUTPUT" + fi + + - name: Validate красный — вернуть автору без ревью + if: steps.rebase.outputs.conflict != 'true' && steps.gate.outputs.proceed != 'true' + env: + GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }} + NUM: ${{ github.event.issue.number }} + BRANCH: ${{ steps.branch.outputs.name }} + SHA: ${{ steps.material.outputs.sha }} + RESULT: ${{ steps.gate.outputs.result }} + NOTE: ${{ steps.gate.outputs.note }} + URL: ${{ steps.gate.outputs.url }} + run: | + short=$(git rev-parse --short "$SHA") + cat > /tmp/gate.md < S6-in-progress (Validate с мутантами: $RESULT)" + # Ревьюер перегонял tsc, юниты и сборку заново в каждом раунде, хотя # Validate на том же SHA уже зелёный (#343). Это не тщательность: бюджет # ревью тратится на повторение CI вместо чтения кода. @@ -483,7 +541,7 @@ jobs: # ребейза SHA другой, прогона для него нет — и ревьюер честно гоняет сам. - name: Зелёные гейты на этом SHA id: validated - if: steps.rebase.outputs.conflict != 'true' + if: steps.gate.outputs.proceed == 'true' env: GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }} run: | @@ -512,21 +570,21 @@ jobs: # Зависимости ставятся ПОСЛЕ переключения на ветку задачи: lockfile мог # измениться именно в ней, и установка по копии из dev дала бы не то дерево. - name: Установить зависимости - if: steps.rebase.outputs.conflict != 'true' && steps.reuse.outputs.reuse != 'true' + if: steps.gate.outputs.proceed == 'true' && steps.reuse.outputs.reuse != 'true' run: npm ci # Браузер нужен не всякому ревью (см. правило выбора гейтов в промпте), # но когда нужен — качать его заново дороже, чем держать в кэше. - name: Кэш браузеров Playwright id: pw - if: steps.rebase.outputs.conflict != 'true' && steps.reuse.outputs.reuse != 'true' + if: steps.gate.outputs.proceed == 'true' && steps.reuse.outputs.reuse != 'true' uses: actions/cache@v6 with: path: ~/.cache/ms-playwright key: playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }} - name: Установить Chromium - if: steps.rebase.outputs.conflict != 'true' && steps.reuse.outputs.reuse != 'true' && steps.pw.outputs.cache-hit != 'true' + if: steps.gate.outputs.proceed == 'true' && steps.reuse.outputs.reuse != 'true' && steps.pw.outputs.cache-hit != 'true' # Без --with-deps: системные библиотеки Chromium предустановлены в # образе ubuntu-latest, а apt при промахе кэша съедал минуты из бюджета # ревью и подолгу перебирал недоступное azure-зеркало (#175). Если @@ -542,7 +600,7 @@ jobs: # скачан в _actions к началу job), контрольную сумму — из манифеста релиза. - name: Установить Claude Code детерминированно id: claude_bin - if: steps.rebase.outputs.conflict != 'true' && steps.reuse.outputs.reuse != 'true' + if: steps.gate.outputs.proceed == 'true' && steps.reuse.outputs.reuse != 'true' run: | src=$(ls "$RUNNER_WORKSPACE"/../_actions/anthropics/claude-code-*/v1/src/entrypoints/run.ts 2>/dev/null | head -1) ver=$(grep -oE 'claudeCodeVersion = "[0-9]+\.[0-9]+\.[0-9]+"' "$src" 2>/dev/null | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' || true) @@ -559,7 +617,7 @@ jobs: - name: Review id: review - if: steps.rebase.outputs.conflict != 'true' && steps.reuse.outputs.reuse != 'true' + if: steps.gate.outputs.proceed == 'true' && steps.reuse.outputs.reuse != 'true' uses: anthropics/claude-code-action@v1 env: # Вне рабочей копии: восстановление дерева ревьюером не должно @@ -783,7 +841,7 @@ jobs: # Ревьюер пишет только в docs/reviews/. Что именно попадёт в коммит, # решает этот шаг, а не модель: всё остальное откатывается. - name: Опубликовать документ ревью - if: steps.rebase.outputs.conflict != 'true' && steps.reuse.outputs.reuse != 'true' + if: steps.gate.outputs.proceed == 'true' && steps.reuse.outputs.reuse != 'true' env: TOKEN: ${{ secrets.HP_PROCESS_TOKEN }} BRANCH: ${{ steps.branch.outputs.name }} @@ -945,7 +1003,7 @@ jobs: # достижим там из необновлённой локальной ветки. Читателю отчёта от этого # пользы нет — он достанет только то, что есть на origin. - name: "Материал раунда воспроизводим (#413)" - if: steps.rebase.outputs.conflict != 'true' && steps.reuse.outputs.reuse != 'true' + if: steps.gate.outputs.proceed == 'true' && steps.reuse.outputs.reuse != 'true' env: NUM: ${{ github.event.issue.number }} STAGE: ${{ needs.guard.outputs.stage }} @@ -962,7 +1020,7 @@ jobs: git show "origin/$target:$doc" | node scripts/review-doc-guard.mjs --doc=- - name: Решение по вердикту id: decide - if: steps.rebase.outputs.conflict != 'true' + if: steps.gate.outputs.proceed == 'true' env: OUT: ${{ steps.review.outputs.structured_output }} STAGE: ${{ needs.guard.outputs.stage }} @@ -1017,7 +1075,7 @@ jobs: # dev действительно ушёл и вердикт зелёный, то есть слияние вот-вот # случится (#364). - name: dev ушёл вперёд, пока шло ревью - if: steps.rebase.outputs.conflict != 'true' && needs.guard.outputs.stage == 'code' + if: steps.gate.outputs.proceed == 'true' && needs.guard.outputs.stage == 'code' env: GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }} NUM: ${{ github.event.issue.number }} @@ -1063,7 +1121,7 @@ jobs: --issue="$NUM" --repo="${{ github.repository }}" - name: Переставить метку - if: steps.rebase.outputs.conflict != 'true' + if: steps.gate.outputs.proceed == 'true' env: # Именно PAT: с GITHUB_TOKEN следующий шаг конвейера не запустится. GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }} diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 07dbe1c2..8896cd3f 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -18,9 +18,15 @@ on: workflow_dispatch: inputs: full: - description: 'Полный набор: смоки, golden, performance_smoke' + description: 'Полный набор: смоки, golden, performance_smoke, мутанты по диффу' type: boolean default: true + # Мутанты по диффу бегут по запросу (#510): ревью-конвейер и слияние + # кандидата запускают `-f full=false -f mutants=true` на материале. + mutants: + description: 'Мутанты по диффу на этом SHA (кандидат ревью)' + type: boolean + default: false # A new push supersedes an unfinished validation for the same branch or PR. # Exact-SHA release gates never depend on an obsolete commit. @@ -218,10 +224,10 @@ jobs: unknown_inputs: ${{ steps.classify.outputs.unknown_inputs }} # Тяжёлые job только на кандидате/по кнопке/на PR (#479), см. шаг heavy. heavy: ${{ steps.heavy.outputs.heavy }} + mutants_requested: ${{ steps.heavy.outputs.mutants_requested }} # #510 base: ${{ steps.base.outputs.base }} - # Разные вещи под разными именами намеренно: `base` — до какого коммита - # классифицировать файлы ветки (#387), `range_base` — от какого судить - # диапазон на dev (#388): общее имя — чужая база у потребителя. + # `base` — до какого коммита классифицировать файлы ветки (#387), + # `range_base` — от какого судить диапазон на dev (#388). range_base: ${{ steps.base.outputs.range_base }} steps: # `git diff --name-only` содержимого файлов не читает вовсе, поэтому @@ -241,6 +247,7 @@ jobs: EVENT_NAME: ${{ github.event_name }} HEAD_MESSAGE: ${{ github.event.head_commit.message }} FULL_INPUT: ${{ inputs.full }} + MUTANTS_INPUT: ${{ inputs.mutants }} run: node scripts/classify-changes.mjs --heavy | tee -a "$GITHUB_OUTPUT" - id: base if: github.event_name != 'pull_request' @@ -476,10 +483,17 @@ jobs: # что и остальные гейты ветки (#387/#388). Релизный диапазон всё же может # задеть сотню свидетелей, поэтому #480 делит тот же набор на три # детерминированных непересекающихся шарда вместо ослабления проверки. + # С #510 job бежит не на каждом пуше, а на кандидате: ревью-конвейер и + # слияние кандидата запускают Validate по кнопке с `mutants=true` на + # материале, ночной прогон/PR/кандидат беты берут её сами. За 08–09.09 на + # промежуточных пушах она стоила 48 из 56 часов job-минут и в основном + # отменялась следующим пушем. Когда мутанты запрошены, job бежит даже при + # диффе без входов (отбор пустой, минута на checkout): гейт ревью читает + # её исход по job, и skipped был бы неотличим от «не запрашивали». changed_mutants: name: "Мутанты по диффу (${{ matrix.shard }}/3): затронутые свидетели краснеют" needs: changes - if: needs.changes.outputs.frontend == 'true' || needs.changes.outputs.backend == 'true' || needs.changes.outputs.mutants == 'true' + if: needs.changes.outputs.mutants_requested == 'true' strategy: fail-fast: false matrix: diff --git a/AGENTS.md b/AGENTS.md index 7f8f6b0c..4e4511db 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -387,6 +387,18 @@ demo/smoke_.mjs`. A red smoke that reaches the review costs a cycle; run locally it costs a minute. Precedent: on #89 a fixture error lived through a whole review round that a local run would have caught immediately. +**One handoff, one push (#510).** Run `node scripts/process-gate.mjs --issues` +locally with `gh` available before pushing (without `gh` the hook cannot check the +issue status and stays silent). After `S7-code-review` do not push to the branch +until the verdict or the return arrives: a push on top of a running review cancels +it (10–20 runner minutes) and, after the material is fixed, also the merge (#312). +Set `S7` once per round, not after every CI fix: the pipeline now runs Validate +with the diff mutants on the material itself and returns a red one to `S6` without +spending a review cycle. Mutants by diff no longer run on ordinary pushes — only +on the review candidate, the merge candidate, the beta candidate, PRs and the +nightly run — so a routine push costs ~3 minutes; 08–09.09 they cost 48 of 56 +Validate job-hours and were mostly cancelled by the next push. + The full smoke set, `golden` and `performance_smoke` still belong to the pre-beta run — which is then mandatory and complete. WSL runs of the full HA harness (`~/houseplan-card`, venv) are advisory; **the canon does not move**: diff --git a/PROCESS.md b/PROCESS.md index 19cb7a1d..bc312884 100644 --- a/PROCESS.md +++ b/PROCESS.md @@ -897,6 +897,23 @@ S7-code-review → код-ревью → слияние в dev → S8-merged л 4. многострочный текст внутри `run:` — только через heredoc: строка с нулевым отступом обрывает блок YAML, и скрипт обрезается без ошибки парсера. +**Ревью не начинается на красном коде** (#510). После фиксации материала конвейер +запускает Validate с мутантами по диффу на этом SHA (`scripts/validate-gate.mjs`: +`workflow_dispatch validate.yml -f mutants=true`) и ждёт его до 45 минут. Красный или +пропавший прогон возвращает задачу в `S6-in-progress` с комментарием и ссылкой — +код никто не читал, цикл ревью не израсходован. Мутанты по диффу вообще бегут +только по запросу: на кандидате ревью, кандидате слияния (#492), кандидате беты, +в ночном прогоне и на PR; обычный push обходится дешёвыми гейтами (~3 минуты). +За 08–09.09 мутанты на каждом промежуточном пуше стоили 48 из 56 часов +job-минут Validate и в основном отменялись следующим пушем. + +**Один хендофф — один пуш.** Перед пушем — локальный `node scripts/process-gate.mjs +--issues` при доступном `gh` (хук без `gh` статус issue не проверяет и молчит); +после `S7-code-review` в ветку не пушить, пока не пришёл вердикт или возврат: пуш +поверх идущего ревью отменяет его и стоит 10–20 минут раннера, а после фиксации +материала — ещё и слияние (#312). `S7` ставится один раз на заход, не после +каждого фикса CI: красный Validate конвейер вернёт сам. + **Автор обязан дождаться вердикта, а не заканчивать сессию.** Ревью идёт от десяти минут до сорока пяти. Отчёт «передал на ревью» останавливает конвейер там, где он мог идти сам: вердикт придёт, а подхватить его будет некому. У агента нет часов — @@ -945,8 +962,9 @@ S7-code-review → код-ревью → слияние в dev → S8-merged л - если `dev` не двигался — push с `--force-with-lease` на текущую вершину; - если двигался — ребейз (конфликт — `S6-in-progress`, как раньше), сравнение patch-id проверенного и получившегося диффа (различие — `S7-code-review`: вердикт - к другому диффу не применим, §7.2), публикация кандидата в ветку задачи, ожидание - зелёного Validate **на этом SHA** и только затем push в `dev` с lease на ту + к другому диффу не применим, §7.2), публикация кандидата в ветку задачи, запуск + Validate с мутантами на ней (#510) и ожидание зелёного dispatch-прогона **на этом + SHA** — push-прогон мутантов не несёт — и только затем push в `dev` с lease на ту вершину, поверх которой кандидат собран. Отклонённый lease — `dev` двинулся снова — новая попытка; после третьей — `S6-in-progress` с комментарием; - красный Validate на кандидате или прогон, не появившийся за три минуты, — diff --git a/docs/TESTING.md b/docs/TESTING.md index 9ae5de49..d96565a7 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -46,7 +46,15 @@ GUARD_INPUTS` (умолчание `backend-test-guard.mjs` — Бандл собирается только мутантам с браузерным гвардом; компиляция тестов в worktree стартует с тёплого `test-build/` основного дерева. -В CI `changed_mutants` добавляет `--ledger=<файл>` — журнал пойманных +В CI `changed_mutants` бежит не на каждом пуше, а по запросу (#510): +`workflow_dispatch validate.yml -f mutants=true` (его делают ревью-конвейер на +материале ревью и слияние на кандидате), `full=true` (ночь, кнопка), PR и кандидат +беты (трейлер `Release:`). Обычный push в ветку задачи обходится дешёвыми гейтами: +за 08–09.09 мутанты на промежуточных пушах стоили 48 из 56 часов job-минут и в +основном отменялись следующим пушем. Доказательство мутантов для ревью — именно +dispatch-прогон на точном SHA; зелёный push-прогон им не является. + +`changed_mutants` добавляет `--ledger=<файл>` — журнал пойманных свидетелей (#481): после каждого пойманного мутанта в файл пишется отпечаток его входов (файлы патча, все входы гарда по замыканию выше, объявление мутанта; строка версии продукта нормализована), и мутант с тем же отпечатком в следующем diff --git a/scripts/classify-changes.mjs b/scripts/classify-changes.mjs index 1a16ef6c..a6a18446 100755 --- a/scripts/classify-changes.mjs +++ b/scripts/classify-changes.mjs @@ -94,6 +94,20 @@ export function heavyGatesRequested({ eventName, headMessage, fullInput } = {}) return hasReleaseTrailer(headMessage); } +/** + * Нужны ли мутанты по диффу (#510). За 08–09.09 они съели 86 % job-минут + * Validate, потому что бежали на каждом промежуточном пуше и отменялись + * следующим. Место мутантов — кандидат: ревью-конвейер и слияние кандидата + * запускают Validate по кнопке с `mutants=true`, ночной прогон и PR берут + * полный набор, кандидат беты несёт трейлер `Release:`. Обычный push — нет. + */ +export function mutantsRequested({ eventName, headMessage, fullInput, mutantsInput } = {}) { + if (eventName === 'pull_request') return true; + if (eventName === 'schedule') return true; + if (eventName === 'workflow_dispatch') return String(fullInput) === 'true' || String(mutantsInput) === 'true'; + return hasReleaseTrailer(headMessage); +} + /** Трейлер `Release: vX.Y.Z` в конце сообщения коммита — признак кандидата. */ export function hasReleaseTrailer(message) { return /^Release:\s*v?\d+\.\d+\.\d+\S*\s*$/m.test(String(message || '')); @@ -119,6 +133,13 @@ if (invokedDirectly) { fullInput: process.env.FULL_INPUT, }); process.stdout.write(`heavy=${heavy ? 'true' : 'false'}\n`); + const mutants = mutantsRequested({ + eventName: process.env.EVENT_NAME, + headMessage: process.env.HEAD_MESSAGE, + fullInput: process.env.FULL_INPUT, + mutantsInput: process.env.MUTANTS_INPUT, + }); + process.stdout.write(`mutants_requested=${mutants ? 'true' : 'false'}\n`); } else { const all = process.argv.includes('--all'); const outputs = all ? classifyAll() : classifyChanges(readFileSync(0, 'utf8')); diff --git a/scripts/merge-candidate.mjs b/scripts/merge-candidate.mjs index a1c9f4af..84344867 100755 --- a/scripts/merge-candidate.mjs +++ b/scripts/merge-candidate.mjs @@ -127,12 +127,20 @@ export function realOps({ repo, token, workflow = 'validate.yml', sleep = (ms) = if (/stale info|rejected|fetch first|lease/i.test(r.stderr)) return false; throw new Error(`git push ${ref}: ${r.stderr}`); }, - waitValidate: async (sha) => { + // Мутанты по диффу бегут только по запросу (#510): кандидат после ребейза — + // новое дерево, поэтому слияние запускает Validate с мутантами само и ждёт + // именно этот dispatch-прогон; push-прогон на том же SHA их не содержит. + dispatchValidate: (ref) => { + const r = sh('gh', ['workflow', 'run', workflow, '--repo', repo, '--ref', ref, '-f', 'full=false', '-f', 'mutants=true']); + if (r.status !== 0) throw new Error(`gh workflow run ${workflow}: ${r.stderr || r.stdout}`); + }, + waitValidate: async (sha, { event = 'workflow_dispatch' } = {}) => { const started = now(); let runId = null; while (now() - started < VALIDATE_TOTAL_MS) { - const r = sh('gh', ['run', 'list', '--repo', repo, '--workflow', workflow, '--commit', sha, '--json', 'databaseId,status,conclusion,url', '--limit', '5']); - const runs = r.status === 0 && r.stdout ? JSON.parse(r.stdout) : []; + const r = sh('gh', ['run', 'list', '--repo', repo, '--workflow', workflow, '--commit', sha, '--json', 'databaseId,status,conclusion,url,event', '--limit', '10']); + const all = r.status === 0 && r.stdout ? JSON.parse(r.stdout) : []; + const runs = all.filter((x) => !event || x.event === event); const run = runs.find((x) => x.databaseId === runId) || runs[0]; if (run) { runId = run.databaseId; @@ -200,8 +208,10 @@ export async function mergeCandidate({ branch, material, issue, ops, maxAttempts tip = candidate; if (!patchIdEqual) return finish(decideMerge({ fresh: true, devMoved: true, patchIdEqual: false }), { candidate, devNow }); - ops.log(`Validate на кандидате ${candidate.slice(0, 8)} — ждём`); - const { result, url } = await ops.waitValidate(candidate); + // мутанты по диффу — по запросу (#510): dispatch на ветке, где теперь стоит кандидат + ops.dispatchValidate(branch); + ops.log(`Validate с мутантами на кандидате ${candidate.slice(0, 8)} — ждём`); + const { result, url } = await ops.waitValidate(candidate, { event: 'workflow_dispatch' }); let decision = decideMerge({ fresh: true, devMoved: true, patchIdEqual: true, validate: result, attempt, maxAttempts }); if (decision.action !== 'push') return finish(decision, { candidate, devNow, runUrl: url }); diff --git a/scripts/mutation-gate.mjs b/scripts/mutation-gate.mjs index 229592a4..65748926 100644 --- a/scripts/mutation-gate.mjs +++ b/scripts/mutation-gate.mjs @@ -3694,7 +3694,7 @@ const MUTANT_DEFINITIONS = [ + 'it without a green Validate on that SHA is the false-green the audit reproduced (#492 §4)', patches: [{ file: 'scripts/merge-candidate.mjs', - find: ' const { result, url } = await ops.waitValidate(candidate);', + find: " const { result, url } = await ops.waitValidate(candidate, { event: 'workflow_dispatch' });", replace: " const { result, url } = { result: 'green', url: 'skipped' }; // mutant: no validation", }], }, @@ -8125,6 +8125,50 @@ const MUTANT_DEFINITIONS = [ replace: ' // mutant: the warm factory is deferred like a cold import\n', }], }, + { + id: 'mutants-run-on-every-push', + guard: 'node --test --test-name-pattern="#510" test/classify-changes.test.mjs', + because: 'mutants by diff belong to the review candidate, the PR, the nightly run and the beta ' + + 'candidate — an ordinary push must not spend 3×8 runner minutes on them (#510 AC1)', + patches: [{ + file: 'scripts/classify-changes.mjs', + find: " if (eventName === 'workflow_dispatch') return String(fullInput) === 'true' || String(mutantsInput) === 'true';\n return hasReleaseTrailer(headMessage);\n}", + replace: " if (eventName === 'workflow_dispatch') return String(fullInput) === 'true' || String(mutantsInput) === 'true';\n return true; // mutant: every push\n}", + }], + }, + { + id: 'review-starts-on-red-validate', + guard: 'node --test test/validate-gate.test.mjs', + because: 'a red dispatch run on the material must return the task without a review; treating ' + + 'any completed run as green spends the review cycle on code CI already rejected (#510 AC2)', + patches: [{ + file: 'scripts/validate-gate.mjs', + find: " result: run.conclusion === 'success' ? 'green' : 'red',", + replace: " result: 'green', // mutant: completed means green", + }], + }, + { + id: 'review-trusts-push-run-without-mutants', + guard: 'node --test test/validate-gate.test.mjs', + because: 'a green push run on the same SHA holds no mutants and is not proof; the gate must ' + + 'dispatch the mutant run instead of accepting it (#510 AC2)', + patches: [{ + file: 'scripts/validate-gate.mjs', + find: " return run?.event === 'workflow_dispatch';", + replace: " return !!run; // mutant: any run counts", + }], + }, + { + id: 'merge-waits-push-run-without-mutants', + guard: 'node --test test/merge-candidate.test.mjs', + because: 'the merged candidate is a new tree; the merge must dispatch the mutant run on it and ' + + 'wait for that run, not for the push run that carries no mutants (#510 AC3)', + patches: [{ + file: 'scripts/merge-candidate.mjs', + find: " ops.dispatchValidate(branch);\n ops.log(`Validate с мутантами на кандидате ${candidate.slice(0, 8)} — ждём`);\n const { result, url } = await ops.waitValidate(candidate, { event: 'workflow_dispatch' });", + replace: " ops.log(`Validate на кандидате ${candidate.slice(0, 8)} — ждём`);\n const { result, url } = await ops.waitValidate(candidate); // mutant: push run, no dispatch", + }], + }, { id: 'quota-counts-the-staged-upload-twice', guard: 'node scripts/backend-test-guard.mjs ' diff --git a/scripts/validate-gate.mjs b/scripts/validate-gate.mjs new file mode 100755 index 00000000..34a58c55 --- /dev/null +++ b/scripts/validate-gate.mjs @@ -0,0 +1,120 @@ +#!/usr/bin/env node +/** + * Validate с мутантами на материале ревью — до того, как ревьюер потратит + * цикл (#510 §5). + * + * Мутанты по диффу бегут только по запросу (`validate.yml`, `mutants=true`), и + * доказательство для ревью — dispatch-прогон на точном SHA материала. Push- + * прогон на том же SHA зелёный не считается: в нём мутантов нет. + * + * node scripts/validate-gate.mjs --repo= --ref=<ветка> --sha= [--workflow=validate.yml] + * + * Печатает `result=green|red|missing` и `url=…` (и в $GITHUB_OUTPUT, если он + * задан); код выхода 0 только при green. Логика — чистая функция `validateGate` + * поверх инъектируемых `ops`, чтобы тесты и мутанты гоняли её без gh. + */ +import { spawnSync } from 'node:child_process'; +import { appendFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { resolve } from 'node:path'; +import { VALIDATE_APPEAR_MS, VALIDATE_TOTAL_MS } from './merge-candidate.mjs'; + +export const POLL_MS = 20_000; + +/** Кандидат в доказательства: dispatch — только там мутанты могут быть запрошены. */ +export function isMutantRun(run) { + return run?.event === 'workflow_dispatch'; +} + +export const MUTANT_JOB_PREFIX = 'Мутанты по диффу'; + +/** + * Зелёный dispatch доказывает мутанты, только если их job реально исполнены + * (ревью ТЗ r1): чужой dispatch с `mutants=false` на том же SHA тоже зелёный, + * но с `changed_mutants: skipped`. `validate.yml` при запросе исполняет job + * даже на пустом отборе, поэтому skipped однозначно значит «не запрашивали». + */ +export function provesMutants(jobs) { + const mutantJobs = (Array.isArray(jobs) ? jobs : []).filter((job) => String(job?.name || '').startsWith(MUTANT_JOB_PREFIX)); + return mutantJobs.length > 0 && mutantJobs.every((job) => job.conclusion === 'success'); +} + +/** + * @param {object} p + * @param {string} p.ref ветка, на которой запускать + * @param {string} p.sha SHA материала + * @param {object} p.ops { listRuns(sha) → [{databaseId,status,conclusion,url,event,headSha}], listRunsOnRef(ref) → те же, jobs(runId) → [{name,conclusion}], dispatch(ref), sleep(ms), now() } + * @returns {Promise<{result:'green'|'red'|'missing', url:string|null, note:string}>} + */ +export async function validateGate({ ref, sha, ops, appearMs = VALIDATE_APPEAR_MS, totalMs = VALIDATE_TOTAL_MS, pollMs = POLL_MS }) { + const started = ops.now(); + const ignored = new Set(); // завершённые зелёные dispatch без исполненных мутантов + let tracked = null; + let dispatchedAt = null; + while (ops.now() - started < totalMs) { + const runs = (await ops.listRuns(sha)).filter((x) => isMutantRun(x) && !ignored.has(x.databaseId)); + const run = runs.find((x) => tracked && x.databaseId === tracked) || runs[0]; + if (run) { + tracked = run.databaseId; + if (run.status === 'completed') { + if (run.conclusion !== 'success') return { result: 'red', url: run.url, note: `dispatch-прогон завершился: ${run.conclusion}` }; + if (provesMutants(await ops.jobs(run.databaseId))) return { result: 'green', url: run.url, note: 'dispatch-прогон с исполненными мутантами зелёный' }; + // зелёный, но мутанты не исполнялись (чужой dispatch без mutants=true) — не доказательство + ignored.add(run.databaseId); + tracked = null; + continue; + } + } else if (dispatchedAt === null) { + await ops.dispatch(ref); + dispatchedAt = ops.now(); + } else if (ops.now() - dispatchedAt > appearMs) { + // Прогон должен был появиться. Если на ветке появился dispatch на другом + // SHA — материал сменился под ногами; иначе запуск просто не прошёл. + const elsewhere = (await ops.listRunsOnRef(ref)).filter(isMutantRun).find((x) => x.headSha && x.headSha !== sha); + return { + result: 'missing', url: elsewhere?.url || null, + note: elsewhere ? `материал сменился: dispatch-прогон стоит на ${String(elsewhere.headSha).slice(0, 8)}` : 'dispatch-прогон не появился за 3 минуты', + }; + } + await ops.sleep(pollMs); + } + return { result: 'red', url: null, note: 'Validate с мутантами не завершился за 45 минут' }; +} + +const sh = (cmd, args) => spawnSync(cmd, args, { encoding: 'utf8' }); + +export function realOps({ repo, workflow = 'validate.yml' }) { + const fields = 'databaseId,status,conclusion,url,event,headSha'; + const parse = (r) => (r.status === 0 && r.stdout ? JSON.parse(r.stdout) : []); + return { + listRuns: async (sha) => parse(sh('gh', ['run', 'list', '--repo', repo, '--workflow', workflow, '--commit', sha, '--json', fields, '--limit', '20'])), + jobs: async (runId) => { + const r = sh('gh', ['run', 'view', String(runId), '--repo', repo, '--json', 'jobs']); + return r.status === 0 && r.stdout ? (JSON.parse(r.stdout).jobs || []).map((job) => ({ name: job.name, conclusion: job.conclusion })) : []; + }, + listRunsOnRef: async (ref) => parse(sh('gh', ['run', 'list', '--repo', repo, '--workflow', workflow, '--branch', ref, '--event', 'workflow_dispatch', '--json', fields, '--limit', '5'])), + dispatch: async (ref) => { + const r = sh('gh', ['workflow', 'run', workflow, '--repo', repo, '--ref', ref, '-f', 'full=false', '-f', 'mutants=true']); + if (r.status !== 0) throw new Error(`gh workflow run: ${r.stderr || r.stdout}`); + }, + sleep: (ms) => new Promise((done) => setTimeout(done, ms)), + now: () => Date.now(), + }; +} + +const invokedDirectly = process.argv[1] && resolve(process.argv[1]) === resolve(fileURLToPath(import.meta.url)); +if (invokedDirectly) { + const arg = (name) => process.argv.find((a) => a.startsWith(`--${name}=`))?.slice(name.length + 3); + const repo = arg('repo') || process.env.GITHUB_REPOSITORY; + const ref = arg('ref'); + const sha = arg('sha'); + if (!repo || !ref || !sha) { + console.error('usage: validate-gate.mjs --repo= --ref= --sha= [--workflow=validate.yml]'); + process.exit(2); + } + const outcome = await validateGate({ ref, sha, ops: realOps({ repo, workflow: arg('workflow') || 'validate.yml' }) }); + const lines = [`result=${outcome.result}`, `url=${outcome.url || ''}`, `note=${outcome.note}`]; + for (const line of lines) console.log(line); + if (process.env.GITHUB_OUTPUT) appendFileSync(process.env.GITHUB_OUTPUT, `${lines.join('\n')}\n`); + process.exit(outcome.result === 'green' ? 0 : 1); +} diff --git a/test/classify-changes.test.mjs b/test/classify-changes.test.mjs index 3a61a9a7..63e4b932 100644 --- a/test/classify-changes.test.mjs +++ b/test/classify-changes.test.mjs @@ -2,7 +2,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { execFileSync } from 'node:child_process'; import { - CHECK_OF_OUTPUT, CLASSIFIERS, OUTPUTS, PERF_PROFILES, classifyAll, classifyChanges, formatOutputs, + CHECK_OF_OUTPUT, CLASSIFIERS, OUTPUTS, PERF_PROFILES, classifyAll, classifyChanges, formatOutputs, mutantsRequested, } from '../scripts/classify-changes.mjs'; import { manifest } from '../scripts/check-inputs.mjs'; import { fileURLToPath } from 'node:url'; @@ -160,7 +160,20 @@ test('CLI --heavy читает событие и сообщение из окр const run = (env) => execFileSync(process.execPath, ['scripts/classify-changes.mjs', '--heavy'], { encoding: 'utf8', env: { ...process.env, ...env }, }).trim(); - assert.equal(run({ EVENT_NAME: 'push', HEAD_MESSAGE: 'fix: x\n\nIssue: #1\nUser-Visible: no' }), 'heavy=false'); - assert.equal(run({ EVENT_NAME: 'push', HEAD_MESSAGE: 'x\n\nRelease: v1.2.3' }), 'heavy=true'); - assert.equal(run({ EVENT_NAME: 'workflow_dispatch', FULL_INPUT: 'true', HEAD_MESSAGE: '' }), 'heavy=true'); + assert.equal(run({ EVENT_NAME: 'push', HEAD_MESSAGE: 'fix: x\n\nIssue: #1\nUser-Visible: no' }), 'heavy=false\nmutants_requested=false'); + assert.equal(run({ EVENT_NAME: 'push', HEAD_MESSAGE: 'x\n\nRelease: v1.2.3' }), 'heavy=true\nmutants_requested=true'); + assert.equal(run({ EVENT_NAME: 'workflow_dispatch', FULL_INPUT: 'true', HEAD_MESSAGE: '' }), 'heavy=true\nmutants_requested=true'); + // #510: мутанты по кнопке без полного набора — вызов конвейера ревью и слияния + assert.equal(run({ EVENT_NAME: 'workflow_dispatch', FULL_INPUT: 'false', MUTANTS_INPUT: 'true', HEAD_MESSAGE: '' }), 'heavy=false\nmutants_requested=true'); +}); + +test('#510 AC1: мутанты по диффу запрашиваются кандидатом, PR, ночью и по кнопке — не обычным пушем', () => { + const t = (env) => mutantsRequested(env); + assert.equal(t({ eventName: 'push', headMessage: 'fix: x\n\nIssue: #1\nUser-Visible: no' }), false, 'обычный push'); + assert.equal(t({ eventName: 'push', headMessage: 'x\n\nRelease: v1.2.3' }), true, 'кандидат беты'); + assert.equal(t({ eventName: 'pull_request' }), true); + assert.equal(t({ eventName: 'schedule' }), true); + assert.equal(t({ eventName: 'workflow_dispatch', fullInput: 'true' }), true); + assert.equal(t({ eventName: 'workflow_dispatch', fullInput: 'false', mutantsInput: 'true' }), true); + assert.equal(t({ eventName: 'workflow_dispatch', fullInput: 'false', mutantsInput: 'false' }), false, 'кнопка без запроса'); }); diff --git a/test/merge-candidate.test.mjs b/test/merge-candidate.test.mjs index abe0e8f4..f34ed895 100755 --- a/test/merge-candidate.test.mjs +++ b/test/merge-candidate.test.mjs @@ -79,8 +79,9 @@ function fakeOps({ base = 'dev0', devTips = ['dev0'], validate = [], leaseReject if (ref === 'dev' && rejects > 0) { rejects -= 1; devIndex += 1; return false; } return true; }, - waitValidate: async (sha) => { - calls.push(['validate', sha]); + dispatchValidate: (ref) => { calls.push(['dispatch', ref]); }, + waitValidate: async (sha, options) => { + calls.push(['validate', sha, options?.event]); const result = validate[Math.min(validateIndex, validate.length - 1)] || 'green'; validateIndex += 1; return { result, url: `https://run/${sha}` }; @@ -108,8 +109,12 @@ test('эксперимент аудита: dev двигался, ребейз ч const validateAt = order.indexOf('validate'); const devPushAt = ops.calls.findIndex((c) => c[0] === 'push' && c[2] === 'dev'); assert.ok(validateAt >= 0 && validateAt < devPushAt, `Validate (${validateAt}) раньше push в dev (${devPushAt})`); - // кандидат сначала опубликован в ветку (от этого push стартует Validate) + // кандидат сначала опубликован в ветку, затем на ней запрошен Validate с мутантами (#510) assert.deepEqual(ops.calls.find((c) => c[0] === 'push'), ['push', 'cand-mat-on-dev1', 'issue/1-x', 'mat']); + const dispatchAt = order.indexOf('dispatch'); + assert.ok(dispatchAt > order.indexOf('push') && dispatchAt < validateAt, 'dispatch после пуша кандидата и до ожидания'); + assert.deepEqual(ops.calls[dispatchAt], ['dispatch', 'issue/1-x']); + assert.deepEqual(ops.calls[validateAt], ['validate', 'cand-mat-on-dev1', 'workflow_dispatch'], 'ждём именно dispatch-прогон, не push'); assert.deepEqual(ops.calls.find((c) => c[0] === 'push' && c[2] === 'dev'), ['push', 'cand-mat-on-dev1', 'dev', 'dev1']); }); @@ -198,6 +203,7 @@ test('на настоящем git: чистый ребейз с равным pat const r = spawnSync('git', ['-C', work, 'push', '-q', `--force-with-lease=refs/heads/${ref}:${expected}`, 'origin', `${sha}:refs/heads/${ref}`], { encoding: 'utf8' }); return r.status === 0; }; + ops.dispatchValidate = (ref) => { calls.push(['dispatch', ref]); }; ops.waitValidate = async (sha) => { calls.push(['validate', sha]); return { result: 'green', url: 'https://run/1' }; }; ops.comment = (issue, body) => { calls.push(['comment', body.slice(0, 40)]); }; ops.log = () => {}; diff --git a/test/review-doc-guard.test.mjs b/test/review-doc-guard.test.mjs index fb948fc5..7ff0a10d 100644 --- a/test/review-doc-guard.test.mjs +++ b/test/review-doc-guard.test.mjs @@ -601,3 +601,35 @@ test('конвейер: зелёный вердикт применяется п // Ревьюер привязан к SHA материала — сам подтягивать новее не должен. assert.match(workflow, /Материал ревью — ровно\s+`\$\{\{ steps\.material\.outputs\.sha \}\}`/); }); + +test('#510 AC2: конвейер запускает Validate с мутантами на материале и не ревьюит красный', () => { + const workflow = readFileSync(new URL('../.github/workflows/process.yml', import.meta.url), 'utf8'); + const at = (marker) => { const i = workflow.indexOf(marker); assert.ok(i > 0, `нет «${marker}»`); return i; }; + const material = at(' - name: Зафиксировать SHA материала ревью\n'); + const reuse = at(' - name: "Зелёный вердикт прошлого захода применим без ревью (#499)"\n'); + const gate = at(' - name: Validate с мутантами на материале\n'); + assert.ok(material < reuse && reuse < gate, 'gate читает steps.reuse.outputs — стоит после шага reuse (ревью ТЗ r1)'); + const back = at(' - name: Validate красный — вернуть автору без ревью\n'); + const deps = at(' - name: Установить зависимости\n'); + const review = at(' - name: Review\n'); + assert.ok(material < gate && gate < back && back < deps && deps < review, 'гейт стоит после фиксации материала и до установки зависимостей/ревью'); + const gateStep = workflow.slice(gate, back); + assert.match(gateStep, /node scripts\/validate-gate\.mjs --repo="\$\{\{ github\.repository \}\}" --ref="\$BRANCH" --sha="\$SHA"/); + assert.match(gateStep, /if \[ "\$STAGE" != "code" \] \|\| \[ "\$REUSE" = "true" \]/, 'этап spec и reuse гейт не проходят'); + assert.match(gateStep, /SHA: \$\{\{ steps\.material\.outputs\.sha \}\}/, 'проверяется именно материал'); + // skip-ветка (spec/reuse) даёт proceed=true: ревью идёт, возврата S7→S6 нет (ревью ТЗ r2) + assert.match(gateStep, /\{ echo 'proceed=true'; echo 'result=skipped'; \}/, 'skipped = proceed'); + assert.doesNotMatch(workflow.slice(back), /if:[^\n]*steps\.gate\.outputs\.result/, 'условия шагов — только по proceed, result идёт в текст комментария'); + const backStep = workflow.slice(back, deps); + assert.match(backStep, /if: steps\.rebase\.outputs\.conflict != 'true' && steps\.gate\.outputs\.proceed != 'true'/); + assert.match(backStep, /--add-label S6-in-progress --remove-label S7-code-review/); + assert.match(backStep, /цикл ревью не израсходован/); + // всё, что после гейта, условно по proceed — включая перестановку метки и слияние + const after = workflow.slice(deps); + assert.doesNotMatch(after, /if: steps\.rebase\.outputs\.conflict != 'true'/, 'после гейта нет шагов, условных только по конфликту'); + for (const name of ['Установить зависимости', 'Review', 'Решение по вердикту', 'Слить ветку в dev', 'Переставить метку']) { + const i = at(` - name: ${name}\n`); + const chunk = workflow.slice(i, i + 400); + assert.match(chunk, /if: (needs\.guard\.outputs\.stage == 'code' && )?steps\.(gate\.outputs\.proceed == 'true'|decide\.outputs\.green == 'true')/, `${name}: условие по proceed/зелёному`); + } +}); diff --git a/test/validate-gate.test.mjs b/test/validate-gate.test.mjs new file mode 100755 index 00000000..3789b58c --- /dev/null +++ b/test/validate-gate.test.mjs @@ -0,0 +1,107 @@ +// #510 §5: the review pipeline proves mutants on the material before spending a review cycle. +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { validateGate, isMutantRun, provesMutants } from '../scripts/validate-gate.mjs'; + +const SHA = 'a'.repeat(40); + +/** Fake gh: a scripted list of run snapshots per call, a virtual clock. */ +const MUTANT_JOBS = [1, 2, 3].map((n) => ({ name: `Мутанты по диффу (${n}/3): затронутые свидетели краснеют`, conclusion: 'success' })); +const OTHER_JOBS = [{ name: 'Фронтенд: типы, юниты, мутанты, синхрон бандла', conclusion: 'success' }]; + +function fakeOps({ snapshots, onRef = [], jobsById = {} }) { + let clock = 0; + let calls = 0; + const dispatched = []; + return { + ops: { + listRuns: async () => { const s = snapshots[Math.min(calls, snapshots.length - 1)]; calls += 1; return s; }, + listRunsOnRef: async () => onRef, + jobs: async (id) => jobsById[id] ?? [...OTHER_JOBS, ...MUTANT_JOBS], + dispatch: async (ref) => { dispatched.push(ref); }, + sleep: async (ms) => { clock += ms; }, + now: () => clock, + }, + dispatched, + calls: () => calls, + }; +} + +const run = (over) => ({ databaseId: 1, status: 'completed', conclusion: 'success', url: 'https://run/1', event: 'workflow_dispatch', headSha: SHA, ...over }); + +test('#510: only a dispatch run proves mutants; a push run on the same SHA does not', () => { + assert.equal(isMutantRun(run()), true); + assert.equal(isMutantRun(run({ event: 'push' })), false); +}); + +test('#510 (ревью ТЗ r1): proof needs the mutant jobs executed and green, not just a green run', () => { + assert.equal(provesMutants([...OTHER_JOBS, ...MUTANT_JOBS]), true); + assert.equal(provesMutants(OTHER_JOBS), false, 'no mutant job at all — mutants were not requested'); + assert.equal(provesMutants([...OTHER_JOBS, ...MUTANT_JOBS.map((job) => ({ ...job, conclusion: 'skipped' }))]), false, 'skipped is not executed'); + assert.equal(provesMutants([...MUTANT_JOBS.slice(0, 2), { ...MUTANT_JOBS[2], conclusion: 'failure' }]), false); + assert.equal(provesMutants([]), false); +}); + +test('#510 (ревью ТЗ r1): a green foreign dispatch whose mutant jobs were skipped is ignored — the gate dispatches its own', async () => { + const foreign = run({ databaseId: 5, url: 'https://run/foreign' }); + const own = run({ databaseId: 6, url: 'https://run/own' }); + const fake = fakeOps({ + snapshots: [[foreign], [foreign], [own, foreign], [own, foreign]], + jobsById: { 5: [...OTHER_JOBS, ...MUTANT_JOBS.map((job) => ({ ...job, conclusion: 'skipped' }))] }, + }); + const outcome = await validateGate({ ref: 'issue/1', sha: SHA, ops: fake.ops, pollMs: 1000 }); + assert.equal(outcome.result, 'green'); + assert.equal(outcome.url, 'https://run/own'); + assert.deepEqual(fake.dispatched, ['issue/1']); +}); + +test('#510 AC2: a completed green dispatch run on the material is accepted without a new dispatch', async () => { + const fake = fakeOps({ snapshots: [[run({ event: 'push', databaseId: 7 }), run()]] }); + const outcome = await validateGate({ ref: 'issue/1', sha: SHA, ops: fake.ops }); + assert.equal(outcome.result, 'green'); + assert.equal(outcome.url, 'https://run/1'); + assert.deepEqual(fake.dispatched, []); +}); + +test('#510 AC2: a completed red dispatch run returns the task without review', async () => { + const fake = fakeOps({ snapshots: [[run({ conclusion: 'failure', url: 'https://run/red' })]] }); + const outcome = await validateGate({ ref: 'issue/1', sha: SHA, ops: fake.ops }); + assert.equal(outcome.result, 'red'); + assert.equal(outcome.url, 'https://run/red'); +}); + +test('#510 AC2: a green push run alone is not proof — the gate dispatches and waits', async () => { + const pushOnly = [run({ event: 'push', databaseId: 7 })]; + const fake = fakeOps({ snapshots: [pushOnly, pushOnly, [...pushOnly, run({ status: 'in_progress', conclusion: null })], [...pushOnly, run()]] }); + const outcome = await validateGate({ ref: 'issue/1', sha: SHA, ops: fake.ops, pollMs: 1000 }); + assert.equal(outcome.result, 'green'); + assert.deepEqual(fake.dispatched, ['issue/1'], 'exactly one dispatch on the branch'); +}); + +test('#510 AC2: the dispatch that never appears is reported as missing, naming a moved material', async () => { + const fake = fakeOps({ snapshots: [[]], onRef: [run({ headSha: 'b'.repeat(40), url: 'https://run/other' })] }); + const outcome = await validateGate({ ref: 'issue/1', sha: SHA, ops: fake.ops, appearMs: 5000, pollMs: 1000 }); + assert.equal(outcome.result, 'missing'); + assert.equal(outcome.url, 'https://run/other'); + assert.match(outcome.note, /материал сменился: dispatch-прогон стоит на bbbbbbbb/); + assert.deepEqual(fake.dispatched, ['issue/1']); +}); + +test('#510 AC2: a dispatch that never finishes is red after the total window', async () => { + const running = [run({ status: 'in_progress', conclusion: null })]; + const fake = fakeOps({ snapshots: [running] }); + const outcome = await validateGate({ ref: 'issue/1', sha: SHA, ops: fake.ops, totalMs: 10_000, pollMs: 4000 }); + assert.equal(outcome.result, 'red'); + assert.match(outcome.note, /не завершился/); + assert.deepEqual(fake.dispatched, []); +}); + +test('#510: the tracked dispatch run is followed even when a newer dispatch appears first in the list', async () => { + const first = run({ databaseId: 1, status: 'in_progress', conclusion: null }); + const newer = run({ databaseId: 2, status: 'in_progress', conclusion: null, url: 'https://run/2' }); + const fake = fakeOps({ snapshots: [[first], [newer, first], [newer, run({ databaseId: 1, url: 'https://run/1' })]] }); + const outcome = await validateGate({ ref: 'issue/1', sha: SHA, ops: fake.ops, pollMs: 1000 }); + assert.equal(outcome.result, 'green'); + assert.equal(outcome.url, 'https://run/1'); +}); diff --git a/test/validate-workflow.test.mjs b/test/validate-workflow.test.mjs index 3e51b5f7..d2891ab1 100644 --- a/test/validate-workflow.test.mjs +++ b/test/validate-workflow.test.mjs @@ -366,14 +366,21 @@ test('релизные гейты требуют трейлер Release: и св assert.match(read('publish-prerelease.yml'), /check-docs\.mjs --screenshots=strict/); }); -test('мутанты по диффу гоняются на каждом пуше с базы диапазона (#475 AC4)', () => { +test('мутанты по диффу гоняются по запросу с базы диапазона (#475 AC4, #510 AC1)', () => { const workflow = read('validate.yml'); const start = workflow.indexOf('\n changed_mutants:\n'); assert.ok(start > 0, 'нет job changed_mutants'); const job = workflow.slice(start, workflow.indexOf('\n frontend:\n', start)); // Триггер — и фронтенд, и бэкенд: бэкенд-мутанты патчат .py и охраняются // pytest, а дифф только по ним даёт backend=true без frontend=true (ревью r1). - assert.match(job, /if: needs\.changes\.outputs\.frontend == 'true' \|\| needs\.changes\.outputs\.backend == 'true' \|\| needs\.changes\.outputs\.mutants == 'true'/); + // #510: job идёт ровно тогда, когда мутанты запрошены — dispatch mutants/full, + // PR, ночь, кандидат беты; обычный push обходится дешёвыми гейтами. Отбор по + // файлам живёт внутри job (`--changed`): при запросе она обязана исполниться, + // иначе гейт ревью не отличит «нечего гонять» от «не запрашивали» (ревью ТЗ r1). + assert.match(job, /\n if: needs\.changes\.outputs\.mutants_requested == 'true'\n/); + assert.match(workflow, /mutants_requested: \$\{\{ steps\.heavy\.outputs\.mutants_requested \}\}/); + assert.match(workflow, /MUTANTS_INPUT: \$\{\{ inputs\.mutants \}\}/); + assert.match(workflow, /\n mutants:\n description: [^\n]*\n type: boolean\n default: false\n/, 'вход workflow_dispatch mutants, по умолчанию выключен'); // Третий дизъюнкт (ТЗ §2, ревью r1): правка одного реестра мутантов — тоже // вход гейта, классификатор обязан выдавать `mutants` по этому файлу. assert.match(workflow, /mutants: \$\{\{ steps\.classify\.outputs\.mutants \}\}/);