Files
houseplan-card/.github/workflows/_mutation-gate.yml
Claude bdac4b4fdc ci: закрепить образ раннера, таймауты job и некруглые cron (#658)
`ubuntu-latest` с 19.10.2026 переезжает на Ubuntu 26, а golden, скриншоты
документации и перф-бюджеты сняты на текущем образе: все 43 job на раннере
теперь явно на `ubuntu-24.04`, один образ на все workflow. 26 job получили
`timeout-minutes` по наблюдённой длительности с запасом; гейт релиза — 180,
больше суммы собственных ожиданий (60 + 60 + 45). Расписания ушли с круглых
минут (ночь 02:17, мутанты 00:43, метрики 05:23, полный перф 04:11), ночь
пишет в summary сдвиг старта и предупреждает, если он больше часа.

test/workflow-hygiene.test.mjs держит все три правила по тексту workflow
(разбор `parseJobSettings` в scripts/workflow-jobs.mjs) и исполняет шаг
сдвига старта настоящим bash; порядок осознанного подъёма образа —
docs/DEVELOPMENT.md.

Issue: #658
User-Visible: no
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
2026-09-27 13:57:21 +03:00

358 lines
20 KiB
YAML
Raw Permalink 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: "Мутационный гейт · тело (#623)"
# Реестр известных поломок (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.
#
# #623: «SHA workflow» — `job.workflow_sha`, SHA этого файла-тела. В вызываемом
# workflow `github.workflow_sha` принадлежит вызывающему `mutation-gate.yml` из
# main и не меняется вместе с телом, поэтому здесь он не используется.
on:
# #623: тело вызывается тонким файлом `mutation-gate.yml` из ветки по умолчанию
# по ссылке `@dev`; триггеры, run-name и concurrency живут там.
workflow_call:
inputs:
ref:
description: "Git ref whose mutation guards must be proved"
required: false
type: string
default: dev
permissions:
contents: read
# Concurrency уровня workflow — у вызывающего `mutation-gate.yml` (#623).
jobs:
# #549: moving ref разрешается ровно один раз. Все шарды ниже получают один
# commit/tree, а не самостоятельно читают dev в разное время.
material:
name: "Зафиксировать неизменяемый материал"
runs-on: ubuntu-24.04
timeout-minutes: 10
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 }}-${{ job.workflow_sha }}-${{ github.run_id }}
restore-keys: |
mutation-green-v1-${{ steps.identity.outputs.tree }}-${{ job.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: ${{ job.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-24.04
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=${{ job.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-24.04
timeout-minutes: 10
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=${{ job.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-24.04
timeout-minutes: 10
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=${{ job.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 }}-${{ job.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-24.04
timeout-minutes: 10
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=${{ job.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