process: derived artifacts are accepted on dev once per beta (#697)

The screenshot fingerprint and golden baselines stop being a tax on every
task branch:

- Task branches no longer commit docs/images/** or golden baselines. On a
  branch the screenshot freshness stays a preflight warning; the review
  prompt, REVIEWER.md and AUTHOR.md drop check-docs as a per-task gate.
- beta-derived.yml refreshes them on dev in one bot commit before the beta
  candidate: canonical docs capture + docs:accept --reviewed, golden from
  the golden-images artifact of a completed Validate on dev +
  golden:accept --reviewed. A changed frame or scene is accepted only when
  named in the inputs; undeclared differences refuse. Baseline commits carry
  Release: and Baseline-Reviewed:; the subject is not a candidate subject.
- classify-changes: the Release: trailer on an issue/* branch no longer
  switches on the heavy set. ci:full / ci:golden do: process-track emits
  full=true, the review gate dispatches Validate with full=true and does not
  accept a light proof.

Canon: PROCESS.md §3 п.13, §5.1, §8, §11.4; CONTRIBUTING.md.

Issue: #697
User-Visible: no
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
This commit is contained in:
Claude
2026-09-28 23:38:50 +03:00
parent c716bb0f63
commit 2a62ad5b95
15 changed files with 490 additions and 63 deletions
+10 -6
View File
@@ -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 <base> --head <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 <экспорт>` при правке геометрии или
+214
View File
@@ -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"
+5 -1
View File
@@ -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'
+9 -6
View File
@@ -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=<unpacked
artifact>`; 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=<unpacked artifact>`, 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:`
+45 -22
View File
@@ -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), а не задачей, которая
наткнулась на него следующей.
**Границы, за которыми исключение не действует.** Оно про починку названного
гейтом дефекта, а не про продолжение разработки под видом починки. Правка идёт
+7 -5
View File
@@ -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-гейты)).
- Полные наборы — предрелизный гейт, а не гейт ревью. Упавший предрелизный
гейт автор чинит и повторно прогоняет; повторного код-ревью нет, если
+5 -4
View File
@@ -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 <base> --head <head>` с решением по
каждой строке; `golden:verify` при видимом изменении; `pytest tests_backend`
каждой строке; `golden:verify` при метке `ci:golden`; `pytest tests_backend`
при правке Python; инварианты модели при правке геометрии; performance —
если назван в AC. Полные наборы — предрелизный гейт, а не гейт ревью
([§8](../../PROCESS.md#8-гейты)).
+13 -1
View File
@@ -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/<NN>-…` (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({
+45
View File
@@ -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');
+10 -6
View File
@@ -5,9 +5,11 @@
* node scripts/process-track.mjs resolve --labels="a,b" --base=<ref> --head=<ref>
* node scripts/process-track.mjs ship-limits --base=<ref> --head=<ref>
*
* `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({
+15 -9
View File
@@ -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=<owner/repo> --ref=<branch> --sha=<sha> [--workflow=validate.yml] [--mutants=false] [--no-wait]');
console.error('usage: validate-gate.mjs --repo=<owner/repo> --ref=<branch> --sha=<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`);
+62
View File
@@ -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']), [], 'эталоны без провенанса — отказ');
});
+17
View File
@@ -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, 'оба вызова знают ветку');
});
+18 -2
View File
@@ -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'\)/);
});
+15 -1
View File
@@ -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, []);
});