mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-28 19:01:34 +00:00
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:
@@ -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)"
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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). Запас на холодный кэш — при попадании
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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,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');
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}"`);
|
||||
}
|
||||
@@ -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; });
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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} остаётся ошибкой в обоих режимах`);
|
||||
}
|
||||
});
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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 }); }
|
||||
});
|
||||
@@ -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/);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user