mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
ci: diff mutants only on request; the review pipeline proves them on the material before reviewing
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
This commit is contained in:
@@ -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 <<EOF
|
||||
**Ревью не запускалось:** Validate с мутантами на материале \`$short\` (ветка \`$BRANCH\`) — **$RESULT**: $NOTE.${URL:+ [Прогон]($URL).} Код никто не читал, вердикта нет, цикл ревью не израсходован.
|
||||
|
||||
Гейт стоит до ревью намеренно (#510): красный CI всё равно вернул бы задачу, но уже после потраченного ревью.
|
||||
|
||||
Задача переведена в \`S6-in-progress\`. Осталось:
|
||||
|
||||
1. починить то, что назвал прогон, и запушить ветку **одним** коммитом-заходом;
|
||||
2. дождаться зелёного дешёвого Validate на пуше;
|
||||
3. вернуть метку \`S7-code-review\` — конвейер сам запустит Validate с мутантами и ревью.
|
||||
|
||||
[Прогон конвейера](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}).
|
||||
EOF
|
||||
gh issue comment "$NUM" --repo "${{ github.repository }}" --body-file /tmp/gate.md
|
||||
gh issue edit "$NUM" --repo "${{ github.repository }}" \
|
||||
--add-label S6-in-progress --remove-label S7-code-review
|
||||
echo "S7-code-review -> 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 }}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -387,6 +387,18 @@ demo/smoke_<name>.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**:
|
||||
|
||||
+20
-2
@@ -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 на кандидате или прогон, не появившийся за три минуты, —
|
||||
|
||||
+9
-1
@@ -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): после каждого пойманного мутанта в файл пишется отпечаток
|
||||
его входов (файлы патча, все входы гарда по замыканию выше, объявление мутанта;
|
||||
строка версии продукта нормализована), и мутант с тем же отпечатком в следующем
|
||||
|
||||
@@ -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'));
|
||||
|
||||
@@ -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 });
|
||||
|
||||
|
||||
@@ -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 '
|
||||
|
||||
Executable
+120
@@ -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=<owner/repo> --ref=<ветка> --sha=<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=<owner/repo> --ref=<branch> --sha=<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);
|
||||
}
|
||||
@@ -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, 'кнопка без запроса');
|
||||
});
|
||||
|
||||
@@ -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 = () => {};
|
||||
|
||||
@@ -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/зелёному`);
|
||||
}
|
||||
});
|
||||
|
||||
Executable
+107
@@ -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');
|
||||
});
|
||||
@@ -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 \}\}/);
|
||||
|
||||
Reference in New Issue
Block a user