mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
Процесс: независимое ревью линии перед стабильным релизом, не блокирующее выпуск (#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:
@@ -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
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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. Запрещено
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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-независимое-ревью-линии-перед-стабильным-релизом)).
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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'/);
|
||||||
|
});
|
||||||
@@ -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} не читает результат ревью линии`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user