mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
Six workflows run from the default branch (issues, schedule, workflow_run): process, process-resume, process-reconcile, mutation-gate, nightly, process-metrics. Their bodies move to _<name>.yml (on: workflow_call); the original files keep only triggers, run-name, permissions, concurrency and one job `uses: Matysh/houseplan-card/.github/workflows/_<name>.yml@dev` with `secrets: inherit`. A pipeline change becomes one commit to dev. - caller job permissions = union of body job permissions (#556 minimum kept per job inside the body); caller `if` repeats the body guard for process and process-resume so unrelated events stay skipped; - dispatch inputs forwarded via workflow_call inputs of the same names; - _mutation-gate.yml keys evidence/marker on job.workflow_sha (the body SHA): in a called workflow github.workflow_sha belongs to the caller in main; - action-pins: narrow exception for this repo's _*.yml at @dev with a reason; - preflight workflow_sync compares all six thin callers (was 3 of 6); performance.yml excluded: its schedule judges main with main's own body; - tests read bodies from _*.yml; new test/default-branch-workflows.test.mjs; six mutants; PROCESS.md §10.4, AGENTS.md, REVIEWER.md updated. Issue: #623 User-Visible: no
354 lines
20 KiB
YAML
354 lines
20 KiB
YAML
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-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 }}-${{ 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-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=${{ 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-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=${{ 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-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=${{ 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-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=${{ 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
|