diff --git a/.github/workflows/release-review.yml b/.github/workflows/release-review.yml new file mode 100644 index 00000000..1e5f0b40 --- /dev/null +++ b/.github/workflows/release-review.yml @@ -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\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 - <> "$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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8d2d1031..f488fa12 100755 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -131,6 +131,36 @@ jobs: echo "release $TAG is public: repair mode — only missing assets may be added" 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: name: "Гейт: контракт, Validate, Full Performance и E2E на точном SHA" needs: candidate diff --git a/AGENTS.md b/AGENTS.md index 2ecfcab1..45aedb1c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 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 -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.** diff --git a/PROCESS.md b/PROCESS.md index 9cf4074d..a75cec68 100644 --- a/PROCESS.md +++ b/PROCESS.md @@ -1280,6 +1280,50 @@ Golden, браузерные смоки, performance и полный HA-харн Это исключение из правила «код-ревью не пропускается никогда» (§5, §7.1) — единственное, и относится только к окну между `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=]`. +Беты шаг пропускают. Первый прогон — линия v1.78.0. + --- ## 12. Запрещено diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 18c96738..951cdcde 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -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 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 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 diff --git a/docs/process/REVIEWER.md b/docs/process/REVIEWER.md index 341d963a..851d8226 100644 --- a/docs/process/REVIEWER.md +++ b/docs/process/REVIEWER.md @@ -128,3 +128,18 @@ [§10.4](../../PROCESS.md#104-событийный-конвейер-метка-как-триггер)). - Запрещено: Medium-находки, оставленные как TODO в документе ревью; ревью-документы вне репозитория ([§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-независимое-ревью-линии-перед-стабильным-релизом)). diff --git a/scripts/mutation-registry.mjs b/scripts/mutation-registry.mjs index 58b5ea2d..1305ad4b 100644 --- a/scripts/mutation-registry.mjs +++ b/scripts/mutation-registry.mjs @@ -11051,6 +11051,50 @@ const MUTANT_DEFINITIONS = [ 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', guard: 'node --test test/validate-gate.test.mjs', diff --git a/scripts/release-review.mjs b/scripts/release-review.mjs new file mode 100644 index 00000000..f1d2e66c --- /dev/null +++ b/scripts/release-review.mjs @@ -0,0 +1,144 @@ +#!/usr/bin/env node +/** + * Вход независимого ревью линии перед стабильным релизом (#638, PROCESS.md §11.5). + * + * node scripts/release-review.mjs prepare --tag=v1.78.0 --candidate= --out= + * 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= --out= | doc --tag=vX.Y.Z'); + } + } catch (error) { + console.error(`::error::${error.message}`); + process.exit(1); + } +} diff --git a/test/release-review.test.mjs b/test/release-review.test.mjs new file mode 100644 index 00000000..c02aab19 --- /dev/null +++ b/test/release-review.test.mjs @@ -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'/); +}); diff --git a/test/release-workflow.test.mjs b/test/release-workflow.test.mjs index e7dc5225..68d10cc8 100644 --- a/test/release-workflow.test.mjs +++ b/test/release-workflow.test.mjs @@ -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')); // 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]); - 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))); assert.deepEqual(uploads, ['stage'], 'the one uploading job'); assert.deepEqual(jobNeeds('stage'), ['candidate', 'gate']); @@ -116,3 +116,21 @@ test('#538 AC2 / #540: release.yml зовёт анонс только после 'беты остаются тихими по признаку тега'); 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} не читает результат ревью линии`); + } +});