From 2b1964f973db5eb63130377b8816497334d8c482 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 02:04:40 +0300 Subject: [PATCH] =?UTF-8?q?ci:=20=D0=B1=D0=B0=D0=BD=D0=B4=D0=BB=20=D1=81?= =?UTF-8?q?=D0=BE=D0=B1=D0=B8=D1=80=D0=B0=D0=B5=D1=82=D1=81=D1=8F=20=D0=BE?= =?UTF-8?q?=D0=B4=D0=B8=D0=BD=20=D1=80=D0=B0=D0=B7,=20=D1=87=D0=B5=D1=82?= =?UTF-8?q?=D1=8B=D1=80=D0=B5=20=D0=BB=D1=91=D0=B3=D0=BA=D0=B8=D0=B5=20?= =?UTF-8?q?=D0=B4=D0=B6=D0=BE=D0=B1=D1=8B=20=D1=81=D1=82=D0=B0=D0=BB=D0=B8?= =?UTF-8?q?=20=D0=BE=D0=B4=D0=BD=D0=BE=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Бандл собирался пятью job независимо: три шарда смоков, golden, перф-смок — каждая гоняла `bundle:sync`, то есть `tsc --noEmit` плюс rollup. Теперь его собирает `frontend` и выкладывает артефактом, остальные скачивают и раскладывают `bundle-sync.mjs`. Подмену артефакта отдельной проверкой ловить не нужно: assertFreshDemoBundle сверяет вшитый в бандл отпечаток с sourceFingerprint выкачанного дерева, и каждая браузерная job делает это перед первым кадром. `npm ci` остаётся во всех: браузерным job нужен playwright из node_modules, а не только бандл. Артефакт node_modules был бы медленнее `npm ci` с тёплым кэшем. docs, process-workflow-sync, provenance и process-gate стали шагами одной job `preflight`. Независимость сохранена намеренно: у каждого шага continue-on-error, вердикт в конце падает и перечисляет всё упавшее сразу. Прежняя запись «краснеет сам и не роняет остальные» продолжает действовать — на уровне шагов, с той же гранулярностью в логе. hacs и hassfest не тронуты: предложение сузить их до dev и тегов уже выполнено классификатором `changes` — на ветках задач они и так идут только при правке манифестов, а на dev фильтров нет намеренно (гейт беты требует, чтобы «зелёный Validate» значил одно и то же). test/validate-workflow.test.mjs закрепляет то, что в диффе строк не видно: висячая зависимость `needs` не роняет YAML, а молча пропускает job навсегда. Три мутанта проверены руками — висячая зависимость, вернувшаяся вторая сборка, шаг без continue-on-error, — каждый убит. Issue: #336 User-Visible: no --- .github/workflows/validate.yml | 146 ++++++++++++++++++++++---------- test/validate-workflow.test.mjs | 72 ++++++++++++++++ 2 files changed, 175 insertions(+), 43 deletions(-) create mode 100644 test/validate-workflow.test.mjs diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 515fbc62..3852642f 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -21,27 +21,34 @@ concurrency: cancel-in-progress: true jobs: - docs: - name: "Документация: гайды, ченджлоги, скриншот-индекс" + # Четыре секундных скрипта жили четырьмя job, каждая со своим checkout и + # setup-node ради нескольких секунд работы (#336). Здесь они шаги одной job. + # + # Независимость при этом сохранена намеренно: у каждого шага + # `continue-on-error`, поэтому упавший провенанс не прячет результат + # процессного гейта, а вердикт в конце падает, если упал хоть один. Прежняя + # запись «job независимый: краснеет сам и не роняет остальные» продолжает + # действовать — на уровне шагов, с той же гранулярностью в логе. + preflight: + name: "Предполётные проверки: документация, провенанс, процесс" runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 + with: { fetch-depth: 0 } - uses: actions/setup-node@v7 with: { node-version: 22 } - - name: Validate public documentation + + - name: "Документация: гайды, ченджлоги, скриншот-индекс" + id: docs + continue-on-error: true run: node scripts/check-docs.mjs --external - # Конвейер читает `process.yml` из ветки по умолчанию, поэтому файл обязан - # совпадать в `main` и `dev`. До этой проверки совпадение держалось на - # дисциплине: каждая правка требовала двух пушей и ручной сверки. - process-workflow-sync: - name: "Процесс: process.yml идентичен в main и dev" - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v7 - with: - fetch-depth: 0 - - name: process.yml идентичен в main и dev + # Конвейер читает `process.yml` из ветки по умолчанию, поэтому файл обязан + # совпадать в `main` и `dev`. До этой проверки совпадение держалось на + # дисциплине: каждая правка требовала двух пушей и ручной сверки. + - name: "Процесс: process.yml идентичен в main и dev" + id: workflow_sync + continue-on-error: true run: | git fetch --quiet origin main dev if diff <(git show origin/main:.github/workflows/process.yml) \ @@ -54,15 +61,9 @@ jobs: exit 1 fi - provenance: - name: "Провенанс коммитов: трейлеры и эталоны" - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v7 - with: { fetch-depth: 0 } - - uses: actions/setup-node@v7 - with: { node-version: 22 } - - name: Validate commit trailers and hook mode + - name: "Провенанс коммитов: трейлеры и эталоны" + id: provenance + continue-on-error: true env: EVENT_NAME: ${{ github.event_name }} BEFORE_SHA: ${{ github.event.before }} @@ -73,20 +74,14 @@ jobs: git fetch -q origin "refs/heads/$DEVELOPMENT_BRANCH:refs/remotes/origin/$DEVELOPMENT_BRANCH" node scripts/validate-commit-provenance.mjs --check-hook-mode --github-range - # Догоняющая проверка процесса (PROCESS.md §10.3). Хуки ловят нарушение на - # машине автора, но их можно обойти `--no-verify`, а коммиты идут прямо в dev - # без PR — GitHub на своей стороне не блокирует ничего. Это последнее место, - # где нарушение правила №1 ловится машиной. Job независимый: краснеет сам и - # не роняет остальные, откат — удалить его отсюда, скрипт остаётся рабочим. - process-gate: - name: "Процессный гейт: диапазон, трейлеры, статусы issue" - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v7 - with: { fetch-depth: 0 } - - uses: actions/setup-node@v7 - with: { node-version: 22 } - - name: Process gate + # Догоняющая проверка процесса (PROCESS.md §10.3). Хуки ловят нарушение на + # машине автора, но их можно обойти `--no-verify`, а коммиты идут прямо в + # dev без PR — GitHub на своей стороне не блокирует ничего. Это последнее + # место, где нарушение правила №1 ловится машиной. Откат — удалить шаг, + # скрипт остаётся рабочим. + - name: "Процессный гейт: диапазон, трейлеры, статусы issue" + id: process_gate + continue-on-error: true env: EVENT_NAME: ${{ github.event_name }} BEFORE_SHA: ${{ github.event.before }} @@ -100,6 +95,33 @@ jobs: git fetch -q origin "refs/heads/$DEVELOPMENT_BRANCH:refs/remotes/origin/$DEVELOPMENT_BRANCH" node scripts/process-gate.mjs --github-range --issues + # Вердикт обязан быть громким и перечислять всё упавшее сразу: иначе + # слияние job стоило бы автору лишнего круга «починил одно — узнал о + # втором». + - name: Вердикт предполётных проверок + env: + DOCS: ${{ steps.docs.outcome }} + WORKFLOW_SYNC: ${{ steps.workflow_sync.outcome }} + PROVENANCE: ${{ steps.provenance.outcome }} + PROCESS_GATE: ${{ steps.process_gate.outcome }} + run: | + fail=0 + check() { + if [ "$2" = "success" ]; then + echo "ok $1" + else + echo "FAIL $1 ($2)" + echo "- **$1**: $2" >> "$GITHUB_STEP_SUMMARY" + fail=1 + fi + } + echo "### Предполётные проверки" >> "$GITHUB_STEP_SUMMARY" + check "документация" "$DOCS" + check "process.yml в main и dev" "$WORKFLOW_SYNC" + check "провенанс коммитов" "$PROVENANCE" + check "процессный гейт" "$PROCESS_GATE" + exit $fail + # Классификация изменённых путей: тяжёлые job идут только там, где менялось # относящееся к ним. НА DEV ФИЛЬТРОВ НЕТ: гейт беты принимает «зелёный Validate # на точном SHA», и если объём прогона зависит от diff, «зелёный» перестаёт @@ -285,6 +307,20 @@ jobs: # Копия стенда больше не коммитится (#255): сверяются две обязательные. - name: Card bundle snapshots in sync run: cmp dist/houseplan-card.js custom_components/houseplan/frontend/houseplan-card.js + # Бандл собирается один раз и уезжает артефактом (#336). Прежде каждая из + # пяти браузерных job собирала его заново: `bundle:sync` — это `tsc + # --noEmit` плюс rollup, около минуты на job. + # + # Подмену артефакта ловить не нужно отдельной проверкой: `assertFreshDemoBundle` + # сверяет вшитый в бандл отпечаток с `sourceFingerprint` выкачанного + # дерева, и каждая браузерная job делает это перед первым кадром. + - name: Выложить бандл для браузерных job + uses: actions/upload-artifact@v7 + with: + name: card-bundle + path: dist/houseplan-card.js + retention-days: 1 + if-no-files-found: error smoke: name: Смоки в браузере (шард ${{ matrix.shard }} из 3) @@ -320,8 +356,16 @@ jobs: - name: Install pinned Chromium if: steps.pw.outputs.cache-hit != 'true' run: npx playwright install chromium - - name: Build a fresh bundle for the smokes - run: npm run bundle:sync + # Бандл приходит артефактом из job `frontend` (#336): та же сборка, но + # один раз на прогон. Свежесть проверяет сам продукт — `assertFreshDemoBundle` + # сверяет вшитый отпечаток с исходниками этого дерева. + - name: Забрать собранный бандл + uses: actions/download-artifact@v7 + with: + name: card-bundle + path: dist + - name: Разложить бандл по копиям + run: node scripts/bundle-sync.mjs - name: Smoke suite (шард ${{ matrix.shard }} из 3) env: SHARD: ${{ matrix.shard }} @@ -408,8 +452,16 @@ jobs: - name: Install pinned Chromium if: steps.pw.outputs.cache-hit != 'true' run: npx playwright install chromium - - name: Build the exact source under review - run: npm run bundle:sync + # Бандл приходит артефактом из job `frontend` (#336): та же сборка, но + # один раз на прогон. Свежесть проверяет сам продукт — `assertFreshDemoBundle` + # сверяет вшитый отпечаток с исходниками этого дерева. + - name: Забрать собранный бандл + uses: actions/download-artifact@v7 + with: + name: card-bundle + path: dist + - name: Разложить бандл по копиям + run: node scripts/bundle-sync.mjs - name: Capture or verify golden matrix id: golden run: | @@ -475,8 +527,16 @@ jobs: - name: Install pinned Chromium if: steps.pw.outputs.cache-hit != 'true' run: npx playwright install chromium - - name: Build the exact candidate source - run: npm run bundle:sync + # Бандл приходит артефактом из job `frontend` (#336): та же сборка, но + # один раз на прогон. Свежесть проверяет сам продукт — `assertFreshDemoBundle` + # сверяет вшитый отпечаток с исходниками этого дерева. + - name: Забрать собранный бандл + uses: actions/download-artifact@v7 + with: + name: card-bundle + path: dist + - name: Разложить бандл по копиям + run: node scripts/bundle-sync.mjs - name: Capture the heaviest Glow state run: | npm run benchmark:glow -- --profile=large-house-glow-overlay-v1 --variants=60 --samples=3 --warmups=1 --output=artifacts/performance-smoke/candidate.json diff --git a/test/validate-workflow.test.mjs b/test/validate-workflow.test.mjs new file mode 100644 index 00000000..0b17ba20 --- /dev/null +++ b/test/validate-workflow.test.mjs @@ -0,0 +1,72 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync, readdirSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; + +// #336. Воркфлоу — не текст, а контракт, и ломается он молча: висячая +// зависимость `needs` не роняет YAML, а просто навсегда пропускает job, и +// «зелёный Validate» начинает значить меньше, чем значил. Здесь закреплены +// свойства, которые нельзя увидеть в диффе строк. + +const WORKFLOWS = fileURLToPath(new URL('../.github/workflows/', import.meta.url)); +const read = (name) => readFileSync(new URL(name, `file://${WORKFLOWS}`), 'utf8'); + +/** + * Разбор без yaml-зависимости: имена job — ключи на двух пробелах, значения + * `needs` берутся строкой. Полноценный парсер здесь был бы лишней зависимостью + * ради двух форм записи, которые в этом репозитории и используются. + */ +const jobsOf = (text) => { + const body = text.slice(text.indexOf('\njobs:')); + const names = [...body.matchAll(/^ {2}([a-zA-Z0-9_-]+):$/gm)].map((match) => match[1]); + const needs = new Map(); + for (const name of names) { + const start = body.indexOf(`\n ${name}:\n`); + const next = names + .map((other) => body.indexOf(`\n ${other}:\n`)) + .filter((index) => index > start); + const chunk = body.slice(start, next.length ? Math.min(...next) : body.length); + const line = chunk.match(/^ {4}needs:\s*(.+)$/m)?.[1] || ''; + needs.set(name, [...line.matchAll(/[a-zA-Z0-9_-]+/g)].map((match) => match[0])); + } + return { names, needs }; +}; + +test('ни один воркфлоу не зависит от несуществующей job (#336)', () => { + for (const file of readdirSync(WORKFLOWS).filter((name) => name.endsWith('.yml'))) { + const { names, needs } = jobsOf(read(file)); + assert.ok(names.length, `${file}: не нашлось ни одной job — проверьте разбор`); + for (const [job, list] of needs) { + for (const dependency of list) { + assert.ok(names.includes(dependency), + `${file}: job ${job} зависит от несуществующей ${dependency}`); + } + } + } +}); + +test('бандл собирается один раз и приезжает браузерным job артефактом (#336)', () => { + const workflow = read('validate.yml'); + // Сборка ровно в одном месте. Пять сборок одного и того же бандла — это + // ~10 джобо-минут на каждом непереиспользованном прогоне. + const builds = [...workflow.matchAll(/^ +run: npm run (build|bundle:sync)$/gm)]; + assert.equal(builds.length, 1, 'бандл должен собираться ровно в одной job'); + assert.equal(workflow.match(/name: card-bundle/g)?.length, 4, + 'один upload и три download артефакта бандла'); + // Каждая браузерная job раскладывает скачанный бандл по копиям: без этого + // стенд читает вчерашний файл, а смок врёт согласованно (#236). + assert.equal(workflow.match(/node scripts\/bundle-sync\.mjs/g)?.length, 3); +}); + +test('предполётные проверки не прячут друг друга (#336)', () => { + const workflow = read('validate.yml'); + const preflight = workflow.slice(workflow.indexOf(' preflight:'), workflow.indexOf(' changes:')); + for (const id of ['docs', 'workflow_sync', 'provenance', 'process_gate']) { + assert.ok(preflight.includes(`id: ${id}`), `нет шага ${id}`); + assert.ok(preflight.includes(`steps.${id}.outcome`), `вердикт не читает ${id}`); + } + // Слияние джоб не имеет права превратить четыре независимых сигнала в один + // «первый упавший»: иначе автор узнаёт о втором нарушении следующим кругом. + assert.equal(preflight.match(/continue-on-error: true/g)?.length, 4); + assert.ok(preflight.includes('exit $fail'), 'вердикт обязан падать сам'); +});