mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
ci: бандл собирается один раз, четыре лёгкие джобы стали одной
Бандл собирался пятью 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
This commit is contained in:
+103
-43
@@ -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
|
||||
|
||||
@@ -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'), 'вердикт обязан падать сам');
|
||||
});
|
||||
Reference in New Issue
Block a user