mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
ci: thin default-branch callers invoke reusable bodies at @dev (#623)
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
This commit is contained in:
@@ -0,0 +1,353 @@
|
||||
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
|
||||
@@ -0,0 +1,64 @@
|
||||
# Ночной полный прогон (#479).
|
||||
#
|
||||
# Тяжёлые job Validate — смоки, golden, performance_smoke — на обычном пуше не
|
||||
# идут: они ни разу не ловили дефект в момент ревью и стоили ~6 минут
|
||||
# критического пути на каждую итерацию. Полный набор идёт на кандидате беты
|
||||
# (трейлер `Release:`), по кнопке и здесь — каждую ночь на голове `dev`.
|
||||
#
|
||||
# Почему не `schedule` прямо в validate.yml: расписание исполняется на ветке
|
||||
# по умолчанию (`main`), а проверять надо `dev`. Один dispatch с `--ref dev`
|
||||
# делает это без переписывания checkout во всех job. Reuse (#208) сохраняется:
|
||||
# при неизменённом дереве ночной прогон обойдётся маркерами.
|
||||
#
|
||||
# Красный ночной прогон — сигнал автору последних коммитов на dev, не гейт:
|
||||
# гейт беты по-прежнему требует зелёный Validate на точном SHA кандидата, и
|
||||
# там полный набор идёт заново.
|
||||
#
|
||||
# Сигнал обязан быть настоящим (#492 §7): до этой задачи job завершалась
|
||||
# успехом в момент постановки Validate в очередь, и красный полный прогон не
|
||||
# делал ночной workflow красным. Теперь job находит запущенный прогон и ждёт
|
||||
# его: успешный dispatch — не успешная проверка.
|
||||
name: "Ночной полный прогон dev · тело (#623)"
|
||||
|
||||
on:
|
||||
# #623: тело вызывается тонким файлом `nightly.yml` из ветки по умолчанию
|
||||
# по ссылке `@dev`; триггеры, run-name и concurrency живут там.
|
||||
workflow_call:
|
||||
|
||||
permissions:
|
||||
actions: write
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
dispatch:
|
||||
name: "Запустить Validate на dev с полным набором и дождаться результата"
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 90
|
||||
steps:
|
||||
- env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
REPO: ${{ github.repository }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
since=$(date -u +%FT%TZ)
|
||||
gh workflow run validate.yml --repo "$REPO" --ref dev -f full=true
|
||||
echo "Validate(dev, full=true) поставлен в очередь: $since"
|
||||
# Найти именно этот прогон: workflow_dispatch на dev, созданный не
|
||||
# раньше момента запуска. До трёх минут на появление в списке.
|
||||
run_id=""
|
||||
for _ in $(seq 1 18); do
|
||||
sleep 10
|
||||
run_id=$(gh run list --repo "$REPO" --workflow validate.yml --branch dev \
|
||||
--event workflow_dispatch --json databaseId,createdAt --limit 5 \
|
||||
--jq "[.[] | select(.createdAt >= \"$since\")] | sort_by(.createdAt) | last | .databaseId // empty")
|
||||
[ -n "$run_id" ] && break
|
||||
done
|
||||
if [ -z "$run_id" ]; then
|
||||
echo "::error::прогон Validate не появился за 3 минуты — dispatch не равен проверке"
|
||||
exit 1
|
||||
fi
|
||||
url="${{ github.server_url }}/$REPO/actions/runs/$run_id"
|
||||
echo "дочерний прогон: $url"
|
||||
echo "- Validate(dev, full=true): $url" >> "$GITHUB_STEP_SUMMARY"
|
||||
# Ждём завершения; красный дочерний прогон — красный ночной.
|
||||
gh run watch "$run_id" --repo "$REPO" --exit-status --interval 30
|
||||
@@ -0,0 +1,59 @@
|
||||
name: "Метрики процесса · тело (#623)"
|
||||
|
||||
# #637: еженедельный замер вместо ощущений — lead time S→S7→S8, раунды ревью,
|
||||
# прогоны Validate по исходам, минуты конвейера в S4/S7. Только чтение: отчёт
|
||||
# идёт в step summary и артефакт; в issue и репозиторий ничего не пишется.
|
||||
# Цифры аудита 22.09 (1,57 раунда код-ревью, S7 ≈ 36 мин/раунд, 226 Validate
|
||||
# за неделю) были собраны руками за час — теперь они стоят один запуск.
|
||||
|
||||
on:
|
||||
# #623: тело вызывается тонким файлом `process-metrics.yml` из ветки по умолчанию
|
||||
# по ссылке `@dev`; триггеры, run-name и concurrency живут там.
|
||||
workflow_call:
|
||||
inputs:
|
||||
days:
|
||||
description: "Окно в днях"
|
||||
required: false
|
||||
type: string
|
||||
default: "7"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
issues: read
|
||||
|
||||
jobs:
|
||||
metrics:
|
||||
name: "Снимок недели: issue, раунды, прогоны"
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
# Код — из dev, как у reconcile: расписание читается из main, а исполняется
|
||||
# версия, которую проверил CI.
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
ref: dev
|
||||
fetch-depth: 1
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
node-version: 22
|
||||
- name: Собрать отчёт
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
|
||||
REPO: ${{ github.repository }}
|
||||
DAYS: ${{ inputs.days || '7' }}
|
||||
run: |
|
||||
mkdir -p artifacts/process-metrics
|
||||
node scripts/process-metrics.mjs --repo="$REPO" --days="$DAYS" \
|
||||
--output=artifacts/process-metrics/report.md \
|
||||
--json=artifacts/process-metrics/report.json > /dev/null
|
||||
cat artifacts/process-metrics/report.md >> "$GITHUB_STEP_SUMMARY"
|
||||
- name: Сохранить отчёт
|
||||
if: always()
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
with:
|
||||
name: process-metrics-${{ github.run_id }}
|
||||
path: artifacts/process-metrics
|
||||
if-no-files-found: error
|
||||
retention-days: 90
|
||||
@@ -0,0 +1,57 @@
|
||||
name: "Сверка очереди ревью · тело (#623)"
|
||||
|
||||
on:
|
||||
# #623: тело вызывается тонким файлом `process-reconcile.yml` из ветки по умолчанию
|
||||
# по ссылке `@dev`; триггеры, run-name и concurrency живут там.
|
||||
workflow_call:
|
||||
inputs:
|
||||
apply:
|
||||
description: "Повторно будить потерянные запросы и публиковать диагностику"
|
||||
required: false
|
||||
type: boolean
|
||||
default: true
|
||||
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
issues: read
|
||||
|
||||
jobs:
|
||||
reconcile:
|
||||
name: "Один снимок S4/S7 без polling модели"
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
concurrency:
|
||||
group: process-reconcile
|
||||
cancel-in-progress: false
|
||||
steps:
|
||||
# Расписание читается из main, а исполняемый reconciler — из dev: так
|
||||
# после штатного merge действует та же версия кода, которую проверил CI.
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
ref: dev
|
||||
fetch-depth: 1
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
node-version: 22
|
||||
- name: Сопоставить labels, requests, runs и sealed evidence
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
|
||||
REPO: ${{ github.repository }}
|
||||
APPLY: ${{ github.event_name == 'schedule' || inputs.apply == true }}
|
||||
run: |
|
||||
mkdir -p artifacts/process-reconcile
|
||||
node scripts/process-reconcile.mjs \
|
||||
--repo "$REPO" \
|
||||
--apply="$APPLY" \
|
||||
--max-actions=5 \
|
||||
--output=artifacts/process-reconcile/summary.json
|
||||
- name: Опубликовать компактный machine-readable итог
|
||||
if: always()
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
with:
|
||||
name: process-reconcile-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
path: artifacts/process-reconcile/summary.json
|
||||
if-no-files-found: error
|
||||
retention-days: 14
|
||||
@@ -0,0 +1,60 @@
|
||||
name: "Продолжение ревью после Validate · тело (#623)"
|
||||
|
||||
# #636. Стадия `prepare` конвейера (process.yml) больше не ждёт Validate с
|
||||
# мутантами на материале внутри job — раннер спал ≈ 28 минут на раунд при
|
||||
# 10–12 минутах работы модели. Она диспатчит прогон, кладёт запечатанный
|
||||
# маркер `review-pending-…` и выходит. Этот workflow просыпается на завершение
|
||||
# любого Validate и, если раунд ждал именно этот прогон (маркер на материале,
|
||||
# метка S7 стоит, активного прогона конвейера нет), переставляет метку S7 —
|
||||
# новый прогон `prepare` находит завершённый dispatch и продолжает раунд.
|
||||
# Ничего не оценивает: зелёный/красный разбирает сам конвейер. Страховка на
|
||||
# потерянное событие — process-reconcile.yml с тем же маркером.
|
||||
#
|
||||
# Для события `workflow_run` GitHub берёт workflow только из ветки по
|
||||
# умолчанию (main). Там лежит тонкий `process-resume.yml`, который вызывает
|
||||
# этот файл по ссылке `@dev` (#623); сверка тонких копий — в preflight
|
||||
# validate.yml.
|
||||
|
||||
on:
|
||||
# #623: тело вызывается тонким файлом `process-resume.yml` из ветки по умолчанию
|
||||
# по ссылке `@dev`; триггеры, run-name и concurrency живут там.
|
||||
workflow_call:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
|
||||
jobs:
|
||||
resume:
|
||||
name: "Разбудить раунд, ждавший этот Validate"
|
||||
if: github.event.workflow_run.event == 'workflow_dispatch' && startsWith(github.event.workflow_run.head_branch, 'issue/')
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
concurrency:
|
||||
group: process-resume-${{ github.event.workflow_run.head_branch }}
|
||||
cancel-in-progress: false
|
||||
steps:
|
||||
# Код берётся из dev, как у reconcile: после штатного слияния действует
|
||||
# версия, которую проверил CI, а не копия из main.
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
ref: dev
|
||||
fetch-depth: 1
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
node-version: 22
|
||||
# Метка переставляется HP_PROCESS_TOKEN: событие от GITHUB_TOKEN не
|
||||
# запустило бы process.yml (см. шапку process.yml, п. 1).
|
||||
- name: Решить по маркеру ожидания и переставить S7
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
|
||||
REPO: ${{ github.repository }}
|
||||
BRANCH: ${{ github.event.workflow_run.head_branch }}
|
||||
SHA: ${{ github.event.workflow_run.head_sha }}
|
||||
EVENT: ${{ github.event.workflow_run.event }}
|
||||
STATUS: ${{ github.event.workflow_run.status }}
|
||||
run: |
|
||||
node scripts/process-resume.mjs \
|
||||
--repo="$REPO" --branch="$BRANCH" --sha="$SHA" \
|
||||
--event="$EVENT" --status="$STATUS" --apply=true | tee -a "$GITHUB_STEP_SUMMARY"
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,31 +1,12 @@
|
||||
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.
|
||||
# Тонкий вызывающий файл (#623). Для этого события GitHub берёт workflow из
|
||||
# ветки по умолчанию (`main`), поэтому здесь только то, что обязано жить там:
|
||||
# триггеры, run-name, права и concurrency. Тело — `_mutation-gate.yml` по ссылке
|
||||
# `@dev`: правка конвейера — один коммит в `dev`, зеркало в `main` не нужно.
|
||||
# Этот файл меняется, только когда меняются сами триггеры или потолок прав;
|
||||
# тогда он зеркалится в `main`, и preflight `workflow_sync` (validate.yml)
|
||||
# держит копии равными.
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
@@ -52,306 +33,15 @@ concurrency:
|
||||
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
|
||||
# Потолок прав тела: объединение job-level прав `_mutation-gate.yml`. Вызываемый
|
||||
# workflow может права только сузить, поэтому каждая его job по-прежнему
|
||||
# получает свой прежний минимум (#556), а шире этого набора не получит никто.
|
||||
dev:
|
||||
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
|
||||
uses: Matysh/houseplan-card/.github/workflows/_mutation-gate.yml@dev # #623: тело конвейера из dev
|
||||
with:
|
||||
ref: ${{ inputs.ref }}
|
||||
secrets: inherit
|
||||
|
||||
@@ -1,25 +1,13 @@
|
||||
# Ночной полный прогон (#479).
|
||||
#
|
||||
# Тяжёлые job Validate — смоки, golden, performance_smoke — на обычном пуше не
|
||||
# идут: они ни разу не ловили дефект в момент ревью и стоили ~6 минут
|
||||
# критического пути на каждую итерацию. Полный набор идёт на кандидате беты
|
||||
# (трейлер `Release:`), по кнопке и здесь — каждую ночь на голове `dev`.
|
||||
#
|
||||
# Почему не `schedule` прямо в validate.yml: расписание исполняется на ветке
|
||||
# по умолчанию (`main`), а проверять надо `dev`. Один dispatch с `--ref dev`
|
||||
# делает это без переписывания checkout во всех job. Reuse (#208) сохраняется:
|
||||
# при неизменённом дереве ночной прогон обойдётся маркерами.
|
||||
#
|
||||
# Красный ночной прогон — сигнал автору последних коммитов на dev, не гейт:
|
||||
# гейт беты по-прежнему требует зелёный Validate на точном SHA кандидата, и
|
||||
# там полный набор идёт заново.
|
||||
#
|
||||
# Сигнал обязан быть настоящим (#492 §7): до этой задачи job завершалась
|
||||
# успехом в момент постановки Validate в очередь, и красный полный прогон не
|
||||
# делал ночной workflow красным. Теперь job находит запущенный прогон и ждёт
|
||||
# его: успешный dispatch — не успешная проверка.
|
||||
name: Ночной полный прогон dev
|
||||
|
||||
# Тонкий вызывающий файл (#623). Для этого события GitHub берёт workflow из
|
||||
# ветки по умолчанию (`main`), поэтому здесь только то, что обязано жить там:
|
||||
# триггеры, run-name, права и concurrency. Тело — `_nightly.yml` по ссылке
|
||||
# `@dev`: правка конвейера — один коммит в `dev`, зеркало в `main` не нужно.
|
||||
# Этот файл меняется, только когда меняются сами триггеры или потолок прав;
|
||||
# тогда он зеркалится в `main`, и preflight `workflow_sync` (validate.yml)
|
||||
# держит копии равными.
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '30 2 * * *'
|
||||
@@ -30,35 +18,12 @@ permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
dispatch:
|
||||
name: "Запустить Validate на dev с полным набором и дождаться результата"
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 90
|
||||
steps:
|
||||
- env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
REPO: ${{ github.repository }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
since=$(date -u +%FT%TZ)
|
||||
gh workflow run validate.yml --repo "$REPO" --ref dev -f full=true
|
||||
echo "Validate(dev, full=true) поставлен в очередь: $since"
|
||||
# Найти именно этот прогон: workflow_dispatch на dev, созданный не
|
||||
# раньше момента запуска. До трёх минут на появление в списке.
|
||||
run_id=""
|
||||
for _ in $(seq 1 18); do
|
||||
sleep 10
|
||||
run_id=$(gh run list --repo "$REPO" --workflow validate.yml --branch dev \
|
||||
--event workflow_dispatch --json databaseId,createdAt --limit 5 \
|
||||
--jq "[.[] | select(.createdAt >= \"$since\")] | sort_by(.createdAt) | last | .databaseId // empty")
|
||||
[ -n "$run_id" ] && break
|
||||
done
|
||||
if [ -z "$run_id" ]; then
|
||||
echo "::error::прогон Validate не появился за 3 минуты — dispatch не равен проверке"
|
||||
exit 1
|
||||
fi
|
||||
url="${{ github.server_url }}/$REPO/actions/runs/$run_id"
|
||||
echo "дочерний прогон: $url"
|
||||
echo "- Validate(dev, full=true): $url" >> "$GITHUB_STEP_SUMMARY"
|
||||
# Ждём завершения; красный дочерний прогон — красный ночной.
|
||||
gh run watch "$run_id" --repo "$REPO" --exit-status --interval 30
|
||||
# Потолок прав тела: объединение job-level прав `_nightly.yml`. Вызываемый
|
||||
# workflow может права только сузить, поэтому каждая его job по-прежнему
|
||||
# получает свой прежний минимум (#556), а шире этого набора не получит никто.
|
||||
dev:
|
||||
permissions:
|
||||
actions: write
|
||||
contents: read
|
||||
uses: Matysh/houseplan-card/.github/workflows/_nightly.yml@dev # #623: тело конвейера из dev
|
||||
secrets: inherit
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
name: Метрики процесса
|
||||
|
||||
# #637: еженедельный замер вместо ощущений — lead time S→S7→S8, раунды ревью,
|
||||
# прогоны Validate по исходам, минуты конвейера в S4/S7. Только чтение: отчёт
|
||||
# идёт в step summary и артефакт; в issue и репозиторий ничего не пишется.
|
||||
# Цифры аудита 22.09 (1,57 раунда код-ревью, S7 ≈ 36 мин/раунд, 226 Validate
|
||||
# за неделю) были собраны руками за час — теперь они стоят один запуск.
|
||||
# Тонкий вызывающий файл (#623). Для этого события GitHub берёт workflow из
|
||||
# ветки по умолчанию (`main`), поэтому здесь только то, что обязано жить там:
|
||||
# триггеры, run-name, права и concurrency. Тело — `_process-metrics.yml` по ссылке
|
||||
# `@dev`: правка конвейера — один коммит в `dev`, зеркало в `main` не нужно.
|
||||
# Этот файл меняется, только когда меняются сами триггеры или потолок прав;
|
||||
# тогда он зеркалится в `main`, и preflight `workflow_sync` (validate.yml)
|
||||
# держит копии равными.
|
||||
|
||||
on:
|
||||
schedule:
|
||||
@@ -24,37 +26,15 @@ permissions:
|
||||
issues: read
|
||||
|
||||
jobs:
|
||||
metrics:
|
||||
name: "Снимок недели: issue, раунды, прогоны"
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
# Код — из dev, как у reconcile: расписание читается из main, а исполняется
|
||||
# версия, которую проверил CI.
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
ref: dev
|
||||
fetch-depth: 1
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
node-version: 22
|
||||
- name: Собрать отчёт
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
|
||||
REPO: ${{ github.repository }}
|
||||
DAYS: ${{ inputs.days || '7' }}
|
||||
run: |
|
||||
mkdir -p artifacts/process-metrics
|
||||
node scripts/process-metrics.mjs --repo="$REPO" --days="$DAYS" \
|
||||
--output=artifacts/process-metrics/report.md \
|
||||
--json=artifacts/process-metrics/report.json > /dev/null
|
||||
cat artifacts/process-metrics/report.md >> "$GITHUB_STEP_SUMMARY"
|
||||
- name: Сохранить отчёт
|
||||
if: always()
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
with:
|
||||
name: process-metrics-${{ github.run_id }}
|
||||
path: artifacts/process-metrics
|
||||
if-no-files-found: error
|
||||
retention-days: 90
|
||||
# Потолок прав тела: объединение job-level прав `_process-metrics.yml`. Вызываемый
|
||||
# workflow может права только сузить, поэтому каждая его job по-прежнему
|
||||
# получает свой прежний минимум (#556), а шире этого набора не получит никто.
|
||||
dev:
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
issues: read
|
||||
uses: Matysh/houseplan-card/.github/workflows/_process-metrics.yml@dev # #623: тело конвейера из dev
|
||||
with:
|
||||
days: ${{ inputs.days }}
|
||||
secrets: inherit
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
name: Сверка очереди ревью
|
||||
run-name: "reconcile process queue · ${{ github.event_name }}"
|
||||
|
||||
# Тонкий вызывающий файл (#623). Для этого события GitHub берёт workflow из
|
||||
# ветки по умолчанию (`main`), поэтому здесь только то, что обязано жить там:
|
||||
# триггеры, run-name, права и concurrency. Тело — `_process-reconcile.yml` по ссылке
|
||||
# `@dev`: правка конвейера — один коммит в `dev`, зеркало в `main` не нужно.
|
||||
# Этот файл меняется, только когда меняются сами триггеры или потолок прав;
|
||||
# тогда он зеркалится в `main`, и preflight `workflow_sync` (validate.yml)
|
||||
# держит копии равными.
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '7,37 * * * *'
|
||||
@@ -18,41 +26,15 @@ permissions:
|
||||
issues: read
|
||||
|
||||
jobs:
|
||||
reconcile:
|
||||
name: "Один снимок S4/S7 без polling модели"
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
concurrency:
|
||||
group: process-reconcile
|
||||
cancel-in-progress: false
|
||||
steps:
|
||||
# Расписание читается из main, а исполняемый reconciler — из dev: так
|
||||
# после штатного merge действует та же версия кода, которую проверил CI.
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
ref: dev
|
||||
fetch-depth: 1
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
node-version: 22
|
||||
- name: Сопоставить labels, requests, runs и sealed evidence
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
|
||||
REPO: ${{ github.repository }}
|
||||
APPLY: ${{ github.event_name == 'schedule' || inputs.apply == true }}
|
||||
run: |
|
||||
mkdir -p artifacts/process-reconcile
|
||||
node scripts/process-reconcile.mjs \
|
||||
--repo "$REPO" \
|
||||
--apply="$APPLY" \
|
||||
--max-actions=5 \
|
||||
--output=artifacts/process-reconcile/summary.json
|
||||
- name: Опубликовать компактный machine-readable итог
|
||||
if: always()
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
with:
|
||||
name: process-reconcile-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
path: artifacts/process-reconcile/summary.json
|
||||
if-no-files-found: error
|
||||
retention-days: 14
|
||||
# Потолок прав тела: объединение job-level прав `_process-reconcile.yml`. Вызываемый
|
||||
# workflow может права только сузить, поэтому каждая его job по-прежнему
|
||||
# получает свой прежний минимум (#556), а шире этого набора не получит никто.
|
||||
dev:
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
issues: read
|
||||
uses: Matysh/houseplan-card/.github/workflows/_process-reconcile.yml@dev # #623: тело конвейера из dev
|
||||
with:
|
||||
apply: ${{ inputs.apply == true }}
|
||||
secrets: inherit
|
||||
|
||||
@@ -1,19 +1,13 @@
|
||||
name: Продолжение ревью после Validate
|
||||
run-name: "resume · ${{ github.event.workflow_run.head_branch }} · ${{ github.event.workflow_run.conclusion }}"
|
||||
|
||||
# #636. Стадия `prepare` конвейера (process.yml) больше не ждёт Validate с
|
||||
# мутантами на материале внутри job — раннер спал ≈ 28 минут на раунд при
|
||||
# 10–12 минутах работы модели. Она диспатчит прогон, кладёт запечатанный
|
||||
# маркер `review-pending-…` и выходит. Этот workflow просыпается на завершение
|
||||
# любого Validate и, если раунд ждал именно этот прогон (маркер на материале,
|
||||
# метка S7 стоит, активного прогона конвейера нет), переставляет метку S7 —
|
||||
# новый прогон `prepare` находит завершённый dispatch и продолжает раунд.
|
||||
# Ничего не оценивает: зелёный/красный разбирает сам конвейер. Страховка на
|
||||
# потерянное событие — process-reconcile.yml с тем же маркером.
|
||||
#
|
||||
# Как и process.yml, файл исполняется из ветки по умолчанию (main): для
|
||||
# события `workflow_run` GitHub берёт workflow только оттуда. Сверка копий —
|
||||
# в preflight validate.yml.
|
||||
# Тонкий вызывающий файл (#623). Для этого события GitHub берёт workflow из
|
||||
# ветки по умолчанию (`main`), поэтому здесь только то, что обязано жить там:
|
||||
# триггеры, run-name, права и concurrency. Тело — `_process-resume.yml` по ссылке
|
||||
# `@dev`: правка конвейера — один коммит в `dev`, зеркало в `main` не нужно.
|
||||
# Этот файл меняется, только когда меняются сами триггеры или потолок прав;
|
||||
# тогда он зеркалится в `main`, и preflight `workflow_sync` (validate.yml)
|
||||
# держит копии равными.
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
@@ -25,36 +19,15 @@ permissions:
|
||||
actions: read
|
||||
|
||||
jobs:
|
||||
resume:
|
||||
name: "Разбудить раунд, ждавший этот Validate"
|
||||
# Потолок прав тела: объединение job-level прав `_process-resume.yml`. Вызываемый
|
||||
# workflow может права только сузить, поэтому каждая его job по-прежнему
|
||||
# получает свой прежний минимум (#556), а шире этого набора не получит никто.
|
||||
dev:
|
||||
# Тот же фильтр, что у job `resume` тела: посторонние события не
|
||||
# поднимают вызов, а прогон остаётся skipped, как до #623.
|
||||
if: github.event.workflow_run.event == 'workflow_dispatch' && startsWith(github.event.workflow_run.head_branch, 'issue/')
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
concurrency:
|
||||
group: process-resume-${{ github.event.workflow_run.head_branch }}
|
||||
cancel-in-progress: false
|
||||
steps:
|
||||
# Код берётся из dev, как у reconcile: после штатного слияния действует
|
||||
# версия, которую проверил CI, а не копия из main.
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
ref: dev
|
||||
fetch-depth: 1
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
node-version: 22
|
||||
# Метка переставляется HP_PROCESS_TOKEN: событие от GITHUB_TOKEN не
|
||||
# запустило бы process.yml (см. шапку process.yml, п. 1).
|
||||
- name: Решить по маркеру ожидания и переставить S7
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
|
||||
REPO: ${{ github.repository }}
|
||||
BRANCH: ${{ github.event.workflow_run.head_branch }}
|
||||
SHA: ${{ github.event.workflow_run.head_sha }}
|
||||
EVENT: ${{ github.event.workflow_run.event }}
|
||||
STATUS: ${{ github.event.workflow_run.status }}
|
||||
run: |
|
||||
node scripts/process-resume.mjs \
|
||||
--repo="$REPO" --branch="$BRANCH" --sha="$SHA" \
|
||||
--event="$EVENT" --status="$STATUS" --apply=true | tee -a "$GITHUB_STEP_SUMMARY"
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
uses: Matysh/houseplan-card/.github/workflows/_process-resume.yml@dev # #623: тело конвейера из dev
|
||||
secrets: inherit
|
||||
|
||||
+15
-1630
File diff suppressed because it is too large
Load Diff
@@ -66,7 +66,7 @@ jobs:
|
||||
#
|
||||
# Коммиты и деревья скачиваются целиком, поэтому диапазоны и `merge-base`
|
||||
# работают как раньше. Единственная догрузка по требованию здесь —
|
||||
# `git show origin/main:.github/workflows/process.yml`: один блоб.
|
||||
# `git show origin/main:.github/workflows/<тонкий файл>`: по блобу на файл.
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with: { fetch-depth: 0, filter: 'blob:none' }
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
@@ -106,26 +106,32 @@ jobs:
|
||||
continue-on-error: true
|
||||
run: node scripts/reviews-index.mjs --dir=docs/reviews --check
|
||||
|
||||
# Конвейер читает `process.yml` из ветки по умолчанию, поэтому файл обязан
|
||||
# совпадать в `main` и `dev`. До этой проверки совпадение держалось на
|
||||
# дисциплине: каждая правка требовала двух пушей и ручной сверки.
|
||||
- name: "Процесс: process.yml и mutation-gate.yml идентичны в main и dev"
|
||||
# #623: для событий `issues`, `schedule` и `workflow_run` GitHub берёт
|
||||
# workflow из ветки по умолчанию (`main`). Там лежат тонкие вызывающие
|
||||
# файлы — триггеры, run-name, права, concurrency, — а тело каждого
|
||||
# (`_<имя>.yml`) они вызывают по ссылке `@dev`. Правка конвейера — один
|
||||
# коммит в `dev`; тела здесь не сверяются: их копия в `main` не исполняется
|
||||
# ни одним событием. Тонкий файл сверяется: правка триггера или потолка
|
||||
# прав, не доехавшая до `main`, действовала бы только в dev-копии.
|
||||
#
|
||||
# `performance.yml` не входит: по расписанию он судит `main` собственным
|
||||
# телом из `main`, расхождение с `dev` до промоушена законно. Список ниже
|
||||
# держит равным множеству файлов с такими триггерами
|
||||
# test/default-branch-workflows.test.mjs.
|
||||
- name: "Процесс: тонкие вызывающие workflow идентичны в main и dev"
|
||||
id: workflow_sync
|
||||
continue-on-error: true
|
||||
run: |
|
||||
git fetch --quiet origin main dev
|
||||
# #472: расписание mutation-gate.yml тоже исполняется из ветки по
|
||||
# умолчанию — та же ловушка, что у process.yml; #636 добавил
|
||||
# process-resume.yml (событие workflow_run). Сверяются все три.
|
||||
status=0
|
||||
for file in process.yml mutation-gate.yml process-resume.yml; do
|
||||
for file in process.yml mutation-gate.yml process-resume.yml nightly.yml process-reconcile.yml process-metrics.yml; do
|
||||
if diff <(git show "origin/main:.github/workflows/$file") \
|
||||
<(git show "origin/dev:.github/workflows/$file"); then
|
||||
echo "$file: main и dev идентичны"
|
||||
else
|
||||
echo "РАСХОЖДЕНИЕ: $file в main и dev различаются."
|
||||
echo "Файл исполняется из ветки по умолчанию, поэтому"
|
||||
echo "правку нужно отправить в обе ветки."
|
||||
echo "Тонкий вызывающий файл исполняется из ветки по умолчанию,"
|
||||
echo "поэтому правку триггеров или прав нужно зеркалить в main (#623)."
|
||||
status=1
|
||||
fi
|
||||
done
|
||||
@@ -224,7 +230,7 @@ jobs:
|
||||
}
|
||||
echo "### Предполётные проверки" >> "$GITHUB_STEP_SUMMARY"
|
||||
check "документация" "$DOCS"
|
||||
check "process.yml и mutation-gate.yml в main и dev" "$WORKFLOW_SYNC"
|
||||
check "тонкие вызывающие workflow в main и dev" "$WORKFLOW_SYNC"
|
||||
check "провенанс коммитов" "$PROVENANCE"
|
||||
check "процессный гейт" "$PROCESS_GATE"
|
||||
check "пины сторонних Actions" "$ACTION_PINS"
|
||||
|
||||
@@ -466,6 +466,18 @@ cancelled by the next push), and by the beta every issue has already been
|
||||
mutated twice — on review and on the rebased merge candidate; the night runs
|
||||
the full registry (`mutation-gate.yml`), not a diff subset.
|
||||
|
||||
**Workflows run from the default branch are thin callers (#623).** For
|
||||
`issues`, `schedule` and `workflow_run` GitHub executes the file from `main`.
|
||||
The six such files (`process.yml`, `process-resume.yml`,
|
||||
`process-reconcile.yml`, `mutation-gate.yml`, `nightly.yml`,
|
||||
`process-metrics.yml`) carry only triggers, run-name, permissions and
|
||||
concurrency and call their body `_<name>.yml` at `@dev` with
|
||||
`secrets: inherit`. A pipeline change is one commit to `dev` — edit the
|
||||
`_<name>.yml` body; no mirror into `main`, no merge-back before promotion.
|
||||
Only a change of triggers, dispatch inputs or the permission ceiling touches the
|
||||
thin file, and then it is mirrored into `main` (preflight `workflow_sync`
|
||||
compares exactly these six; `PROCESS.md` §10.4).
|
||||
|
||||
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**:
|
||||
|
||||
+24
-3
@@ -990,7 +990,7 @@ SHA. Manifest публикуется и входит в `SHA256SUMS`; свежа
|
||||
|
||||
### 10.4 Событийный конвейер: метка как триггер
|
||||
|
||||
`.github/workflows/process.yml`, issue #114. Смена статусной метки — не запись в
|
||||
`.github/workflows/process.yml` (тело — `_process.yml`, #623), issue #114. Смена статусной метки — не запись в
|
||||
журнал, а **сообщение**: она порождает событие, событие запускает следующий шаг.
|
||||
|
||||
```
|
||||
@@ -1012,13 +1012,34 @@ Medium-находки вне скоупа задачи (#202), кладёт до
|
||||
1. метки переставляет **PAT**, а не `GITHUB_TOKEN`: GitHub намеренно не порождает
|
||||
события от `GITHUB_TOKEN`, чтобы не было циклов, и цепочка обрывалась бы после
|
||||
первого шага без ошибок в логах;
|
||||
2. `process.yml` обязан лежать в **ветке по умолчанию**: для события `issues`
|
||||
GitHub берёт workflow только оттуда, независимо от содержимого `dev`;
|
||||
2. для события `issues` GitHub берёт workflow только из **ветки по умолчанию**
|
||||
(`main`), независимо от содержимого `dev`. Поэтому в `main` лежит тонкий
|
||||
`process.yml` — триггер, run-name, потолок прав, — а тело `_process.yml` он
|
||||
вызывает по ссылке `@dev` (#623). Правило ниже, «Workflow из ветки по
|
||||
умолчанию», — общее для всех таких файлов;
|
||||
3. слияние в `dev` происходит **до** простановки `S8-merged`, иначе метка врёт в
|
||||
промежутке — она утверждает, что код в `dev`;
|
||||
4. многострочный текст внутри `run:` — только через heredoc: строка с нулевым
|
||||
отступом обрывает блок YAML, и скрипт обрезается без ошибки парсера.
|
||||
|
||||
**Workflow из ветки по умолчанию: тонкий файл и тело из `dev`** (#623). Для
|
||||
событий `issues`, `schedule` и `workflow_run` GitHub исполняет workflow из
|
||||
`main`. Таких файлов шесть: `process.yml`, `process-resume.yml`,
|
||||
`process-reconcile.yml`, `mutation-gate.yml`, `nightly.yml`,
|
||||
`process-metrics.yml`. Каждый — тонкий вызывающий: триггеры, run-name,
|
||||
права, concurrency и одна job `uses:
|
||||
Matysh/houseplan-card/.github/workflows/_<имя>.yml@dev` с `secrets: inherit`.
|
||||
Тело `_<имя>.yml` читается из `dev` в момент запуска, поэтому **правка
|
||||
конвейера — один коммит в `dev`**, зеркало в `main` и возврат `main` в `dev`
|
||||
перед промоушеном не нужны. Потолок прав вызывающей job равен объединению
|
||||
прав job тела: вызываемый workflow права только сужает, и каждая job тела
|
||||
получает прежний минимум (#556). Тонкий файл меняется, только когда меняются
|
||||
триггеры, входы ручного запуска или потолок прав; тогда он зеркалится в
|
||||
`main`, и preflight `workflow_sync` в `validate.yml` держит копии равными —
|
||||
сверяются ровно эти шесть файлов, список держит
|
||||
`test/default-branch-workflows.test.mjs`. `performance.yml` в список не входит:
|
||||
по расписанию он судит `main` собственным телом из `main`.
|
||||
|
||||
**Ревью не начинается на красном коде** (#510). После фиксации материала конвейер
|
||||
запускает Validate с мутантами по диффу на этом SHA (`scripts/validate-gate.mjs`:
|
||||
`workflow_dispatch validate.yml -f mutants=true`). **Ждёт его не раннер, а событие**
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Конспект для ревьюера
|
||||
|
||||
Роли: ревьюер ТЗ и ревьюер кода ([§6](../../PROCESS.md#6-роли)). Штатный
|
||||
ревьюер конвейера получает этот файл из промпта `.github/workflows/process.yml`;
|
||||
ревьюер конвейера получает этот файл из промпта `.github/workflows/_process.yml`;
|
||||
ручное ревью по просьбе владельца идёт по нему же.
|
||||
|
||||
> **Это выжимка, а не канон.** Канон процесса — [`PROCESS.md`](../../PROCESS.md);
|
||||
|
||||
@@ -12,6 +12,16 @@
|
||||
* (`./.github/…`), либо `<owner>/<repo>[/<path>]@<40 hex>` с комментарием, где
|
||||
* записана человекочитаемая версия — то, что при обновлении сверяет человек.
|
||||
*
|
||||
* Одно исключение (#623): тело workflow этого же репозитория из ветки `dev` —
|
||||
* `Matysh/houseplan-card/.github/workflows/_<имя>.yml@dev`. Для событий
|
||||
* `issues`/`schedule`/`workflow_run` GitHub исполняет файл из `main`, а локальный
|
||||
* `./…` взял бы тело из того же коммита `main` — и правку конвейера снова
|
||||
* пришлось бы зеркалить. Это не чужой код: `dev` — наша ветка, её коммиты
|
||||
* проходят тот же процессный гейт и ревью, что и всё остальное. Исключение
|
||||
* узкое: только этот репозиторий, только `_*.yml` в `.github/workflows`, только
|
||||
* ссылка `@dev` и только с комментарием о причине. Любой другой ref или
|
||||
* репозиторий — прежняя находка.
|
||||
*
|
||||
* node scripts/action-pins.mjs # проверить
|
||||
* node scripts/action-pins.mjs --list # что и к чему закреплено
|
||||
*
|
||||
@@ -29,6 +39,9 @@ const USES = /^\s*(?:-\s*)?uses:\s*(\S+)(.*)$/;
|
||||
|
||||
/** Локальная переиспользуемая workflow — не сторонний код, пина не требует. */
|
||||
export const isLocal = (spec) => spec.startsWith('./');
|
||||
/** #623: тело workflow этого репозитория из `dev` — наш код, а не сторонний. */
|
||||
export const OWN_DEV_REUSABLE = /^Matysh\/houseplan-card\/\.github\/workflows\/_[\w.-]+\.ya?ml@dev$/;
|
||||
export const isOwnDevReusable = (spec) => OWN_DEV_REUSABLE.test(spec);
|
||||
export const isPinned = (spec) => /^[\w.-]+\/[\w./-]+@[0-9a-f]{40}$/.test(spec);
|
||||
/** Комментарий обязателен: без него человек не знает, какую версию он закрепил. */
|
||||
export const hasVersionNote = (tail) => /#\s*\S/.test(tail);
|
||||
@@ -41,6 +54,10 @@ export function auditWorkflowSource(file, source) {
|
||||
const [, spec, tail] = match;
|
||||
const at = `${file}:${index + 1}`;
|
||||
if (isLocal(spec)) return;
|
||||
if (isOwnDevReusable(spec)) {
|
||||
if (!hasVersionNote(tail)) problems.push(`${at}: «${spec}» без комментария с причиной ссылки на dev`);
|
||||
return;
|
||||
}
|
||||
if (!isPinned(spec)) {
|
||||
problems.push(`${at}: «${spec}» не закреплён полным SHA`);
|
||||
return;
|
||||
|
||||
@@ -4249,7 +4249,7 @@ const MUTANT_DEFINITIONS = [
|
||||
because: 'a marker written after a red or partial run would let the next night skip the registry '
|
||||
+ 'and never file the failure issue again (#620, #472)',
|
||||
patches: [{
|
||||
file: '.github/workflows/mutation-gate.yml',
|
||||
file: '.github/workflows/_mutation-gate.yml',
|
||||
find: " if: needs.material.outputs.reuse != 'true' && needs.mutants.result == 'success' && needs.evidence.result == 'success'",
|
||||
replace: " if: always() && needs.material.outputs.reuse != 'true'",
|
||||
}],
|
||||
@@ -4260,7 +4260,7 @@ const MUTANT_DEFINITIONS = [
|
||||
because: 'a skipped night must be proved by a marker; a failed decision step has to mean '
|
||||
+ 'a full run, not a silent skip (#620)',
|
||||
patches: [{
|
||||
file: '.github/workflows/mutation-gate.yml',
|
||||
file: '.github/workflows/_mutation-gate.yml',
|
||||
find: ' echo "reuse=false" >> "$GITHUB_OUTPUT"',
|
||||
replace: ' echo "reuse=true" >> "$GITHUB_OUTPUT"',
|
||||
}],
|
||||
@@ -4620,7 +4620,7 @@ const MUTANT_DEFINITIONS = [
|
||||
because: 'a nightly that returns green at dispatch time hides a red full run; the job must wait '
|
||||
+ 'for the child and inherit its conclusion (#492 §7)',
|
||||
patches: [{
|
||||
file: '.github/workflows/nightly.yml',
|
||||
file: '.github/workflows/_nightly.yml',
|
||||
find: ' gh run watch "$run_id" --repo "$REPO" --exit-status --interval 30',
|
||||
replace: ' echo "watching skipped" # mutant: dispatch counted as success',
|
||||
}],
|
||||
@@ -8617,7 +8617,7 @@ const MUTANT_DEFINITIONS = [
|
||||
+ 'ветки. Без ожидания шаг возвращается сразу после push, и запуск встаёт на вершину, '
|
||||
+ 'которой на ветке уже нет',
|
||||
patches: [{
|
||||
file: '.github/workflows/process.yml',
|
||||
file: '.github/workflows/_process.yml',
|
||||
find: ' if [ "$seen" = "$after" ]; then settled=true; break; fi',
|
||||
replace: ' settled=true; break',
|
||||
}],
|
||||
@@ -8628,7 +8628,7 @@ const MUTANT_DEFINITIONS = [
|
||||
because: '#551: модель должна читать exact material, подготовленный до её запуска; checkout '
|
||||
+ 'подвижного dev разрывает контракт между зелёным gate и вердиктом',
|
||||
patches: [{
|
||||
file: '.github/workflows/process.yml',
|
||||
file: '.github/workflows/_process.yml',
|
||||
find: ' ref: ${{ needs.prepare.outputs.material_sha }}',
|
||||
replace: ' ref: dev # mutant: moving material',
|
||||
}],
|
||||
@@ -8639,7 +8639,7 @@ const MUTANT_DEFINITIONS = [
|
||||
because: '#551: результат JSON-schema review должен дойти до интеграции объектом; '
|
||||
+ 'перенаправление stdout предиката jq записывает boolean true и роняет публикацию',
|
||||
patches: [{
|
||||
file: '.github/workflows/process.yml',
|
||||
file: '.github/workflows/_process.yml',
|
||||
find: ' printf \'%s\' "$OUT" > "$RUNNER_TEMP/verdict.json"',
|
||||
replace: ' printf \'%s\' "$OUT" | jq -e \'true\' > "$RUNNER_TEMP/verdict.json" # mutant: boolean payload',
|
||||
}],
|
||||
@@ -8650,7 +8650,7 @@ const MUTANT_DEFINITIONS = [
|
||||
because: '#551: structured verdict записан через printf без финального LF; GitHub output '
|
||||
+ 'delimiter обязан начинаться с новой строки, иначе integration падает после зелёной модели',
|
||||
patches: [{
|
||||
file: '.github/workflows/process.yml',
|
||||
file: '.github/workflows/_process.yml',
|
||||
find: ' printf \'\\nEOF_RESULT\\n\' >> "$GITHUB_OUTPUT"',
|
||||
replace: ' printf \'EOF_RESULT\\n\' >> "$GITHUB_OUTPUT" # mutant: delimiter glued to JSON',
|
||||
}],
|
||||
@@ -9113,13 +9113,80 @@ const MUTANT_DEFINITIONS = [
|
||||
replace: "export const isPinned = (spec) => /^[\\w.-]+\\/[\\w./-]+@\\S+$/.test(spec);",
|
||||
}],
|
||||
},
|
||||
// #623: тонкие вызывающие файлы в main и их тела из dev.
|
||||
{
|
||||
id: 'own-reusable-accepts-any-ref',
|
||||
guard: 'node --test --test-name-pattern="#623" test/action-pins.test.mjs',
|
||||
because: '#623: исключение пина для тела из dev этого репозитория, расширенное на любой ref, '
|
||||
+ 'пропустило бы перемещаемую ссылку `@main` или `@feature` мимо #556',
|
||||
patches: [{
|
||||
file: 'scripts/action-pins.mjs',
|
||||
find: "export const OWN_DEV_REUSABLE = /^Matysh\\/houseplan-card\\/\\.github\\/workflows\\/_[\\w.-]+\\.ya?ml@dev$/;",
|
||||
replace: "export const OWN_DEV_REUSABLE = /^Matysh\\/houseplan-card\\/\\.github\\/workflows\\/_[\\w.-]+\\.ya?ml@[\\w./-]+$/;",
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'workflow-sync-forgets-a-thin-caller',
|
||||
guard: 'node --test test/default-branch-workflows.test.mjs',
|
||||
because: '#623: preflight сверял 3 файла из 6 исполняемых из main — правка четвёртого '
|
||||
+ 'доезжала до dev и молча не действовала',
|
||||
patches: [{
|
||||
file: '.github/workflows/validate.yml',
|
||||
find: ' for file in process.yml mutation-gate.yml process-resume.yml nightly.yml process-reconcile.yml process-metrics.yml; do',
|
||||
replace: ' for file in process.yml mutation-gate.yml process-resume.yml nightly.yml process-reconcile.yml; do',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'thin-caller-runs-the-main-body',
|
||||
guard: 'node --test test/default-branch-workflows.test.mjs',
|
||||
because: '#623: ссылка на тело не из dev (локальный `./` или `@main`) исполняла бы копию из '
|
||||
+ 'main — ровно то зеркалирование, которое снимает задача',
|
||||
patches: [{
|
||||
file: '.github/workflows/process.yml',
|
||||
find: ' uses: Matysh/houseplan-card/.github/workflows/_process.yml@dev # #623: тело конвейера из dev',
|
||||
replace: ' uses: ./.github/workflows/_process.yml',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'thin-caller-widens-permissions',
|
||||
guard: 'node --test test/default-branch-workflows.test.mjs',
|
||||
because: '#623/#556: потолок прав вызывающей job шире объединения прав тела отдал бы '
|
||||
+ 'лишнее право каждой job тела без собственного блока permissions',
|
||||
patches: [{
|
||||
file: '.github/workflows/mutation-gate.yml',
|
||||
find: ' issues: write\n uses: Matysh/houseplan-card/.github/workflows/_mutation-gate.yml@dev',
|
||||
replace: ' issues: write\n contents: write\n uses: Matysh/houseplan-card/.github/workflows/_mutation-gate.yml@dev',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'thin-caller-drops-secrets',
|
||||
guard: 'node --test test/default-branch-workflows.test.mjs',
|
||||
because: '#623: без `secrets: inherit` тело получает пустые HP_PROCESS_TOKEN и '
|
||||
+ 'CLAUDE_CODE_OAUTH_TOKEN — конвейер падает на первом шаге с меткой',
|
||||
patches: [{
|
||||
file: '.github/workflows/process-reconcile.yml',
|
||||
find: ' secrets: inherit\n',
|
||||
replace: '',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'mutation-body-keys-marker-on-caller-sha',
|
||||
guard: 'node --test test/default-branch-workflows.test.mjs',
|
||||
because: '#623: в вызываемом workflow github.workflow_sha — SHA вызывающего файла из main; '
|
||||
+ 'маркер повторного использования перестал бы зависеть от версии тела',
|
||||
patches: [{
|
||||
file: '.github/workflows/_mutation-gate.yml',
|
||||
find: ' WORKFLOW_SHA: ${{ job.workflow_sha }}',
|
||||
replace: ' WORKFLOW_SHA: ${{ github.workflow_sha }}',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'review-model-gets-repository-write',
|
||||
guard: 'node --test --test-name-pattern="по job" test/review-doc-guard.test.mjs',
|
||||
because: '#556: недоверенная стадия с правом записи в репозиторий может положить коммит в '
|
||||
+ 'ветку задачи в обход ревью и слияния — потолок прав этой job проверяется дословно',
|
||||
patches: [{
|
||||
file: '.github/workflows/process.yml',
|
||||
file: '.github/workflows/_process.yml',
|
||||
find: ' permissions:\n contents: read\n issues: write\n needs: [guard, prepare]\n',
|
||||
replace: ' permissions:\n contents: write\n issues: write\n needs: [guard, prepare]\n',
|
||||
}],
|
||||
@@ -9131,7 +9198,7 @@ const MUTANT_DEFINITIONS = [
|
||||
+ 'собственный App-токен с дефолтом contents/issues/pull_requests: write, и объявленные '
|
||||
+ '`permissions:` недоверенной стадии перестают быть потолком — молча, зелёным прогоном',
|
||||
patches: [{
|
||||
file: '.github/workflows/process.yml',
|
||||
file: '.github/workflows/_process.yml',
|
||||
find: ' github_token: ${{ secrets.GITHUB_TOKEN }}\n',
|
||||
replace: '',
|
||||
}],
|
||||
@@ -9142,7 +9209,7 @@ const MUTANT_DEFINITIONS = [
|
||||
because: '#551: timeout/cancel/failure модели не является вердиктом; интеграция обязана '
|
||||
+ 'остановиться, сохранить метку и назвать упавшую стадию',
|
||||
patches: [{
|
||||
file: '.github/workflows/process.yml',
|
||||
file: '.github/workflows/_process.yml',
|
||||
find: ' if [ "$REUSE" != "true" ] && [ "$MODEL_RESULT" != "success" ]; then',
|
||||
replace: ' if false; then # mutant: every model result is accepted',
|
||||
}],
|
||||
|
||||
@@ -8,7 +8,7 @@ import test from 'node:test';
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
import {
|
||||
auditRepository, auditWorkflowSource, isLocal, isPinned, hasVersionNote, listWorkflows,
|
||||
auditRepository, auditWorkflowSource, isLocal, isOwnDevReusable, isPinned, hasVersionNote, listWorkflows,
|
||||
} from '../scripts/action-pins.mjs';
|
||||
|
||||
test('#556: в репозитории не осталось незакреплённых Actions', () => {
|
||||
@@ -65,3 +65,32 @@ test('#556: preflight Validate считает пины частью вердик
|
||||
assert.match(preflight, /^\s+check "пины сторонних Actions" "\$ACTION_PINS"$/m,
|
||||
'исход попадает в вердикт, а не теряется в continue-on-error');
|
||||
});
|
||||
|
||||
// #623: тонкие файлы в main вызывают тело из dev этого же репозитория. Исключение
|
||||
// обязано быть узким: другой ref, другой репозиторий или файл вне `_*.yml`
|
||||
// остаются находкой, иначе «@dev» стал бы дверью для любой перемещаемой ссылки.
|
||||
test('#623: тело этого репозитория по ссылке @dev пина не требует, но требует причины', () => {
|
||||
const own = 'Matysh/houseplan-card/.github/workflows/_process.yml@dev';
|
||||
assert.equal(isOwnDevReusable(own), true);
|
||||
assert.deepEqual(auditWorkflowSource('x.yml', ` uses: ${own} # #623: тело из dev\n`), []);
|
||||
const bare = auditWorkflowSource('x.yml', ` uses: ${own}\n`);
|
||||
assert.equal(bare.length, 1);
|
||||
assert.match(bare[0], /без комментария с причиной/);
|
||||
});
|
||||
|
||||
test('#623: исключение @dev не распространяется на другие ref, репозитории и пути', () => {
|
||||
for (const spec of [
|
||||
'Matysh/houseplan-card/.github/workflows/_process.yml@main',
|
||||
'Matysh/houseplan-card/.github/workflows/_process.yml@issue/623-x',
|
||||
'Matysh/houseplan-card/.github/workflows/process.yml@dev',
|
||||
'Matysh/houseplan-card/scripts/_x.yml@dev',
|
||||
'Matysh/houseplan-card-fork/.github/workflows/_process.yml@dev',
|
||||
'Other/houseplan-card/.github/workflows/_process.yml@dev',
|
||||
'actions/checkout@dev',
|
||||
]) {
|
||||
assert.equal(isOwnDevReusable(spec), false, spec);
|
||||
const found = auditWorkflowSource('x.yml', ` uses: ${spec} # comment\n`);
|
||||
assert.equal(found.length, 1, spec);
|
||||
assert.match(found[0], /не закреплён полным SHA/, spec);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
// #623: workflow, которые GitHub исполняет из ветки по умолчанию.
|
||||
//
|
||||
// Для `issues`, `schedule`, `workflow_run` и ещё ряда событий GitHub берёт файл
|
||||
// workflow только из `main`. До #623 тела конвейера лежали в этих файлах
|
||||
// целиком, и каждая правка требовала зеркального коммита в `main`: 26 mirror-
|
||||
// коммитов и 13 merge-back за месяц, а preflight сверял 3 файла из 6.
|
||||
//
|
||||
// Теперь в `main` живут тонкие вызывающие файлы — триггеры, run-name, права,
|
||||
// concurrency, — а тело `_<имя>.yml` они вызывают по ссылке `@dev`. Этот тест
|
||||
// держит проводку: каждый такой файл тонкий, вызывает своё тело из dev, передаёт
|
||||
// секреты и входы, не расширяет права, а preflight сверяет ровно их.
|
||||
//
|
||||
// Полноценного YAML-парсера в зависимостях нет (см. scripts/workflow-jobs.mjs):
|
||||
// разбирается ровно та блочная структура, которую пишут эти файлы; всё
|
||||
// неожиданное — громкая ошибка разбора, а не догадка.
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { readFileSync, readdirSync, existsSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { join } from 'node:path';
|
||||
|
||||
const DIR = fileURLToPath(new URL('../.github/workflows/', import.meta.url));
|
||||
const read = (name) => readFileSync(join(DIR, name), 'utf8');
|
||||
const OWN_REPO = 'Matysh/houseplan-card';
|
||||
|
||||
// События, для которых GitHub берёт workflow из ветки по умолчанию (или из базы
|
||||
// PR — `pull_request_target`), а не из коммита, который их породил.
|
||||
const DEFAULT_BRANCH_EVENTS = new Set([
|
||||
'branch_protection_rule', 'check_run', 'check_suite', 'delete', 'discussion',
|
||||
'discussion_comment', 'fork', 'gollum', 'issue_comment', 'issues', 'label',
|
||||
'milestone', 'page_build', 'project', 'project_card', 'project_column', 'public',
|
||||
'pull_request_target', 'registry_package', 'repository_dispatch', 'schedule',
|
||||
'status', 'watch', 'workflow_run',
|
||||
]);
|
||||
|
||||
// performance.yml по расписанию судит `main` собственным телом из `main`:
|
||||
// исполняемая копия и есть та, что лежит рядом с проверяемым кодом.
|
||||
const SELF_CONSISTENT = new Set(['performance.yml']);
|
||||
|
||||
const isComment = (line) => !line.trim() || line.trimStart().startsWith('#');
|
||||
const indentOf = (line) => line.length - line.trimStart().length;
|
||||
|
||||
/** Верхнеуровневые блоки: ключ → строки блока без строки-заголовка. */
|
||||
function topBlocks(text) {
|
||||
const blocks = new Map();
|
||||
let current = null;
|
||||
for (const line of text.split('\n')) {
|
||||
if (isComment(line) && indentOf(line) === 0) { if (current && !line.trim()) current.push(line); continue; }
|
||||
const key = /^([\w-]+):(.*)$/.exec(line);
|
||||
if (key && indentOf(line) === 0) {
|
||||
current = [];
|
||||
blocks.set(key[1], current);
|
||||
if (key[2].trim() && !/^\s*#/.test(key[2])) current.push(` ${key[2].trim()}`);
|
||||
continue;
|
||||
}
|
||||
if (current) current.push(line);
|
||||
}
|
||||
return blocks;
|
||||
}
|
||||
|
||||
/** Ключи на заданном отступе внутри блока: ключ → строки под ним. */
|
||||
function children(lines, indent) {
|
||||
const out = new Map();
|
||||
let current = null;
|
||||
for (const line of lines) {
|
||||
if (isComment(line)) continue;
|
||||
const at = indentOf(line);
|
||||
if (at < indent) break;
|
||||
if (at === indent) {
|
||||
const key = /^\s*([\w-]+):\s*(.*?)\s*(?:#.*)?$/.exec(line);
|
||||
assert.ok(key, `не разобрана строка ${JSON.stringify(line)}`);
|
||||
current = [];
|
||||
out.set(key[1], current);
|
||||
if (key[2]) current.value = key[2];
|
||||
continue;
|
||||
}
|
||||
if (current) current.push(line);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Плоская карта `scope: level` на заданном отступе. */
|
||||
function permissionMap(lines, indent) {
|
||||
const map = {};
|
||||
for (const [scope, rest] of children(lines, indent)) {
|
||||
assert.equal(rest.length, 0, `права ${scope}: вложенность не ожидается`);
|
||||
map[scope] = rest.value;
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
const LEVEL = { none: 0, read: 1, write: 2 };
|
||||
function unionPermissions(maps) {
|
||||
const out = {};
|
||||
for (const map of maps) {
|
||||
for (const [scope, level] of Object.entries(map)) {
|
||||
assert.ok(level in LEVEL, `уровень ${scope}: ${level}`);
|
||||
if (!(scope in out) || LEVEL[level] > LEVEL[out[scope]]) out[scope] = level;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
const sorted = (map) => Object.fromEntries(Object.entries(map).sort(([a], [b]) => a.localeCompare(b)));
|
||||
|
||||
function triggers(name) {
|
||||
const on = topBlocks(read(name)).get('on');
|
||||
assert.ok(on, `${name}: нет блока on:`);
|
||||
assert.ok(!on.some((line) => /^\s{2}\[/.test(line)), `${name}: on: в строку не поддерживается`);
|
||||
return [...children(on, 2).keys()];
|
||||
}
|
||||
|
||||
const workflows = readdirSync(DIR).filter((name) => /\.ya?ml$/.test(name)).sort();
|
||||
const bodies = workflows.filter((name) => name.startsWith('_'));
|
||||
const fromDefaultBranch = workflows
|
||||
.filter((name) => !name.startsWith('_') && triggers(name).some((event) => DEFAULT_BRANCH_EVENTS.has(event)));
|
||||
const THIN = fromDefaultBranch.filter((name) => !SELF_CONSISTENT.has(name));
|
||||
|
||||
function syncList() {
|
||||
const validate = read('validate.yml');
|
||||
const step = validate.slice(validate.indexOf('id: workflow_sync'));
|
||||
const loop = /for file in ([^;\n]+); do/.exec(step);
|
||||
assert.ok(loop, 'preflight workflow_sync перебирает список файлов');
|
||||
return loop[1].trim().split(/\s+/);
|
||||
}
|
||||
|
||||
test('#623: исполняемые из main файлы найдены по триггерам, а не по памяти', () => {
|
||||
assert.deepEqual(THIN, [
|
||||
'mutation-gate.yml', 'nightly.yml', 'process-metrics.yml', 'process-reconcile.yml',
|
||||
'process-resume.yml', 'process.yml',
|
||||
]);
|
||||
for (const name of SELF_CONSISTENT) assert.ok(fromDefaultBranch.includes(name), `${name}: исключение без причины`);
|
||||
});
|
||||
|
||||
test('#623: preflight сверяет в main и dev ровно тонкие вызывающие файлы', () => {
|
||||
const list = syncList();
|
||||
assert.equal(new Set(list).size, list.length, 'в списке сверки нет повторов');
|
||||
assert.deepEqual([...list].sort(), THIN, 'список сверки = файлы, исполняемые из main');
|
||||
});
|
||||
|
||||
test('#623: performance.yml из main судит main — сверка ему не нужна', () => {
|
||||
const name = 'performance.yml';
|
||||
const events = triggers(name).filter((event) => DEFAULT_BRANCH_EVENTS.has(event));
|
||||
assert.deepEqual(events, ['schedule'], 'из ветки по умолчанию — только расписание');
|
||||
const text = read(name);
|
||||
assert.match(text, /push:\n\s+branches:\n\s+- main\n/);
|
||||
assert.ok(!/ref: dev\b/.test(text), 'кандидат — коммит самого прогона, а не голова dev');
|
||||
});
|
||||
|
||||
for (const name of THIN) {
|
||||
const body = `_${name}`;
|
||||
|
||||
test(`#623: ${name} — тонкий файл, вызывающий ${body} из dev`, () => {
|
||||
const caller = topBlocks(read(name));
|
||||
for (const key of caller.keys()) {
|
||||
assert.ok(['name', 'run-name', 'on', 'permissions', 'concurrency', 'jobs'].includes(key), `${name}: лишний ключ ${key}`);
|
||||
}
|
||||
const jobs = children(caller.get('jobs'), 2);
|
||||
assert.equal(jobs.size, 1, `${name}: ровно одна вызывающая job`);
|
||||
const [job] = jobs.values();
|
||||
const keys = children(job, 4);
|
||||
for (const key of keys.keys()) {
|
||||
assert.ok(['name', 'if', 'permissions', 'uses', 'with', 'secrets'].includes(key), `${name}: у вызывающей job лишний ключ ${key}`);
|
||||
}
|
||||
assert.equal(keys.get('uses')?.value, `${OWN_REPO}/.github/workflows/${body}@dev`, `${name}: тело берётся из dev`);
|
||||
assert.equal(keys.get('secrets')?.value, 'inherit', `${name}: секреты тела наследуются`);
|
||||
assert.ok(existsSync(join(DIR, body)), `${body} существует`);
|
||||
});
|
||||
|
||||
test(`#623: ${body} — только workflow_call, без run-name и concurrency уровня workflow`, () => {
|
||||
const blocks = topBlocks(read(body));
|
||||
assert.deepEqual(triggers(body), ['workflow_call'], `${body}: не запускается сам по себе`);
|
||||
assert.ok(!blocks.has('run-name'), `${body}: run-name живёт у вызывающего`);
|
||||
assert.ok(!blocks.has('concurrency'), `${body}: concurrency уровня workflow живёт у вызывающего`);
|
||||
assert.ok(!/\$\{\{[^}]*\bgithub\.workflow_sha\b/.test(read(body)),
|
||||
`${body}: в вызываемом workflow github.workflow_sha — SHA вызывающего из main, не тела`);
|
||||
});
|
||||
|
||||
test(`#623: ${name} не расширяет права тела и не сужает их`, () => {
|
||||
const caller = topBlocks(read(name));
|
||||
const callee = topBlocks(read(body));
|
||||
const callerTop = permissionMap(caller.get('permissions'), 2);
|
||||
const calleeTop = permissionMap(callee.get('permissions'), 2);
|
||||
assert.deepEqual(sorted(callerTop), sorted(calleeTop), 'права уровня workflow совпадают');
|
||||
const effective = [...children(callee.get('jobs'), 2).values()].map((job) => {
|
||||
const own = children(job, 4).get('permissions');
|
||||
return own ? permissionMap(own, 6) : calleeTop;
|
||||
});
|
||||
const [callerJob] = children(caller.get('jobs'), 2).values();
|
||||
const granted = permissionMap(children(callerJob, 4).get('permissions') || [], 6);
|
||||
assert.deepEqual(sorted(granted), sorted(unionPermissions(effective)),
|
||||
'потолок вызывающей job = объединение прав job тела: шире — лишнее, уже — отказ GitHub на старте');
|
||||
});
|
||||
|
||||
test(`#623: ${name} передаёт телу каждый вход ручного запуска`, () => {
|
||||
const caller = topBlocks(read(name));
|
||||
const dispatch = children(caller.get('on'), 2).get('workflow_dispatch') || [];
|
||||
const dispatchInputs = [...(children(dispatch, 4).get('inputs') ? children(children(dispatch, 4).get('inputs'), 6).keys() : [])];
|
||||
const call = children(topBlocks(read(body)).get('on'), 2).get('workflow_call') || [];
|
||||
const callInputs = [...(children(call, 4).get('inputs') ? children(children(call, 4).get('inputs'), 6).keys() : [])];
|
||||
assert.deepEqual(callInputs.sort(), [...dispatchInputs].sort(), `${body}: те же входы, что у ручного запуска`);
|
||||
const [callerJob] = children(caller.get('jobs'), 2).values();
|
||||
const withBlock = children(callerJob, 4).get('with') || [];
|
||||
const forwarded = children(withBlock, 6);
|
||||
assert.deepEqual([...forwarded.keys()].sort(), [...dispatchInputs].sort(), `${name}: передаётся каждый вход`);
|
||||
for (const [input, rest] of forwarded) {
|
||||
assert.match(rest.value, new RegExp(`^\\$\\{\\{ inputs\\.${input}\\b`), `${name}: ${input} берётся из входа запуска`);
|
||||
}
|
||||
});
|
||||
|
||||
test(`#623: фильтр вызывающей job ${name} совпадает с фильтром тела`, () => {
|
||||
const [callerJob] = children(topBlocks(read(name)).get('jobs'), 2).values();
|
||||
const condition = children(callerJob, 4).get('if')?.value;
|
||||
if (!condition) return;
|
||||
const [first] = children(topBlocks(read(body)).get('jobs'), 2).values();
|
||||
assert.equal(children(first, 4).get('if')?.value, condition,
|
||||
`${name}: if повторяет стража тела — иначе посторонние события либо поднимут тело, либо потеряют запуск`);
|
||||
});
|
||||
}
|
||||
|
||||
test('#623: у каждого тела есть свой тонкий вызывающий, других вызовов нет', () => {
|
||||
assert.deepEqual(bodies, THIN.map((name) => `_${name}`).sort());
|
||||
for (const name of workflows) {
|
||||
const calls = [...read(name).matchAll(/uses:\s*(\S*\/\.github\/workflows\/_[\w.-]+\.ya?ml@\S+|\.\/\.github\/workflows\/_[\w.-]+\.ya?ml)/g)]
|
||||
.map((match) => match[1]);
|
||||
const expected = THIN.includes(name) ? [`${OWN_REPO}/.github/workflows/_${name}@dev`] : [];
|
||||
assert.deepEqual(calls, expected, `${name}: тела вызываются только своими тонкими файлами`);
|
||||
}
|
||||
});
|
||||
+14
-10
@@ -17,7 +17,7 @@ const repoRoot = fileURLToPath(new URL('..', import.meta.url));
|
||||
|
||||
// Дешёвая половина гейта, идёт с обычными юнитами на каждом прогоне. Полный
|
||||
// прогон с пересборкой бандла на мутанта — ночное расписание (#513), он в
|
||||
// .github/workflows/mutation-gate.yml.
|
||||
// .github/workflows/_mutation-gate.yml.
|
||||
//
|
||||
// Реестр, отставший от кода, хуже отсутствующего: он выглядит защитой. Поэтому
|
||||
// дрейф якорей ловится здесь, а не при редком полном прогоне.
|
||||
@@ -281,6 +281,10 @@ test('#458 у каждого модуля горячего пути отрисо
|
||||
// всё позволяла ручному запуску молча отменить расписание.
|
||||
import { readFileSync as readWorkflowFile } from 'node:fs';
|
||||
const mutationWorkflow = readWorkflowFile(
|
||||
new URL('../.github/workflows/_mutation-gate.yml', import.meta.url), 'utf8',
|
||||
);
|
||||
// #623: триггеры и concurrency — у тонкого вызывающего файла, тело — в `_mutation-gate.yml`.
|
||||
const mutationCaller = readWorkflowFile(
|
||||
new URL('../.github/workflows/mutation-gate.yml', import.meta.url), 'utf8',
|
||||
);
|
||||
const validateWorkflowText = readWorkflowFile(
|
||||
@@ -288,13 +292,13 @@ const validateWorkflowText = readWorkflowFile(
|
||||
);
|
||||
|
||||
test('#513 AC1: полный мутационный прогон идёт каждую ночь, не раз в неделю и не перед релизом', () => {
|
||||
assert.match(mutationWorkflow, /- cron: '0 1 \* \* \*'/, 'ежедневно 01:00 UTC');
|
||||
assert.ok(!/cron: '[^']*\* [0-6]'/.test(mutationWorkflow), 'недельного расписания (день недели) быть не должно');
|
||||
assert.match(mutationCaller, /- cron: '0 1 \* \* \*'/, 'ежедневно 01:00 UTC');
|
||||
assert.ok(!/cron: '[^']*\* [0-6]'/.test(mutationCaller), 'недельного расписания (день недели) быть не должно');
|
||||
assert.ok(!mutationWorkflow.includes('перед стабильным релизом'), 'полный прогон — не шаг релиза');
|
||||
});
|
||||
|
||||
test('#472 AC1: у расписания и ручного запуска разные concurrency-группы', () => {
|
||||
assert.match(mutationWorkflow, /group: mutation-gate-\$\{\{ github\.event_name \}\}/);
|
||||
assert.match(mutationCaller, /^concurrency:\n group: mutation-gate-\$\{\{ github\.event_name \}\}/m);
|
||||
});
|
||||
|
||||
test('#472 AC2 / #549: каждый шард сохраняет лог и identity при любом исходе', () => {
|
||||
@@ -358,11 +362,11 @@ test('#549: агрегатор требует четыре evidence одного
|
||||
assert.match(evidence, /ref: \$\{\{ needs\.material\.outputs\.sha \}\}/);
|
||||
assert.match(evidence, /--sha=\$\{\{ needs\.material\.outputs\.sha \}\}/);
|
||||
assert.match(evidence, /--tree=\$\{\{ needs\.material\.outputs\.tree \}\}/);
|
||||
assert.match(evidence, /--workflow-sha=\$\{\{ github\.workflow_sha \}\}/);
|
||||
assert.match(evidence, /--workflow-sha=\$\{\{ job\.workflow_sha \}\}/);
|
||||
assert.match(evidence, /--run-id=\$\{\{ github\.run_id \}\} --run-attempt=\$\{\{ github\.run_attempt \}\}/);
|
||||
assert.match(report, /--require-evidence/);
|
||||
assert.match(report, /ref: \$\{\{ needs\.material\.outputs\.sha \}\}/);
|
||||
assert.match(report, /--workflow-sha=\$\{\{ github\.workflow_sha \}\}/);
|
||||
assert.match(report, /--workflow-sha=\$\{\{ job\.workflow_sha \}\}/);
|
||||
assert.ok(!report.includes('git rev-parse HEAD'));
|
||||
assert.ok(!report.includes('ref: dev'));
|
||||
assert.ok(!report.includes('ref: ${{ github.sha }}'), 'main может не содержать dev-CLI отчётчика');
|
||||
@@ -381,8 +385,8 @@ test('#472 AC7: отсутствие Telegram-секретов не роняет
|
||||
assert.match(telegram, /if \[ -z "\$TOKEN" \] \|\| \[ -z "\$CHAT" \]; then\n\s+echo "::warning::[^\n]*"\n\s+exit 0/);
|
||||
});
|
||||
|
||||
test('#472 AC8: Validate сверяет mutation-gate.yml между main и dev наравне с process.yml', () => {
|
||||
assert.match(validateWorkflowText, /for file in process\.yml mutation-gate\.yml process-resume\.yml; do/);
|
||||
test('#472 AC8 / #623: Validate сверяет тонкий mutation-gate.yml между main и dev наравне с process.yml', () => {
|
||||
assert.match(validateWorkflowText, /for file in process\.yml mutation-gate\.yml process-resume\.yml [^\n]*; do/);
|
||||
});
|
||||
|
||||
// #475. Свидетель гниёт двумя способами: изменился файл, который он патчит,
|
||||
@@ -1022,7 +1026,7 @@ test('#620: пропуск ночи — только по маркеру, мар
|
||||
const restore = material.slice(material.indexOf('actions/cache/restore@'), material.indexOf('- name: Нужен ли прогон'));
|
||||
assert.match(material.slice(material.lastIndexOf('- name:', material.indexOf('actions/cache/restore@'))),
|
||||
/^- name: [^\n]*\n\s+if: github\.event_name == 'schedule'\n/);
|
||||
assert.match(restore, /restore-keys: \|\n\s+mutation-green-v1-\$\{\{ steps\.identity\.outputs\.tree \}\}-\$\{\{ github\.workflow_sha \}\}-\n/);
|
||||
assert.match(restore, /restore-keys: \|\n\s+mutation-green-v1-\$\{\{ steps\.identity\.outputs\.tree \}\}-\$\{\{ job\.workflow_sha \}\}-\n/);
|
||||
// Сбой решения — полный прогон.
|
||||
assert.match(material, /else\n\s+echo "::warning::[^\n]*"\n\s+echo "reuse=false" >> "\$GITHUB_OUTPUT"/);
|
||||
assert.match(material, /--decide/);
|
||||
@@ -1030,7 +1034,7 @@ test('#620: пропуск ночи — только по маркеру, мар
|
||||
assert.match(evidence, /\n if: always\(\) && needs\.material\.outputs\.reuse != 'true'\n/);
|
||||
assert.match(marker, /\n if: needs\.material\.outputs\.reuse != 'true' && needs\.mutants\.result == 'success' && needs\.evidence\.result == 'success'\n/);
|
||||
assert.match(marker, /--write-marker=artifacts\/mutation-green\/marker\.json/);
|
||||
assert.match(marker, /key: mutation-green-v1-\$\{\{ needs\.material\.outputs\.tree \}\}-\$\{\{ github\.workflow_sha \}\}-\$\{\{ github\.run_id \}\}/);
|
||||
assert.match(marker, /key: mutation-green-v1-\$\{\{ needs\.material\.outputs\.tree \}\}-\$\{\{ job\.workflow_sha \}\}-\$\{\{ github\.run_id \}\}/);
|
||||
// Отказ по-прежнему заводит issue; не заводит только принятое доказательство.
|
||||
assert.match(report, /\n if: always\(\) && github\.event_name == 'schedule' && needs\.material\.outputs\.reuse != 'true' && \(needs\.mutants\.result != 'success' \|\| needs\.evidence\.result != 'success'\)\n/);
|
||||
});
|
||||
|
||||
@@ -8,7 +8,7 @@ import { readFileSync } from 'node:fs';
|
||||
const read = (name) => readFileSync(new URL(`../.github/workflows/${name}`, import.meta.url), 'utf8');
|
||||
|
||||
test('nightly ждёт запущенный Validate и падает вместе с ним (#492 §7)', () => {
|
||||
const nightly = read('nightly.yml');
|
||||
const nightly = read('_nightly.yml');
|
||||
assert.match(nightly, /gh workflow run validate\.yml --repo "\$REPO" --ref dev -f full=true/);
|
||||
// найти именно свой прогон: dispatch на dev, созданный не раньше запуска
|
||||
assert.match(nightly, /gh run list --repo "\$REPO" --workflow validate\.yml --branch dev/);
|
||||
@@ -23,7 +23,7 @@ test('nightly ждёт запущенный Validate и падает вмест
|
||||
});
|
||||
|
||||
test('ночная job носит русское имя и не выдаёт очередь за результат (#327, #492)', () => {
|
||||
const nightly = read('nightly.yml');
|
||||
const nightly = read('_nightly.yml');
|
||||
assert.match(nightly, /name: "Запустить Validate на dev с полным набором и дождаться результата"/);
|
||||
assert.ok(!/поставлен в очередь[^\n]*\n\s*$/.test(nightly), 'echo про очередь не может быть последним шагом');
|
||||
});
|
||||
|
||||
@@ -154,7 +154,7 @@ test('#634 конспект: объявляет себя выжимкой, ка
|
||||
});
|
||||
|
||||
test('#634 промпт ревьюера: конспект вместо пересказа, машинные требования на месте', () => {
|
||||
const workflow = read('.github/workflows/process.yml');
|
||||
const workflow = read('.github/workflows/_process.yml');
|
||||
const start = workflow.indexOf(' prompt: |\n');
|
||||
const end = workflow.indexOf(' claude_args: |', start);
|
||||
assert.ok(start > 0 && end > start, 'блок prompt найден');
|
||||
|
||||
@@ -129,9 +129,12 @@ test('#637 buildReport: нулевая медиана не смешиваетс
|
||||
});
|
||||
|
||||
test('#637 workflow: еженедельный запуск читает только, публикует summary и artifact', () => {
|
||||
const wf = readFileSync(new URL('../.github/workflows/process-metrics.yml', import.meta.url), 'utf8');
|
||||
assert.match(wf, /schedule:\n(?:\s+#[^\n]*\n)*\s+- cron: '/);
|
||||
assert.match(wf, /workflow_dispatch:/);
|
||||
const wf = readFileSync(new URL('../.github/workflows/_process-metrics.yml', import.meta.url), 'utf8');
|
||||
// #623: расписание — у тонкого вызывающего файла, тело — в `_process-metrics.yml`.
|
||||
const caller = readFileSync(new URL('../.github/workflows/process-metrics.yml', import.meta.url), 'utf8');
|
||||
assert.match(caller, /schedule:\n(?:\s+#[^\n]*\n)*\s+- cron: '/);
|
||||
assert.match(caller, /workflow_dispatch:/);
|
||||
assert.ok(!/issues: write/.test(caller), 'потолок прав вызывающего тоже без записи в issue');
|
||||
assert.match(wf, /permissions:\n\s+contents: read\n\s+actions: read\n\s+issues: read/);
|
||||
assert.match(wf, /node scripts\/process-metrics\.mjs[\s\S]*--output=artifacts\/process-metrics\/report\.md/);
|
||||
assert.match(wf, /GITHUB_STEP_SUMMARY/);
|
||||
|
||||
@@ -93,19 +93,22 @@ test('#636 resume() relabels only on a resume decision and only with apply', asy
|
||||
});
|
||||
|
||||
test('#636 workflows: prepare exits pending with a sealed marker, resume relabels by the marker, preflight mirrors the new file', () => {
|
||||
const process = readFileSync(new URL('../.github/workflows/process.yml', import.meta.url), 'utf8');
|
||||
const resumeWf = readFileSync(new URL('../.github/workflows/process-resume.yml', import.meta.url), 'utf8');
|
||||
const process = readFileSync(new URL('../.github/workflows/_process.yml', import.meta.url), 'utf8');
|
||||
const resumeWf = readFileSync(new URL('../.github/workflows/_process-resume.yml', import.meta.url), 'utf8');
|
||||
// #623: триггер — у тонкого вызывающего файла, тело — в `_process-resume.yml`.
|
||||
const resumeCaller = readFileSync(new URL('../.github/workflows/process-resume.yml', import.meta.url), 'utf8');
|
||||
const validate = readFileSync(new URL('../.github/workflows/validate.yml', import.meta.url), 'utf8');
|
||||
assert.match(process, /validate-gate\.mjs --repo="\$\{\{ github\.repository \}\}" --ref="\$BRANCH" --sha="\$SHA" --no-wait/);
|
||||
assert.match(process, /2\) echo 'proceed=pending' >> "\$GITHUB_OUTPUT"/);
|
||||
assert.match(process, /review-pending-\$\{NUM\}-\$\{GITHUB_RUN_ID\}-\$\{GITHUB_RUN_ATTEMPT\}/);
|
||||
assert.match(process, /sha256sum pending\.json > manifest\.sha256/);
|
||||
assert.match(process, /Validate красный — вернуть автору без ревью\n\s+if: steps\.rebase\.outputs\.conflict != 'true' && steps\.gate\.outputs\.proceed == 'false'/);
|
||||
assert.match(resumeWf, /workflow_run:\n\s+workflows: \["Проверка \(CI\)"\]\n\s+types: \[completed\]/);
|
||||
assert.match(resumeCaller, /workflow_run:\n\s+workflows: \["Проверка \(CI\)"\]\n\s+types: \[completed\]/);
|
||||
assert.match(resumeWf, /github\.event\.workflow_run\.event == 'workflow_dispatch' && startsWith\(github\.event\.workflow_run\.head_branch, 'issue\/'\)/);
|
||||
assert.match(resumeWf, /GH_TOKEN: \$\{\{ secrets\.HP_PROCESS_TOKEN \}\}/);
|
||||
assert.match(resumeWf, /node scripts\/process-resume\.mjs/);
|
||||
assert.ok(!/issues: write/.test(resumeWf), 'resume relabels with HP_PROCESS_TOKEN only');
|
||||
assert.match(validate, /for file in process\.yml mutation-gate\.yml process-resume\.yml; do/);
|
||||
assert.ok(!/issues: write/.test(resumeCaller), 'the caller ceiling does not grant issues: write either');
|
||||
assert.match(validate, /for file in process\.yml mutation-gate\.yml process-resume\.yml [^\n]*; do/);
|
||||
assert.equal(readFileSync(new URL('../.github/workflows/validate.yml', import.meta.url), 'utf8').includes('name: Проверка (CI)'), true, 'workflow_run listens to the Validate workflow name');
|
||||
});
|
||||
|
||||
@@ -200,7 +200,7 @@ test('#643 CLI: отказ — код 3 и по строке на конфлик
|
||||
|
||||
// ---------- проводка в process.yml: свидетели и настоящий bash ----------
|
||||
|
||||
const WORKFLOW = readFileSync(new URL('../.github/workflows/process.yml', import.meta.url), 'utf8');
|
||||
const WORKFLOW = readFileSync(new URL('../.github/workflows/_process.yml', import.meta.url), 'utf8');
|
||||
const rebaseStep = () => WORKFLOW.slice(
|
||||
WORKFLOW.indexOf(' - name: Привести ветку к dev\n'),
|
||||
WORKFLOW.indexOf(' - name: Зафиксировать SHA материала ревью'),
|
||||
|
||||
@@ -63,7 +63,7 @@ test('allowlist задаётся снаружи и по умолчанию то
|
||||
|
||||
test('шаг публикации в конвейере проверяет и индекс, и то, что уедет (#365 AC4)', () => {
|
||||
const workflow = readFileSync(
|
||||
new URL('../.github/workflows/process.yml', import.meta.url), 'utf8',
|
||||
new URL('../.github/workflows/_process.yml', import.meta.url), 'utf8',
|
||||
);
|
||||
const step = workflow.slice(
|
||||
workflow.indexOf('- name: Опубликовать документ ревью'),
|
||||
@@ -448,7 +448,7 @@ test('момент коллизии на #449: файлы дали бы своб
|
||||
|
||||
test('guard считает раунды скриптом, а не inline-shell (#454 AC9)', () => {
|
||||
const workflow = readFileSync(
|
||||
new URL('../.github/workflows/process.yml', import.meta.url), 'utf8',
|
||||
new URL('../.github/workflows/_process.yml', import.meta.url), 'utf8',
|
||||
);
|
||||
const step = workflow.slice(
|
||||
workflow.indexOf(' - id: decide'),
|
||||
@@ -575,7 +575,7 @@ test('изменённое дерево, жёлтый вердикт, High>0 и
|
||||
});
|
||||
|
||||
test('конвейер: посторонняя метка не входит в concurrency, guard читает текущие метки (#499)', () => {
|
||||
const workflow = readFileSync(new URL('../.github/workflows/process.yml', import.meta.url), 'utf8');
|
||||
const workflow = readFileSync(new URL('../.github/workflows/_process.yml', import.meta.url), 'utf8');
|
||||
// Concurrency — на job, не на workflow: иначе любой `labeled` вытеснял ожидающий S7.
|
||||
const head = workflow.slice(0, workflow.indexOf('\njobs:'));
|
||||
assert.doesNotMatch(head, /^concurrency:/m, 'concurrency на уровне workflow снова пустит в группу все метки');
|
||||
@@ -594,7 +594,7 @@ test('конвейер: посторонняя метка не входит в c
|
||||
});
|
||||
|
||||
test('конвейер: зелёный вердикт применяется повторно без модели, вердикт пишется в якоря (#499)', () => {
|
||||
const workflow = readFileSync(new URL('../.github/workflows/process.yml', import.meta.url), 'utf8');
|
||||
const workflow = readFileSync(new URL('../.github/workflows/_process.yml', import.meta.url), 'utf8');
|
||||
assert.match(workflow, /review-doc-guard\.mjs --reuse --marker=CODE-REVIEW --num="\$NUM" --head=HEAD/);
|
||||
const modelJob = workflow.slice(workflow.indexOf('\n model_review:'), workflow.indexOf('\n integrate:'));
|
||||
assert.match(modelJob, /if: needs\.prepare\.outputs\.proceed == 'true' && needs\.prepare\.outputs\.reuse != 'true'/,
|
||||
@@ -608,7 +608,7 @@ test('конвейер: зелёный вердикт применяется п
|
||||
});
|
||||
|
||||
test('#510 AC2: конвейер запускает Validate с мутантами на материале и не ревьюит красный', () => {
|
||||
const workflow = readFileSync(new URL('../.github/workflows/process.yml', import.meta.url), 'utf8');
|
||||
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');
|
||||
@@ -641,7 +641,7 @@ test('#510 AC2: конвейер запускает Validate с мутантам
|
||||
});
|
||||
|
||||
test('#515: якоря материала снимаются ПОСЛЕ ребейза конвейером и публикуются из шага material', () => {
|
||||
const workflow = readFileSync(new URL('../.github/workflows/process.yml', import.meta.url), 'utf8');
|
||||
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 rebase = at(' - name: Привести ветку к dev\n');
|
||||
const material = at(' - name: Зафиксировать SHA материала ревью\n');
|
||||
@@ -716,7 +716,7 @@ test('#517 AC6: reuse не применяет зелёный вердикт, е
|
||||
});
|
||||
|
||||
test('#517: конвейер снимает хеш тела на материале и передаёт его в якоря, reuse и ревьюеру', () => {
|
||||
const workflow = readFileSync(new URL('../.github/workflows/process.yml', import.meta.url), 'utf8');
|
||||
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');
|
||||
@@ -773,7 +773,9 @@ test('#553: канон разделяет review и исполнение тес
|
||||
|
||||
test('#555: bounded reconciler wakes only lost review requests and emits one machine summary', () => {
|
||||
const read = (rel) => readFileSync(new URL(`../${rel}`, import.meta.url), 'utf8');
|
||||
const workflow = read('.github/workflows/process-reconcile.yml');
|
||||
const workflow = read('.github/workflows/_process-reconcile.yml');
|
||||
// #623: run-name и расписание — у тонких вызывающих файлов, тела — в `_*.yml`.
|
||||
const caller = read('.github/workflows/process-reconcile.yml');
|
||||
const processWorkflow = read('.github/workflows/process.yml');
|
||||
const process = read('PROCESS.md');
|
||||
const agents = read('AGENTS.md');
|
||||
@@ -781,8 +783,8 @@ test('#555: bounded reconciler wakes only lost review requests and emits one mac
|
||||
assert.match(processWorkflow,
|
||||
/run-name: "process #\$\{\{ github\.event\.issue\.number \}\} · \$\{\{ github\.event\.label\.name \}\}/,
|
||||
'run identity includes issue and requested stage');
|
||||
assert.match(workflow, /cron: '7,37 \* \* \* \*'/);
|
||||
assert.match(workflow, /workflow_dispatch:/);
|
||||
assert.match(caller, /cron: '7,37 \* \* \* \*'/);
|
||||
assert.match(caller, /workflow_dispatch:/);
|
||||
assert.match(workflow, /ref: dev/);
|
||||
assert.match(workflow, /secrets\.HP_PROCESS_TOKEN/);
|
||||
assert.match(workflow, /node scripts\/process-reconcile\.mjs[\s\S]*--apply="\$APPLY"/);
|
||||
@@ -796,7 +798,7 @@ test('#555: bounded reconciler wakes only lost review requests and emits one mac
|
||||
});
|
||||
|
||||
test('#551: gates, модель и интеграция имеют независимые jobs, contracts и бюджеты', () => {
|
||||
const workflow = readFileSync(new URL('../.github/workflows/process.yml', import.meta.url), 'utf8');
|
||||
const workflow = readFileSync(new URL('../.github/workflows/_process.yml', import.meta.url), 'utf8');
|
||||
const job = (name, next) => {
|
||||
const start = workflow.indexOf(`\n ${name}:`);
|
||||
assert.ok(start > 0, `job ${name} найден`);
|
||||
@@ -868,7 +870,7 @@ test('#551: gates, модель и интеграция имеют незави
|
||||
// обязан дождаться, что ссылка доехала, и спрашивать об этом REST — через него
|
||||
// же идёт диспатч.
|
||||
test('конвейер: ребейз не заканчивается, пока ссылка не укажет на новую вершину (#539)', () => {
|
||||
const workflow = readFileSync(new URL('../.github/workflows/process.yml', import.meta.url), 'utf8');
|
||||
const workflow = readFileSync(new URL('../.github/workflows/_process.yml', import.meta.url), 'utf8');
|
||||
const rebase = workflow.slice(
|
||||
workflow.indexOf(' - name: Привести ветку к dev\n'),
|
||||
workflow.indexOf(' - name: Зафиксировать SHA материала ревью\n'),
|
||||
@@ -891,7 +893,7 @@ test('конвейер: ребейз не заканчивается, пока
|
||||
// `issues: write` и OIDC каждой стадии, включая единственную недоверенную —
|
||||
// работу модели. Права выдаются по job и по факту использования.
|
||||
test('конвейер: права выдаются по job, модель не пишет в issue (#556)', () => {
|
||||
const workflow = readFileSync(new URL('../.github/workflows/process.yml', import.meta.url), 'utf8');
|
||||
const workflow = readFileSync(new URL('../.github/workflows/_process.yml', import.meta.url), 'utf8');
|
||||
const head = workflow.slice(0, workflow.indexOf('\njobs:'));
|
||||
assert.match(head, /^permissions:\n contents: read\n/m, 'на уровне workflow остаётся только чтение');
|
||||
assert.doesNotMatch(head, /^\s+issues: write$/m, 'issues: write на весь workflow больше не выдаётся');
|
||||
@@ -920,7 +922,7 @@ test('конвейер: права выдаются по job, модель не
|
||||
// `setupGitHubToken`. Свидетель стоит на проводке, потому что снятие одной
|
||||
// строки возвращает модели запись в репозиторий молча — прогон остаётся зелёным.
|
||||
test('ревью: модель работает job-scoped токеном, а не App-обменом (#556)', () => {
|
||||
const workflow = readFileSync(new URL('../.github/workflows/process.yml', import.meta.url), 'utf8');
|
||||
const workflow = readFileSync(new URL('../.github/workflows/_process.yml', import.meta.url), 'utf8');
|
||||
const model = workflow.slice(workflow.indexOf('\n model_review:\n'), workflow.indexOf('\n integrate:\n'));
|
||||
const review = model.slice(model.indexOf(' - name: Review\n'));
|
||||
const withBlock = review.slice(review.indexOf(' with:'), review.indexOf(' prompt: |'));
|
||||
@@ -952,7 +954,7 @@ test('счёт раундов не зависит от числа файлов
|
||||
});
|
||||
|
||||
test('guard перечисляет docs/reviews деревом, а не contents, и без предупреждения о потолке (#621 AC2)', () => {
|
||||
const workflow = readFileSync(new URL('../.github/workflows/process.yml', import.meta.url), 'utf8');
|
||||
const workflow = readFileSync(new URL('../.github/workflows/_process.yml', import.meta.url), 'utf8');
|
||||
const guard = workflow.slice(workflow.indexOf('\n guard:\n'), workflow.indexOf('\n prepare:\n'));
|
||||
assert.doesNotMatch(guard, /contents\/docs\/reviews\?ref=/, 'листинг каталога через contents API снят');
|
||||
assert.doesNotMatch(guard, /1000 файлов/, 'предупреждение о потолке удалено вместе с зависимостью');
|
||||
|
||||
@@ -129,7 +129,7 @@ test('#556: разбор строки sha256sum терпит и пробел, и
|
||||
// Проверка обязана стоять на пути привилегированной стадии, а не просто
|
||||
// существовать в репозитории.
|
||||
test('#556: integrate пропускает artifact только через гейт', () => {
|
||||
const workflow = readFileSync(new URL('../.github/workflows/process.yml', import.meta.url), 'utf8');
|
||||
const workflow = readFileSync(new URL('../.github/workflows/_process.yml', import.meta.url), 'utf8');
|
||||
const integrate = workflow.slice(workflow.indexOf('\n integrate:\n'));
|
||||
const step = integrate.slice(
|
||||
integrate.indexOf(' - name: Проверить полноту и происхождение результата'),
|
||||
|
||||
@@ -109,7 +109,7 @@ test('#635 r3: свежесть индекса судится на dev, конв
|
||||
});
|
||||
|
||||
test('#635 конвейер пересобирает индекс тем же коммитом, что и документ ревью', () => {
|
||||
const wf = new URL('../.github/workflows/process.yml', import.meta.url);
|
||||
const wf = new URL('../.github/workflows/_process.yml', import.meta.url);
|
||||
const text = readFileSync(wf, 'utf8');
|
||||
assert.match(text, /node scripts\/reviews-index\.mjs --dir=docs\/reviews\n\s+git add -- docs\/reviews\/INDEX\.md/);
|
||||
// r2 H1: после приведения ветки к dev индекс пересобирается коммитом
|
||||
|
||||
@@ -406,8 +406,9 @@ test('смоки, golden и performance_smoke условны по heavy (#479)',
|
||||
});
|
||||
|
||||
test('ночной прогон — dispatch Validate на dev с full=true (#479)', () => {
|
||||
const text = read('nightly.yml');
|
||||
assert.match(text, /schedule:\n\s+- cron:/);
|
||||
const text = read('_nightly.yml');
|
||||
// #623: расписание — у тонкого вызывающего `nightly.yml`, тело — в `_nightly.yml`.
|
||||
assert.match(read('nightly.yml'), /schedule:\n\s+- cron:/);
|
||||
assert.match(text, /gh workflow run validate\.yml --repo "\$REPO" --ref dev -f full=true/);
|
||||
assert.match(text, /actions: write/);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user