infra: heavy CI gates on the beta candidate, bundle rebase script, gate:small

Validate ran three smoke shards, golden and performance_smoke on every push,
check-docs went red on any src/** change until screenshots were re-captured,
and a parallel bundle build made every second task branch fail to rebase.
None of these gates ever failed at review time; they fail before betas.

- `heavy` output in job `changes` (scripts/classify-changes.mjs): smoke,
  smoke_done, golden, performance_smoke run only for a head commit with a
  `Release:` trailer, `workflow_dispatch full=true` and pull requests.
- nightly.yml dispatches Validate on dev with full=true every night.
- check-docs `--screenshots=warn|strict`: freshness of the screenshot index
  warns on a plain push, errors on the candidate; everything else still errors.
- publish-prerelease.yml and release.yml refuse a candidate without the
  `Release:` trailer and (prerelease) require fresh screenshots — a green
  Validate without the heavy jobs cannot pass for a release.
- scripts/rebase-on-dev.mjs: rebase on origin/dev taking dev's copy of the
  committed bundle, rebuild with bundle:sync, amend; any other conflict aborts.
- npm run gate:small: mandatory PROCESS §8 part in one parallel run.

Issue: #479
User-Visible: no
This commit is contained in:
Claude
2026-09-06 15:39:34 +03:00
parent 88e4cf50e3
commit 7195ad1914
17 changed files with 694 additions and 19 deletions
+38
View File
@@ -0,0 +1,38 @@
# Ночной полный прогон (#479).
#
# Тяжёлые job Validate — смоки, golden, performance_smoke — на обычном пуше не
# идут: они ни разу не ловили дефект в момент ревью и стоили ~6 минут
# критического пути на каждую итерацию. Полный набор идёт на кандидате беты
# (трейлер `Release:`), по кнопке и здесь — каждую ночь на голове `dev`.
#
# Почему не `schedule` прямо в validate.yml: расписание исполняется на ветке
# по умолчанию (`main`), а проверять надо `dev`. Один dispatch с `--ref dev`
# делает это без переписывания checkout во всех job. Reuse (#208) сохраняется:
# при неизменённом дереве ночной прогон обойдётся маркерами.
#
# Красный ночной прогон — сигнал автору последних коммитов на dev, не гейт:
# гейт беты по-прежнему требует зелёный Validate на точном SHA кандидата, и
# там полный набор идёт заново.
name: Ночной полный прогон dev
on:
schedule:
- cron: '30 2 * * *'
workflow_dispatch: {}
permissions:
actions: write
contents: read
jobs:
dispatch:
name: "Запустить Validate на dev с полным набором"
runs-on: ubuntu-latest
steps:
- env:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
run: |
set -euo pipefail
gh workflow run validate.yml --repo "$REPO" --ref dev -f full=true
echo "Validate(dev, full=true) поставлен в очередь: $(date -u +%FT%TZ)"
+17
View File
@@ -54,6 +54,23 @@ jobs:
env:
TAG: ${{ inputs.tag }}
run: node scripts/release-contract.mjs "$TAG" --repo="$GITHUB_REPOSITORY"
# #479: тяжёлые job Validate идут только на коммите с трейлером `Release:`.
# Зелёный Validate без трейлера означал бы прогон без смоков и golden —
# класс тихого пропуска #171/#207, поэтому трейлер проверяется здесь явно.
- name: Require the Release trailer on the candidate commit
env:
SHA: ${{ steps.candidate.outputs.sha }}
run: |
set -euo pipefail
git log -1 --format=%B "$SHA" > /tmp/head-message.txt
if ! grep -Eq '^Release:[[:space:]]*v?[0-9]+\.[0-9]+\.[0-9]+' /tmp/head-message.txt; then
echo "::error::Candidate $SHA has no Release: trailer — Validate ran without the heavy gates (#479)"
exit 1
fi
# #479: свежесть скриншотов на обычном пуше — предупреждение; на
# кандидате она обязана быть доказана строгим режимом.
- name: Documentation screenshots are fresh for the candidate
run: node scripts/check-docs.mjs --screenshots=strict
- name: Require green Validate for this exact SHA
env:
GH_TOKEN: ${{ github.token }}
+6
View File
@@ -34,6 +34,12 @@ jobs:
# target_commitish (it may be a branch name) or an event-context SHA.
SHA=$(git rev-parse HEAD)
echo "release tag: $TAG; exact commit: $SHA"
# #479: тяжёлые job Validate идут только на коммите с трейлером
# `Release:`; без него зелёный Validate прогона без смоков не доказывает.
if ! git log -1 --format=%B "$SHA" | grep -Eq '^Release:[[:space:]]*v?[0-9]+\.[0-9]+\.[0-9]+'; then
echo "::error::$SHA has no Release: trailer — Validate ran without the heavy gates (#479)"
exit 1
fi
node scripts/release-gate.mjs "$SHA"
- name: Require full performance for a stable release
if: ${{ !github.event.release.prerelease }}
+40 -8
View File
@@ -13,6 +13,14 @@ on:
paths-ignore:
- 'docs/reviews/**'
pull_request:
# Полный набор тяжёлых job по кнопке и по ночам (#479): nightly.yml делает
# `gh workflow run validate.yml --ref dev -f full=true`.
workflow_dispatch:
inputs:
full:
description: 'Полный набор: смоки, golden, performance_smoke'
type: boolean
default: true
# A new push supersedes an unfinished validation for the same branch or PR.
# Exact-SHA release gates never depend on an obsolete commit.
@@ -53,10 +61,23 @@ jobs:
- uses: actions/setup-node@v7
with: { node-version: 22 }
# Свежесть скриншотов на обычном пуше — предупреждение, на кандидате беты
# (трейлер `Release:`), по кнопке и на PR — ошибка (#479). Остальные
# проверки документации красят всегда. Режим считает тот же скрипт, что и
# выход `heavy` job `changes`, чтобы два места не разошлись.
- name: "Документация: гайды, ченджлоги, скриншот-индекс"
id: docs
continue-on-error: true
run: node scripts/check-docs.mjs --external
env:
EVENT_NAME: ${{ github.event_name }}
HEAD_MESSAGE: ${{ github.event.head_commit.message }}
FULL_INPUT: ${{ inputs.full }}
run: |
heavy=$(node scripts/classify-changes.mjs --heavy)
mode=warn
[ "$heavy" = "heavy=true" ] && mode=strict
echo "скриншоты документации: режим $mode ($heavy)"
node scripts/check-docs.mjs --external --screenshots=$mode
# Конвейер читает `process.yml` из ветки по умолчанию, поэтому файл обязан
# совпадать в `main` и `dev`. До этой проверки совпадение держалось на
@@ -191,6 +212,8 @@ jobs:
# Диффозависимые профили перф-смока (#473 §5).
perf_iso: ${{ steps.classify.outputs.perf_iso }}
perf_interaction: ${{ steps.classify.outputs.perf_interaction }}
# Тяжёлые job только на кандидате/по кнопке/на PR (#479), см. шаг heavy.
heavy: ${{ steps.heavy.outputs.heavy }}
base: ${{ steps.base.outputs.base }}
# Разные вещи под разными именами намеренно: `base` — до какого коммита
# классифицировать файлы ветки (#387), `range_base` — от какого коммита
@@ -207,6 +230,15 @@ jobs:
# то есть допущение «до этого всё проверено». Concurrency отменяет прогон
# предыдущего пуша штатно, и на #86 (r5) это дало зелёный статус ветки
# без единого исполненного тяжёлого гейта.
# Смоки, golden, performance_smoke — на кандидате беты (трейлер
# `Release:`), по `workflow_dispatch full=true`, на PR (#479). Логика и
# её тест — scripts/classify-changes.mjs.
- id: heavy
env:
EVENT_NAME: ${{ github.event_name }}
HEAD_MESSAGE: ${{ github.event.head_commit.message }}
FULL_INPUT: ${{ inputs.full }}
run: node scripts/classify-changes.mjs --heavy | tee -a "$GITHUB_OUTPUT"
- id: base
if: github.event_name != 'pull_request'
env:
@@ -506,8 +538,8 @@ jobs:
smoke:
name: Смоки в браузере (шард ${{ matrix.shard }} из 3)
# Gated on `frontend` so a typecheck failure does not burn browser minutes.
needs: [frontend, reuse]
if: needs.reuse.outputs.smoke != 'true'
needs: [changes, frontend, reuse]
if: needs.changes.outputs.heavy == 'true' && needs.reuse.outputs.smoke != 'true'
runs-on: ubuntu-latest
timeout-minutes: 20
# Смоки шардируются: последовательный прогон занимал ~7.5 минут и был
@@ -610,8 +642,8 @@ jobs:
# частично прогнанная матрица не имеет права выглядеть как выполненная работа.
smoke_done:
name: "Смоки: все шарды зелёные"
needs: [smoke, reuse]
if: needs.reuse.outputs.smoke != 'true'
needs: [changes, smoke, reuse]
if: needs.changes.outputs.heavy == 'true' && needs.reuse.outputs.smoke != 'true'
runs-on: ubuntu-latest
steps:
- name: Записать маркер успеха
@@ -633,8 +665,8 @@ jobs:
name: Golden-кадры против принятых эталонов
# Deterministic visual correctness stays in every prerelease gate: it is
# inexpensive and catches a different class of regressions than timings.
needs: [frontend, reuse]
if: needs.reuse.outputs.golden != 'true'
needs: [changes, frontend, reuse]
if: needs.changes.outputs.heavy == 'true' && needs.reuse.outputs.golden != 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
@@ -732,7 +764,7 @@ jobs:
# Candidate-only catastrophic-regression guard for ordinary pushes and
# prereleases. The expensive same-runner comparison lives in performance.yml.
needs: [changes, frontend, reuse]
if: needs.reuse.outputs.performance_smoke != 'true'
if: needs.changes.outputs.heavy == 'true' && needs.reuse.outputs.performance_smoke != 'true'
runs-on: ubuntu-latest
# 15 минут не хватало, когда установка браузера шла через apt: замер
# начинался на исходе окна (#206). Запас на холодный кэш — при попадании
+23 -2
View File
@@ -153,8 +153,12 @@ in between.
If the rebase conflicts the pipeline says so in the issue and sends the task back
to `S6-in-progress`. The verdict still stands: nothing needs reviewing again, the
remaining work is the rebase. Resolve it, push the branch, re-apply
`S7-code-review`. The second review run is not a formality — after a rebase onto a
remaining work is the rebase. When the conflict is only in the committed bundle
(`dist/**`, `custom_components/houseplan/frontend/**` — the usual case when two
tasks built it in parallel), run `node scripts/rebase-on-dev.mjs` (#479): it takes
`dev`'s copy through the rebase, rebuilds with `npm run bundle:sync` and amends
the result into your last commit; a conflict anywhere else aborts and leaves the
tree as it was. Then push the branch and re-apply `S7-code-review`. The second review run is not a formality — after a rebase onto a
moved `dev` this is different code, and accepting it unchecked is how regressions
arrive. Cycles are counted per stage, so a code review spends its own budget.
@@ -334,6 +338,23 @@ npm run bundle:sync # dist → custom_components + demo/srv/assets (#255)
npm run bundle:budget # initial View graph <= 256000 B gzip (#337)
```
`npm run gate:small` runs the mandatory part of PROCESS §8 in one go (#479):
unit tests, build with typecheck, `no-new-any` and `smoke-select` in parallel,
then the bundle-tree comparison and the bundle budget. It prints the smokes the
diff selects but does not run them — those, `golden`, `pytest` and `check-docs
--screenshots=strict` remain the author's call by diff and AC.
**Heavy CI gates run on the beta candidate, nightly and on demand — not on every
push (#479).** `smoke`, `golden` and `performance_smoke` in Validate are gated
on the `heavy` output: true for a head commit carrying a `Release:` trailer, for
`workflow_dispatch full=true` (which `nightly.yml` issues on `dev` every night)
and for pull requests. A plain push to `dev` runs preflight, frontend (types,
units, build, bundle sync, no-new-any), backend, hacs and hassfest. Screenshot
freshness in `check-docs` is likewise a warning on a plain push and an error on
the candidate; `publish-prerelease.yml` and `release.yml` refuse a candidate
without the `Release:` trailer, so a green Validate without the heavy jobs can
never pass for a release.
During the implementation cycle the fast gates always run. Since 2026-08-14 the
owner's machine also carries Playwright with Chromium (Windows) and a full WSL
environment, which changes one thing (#151): **before moving an issue to
+6 -4
View File
@@ -2264,10 +2264,12 @@ fixture with 60 rooms, 200 devices, 100 openings, 60 partitions, 40 columns and
switch, HA state update, shared-wall resize preview, pan/zoom, settings-dialog render, repeated navigation,
Long Tasks, warmed hot-cache growth and post-GC heap growth.
Every blocking `Validate` uses a candidate-only `performance_smoke`: one
warm-up and three measured samples of the heaviest 60-source Glow state. It
enforces absolute timing, Long Task, heap, cache and 200-device ceilings, but
does not claim to detect small relative regressions.
`Validate` carries a `performance_smoke`: one warm-up and three measured
samples of the heaviest 60-source Glow state. It enforces absolute timing, Long
Task, heap, cache and 200-device ceilings, but does not claim to detect small
relative regressions. Together with the browser smokes and `golden` it runs on
the beta candidate (a head commit with a `Release:` trailer), on the nightly
`full=true` dispatch of `dev` and on pull requests — not on every push (#479).
The dedicated `Full Performance` workflow builds the candidate and base SHA,
then captures seven measured samples for each sequentially on the same Node 22,
+1
View File
@@ -11,6 +11,7 @@
"test": "tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs && node --test test/*.test.mjs",
"benchmark:wall-model": "tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs && node scripts/benchmark-wall-segment-model.mjs",
"bundle:sync": "npm run build && node scripts/bundle-sync.mjs",
"gate:small": "node scripts/gate-small.mjs",
"bundle:budget": "node scripts/bundle-budget.mjs",
"invariants": "tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs && node scripts/model-invariants.mjs",
"docs:capture": "node scripts/assert-capture-env.mjs docs && npm run build && node demo/docs/capture.mjs",
+5 -2
View File
@@ -5,6 +5,7 @@ import { dirname, extname, resolve, sep } from 'node:path';
import { fileURLToPath } from 'node:url';
import { DOC_SCREENSHOTS } from '../demo/docs/screenshots.mjs';
import { visualFingerprint } from './source-fingerprint.mjs';
import { freshnessSink, screenshotsMode } from './docs-freshness.mjs';
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const EXTERNAL = process.argv.includes('--external');
@@ -17,6 +18,8 @@ const PUBLIC_DOCS = [
const EXPECTED_SCREENSHOTS = DOC_SCREENSHOTS.map((scenario) => scenario.id);
const errors = [];
const warnings = [];
// Свежесть скриншотов: `--screenshots=warn|strict`, см. docs-freshness.mjs (#479).
const freshness = freshnessSink(screenshotsMode(process.argv), { errors, warnings });
const externalUrls = new Set();
const sha256 = (value) => createHash('sha256').update(value).digest('hex');
const canonicalText = (path) => readFileSync(path, 'utf8').replace(/\r\n?/g, '\n');
@@ -204,10 +207,10 @@ if (!existsSync(manifestPath)) {
// пикселя, поэтому не обязан требовать пересъёмки — иначе каждый релизный
// коммит оставляет этот гейт красным.
if (manifest.sourceFingerprint !== visualFingerprint(ROOT))
errors.push('screenshot source fingerprint is stale; run npm run build && node demo/docs/capture.mjs');
freshness.push('screenshot source fingerprint is stale; run npm run docs:capture and accept before the beta candidate (#479)');
const scriptPath = resolve(ROOT, 'demo/docs/capture.mjs');
if (manifest.captureScriptSha256 !== sha256(readFileSync(scriptPath)))
errors.push('screenshot capture script changed; run npm run build && node demo/docs/capture.mjs');
freshness.push('screenshot capture script changed; run npm run docs:capture and accept before the beta candidate (#479)');
const ids = Object.keys(manifest.scenarios || {});
if (JSON.stringify(ids.sort()) !== JSON.stringify([...EXPECTED_SCREENSHOTS].sort()))
errors.push('screenshot manifest scenario set is incomplete');
+37 -3
View File
@@ -45,6 +45,29 @@ export function classifyAll() {
return Object.fromEntries(OUTPUTS.map((name) => [name, 'true']));
}
/**
* Нужен ли полный набор тяжёлых job — смоки, golden, performance_smoke (#479).
*
* На обычном пуше они не идут: за всё время они не ловили дефект в момент
* ревью, ловили при подготовке беты, а стоили ~6 минут критического пути на
* каждую итерацию. Полный набор идёт там, где он и нужен:
* - кандидат беты/релиза — head-коммит несёт трейлер `Release:` (класс D
* без него и так невалиден, а publish-prerelease требует трейлер явно);
* - `workflow_dispatch` с `full=true` — ночной прогон (nightly.yml) и ручной;
* - pull_request — там Validate единственный сигнал.
*/
export function heavyGatesRequested({ eventName, headMessage, fullInput } = {}) {
if (eventName === 'pull_request') return true;
if (eventName === 'workflow_dispatch') return String(fullInput) === 'true';
if (eventName === 'schedule') return true;
return hasReleaseTrailer(headMessage);
}
/** Трейлер `Release: vX.Y.Z` в конце сообщения коммита — признак кандидата. */
export function hasReleaseTrailer(message) {
return /^Release:\s*v?\d+\.\d+\.\d+\S*\s*$/m.test(String(message || ''));
}
/** Формат `$GITHUB_OUTPUT`. */
export function formatOutputs(outputs) {
return OUTPUTS.map((name) => `${name}=${outputs[name]}`).join('\n') + '\n';
@@ -53,7 +76,18 @@ export function formatOutputs(outputs) {
const invokedDirectly = process.argv[1]
&& import.meta.url === new URL(`file://${process.argv[1]}`).href;
if (invokedDirectly) {
const all = process.argv.includes('--all');
const outputs = all ? classifyAll() : classifyChanges(readFileSync(0, 'utf8'));
process.stdout.write(formatOutputs(outputs));
if (process.argv.includes('--heavy')) {
// Отдельный вызов: у `heavy` другие входы (событие, сообщение head-коммита),
// и на dev он нужен даже там, где классификация путей выключена.
const heavy = heavyGatesRequested({
eventName: process.env.EVENT_NAME,
headMessage: process.env.HEAD_MESSAGE,
fullInput: process.env.FULL_INPUT,
});
process.stdout.write(`heavy=${heavy ? 'true' : 'false'}\n`);
} else {
const all = process.argv.includes('--all');
const outputs = all ? classifyAll() : classifyChanges(readFileSync(0, 'utf8'));
process.stdout.write(formatOutputs(outputs));
}
}
+28
View File
@@ -0,0 +1,28 @@
// Режим проверки свежести скриншотов документации (#479).
//
// Отпечаток скриншотов считается по всему `src/**`, поэтому любая правка
// фронтенда делает его устаревшим, а содержательно кадры меняются раз в
// несколько бет. Две проверки свежести — отпечаток и capture-скрипт — в режиме
// `warn` предупреждают, не роняя код выхода; всё остальное у `check-docs`
// (гайды, ссылки, хеши картинок, полнота набора сцен) красит в обоих режимах.
// Умолчание — `strict`: старый вызов без флага не ослабевает молча. `warn`
// включают preflight на обычном пуше; кандидат беты и релизный гейт — `strict`.
export const SCREENSHOT_MODES = ['warn', 'strict'];
export function screenshotsMode(argv) {
const flag = (argv || []).find((arg) => arg.startsWith('--screenshots='));
if (!flag) return 'strict';
const mode = flag.slice('--screenshots='.length);
if (!SCREENSHOT_MODES.includes(mode)) {
throw new Error(`--screenshots expects ${SCREENSHOT_MODES.join('|')}, got "${mode}"`);
}
return mode;
}
/** Куда класть находку свежести: в предупреждения (`warn`) или в ошибки. */
export function freshnessSink(mode, { errors, warnings }) {
if (mode === 'warn') return warnings;
if (mode === 'strict') return errors;
throw new Error(`unknown screenshots mode "${mode}"`);
}
+97
View File
@@ -0,0 +1,97 @@
#!/usr/bin/env node
// Локальный гейт лёгкого трека одной командой (#479): `npm run gate:small`.
//
// PROCESS §8 перечисляет автору шесть команд, и в #476 они гонялись
// последовательно, вперемешку с гейтами, к задаче не относящимися. Здесь
// обязательная часть §8 идёт параллельно — юниты, сборка с typecheck, «новый
// код не добавляет any», выбор смоков по диффу — а затем сверяется бандл. Что
// НЕ входит и остаётся по диффу и AC: сами смоки (их список печатается),
// golden, pytest, инварианты модели, check-docs в строгом режиме.
//
// npm run gate:small # база origin/dev
// npm run gate:small -- --base=origin/dev # явная база диапазона
import { spawn } from 'node:child_process';
import { resolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const npm = process.platform === 'win32' ? 'npm.cmd' : 'npm';
export function parseArgs(argv) {
const base = argv.find((a) => a.startsWith('--base='))?.slice('--base='.length) || 'origin/dev';
return { base };
}
/** Шаги параллельной фазы: имя → команда. `base` — начало диапазона диффа. */
export function parallelSteps(base) {
return [
{ name: 'юниты (npm test)', cmd: npm, args: ['test'] },
{ name: 'сборка + typecheck (npm run build)', cmd: npm, args: ['run', 'build'] },
{ name: 'новый код не добавляет any', cmd: process.execPath, args: ['scripts/no-new-any.mjs', '--base', base, '--head', 'HEAD'] },
{ name: 'смоки по диффу (smoke-select)', cmd: process.execPath, args: ['scripts/smoke-select.mjs', '--base', base, '--head', 'HEAD'], informational: true },
];
}
/** Фаза после сборки: три копии бандла совпадают, бюджет не превышен. */
export function serialSteps() {
return [
{ name: 'копии бандла совпадают (bundle-tree)', cmd: process.execPath, args: ['scripts/bundle-tree.mjs', 'dist', 'custom_components/houseplan/frontend'], hint: 'npm run bundle:sync' },
{ name: 'бюджет бандла', cmd: npm, args: ['run', 'bundle:budget'] },
];
}
function runStep(step, cwd) {
return new Promise((done) => {
const started = Date.now();
const child = spawn(step.cmd, step.args, { cwd, shell: process.platform === 'win32', env: process.env });
let out = '';
child.stdout.on('data', (chunk) => { out += chunk; });
child.stderr.on('data', (chunk) => { out += chunk; });
child.on('close', (code) => done({ ...step, code, out, ms: Date.now() - started }));
child.on('error', (error) => done({ ...step, code: 1, out: String(error), ms: Date.now() - started }));
});
}
export function summarize(results) {
const lines = []; let failed = 0;
for (const r of results) {
const ok = r.code === 0;
if (!ok && !r.informational) failed += 1;
const mark = ok ? 'ok ' : (r.informational ? 'info' : 'FAIL');
lines.push(`${mark} ${String(Math.round(r.ms / 1000)).padStart(4)} с ${r.name}${!ok && r.hint ? ` → ${r.hint}` : ''}`);
}
return { lines, failed };
}
export async function gateSmall({ cwd = ROOT, base = 'origin/dev', log = console.log } = {}) {
const started = Date.now();
log(`gate:small — база диапазона ${base}; параллельно: юниты, сборка, no-new-any, smoke-select`);
const parallel = await Promise.all(parallelSteps(base).map((step) => runStep(step, cwd)));
const buildOk = parallel.find((r) => r.args.includes('build'))?.code === 0;
const serial = [];
if (buildOk) for (const step of serialSteps()) serial.push(await runStep(step, cwd));
const results = [...parallel, ...serial];
const { lines, failed } = summarize(results);
log('');
for (const line of lines) log(line);
const select = parallel.find((r) => r.args.includes('scripts/smoke-select.mjs'));
if (select) {
log('');
log('смоки, относящиеся к диффу (гоняются автором отдельно, решение по каждой строке — в ревью):');
log(select.out.trim() || ' (smoke-select ничего не напечатал)');
}
for (const r of results) {
if (r.code !== 0 && !r.informational) { log(''); log(`--- ${r.name}`); log(r.out.trim()); }
}
log('');
log(`итого ${Math.round((Date.now() - started) / 1000)} с; упало: ${failed}${buildOk ? '' : ' (сверка бандла пропущена — сборка не прошла)'}`);
return { failed, results };
}
const invokedDirectly = process.argv[1]
&& resolve(process.argv[1]) === resolve(fileURLToPath(import.meta.url));
if (invokedDirectly) {
const { base } = parseArgs(process.argv.slice(2));
gateSmall({ base }).then(({ failed }) => { process.exitCode = failed ? 1 : 0; });
}
+140
View File
@@ -0,0 +1,140 @@
#!/usr/bin/env node
// Ребейз ветки задачи на origin/dev без ручных конфликтов в бандле (#479).
//
// Бандл лежит в репозитории (класс D: dist/**, custom_components/houseplan/
// frontend/**), поэтому две задачи, собравшие его параллельно, конфликтуют на
// нём всегда — 1.16 МБ минифицированного текста плюс переименованные
// content-hashed чанки. Руками это не решается, решается пересборкой. Скрипт
// делает ровно это: при конфликте ТОЛЬКО в сгенерированных путях берёт версию
// dev, доводит ребейз до конца, пересобирает бандл (`npm run bundle:sync`) и,
// если он отличается, амендит последний коммит ветки. Конфликт в любом другом
// пути — останов с `git rebase --abort`: содержательные конфликты решает автор.
//
// node scripts/rebase-on-dev.mjs # ребейз текущей ветки
// node scripts/rebase-on-dev.mjs --dry-run # только план, дерево не трогается
//
// Дерево должно быть чистым. Ветка `dev` сама себя не ребейзит.
import { spawnSync } from 'node:child_process';
import { existsSync, rmSync } from 'node:fs';
import { resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
export const GENERATED_ROOTS = ['dist/', 'custom_components/houseplan/frontend/'];
export const isGenerated = (path) => GENERATED_ROOTS.some((root) => path.startsWith(root));
/** Разделить конфликтующие пути: сгенерированные решаем сами, остальные — нет. */
export function splitConflicts(paths) {
const generated = []; const manual = [];
for (const path of paths.map((p) => p.trim()).filter(Boolean)) {
(isGenerated(path) ? generated : manual).push(path);
}
return { generated, manual };
}
export function makeGit(cwd) {
return (args, { allowFailure = false, input } = {}) => {
const result = spawnSync('git', args, {
cwd, encoding: 'utf8', input, stdio: ['pipe', 'pipe', 'pipe'],
env: { ...process.env, GIT_EDITOR: 'true' },
});
if (result.error) throw result.error;
if (result.status !== 0 && !allowFailure) {
throw new Error(`git ${args.join(' ')} → ${(result.stderr || result.stdout || '').trim()}`);
}
return { ok: result.status === 0, stdout: (result.stdout || '').trim(), stderr: (result.stderr || '').trim() };
};
}
/**
* Во время rebase «ours» — это upstream (dev), «theirs» — переигрываемый
* коммит ветки. Для сгенерированного пути берём dev: если в dev файла нет
* (чанк переименован), путь удаляется — пересборка вернёт актуальное имя.
*/
export function resolveGeneratedConflict(git, path) {
const inOurs = git(['cat-file', '-e', `:2:${path}`], { allowFailure: true }).ok;
if (inOurs) {
git(['checkout', '--ours', '--', path]);
git(['add', '--', path]);
return 'dev';
}
git(['rm', '--cached', '-f', '--quiet', '--', path], { allowFailure: true });
rmSync(resolve(git.cwd, path), { force: true });
return 'removed';
}
export function rebaseOnDev({
cwd = process.cwd(), upstream = 'origin/dev', dryRun = false,
syncCommand = ['npm', 'run', 'bundle:sync'], log = console.log, fetch = true,
} = {}) {
const git = Object.assign(makeGit(cwd), { cwd });
const dirty = git(['status', '--porcelain']).stdout;
if (dirty) throw new Error(`рабочее дерево не чистое — закоммитьте или спрятайте изменения:\n${dirty}`);
const branch = git(['rev-parse', '--abbrev-ref', 'HEAD']).stdout;
if (branch === 'HEAD') throw new Error('detached HEAD: ребейзится ветка, не коммит');
if (branch === 'dev' || branch === 'main') throw new Error(`ветка ${branch} не ребейзится этим скриптом`);
if (fetch) {
const [remote, ...rest] = upstream.split('/');
git(['fetch', '--quiet', remote, rest.join('/')]);
}
const base = git(['merge-base', upstream, 'HEAD']).stdout;
const ahead = Number(git(['rev-list', '--count', `${upstream}..HEAD`]).stdout);
const behind = Number(git(['rev-list', '--count', `HEAD..${upstream}`]).stdout);
log(`ветка ${branch}: впереди ${upstream} на ${ahead}, позади на ${behind}`);
if (behind === 0) { log('ребейз не нужен'); return { branch, rebased: false, resolved: [], rebuilt: false }; }
// Предсказание конфликтов по сгенерированным путям: файлы, которые менялись
// по обе стороны от merge-base. Точный список даёт только сам ребейз.
const ours = new Set(git(['diff', '--name-only', base, 'HEAD']).stdout.split('\n').filter(Boolean));
const theirs = git(['diff', '--name-only', base, upstream]).stdout.split('\n').filter(Boolean);
const both = theirs.filter((path) => ours.has(path));
const predicted = splitConflicts(both);
if (predicted.generated.length) log(`бандл менялся с обеих сторон: ${predicted.generated.length} файл(ов) — решится пересборкой`);
if (predicted.manual.length) log(`менялись с обеих сторон и НЕ сгенерированы (возможен ручной конфликт): ${predicted.manual.join(', ')}`);
if (dryRun) { log('--dry-run: дерево не тронуто'); return { branch, rebased: false, resolved: [], rebuilt: false, predicted }; }
const resolved = [];
let step = git(['rebase', upstream], { allowFailure: true });
while (!step.ok) {
const conflicts = git(['diff', '--name-only', '--diff-filter=U']).stdout.split('\n').filter(Boolean);
if (!conflicts.length) {
git(['rebase', '--abort'], { allowFailure: true });
throw new Error(`rebase остановился без конфликтов:\n${step.stderr || step.stdout}`);
}
const { generated, manual } = splitConflicts(conflicts);
if (manual.length) {
git(['rebase', '--abort']);
throw new Error(`конфликт вне сгенерированных путей — ребейз отменён, дерево как было:\n ${manual.join('\n ')}`);
}
for (const path of generated) resolved.push(`${path} ← ${resolveGeneratedConflict(git, path)}`);
step = git(['rebase', '--continue'], { allowFailure: true });
}
log(`ребейз завершён; сгенерированных конфликтов решено: ${resolved.length}`);
// Пересборка: версия dev в бандле — не версия этой ветки. Собираем и, если
// бандл отличается, амендим последний коммит ветки.
const [cmd, ...args] = syncCommand;
const sync = spawnSync(cmd, args, { cwd, stdio: 'inherit', shell: process.platform === 'win32' });
if (sync.status !== 0) throw new Error(`${syncCommand.join(' ')} завершился с кодом ${sync.status}; ребейз сделан, бандл не закоммичен`);
git(['add', '-A', '--', ...GENERATED_ROOTS.filter((root) => existsSync(resolve(cwd, root)))]);
const staged = git(['diff', '--cached', '--name-only']).stdout;
const rebuilt = staged.length > 0;
if (rebuilt) {
git(['commit', '--amend', '--no-edit', '--quiet']);
log(`бандл пересобран и добавлен в последний коммит (${staged.split('\n').length} файл(ов))`);
} else {
log('бандл после пересборки совпал с dev — амендить нечего');
}
return { branch, rebased: true, resolved, rebuilt };
}
const invokedDirectly = process.argv[1]
&& resolve(process.argv[1]) === resolve(fileURLToPath(import.meta.url));
if (invokedDirectly) {
try {
rebaseOnDev({ dryRun: process.argv.includes('--dry-run') });
} catch (error) {
console.error(`rebase-on-dev: ${error.message}`);
process.exitCode = 1;
}
}
+46
View File
@@ -62,3 +62,49 @@ test('CLI пишет формат $GITHUB_OUTPUT: stdin — список фай
const all = execFileSync('node', [script, '--all'], { input: '', encoding: 'utf8' });
assert.equal(all, OUTPUTS.map((name) => `${name}=true`).join('\n') + '\n');
});
// #479: тяжёлые job идут на кандидате беты, по кнопке, на PR и по расписанию —
// и НЕ идут на обычном пуше. Обе стороны доказаны на самой функции, которую
// исполняет шаг `heavy` job `changes`.
import { heavyGatesRequested, hasReleaseTrailer } from '../scripts/classify-changes.mjs';
test('обычный push в dev не запрашивает тяжёлые job (#479)', () => {
assert.equal(heavyGatesRequested({
eventName: 'push',
headMessage: 'fix: speed up wall-chain commits\n\nIssue: #461\nUser-Visible: yes\n',
}), false);
assert.equal(heavyGatesRequested({ eventName: 'push', headMessage: '' }), false);
assert.equal(heavyGatesRequested({}), false);
});
test('кандидат беты и релиза — трейлер Release: — запрашивает тяжёлые job (#479)', () => {
assert.equal(heavyGatesRequested({
eventName: 'push',
headMessage: 'build: prepare v1.73.0-beta.1 candidate\n\nIssue: #160\nUser-Visible: yes\nRelease: v1.73.0-beta.1\n',
}), true);
assert.equal(hasReleaseTrailer('Release v1.72.0\n\nRelease: v1.72.0'), true);
// Слово в теле — не трейлер: строка должна начинаться с `Release:`.
assert.equal(hasReleaseTrailer('docs: mention the Release: process in AGENTS'), false);
assert.equal(hasReleaseTrailer('Release: soon'), false);
});
test('workflow_dispatch запрашивает тяжёлые job только с full=true (#479)', () => {
assert.equal(heavyGatesRequested({ eventName: 'workflow_dispatch', fullInput: 'true' }), true);
assert.equal(heavyGatesRequested({ eventName: 'workflow_dispatch', fullInput: true }), true);
assert.equal(heavyGatesRequested({ eventName: 'workflow_dispatch', fullInput: 'false' }), false);
assert.equal(heavyGatesRequested({ eventName: 'workflow_dispatch', fullInput: '' }), false);
});
test('pull_request и schedule всегда запрашивают тяжёлые job (#479)', () => {
assert.equal(heavyGatesRequested({ eventName: 'pull_request', headMessage: 'x' }), true);
assert.equal(heavyGatesRequested({ eventName: 'schedule' }), true);
});
test('CLI --heavy читает событие и сообщение из окружения (#479)', () => {
const run = (env) => execFileSync(process.execPath, ['scripts/classify-changes.mjs', '--heavy'], {
encoding: 'utf8', env: { ...process.env, ...env },
}).trim();
assert.equal(run({ EVENT_NAME: 'push', HEAD_MESSAGE: 'fix: x\n\nIssue: #1\nUser-Visible: no' }), 'heavy=false');
assert.equal(run({ EVENT_NAME: 'push', HEAD_MESSAGE: 'x\n\nRelease: v1.2.3' }), 'heavy=true');
assert.equal(run({ EVENT_NAME: 'workflow_dispatch', FULL_INPUT: 'true', HEAD_MESSAGE: '' }), 'heavy=true');
});
+37
View File
@@ -0,0 +1,37 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { freshnessSink, screenshotsMode } from '../scripts/docs-freshness.mjs';
// #479: свежесть скриншотов документации на обычном пуше — предупреждение, на
// кандидате беты — ошибка. Умолчание строгое, чтобы старый вызов не ослаб молча.
test('без флага режим строгий, warn и strict принимаются, мусор отвергается (#479)', () => {
assert.equal(screenshotsMode([]), 'strict');
assert.equal(screenshotsMode(['--external']), 'strict');
assert.equal(screenshotsMode(['--screenshots=warn']), 'warn');
assert.equal(screenshotsMode(['--screenshots=strict']), 'strict');
assert.throws(() => screenshotsMode(['--screenshots=off']), /warn\|strict/);
});
test('warn складывает находки свежести в предупреждения, strict — в ошибки (#479)', () => {
const errors = []; const warnings = [];
freshnessSink('warn', { errors, warnings }).push('stale');
freshnessSink('strict', { errors, warnings }).push('stale');
assert.deepEqual(warnings, ['stale']);
assert.deepEqual(errors, ['stale']);
assert.throws(() => freshnessSink('maybe', { errors, warnings }));
});
test('check-docs: только две проверки свежести идут через режим, остальное — всегда ошибка (#479)', () => {
const source = readFileSync(new URL('../scripts/check-docs.mjs', import.meta.url), 'utf8');
const viaMode = [...source.matchAll(/freshness\.push\(([^)]*)\)/g)].map((m) => m[1]);
assert.equal(viaMode.length, 2, 'ровно две проверки свежести: отпечаток и capture-скрипт');
assert.ok(viaMode[0].includes('fingerprint is stale'));
assert.ok(viaMode[1].includes('capture script changed'));
// Хеш картинки, полнота набора сцен и ссылки не имеют права ослабляться.
for (const always of ['image hash does not match manifest', 'scenario set is incomplete', 'external link returned']) {
const line = source.split('\n').find((l) => l.includes(always));
assert.ok(line && line.includes('errors.push'), `${always} остаётся ошибкой в обоих режимах`);
}
});
+35
View File
@@ -0,0 +1,35 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { parseArgs, parallelSteps, serialSteps, summarize } from '../scripts/gate-small.mjs';
// #479 AC6: одна команда вместо списка §8 — состав обязательной части закреплён,
// информационный шаг (smoke-select) не считается падением, сверка бандла идёт
// после сборки.
test('gate:small гоняет обязательную часть PROCESS §8 и сверяет бандл (#479)', () => {
const names = parallelSteps('origin/dev').map((s) => `${s.cmd} ${s.args.join(' ')}`);
assert.ok(names.some((n) => n.endsWith('npm test') || n.endsWith('npm.cmd test')));
assert.ok(names.some((n) => n.includes('run build')));
assert.ok(names.some((n) => n.includes('scripts/no-new-any.mjs --base origin/dev --head HEAD')));
assert.ok(names.some((n) => n.includes('scripts/smoke-select.mjs --base origin/dev --head HEAD')));
const serial = serialSteps().map((s) => s.args.join(' '));
assert.ok(serial.some((s) => s.includes('bundle-tree.mjs dist custom_components/houseplan/frontend')));
assert.ok(serial.some((s) => s.includes('bundle:budget')));
assert.equal(parseArgs(['--base=abc']).base, 'abc');
assert.equal(parseArgs([]).base, 'origin/dev');
assert.equal(JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).scripts['gate:small'],
'node scripts/gate-small.mjs');
});
test('сводка: информационный шаг не падение, упавший шаг с подсказкой (#479)', () => {
const { lines, failed } = summarize([
{ name: 'a', code: 0, ms: 1000 },
{ name: 'select', code: 1, ms: 10, informational: true },
{ name: 'bundle', code: 1, ms: 10, hint: 'npm run bundle:sync' },
]);
assert.equal(failed, 1);
assert.match(lines[0], /^ok /);
assert.match(lines[1], /^info/);
assert.match(lines[2], /^FAIL.*→ npm run bundle:sync/);
});
+106
View File
@@ -0,0 +1,106 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { execFileSync } from 'node:child_process';
import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { rebaseOnDev, splitConflicts } from '../scripts/rebase-on-dev.mjs';
// #479 AC5: конфликт только в бандле решается пересборкой, конфликт в src/**
// останавливает ребейз, не тронув дерево. Сценарий — настоящий git в temp.
// Личность коммитера нужна и скрипту (rebase, amend), не только тесту.
Object.assign(process.env, {
GIT_AUTHOR_NAME: 't', GIT_AUTHOR_EMAIL: 't@t', GIT_COMMITTER_NAME: 't', GIT_COMMITTER_EMAIL: 't@t',
});
const git = (cwd, ...args) => execFileSync('git', args, {
cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'],
}).trim();
// Фальшивая сборка: dist/a.js = 'built:' + содержимое src/x.ts.
const SYNC = [process.execPath, '-e',
"const fs=require('fs');fs.writeFileSync('dist/a.js','built:'+fs.readFileSync('src/x.ts','utf8'))"];
function repo({ conflictInSrc }) {
const root = mkdtempSync(join(tmpdir(), 'hp-rebase-'));
const origin = join(root, 'origin.git'); const work = join(root, 'work');
git(root, 'init', '--bare', '-q', '-b', 'dev', origin);
git(root, 'clone', '-q', origin, work);
git(work, 'checkout', '-q', '-b', 'dev');
mkdirSync(join(work, 'dist')); mkdirSync(join(work, 'src'));
writeFileSync(join(work, 'src/x.ts'), 'base\n');
writeFileSync(join(work, 'src/y.ts'), 'y0\n');
writeFileSync(join(work, 'dist/a.js'), 'built:base\n');
git(work, 'add', '-A'); git(work, 'commit', '-q', '-m', 'base'); git(work, 'push', '-q', '-u', 'origin', 'dev');
// Ветка задачи: правит src/x.ts (или src/y.ts) и бандл.
git(work, 'checkout', '-q', '-b', 'issue/1-x');
writeFileSync(join(work, conflictInSrc ? 'src/y.ts' : 'src/x.ts'), 'branch\n');
writeFileSync(join(work, 'dist/a.js'), 'built:branch\n');
git(work, 'add', '-A'); git(work, 'commit', '-q', '-m', 'feat: branch');
// dev уходит вперёд: другой файл (или тот же y.ts) и тот же бандл.
git(work, 'checkout', '-q', 'dev');
writeFileSync(join(work, conflictInSrc ? 'src/y.ts' : 'src/z.ts'), 'dev\n');
writeFileSync(join(work, 'dist/a.js'), 'built:dev\n');
git(work, 'add', '-A'); git(work, 'commit', '-q', '-m', 'dev moves'); git(work, 'push', '-q', 'origin', 'dev');
git(work, 'checkout', '-q', 'issue/1-x');
return { root, work };
}
test('splitConflicts делит пути на сгенерированные и ручные (#479)', () => {
const { generated, manual } = splitConflicts([
'dist/houseplan-card.js', 'custom_components/houseplan/frontend/houseplan-assets.json',
'src/houseplan-card.ts', 'custom_components/houseplan/const.py', '',
]);
assert.deepEqual(generated, ['dist/houseplan-card.js', 'custom_components/houseplan/frontend/houseplan-assets.json']);
assert.deepEqual(manual, ['src/houseplan-card.ts', 'custom_components/houseplan/const.py']);
});
test('конфликт только в бандле: ребейз доведён, бандл пересобран и зааменден (#479 AC5)', () => {
const { root, work } = repo({ conflictInSrc: false });
try {
const result = rebaseOnDev({ cwd: work, syncCommand: SYNC, log: () => {} });
assert.equal(result.rebased, true);
assert.equal(result.resolved.length, 1);
assert.equal(result.rebuilt, true);
assert.equal(git(work, 'status', '--porcelain'), '');
assert.equal(git(work, 'rev-list', '--count', 'origin/dev..HEAD'), '1', 'один коммит ветки поверх dev');
assert.equal(git(work, 'rev-list', '--count', 'HEAD..origin/dev'), '0', 'dev полностью под веткой');
assert.equal(readFileSync(join(work, 'dist/a.js'), 'utf8'), 'built:branch\n', 'бандл собран из src ветки, а не dev');
assert.equal(readFileSync(join(work, 'src/z.ts'), 'utf8'), 'dev\n', 'правка dev на месте');
} finally { rmSync(root, { recursive: true, force: true }); }
});
test('конфликт в src/**: ребейз отменён, дерево и HEAD как были (#479 AC5)', () => {
const { root, work } = repo({ conflictInSrc: true });
try {
const before = git(work, 'rev-parse', 'HEAD');
assert.throws(() => rebaseOnDev({ cwd: work, syncCommand: SYNC, log: () => {} }), /src\/y\.ts/);
assert.equal(git(work, 'rev-parse', 'HEAD'), before);
assert.equal(git(work, 'status', '--porcelain'), '');
assert.equal(readFileSync(join(work, 'src/y.ts'), 'utf8'), 'branch\n');
} finally { rmSync(root, { recursive: true, force: true }); }
});
test('--dry-run предсказывает конфликт по бандлу и не трогает дерево (#479)', () => {
const { root, work } = repo({ conflictInSrc: false });
try {
const before = git(work, 'rev-parse', 'HEAD');
const lines = [];
const result = rebaseOnDev({ cwd: work, dryRun: true, syncCommand: SYNC, log: (l) => lines.push(l) });
assert.equal(result.rebased, false);
assert.deepEqual(result.predicted.generated, ['dist/a.js']);
assert.equal(git(work, 'rev-parse', 'HEAD'), before);
assert.ok(lines.some((l) => l.includes('dry-run')));
} finally { rmSync(root, { recursive: true, force: true }); }
});
test('грязное дерево и ветка dev отвергаются до любого действия (#479)', () => {
const { root, work } = repo({ conflictInSrc: false });
try {
writeFileSync(join(work, 'src/x.ts'), 'dirty\n');
assert.throws(() => rebaseOnDev({ cwd: work, syncCommand: SYNC, log: () => {} }), /не чистое/);
git(work, 'checkout', '-q', '--', 'src/x.ts');
git(work, 'checkout', '-q', 'dev');
assert.throws(() => rebaseOnDev({ cwd: work, syncCommand: SYNC, log: () => {} }), /ветка dev/);
} finally { rmSync(root, { recursive: true, force: true }); }
});
+32
View File
@@ -328,3 +328,35 @@ test('#399 AC5: тот же код ловит третий workflow в подс
rmSync(directory, { recursive: true, force: true });
}
});
// #479: тяжёлые job идут только по выходу `heavy`, а релизные гейты требуют
// трейлер `Release:` — иначе зелёный Validate мог означать прогон без них.
test('смоки, golden и performance_smoke условны по heavy (#479)', () => {
const text = read('validate.yml');
for (const job of ['smoke', 'smoke_done', 'golden', 'performance_smoke']) {
const start = text.indexOf(`\n ${job}:\n`);
assert.ok(start > 0, `job ${job} есть`);
const chunk = text.slice(start, start + 600);
assert.match(chunk, /needs: \[changes,/, `${job}: зависит от changes`);
assert.match(chunk, /if: needs\.changes\.outputs\.heavy == 'true' &&/, `${job}: условие heavy`);
}
assert.match(text, /heavy: \$\{\{ steps\.heavy\.outputs\.heavy \}\}/);
assert.match(text, /classify-changes\.mjs --heavy/);
assert.match(text, /workflow_dispatch:\n\s+inputs:\n\s+full:/);
// preflight: режим скриншотов считает тот же скрипт.
assert.match(text, /check-docs\.mjs --external --screenshots=\$mode/);
});
test('ночной прогон — dispatch Validate на dev с full=true (#479)', () => {
const text = read('nightly.yml');
assert.match(text, /schedule:\n\s+- cron:/);
assert.match(text, /gh workflow run validate\.yml --repo "\$REPO" --ref dev -f full=true/);
assert.match(text, /actions: write/);
});
test('релизные гейты требуют трейлер Release: и свежие скриншоты (#479)', () => {
const trailer = /grep -Eq '\^Release:\[\[:space:\]\]\*v\?\[0-9\]\+\\\.\[0-9\]\+\\\.\[0-9\]\+'/;
assert.match(read('publish-prerelease.yml'), trailer);
assert.match(read('release.yml'), trailer);
assert.match(read('publish-prerelease.yml'), /check-docs\.mjs --screenshots=strict/);
});