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 : тест остался зелёным…` — единственное место, где названо, # ЧТО сбежало. Без артефакта отказ безымянный. `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