Процесс: независимое ревью линии перед стабильным релизом, не блокирующее выпуск (#638)

PROCESS.md §11.5: перед стабильным релизом — одно ревью поверхностей всей
линии бет «с нуля», без ТЗ и документов раундов, по SCOPE и USER-GUIDE.

- scripts/release-review.mjs: вход линии — прошлый стабильный тег, issue по
  трейлерам в схеме RELEASE-MEMBERSHIP.json, продуктовые файлы; бриф промпта.
- .github/workflows/release-review.yml (workflow_dispatch, исполняется с dev):
  prepare → model_review (модель без прав на запись, github_token #556) →
  publish (docs/reviews/RELEASE-REVIEW-vX.Y.Z.md в dev токеном процесса,
  индекс тем же коммитом, review-doc-guard). Повтор на тот же тег не тратит
  модель, если документ уже в dev.
- release.yml: job independent-review ставит ревью в очередь сразу после
  candidate, continue-on-error; ни один job выпуска от него не зависит
  (решение владельца 2026-09-25).
- REVIEWER.md, AGENTS.md, DEVELOPMENT.md; тесты и четыре мутанта.

Issue: #638
User-Visible: no
This commit is contained in:
Claude
2026-09-25 12:53:14 +03:00
parent d35a52a3f1
commit 0ba81a994b
10 changed files with 743 additions and 2 deletions
+335
View File
@@ -0,0 +1,335 @@
name: "Релиз: независимое ревью линии"
run-name: "Release review ${{ inputs.tag }}"
# #638, PROCESS.md §11.5: перед стабильным релизом — одно независимое ревью
# поверхностей, изменённых всей линией бет, «с нуля»: без ТЗ и документов
# раундов, по SCOPE и USER-GUIDE. Инкрементальное ревью судит дифф задачи
# против её ТЗ; четыре дефекта линии 1.77 (#607, #608, #611, #619) не входили
# ни в один AC и нашлись только так.
#
# Решение владельца 2026-09-25: ревью НЕ блокирует выпуск. `release.yml`
# запускает этот workflow параллельно гейтам из job, от которого не зависит ни
# один job выпуска; документ — рекомендация, в работу его берёт владелец.
#
# Только `workflow_dispatch`: GitHub исполняет файл с той ветки, на которой
# запущен прогон (`release.yml` зовёт `--ref dev`), поэтому зеркало в `main`
# не нужно, а правка — один коммит в `dev`.
#
# Три job, как у конвейера (#551, #556): детерминированная подготовка,
# недоверенная модель без единого права на запись, детерминированная
# публикация документа в `dev`.
on:
workflow_dispatch:
inputs:
tag:
description: "Stable release tag, for example v1.78.0"
required: true
type: string
candidate:
description: "Exact candidate SHA; empty = the commit of the tag"
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: release-review-${{ inputs.tag }}
cancel-in-progress: false
jobs:
prepare:
name: "Ревью релиза: вход линии"
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: read
outputs:
proceed: ${{ steps.line.outputs.proceed }}
candidate: ${{ steps.line.outputs.candidate }}
base: ${{ steps.line.outputs.base }}
doc: ${{ steps.line.outputs.doc }}
issues: ${{ steps.line.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: Кандидат, база и issue линии
id: line
env:
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/release-review.mjs doc --tag="$TAG")
git fetch -q --tags origin
if [ -z "$CANDIDATE" ]; then
CANDIDATE=$(git rev-parse --verify -q "refs/tags/$TAG^{commit}") || {
echo "::error::тега $TAG нет, а кандидат не передан"; exit 1; }
fi
git cat-file -e "$CANDIDATE^{commit}"
CANDIDATE=$(git rev-parse "$CANDIDATE^{commit}")
# Повтор на тот же тег не тратит модель: документ уже есть (§11.5).
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/release-review-input"
node scripts/release-review.mjs prepare --tag="$TAG" --candidate="$CANDIDATE" \
--out="$out" --run-url="$RUN_URL" | tee -a "$GITHUB_OUTPUT"
echo "candidate=$CANDIDATE" >> "$GITHUB_OUTPUT"
echo "proceed=true" >> "$GITHUB_OUTPUT"
(cd "$out" && sha256sum brief.md line-membership.json > manifest.sha256)
cat "$out/brief.md" >> "$GITHUB_STEP_SUMMARY"
- name: Передать вход модели
if: steps.line.outputs.proceed == 'true'
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: release-review-input-${{ github.run_id }}-${{ github.run_attempt }}
path: ${{ runner.temp }}/release-review-input
if-no-files-found: error
retention-days: 3
model_review:
name: "Ревью релиза: работа модели"
needs: prepare
if: needs.prepare.outputs.proceed == 'true'
runs-on: ubuntu-latest
timeout-minutes: 60
# Недоверенная стадия. Прав на запись нет никаких: ни в репозиторий, ни в
# issue. Документ публикует `publish`; находки в issue превращает владелец.
# `github_token` у шага Review обязателен (#556): без него 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: release-review-input-${{ github.run_id }}-${{ github.run_attempt }}
path: ${{ runner.temp }}/release-review-input
- name: Проверить вход и кандидата
env:
CANDIDATE: ${{ needs.prepare.outputs.candidate }}
run: |
(cd "$RUNNER_TEMP/release-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 }}/release-review.md
REVIEW_INPUT: ${{ runner.temp }}/release-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 || 'нет' }}.
Вход линии — файл $REVIEW_INPUT/brief.md (issue линии, доказанные
трейлерами, и изменённые продуктовые файлы) и
$REVIEW_INPUT/line-membership.json.
Правила этого ревью — docs/process/REVIEWER.md, раздел «Независимое
ревью линии», канон — PROCESS.md §11.5. Прочитай их первыми.
Главное, что нельзя пропустить:
- Ты судишь ПОВЕРХНОСТИ, изменённые линией, против ПОЛЬЗОВАТЕЛЯ, а
не дифф против ТЗ. Не читай ТЗ задач (раздел «## ТЗ» в issue,
docs/specs/**) и документы раундов (docs/reviews/**): независимость
и есть смысл шага. Основа суждения — docs/SCOPE.md (персоны и
работы) и docs/USER-GUIDE.ru.md (что обещано пользователю).
- Проверяй исполнением, а не чтением: собери бандл
(`npm run bundle:sync`), открой затронутые поверхности в браузере
(стенд `demo/`, смоки `demo/smoke_*.mjs`), вводи текст
посимвольно, закрывай диалоги настоящим Escape и крестиком. Где
есть диалоги HA — пиннутая фикстура `ha-dialog` (#505,
demo/helpers/README-ha-dialog-505.md,
`node demo/verify_ha_dialog_discard_recovery.mjs`).
- Для каждой поверхности: обычный сценарий и самый рискованный
соседний, шесть классов риска (async, данные/права, геометрия,
визуал, объём/perf, host/input) — PROCESS.md §2.6.
- Ты ничего не правишь и не публикуешь: ни код, ни issue, ни
комментарии. Права на запись у тебя нет. Любые изменения рабочей
копии будут отброшены — после проверок восстанови её сам
(`git checkout -- . && git clean -fd`), если что-то менял.
Находки: High / Medium / Low, у каждой — поверхность, воспроизведение
(команда или шаги), что увидит пользователь, какая персона задета.
Выпуск это ревью не останавливает: документ — рекомендация владельцу.
Напиши полный документ в файл по пути из переменной REVIEW_DOC
(абсолютный, вне репозитория). Разделы: что входило в линию,
поверхности и как каждая проверялась (команда → результат),
находки, что проверено и корректно, чего не проверял и почему.
Первой строкой после заголовка — `Итог: High N · Medium N · Low N`.
Затем верни JSON по схеме — последнее обязательное действие.
claude_args: |
--max-turns 200
--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 }}/release-review.md
OUT: ${{ steps.review.outputs.structured_output }}
run: |
test -s "$SOURCE" || { echo "::error::модель не оставила документ ревью"; exit 1; }
dir="$RUNNER_TEMP/release-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/release-review.md"
(cd "$dir" && sha256sum release-review.md result.json > manifest.sha256)
- name: Передать результат публикации
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: release-review-result-${{ github.run_id }}-${{ github.run_attempt }}
path: ${{ runner.temp }}/release-review-result
if-no-files-found: error
retention-days: 3
publish:
name: "Ревью релиза: документ в dev"
needs: [prepare, model_review]
runs-on: ubuntu-latest
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: release-review-result-${{ github.run_id }}-${{ github.run_attempt }}
path: ${{ runner.temp }}/release-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/release-review-result"
(cd "$dir" && sha256sum -c manifest.sha256)
test "$DOC" = "$(node scripts/release-review.mjs doc --tag="$TAG")"
counts=$(jq -r '"High \(.high) · Medium \(.medium) · Low \(.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/release-review.md"
printf '\n\n<!-- hp-release-review-anchors -->\n### Материал ревью\n\n```\n'
printf 'tag %s\ncandidate %s\nbase %s\nissues %s\nrun %s\n' \
"$TAG" "$CANDIDATE" "${BASE:-—}" "${ISSUES:-—}" "$RUN_URL"
printf '```\n'
} > "$DOC"
node scripts/reviews-index.mjs --dir=docs/reviews
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: release review for $TAG
Независимое ревью линии перед стабильным релизом (PROCESS.md §11.5).
Итог: $counts. Выпуск не блокирует; решение по находкам — за владельцем.
Issue: #638
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 "### Независимое ревью $TAG" >> "$GITHUB_STEP_SUMMARY"
echo "Итог: $counts — \`$DOC\` в dev. Выпуск не блокируется." >> "$GITHUB_STEP_SUMMARY"
echo "::notice::$DOC опубликован: $counts"
exit 0
fi
echo "::warning::dev ушёл вперёд — попытка $attempt из 3, документ собирается заново"
sleep $((attempt * 10))
done
echo "::error::документ ревью не опубликован в dev за три попытки"
exit 1
+30
View File
@@ -131,6 +131,36 @@ jobs:
echo "release $TAG is public: repair mode — only missing assets may be added" echo "release $TAG is public: repair mode — only missing assets may be added"
fi fi
independent-review:
name: "Независимое ревью линии (не блокирует выпуск)"
# #638, PROCESS.md §11.5. Решение владельца 2026-09-25: ревью идёт
# параллельно гейтам и выпуск не ждёт и не останавливает. Поэтому этот job
# только ставит в очередь `release-review.yml` на `dev` и ни один job
# выпуска от него не зависит (`needs` на него запрещён тестом
# release-workflow); его отказ — предупреждение, а не красный релиз.
# Беты пропускаются: ревью линии — перед стабильным.
needs: candidate
if: ${{ needs.candidate.outputs.prerelease != 'true' }}
continue-on-error: true
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
actions: write
steps:
- name: Поставить в очередь ревью линии
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ needs.candidate.outputs.tag }}
SHA: ${{ needs.candidate.outputs.sha }}
run: |
if gh workflow run release-review.yml --repo "${{ github.repository }}" --ref dev \
-f tag="$TAG" -f candidate="$SHA"; then
echo "Независимое ревью $TAG поставлено в очередь: release-review.yml на dev" >> "$GITHUB_STEP_SUMMARY"
else
echo "::warning::ревью линии $TAG не запущено — выпуск продолжается; запустить руками: gh workflow run release-review.yml --ref dev -f tag=$TAG -f candidate=$SHA"
exit 1
fi
gate: gate:
name: "Гейт: контракт, Validate, Full Performance и E2E на точном SHA" name: "Гейт: контракт, Validate, Full Performance и E2E на точном SHA"
needs: candidate needs: candidate
+5 -1
View File
@@ -567,7 +567,11 @@ development cycle never run E2E. Stable installable assets (`houseplan.zip`,
`houseplan-card.js` and their `SHA256SUMS`) reach the public stable release only `houseplan-card.js` and their `SHA256SUMS`) reach the public stable release only
from `release.yml` after those gates; a release published by hand is turned back from `release.yml` after those gates; a release published by hand is turned back
into a draft first (#540). The prerelease publisher additionally ships a into a draft first (#540). The prerelease publisher additionally ships a
candidate-bound `RELEASE-MEMBERSHIP.json` covered by the same passport (#547). candidate-bound `RELEASE-MEMBERSHIP.json` covered by the same passport (#547). Before
every stable release `release.yml` also queues an independent review of the
whole beta line (`release-review.yml`, `PROCESS.md` §11.5, #638): no specs, no
review rounds, output `docs/reviews/RELEASE-REVIEW-vX.Y.Z.md`; it runs in
parallel and never blocks the release.
**"Verified" without a named command and its result is not evidence.** **"Verified" without a named command and its result is not evidence.**
+44
View File
@@ -1280,6 +1280,50 @@ Golden, браузерные смоки, performance и полный HA-харн
Это исключение из правила «код-ревью не пропускается никогда» (§5, §7.1) — Это исключение из правила «код-ревью не пропускается никогда» (§5, §7.1) —
единственное, и относится только к окну между `S8-merged` и выпуском. единственное, и относится только к окну между `S8-merged` и выпуском.
### 11.5 Независимое ревью линии перед стабильным релизом
Решение владельца 2026-09-25, issue #638.
**Зачем.** Инкрементальное ревью судит дифф задачи против её ТЗ, а не
поверхность против пользователя. Пять раундов по эпику #591 не нашли того, что
нашли четыре независимых ревью перед аудитом 22.09: невидимый после крестика HA
диалог (#607), кламп по символу (#608), маршруты робота при импорте (#611),
детерминированный отказ релизного гейта (#619). Ни ветка `ha-dialog`, ни
посимвольный ввод не входили ни в один AC.
**Шаг.** Перед каждым стабильным релизом — одно ревью поверхностей, изменённых
всей линией бет, «с нуля»:
- **вход** — issue линии, доказанные трейлерами `Issue: #NN` в диапазоне
«прошлый стабильный тег..кандидат» (тот же построитель и та же схема, что
`RELEASE-MEMBERSHIP.json` беты, #547; метка S8 доказательством не является),
и изменённые продуктовые файлы. Собирает их
`scripts/release-review.mjs prepare`;
- **без ТЗ и без документов раундов**: основа суждения — `docs/SCOPE.md` и
`docs/USER-GUIDE.ru.md`. Проверка исполнением: бандл, стенд и смоки, пиннутая
фикстура `ha-dialog` (#505) там, где есть диалоги, посимвольный ввод,
настоящие Escape и крестик; для каждой поверхности — обычный сценарий и самый
рискованный соседний (§2.6, шесть классов риска);
- **выход** — `docs/reviews/RELEASE-REVIEW-vX.Y.Z.md` в `dev`, находки
High/Medium/Low с воспроизведением.
**Исполнитель** — модель в CI, `.github/workflows/release-review.yml`: три job
(вход, модель, публикация), модель без единого права на запись, документ, его
машинный блок, индекс и коммит — детерминированный шаг. Независимость
обеспечена построением: сессия свежая, ТЗ и раунды ей не даются.
**Выпуск не блокирует.** `release.yml` ставит ревью в очередь job
`independent-review` сразу после закрепления SHA кандидата — параллельно
гейтам; ни один job выпуска от него не зависит, его отказ — предупреждение.
Документ — рекомендация: владелец берёт находки в работу (issue в очередь
следующей беты) либо оставляет без действий. Автоматически находки в issue
не превращаются.
Повторный запуск на тот же тег модель не тратит, если документ уже в `dev`
(`force=true` — переснять). Ручной запуск:
`gh workflow run release-review.yml --ref dev -f tag=vX.Y.Z [-f candidate=<sha>]`.
Беты шаг пропускают. Первый прогон — линия v1.78.0.
--- ---
## 12. Запрещено ## 12. Запрещено
+11
View File
@@ -555,6 +555,17 @@ verification, so work merged later remains open and an accepted external issue
is treated like an owner-authored one. A retry reuses the published manifest and is treated like an owner-authored one. A retry reuses the published manifest and
resumes bookkeeping even when the release is already public (#120, #547). resumes bookkeeping even when the release is already public (#120, #547).
**Independent line review (#638, `PROCESS.md` §11.5).** Right after the
candidate SHA is pinned, the `independent-review` job of `release.yml` queues
`.github/workflows/release-review.yml` on `dev` for the same tag and SHA. It
reviews every product surface changed since the previous stable tag against
`docs/SCOPE.md` and `docs/USER-GUIDE.ru.md` — without specs or review rounds —
and publishes `docs/reviews/RELEASE-REVIEW-vX.Y.Z.md` to `dev`. It runs in
parallel and **never blocks the release**: no release job needs it, a failure
is a warning, and the findings are the owner's call. Manual run:
`gh workflow run release-review.yml --ref dev -f tag=vX.Y.Z`; a repeat for a
tag whose document already exists is skipped unless `-f force=true`.
**Stable releases** go through `.github/workflows/release.yml`, the only **Stable releases** go through `.github/workflows/release.yml`, the only
publisher of installable assets (#540). Run it with `workflow_dispatch` on publisher of installable assets (#540). Run it with `workflow_dispatch` on
`main` with the exact tag: when the tag does not exist yet it is created on the `main` with the exact tag: when the tag does not exist yet it is created on the
+15
View File
@@ -128,3 +128,18 @@
[§10.4](../../PROCESS.md#104-событийный-конвейер-метка-как-триггер)). [§10.4](../../PROCESS.md#104-событийный-конвейер-метка-как-триггер)).
- Запрещено: Medium-находки, оставленные как TODO в документе ревью; - Запрещено: Medium-находки, оставленные как TODO в документе ревью;
ревью-документы вне репозитория ([§12](../../PROCESS.md#12-запрещено)). ревью-документы вне репозитория ([§12](../../PROCESS.md#12-запрещено)).
## Независимое ревью линии
- Перед стабильным релизом `release-review.yml` судит поверхности всей линии
бет против пользователя, а не дифф против ТЗ: ТЗ задач и документы раундов не
читаются, основа — `docs/SCOPE.md` и `docs/USER-GUIDE.ru.md`
([§11.5](../../PROCESS.md#115-независимое-ревью-линии-перед-стабильным-релизом)).
- Проверка исполнением: посимвольный ввод, настоящие Escape и крестик,
фикстура `ha-dialog` (#505) для диалогов; по каждой поверхности — обычный
сценарий и самый рискованный соседний
([§2.6](../../PROCESS.md#26-в-разработке--реализация), [§11.5](../../PROCESS.md#115-независимое-ревью-линии-перед-стабильным-релизом)).
- Документ `docs/reviews/RELEASE-REVIEW-vX.Y.Z.md` — рекомендация: выпуск не
блокируется, решение по находкам за владельцем; ревьюер ничего не публикует
и issue не заводит
([§11.5](../../PROCESS.md#115-независимое-ревью-линии-перед-стабильным-релизом)).
+44
View File
@@ -11051,6 +11051,50 @@ const MUTANT_DEFINITIONS = [
replace: " return true; // mutant: every dispatch is ours", replace: " return true; // mutant: every dispatch is ours",
}], }],
}, },
{
id: 'release-waits-for-line-review',
guard: 'node --test test/release-workflow.test.mjs',
because: 'the independent line review is a recommendation, not a gate: a release job that needs it '
+ 'turns the owner decision "never blocks the release" into a silent blocker (#638 AC2)',
patches: [{
file: '.github/workflows/release.yml',
find: ' name: "Гейт: контракт, Validate, Full Performance и E2E на точном SHA"\n needs: candidate\n',
replace: ' name: "Гейт: контракт, Validate, Full Performance и E2E на точном SHA"\n needs: [candidate, independent-review]\n',
}],
},
{
id: 'release-review-base-accepts-beta',
guard: 'node --test test/release-review.test.mjs',
because: 'the line starts at the previous STABLE tag; taking the last beta shrinks the review of '
+ 'the whole line to its tail and hides the defects #607/#608/#611/#619 were found in (#638 AC1)',
patches: [{
file: 'scripts/release-review.mjs',
find: " const older = reachable.filter((name) => STABLE_TAG_RE.test(name) && compare(name, tag) < 0);",
replace: " const older = reachable.filter((name) => /^v\\d+\\.\\d+\\.\\d+/.test(name) && name !== tag && compare(name.split('-')[0], tag) <= 0);",
}],
},
{
id: 'release-review-model-gets-github-tools',
guard: 'node --test test/release-review.test.mjs',
because: 'the release reviewer is the untrusted stage and must not write anywhere; GitHub tools '
+ 'would let it file issues and comments the owner never took into work (#638, #556)',
patches: [{
file: '.github/workflows/release-review.yml',
find: ' --allowedTools Read,Write,Grep,Glob,Bash\n',
replace: ' --allowedTools Read,Write,Grep,Glob,Bash,mcp__github__add_issue_comment\n',
}],
},
{
id: 'release-review-reruns-existing-doc',
guard: 'node --test test/release-review.test.mjs',
because: 'a repeated dispatch for a tag whose document is already in dev must not pay for a second '
+ 'model run and overwrite the published review (#638)',
patches: [{
file: '.github/workflows/release-review.yml',
find: ' if [ "$FORCE" != "true" ] && git cat-file -e "origin/dev:$doc" 2>/dev/null; then\n',
replace: ' if false; then\n',
}],
},
{ {
id: 'review-trusts-push-run-without-mutants', id: 'review-trusts-push-run-without-mutants',
guard: 'node --test test/validate-gate.test.mjs', guard: 'node --test test/validate-gate.test.mjs',
+144
View File
@@ -0,0 +1,144 @@
#!/usr/bin/env node
/**
* Вход независимого ревью линии перед стабильным релизом (#638, PROCESS.md §11.5).
*
* node scripts/release-review.mjs prepare --tag=v1.78.0 --candidate=<sha> --out=<dir>
* node scripts/release-review.mjs doc --tag=v1.78.0
*
* Ревью «с нуля» судит поверхности, изменённые всей линией бет, а не дифф
* одной задачи. Поэтому база — прошлый СТАБИЛЬНЫЙ тег, достижимый из
* кандидата (беты этой линии внутри диапазона), а список issue доказывается
* трейлерами `Issue: #NN` тем же построителем и в той же схеме, что
* `RELEASE-MEMBERSHIP.json` беты (#547): метка S8 — не доказательство.
*
* `prepare` пишет в `--out` два файла — `line-membership.json` (манифест линии)
* и `brief.md` (вход промпта) — и печатает `key=value` для `$GITHUB_OUTPUT`.
* Ни ТЗ, ни документов раундов в брифе нет намеренно: ревью независимое.
*/
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 { buildReleaseMembership, issueTrailers, validateReleaseMembership } from './release-membership.mjs';
import { classify } from './process-gate.mjs';
export const RELEASE_REVIEW_DIR = 'docs/reviews';
export const STABLE_TAG_RE = /^v(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/;
const SHA_RE = /^[0-9a-f]{40,64}$/;
/** Путь документа ревью для стабильного тега; бета и мусор — отказ. */
export function releaseReviewDocPath(tag) {
if (!STABLE_TAG_RE.test(String(tag))) throw new Error(`not a stable release tag: ${tag}`);
return `${RELEASE_REVIEW_DIR}/RELEASE-REVIEW-${tag}.md`;
}
const parts = (tag) => STABLE_TAG_RE.exec(tag).slice(1, 4).map(Number);
const compare = (a, b) => {
const [x, y] = [parts(a), parts(b)];
for (let i = 0; i < 3; i++) if (x[i] !== y[i]) return x[i] - y[i];
return 0;
};
/**
* Прошлый стабильный тег среди `reachable` (тегов, достижимых из кандидата):
* наибольший стабильный строго ниже `tag`. Беты и сам тег не годятся — иначе
* диапазон сжался бы до последней беты и ревью линии стало бы ревью хвоста.
*/
export function previousStableTag(reachable, tag) {
releaseReviewDocPath(tag);
const older = reachable.filter((name) => STABLE_TAG_RE.test(name) && compare(name, tag) < 0);
older.sort(compare);
return older.at(-1) ?? null;
}
/** Продуктовые файлы (класс A) из диффа линии — поверхности для ревью. */
export function productFiles(files) {
return [...new Set(files.map((file) => file.replaceAll('\\', '/')))]
.filter((file) => classify(file) === 'A')
.sort();
}
/** Манифест линии: все issue, чьи трейлеры есть в диапазоне base..candidate. */
export function buildLineMembership({ tag, candidate, base, commits }) {
releaseReviewDocPath(tag);
const issueNumbers = [...new Set(commits.flatMap((commit) => issueTrailers(commit.message)))];
const { manifest } = buildReleaseMembership({ tag, candidate, base, commits, issueNumbers });
return validateReleaseMembership(manifest, { tag, candidate });
}
export function renderBrief({ membership, files, runUrl = '' }) {
const product = productFiles(files);
const lines = [
`# Вход независимого ревью ${membership.tag}`,
'',
`- Кандидат: \`${membership.candidate}\``,
`- База линии (прошлый стабильный): ${membership.base ? `\`${membership.base.tag}\` · \`${membership.base.sha}\`` : 'нет — первая стабильная версия, судится всё дерево'}`,
...(runUrl ? [`- Прогон: ${runUrl}`] : []),
`- Документ: \`${releaseReviewDocPath(membership.tag)}\``,
'',
`## Issue линии (${membership.issues.length}) — доказаны трейлерами \`Issue: #NN\``,
'',
...(membership.issues.length
? membership.issues.map((row) => `- #${row.number} · коммитов: ${row.commits.length}`)
: ['- (нет)']),
'',
`## Изменённые продуктовые файлы (класс A, ${product.length}) — из них выводятся поверхности`,
'',
...(product.length ? product.map((file) => `- \`${file}\``) : ['- (нет)']),
'',
`Всего файлов в диффе линии: ${files.length}.`,
'',
];
return lines.filter((line, index) => line !== '' || lines[index - 1] !== '').join('\n');
}
function git(args) {
const r = spawnSync('git', args, { encoding: 'utf8', maxBuffer: 256 * 1024 * 1024 });
if (r.status !== 0) throw new Error(`git ${args.join(' ')} failed: ${(r.stderr || '').trim()}`);
return r.stdout.trim();
}
/** Кандидат, база и история линии из git (без сети). */
export function readLine(tag, candidate) {
if (!SHA_RE.test(String(candidate))) throw new Error(`invalid candidate SHA: ${candidate}`);
const reachable = git(['tag', '--merged', candidate]).split('\n').map((s) => s.trim()).filter(Boolean);
const baseTag = previousStableTag(reachable, tag);
const base = baseTag ? { tag: baseTag, sha: git(['rev-list', '-n', '1', baseTag]) } : null;
const range = base ? `${base.sha}..${candidate}` : candidate;
const raw = git(['log', '--format=%H%x1f%B%x1e', range]);
const commits = raw.split('\x1e').map((r) => r.trim()).filter(Boolean).map((record) => {
const at = record.indexOf('\x1f');
return { sha: record.slice(0, at).trim(), message: record.slice(at + 1) };
});
const files = base
? git(['diff', '--name-only', base.sha, candidate]).split('\n').filter(Boolean)
: git(['ls-tree', '-r', '--name-only', candidate]).split('\n').filter(Boolean);
return { base, commits, files };
}
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');
if (command === 'doc') {
console.log(releaseReviewDocPath(tag));
} else if (command === 'prepare') {
const candidate = value('candidate');
const out = resolve(value('out') || '.');
const { base, commits, files } = readLine(tag, candidate);
const membership = buildLineMembership({ tag, candidate, base, commits });
mkdirSync(out, { recursive: true });
writeFileSync(join(out, 'line-membership.json'), `${JSON.stringify(membership, null, 2)}\n`);
writeFileSync(join(out, 'brief.md'), renderBrief({ membership, files, runUrl: value('run-url') }));
console.log(`doc=${releaseReviewDocPath(tag)}`);
console.log(`base=${base ? base.tag : ''}`);
console.log(`issues=${membership.issues.map((row) => row.number).join(',')}`);
} else {
throw new Error('usage: release-review.mjs prepare --tag=vX.Y.Z --candidate=<sha> --out=<dir> | doc --tag=vX.Y.Z');
}
} catch (error) {
console.error(`::error::${error.message}`);
process.exit(1);
}
}
+96
View File
@@ -0,0 +1,96 @@
// #638, PROCESS.md §11.5: независимое ревью линии перед стабильным релизом.
// Вход — issue, доказанные трейлерами в диапазоне «прошлый стабильный..кандидат»;
// модель без права записи; документ в dev публикует детерминированный шаг.
import assert from 'node:assert/strict';
import test from 'node:test';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import {
buildLineMembership, previousStableTag, productFiles, releaseReviewDocPath, renderBrief,
} from '../scripts/release-review.mjs';
const WORKFLOW = readFileSync(fileURLToPath(new URL('../.github/workflows/release-review.yml', import.meta.url)), 'utf8');
const jobBlock = (name) => {
const start = WORKFLOW.indexOf(`\n ${name}:\n`);
assert.ok(start > 0, `нет job ${name}`);
const rest = WORKFLOW.slice(start + 1);
const next = rest.slice(1).search(/\n {2}[a-z_-]+:\n/);
return next < 0 ? rest : rest.slice(0, next + 1);
};
const sha = (c) => c.repeat(40);
test('#638 документ — только для стабильного тега, имя фиксировано', () => {
assert.equal(releaseReviewDocPath('v1.78.0'), 'docs/reviews/RELEASE-REVIEW-v1.78.0.md');
for (const bad of ['v1.78.0-beta.2', '1.78.0', 'v1.78', 'v01.2.3', '', 'v1.78.0/../x']) {
assert.throws(() => releaseReviewDocPath(bad), /not a stable release tag/, bad);
}
});
test('#638 база линии — прошлый СТАБИЛЬНЫЙ тег: беты, сам тег и более новые не годятся', () => {
const tags = ['v1.76.0', 'v1.77.0-beta.1', 'v1.77.0-beta.5', 'v1.77.0', 'v1.78.0-beta.1', 'v1.78.0-beta.2', 'v1.78.0', 'v1.9.0', 'v2.0.0'];
assert.equal(previousStableTag(tags, 'v1.78.0'), 'v1.77.0', 'бета линии не сжимает диапазон до хвоста');
assert.equal(previousStableTag(tags, 'v1.77.0'), 'v1.76.0');
assert.equal(previousStableTag(['v1.9.0', 'v1.10.0'], 'v1.11.0'), 'v1.10.0', 'сравнение числовое, не строковое');
assert.equal(previousStableTag(['v1.78.0-beta.1'], 'v1.78.0'), null, 'первая стабильная — база не выдумывается');
assert.equal(previousStableTag(['v1.77.1'], 'v1.77.2'), 'v1.77.1', 'патч-релиз судит свой патч-диапазон');
});
test('#638 AC1: issue линии — только доказанные трейлерами, в схеме RELEASE-MEMBERSHIP.json', () => {
const commits = [
{ sha: sha('a'), message: 'fix: x\n\nIssue: #607\nUser-Visible: yes' },
{ sha: sha('b'), message: 'docs: review document for #607\n\nIssue: #607\nUser-Visible: no' },
{ sha: sha('c'), message: 'feat: y\n\nIssue: #611\nUser-Visible: yes' },
{ sha: sha('d'), message: 'chore: упоминание #999 в тексте — не трейлер' },
];
const m = buildLineMembership({ tag: 'v1.78.0', candidate: sha('e'), base: { tag: 'v1.77.0', sha: sha('f') }, commits });
assert.equal(m.schema, 1);
assert.deepEqual(m.issues.map((row) => row.number), [607, 611]);
assert.deepEqual(m.issues[0].commits, [sha('a'), sha('b')]);
assert.throws(() => buildLineMembership({ tag: 'v1.78.0-beta.3', candidate: sha('e'), base: null, commits }), /stable/);
});
test('#638 поверхности — продуктовые файлы (класс A), без бандла и документов', () => {
assert.deepEqual(productFiles([
'src/room-cards.ts', 'src\\i18n\\ru.json', 'dist/houseplan-card.js',
'custom_components/houseplan/frontend/houseplan-card.js', 'custom_components/houseplan/api.py',
'docs/USER-GUIDE.ru.md', 'scripts/release-review.mjs', 'src/room-cards.ts',
]), ['custom_components/houseplan/api.py', 'src/i18n/ru.json', 'src/room-cards.ts']);
});
test('#638 бриф: вход без ТЗ и документов раундов, путь документа и база названы', () => {
const membership = { schema: 1, tag: 'v1.78.0', candidate: sha('e'), base: { tag: 'v1.77.0', sha: sha('f') }, issues: [{ number: 607, commits: [sha('a')] }] };
const brief = renderBrief({ membership, files: ['src/a.ts', 'docs/x.md'], runUrl: 'https://example.test/run/1' });
assert.match(brief, /RELEASE-REVIEW-v1\.78\.0\.md/);
assert.match(brief, /`v1\.77\.0`/);
assert.match(brief, /- #607 · коммитов: 1/);
assert.match(brief, /- `src\/a\.ts`/);
assert.ok(!/docs\/x\.md/.test(brief), 'не-продуктовые файлы в перечень поверхностей не входят');
assert.ok(!/\n\n\n/.test(brief), 'без пустых дыр');
});
test('#638: только ручной/вызванный запуск на dev, модель без права записи, документ пишет publish', () => {
const on = WORKFLOW.slice(WORKFLOW.indexOf('\non:'), WORKFLOW.indexOf('\npermissions:'));
assert.match(on, /^ {2}workflow_dispatch:/m);
assert.ok(!/^ {2}(?:push|schedule|workflow_run|issues|release):/m.test(on), 'не событие ветки по умолчанию — зеркало в main не нужно');
assert.match(WORKFLOW, /\npermissions:\n {2}contents: read\n/, 'потолок прав workflow — чтение');
assert.ok(!/: write/.test(WORKFLOW), 'ни одного права на запись: документ пушится токеном процесса, а не GITHUB_TOKEN');
const model = jobBlock('model_review');
assert.match(model, /github_token: \$\{\{ secrets\.GITHUB_TOKEN \}\}/, '#556: без него action выдаст App-токен с записью');
assert.match(model, /--allowedTools Read,Write,Grep,Glob,Bash\n/, 'модели не даны инструменты GitHub');
assert.ok(!/HP_PROCESS_TOKEN/.test(model), 'токен процесса модели недоступен');
assert.match(model, /ref: \$\{\{ needs\.prepare\.outputs\.candidate \}\}/, 'судится ровно кандидат');
assert.match(model, /Не читай ТЗ задач/, 'независимость: без ТЗ');
assert.match(model, /документы раундов \(docs\/reviews\/\*\*\)/, 'независимость: без раундов');
const publish = jobBlock('publish');
assert.match(publish, /secrets\.HP_PROCESS_TOKEN/);
assert.match(publish, /review-doc-guard\.mjs/, 'в dev уходит только docs\/reviews');
assert.match(publish, /reviews-index\.mjs --dir=docs\/reviews/, 'индекс тем же коммитом');
assert.match(publish, /Issue: #638\n\s+User-Visible: no/, 'трейлеры провенанса');
});
test('#638: повтор на тот же тег не тратит модель, если документ уже в dev', () => {
const prepare = jobBlock('prepare');
assert.match(prepare, /if \[ "\$FORCE" != "true" \] && git cat-file -e "origin\/dev:\$doc"/);
assert.match(prepare, /echo "proceed=false"/);
assert.match(jobBlock('model_review'), /if: needs\.prepare\.outputs\.proceed == 'true'/);
});
+19 -1
View File
@@ -29,7 +29,7 @@ test('#540 AC1: exactly one workflow reacts to the release event, and none of th
assert.ok(!readdirSync(WORKFLOWS).includes('release-zip.yml')); assert.ok(!readdirSync(WORKFLOWS).includes('release-zip.yml'));
// asset uploads live only in the job that needs the gate // asset uploads live only in the job that needs the gate
const jobs = [...workflow.slice(at('\njobs:\n')).matchAll(/^ {2}([a-z-]+):\n/gm)].map((m) => m[1]); const jobs = [...workflow.slice(at('\njobs:\n')).matchAll(/^ {2}([a-z-]+):\n/gm)].map((m) => m[1]);
assert.deepEqual(jobs, ['candidate', 'gate', 'stage', 'publish', 'announce', 'hacs-discovery']); assert.deepEqual(jobs, ['candidate', 'independent-review', 'gate', 'stage', 'publish', 'announce', 'hacs-discovery']);
const uploads = jobs.filter((name) => /gh release upload|softprops\/action-gh-release/.test(job(name))); const uploads = jobs.filter((name) => /gh release upload|softprops\/action-gh-release/.test(job(name)));
assert.deepEqual(uploads, ['stage'], 'the one uploading job'); assert.deepEqual(uploads, ['stage'], 'the one uploading job');
assert.deepEqual(jobNeeds('stage'), ['candidate', 'gate']); assert.deepEqual(jobNeeds('stage'), ['candidate', 'gate']);
@@ -116,3 +116,21 @@ test('#538 AC2 / #540: release.yml зовёт анонс только после
'беты остаются тихими по признаку тега'); 'беты остаются тихими по признаку тега');
assert.match(block, /secrets: inherit/); assert.match(block, /secrets: inherit/);
}); });
// #638, PROCESS.md §11.5: независимое ревью линии запускается параллельно и
// выпуск не блокирует (решение владельца 2026-09-25). Ни один job выпуска не
// может зависеть от него: иначе «рекомендация» молча превращается в гейт.
test('#638 AC2: ревью линии ставится в очередь параллельно гейтам и ни один job выпуска его не ждёт', () => {
const block = job('independent-review');
assert.deepEqual(jobNeeds('independent-review'), ['candidate'], 'стартует сразу после закрепления SHA');
assert.match(block, /^ {4}continue-on-error: true$/m, 'отказ запуска — не красный релиз');
assert.match(block, /if: \$\{\{ needs\.candidate\.outputs\.prerelease != 'true' \}\}/, 'только стабильные');
assert.match(block, /gh workflow run release-review\.yml --repo "\$\{\{ github\.repository \}\}" --ref dev/);
assert.match(block, /-f tag="\$TAG" -f candidate="\$SHA"/, 'ревью судит тот же SHA, что гейты');
assert.match(block, /^ {4}permissions:\n {6}actions: write\n {4}steps:/m, 'единственное право — поставить workflow в очередь');
const jobs = [...workflow.slice(at('\njobs:\n')).matchAll(/^ {2}([a-z-]+):\n/gm)].map((m) => m[1]);
for (const name of jobs) {
assert.ok(!jobNeeds(name).includes('independent-review'), `${name} не зависит от ревью линии`);
assert.ok(!/needs\.independent-review/.test(job(name)), `${name} не читает результат ревью линии`);
}
});