Files
houseplan-card/.github/workflows/mutation-gate.yml
T
Claudeandclaude[bot] 44ee23ee33 ci(mutants): skip the nightly registry on a proven tree; install only the shard's environment
#620. Mutants were ~70 % of CI machine time.

1. mutation-gate.yml: a green full run (aggregator verified all six shards)
   leaves a cache marker keyed by material tree + workflow SHA. A scheduled
   night with the same tree and workflow, marker not older than 7 days, skips
   the shards and writes "reused from run N" to the run summary. Red runs
   leave no marker, so the next night runs again and still files the issue
   (#472). Manual dispatch always runs the full registry. Decision is a pure
   function in scripts/mutation-nightly-reuse.mjs.
2. changed_mutants: the shard plan (already computed before setup, #518) now
   names the environment of its guards (plan-browser=/plan-python=, from
   scripts/mutation-environment.mjs). Python + backend deps only for shards
   with pytest guards, Chromium only for shards whose guards reach
   Playwright; pip wheels cached. Every shard still runs and reports, so the
   six mutant jobs of the review proof are unchanged.

Item 3 of the issue (smoke guards -> node --test) is out of scope here.

Issue: #620
User-Visible: no
2026-09-24 05:48:42 +00:00

358 lines
20 KiB
YAML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
name: Мутационный гейт
# Реестр известных поломок (issue #85): каждый мутант ломает продуктовый код
# известным способом, и объявленный тест ОБЯЗАН на этом покраснеть. Тест,
# оставшийся зелёным на сломанном коде, ничего не защищает — он лишь выглядит
# защитой, и это хуже его отсутствия.
#
# Прогон дорогой и проверяет не продукт, а тесты, поэтому он не входит ни в
# Validate, ни в цикл разработки, ни в релизный гейт (#513, решение владельца
# 09.09): каждую ночь по расписанию, отказ — issue с отчётом (#472).
# Мутанты, задетые диффом, конвейер ревью и слияние гоняют отдельно на своём
# кандидате (#510); кандидат беты, `full=true` и ночной Validate их не
# запрашивают (#601) — ночью достаточно этого полного реестра.
# Дешёвая половина — «якоря патчей живы, guard-файлы существуют» — идёт с
# обычными юнитами: test/mutation-gate.test.mjs.
#
# #332: бандл собирается только мутантам с браузерным гвардом (guardNeedsBundle),
# компиляция тестов в worktree стартует с тёплого test-build (инкрементальный
# tsc), а реестр режется на четыре чересполосных шарда — полный прогон
# укладывается в десятки минут вместо часов. Локальный дифф-режим:
# node scripts/mutation-gate.mjs --changed origin/dev..HEAD
#
# #620: ночь по расписанию на дереве, уже доказанном зелёным полным прогоном
# (тот же tree материала, тот же SHA workflow, маркер не старше недели), шарды не
# гоняет — в сводке «reused from run N». Маркер пишет только зелёный агрегатор,
# поэтому красный не переносится: следующая ночь гонит реестр заново и снова
# заводит issue (#472). Ручной dispatch гонит полный реестр всегда. Решение —
# чистая функция scripts/mutation-nightly-reuse.mjs.
on:
workflow_dispatch:
inputs:
ref:
description: Git ref whose mutation guards must be proved
required: false
default: dev
schedule:
# Каждую ночь, 01:00 UTC (04:00 MSK) — после суток правок и до ночного
# полного Validate (nightly.yml, 02:30 UTC), чтобы не делить раннеры (#513).
- cron: '0 1 * * *'
permissions:
contents: read
# Группа зависит от события (#472). Прежде она была одна на всё, и ручной
# запуск (отладка гейта) отменял идущий по расписанию — так 24.08 погиб
# еженедельный прогон, а отменённый в списке выглядит «не красным». Поймать
# отмену изнутри нельзя: вместе с прогоном отменяются и не начавшиеся job,
# включая любой репортёр. Значит отмену надо не ловить, а не допускать.
concurrency:
group: mutation-gate-${{ github.event_name }}
cancel-in-progress: true
jobs:
# #549: moving ref разрешается ровно один раз. Все шарды ниже получают один
# commit/tree, а не самостоятельно читают dev в разное время.
material:
name: "Зафиксировать неизменяемый материал"
runs-on: ubuntu-latest
outputs:
sha: ${{ steps.identity.outputs.sha }}
tree: ${{ steps.identity.outputs.tree }}
ref: ${{ steps.identity.outputs.ref }}
reuse: ${{ steps.reuse.outputs.reuse }}
reused_run: ${{ steps.reuse.outputs.reused_run }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
ref: ${{ github.event_name == 'workflow_dispatch' && inputs.ref || 'dev' }}
fetch-depth: 0
- name: Зафиксировать commit и tree
id: identity
run: |
echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
echo "tree=$(git rev-parse 'HEAD^{tree}')" >> "$GITHUB_OUTPUT"
echo "ref=${{ github.event_name == 'workflow_dispatch' && inputs.ref || 'dev' }}" >> "$GITHUB_OUTPUT"
# #620: самый свежий маркер зелёного прогона этого tree и этого workflow.
# Ключ уникален на прогон (кэш неизменяем), восстанавливается по префиксу —
# иначе перепроверка после недели не смогла бы освежить маркер.
- name: Маркер зелёного прогона этого дерева
if: github.event_name == 'schedule'
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
with:
path: artifacts/mutation-green
key: mutation-green-v1-${{ steps.identity.outputs.tree }}-${{ github.workflow_sha }}-${{ github.run_id }}
restore-keys: |
mutation-green-v1-${{ steps.identity.outputs.tree }}-${{ github.workflow_sha }}-
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with:
node-version: 22
# Любой сбой решения — полный прогон: пропуск должен быть доказан, а не
# выведен из ошибки. Материал без скрипта (dispatch старого ref) — тоже.
- name: Нужен ли прогон
id: reuse
env:
EVENT_NAME: ${{ github.event_name }}
TREE: ${{ steps.identity.outputs.tree }}
WORKFLOW_SHA: ${{ github.workflow_sha }}
RUN_URL_BASE: ${{ github.server_url }}/${{ github.repository }}/actions/runs
run: |
decision="$RUNNER_TEMP/mutation-reuse.txt"
if [ -f scripts/mutation-nightly-reuse.mjs ] && node scripts/mutation-nightly-reuse.mjs --decide \
--event="$EVENT_NAME" --tree="$TREE" --workflow-sha="$WORKFLOW_SHA" \
--marker=artifacts/mutation-green/marker.json \
--summary="$GITHUB_STEP_SUMMARY" --run-url-base="$RUN_URL_BASE" > "$decision"; then
cat "$decision" >> "$GITHUB_OUTPUT"
if grep -qx 'reuse=true' "$decision"; then
echo "::notice::reused from run $(sed -n 's/^reused_run=//p' "$decision")"
fi
else
echo "::warning::решение о повторном использовании не получено — полный прогон"
echo "reuse=false" >> "$GITHUB_OUTPUT"
fi
mutants:
name: "Мутанты: каждый обязан красить тесты (шард ${{ matrix.shard }} из 6)"
needs: material
if: needs.material.outputs.reuse != 'true'
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
shard: [1, 2, 3, 4, 5, 6]
# Шесть чересполосных шардов (#604): при четырёх шард нёс ~203 мутанта из
# 810 и рос с реестром — 42 мин 10.09, 57 мин 20.09, 61 мин 21.09, и шард
# 2/4 был снят по потолку без единого FAIL. Делитель тот же, что у
# `changed_mutants` в Validate; число шардов повторяется в `--shard=i/6`,
# `--shards=6` и имени job — тест `mutation-gate.test.mjs` держит их
# равными. Бандл собирают только браузерные гварды. Час — потолок против
# зависшего Chromium, а не бюджет шарда: шард, упёршийся в него, — сигнал
# снова делить, и отчёт (#472) называет такой шард прерванным, не «ok».
timeout-minutes: 60
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
ref: ${{ needs.material.outputs.sha }}
fetch-depth: 0
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with:
node-version: 22
cache: npm
- run: npm ci
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7
with:
python-version: '3.14'
- name: Установить backend test dependencies
run: |
# Точные версии — в tests_backend/requirements.txt (#392): без них
# резолвер выбирает набор сам, и «зелёный backend» значит разное
# в разные дни.
pip install -r tests_backend/requirements.txt
pip list --format=columns | grep -Ei 'homeassistant|voluptuous|^pytest '
- name: Кэш браузеров Playwright
id: pw
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
with:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
- name: Установить Chromium
if: steps.pw.outputs.cache-hit != 'true'
run: npx playwright install --with-deps chromium
- name: Реестр применим к текущему коду
run: node scripts/mutation-gate.mjs --check
- name: Тёплый test-build для инкрементальной компиляции мутантов
run: npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs
# Вывод шарда сохраняется артефактом (#472): строки
# `FAIL <id>: тест остался зелёным…` — единственное место, где названо,
# ЧТО сбежало. Без артефакта отказ безымянный. `PIPESTATUS` — чтобы
# `tee` не съел код выхода раннера.
- name: Каждый тест ловит свою поломку
id: gate
run: |
mkdir -p artifacts/mutation-shard-${{ matrix.shard }}
set -o pipefail
node scripts/mutation-gate.mjs --shard=${{ matrix.shard }}/6 2>&1 | tee artifacts/mutation-shard-${{ matrix.shard }}/mutation-shard-${{ matrix.shard }}.log
# Исход шага едет в evidence (#604): снятый по timeout-minutes шаг даёт
# `cancelled`, и агрегатор отвергает такой шард как неполный — лог без
# строк FAIL сам по себе зелёным не считается.
- name: Записать identity шарда
if: always()
run: |
node scripts/mutation-gate-report.mjs \
--write-evidence=artifacts/mutation-shard-${{ matrix.shard }}/evidence.json \
--sha=${{ needs.material.outputs.sha }} \
--tree=${{ needs.material.outputs.tree }} \
--workflow-sha=${{ github.workflow_sha }} \
--run-id=${{ github.run_id }} --run-attempt=${{ github.run_attempt }} \
--shard=${{ matrix.shard }} --shards=6 \
--outcome=${{ steps.gate.outcome }}
- name: Сохранить лог и identity шарда
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: mutation-shard-${{ matrix.shard }}-attempt-${{ github.run_attempt }}
path: artifacts/mutation-shard-${{ matrix.shard }}
if-no-files-found: warn
retention-days: 30
# Результат нельзя приписывать material, пока не доказаны все шесть шардов —
# каждый с identity и с дошедшим до конца прогоном (#549, #604).
# always() нужен при красном мутанте: лог красного шарда всё равно evidence.
evidence:
name: "Доказать единый material всех шардов"
needs: [material, mutants]
if: always() && needs.material.outputs.reuse != 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
# CLI отчётчика принадлежит зафиксированному material. В main лежит
# только workflow и может не быть dev-версии scripts/**.
ref: ${{ needs.material.outputs.sha }}
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with:
node-version: 22
- name: Забрать evidence всех попыток
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
with:
pattern: mutation-shard-*
path: artifacts/mutation-logs
- name: Проверить полноту и identity
run: |
node scripts/mutation-gate-report.mjs --verify-only \
--logs=artifacts/mutation-logs --shards=6 \
--sha=${{ needs.material.outputs.sha }} \
--tree=${{ needs.material.outputs.tree }} \
--workflow-sha=${{ github.workflow_sha }} \
--run-id=${{ github.run_id }} --run-attempt=${{ github.run_attempt }}
# #620: маркер пишется ТОЛЬКО после зелёного агрегатора — все шесть шардов
# доказаны на одном material. Красный или неполный прогон маркера не оставляет,
# и следующая ночь гонит реестр заново.
green_marker:
name: "Записать маркер зелёного прогона"
needs: [material, mutants, evidence]
if: needs.material.outputs.reuse != 'true' && needs.mutants.result == 'success' && needs.evidence.result == 'success'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
ref: ${{ needs.material.outputs.sha }}
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with:
node-version: 22
- name: Маркер
run: |
node scripts/mutation-nightly-reuse.mjs \
--write-marker=artifacts/mutation-green/marker.json \
--tree=${{ needs.material.outputs.tree }} \
--sha=${{ needs.material.outputs.sha }} \
--workflow-sha=${{ github.workflow_sha }} \
--run-id=${{ github.run_id }} --run-attempt=${{ github.run_attempt }} \
--event=${{ github.event_name }}
- name: Сохранить маркер
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
with:
path: artifacts/mutation-green
key: mutation-green-v1-${{ needs.material.outputs.tree }}-${{ github.workflow_sha }}-${{ github.run_id }}
# Адресат у отказа (#472). Только по расписанию: ручной dispatch остаётся
# для отладки самого гейта, его результат смотрят в прогоне — issue на
# каждый такой отказ был бы шумом, который снова перестанут читать.
#
# Права job-уровня ЗАМЕНЯЮТ права workflow, а не дополняют (прецедент —
# validate.yml, job с actions: read): перечислены все три.
report:
name: "Отказ расписания: issue и Telegram"
needs: [material, mutants, evidence]
# #620: ночь, принявшая доказательство прошлого зелёного прогона, отказом не
# является — её шарды пропущены намеренно. Любой другой пропуск шардов
# (упал material) по-прежнему заводит issue.
if: always() && github.event_name == 'schedule' && needs.material.outputs.reuse != 'true' && (needs.mutants.result != 'success' || needs.evidence.result != 'success')
runs-on: ubuntu-latest
permissions:
contents: read
actions: read
issues: write
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
# Не перечитываем moving dev: код отчётчика берётся из уже
# зафиксированного material. В стабильном main dev-скриптов нет.
ref: ${{ needs.material.outputs.sha }}
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with:
node-version: 22
- name: Забрать логи шардов
continue-on-error: true
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
with:
pattern: mutation-shard-*
path: artifacts/mutation-logs
- name: Собрать отчёт
id: report
env:
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
run: |
mkdir -p artifacts
node scripts/mutation-gate-report.mjs \
--require-evidence --logs=artifacts/mutation-logs --shards=6 \
--sha=${{ needs.material.outputs.sha }} \
--tree=${{ needs.material.outputs.tree }} \
--workflow-sha=${{ github.workflow_sha }} \
--run-id=${{ github.run_id }} --run-attempt=${{ github.run_attempt }} \
--run-url="$RUN_URL" --ref=${{ needs.material.outputs.ref }} \
--body-out=artifacts/mutation-report.md \
--telegram-out=artifacts/mutation-telegram.txt >> "$GITHUB_OUTPUT"
# Одно issue, а не одно на неделю: открытое с тем же маркером в заголовке
# получает комментарий, новое заводится только если открытого нет.
- name: Issue — создать или дописать
id: issue
env:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
MARKER: ${{ steps.report.outputs.marker }}
TITLE: ${{ steps.report.outputs.title }}
run: |
existing=$(gh issue list --repo "$REPO" --state open --search "\"$MARKER\" in:title" \
--json number,title --jq '[.[] | select(.title | startswith(env.MARKER))][0].number // empty')
if [ -n "$existing" ]; then
gh issue comment "$existing" --repo "$REPO" --body-file artifacts/mutation-report.md
url="${{ github.server_url }}/$REPO/issues/$existing"
else
url=$(gh issue create --repo "$REPO" --title "$TITLE" \
--label infra --label process --label tests \
--body-file artifacts/mutation-report.md)
fi
echo "url=$url" >> "$GITHUB_OUTPUT"
echo "issue: $url"
# Тот же канал, что у релизов (announce.yml). Нет секретов — не отказ:
# issue уже заведено, а Telegram — второй адресат, не единственный.
- name: Telegram
if: always() && steps.issue.outcome == 'success'
env:
TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }}
CHAT: ${{ secrets.TELEGRAM_CHAT_ID }}
ISSUE_URL: ${{ steps.issue.outputs.url }}
run: |
if [ -z "$TOKEN" ] || [ -z "$CHAT" ]; then
echo "::warning::TELEGRAM_BOT_TOKEN/TELEGRAM_CHAT_ID не заданы — оповещение пропущено, issue заведено"
exit 0
fi
TEXT=$(sed "s|(issue)|$ISSUE_URL|" artifacts/mutation-telegram.txt)
curl -sS --fail-with-body -X POST \
"https://api.telegram.org/bot$TOKEN/sendMessage" \
--data-urlencode "chat_id=$CHAT" \
--data-urlencode "text=$TEXT" \
-d disable_web_page_preview=true