diff --git a/.github/workflows/_process.yml b/.github/workflows/_process.yml index c37a6b45..d8b7a396 100644 --- a/.github/workflows/_process.yml +++ b/.github/workflows/_process.yml @@ -341,6 +341,7 @@ jobs: duration_seconds: ${{ steps.duration.outputs.seconds }} track: ${{ steps.track.outputs.track }} mutants: ${{ steps.track.outputs.mutants }} + full: ${{ steps.track.outputs.full }} ship: ${{ steps.track.outputs.ship }} steps: - name: Начать измерение стадии @@ -439,6 +440,7 @@ jobs: printf '%s\n' "$out" track=$(printf '%s\n' "$out" | sed -n 's/^track=//p') mutants=$(printf '%s\n' "$out" | sed -n 's/^mutants=//p') + full=$(printf '%s\n' "$out" | sed -n 's/^full=//p') ship=false if [ "$track" = "ship" ] && [ "$STAGE" = "code" ] && [ -n "$BRANCH" ]; then limits=$(node "$tools/scripts/process-track.mjs" ship-limits --base=origin/dev --head=HEAD) @@ -461,8 +463,8 @@ jobs: echo "track:ship -> track:show: $violations" fi fi - { echo "track=$track"; echo "mutants=$mutants"; echo "ship=$ship"; } >> "$GITHUB_OUTPUT" - echo "- трек **$track** · мутанты по диффу: $mutants · слияние без модели: $ship" >> "$GITHUB_STEP_SUMMARY" + { echo "track=$track"; echo "mutants=$mutants"; echo "full=${full:-false}"; echo "ship=$ship"; } >> "$GITHUB_OUTPUT" + echo "- трек **$track** · мутанты по диффу: $mutants · полный набор: ${full:-false} · слияние без модели: $ship" >> "$GITHUB_STEP_SUMMARY" # Ревьюер обязан смотреть тот же код, который уедет в dev (#257). Раньше # ревью шло по ветке как есть, а слияние делало ребейз — проверенный SHA и @@ -719,6 +721,8 @@ jobs: BRANCH: ${{ steps.branch.outputs.name }} SHA: ${{ steps.material.outputs.sha }} MUTANTS: ${{ steps.track.outputs.mutants }} + # #697: `ci:full`/`ci:golden` — полный набор на материале. + FULL: ${{ steps.track.outputs.full }} run: | if [ "$STAGE" != "code" ] || [ "$REUSE" = "true" ] || [ -z "$BRANCH" ]; then echo "гейт не применяется: этап $STAGE, reuse=${REUSE:-false}, ветка ${BRANCH:-dev}" @@ -733,7 +737,7 @@ jobs: # прогон гейт и без ожидания возвращает сразу. set +e node scripts/validate-gate.mjs --repo="${{ github.repository }}" --ref="$BRANCH" --sha="$SHA" --no-wait \ - --mutants="${MUTANTS:-true}" + --mutants="${MUTANTS:-true}" --full="${FULL:-false}" code=$? set -e case "$code" in @@ -1218,8 +1222,8 @@ jobs: Если зелёного прогона на этом SHA нет — прогоняешь сам, они дешёвые, и в повторном раунде тоже: `npx tsc --noEmit`, `npm test`, - `npm run build` со сверкой трёх копий бандла, плюс - `node scripts/check-docs.mjs`, если diff трогает `src/**`. + `npm run build` со сверкой трёх копий бандла. Свежесть скриншотов + документации — не гейт задачи (#697): её обновляет бот на dev. ${{ needs.guard.outputs.stage == 'spec' && 'Зависимости и Chromium на этапе spec не ставились (#696).' || (needs.prepare.outputs.track == 'show' && 'Зависимости установлены (`npm ci` не нужен); Chromium — только если тело issue называет смоук (#696).' || 'Зависимости уже установлены workflow, Chromium тоже — `npm ci` выполнять не нужно.') }} По диффу и AC: браузерные смоки — названные в AC плюс вывод `node scripts/smoke-select.mjs --base --head `, @@ -1228,7 +1232,7 @@ jobs: совпадение», «зарегистрированная связь», «НЕОПРЕДЕЛЁННОСТЬ» — связь не доказана, и это не разрешение ничего не прогонять; слабые связи — повод посмотреть, а не обязанность прогонять; - `npm run golden:verify` при видимом изменении; + `npm run golden:verify` при метке `ci:golden`; `python -m pytest tests_backend -q` при правке `custom_components/**/*.py`; инварианты модели `npm run invariants -- --config <экспорт>` при правке геометрии или diff --git a/.github/workflows/beta-derived.yml b/.github/workflows/beta-derived.yml new file mode 100644 index 00000000..b0bb4e45 --- /dev/null +++ b/.github/workflows/beta-derived.yml @@ -0,0 +1,214 @@ +name: "Бета: производные артефакты на dev" +run-name: "Derived artifacts ${{ inputs.tag }}" + +# #697, PROCESS.md §8 и §11.4: ветки задач не коммитят ни отпечаток и кадры +# скриншотов документации, ни эталоны golden. Их обновляет один коммит бота на +# `dev` перед кандидатом беты; коммит проверяет релиз-менеджер. +# +# - Скриншоты: съёмка на `dev` тем же каноническим способом, что +# `docs-screenshots.yml`, и приёмка `docs:accept --reviewed`. Кадры, которые +# менять не собирались, обязаны совпасть байт-в-байт (свидетели среды); +# изменившийся кадр принимается, только если назван в `docs_expect_change`. +# Ничего не изменилось — обновляется один отпечаток исходников. +# - Golden: артефакт `golden-images` полного Validate на `dev` +# (`golden_run`) и `golden:accept --reviewed` с объявленными сценами. Коммит +# несёт `Release:` и `Baseline-Reviewed:` — провенанс, который требует +# validate-commit-provenance.mjs. +# +# Необъявленная разница — отказ с перечнем кадров, артефакт съёмки остаётся +# для просмотра: решение, какие кадры сдвинулись законно, принимает человек. +on: + workflow_dispatch: + inputs: + tag: + description: "Beta tag these artifacts are for, for example v1.79.0-beta.1" + required: true + type: string + docs_expect_change: + description: "Doc scenario ids expected to change, comma-separated (empty = none)" + required: false + type: string + default: "" + golden_run: + description: "Validate run id on dev whose golden-images artifact holds the shifted frames (empty = skip golden)" + required: false + type: string + default: "" + golden_expect_change: + description: "Golden scenes expected to change, comma-separated" + required: false + type: string + default: "" + golden_expect_new: + description: "New golden scenes, comma-separated" + required: false + type: string + default: "" + +permissions: + contents: read + +concurrency: + group: beta-derived + cancel-in-progress: false + +jobs: + accept: + name: "Отпечаток, кадры и эталоны — одним коммитом в dev" + runs-on: ubuntu-24.04 + timeout-minutes: 40 + permissions: + contents: read + actions: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + ref: dev + fetch-depth: 0 + persist-credentials: false + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 + with: + node-version: 22 + cache: npm + - run: npm ci + - name: Кэш браузеров Playwright + id: pw + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 + with: + path: ~/.cache/ms-playwright + key: playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }} + - name: Install pinned Chromium + if: steps.pw.outputs.cache-hit != 'true' + run: npx playwright install chromium + - name: Build the bundle the screenshots must come from + run: npm run build + # Тот же закреплённый упаковщик, что у docs-screenshots.yml: другой + # переписал бы все кадры без единого изменённого пикселя. + - name: Установить oxipng + env: + OXIPNG_VERSION: 10.2.0 + OXIPNG_SHA256: b33f84c73d42cb592bea5d84c431030b1e97784817693380dfcec7d9575f871e + run: | + set -euo pipefail + asset="oxipng-${OXIPNG_VERSION}-x86_64-unknown-linux-gnu.tar.gz" + curl -fsSL -o "$asset" \ + "https://github.com/oxipng/oxipng/releases/download/v${OXIPNG_VERSION}/${asset}" + echo "${OXIPNG_SHA256} ${asset}" | sha256sum -c - + mkdir -p "$HOME/.local/bin" + tar -xzf "$asset" --strip-components=1 -C "$HOME/.local/bin" \ + "oxipng-${OXIPNG_VERSION}-x86_64-unknown-linux-gnu/oxipng" + echo "$HOME/.local/bin" >> "$GITHUB_PATH" + "$HOME/.local/bin/oxipng" --version + - name: "Съёмка воспроизводима между прогонами (#410, #422)" + run: node scripts/capture-determinism.mjs + - name: Кадры документации — съёмка и приёмка + id: docs + env: + EXPECT: ${{ inputs.docs_expect_change }} + run: | + set -euo pipefail + node demo/docs/capture.mjs --stability=3 + cand="$RUNNER_TEMP/docs-candidate" + rm -rf "$cand" && mkdir -p "$cand" + cp docs/images/*.png docs/images/screenshots.json "$cand/" + # Приёмка сравнивает кандидата с ЗАКОММИЧЕННЫМИ кадрами: рабочая + # копия возвращается к dev, и заменить файлы может только она. + git checkout -- docs/images + git clean -fdq -- docs/images + args=(--reviewed "--from=$cand") + if [ -n "$EXPECT" ]; then args+=("--expect-change=$EXPECT"); fi + node scripts/docs-accept.mjs "${args[@]}" + if git diff --quiet -- docs/images; then changed=false; else changed=true; fi + echo "changed=$changed" >> "$GITHUB_OUTPUT" + git diff --stat -- docs/images + - name: Сохранить кандидата скриншотов для просмотра + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: docs-candidate-${{ github.run_id }} + path: ${{ runner.temp }}/docs-candidate + if-no-files-found: ignore + retention-days: 7 + - name: Эталоны golden из прогона Validate + id: golden + if: inputs.golden_run != '' + env: + GH_TOKEN: ${{ github.token }} + RUN: ${{ inputs.golden_run }} + EXPECT_CHANGE: ${{ inputs.golden_expect_change }} + EXPECT_NEW: ${{ inputs.golden_expect_new }} + run: | + set -euo pipefail + case "$RUN" in ''|*[!0-9]*) echo "::error::golden_run — числовой id прогона Validate"; exit 1 ;; esac + meta=$(gh api "repos/${{ github.repository }}/actions/runs/$RUN" \ + --jq '[.path, .head_branch, .status, .html_url] | @tsv') + IFS=$'\t' read -r path branch status url <<< "$meta" + # Провенанс эталонов — завершённый Validate на dev: другой workflow + # или ветка задачи доказательством для кандидата беты не являются. + if [ "$path" != ".github/workflows/validate.yml" ] || [ "$branch" != "dev" ] || [ "$status" != "completed" ]; then + echo "::error::прогон $RUN — не завершённый Validate на dev ($path, $branch, $status)" + exit 1 + fi + from="$RUNNER_TEMP/golden-candidate" + rm -rf "$from" && mkdir -p "$from" + gh run download "$RUN" --repo "${{ github.repository }}" -n golden-images -D "$from" + # Отчёт обязан быть снят с этого же дерева: accept.mjs сверяет его + # отпечаток с исходниками и откажет, если dev ушёл вперёд. + args=(--reviewed "--from=$from") + if [ -n "$EXPECT_CHANGE" ]; then args+=("--expect-change=$EXPECT_CHANGE"); fi + if [ -n "$EXPECT_NEW" ]; then args+=("--expect-new=$EXPECT_NEW"); fi + node scripts/golden-accept.mjs "${args[@]}" + if git diff --quiet -- demo/golden/baselines; then changed=false; else changed=true; fi + { echo "changed=$changed"; echo "url=$url"; } >> "$GITHUB_OUTPUT" + git diff --stat -- demo/golden/baselines + - name: Коммит в dev + env: + TOKEN: ${{ secrets.HP_PROCESS_TOKEN }} + TAG: ${{ inputs.tag }} + DOCS_CHANGED: ${{ steps.docs.outputs.changed }} + DOCS_EXPECT: ${{ inputs.docs_expect_change }} + GOLDEN_CHANGED: ${{ steps.golden.outputs.changed }} + GOLDEN_URL: ${{ steps.golden.outputs.url }} + GOLDEN_EXPECT_CHANGE: ${{ inputs.golden_expect_change }} + GOLDEN_EXPECT_NEW: ${{ inputs.golden_expect_new }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + # Хук pre-push гоняет process-gate; gate:small здесь не нужен — + # продуктового кода коммит не несёт. + HP_PREPUSH_GATE: "0" + run: | + set -euo pipefail + git add -- docs/images demo/golden/baselines + if git diff --cached --quiet; then + echo "::notice::отпечаток, кадры и эталоны на dev уже свежие — коммитить нечего" + exit 0 + fi + msg="$RUNNER_TEMP/message.txt" + { + echo "docs: accept derived artifacts on dev for $TAG" + echo "" + echo "Производные артефакты беты — одним коммитом на dev (PROCESS.md §8, §11.4, #697)." + echo "Прогон: $RUN_URL" + if [ "$DOCS_CHANGED" = "true" ]; then + echo "Скриншоты документации: отпечаток исходников; изменённые кадры: ${DOCS_EXPECT:-нет}." + fi + if [ "$GOLDEN_CHANGED" = "true" ]; then + echo "Golden: изменённые сцены: ${GOLDEN_EXPECT_CHANGE:-нет}; новые: ${GOLDEN_EXPECT_NEW:-нет}." + fi + echo "" + if [ "$GOLDEN_CHANGED" = "true" ]; then + echo "Release: $TAG" + echo "Baseline-Reviewed: $GOLDEN_URL" + fi + echo "Issue: #697" + echo "User-Visible: no" + } > "$msg" + git -c user.name="claude[bot]" \ + -c user.email="209825114+claude[bot]@users.noreply.github.com" \ + commit -q -F "$msg" + git show --stat --format='%H %s' HEAD | head -40 + if ! git push -q "https://x-access-token:$TOKEN@github.com/${{ github.repository }}" HEAD:dev; then + echo "::error::dev ушёл вперёд за время съёмки — запустить workflow заново: отпечаток судит дерево, а оно уже другое" + exit 1 + fi + echo "### Производные артефакты $TAG" >> "$GITHUB_STEP_SUMMARY" + echo "Коммит \`$(git rev-parse --short HEAD)\` в dev — проверить перед кандидатом беты." >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index da2643ab..c72a09dc 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -84,6 +84,8 @@ jobs: EVENT_NAME: ${{ github.event_name }} HEAD_MESSAGE: ${{ github.event.head_commit.message }} FULL_INPUT: ${{ inputs.full }} + # #697: на ветке задачи `Release:` строгий режим не включает. + REF_NAME: ${{ github.ref_name }} run: | # #586: CLI отдаёт ОДИН ответ. Прежде здесь сравнивался со строкой # `heavy=true` весь вывод `--heavy`, а он двухстрочный: в `$(…)` @@ -282,7 +284,8 @@ jobs: # предыдущего пуша штатно, и на #86 (r5) это дало зелёный статус ветки # без единого исполненного тяжёлого гейта. # Смоки, golden, performance_smoke — на кандидате беты (трейлер - # `Release:`), по `workflow_dispatch full=true`, на PR (#479). Логика и + # `Release:` вне веток задач, #697), по `workflow_dispatch full=true`, на + # PR (#479). Логика и # её тест — scripts/classify-changes.mjs. - id: heavy env: @@ -290,6 +293,7 @@ jobs: HEAD_MESSAGE: ${{ github.event.head_commit.message }} FULL_INPUT: ${{ inputs.full }} MUTANTS_INPUT: ${{ inputs.mutants }} + REF_NAME: ${{ github.ref_name }} run: node scripts/classify-changes.mjs --heavy | tee -a "$GITHUB_OUTPUT" - id: base if: github.event_name != 'pull_request' diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 084e308b..49033063 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -60,12 +60,15 @@ because the plan canvas and editors cannot be mirrored by translations alone. ## Documentation screenshots -The images under `docs/images/` are produced only from synthetic data by the -`Docs screenshots` workflow (`demo/docs/capture.mjs` on the pinned Chromium) -and accepted locally with `npm run docs:accept -- --reviewed --from=`; when a change cannot move a pixel, `npm run docs:accept -- ---identical` re-captures locally, compares decoded pixels and refreshes only the -source fingerprint. Scenario version, source fingerprint and every image hash +The images under `docs/images/` are produced only from synthetic data on the +pinned Chromium in CI. A task branch does not commit them, nor the golden +baselines: once per beta the `beta-derived.yml` workflow refreshes the +fingerprint, the frames and the golden baselines on `dev` in one bot commit, +accepting only the frames it was told to expect (`PROCESS.md` §8, #697). A task +that changes visuals on purpose sets the `ci:golden` label. The manual path — +the `Docs screenshots` workflow and `npm run docs:accept -- --reviewed +--from=`, or `--identical` when no pixel can move — stays +for the release manager. Scenario version, source fingerprint and every image hash are recorded in the [screenshot index](docs/images/screenshots.json), and `node scripts/check-docs.mjs` reports a stale fingerprint: a warning on an ordinary push, an error on a beta candidate (a commit with a `Release:` diff --git a/PROCESS.md b/PROCESS.md index 10d2a999..855863ef 100644 --- a/PROCESS.md +++ b/PROCESS.md @@ -473,7 +473,8 @@ dev, ни при публикации документа код-ревью: ин WSL/ext4 с clean опубликованным SHA и машинно-проверяемым паспортом. Второй путь убирает только первый ожидаемо красный CI-прогон; полный GitHub Validate на точном SHA коммита с эталонами остаётся обязательным. Принятие ради - зелёного CI — нарушение процесса. + зелёного CI — нарушение процесса. Где принимаются эталоны — §8: на `dev` + одним коммитом на бету, в задаче — только с меткой `ci:golden` (#697). 14. **Issue закрывается после выпуска беты** с зелёным CI на точном SHA. Не раньше, не «по факту наличия кода», не исполнителем. 15. **Закрытый issue не переоткрывается.** Новый дефект — новый issue со ссылкой. @@ -599,8 +600,8 @@ dev, ни при публикации документа код-ревью: ин | `ci:golden` | golden на ветке и приёмку сдвинутых кадров в самой задаче | | `ci:mutants` | мутанты по диффу на кандидате ревью и слияния | -Как конвейер читает `ci:mutants`, описывает §10.4 (#696); `ci:full` и -`ci:golden` до #697 действуют по прежним правилам §8. +Как конвейер читает метки, описывает §10.4: `ci:mutants` (#696), `ci:full` и +`ci:golden` — dispatch Validate с `full=true` на материале ревью (#697). **Прежние метки.** `trivial` и `small` читаются как `track:show`; продуктовая задача без трековой метки — как `track:ask`; инфраструктурная задача (§1) без @@ -734,8 +735,7 @@ npm run build && node scripts/bundle-policy.mjs --verify HEAD node scripts/smoke-select.mjs --base origin/dev --head HEAD # какие смоки относятся к диффу node demo/smoke_<целевые>.mjs node scripts/no-new-any.mjs --base origin/dev --head HEAD # новый код не добавляет any -npm run golden:verify # если менялся визуал -node scripts/check-docs.mjs # если менялся src/** +npm run golden:verify # только с меткой ci:golden (#697) node scripts/model-invariants.mjs --config <экспорт> # если правилась геометрия или ссылки python -m pytest tests_backend -q # py3.14 как в CI (npm run toolchain:check), если менялся бэкенд npx tsc -p tsconfig.junction-parity.json && node scripts/fix-test-build.mjs \ @@ -755,24 +755,25 @@ npx tsc -p tsconfig.junction-parity.json && node scripts/fix-test-build.mjs \ комментарии, строке или идентификаторе ложных срабатываний не даёт. **Объём гейтов на код-ревью соразмерен задаче** (issue #127). Всегда: -`typecheck`, `npm test`, `npm run build` с `bundle-policy --verify` (копии сверяются на кандидате, #657), а при -любом diff'е по `src/**` — ещё и `node scripts/check-docs.mjs`. По +`typecheck`, `npm test`, `npm run build` с `bundle-policy --verify` (копии сверяются на кандидате, #657). +Свежесть скриншотов документации — не гейт задачи (#697, ниже). По необходимости, определяемой diff'ом и AC: браузерные смоки (сколько их — считает `ls demo/smoke_*.mjs | wc -l`, вшитое число здесь трижды отставало от дерева; прогон всех уместен только когда задача задевает всё; какие относятся к диффу, печатает `node scripts/smoke-select.mjs --base origin/dev --head HEAD`, и его вывод -прикладывается к ревью вместе с решением по каждой строке), `golden:verify` при изменении видимого -результата, `pytest tests_backend` при правках в Python, performance-профили при +прикладывается к ревью вместе с решением по каждой строке), `golden:verify` при метке `ci:golden`, `pytest tests_backend` при правках в Python, performance-профили при названном в AC влиянии. **Полные наборы — предрелизный гейт, а не гейт ревью.** -Скриншоты снимаются **только** джобой `Docs screenshots` (`workflow_dispatch`) и -принимаются локально: `npm run docs:accept -- --reviewed --from=<распакованный -артефакт>` (#246). Съёмка на своей машине даёт байтово другой PNG при том же +Скриншоты снимаются **только** каноническим прогоном в CI — `beta-derived.yml` +(съёмка и приёмка одним коммитом бота на `dev`, #697) или `Docs screenshots` +(`workflow_dispatch`, только артефакт) с приёмкой вручную: `npm run docs:accept -- +--reviewed --from=<распакованный артефакт>` (#246). Ручной путь остаётся +релиз-менеджеру, если бот недоступен. Съёмка на своей машине даёт байтово другой PNG при том же кадре, и набор из «не того» браузера переписывает все десять файлов без единого содержательного изменения. Приёмка отказывает, если кандидат снят не с этого -дерева, не тем капчуром, не называет свой Chromium или неполон; коммит делает -человек. +дерева, не тем капчуром, не называет свой Chromium или неполон; коммит бота +проверяет релиз-менеджер, коммит ручной приёмки делает человек. Когда правка `src/**` кадров не меняет — а это большинство правок — CI-цикл не нужен (#512): `npm run docs:accept -- --identical` снимает кадры локально, @@ -781,13 +782,32 @@ npx tsc -p tsconfig.junction-parity.json && node scripts/fix-test-build.mjs \ закоммиченных PNG, их sha, браузер и упаковщик съёмки остаются прежними. Хотя бы один отличающийся пиксель — отказ с перечнем кадров и штатный путь через артефакт. -`check-docs` стоит в обязательной части не по важности, а по механике: отпечаток -скриншотов документации считается по всему `src/**`, поэтому **любая** правка -фронтенда делает его устаревшим. Выборка «по diff и AC» здесь не работает — diff -всегда попадает, и решать нечего. Цена пропуска измерена: скриншоты не -пересняли в #230 и #234, и `dev` стоял с красным job `docs`, пока это не нашли -при следующей задаче (#237). Пересъёмка — по двум абзацам выше, коммит -вместе с задачей. +**Производные артефакты — на `dev`, один коммит на бету** (#697, решение +владельца 2026-09-28). Отпечаток скриншотов документации считается по всему +`src/**`, поэтому любая правка фронтенда делает его устаревшим. Пока его +коммитила каждая задача, `docs/images/screenshots.json` правили 70 раз за 14 дней, +и две параллельные задачи конфликтовали на нём гарантированно. Golden +оплачивала следующая задача: сдвиг, влитый одной, всплывал у другой (#687 → +#685, #688 → #689). Теперь: + +- **ветки задач не коммитят** `docs/images/**` и `demo/golden/baselines/**`. На + ветке свежесть скриншотов — предупреждение preflight, а golden не идёт; +- **перед кандидатом беты** `beta-derived.yml` одним коммитом бота обновляет на + `dev` отпечаток и кадры (съёмка тем же каноном, приёмка `docs:accept + --reviewed`) и эталоны golden из артефакта полного Validate на `dev` + (`golden:accept --reviewed`, `Release:` и `Baseline-Reviewed:` в коммите). + Изменившийся кадр или сцена принимается, только если назван во входах + workflow; необъявленная разница — отказ с перечнем. Коммит проверяет + релиз-менеджер; +- **задача, которая меняет визуал намеренно**, ставит `ci:golden`: конвейер + прогоняет полный набор на материале ревью, и сдвинутые кадры задача + принимает сама — по §3 п.13; +- **`Release:` на ветке задачи тяжёлый набор не включает** (`classify-changes.mjs`): + тяжёлое запускают `ci:full` и `ci:golden`, трейлер `Release:` на `dev` и ночной + прогон. + +Строгая свежесть скриншотов по-прежнему обязательна на кандидате беты +(`publish-prerelease.yml`, `check-docs --screenshots=strict`). **Перф-смок в Validate зависит от диффа** (#473). Два glow-профиля гоняются всегда; при правке `src/iso-*` добавляется `large-house-isometric-v1`, @@ -1318,7 +1338,10 @@ Golden, браузерные смоки, performance и полный HA-харн - эталоны golden принимаются только через `npm run golden:accept -- --reviewed` на полном Linux-артефакте GitHub CI либо полном аттестованном WSL-артефакте; после локальной приёмки полный GitHub Validate на точном финальном SHA всё - равно обязателен. «Чтобы гейт позеленел» основанием не является. + равно обязателен. «Чтобы гейт позеленел» основанием не является. Сдвиг, который + влили задачи линии, принимается на `dev` одним коммитом на бету — + `beta-derived.yml` с объявленными сценами (§8, #697), а не задачей, которая + наткнулась на него следующей. **Границы, за которыми исключение не действует.** Оно про починку названного гейтом дефекта, а не про продолжение разработки под видом починки. Правка идёт diff --git a/docs/process/AUTHOR.md b/docs/process/AUTHOR.md index ee6e9154..dd43246b 100644 --- a/docs/process/AUTHOR.md +++ b/docs/process/AUTHOR.md @@ -139,8 +139,9 @@ - Минимальный набор по изменённым поверхностям: `npx tsc --noEmit`, `npm test`, `npm run build` + `bundle-policy --verify`, `smoke-select` и - целевые смоки, `no-new-any`; по диффу — `golden:verify`, `check-docs`, - `model-invariants`, `pytest tests_backend`, junction parity. Команды — + целевые смоки, `no-new-any`; по диффу — `model-invariants`, + `pytest tests_backend`, junction parity; `golden:verify` — только с меткой + `ci:golden`. Команды — в каноне ([§8](../../PROCESS.md#8-гейты)); `npm run gate:small` собирает обязательную часть (`docs/TESTING.md`, «Локальный набор перед пушем»). - Бандл в коммит задачи не идёт: сборка переписывает отслеживаемый `dist/`, @@ -149,9 +150,10 @@ - Новый код не добавляет `any`: гейт судит добавленные строки; исключение — `// any-ok: <конкретная причина>` на той же строке ([§8](../../PROCESS.md#8-гейты)). -- Любая правка `src/**` требует `node scripts/check-docs.mjs`: отпечаток - скриншотов считается по всему фронтенду. Скриншоты снимает только CI; - без изменения кадров — `npm run docs:accept -- --identical` +- Ветка задачи не коммитит `docs/images/**` и `demo/golden/baselines/**`: + отпечаток и кадры скриншотов, эталоны golden обновляет один коммит бота на + `dev` перед бетой. Задача, которая меняет визуал намеренно, ставит + `ci:golden` и принимает сдвинутые кадры сама ([§8](../../PROCESS.md#8-гейты)). - Полные наборы — предрелизный гейт, а не гейт ревью. Упавший предрелизный гейт автор чинит и повторно прогоняет; повторного код-ревью нет, если diff --git a/docs/process/REVIEWER.md b/docs/process/REVIEWER.md index efb6cbc1..2a2e2bd9 100644 --- a/docs/process/REVIEWER.md +++ b/docs/process/REVIEWER.md @@ -71,12 +71,13 @@ ## Объём гейтов -- Всегда: `typecheck`, `npm test`, `npm run build` + `bundle-policy --verify` (копии сверяются только на кандидате, #657); при - диффе по `src/**` — ещё `node scripts/check-docs.mjs`. Зелёный Validate на - SHA материала подтверждает дешёвые гейты ([§8](../../PROCESS.md#8-гейты)). +- Всегда: `typecheck`, `npm test`, `npm run build` + `bundle-policy --verify` (копии сверяются только на кандидате, #657). + Свежесть скриншотов документации — не гейт задачи: отпечаток обновляет + коммит бота на `dev` перед бетой. Зелёный Validate на SHA материала + подтверждает дешёвые гейты ([§8](../../PROCESS.md#8-гейты)). - По диффу и AC: смоки — названные в AC плюс вывод `node scripts/smoke-select.mjs --base --head ` с решением по - каждой строке; `golden:verify` при видимом изменении; `pytest tests_backend` + каждой строке; `golden:verify` при метке `ci:golden`; `pytest tests_backend` при правке Python; инварианты модели при правке геометрии; performance — если назван в AC. Полные наборы — предрелизный гейт, а не гейт ревью ([§8](../../PROCESS.md#8-гейты)). diff --git a/scripts/classify-changes.mjs b/scripts/classify-changes.mjs index 57ce9cd4..0aba6641 100755 --- a/scripts/classify-changes.mjs +++ b/scripts/classify-changes.mjs @@ -88,13 +88,23 @@ export function classifyAll() { * - `workflow_dispatch` с `full=true` — ночной прогон (nightly.yml) и ручной; * - pull_request — там Validate единственный сигнал. */ -export function heavyGatesRequested({ eventName, headMessage, fullInput } = {}) { +export function heavyGatesRequested({ eventName, headMessage, fullInput, refName } = {}) { if (eventName === 'pull_request') return true; if (eventName === 'workflow_dispatch') return String(fullInput) === 'true'; if (eventName === 'schedule') return true; + // #697: на ветке задачи трейлер `Release:` тяжёлый набор больше не включает. + // Его там носит только приёмка эталонов, а golden на ветке задачи — по метке + // `ci:golden`: конвейер ревью диспатчит Validate с `full=true` сам. Кандидат + // беты и релиза собирается на `dev` — там трейлер работает как прежде. + if (isTaskBranch(refName)) return false; return hasReleaseTrailer(headMessage); } +/** Ветка задачи — `issue/-…` (PROCESS §3). */ +export function isTaskBranch(refName) { + return /^issue\//.test(String(refName || '').replace(/^refs\/heads\//, '')); +} + /** * Нужны ли мутанты по диффу (#510, сужено в #601). За 08–09.09 они съели 86 % * job-минут Validate, потому что бежали на каждом промежуточном пуше и @@ -159,6 +169,7 @@ if (invokedDirectly) { eventName: process.env.EVENT_NAME, headMessage: process.env.HEAD_MESSAGE, fullInput: process.env.FULL_INPUT, + refName: process.env.REF_NAME, })}\n`); } else if (process.argv.includes('--heavy')) { // Отдельный вызов: у `heavy` другие входы (событие, сообщение head-коммита), @@ -167,6 +178,7 @@ if (invokedDirectly) { eventName: process.env.EVENT_NAME, headMessage: process.env.HEAD_MESSAGE, fullInput: process.env.FULL_INPUT, + refName: process.env.REF_NAME, }); process.stdout.write(`heavy=${heavy ? 'true' : 'false'}\n`); const mutants = mutantsRequested({ diff --git a/scripts/mutation-registry.mjs b/scripts/mutation-registry.mjs index fc104c4d..aabe01ce 100644 --- a/scripts/mutation-registry.mjs +++ b/scripts/mutation-registry.mjs @@ -13455,6 +13455,51 @@ const MUTANT_DEFINITIONS = [ replace: ' else if (false && block.high > 0) problems.push(', }], }, + // #697: производные артефакты — на dev, тяжёлое на ветке задачи — по меткам. + { + id: 'task-branch-release-trailer-heavy-again', + guard: 'node --test --test-name-pattern="#697" test/classify-changes.test.mjs', + because: '#697: on a task branch the Release: trailer only marks a baseline acceptance; ' + + 'it must not switch on smokes, golden and perf again — ci:full/ci:golden do that', + patches: [{ + file: 'scripts/classify-changes.mjs', + find: ' if (isTaskBranch(refName)) return false;\n', + replace: '', + }], + }, + { + id: 'ci-golden-label-does-not-order-full-set', + guard: 'node --test --test-name-pattern="#697" test/process-track.test.mjs', + because: '#697: a task that changes visuals on purpose sets ci:golden; without the full set ' + + 'on its review material its shifted frames surface in the next task again', + patches: [{ + file: 'scripts/process-track.mjs', + find: " const full = labels.includes('ci:full') || labels.includes('ci:golden');", + replace: " const full = labels.includes('ci:full'); // mutant: ci:golden ignored", + }], + }, + { + id: 'review-gate-accepts-light-proof-for-full', + guard: 'node --test --test-name-pattern="#697" test/validate-gate.test.mjs', + because: '#697: with ci:full/ci:golden a light push run proves nothing about smokes and golden; ' + + 'the gate must dispatch full=true instead of accepting it', + patches: [{ + file: 'scripts/validate-gate.mjs', + find: " const policy = full ? Object.freeze({ ...base, name: `${base.name}-full`, full: true }) : base;", + replace: ' const policy = base; // mutant: full never required', + }], + }, + { + id: 'bot-golden-commit-without-provenance', + guard: 'node --test --test-name-pattern="провенанс" test/beta-derived.test.mjs', + because: '#697: the bot commit that moves baselines must carry Release: and Baseline-Reviewed:, ' + + 'otherwise validate-commit-provenance turns dev red on the push', + patches: [{ + file: '.github/workflows/beta-derived.yml', + find: ' echo "Release: $TAG"\n', + replace: '', + }], + }, ]; const mutationCardSource = readFileSync(join(repoRoot, 'src/houseplan-card.ts'), 'utf8'); diff --git a/scripts/process-track.mjs b/scripts/process-track.mjs index 65709831..10901093 100644 --- a/scripts/process-track.mjs +++ b/scripts/process-track.mjs @@ -5,9 +5,11 @@ * node scripts/process-track.mjs resolve --labels="a,b" --base= --head= * node scripts/process-track.mjs ship-limits --base= --head= * - * `resolve` печатает `track=ship|show|ask` и `mutants=true|false` — то, что - * конвейер ревью читает, решая, сколько стоит заход: мутанты по диффу нужны - * только `ask` и метке `ci:mutants`. Инфраструктурная задача без трековой + * `resolve` печатает `track=ship|show|ask`, `mutants=true|false` и + * `full=true|false` — то, что конвейер ревью читает, решая, сколько стоит + * заход: мутанты по диффу нужны только `ask` и метке `ci:mutants`; полный + * набор (смоки, golden, perf) на ветке задачи — только меткам `ci:full` и + * `ci:golden` (#697). Инфраструктурная задача без трековой * метки — `show` (§5.1); признак инфраструктуры механический, как в §1: в * диффе ни одного файла класса A. * @@ -46,12 +48,14 @@ export const hasTrackLabel = (labels = []) => ['track:ship', 'track:show', 'trac * Трек, по которому конвейер оценивает заход. Явная метка решает всё; без неё * инфраструктурная задача (ни одного файла класса A в диффе) — `show`, прочие — * `ask`. Мутанты по диффу — только на `ask` или по метке `ci:mutants`. + * Полный набор — по меткам `ci:full` и `ci:golden` на любом треке (#697). */ export function resolveTrack({ labels = [], files = [] } = {}) { const infrastructure = files.length > 0 && files.every((file) => classify(file) !== 'A'); const track = hasTrackLabel(labels) ? trackFromLabels(labels) : (infrastructure ? 'show' : 'ask'); const mutants = track === 'ask' || labels.includes('ci:mutants'); - return { track, mutants, infrastructure }; + const full = labels.includes('ci:full') || labels.includes('ci:golden'); + return { track, mutants, full, infrastructure }; } const I18N = [/^src\/i18n\//, /^custom_components\/[^/]+\/translations\//]; @@ -120,8 +124,8 @@ if (isMainModule(import.meta.url)) { if (command === 'resolve') { const labels = value('labels').split(',').map((s) => s.trim()).filter(Boolean); const files = range ? git(['diff', '--name-only', range]).split('\n').filter(Boolean) : []; - const { track, mutants } = resolveTrack({ labels, files }); - emit([`track=${track}`, `mutants=${mutants}`]); + const { track, mutants, full } = resolveTrack({ labels, files }); + emit([`track=${track}`, `mutants=${mutants}`, `full=${full}`]); } else if (command === 'ship-limits') { if (!range) throw new Error('--base is required'); const violations = shipLimitViolations({ diff --git a/scripts/validate-gate.mjs b/scripts/validate-gate.mjs index cd7ed5e9..80bfbea0 100755 --- a/scripts/validate-gate.mjs +++ b/scripts/validate-gate.mjs @@ -85,10 +85,14 @@ export function provesMutants(jobs) { */ export async function validateGate({ ref, sha, ops, appearMs = VALIDATE_APPEAR_MS, totalMs = VALIDATE_TOTAL_MS, pollMs = POLL_MS, wait = true, - mutants = true, + mutants = true, full = false, }) { - const policy = mutants ? CI_PROOF_POLICIES.review : CI_PROOF_POLICIES.reviewLight; - const label = mutants ? 'Validate с мутантами' : 'Validate'; + // #697: метки `ci:full`/`ci:golden` заказывают полный набор на материале — + // смоки, golden, perf. Лёгкий push-прогон его не несёт и доказательством не + // считается (policy.full), поэтому гейт диспатчит `full=true` сам. + const base = mutants ? CI_PROOF_POLICIES.review : CI_PROOF_POLICIES.reviewLight; + const policy = full ? Object.freeze({ ...base, name: `${base.name}-full`, full: true }) : base; + const label = `${mutants ? 'Validate с мутантами' : 'Validate'}${full ? ' (полный набор)' : ''}`; const started = ops.now(); const candidateTree = await ops.candidateTree(sha); const ignored = new Set(); // завершённые dispatch без применимого proof @@ -125,7 +129,7 @@ export async function validateGate({ }; } } else if (dispatchedAt === null) { - await ops.dispatch(ref, { mutants }); + await ops.dispatch(ref, { mutants, full }); dispatchedAt = ops.now(); attempts = 1; } else if (ops.now() - dispatchedAt > appearMs) { @@ -137,7 +141,7 @@ export async function validateGate({ // чужой коммит переживёт и вторую попытку. const elsewhere = (await ops.listRunsOnRef(ref)).filter(isMutantRun).find((x) => x.headSha && x.headSha !== sha); if (elsewhere && attempts < DISPATCH_ATTEMPTS) { - await ops.dispatch(ref, { mutants }); + await ops.dispatch(ref, { mutants, full }); dispatchedAt = ops.now(); attempts += 1; await ops.sleep(pollMs); @@ -172,8 +176,8 @@ export function realOps({ repo, workflow = 'validate.yml', token = process.env.G catch { return { proof: null, jobs: [], reuseRuns: new Map() }; } }, listRunsOnRef: async (ref) => parse(sh('gh', ['run', 'list', '--repo', repo, '--workflow', workflow, '--branch', ref, '--event', 'workflow_dispatch', '--json', fields, '--limit', '5'])), - dispatch: async (ref, { mutants = true } = {}) => { - const r = sh('gh', ['workflow', 'run', workflow, '--repo', repo, '--ref', ref, '-f', 'full=false', '-f', `mutants=${mutants ? 'true' : 'false'}`]); + dispatch: async (ref, { mutants = true, full = false } = {}) => { + const r = sh('gh', ['workflow', 'run', workflow, '--repo', repo, '--ref', ref, '-f', `full=${full ? 'true' : 'false'}`, '-f', `mutants=${mutants ? 'true' : 'false'}`]); if (r.status !== 0) throw new Error(`gh workflow run: ${r.stderr || r.stdout}`); }, sleep: (ms) => new Promise((done) => setTimeout(done, ms)), @@ -188,13 +192,15 @@ if (invokedDirectly) { const ref = arg('ref'); const sha = arg('sha'); if (!repo || !ref || !sha) { - console.error('usage: validate-gate.mjs --repo= --ref= --sha= [--workflow=validate.yml] [--mutants=false] [--no-wait]'); + console.error('usage: validate-gate.mjs --repo= --ref= --sha= [--workflow=validate.yml] [--mutants=false] [--full=true] [--no-wait]'); process.exit(2); } const wait = !process.argv.includes('--no-wait'); // #696: `--mutants=false` — лёгкое доказательство треков show/ship. const mutants = arg('mutants') !== 'false'; - const outcome = await validateGate({ ref, sha, wait, mutants, ops: realOps({ repo, workflow: arg('workflow') || 'validate.yml' }) }); + // #697: `--full=true` — метки ci:full/ci:golden. + const full = arg('full') === 'true'; + const outcome = await validateGate({ ref, sha, wait, mutants, full, ops: realOps({ repo, workflow: arg('workflow') || 'validate.yml' }) }); const lines = [`result=${outcome.result}`, `url=${outcome.url || ''}`, `run_id=${outcome.runId || ''}`, `note=${outcome.note}`]; for (const line of lines) console.log(line); if (process.env.GITHUB_OUTPUT) appendFileSync(process.env.GITHUB_OUTPUT, `${lines.join('\n')}\n`); diff --git a/test/beta-derived.test.mjs b/test/beta-derived.test.mjs new file mode 100644 index 00000000..aa6635e7 --- /dev/null +++ b/test/beta-derived.test.mjs @@ -0,0 +1,62 @@ +// #697, PROCESS.md §8: отпечаток и кадры скриншотов, эталоны golden — +// одним коммитом бота на dev перед бетой, а не в каждой ветке задачи. +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { validateCommitMessage } from '../scripts/validate-commit-provenance.mjs'; +import { isCandidateSubject } from '../scripts/bundle-policy.mjs'; + +const WORKFLOW = readFileSync(fileURLToPath(new URL('../.github/workflows/beta-derived.yml', import.meta.url)), 'utf8'); +const step = (name) => { + const start = WORKFLOW.indexOf(` - name: ${name}\n`); + assert.ok(start > 0, `нет шага ${name}`); + const next = WORKFLOW.indexOf('\n - ', start + 10); + return next < 0 ? WORKFLOW.slice(start) : WORKFLOW.slice(start, next); +}; + +test('#697 бот: только по кнопке, прав на запись у job нет — пишет PAT одним push', () => { + assert.match(WORKFLOW, /^on:\n workflow_dispatch:\n/m); + assert.doesNotMatch(WORKFLOW, /^\s+(push|schedule|workflow_run):/m); + assert.match(WORKFLOW, /permissions:\n\s+contents: read\n\s+actions: read\n/); + assert.doesNotMatch(WORKFLOW, /contents: write/); + const commit = step('Коммит в dev'); + assert.match(commit, /git push -q "https:\/\/x-access-token:\$TOKEN@github\.com\/\$\{\{ github\.repository \}\}" HEAD:dev/); + assert.doesNotMatch(commit, /--force/, 'ушедший dev — перезапуск, а не перезапись'); +}); + +test('#697 бот: скриншоты принимаются по канону, изменённый кадр — только объявленный', () => { + const docs = step('Кадры документации — съёмка и приёмка'); + assert.match(docs, /node demo\/docs\/capture\.mjs --stability=3/); + assert.match(docs, /git checkout -- docs\/images\n\s+git clean -fdq -- docs\/images/, 'приёмка сравнивает с закоммиченными кадрами'); + assert.match(docs, /args=\(--reviewed "--from=\$cand"\)/); + assert.match(docs, /--expect-change=\$EXPECT/); + assert.match(docs, /EXPECT: \$\{\{ inputs\.docs_expect_change \}\}/); + assert.match(WORKFLOW, /OXIPNG_VERSION: 10\.2\.0/, 'тот же упаковщик, что docs-screenshots.yml'); + const screenshots = readFileSync(fileURLToPath(new URL('../.github/workflows/docs-screenshots.yml', import.meta.url)), 'utf8'); + assert.match(screenshots, /OXIPNG_VERSION: 10\.2\.0/); +}); + +test('#697 бот: эталоны golden — только из завершённого Validate на dev', () => { + const golden = step('Эталоны golden из прогона Validate'); + assert.match(golden, /if: inputs\.golden_run != ''/); + assert.match(golden, /\[ "\$path" != "\.github\/workflows\/validate\.yml" \] \|\| \[ "\$branch" != "dev" \] \|\| \[ "\$status" != "completed" \]/); + assert.match(golden, /gh run download "\$RUN" --repo "\$\{\{ github\.repository \}\}" -n golden-images/); + assert.match(golden, /node scripts\/golden-accept\.mjs "\$\{args\[@\]\}"/); +}); + +test('#697 бот: сообщение коммита проходит провенанс и не выдаёт себя за кандидата', () => { + const commit = step('Коммит в dev'); + assert.match(commit, /if \[ "\$GOLDEN_CHANGED" = "true" \]; then\n\s+echo "Release: \$TAG"\n\s+echo "Baseline-Reviewed: \$GOLDEN_URL"/, + 'эталоны без Release: и Baseline-Reviewed: провенанс отклонит'); + assert.match(commit, /echo "Issue: #697"\n\s+echo "User-Visible: no"/); + const subject = 'docs: accept derived artifacts on dev for v1.79.0-beta.1'; + assert.match(commit, /echo "docs: accept derived artifacts on dev for \$TAG"/); + assert.equal(isCandidateSubject(subject), false, 'бандл кандидата у коммита бота не сверяется'); + const golden = [subject, '', 'Производные артефакты беты.', '', + 'Release: v1.79.0-beta.1', 'Baseline-Reviewed: https://github.com/o/r/actions/runs/1', 'Issue: #697', 'User-Visible: no'].join('\n'); + assert.deepEqual(validateCommitMessage(golden, ['docs/images/screenshots.json', 'demo/golden/baselines/a.png']), []); + const docsOnly = [subject, '', 'Производные артефакты беты.', '', 'Issue: #697', 'User-Visible: no'].join('\n'); + assert.deepEqual(validateCommitMessage(docsOnly, ['docs/images/screenshots.json']), []); + assert.notDeepEqual(validateCommitMessage(docsOnly, ['demo/golden/baselines/a.png']), [], 'эталоны без провенанса — отказ'); +}); diff --git a/test/classify-changes.test.mjs b/test/classify-changes.test.mjs index f6f00f38..43baf37a 100644 --- a/test/classify-changes.test.mjs +++ b/test/classify-changes.test.mjs @@ -236,3 +236,20 @@ test('#510 AC1 / #601 AC1: мутанты по диффу запрашивают assert.equal(heavyGatesRequested({ eventName: 'push', headMessage: 'x\n\nRelease: v1.2.3' }), true); assert.equal(heavyGatesRequested({ eventName: 'workflow_dispatch', fullInput: 'true' }), true); }); + +test('#697: на ветке задачи трейлер Release: тяжёлый набор не включает, на dev — как прежде', () => { + const acceptance = 'test: accept golden\n\nIssue: #687\nUser-Visible: no\nRelease: v1.78.0-beta.9\nBaseline-Reviewed: https://github.com/o/r/actions/runs/1'; + assert.equal(heavyGatesRequested({ eventName: 'push', headMessage: acceptance, refName: 'issue/687-x' }), false); + assert.equal(heavyGatesRequested({ eventName: 'push', headMessage: acceptance, refName: 'refs/heads/issue/687-x' }), false); + assert.equal(heavyGatesRequested({ eventName: 'push', headMessage: acceptance, refName: 'dev' }), true, 'кандидат на dev'); + assert.equal(heavyGatesRequested({ eventName: 'push', headMessage: acceptance }), true, 'без ветки — прежнее правило'); + assert.equal(heavyGatesRequested({ eventName: 'workflow_dispatch', fullInput: 'true', refName: 'issue/687-x' }), true, + 'ci:full/ci:golden — dispatch full=true на ветке задачи'); + assert.equal(screenshotsGateMode({ eventName: 'push', headMessage: acceptance, refName: 'issue/687-x' }), 'warn'); + 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: acceptance, REF_NAME: 'issue/687-x' }), 'heavy=false\nmutants_requested=false'); + const workflow = readFileSync(new URL('../.github/workflows/validate.yml', import.meta.url), 'utf8'); + assert.equal((workflow.match(/REF_NAME: \$\{\{ github\.ref_name \}\}/g) || []).length >= 2, true, 'оба вызова знают ветку'); +}); diff --git a/test/process-track.test.mjs b/test/process-track.test.mjs index 9f3cfd4a..5d37d159 100644 --- a/test/process-track.test.mjs +++ b/test/process-track.test.mjs @@ -21,8 +21,8 @@ test('пакет задачи и конвейер читают трек одно test('явная трековая метка главнее признака инфраструктуры (#696)', () => { const infra = ['scripts/x.mjs', '.github/workflows/y.yml']; - assert.deepEqual(resolveTrack({ labels: ['track:ask'], files: infra }), { track: 'ask', mutants: true, infrastructure: true }); - assert.deepEqual(resolveTrack({ labels: ['track:ship'], files: ['src/a.ts'] }), { track: 'ship', mutants: false, infrastructure: false }); + assert.deepEqual(resolveTrack({ labels: ['track:ask'], files: infra }), { track: 'ask', mutants: true, full: false, infrastructure: true }); + assert.deepEqual(resolveTrack({ labels: ['track:ship'], files: ['src/a.ts'] }), { track: 'ship', mutants: false, full: false, infrastructure: false }); assert.equal(resolveTrack({ labels: ['small'], files: ['src/a.ts'] }).track, 'show'); }); @@ -168,3 +168,19 @@ test('конвейер: ship в рамках сливается без моде assert.match(env, /if: steps\.env_needs\.outputs\.deps == 'true'\n\s+run: npm ci/); assert.match(env, /if: steps\.env_needs\.outputs\.browser == 'true' && steps\.pw\.outputs\.cache-hit != 'true'/); }); + +test('#697: полный набор на ветке задачи — только по меткам ci:full и ci:golden', () => { + assert.equal(resolveTrack({ labels: ['track:show'], files: ['src/a.ts'] }).full, false); + assert.equal(resolveTrack({ labels: ['track:show', 'ci:golden'], files: ['src/a.ts'] }).full, true); + assert.equal(resolveTrack({ labels: ['track:ask', 'ci:full'], files: ['src/a.ts'] }).full, true); + assert.equal(resolveTrack({ labels: ['ci:mutants'], files: ['src/a.ts'] }).full, false, 'мутанты полного набора не заказывают'); +}); + +test('#697: конвейер передаёт полный набор гейту материала', async () => { + const { readFileSync } = await import('node:fs'); + const workflow = readFileSync(WORKFLOW, 'utf8'); + const gate = workflow.slice(workflow.indexOf(' - name: Validate на материале\n'), workflow.indexOf(' - name: Validate идёт — раунд продолжит событие\n')); + assert.match(gate, /FULL: \$\{\{ steps\.track\.outputs\.full \}\}/); + assert.match(gate, /--full="\$\{FULL:-false\}"/); + assert.match(workflow, /full=\$\(printf '%s\\n' "\$out" \| sed -n 's\/\^full=\/\/p'\)/); +}); diff --git a/test/validate-gate.test.mjs b/test/validate-gate.test.mjs index 97a36417..4869868f 100755 --- a/test/validate-gate.test.mjs +++ b/test/validate-gate.test.mjs @@ -46,7 +46,7 @@ function fakeOps({ snapshots, onRef = [], jobsById = {} }) { }); return { proof, jobs: [...BASE_JOBS, ...selected], reuseRuns: new Map() }; }, - dispatch: async (ref, { mutants = true } = {}) => { dispatched.push(mutants ? ref : `${ref}:light`); }, + dispatch: async (ref, { mutants = true, full = false } = {}) => { dispatched.push(`${mutants ? ref : `${ref}:light`}${full ? ':full' : ''}`); }, sleep: async (ms) => { clock += ms; }, now: () => clock, }, @@ -242,3 +242,17 @@ test('#696: a completed run beats a newer running dispatch only without mutants' assert.equal(proofCandidate(run({ event: 'push', status: 'in_progress' }), { mutants: false }), false); assert.equal(proofCandidate(run({ event: 'push' }), { mutants: true }), false); }); + +test('#697: ci:full/ci:golden — лёгкий push-прогон не доказательство, гейт диспатчит full=true', async () => { + const done = run({ event: 'push', databaseId: 7, url: 'https://run/push' }); + const dispatchedRun = run({ databaseId: 8, status: 'in_progress', conclusion: null }); + const fake = fakeOps({ snapshots: [[done], [done], [done, dispatchedRun]], jobsById: { 7: OTHER_JOBS } }); + const outcome = await validateGate({ ref: 'issue/1', sha: SHA, ops: fake.ops, mutants: false, full: true, wait: false, pollMs: 1000 }); + assert.equal(outcome.result, 'pending'); + assert.equal(outcome.runId, 8); + assert.deepEqual(fake.dispatched, ['issue/1:light:full']); + // без метки тот же push-прогон — доказательство, и dispatch не нужен + const light = fakeOps({ snapshots: [[done]], jobsById: { 7: OTHER_JOBS } }); + assert.equal((await validateGate({ ref: 'issue/1', sha: SHA, ops: light.ops, mutants: false, wait: false })).result, 'green'); + assert.deepEqual(light.dispatched, []); +});