mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fa570642df | ||
|
|
2a62ad5b95 | ||
|
|
c716bb0f63 | ||
|
|
ac75df7e9a | ||
|
|
e1ae8f4ac7 | ||
|
|
9e4bfb4379 | ||
|
|
d8465b3a09 | ||
|
|
f6c76b7351 | ||
|
|
51854aea53 | ||
|
|
57ce10721f |
+192
-25
@@ -58,6 +58,8 @@ jobs:
|
||||
cycle: ${{ steps.decide.outputs.cycle }}
|
||||
spent: ${{ steps.decide.outputs.spent }}
|
||||
limit: ${{ steps.decide.outputs.limit }}
|
||||
# #696: текущие метки — prepare выводит из них трек захода.
|
||||
labels: ${{ steps.decide.outputs.labels }}
|
||||
steps:
|
||||
# Мелкий checkout: guard остаётся лёгким, но ему нужен
|
||||
# scripts/review-doc-guard.mjs — счёт раундов вынесен туда, потому что
|
||||
@@ -84,8 +86,15 @@ jobs:
|
||||
BLOCKED=false; EXHAUSTED=false; SMALL=false; TRIVIAL=false
|
||||
has blocked && BLOCKED=true
|
||||
has review-4 && EXHAUSTED=true
|
||||
# Треки #695 (PROCESS §5): `track:show` и `track:ship` — лимит 2, как у
|
||||
# прежних `small`/`trivial`; `track:ask` — 4. Цену захода по треку
|
||||
# (мутанты, ребейз, модель) решает prepare (#696).
|
||||
has small && SMALL=true
|
||||
has trivial && TRIVIAL=true
|
||||
has track:show && SMALL=true
|
||||
has track:ship && SMALL=true
|
||||
# Явный `track:ask` главнее прежних меток: метка владельца окончательна.
|
||||
if has track:ask; then SMALL=false; TRIVIAL=false; fi
|
||||
|
||||
# Этап определяется первым: от него зависит, какие вердикты считать.
|
||||
stage=""; marker=""
|
||||
@@ -102,7 +111,7 @@ jobs:
|
||||
stage=""
|
||||
fi
|
||||
|
||||
# Лимит циклов: 4 обычный, 2 на лёгком и коротком треке (PROCESS.md §4).
|
||||
# Лимит циклов: 4 на `track:ask`, 2 на `track:show`/`track:ship` (PROCESS.md §4, §5).
|
||||
limit=4
|
||||
if [ "$SMALL" = "true" ] || [ "$TRIVIAL" = "true" ]; then limit=2; fi
|
||||
|
||||
@@ -213,6 +222,26 @@ jobs:
|
||||
- документы: $new_blocking"
|
||||
fi
|
||||
echo "ветка материала: ${branch:-нет, читался dev}"
|
||||
|
||||
# §5.1 (r1 #695): инфраструктурная задача (§1) без трековой метки
|
||||
# идёт как `track:show` — лимит 2. Признак механический, как в §1:
|
||||
# в диффе ветки против dev ни одного файла класса A. Guard историю
|
||||
# не клонирует, поэтому пути — из compare API; 300 файлов — потолок
|
||||
# ответа, и полный список тогда не доказан: остаётся лимит 4.
|
||||
if [ -n "$branch" ] && ! has track:ship && ! has track:show && ! has track:ask \
|
||||
&& ! has small && ! has trivial; then
|
||||
changed=$(mktemp)
|
||||
if gh api "repos/$REPO/compare/dev...$branch" --jq '.files[].filename' > "$changed" 2>/dev/null \
|
||||
&& node --input-type=module -e '
|
||||
import { readFileSync } from "node:fs";
|
||||
import { classify } from "./scripts/process-gate.mjs";
|
||||
const files = readFileSync(process.argv[1], "utf8").split("\n").filter(Boolean);
|
||||
process.exit(files.length > 0 && files.length < 300 && files.every((f) => classify(f) !== "A") ? 0 : 1);
|
||||
' "$changed"; then
|
||||
limit=2
|
||||
echo "инфраструктурная задача без трековой метки — трек show (§5.1), лимит 2"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# Отказ обязан быть виден в issue, а не только в логе прогона.
|
||||
@@ -274,6 +303,7 @@ jobs:
|
||||
echo "cycle=$attempt" >> "$GITHUB_OUTPUT"
|
||||
echo "spent=$spent" >> "$GITHUB_OUTPUT"
|
||||
echo "limit=$limit" >> "$GITHUB_OUTPUT"
|
||||
echo "labels=$(printf '%s\n' "$current" | paste -sd, -)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
prepare:
|
||||
name: "Ревью: материал и deterministic gates"
|
||||
@@ -309,6 +339,10 @@ jobs:
|
||||
spec_body_doc: ${{ steps.spec_body.outputs.doc }}
|
||||
spec_body_recorded: ${{ steps.spec_body.outputs.recorded }}
|
||||
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: Начать измерение стадии
|
||||
id: clock
|
||||
@@ -379,6 +413,59 @@ jobs:
|
||||
echo "МАТЕРИАЛ НЕ ЗАПУШЕН" >> "$GITHUB_STEP_SUMMARY"
|
||||
fi
|
||||
|
||||
# Трек решает цену захода (PROCESS.md §5, §10.4, #696): мутанты по диффу —
|
||||
# только `track:ask` и метка `ci:mutants`; `ship` сливается без модели,
|
||||
# если правка укладывается в механические рамки. Рамки судят дифф от
|
||||
# merge-base с dev — работу задачи, а не то, что принёс dev, — поэтому шаг
|
||||
# стоит до ребейза. Выход за рамки повышает трек до `show` здесь же:
|
||||
# повысить трек вправе любой, понизить — только владелец (§5). Скрипт
|
||||
# берётся из dev, как помощник ребейза: show/ship до ревью не ребейзятся,
|
||||
# и ветка, отставшая от dev, его может ещё не нести.
|
||||
- name: "Трек задачи и рамки ship (#696)"
|
||||
id: track
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
|
||||
NUM: ${{ github.event.issue.number }}
|
||||
STAGE: ${{ needs.guard.outputs.stage }}
|
||||
LABELS: ${{ needs.guard.outputs.labels }}
|
||||
BRANCH: ${{ steps.branch.outputs.name }}
|
||||
run: |
|
||||
git fetch -q origin dev
|
||||
tools="$RUNNER_TEMP/track-tools"
|
||||
rm -rf "$tools" && mkdir -p "$tools"
|
||||
git archive origin/dev scripts | tar -x -C "$tools"
|
||||
head=HEAD
|
||||
if [ -z "$BRANCH" ]; then head=origin/dev; fi
|
||||
out=$(node "$tools/scripts/process-track.mjs" resolve --labels="$LABELS" --base=origin/dev --head="$head")
|
||||
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)
|
||||
printf '%s\n' "$limits"
|
||||
if printf '%s\n' "$limits" | grep -qx 'ship=true'; then
|
||||
ship=true
|
||||
else
|
||||
violations=$(printf '%s\n' "$limits" | sed -n 's/^violations=//p')
|
||||
track=show
|
||||
cat > /tmp/ship.md <<EOF
|
||||
**Трек повышен: \`track:ship\` → \`track:show\`.** Правка выходит за механические рамки ship (PROCESS.md §5): $violations.
|
||||
|
||||
Слияние без ревью модели для неё закрыто. Этот заход идёт по треку show: лёгкий Validate и ревью модели «корректность и AC». Понизить трек обратно может только владелец.
|
||||
|
||||
[Прогон](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}).
|
||||
EOF
|
||||
gh issue comment "$NUM" --repo "${{ github.repository }}" --body-file /tmp/ship.md
|
||||
gh issue edit "$NUM" --repo "${{ github.repository }}" \
|
||||
--add-label track:show --remove-label track:ship
|
||||
echo "track:ship -> track:show: $violations"
|
||||
fi
|
||||
fi
|
||||
{ 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 и
|
||||
# слитый SHA были разными коммитами. Пока расхождение с dev текстовое,
|
||||
@@ -400,6 +487,7 @@ jobs:
|
||||
GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
|
||||
BRANCH: ${{ steps.branch.outputs.name }}
|
||||
NUM: ${{ github.event.issue.number }}
|
||||
TRACK: ${{ steps.track.outputs.track }}
|
||||
# rebase, в отличие от commit, не принимает -c user.*: он запускает
|
||||
# свои процессы и требует личность в окружении, иначе падает с
|
||||
# «unable to auto-detect email address».
|
||||
@@ -414,6 +502,21 @@ jobs:
|
||||
exit 0
|
||||
fi
|
||||
behind=$(git rev-list --count "HEAD..origin/dev")
|
||||
# #696: на show/ship ветка до ревью к dev не приводится, если
|
||||
# сливается с ним без конфликта. Ребейз — один раз, при слиянии, и
|
||||
# кандидат проходит Validate до push в dev (merge-candidate.mjs, #492):
|
||||
# в dev по-прежнему уезжает только проверенный CI SHA. Каждый сдвиг dev
|
||||
# больше не оплачивается force-push, новым SHA и новым прогоном до
|
||||
# ревью. На ask ребейз остаётся (#257): ревьюер читает то дерево,
|
||||
# которое уедет в dev.
|
||||
if [ "$TRACK" = "show" ] || [ "$TRACK" = "ship" ]; then
|
||||
if git merge-tree --write-tree origin/dev HEAD >/dev/null 2>&1; then
|
||||
echo "note=Ветка не приводилась к dev (трек $TRACK, #696): dev впереди на $behind коммит(ов), слияние без конфликта. Материал — ветка как есть; ребейз будет один раз, при слиянии, и кандидат пройдёт Validate до push в dev." >> "$GITHUB_OUTPUT"
|
||||
echo "трек $TRACK: dev впереди на $behind коммит(ов), слияние чистое — ребейз отложен до слияния"
|
||||
exit 0
|
||||
fi
|
||||
echo "трек $TRACK: слияние с dev не чистое — ветка приводится к dev до ревью"
|
||||
fi
|
||||
before=$(git rev-parse "origin/$BRANCH")
|
||||
echo "dev впереди на $behind коммит(ов) — привожу ветку"
|
||||
# #643: doc-коммит ветки конфликтует с документами других задач в dev
|
||||
@@ -600,13 +703,15 @@ jobs:
|
||||
echo "S7-code-review -> S6-in-progress (ревью не запускалось)"
|
||||
|
||||
# Мутанты по диффу бегут только по запросу (#510): до ревью конвейер
|
||||
# запускает Validate с мутантами на материале и ждёт его. Красный или
|
||||
# запускает Validate на материале и ждёт его — с мутантами на `ask` и по
|
||||
# метке `ci:mutants`, лёгкий на `show`/`ship` (#696). Лёгкому годится и
|
||||
# завершённый push-прогон на том же SHA: dispatch повторил бы его. Красный или
|
||||
# пропавший прогон возвращает задачу автору без ревью — цикл не
|
||||
# тратится на код, который CI уже отверг (08.09: #437 дважды ушёл в S6
|
||||
# после запущенного 15-минутного ревью). Этап spec кода не несёт и
|
||||
# гейт не проходит; повторное применение вердикта (#499) — тоже: там
|
||||
# слияние само дожидается Validate на кандидате.
|
||||
- name: Validate с мутантами на материале
|
||||
- name: Validate на материале
|
||||
id: gate
|
||||
if: steps.rebase.outputs.conflict != 'true'
|
||||
env:
|
||||
@@ -615,6 +720,9 @@ jobs:
|
||||
REUSE: ${{ steps.reuse.outputs.reuse }}
|
||||
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}"
|
||||
@@ -628,7 +736,8 @@ jobs:
|
||||
# S7), страховка — process-reconcile. Зелёный или красный завершённый
|
||||
# прогон гейт и без ожидания возвращает сразу.
|
||||
set +e
|
||||
node scripts/validate-gate.mjs --repo="${{ github.repository }}" --ref="$BRANCH" --sha="$SHA" --no-wait
|
||||
node scripts/validate-gate.mjs --repo="${{ github.repository }}" --ref="$BRANCH" --sha="$SHA" --no-wait \
|
||||
--mutants="${MUTANTS:-true}" --full="${FULL:-false}"
|
||||
code=$?
|
||||
set -e
|
||||
case "$code" in
|
||||
@@ -653,6 +762,7 @@ jobs:
|
||||
SHA: ${{ steps.material.outputs.sha }}
|
||||
VALIDATE_RUN_ID: ${{ steps.gate.outputs.run_id }}
|
||||
VALIDATE_URL: ${{ steps.gate.outputs.url }}
|
||||
MUTANTS: ${{ steps.track.outputs.mutants }}
|
||||
run: |
|
||||
dir="$RUNNER_TEMP/review-pending"
|
||||
mkdir -p "$dir"
|
||||
@@ -666,7 +776,9 @@ jobs:
|
||||
(cd "$dir" && sha256sum pending.json > manifest.sha256)
|
||||
echo "artifact=review-pending-${NUM}-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" >> "$GITHUB_OUTPUT"
|
||||
short=$(git rev-parse --short "$SHA")
|
||||
echo "Validate с мутантами на \`$short\` идёт — раннер освобождён, раунд продолжится по завершении прогона${VALIDATE_URL:+ ($VALIDATE_URL)}." >> "$GITHUB_STEP_SUMMARY"
|
||||
kind="Validate с мутантами"
|
||||
if [ "$MUTANTS" = "false" ]; then kind="Лёгкий Validate"; fi
|
||||
echo "$kind на \`$short\` идёт — раннер освобождён, раунд продолжится по завершении прогона${VALIDATE_URL:+ ($VALIDATE_URL)}." >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Сохранить маркер ожидания
|
||||
if: steps.gate.outputs.proceed == 'pending'
|
||||
@@ -687,10 +799,13 @@ jobs:
|
||||
RESULT: ${{ steps.gate.outputs.result }}
|
||||
NOTE: ${{ steps.gate.outputs.note }}
|
||||
URL: ${{ steps.gate.outputs.url }}
|
||||
MUTANTS: ${{ steps.track.outputs.mutants }}
|
||||
run: |
|
||||
short=$(git rev-parse --short "$SHA")
|
||||
kind="Validate с мутантами"
|
||||
if [ "$MUTANTS" = "false" ]; then kind="Validate"; fi
|
||||
cat > /tmp/gate.md <<EOF
|
||||
**Ревью не запускалось:** Validate с мутантами на материале \`$short\` (ветка \`$BRANCH\`) — **$RESULT**: $NOTE.${URL:+ [Прогон]($URL).} Код никто не читал, вердикта нет, цикл ревью не израсходован.
|
||||
**Ревью не запускалось:** $kind на материале \`$short\` (ветка \`$BRANCH\`) — **$RESULT**: $NOTE.${URL:+ [Прогон]($URL).} Код никто не читал, вердикта нет, цикл ревью не израсходован.
|
||||
|
||||
Гейт стоит до ревью намеренно (#510): красный CI всё равно вернул бы задачу, но уже после потраченного ревью.
|
||||
|
||||
@@ -698,14 +813,14 @@ jobs:
|
||||
|
||||
1. починить то, что назвал прогон, и запушить ветку **одним** коммитом-заходом;
|
||||
2. дождаться зелёного дешёвого Validate на пуше;
|
||||
3. вернуть метку \`S7-code-review\` — конвейер сам запустит Validate с мутантами и ревью.
|
||||
3. вернуть метку \`S7-code-review\` — конвейер сам запустит $kind и ревью.
|
||||
|
||||
[Прогон конвейера](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}).
|
||||
EOF
|
||||
gh issue comment "$NUM" --repo "${{ github.repository }}" --body-file /tmp/gate.md
|
||||
gh issue edit "$NUM" --repo "${{ github.repository }}" \
|
||||
--add-label S6-in-progress --remove-label S7-code-review
|
||||
echo "S7-code-review -> S6-in-progress (Validate с мутантами: $RESULT)"
|
||||
echo "S7-code-review -> S6-in-progress ($kind: $RESULT)"
|
||||
|
||||
# Ревьюер перегонял tsc, юниты и сборку заново в каждом раунде, хотя
|
||||
# Validate на том же SHA уже зелёный (#343). Это не тщательность: бюджет
|
||||
@@ -773,7 +888,7 @@ jobs:
|
||||
|
||||
- name: Собрать контракт материала между стадиями
|
||||
id: prepared
|
||||
if: steps.gate.outputs.proceed == 'true' && steps.reuse.outputs.reuse != 'true'
|
||||
if: steps.gate.outputs.proceed == 'true' && steps.reuse.outputs.reuse != 'true' && steps.track.outputs.ship != 'true'
|
||||
env:
|
||||
NUM: ${{ github.event.issue.number }}
|
||||
STAGE: ${{ needs.guard.outputs.stage }}
|
||||
@@ -809,7 +924,7 @@ jobs:
|
||||
echo "artifact=review-prepared-${NUM}-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Передать подтверждённый материал модели
|
||||
if: steps.gate.outputs.proceed == 'true' && steps.reuse.outputs.reuse != 'true'
|
||||
if: steps.gate.outputs.proceed == 'true' && steps.reuse.outputs.reuse != 'true' && steps.track.outputs.ship != 'true'
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
with:
|
||||
name: ${{ steps.prepared.outputs.artifact }}
|
||||
@@ -852,7 +967,9 @@ jobs:
|
||||
contents: read
|
||||
issues: write
|
||||
needs: [guard, prepare]
|
||||
if: needs.prepare.outputs.proceed == 'true' && needs.prepare.outputs.reuse != 'true'
|
||||
# #696: `ship` в рамках сливается без модели — пакетное ревью диапазона
|
||||
# перед бетой (PROCESS.md §11.7, ship-review.yml).
|
||||
if: needs.prepare.outputs.proceed == 'true' && needs.prepare.outputs.reuse != 'true' && needs.prepare.outputs.ship != 'true'
|
||||
runs-on: ubuntu-24.04
|
||||
concurrency:
|
||||
group: process-issue-${{ github.event.issue.number }}
|
||||
@@ -918,22 +1035,46 @@ jobs:
|
||||
test "$(git rev-parse HEAD)" = "$MATERIAL_SHA"
|
||||
test "$(git rev-parse 'HEAD^{tree}')" = "$MATERIAL_TREE"
|
||||
|
||||
# #696: окружение ставится по нужде. Ревью ТЗ кода не исполняет — ни
|
||||
# `npm ci`, ни браузера. Ревью `show` ставит Chromium, только если тело
|
||||
# issue называет смоук или браузер; `ask` — как прежде, всегда. Тело не
|
||||
# прочитано — браузер ставится: лишняя минута дешевле непрогнанного смоука.
|
||||
- name: Что ревьюеру нужно из окружения
|
||||
id: env_needs
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
NUM: ${{ github.event.issue.number }}
|
||||
STAGE: ${{ needs.guard.outputs.stage }}
|
||||
TRACK: ${{ needs.prepare.outputs.track }}
|
||||
run: |
|
||||
deps=true; browser=true
|
||||
if [ "$STAGE" = "spec" ]; then deps=false; browser=false; fi
|
||||
if [ "$STAGE" = "code" ] && [ "$TRACK" = "show" ]; then
|
||||
if body=$(gh issue view "$NUM" --repo "${{ github.repository }}" --json body --jq .body 2>/dev/null); then
|
||||
if ! printf '%s' "$body" | grep -qiE 'smoke|смоук|смок|playwright|браузер|demo/'; then browser=false; fi
|
||||
fi
|
||||
fi
|
||||
{ echo "deps=$deps"; echo "browser=$browser"; } >> "$GITHUB_OUTPUT"
|
||||
echo "- окружение ревьюера: npm ci $deps · Chromium $browser" >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
# Зависимости ставятся ПОСЛЕ переключения на ветку задачи: lockfile мог
|
||||
# измениться именно в ней, и установка по копии из dev дала бы не то дерево.
|
||||
- name: Установить зависимости
|
||||
if: steps.env_needs.outputs.deps == 'true'
|
||||
run: npm ci
|
||||
|
||||
# Браузер нужен не всякому ревью (см. правило выбора гейтов в промпте),
|
||||
# но когда нужен — качать его заново дороже, чем держать в кэше.
|
||||
- name: Кэш браузеров Playwright
|
||||
id: pw
|
||||
if: steps.env_needs.outputs.browser == 'true'
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
key: playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
|
||||
|
||||
- name: Установить Chromium
|
||||
if: steps.pw.outputs.cache-hit != 'true'
|
||||
if: steps.env_needs.outputs.browser == 'true' && steps.pw.outputs.cache-hit != 'true'
|
||||
# Без --with-deps: системные библиотеки Chromium предустановлены в
|
||||
# образе раннера, а apt при промахе кэша съедал минуты из бюджета
|
||||
# ревью и подолгу перебирал недоступное azure-зеркало (#175). Если
|
||||
@@ -993,6 +1134,9 @@ jobs:
|
||||
ничего не вернул на правки и цикла не образует (#227).
|
||||
Номер захода нужен для имени документа — два документа с
|
||||
одинаковым номером затёрли бы друг друга.
|
||||
Трек: ${{ needs.prepare.outputs.track || 'ask' }} (PROCESS.md §5) · мутанты по диффу на материале: ${{ needs.prepare.outputs.mutants == 'false' && 'не запрашивались' || 'прогнаны Validate' }}
|
||||
|
||||
${{ needs.prepare.outputs.track == 'show' && needs.guard.outputs.stage == 'code' && 'Трек show: объём и серьёзность находок — docs/process/REVIEWER.md, «Трек show» (#696). Отсутствие мутантов по диффу — не находка.' || '' }}
|
||||
|
||||
${{ needs.prepare.outputs.rebase_note }}
|
||||
|
||||
@@ -1078,17 +1222,17 @@ jobs:
|
||||
|
||||
Если зелёного прогона на этом SHA нет — прогоняешь сам, они дешёвые,
|
||||
и в повторном раунде тоже: `npx tsc --noEmit`, `npm test`,
|
||||
`npm run build` со сверкой трёх копий бандла, плюс
|
||||
`node scripts/check-docs.mjs`, если diff трогает `src/**`.
|
||||
Зависимости уже установлены workflow, Chromium тоже — `npm ci`
|
||||
выполнять не нужно. По диффу и AC: браузерные смоки — названные в AC
|
||||
`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>`,
|
||||
приложенный к комментарию с решением по каждой строке: прогнал либо
|
||||
не прогнал и почему. Три вида ответа инструмента разные: «прямое
|
||||
совпадение», «зарегистрированная связь», «НЕОПРЕДЕЛЁННОСТЬ» — связь
|
||||
не доказана, и это не разрешение ничего не прогонять; слабые связи —
|
||||
повод посмотреть, а не обязанность прогонять;
|
||||
`npm run golden:verify` при видимом изменении;
|
||||
`npm run golden:verify` при метке `ci:golden`;
|
||||
`python -m pytest tests_backend -q` при правке
|
||||
`custom_components/**/*.py`; инварианты модели
|
||||
`npm run invariants -- --config <экспорт>` при правке геометрии или
|
||||
@@ -1221,6 +1365,7 @@ jobs:
|
||||
MODEL_RESULT: ${{ needs.model_review.result }}
|
||||
PROCEED: ${{ needs.prepare.outputs.proceed }}
|
||||
REUSE: ${{ needs.prepare.outputs.reuse }}
|
||||
SHIP: ${{ needs.prepare.outputs.ship }}
|
||||
run: |
|
||||
if [ "$PREPARE_RESULT" != "success" ]; then
|
||||
echo "::error::стадия deterministic prerequisites завершилась: $PREPARE_RESULT"
|
||||
@@ -1236,7 +1381,8 @@ jobs:
|
||||
echo "proceed=false" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
if [ "$REUSE" != "true" ] && [ "$MODEL_RESULT" != "success" ]; then
|
||||
# #696: `ship` в рамках модель не вызывает — её результата не ждём.
|
||||
if [ "$REUSE" != "true" ] && [ "$SHIP" != "true" ] && [ "$MODEL_RESULT" != "success" ]; then
|
||||
echo "::error::стадия model review завершилась: $MODEL_RESULT"
|
||||
exit 1
|
||||
fi
|
||||
@@ -1255,7 +1401,7 @@ jobs:
|
||||
node-version: 22
|
||||
|
||||
- name: Получить результат модели
|
||||
if: steps.ready.outputs.proceed == 'true' && needs.prepare.outputs.reuse != 'true'
|
||||
if: steps.ready.outputs.proceed == 'true' && needs.prepare.outputs.reuse != 'true' && needs.prepare.outputs.ship != 'true'
|
||||
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
|
||||
with:
|
||||
name: review-result-${{ github.event.issue.number }}-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
@@ -1263,7 +1409,7 @@ jobs:
|
||||
|
||||
- name: Проверить полноту и происхождение результата
|
||||
id: result
|
||||
if: steps.ready.outputs.proceed == 'true' && needs.prepare.outputs.reuse != 'true'
|
||||
if: steps.ready.outputs.proceed == 'true' && needs.prepare.outputs.reuse != 'true' && needs.prepare.outputs.ship != 'true'
|
||||
# #556: единственное, что недоверенная стадия модели может передать
|
||||
# дальше, — этот artifact, и принимается он как ввод противника: полный
|
||||
# набор файлов, сходящиеся суммы, совпадение КАЖДОГО поля паспорта с
|
||||
@@ -1299,7 +1445,7 @@ jobs:
|
||||
# Ревьюер пишет только в docs/reviews/. Что именно попадёт в коммит,
|
||||
# решает этот шаг, а не модель: всё остальное откатывается.
|
||||
- name: Опубликовать документ ревью
|
||||
if: steps.ready.outputs.proceed == 'true' && needs.prepare.outputs.reuse != 'true'
|
||||
if: steps.ready.outputs.proceed == 'true' && needs.prepare.outputs.reuse != 'true' && needs.prepare.outputs.ship != 'true'
|
||||
env:
|
||||
TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
|
||||
BRANCH: ${{ needs.prepare.outputs.branch }}
|
||||
@@ -1472,7 +1618,7 @@ jobs:
|
||||
# достижим там из необновлённой локальной ветки. Читателю отчёта от этого
|
||||
# пользы нет — он достанет только то, что есть на origin.
|
||||
- name: "Материал раунда воспроизводим (#413)"
|
||||
if: steps.ready.outputs.proceed == 'true' && needs.prepare.outputs.reuse != 'true'
|
||||
if: steps.ready.outputs.proceed == 'true' && needs.prepare.outputs.reuse != 'true' && needs.prepare.outputs.ship != 'true'
|
||||
env:
|
||||
NUM: ${{ github.event.issue.number }}
|
||||
STAGE: ${{ needs.guard.outputs.stage }}
|
||||
@@ -1497,10 +1643,28 @@ jobs:
|
||||
REUSE_DOC: ${{ needs.prepare.outputs.reuse_doc }}
|
||||
REUSE_ROUND: ${{ needs.prepare.outputs.reuse_round }}
|
||||
REUSE_TREE: ${{ needs.prepare.outputs.reuse_tree }}
|
||||
SHIP: ${{ needs.prepare.outputs.ship }}
|
||||
MATERIAL: ${{ needs.prepare.outputs.material_sha }}
|
||||
VALIDATE_URL: ${{ needs.prepare.outputs.validate_url }}
|
||||
GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
|
||||
NUM: ${{ github.event.issue.number }}
|
||||
run: |
|
||||
if [ "$REUSE" = "true" ]; then
|
||||
# Повторно применимый зелёный вердикт (#499) главнее ship: код уже
|
||||
# прочитан моделью, и пакетному ревью перечитывать его незачем.
|
||||
if [ "$SHIP" = "true" ] && [ "$REUSE" != "true" ]; then
|
||||
# #696: `ship` в рамках (PROCESS.md §5) сливается без ревью модели
|
||||
# после зелёного лёгкого Validate. Комментарий — не вердикт ревью и
|
||||
# так себя не называет: ревью не было. Машинный маркер находит
|
||||
# пакетное ревью диапазона перед бетой (ship-review.mjs, §11.7).
|
||||
verdict=green; high=0
|
||||
short=$(printf '%s' "$MATERIAL" | cut -c1-8)
|
||||
cat > /tmp/ship-merge.md <<EOF
|
||||
**Слияние без ревью модели: трек ship (PROCESS.md §5, #696).** Правка укладывается в механические рамки ship, лёгкий Validate на \`$short\` зелёный${VALIDATE_URL:+ ([прогон]($VALIDATE_URL))}. Код до слияния не читал никто: его прочтёт пакетное ревью диапазона перед бетой (PROCESS.md §11.7), документ \`docs/reviews/SHIP-REVIEW-<тег>.md\`.
|
||||
|
||||
<!-- hp:ship-merge material=$MATERIAL -->
|
||||
EOF
|
||||
gh issue comment "$NUM" --repo "${{ github.repository }}" --body-file /tmp/ship-merge.md
|
||||
elif [ "$REUSE" = "true" ]; then
|
||||
# Модель не вызывалась: вердикт — записанный конвейером зелёный
|
||||
# прошлого захода, дерево вне docs/reviews с тех пор не менялось (#499).
|
||||
verdict=green; high=0
|
||||
@@ -1580,6 +1744,8 @@ jobs:
|
||||
BRANCH: ${{ needs.prepare.outputs.branch }}
|
||||
NUM: ${{ github.event.issue.number }}
|
||||
MATERIAL_SHA: ${{ needs.prepare.outputs.material_sha }}
|
||||
# #696: show/ship сливаются по лёгкому Validate на кандидате.
|
||||
MUTANTS: ${{ needs.prepare.outputs.mutants }}
|
||||
run: |
|
||||
if [ -z "$BRANCH" ]; then
|
||||
echo "::error::ветки задачи нет — сливать нечего"
|
||||
@@ -1587,7 +1753,7 @@ jobs:
|
||||
exit 0
|
||||
fi
|
||||
node scripts/merge-candidate.mjs --branch="$BRANCH" --material="$MATERIAL_SHA" \
|
||||
--issue="$NUM" --repo="${{ github.repository }}"
|
||||
--issue="$NUM" --repo="${{ github.repository }}" --mutants="${MUTANTS:-true}"
|
||||
|
||||
- name: Переставить метку
|
||||
if: steps.ready.outputs.proceed == 'true'
|
||||
@@ -1634,13 +1800,14 @@ jobs:
|
||||
MODEL_RESULT: ${{ needs.model_review.result }}
|
||||
PROCEED: ${{ needs.prepare.outputs.proceed }}
|
||||
REUSE: ${{ needs.prepare.outputs.reuse }}
|
||||
SHIP: ${{ needs.prepare.outputs.ship }}
|
||||
run: |
|
||||
stage="публикация/интеграция"
|
||||
detail="Модель уже завершила работу; её запечатанный результат сохранён artifact-ом этого run, но не был применён."
|
||||
if [ "$PREPARE_RESULT" != "success" ]; then
|
||||
stage="deterministic prerequisites"
|
||||
detail="Модель не запускалась, цикл ревью не израсходован."
|
||||
elif [ "$PROCEED" = "true" ] && [ "$REUSE" != "true" ] && [ "$MODEL_RESULT" != "success" ]; then
|
||||
elif [ "$PROCEED" = "true" ] && [ "$REUSE" != "true" ] && [ "$SHIP" != "true" ] && [ "$MODEL_RESULT" != "success" ]; then
|
||||
stage="model review"
|
||||
detail="Полного валидного результата модели нет; метка не менялась."
|
||||
fi
|
||||
|
||||
@@ -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"
|
||||
@@ -91,6 +91,14 @@ jobs:
|
||||
REPO: ${{ github.repository }}
|
||||
SHA: ${{ steps.candidate.outputs.sha }}
|
||||
run: node scripts/release-gate.mjs "$SHA"
|
||||
# #696, PROCESS.md §11.7: задачи track:ship слиты без ревью модели — бета
|
||||
# публикуется, только когда их код прочитало пакетное ревью диапазона.
|
||||
- name: Ship tasks of the range are batch-reviewed
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
TAG: ${{ steps.candidate.outputs.tag }}
|
||||
SHA: ${{ steps.candidate.outputs.sha }}
|
||||
run: node scripts/ship-review.mjs check --tag="$TAG" --candidate="$SHA" --repo="$GITHUB_REPOSITORY"
|
||||
- name: Bind issue membership to the exact candidate
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
|
||||
@@ -0,0 +1,334 @@
|
||||
name: "Бета: пакетное ревью ship"
|
||||
run-name: "Ship review ${{ inputs.tag }}"
|
||||
|
||||
# #696, PROCESS.md §11.7: задачи `track:ship` сливаются без ревью модели —
|
||||
# правка в механических рамках и зелёный лёгкий Validate. Их код читает одна
|
||||
# сессия модели перед бетой: все ship-задачи диапазона «прошлый тег..кандидат».
|
||||
# Гейт публикации беты (`ship-review.mjs check`) требует этот документ, если в
|
||||
# диапазоне есть ship-задачи.
|
||||
#
|
||||
# Только `workflow_dispatch`: файл исполняется с ветки прогона (`--ref dev`),
|
||||
# зеркало в `main` не нужно. Три job, как у release-review.yml (#551, #556):
|
||||
# детерминированный вход, модель без права записи, детерминированная
|
||||
# публикация документа в `dev`.
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: "Beta tag the review is for, for example v1.79.0-beta.1"
|
||||
required: true
|
||||
type: string
|
||||
candidate:
|
||||
description: "Exact candidate SHA; empty = the dev tip"
|
||||
required: false
|
||||
type: string
|
||||
default: ""
|
||||
force:
|
||||
description: "Review again even when the document already exists in dev"
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ship-review-${{ inputs.tag }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
prepare:
|
||||
name: "Ship-ревью: задачи диапазона"
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
contents: read
|
||||
issues: read
|
||||
outputs:
|
||||
proceed: ${{ steps.range.outputs.proceed }}
|
||||
candidate: ${{ steps.range.outputs.candidate }}
|
||||
base: ${{ steps.range.outputs.base }}
|
||||
doc: ${{ steps.range.outputs.doc }}
|
||||
issues: ${{ steps.range.outputs.issues }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: dev
|
||||
persist-credentials: false
|
||||
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- name: Кандидат, база и ship-задачи
|
||||
id: range
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
TAG: ${{ inputs.tag }}
|
||||
CANDIDATE: ${{ inputs.candidate }}
|
||||
FORCE: ${{ inputs.force }}
|
||||
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||
run: |
|
||||
doc=$(node scripts/ship-review.mjs doc --tag="$TAG")
|
||||
git fetch -q --tags origin
|
||||
if [ -z "$CANDIDATE" ]; then CANDIDATE=$(git rev-parse origin/dev); fi
|
||||
git cat-file -e "$CANDIDATE^{commit}"
|
||||
CANDIDATE=$(git rev-parse "$CANDIDATE^{commit}")
|
||||
if [ "$FORCE" != "true" ] && git cat-file -e "origin/dev:$doc" 2>/dev/null; then
|
||||
echo "::notice::$doc уже есть в dev — повторное ревью не запускается (force=true, чтобы переснять)"
|
||||
echo "proceed=false" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
out="$RUNNER_TEMP/ship-review-input"
|
||||
node scripts/ship-review.mjs prepare --tag="$TAG" --candidate="$CANDIDATE" \
|
||||
--out="$out" --repo="${{ github.repository }}" --run-url="$RUN_URL" | tee "$RUNNER_TEMP/range.txt"
|
||||
cat "$RUNNER_TEMP/range.txt" >> "$GITHUB_OUTPUT"
|
||||
echo "candidate=$CANDIDATE" >> "$GITHUB_OUTPUT"
|
||||
issues=$(sed -n 's/^issues=//p' "$RUNNER_TEMP/range.txt")
|
||||
if [ -z "$issues" ]; then
|
||||
echo "::notice::ship-задач в диапазоне нет — ревью не нужно"
|
||||
echo "proceed=false" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
echo "proceed=true" >> "$GITHUB_OUTPUT"
|
||||
(cd "$out" && sha256sum brief.md ship-issues.json > manifest.sha256)
|
||||
cat "$out/brief.md" >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Передать вход модели
|
||||
if: steps.range.outputs.proceed == 'true'
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
with:
|
||||
name: ship-review-input-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
path: ${{ runner.temp }}/ship-review-input
|
||||
if-no-files-found: error
|
||||
retention-days: 3
|
||||
|
||||
model_review:
|
||||
name: "Ship-ревью: работа модели"
|
||||
needs: prepare
|
||||
if: needs.prepare.outputs.proceed == 'true'
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 45
|
||||
# Недоверенная стадия без права записи (#556): документ публикует
|
||||
# `publish`. `github_token` у шага Review обязателен — без него action
|
||||
# меняет OIDC на собственный App-токен с правом записи.
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: ${{ needs.prepare.outputs.candidate }}
|
||||
persist-credentials: false
|
||||
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
|
||||
- name: Получить вход
|
||||
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
|
||||
with:
|
||||
name: ship-review-input-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
path: ${{ runner.temp }}/ship-review-input
|
||||
|
||||
- name: Проверить вход и кандидата
|
||||
env:
|
||||
CANDIDATE: ${{ needs.prepare.outputs.candidate }}
|
||||
run: |
|
||||
(cd "$RUNNER_TEMP/ship-review-input" && sha256sum -c manifest.sha256)
|
||||
test "$(git rev-parse HEAD)" = "$CANDIDATE"
|
||||
|
||||
- name: Установить зависимости
|
||||
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: Установить Chromium
|
||||
if: steps.pw.outputs.cache-hit != 'true'
|
||||
run: npx playwright install chromium
|
||||
|
||||
# Тот же обход, что у конвейера (_process.yml, anthropics issue 1817).
|
||||
- name: Установить Claude Code детерминированно
|
||||
id: claude_bin
|
||||
run: |
|
||||
src=$(ls "$RUNNER_WORKSPACE"/../_actions/anthropics/claude-code-*/v1/src/entrypoints/run.ts 2>/dev/null | head -1)
|
||||
ver=$(grep -oE 'claudeCodeVersion = "[0-9]+\.[0-9]+\.[0-9]+"' "$src" 2>/dev/null | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' || true)
|
||||
ver="${ver:-2.1.265}"
|
||||
base=https://downloads.claude.ai/claude-code-releases
|
||||
bin="$HOME/.local/bin/claude"
|
||||
mkdir -p "$(dirname "$bin")"
|
||||
curl -fsSL --retry 3 "$base/$ver/linux-x64/claude" -o "$bin"
|
||||
sum=$(curl -fsSL --retry 3 "$base/$ver/manifest.json" | jq -r '.platforms["linux-x64"].checksum')
|
||||
echo "$sum $bin" | sha256sum -c -
|
||||
chmod +x "$bin"
|
||||
"$bin" --version
|
||||
echo "path=$bin" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Review
|
||||
id: review
|
||||
uses: anthropics/claude-code-action@9cdae7f0d995e3ba7c33f226087fdf82a59cd520 # v1
|
||||
env:
|
||||
REVIEW_DOC: ${{ runner.temp }}/ship-review.md
|
||||
REVIEW_INPUT: ${{ runner.temp }}/ship-review-input
|
||||
with:
|
||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
path_to_claude_code_executable: ${{ steps.claude_bin.outputs.path }}
|
||||
prompt: |
|
||||
Ты ревьюер кода проекта House Plan. Язык ответа — русский.
|
||||
|
||||
Бета: ${{ inputs.tag }} · кандидат ${{ needs.prepare.outputs.candidate }}
|
||||
(рабочая копия уже на нём) · база диапазона: ${{ needs.prepare.outputs.base || 'нет' }}.
|
||||
Задачи: ${{ needs.prepare.outputs.issues }} — вход в $REVIEW_INPUT/brief.md
|
||||
(строка ТЗ каждой задачи и её коммиты) и $REVIEW_INPUT/ship-issues.json.
|
||||
|
||||
Эти задачи шли по треку ship и слиты в dev без ревью модели: правка
|
||||
в механических рамках и зелёный лёгкий Validate. Ты — первый, кто
|
||||
читает их код. Правила — docs/process/REVIEWER.md, раздел
|
||||
«Пакетное ревью ship», канон — PROCESS.md §5 и §11.7. Прочитай их
|
||||
первыми.
|
||||
|
||||
По каждой задаче: `git show <sha>` каждого коммита; делает ли код
|
||||
то, что сказано в строке ТЗ, и только это; не ломает ли соседнее
|
||||
поведение; не вышла ли правка из трека ship по смыслу (новый
|
||||
UX-контракт, геометрия, конфиг, перф) — это находка Medium с
|
||||
предложением трека. Исполняй, где дёшево: `npm test`, смоук,
|
||||
который называет строка ТЗ или выбирает
|
||||
`node scripts/smoke-select.mjs --base <sha>^ --head <sha>`.
|
||||
|
||||
Серьёзность: High — дефект, который увидит пользователь беты, или
|
||||
правка не делает заявленного; High блокирует публикацию беты.
|
||||
Medium — поведение вне заявленного или вне рамок трека; Low —
|
||||
остальное. Находка чинится отдельной задачей, ты ничего не правишь.
|
||||
|
||||
Ты ничего не правишь и не публикуешь: ни код, ни issue, ни
|
||||
комментарии. Права на запись у тебя нет. Любые изменения рабочей
|
||||
копии будут отброшены — после проверок восстанови её сам
|
||||
(`git checkout -- . && git clean -fd`), если что-то менял.
|
||||
|
||||
Напиши документ в файл по пути из переменной REVIEW_DOC (абсолютный,
|
||||
вне репозитория). Первой строкой после заголовка —
|
||||
`Итог: High N · Medium N · Low N`. Затем по задаче на раздел
|
||||
`## #NN — заголовок`: что проверил (команда → результат), находки с
|
||||
воспроизведением, чего не проверял.
|
||||
|
||||
Затем верни JSON по схеме — последнее обязательное действие.
|
||||
claude_args: |
|
||||
--max-turns 150
|
||||
--allowedTools Read,Write,Grep,Glob,Bash
|
||||
--json-schema '{"type":"object","properties":{"high":{"type":"integer"},"medium":{"type":"integer"},"low":{"type":"integer"},"summary":{"type":"string"}},"required":["high","medium","low","summary"]}'
|
||||
|
||||
- name: Запечатать результат модели
|
||||
env:
|
||||
SOURCE: ${{ runner.temp }}/ship-review.md
|
||||
OUT: ${{ steps.review.outputs.structured_output }}
|
||||
run: |
|
||||
test -s "$SOURCE" || { echo "::error::модель не оставила документ ревью"; exit 1; }
|
||||
dir="$RUNNER_TEMP/ship-review-result"
|
||||
mkdir -p "$dir"
|
||||
printf '%s' "$OUT" > "$dir/result.json"
|
||||
jq -e '(.high|type=="number") and (.medium|type=="number") and (.low|type=="number") and (.summary|type=="string")' \
|
||||
"$dir/result.json" >/dev/null
|
||||
cp "$SOURCE" "$dir/ship-review.md"
|
||||
(cd "$dir" && sha256sum ship-review.md result.json > manifest.sha256)
|
||||
|
||||
- name: Передать результат публикации
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
with:
|
||||
name: ship-review-result-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
path: ${{ runner.temp }}/ship-review-result
|
||||
if-no-files-found: error
|
||||
retention-days: 3
|
||||
|
||||
publish:
|
||||
name: "Ship-ревью: документ в dev"
|
||||
needs: [prepare, model_review]
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: dev
|
||||
persist-credentials: false
|
||||
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- name: Получить результат модели
|
||||
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
|
||||
with:
|
||||
name: ship-review-result-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
path: ${{ runner.temp }}/ship-review-result
|
||||
|
||||
# Модель пишет только текст. Путь, машинный блок (его читает гейт беты),
|
||||
# индекс и коммит решает этот шаг.
|
||||
- name: Опубликовать документ
|
||||
env:
|
||||
TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
|
||||
TAG: ${{ inputs.tag }}
|
||||
DOC: ${{ needs.prepare.outputs.doc }}
|
||||
CANDIDATE: ${{ needs.prepare.outputs.candidate }}
|
||||
BASE: ${{ needs.prepare.outputs.base }}
|
||||
ISSUES: ${{ needs.prepare.outputs.issues }}
|
||||
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||
run: |
|
||||
dir="$RUNNER_TEMP/ship-review-result"
|
||||
(cd "$dir" && sha256sum -c manifest.sha256)
|
||||
test "$DOC" = "$(node scripts/ship-review.mjs doc --tag="$TAG")"
|
||||
high=$(jq -r '.high' "$dir/result.json")
|
||||
medium=$(jq -r '.medium' "$dir/result.json")
|
||||
low=$(jq -r '.low' "$dir/result.json")
|
||||
for attempt in 1 2 3; do
|
||||
git fetch -q origin dev
|
||||
git reset -q --hard origin/dev
|
||||
git clean -fdq
|
||||
mkdir -p docs/reviews
|
||||
{
|
||||
cat "$dir/ship-review.md"
|
||||
printf '\n\n'
|
||||
node -e '
|
||||
import("./scripts/ship-review.mjs").then((m) => process.stdout.write(m.anchorBlock({
|
||||
tag: process.env.TAG, candidate: process.env.CANDIDATE, base: process.env.BASE || null,
|
||||
issues: String(process.env.ISSUES || "").split(",").filter(Boolean).map(Number),
|
||||
high: Number(process.argv[1]), medium: Number(process.argv[2]), low: Number(process.argv[3]),
|
||||
runUrl: process.env.RUN_URL,
|
||||
})));
|
||||
' "$high" "$medium" "$low"
|
||||
} > "$DOC"
|
||||
node scripts/reviews-index.mjs --dir=docs/reviews --strict
|
||||
git add -- "$DOC" docs/reviews/INDEX.md
|
||||
git diff --cached --name-only | node scripts/review-doc-guard.mjs
|
||||
git -c user.name="claude[bot]" \
|
||||
-c user.email="209825114+claude[bot]@users.noreply.github.com" \
|
||||
commit -q -F - <<MSG
|
||||
docs: ship review for $TAG
|
||||
|
||||
Пакетное ревью задач track:ship перед бетой (PROCESS.md §11.7).
|
||||
Задачи: $ISSUES. Итог: High $high · Medium $medium · Low $low.
|
||||
|
||||
Issue: #696
|
||||
User-Visible: no
|
||||
MSG
|
||||
git diff --name-only "origin/dev...HEAD" | node scripts/review-doc-guard.mjs
|
||||
if git push -q "https://x-access-token:$TOKEN@github.com/${{ github.repository }}" HEAD:dev; then
|
||||
echo "### Пакетное ревью ship $TAG" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "Задачи $ISSUES · High $high · Medium $medium · Low $low — \`$DOC\` в dev." >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "::notice::$DOC опубликован: High $high · Medium $medium · Low $low"
|
||||
exit 0
|
||||
fi
|
||||
echo "::warning::dev ушёл вперёд — попытка $attempt из 3, документ собирается заново"
|
||||
sleep $((attempt * 10))
|
||||
done
|
||||
echo "::error::документ ревью не опубликован в dev за три попытки"
|
||||
exit 1
|
||||
@@ -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'
|
||||
|
||||
@@ -73,13 +73,19 @@ overlap. The committed bundle changes only in a commit with a `Release:` trailer
|
||||
(#657); an ordinary task restores it with `npm run bundle:clean` before
|
||||
committing.
|
||||
|
||||
**Tracks** (`PROCESS.md` §5, §5.1): `small` is the default — the spec lives in the
|
||||
issue body and its review is a comment; taking the full track means naming the
|
||||
`small` criterion the task fails. `trivial` skips the spec stage for a bug whose
|
||||
expected behaviour is already on record. An **infrastructure** task — not a single
|
||||
**Tracks** (`PROCESS.md` §5, §5.1): the label `track:ship`, `track:show` or
|
||||
`track:ask` sets the route, and the owner's label beats the criteria. `show` is the
|
||||
default: up to three AC in the issue body, no spec review, `S2` → `S5`. `ship` is a
|
||||
one-sentence change within fixed limits, `S1` → `S5`. `ask` is the full route with
|
||||
a spec review. Any agent may raise a track with a reason; only the owner lowers it.
|
||||
`small` and `trivial` read as `show`. An **infrastructure** task — not a single
|
||||
class A file — skips analysis and spec and enters at `S7-code-review`
|
||||
(`PROCESS.md` §1). Code review is never skipped on any track: it checks scope,
|
||||
risks and the evidence from executed tests, but does not replace executing them.
|
||||
(`PROCESS.md` §1). Every change is code-reviewed; on `ship` the review moves to a
|
||||
batch review of the beta range before the tag (`ship-review.yml`, `PROCESS.md`
|
||||
§11.7). The review pipeline prices each round by track (§10.4): diff mutants
|
||||
only on `ask` or with `ci:mutants`; a rebase before review only on `ask` or when
|
||||
the branch does not merge cleanly into `dev`. Review checks scope, risks and the
|
||||
evidence from executed tests, but does not replace executing them.
|
||||
|
||||
## Specs
|
||||
|
||||
|
||||
+9
-6
@@ -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:`
|
||||
|
||||
+203
-105
@@ -3,7 +3,7 @@
|
||||
> **Статус документа: канон** (редакция 2026-08-13, ролевое уточнение
|
||||
> 2026-09-13). Решения владельца, на
|
||||
> которых он стоит: прямые коммиты в `dev` **без PR** · канон статуса — **метки**,
|
||||
> имена английские · лёгкий трек **включён** · автор и ревьюер — независимые
|
||||
> имена английские · треки `ship`/`show`/`ask` задаёт метка владельца (#695) · автор и ревьюер — независимые
|
||||
> агенты/сессии · любой агент может взять любую роль · инфраструктурные задачи
|
||||
> входят в общий флоу сразу на `S7-code-review`.
|
||||
>
|
||||
@@ -93,8 +93,9 @@ S1-new → S2-analysis → S3-spec → S4-spec-review ⟲ → S5-ready →
|
||||
→ S6-in-progress → S7-code-review ⟲ → S8-merged → закрыт при выпуске беты
|
||||
|
||||
служебные: blocked (поверх статуса) rejected (закрыт)
|
||||
⟲ — возврат на правки, не более 4 циклов (§4), на лёгком и коротком треке 2
|
||||
короткий трек (`trivial`, §5.1) идёт S2-analysis → S5-ready, минуя S3 и S4
|
||||
⟲ — возврат на правки, не более 4 циклов (§4), на треке `show` 2
|
||||
трек `show` (§5) идёт S2-analysis → S5-ready, минуя S3 и S4
|
||||
трек `ship` (§5) идёт S1-new → S5-ready, минуя S2, S3 и S4
|
||||
инфраструктурный трек (§1): без S → S7-code-review ⟲ S6-in-progress → S8-merged
|
||||
```
|
||||
|
||||
@@ -125,9 +126,10 @@ S1-new → S2-analysis → S3-spec → S4-spec-review ⟲ → S5-ready →
|
||||
5. приоритет **P1/P2/P3**;
|
||||
6. тип: баг / фича / техдолг;
|
||||
7. затронутые поверхности (модули, диалоги, бэкенд, i18n);
|
||||
8. трек — **по умолчанию `small`** (§5). Если задача идёт полным треком,
|
||||
называется критерий §5, который она не проходит: «обычный трек» без
|
||||
названного критерия обоснованием не является.
|
||||
8. трек — метка `track:ship`, `track:show` или `track:ask` (§5), **по
|
||||
умолчанию `track:show`**. Для `track:ask` называется критерий §5, которого
|
||||
задача не проходит; `track:ship` предлагается, когда правка описывается
|
||||
одним предложением. Метка владельца главнее предложения аналитика.
|
||||
- **Оценки и приоритет ставятся метками сразу, согласие не запрашивается.**
|
||||
Комментарий аналитики — уведомление, а не запрос: **молчание владельца —
|
||||
согласие**, несогласие он выражает правкой меток или комментарием, и это не
|
||||
@@ -139,7 +141,8 @@ S1-new → S2-analysis → S3-spec → S4-spec-review ⟲ → S5-ready →
|
||||
умолчанию и `blocked`. Вопрос, который можно отложить до ТЗ, не задаётся в
|
||||
аналитике; вопрос, не блокирующий написание ТЗ, не задаётся вовсе — вместо
|
||||
него в ТЗ пишется блок принятых предположений.
|
||||
- **Выход:** `S3-spec` — переход выполняет сам аналитик, не дожидаясь ответа.
|
||||
- **Выход:** на `track:ask` — `S3-spec`, на `track:show` — `S5-ready` после AC в
|
||||
теле issue (§5); переход выполняет сам аналитик, не дожидаясь ответа.
|
||||
Либо, при явном конфликте со `SCOPE.md`, — предложение отклонить с причиной:
|
||||
это единственный случай, когда аналитика останавливается и ждёт владельца.
|
||||
|
||||
@@ -159,7 +162,7 @@ S1-new → S2-analysis → S3-spec → S4-spec-review ⟲ → S5-ready →
|
||||
- **Ревьюер ≠ автор.** Ревьюер получает issue и ТЗ, без устных пояснений автора.
|
||||
Его задача — не согласиться, а найти, где ТЗ не выполнимо или не проверяемо.
|
||||
- **Артефакт:** `docs/reviews/SPEC-REVIEW-<NN>-r<N>.md`, вердикт
|
||||
зелёный / жёлтый / красный. Лёгкий трек — комментарий в issue.
|
||||
зелёный / жёлтый / красный. Ревью ТЗ проходит только трек `ask` (§5).
|
||||
- **High-находки блокируют.** Medium **в скоупе задачи** чинится в текущем
|
||||
issue: без High это жёлтый вердикт, автор правит ТЗ, фикс проходит повторный
|
||||
цикл. Medium **вне скоупа** — отдельный issue: чужой скоуп в этой задаче не
|
||||
@@ -174,7 +177,9 @@ S1-new → S2-analysis → S3-spec → S4-spec-review ⟲ → S5-ready →
|
||||
Не работа, а **очередь**: единственный статус, из которого можно трогать код.
|
||||
Все пункты обязательны:
|
||||
|
||||
- ТЗ существует, ревью ТЗ зелёное, ссылки issue ↔ ТЗ на месте;
|
||||
- ТЗ существует, на `track:ask` ревью ТЗ зелёное, ссылки issue ↔ ТЗ на месте.
|
||||
На `ship` и `show` ТЗ — в объёме §5, а пункты ниже закрываются одним словом
|
||||
«нет»: критерии трека их исключают, иначе это `track:ask`;
|
||||
- **AC1…ACn** — пронумерованные проверяемые критерии приёмки; у каждого указано,
|
||||
чем он доказывается: `unit` / `backend` / `smoke` / `golden` / «ревью кода»;
|
||||
- перечислены затронутые файлы и модули;
|
||||
@@ -468,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 со ссылкой.
|
||||
@@ -516,90 +522,90 @@ dev, ни при публикации документа код-ревью: ин
|
||||
- **Граница между «циклом» и «новым багом»:** до закрытия беты находка ревьюера —
|
||||
возврат на правки; после закрытия — новый issue. Иначе лимит 4 обходится
|
||||
заведением issue вместо возврата.
|
||||
- Для лёгкого трека лимит ревью ТЗ — **2** цикла: задача на три часа, которую
|
||||
переписывают трижды, лёгкой не была.
|
||||
- На треке `show` лимит код-ревью — **2** цикла, ревью ТЗ на нём нет (§5):
|
||||
задача на три часа, которую переписывают трижды, лёгкой не была.
|
||||
|
||||
---
|
||||
|
||||
## 5. Лёгкий трек (метка `small`) — путь по умолчанию
|
||||
## 5. Треки `ship`, `show`, `ask` — метка владельца
|
||||
|
||||
Умолчание изменено решением владельца 2026-08-27, issue #338. Прежде полный трек
|
||||
был бесплатен, а выбор лёгкого требовал обоснования. Фактическая цена: **2.9
|
||||
ревью-документа на задачу** в среднем и до шести на одну issue (#329, #316,
|
||||
#290) — при том что Medium-находки всё равно чинятся в той же задаче, без
|
||||
отдельного цикла.
|
||||
Решение владельца 2026-09-28, issue #695. Разбор 85 закрытых задач #600–#691
|
||||
показал, что прежний лёгкий трек (`small`) стоил почти столько же, сколько
|
||||
полный: медиана 115 мин и 12 событий против 102 мин и 13. Дешевле был только
|
||||
короткий (`trivial`) — 41 мин и 5 событий. Трек определялся формальными
|
||||
критериями, и у владельца не было метки, чтобы задать его самому.
|
||||
|
||||
**Порог не изменился.** Критерии ниже те же и по-прежнему обязательны все
|
||||
одновременно. Изменилась сторона доказательства: теперь обосновывается не выбор
|
||||
лёгкого трека, а отказ от него — в `S2-analysis` называется критерий, который
|
||||
задача не проходит. Полный трек остаётся тем, чем был, для геометрии, миграций
|
||||
конфига и публичных контрактов: там критерии нарушаются сами, и назвать
|
||||
нарушенный несложно.
|
||||
**Трек задаёт метка `track:ship`, `track:show` или `track:ask`. Метка владельца
|
||||
главнее критериев:** критерии ниже — подсказка аналитика, а не приговор.
|
||||
|
||||
Инверсия умолчания не отменяет ничего из §5 ниже и ничего из §4: бюджет четырёх
|
||||
циклов, арбитраж владельца, обязательность ТЗ на полном треке и правило «ревью
|
||||
до мержа» остаются как были. Меняется только стоимость пути по умолчанию.
|
||||
- Аналитик предлагает трек в «Оценке» (§7.2) и ставит метку. По умолчанию —
|
||||
`track:show`.
|
||||
- Владелец ставит или меняет любую из трёх меток в любой момент; его метка
|
||||
окончательна.
|
||||
- Повысить трек (`ship` → `show` → `ask`) вправе любой агент, с причиной в
|
||||
комментарии. Понизить — только владелец.
|
||||
- Трек пересматривается, когда владелец снял усложнявший пункт (#688):
|
||||
аналитик предлагает понижение, решает владелец.
|
||||
|
||||
**Критерии — все одновременно; нарушенный называется явно:**
|
||||
| | `track:ship` | `track:show` | `track:ask` |
|
||||
|---|---|---|---|
|
||||
| Для чего | документация, текст, очевидная правка в несколько строк, CSS-мелочь | баг и полировка в рамках описанного поведения; инфраструктура (§1) по умолчанию | геометрия, миграции конфига, публичные контракты, перф и touch, новый UX-контракт |
|
||||
| Маршрут | `S1-new` → `S5-ready` → `S6` → `S7` → `S8` | `S1-new` → `S2-analysis` → `S5-ready` → `S6` → `S7` → `S8` | полный, §2 |
|
||||
| ТЗ | строка «что меняется и чем проверить» в теле issue под `## ТЗ` | «Оценка» (§7.2) и до трёх AC в теле issue под `## ТЗ` | полное ТЗ по §7.1 |
|
||||
| Ревью ТЗ | нет | нет | да, лимит 4 цикла |
|
||||
| Локальный гейт | `npm run gate:small` | `npm run gate:small` плюс смоуки `smoke-select` и AC | §8 |
|
||||
| Код-ревью | до слияния нет; пакетное ревью диапазона перед бетой | модель: корректность и AC | документ ревью, как в §2.7 |
|
||||
| Лимит циклов код-ревью | — | 2 | 4 |
|
||||
|
||||
**Рамки `track:ship`** механические: дифф `src/**` не больше 30 строк, без новых
|
||||
файлов в `src/**`, без ключей i18n, без полей конфига и без Python. Выход за
|
||||
рамки переводит задачу в `track:show`. Рамки и слияние `ship` без ревью модели
|
||||
проверяет и исполняет конвейер (§10.4, #696); код `ship` читает пакетное ревью
|
||||
диапазона перед бетой (§11.7).
|
||||
|
||||
**Подсказка аналитику.** `track:show` уместен, когда выполнено всё сразу:
|
||||
|
||||
- сложность и риск ≤ 3;
|
||||
- одна поверхность (один диалог, один модуль, один эндпоинт);
|
||||
- нет миграции конфига и новых compatibility-полей;
|
||||
- нет нового UX-контракта — меняется поведение в рамках уже описанного;
|
||||
- нет влияния на производительность и на touch-контракт.
|
||||
|
||||
**Что упрощается:**
|
||||
|
||||
- ТЗ короче: проблема · контракт · AC1…ACn с доказательством · откат
|
||||
(в теле issue, как и на полном треке с 2026-09-10);
|
||||
- ревью ТЗ — комментарий второго агента, отдельный документ не нужен;
|
||||
- лимит ревью ТЗ — 2 цикла.
|
||||
|
||||
**Что не упрощается:** issue, оценка, статусы, трейлеры коммитов, changelog,
|
||||
**код-ревью и его документ**, закрытие после беты. Код-ревью не пропускается:
|
||||
оно проверяет скоуп, риски и качество доказательств, но не заменяет исполнение
|
||||
тестов. Единственное исключение из повторного ревью — починка упавшего
|
||||
предрелизного гейта, §11.4.
|
||||
|
||||
Если по ходу выясняется, что критерий нарушен (появилась миграция, задело второй
|
||||
модуль) — метка `small` снимается, issue возвращается в `S3-spec` и получает
|
||||
полное ТЗ в теле issue по §7.1. Это не провал, это ранняя диагностика.
|
||||
|
||||
### 5.1 Короткий трек (метка `trivial`)
|
||||
|
||||
Решение владельца 2026-08-13, issue #128. Лёгкий трек делает ТЗ дешёвым; короткий
|
||||
обходится без него совсем.
|
||||
|
||||
**Маршрут:** `S1-new` → `S2-analysis` → `S5-ready` → `S6-in-progress` →
|
||||
`S7-code-review` → `S8-merged`. Стадии `S3-spec` и `S4-spec-review` пропускаются.
|
||||
|
||||
`S2-analysis` остаётся: это комментарий, а не прогон CI, и именно там владелец
|
||||
решает приоритет и ценность. AC пишет автор в теле issue при переводе в
|
||||
`S5-ready` — до перехода, иначе ревьюеру нечего будет сверять.
|
||||
|
||||
**Критерии, все обязательны:**
|
||||
|
||||
- тип `bug`;
|
||||
- правка ограничена одной поверхностью, нового UX-контракта нет;
|
||||
- нет миграции конфига, новых ключей i18n, влияния на перф и touch;
|
||||
- AC выражаются тремя проверяемыми утверждениями или меньше;
|
||||
- нет влияния на производительность и на touch-контракт;
|
||||
- **ожидаемое поведение уже зафиксировано** — в `docs/USER-GUIDE.ru.md`, в
|
||||
каноническом документе подсистемы либо однозначно в самом отчёте. Решать нечего.
|
||||
Если есть что решать, это `S3-spec`, и никакая экономия этого не отменяет.
|
||||
|
||||
Метка ставится в `S2-analysis` вместе с остальными оценками, одним комментарием,
|
||||
где владелец утверждает и приоритет.
|
||||
Невыполненный пункт — повод предложить `track:ask` с названным критерием.
|
||||
`track:ship` — когда правку можно описать одним предложением и она укладывается
|
||||
в рамки выше.
|
||||
|
||||
**Что не упрощается:** issue, оценка, статусы, трейлеры, changelog и **код-ревью**.
|
||||
Лимит циклов код-ревью — 2, как на лёгком треке.
|
||||
|
||||
Если по ходу выясняется, что критерий нарушен, метка снимается и issue уходит в
|
||||
`S3-spec` за нормальным ТЗ. Как и на лёгком треке, это не провал, а ранняя
|
||||
**Чем опасны `show` и `ship`.** Они убирают место, где решение проверялось до
|
||||
написания кода. Признак «решать нечего» держит всю конструкцию, и его нельзя
|
||||
подтверждать ощущением — только ссылкой на уже зафиксированное поведение. Если по
|
||||
ходу выясняется, что решать есть что, трек повышается до `ask`: issue уходит в
|
||||
`S3-spec` и получает полное ТЗ в теле issue по §7.1. Это не провал, а ранняя
|
||||
диагностика.
|
||||
|
||||
**Чем этот трек опасен.** Он убирает единственное место, где решение проверялось
|
||||
до написания кода. Признак «решать нечего» держит всю конструкцию, и его нельзя
|
||||
подтверждать ощущением — только ссылкой на уже зафиксированное поведение.
|
||||
**Что не меняется ни на одном треке:** issue и правило №1 (§1), трейлеры
|
||||
коммитов, changelog для видимого изменения, зелёный Validate на точном SHA тега
|
||||
беты (§8), гейты стабильного релиза. Качество держится на `dev` и на кандидате
|
||||
беты, а не на каждой ветке.
|
||||
|
||||
### 5.1 Метки тяжёлых проверок и прежние метки
|
||||
|
||||
Тяжёлые проверки заказываются метками на любом треке — ставит владелец или
|
||||
автор с причиной в комментарии:
|
||||
|
||||
| Метка | Что включает |
|
||||
|---|---|
|
||||
| `ci:full` | полный Validate на ветке задачи |
|
||||
| `ci:golden` | golden на ветке и приёмку сдвинутых кадров в самой задаче |
|
||||
| `ci:mutants` | мутанты по диффу на кандидате ревью и слияния |
|
||||
|
||||
Как конвейер читает метки, описывает §10.4: `ci:mutants` (#696), `ci:full` и
|
||||
`ci:golden` — dispatch Validate с `full=true` на материале ревью (#697).
|
||||
|
||||
**Прежние метки.** `trivial` и `small` читаются как `track:show`; продуктовая
|
||||
задача без трековой метки — как `track:ask`; инфраструктурная задача (§1) без
|
||||
трековой метки — как `track:show`. Новые задачи получают только `track:*`.
|
||||
|
||||
---
|
||||
|
||||
@@ -643,7 +649,7 @@ dev, ни при публикации документа код-ревью: ин
|
||||
```
|
||||
issue #NN
|
||||
↔ ТЗ тело issue, раздел `## ТЗ` (хеш тела — в якорях ревью)
|
||||
↔ ревью ТЗ docs/reviews/SPEC-REVIEW-NN-rN.md (или комментарий при `small`)
|
||||
↔ ревью ТЗ docs/reviews/SPEC-REVIEW-NN-rN.md (только `track:ask`)
|
||||
↔ ветка issue/NN-slug
|
||||
↔ коммиты трейлеры Issue: #NN · User-Visible: yes|no
|
||||
↔ ревью кода docs/reviews/CODE-REVIEW-<tag|NN>-rN.md
|
||||
@@ -695,7 +701,7 @@ issue #NN
|
||||
Короткие и однообразные, чтобы читались и человеком, и машиной.
|
||||
|
||||
- **Аналитика:** `Оценка: ценность N/10 · сложность N/10 · P<1-3> · тип ·
|
||||
поверхности: … · дубликаты: … · лёгкий трек: да/нет`
|
||||
поверхности: … · дубликаты: … · трек: ship/show/ask (причина)`
|
||||
- **Занятие:** `Взял: <роль> · сессия <id> · ветка issue/NN-slug`
|
||||
- **Хендофф:** `Сделано: … · Файлы: … · Гейты: <команда → результат> ·
|
||||
НЕ сделано: … · Риски: … · Следующий статус: … · Новые issue: #…`
|
||||
@@ -729,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 \
|
||||
@@ -750,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` снимает кадры локально,
|
||||
@@ -776,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`,
|
||||
@@ -850,8 +875,10 @@ Performance зелёные на точном SHA, плюс зелёный E2E н
|
||||
| `blocked` | Ждём внешнего или владельца, **поверх** статусной метки |
|
||||
| `rejected` | Отклонено, issue закрыт |
|
||||
|
||||
Модификаторы: `small` (лёгкий трек, сложность ≤3), `trivial` (короткий трек,
|
||||
§5.1), `hotfix`, `process`, `review-4`; приоритет `P1`/`P2`/`P3`; тип `bug`/`feature`/`tech-debt`.
|
||||
Модификаторы: трек `track:ship`/`track:show`/`track:ask` (§5), тяжёлые проверки
|
||||
`ci:full`/`ci:golden`/`ci:mutants` (§5.1), `hotfix`, `process`, `review-4`;
|
||||
приоритет `P1`/`P2`/`P3`; тип `bug`/`feature`/`tech-debt`. Прежние `small` и
|
||||
`trivial` читаются как `track:show` (§5.1).
|
||||
Тематические метки (`polish`, `infra`, `tests`, `docs`, `security`, `vacuum`)
|
||||
ортогональны процессу.
|
||||
|
||||
@@ -1065,6 +1092,38 @@ Matysh/houseplan-card/.github/workflows/_<имя>.yml@dev` с `secrets: inherit`
|
||||
`test/default-branch-workflows.test.mjs`. `performance.yml` в список не входит:
|
||||
по расписанию он судит `main` собственным телом из `main`.
|
||||
|
||||
**Цена захода зависит от трека** (#696, решение владельца 2026-09-28). Трек
|
||||
снимает `scripts/process-track.mjs` в стадии подготовки — по текущим меткам и
|
||||
диффу от merge-base с `dev`, до ребейза. Прежние метки читаются по §5.1:
|
||||
инфраструктурная задача без трековой метки — `show`, продуктовая — `ask`.
|
||||
|
||||
| | `ship` | `show` | `ask` |
|
||||
|---|---|---|---|
|
||||
| Validate на материале | лёгкий; годится завершённый push-прогон на этом SHA | лёгкий, как у `ship` | с мутантами по диффу, dispatch |
|
||||
| Ребейз до ревью | нет, если `git merge-tree` с `dev` чистый | нет, если чистый | да (#257) |
|
||||
| Ревью модели | нет — пакетное ревью перед бетой (§11.7) | корректность и AC; окружение — по нужде | полное |
|
||||
| Проверка кандидата слияния | ждёт push-прогон кандидата; dispatch — только если его нет | как у `ship` | dispatch с мутантами |
|
||||
|
||||
Метка `ci:mutants` возвращает мутанты по диффу на любом треке. Рамки `ship`
|
||||
(§5) проверяет тот же шаг; выход за них — комментарий в issue и замена
|
||||
`track:ship` на `track:show` в этом же заходе. Слияние `ship` оставляет в issue
|
||||
комментарий с машинным маркером `hp:ship-merge`. Это не вердикт ревью и так себя
|
||||
не называет; по маркеру пакетное ревью находит задачи диапазона. Повторно
|
||||
применимый зелёный вердикт (#499) главнее `ship`: код уже прочитан.
|
||||
|
||||
В `dev` по-прежнему уезжает только SHA с зелёным Validate. Без ребейза до ревью
|
||||
кандидат собирается один раз, при слиянии, и лёгкий Validate проходит там.
|
||||
Ориентир — данные #695: `small` стоил медиану 115 минут и 12 событий, `trivial` —
|
||||
41 минуту и 5 событий; `show` целится в уровень `trivial`.
|
||||
|
||||
**Ревью `show` судит корректность и AC.** Medium — дефект поведения, который
|
||||
увидит пользователь, или невыполненный AC. Бухгалтерия — нет мутанта или записи
|
||||
в реестре, нечувствительный тест на побочный вызов, формулировка в документе —
|
||||
Low и цикла не открывает. Отсутствие мутантов по диффу на `show` не находка:
|
||||
полный реестр гоняется ночью. Ревью `show` не ставит Chromium, если тело issue
|
||||
не называет смоук или браузер. Ревью ТЗ не ставит ни `npm ci`, ни браузер: кода
|
||||
оно не исполняет.
|
||||
|
||||
**Ревью не начинается на красном коде** (#510). После фиксации материала конвейер
|
||||
запускает Validate с мутантами по диффу на этом SHA (`scripts/validate-gate.mjs`:
|
||||
`workflow_dispatch validate.yml -f mutants=true`). **Ждёт его не раннер, а событие**
|
||||
@@ -1138,7 +1197,8 @@ npm ci, Python и Chromium, оставаясь исполненной job: до
|
||||
никто не может выйти и о котором никто не узнает, для конвейера хуже громкой
|
||||
ошибки.
|
||||
|
||||
**Ветка приводится к `dev` до ревью, а не после** (#257). Раньше ревью читало ветку
|
||||
**Ветка приводится к `dev` до ревью, а не после** (#257) — на треке `ask`. `show`
|
||||
и `ship` с чистым слиянием ребейзятся один раз, при слиянии (#696, выше). Раньше ревью читало ветку
|
||||
как есть, а слияние делало ребейз — проверенный SHA и слитый SHA были разными
|
||||
коммитами. Пока расхождение с `dev` текстовое, ребейз упирается в конфликт и это
|
||||
видно; смысловое расхождение git склеивает молча, и в `dev` уезжает комбинация,
|
||||
@@ -1173,7 +1233,9 @@ npm ci, Python и Chromium, оставаясь исполненной job: до
|
||||
к другому диффу не применим, §2.10), публикация кандидата в ветку задачи, запуск
|
||||
Validate с мутантами на ней (#510) и ожидание зелёного dispatch-прогона **на этом
|
||||
SHA** — push-прогон мутантов не несёт — и только затем push в `dev` с lease на ту
|
||||
вершину, поверх которой кандидат собран. Отклонённый lease — `dev` двинулся снова
|
||||
вершину, поверх которой кандидат собран. На `show`/`ship` мутантов нет, и
|
||||
лёгкий Validate кандидата уже запустил сам push в ветку: слияние ждёт этот
|
||||
push-прогон, а dispatch шлёт, только если его нет за три минуты (#696). Отклонённый lease — `dev` двинулся снова
|
||||
— новая попытка; после третьей — `S6-in-progress` с комментарием;
|
||||
- красный Validate на кандидате или прогон, не появившийся за три минуты, —
|
||||
`S6-in-progress` с ссылкой; `S8-merged` ставится только после push.
|
||||
@@ -1230,9 +1292,10 @@ npm ci, Python и Chromium, оставаясь исполненной job: до
|
||||
|
||||
## 11. Исключения
|
||||
|
||||
### 11.1 Лёгкий трек
|
||||
### 11.1 Треки
|
||||
|
||||
См. §5 — это не исключение из правила №1, а более дешёвый путь по тем же статусам.
|
||||
См. §5 — треки `ship` и `show` не исключения из правила №1, а более дешёвые пути
|
||||
по тем же статусам.
|
||||
|
||||
### 11.2 Аварийный хотфикс (метка `hotfix`, решение владельца)
|
||||
|
||||
@@ -1275,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), а не задачей, которая
|
||||
наткнулась на него следующей.
|
||||
|
||||
**Границы, за которыми исключение не действует.** Оно про починку названного
|
||||
гейтом дефекта, а не про продолжение разработки под видом починки. Правка идёт
|
||||
@@ -1295,8 +1361,10 @@ Golden, браузерные смоки, performance и полный HA-харн
|
||||
запись в issue публична и релиз-менеджер видит, что именно было сделано перед
|
||||
выпуском.
|
||||
|
||||
Это исключение из правила «код-ревью не пропускается никогда» (§5, §7.1) —
|
||||
единственное, и относится только к окну между `S8-merged` и выпуском.
|
||||
Это исключение из правила «каждое изменение проходит код-ревью» (§5, §7.1).
|
||||
Второе такое место — трек `ship` (§5): его ревью не отменяется, а переносится
|
||||
на пакетное ревью диапазона перед бетой. Здесь же исключение относится только к
|
||||
окну между `S8-merged` и выпуском.
|
||||
|
||||
### 11.5 Независимое ревью линии перед стабильным релизом
|
||||
|
||||
@@ -1360,6 +1428,36 @@ Golden, браузерные смоки, performance и полный HA-харн
|
||||
выпуск после такого отказа, исправляют причину и получают новый совместимый
|
||||
полный зелёный прогон на том же финальном SHA либо на новом SHA кандидата.
|
||||
|
||||
### 11.7 Пакетное ревью `ship` перед бетой
|
||||
|
||||
Решение владельца 2026-09-28, issue #696.
|
||||
|
||||
**Зачем.** `track:ship` сливается без ревью модели (§5, §10.4): правка в
|
||||
механических рамках и зелёный лёгкий Validate. Прочитать её код обязан кто-то
|
||||
до того, как она уйдёт пользователям беты. Одна сессия на все ship-задачи
|
||||
диапазона дешевле ревью на каждую, а рамки ship держат объём малым.
|
||||
|
||||
**Шаг.** Перед публикацией беты — `ship-review.yml`
|
||||
(`gh workflow run ship-review.yml --ref dev -f tag=vX.Y.Z-beta.N`):
|
||||
|
||||
- **вход** — issue из трейлеров `Issue: #NN` в диапазоне «прошлый тег..кандидат»
|
||||
(тот же построитель, что `RELEASE-MEMBERSHIP.json`, #547), из них — ship: с
|
||||
маркером `hp:ship-merge` в комментариях или с меткой `track:ship`. Собирает
|
||||
`scripts/ship-review.mjs prepare`; ship-задач нет — модель не запускается;
|
||||
- **суждение** — по строке ТЗ каждой задачи и её коммитам: делает ли код
|
||||
заявленное и только его, не ломает ли соседнее, не вышла ли правка из ship по
|
||||
смыслу. Правила ревьюера — `docs/process/REVIEWER.md`, «Пакетное ревью ship»;
|
||||
- **выход** — `docs/reviews/SHIP-REVIEW-<тег>.md` в `dev` с машинным блоком:
|
||||
задачи и счёт High/Medium/Low. У модели нет прав записи, документ публикует
|
||||
детерминированный шаг.
|
||||
|
||||
**Гейт беты.** `ship-review.mjs check` стоит в обоих путях публикации —
|
||||
`publish-prerelease.yml` и `npm run release:prerelease` (включая `--check`).
|
||||
Если в диапазоне есть ship-задачи, документ обязан лежать в кандидате или в
|
||||
`dev`, покрывать их все и не нести High. Задача, слитая после ревью, требует
|
||||
пересъёмки (`-f force=true`). High чинится отдельной задачей, затем ревью
|
||||
переснимается. Medium и Low решает владелец, как в §11.5.
|
||||
|
||||
---
|
||||
|
||||
## 12. Запрещено
|
||||
|
||||
+41
-26
@@ -40,21 +40,28 @@
|
||||
- Чек-лист комментарием: дубликаты, скоуп по `docs/SCOPE.md` и
|
||||
`docs/TOUCH-SUPPORT.md`, ценность, сложность и риск, приоритет, тип,
|
||||
поверхности, трек. Оценки ставятся метками сразу; молчание владельца —
|
||||
согласие; в `S3-spec` аналитик переводит сам. Останавливается аналитика
|
||||
согласие; дальше аналитик переводит сам: `track:ask` — в `S3-spec`,
|
||||
`track:show` — в `S5-ready`. Останавливается аналитика
|
||||
только на конфликте со `SCOPE.md`
|
||||
([§2.2](../../PROCESS.md#22-аналитика-и-оценка)).
|
||||
- Шаблон: `Оценка: ценность N/10 · сложность N/10 · P<1-3> · тип ·
|
||||
поверхности: … · дубликаты: … · лёгкий трек: да/нет`
|
||||
поверхности: … · дубликаты: … · трек: ship/show/ask (причина)`
|
||||
([§7.2](../../PROCESS.md#72-шаблоны-комментариев)).
|
||||
- Лёгкий трек `small` — путь по умолчанию: обосновывается не выбор лёгкого
|
||||
трека, а отказ от него — называется нарушенный критерий. Критерии, все
|
||||
сразу: сложность и риск ≤ 3; одна поверхность; нет миграции конфига;
|
||||
нет нового UX-контракта; нет влияния на перф и touch
|
||||
([§5](../../PROCESS.md#5-лёгкий-трек-метка-small--путь-по-умолчанию)).
|
||||
- Короткий трек `trivial`: `S2-analysis` → `S5-ready`, AC автор пишет в теле
|
||||
issue до перехода. Тип `bug`, одна поверхность, без i18n, миграции, перфа и
|
||||
touch, не больше трёх AC, и ожидаемое поведение уже зафиксировано — решать
|
||||
нечего ([§5.1](../../PROCESS.md#51-короткий-трек-метка-trivial)).
|
||||
- Трек задаёт метка `track:ship`, `track:show` или `track:ask`, по умолчанию
|
||||
`track:show`. Метка владельца главнее критериев; повысить трек может любой
|
||||
агент с причиной в комментарии, понизить — только владелец
|
||||
([§5](../../PROCESS.md#5-треки-ship-show-ask--метка-владельца)).
|
||||
- `track:show`: `S2-analysis` → `S5-ready`, до трёх AC автор пишет в теле
|
||||
issue до перехода; ревью ТЗ нет, лимит код-ревью 2. Уместен, когда всё
|
||||
сразу: сложность и риск ≤ 3, одна поверхность, нет миграции, нового
|
||||
UX-контракта, влияния на перф и touch, и ожидаемое поведение уже
|
||||
зафиксировано — решать нечего ([§5](../../PROCESS.md#5-треки-ship-show-ask--метка-владельца)).
|
||||
- `track:ship`: `S1-new` → `S5-ready`, в теле issue строка «что меняется и чем
|
||||
проверить». Рамки: дифф `src/**` до 30 строк, без новых файлов, i18n, полей
|
||||
конфига и Python ([§5](../../PROCESS.md#5-треки-ship-show-ask--метка-владельца)).
|
||||
- Тяжёлые проверки на любом треке — метками `ci:full`, `ci:golden`,
|
||||
`ci:mutants`; прежние `small` и `trivial` читаются как
|
||||
`track:show` ([§5.1](../../PROCESS.md#51-метки-тяжёлых-проверок-и-прежние-метки)).
|
||||
|
||||
## ТЗ (`S3-spec`)
|
||||
|
||||
@@ -63,9 +70,9 @@
|
||||
- Обязательные разделы: сценарий · что человек увидит до и после · проблема ·
|
||||
скоуп и не-скоуп · контракт поведения · UX · модель данных и миграция ·
|
||||
i18n · AC1…ACn с доказательством · план автотестов · риски · откат ·
|
||||
release-артефакты. На лёгком треке короче: проблема · контракт · AC · откат
|
||||
([§7.1](../../PROCESS.md#71-цепочка),
|
||||
[§5](../../PROCESS.md#5-лёгкий-трек-метка-small--путь-по-умолчанию)).
|
||||
release-артефакты. На `track:show` — до трёх AC, на `track:ship` — одна
|
||||
строка ([§7.1](../../PROCESS.md#71-цепочка),
|
||||
[§5](../../PROCESS.md#5-треки-ship-show-ask--метка-владельца)).
|
||||
- Размытое место не додумывается. Владельцу задаются только продуктовые
|
||||
вопросы — что человек видит или делает и какой объём видимых изменений
|
||||
входит в issue. Всё, чего пользователь не наблюдает, автор решает сам и
|
||||
@@ -74,13 +81,13 @@
|
||||
- Вопросы — одним комментарием, пачкой: что неясно · что изменится от ответа ·
|
||||
вариант по умолчанию. Пока ждём ответа, issue остаётся в `S3-spec` и
|
||||
получает `blocked` ([§7.1](../../PROCESS.md#71-цепочка)).
|
||||
- DoR перед `S5-ready`: зелёное ревью ТЗ; пронумерованные AC со способом
|
||||
- DoR перед `S5-ready`: на `track:ask` зелёное ревью ТЗ; пронумерованные AC со способом
|
||||
доказательства (`unit` / `backend` / `smoke` / `golden` / «ревью кода»);
|
||||
файлы и модули; ключи i18n en + ru; миграция по
|
||||
`docs/CONFIG-COMPATIBILITY.md`; перф; touch; release-артефакты; откат; нет
|
||||
открытых продуктовых вопросов
|
||||
([§2.5](../../PROCESS.md#25-готово-к-разработке-dor)).
|
||||
- Лимит — 4 цикла ревью, на лёгком и коротком треке 2; зелёный вердикт цикла
|
||||
открытых продуктовых вопросов. На `ship` и `show` пункты DoR закрываются
|
||||
словом «нет» ([§2.5](../../PROCESS.md#25-готово-к-разработке-dor)).
|
||||
- Лимит — 4 цикла ревью, на `track:show` 2 цикла код-ревью; зелёный вердикт цикла
|
||||
не тратит; исчерпание — решение владельца: разделить, отклонить, арбитраж
|
||||
([§4](../../PROCESS.md#4-лимит-циклов-ревью-4)).
|
||||
|
||||
@@ -132,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/`,
|
||||
@@ -142,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-гейты)).
|
||||
- Полные наборы — предрелизный гейт, а не гейт ревью. Упавший предрелизный
|
||||
гейт автор чинит и повторно прогоняет; повторного код-ревью нет, если
|
||||
@@ -164,11 +173,17 @@
|
||||
`node scripts/process-gate.mjs --issues`; после `S7-code-review` в ветку не
|
||||
пушить до вердикта; `S7` ставится один раз на заход
|
||||
([§10.4](../../PROCESS.md#104-событийный-конвейер-метка-как-триггер)).
|
||||
- Ревью не начинается на красном коде: конвейер сам гоняет Validate с
|
||||
мутантами и возвращает красный в `S6-in-progress` без траты цикла
|
||||
- Ревью не начинается на красном коде: конвейер сам гоняет Validate — с
|
||||
мутантами на `ask`, лёгкий на `show`/`ship` — и возвращает красный в
|
||||
`S6-in-progress` без траты цикла
|
||||
([§10.4](../../PROCESS.md#104-событийный-конвейер-метка-как-триггер)).
|
||||
- Ветка приводится к `dev` до ревью, а не после: конфликт — возврат в
|
||||
`S6-in-progress` до ревью ([§10.4](../../PROCESS.md#104-событийный-конвейер-метка-как-триггер)).
|
||||
`S6-in-progress` до ревью; `show`/`ship` с чистым слиянием ребейзятся один
|
||||
раз, при слиянии ([§10.4](../../PROCESS.md#104-событийный-конвейер-метка-как-триггер)).
|
||||
- `ship` в рамках сливается без ревью модели; выход за рамки конвейер сам
|
||||
переводит в `track:show`. Код `ship` читает пакетное ревью перед бетой
|
||||
([§10.4](../../PROCESS.md#104-событийный-конвейер-метка-как-триггер),
|
||||
[§11.7](../../PROCESS.md#117-пакетное-ревью-ship-перед-бетой)).
|
||||
- Автор обязан дождаться вердикта, а не заканчивать сессию:
|
||||
`node scripts/wait-verdict.mjs --issue NN`, смотреть на метку, а не на
|
||||
комментарий; при `blocked` не ждать. После прогона ревью метка меняется
|
||||
|
||||
@@ -24,8 +24,8 @@
|
||||
|
||||
## Ревью ТЗ
|
||||
|
||||
- Артефакт — `docs/reviews/SPEC-REVIEW-<NN>-r<N>.md`, на лёгком треке —
|
||||
комментарий ([§2.4](../../PROCESS.md#24-тз-на-ревью)).
|
||||
- Артефакт — `docs/reviews/SPEC-REVIEW-<NN>-r<N>.md`. Ревью ТЗ проходит только
|
||||
трек `ask` ([§2.4](../../PROCESS.md#24-тз-на-ревью)).
|
||||
- ТЗ живёт в теле issue, раздел `## ТЗ`; `docs/specs/` — архив до 2026-09-10
|
||||
([§2.3](../../PROCESS.md#23-тз-в-работе--написание-тз)).
|
||||
- Проверить обязательные разделы, однозначность каждого AC и способ его
|
||||
@@ -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-гейты)).
|
||||
@@ -122,13 +123,39 @@
|
||||
([§7.2](../../PROCESS.md#72-шаблоны-комментариев)).
|
||||
- Вперёд двигает только зелёный вердикт; жёлтый и красный возвращают автору
|
||||
([§7.2](../../PROCESS.md#72-шаблоны-комментариев)).
|
||||
- Зелёный вердикт цикла не образует; лимит — 4 цикла, на лёгком и коротком
|
||||
треке 2; бюджет считается по этапу
|
||||
- Зелёный вердикт цикла не образует; лимит — 4 цикла, на `track:show` 2;
|
||||
бюджет считается по этапу
|
||||
([§4](../../PROCESS.md#4-лимит-циклов-ревью-4),
|
||||
[§10.4](../../PROCESS.md#104-событийный-конвейер-метка-как-триггер)).
|
||||
- Запрещено: Medium-находки, оставленные как TODO в документе ревью;
|
||||
ревью-документы вне репозитория ([§12](../../PROCESS.md#12-запрещено)).
|
||||
|
||||
## Трек show
|
||||
|
||||
- Ревью `show` судит корректность и AC: Medium — дефект поведения, который
|
||||
увидит пользователь, или невыполненный AC; бухгалтерия — нет мутанта или
|
||||
записи в реестре, нечувствительный тест на побочный вызов — Low и цикла не
|
||||
открывает ([§10.4](../../PROCESS.md#104-событийный-конвейер-метка-как-триггер),
|
||||
[§5](../../PROCESS.md#5-треки-ship-show-ask--метка-владельца)).
|
||||
- Мутанты по диффу на `show` не запрашиваются; их отсутствие не находка
|
||||
([§10.4](../../PROCESS.md#104-событийный-конвейер-метка-как-триггер)).
|
||||
- Ветка `show` с чистым слиянием к `dev` до ревью не приводится: материал —
|
||||
ветка как есть, кандидат проверит Validate при слиянии
|
||||
([§10.4](../../PROCESS.md#104-событийный-конвейер-метка-как-триггер)).
|
||||
|
||||
## Пакетное ревью ship
|
||||
|
||||
- Задачи `track:ship` слиты без ревью модели; перед бетой `ship-review.yml`
|
||||
читает их код одной сессией: по строке ТЗ каждой задачи и её коммитам
|
||||
([§11.7](../../PROCESS.md#117-пакетное-ревью-ship-перед-бетой)).
|
||||
- Вопросы к задаче: делает ли код заявленное и только его, не ломает ли
|
||||
соседнее, не вышла ли правка из ship по смыслу
|
||||
([§11.7](../../PROCESS.md#117-пакетное-ревью-ship-перед-бетой),
|
||||
[§5](../../PROCESS.md#5-треки-ship-show-ask--метка-владельца)).
|
||||
- Документ `docs/reviews/SHIP-REVIEW-<тег>.md` публикует детерминированный
|
||||
шаг; High не пускает бету, Medium и Low решает владелец
|
||||
([§11.7](../../PROCESS.md#117-пакетное-ревью-ship-перед-бетой)).
|
||||
|
||||
## Независимое ревью линии
|
||||
|
||||
- Перед стабильным релизом `release-review.yml` судит поверхности всей линии
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
# CODE-REVIEW #695 r1
|
||||
|
||||
Материал: `origin/dev..HEAD`, ровно один коммит
|
||||
`57ce10721fc0e48cb48b20798d7fb861b4c75ba8` поверх `dev@7d4d75bd`.
|
||||
Диапазон: `PROCESS.md`, `AGENTS.md`, `docs/process/AUTHOR.md`,
|
||||
`docs/process/REVIEWER.md`, `.github/workflows/_process.yml`,
|
||||
`scripts/mutation-registry.mjs`, `scripts/task-packet.mjs`,
|
||||
`test/process-digests.test.mjs`, `test/task-packet.test.mjs`.
|
||||
|
||||
## Скоуп
|
||||
|
||||
Инфраструктурная задача (§1: ни одного файла класса A) — без S1–S6, вход сразу
|
||||
на `S7-code-review`, что подтверждено веткой `issue/695-track-labels` и меткой
|
||||
`process` на issue. Заявленный автором скоуп (комментарий «Взял»): канон
|
||||
треков PROCESS §5/§5.1/§2/§4/§7/§9/§11, конспекты AUTHOR/REVIEWER, AGENTS,
|
||||
`task-packet.mjs`, лимит циклов по `track:*` в `_process.yml`. Явно вне
|
||||
скоупа — поведение конвейера по треку (мутанты, слияние `ship` без ревью
|
||||
модели, ребейз) — это #696, отдельная задача; проверял только то, что заявлено
|
||||
сделанным в #695.
|
||||
|
||||
Проверял по строке кода/текста, а не по заявлению автора: каждый пункт
|
||||
хендоффа сверен с диффом и/или исполнением ниже.
|
||||
|
||||
## Как проверялось
|
||||
|
||||
| Гейт | Команда | Результат |
|
||||
|---|---|---|
|
||||
| Validate на материале | CI run на `57ce1072` | зелёный (ссылка в задаче ревью), дешёвые гейты (`tsc`, `npm test`, `build`+`bundle-policy`) не перегонял — приняты по этой ссылке (#343) |
|
||||
| Целевой юнит-набор | `node --test test/process-digests.test.mjs test/task-packet.test.mjs test/entry-cost.test.mjs` | 28 pass, 0 fail (прогнано мной точечно поверх зелёного Validate, т.к. это ядро правок) |
|
||||
| `process-gate.mjs` (офлайн) | `node scripts/process-gate.mjs --range origin/dev..HEAD` | «гейт пройден, предупреждений 0» |
|
||||
| `mutation-gate --check` | `node scripts/mutation-gate.mjs --check` | `browser guards: 200/200`; 3 предупреждения — все про `#650` (geometry-corpus/nightly-reuse), не связаны с этим диффом, baseline |
|
||||
| Мутант `process-digest-dead-anchor` | вручную заменил якорь AUTHOR.md `#51-метки-тяжёлых-проверок-и-прежние-метки` → `#51-метки-тяжёлых-проверок`, прогнал `node --test --test-name-pattern="#634 конспект: каждая ссылка" test/process-digests.test.mjs`, вернул файл | **краснеет**: `AssertionError` — тест поймал мёртвый якорь |
|
||||
| Мутант `process-digest-bullet-without-canon-link` | вручную снял `([§5.1](...))` у пункта про `track:show`/`ci:*`, прогнал `--test-name-pattern="#634 конспект: каждый пункт"`, вернул файл | **краснеет**: пункт без ссылки на канон пойман |
|
||||
| Якоря §5/§5.1 в новых ссылках | `headings()`/`markdownLinks()` из `scripts/md-anchors.mjs` (настоящий алгоритм GitHub slug, не схлопывающий дефисы) — покрыто тестом выше | зелёный тест = якоря `5-треки-ship-show-ask--метка-владельца` и `51-метки-тяжёлых-проверок-и-прежние-метки` существуют и совпадают с реальными заголовками |
|
||||
| Ветка/трейлеры | `git log`, `git show -s --format=full HEAD` | один коммит, `Issue: #695`, `User-Visible: no`, ветка `issue/695-track-labels` — соответствует §3 п.10 |
|
||||
|
||||
Не прогонял (и почему): golden/скриншоты, браузерные смоки, `pytest tests_backend`,
|
||||
инварианты модели, `check-docs.mjs`, performance-профили — диапазон не
|
||||
затрагивает `src/**`, `custom_components/**/*.py` ни один файл класса A;
|
||||
`smoke-select.mjs` не запускал по той же причине (нет продуктового диффа,
|
||||
которому смок мог бы соответствовать). `no-new-any.mjs` не применим — TS не
|
||||
менялся.
|
||||
|
||||
## Находки
|
||||
|
||||
### Medium (в скоупе) — «инфраструктура без метки трека» не читается как `track:show` нигде в коде
|
||||
|
||||
**Файл:** `PROCESS.md:607` (§5.1) и отсутствие реализации в
|
||||
`.github/workflows/_process.yml`, `scripts/task-packet.mjs`.
|
||||
|
||||
Новый канон §5.1 утверждает:
|
||||
|
||||
> «...продуктовая задача без трековой метки — как `track:ask`; **инфраструктурная
|
||||
> задача (§1) без трековой метки — как `track:show`**. Новые задачи получают
|
||||
> только `track:*`.»
|
||||
|
||||
Это прямое, машинно-проверяемое правило (наравне с «trivial/small читаются как
|
||||
show», которое реализовано). Скоуп задачи явно включает «лимит циклов по
|
||||
`track:*` в `_process.yml`» и `task-packet.mjs`. Ни один из них правило не
|
||||
реализует:
|
||||
|
||||
- `.github/workflows/_process.yml:87-93` (`guard` job) считает `SMALL=true`
|
||||
только по меткам `small`, `trivial`, `track:show`, `track:ship`. Нет ветки,
|
||||
которая бы распознавала инфраструктурную задачу без трековой метки — сам
|
||||
bash-скрипт вообще не читает признак «инфраструктура» (только `gh issue view
|
||||
--json labels`, без диффа). Инфраструктурная задача без метки трека
|
||||
(обычный случай: конспект автора **нигде** не просит поставить
|
||||
`track:*` инфраструктурной задаче — ни в `docs/process/AUTHOR.md`, ни в
|
||||
«Вход в процесс» этого файла) получает `limit=4`, а не `2`, как обещает §5.1.
|
||||
- `scripts/task-packet.mjs:180-183` (`buildPacket`) для инфраструктурной ветки
|
||||
никогда не вызывает `trackFromLabels()` — track всегда равен
|
||||
`'инфраструктурный'` / `'инфраструктурный (предварительно...)'`, даже когда
|
||||
меток трека нет вовсе. Докстрока `trackFromLabels` (строки 128-134) прямо
|
||||
говорит «задача без трековой метки — как `ask`» без исключения для
|
||||
инфраструктуры, то есть и здесь правило §5.1 не реализовано ни в основной,
|
||||
ни в обходной ветке.
|
||||
|
||||
**Воспроизведение (не гипотеза — существующий зелёный тест доказывает
|
||||
обратное поведение):** `test/task-packet.test.mjs:171-183`
|
||||
(`'#632: statusless or returned infra issue without spec keeps the class A
|
||||
ban'`) собирает пакет с `labels: ['infra', 'S7-code-review']` (нет ни одной
|
||||
`track:*`, `small`, `trivial`) и **утверждает** `packet.track === 'инфраструктурный'`
|
||||
— то есть текущий код по конструкции не даёт `'show'` для этого случая, и тест
|
||||
это фиксирует как ожидаемое поведение уже сейчас, при полностью зелёном прогоне.
|
||||
|
||||
**Почему это не редакционная мелочь.** Практический эффект — не
|
||||
косметический: лимит циклов управляет тем, когда конвейер обязан
|
||||
эскалировать задачу владельцу (`review-4`, §4). Инфраструктурная задача без
|
||||
метки трека (а таких, по конспекту автора, будет большинство — постановка
|
||||
метки трека нигде не предписана для инфраструктурного входа) при 2 подряд
|
||||
жёлтых/красных вердиктах должна была бы уже упереться в лимит и уйти на
|
||||
решение владельца (разделить/отклонить/арбитраж), а по факту реализованного
|
||||
кода получает бюджет 4 — вдвое больше заявленного в собственном канона этой
|
||||
же задачи. Это ровно тот класс расхождения «канон говорит одно, автоматизация
|
||||
делает другое», который сам документ требует не игнорировать, а заводить
|
||||
находкой (шапка PROCESS.md: «Расхождение не игнорируется, а заводится issue с
|
||||
меткой `process`»); поскольку расхождение целиком внутри диффа и заявленного
|
||||
скоупа этой задачи — чинится здесь, не отдельным issue.
|
||||
|
||||
**Чем закрыть:** либо реализовать правило (например, `_process.yml` мог бы
|
||||
трактовать `S7-code-review` без каких-либо `track:*`/`small`/`trivial` меток
|
||||
как `SMALL=true`, когда issue не несёт продуктовых `S1-S6`-признаков — но тогда
|
||||
нужен признак, отличающий это от «продуктовая задача без метки = ask», которого
|
||||
у bash-скрипта сейчас нет вовсе), либо явно понизить формулировку §5.1
|
||||
до «рекомендации», сняв слово «читается как» и обязанность машинного
|
||||
соответствия, как это сделано для temporarily-not-implemented частей `ship`
|
||||
(«пока #696 не влит...», PROCESS.md:562-564) — то есть либо код, либо честная
|
||||
оговорка о недоделке, но не утверждение без покрытия.
|
||||
|
||||
## Что проверено и корректно
|
||||
|
||||
- **Таблица треков §5 и её колонки** (для чего, маршрут, ТЗ, ревью ТЗ,
|
||||
локальный гейт, код-ревью, лимит циклов) — согласована между PROCESS.md,
|
||||
AGENTS.md, `docs/process/AUTHOR.md`, `docs/process/REVIEWER.md`; сверено
|
||||
построчно диффом каждого файла, расхождений в формулировках не нашёл.
|
||||
- **`trackFromLabels()` для явно помеченных задач** (`track:ship` /
|
||||
`track:show` / `track:ask` / легаси `trivial`/`small` / без меток →
|
||||
продуктовая задача) — корректна и покрыта новым тестом
|
||||
`test/task-packet.test.mjs` (`#695: трек по меткам`), прогнан, зелёный.
|
||||
Проверено исполнением, не только чтением.
|
||||
Приоритет `track:ship` → `track:show` → `track:ask` → legacy → default
|
||||
реализован в объявленном порядке (§5: «метка владельца главнее критериев»,
|
||||
явный `track:ask` переопределяет legacy-метки) — сверил построчно с
|
||||
`.github/workflows/_process.yml:87-93`: `if has track:ask; then SMALL=false;
|
||||
TRIVIAL=false; fi` идёт после чтения legacy-меток, порядок совпадает.
|
||||
- **Лимит циклов для явно помеченных `track:show`/`track:ship`/`track:ask`** —
|
||||
`_process.yml` строки 87-99 корректно матчатся: `track:show`/`track:ship`/
|
||||
legacy `small`/`trivial` → `limit=2`, явный `track:ask` возвращает `limit=4`
|
||||
даже поверх legacy-меток. Дешёвая, но реальная проверка: прогнал bash-логику
|
||||
построчно на всех комбинациях меток из `has()`; для случаев, где метка трека
|
||||
явно стоит, поведение соответствует таблице §5 и §4.
|
||||
- **Якоря конспектов (AC2 #634)** — оба новых заголовка (`§5`, `§5.1`) и все
|
||||
ссылки на них из `AUTHOR.md`/`REVIEWER.md` резолвятся настоящим алгоритмом
|
||||
GitHub-слага (`scripts/md-anchors.mjs`, без схлопывания дефисов) — проверено
|
||||
исполнением зелёного `test/process-digests.test.mjs` (5/5) и двумя ручными
|
||||
мутациями, которые «умеют падать» (см. таблицу гейтов).
|
||||
Оба перенацеленных мутанта в `scripts/mutation-registry.mjs`
|
||||
(`process-digest-dead-anchor`,
|
||||
`process-digest-bullet-without-canon-link`) действительно ловят регресс —
|
||||
проверено воспроизведением, не по названию.
|
||||
- **Ключевые формулировки конспекта** («Метка владельца главнее критериев»,
|
||||
«ожидаемое поведение уже зафиксировано») дословно совпадают в PROCESS.md §5 и
|
||||
в `AUTHOR.md`, со ссылкой на верный раздел — проверено тестом
|
||||
`test/process-digests.test.mjs` (правило KEY_RULES), зелёный.
|
||||
- **Трейлеры и провенанс коммита** — один коммит, `Issue: #695`,
|
||||
`User-Visible: no`, ветка `issue/695-track-labels` от `dev@7d4d75bd`;
|
||||
`User-Visible: no` корректен — изменение не задевает ни одного
|
||||
пользовательского поведения (правок в CHANGELOG нет и не требуется).
|
||||
`process-gate.mjs --range origin/dev..HEAD` — 0 предупреждений.
|
||||
- **Отсутствие продуктового кода** — диффом не задет ни один файл класса A
|
||||
(`src/**`, `custom_components/houseplan/**/*.py`, i18n); инфраструктурный
|
||||
маршрут применён верно.
|
||||
- **Обратная совместимость легаси-меток** (`small`/`trivial` → `show`,
|
||||
продукт без метки → `ask`) реализована и в `_process.yml`, и в
|
||||
`task-packet.mjs`, и задокументирована в §5.1 — согласованно во всех трёх
|
||||
местах.
|
||||
- Мусора после диффа не осталось: `grep` по репозиторию не находит мёртвых
|
||||
ссылок на старые заголовки `§5 лёгкий трек`/`§5.1 короткий трек` ни в одном
|
||||
скрипте, тесте или документе вне `docs/reviews/`.
|
||||
|
||||
## Чего не проверял
|
||||
|
||||
- **Поведение конвейера, отличное от лимита циклов** (мутанты по диффу на
|
||||
`ship`, слияние `ship` без ревью модели, ребейз-семантика треков) — заявлено
|
||||
автором как вне скоупа (#696), не проверял вовсе.
|
||||
- **Живой прогон `_process.yml` guard job** на реальном issue с `track:show`/
|
||||
`track:ship` — GitHub Actions с реальным `gh issue view` не воспроизводил;
|
||||
проверка bash-логики — чтением и ручной подстановкой значений `has()`, не
|
||||
исполнением job. Записываю явно: **проверено чтением, не исполнением** для
|
||||
всей ветки guard job, кроме той части, что покрыта `process-gate.mjs`
|
||||
(офлайн-подмножество).
|
||||
Тесты, исполняющие именно этот bash-скрипт (`_process.yml`), в репозитории
|
||||
не существуют ни для старой, ни для новой логики — это не новый пробел
|
||||
этой задачи, а существующее свойство инфраструктуры тестирования workflow.
|
||||
- **`docs/reviews/INDEX.md`, golden, скриншоты, смоки, perf, pytest,
|
||||
инварианты модели** — не прогонял: диапазон не касается `src/**`,
|
||||
`custom_components/**/*.py`, визуала или геометрии.
|
||||
- Полный `npm test` (3223 теста) не перегонял целиком — принят по зелёному
|
||||
Validate на этом SHA (#343); перегонял точечно только изменённые файлы
|
||||
(`process-digests`, `task-packet`, `entry-cost`) и мутации по ним.
|
||||
- Не проверял, действительно ли `process-labels/labels.tsv` (вне репозитория,
|
||||
в «папке владельца» по словам автора) создаёт `track:ship`, `track:ask`,
|
||||
`ci:full`, `ci:golden`, `ci:mutants` с корректными цветами/описаниями — это
|
||||
файл вне git-дерева репозитория, недоступен ревью.
|
||||
|
||||
## Вердикт
|
||||
|
||||
Единственная находка — Medium, в скоупе задачи (реализация лимита циклов по
|
||||
`track:*` в `_process.yml` и track в `task-packet.mjs` — часть заявленного
|
||||
скоупа), не High: явных меток трека это не касается, дефект сужен до случая
|
||||
«инфраструктурная задача без единой метки трека», расхождение делает конвейер
|
||||
мягче объявленного (даёт больше циклов, а не меньше) — не блокирует
|
||||
использование, но противоречит только что написанному собственному канону.
|
||||
Без High это жёлтый вердикт, возврат автору на исправление в этом же issue.
|
||||
|
||||
Вердикт: жёлтый · заход r1 · блокирующих циклов 1/4 · High: 0 · Medium: 1 → в задаче
|
||||
|
||||
---
|
||||
|
||||
<!-- material-anchors: сгенерировано конвейером (#414) -->
|
||||
|
||||
## Материал раунда
|
||||
|
||||
- Ветка: `issue/695-track-labels`, коммит `57ce10721fc0` — ребейз его осиротит, и это нормально: ниже якоря, которые ребейз не меняет.
|
||||
- Дерево материала: `2faf4770f1ba6a32a599552db9c611bfb5cc5a18`
|
||||
```
|
||||
git log --all --format='%H %T' | grep 2faf4770f1ba
|
||||
```
|
||||
- Тело issue: `c74209278f8dd164514eda40d8841d6eefdeffd3b490a90f47b20e3ff2b667e7`
|
||||
- Вердикт конвейера: `yellow` · High 0
|
||||
@@ -0,0 +1,175 @@
|
||||
# CODE-REVIEW #695 r2
|
||||
|
||||
Материал: `origin/dev..HEAD`, ровно `f6c76b7351b1d0b9a61954b0153a44bb098bc7f3`
|
||||
(рабочая копия уже на нём). Диапазон трёх коммитов: `57ce1072` (реализация r1,
|
||||
уже разобрана в CODE-REVIEW-695-r1), `51854aea` (публикация документа r1,
|
||||
служебный коммит конвейера), `f6c76b73` (правка Medium из r1 — материал этого
|
||||
раунда). Дельта этого раунда — ровно `57ce1072..f6c76b73`:
|
||||
`.github/workflows/_process.yml`, `scripts/mutation-registry.mjs`,
|
||||
`scripts/task-packet.mjs`, `test/review-doc-guard.test.mjs`,
|
||||
`test/task-packet.test.mjs`.
|
||||
|
||||
## Скоуп
|
||||
|
||||
Единственная блокирующая находка r1 (Medium, в скоупе) — канон §5.1
|
||||
(«инфраструктурная задача (§1) без трековой метки — как `track:show`») не
|
||||
был реализован ни в `_process.yml` (лимит циклов), ни в `task-packet.mjs`
|
||||
(отображаемый трек). Автор заявляет это закрытым коммитом `f6c76b73`
|
||||
(комментарий «Сделано (r1)»). Разбор по PROCESS.md §2.10 — по дельте: полный
|
||||
пересчёт находки r1, остальное (таблица треков §5, `trackFromLabels()` для
|
||||
явно помеченных задач, якоря конспектов, трейлеры) унаследовано без повтора —
|
||||
дельта их не задевает (ни один из пяти изменённых файлов не входит в
|
||||
доказательную базу этих пунктов r1, кроме `task-packet.mjs`, где тронута
|
||||
только инфраструктурная ветвь `track`, разобранная ниже).
|
||||
|
||||
## Как проверялось
|
||||
|
||||
| Гейт | Команда | Результат |
|
||||
|---|---|---|
|
||||
| Validate на материале | CI run на `f6c76b73` (ссылка в задаче ревью) | зелёный; дешёвые гейты (`tsc`, `npm test`, `build`+`bundle-policy`) не перегонял — приняты по этой ссылке (#343) |
|
||||
| Целевой юнит-набор | `node --test test/task-packet.test.mjs test/review-doc-guard.test.mjs` | 80/80 и 64/64 pass, 0 fail |
|
||||
| `process-gate.mjs` (офлайн) | `node scripts/process-gate.mjs --range origin/dev..HEAD` | «гейт пройден, предупреждений 0» |
|
||||
| `mutation-gate --check` | `node scripts/mutation-gate.mjs --check` | `guard-infra-keeps-ask-limit` — ok; `packet-infra-track-ignores-show-default` — ok; `browser guards: 200/200`; 3 предупреждения — все про `#650` (baseline, не связаны с диффом — совпадают с r1) |
|
||||
| Мутант `guard-infra-keeps-ask-limit` | вручную заменил `limit=2` на `limit=4 # mutant` в `_process.yml`, прогнал `--test-name-pattern="r1 #695" test/review-doc-guard.test.mjs`, вернул файл (`git diff --stat` после — пусто) | **краснеет**: `AssertionError`, тест ловит мутацию |
|
||||
| Мутант `packet-infra-track-ignores-show-default` | вручную заменил тернарник `hasTrackLabel(labels) ? trackFromLabels(labels) : 'show'` на `trackFromLabels(labels)` в `task-packet.mjs`, прогнал `--test-name-pattern="r1 #695" test/task-packet.test.mjs`, вернул файл | **краснеет**: `AssertionError: expected 'инфраструктурный · show', actual 'инфраструктурный · ask'` |
|
||||
| Исполнение bash-ветки guard (не только чтение) | Извлёк новый блок `.github/workflows/_process.yml:224-242` в отдельный скрипт с mock `gh`/`has`, прогнал 6 сценариев (нет меток+только инфра-файлы; нет меток+файл класса A; явный `track:ask`; явный `track:show`; отказ `gh api`; legacy `small`) | все 6 дали ожидаемый `limit` — см. таблицу «Что проверено» |
|
||||
| Синтаксис bash-шага | `python3 -c "yaml.safe_load(...)"` извлёк `run:` строку `guard`, `bash -n` на неё | синтаксис корректен |
|
||||
| Node-однострочник guard | Прогнал ровно ту же `node --input-type=module -e '...'` строку на трёх наборах путей (только `scripts/`+`test/`; с `src/houseplan-card.ts`; пустой список) | exit 0 / 1 / 1 — соответствует логике «инфраструктура доказана только непустым списком путей без класса A» |
|
||||
| `smoke-select.mjs` | `node scripts/smoke-select.mjs --base 57ce1072 --head f6c76b73` | «Исполняемого frontend-диффа нет (src/**/*.ts не тронут)» — смоки не выбираются, выбирать нечего |
|
||||
| Трейлеры и провенанс коммита | `git show -s --format=full f6c76b73` | `Issue: #695`, `User-Visible: no`, корректно: изменение автоматизации/скриптов, ни одного пользовательского поведения продукта не задето |
|
||||
| Сверка со сформированным пакетом | `node scripts/task-packet.mjs --issue 695` | трек issue #695 (само помечено `track:show` владельцем) печатается как `инфраструктурный · show` — согласуется с явной меткой |
|
||||
|
||||
Не прогонял (и почему): golden/скриншоты, браузерные смоки (подтверждено
|
||||
`smoke-select.mjs` — нечего выбирать), `pytest tests_backend`, инварианты
|
||||
модели, `check-docs.mjs` (диапазон не касается `src/**`), performance-профили —
|
||||
диапазон не затрагивает ни одного файла класса A и не меняет геометрию/рендер.
|
||||
Полный `npm test` (3223 теста) не перегонял целиком — принят по зелёному
|
||||
Validate на этом SHA (#343); перегонял точечно только изменённые тестовые
|
||||
файлы и мутации по ним, плюс собственноручный харнесс исполнения bash-ветки
|
||||
(таблица выше) — этого требует #454 (inline-shell без исполнения не
|
||||
доказывает поведение), выполнено сверх минимума, а не вместо него.
|
||||
|
||||
## Закрытие раунда r1
|
||||
|
||||
| Находка r1 | Чем закрыта | Где это видно |
|
||||
|---|---|---|
|
||||
| Medium: §5.1 («инфраструктура без трековой метки — `track:show`») не реализован ни в `_process.yml` (лимит циклов), ни в `task-packet.mjs` (отображаемый трек); зелёный тест `task-packet.test.mjs:171` прямо утверждал старое поведение | `_process.yml`: новая ветка `guard` (строки 224-242) — при отсутствии `track:*`/`small`/`trivial` берёт диффа ветки против `dev` через `compare` API, классифицирует пути `classify()` из `process-gate.mjs`; при непустом списке (< 300, потолок ответа) без единого файла класса A ставит `limit=2`. `task-packet.mjs`: `infraTrack = hasTrackLabel(labels) ? trackFromLabels(labels) : 'show'` (строка 189), трек инфраструктурной задачи теперь печатается как `` инфраструктурный · ${infraTrack} `` вместо голого `'инфраструктурный'`. Тест `task-packet.test.mjs:171` (`#632`), на который указывала находка, переписан: `assert.equal(packet.track, 'инфраструктурный · show', ...)` — прежнее ожидание `'инфраструктурный'` больше не в коде. | `.github/workflows/_process.yml:224-242`; `scripts/task-packet.mjs:143-144,189-191`; `test/task-packet.test.mjs:110,180,231-241`; `test/review-doc-guard.test.mjs:971-982`; оба новых мутанта (`scripts/mutation-registry.mjs`) реально ловят регресс — проверено воспроизведением (таблица «Как проверялось»), не по названию. Исполнение bash-ветки (6 сценариев харнессом) подтверждает: без метки трека и без класса A → `limit=2`; с любой явной меткой трека → блок не трогает `limit`; при отказе `gh api` или файле класса A → `limit` остаётся консервативным (не понижается ошибочно). |
|
||||
|
||||
Находка полностью закрыта: и механизм (§1: «ни одного файла класса A» —
|
||||
ровно та же формула, что в PROCESS.md §1), и обе точки, которые r1 назвал
|
||||
нереализованными, и тест, который r1 предъявил как доказательство обратного
|
||||
поведения, — всё сходится с текстом §5.1 дословно («инфраструктурная задача
|
||||
(§1) без трековой метки — как `track:show`»).
|
||||
|
||||
## Унаследовано из r1 (без повторной проверки — дельта не задевает)
|
||||
|
||||
- **Таблица треков §5 и её колонки**, согласованность PROCESS.md / AGENTS.md /
|
||||
`docs/process/AUTHOR.md` / `docs/process/REVIEWER.md` — документ
|
||||
`CODE-REVIEW-695-r1.md`, дерево `2faf4770f1ba6a32a599552db9c611bfb5cc5a18`.
|
||||
Ни один из этих четырёх файлов не входит в дельту `57ce1072..f6c76b73`.
|
||||
- **`trackFromLabels()` для явно помеченных задач** (`track:ship` /
|
||||
`track:show` / `track:ask` / legacy `trivial`/`small` / без меток → `ask`),
|
||||
приоритет меток, лимит циклов для явно помеченных `track:show`/`track:ship`/
|
||||
`track:ask` в `_process.yml:87-99` — там же, дерево `2faf4770f1ba6a3...`.
|
||||
Дельта r2 добавляет только новую ветку ПОСЛЕ этой логики (строки 224-242) и
|
||||
не меняет ни один из проверенных r1 путей; сама функция `trackFromLabels`
|
||||
в дельте не тронута (diff `scripts/task-packet.mjs` добавляет только
|
||||
`hasTrackLabel` и меняет строку сборки `track`, не трогая тело
|
||||
`trackFromLabels`).
|
||||
- **Якоря конспектов (AC2 #634), ключевые формулировки, обратная
|
||||
совместимость легаси-меток `small`/`trivial`, трейлеры провенанса,
|
||||
отсутствие продуктового кода в диффе** — там же, дерево
|
||||
`2faf4770f1ba6a3...`; ни один из файлов этой доказательной базы
|
||||
(`PROCESS.md`, `AUTHOR.md`, `REVIEWER.md`, `test/process-digests.test.mjs`)
|
||||
не входит в дельту r2.
|
||||
|
||||
## Что проверено и корректно
|
||||
|
||||
- **Механический признак §1 применён идентично канону**: и в `_process.yml`
|
||||
(список файлов диффа `dev...branch`, ни одного `classify() === 'A'`), и в
|
||||
`PROCESS.md` §1 («ни одного файла класса A») — то же условие, что уже
|
||||
реализовано в `branchIsInfrastructure()` (`task-packet.mjs`, не тронут
|
||||
этим диффом) и в самом `process-gate.mjs`. Никакого нового, отличного от
|
||||
канона определения «инфраструктуры» дифф не вводит.
|
||||
- **Приоритет явной метки владельца сохранён**: guard пропускает новую ветку
|
||||
целиком (`&& ! has track:ship && ! has track:show && ! has track:ask && !
|
||||
has small && ! has trivial`), если стоит любая из пяти меток — идентичный
|
||||
набор в JS (`hasTrackLabel`). Подтверждено исполнением (харнесс, сценарии
|
||||
3, 4, 6) и юнит-тестом `'метка владельца главнее'`.
|
||||
- **Fail-safe при неполном доказательстве**: пустой список файлов, отказ
|
||||
`gh api`, наличие файла класса A или обрезанный (≥300) ответ compare — во
|
||||
всех случаях `limit` остаётся 4 (не понижается ошибочно), задача не
|
||||
наказывается лимитом 2 без доказанной инфраструктуры. Подтверждено
|
||||
исполнением (харнесс, сценарии 2, 5) и отдельным прогоном
|
||||
node-однострочника на пустом списке.
|
||||
- **Мутанты `guard-infra-keeps-ask-limit` и
|
||||
`packet-infra-track-ignores-show-default`** реально ловят регресс —
|
||||
проверено ручным воспроизведением обеих мутаций (не по названию `ok` в
|
||||
`mutation-gate --check`, а прогоном упавшего теста).
|
||||
- **Синтаксис нового bash-блока и YAML-документа** — `bash -n` и
|
||||
`yaml.safe_load` не нашли ошибок; блок стоит внутри уже существующего
|
||||
`if [ -n "$stage" ]`, использует уже установленное `limit` до последующих
|
||||
проверок `EXHAUSTED`/`spent -ge limit` (строки 270-299) — порядок
|
||||
вычисления корректен, «состояния гонки» с более поздним использованием
|
||||
`limit` нет.
|
||||
- **Трейлеры коммита** — `Issue: #695`, `User-Visible: no` (корректно: не
|
||||
задето ни одно пользовательское поведение продукта), ветка
|
||||
`issue/695-track-labels`.
|
||||
- **Согласованность с самим issue #695**: у issue стоит метка `track:show` —
|
||||
собранный пакет (`task-packet.mjs --issue 695`) печатает
|
||||
`инфраструктурный · show`, то есть явная метка отражена корректно и
|
||||
задокументированный пример (issue #695 сам себе) воспроизводится.
|
||||
|
||||
## Чего не проверял
|
||||
|
||||
- **Живой прогон `_process.yml` guard job** на реальном GitHub Actions
|
||||
раннере с настоящим `gh api compare` — не воспроизводил; логика проверена
|
||||
чтением синтаксиса, ручным bash-харнессом с мок-`gh`/`has` (6 сценариев,
|
||||
см. таблицу) и юнит-тестом на текст блока. **Записываю явно: для самого
|
||||
факта исполнения на реальном GitHub API — проверено чтением и
|
||||
контролируемым харнессом, не живым запуском workflow.** Это не новый
|
||||
пробел этой задачи — тот же пробел зафиксирован в r1 для всей ветки guard
|
||||
job (тестов, исполняющих `_process.yml` напрямую, в репозитории нет ни для
|
||||
старой, ни для новой логики).
|
||||
- **Поведение конвейера по треку** (мутанты на диффе кандидата `ship`,
|
||||
слияние без ревью модели, ребейз-семантика) — вне скоупа #695, это #696,
|
||||
не проверял.
|
||||
- **`process-labels/labels.tsv`** (папка владельца, вне git-дерева) — файл
|
||||
недоступен ревью, как и в r1.
|
||||
- **golden/скриншоты, браузерные смоки** (кроме факта, что `smoke-select.mjs`
|
||||
ничего не выбрал), `pytest tests_backend`, инварианты модели,
|
||||
performance-профили — диапазон не касается визуала, геометрии или Python.
|
||||
- **Ребейз-устойчивость якорей мутантов** `guard-infra-keeps-ask-limit` /
|
||||
`packet-infra-track-ignores-show-default` на дальнейших ребейзах — не
|
||||
тестировал долгосрочную устойчивость find/replace патчей, только их
|
||||
текущую верность материалу.
|
||||
|
||||
## Вердикт
|
||||
|
||||
Единственная находка r1 (Medium, в скоупе) закрыта построчно: оба места,
|
||||
которые она называла нереализованными, реализованы правилом, дословно
|
||||
совпадающим с §5.1; тест, который r1 предъявил как живое доказательство
|
||||
старого поведения, переписан и подтверждает новое; оба новых мутанта реально
|
||||
ловят регресс — проверено воспроизведением, а не по имени `ok`; сама bash-
|
||||
ветка проверена не только чтением, но и контролируемым исполнением на шести
|
||||
сценариях. Новых находок дельта `57ce1072..f6c76b73` не даёт: правка узкая,
|
||||
ограничена ровно заявленным скоупом, не трогает ни один файл класса A, не
|
||||
меняет ни одну ранее проверенную ветвь `trackFromLabels`/таблицы §5.
|
||||
|
||||
Вердикт: зелёный · заход r2 · блокирующих циклов 1/4 · High: 0 · Medium: 0
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
<!-- material-anchors: сгенерировано конвейером (#414) -->
|
||||
|
||||
## Материал раунда
|
||||
|
||||
- Ветка: `issue/695-track-labels`, коммит `f6c76b7351b1` — ребейз его осиротит, и это нормально: ниже якоря, которые ребейз не меняет.
|
||||
- Дерево материала: `2645a194ffcd661ca9d05eb38db68d9e378faae9`
|
||||
```
|
||||
git log --all --format='%H %T' | grep 2645a194ffcd
|
||||
```
|
||||
- Тело issue: `c74209278f8dd164514eda40d8841d6eefdeffd3b490a90f47b20e3ff2b667e7`
|
||||
- Вердикт конвейера: `green` · High 0
|
||||
@@ -0,0 +1,97 @@
|
||||
# CODE-REVIEW-696-r1
|
||||
|
||||
Материал раунда: `e1ae8f4ac70f9342b9faf221c4cba84141e213b3` (единственный коммит поверх `dev@9e4bfb43`, ветка `issue/696-pipeline-by-track`). Этап: code. Заход r1, блокирующих циклов израсходовано 0/4. Трек — `track:ask` (владелец повысил его сам в комментарии «Взял», причина — задача меняет путь слияния в `dev` и добавляет гейт беты).
|
||||
|
||||
## Скоуп
|
||||
|
||||
Продолжение #695: конвейер ревью перестаёт стоить одинаково для `ask`, `show` и `ship` (PROCESS.md §5, §10.4 — новая таблица цены захода; §11.7 — новый раздел «Пакетное ревью `ship` перед бетой»). Изменение целиком инфраструктурное (файлов класса A нет), Core user jobs `docs/SCOPE.md` не касается — это внутренний процесс, не продуктовая поверхность.
|
||||
|
||||
Файлы: `.github/workflows/_process.yml`, `.github/workflows/publish-prerelease.yml`, новый `.github/workflows/ship-review.yml`; `scripts/process-track.mjs` (новый), `scripts/ship-review.mjs` (новый), правки `scripts/ci-proof.mjs`, `scripts/merge-candidate.mjs`, `scripts/validate-gate.mjs`, `scripts/release-prerelease.mjs`, `scripts/reviews-archive.mjs`, `scripts/reviews-index.mjs`, `scripts/task-packet.mjs`, `scripts/mutation-registry.mjs` (+9 новых мутантов, 3 перенацелены); тесты `test/process-track.test.mjs` (новый), `test/ship-review.test.mjs` (новый), правки `test/merge-candidate.test.mjs`, `test/validate-gate.test.mjs`, `test/review-doc-guard.test.mjs`; документы `PROCESS.md`, `docs/process/REVIEWER.md`, `docs/process/AUTHOR.md`, `AGENTS.md`.
|
||||
|
||||
## Как проверялось
|
||||
|
||||
| Гейт | Прогнан | Результат |
|
||||
|---|---|---|
|
||||
| `npx tsc --noEmit`, `npm test` (весь набор), `npm run build` + сверка бандла | Нет — Validate на `e1ae8f4a` зелёный (ссылка в промпте), гейты подтверждены (#343) | — |
|
||||
| `node scripts/check-docs.mjs` | Нет | diff не трогает `src/**` — гейт неприменим |
|
||||
| Целевой прогон новых/изменённых тестовых файлов | Да, напрямую `node --test` | `test/process-track.test.mjs` 28/28, `test/ship-review.test.mjs` 18/18 (одной командой, см. ниже), `test/validate-gate.test.mjs`+`test/merge-candidate.test.mjs`+`test/review-doc-guard.test.mjs` 106/106, `test/reviews-index.test.mjs`+`test/reviews-archive.test.mjs` 21/21, `test/task-packet.test.mjs` 16/16, `test/process-digests.test.mjs` 5/5 — все зелёные |
|
||||
| «Тест умеет падать» для defensive AC | Да, вручную для 2 из 9+3 мутантов реестра, остальные — чтением | см. таблицу ниже |
|
||||
| `node scripts/smoke-select.mjs --base origin/dev --head HEAD` | Да | «Исполняемого frontend-диффа нет» — смоки не выбираются, `src/**` не тронут |
|
||||
| `golden:verify` | Нет | diff не меняет рендер |
|
||||
| `pytest tests_backend` | Нет | diff не трогает `custom_components/**/*.py` |
|
||||
| Инварианты модели | Нет | diff не трогает геометрию |
|
||||
| Performance-профили | Нет | не названы в задаче |
|
||||
| `actionlint` / `bash -n` на новых и изменённых workflow-файлах | Нет (бинаря нет в среде) | автор заявил «чисто»; синтаксис `run:`-блоков проверен чтением, YAML-структура — тестами `test/process-track.test.mjs`/`test/ship-review.test.mjs`/`test/review-doc-guard.test.mjs`, которые матчат конкретные строки конвейера |
|
||||
| `node --check` на новых/изменённых `.mjs` | Да | `process-track.mjs`, `ship-review.mjs`, `mutation-registry.mjs` — без синтаксических ошибок |
|
||||
|
||||
Рабочая копия после проверок чистая (`git status --porcelain` пуст), `HEAD` не сдвигался.
|
||||
|
||||
### Проверка «тест умеет падать» (defensive AC)
|
||||
|
||||
Полный реестр `mutation-registry.mjs` не гонял (28 минут, ночной цикл его покрывает; трек `ask`, но полный прогон мутантов — не гейт ревью, а Validate-дispatch, который уже зелёный). По двум мутантам проверил вручную (применил патч → тест упал → откатил):
|
||||
|
||||
| Мутант | Что убивает | Проверено |
|
||||
|---|---|---|
|
||||
| `ship-limit-off-by-one` (`>` → `>=` на границе 30 строк) | `рамки ship: строки src/** считаются вместе, граница включительна (#696)` | Исполнением: применил, `node --test` упал на `assert.deepEqual(shipLimitViolations({numstat:[at(20,10)]}), [])` (30 строк дало ложное нарушение) |
|
||||
| `ship-review-accepts-high` (отключение проверки `block.high > 0`) | `#696 гейт: машинный блок покрывает все задачи и не несёт High` | Исполнением: применил, `node --test` упал (`0 !== 1`) |
|
||||
| остальные 7 новых + 3 перенацеленных | см. ниже | Чтением, не исполнением |
|
||||
|
||||
Остальные разобраны чтением кода и тестов:
|
||||
- `track-show-pays-for-mutants` — `mutants = true` вместо `track === 'ask' || ci:mutants` убивается тестом «мутанты по диффу — только ask и метка ci:mutants», который явно проверяет `mutants: false` для `track:show`.
|
||||
- `ship-limits-miss-new-src-file` — `false && added.length` убирает проверку новых файлов в `src/**`; тест «рамки ship: новые файлы…» проверяет ровно этот случай через `v.some(s => s.startsWith('новые файлы')...)`.
|
||||
- `pipeline-ship-ignores-limits` — замена `if printf ... grep -qx 'ship=true'; then` на `if true; then` в YAML; убивается текстовым `assert.match` в «конвейер: трек снимается до ребейза…», который ищет буквальную строку.
|
||||
- `light-review-waits-running-push` (`proofCandidate` перестаёт требовать `status==='completed'` для push) — убивается тестом «#696: without mutants a red push run returns the task, a push still running is not waited for»: с мутацией идущий push немедленно принимается за доказательство и гейт возвращает `pending` без диспатча, тест ждёт `dispatched === ['issue/1:light']`.
|
||||
- `light-merge-dispatches-second-run` — мутация заставляет всегда диспатчить второй прогон; тест «трек show/ship — слияние ждёт push-прогон кандидата, без второго dispatch» проверяет `!ops.calls.some(c => c[0] === 'dispatch')`.
|
||||
- `ship-review-ignores-merge-marker` — тест «ship-задача — по маркеру конвейера или метке track:ship» проверяет включение по маркеру при пустых метках.
|
||||
- `ship-review-accepts-partial-coverage` — тест «машинный блок покрывает…» проверяет `partial.length === 1` для непокрытой задачи.
|
||||
|
||||
Пустого третьего столбца («чем краснеет») в реестре нет ни у одного из 9+3 пунктов — соответствует §2.7.
|
||||
|
||||
## Находки
|
||||
|
||||
Блокирующих (High/Medium) находок нет.
|
||||
|
||||
Разобрал один потенциальный источник расхождения и снял его как безопасный (без записи как Low — поведенческого расхождения нет):
|
||||
|
||||
- `scripts/process-track.mjs`'s `resolveTrack` вычисляет признак «инфраструктура» как `files.every(f => classify(f) !== 'A')`, **не** исключая `docs/reviews/**`, в отличие от параллельной `branchIsInfrastructure` в `task-packet.mjs`, которая явно фильтрует `docs/reviews/**` (комментарий про #632: ветка S6-задачи до первого кодового коммита не должна выглядеть инфраструктурной). Проверил, манифестируется ли это различие: шаг «Трек задачи и рамки ship» в `_process.yml` вызывается только на `STAGE=spec` и `STAGE=code` (сам конвейер срабатывает только на метках `S4-spec-review`/`S7-code-review`); шаг ребейза и вся ship/mutants-логика дополнительно ограничены `needs.guard.outputs.stage == 'code'`. На `STAGE=code` ветка по определению уже несёт хотя бы один класса-A коммит (иначе `S7-code-review` не была бы проставлена), поэтому `docs/reviews/**` в диффе не меняет исход `every(...) !== 'A'` — класс-A файл уже есть. На `STAGE=spec` вывод трека не влияет ни на что: `npm ci`/Chromium решает отдельная проверка `STAGE=='spec'`, а вся ship/mutants-ветка кода закрыта условием `STAGE=='code'`. Разошедшийся, но недостижимый код — не находка; дублирование логики (два места считают «инфраструктуру» по-разному) можно было бы вынести в одну функцию, но это стилистическое желание, а не дефект.
|
||||
|
||||
## Что проверено и корректно
|
||||
|
||||
- **Цена по треку (§10.4).** `process-track.mjs`: `resolveTrack` — явная метка (`track:*`, включая старые `trivial`/`small` → `show`) главнее эвристики по диффу; `mutants` — только `ask` или `ci:mutants`; `shipLimitViolations` — граница 30 строк `src/**` включительна, ловит бинарники, новые файлы, i18n, `types.ts`/`config-*.ts`, Python. Все ветки покрыты тестами и (по образцу) — исполнением.
|
||||
- **Лёгкий Validate (`validate-gate.mjs`).** `proofCandidate` для `mutants:false` принимает и завершённый push-прогон на материале, и dispatch; ждать можно только dispatch (push, который ещё идёт, не разбудит `process-resume.yml`) — комментарий и код согласованы, подтверждено тестами `#696` в `validate-gate.test.mjs` (все проходят).
|
||||
- **Лёгкое слияние (`merge-candidate.mjs`).** На `show`/`ship` слияние ждёт push-прогон уже опубликованного кандидата и диспатчит только если его нет за 3 минуты; на `ask` поведение не изменилось (отдельный тест это явно проверяет). Приоритет: fast-forward (dev не двигался) — старая ветка кода, без Validate вообще; light-merge — только когда `devMoved` истинен.
|
||||
- **Пропуск ребейза до ревью.** Условие `git merge-tree --write-tree origin/dev HEAD` для `show`/`ship`; при конфликте — обычный ребейз с помощником, как на `ask`. Ограничено `STAGE=='code'`.
|
||||
- **`ship` в рамках.** Downgrade `track:ship → track:show` при выходе за рамки происходит в том же заходе, до слияния (не после); `model_review` job корректно пропускается только когда `ship==true` (условие job'а `... && needs.prepare.outputs.ship != 'true'`); `integrate` job не блокируется пропуском модели (`if: always() && needs.guard.outputs.stage != ''`, и `ready`-шаг явно допускает `SHIP=true` без ожидания `MODEL_RESULT`). Маркер `hp:ship-merge` пишется только в этом пути и не называет себя вердиктом (проверил текстом комментария и тестом, который явно это утверждает).
|
||||
- **Приоритет reuse над ship (§499).** В `integrate`: `if SHIP && !REUSE ... elif REUSE ...` — если оба истинны, применяется повторно применимый зелёный вердикт, а не ship-маркер, как и требует PROCESS.md.
|
||||
- **Пакетное ревью ship (`ship-review.mjs`, `ship-review.yml`).** `isShipIssue` — по маркеру ИЛИ по текущей метке `track:ship` (тест подтверждает оба пути и отрицательный случай); `shipIssuesInRange` строит список по трейлерам `Issue: #NN` первого-родителя истории от прошлого тега (та же функция, что `RELEASE-MEMBERSHIP.json`); `shipReviewProblems` отказывает без документа, без покрытия всех ship-задач или при `High > 0`, и не отказывает без ship-задач в диапазоне (документ не нужен). Модель в `ship-review.yml` без прав на запись (`contents: read`, явный `github_token`, без обмена OIDC — совпадает с #556), документ публикует детерминированный `publish` job.
|
||||
- **Гейт беты в обоих путях.** `publish-prerelease.yml` (`gate` job) и `scripts/release-prerelease.mjs` (`main`, до `if (checkOnly) return`) оба зовут `ship-review.mjs check` — подтверждено тестом и чтением; `readShipDoc` ищет документ сначала в кандидате, затем в `origin/dev` — корректно для случая «ship-review.yml закоммитил документ в dev до создания тега».
|
||||
- **Архив и индекс.** `SHIP-REVIEW-<tag>` распознаётся `parseDocName`, попадает в общую таблицу «линия/бета» индекса, сортировку по тегу с бетами ниже релиза; архивация уводит в каталог стабильной линии (`beta.N` суффикс отрезается для сопоставления с `tags`).
|
||||
- **Трейлеры и changelog.** Коммит несёт `Issue: #696`, `User-Visible: no` — changelog не требуется и не тронут (подтверждено `git diff --stat`, `docs/CHANGELOG*.md` в диффе нет).
|
||||
- **Документация.** `PROCESS.md` §5, §5.1, §10.4 (новая таблица), §11.7 — согласованы с `REVIEWER.md` («Трек show», «Пакетное ревью ship») и `AUTHOR.md`/`AGENTS.md`; markdown-якоря (`#117-пакетное-ревью-ship-перед-бетой` и т. д.) соответствуют заголовкам; `test/process-digests.test.mjs` (сверяет конспект с каноном) зелёный.
|
||||
- **Раскрытое отклонение от текста issue.** Автор оставил документ ревью `show` файлом в `docs/reviews/`, а не комментарием, как предлагалось в issue — с явным обоснованием (зависимость #499/#413/счёт раундов) в комментарии «Взял». Решение по существу, а не техническим вопросом владельцу — принимаю его как обоснованную инженерную поправку к предложению, не как дефект.
|
||||
|
||||
## Чего не проверял
|
||||
|
||||
- Полный `npm test`/`tsc`/`build`/`mutation-gate` целиком заново — не гонял, полагаясь на зелёный Validate на этом SHA (#343) и точечные перезапуски затронутых файлов (все зелёные).
|
||||
- `actionlint`/`bash -n` — инструмента нет в среде ревью; синтаксис новых `run:`-блоков проверен только чтением и совпадением с тестами, которые парсят эти же строки текстом.
|
||||
- Реальный прогон `ship-review.yml` (workflow_dispatch) и `publish-prerelease.yml` end-to-end на GitHub Actions — оценивал по коду и юнит-тестам, не по фактическому прогону workflow (у ревью нет доступа к запуску Actions).
|
||||
- Мутанты реестра — 2 из 12 (9 новых + 3 перенацеленных) убил исполнением, остальные 10 разобраны чтением кода и утверждений тестов (см. таблицу выше), «проверено чтением, не исполнением».
|
||||
- Браузерные смоки, golden, pytest, инварианты модели, performance — не запускал: `smoke-select` и осмотр диффа показывают, что `src/**`/геометрия/Python/рендер не затронуты, эти гейты неприменимы к этому диффу.
|
||||
|
||||
## Вердикт
|
||||
|
||||
Зелёный. AC issue (5 пунктов предложения) реализованы и подтверждены тестами и точечным исполнением; единственное отклонение от текста issue раскрыто и обосновано автором. High/Medium находок нет.
|
||||
|
||||
---
|
||||
|
||||
<!-- material-anchors: сгенерировано конвейером (#414) -->
|
||||
|
||||
## Материал раунда
|
||||
|
||||
- Ветка: `issue/696-pipeline-by-track`, коммит `e1ae8f4ac70f` — ребейз его осиротит, и это нормально: ниже якоря, которые ребейз не меняет.
|
||||
- Дерево материала: `9be4c7079bacdba3cb21a4c0d2caa84fbf7299fc`
|
||||
```
|
||||
git log --all --format='%H %T' | grep 9be4c7079bac
|
||||
```
|
||||
- Тело issue: `398bd7cddfc58767cac5aa98d94385c4fb3a38babe04ac379a9517e9bf530ef0`
|
||||
- Вердикт конвейера: `green` · High 0
|
||||
@@ -0,0 +1,210 @@
|
||||
# CODE-REVIEW-697-r1
|
||||
|
||||
Issue: [#697](https://github.com/Matysh/houseplan-card/issues/697) · этап: code · заход: r1 ·
|
||||
блокирующих циклов израсходовано 0 из 4 · трек: `ask` (поднят автором с причиной,
|
||||
задача меняет два общих механизма конвейера) · маршрут: инфраструктурная задача,
|
||||
`S2`/`S3` пропущены (§1) · материал: **`2a62ad5b95e02b19f339ef71e67dbf07265adcce`**
|
||||
(единственный коммит ветки `issue/697-derived-on-dev` поверх `dev@c716bb0f`).
|
||||
|
||||
## Скоуп
|
||||
|
||||
Задача переносит два производных артефакта — отпечаток скриншотов документации
|
||||
(`docs/images/screenshots.json` + кадры) и эталоны golden
|
||||
(`demo/golden/baselines/**`) — с веток задач на `dev`, принимая их один раз на
|
||||
бету одним коммитом бота (`beta-derived.yml`), вместо того чтобы каждая задача с
|
||||
`src/**`-диффом их коммитила и разрешала конфликты. Второе следствие: трейлер
|
||||
`Release:` на ветке задачи больше не включает тяжёлый набор Validate (смоки,
|
||||
golden, perf) — его теперь включают только метки `ci:full`/`ci:golden`, и гейт
|
||||
материала ревью (`validate-gate.mjs`) сам диспатчит `full=true`, не принимая
|
||||
лёгкий push-прогон как доказательство.
|
||||
|
||||
Файлов класса A (`src/**`, backend Python, манифесты, i18n) в диффе нет —
|
||||
подтверждено `git diff --stat origin/dev...HEAD` (15 файлов: `.github/workflows/**`,
|
||||
`scripts/**`, `test/**`, `PROCESS.md`, `docs/process/**`, `CONTRIBUTING.md`).
|
||||
Это ровно определение инфраструктурной задачи из §1, поэтому пропуск `S2-analysis`
|
||||
и `S3-spec` корректен — предложение и решение владельца зафиксированы в теле
|
||||
issue и его первом комментарии, а не в отдельном ТЗ.
|
||||
|
||||
Какую строку `docs/SCOPE.md` это обслуживает: задача не продуктовая, это
|
||||
`PROCESS.md`/пайплайн, явно разрешённый класс правок (§1, класс B) — сам
|
||||
`docs/SCOPE.md` о ней не говорит, и это ожидаемо.
|
||||
|
||||
## Как проверялось
|
||||
|
||||
Проверялась ровно дельта: единственный коммит целиком, других раундов не было
|
||||
(r1), поэтому раздел «Закрытие раунда r0» не пишется.
|
||||
|
||||
Прочитано построчно:
|
||||
- `PROCESS.md` — весь дифф (§3 п.13, §5.1, §8, §11.4);
|
||||
- `docs/process/AUTHOR.md`, `docs/process/REVIEWER.md`, `CONTRIBUTING.md` — весь дифф;
|
||||
- `.github/workflows/beta-derived.yml` — целиком (новый файл, 215 строк);
|
||||
- `.github/workflows/_process.yml`, `.github/workflows/validate.yml` — весь дифф;
|
||||
- `scripts/classify-changes.mjs`, `scripts/process-track.mjs`,
|
||||
`scripts/validate-gate.mjs`, `scripts/mutation-registry.mjs` — весь дифф плюс
|
||||
окружающий контекст (`ci-proof.mjs::evaluateCiProof`, `requiredCheckIds`,
|
||||
`merge-candidate.mjs`) — не изменены этим диффом, но нужны, чтобы понять,
|
||||
дотягивается ли `full=true` до кандидата слияния;
|
||||
- `test/beta-derived.test.mjs`, `test/classify-changes.test.mjs`,
|
||||
`test/process-track.test.mjs`, `test/validate-gate.test.mjs` — весь дифф.
|
||||
|
||||
Независимо от заявлений автора в issue я перепроверил Validate-прогон
|
||||
[36480865713](https://github.com/Matysh/houseplan-card/actions/runs/36480865713)
|
||||
через `gh run view`/`gh api`:
|
||||
- прогон — `workflow_dispatch` на `issue/697-derived-on-dev`@`2a62ad5b`, `conclusion: success`;
|
||||
- лог job «Классификация изменённых файлов» печатает `heavy=false`,
|
||||
`mutants_requested=true` — то есть на материале был запрошен мутационный, но
|
||||
не полный набор (метки `ci:full`/`ci:golden` на issue нет, диффа по `src/**`
|
||||
тоже нет — ожидаемо);
|
||||
- все шесть шардов `Мутанты по диффу (N/6)` — `success`; смоки/golden/perf —
|
||||
`skipped` (согласовано с `heavy=false`);
|
||||
- логи шардов подтверждают убийство ИМЕННО четырёх новых мутантов #697:
|
||||
`bot-golden-commit-without-provenance`, `ci-golden-label-does-not-order-full-set`,
|
||||
`review-gate-accepts-light-proof-for-full`, `task-branch-release-trailer-heavy-again`
|
||||
— каждый напечатал `ok <id>: заявленный тест покраснел на мутанте`.
|
||||
|
||||
Это и есть таблица «AC · чем доказан · чем краснеет» для защитных AC этой
|
||||
задачи:
|
||||
|
||||
| Защитный AC | Чем доказан | Чем краснеет (мутант · результат) |
|
||||
|---|---|---|
|
||||
| Ветка задачи с `Release:` не включает тяжёлый набор повторно | `test/classify-changes.test.mjs` (`#697: на ветке задачи…`), исполнено | `task-branch-release-trailer-heavy-again` — `success` на прогоне 36480865713 |
|
||||
| `ci:golden` без `ci:full` тоже заказывает полный набор на материале | `test/process-track.test.mjs` (`#697: полный набор…`), исполнено | `ci-golden-label-does-not-order-full-set` — `success` |
|
||||
| Лёгкий push-прогон не засчитывается доказательством при `full=true` | `test/validate-gate.test.mjs` (`#697: ci:full/ci:golden…`), исполнено; плюс `evaluateCiProof`: `policy?.full && !proof.request?.full → stale` (`scripts/ci-proof.mjs:376`, не менялся этим диффом, переиспользован) | `review-gate-accepts-light-proof-for-full` — `success` |
|
||||
| Коммит бота с изменёнными эталонами обязан нести `Release:`/`Baseline-Reviewed:`, иначе провенанс отклонит | `test/beta-derived.test.mjs` (`#697: сообщение коммита…`), исполнено через `validateCommitMessage` | `bot-golden-commit-without-provenance` — `success` |
|
||||
|
||||
Пустых третьих столбцов нет — все четыре мутанта реально прогнаны на материале,
|
||||
не только заявлены.
|
||||
|
||||
Дополнительно прогнано вручную в этом ревью (дёшево, воспроизводимо):
|
||||
- `node scripts/smoke-select.mjs --base origin/dev --head HEAD` →
|
||||
«Исполняемого frontend-диффа нет… Browser-smoke этим диффом не выбираются».
|
||||
Решение: смоки не нужны — диффа по `src/**` нет, выбирать нечего, а не
|
||||
«пропустить проверку».
|
||||
- `actionlint` (бинарь `v1.7.7`, установлен вручную в `/tmp`, в окружении не был)
|
||||
на все три изменённых workflow: единственные находки — 8 preexisting
|
||||
shellcheck-предупреждений (стиль/info) в `_process.yml`/`validate.yml` на
|
||||
строках, которые этот дифф не трогает (проверено построчным сравнением с
|
||||
`origin/dev`); `beta-derived.yml` — чисто.
|
||||
- `python3 -c 'yaml.safe_load(...)'` на все три workflow — валидный YAML.
|
||||
- Прочитаны (не исполнены) `scripts/ci-proof.mjs::evaluateCiProof`,
|
||||
`requiredCheckIds`, `scripts/merge-candidate.mjs` — чтобы ответить, доходит
|
||||
ли `ci:full`/`ci:golden` до кандидата слияния (см. находки — нет, и это не
|
||||
дефект, см. ниже).
|
||||
|
||||
## Что проверено и корректно
|
||||
|
||||
- **Дешёвые гейты подтверждены зелёным Validate на точном SHA материала**
|
||||
(ссылка выше, `conclusion: success`) — `typecheck`, `npm test`, `npm run build`
|
||||
+ `bundle-policy --verify` перегонять не нужно.
|
||||
- **Мутанты по диффу** — обязательны на треке `ask`, прогнаны на материале
|
||||
(dispatch, не push), все шесть шардов зелёные, четыре новых мутанта явно
|
||||
проверены построчно в логах (см. таблицу выше).
|
||||
- **Трейлеры коммита**: `Issue: #697`, `User-Visible: no` — верно: изменение не
|
||||
видимо пользователю карточки, changelog не нужен. Один коммит, ветка
|
||||
`issue/697-derived-on-dev`, без PR — соответствует правилу репозитория.
|
||||
- **`isTaskBranch`/`heavyGatesRequested`**: порядок проверок в
|
||||
`scripts/classify-changes.mjs` корректен — `pull_request`/`workflow_dispatch`/
|
||||
`schedule` решают исход до проверки ветки, `isTaskBranch` перехватывает только
|
||||
обычный `push` на `issue/*`, и только там глушит `Release:`. На `dev` и вне
|
||||
ветки задачи (`refName` не передан) поведение не изменилось — тест это
|
||||
явно закрывает (`'без ветки — прежнее правило'`). Оба вызова
|
||||
`classify-changes.mjs` в `validate.yml` (`--heavy` и `--screenshots-mode`)
|
||||
получают `REF_NAME` — проверено регэкспом в тесте и вручную (`grep -c`).
|
||||
- **`validate-gate.mjs`/`ci-proof.mjs` совместность**: `policy.full` — не новое
|
||||
поле «для галочки»: `evaluateCiProof` действительно отклоняет прокси с
|
||||
`stale`, если `proof.request.full` не совпадает с ожиданием политики
|
||||
(`scripts/ci-proof.mjs:376`), и `requiredCheckIds` при `request.full`
|
||||
требует зелёных `smoke`/`golden`/`performance_smoke` (строка 249, не
|
||||
изменена этим диффом, но именно на неё опирается новый `--full`). Это
|
||||
честное переиспользование существующего механизма (было введено под
|
||||
`release`-политику), а не декоративный флаг.
|
||||
- **`merge-candidate.mjs` не тронут и не обязан быть тронут этим диффом.**
|
||||
Я проверил, не открывает ли это дыру для `ci:golden`-задач: слияние
|
||||
сверяет `patch-id` дифф-ветки после ребейза с диффом, проверенным на ревью
|
||||
(`scripts/merge-candidate.mjs:274, 282`), и при расхождении **отказывает**
|
||||
и возвращает issue в `S7-code-review`, а не сливает молча. Значит любое
|
||||
изменение golden-файлов, прошедшее полный Validate на материале ревью,
|
||||
либо доедет до `dev` байт-в-байт (patch-id равен), либо слияние
|
||||
переоткроет ревью — полный набор на кандидате слияния отдельно не нужен.
|
||||
Это не новая находка, а подтверждение того, что задача корректно не
|
||||
расширяла свой скоуп на файл, где менять было нечего.
|
||||
- **Документация синхронна с кодом**: `PROCESS.md` §3 п.13/§5.1/§8/§11.4,
|
||||
`docs/process/AUTHOR.md`, `docs/process/REVIEWER.md`, `CONTRIBUTING.md`
|
||||
правлены в одном коммите с кодом и друг другу не противоречат;
|
||||
`publish-prerelease.yml` (не в диффе) по-прежнему держит
|
||||
`check-docs --screenshots=strict` на кандидате — заявленный в PROCESS.md
|
||||
«предохранитель» существует и не сломан этим диффом.
|
||||
- **`beta-derived.yml`**: без прав `contents: write` у job, пишет в `dev`
|
||||
единственным `git push` без `--force` через `HP_PROCESS_TOKEN` (существующий
|
||||
секрет, уже используемый в остальном конвейере); коммит golden несёт
|
||||
`Release:`/`Baseline-Reviewed:` только когда golden реально изменился;
|
||||
подпись коммита (`docs: accept derived artifacts on dev for …`) намеренно не
|
||||
похожа на кандидата — проверено `isCandidateSubject(...) === false`
|
||||
(`bundle-policy.mjs`, не менялся). Приёмка golden требует завершённого
|
||||
`Validate` именно на `dev` (`path`/`branch`/`status` проверяются перед
|
||||
`gh run download`) — чужой прогон или прогон на ветке задачи доказательством
|
||||
не станет.
|
||||
- **Одно число — один источник**: изменение не трогает ни одну пользователю
|
||||
видимую величину (нет диффа в `src/**`, нет правок пользовательских текстов),
|
||||
вопрос неприменим.
|
||||
|
||||
## Находки
|
||||
|
||||
Блокирующих (High) находок нет. Находок Medium в скоупе или вне скоупа нет.
|
||||
|
||||
Low, снятые без правки (запись, не находка на вердикт):
|
||||
- `beta-derived.yml` ни разу не запускался «вживую» (пишет в `dev`, а это
|
||||
публичная запись, согласованная только на бету) — открыто заявлено автором
|
||||
в разделе «НЕ сделано» issue-комментария. Первый реальный прогон пойдёт под
|
||||
контролем релиз-менеджера перед ближайшей бетой с ручной проверкой диффа
|
||||
`docs/images`/`demo/golden/baselines` до принятия — то есть у механизма есть
|
||||
человеческий контроль на первом реальном использовании, а не слепое доверие
|
||||
синтетическому тесту текста workflow. Отмечаю как остаточный риск first-run,
|
||||
не как дефект кода.
|
||||
- Опциональный пункт «привязать приёмку к хешу входов рендера, а не к
|
||||
commit/tree» из тела issue не реализован — но он явно помечен
|
||||
«Опционально» и решения владельца по нему не запрашивалось (только два
|
||||
вопроса из «Нужно решение владельца» с ответами «да»/«бот»). Не пропуск AC.
|
||||
|
||||
## Чего не проверял
|
||||
|
||||
- Не исполнял `beta-derived.yml` (ни живьём, ни через `act`) — GitHub Actions
|
||||
workflow с реальным `gh api`/push в `dev`; проверка построчная, чтением, не
|
||||
исполнением (`step()`-парсинг совпадает с тем, что делают
|
||||
`test/beta-derived.test.mjs`).
|
||||
- Не прогонял `npm run golden:verify`, `python -m pytest tests_backend`,
|
||||
`npm run invariants` — не применимы: диффа по визуалу/Python/геометрии нет
|
||||
(подтверждено `smoke-select`, `git diff --stat`), и на issue нет меток
|
||||
`ci:golden`/правок `custom_components/**/*.py`.
|
||||
- Не повторял `npx tsc --noEmit`/`npm test`/`npm run build` локально — покрыты
|
||||
зелёным Validate 36480865713 на точном SHA материала, что честно
|
||||
засчитывается по правилам этого прогона (#343).
|
||||
- Не проверял `scripts/golden-accept.mjs`/`scripts/docs-accept.mjs`/
|
||||
`scripts/validate-commit-provenance.mjs` изнутри — они не изменены этим
|
||||
диффом (`git diff --stat` подтверждает), это переиспользуемые механизмы
|
||||
более ранних задач (#246, #573, #657); их корректность — не предмет этого
|
||||
ревью.
|
||||
- Не проверял `ship-review.yml`/`release-review.yml` на предмет использования
|
||||
`HP_PROCESS_TOKEN` или новых меток — они этим диффом не тронуты.
|
||||
|
||||
## Вердикт
|
||||
|
||||
Зелёный. Задача сузила проверяемый периметр правильно (никакого файла класса A,
|
||||
инфраструктурный маршрут обоснован), тесты — исполняемые, а не только текстовые
|
||||
грепы поверх прозы, и все четыре заявленных мутанта я перепроверил по логам
|
||||
реального CI-прогона на точном SHA материала, а не поверил заявлению автора.
|
||||
Документация синхронна с кодом в одном коммите. Блокирующих находок нет.
|
||||
|
||||
---
|
||||
|
||||
<!-- material-anchors: сгенерировано конвейером (#414) -->
|
||||
|
||||
## Материал раунда
|
||||
|
||||
- Ветка: `issue/697-derived-on-dev`, коммит `2a62ad5b95e0` — ребейз его осиротит, и это нормально: ниже якоря, которые ребейз не меняет.
|
||||
- Дерево материала: `b3ce3332568fabce6d9204ffc8f0fa27cbd31e35`
|
||||
```
|
||||
git log --all --format='%H %T' | grep b3ce3332568f
|
||||
```
|
||||
- Тело issue: `8a1b08521497d715ba4b7870ef91556ec417feec89151bec0daf9733401188c2`
|
||||
- Вердикт конвейера: `green` · High 0
|
||||
@@ -1,9 +1,12 @@
|
||||
# Индекс ревью
|
||||
|
||||
Генерируется `node scripts/reviews-index.mjs` (#635) — не редактировать руками. Документов: 173, issue: 80. Вердикт: 🟢 зелёный · 🟡 жёлтый · 🔴 красный · ⚪ не распознан (свободная форма старых документов). H/M — число High/Medium по строке вердикта или заголовкам находок. Файлы — пути, названные в находках; ищите по имени файла: `grep form-kit INDEX.md`.
|
||||
Генерируется `node scripts/reviews-index.mjs` (#635) — не редактировать руками. Документов: 176, issue: 82. Вердикт: 🟢 зелёный · 🟡 жёлтый · 🔴 красный · ⚪ не распознан (свободная форма старых документов). H/M — число High/Medium по строке вердикта или заголовкам находок. Файлы — пути, названные в находках; ищите по имени файла: `grep form-kit INDEX.md`.
|
||||
|
||||
| Issue | Документ | Этап · раунд | Вердикт | H | M | Находки | Файлы |
|
||||
|---|---|---|---|---:|---:|---|---|
|
||||
| #696 | [CODE-REVIEW-696-r1.md](CODE-REVIEW-696-r1.md) | code · r1 | 🟢 зелёный | 0 | 0 | — | — |
|
||||
| #695 | [CODE-REVIEW-695-r1.md](CODE-REVIEW-695-r1.md) | code · r1 | 🟡 жёлтый | 0 | 1 | «инфраструктура без метки трека» не читается как track:show нигде в коде | `PROCESS.md` `.github/workflows/_process.yml` `scripts/task-packet.mjs` `_process.yml` `task-packet.mjs` `docs/process/AUTHOR.md` `test/task-packet.test.mjs` |
|
||||
| #695 | [CODE-REVIEW-695-r2.md](CODE-REVIEW-695-r2.md) | code · r2 | 🟢 зелёный | 0 | 0 | — | — |
|
||||
| #691 | [SPEC-REVIEW-691-r1.md](SPEC-REVIEW-691-r1.md) | spec · r1 | 🟡 жёлтый | 0 | 1 | направление edge-swipe не квантифицировано и не отмечено как допущение | `src/houseplan-card.ts` |
|
||||
| #691 | [SPEC-REVIEW-691-r2.md](SPEC-REVIEW-691-r2.md) | spec · r2 | 🟢 зелёный | 0 | 0 | — | — |
|
||||
| #691 | [CODE-REVIEW-691-r1.md](CODE-REVIEW-691-r1.md) | code · r1 | 🟡 жёлтый | 0 | 2 | docs/USER-GUIDE.ru.md не обновлён и описывает старое поведение; существующий регрессионный смок demo/smoke_pan_any_zoom.mjs красный на материале ревью | `docs/USER-GUIDE.ru.md` `AGENTS.md` `docs/process/REVIEWER.md` `docs/SCOPE.md` `docs/USER-GUIDE.md` `docs/TOUCH-SUPPORT.md` `demo/smoke_pan_any_zoom.mjs` `docs/CANVAS.md` |
|
||||
|
||||
@@ -25,9 +25,16 @@ export const CI_PROOF_STATES = Object.freeze([
|
||||
// требует (#601): к кандидату беты каждая задача прогнана ими на ревью и на
|
||||
// слитом кандидате, а `Release:` мутантов больше не запрашивает — политика
|
||||
// с `mutants: true` объявляла бы каждый кандидат беты `stale`.
|
||||
//
|
||||
// #696: треки `show` и `ship` (PROCESS §5) мутантов по диффу до слияния не
|
||||
// запрашивают — их доказательство лёгкое, `reviewLight`/`mergeLight`. Мутанты
|
||||
// у них остаются в ночном полном реестре; `track:ask` и метка `ci:mutants`
|
||||
// идут по прежним `review`/`merge`.
|
||||
export const CI_PROOF_POLICIES = Object.freeze({
|
||||
review: Object.freeze({ name: 'review', full: false, mutants: true }),
|
||||
merge: Object.freeze({ name: 'merge', full: false, mutants: true }),
|
||||
reviewLight: Object.freeze({ name: 'review-light', full: false, mutants: false }),
|
||||
mergeLight: Object.freeze({ name: 'merge-light', full: false, mutants: false }),
|
||||
release: Object.freeze({ name: 'release', full: true, mutants: false }),
|
||||
});
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
+33
-11
@@ -132,6 +132,7 @@ export function realOps({
|
||||
now = Date.now, exec = sh,
|
||||
candidateTree = (sha) => githubCandidateTree({ repo, sha, token }),
|
||||
proofContext = (run) => loadGithubProofContext({ repo, run, token }),
|
||||
mutants = true,
|
||||
}) {
|
||||
const pushUrl = `https://x-access-token:${token}@github.com/${repo}`;
|
||||
const git = (...args) => exec('git', args);
|
||||
@@ -187,8 +188,8 @@ export function realOps({
|
||||
// Мутанты по диффу бегут только по запросу (#510): кандидат после ребейза —
|
||||
// новое дерево, поэтому слияние запускает Validate с мутантами само и ждёт
|
||||
// именно этот dispatch-прогон; push-прогон на том же SHA их не содержит.
|
||||
dispatchValidate: (ref) => {
|
||||
const r = exec('gh', ['workflow', 'run', workflow, '--repo', repo, '--ref', ref, '-f', 'full=false', '-f', 'mutants=true']);
|
||||
dispatchValidate: (ref, { mutants: withMutants = mutants } = {}) => {
|
||||
const r = exec('gh', ['workflow', 'run', workflow, '--repo', repo, '--ref', ref, '-f', 'full=false', '-f', `mutants=${withMutants ? 'true' : 'false'}`]);
|
||||
if (r.status !== 0) throw new Error(`gh workflow run ${workflow}: ${r.stderr || r.stdout}`);
|
||||
},
|
||||
waitValidate: async (sha, { event = 'workflow_dispatch' } = {}) => {
|
||||
@@ -208,7 +209,7 @@ export function realOps({
|
||||
try { context = await proofContext(run); }
|
||||
catch { context = { proof: null, jobs: [], reuseRuns: new Map() }; }
|
||||
const verdict = evaluateCiProof({
|
||||
run, ...context, candidate: { sha, tree }, policy: CI_PROOF_POLICIES.merge,
|
||||
run, ...context, candidate: { sha, tree }, policy: mutants ? CI_PROOF_POLICIES.merge : CI_PROOF_POLICIES.mergeLight,
|
||||
});
|
||||
if (verdict.status === 'green' || verdict.status === 'failed')
|
||||
return { result: verdict.status, url: verdict.url, note: verdict.note };
|
||||
@@ -234,7 +235,7 @@ export function realOps({
|
||||
/**
|
||||
* Слияние по алгоритму §4.2. Возвращает { merged, to, action, candidate }.
|
||||
*/
|
||||
export async function mergeCandidate({ branch, material, issue, ops, maxAttempts = MAX_ATTEMPTS }) {
|
||||
export async function mergeCandidate({ branch, material, issue, ops, maxAttempts = MAX_ATTEMPTS, mutants = true }) {
|
||||
ops.fetch('dev', branch);
|
||||
const actual = ops.revParse(`origin/${branch}`);
|
||||
const reviewedFresh = actual === material
|
||||
@@ -280,10 +281,29 @@ export async function mergeCandidate({ branch, material, issue, ops, maxAttempts
|
||||
tip = candidate;
|
||||
if (!patchIdEqual) return finish(decideMerge({ fresh: true, devMoved: true, patchIdEqual: false }), { candidate, devNow });
|
||||
|
||||
// мутанты по диффу — по запросу (#510): dispatch на ветке, где теперь стоит кандидат
|
||||
ops.dispatchValidate(branch);
|
||||
ops.log(`Validate с мутантами на кандидате ${candidate.slice(0, 8)} — ждём`);
|
||||
const { result, url } = await ops.waitValidate(candidate, { event: 'workflow_dispatch' });
|
||||
// мутанты по диффу — по запросу (#510): dispatch на ветке, где теперь стоит
|
||||
// кандидат.
|
||||
//
|
||||
// #696: на треках show/ship мутантов нет, и лёгкий Validate на кандидате
|
||||
// уже запустил сам push выше — второй, dispatch-прогон, повторил бы его
|
||||
// целиком. Ждётся push-прогон; dispatch — только если его нет: push, в
|
||||
// котором сдвинулись одни docs/reviews/**, Validate не запускает
|
||||
// (paths-ignore), а отменённый concurrency прогон заменить некому.
|
||||
let result;
|
||||
let url;
|
||||
if (mutants) {
|
||||
ops.dispatchValidate(branch);
|
||||
ops.log(`Validate с мутантами на кандидате ${candidate.slice(0, 8)} — ждём`);
|
||||
({ result, url } = await ops.waitValidate(candidate, { event: 'workflow_dispatch' }));
|
||||
} else {
|
||||
ops.log(`лёгкий Validate на кандидате ${candidate.slice(0, 8)} — ждём push-прогон`);
|
||||
({ result, url } = await ops.waitValidate(candidate, { event: 'push' }));
|
||||
if (result === 'missing') {
|
||||
ops.log('push-прогона на кандидате нет — лёгкий dispatch');
|
||||
ops.dispatchValidate(branch);
|
||||
({ result, url } = await ops.waitValidate(candidate, { event: 'workflow_dispatch' }));
|
||||
}
|
||||
}
|
||||
let decision = decideMerge({ fresh: true, devMoved: true, patchIdEqual: true, validate: result, attempt, maxAttempts });
|
||||
if (decision.action !== 'push') return finish(decision, { candidate, devNow, runUrl: url });
|
||||
|
||||
@@ -306,11 +326,13 @@ if (isMainModule(import.meta.url)) { // #496: переносимо для Window
|
||||
const repo = arg('repo') || process.env.GITHUB_REPOSITORY;
|
||||
const token = process.env.HP_PROCESS_TOKEN || process.env.TOKEN;
|
||||
if (!branch || !material || !issue || !repo || !token) {
|
||||
console.error('usage: merge-candidate.mjs --branch=<issue branch> --material=<sha> --issue=<n> [--repo=owner/name]; HP_PROCESS_TOKEN in env');
|
||||
console.error('usage: merge-candidate.mjs --branch=<issue branch> --material=<sha> --issue=<n> [--repo=owner/name] [--mutants=false]; HP_PROCESS_TOKEN in env');
|
||||
process.exit(2);
|
||||
}
|
||||
const ops = realOps({ repo, token, issue });
|
||||
mergeCandidate({ branch, material, issue, ops }).then((r) => {
|
||||
// #696: `--mutants=false` — треки show/ship сливаются по лёгкому Validate.
|
||||
const mutants = arg('mutants') !== 'false';
|
||||
const ops = realOps({ repo, token, issue, mutants });
|
||||
mergeCandidate({ branch, material, issue, ops, mutants }).then((r) => {
|
||||
const out = `merged=${r.merged}\nto=${r.to || ''}\naction=${r.action}\ncandidate=${r.candidate}\n`;
|
||||
if (process.env.GITHUB_OUTPUT) appendFileSync(process.env.GITHUB_OUTPUT, out);
|
||||
process.stdout.write(out);
|
||||
|
||||
@@ -5494,8 +5494,8 @@ const MUTANT_DEFINITIONS = [
|
||||
+ 'it without a green Validate on that SHA is the false-green the audit reproduced (#492 §4)',
|
||||
patches: [{
|
||||
file: 'scripts/merge-candidate.mjs',
|
||||
find: " const { result, url } = await ops.waitValidate(candidate, { event: 'workflow_dispatch' });",
|
||||
replace: " const { result, url } = { result: 'green', url: 'skipped' }; // mutant: no validation",
|
||||
find: " ops.log(`Validate с мутантами на кандидате ${candidate.slice(0, 8)} — ждём`);\n ({ result, url } = await ops.waitValidate(candidate, { event: 'workflow_dispatch' }));",
|
||||
replace: " ops.log(`Validate с мутантами на кандидате ${candidate.slice(0, 8)} — ждём`);\n ({ result, url } = { result: 'green', url: 'skipped' }); // mutant: no validation",
|
||||
}],
|
||||
},
|
||||
{
|
||||
@@ -10262,8 +10262,8 @@ const MUTANT_DEFINITIONS = [
|
||||
+ 'the digest must fail as soon as an anchor goes stale (#634 AC2)',
|
||||
patches: [{
|
||||
file: 'docs/process/AUTHOR.md',
|
||||
find: '[§5.1](../../PROCESS.md#51-короткий-трек-метка-trivial)',
|
||||
replace: '[§5.1](../../PROCESS.md#51-короткий-трек)',
|
||||
find: '[§5.1](../../PROCESS.md#51-метки-тяжёлых-проверок-и-прежние-метки)',
|
||||
replace: '[§5.1](../../PROCESS.md#51-метки-тяжёлых-проверок)',
|
||||
}],
|
||||
},
|
||||
{
|
||||
@@ -10273,8 +10273,8 @@ const MUTANT_DEFINITIONS = [
|
||||
+ 'digest invented or a paraphrase nobody can check (#634 AC2)',
|
||||
patches: [{
|
||||
file: 'docs/process/AUTHOR.md',
|
||||
find: ' нечего ([§5.1](../../PROCESS.md#51-короткий-трек-метка-trivial)).',
|
||||
replace: ' нечего.',
|
||||
find: ' `track:show` ([§5.1](../../PROCESS.md#51-метки-тяжёлых-проверок-и-прежние-метки)).',
|
||||
replace: ' `track:show`.',
|
||||
}],
|
||||
},
|
||||
{
|
||||
@@ -10613,7 +10613,7 @@ const MUTANT_DEFINITIONS = [
|
||||
+ 'остановиться, сохранить метку и назвать упавшую стадию',
|
||||
patches: [{
|
||||
file: '.github/workflows/_process.yml',
|
||||
find: ' if [ "$REUSE" != "true" ] && [ "$MODEL_RESULT" != "success" ]; then',
|
||||
find: ' if [ "$REUSE" != "true" ] && [ "$SHIP" != "true" ] && [ "$MODEL_RESULT" != "success" ]; then',
|
||||
replace: ' if false; then # mutant: every model result is accepted',
|
||||
}],
|
||||
},
|
||||
@@ -12239,8 +12239,8 @@ const MUTANT_DEFINITIONS = [
|
||||
+ 'wait for that run, not for the push run that carries no mutants (#510 AC3)',
|
||||
patches: [{
|
||||
file: 'scripts/merge-candidate.mjs',
|
||||
find: " ops.dispatchValidate(branch);\n ops.log(`Validate с мутантами на кандидате ${candidate.slice(0, 8)} — ждём`);\n const { result, url } = await ops.waitValidate(candidate, { event: 'workflow_dispatch' });",
|
||||
replace: " ops.log(`Validate на кандидате ${candidate.slice(0, 8)} — ждём`);\n const { result, url } = await ops.waitValidate(candidate); // mutant: push run, no dispatch",
|
||||
find: " ops.dispatchValidate(branch);\n ops.log(`Validate с мутантами на кандидате ${candidate.slice(0, 8)} — ждём`);\n ({ result, url } = await ops.waitValidate(candidate, { event: 'workflow_dispatch' }));",
|
||||
replace: " ops.log(`Validate на кандидате ${candidate.slice(0, 8)} — ждём`);\n ({ result, url } = await ops.waitValidate(candidate)); // mutant: push run, no dispatch",
|
||||
}],
|
||||
},
|
||||
{
|
||||
@@ -13334,6 +13334,172 @@ const MUTANT_DEFINITIONS = [
|
||||
replace: ' if True: # mutant: concurrent flushes are not serialised\n',
|
||||
}],
|
||||
},
|
||||
// r1 #695: инфраструктура без трековой метки — трек show (§5.1).
|
||||
{
|
||||
id: 'guard-infra-keeps-ask-limit',
|
||||
guard: 'node --test --test-name-pattern="r1 #695" test/review-doc-guard.test.mjs',
|
||||
because: 'r1 #695: an infrastructure task without a track label reads as track:show (PROCESS §5.1); '
|
||||
+ 'the guard must give it the show cycle limit 2, not the ask limit 4',
|
||||
patches: [{
|
||||
file: '.github/workflows/_process.yml',
|
||||
find: ' limit=2\n echo "инфраструктурная задача без трековой метки',
|
||||
replace: ' limit=4 # mutant: infra keeps the ask limit\n echo "инфраструктурная задача без трековой метки',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'packet-infra-track-ignores-show-default',
|
||||
guard: 'node --test --test-name-pattern="r1 #695" test/task-packet.test.mjs',
|
||||
because: 'r1 #695: the packet names the track an infrastructure task actually runs on — show '
|
||||
+ 'without a label, the owner label otherwise',
|
||||
patches: [{
|
||||
file: 'scripts/task-packet.mjs',
|
||||
find: " const infraTrack = hasTrackLabel(labels) ? trackFromLabels(labels) : 'show';",
|
||||
replace: ' const infraTrack = trackFromLabels(labels); // mutant: unlabelled infra reads as ask',
|
||||
}],
|
||||
},
|
||||
// #696: цена захода по треку — мутанты, рамки ship, пакетное ревью перед бетой.
|
||||
{
|
||||
id: 'track-show-pays-for-mutants',
|
||||
guard: 'node --test --test-name-pattern="мутанты по диффу — только ask" test/process-track.test.mjs',
|
||||
because: '#696: show/ship request no diff mutants before merge; only ask and the ci:mutants '
|
||||
+ 'label do — otherwise every small task pays the 28-minute mutant run again',
|
||||
patches: [{
|
||||
file: 'scripts/process-track.mjs',
|
||||
find: " const mutants = track === 'ask' || labels.includes('ci:mutants');",
|
||||
replace: ' const mutants = true; // mutant: every track pays for mutants',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'ship-limits-miss-new-src-file',
|
||||
guard: 'node --test --test-name-pattern="рамки ship: новые файлы" test/process-track.test.mjs',
|
||||
because: '#696: a new file in src/** is outside ship limits — ship merges without a model '
|
||||
+ 'review, so the mechanical limits are the only thing standing between it and dev',
|
||||
patches: [{
|
||||
file: 'scripts/process-track.mjs',
|
||||
find: ' if (added.length) out.push(',
|
||||
replace: ' if (false && added.length) out.push(',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'ship-limit-off-by-one',
|
||||
guard: 'node --test --test-name-pattern="граница включительна" test/process-track.test.mjs',
|
||||
because: '#696: the ship limit is 30 src lines inclusive (owner decision); 30 lines still ship',
|
||||
patches: [{
|
||||
file: 'scripts/process-track.mjs',
|
||||
find: ' if (lines > SHIP_SRC_LINE_LIMIT) out.push(',
|
||||
replace: ' if (lines >= SHIP_SRC_LINE_LIMIT) out.push(',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'pipeline-ship-ignores-limits',
|
||||
guard: 'node --test --test-name-pattern="трек снимается до ребейза" test/process-track.test.mjs',
|
||||
because: '#696: ship skips the model only inside the limits; ignoring the limits merges '
|
||||
+ 'unread code of any size',
|
||||
patches: [{
|
||||
file: '.github/workflows/_process.yml',
|
||||
find: " if printf '%s\\n' \"$limits\" | grep -qx 'ship=true'; then",
|
||||
replace: ' if true; then # mutant: ship limits ignored',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'light-review-waits-running-push',
|
||||
guard: 'node --test --test-name-pattern="#696" test/validate-gate.test.mjs',
|
||||
because: '#696: a running push run cannot wake the round (process-resume listens to '
|
||||
+ 'dispatch only); treating it as proof candidate parks the round forever',
|
||||
patches: [{
|
||||
file: 'scripts/validate-gate.mjs',
|
||||
find: " return run?.event === 'workflow_dispatch' || (run?.event === 'push' && run?.status === 'completed');",
|
||||
replace: " return run?.event === 'workflow_dispatch' || run?.event === 'push'; // mutant: running push counts",
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'light-merge-dispatches-second-run',
|
||||
guard: 'node --test --test-name-pattern="слияние ждёт push-прогон" test/merge-candidate.test.mjs',
|
||||
because: '#696: the candidate push already starts the light Validate; a second dispatch '
|
||||
+ 'doubles runner minutes on every show/ship merge',
|
||||
patches: [{
|
||||
file: 'scripts/merge-candidate.mjs',
|
||||
find: " ({ result, url } = await ops.waitValidate(candidate, { event: 'push' }));",
|
||||
replace: " ops.dispatchValidate(branch); ({ result, url } = await ops.waitValidate(candidate, { event: 'workflow_dispatch' })); // mutant: second run",
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'ship-review-ignores-merge-marker',
|
||||
guard: 'node --test --test-name-pattern="ship-задача — по маркеру" test/ship-review.test.mjs',
|
||||
because: '#696: the pipeline marker proves a merge without model review even when the '
|
||||
+ 'label was changed afterwards; missing it lets unread code into a beta',
|
||||
patches: [{
|
||||
file: 'scripts/ship-review.mjs',
|
||||
find: " return names.includes('track:ship') || comments.some((c) => SHIP_MERGE_MARKER_RE.test(String(c?.body ?? '')));",
|
||||
replace: " return names.includes('track:ship'); // mutant: marker ignored",
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'ship-review-accepts-partial-coverage',
|
||||
guard: 'node --test --test-name-pattern="машинный блок покрывает" test/ship-review.test.mjs',
|
||||
because: '#696: a ship task merged after the batch review ran was read by nobody; the beta '
|
||||
+ 'gate must refuse until the review is re-run',
|
||||
patches: [{
|
||||
file: 'scripts/ship-review.mjs',
|
||||
find: ' if (missing.length) {',
|
||||
replace: ' if (false && missing.length) {',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'ship-review-accepts-high',
|
||||
guard: 'node --test --test-name-pattern="машинный блок покрывает" test/ship-review.test.mjs',
|
||||
because: '#696: a High in the batch review is a defect a beta user would see; it blocks the beta',
|
||||
patches: [{
|
||||
file: 'scripts/ship-review.mjs',
|
||||
find: ' else if (block.high > 0) problems.push(',
|
||||
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');
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Трек задачи и рамки `ship` (PROCESS.md §5, #695/#696).
|
||||
*
|
||||
* 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` и
|
||||
* `full=true|false` — то, что конвейер ревью читает, решая, сколько стоит
|
||||
* заход: мутанты по диффу нужны только `ask` и метке `ci:mutants`; полный
|
||||
* набор (смоки, golden, perf) на ветке задачи — только меткам `ci:full` и
|
||||
* `ci:golden` (#697). Инфраструктурная задача без трековой
|
||||
* метки — `show` (§5.1); признак инфраструктуры механический, как в §1: в
|
||||
* диффе ни одного файла класса A.
|
||||
*
|
||||
* `ship-limits` печатает `ship=true|false` и по строке `violation=…` на каждое
|
||||
* нарушение рамок: дифф `src/**` не больше 30 строк, без новых файлов в
|
||||
* `src/**`, без i18n, без полей конфига и без Python. Рамки механические
|
||||
* намеренно: по ним конвейер сливает задачу без ревью модели, и решать их
|
||||
* «на глаз» некому.
|
||||
*/
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { appendFileSync } from 'node:fs';
|
||||
import { isMainModule } from './spawn-portable.mjs';
|
||||
import { classify } from './process-gate.mjs';
|
||||
|
||||
export const SHIP_SRC_LINE_LIMIT = 30;
|
||||
|
||||
/**
|
||||
* Трек по меткам: `track:*` главнее прежних меток; `trivial` и `small`
|
||||
* читаются как `show` (§5.1); задача без трековой метки — `ask`. Метки трека
|
||||
* не доказывают продуктовый поток: инфраструктурной задаче владелец тоже может
|
||||
* поставить `track:*`, чтобы задать цену конвейера.
|
||||
*/
|
||||
export function trackFromLabels(labels = []) {
|
||||
if (labels.includes('track:ship')) return 'ship';
|
||||
if (labels.includes('track:show')) return 'show';
|
||||
if (labels.includes('track:ask')) return 'ask';
|
||||
if (labels.includes('trivial') || labels.includes('small')) return 'show';
|
||||
return 'ask';
|
||||
}
|
||||
|
||||
/** Есть ли у задачи трековая метка вообще — новая или прежняя. */
|
||||
export const hasTrackLabel = (labels = []) => ['track:ship', 'track:show', 'track:ask', 'trivial', 'small']
|
||||
.some((label) => labels.includes(label));
|
||||
|
||||
/**
|
||||
* Трек, по которому конвейер оценивает заход. Явная метка решает всё; без неё
|
||||
* инфраструктурная задача (ни одного файла класса 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');
|
||||
const full = labels.includes('ci:full') || labels.includes('ci:golden');
|
||||
return { track, mutants, full, infrastructure };
|
||||
}
|
||||
|
||||
const I18N = [/^src\/i18n\//, /^custom_components\/[^/]+\/translations\//];
|
||||
const CONFIG = [/^src\/types\.ts$/, /^src\/config-[^/]+\.ts$/];
|
||||
const PYTHON = /\.py$/;
|
||||
|
||||
/**
|
||||
* Нарушения рамок `ship` по `git diff --numstat` и `--name-status` базы и
|
||||
* вершины. Пустой список — задача укладывается в рамки.
|
||||
*
|
||||
* @param {{added:number|null, deleted:number|null, path:string}[]} numstat
|
||||
* @param {{status:string, path:string}[]} nameStatus
|
||||
*/
|
||||
export function shipLimitViolations({ numstat = [], nameStatus = [] } = {}) {
|
||||
const out = [];
|
||||
const src = numstat.filter((row) => row.path.startsWith('src/'));
|
||||
const binary = src.filter((row) => row.added === null || row.deleted === null);
|
||||
const lines = src.reduce((sum, row) => sum + (row.added ?? 0) + (row.deleted ?? 0), 0);
|
||||
if (lines > SHIP_SRC_LINE_LIMIT) out.push(`дифф src/** — ${lines} строк при рамке ${SHIP_SRC_LINE_LIMIT}`);
|
||||
if (binary.length) out.push(`двоичные файлы в src/**: ${binary.map((row) => row.path).join(', ')}`);
|
||||
const added = nameStatus.filter((row) => row.status.startsWith('A') && row.path.startsWith('src/'));
|
||||
if (added.length) out.push(`новые файлы в src/**: ${added.map((row) => row.path).join(', ')}`);
|
||||
const paths = [...new Set([...numstat, ...nameStatus].map((row) => row.path))];
|
||||
const i18n = paths.filter((path) => I18N.some((re) => re.test(path)));
|
||||
if (i18n.length) out.push(`ключи i18n: ${i18n.join(', ')}`);
|
||||
const config = paths.filter((path) => CONFIG.some((re) => re.test(path)));
|
||||
if (config.length) out.push(`поля конфига: ${config.join(', ')}`);
|
||||
const python = paths.filter((path) => PYTHON.test(path));
|
||||
if (python.length) out.push(`Python: ${python.join(', ')}`);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** `git diff --numstat` → строки; двоичный файл даёт `-\t-`. */
|
||||
export function parseNumstat(text = '') {
|
||||
return String(text).split('\n').map((line) => line.trim()).filter(Boolean).map((line) => {
|
||||
const [added, deleted, ...rest] = line.split('\t');
|
||||
return { added: added === '-' ? null : Number(added), deleted: deleted === '-' ? null : Number(deleted), path: rest.at(-1) };
|
||||
});
|
||||
}
|
||||
|
||||
/** `git diff --name-status` → строки; у переименования путь — новый. */
|
||||
export function parseNameStatus(text = '') {
|
||||
return String(text).split('\n').map((line) => line.trim()).filter(Boolean).map((line) => {
|
||||
const [status, ...paths] = line.split('\t');
|
||||
return { status, path: paths.at(-1) };
|
||||
});
|
||||
}
|
||||
|
||||
function git(args) {
|
||||
const r = spawnSync('git', args, { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
|
||||
if (r.status !== 0) throw new Error(`git ${args.join(' ')}: ${(r.stderr || '').trim()}`);
|
||||
return r.stdout;
|
||||
}
|
||||
|
||||
if (isMainModule(import.meta.url)) {
|
||||
const [command, ...rest] = process.argv.slice(2);
|
||||
const value = (name) => rest.find((a) => a.startsWith(`--${name}=`))?.slice(name.length + 3) ?? '';
|
||||
const emit = (lines) => {
|
||||
for (const line of lines) console.log(line);
|
||||
if (process.env.GITHUB_OUTPUT) appendFileSync(process.env.GITHUB_OUTPUT, `${lines.join('\n')}\n`);
|
||||
};
|
||||
try {
|
||||
const base = value('base');
|
||||
const head = value('head') || 'HEAD';
|
||||
const range = base ? `${base}...${head}` : null;
|
||||
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, 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({
|
||||
numstat: parseNumstat(git(['diff', '--numstat', range])),
|
||||
nameStatus: parseNameStatus(git(['diff', '--name-status', range])),
|
||||
});
|
||||
emit([`ship=${violations.length === 0}`, `violations=${violations.join('; ')}`]);
|
||||
} else {
|
||||
throw new Error('usage: process-track.mjs resolve --labels=a,b [--base=<ref> --head=<ref>] | ship-limits --base=<ref> [--head=<ref>]');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`::error::${error.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
@@ -503,6 +503,11 @@ if (invokedDirectly) {
|
||||
const bundleSha256 = bundleSnapshot.entrySha256;
|
||||
const validateRuns = await assertGreenValidate(sha);
|
||||
validateIssues();
|
||||
// #696, PROCESS.md §11.7: ship-задачи диапазона слиты без ревью модели —
|
||||
// бета только после пакетного ревью их кода (`ship-review.yml`).
|
||||
run(process.execPath, [
|
||||
'scripts/ship-review.mjs', 'check', `--tag=${tag}`, `--candidate=${sha}`, `--repo=${repo}`,
|
||||
], { inherit: true });
|
||||
const history = readCandidateHistory(sha);
|
||||
const generatedMembership = buildReleaseMembership({
|
||||
tag, candidate: sha, base: history.base, commits: history.commits, issueNumbers: issues,
|
||||
|
||||
@@ -88,9 +88,11 @@ export function archivePlan({ names, lines, open, through, addedIn = new Map() }
|
||||
if (name === INDEX_FILE) continue;
|
||||
const doc = parseDocName(name);
|
||||
if (!doc) { kept.push({ name, reason: 'вне схемы имён' }); continue; }
|
||||
if (doc.stage === 'release') {
|
||||
if (tags.has(doc.tag)) moves.push({ name, from: `${LIVE_DIR}/${name}`, to: `${ARCHIVE_DIR}/${doc.tag}/${name}`, tag: doc.tag, issue: null });
|
||||
else kept.push({ name, reason: `ревью линии ${doc.tag} не входит в архивируемые линии` });
|
||||
if (doc.stage === 'release' || doc.stage === 'ship') {
|
||||
// #696: пакетное ревью беты уходит в каталог своей стабильной линии.
|
||||
const line = doc.tag.replace(/-beta\.\d+$/, '');
|
||||
if (tags.has(line)) moves.push({ name, from: `${LIVE_DIR}/${name}`, to: `${ARCHIVE_DIR}/${line}/${name}`, tag: line, issue: null });
|
||||
else kept.push({ name, reason: `ревью линии ${line} не входит в архивируемые линии` });
|
||||
continue;
|
||||
}
|
||||
if (openSet.has(doc.issue)) { kept.push({ name, issue: doc.issue, reason: 'задача есть в открытой линии' }); continue; }
|
||||
|
||||
@@ -28,6 +28,8 @@ import { isMainModule } from './spawn-portable.mjs';
|
||||
export const INDEX_FILE = 'INDEX.md';
|
||||
const DOC_NAME = /^(CODE|SPEC)-REVIEW-(?:issue-)?(\d+)(?:-r(\d+))?(?:-([a-z0-9-]+))?\.md$/i;
|
||||
const RELEASE_DOC_NAME = /^RELEASE-REVIEW-(v(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*))\.md$/i;
|
||||
// #696: пакетное ревью ship-задач перед бетой (PROCESS.md §11.7) — по тегу беты.
|
||||
const SHIP_DOC_NAME = /^SHIP-REVIEW-(v(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-beta\.(?:0|[1-9]\d*))?)\.md$/i;
|
||||
const COLOUR = {
|
||||
'зелёный': 'зелёный', 'зеленый': 'зелёный', green: 'зелёный',
|
||||
'жёлтый': 'жёлтый', 'желтый': 'жёлтый', yellow: 'жёлтый',
|
||||
@@ -43,6 +45,8 @@ const VERDICT_OWN_LINE_RE = /^[ \t]*(?:[-*]\s*)?\**(?:Вердикт|Verdict)[^\
|
||||
export function parseDocName(name) {
|
||||
const release = RELEASE_DOC_NAME.exec(String(name));
|
||||
if (release) return { stage: 'release', issue: null, round: null, suffix: null, tag: release[1] };
|
||||
const ship = SHIP_DOC_NAME.exec(String(name));
|
||||
if (ship) return { stage: 'ship', issue: null, round: null, suffix: null, tag: ship[1] };
|
||||
const match = DOC_NAME.exec(String(name));
|
||||
if (!match) return null;
|
||||
return {
|
||||
@@ -301,14 +305,20 @@ export function assertAllDocumentsIndexed({ skipped }) {
|
||||
const badge = (verdict) => ({ 'зелёный': '🟢', 'жёлтый': '🟡', 'красный': '🔴' }[verdict] || '⚪');
|
||||
|
||||
export function renderIndex({ entries, skipped = [] }) {
|
||||
const releaseDocs = entries.filter((entry) => entry.stage === 'release').sort((a, b) => {
|
||||
const av = a.tag.slice(1).split('.').map(Number);
|
||||
const bv = b.tag.slice(1).split('.').map(Number);
|
||||
for (let i = 0; i < 3; i += 1) if (av[i] !== bv[i]) return bv[i] - av[i];
|
||||
return 0;
|
||||
// Документы линии и беты (release, ship) — без issue; свежий тег выше.
|
||||
// Стабильный тег старше своих бет: `v1.79.0` выше `v1.79.0-beta.3`.
|
||||
const tagKey = (tag) => {
|
||||
const [core, beta] = tag.slice(1).split('-beta.');
|
||||
return [...core.split('.').map(Number), beta == null ? Infinity : Number(beta)];
|
||||
};
|
||||
const releaseDocs = entries.filter((entry) => entry.issue == null).sort((a, b) => {
|
||||
const av = tagKey(a.tag);
|
||||
const bv = tagKey(b.tag);
|
||||
for (let i = 0; i < 4; i += 1) if (av[i] !== bv[i]) return bv[i] - av[i];
|
||||
return a.stage.localeCompare(b.stage);
|
||||
});
|
||||
const byIssue = new Map();
|
||||
for (const entry of entries.filter((item) => item.stage !== 'release')) {
|
||||
for (const entry of entries.filter((item) => item.issue != null)) {
|
||||
const list = byIssue.get(entry.issue) || [];
|
||||
list.push(entry);
|
||||
byIssue.set(entry.issue, list);
|
||||
@@ -322,7 +332,8 @@ export function renderIndex({ entries, skipped = [] }) {
|
||||
lines.push('| Issue | Документ | Этап · раунд | Вердикт | H | M | Находки | Файлы |');
|
||||
lines.push('|---|---|---|---|---:|---:|---|---|');
|
||||
for (const doc of releaseDocs) {
|
||||
lines.push(`| линия ${doc.tag} | [${doc.name}](${doc.name}) | ревью линии · — | ${badge(doc.verdict)} ${doc.verdict} | ${doc.high} | ${doc.medium} | ${doc.findings.join('; ').replace(/\|/g, '\\|') || '—'} | ${(doc.files || []).map((f) => `\`${f}\``).join(' ') || '—'} |`);
|
||||
const [who, what] = doc.stage === 'ship' ? ['бета', 'пакетное ревью ship'] : ['линия', 'ревью линии'];
|
||||
lines.push(`| ${who} ${doc.tag} | [${doc.name}](${doc.name}) | ${what} · — | ${badge(doc.verdict)} ${doc.verdict} | ${doc.high} | ${doc.medium} | ${doc.findings.join('; ').replace(/\|/g, '\\|') || '—'} | ${(doc.files || []).map((f) => `\`${f}\``).join(' ') || '—'} |`);
|
||||
}
|
||||
for (const issue of issues) {
|
||||
const docs = byIssue.get(issue).sort((a, b) => (a.stage === b.stage ? (a.round || 0) - (b.round || 0) : a.stage === 'spec' ? -1 : 1));
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Пакетное ревью задач `track:ship` перед бетой (#696, PROCESS.md §11.7).
|
||||
*
|
||||
* node scripts/ship-review.mjs doc --tag=v1.79.0-beta.1
|
||||
* node scripts/ship-review.mjs prepare --tag=<тег> --candidate=<sha> --out=<dir> [--repo=owner/name]
|
||||
* node scripts/ship-review.mjs check --tag=<тег> --candidate=<sha> [--repo=owner/name]
|
||||
*
|
||||
* `ship` сливается без ревью модели (§5): правка в механических рамках и
|
||||
* зелёный лёгкий Validate. Прочитать её код обязан кто-то до того, как она
|
||||
* уйдёт пользователям, — это пакетное ревью всех ship-задач диапазона
|
||||
* «прошлый тег..кандидат беты» одной сессией модели.
|
||||
*
|
||||
* Какие задачи — ship, доказывает конвейер, а не метка: слияние без модели
|
||||
* оставляет в issue машинный маркер `<!-- hp:ship-merge material=<sha> -->`.
|
||||
* Метка `track:ship` на issue в диапазоне тоже включает задачу в пакет —
|
||||
* лишний разбор дешевле пропущенного. Состав диапазона — трейлеры `Issue: #NN`,
|
||||
* тот же построитель, что у `RELEASE-MEMBERSHIP.json` (#547).
|
||||
*
|
||||
* `check` — гейт публикации беты: если в диапазоне есть ship-задачи, документ
|
||||
* `docs/reviews/SHIP-REVIEW-<тег>.md` обязан быть в кандидате или в `dev`,
|
||||
* покрывать их все машинным блоком и не нести High.
|
||||
*/
|
||||
import { mkdirSync, writeFileSync } from 'node:fs';
|
||||
import { join, resolve } from 'node:path';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { isMainModule } from './spawn-portable.mjs';
|
||||
import { issueTrailers, readCandidateHistory } from './release-membership.mjs';
|
||||
|
||||
export const SHIP_REVIEW_DIR = 'docs/reviews';
|
||||
export const SHIP_MERGE_MARKER_RE = /<!-- hp:ship-merge material=([0-9a-f]{40}) -->/;
|
||||
export const SHIP_REVIEW_ANCHOR = '<!-- hp-ship-review-anchors -->';
|
||||
export const RELEASE_TAG_RE = /^v(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-beta\.(0|[1-9]\d*))?$/;
|
||||
const SHA_RE = /^[0-9a-f]{40,64}$/;
|
||||
const TZ_LIMIT = 1500;
|
||||
|
||||
/** Путь документа пакетного ревью для тега беты или стабильного. */
|
||||
export function shipReviewDocPath(tag) {
|
||||
if (!RELEASE_TAG_RE.test(String(tag))) throw new Error(`not a release tag: ${tag}`);
|
||||
return `${SHIP_REVIEW_DIR}/SHIP-REVIEW-${tag}.md`;
|
||||
}
|
||||
|
||||
/** Задача — ship, если конвейер слил её без модели или на ней стоит `track:ship`. */
|
||||
export function isShipIssue({ labels = [], comments = [] } = {}) {
|
||||
const names = labels.map((label) => (typeof label === 'string' ? label : label?.name));
|
||||
return names.includes('track:ship') || comments.some((c) => SHIP_MERGE_MARKER_RE.test(String(c?.body ?? '')));
|
||||
}
|
||||
|
||||
/** Раздел `## ТЗ` тела issue — для ship это строка «что меняется и чем проверить». */
|
||||
export function specSection(body = '') {
|
||||
const text = String(body);
|
||||
const start = text.search(/^#{1,3}\s*ТЗ(?![\p{L}\p{N}_])/mu);
|
||||
if (start < 0) return '';
|
||||
const rest = text.slice(start).split('\n');
|
||||
const out = [rest[0]];
|
||||
for (const line of rest.slice(1)) {
|
||||
if (/^#{1,3}\s/.test(line)) break;
|
||||
out.push(line);
|
||||
}
|
||||
const section = out.join('\n').trim();
|
||||
return section.length > TZ_LIMIT ? `${section.slice(0, TZ_LIMIT)}…` : section;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ship-задачи диапазона: номера из трейлеров коммитов, признак ship — из
|
||||
* данных issue. `issueData(number)` → `{ title, body, labels, comments }` или null.
|
||||
*/
|
||||
export function shipIssuesInRange({ commits = [], issueData }) {
|
||||
const byIssue = new Map();
|
||||
for (const commit of commits) {
|
||||
for (const number of issueTrailers(commit.message)) {
|
||||
if (!byIssue.has(number)) byIssue.set(number, []);
|
||||
byIssue.get(number).push({ sha: commit.sha, subject: String(commit.message).split('\n')[0] });
|
||||
}
|
||||
}
|
||||
const out = [];
|
||||
for (const [number, list] of [...byIssue].sort((a, b) => a[0] - b[0])) {
|
||||
const data = issueData(number);
|
||||
if (!data || !isShipIssue(data)) continue;
|
||||
out.push({ number, title: data.title || '', spec: specSection(data.body), commits: list.reverse() });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function renderShipBrief({ tag, candidate, base, ship, runUrl = '' }) {
|
||||
const lines = [
|
||||
`# Вход пакетного ревью ship ${tag}`,
|
||||
'',
|
||||
`- Кандидат: \`${candidate}\``,
|
||||
`- Диапазон: ${base ? `\`${base.tag}\` · \`${base.sha}\`` : 'нет прошлого тега — всё дерево'} .. кандидат`,
|
||||
...(runUrl ? [`- Прогон: ${runUrl}`] : []),
|
||||
`- Документ: \`${shipReviewDocPath(tag)}\``,
|
||||
'',
|
||||
`## Задачи ship (${ship.length}) — слиты без ревью модели`,
|
||||
'',
|
||||
];
|
||||
for (const issue of ship) {
|
||||
lines.push(`### #${issue.number} · ${issue.title}`, '');
|
||||
lines.push(issue.spec ? issue.spec : '(раздела «## ТЗ» в теле нет — ТЗ задачи не записано, это находка)', '');
|
||||
lines.push('Коммиты (`git show <sha>`):', '');
|
||||
for (const commit of issue.commits) lines.push(`- \`${commit.sha}\` ${commit.subject}`);
|
||||
lines.push('');
|
||||
}
|
||||
return `${lines.join('\n').replace(/\n{3,}/g, '\n\n').trim()}\n`;
|
||||
}
|
||||
|
||||
/** Машинный блок документа: его пишет публикация, читает `check`. */
|
||||
export function anchorBlock({ tag, candidate, base = null, issues = [], high = 0, medium = 0, low = 0, runUrl = '' }) {
|
||||
return [
|
||||
SHIP_REVIEW_ANCHOR,
|
||||
'### Материал пакетного ревью',
|
||||
'',
|
||||
'```',
|
||||
`tag ${tag}`,
|
||||
`candidate ${candidate}`,
|
||||
`base ${base || '—'}`,
|
||||
`issues ${issues.length ? issues.join(',') : '—'}`,
|
||||
`high ${high}`,
|
||||
`medium ${medium}`,
|
||||
`low ${low}`,
|
||||
`run ${runUrl || '—'}`,
|
||||
'```',
|
||||
'',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
export function parseAnchorBlock(text = '') {
|
||||
const at = String(text).lastIndexOf(SHIP_REVIEW_ANCHOR);
|
||||
if (at < 0) return null;
|
||||
const fence = /```\n([\s\S]*?)\n```/.exec(String(text).slice(at));
|
||||
if (!fence) return null;
|
||||
const fields = Object.fromEntries(fence[1].split('\n').map((line) => {
|
||||
const space = line.indexOf(' ');
|
||||
return space < 0 ? [line, ''] : [line.slice(0, space), line.slice(space + 1).trim()];
|
||||
}));
|
||||
const number = (value) => (/^\d+$/.test(String(value)) ? Number(value) : null);
|
||||
return {
|
||||
tag: fields.tag || null,
|
||||
candidate: fields.candidate || null,
|
||||
issues: String(fields.issues || '').split(',').map((s) => number(s.trim())).filter((n) => n != null),
|
||||
high: number(fields.high),
|
||||
medium: number(fields.medium),
|
||||
low: number(fields.low),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Причины не публиковать бету; пустой список — гейт пройден. Без ship-задач в
|
||||
* диапазоне документ не нужен: пакетному ревью нечего читать.
|
||||
*/
|
||||
export function shipReviewProblems({ tag, ship = [], docText = null }) {
|
||||
if (!ship.length) return [];
|
||||
const doc = shipReviewDocPath(tag);
|
||||
const numbers = ship.map((issue) => issue.number);
|
||||
const run = `gh workflow run ship-review.yml --ref dev -f tag=${tag}`;
|
||||
if (!docText) {
|
||||
return [`${doc} нет ни в кандидате, ни в dev: ship-задачи ${numbers.map((n) => `#${n}`).join(', ')} слиты без ревью модели, и их код до беты не читал никто (PROCESS.md §11.7). Запустить: ${run}`];
|
||||
}
|
||||
const block = parseAnchorBlock(docText);
|
||||
if (!block) return [`${doc} без машинного блока ${SHIP_REVIEW_ANCHOR}: покрытие задач не доказано. Переснять: ${run} -f force=true`];
|
||||
const problems = [];
|
||||
if (block.tag !== tag) problems.push(`${doc} записан для тега ${block.tag}, а публикуется ${tag}`);
|
||||
const missing = numbers.filter((n) => !block.issues.includes(n));
|
||||
if (missing.length) {
|
||||
problems.push(`${doc} не покрывает ship-задачи ${missing.map((n) => `#${n}`).join(', ')} — они слиты после ревью. Переснять: ${run} -f force=true`);
|
||||
}
|
||||
if (block.high == null) problems.push(`${doc}: в машинном блоке нет числа High`);
|
||||
else if (block.high > 0) problems.push(`${doc}: High ${block.high} — бета ждёт починки: находка чинится отдельной задачей, затем ревью переснимается`);
|
||||
return problems;
|
||||
}
|
||||
|
||||
function git(args, { allowFailure = false } = {}) {
|
||||
const r = spawnSync('git', args, { encoding: 'utf8', maxBuffer: 256 * 1024 * 1024 });
|
||||
if (r.status !== 0 && !allowFailure) throw new Error(`git ${args.join(' ')} failed: ${(r.stderr || '').trim()}`);
|
||||
return r.status === 0 ? r.stdout : null;
|
||||
}
|
||||
|
||||
function ghIssue(repo, number) {
|
||||
const r = spawnSync('gh', ['issue', 'view', String(number), '--repo', repo, '--json', 'number,title,body,labels,comments'], {
|
||||
encoding: 'utf8', maxBuffer: 64 * 1024 * 1024,
|
||||
});
|
||||
if (r.status !== 0) throw new Error(`gh issue view ${number}: ${(r.stderr || r.stdout || '').trim()}`);
|
||||
return JSON.parse(r.stdout);
|
||||
}
|
||||
|
||||
/** Документ — из кандидата, иначе из `origin/dev`: ревью могло лечь в dev после Release-коммита. */
|
||||
export function readShipDoc(tag, candidate) {
|
||||
const path = shipReviewDocPath(tag);
|
||||
return git(['show', `${candidate}:${path}`], { allowFailure: true })
|
||||
?? git(['show', `origin/dev:${path}`], { allowFailure: true });
|
||||
}
|
||||
|
||||
function collect({ tag, candidate, repo }) {
|
||||
shipReviewDocPath(tag);
|
||||
if (!SHA_RE.test(String(candidate))) throw new Error(`invalid candidate SHA: ${candidate}`);
|
||||
if (!repo) throw new Error('--repo (или GITHUB_REPOSITORY) обязателен');
|
||||
const { base, commits } = readCandidateHistory(candidate);
|
||||
const ship = shipIssuesInRange({ commits, issueData: (number) => ghIssue(repo, number) });
|
||||
return { base, ship };
|
||||
}
|
||||
|
||||
if (isMainModule(import.meta.url)) {
|
||||
try {
|
||||
const [command, ...rest] = process.argv.slice(2);
|
||||
const value = (name) => rest.find((a) => a.startsWith(`--${name}=`))?.slice(name.length + 3) ?? '';
|
||||
const tag = value('tag');
|
||||
const repo = value('repo') || process.env.GITHUB_REPOSITORY || '';
|
||||
if (command === 'doc') {
|
||||
console.log(shipReviewDocPath(tag));
|
||||
} else if (command === 'prepare') {
|
||||
const candidate = value('candidate');
|
||||
const out = resolve(value('out') || '.');
|
||||
const { base, ship } = collect({ tag, candidate, repo });
|
||||
mkdirSync(out, { recursive: true });
|
||||
writeFileSync(join(out, 'ship-issues.json'), `${JSON.stringify({ schema: 1, tag, candidate, base, issues: ship }, null, 2)}\n`);
|
||||
writeFileSync(join(out, 'brief.md'), renderShipBrief({ tag, candidate, base, ship, runUrl: value('run-url') }));
|
||||
console.log(`doc=${shipReviewDocPath(tag)}`);
|
||||
console.log(`base=${base ? base.tag : ''}`);
|
||||
console.log(`issues=${ship.map((issue) => issue.number).join(',')}`);
|
||||
} else if (command === 'check') {
|
||||
const candidate = value('candidate');
|
||||
const { ship } = collect({ tag, candidate, repo });
|
||||
const problems = shipReviewProblems({ tag, ship, docText: readShipDoc(tag, candidate) });
|
||||
if (!ship.length) console.log('ship-задач в диапазоне нет — пакетное ревью не требуется');
|
||||
else if (!problems.length) console.log(`пакетное ревью ${shipReviewDocPath(tag)} покрывает ship-задачи ${ship.map((i) => `#${i.number}`).join(', ')}`);
|
||||
for (const problem of problems) console.error(`::error::${problem}`);
|
||||
process.exit(problems.length ? 1 : 0);
|
||||
} else {
|
||||
throw new Error('usage: ship-review.mjs doc --tag=<tag> | prepare --tag=<tag> --candidate=<sha> --out=<dir> [--repo=o/r] | check --tag=<tag> --candidate=<sha> [--repo=o/r]');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`::error::${error.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
+13
-5
@@ -20,6 +20,7 @@ import { spawnSync } from 'node:child_process';
|
||||
import { isMainModule } from './spawn-portable.mjs';
|
||||
import { anchorTreeFrom, anchorVerdictFrom, verdictDeclaration } from './review-doc-guard.mjs';
|
||||
import { classify } from './process-gate.mjs';
|
||||
import { hasTrackLabel, trackFromLabels } from './process-track.mjs';
|
||||
|
||||
export const STATUS_LABELS = ['S1-new', 'S2-analysis', 'S3-spec', 'S4-spec-review', 'S5-ready', 'S6-in-progress', 'S7-code-review', 'S8-merged'];
|
||||
|
||||
@@ -40,8 +41,8 @@ export function rightsFor(status, labels = [], { infrastructure = false, infrast
|
||||
lines.push('метка infra — только подсказка, не доказательство и не право: до ветки проверь предполагаемые пути; без class A начинай сразу, при любом class A нужен продуктовый S-flow (#562)');
|
||||
}
|
||||
switch (status) {
|
||||
case 'S1-new': lines.push('следующий шаг: аналитика (S2) — оценки метками, критерий лёгкого трека, затем ТЗ'); break;
|
||||
case 'S2-analysis': lines.push('следующий шаг: ТЗ (S3); трек по умолчанию small — отказ от него обосновать названным критерием §5'); break;
|
||||
case 'S1-new': lines.push('следующий шаг: аналитика (S2) — оценки и трек метками (по умолчанию track:show); на track:ship — строка «что меняется и чем проверить» под «## ТЗ», затем S5'); break;
|
||||
case 'S2-analysis': lines.push('следующий шаг: track:show — до трёх AC под «## ТЗ», затем S5; track:ask — ТЗ (S3) с названным критерием §5'); break;
|
||||
case 'S3-spec': lines.push('следующий шаг: ТЗ готово → push ветки → метка S4-spec-review (метку после push)'); break;
|
||||
case 'S4-spec-review': lines.push('идёт ревью ТЗ: ждать вердикт (scripts/wait-verdict.mjs), не править материал'); break;
|
||||
case 'S5-ready': lines.push('следующий шаг: ветка issue/NN-slug от dev, код по ТЗ, метка S6-in-progress'); break;
|
||||
@@ -126,6 +127,9 @@ export function branchIsInfrastructure(changedFiles = []) {
|
||||
|
||||
const PRE_CODE_STATUSES = ['S1-new', 'S2-analysis', 'S3-spec', 'S4-spec-review', 'S5-ready'];
|
||||
|
||||
// Трек по меткам — одна функция на конвейер и пакет (#696): process-track.mjs.
|
||||
export { hasTrackLabel, trackFromLabels };
|
||||
|
||||
/**
|
||||
* Признаки продуктового S-flow (#632). Инфраструктурная задача входит в поток
|
||||
* сразу на S7 и никогда не несёт S1–S5, ТЗ и ревью ТЗ; поэтому любой из этих
|
||||
@@ -162,10 +166,14 @@ export function buildPacket(inputs) {
|
||||
// продуктового потока.
|
||||
const infrastructure = branch?.infrastructure === true && productFlow.length === 0;
|
||||
const infrastructureHint = branch == null && status == null && labels.includes('infra') && productFlow.length === 0;
|
||||
// §5.1 (r1 #695): инфраструктурная задача без трековой метки идёт как
|
||||
// `show`; явная метка владельца главнее. Маршрут при этом остаётся
|
||||
// инфраструктурным — вход сразу на S7, без S1–S5.
|
||||
const infraTrack = hasTrackLabel(labels) ? trackFromLabels(labels) : 'show';
|
||||
const track = infrastructure
|
||||
? 'инфраструктурный'
|
||||
: infrastructureHint ? 'инфраструктурный (предварительно; подтвердить путями/diff)'
|
||||
: labels.includes('trivial') ? 'trivial' : labels.includes('small') ? 'small' : 'полный';
|
||||
? `инфраструктурный · ${infraTrack}`
|
||||
: infrastructureHint ? `инфраструктурный · ${infraTrack} (предварительно; подтвердить путями/diff)`
|
||||
: trackFromLabels(labels);
|
||||
const stage = status === 'S4-spec-review' || status === 'S3-spec' || status === 'S5-ready' ? 'spec' : 'code';
|
||||
const verdict = lastVerdict(comments, reviewDocs, stage);
|
||||
// ТЗ живёт в теле issue (#517); архивный файл — источник только у задач до
|
||||
|
||||
+41
-12
@@ -7,7 +7,7 @@
|
||||
* доказательство для ревью — dispatch-прогон на точном SHA материала. Push-
|
||||
* прогон на том же SHA зелёный не считается: в нём мутантов нет.
|
||||
*
|
||||
* node scripts/validate-gate.mjs --repo=<owner/repo> --ref=<ветка> --sha=<sha> [--workflow=validate.yml] [--no-wait]
|
||||
* node scripts/validate-gate.mjs --repo=<owner/repo> --ref=<ветка> --sha=<sha> [--workflow=validate.yml] [--no-wait] [--mutants=false]
|
||||
*
|
||||
* Печатает `result=green|failed|missing|pending`, `url=…`, `run_id=…` (и в
|
||||
* $GITHUB_OUTPUT, если он задан); код выхода 0 только при green, 2 — pending.
|
||||
@@ -45,6 +45,19 @@ export function isMutantRun(run) {
|
||||
return run?.event === 'workflow_dispatch';
|
||||
}
|
||||
|
||||
/**
|
||||
* #696: какие прогоны годятся в доказательство. С мутантами — только dispatch
|
||||
* (push их не запрашивает). Без мутантов (`show`/`ship`) годится и
|
||||
* завершённый push-прогон на материале: лёгкий Validate на этом SHA уже
|
||||
* исполнен, и dispatch повторил бы его. Ждать же можно только dispatch:
|
||||
* продолжение раунда будит его завершение (process-resume.yml слушает
|
||||
* `workflow_dispatch`), и push, который ещё идёт, раунд не разбудил бы.
|
||||
*/
|
||||
export function proofCandidate(run, { mutants = true } = {}) {
|
||||
if (mutants) return isMutantRun(run);
|
||||
return run?.event === 'workflow_dispatch' || (run?.event === 'push' && run?.status === 'completed');
|
||||
}
|
||||
|
||||
// #622: префикс — из контракта ci-proof (JOB_RULES.mutants), который сверяется
|
||||
// с validate.yml; своей строки здесь больше нет.
|
||||
export { MUTANT_JOB_PREFIX };
|
||||
@@ -72,7 +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, full = false,
|
||||
}) {
|
||||
// #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
|
||||
@@ -80,14 +100,19 @@ export async function validateGate({
|
||||
let dispatchedAt = null;
|
||||
let attempts = 0;
|
||||
while (ops.now() - started < totalMs) {
|
||||
const runs = (await ops.listRuns(sha)).filter((x) => isMutantRun(x) && !ignored.has(x.databaseId));
|
||||
const run = runs.find((x) => tracked && x.databaseId === tracked) || runs[0];
|
||||
const runs = (await ops.listRuns(sha)).filter((x) => proofCandidate(x, { mutants }) && !ignored.has(x.databaseId));
|
||||
// Без мутантов завершённый прогон на материале предпочтительнее идущего
|
||||
// dispatch: доказательство уже есть, ждать нечего (#696). С мутантами
|
||||
// порядок прежний — свежий прогон решает.
|
||||
const run = runs.find((x) => tracked && x.databaseId === tracked)
|
||||
|| (!mutants && runs.find((x) => x.status === 'completed'))
|
||||
|| runs[0];
|
||||
if (run) {
|
||||
tracked = run.databaseId;
|
||||
if (run.status === 'completed') {
|
||||
const context = await ops.proof(run);
|
||||
const verdict = evaluateCiProof({
|
||||
run, ...context, candidate: { sha, tree: candidateTree }, policy: CI_PROOF_POLICIES.review,
|
||||
run, ...context, candidate: { sha, tree: candidateTree }, policy,
|
||||
});
|
||||
if (verdict.status === 'green') return { result: 'green', url: verdict.url, note: verdict.note };
|
||||
if (verdict.status === 'failed') return { result: 'failed', url: verdict.url, note: verdict.note };
|
||||
@@ -100,11 +125,11 @@ export async function validateGate({
|
||||
// #636: прогон найден и идёт — ждать его будет событие, не раннер.
|
||||
return {
|
||||
result: 'pending', url: run.url || null, runId: run.databaseId,
|
||||
note: `Validate с мутантами идёт (${run.status}); продолжение — по завершении прогона`,
|
||||
note: `${label} идёт (${run.status}); продолжение — по завершении прогона`,
|
||||
};
|
||||
}
|
||||
} else if (dispatchedAt === null) {
|
||||
await ops.dispatch(ref);
|
||||
await ops.dispatch(ref, { mutants, full });
|
||||
dispatchedAt = ops.now();
|
||||
attempts = 1;
|
||||
} else if (ops.now() - dispatchedAt > appearMs) {
|
||||
@@ -116,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);
|
||||
await ops.dispatch(ref, { mutants, full });
|
||||
dispatchedAt = ops.now();
|
||||
attempts += 1;
|
||||
await ops.sleep(pollMs);
|
||||
@@ -131,7 +156,7 @@ export async function validateGate({
|
||||
}
|
||||
await ops.sleep(pollMs);
|
||||
}
|
||||
return { result: 'failed', url: null, note: 'Validate с мутантами не завершился за 45 минут' };
|
||||
return { result: 'failed', url: null, note: `${label} не завершился за 45 минут` };
|
||||
}
|
||||
|
||||
const sh = (cmd, args) => spawnSync(cmd, args, { encoding: 'utf8' });
|
||||
@@ -151,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) => {
|
||||
const r = sh('gh', ['workflow', 'run', workflow, '--repo', repo, '--ref', ref, '-f', 'full=false', '-f', 'mutants=true']);
|
||||
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)),
|
||||
@@ -167,11 +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]');
|
||||
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');
|
||||
const outcome = await validateGate({ ref, sha, wait, ops: realOps({ repo, workflow: arg('workflow') || 'validate.yml' }) });
|
||||
// #696: `--mutants=false` — лёгкое доказательство треков show/ship.
|
||||
const mutants = arg('mutants') !== 'false';
|
||||
// #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`);
|
||||
|
||||
@@ -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']), [], 'эталоны без провенанса — отказ');
|
||||
});
|
||||
@@ -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, 'оба вызова знают ветку');
|
||||
});
|
||||
|
||||
@@ -574,3 +574,71 @@ test('#657 r1 H1 на настоящем git: fast-forward несёт свежи
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
// #696: треки show/ship сливаются по лёгкому Validate — dispatch без мутантов.
|
||||
test('#696: realOps with mutants=false dispatches a light Validate and accepts a light proof', async () => {
|
||||
const sha = 'c'.repeat(40);
|
||||
const tree = 'd'.repeat(40);
|
||||
const dispatches = [];
|
||||
const light = { databaseId: 5, attempt: 1, status: 'completed', conclusion: 'success', url: 'https://run/5', event: 'workflow_dispatch', headSha: sha };
|
||||
const exec = (cmd, args) => {
|
||||
if (cmd === 'gh' && args[0] === 'workflow' && args[1] === 'run') { dispatches.push(args.join(' ')); return { status: 0, stdout: '', stderr: '' }; }
|
||||
if (cmd === 'gh' && args[0] === 'run' && args[1] === 'list') return { status: 0, stdout: JSON.stringify([light]), stderr: '' };
|
||||
throw new Error(`unexpected ${cmd} ${args.join(' ')}`);
|
||||
};
|
||||
let clock = 0;
|
||||
const lightProof = (row) => {
|
||||
const context = mergeProofContext(row, sha, tree);
|
||||
const proof = buildCiProof({
|
||||
candidateSha: sha, candidateTree: tree, runId: row.databaseId, attempt: 1, event: row.event,
|
||||
needs: {
|
||||
preflight: { result: 'success' },
|
||||
changes: { result: 'success', outputs: { heavy: 'false', mutants_requested: 'false', frontend: 'true', backend: 'false', integration: 'false' } },
|
||||
reuse: { result: 'success', outputs: {} }, frontend: { result: 'success' }, changed_mutants: { result: 'skipped' },
|
||||
},
|
||||
});
|
||||
return { ...context, proof, jobs: context.jobs.filter((job) => !job.name.startsWith('Мутанты')) };
|
||||
};
|
||||
const opsLight = realOps({
|
||||
repo: 'x/y', token: 'none', exec, mutants: false,
|
||||
sleep: async (ms) => { clock += ms; }, now: () => clock,
|
||||
candidateTree: async () => tree, proofContext: async (row) => lightProof(row),
|
||||
});
|
||||
opsLight.dispatchValidate('issue/9-x');
|
||||
assert.match(dispatches.at(-1), /mutants=false/);
|
||||
assert.equal((await opsLight.waitValidate(sha, { event: 'workflow_dispatch' })).result, 'green');
|
||||
const opsFull = realOps({
|
||||
repo: 'x/y', token: 'none', exec,
|
||||
sleep: async (ms) => { clock += ms; }, now: () => clock,
|
||||
candidateTree: async () => tree, proofContext: async (row) => lightProof(row),
|
||||
});
|
||||
opsFull.dispatchValidate('issue/9-x');
|
||||
assert.match(dispatches.at(-1), /mutants=true/);
|
||||
assert.notEqual((await opsFull.waitValidate(sha, { event: 'workflow_dispatch' })).result, 'green',
|
||||
'track ask still refuses a proof without mutants');
|
||||
});
|
||||
|
||||
test('#696: трек show/ship — слияние ждёт push-прогон кандидата, без второго dispatch', async () => {
|
||||
const ops = fakeOps({ base: 'dev0', devTips: ['dev1'], branchTip: 'mat', material: 'mat' });
|
||||
const r = await mergeCandidate({ branch: 'issue/1-x', material: 'mat', issue: 1, ops, mutants: false });
|
||||
assert.equal(r.action, 'push');
|
||||
assert.ok(!ops.calls.some((c) => c[0] === 'dispatch'), 'лёгкий Validate уже запущен push кандидата');
|
||||
assert.deepEqual(ops.calls.filter((c) => c[0] === 'validate'), [['validate', 'cand-mat-on-dev1', 'push']]);
|
||||
});
|
||||
|
||||
test('#696: push-прогона на кандидате нет — лёгкий dispatch и ожидание его', async () => {
|
||||
const ops = fakeOps({ base: 'dev0', devTips: ['dev1'], branchTip: 'mat', material: 'mat', validate: ['missing', 'green'] });
|
||||
const r = await mergeCandidate({ branch: 'issue/1-x', material: 'mat', issue: 1, ops, mutants: false });
|
||||
assert.equal(r.action, 'push');
|
||||
assert.deepEqual(ops.calls.filter((c) => c[0] === 'dispatch' || c[0] === 'validate'), [
|
||||
['validate', 'cand-mat-on-dev1', 'push'], ['dispatch', 'issue/1-x'], ['validate', 'cand-mat-on-dev1', 'workflow_dispatch'],
|
||||
]);
|
||||
});
|
||||
|
||||
test('#696: трек ask по-прежнему диспатчит Validate с мутантами и push-прогон не ждёт', async () => {
|
||||
const ops = fakeOps({ base: 'dev0', devTips: ['dev1'], branchTip: 'mat', material: 'mat' });
|
||||
await mergeCandidate({ branch: 'issue/1-x', material: 'mat', issue: 1, ops });
|
||||
assert.deepEqual(ops.calls.filter((c) => c[0] === 'dispatch' || c[0] === 'validate'), [
|
||||
['dispatch', 'issue/1-x'], ['validate', 'cand-mat-on-dev1', 'workflow_dispatch'],
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -46,8 +46,8 @@ const KEY_RULES = {
|
||||
['1-основное-правило', 'D сильнее A'],
|
||||
['3-правила', 'Ровно одна метка статуса'],
|
||||
['3-правила', 'Статус меняется до действия, а не после'],
|
||||
['5-лёгкий-трек-метка-small--путь-по-умолчанию', 'обосновывается не выбор лёгкого трека, а отказ от него'],
|
||||
['51-короткий-трек-метка-trivial', 'ожидаемое поведение уже зафиксировано'],
|
||||
['5-треки-ship-show-ask--метка-владельца', 'Метка владельца главнее критериев'],
|
||||
['5-треки-ship-show-ask--метка-владельца', 'ожидаемое поведение уже зафиксировано'],
|
||||
['71-цепочка', 'Владельцу задаются только продуктовые вопросы'],
|
||||
['71-цепочка', 'issue остаётся в `S3-spec` и получает `blocked`'],
|
||||
['26-в-разработке--реализация', 'каждый коммит несёт трейлеры `Issue: #<NN>` и `User-Visible: yes|no`'],
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { mkdtempSync, writeFileSync, mkdirSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import {
|
||||
SHIP_SRC_LINE_LIMIT, hasTrackLabel, parseNameStatus, parseNumstat, resolveTrack, shipLimitViolations, trackFromLabels,
|
||||
} from '../scripts/process-track.mjs';
|
||||
import { trackFromLabels as packetTrack } from '../scripts/task-packet.mjs';
|
||||
|
||||
// #696: конвейер ревью решает цену захода по треку; трек и рамки ship —
|
||||
// механические, потому что по ним задача сливается без ревью модели.
|
||||
|
||||
const SCRIPT = join(dirname(fileURLToPath(import.meta.url)), '..', 'scripts', 'process-track.mjs');
|
||||
|
||||
test('пакет задачи и конвейер читают трек одной функцией (#696)', () => {
|
||||
assert.equal(packetTrack, trackFromLabels);
|
||||
});
|
||||
|
||||
test('явная трековая метка главнее признака инфраструктуры (#696)', () => {
|
||||
const infra = ['scripts/x.mjs', '.github/workflows/y.yml'];
|
||||
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');
|
||||
});
|
||||
|
||||
test('без трековой метки инфраструктура — show, продуктовая задача — ask (#696)', () => {
|
||||
assert.equal(resolveTrack({ labels: ['infra'], files: ['scripts/x.mjs'] }).track, 'show');
|
||||
assert.equal(resolveTrack({ labels: ['bug'], files: ['scripts/x.mjs', 'src/a.ts'] }).track, 'ask');
|
||||
assert.equal(resolveTrack({ labels: [], files: [] }).track, 'ask', 'пустой дифф не доказывает инфраструктуру');
|
||||
assert.equal(hasTrackLabel(['bug', 'P2']), false);
|
||||
assert.equal(hasTrackLabel(['trivial']), true);
|
||||
});
|
||||
|
||||
test('мутанты по диффу — только ask и метка ci:mutants (#696)', () => {
|
||||
assert.equal(resolveTrack({ labels: ['track:show'], files: ['src/a.ts'] }).mutants, false);
|
||||
assert.equal(resolveTrack({ labels: ['track:show', 'ci:mutants'], files: ['src/a.ts'] }).mutants, true);
|
||||
assert.equal(resolveTrack({ labels: ['track:ship', 'ci:mutants'], files: ['src/a.ts'] }).mutants, true);
|
||||
assert.equal(resolveTrack({ labels: [], files: ['src/a.ts'] }).mutants, true);
|
||||
});
|
||||
|
||||
test('рамки ship: строки src/** считаются вместе, граница включительна (#696)', () => {
|
||||
const at = (added, deleted, path = 'src/a.ts') => ({ added, deleted, path });
|
||||
assert.deepEqual(shipLimitViolations({ numstat: [at(20, 10)] }), []);
|
||||
assert.equal(SHIP_SRC_LINE_LIMIT, 30);
|
||||
const over = shipLimitViolations({ numstat: [at(20, 5), at(4, 2, 'src/b.ts')] });
|
||||
assert.equal(over.length, 1);
|
||||
assert.match(over[0], /31 строк/);
|
||||
assert.deepEqual(shipLimitViolations({ numstat: [at(500, 0, 'scripts/x.mjs'), at(200, 0, 'test/x.test.mjs')] }), [],
|
||||
'тесты и скрипты рамку src не расходуют');
|
||||
});
|
||||
|
||||
test('рамки ship: новые файлы, двоичные, i18n, конфиг и Python (#696)', () => {
|
||||
const v = shipLimitViolations({
|
||||
numstat: [
|
||||
{ added: null, deleted: null, path: 'src/icon.png' },
|
||||
{ added: 1, deleted: 0, path: 'src/i18n/ru.json' },
|
||||
{ added: 1, deleted: 0, path: 'src/types.ts' },
|
||||
{ added: 1, deleted: 0, path: 'src/config-editor.ts' },
|
||||
{ added: 1, deleted: 0, path: 'custom_components/houseplan/__init__.py' },
|
||||
{ added: 1, deleted: 0, path: 'custom_components/houseplan/translations/en.json' },
|
||||
],
|
||||
nameStatus: [{ status: 'A', path: 'src/new-thing.ts' }, { status: 'M', path: 'src/types.ts' }],
|
||||
});
|
||||
assert.equal(v.length, 5, v.join('\n'));
|
||||
assert.ok(v.some((s) => s.startsWith('двоичные') && s.includes('src/icon.png')));
|
||||
assert.ok(v.some((s) => s.startsWith('новые файлы') && s.includes('src/new-thing.ts')));
|
||||
assert.ok(v.some((s) => s.startsWith('ключи i18n') && s.includes('src/i18n/ru.json') && s.includes('translations/en.json')));
|
||||
assert.ok(v.some((s) => s.startsWith('поля конфига') && s.includes('src/types.ts') && s.includes('src/config-editor.ts')));
|
||||
assert.ok(v.some((s) => s.startsWith('Python')));
|
||||
assert.deepEqual(shipLimitViolations({ nameStatus: [{ status: 'A', path: 'test/new.test.mjs' }] }), [],
|
||||
'новый тест вне src рамки не нарушает');
|
||||
});
|
||||
|
||||
test('разбор numstat и name-status: двоичный файл и переименование (#696)', () => {
|
||||
assert.deepEqual(parseNumstat('3\t1\tsrc/a.ts\n-\t-\tsrc/i.png\n'), [
|
||||
{ added: 3, deleted: 1, path: 'src/a.ts' }, { added: null, deleted: null, path: 'src/i.png' },
|
||||
]);
|
||||
assert.deepEqual(parseNameStatus('M\tsrc/a.ts\nR100\tsrc/old.ts\tsrc/new.ts\n'), [
|
||||
{ status: 'M', path: 'src/a.ts' }, { status: 'R100', path: 'src/new.ts' },
|
||||
]);
|
||||
});
|
||||
|
||||
test('CLI ship-limits и resolve читают реальный дифф (#696)', (t) => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'hp-track-'));
|
||||
t.after(() => rmSync(dir, { recursive: true, force: true }));
|
||||
const git = (...args) => {
|
||||
const r = spawnSync('git', args, { cwd: dir, encoding: 'utf8' });
|
||||
assert.equal(r.status, 0, r.stderr);
|
||||
return r.stdout.trim();
|
||||
};
|
||||
git('init', '-q', '-b', 'main');
|
||||
git('config', 'user.email', 't@t'); git('config', 'user.name', 't');
|
||||
git('config', 'core.hooksPath', '/dev/null');
|
||||
mkdirSync(join(dir, 'src'));
|
||||
writeFileSync(join(dir, 'src', 'a.ts'), 'a\n');
|
||||
git('add', '.'); git('commit', '-q', '-m', 'base');
|
||||
const base = git('rev-parse', 'HEAD');
|
||||
writeFileSync(join(dir, 'src', 'a.ts'), 'a\nb\n');
|
||||
git('commit', '-q', '-am', 'small');
|
||||
const run = (...args) => spawnSync(process.execPath, [SCRIPT, ...args], { cwd: dir, encoding: 'utf8', env: { ...process.env, GITHUB_OUTPUT: '' } });
|
||||
let r = run('ship-limits', `--base=${base}`);
|
||||
assert.equal(r.status, 0, r.stderr);
|
||||
assert.match(r.stdout, /^ship=true$/m);
|
||||
r = run('resolve', '--labels=track:show', `--base=${base}`);
|
||||
assert.match(r.stdout, /^track=show$/m);
|
||||
assert.match(r.stdout, /^mutants=false$/m);
|
||||
writeFileSync(join(dir, 'src', 'b.ts'), 'x\n');
|
||||
git('add', '.'); git('commit', '-q', '-m', 'new file');
|
||||
r = run('ship-limits', `--base=${base}`);
|
||||
assert.match(r.stdout, /^ship=false$/m);
|
||||
assert.match(r.stdout, /новые файлы в src\/\*\*: src\/b\.ts/);
|
||||
r = run('ship-limits');
|
||||
assert.notEqual(r.status, 0, 'без базы рамки не проверяются');
|
||||
});
|
||||
|
||||
// ---------- конвейер читает трек (#696) ----------
|
||||
|
||||
const WORKFLOW = join(dirname(fileURLToPath(import.meta.url)), '..', '.github', 'workflows', '_process.yml');
|
||||
|
||||
test('конвейер: трек снимается до ребейза, мутанты и ship идут из него (#696)', async () => {
|
||||
const { readFileSync } = await import('node:fs');
|
||||
const workflow = readFileSync(WORKFLOW, 'utf8');
|
||||
const at = (marker) => { const i = workflow.indexOf(marker); assert.ok(i > 0, `нет «${marker}»`); return i; };
|
||||
const branch = at(' - name: Перейти на ветку задачи\n');
|
||||
const track = at(' - name: "Трек задачи и рамки ship (#696)"\n');
|
||||
const rebase = at(' - name: Привести ветку к dev\n');
|
||||
const gate = at(' - name: Validate на материале\n');
|
||||
assert.ok(branch < track && track < rebase && rebase < gate, 'трек — после выбора ветки и до ребейза');
|
||||
const trackStep = workflow.slice(track, rebase);
|
||||
assert.match(trackStep, /LABELS: \$\{\{ needs\.guard\.outputs\.labels \}\}/, 'метки — текущие, из guard');
|
||||
assert.match(trackStep, /git archive origin\/dev scripts \| tar -x -C "\$tools"/, 'скрипт — из dev: ветка show/ship не ребейзится');
|
||||
assert.match(trackStep, /node "\$tools\/scripts\/process-track\.mjs" resolve --labels="\$LABELS" --base=origin\/dev/);
|
||||
assert.match(trackStep, /node "\$tools\/scripts\/process-track\.mjs" ship-limits --base=origin\/dev --head=HEAD/);
|
||||
assert.match(trackStep, /grep -qx 'ship=true'; then\n\s+ship=true/, 'ship — только в рамках');
|
||||
assert.match(trackStep, /--add-label track:show --remove-label track:ship/, 'выход за рамки повышает трек');
|
||||
assert.match(workflow, /labels=\$\(printf '%s\\n' "\$current" \| paste -sd, -\)/, 'guard отдаёт текущие метки');
|
||||
const rebaseStep = workflow.slice(rebase, at(' - name: Зафиксировать SHA материала ревью\n'));
|
||||
assert.match(rebaseStep, /if \[ "\$TRACK" = "show" \] \|\| \[ "\$TRACK" = "ship" \]; then\n\s+if git merge-tree --write-tree origin\/dev HEAD/,
|
||||
'show/ship не ребейзятся при чистом слиянии');
|
||||
const gateStep = workflow.slice(gate, at(' - name: Validate идёт — раунд продолжит событие\n'));
|
||||
assert.match(gateStep, /--mutants="\$\{MUTANTS:-true\}"/, 'по умолчанию — с мутантами');
|
||||
assert.match(gateStep, /MUTANTS: \$\{\{ steps\.track\.outputs\.mutants \}\}/);
|
||||
});
|
||||
|
||||
test('конвейер: ship в рамках сливается без модели, с маркером для пакетного ревью (#696)', async () => {
|
||||
const { readFileSync } = await import('node:fs');
|
||||
const workflow = readFileSync(WORKFLOW, 'utf8');
|
||||
const modelJob = workflow.slice(workflow.indexOf('\n model_review:'), workflow.indexOf('\n integrate:'));
|
||||
assert.match(modelJob, /if: needs\.prepare\.outputs\.proceed == 'true' && needs\.prepare\.outputs\.reuse != 'true' && needs\.prepare\.outputs\.ship != 'true'/);
|
||||
const integrate = workflow.slice(workflow.indexOf('\n integrate:'));
|
||||
for (const step of ['Получить результат модели', 'Опубликовать документ ревью', '"Материал раунда воспроизводим (#413)"']) {
|
||||
const i = integrate.indexOf(`- name: ${step}`);
|
||||
assert.ok(i > 0, step);
|
||||
assert.match(integrate.slice(i, i + 400), /needs\.prepare\.outputs\.ship != 'true'/, `${step} пропускается на ship`);
|
||||
}
|
||||
const decide = integrate.slice(integrate.indexOf('- name: Решение по вердикту'), integrate.indexOf('- name: dev ушёл вперёд'));
|
||||
assert.match(decide, /if \[ "\$SHIP" = "true" \] && \[ "\$REUSE" != "true" \]; then[\s\S]*verdict=green; high=0/);
|
||||
assert.match(decide, /<!-- hp:ship-merge material=\$MATERIAL -->/, 'маркер для ship-review.mjs');
|
||||
assert.doesNotMatch(decide.slice(decide.indexOf('SHIP" = "true'), decide.indexOf('elif [ "$REUSE"')), /Вердикт:/,
|
||||
'слияние без ревью не называет себя вердиктом ревью');
|
||||
const merge = integrate.slice(integrate.indexOf('- name: Слить ветку в dev'), integrate.indexOf('- name: Переставить метку'));
|
||||
assert.match(merge, /--mutants="\$\{MUTANTS:-true\}"/);
|
||||
const env = modelJob.slice(modelJob.indexOf('- name: Что ревьюеру нужно из окружения'), modelJob.indexOf('- name: Установить Claude Code'));
|
||||
assert.match(env, /if \[ "\$STAGE" = "spec" \]; then deps=false; browser=false; fi/, 'ревью ТЗ не ставит окружение');
|
||||
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'\)/);
|
||||
});
|
||||
@@ -612,7 +612,7 @@ test('#510 AC2: конвейер запускает Validate с мутантам
|
||||
const at = (marker) => { const i = workflow.indexOf(marker); assert.ok(i > 0, `нет «${marker}»`); return i; };
|
||||
const material = at(' - name: Зафиксировать SHA материала ревью\n');
|
||||
const reuse = at(' - name: "Зелёный вердикт прошлого захода применим без ревью (#499)"\n');
|
||||
const gate = at(' - name: Validate с мутантами на материале\n');
|
||||
const gate = at(' - name: Validate на материале\n');
|
||||
assert.ok(material < reuse && reuse < gate, 'gate читает steps.reuse.outputs — стоит после шага reuse (ревью ТЗ r1)');
|
||||
const back = at(' - name: Validate красный — вернуть автору без ревью\n');
|
||||
const modelJob = at('\n model_review:\n');
|
||||
@@ -857,7 +857,8 @@ test('#551: gates, модель и интеграция имеют незави
|
||||
}
|
||||
assert.match(integrate, /PREPARE_RESULT: \$\{\{ needs\.prepare\.result \}\}/);
|
||||
assert.match(integrate, /MODEL_RESULT: \$\{\{ needs\.model_review\.result \}\}/);
|
||||
assert.match(integrate, /if \[ "\$REUSE" != "true" \] && \[ "\$MODEL_RESULT" != "success" \]; then/,
|
||||
// #696: `ship` в рамках модель не вызывает — её исход не ждётся только там.
|
||||
assert.match(integrate, /if \[ "\$REUSE" != "true" \] && \[ "\$SHIP" != "true" \] && \[ "\$MODEL_RESULT" != "success" \]; then/,
|
||||
'интеграция не доверяет failed/cancelled/skipped модели');
|
||||
assert.match(integrate, /цикл ревью не израсходован/);
|
||||
assert.match(integrate, /Бюджеты стадий \(#551\)/, 'длительности публикуются раздельно');
|
||||
@@ -968,3 +969,15 @@ test('guard перечисляет docs/reviews деревом, а не contents
|
||||
// потолка нет.
|
||||
assert.match(guard, /contents\/docs\/reviews\/\$name\?ref=\$target/);
|
||||
});
|
||||
|
||||
test('r1 #695: guard даёт инфраструктуре без трековой метки лимит show (§5.1)', () => {
|
||||
const workflow = readFileSync(new URL('../.github/workflows/_process.yml', import.meta.url), 'utf8');
|
||||
const guard = workflow.slice(workflow.indexOf('\n guard:'), workflow.indexOf('\n prepare:'));
|
||||
assert.match(guard, /gh api "repos\/\$REPO\/compare\/dev\.\.\.\$branch" --jq '\.files\[\]\.filename'/,
|
||||
'признак инфраструктуры — пути диффа ветки против dev');
|
||||
assert.match(guard, /files\.every\(\(f\) => classify\(f\) !== "A"\)/, 'механический признак §1: ни одного файла класса A');
|
||||
assert.match(guard, /files\.length < 300/, 'обрезанный ответ compare инфраструктуру не доказывает');
|
||||
assert.match(guard, /; then\n\s+limit=2\n\s+echo "инфраструктурная задача без трековой метки/);
|
||||
assert.match(guard, /! has track:ship && ! has track:show && ! has track:ask \\\n\s+&& ! has small && ! has trivial; then/,
|
||||
'явная метка трека главнее признака инфраструктуры');
|
||||
});
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
// #696, PROCESS.md §11.7: задачи track:ship сливаются без ревью модели; их код
|
||||
// читает пакетное ревью диапазона перед бетой, и гейт беты требует документ.
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import {
|
||||
anchorBlock, isShipIssue, parseAnchorBlock, renderShipBrief, shipIssuesInRange, shipReviewDocPath,
|
||||
shipReviewProblems, specSection,
|
||||
} from '../scripts/ship-review.mjs';
|
||||
import { parseDocName, renderIndex } from '../scripts/reviews-index.mjs';
|
||||
import { archivePlan } from '../scripts/reviews-archive.mjs';
|
||||
|
||||
const sha = (c) => c.repeat(40);
|
||||
const MARKER = `<!-- hp:ship-merge material=${sha('a')} -->`;
|
||||
|
||||
test('#696 документ — по тегу беты или стабильному, имя фиксировано', () => {
|
||||
assert.equal(shipReviewDocPath('v1.79.0-beta.1'), 'docs/reviews/SHIP-REVIEW-v1.79.0-beta.1.md');
|
||||
assert.equal(shipReviewDocPath('v1.79.0'), 'docs/reviews/SHIP-REVIEW-v1.79.0.md');
|
||||
for (const bad of ['1.79.0', 'v1.79', 'v1.79.0-rc.1', '', 'v1.79.0/../x']) {
|
||||
assert.throws(() => shipReviewDocPath(bad), /not a release tag/, bad);
|
||||
}
|
||||
});
|
||||
|
||||
test('#696 ship-задача — по маркеру конвейера или метке track:ship', () => {
|
||||
assert.equal(isShipIssue({ labels: [], comments: [{ body: `Слияние без ревью модели\n\n${MARKER}` }] }), true);
|
||||
assert.equal(isShipIssue({ labels: [{ name: 'track:ship' }], comments: [] }), true);
|
||||
assert.equal(isShipIssue({ labels: ['track:show'], comments: [{ body: 'hp:ship-merge без маркера' }] }), false);
|
||||
assert.equal(isShipIssue({}), false);
|
||||
});
|
||||
|
||||
test('#696 состав — трейлеры диапазона; show и ask в пакет не входят', () => {
|
||||
const commits = [
|
||||
{ sha: sha('c'), message: 'fix: text\n\nIssue: #701\nUser-Visible: yes' },
|
||||
{ sha: sha('b'), message: 'feat: y\n\nIssue: #702\nUser-Visible: yes' },
|
||||
{ sha: sha('a'), message: 'fix: css\n\nIssue: #701\nUser-Visible: yes' },
|
||||
{ sha: sha('d'), message: 'chore: упоминание #703 — не трейлер' },
|
||||
];
|
||||
const data = {
|
||||
701: { title: 'Опечатка', body: 'Отчёт\n\n## ТЗ\n\nЗаменить «Сохранть» на «Сохранить»; видно в диалоге.\n\n## Прочее\nх', labels: [], comments: [{ body: MARKER }] },
|
||||
702: { title: 'Фича', body: '', labels: [{ name: 'track:show' }], comments: [] },
|
||||
};
|
||||
const ship = shipIssuesInRange({ commits, issueData: (n) => data[n] ?? null });
|
||||
assert.deepEqual(ship.map((i) => i.number), [701]);
|
||||
assert.deepEqual(ship[0].commits.map((c) => c.sha), [sha('a'), sha('c')], 'коммиты по порядку истории');
|
||||
assert.equal(ship[0].spec, '## ТЗ\n\nЗаменить «Сохранть» на «Сохранить»; видно в диалоге.');
|
||||
const brief = renderShipBrief({ tag: 'v1.79.0-beta.1', candidate: sha('e'), base: { tag: 'v1.78.0', sha: sha('f') }, ship });
|
||||
assert.match(brief, /### #701 · Опечатка/);
|
||||
assert.match(brief, /SHIP-REVIEW-v1\.79\.0-beta\.1\.md/);
|
||||
assert.match(brief, new RegExp(sha('a')));
|
||||
assert.equal(specSection('без раздела'), '');
|
||||
});
|
||||
|
||||
test('#696 гейт: без ship-задач документ не нужен, без документа — отказ с командой', () => {
|
||||
assert.deepEqual(shipReviewProblems({ tag: 'v1.79.0-beta.1', ship: [], docText: null }), []);
|
||||
const [problem] = shipReviewProblems({ tag: 'v1.79.0-beta.1', ship: [{ number: 701 }], docText: null });
|
||||
assert.match(problem, /#701/);
|
||||
assert.match(problem, /gh workflow run ship-review\.yml --ref dev -f tag=v1\.79\.0-beta\.1/);
|
||||
});
|
||||
|
||||
test('#696 гейт: машинный блок покрывает все задачи и не несёт High', () => {
|
||||
const tag = 'v1.79.0-beta.1';
|
||||
const doc = (fields) => `# Ревью\nИтог: …\n\n${anchorBlock({ tag, candidate: sha('e'), issues: [701, 704], ...fields })}`;
|
||||
assert.deepEqual(parseAnchorBlock(doc({ high: 0, medium: 1, low: 2 })), {
|
||||
tag, candidate: sha('e'), issues: [701, 704], high: 0, medium: 1, low: 2,
|
||||
});
|
||||
assert.deepEqual(shipReviewProblems({ tag, ship: [{ number: 701 }, { number: 704 }], docText: doc({ high: 0 }) }), []);
|
||||
const partial = shipReviewProblems({ tag, ship: [{ number: 701 }, { number: 709 }], docText: doc({ high: 0 }) });
|
||||
assert.equal(partial.length, 1);
|
||||
assert.match(partial[0], /не покрывает ship-задачи #709/);
|
||||
const high = shipReviewProblems({ tag, ship: [{ number: 701 }], docText: doc({ high: 2 }) });
|
||||
assert.equal(high.length, 1);
|
||||
assert.match(high[0], /High 2/);
|
||||
assert.match(shipReviewProblems({ tag, ship: [{ number: 701 }], docText: '# без блока' })[0], /без машинного блока/);
|
||||
assert.match(shipReviewProblems({ tag: 'v1.79.0-beta.2', ship: [{ number: 701 }], docText: doc({ high: 0 }) })[0], /для тега v1\.79\.0-beta\.1/);
|
||||
});
|
||||
|
||||
test('#696 индекс и архив знают SHIP-REVIEW: бета в индексе, архив — каталог стабильной линии', () => {
|
||||
assert.deepEqual(parseDocName('SHIP-REVIEW-v1.79.0-beta.1.md'), { stage: 'ship', issue: null, round: null, suffix: null, tag: 'v1.79.0-beta.1' });
|
||||
const md = renderIndex({ entries: [
|
||||
{ name: 'SHIP-REVIEW-v1.79.0-beta.1.md', stage: 'ship', issue: null, tag: 'v1.79.0-beta.1', verdict: '—', high: 0, medium: 1, findings: [], files: [] },
|
||||
{ name: 'SHIP-REVIEW-v1.79.0-beta.2.md', stage: 'ship', issue: null, tag: 'v1.79.0-beta.2', verdict: '—', high: 0, medium: 0, findings: [], files: [] },
|
||||
{ name: 'RELEASE-REVIEW-v1.79.0.md', stage: 'release', issue: null, tag: 'v1.79.0', verdict: '—', high: 1, medium: 0, findings: [], files: [] },
|
||||
] });
|
||||
const rows = md.split('\n').filter((line) => line.startsWith('| бета') || line.startsWith('| линия'));
|
||||
assert.deepEqual(rows.map((r) => r.split('|')[1].trim()), ['линия v1.79.0', 'бета v1.79.0-beta.2', 'бета v1.79.0-beta.1']);
|
||||
assert.match(rows[1], /пакетное ревью ship · —/);
|
||||
const plan = archivePlan({
|
||||
names: ['SHIP-REVIEW-v1.79.0-beta.1.md', 'SHIP-REVIEW-v1.80.0-beta.1.md'],
|
||||
lines: [{ tag: 'v1.79.0', issues: [] }], open: [], through: 'v1.79.0',
|
||||
});
|
||||
assert.deepEqual(plan.moves.map((m) => m.to), ['legacy/reviews/v1.79.0/SHIP-REVIEW-v1.79.0-beta.1.md']);
|
||||
assert.ok(plan.kept.some((k) => k.name === 'SHIP-REVIEW-v1.80.0-beta.1.md'));
|
||||
});
|
||||
|
||||
test('#696 ship-review.yml: модель без права записи, документ с машинным блоком в dev', () => {
|
||||
const workflow = readFileSync(fileURLToPath(new URL('../.github/workflows/ship-review.yml', import.meta.url)), 'utf8');
|
||||
const model = workflow.slice(workflow.indexOf('\n model_review:'), workflow.indexOf('\n publish:'));
|
||||
assert.match(model, /permissions:\n\s+contents: read\n\s+steps:/, 'модель только читает');
|
||||
assert.match(model, /github_token: \$\{\{ secrets\.GITHUB_TOKEN \}\}/, 'без обмена OIDC на App-токен (#556)');
|
||||
const publish = workflow.slice(workflow.indexOf('\n publish:'));
|
||||
assert.match(publish, /m\.anchorBlock\(/, 'машинный блок пишет публикация, не модель');
|
||||
assert.match(publish, /reviews-index\.mjs --dir=docs\/reviews --strict/);
|
||||
assert.match(workflow, /if: needs\.prepare\.outputs\.proceed == 'true'/);
|
||||
assert.match(workflow, /ship-задач в диапазоне нет — ревью не нужно/);
|
||||
});
|
||||
|
||||
test('#696 оба пути публикации беты проверяют пакетное ревью ship до выпуска', () => {
|
||||
const workflow = readFileSync(fileURLToPath(new URL('../.github/workflows/publish-prerelease.yml', import.meta.url)), 'utf8');
|
||||
const gate = workflow.slice(workflow.indexOf('\n gate:'), workflow.indexOf('\n publish:'));
|
||||
assert.match(gate, /node scripts\/ship-review\.mjs check --tag="\$TAG" --candidate="\$SHA"/, 'гейт CI-публикации');
|
||||
const local = readFileSync(fileURLToPath(new URL('../scripts/release-prerelease.mjs', import.meta.url)), 'utf8');
|
||||
const main = local.slice(local.indexOf('const main = async'));
|
||||
const check = main.indexOf("'scripts/ship-review.mjs', 'check'");
|
||||
assert.ok(check > 0, 'локальная публикация зовёт тот же гейт');
|
||||
assert.ok(check < main.indexOf('if (checkOnly) return;'), 'и в режиме --check тоже');
|
||||
});
|
||||
@@ -2,6 +2,7 @@ import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
branchIsInfrastructure, buildPacket, evidenceFor, productFlowEvidence, extractAcceptanceCriteria, lastVerdict, ownerDecisions, renderPacket, rightsFor,
|
||||
trackFromLabels, hasTrackLabel,
|
||||
} from '../scripts/task-packet.mjs';
|
||||
import { materialAnchorBlock } from '../scripts/review-doc-guard.mjs';
|
||||
|
||||
@@ -87,7 +88,7 @@ test('пакет собирается и рендерится: статус, м
|
||||
validate: { status: 'зелёный', url: 'https://run' },
|
||||
});
|
||||
assert.equal(packet.status, 'S6-in-progress');
|
||||
assert.equal(packet.track, 'small');
|
||||
assert.equal(packet.track, 'show');
|
||||
assert.equal(packet.material.treeMatchesVerdict, true);
|
||||
assert.deepEqual(packet.unverified, ['AC2']);
|
||||
const md = renderPacket(packet);
|
||||
@@ -106,7 +107,7 @@ test('#562: statusless infra issue is the accelerated track ending at S7 review'
|
||||
branch: { name: 'issue/562-process', tip: 'e'.repeat(40), base: 'f'.repeat(40), ahead: 1, behind: 0, treeWithoutReviews: null, infrastructure: true },
|
||||
});
|
||||
assert.equal(packet.status, null);
|
||||
assert.equal(packet.track, 'инфраструктурный');
|
||||
assert.equal(packet.track, 'инфраструктурный · show', 'r1 #695: инфраструктура без трековой метки — show (§5.1)');
|
||||
const md = renderPacket(packet);
|
||||
assert.match(md, /инфраструктурный вход/);
|
||||
assert.match(md, /S7-code-review/);
|
||||
@@ -118,7 +119,7 @@ test('#562: the infra label alone never grants the accelerated track', () => {
|
||||
labels: ['infra', 'S6-in-progress'],
|
||||
branch: { name: 'issue/999-product', tip: 'e'.repeat(40), base: 'f'.repeat(40), ahead: 1, behind: 0, treeWithoutReviews: null, infrastructure: false },
|
||||
});
|
||||
assert.equal(packet.track, 'полный');
|
||||
assert.equal(packet.track, 'ask');
|
||||
assert.ok(packet.rights.some((l) => l.includes('продуктовый код трогать МОЖНО')));
|
||||
});
|
||||
|
||||
@@ -152,7 +153,7 @@ test('#632: product S6 issue keeps class A rights while its diff has no class A
|
||||
branch: { name: 'issue/607-ha-dialog-close', tip: 'e'.repeat(40), base: 'f'.repeat(40), ahead: 1, behind: 0, treeWithoutReviews: null, infrastructure: true },
|
||||
reviewDocs: [{ name: 'SPEC-REVIEW-607-r1.md', text: 'Вердикт: зелёный' }],
|
||||
});
|
||||
assert.equal(packet.track, 'small');
|
||||
assert.equal(packet.track, 'show');
|
||||
assert.ok(packet.rights.some((l) => l.includes('продуктовый код трогать МОЖНО')));
|
||||
assert.ok(packet.rights.every((l) => !l.includes('файлы класса A трогать НЕЛЬЗЯ')));
|
||||
assert.match(renderPacket(packet), /Продуктовый поток: .*ТЗ/);
|
||||
@@ -176,7 +177,7 @@ test('#632: statusless or returned infra issue without spec keeps the class A ba
|
||||
reviewDocs: [{ name: 'CODE-REVIEW-632-r1.md', text: 'Вердикт: жёлтый' }],
|
||||
});
|
||||
assert.deepEqual(packet.productFlow, [], labels.join(','));
|
||||
assert.equal(packet.track, 'инфраструктурный', labels.join(','));
|
||||
assert.equal(packet.track, 'инфраструктурный · show', labels.join(','));
|
||||
assert.ok(packet.rights.some((l) => l.includes('файлы класса A трогать НЕЛЬЗЯ')), labels.join(','));
|
||||
assert.ok(packet.rights.every((l) => !l.includes('продуктовый код трогать МОЖНО')), labels.join(','));
|
||||
}
|
||||
@@ -192,7 +193,7 @@ test('#632 r1: trivial issue in S6/S7 keeps class A rights without any spec arte
|
||||
labels,
|
||||
branch: { name: 'issue/612-x', tip: 'e'.repeat(40), base: 'f'.repeat(40), ahead: 1, behind: 0, treeWithoutReviews: null, infrastructure: true },
|
||||
});
|
||||
assert.equal(packet.track, 'trivial', labels.join(','));
|
||||
assert.equal(packet.track, 'show', labels.join(','));
|
||||
assert.ok(packet.rights.some((l) => l.includes('продуктовый код трогать МОЖНО')), labels.join(','));
|
||||
assert.ok(packet.rights.every((l) => !l.includes('файлы класса A трогать НЕЛЬЗЯ')), labels.join(','));
|
||||
}
|
||||
@@ -217,3 +218,26 @@ test('#517 AC5: AC берутся из тела issue, файл ТЗ — тол
|
||||
const neither = buildPacket({ ...base, issue: { ...base.issue, body: 'ничего' }, specs: [] });
|
||||
assert.deepEqual(neither.acceptance, []);
|
||||
});
|
||||
|
||||
test('#695: трек по меткам — track:* главнее прежних, по умолчанию ask', () => {
|
||||
assert.equal(trackFromLabels(['track:ship']), 'ship');
|
||||
assert.equal(trackFromLabels(['track:show', 'bug']), 'show');
|
||||
assert.equal(trackFromLabels(['track:ask', 'small']), 'ask', 'явная метка владельца главнее прежней');
|
||||
assert.equal(trackFromLabels(['trivial']), 'show');
|
||||
assert.equal(trackFromLabels(['small']), 'show');
|
||||
assert.equal(trackFromLabels(['bug', 'P2']), 'ask');
|
||||
assert.equal(trackFromLabels([]), 'ask');
|
||||
});
|
||||
|
||||
test('r1 #695: инфраструктурная задача с явной меткой трека несёт её, без метки — show', () => {
|
||||
const at = (labels) => buildPacket({
|
||||
issue: { number: 7, title: 'infra', state: 'OPEN', url: 'u', body: '' },
|
||||
labels,
|
||||
branch: { name: 'issue/7-x', tip: 'e'.repeat(40), base: 'f'.repeat(40), ahead: 1, behind: 0, treeWithoutReviews: null, infrastructure: true },
|
||||
}).track;
|
||||
assert.equal(at(['infra']), 'инфраструктурный · show');
|
||||
assert.equal(at(['infra', 'track:ask']), 'инфраструктурный · ask', 'метка владельца главнее');
|
||||
assert.equal(at(['infra', 'track:ship']), 'инфраструктурный · ship');
|
||||
assert.equal(hasTrackLabel(['bug', 'infra']), false);
|
||||
assert.equal(hasTrackLabel(['small']), true);
|
||||
});
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { validateGate, isMutantRun, provesMutants } from '../scripts/validate-gate.mjs';
|
||||
import { validateGate, isMutantRun, proofCandidate, provesMutants } from '../scripts/validate-gate.mjs';
|
||||
import { buildCiProof } from '../scripts/ci-proof.mjs';
|
||||
import { jobInstanceNames, validateJobs } from '../scripts/workflow-jobs.mjs';
|
||||
|
||||
@@ -46,7 +46,7 @@ function fakeOps({ snapshots, onRef = [], jobsById = {} }) {
|
||||
});
|
||||
return { proof, jobs: [...BASE_JOBS, ...selected], reuseRuns: new Map() };
|
||||
},
|
||||
dispatch: async (ref) => { dispatched.push(ref); },
|
||||
dispatch: async (ref, { mutants = true, full = false } = {}) => { dispatched.push(`${mutants ? ref : `${ref}:light`}${full ? ':full' : ''}`); },
|
||||
sleep: async (ms) => { clock += ms; },
|
||||
now: () => clock,
|
||||
},
|
||||
@@ -211,3 +211,48 @@ test('#636: с ожиданием (умолчание) поведение пре
|
||||
assert.equal(outcome.result, 'green');
|
||||
assert.ok(fake.ops.now() > 0, 'один poll прошёл');
|
||||
});
|
||||
|
||||
// #696: треки show/ship доказываются лёгким Validate — без мутантов по диффу.
|
||||
test('#696: without mutants a completed green push run on the material is proof, no dispatch', async () => {
|
||||
const fake = fakeOps({ snapshots: [[run({ event: 'push', databaseId: 7, url: 'https://run/push' })]], jobsById: { 7: OTHER_JOBS } });
|
||||
const outcome = await validateGate({ ref: 'issue/1', sha: SHA, ops: fake.ops, mutants: false });
|
||||
assert.equal(outcome.result, 'green');
|
||||
assert.equal(outcome.url, 'https://run/push');
|
||||
assert.deepEqual(fake.dispatched, []);
|
||||
});
|
||||
|
||||
test('#696: without mutants a red push run returns the task, a push still running is not waited for', async () => {
|
||||
const red = fakeOps({ snapshots: [[run({ event: 'push', databaseId: 7, conclusion: 'failure', url: 'https://run/red' })]], jobsById: { 7: OTHER_JOBS } });
|
||||
assert.equal((await validateGate({ ref: 'issue/1', sha: SHA, ops: red.ops, mutants: false })).result, 'failed');
|
||||
// Идущий push раунд не разбудит (resume слушает dispatch) — гейт диспатчит
|
||||
// свой лёгкий прогон и ждёт уже его.
|
||||
const running = [run({ event: 'push', databaseId: 7, status: 'in_progress', conclusion: null })];
|
||||
const fake = fakeOps({ snapshots: [running, [...running, run({ databaseId: 8, status: 'in_progress', conclusion: null })]], jobsById: { 8: OTHER_JOBS } });
|
||||
const outcome = await validateGate({ ref: 'issue/1', sha: SHA, ops: fake.ops, mutants: false, wait: false, pollMs: 1000 });
|
||||
assert.equal(outcome.result, 'pending');
|
||||
assert.equal(outcome.runId, 8);
|
||||
assert.deepEqual(fake.dispatched, ['issue/1:light'], 'the light dispatch asks for no mutants');
|
||||
});
|
||||
|
||||
test('#696: a completed run beats a newer running dispatch only without mutants', async () => {
|
||||
const done = run({ event: 'push', databaseId: 7, url: 'https://run/push' });
|
||||
const running = run({ databaseId: 9, status: 'in_progress', conclusion: null });
|
||||
const light = fakeOps({ snapshots: [[running, done]], jobsById: { 7: OTHER_JOBS } });
|
||||
assert.equal((await validateGate({ ref: 'issue/1', sha: SHA, ops: light.ops, mutants: false, wait: false })).result, 'green');
|
||||
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, []);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user