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, []);
+});
]