From 7195ad1914fc453f1c1e627f9d77af835a5b9fbe Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 6 Sep 2026 15:39:00 +0300 Subject: [PATCH] infra: heavy CI gates on the beta candidate, bundle rebase script, gate:small MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .github/workflows/nightly.yml | 38 ++++++ .github/workflows/publish-prerelease.yml | 17 +++ .github/workflows/release.yml | 6 + .github/workflows/validate.yml | 48 ++++++-- AGENTS.md | 25 +++- docs/TESTING.md | 10 +- package.json | 1 + scripts/check-docs.mjs | 7 +- scripts/classify-changes.mjs | 40 ++++++- scripts/docs-freshness.mjs | 28 +++++ scripts/gate-small.mjs | 97 ++++++++++++++++ scripts/rebase-on-dev.mjs | 140 +++++++++++++++++++++++ test/classify-changes.test.mjs | 46 ++++++++ test/docs-freshness.test.mjs | 37 ++++++ test/gate-small.test.mjs | 35 ++++++ test/rebase-on-dev.test.mjs | 106 +++++++++++++++++ test/validate-workflow.test.mjs | 32 ++++++ 17 files changed, 694 insertions(+), 19 deletions(-) create mode 100644 .github/workflows/nightly.yml create mode 100644 scripts/docs-freshness.mjs create mode 100644 scripts/gate-small.mjs create mode 100644 scripts/rebase-on-dev.mjs create mode 100644 test/docs-freshness.test.mjs create mode 100644 test/gate-small.test.mjs create mode 100644 test/rebase-on-dev.test.mjs diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml new file mode 100644 index 00000000..ca121e11 --- /dev/null +++ b/.github/workflows/nightly.yml @@ -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)" diff --git a/.github/workflows/publish-prerelease.yml b/.github/workflows/publish-prerelease.yml index 126e34db..51f0be91 100644 --- a/.github/workflows/publish-prerelease.yml +++ b/.github/workflows/publish-prerelease.yml @@ -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 }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ae69e9c1..145605a8 100755 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 }} diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index cdaeecc2..7aa6f23d 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -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). Запас на холодный кэш — при попадании diff --git a/AGENTS.md b/AGENTS.md index e5143e13..eb3fa8a3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/docs/TESTING.md b/docs/TESTING.md index 409adda1..0d1cfd6d 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -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, diff --git a/package.json b/package.json index 6d4ecc10..cb1372c6 100755 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/check-docs.mjs b/scripts/check-docs.mjs index 19e8048b..655ab9c3 100644 --- a/scripts/check-docs.mjs +++ b/scripts/check-docs.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'); diff --git a/scripts/classify-changes.mjs b/scripts/classify-changes.mjs index 18f47469..53d3c24b 100755 --- a/scripts/classify-changes.mjs +++ b/scripts/classify-changes.mjs @@ -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)); + } } diff --git a/scripts/docs-freshness.mjs b/scripts/docs-freshness.mjs new file mode 100644 index 00000000..0fb654bb --- /dev/null +++ b/scripts/docs-freshness.mjs @@ -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}"`); +} diff --git a/scripts/gate-small.mjs b/scripts/gate-small.mjs new file mode 100644 index 00000000..7b1d04bf --- /dev/null +++ b/scripts/gate-small.mjs @@ -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; }); +} diff --git a/scripts/rebase-on-dev.mjs b/scripts/rebase-on-dev.mjs new file mode 100644 index 00000000..7434bcd2 --- /dev/null +++ b/scripts/rebase-on-dev.mjs @@ -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; + } +} diff --git a/test/classify-changes.test.mjs b/test/classify-changes.test.mjs index 3f0e12d5..ffa6d472 100644 --- a/test/classify-changes.test.mjs +++ b/test/classify-changes.test.mjs @@ -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'); +}); diff --git a/test/docs-freshness.test.mjs b/test/docs-freshness.test.mjs new file mode 100644 index 00000000..de52fb76 --- /dev/null +++ b/test/docs-freshness.test.mjs @@ -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} остаётся ошибкой в обоих режимах`); + } +}); diff --git a/test/gate-small.test.mjs b/test/gate-small.test.mjs new file mode 100644 index 00000000..50dd346e --- /dev/null +++ b/test/gate-small.test.mjs @@ -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/); +}); diff --git a/test/rebase-on-dev.test.mjs b/test/rebase-on-dev.test.mjs new file mode 100644 index 00000000..4be61f07 --- /dev/null +++ b/test/rebase-on-dev.test.mjs @@ -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 }); } +}); diff --git a/test/validate-workflow.test.mjs b/test/validate-workflow.test.mjs index 74f8d055..4f628c77 100644 --- a/test/validate-workflow.test.mjs +++ b/test/validate-workflow.test.mjs @@ -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/); +});