process: the review pipeline prices each round by track (#696)

show/ship stop paying for diff mutants and for every move of dev:

- scripts/process-track.mjs resolves the track from the current labels and
  the diff (show for unlabelled infra, ask for unlabelled product work) and
  checks the mechanical ship limits; outside them the pipeline comments and
  relabels track:ship -> track:show in the same round.
- Validate on the review material is light on show/ship: a completed push
  run on the exact SHA is proof, a dispatch asks mutants=false. ask and the
  ci:mutants label keep the mutant dispatch.
- show/ship skip the pre-review rebase when git merge-tree with dev is
  clean; the candidate is rebased once at merge and still passes Validate
  before the push to dev. The light merge waits for the push run of the
  candidate and dispatches only when none appears.
- ship inside the limits merges after the light Validate without a model
  review; the issue gets a machine marker hp:ship-merge.
- ship-review.yml + scripts/ship-review.mjs read the code of all ship
  tasks of a beta range in one model session and publish
  docs/reviews/SHIP-REVIEW-<tag>.md; both beta publication paths refuse a
  range with ship tasks the document does not cover or that carries a High.
- show reviews judge correctness and AC; the spec review installs neither
  npm ci nor Chromium, the show review installs Chromium only when the issue
  names a smoke.

Canon: PROCESS.md §5, §5.1, §10.4, new §11.7; REVIEWER.md, AUTHOR.md and
AGENTS.md digests.

Issue: #696
User-Visible: no
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
This commit is contained in:
Claude
2026-09-28 23:09:46 +03:00
parent 9e4bfb4379
commit e1ae8f4ac7
22 changed files with 1585 additions and 94 deletions
+159 -23
View File
@@ -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 — счёт раундов вынесен туда, потому что
@@ -85,8 +87,8 @@ jobs:
has blocked && BLOCKED=true
has review-4 && EXHAUSTED=true
# Треки #695 (PROCESS §5): `track:show` и `track:ship` — лимит 2, как у
# прежних `small`/`trivial`; `track:ask` — 4. Пока #696 не влит, `ship`
# проходит конвейер как `show`.
# прежних `small`/`trivial`; `track:ask` — 4. Цену захода по треку
# (мутанты, ребейз, модель) решает prepare (#696).
has small && SMALL=true
has trivial && TRIVIAL=true
has track:show && SMALL=true
@@ -301,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"
@@ -336,6 +339,9 @@ 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 }}
ship: ${{ steps.track.outputs.ship }}
steps:
- name: Начать измерение стадии
id: clock
@@ -406,6 +412,58 @@ 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')
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 "ship=$ship"; } >> "$GITHUB_OUTPUT"
echo "- трек **$track** · мутанты по диффу: $mutants · слияние без модели: $ship" >> "$GITHUB_STEP_SUMMARY"
# Ревьюер обязан смотреть тот же код, который уедет в dev (#257). Раньше
# ревью шло по ветке как есть, а слияние делало ребейз — проверенный SHA и
# слитый SHA были разными коммитами. Пока расхождение с dev текстовое,
@@ -427,6 +485,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».
@@ -441,6 +500,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
@@ -627,13 +701,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:
@@ -642,6 +718,7 @@ jobs:
REUSE: ${{ steps.reuse.outputs.reuse }}
BRANCH: ${{ steps.branch.outputs.name }}
SHA: ${{ steps.material.outputs.sha }}
MUTANTS: ${{ steps.track.outputs.mutants }}
run: |
if [ "$STAGE" != "code" ] || [ "$REUSE" = "true" ] || [ -z "$BRANCH" ]; then
echo "гейт не применяется: этап $STAGE, reuse=${REUSE:-false}, ветка ${BRANCH:-dev}"
@@ -655,7 +732,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}"
code=$?
set -e
case "$code" in
@@ -680,6 +758,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"
@@ -693,7 +772,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'
@@ -714,10 +795,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 всё равно вернул бы задачу, но уже после потраченного ревью.
@@ -725,14 +809,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). Это не тщательность: бюджет
@@ -800,7 +884,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 }}
@@ -836,7 +920,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 }}
@@ -879,7 +963,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 }}
@@ -945,22 +1031,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). Если
@@ -1020,6 +1130,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 }}
@@ -1107,8 +1220,8 @@ jobs:
и в повторном раунде тоже: `npx tsc --noEmit`, `npm test`,
`npm run build` со сверкой трёх копий бандла, плюс
`node scripts/check-docs.mjs`, если diff трогает `src/**`.
Зависимости уже установлены workflow, Chromium тоже — `npm ci`
выполнять не нужно. По диффу и AC: браузерные смоки — названные в AC
${{ 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>`,
приложенный к комментарию с решением по каждой строке: прогнал либо
не прогнал и почему. Три вида ответа инструмента разные: «прямое
@@ -1248,6 +1361,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"
@@ -1263,7 +1377,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
@@ -1282,7 +1397,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 }}
@@ -1290,7 +1405,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, и принимается он как ввод противника: полный
# набор файлов, сходящиеся суммы, совпадение КАЖДОГО поля паспорта с
@@ -1326,7 +1441,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 }}
@@ -1499,7 +1614,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 }}
@@ -1524,10 +1639,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
@@ -1607,6 +1740,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::ветки задачи нет — сливать нечего"
@@ -1614,7 +1749,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'
@@ -1661,13 +1796,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
+8
View File
@@ -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 }}
+334
View File
@@ -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
+4 -1
View File
@@ -81,7 +81,10 @@ a spec review. Any agent may raise a track with a reason; only the owner lowers
`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). Every change is code-reviewed; on `ship` the review moves to a
batch review of the beta range before the tag. Review checks scope, risks and the
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
+71 -7
View File
@@ -559,9 +559,8 @@ dev, ни при публикации документа код-ревью: ин
**Рамки `track:ship`** механические: дифф `src/**` не больше 30 строк, без новых
файлов в `src/**`, без ключей i18n, без полей конфига и без Python. Выход за
рамки переводит задачу в `track:show`. Рамки и слияние `ship` без ревью модели
проверяет и исполняет конвейер (§10.4, #696); **пока #696 не влит**, `ship` и
`show` проходят конвейер как прежний лёгкий трек — с мутантами по диффу и
документом код-ревью.
проверяет и исполняет конвейер (§10.4, #696); код `ship` читает пакетное ревью
диапазона перед бетой (§11.7).
**Подсказка аналитику.** `track:show` уместен, когда выполнено всё сразу:
@@ -600,8 +599,8 @@ dev, ни при публикации документа код-ревью: ин
| `ci:golden` | golden на ветке и приёмку сдвинутых кадров в самой задаче |
| `ci:mutants` | мутанты по диффу на кандидате ревью и слияния |
Как конвейер читает эти метки, описывает §10.4; до #696 и #697 действуют
прежние правила §8.
Как конвейер читает `ci:mutants`, описывает §10.4 (#696); `ci:full` и
`ci:golden` до #697 действуют по прежним правилам §8.
**Прежние метки.** `trivial` и `small` читаются как `track:show`; продуктовая
задача без трековой метки — как `track:ask`; инфраструктурная задача (§1) без
@@ -1073,6 +1072,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`). **Ждёт его не раннер, а событие**
@@ -1146,7 +1177,8 @@ npm ci, Python и Chromium, оставаясь исполненной job: до
никто не может выйти и о котором никто не узнает, для конвейера хуже громкой
ошибки.
**Ветка приводится к `dev` до ревью, а не после** (#257). Раньше ревью читало ветку
**Ветка приводится к `dev` до ревью, а не после** (#257) — на треке `ask`. `show`
и `ship` с чистым слиянием ребейзятся один раз, при слиянии (#696, выше). Раньше ревью читало ветку
как есть, а слияние делало ребейз — проверенный SHA и слитый SHA были разными
коммитами. Пока расхождение с `dev` текстовое, ребейз упирается в конфликт и это
видно; смысловое расхождение git склеивает молча, и в `dev` уезжает комбинация,
@@ -1181,7 +1213,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.
@@ -1371,6 +1405,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. Запрещено
+9 -3
View File
@@ -171,11 +171,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` не ждать. После прогона ревью метка меняется
+26
View File
@@ -129,6 +129,32 @@
- Запрещено: 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` судит поверхности всей линии
+7
View File
@@ -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 }),
});
+33 -11
View File
@@ -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);
+103 -5
View File
@@ -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",
}],
},
{
@@ -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",
}],
},
{
@@ -13357,6 +13357,104 @@ const MUTANT_DEFINITIONS = [
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(',
}],
},
];
const mutationCardSource = readFileSync(join(repoRoot, 'src/houseplan-card.ts'), 'utf8');
+139
View File
@@ -0,0 +1,139 @@
#!/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` — то, что
* конвейер ревью читает, решая, сколько стоит заход: мутанты по диффу нужны
* только `ask` и метке `ci:mutants`. Инфраструктурная задача без трековой
* метки — `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`.
*/
export function resolveTrack({ labels = [], files = [] } = {}) {
const infrastructure = files.length > 0 && files.every((file) => classify(file) !== 'A');
const track = hasTrackLabel(labels) ? trackFromLabels(labels) : (infrastructure ? 'show' : 'ask');
const mutants = track === 'ask' || labels.includes('ci:mutants');
return { track, mutants, infrastructure };
}
const 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 } = resolveTrack({ labels, files });
emit([`track=${track}`, `mutants=${mutants}`]);
} 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);
}
}
+5
View File
@@ -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,
+5 -3
View File
@@ -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; }
+18 -7
View File
@@ -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));
+235
View File
@@ -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);
}
}
+3 -18
View File
@@ -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'];
@@ -126,24 +127,8 @@ export function branchIsInfrastructure(changedFiles = []) {
const PRE_CODE_STATUSES = ['S1-new', 'S2-analysis', 'S3-spec', 'S4-spec-review', 'S5-ready'];
/**
* Трек продуктовой задачи по меткам (PROCESS §5, #695): `track:ship`,
* `track:show` или `track:ask`; прежние `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';
}
/** Есть ли у задачи трековая метка — новая или прежняя (§5.1). */
export const hasTrackLabel = (labels = []) => ['track:ship', 'track:show', 'track:ask', 'trivial', 'small']
.some((label) => labels.includes(label));
// Трек по меткам — одна функция на конвейер и пакет (#696): process-track.mjs.
export { hasTrackLabel, trackFromLabels };
/**
* Признаки продуктового S-flow (#632). Инфраструктурная задача входит в поток
+35 -12
View File
@@ -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,10 @@ 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,
}) {
const policy = mutants ? CI_PROOF_POLICIES.review : CI_PROOF_POLICIES.reviewLight;
const label = mutants ? 'Validate с мутантами' : 'Validate';
const started = ops.now();
const candidateTree = await ops.candidateTree(sha);
const ignored = new Set(); // завершённые dispatch без применимого proof
@@ -80,14 +96,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 +121,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 });
dispatchedAt = ops.now();
attempts = 1;
} else if (ops.now() - dispatchedAt > appearMs) {
@@ -116,7 +137,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 });
dispatchedAt = ops.now();
attempts += 1;
await ops.sleep(pollMs);
@@ -131,7 +152,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 +172,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 } = {}) => {
const r = sh('gh', ['workflow', 'run', workflow, '--repo', repo, '--ref', ref, '-f', 'full=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 +188,13 @@ 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] [--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';
const outcome = await validateGate({ ref, sha, wait, mutants, 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`);
+68
View File
@@ -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'],
]);
});
+170
View File
@@ -0,0 +1,170 @@
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, infrastructure: true });
assert.deepEqual(resolveTrack({ labels: ['track:ship'], files: ['src/a.ts'] }), { track: 'ship', mutants: 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'/);
});
+3 -2
View File
@@ -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\)/, 'длительности публикуются раздельно');
+117
View File
@@ -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 тоже');
});
+33 -2
View File
@@ -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 } = {}) => { dispatched.push(mutants ? ref : `${ref}:light`); },
sleep: async (ms) => { clock += ms; },
now: () => clock,
},
@@ -211,3 +211,34 @@ 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);
});