diff --git a/.github/workflows/_process.yml b/.github/workflows/_process.yml index c8f51e73..c37a6b45 100644 --- a/.github/workflows/_process.yml +++ b/.github/workflows/_process.yml @@ -58,6 +58,8 @@ jobs: cycle: ${{ steps.decide.outputs.cycle }} spent: ${{ steps.decide.outputs.spent }} limit: ${{ steps.decide.outputs.limit }} + # #696: текущие метки — prepare выводит из них трек захода. + labels: ${{ steps.decide.outputs.labels }} steps: # Мелкий checkout: guard остаётся лёгким, но ему нужен # scripts/review-doc-guard.mjs — счёт раундов вынесен туда, потому что @@ -85,8 +87,8 @@ jobs: has blocked && BLOCKED=true has review-4 && EXHAUSTED=true # Треки #695 (PROCESS §5): `track:show` и `track:ship` — лимит 2, как у - # прежних `small`/`trivial`; `track:ask` — 4. Пока #696 не влит, `ship` - # проходит конвейер как `show`. + # прежних `small`/`trivial`; `track:ask` — 4. Цену захода по треку + # (мутанты, ребейз, модель) решает prepare (#696). has small && SMALL=true has trivial && TRIVIAL=true has track:show && SMALL=true @@ -301,6 +303,7 @@ jobs: echo "cycle=$attempt" >> "$GITHUB_OUTPUT" echo "spent=$spent" >> "$GITHUB_OUTPUT" echo "limit=$limit" >> "$GITHUB_OUTPUT" + echo "labels=$(printf '%s\n' "$current" | paste -sd, -)" >> "$GITHUB_OUTPUT" prepare: name: "Ревью: материал и deterministic gates" @@ -336,6 +339,9 @@ jobs: spec_body_doc: ${{ steps.spec_body.outputs.doc }} spec_body_recorded: ${{ steps.spec_body.outputs.recorded }} duration_seconds: ${{ steps.duration.outputs.seconds }} + track: ${{ steps.track.outputs.track }} + mutants: ${{ steps.track.outputs.mutants }} + ship: ${{ steps.track.outputs.ship }} steps: - name: Начать измерение стадии id: clock @@ -406,6 +412,58 @@ jobs: echo "МАТЕРИАЛ НЕ ЗАПУШЕН" >> "$GITHUB_STEP_SUMMARY" fi + # Трек решает цену захода (PROCESS.md §5, §10.4, #696): мутанты по диффу — + # только `track:ask` и метка `ci:mutants`; `ship` сливается без модели, + # если правка укладывается в механические рамки. Рамки судят дифф от + # merge-base с dev — работу задачи, а не то, что принёс dev, — поэтому шаг + # стоит до ребейза. Выход за рамки повышает трек до `show` здесь же: + # повысить трек вправе любой, понизить — только владелец (§5). Скрипт + # берётся из dev, как помощник ребейза: show/ship до ревью не ребейзятся, + # и ветка, отставшая от dev, его может ещё не нести. + - name: "Трек задачи и рамки ship (#696)" + id: track + env: + GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }} + NUM: ${{ github.event.issue.number }} + STAGE: ${{ needs.guard.outputs.stage }} + LABELS: ${{ needs.guard.outputs.labels }} + BRANCH: ${{ steps.branch.outputs.name }} + run: | + git fetch -q origin dev + tools="$RUNNER_TEMP/track-tools" + rm -rf "$tools" && mkdir -p "$tools" + git archive origin/dev scripts | tar -x -C "$tools" + head=HEAD + if [ -z "$BRANCH" ]; then head=origin/dev; fi + out=$(node "$tools/scripts/process-track.mjs" resolve --labels="$LABELS" --base=origin/dev --head="$head") + printf '%s\n' "$out" + track=$(printf '%s\n' "$out" | sed -n 's/^track=//p') + mutants=$(printf '%s\n' "$out" | sed -n 's/^mutants=//p') + ship=false + if [ "$track" = "ship" ] && [ "$STAGE" = "code" ] && [ -n "$BRANCH" ]; then + limits=$(node "$tools/scripts/process-track.mjs" ship-limits --base=origin/dev --head=HEAD) + printf '%s\n' "$limits" + if printf '%s\n' "$limits" | grep -qx 'ship=true'; then + ship=true + else + violations=$(printf '%s\n' "$limits" | sed -n 's/^violations=//p') + track=show + cat > /tmp/ship.md < track:show: $violations" + fi + fi + { echo "track=$track"; echo "mutants=$mutants"; echo "ship=$ship"; } >> "$GITHUB_OUTPUT" + echo "- трек **$track** · мутанты по диффу: $mutants · слияние без модели: $ship" >> "$GITHUB_STEP_SUMMARY" + # Ревьюер обязан смотреть тот же код, который уедет в dev (#257). Раньше # ревью шло по ветке как есть, а слияние делало ребейз — проверенный SHA и # слитый SHA были разными коммитами. Пока расхождение с dev текстовое, @@ -427,6 +485,7 @@ jobs: GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }} BRANCH: ${{ steps.branch.outputs.name }} NUM: ${{ github.event.issue.number }} + TRACK: ${{ steps.track.outputs.track }} # rebase, в отличие от commit, не принимает -c user.*: он запускает # свои процессы и требует личность в окружении, иначе падает с # «unable to auto-detect email address». @@ -441,6 +500,21 @@ jobs: exit 0 fi behind=$(git rev-list --count "HEAD..origin/dev") + # #696: на show/ship ветка до ревью к dev не приводится, если + # сливается с ним без конфликта. Ребейз — один раз, при слиянии, и + # кандидат проходит Validate до push в dev (merge-candidate.mjs, #492): + # в dev по-прежнему уезжает только проверенный CI SHA. Каждый сдвиг dev + # больше не оплачивается force-push, новым SHA и новым прогоном до + # ревью. На ask ребейз остаётся (#257): ревьюер читает то дерево, + # которое уедет в dev. + if [ "$TRACK" = "show" ] || [ "$TRACK" = "ship" ]; then + if git merge-tree --write-tree origin/dev HEAD >/dev/null 2>&1; then + echo "note=Ветка не приводилась к dev (трек $TRACK, #696): dev впереди на $behind коммит(ов), слияние без конфликта. Материал — ветка как есть; ребейз будет один раз, при слиянии, и кандидат пройдёт Validate до push в dev." >> "$GITHUB_OUTPUT" + echo "трек $TRACK: dev впереди на $behind коммит(ов), слияние чистое — ребейз отложен до слияния" + exit 0 + fi + echo "трек $TRACK: слияние с dev не чистое — ветка приводится к dev до ревью" + fi before=$(git rev-parse "origin/$BRANCH") echo "dev впереди на $behind коммит(ов) — привожу ветку" # #643: doc-коммит ветки конфликтует с документами других задач в dev @@ -627,13 +701,15 @@ jobs: echo "S7-code-review -> S6-in-progress (ревью не запускалось)" # Мутанты по диффу бегут только по запросу (#510): до ревью конвейер - # запускает Validate с мутантами на материале и ждёт его. Красный или + # запускает Validate на материале и ждёт его — с мутантами на `ask` и по + # метке `ci:mutants`, лёгкий на `show`/`ship` (#696). Лёгкому годится и + # завершённый push-прогон на том же SHA: dispatch повторил бы его. Красный или # пропавший прогон возвращает задачу автору без ревью — цикл не # тратится на код, который CI уже отверг (08.09: #437 дважды ушёл в S6 # после запущенного 15-минутного ревью). Этап spec кода не несёт и # гейт не проходит; повторное применение вердикта (#499) — тоже: там # слияние само дожидается Validate на кандидате. - - name: Validate с мутантами на материале + - name: Validate на материале id: gate if: steps.rebase.outputs.conflict != 'true' env: @@ -642,6 +718,7 @@ jobs: REUSE: ${{ steps.reuse.outputs.reuse }} BRANCH: ${{ steps.branch.outputs.name }} SHA: ${{ steps.material.outputs.sha }} + MUTANTS: ${{ steps.track.outputs.mutants }} run: | if [ "$STAGE" != "code" ] || [ "$REUSE" = "true" ] || [ -z "$BRANCH" ]; then echo "гейт не применяется: этап $STAGE, reuse=${REUSE:-false}, ветка ${BRANCH:-dev}" @@ -655,7 +732,8 @@ jobs: # S7), страховка — process-reconcile. Зелёный или красный завершённый # прогон гейт и без ожидания возвращает сразу. set +e - node scripts/validate-gate.mjs --repo="${{ github.repository }}" --ref="$BRANCH" --sha="$SHA" --no-wait + node scripts/validate-gate.mjs --repo="${{ github.repository }}" --ref="$BRANCH" --sha="$SHA" --no-wait \ + --mutants="${MUTANTS:-true}" code=$? set -e case "$code" in @@ -680,6 +758,7 @@ jobs: SHA: ${{ steps.material.outputs.sha }} VALIDATE_RUN_ID: ${{ steps.gate.outputs.run_id }} VALIDATE_URL: ${{ steps.gate.outputs.url }} + MUTANTS: ${{ steps.track.outputs.mutants }} run: | dir="$RUNNER_TEMP/review-pending" mkdir -p "$dir" @@ -693,7 +772,9 @@ jobs: (cd "$dir" && sha256sum pending.json > manifest.sha256) echo "artifact=review-pending-${NUM}-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" >> "$GITHUB_OUTPUT" short=$(git rev-parse --short "$SHA") - echo "Validate с мутантами на \`$short\` идёт — раннер освобождён, раунд продолжится по завершении прогона${VALIDATE_URL:+ ($VALIDATE_URL)}." >> "$GITHUB_STEP_SUMMARY" + kind="Validate с мутантами" + if [ "$MUTANTS" = "false" ]; then kind="Лёгкий Validate"; fi + echo "$kind на \`$short\` идёт — раннер освобождён, раунд продолжится по завершении прогона${VALIDATE_URL:+ ($VALIDATE_URL)}." >> "$GITHUB_STEP_SUMMARY" - name: Сохранить маркер ожидания if: steps.gate.outputs.proceed == 'pending' @@ -714,10 +795,13 @@ jobs: RESULT: ${{ steps.gate.outputs.result }} NOTE: ${{ steps.gate.outputs.note }} URL: ${{ steps.gate.outputs.url }} + MUTANTS: ${{ steps.track.outputs.mutants }} run: | short=$(git rev-parse --short "$SHA") + kind="Validate с мутантами" + if [ "$MUTANTS" = "false" ]; then kind="Validate"; fi cat > /tmp/gate.md < S6-in-progress (Validate с мутантами: $RESULT)" + echo "S7-code-review -> S6-in-progress ($kind: $RESULT)" # Ревьюер перегонял tsc, юниты и сборку заново в каждом раунде, хотя # Validate на том же SHA уже зелёный (#343). Это не тщательность: бюджет @@ -800,7 +884,7 @@ jobs: - name: Собрать контракт материала между стадиями id: prepared - if: steps.gate.outputs.proceed == 'true' && steps.reuse.outputs.reuse != 'true' + if: steps.gate.outputs.proceed == 'true' && steps.reuse.outputs.reuse != 'true' && steps.track.outputs.ship != 'true' env: NUM: ${{ github.event.issue.number }} STAGE: ${{ needs.guard.outputs.stage }} @@ -836,7 +920,7 @@ jobs: echo "artifact=review-prepared-${NUM}-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" >> "$GITHUB_OUTPUT" - name: Передать подтверждённый материал модели - if: steps.gate.outputs.proceed == 'true' && steps.reuse.outputs.reuse != 'true' + if: steps.gate.outputs.proceed == 'true' && steps.reuse.outputs.reuse != 'true' && steps.track.outputs.ship != 'true' uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: name: ${{ steps.prepared.outputs.artifact }} @@ -879,7 +963,9 @@ jobs: contents: read issues: write needs: [guard, prepare] - if: needs.prepare.outputs.proceed == 'true' && needs.prepare.outputs.reuse != 'true' + # #696: `ship` в рамках сливается без модели — пакетное ревью диапазона + # перед бетой (PROCESS.md §11.7, ship-review.yml). + if: needs.prepare.outputs.proceed == 'true' && needs.prepare.outputs.reuse != 'true' && needs.prepare.outputs.ship != 'true' runs-on: ubuntu-24.04 concurrency: group: process-issue-${{ github.event.issue.number }} @@ -945,22 +1031,46 @@ jobs: test "$(git rev-parse HEAD)" = "$MATERIAL_SHA" test "$(git rev-parse 'HEAD^{tree}')" = "$MATERIAL_TREE" + # #696: окружение ставится по нужде. Ревью ТЗ кода не исполняет — ни + # `npm ci`, ни браузера. Ревью `show` ставит Chromium, только если тело + # issue называет смоук или браузер; `ask` — как прежде, всегда. Тело не + # прочитано — браузер ставится: лишняя минута дешевле непрогнанного смоука. + - name: Что ревьюеру нужно из окружения + id: env_needs + env: + GH_TOKEN: ${{ github.token }} + NUM: ${{ github.event.issue.number }} + STAGE: ${{ needs.guard.outputs.stage }} + TRACK: ${{ needs.prepare.outputs.track }} + run: | + deps=true; browser=true + if [ "$STAGE" = "spec" ]; then deps=false; browser=false; fi + if [ "$STAGE" = "code" ] && [ "$TRACK" = "show" ]; then + if body=$(gh issue view "$NUM" --repo "${{ github.repository }}" --json body --jq .body 2>/dev/null); then + if ! printf '%s' "$body" | grep -qiE 'smoke|смоук|смок|playwright|браузер|demo/'; then browser=false; fi + fi + fi + { echo "deps=$deps"; echo "browser=$browser"; } >> "$GITHUB_OUTPUT" + echo "- окружение ревьюера: npm ci $deps · Chromium $browser" >> "$GITHUB_STEP_SUMMARY" + # Зависимости ставятся ПОСЛЕ переключения на ветку задачи: lockfile мог # измениться именно в ней, и установка по копии из dev дала бы не то дерево. - name: Установить зависимости + if: steps.env_needs.outputs.deps == 'true' run: npm ci # Браузер нужен не всякому ревью (см. правило выбора гейтов в промпте), # но когда нужен — качать его заново дороже, чем держать в кэше. - name: Кэш браузеров Playwright id: pw + if: steps.env_needs.outputs.browser == 'true' uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 with: path: ~/.cache/ms-playwright key: playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }} - name: Установить Chromium - if: steps.pw.outputs.cache-hit != 'true' + if: steps.env_needs.outputs.browser == 'true' && steps.pw.outputs.cache-hit != 'true' # Без --with-deps: системные библиотеки Chromium предустановлены в # образе раннера, а apt при промахе кэша съедал минуты из бюджета # ревью и подолгу перебирал недоступное azure-зеркало (#175). Если @@ -1020,6 +1130,9 @@ jobs: ничего не вернул на правки и цикла не образует (#227). Номер захода нужен для имени документа — два документа с одинаковым номером затёрли бы друг друга. + Трек: ${{ needs.prepare.outputs.track || 'ask' }} (PROCESS.md §5) · мутанты по диффу на материале: ${{ needs.prepare.outputs.mutants == 'false' && 'не запрашивались' || 'прогнаны Validate' }} + + ${{ needs.prepare.outputs.track == 'show' && needs.guard.outputs.stage == 'code' && 'Трек show: объём и серьёзность находок — docs/process/REVIEWER.md, «Трек show» (#696). Отсутствие мутантов по диффу — не находка.' || '' }} ${{ needs.prepare.outputs.rebase_note }} @@ -1107,8 +1220,8 @@ jobs: и в повторном раунде тоже: `npx tsc --noEmit`, `npm test`, `npm run build` со сверкой трёх копий бандла, плюс `node scripts/check-docs.mjs`, если diff трогает `src/**`. - Зависимости уже установлены workflow, Chromium тоже — `npm ci` - выполнять не нужно. По диффу и AC: браузерные смоки — названные в AC + ${{ needs.guard.outputs.stage == 'spec' && 'Зависимости и Chromium на этапе spec не ставились (#696).' || (needs.prepare.outputs.track == 'show' && 'Зависимости установлены (`npm ci` не нужен); Chromium — только если тело issue называет смоук (#696).' || 'Зависимости уже установлены workflow, Chromium тоже — `npm ci` выполнять не нужно.') }} + По диффу и AC: браузерные смоки — названные в AC плюс вывод `node scripts/smoke-select.mjs --base --head `, приложенный к комментарию с решением по каждой строке: прогнал либо не прогнал и почему. Три вида ответа инструмента разные: «прямое @@ -1248,6 +1361,7 @@ jobs: MODEL_RESULT: ${{ needs.model_review.result }} PROCEED: ${{ needs.prepare.outputs.proceed }} REUSE: ${{ needs.prepare.outputs.reuse }} + SHIP: ${{ needs.prepare.outputs.ship }} run: | if [ "$PREPARE_RESULT" != "success" ]; then echo "::error::стадия deterministic prerequisites завершилась: $PREPARE_RESULT" @@ -1263,7 +1377,8 @@ jobs: echo "proceed=false" >> "$GITHUB_OUTPUT" exit 0 fi - if [ "$REUSE" != "true" ] && [ "$MODEL_RESULT" != "success" ]; then + # #696: `ship` в рамках модель не вызывает — её результата не ждём. + if [ "$REUSE" != "true" ] && [ "$SHIP" != "true" ] && [ "$MODEL_RESULT" != "success" ]; then echo "::error::стадия model review завершилась: $MODEL_RESULT" exit 1 fi @@ -1282,7 +1397,7 @@ jobs: node-version: 22 - name: Получить результат модели - if: steps.ready.outputs.proceed == 'true' && needs.prepare.outputs.reuse != 'true' + if: steps.ready.outputs.proceed == 'true' && needs.prepare.outputs.reuse != 'true' && needs.prepare.outputs.ship != 'true' uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 with: name: review-result-${{ github.event.issue.number }}-${{ github.run_id }}-${{ github.run_attempt }} @@ -1290,7 +1405,7 @@ jobs: - name: Проверить полноту и происхождение результата id: result - if: steps.ready.outputs.proceed == 'true' && needs.prepare.outputs.reuse != 'true' + if: steps.ready.outputs.proceed == 'true' && needs.prepare.outputs.reuse != 'true' && needs.prepare.outputs.ship != 'true' # #556: единственное, что недоверенная стадия модели может передать # дальше, — этот artifact, и принимается он как ввод противника: полный # набор файлов, сходящиеся суммы, совпадение КАЖДОГО поля паспорта с @@ -1326,7 +1441,7 @@ jobs: # Ревьюер пишет только в docs/reviews/. Что именно попадёт в коммит, # решает этот шаг, а не модель: всё остальное откатывается. - name: Опубликовать документ ревью - if: steps.ready.outputs.proceed == 'true' && needs.prepare.outputs.reuse != 'true' + if: steps.ready.outputs.proceed == 'true' && needs.prepare.outputs.reuse != 'true' && needs.prepare.outputs.ship != 'true' env: TOKEN: ${{ secrets.HP_PROCESS_TOKEN }} BRANCH: ${{ needs.prepare.outputs.branch }} @@ -1499,7 +1614,7 @@ jobs: # достижим там из необновлённой локальной ветки. Читателю отчёта от этого # пользы нет — он достанет только то, что есть на origin. - name: "Материал раунда воспроизводим (#413)" - if: steps.ready.outputs.proceed == 'true' && needs.prepare.outputs.reuse != 'true' + if: steps.ready.outputs.proceed == 'true' && needs.prepare.outputs.reuse != 'true' && needs.prepare.outputs.ship != 'true' env: NUM: ${{ github.event.issue.number }} STAGE: ${{ needs.guard.outputs.stage }} @@ -1524,10 +1639,28 @@ jobs: REUSE_DOC: ${{ needs.prepare.outputs.reuse_doc }} REUSE_ROUND: ${{ needs.prepare.outputs.reuse_round }} REUSE_TREE: ${{ needs.prepare.outputs.reuse_tree }} + SHIP: ${{ needs.prepare.outputs.ship }} + MATERIAL: ${{ needs.prepare.outputs.material_sha }} + VALIDATE_URL: ${{ needs.prepare.outputs.validate_url }} GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }} NUM: ${{ github.event.issue.number }} run: | - if [ "$REUSE" = "true" ]; then + # Повторно применимый зелёный вердикт (#499) главнее ship: код уже + # прочитан моделью, и пакетному ревью перечитывать его незачем. + if [ "$SHIP" = "true" ] && [ "$REUSE" != "true" ]; then + # #696: `ship` в рамках (PROCESS.md §5) сливается без ревью модели + # после зелёного лёгкого Validate. Комментарий — не вердикт ревью и + # так себя не называет: ревью не было. Машинный маркер находит + # пакетное ревью диапазона перед бетой (ship-review.mjs, §11.7). + verdict=green; high=0 + short=$(printf '%s' "$MATERIAL" | cut -c1-8) + cat > /tmp/ship-merge.md <.md\`. + + + EOF + gh issue comment "$NUM" --repo "${{ github.repository }}" --body-file /tmp/ship-merge.md + elif [ "$REUSE" = "true" ]; then # Модель не вызывалась: вердикт — записанный конвейером зелёный # прошлого захода, дерево вне docs/reviews с тех пор не менялось (#499). verdict=green; high=0 @@ -1607,6 +1740,8 @@ jobs: BRANCH: ${{ needs.prepare.outputs.branch }} NUM: ${{ github.event.issue.number }} MATERIAL_SHA: ${{ needs.prepare.outputs.material_sha }} + # #696: show/ship сливаются по лёгкому Validate на кандидате. + MUTANTS: ${{ needs.prepare.outputs.mutants }} run: | if [ -z "$BRANCH" ]; then echo "::error::ветки задачи нет — сливать нечего" @@ -1614,7 +1749,7 @@ jobs: exit 0 fi node scripts/merge-candidate.mjs --branch="$BRANCH" --material="$MATERIAL_SHA" \ - --issue="$NUM" --repo="${{ github.repository }}" + --issue="$NUM" --repo="${{ github.repository }}" --mutants="${MUTANTS:-true}" - name: Переставить метку if: steps.ready.outputs.proceed == 'true' @@ -1661,13 +1796,14 @@ jobs: MODEL_RESULT: ${{ needs.model_review.result }} PROCEED: ${{ needs.prepare.outputs.proceed }} REUSE: ${{ needs.prepare.outputs.reuse }} + SHIP: ${{ needs.prepare.outputs.ship }} run: | stage="публикация/интеграция" detail="Модель уже завершила работу; её запечатанный результат сохранён artifact-ом этого run, но не был применён." if [ "$PREPARE_RESULT" != "success" ]; then stage="deterministic prerequisites" detail="Модель не запускалась, цикл ревью не израсходован." - elif [ "$PROCEED" = "true" ] && [ "$REUSE" != "true" ] && [ "$MODEL_RESULT" != "success" ]; then + elif [ "$PROCEED" = "true" ] && [ "$REUSE" != "true" ] && [ "$SHIP" != "true" ] && [ "$MODEL_RESULT" != "success" ]; then stage="model review" detail="Полного валидного результата модели нет; метка не менялась." fi diff --git a/.github/workflows/publish-prerelease.yml b/.github/workflows/publish-prerelease.yml index 5b7015ac..4ed8eeba 100644 --- a/.github/workflows/publish-prerelease.yml +++ b/.github/workflows/publish-prerelease.yml @@ -91,6 +91,14 @@ jobs: REPO: ${{ github.repository }} SHA: ${{ steps.candidate.outputs.sha }} run: node scripts/release-gate.mjs "$SHA" + # #696, PROCESS.md §11.7: задачи track:ship слиты без ревью модели — бета + # публикуется, только когда их код прочитало пакетное ревью диапазона. + - name: Ship tasks of the range are batch-reviewed + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ steps.candidate.outputs.tag }} + SHA: ${{ steps.candidate.outputs.sha }} + run: node scripts/ship-review.mjs check --tag="$TAG" --candidate="$SHA" --repo="$GITHUB_REPOSITORY" - name: Bind issue membership to the exact candidate env: GH_TOKEN: ${{ github.token }} diff --git a/.github/workflows/ship-review.yml b/.github/workflows/ship-review.yml new file mode 100644 index 00000000..10d99186 --- /dev/null +++ b/.github/workflows/ship-review.yml @@ -0,0 +1,334 @@ +name: "Бета: пакетное ревью ship" +run-name: "Ship review ${{ inputs.tag }}" + +# #696, PROCESS.md §11.7: задачи `track:ship` сливаются без ревью модели — +# правка в механических рамках и зелёный лёгкий Validate. Их код читает одна +# сессия модели перед бетой: все ship-задачи диапазона «прошлый тег..кандидат». +# Гейт публикации беты (`ship-review.mjs check`) требует этот документ, если в +# диапазоне есть ship-задачи. +# +# Только `workflow_dispatch`: файл исполняется с ветки прогона (`--ref dev`), +# зеркало в `main` не нужно. Три job, как у release-review.yml (#551, #556): +# детерминированный вход, модель без права записи, детерминированная +# публикация документа в `dev`. +on: + workflow_dispatch: + inputs: + tag: + description: "Beta tag the review is for, for example v1.79.0-beta.1" + required: true + type: string + candidate: + description: "Exact candidate SHA; empty = the dev tip" + required: false + type: string + default: "" + force: + description: "Review again even when the document already exists in dev" + required: false + type: boolean + default: false + +permissions: + contents: read + +concurrency: + group: ship-review-${{ inputs.tag }} + cancel-in-progress: false + +jobs: + prepare: + name: "Ship-ревью: задачи диапазона" + runs-on: ubuntu-24.04 + timeout-minutes: 10 + permissions: + contents: read + issues: read + outputs: + proceed: ${{ steps.range.outputs.proceed }} + candidate: ${{ steps.range.outputs.candidate }} + base: ${{ steps.range.outputs.base }} + doc: ${{ steps.range.outputs.doc }} + issues: ${{ steps.range.outputs.issues }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + fetch-depth: 0 + ref: dev + persist-credentials: false + + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 + with: + node-version: 22 + + - name: Кандидат, база и ship-задачи + id: range + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ inputs.tag }} + CANDIDATE: ${{ inputs.candidate }} + FORCE: ${{ inputs.force }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + run: | + doc=$(node scripts/ship-review.mjs doc --tag="$TAG") + git fetch -q --tags origin + if [ -z "$CANDIDATE" ]; then CANDIDATE=$(git rev-parse origin/dev); fi + git cat-file -e "$CANDIDATE^{commit}" + CANDIDATE=$(git rev-parse "$CANDIDATE^{commit}") + if [ "$FORCE" != "true" ] && git cat-file -e "origin/dev:$doc" 2>/dev/null; then + echo "::notice::$doc уже есть в dev — повторное ревью не запускается (force=true, чтобы переснять)" + echo "proceed=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + out="$RUNNER_TEMP/ship-review-input" + node scripts/ship-review.mjs prepare --tag="$TAG" --candidate="$CANDIDATE" \ + --out="$out" --repo="${{ github.repository }}" --run-url="$RUN_URL" | tee "$RUNNER_TEMP/range.txt" + cat "$RUNNER_TEMP/range.txt" >> "$GITHUB_OUTPUT" + echo "candidate=$CANDIDATE" >> "$GITHUB_OUTPUT" + issues=$(sed -n 's/^issues=//p' "$RUNNER_TEMP/range.txt") + if [ -z "$issues" ]; then + echo "::notice::ship-задач в диапазоне нет — ревью не нужно" + echo "proceed=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + echo "proceed=true" >> "$GITHUB_OUTPUT" + (cd "$out" && sha256sum brief.md ship-issues.json > manifest.sha256) + cat "$out/brief.md" >> "$GITHUB_STEP_SUMMARY" + + - name: Передать вход модели + if: steps.range.outputs.proceed == 'true' + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: ship-review-input-${{ github.run_id }}-${{ github.run_attempt }} + path: ${{ runner.temp }}/ship-review-input + if-no-files-found: error + retention-days: 3 + + model_review: + name: "Ship-ревью: работа модели" + needs: prepare + if: needs.prepare.outputs.proceed == 'true' + runs-on: ubuntu-24.04 + timeout-minutes: 45 + # Недоверенная стадия без права записи (#556): документ публикует + # `publish`. `github_token` у шага Review обязателен — без него action + # меняет OIDC на собственный App-токен с правом записи. + permissions: + contents: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + fetch-depth: 0 + ref: ${{ needs.prepare.outputs.candidate }} + persist-credentials: false + + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 + with: + node-version: 22 + cache: npm + + - name: Получить вход + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + name: ship-review-input-${{ github.run_id }}-${{ github.run_attempt }} + path: ${{ runner.temp }}/ship-review-input + + - name: Проверить вход и кандидата + env: + CANDIDATE: ${{ needs.prepare.outputs.candidate }} + run: | + (cd "$RUNNER_TEMP/ship-review-input" && sha256sum -c manifest.sha256) + test "$(git rev-parse HEAD)" = "$CANDIDATE" + + - name: Установить зависимости + run: npm ci + + - name: Кэш браузеров Playwright + id: pw + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 + with: + path: ~/.cache/ms-playwright + key: playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }} + + - name: Установить Chromium + if: steps.pw.outputs.cache-hit != 'true' + run: npx playwright install chromium + + # Тот же обход, что у конвейера (_process.yml, anthropics issue 1817). + - name: Установить Claude Code детерминированно + id: claude_bin + run: | + src=$(ls "$RUNNER_WORKSPACE"/../_actions/anthropics/claude-code-*/v1/src/entrypoints/run.ts 2>/dev/null | head -1) + ver=$(grep -oE 'claudeCodeVersion = "[0-9]+\.[0-9]+\.[0-9]+"' "$src" 2>/dev/null | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' || true) + ver="${ver:-2.1.265}" + base=https://downloads.claude.ai/claude-code-releases + bin="$HOME/.local/bin/claude" + mkdir -p "$(dirname "$bin")" + curl -fsSL --retry 3 "$base/$ver/linux-x64/claude" -o "$bin" + sum=$(curl -fsSL --retry 3 "$base/$ver/manifest.json" | jq -r '.platforms["linux-x64"].checksum') + echo "$sum $bin" | sha256sum -c - + chmod +x "$bin" + "$bin" --version + echo "path=$bin" >> "$GITHUB_OUTPUT" + + - name: Review + id: review + uses: anthropics/claude-code-action@9cdae7f0d995e3ba7c33f226087fdf82a59cd520 # v1 + env: + REVIEW_DOC: ${{ runner.temp }}/ship-review.md + REVIEW_INPUT: ${{ runner.temp }}/ship-review-input + with: + claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} + github_token: ${{ secrets.GITHUB_TOKEN }} + path_to_claude_code_executable: ${{ steps.claude_bin.outputs.path }} + prompt: | + Ты ревьюер кода проекта House Plan. Язык ответа — русский. + + Бета: ${{ inputs.tag }} · кандидат ${{ needs.prepare.outputs.candidate }} + (рабочая копия уже на нём) · база диапазона: ${{ needs.prepare.outputs.base || 'нет' }}. + Задачи: ${{ needs.prepare.outputs.issues }} — вход в $REVIEW_INPUT/brief.md + (строка ТЗ каждой задачи и её коммиты) и $REVIEW_INPUT/ship-issues.json. + + Эти задачи шли по треку ship и слиты в dev без ревью модели: правка + в механических рамках и зелёный лёгкий Validate. Ты — первый, кто + читает их код. Правила — docs/process/REVIEWER.md, раздел + «Пакетное ревью ship», канон — PROCESS.md §5 и §11.7. Прочитай их + первыми. + + По каждой задаче: `git show ` каждого коммита; делает ли код + то, что сказано в строке ТЗ, и только это; не ломает ли соседнее + поведение; не вышла ли правка из трека ship по смыслу (новый + UX-контракт, геометрия, конфиг, перф) — это находка Medium с + предложением трека. Исполняй, где дёшево: `npm test`, смоук, + который называет строка ТЗ или выбирает + `node scripts/smoke-select.mjs --base ^ --head `. + + Серьёзность: High — дефект, который увидит пользователь беты, или + правка не делает заявленного; High блокирует публикацию беты. + Medium — поведение вне заявленного или вне рамок трека; Low — + остальное. Находка чинится отдельной задачей, ты ничего не правишь. + + Ты ничего не правишь и не публикуешь: ни код, ни issue, ни + комментарии. Права на запись у тебя нет. Любые изменения рабочей + копии будут отброшены — после проверок восстанови её сам + (`git checkout -- . && git clean -fd`), если что-то менял. + + Напиши документ в файл по пути из переменной REVIEW_DOC (абсолютный, + вне репозитория). Первой строкой после заголовка — + `Итог: High N · Medium N · Low N`. Затем по задаче на раздел + `## #NN — заголовок`: что проверил (команда → результат), находки с + воспроизведением, чего не проверял. + + Затем верни JSON по схеме — последнее обязательное действие. + claude_args: | + --max-turns 150 + --allowedTools Read,Write,Grep,Glob,Bash + --json-schema '{"type":"object","properties":{"high":{"type":"integer"},"medium":{"type":"integer"},"low":{"type":"integer"},"summary":{"type":"string"}},"required":["high","medium","low","summary"]}' + + - name: Запечатать результат модели + env: + SOURCE: ${{ runner.temp }}/ship-review.md + OUT: ${{ steps.review.outputs.structured_output }} + run: | + test -s "$SOURCE" || { echo "::error::модель не оставила документ ревью"; exit 1; } + dir="$RUNNER_TEMP/ship-review-result" + mkdir -p "$dir" + printf '%s' "$OUT" > "$dir/result.json" + jq -e '(.high|type=="number") and (.medium|type=="number") and (.low|type=="number") and (.summary|type=="string")' \ + "$dir/result.json" >/dev/null + cp "$SOURCE" "$dir/ship-review.md" + (cd "$dir" && sha256sum ship-review.md result.json > manifest.sha256) + + - name: Передать результат публикации + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: ship-review-result-${{ github.run_id }}-${{ github.run_attempt }} + path: ${{ runner.temp }}/ship-review-result + if-no-files-found: error + retention-days: 3 + + publish: + name: "Ship-ревью: документ в dev" + needs: [prepare, model_review] + runs-on: ubuntu-24.04 + timeout-minutes: 10 + permissions: + contents: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + fetch-depth: 0 + ref: dev + persist-credentials: false + + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 + with: + node-version: 22 + + - name: Получить результат модели + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + name: ship-review-result-${{ github.run_id }}-${{ github.run_attempt }} + path: ${{ runner.temp }}/ship-review-result + + # Модель пишет только текст. Путь, машинный блок (его читает гейт беты), + # индекс и коммит решает этот шаг. + - name: Опубликовать документ + env: + TOKEN: ${{ secrets.HP_PROCESS_TOKEN }} + TAG: ${{ inputs.tag }} + DOC: ${{ needs.prepare.outputs.doc }} + CANDIDATE: ${{ needs.prepare.outputs.candidate }} + BASE: ${{ needs.prepare.outputs.base }} + ISSUES: ${{ needs.prepare.outputs.issues }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + run: | + dir="$RUNNER_TEMP/ship-review-result" + (cd "$dir" && sha256sum -c manifest.sha256) + test "$DOC" = "$(node scripts/ship-review.mjs doc --tag="$TAG")" + high=$(jq -r '.high' "$dir/result.json") + medium=$(jq -r '.medium' "$dir/result.json") + low=$(jq -r '.low' "$dir/result.json") + for attempt in 1 2 3; do + git fetch -q origin dev + git reset -q --hard origin/dev + git clean -fdq + mkdir -p docs/reviews + { + cat "$dir/ship-review.md" + printf '\n\n' + node -e ' + import("./scripts/ship-review.mjs").then((m) => process.stdout.write(m.anchorBlock({ + tag: process.env.TAG, candidate: process.env.CANDIDATE, base: process.env.BASE || null, + issues: String(process.env.ISSUES || "").split(",").filter(Boolean).map(Number), + high: Number(process.argv[1]), medium: Number(process.argv[2]), low: Number(process.argv[3]), + runUrl: process.env.RUN_URL, + }))); + ' "$high" "$medium" "$low" + } > "$DOC" + node scripts/reviews-index.mjs --dir=docs/reviews --strict + git add -- "$DOC" docs/reviews/INDEX.md + git diff --cached --name-only | node scripts/review-doc-guard.mjs + git -c user.name="claude[bot]" \ + -c user.email="209825114+claude[bot]@users.noreply.github.com" \ + commit -q -F - <> "$GITHUB_STEP_SUMMARY" + echo "Задачи $ISSUES · High $high · Medium $medium · Low $low — \`$DOC\` в dev." >> "$GITHUB_STEP_SUMMARY" + echo "::notice::$DOC опубликован: High $high · Medium $medium · Low $low" + exit 0 + fi + echo "::warning::dev ушёл вперёд — попытка $attempt из 3, документ собирается заново" + sleep $((attempt * 10)) + done + echo "::error::документ ревью не опубликован в dev за три попытки" + exit 1 diff --git a/AGENTS.md b/AGENTS.md index ff8938d0..48a9f97d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -81,7 +81,10 @@ a spec review. Any agent may raise a track with a reason; only the owner lowers `small` and `trivial` read as `show`. An **infrastructure** task — not a single class A file — skips analysis and spec and enters at `S7-code-review` (`PROCESS.md` §1). Every change is code-reviewed; on `ship` the review moves to a -batch review of the beta range before the tag. Review checks scope, risks and the +batch review of the beta range before the tag (`ship-review.yml`, `PROCESS.md` +§11.7). The review pipeline prices each round by track (§10.4): diff mutants +only on `ask` or with `ci:mutants`; a rebase before review only on `ask` or when +the branch does not merge cleanly into `dev`. Review checks scope, risks and the evidence from executed tests, but does not replace executing them. ## Specs diff --git a/PROCESS.md b/PROCESS.md index d2d5a493..10d2a999 100644 --- a/PROCESS.md +++ b/PROCESS.md @@ -559,9 +559,8 @@ dev, ни при публикации документа код-ревью: ин **Рамки `track:ship`** механические: дифф `src/**` не больше 30 строк, без новых файлов в `src/**`, без ключей i18n, без полей конфига и без Python. Выход за рамки переводит задачу в `track:show`. Рамки и слияние `ship` без ревью модели -проверяет и исполняет конвейер (§10.4, #696); **пока #696 не влит**, `ship` и -`show` проходят конвейер как прежний лёгкий трек — с мутантами по диффу и -документом код-ревью. +проверяет и исполняет конвейер (§10.4, #696); код `ship` читает пакетное ревью +диапазона перед бетой (§11.7). **Подсказка аналитику.** `track:show` уместен, когда выполнено всё сразу: @@ -600,8 +599,8 @@ dev, ни при публикации документа код-ревью: ин | `ci:golden` | golden на ветке и приёмку сдвинутых кадров в самой задаче | | `ci:mutants` | мутанты по диффу на кандидате ревью и слияния | -Как конвейер читает эти метки, описывает §10.4; до #696 и #697 действуют -прежние правила §8. +Как конвейер читает `ci:mutants`, описывает §10.4 (#696); `ci:full` и +`ci:golden` до #697 действуют по прежним правилам §8. **Прежние метки.** `trivial` и `small` читаются как `track:show`; продуктовая задача без трековой метки — как `track:ask`; инфраструктурная задача (§1) без @@ -1073,6 +1072,38 @@ Matysh/houseplan-card/.github/workflows/_<имя>.yml@dev` с `secrets: inherit` `test/default-branch-workflows.test.mjs`. `performance.yml` в список не входит: по расписанию он судит `main` собственным телом из `main`. +**Цена захода зависит от трека** (#696, решение владельца 2026-09-28). Трек +снимает `scripts/process-track.mjs` в стадии подготовки — по текущим меткам и +диффу от merge-base с `dev`, до ребейза. Прежние метки читаются по §5.1: +инфраструктурная задача без трековой метки — `show`, продуктовая — `ask`. + +| | `ship` | `show` | `ask` | +|---|---|---|---| +| Validate на материале | лёгкий; годится завершённый push-прогон на этом SHA | лёгкий, как у `ship` | с мутантами по диффу, dispatch | +| Ребейз до ревью | нет, если `git merge-tree` с `dev` чистый | нет, если чистый | да (#257) | +| Ревью модели | нет — пакетное ревью перед бетой (§11.7) | корректность и AC; окружение — по нужде | полное | +| Проверка кандидата слияния | ждёт push-прогон кандидата; dispatch — только если его нет | как у `ship` | dispatch с мутантами | + +Метка `ci:mutants` возвращает мутанты по диффу на любом треке. Рамки `ship` +(§5) проверяет тот же шаг; выход за них — комментарий в issue и замена +`track:ship` на `track:show` в этом же заходе. Слияние `ship` оставляет в issue +комментарий с машинным маркером `hp:ship-merge`. Это не вердикт ревью и так себя +не называет; по маркеру пакетное ревью находит задачи диапазона. Повторно +применимый зелёный вердикт (#499) главнее `ship`: код уже прочитан. + +В `dev` по-прежнему уезжает только SHA с зелёным Validate. Без ребейза до ревью +кандидат собирается один раз, при слиянии, и лёгкий Validate проходит там. +Ориентир — данные #695: `small` стоил медиану 115 минут и 12 событий, `trivial` — +41 минуту и 5 событий; `show` целится в уровень `trivial`. + +**Ревью `show` судит корректность и AC.** Medium — дефект поведения, который +увидит пользователь, или невыполненный AC. Бухгалтерия — нет мутанта или записи +в реестре, нечувствительный тест на побочный вызов, формулировка в документе — +Low и цикла не открывает. Отсутствие мутантов по диффу на `show` не находка: +полный реестр гоняется ночью. Ревью `show` не ставит Chromium, если тело issue +не называет смоук или браузер. Ревью ТЗ не ставит ни `npm ci`, ни браузер: кода +оно не исполняет. + **Ревью не начинается на красном коде** (#510). После фиксации материала конвейер запускает Validate с мутантами по диффу на этом SHA (`scripts/validate-gate.mjs`: `workflow_dispatch validate.yml -f mutants=true`). **Ждёт его не раннер, а событие** @@ -1146,7 +1177,8 @@ npm ci, Python и Chromium, оставаясь исполненной job: до никто не может выйти и о котором никто не узнает, для конвейера хуже громкой ошибки. -**Ветка приводится к `dev` до ревью, а не после** (#257). Раньше ревью читало ветку +**Ветка приводится к `dev` до ревью, а не после** (#257) — на треке `ask`. `show` +и `ship` с чистым слиянием ребейзятся один раз, при слиянии (#696, выше). Раньше ревью читало ветку как есть, а слияние делало ребейз — проверенный SHA и слитый SHA были разными коммитами. Пока расхождение с `dev` текстовое, ребейз упирается в конфликт и это видно; смысловое расхождение git склеивает молча, и в `dev` уезжает комбинация, @@ -1181,7 +1213,9 @@ npm ci, Python и Chromium, оставаясь исполненной job: до к другому диффу не применим, §2.10), публикация кандидата в ветку задачи, запуск Validate с мутантами на ней (#510) и ожидание зелёного dispatch-прогона **на этом SHA** — push-прогон мутантов не несёт — и только затем push в `dev` с lease на ту - вершину, поверх которой кандидат собран. Отклонённый lease — `dev` двинулся снова + вершину, поверх которой кандидат собран. На `show`/`ship` мутантов нет, и + лёгкий Validate кандидата уже запустил сам push в ветку: слияние ждёт этот + push-прогон, а dispatch шлёт, только если его нет за три минуты (#696). Отклонённый lease — `dev` двинулся снова — новая попытка; после третьей — `S6-in-progress` с комментарием; - красный Validate на кандидате или прогон, не появившийся за три минуты, — `S6-in-progress` с ссылкой; `S8-merged` ставится только после push. @@ -1371,6 +1405,36 @@ Golden, браузерные смоки, performance и полный HA-харн выпуск после такого отказа, исправляют причину и получают новый совместимый полный зелёный прогон на том же финальном SHA либо на новом SHA кандидата. +### 11.7 Пакетное ревью `ship` перед бетой + +Решение владельца 2026-09-28, issue #696. + +**Зачем.** `track:ship` сливается без ревью модели (§5, §10.4): правка в +механических рамках и зелёный лёгкий Validate. Прочитать её код обязан кто-то +до того, как она уйдёт пользователям беты. Одна сессия на все ship-задачи +диапазона дешевле ревью на каждую, а рамки ship держат объём малым. + +**Шаг.** Перед публикацией беты — `ship-review.yml` +(`gh workflow run ship-review.yml --ref dev -f tag=vX.Y.Z-beta.N`): + +- **вход** — issue из трейлеров `Issue: #NN` в диапазоне «прошлый тег..кандидат» + (тот же построитель, что `RELEASE-MEMBERSHIP.json`, #547), из них — ship: с + маркером `hp:ship-merge` в комментариях или с меткой `track:ship`. Собирает + `scripts/ship-review.mjs prepare`; ship-задач нет — модель не запускается; +- **суждение** — по строке ТЗ каждой задачи и её коммитам: делает ли код + заявленное и только его, не ломает ли соседнее, не вышла ли правка из ship по + смыслу. Правила ревьюера — `docs/process/REVIEWER.md`, «Пакетное ревью ship»; +- **выход** — `docs/reviews/SHIP-REVIEW-<тег>.md` в `dev` с машинным блоком: + задачи и счёт High/Medium/Low. У модели нет прав записи, документ публикует + детерминированный шаг. + +**Гейт беты.** `ship-review.mjs check` стоит в обоих путях публикации — +`publish-prerelease.yml` и `npm run release:prerelease` (включая `--check`). +Если в диапазоне есть ship-задачи, документ обязан лежать в кандидате или в +`dev`, покрывать их все и не нести High. Задача, слитая после ревью, требует +пересъёмки (`-f force=true`). High чинится отдельной задачей, затем ревью +переснимается. Medium и Low решает владелец, как в §11.5. + --- ## 12. Запрещено diff --git a/docs/process/AUTHOR.md b/docs/process/AUTHOR.md index 79dff7c6..ee6e9154 100644 --- a/docs/process/AUTHOR.md +++ b/docs/process/AUTHOR.md @@ -171,11 +171,17 @@ `node scripts/process-gate.mjs --issues`; после `S7-code-review` в ветку не пушить до вердикта; `S7` ставится один раз на заход ([§10.4](../../PROCESS.md#104-событийный-конвейер-метка-как-триггер)). -- Ревью не начинается на красном коде: конвейер сам гоняет Validate с - мутантами и возвращает красный в `S6-in-progress` без траты цикла +- Ревью не начинается на красном коде: конвейер сам гоняет Validate — с + мутантами на `ask`, лёгкий на `show`/`ship` — и возвращает красный в + `S6-in-progress` без траты цикла ([§10.4](../../PROCESS.md#104-событийный-конвейер-метка-как-триггер)). - Ветка приводится к `dev` до ревью, а не после: конфликт — возврат в - `S6-in-progress` до ревью ([§10.4](../../PROCESS.md#104-событийный-конвейер-метка-как-триггер)). + `S6-in-progress` до ревью; `show`/`ship` с чистым слиянием ребейзятся один + раз, при слиянии ([§10.4](../../PROCESS.md#104-событийный-конвейер-метка-как-триггер)). +- `ship` в рамках сливается без ревью модели; выход за рамки конвейер сам + переводит в `track:show`. Код `ship` читает пакетное ревью перед бетой + ([§10.4](../../PROCESS.md#104-событийный-конвейер-метка-как-триггер), + [§11.7](../../PROCESS.md#117-пакетное-ревью-ship-перед-бетой)). - Автор обязан дождаться вердикта, а не заканчивать сессию: `node scripts/wait-verdict.mjs --issue NN`, смотреть на метку, а не на комментарий; при `blocked` не ждать. После прогона ревью метка меняется diff --git a/docs/process/REVIEWER.md b/docs/process/REVIEWER.md index a4b73f69..efb6cbc1 100644 --- a/docs/process/REVIEWER.md +++ b/docs/process/REVIEWER.md @@ -129,6 +129,32 @@ - Запрещено: Medium-находки, оставленные как TODO в документе ревью; ревью-документы вне репозитория ([§12](../../PROCESS.md#12-запрещено)). +## Трек show + +- Ревью `show` судит корректность и AC: Medium — дефект поведения, который + увидит пользователь, или невыполненный AC; бухгалтерия — нет мутанта или + записи в реестре, нечувствительный тест на побочный вызов — Low и цикла не + открывает ([§10.4](../../PROCESS.md#104-событийный-конвейер-метка-как-триггер), + [§5](../../PROCESS.md#5-треки-ship-show-ask--метка-владельца)). +- Мутанты по диффу на `show` не запрашиваются; их отсутствие не находка + ([§10.4](../../PROCESS.md#104-событийный-конвейер-метка-как-триггер)). +- Ветка `show` с чистым слиянием к `dev` до ревью не приводится: материал — + ветка как есть, кандидат проверит Validate при слиянии + ([§10.4](../../PROCESS.md#104-событийный-конвейер-метка-как-триггер)). + +## Пакетное ревью ship + +- Задачи `track:ship` слиты без ревью модели; перед бетой `ship-review.yml` + читает их код одной сессией: по строке ТЗ каждой задачи и её коммитам + ([§11.7](../../PROCESS.md#117-пакетное-ревью-ship-перед-бетой)). +- Вопросы к задаче: делает ли код заявленное и только его, не ломает ли + соседнее, не вышла ли правка из ship по смыслу + ([§11.7](../../PROCESS.md#117-пакетное-ревью-ship-перед-бетой), + [§5](../../PROCESS.md#5-треки-ship-show-ask--метка-владельца)). +- Документ `docs/reviews/SHIP-REVIEW-<тег>.md` публикует детерминированный + шаг; High не пускает бету, Medium и Low решает владелец + ([§11.7](../../PROCESS.md#117-пакетное-ревью-ship-перед-бетой)). + ## Независимое ревью линии - Перед стабильным релизом `release-review.yml` судит поверхности всей линии diff --git a/scripts/ci-proof.mjs b/scripts/ci-proof.mjs index 8e150bb7..0138ec91 100644 --- a/scripts/ci-proof.mjs +++ b/scripts/ci-proof.mjs @@ -25,9 +25,16 @@ export const CI_PROOF_STATES = Object.freeze([ // требует (#601): к кандидату беты каждая задача прогнана ими на ревью и на // слитом кандидате, а `Release:` мутантов больше не запрашивает — политика // с `mutants: true` объявляла бы каждый кандидат беты `stale`. +// +// #696: треки `show` и `ship` (PROCESS §5) мутантов по диффу до слияния не +// запрашивают — их доказательство лёгкое, `reviewLight`/`mergeLight`. Мутанты +// у них остаются в ночном полном реестре; `track:ask` и метка `ci:mutants` +// идут по прежним `review`/`merge`. export const CI_PROOF_POLICIES = Object.freeze({ review: Object.freeze({ name: 'review', full: false, mutants: true }), merge: Object.freeze({ name: 'merge', full: false, mutants: true }), + reviewLight: Object.freeze({ name: 'review-light', full: false, mutants: false }), + mergeLight: Object.freeze({ name: 'merge-light', full: false, mutants: false }), release: Object.freeze({ name: 'release', full: true, mutants: false }), }); diff --git a/scripts/merge-candidate.mjs b/scripts/merge-candidate.mjs index 1670d722..36ba4506 100755 --- a/scripts/merge-candidate.mjs +++ b/scripts/merge-candidate.mjs @@ -132,6 +132,7 @@ export function realOps({ now = Date.now, exec = sh, candidateTree = (sha) => githubCandidateTree({ repo, sha, token }), proofContext = (run) => loadGithubProofContext({ repo, run, token }), + mutants = true, }) { const pushUrl = `https://x-access-token:${token}@github.com/${repo}`; const git = (...args) => exec('git', args); @@ -187,8 +188,8 @@ export function realOps({ // Мутанты по диффу бегут только по запросу (#510): кандидат после ребейза — // новое дерево, поэтому слияние запускает Validate с мутантами само и ждёт // именно этот dispatch-прогон; push-прогон на том же SHA их не содержит. - dispatchValidate: (ref) => { - const r = exec('gh', ['workflow', 'run', workflow, '--repo', repo, '--ref', ref, '-f', 'full=false', '-f', 'mutants=true']); + dispatchValidate: (ref, { mutants: withMutants = mutants } = {}) => { + const r = exec('gh', ['workflow', 'run', workflow, '--repo', repo, '--ref', ref, '-f', 'full=false', '-f', `mutants=${withMutants ? 'true' : 'false'}`]); if (r.status !== 0) throw new Error(`gh workflow run ${workflow}: ${r.stderr || r.stdout}`); }, waitValidate: async (sha, { event = 'workflow_dispatch' } = {}) => { @@ -208,7 +209,7 @@ export function realOps({ try { context = await proofContext(run); } catch { context = { proof: null, jobs: [], reuseRuns: new Map() }; } const verdict = evaluateCiProof({ - run, ...context, candidate: { sha, tree }, policy: CI_PROOF_POLICIES.merge, + run, ...context, candidate: { sha, tree }, policy: mutants ? CI_PROOF_POLICIES.merge : CI_PROOF_POLICIES.mergeLight, }); if (verdict.status === 'green' || verdict.status === 'failed') return { result: verdict.status, url: verdict.url, note: verdict.note }; @@ -234,7 +235,7 @@ export function realOps({ /** * Слияние по алгоритму §4.2. Возвращает { merged, to, action, candidate }. */ -export async function mergeCandidate({ branch, material, issue, ops, maxAttempts = MAX_ATTEMPTS }) { +export async function mergeCandidate({ branch, material, issue, ops, maxAttempts = MAX_ATTEMPTS, mutants = true }) { ops.fetch('dev', branch); const actual = ops.revParse(`origin/${branch}`); const reviewedFresh = actual === material @@ -280,10 +281,29 @@ export async function mergeCandidate({ branch, material, issue, ops, maxAttempts tip = candidate; if (!patchIdEqual) return finish(decideMerge({ fresh: true, devMoved: true, patchIdEqual: false }), { candidate, devNow }); - // мутанты по диффу — по запросу (#510): dispatch на ветке, где теперь стоит кандидат - ops.dispatchValidate(branch); - ops.log(`Validate с мутантами на кандидате ${candidate.slice(0, 8)} — ждём`); - const { result, url } = await ops.waitValidate(candidate, { event: 'workflow_dispatch' }); + // мутанты по диффу — по запросу (#510): dispatch на ветке, где теперь стоит + // кандидат. + // + // #696: на треках show/ship мутантов нет, и лёгкий Validate на кандидате + // уже запустил сам push выше — второй, dispatch-прогон, повторил бы его + // целиком. Ждётся push-прогон; dispatch — только если его нет: push, в + // котором сдвинулись одни docs/reviews/**, Validate не запускает + // (paths-ignore), а отменённый concurrency прогон заменить некому. + let result; + let url; + if (mutants) { + ops.dispatchValidate(branch); + ops.log(`Validate с мутантами на кандидате ${candidate.slice(0, 8)} — ждём`); + ({ result, url } = await ops.waitValidate(candidate, { event: 'workflow_dispatch' })); + } else { + ops.log(`лёгкий Validate на кандидате ${candidate.slice(0, 8)} — ждём push-прогон`); + ({ result, url } = await ops.waitValidate(candidate, { event: 'push' })); + if (result === 'missing') { + ops.log('push-прогона на кандидате нет — лёгкий dispatch'); + ops.dispatchValidate(branch); + ({ result, url } = await ops.waitValidate(candidate, { event: 'workflow_dispatch' })); + } + } let decision = decideMerge({ fresh: true, devMoved: true, patchIdEqual: true, validate: result, attempt, maxAttempts }); if (decision.action !== 'push') return finish(decision, { candidate, devNow, runUrl: url }); @@ -306,11 +326,13 @@ if (isMainModule(import.meta.url)) { // #496: переносимо для Window const repo = arg('repo') || process.env.GITHUB_REPOSITORY; const token = process.env.HP_PROCESS_TOKEN || process.env.TOKEN; if (!branch || !material || !issue || !repo || !token) { - console.error('usage: merge-candidate.mjs --branch= --material= --issue= [--repo=owner/name]; HP_PROCESS_TOKEN in env'); + console.error('usage: merge-candidate.mjs --branch= --material= --issue= [--repo=owner/name] [--mutants=false]; HP_PROCESS_TOKEN in env'); process.exit(2); } - const ops = realOps({ repo, token, issue }); - mergeCandidate({ branch, material, issue, ops }).then((r) => { + // #696: `--mutants=false` — треки show/ship сливаются по лёгкому Validate. + const mutants = arg('mutants') !== 'false'; + const ops = realOps({ repo, token, issue, mutants }); + mergeCandidate({ branch, material, issue, ops, mutants }).then((r) => { const out = `merged=${r.merged}\nto=${r.to || ''}\naction=${r.action}\ncandidate=${r.candidate}\n`; if (process.env.GITHUB_OUTPUT) appendFileSync(process.env.GITHUB_OUTPUT, out); process.stdout.write(out); diff --git a/scripts/mutation-registry.mjs b/scripts/mutation-registry.mjs index 541c7ff3..fc104c4d 100644 --- a/scripts/mutation-registry.mjs +++ b/scripts/mutation-registry.mjs @@ -5494,8 +5494,8 @@ const MUTANT_DEFINITIONS = [ + 'it without a green Validate on that SHA is the false-green the audit reproduced (#492 §4)', patches: [{ file: 'scripts/merge-candidate.mjs', - find: " const { result, url } = await ops.waitValidate(candidate, { event: 'workflow_dispatch' });", - replace: " const { result, url } = { result: 'green', url: 'skipped' }; // mutant: no validation", + find: " ops.log(`Validate с мутантами на кандидате ${candidate.slice(0, 8)} — ждём`);\n ({ result, url } = await ops.waitValidate(candidate, { event: 'workflow_dispatch' }));", + replace: " ops.log(`Validate с мутантами на кандидате ${candidate.slice(0, 8)} — ждём`);\n ({ result, url } = { result: 'green', url: 'skipped' }); // mutant: no validation", }], }, { @@ -10613,7 +10613,7 @@ const MUTANT_DEFINITIONS = [ + 'остановиться, сохранить метку и назвать упавшую стадию', patches: [{ file: '.github/workflows/_process.yml', - find: ' if [ "$REUSE" != "true" ] && [ "$MODEL_RESULT" != "success" ]; then', + find: ' if [ "$REUSE" != "true" ] && [ "$SHIP" != "true" ] && [ "$MODEL_RESULT" != "success" ]; then', replace: ' if false; then # mutant: every model result is accepted', }], }, @@ -12239,8 +12239,8 @@ const MUTANT_DEFINITIONS = [ + 'wait for that run, not for the push run that carries no mutants (#510 AC3)', patches: [{ file: 'scripts/merge-candidate.mjs', - find: " ops.dispatchValidate(branch);\n ops.log(`Validate с мутантами на кандидате ${candidate.slice(0, 8)} — ждём`);\n const { result, url } = await ops.waitValidate(candidate, { event: 'workflow_dispatch' });", - replace: " ops.log(`Validate на кандидате ${candidate.slice(0, 8)} — ждём`);\n const { result, url } = await ops.waitValidate(candidate); // mutant: push run, no dispatch", + find: " ops.dispatchValidate(branch);\n ops.log(`Validate с мутантами на кандидате ${candidate.slice(0, 8)} — ждём`);\n ({ result, url } = await ops.waitValidate(candidate, { event: 'workflow_dispatch' }));", + replace: " ops.log(`Validate на кандидате ${candidate.slice(0, 8)} — ждём`);\n ({ result, url } = await ops.waitValidate(candidate)); // mutant: push run, no dispatch", }], }, { @@ -13357,6 +13357,104 @@ const MUTANT_DEFINITIONS = [ replace: ' const infraTrack = trackFromLabels(labels); // mutant: unlabelled infra reads as ask', }], }, + // #696: цена захода по треку — мутанты, рамки ship, пакетное ревью перед бетой. + { + id: 'track-show-pays-for-mutants', + guard: 'node --test --test-name-pattern="мутанты по диффу — только ask" test/process-track.test.mjs', + because: '#696: show/ship request no diff mutants before merge; only ask and the ci:mutants ' + + 'label do — otherwise every small task pays the 28-minute mutant run again', + patches: [{ + file: 'scripts/process-track.mjs', + find: " const mutants = track === 'ask' || labels.includes('ci:mutants');", + replace: ' const mutants = true; // mutant: every track pays for mutants', + }], + }, + { + id: 'ship-limits-miss-new-src-file', + guard: 'node --test --test-name-pattern="рамки ship: новые файлы" test/process-track.test.mjs', + because: '#696: a new file in src/** is outside ship limits — ship merges without a model ' + + 'review, so the mechanical limits are the only thing standing between it and dev', + patches: [{ + file: 'scripts/process-track.mjs', + find: ' if (added.length) out.push(', + replace: ' if (false && added.length) out.push(', + }], + }, + { + id: 'ship-limit-off-by-one', + guard: 'node --test --test-name-pattern="граница включительна" test/process-track.test.mjs', + because: '#696: the ship limit is 30 src lines inclusive (owner decision); 30 lines still ship', + patches: [{ + file: 'scripts/process-track.mjs', + find: ' if (lines > SHIP_SRC_LINE_LIMIT) out.push(', + replace: ' if (lines >= SHIP_SRC_LINE_LIMIT) out.push(', + }], + }, + { + id: 'pipeline-ship-ignores-limits', + guard: 'node --test --test-name-pattern="трек снимается до ребейза" test/process-track.test.mjs', + because: '#696: ship skips the model only inside the limits; ignoring the limits merges ' + + 'unread code of any size', + patches: [{ + file: '.github/workflows/_process.yml', + find: " if printf '%s\\n' \"$limits\" | grep -qx 'ship=true'; then", + replace: ' if true; then # mutant: ship limits ignored', + }], + }, + { + id: 'light-review-waits-running-push', + guard: 'node --test --test-name-pattern="#696" test/validate-gate.test.mjs', + because: '#696: a running push run cannot wake the round (process-resume listens to ' + + 'dispatch only); treating it as proof candidate parks the round forever', + patches: [{ + file: 'scripts/validate-gate.mjs', + find: " return run?.event === 'workflow_dispatch' || (run?.event === 'push' && run?.status === 'completed');", + replace: " return run?.event === 'workflow_dispatch' || run?.event === 'push'; // mutant: running push counts", + }], + }, + { + id: 'light-merge-dispatches-second-run', + guard: 'node --test --test-name-pattern="слияние ждёт push-прогон" test/merge-candidate.test.mjs', + because: '#696: the candidate push already starts the light Validate; a second dispatch ' + + 'doubles runner minutes on every show/ship merge', + patches: [{ + file: 'scripts/merge-candidate.mjs', + find: " ({ result, url } = await ops.waitValidate(candidate, { event: 'push' }));", + replace: " ops.dispatchValidate(branch); ({ result, url } = await ops.waitValidate(candidate, { event: 'workflow_dispatch' })); // mutant: second run", + }], + }, + { + id: 'ship-review-ignores-merge-marker', + guard: 'node --test --test-name-pattern="ship-задача — по маркеру" test/ship-review.test.mjs', + because: '#696: the pipeline marker proves a merge without model review even when the ' + + 'label was changed afterwards; missing it lets unread code into a beta', + patches: [{ + file: 'scripts/ship-review.mjs', + find: " return names.includes('track:ship') || comments.some((c) => SHIP_MERGE_MARKER_RE.test(String(c?.body ?? '')));", + replace: " return names.includes('track:ship'); // mutant: marker ignored", + }], + }, + { + id: 'ship-review-accepts-partial-coverage', + guard: 'node --test --test-name-pattern="машинный блок покрывает" test/ship-review.test.mjs', + because: '#696: a ship task merged after the batch review ran was read by nobody; the beta ' + + 'gate must refuse until the review is re-run', + patches: [{ + file: 'scripts/ship-review.mjs', + find: ' if (missing.length) {', + replace: ' if (false && missing.length) {', + }], + }, + { + id: 'ship-review-accepts-high', + guard: 'node --test --test-name-pattern="машинный блок покрывает" test/ship-review.test.mjs', + because: '#696: a High in the batch review is a defect a beta user would see; it blocks the beta', + patches: [{ + file: 'scripts/ship-review.mjs', + find: ' else if (block.high > 0) problems.push(', + replace: ' else if (false && block.high > 0) problems.push(', + }], + }, ]; const mutationCardSource = readFileSync(join(repoRoot, 'src/houseplan-card.ts'), 'utf8'); diff --git a/scripts/process-track.mjs b/scripts/process-track.mjs new file mode 100644 index 00000000..65709831 --- /dev/null +++ b/scripts/process-track.mjs @@ -0,0 +1,139 @@ +#!/usr/bin/env node +/** + * Трек задачи и рамки `ship` (PROCESS.md §5, #695/#696). + * + * node scripts/process-track.mjs resolve --labels="a,b" --base= --head= + * node scripts/process-track.mjs ship-limits --base= --head= + * + * `resolve` печатает `track=ship|show|ask` и `mutants=true|false` — то, что + * конвейер ревью читает, решая, сколько стоит заход: мутанты по диффу нужны + * только `ask` и метке `ci:mutants`. Инфраструктурная задача без трековой + * метки — `show` (§5.1); признак инфраструктуры механический, как в §1: в + * диффе ни одного файла класса A. + * + * `ship-limits` печатает `ship=true|false` и по строке `violation=…` на каждое + * нарушение рамок: дифф `src/**` не больше 30 строк, без новых файлов в + * `src/**`, без i18n, без полей конфига и без Python. Рамки механические + * намеренно: по ним конвейер сливает задачу без ревью модели, и решать их + * «на глаз» некому. + */ +import { spawnSync } from 'node:child_process'; +import { appendFileSync } from 'node:fs'; +import { isMainModule } from './spawn-portable.mjs'; +import { classify } from './process-gate.mjs'; + +export const SHIP_SRC_LINE_LIMIT = 30; + +/** + * Трек по меткам: `track:*` главнее прежних меток; `trivial` и `small` + * читаются как `show` (§5.1); задача без трековой метки — `ask`. Метки трека + * не доказывают продуктовый поток: инфраструктурной задаче владелец тоже может + * поставить `track:*`, чтобы задать цену конвейера. + */ +export function trackFromLabels(labels = []) { + if (labels.includes('track:ship')) return 'ship'; + if (labels.includes('track:show')) return 'show'; + if (labels.includes('track:ask')) return 'ask'; + if (labels.includes('trivial') || labels.includes('small')) return 'show'; + return 'ask'; +} + +/** Есть ли у задачи трековая метка вообще — новая или прежняя. */ +export const hasTrackLabel = (labels = []) => ['track:ship', 'track:show', 'track:ask', 'trivial', 'small'] + .some((label) => labels.includes(label)); + +/** + * Трек, по которому конвейер оценивает заход. Явная метка решает всё; без неё + * инфраструктурная задача (ни одного файла класса A в диффе) — `show`, прочие — + * `ask`. Мутанты по диффу — только на `ask` или по метке `ci:mutants`. + */ +export function resolveTrack({ labels = [], files = [] } = {}) { + const infrastructure = files.length > 0 && files.every((file) => classify(file) !== 'A'); + const track = hasTrackLabel(labels) ? trackFromLabels(labels) : (infrastructure ? 'show' : 'ask'); + const mutants = track === 'ask' || labels.includes('ci:mutants'); + return { track, mutants, infrastructure }; +} + +const I18N = [/^src\/i18n\//, /^custom_components\/[^/]+\/translations\//]; +const CONFIG = [/^src\/types\.ts$/, /^src\/config-[^/]+\.ts$/]; +const PYTHON = /\.py$/; + +/** + * Нарушения рамок `ship` по `git diff --numstat` и `--name-status` базы и + * вершины. Пустой список — задача укладывается в рамки. + * + * @param {{added:number|null, deleted:number|null, path:string}[]} numstat + * @param {{status:string, path:string}[]} nameStatus + */ +export function shipLimitViolations({ numstat = [], nameStatus = [] } = {}) { + const out = []; + const src = numstat.filter((row) => row.path.startsWith('src/')); + const binary = src.filter((row) => row.added === null || row.deleted === null); + const lines = src.reduce((sum, row) => sum + (row.added ?? 0) + (row.deleted ?? 0), 0); + if (lines > SHIP_SRC_LINE_LIMIT) out.push(`дифф src/** — ${lines} строк при рамке ${SHIP_SRC_LINE_LIMIT}`); + if (binary.length) out.push(`двоичные файлы в src/**: ${binary.map((row) => row.path).join(', ')}`); + const added = nameStatus.filter((row) => row.status.startsWith('A') && row.path.startsWith('src/')); + if (added.length) out.push(`новые файлы в src/**: ${added.map((row) => row.path).join(', ')}`); + const paths = [...new Set([...numstat, ...nameStatus].map((row) => row.path))]; + const i18n = paths.filter((path) => I18N.some((re) => re.test(path))); + if (i18n.length) out.push(`ключи i18n: ${i18n.join(', ')}`); + const config = paths.filter((path) => CONFIG.some((re) => re.test(path))); + if (config.length) out.push(`поля конфига: ${config.join(', ')}`); + const python = paths.filter((path) => PYTHON.test(path)); + if (python.length) out.push(`Python: ${python.join(', ')}`); + return out; +} + +/** `git diff --numstat` → строки; двоичный файл даёт `-\t-`. */ +export function parseNumstat(text = '') { + return String(text).split('\n').map((line) => line.trim()).filter(Boolean).map((line) => { + const [added, deleted, ...rest] = line.split('\t'); + return { added: added === '-' ? null : Number(added), deleted: deleted === '-' ? null : Number(deleted), path: rest.at(-1) }; + }); +} + +/** `git diff --name-status` → строки; у переименования путь — новый. */ +export function parseNameStatus(text = '') { + return String(text).split('\n').map((line) => line.trim()).filter(Boolean).map((line) => { + const [status, ...paths] = line.split('\t'); + return { status, path: paths.at(-1) }; + }); +} + +function git(args) { + const r = spawnSync('git', args, { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 }); + if (r.status !== 0) throw new Error(`git ${args.join(' ')}: ${(r.stderr || '').trim()}`); + return r.stdout; +} + +if (isMainModule(import.meta.url)) { + const [command, ...rest] = process.argv.slice(2); + const value = (name) => rest.find((a) => a.startsWith(`--${name}=`))?.slice(name.length + 3) ?? ''; + const emit = (lines) => { + for (const line of lines) console.log(line); + if (process.env.GITHUB_OUTPUT) appendFileSync(process.env.GITHUB_OUTPUT, `${lines.join('\n')}\n`); + }; + try { + const base = value('base'); + const head = value('head') || 'HEAD'; + const range = base ? `${base}...${head}` : null; + if (command === 'resolve') { + const labels = value('labels').split(',').map((s) => s.trim()).filter(Boolean); + const files = range ? git(['diff', '--name-only', range]).split('\n').filter(Boolean) : []; + const { track, mutants } = resolveTrack({ labels, files }); + emit([`track=${track}`, `mutants=${mutants}`]); + } else if (command === 'ship-limits') { + if (!range) throw new Error('--base is required'); + const violations = shipLimitViolations({ + numstat: parseNumstat(git(['diff', '--numstat', range])), + nameStatus: parseNameStatus(git(['diff', '--name-status', range])), + }); + emit([`ship=${violations.length === 0}`, `violations=${violations.join('; ')}`]); + } else { + throw new Error('usage: process-track.mjs resolve --labels=a,b [--base= --head=] | ship-limits --base= [--head=]'); + } + } catch (error) { + console.error(`::error::${error.message}`); + process.exit(1); + } +} diff --git a/scripts/release-prerelease.mjs b/scripts/release-prerelease.mjs index 0b87faef..38007cf8 100644 --- a/scripts/release-prerelease.mjs +++ b/scripts/release-prerelease.mjs @@ -503,6 +503,11 @@ if (invokedDirectly) { const bundleSha256 = bundleSnapshot.entrySha256; const validateRuns = await assertGreenValidate(sha); validateIssues(); + // #696, PROCESS.md §11.7: ship-задачи диапазона слиты без ревью модели — + // бета только после пакетного ревью их кода (`ship-review.yml`). + run(process.execPath, [ + 'scripts/ship-review.mjs', 'check', `--tag=${tag}`, `--candidate=${sha}`, `--repo=${repo}`, + ], { inherit: true }); const history = readCandidateHistory(sha); const generatedMembership = buildReleaseMembership({ tag, candidate: sha, base: history.base, commits: history.commits, issueNumbers: issues, diff --git a/scripts/reviews-archive.mjs b/scripts/reviews-archive.mjs index c5317c98..85d20c31 100644 --- a/scripts/reviews-archive.mjs +++ b/scripts/reviews-archive.mjs @@ -88,9 +88,11 @@ export function archivePlan({ names, lines, open, through, addedIn = new Map() } if (name === INDEX_FILE) continue; const doc = parseDocName(name); if (!doc) { kept.push({ name, reason: 'вне схемы имён' }); continue; } - if (doc.stage === 'release') { - if (tags.has(doc.tag)) moves.push({ name, from: `${LIVE_DIR}/${name}`, to: `${ARCHIVE_DIR}/${doc.tag}/${name}`, tag: doc.tag, issue: null }); - else kept.push({ name, reason: `ревью линии ${doc.tag} не входит в архивируемые линии` }); + if (doc.stage === 'release' || doc.stage === 'ship') { + // #696: пакетное ревью беты уходит в каталог своей стабильной линии. + const line = doc.tag.replace(/-beta\.\d+$/, ''); + if (tags.has(line)) moves.push({ name, from: `${LIVE_DIR}/${name}`, to: `${ARCHIVE_DIR}/${line}/${name}`, tag: line, issue: null }); + else kept.push({ name, reason: `ревью линии ${line} не входит в архивируемые линии` }); continue; } if (openSet.has(doc.issue)) { kept.push({ name, issue: doc.issue, reason: 'задача есть в открытой линии' }); continue; } diff --git a/scripts/reviews-index.mjs b/scripts/reviews-index.mjs index 591c4338..2b0f13e7 100644 --- a/scripts/reviews-index.mjs +++ b/scripts/reviews-index.mjs @@ -28,6 +28,8 @@ import { isMainModule } from './spawn-portable.mjs'; export const INDEX_FILE = 'INDEX.md'; const DOC_NAME = /^(CODE|SPEC)-REVIEW-(?:issue-)?(\d+)(?:-r(\d+))?(?:-([a-z0-9-]+))?\.md$/i; const RELEASE_DOC_NAME = /^RELEASE-REVIEW-(v(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*))\.md$/i; +// #696: пакетное ревью ship-задач перед бетой (PROCESS.md §11.7) — по тегу беты. +const SHIP_DOC_NAME = /^SHIP-REVIEW-(v(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-beta\.(?:0|[1-9]\d*))?)\.md$/i; const COLOUR = { 'зелёный': 'зелёный', 'зеленый': 'зелёный', green: 'зелёный', 'жёлтый': 'жёлтый', 'желтый': 'жёлтый', yellow: 'жёлтый', @@ -43,6 +45,8 @@ const VERDICT_OWN_LINE_RE = /^[ \t]*(?:[-*]\s*)?\**(?:Вердикт|Verdict)[^\ export function parseDocName(name) { const release = RELEASE_DOC_NAME.exec(String(name)); if (release) return { stage: 'release', issue: null, round: null, suffix: null, tag: release[1] }; + const ship = SHIP_DOC_NAME.exec(String(name)); + if (ship) return { stage: 'ship', issue: null, round: null, suffix: null, tag: ship[1] }; const match = DOC_NAME.exec(String(name)); if (!match) return null; return { @@ -301,14 +305,20 @@ export function assertAllDocumentsIndexed({ skipped }) { const badge = (verdict) => ({ 'зелёный': '🟢', 'жёлтый': '🟡', 'красный': '🔴' }[verdict] || '⚪'); export function renderIndex({ entries, skipped = [] }) { - const releaseDocs = entries.filter((entry) => entry.stage === 'release').sort((a, b) => { - const av = a.tag.slice(1).split('.').map(Number); - const bv = b.tag.slice(1).split('.').map(Number); - for (let i = 0; i < 3; i += 1) if (av[i] !== bv[i]) return bv[i] - av[i]; - return 0; + // Документы линии и беты (release, ship) — без issue; свежий тег выше. + // Стабильный тег старше своих бет: `v1.79.0` выше `v1.79.0-beta.3`. + const tagKey = (tag) => { + const [core, beta] = tag.slice(1).split('-beta.'); + return [...core.split('.').map(Number), beta == null ? Infinity : Number(beta)]; + }; + const releaseDocs = entries.filter((entry) => entry.issue == null).sort((a, b) => { + const av = tagKey(a.tag); + const bv = tagKey(b.tag); + for (let i = 0; i < 4; i += 1) if (av[i] !== bv[i]) return bv[i] - av[i]; + return a.stage.localeCompare(b.stage); }); const byIssue = new Map(); - for (const entry of entries.filter((item) => item.stage !== 'release')) { + for (const entry of entries.filter((item) => item.issue != null)) { const list = byIssue.get(entry.issue) || []; list.push(entry); byIssue.set(entry.issue, list); @@ -322,7 +332,8 @@ export function renderIndex({ entries, skipped = [] }) { lines.push('| Issue | Документ | Этап · раунд | Вердикт | H | M | Находки | Файлы |'); lines.push('|---|---|---|---|---:|---:|---|---|'); for (const doc of releaseDocs) { - lines.push(`| линия ${doc.tag} | [${doc.name}](${doc.name}) | ревью линии · — | ${badge(doc.verdict)} ${doc.verdict} | ${doc.high} | ${doc.medium} | ${doc.findings.join('; ').replace(/\|/g, '\\|') || '—'} | ${(doc.files || []).map((f) => `\`${f}\``).join(' ') || '—'} |`); + const [who, what] = doc.stage === 'ship' ? ['бета', 'пакетное ревью ship'] : ['линия', 'ревью линии']; + lines.push(`| ${who} ${doc.tag} | [${doc.name}](${doc.name}) | ${what} · — | ${badge(doc.verdict)} ${doc.verdict} | ${doc.high} | ${doc.medium} | ${doc.findings.join('; ').replace(/\|/g, '\\|') || '—'} | ${(doc.files || []).map((f) => `\`${f}\``).join(' ') || '—'} |`); } for (const issue of issues) { const docs = byIssue.get(issue).sort((a, b) => (a.stage === b.stage ? (a.round || 0) - (b.round || 0) : a.stage === 'spec' ? -1 : 1)); diff --git a/scripts/ship-review.mjs b/scripts/ship-review.mjs new file mode 100644 index 00000000..37677304 --- /dev/null +++ b/scripts/ship-review.mjs @@ -0,0 +1,235 @@ +#!/usr/bin/env node +/** + * Пакетное ревью задач `track:ship` перед бетой (#696, PROCESS.md §11.7). + * + * node scripts/ship-review.mjs doc --tag=v1.79.0-beta.1 + * node scripts/ship-review.mjs prepare --tag=<тег> --candidate= --out= [--repo=owner/name] + * node scripts/ship-review.mjs check --tag=<тег> --candidate= [--repo=owner/name] + * + * `ship` сливается без ревью модели (§5): правка в механических рамках и + * зелёный лёгкий Validate. Прочитать её код обязан кто-то до того, как она + * уйдёт пользователям, — это пакетное ревью всех ship-задач диапазона + * «прошлый тег..кандидат беты» одной сессией модели. + * + * Какие задачи — ship, доказывает конвейер, а не метка: слияние без модели + * оставляет в issue машинный маркер ``. + * Метка `track:ship` на issue в диапазоне тоже включает задачу в пакет — + * лишний разбор дешевле пропущенного. Состав диапазона — трейлеры `Issue: #NN`, + * тот же построитель, что у `RELEASE-MEMBERSHIP.json` (#547). + * + * `check` — гейт публикации беты: если в диапазоне есть ship-задачи, документ + * `docs/reviews/SHIP-REVIEW-<тег>.md` обязан быть в кандидате или в `dev`, + * покрывать их все машинным блоком и не нести High. + */ +import { mkdirSync, writeFileSync } from 'node:fs'; +import { join, resolve } from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { isMainModule } from './spawn-portable.mjs'; +import { issueTrailers, readCandidateHistory } from './release-membership.mjs'; + +export const SHIP_REVIEW_DIR = 'docs/reviews'; +export const SHIP_MERGE_MARKER_RE = //; +export const SHIP_REVIEW_ANCHOR = ''; +export const RELEASE_TAG_RE = /^v(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-beta\.(0|[1-9]\d*))?$/; +const SHA_RE = /^[0-9a-f]{40,64}$/; +const TZ_LIMIT = 1500; + +/** Путь документа пакетного ревью для тега беты или стабильного. */ +export function shipReviewDocPath(tag) { + if (!RELEASE_TAG_RE.test(String(tag))) throw new Error(`not a release tag: ${tag}`); + return `${SHIP_REVIEW_DIR}/SHIP-REVIEW-${tag}.md`; +} + +/** Задача — ship, если конвейер слил её без модели или на ней стоит `track:ship`. */ +export function isShipIssue({ labels = [], comments = [] } = {}) { + const names = labels.map((label) => (typeof label === 'string' ? label : label?.name)); + return names.includes('track:ship') || comments.some((c) => SHIP_MERGE_MARKER_RE.test(String(c?.body ?? ''))); +} + +/** Раздел `## ТЗ` тела issue — для ship это строка «что меняется и чем проверить». */ +export function specSection(body = '') { + const text = String(body); + const start = text.search(/^#{1,3}\s*ТЗ(?![\p{L}\p{N}_])/mu); + if (start < 0) return ''; + const rest = text.slice(start).split('\n'); + const out = [rest[0]]; + for (const line of rest.slice(1)) { + if (/^#{1,3}\s/.test(line)) break; + out.push(line); + } + const section = out.join('\n').trim(); + return section.length > TZ_LIMIT ? `${section.slice(0, TZ_LIMIT)}…` : section; +} + +/** + * Ship-задачи диапазона: номера из трейлеров коммитов, признак ship — из + * данных issue. `issueData(number)` → `{ title, body, labels, comments }` или null. + */ +export function shipIssuesInRange({ commits = [], issueData }) { + const byIssue = new Map(); + for (const commit of commits) { + for (const number of issueTrailers(commit.message)) { + if (!byIssue.has(number)) byIssue.set(number, []); + byIssue.get(number).push({ sha: commit.sha, subject: String(commit.message).split('\n')[0] }); + } + } + const out = []; + for (const [number, list] of [...byIssue].sort((a, b) => a[0] - b[0])) { + const data = issueData(number); + if (!data || !isShipIssue(data)) continue; + out.push({ number, title: data.title || '', spec: specSection(data.body), commits: list.reverse() }); + } + return out; +} + +export function renderShipBrief({ tag, candidate, base, ship, runUrl = '' }) { + const lines = [ + `# Вход пакетного ревью ship ${tag}`, + '', + `- Кандидат: \`${candidate}\``, + `- Диапазон: ${base ? `\`${base.tag}\` · \`${base.sha}\`` : 'нет прошлого тега — всё дерево'} .. кандидат`, + ...(runUrl ? [`- Прогон: ${runUrl}`] : []), + `- Документ: \`${shipReviewDocPath(tag)}\``, + '', + `## Задачи ship (${ship.length}) — слиты без ревью модели`, + '', + ]; + for (const issue of ship) { + lines.push(`### #${issue.number} · ${issue.title}`, ''); + lines.push(issue.spec ? issue.spec : '(раздела «## ТЗ» в теле нет — ТЗ задачи не записано, это находка)', ''); + lines.push('Коммиты (`git show `):', ''); + for (const commit of issue.commits) lines.push(`- \`${commit.sha}\` ${commit.subject}`); + lines.push(''); + } + return `${lines.join('\n').replace(/\n{3,}/g, '\n\n').trim()}\n`; +} + +/** Машинный блок документа: его пишет публикация, читает `check`. */ +export function anchorBlock({ tag, candidate, base = null, issues = [], high = 0, medium = 0, low = 0, runUrl = '' }) { + return [ + SHIP_REVIEW_ANCHOR, + '### Материал пакетного ревью', + '', + '```', + `tag ${tag}`, + `candidate ${candidate}`, + `base ${base || '—'}`, + `issues ${issues.length ? issues.join(',') : '—'}`, + `high ${high}`, + `medium ${medium}`, + `low ${low}`, + `run ${runUrl || '—'}`, + '```', + '', + ].join('\n'); +} + +export function parseAnchorBlock(text = '') { + const at = String(text).lastIndexOf(SHIP_REVIEW_ANCHOR); + if (at < 0) return null; + const fence = /```\n([\s\S]*?)\n```/.exec(String(text).slice(at)); + if (!fence) return null; + const fields = Object.fromEntries(fence[1].split('\n').map((line) => { + const space = line.indexOf(' '); + return space < 0 ? [line, ''] : [line.slice(0, space), line.slice(space + 1).trim()]; + })); + const number = (value) => (/^\d+$/.test(String(value)) ? Number(value) : null); + return { + tag: fields.tag || null, + candidate: fields.candidate || null, + issues: String(fields.issues || '').split(',').map((s) => number(s.trim())).filter((n) => n != null), + high: number(fields.high), + medium: number(fields.medium), + low: number(fields.low), + }; +} + +/** + * Причины не публиковать бету; пустой список — гейт пройден. Без ship-задач в + * диапазоне документ не нужен: пакетному ревью нечего читать. + */ +export function shipReviewProblems({ tag, ship = [], docText = null }) { + if (!ship.length) return []; + const doc = shipReviewDocPath(tag); + const numbers = ship.map((issue) => issue.number); + const run = `gh workflow run ship-review.yml --ref dev -f tag=${tag}`; + if (!docText) { + return [`${doc} нет ни в кандидате, ни в dev: ship-задачи ${numbers.map((n) => `#${n}`).join(', ')} слиты без ревью модели, и их код до беты не читал никто (PROCESS.md §11.7). Запустить: ${run}`]; + } + const block = parseAnchorBlock(docText); + if (!block) return [`${doc} без машинного блока ${SHIP_REVIEW_ANCHOR}: покрытие задач не доказано. Переснять: ${run} -f force=true`]; + const problems = []; + if (block.tag !== tag) problems.push(`${doc} записан для тега ${block.tag}, а публикуется ${tag}`); + const missing = numbers.filter((n) => !block.issues.includes(n)); + if (missing.length) { + problems.push(`${doc} не покрывает ship-задачи ${missing.map((n) => `#${n}`).join(', ')} — они слиты после ревью. Переснять: ${run} -f force=true`); + } + if (block.high == null) problems.push(`${doc}: в машинном блоке нет числа High`); + else if (block.high > 0) problems.push(`${doc}: High ${block.high} — бета ждёт починки: находка чинится отдельной задачей, затем ревью переснимается`); + return problems; +} + +function git(args, { allowFailure = false } = {}) { + const r = spawnSync('git', args, { encoding: 'utf8', maxBuffer: 256 * 1024 * 1024 }); + if (r.status !== 0 && !allowFailure) throw new Error(`git ${args.join(' ')} failed: ${(r.stderr || '').trim()}`); + return r.status === 0 ? r.stdout : null; +} + +function ghIssue(repo, number) { + const r = spawnSync('gh', ['issue', 'view', String(number), '--repo', repo, '--json', 'number,title,body,labels,comments'], { + encoding: 'utf8', maxBuffer: 64 * 1024 * 1024, + }); + if (r.status !== 0) throw new Error(`gh issue view ${number}: ${(r.stderr || r.stdout || '').trim()}`); + return JSON.parse(r.stdout); +} + +/** Документ — из кандидата, иначе из `origin/dev`: ревью могло лечь в dev после Release-коммита. */ +export function readShipDoc(tag, candidate) { + const path = shipReviewDocPath(tag); + return git(['show', `${candidate}:${path}`], { allowFailure: true }) + ?? git(['show', `origin/dev:${path}`], { allowFailure: true }); +} + +function collect({ tag, candidate, repo }) { + shipReviewDocPath(tag); + if (!SHA_RE.test(String(candidate))) throw new Error(`invalid candidate SHA: ${candidate}`); + if (!repo) throw new Error('--repo (или GITHUB_REPOSITORY) обязателен'); + const { base, commits } = readCandidateHistory(candidate); + const ship = shipIssuesInRange({ commits, issueData: (number) => ghIssue(repo, number) }); + return { base, ship }; +} + +if (isMainModule(import.meta.url)) { + try { + const [command, ...rest] = process.argv.slice(2); + const value = (name) => rest.find((a) => a.startsWith(`--${name}=`))?.slice(name.length + 3) ?? ''; + const tag = value('tag'); + const repo = value('repo') || process.env.GITHUB_REPOSITORY || ''; + if (command === 'doc') { + console.log(shipReviewDocPath(tag)); + } else if (command === 'prepare') { + const candidate = value('candidate'); + const out = resolve(value('out') || '.'); + const { base, ship } = collect({ tag, candidate, repo }); + mkdirSync(out, { recursive: true }); + writeFileSync(join(out, 'ship-issues.json'), `${JSON.stringify({ schema: 1, tag, candidate, base, issues: ship }, null, 2)}\n`); + writeFileSync(join(out, 'brief.md'), renderShipBrief({ tag, candidate, base, ship, runUrl: value('run-url') })); + console.log(`doc=${shipReviewDocPath(tag)}`); + console.log(`base=${base ? base.tag : ''}`); + console.log(`issues=${ship.map((issue) => issue.number).join(',')}`); + } else if (command === 'check') { + const candidate = value('candidate'); + const { ship } = collect({ tag, candidate, repo }); + const problems = shipReviewProblems({ tag, ship, docText: readShipDoc(tag, candidate) }); + if (!ship.length) console.log('ship-задач в диапазоне нет — пакетное ревью не требуется'); + else if (!problems.length) console.log(`пакетное ревью ${shipReviewDocPath(tag)} покрывает ship-задачи ${ship.map((i) => `#${i.number}`).join(', ')}`); + for (const problem of problems) console.error(`::error::${problem}`); + process.exit(problems.length ? 1 : 0); + } else { + throw new Error('usage: ship-review.mjs doc --tag= | prepare --tag= --candidate= --out= [--repo=o/r] | check --tag= --candidate= [--repo=o/r]'); + } + } catch (error) { + console.error(`::error::${error.message}`); + process.exit(1); + } +} diff --git a/scripts/task-packet.mjs b/scripts/task-packet.mjs index a4b58982..3be23307 100644 --- a/scripts/task-packet.mjs +++ b/scripts/task-packet.mjs @@ -20,6 +20,7 @@ import { spawnSync } from 'node:child_process'; import { isMainModule } from './spawn-portable.mjs'; import { anchorTreeFrom, anchorVerdictFrom, verdictDeclaration } from './review-doc-guard.mjs'; import { classify } from './process-gate.mjs'; +import { hasTrackLabel, trackFromLabels } from './process-track.mjs'; export const STATUS_LABELS = ['S1-new', 'S2-analysis', 'S3-spec', 'S4-spec-review', 'S5-ready', 'S6-in-progress', 'S7-code-review', 'S8-merged']; @@ -126,24 +127,8 @@ export function branchIsInfrastructure(changedFiles = []) { const PRE_CODE_STATUSES = ['S1-new', 'S2-analysis', 'S3-spec', 'S4-spec-review', 'S5-ready']; -/** - * Трек продуктовой задачи по меткам (PROCESS §5, #695): `track:ship`, - * `track:show` или `track:ask`; прежние `trivial` и `small` читаются как `show` - * (§5.1), задача без трековой метки — как `ask`. Метки трека не доказывают - * продуктовый поток: инфраструктурной задаче владелец тоже может поставить - * `track:*`, чтобы задать ей цену конвейера. - */ -export function trackFromLabels(labels = []) { - if (labels.includes('track:ship')) return 'ship'; - if (labels.includes('track:show')) return 'show'; - if (labels.includes('track:ask')) return 'ask'; - if (labels.includes('trivial') || labels.includes('small')) return 'show'; - return 'ask'; -} - -/** Есть ли у задачи трековая метка — новая или прежняя (§5.1). */ -export const hasTrackLabel = (labels = []) => ['track:ship', 'track:show', 'track:ask', 'trivial', 'small'] - .some((label) => labels.includes(label)); +// Трек по меткам — одна функция на конвейер и пакет (#696): process-track.mjs. +export { hasTrackLabel, trackFromLabels }; /** * Признаки продуктового S-flow (#632). Инфраструктурная задача входит в поток diff --git a/scripts/validate-gate.mjs b/scripts/validate-gate.mjs index 1fb80b0f..cd7ed5e9 100755 --- a/scripts/validate-gate.mjs +++ b/scripts/validate-gate.mjs @@ -7,7 +7,7 @@ * доказательство для ревью — dispatch-прогон на точном SHA материала. Push- * прогон на том же SHA зелёный не считается: в нём мутантов нет. * - * node scripts/validate-gate.mjs --repo= --ref=<ветка> --sha= [--workflow=validate.yml] [--no-wait] + * node scripts/validate-gate.mjs --repo= --ref=<ветка> --sha= [--workflow=validate.yml] [--no-wait] [--mutants=false] * * Печатает `result=green|failed|missing|pending`, `url=…`, `run_id=…` (и в * $GITHUB_OUTPUT, если он задан); код выхода 0 только при green, 2 — pending. @@ -45,6 +45,19 @@ export function isMutantRun(run) { return run?.event === 'workflow_dispatch'; } +/** + * #696: какие прогоны годятся в доказательство. С мутантами — только dispatch + * (push их не запрашивает). Без мутантов (`show`/`ship`) годится и + * завершённый push-прогон на материале: лёгкий Validate на этом SHA уже + * исполнен, и dispatch повторил бы его. Ждать же можно только dispatch: + * продолжение раунда будит его завершение (process-resume.yml слушает + * `workflow_dispatch`), и push, который ещё идёт, раунд не разбудил бы. + */ +export function proofCandidate(run, { mutants = true } = {}) { + if (mutants) return isMutantRun(run); + return run?.event === 'workflow_dispatch' || (run?.event === 'push' && run?.status === 'completed'); +} + // #622: префикс — из контракта ci-proof (JOB_RULES.mutants), который сверяется // с validate.yml; своей строки здесь больше нет. export { MUTANT_JOB_PREFIX }; @@ -72,7 +85,10 @@ export function provesMutants(jobs) { */ export async function validateGate({ ref, sha, ops, appearMs = VALIDATE_APPEAR_MS, totalMs = VALIDATE_TOTAL_MS, pollMs = POLL_MS, wait = true, + mutants = true, }) { + const policy = mutants ? CI_PROOF_POLICIES.review : CI_PROOF_POLICIES.reviewLight; + const label = mutants ? 'Validate с мутантами' : 'Validate'; const started = ops.now(); const candidateTree = await ops.candidateTree(sha); const ignored = new Set(); // завершённые dispatch без применимого proof @@ -80,14 +96,19 @@ export async function validateGate({ let dispatchedAt = null; let attempts = 0; while (ops.now() - started < totalMs) { - const runs = (await ops.listRuns(sha)).filter((x) => isMutantRun(x) && !ignored.has(x.databaseId)); - const run = runs.find((x) => tracked && x.databaseId === tracked) || runs[0]; + const runs = (await ops.listRuns(sha)).filter((x) => proofCandidate(x, { mutants }) && !ignored.has(x.databaseId)); + // Без мутантов завершённый прогон на материале предпочтительнее идущего + // dispatch: доказательство уже есть, ждать нечего (#696). С мутантами + // порядок прежний — свежий прогон решает. + const run = runs.find((x) => tracked && x.databaseId === tracked) + || (!mutants && runs.find((x) => x.status === 'completed')) + || runs[0]; if (run) { tracked = run.databaseId; if (run.status === 'completed') { const context = await ops.proof(run); const verdict = evaluateCiProof({ - run, ...context, candidate: { sha, tree: candidateTree }, policy: CI_PROOF_POLICIES.review, + run, ...context, candidate: { sha, tree: candidateTree }, policy, }); if (verdict.status === 'green') return { result: 'green', url: verdict.url, note: verdict.note }; if (verdict.status === 'failed') return { result: 'failed', url: verdict.url, note: verdict.note }; @@ -100,11 +121,11 @@ export async function validateGate({ // #636: прогон найден и идёт — ждать его будет событие, не раннер. return { result: 'pending', url: run.url || null, runId: run.databaseId, - note: `Validate с мутантами идёт (${run.status}); продолжение — по завершении прогона`, + note: `${label} идёт (${run.status}); продолжение — по завершении прогона`, }; } } else if (dispatchedAt === null) { - await ops.dispatch(ref); + await ops.dispatch(ref, { mutants }); dispatchedAt = ops.now(); attempts = 1; } else if (ops.now() - dispatchedAt > appearMs) { @@ -116,7 +137,7 @@ export async function validateGate({ // чужой коммит переживёт и вторую попытку. const elsewhere = (await ops.listRunsOnRef(ref)).filter(isMutantRun).find((x) => x.headSha && x.headSha !== sha); if (elsewhere && attempts < DISPATCH_ATTEMPTS) { - await ops.dispatch(ref); + await ops.dispatch(ref, { mutants }); dispatchedAt = ops.now(); attempts += 1; await ops.sleep(pollMs); @@ -131,7 +152,7 @@ export async function validateGate({ } await ops.sleep(pollMs); } - return { result: 'failed', url: null, note: 'Validate с мутантами не завершился за 45 минут' }; + return { result: 'failed', url: null, note: `${label} не завершился за 45 минут` }; } const sh = (cmd, args) => spawnSync(cmd, args, { encoding: 'utf8' }); @@ -151,8 +172,8 @@ export function realOps({ repo, workflow = 'validate.yml', token = process.env.G catch { return { proof: null, jobs: [], reuseRuns: new Map() }; } }, listRunsOnRef: async (ref) => parse(sh('gh', ['run', 'list', '--repo', repo, '--workflow', workflow, '--branch', ref, '--event', 'workflow_dispatch', '--json', fields, '--limit', '5'])), - dispatch: async (ref) => { - const r = sh('gh', ['workflow', 'run', workflow, '--repo', repo, '--ref', ref, '-f', 'full=false', '-f', 'mutants=true']); + dispatch: async (ref, { mutants = true } = {}) => { + const r = sh('gh', ['workflow', 'run', workflow, '--repo', repo, '--ref', ref, '-f', 'full=false', '-f', `mutants=${mutants ? 'true' : 'false'}`]); if (r.status !== 0) throw new Error(`gh workflow run: ${r.stderr || r.stdout}`); }, sleep: (ms) => new Promise((done) => setTimeout(done, ms)), @@ -167,11 +188,13 @@ if (invokedDirectly) { const ref = arg('ref'); const sha = arg('sha'); if (!repo || !ref || !sha) { - console.error('usage: validate-gate.mjs --repo= --ref= --sha= [--workflow=validate.yml]'); + console.error('usage: validate-gate.mjs --repo= --ref= --sha= [--workflow=validate.yml] [--mutants=false] [--no-wait]'); process.exit(2); } const wait = !process.argv.includes('--no-wait'); - const outcome = await validateGate({ ref, sha, wait, ops: realOps({ repo, workflow: arg('workflow') || 'validate.yml' }) }); + // #696: `--mutants=false` — лёгкое доказательство треков show/ship. + const mutants = arg('mutants') !== 'false'; + const outcome = await validateGate({ ref, sha, wait, mutants, ops: realOps({ repo, workflow: arg('workflow') || 'validate.yml' }) }); const lines = [`result=${outcome.result}`, `url=${outcome.url || ''}`, `run_id=${outcome.runId || ''}`, `note=${outcome.note}`]; for (const line of lines) console.log(line); if (process.env.GITHUB_OUTPUT) appendFileSync(process.env.GITHUB_OUTPUT, `${lines.join('\n')}\n`); diff --git a/test/merge-candidate.test.mjs b/test/merge-candidate.test.mjs index 006e1f2e..85e775cb 100755 --- a/test/merge-candidate.test.mjs +++ b/test/merge-candidate.test.mjs @@ -574,3 +574,71 @@ test('#657 r1 H1 на настоящем git: fast-forward несёт свежи rmSync(dir, { recursive: true, force: true }); } }); + +// #696: треки show/ship сливаются по лёгкому Validate — dispatch без мутантов. +test('#696: realOps with mutants=false dispatches a light Validate and accepts a light proof', async () => { + const sha = 'c'.repeat(40); + const tree = 'd'.repeat(40); + const dispatches = []; + const light = { databaseId: 5, attempt: 1, status: 'completed', conclusion: 'success', url: 'https://run/5', event: 'workflow_dispatch', headSha: sha }; + const exec = (cmd, args) => { + if (cmd === 'gh' && args[0] === 'workflow' && args[1] === 'run') { dispatches.push(args.join(' ')); return { status: 0, stdout: '', stderr: '' }; } + if (cmd === 'gh' && args[0] === 'run' && args[1] === 'list') return { status: 0, stdout: JSON.stringify([light]), stderr: '' }; + throw new Error(`unexpected ${cmd} ${args.join(' ')}`); + }; + let clock = 0; + const lightProof = (row) => { + const context = mergeProofContext(row, sha, tree); + const proof = buildCiProof({ + candidateSha: sha, candidateTree: tree, runId: row.databaseId, attempt: 1, event: row.event, + needs: { + preflight: { result: 'success' }, + changes: { result: 'success', outputs: { heavy: 'false', mutants_requested: 'false', frontend: 'true', backend: 'false', integration: 'false' } }, + reuse: { result: 'success', outputs: {} }, frontend: { result: 'success' }, changed_mutants: { result: 'skipped' }, + }, + }); + return { ...context, proof, jobs: context.jobs.filter((job) => !job.name.startsWith('Мутанты')) }; + }; + const opsLight = realOps({ + repo: 'x/y', token: 'none', exec, mutants: false, + sleep: async (ms) => { clock += ms; }, now: () => clock, + candidateTree: async () => tree, proofContext: async (row) => lightProof(row), + }); + opsLight.dispatchValidate('issue/9-x'); + assert.match(dispatches.at(-1), /mutants=false/); + assert.equal((await opsLight.waitValidate(sha, { event: 'workflow_dispatch' })).result, 'green'); + const opsFull = realOps({ + repo: 'x/y', token: 'none', exec, + sleep: async (ms) => { clock += ms; }, now: () => clock, + candidateTree: async () => tree, proofContext: async (row) => lightProof(row), + }); + opsFull.dispatchValidate('issue/9-x'); + assert.match(dispatches.at(-1), /mutants=true/); + assert.notEqual((await opsFull.waitValidate(sha, { event: 'workflow_dispatch' })).result, 'green', + 'track ask still refuses a proof without mutants'); +}); + +test('#696: трек show/ship — слияние ждёт push-прогон кандидата, без второго dispatch', async () => { + const ops = fakeOps({ base: 'dev0', devTips: ['dev1'], branchTip: 'mat', material: 'mat' }); + const r = await mergeCandidate({ branch: 'issue/1-x', material: 'mat', issue: 1, ops, mutants: false }); + assert.equal(r.action, 'push'); + assert.ok(!ops.calls.some((c) => c[0] === 'dispatch'), 'лёгкий Validate уже запущен push кандидата'); + assert.deepEqual(ops.calls.filter((c) => c[0] === 'validate'), [['validate', 'cand-mat-on-dev1', 'push']]); +}); + +test('#696: push-прогона на кандидате нет — лёгкий dispatch и ожидание его', async () => { + const ops = fakeOps({ base: 'dev0', devTips: ['dev1'], branchTip: 'mat', material: 'mat', validate: ['missing', 'green'] }); + const r = await mergeCandidate({ branch: 'issue/1-x', material: 'mat', issue: 1, ops, mutants: false }); + assert.equal(r.action, 'push'); + assert.deepEqual(ops.calls.filter((c) => c[0] === 'dispatch' || c[0] === 'validate'), [ + ['validate', 'cand-mat-on-dev1', 'push'], ['dispatch', 'issue/1-x'], ['validate', 'cand-mat-on-dev1', 'workflow_dispatch'], + ]); +}); + +test('#696: трек ask по-прежнему диспатчит Validate с мутантами и push-прогон не ждёт', async () => { + const ops = fakeOps({ base: 'dev0', devTips: ['dev1'], branchTip: 'mat', material: 'mat' }); + await mergeCandidate({ branch: 'issue/1-x', material: 'mat', issue: 1, ops }); + assert.deepEqual(ops.calls.filter((c) => c[0] === 'dispatch' || c[0] === 'validate'), [ + ['dispatch', 'issue/1-x'], ['validate', 'cand-mat-on-dev1', 'workflow_dispatch'], + ]); +}); diff --git a/test/process-track.test.mjs b/test/process-track.test.mjs new file mode 100644 index 00000000..9f3cfd4a --- /dev/null +++ b/test/process-track.test.mjs @@ -0,0 +1,170 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { mkdtempSync, writeFileSync, mkdirSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { + SHIP_SRC_LINE_LIMIT, hasTrackLabel, parseNameStatus, parseNumstat, resolveTrack, shipLimitViolations, trackFromLabels, +} from '../scripts/process-track.mjs'; +import { trackFromLabels as packetTrack } from '../scripts/task-packet.mjs'; + +// #696: конвейер ревью решает цену захода по треку; трек и рамки ship — +// механические, потому что по ним задача сливается без ревью модели. + +const SCRIPT = join(dirname(fileURLToPath(import.meta.url)), '..', 'scripts', 'process-track.mjs'); + +test('пакет задачи и конвейер читают трек одной функцией (#696)', () => { + assert.equal(packetTrack, trackFromLabels); +}); + +test('явная трековая метка главнее признака инфраструктуры (#696)', () => { + const infra = ['scripts/x.mjs', '.github/workflows/y.yml']; + assert.deepEqual(resolveTrack({ labels: ['track:ask'], files: infra }), { track: 'ask', mutants: true, infrastructure: true }); + assert.deepEqual(resolveTrack({ labels: ['track:ship'], files: ['src/a.ts'] }), { track: 'ship', mutants: false, infrastructure: false }); + assert.equal(resolveTrack({ labels: ['small'], files: ['src/a.ts'] }).track, 'show'); +}); + +test('без трековой метки инфраструктура — show, продуктовая задача — ask (#696)', () => { + assert.equal(resolveTrack({ labels: ['infra'], files: ['scripts/x.mjs'] }).track, 'show'); + assert.equal(resolveTrack({ labels: ['bug'], files: ['scripts/x.mjs', 'src/a.ts'] }).track, 'ask'); + assert.equal(resolveTrack({ labels: [], files: [] }).track, 'ask', 'пустой дифф не доказывает инфраструктуру'); + assert.equal(hasTrackLabel(['bug', 'P2']), false); + assert.equal(hasTrackLabel(['trivial']), true); +}); + +test('мутанты по диффу — только ask и метка ci:mutants (#696)', () => { + assert.equal(resolveTrack({ labels: ['track:show'], files: ['src/a.ts'] }).mutants, false); + assert.equal(resolveTrack({ labels: ['track:show', 'ci:mutants'], files: ['src/a.ts'] }).mutants, true); + assert.equal(resolveTrack({ labels: ['track:ship', 'ci:mutants'], files: ['src/a.ts'] }).mutants, true); + assert.equal(resolveTrack({ labels: [], files: ['src/a.ts'] }).mutants, true); +}); + +test('рамки ship: строки src/** считаются вместе, граница включительна (#696)', () => { + const at = (added, deleted, path = 'src/a.ts') => ({ added, deleted, path }); + assert.deepEqual(shipLimitViolations({ numstat: [at(20, 10)] }), []); + assert.equal(SHIP_SRC_LINE_LIMIT, 30); + const over = shipLimitViolations({ numstat: [at(20, 5), at(4, 2, 'src/b.ts')] }); + assert.equal(over.length, 1); + assert.match(over[0], /31 строк/); + assert.deepEqual(shipLimitViolations({ numstat: [at(500, 0, 'scripts/x.mjs'), at(200, 0, 'test/x.test.mjs')] }), [], + 'тесты и скрипты рамку src не расходуют'); +}); + +test('рамки ship: новые файлы, двоичные, i18n, конфиг и Python (#696)', () => { + const v = shipLimitViolations({ + numstat: [ + { added: null, deleted: null, path: 'src/icon.png' }, + { added: 1, deleted: 0, path: 'src/i18n/ru.json' }, + { added: 1, deleted: 0, path: 'src/types.ts' }, + { added: 1, deleted: 0, path: 'src/config-editor.ts' }, + { added: 1, deleted: 0, path: 'custom_components/houseplan/__init__.py' }, + { added: 1, deleted: 0, path: 'custom_components/houseplan/translations/en.json' }, + ], + nameStatus: [{ status: 'A', path: 'src/new-thing.ts' }, { status: 'M', path: 'src/types.ts' }], + }); + assert.equal(v.length, 5, v.join('\n')); + assert.ok(v.some((s) => s.startsWith('двоичные') && s.includes('src/icon.png'))); + assert.ok(v.some((s) => s.startsWith('новые файлы') && s.includes('src/new-thing.ts'))); + assert.ok(v.some((s) => s.startsWith('ключи i18n') && s.includes('src/i18n/ru.json') && s.includes('translations/en.json'))); + assert.ok(v.some((s) => s.startsWith('поля конфига') && s.includes('src/types.ts') && s.includes('src/config-editor.ts'))); + assert.ok(v.some((s) => s.startsWith('Python'))); + assert.deepEqual(shipLimitViolations({ nameStatus: [{ status: 'A', path: 'test/new.test.mjs' }] }), [], + 'новый тест вне src рамки не нарушает'); +}); + +test('разбор numstat и name-status: двоичный файл и переименование (#696)', () => { + assert.deepEqual(parseNumstat('3\t1\tsrc/a.ts\n-\t-\tsrc/i.png\n'), [ + { added: 3, deleted: 1, path: 'src/a.ts' }, { added: null, deleted: null, path: 'src/i.png' }, + ]); + assert.deepEqual(parseNameStatus('M\tsrc/a.ts\nR100\tsrc/old.ts\tsrc/new.ts\n'), [ + { status: 'M', path: 'src/a.ts' }, { status: 'R100', path: 'src/new.ts' }, + ]); +}); + +test('CLI ship-limits и resolve читают реальный дифф (#696)', (t) => { + const dir = mkdtempSync(join(tmpdir(), 'hp-track-')); + t.after(() => rmSync(dir, { recursive: true, force: true })); + const git = (...args) => { + const r = spawnSync('git', args, { cwd: dir, encoding: 'utf8' }); + assert.equal(r.status, 0, r.stderr); + return r.stdout.trim(); + }; + git('init', '-q', '-b', 'main'); + git('config', 'user.email', 't@t'); git('config', 'user.name', 't'); + git('config', 'core.hooksPath', '/dev/null'); + mkdirSync(join(dir, 'src')); + writeFileSync(join(dir, 'src', 'a.ts'), 'a\n'); + git('add', '.'); git('commit', '-q', '-m', 'base'); + const base = git('rev-parse', 'HEAD'); + writeFileSync(join(dir, 'src', 'a.ts'), 'a\nb\n'); + git('commit', '-q', '-am', 'small'); + const run = (...args) => spawnSync(process.execPath, [SCRIPT, ...args], { cwd: dir, encoding: 'utf8', env: { ...process.env, GITHUB_OUTPUT: '' } }); + let r = run('ship-limits', `--base=${base}`); + assert.equal(r.status, 0, r.stderr); + assert.match(r.stdout, /^ship=true$/m); + r = run('resolve', '--labels=track:show', `--base=${base}`); + assert.match(r.stdout, /^track=show$/m); + assert.match(r.stdout, /^mutants=false$/m); + writeFileSync(join(dir, 'src', 'b.ts'), 'x\n'); + git('add', '.'); git('commit', '-q', '-m', 'new file'); + r = run('ship-limits', `--base=${base}`); + assert.match(r.stdout, /^ship=false$/m); + assert.match(r.stdout, /новые файлы в src\/\*\*: src\/b\.ts/); + r = run('ship-limits'); + assert.notEqual(r.status, 0, 'без базы рамки не проверяются'); +}); + +// ---------- конвейер читает трек (#696) ---------- + +const WORKFLOW = join(dirname(fileURLToPath(import.meta.url)), '..', '.github', 'workflows', '_process.yml'); + +test('конвейер: трек снимается до ребейза, мутанты и ship идут из него (#696)', async () => { + const { readFileSync } = await import('node:fs'); + const workflow = readFileSync(WORKFLOW, 'utf8'); + const at = (marker) => { const i = workflow.indexOf(marker); assert.ok(i > 0, `нет «${marker}»`); return i; }; + const branch = at(' - name: Перейти на ветку задачи\n'); + const track = at(' - name: "Трек задачи и рамки ship (#696)"\n'); + const rebase = at(' - name: Привести ветку к dev\n'); + const gate = at(' - name: Validate на материале\n'); + assert.ok(branch < track && track < rebase && rebase < gate, 'трек — после выбора ветки и до ребейза'); + const trackStep = workflow.slice(track, rebase); + assert.match(trackStep, /LABELS: \$\{\{ needs\.guard\.outputs\.labels \}\}/, 'метки — текущие, из guard'); + assert.match(trackStep, /git archive origin\/dev scripts \| tar -x -C "\$tools"/, 'скрипт — из dev: ветка show/ship не ребейзится'); + assert.match(trackStep, /node "\$tools\/scripts\/process-track\.mjs" resolve --labels="\$LABELS" --base=origin\/dev/); + assert.match(trackStep, /node "\$tools\/scripts\/process-track\.mjs" ship-limits --base=origin\/dev --head=HEAD/); + assert.match(trackStep, /grep -qx 'ship=true'; then\n\s+ship=true/, 'ship — только в рамках'); + assert.match(trackStep, /--add-label track:show --remove-label track:ship/, 'выход за рамки повышает трек'); + assert.match(workflow, /labels=\$\(printf '%s\\n' "\$current" \| paste -sd, -\)/, 'guard отдаёт текущие метки'); + const rebaseStep = workflow.slice(rebase, at(' - name: Зафиксировать SHA материала ревью\n')); + assert.match(rebaseStep, /if \[ "\$TRACK" = "show" \] \|\| \[ "\$TRACK" = "ship" \]; then\n\s+if git merge-tree --write-tree origin\/dev HEAD/, + 'show/ship не ребейзятся при чистом слиянии'); + const gateStep = workflow.slice(gate, at(' - name: Validate идёт — раунд продолжит событие\n')); + assert.match(gateStep, /--mutants="\$\{MUTANTS:-true\}"/, 'по умолчанию — с мутантами'); + assert.match(gateStep, /MUTANTS: \$\{\{ steps\.track\.outputs\.mutants \}\}/); +}); + +test('конвейер: ship в рамках сливается без модели, с маркером для пакетного ревью (#696)', async () => { + const { readFileSync } = await import('node:fs'); + const workflow = readFileSync(WORKFLOW, 'utf8'); + const modelJob = workflow.slice(workflow.indexOf('\n model_review:'), workflow.indexOf('\n integrate:')); + assert.match(modelJob, /if: needs\.prepare\.outputs\.proceed == 'true' && needs\.prepare\.outputs\.reuse != 'true' && needs\.prepare\.outputs\.ship != 'true'/); + const integrate = workflow.slice(workflow.indexOf('\n integrate:')); + for (const step of ['Получить результат модели', 'Опубликовать документ ревью', '"Материал раунда воспроизводим (#413)"']) { + const i = integrate.indexOf(`- name: ${step}`); + assert.ok(i > 0, step); + assert.match(integrate.slice(i, i + 400), /needs\.prepare\.outputs\.ship != 'true'/, `${step} пропускается на ship`); + } + const decide = integrate.slice(integrate.indexOf('- name: Решение по вердикту'), integrate.indexOf('- name: dev ушёл вперёд')); + assert.match(decide, /if \[ "\$SHIP" = "true" \] && \[ "\$REUSE" != "true" \]; then[\s\S]*verdict=green; high=0/); + assert.match(decide, //, 'маркер для ship-review.mjs'); + assert.doesNotMatch(decide.slice(decide.indexOf('SHIP" = "true'), decide.indexOf('elif [ "$REUSE"')), /Вердикт:/, + 'слияние без ревью не называет себя вердиктом ревью'); + const merge = integrate.slice(integrate.indexOf('- name: Слить ветку в dev'), integrate.indexOf('- name: Переставить метку')); + assert.match(merge, /--mutants="\$\{MUTANTS:-true\}"/); + const env = modelJob.slice(modelJob.indexOf('- name: Что ревьюеру нужно из окружения'), modelJob.indexOf('- name: Установить Claude Code')); + assert.match(env, /if \[ "\$STAGE" = "spec" \]; then deps=false; browser=false; fi/, 'ревью ТЗ не ставит окружение'); + assert.match(env, /if: steps\.env_needs\.outputs\.deps == 'true'\n\s+run: npm ci/); + assert.match(env, /if: steps\.env_needs\.outputs\.browser == 'true' && steps\.pw\.outputs\.cache-hit != 'true'/); +}); diff --git a/test/review-doc-guard.test.mjs b/test/review-doc-guard.test.mjs index f0c66764..753d31e6 100644 --- a/test/review-doc-guard.test.mjs +++ b/test/review-doc-guard.test.mjs @@ -612,7 +612,7 @@ test('#510 AC2: конвейер запускает Validate с мутантам const at = (marker) => { const i = workflow.indexOf(marker); assert.ok(i > 0, `нет «${marker}»`); return i; }; const material = at(' - name: Зафиксировать SHA материала ревью\n'); const reuse = at(' - name: "Зелёный вердикт прошлого захода применим без ревью (#499)"\n'); - const gate = at(' - name: Validate с мутантами на материале\n'); + const gate = at(' - name: Validate на материале\n'); assert.ok(material < reuse && reuse < gate, 'gate читает steps.reuse.outputs — стоит после шага reuse (ревью ТЗ r1)'); const back = at(' - name: Validate красный — вернуть автору без ревью\n'); const modelJob = at('\n model_review:\n'); @@ -857,7 +857,8 @@ test('#551: gates, модель и интеграция имеют незави } assert.match(integrate, /PREPARE_RESULT: \$\{\{ needs\.prepare\.result \}\}/); assert.match(integrate, /MODEL_RESULT: \$\{\{ needs\.model_review\.result \}\}/); - assert.match(integrate, /if \[ "\$REUSE" != "true" \] && \[ "\$MODEL_RESULT" != "success" \]; then/, + // #696: `ship` в рамках модель не вызывает — её исход не ждётся только там. + assert.match(integrate, /if \[ "\$REUSE" != "true" \] && \[ "\$SHIP" != "true" \] && \[ "\$MODEL_RESULT" != "success" \]; then/, 'интеграция не доверяет failed/cancelled/skipped модели'); assert.match(integrate, /цикл ревью не израсходован/); assert.match(integrate, /Бюджеты стадий \(#551\)/, 'длительности публикуются раздельно'); diff --git a/test/ship-review.test.mjs b/test/ship-review.test.mjs new file mode 100644 index 00000000..2581aeb4 --- /dev/null +++ b/test/ship-review.test.mjs @@ -0,0 +1,117 @@ +// #696, PROCESS.md §11.7: задачи track:ship сливаются без ревью модели; их код +// читает пакетное ревью диапазона перед бетой, и гейт беты требует документ. +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { + anchorBlock, isShipIssue, parseAnchorBlock, renderShipBrief, shipIssuesInRange, shipReviewDocPath, + shipReviewProblems, specSection, +} from '../scripts/ship-review.mjs'; +import { parseDocName, renderIndex } from '../scripts/reviews-index.mjs'; +import { archivePlan } from '../scripts/reviews-archive.mjs'; + +const sha = (c) => c.repeat(40); +const MARKER = ``; + +test('#696 документ — по тегу беты или стабильному, имя фиксировано', () => { + assert.equal(shipReviewDocPath('v1.79.0-beta.1'), 'docs/reviews/SHIP-REVIEW-v1.79.0-beta.1.md'); + assert.equal(shipReviewDocPath('v1.79.0'), 'docs/reviews/SHIP-REVIEW-v1.79.0.md'); + for (const bad of ['1.79.0', 'v1.79', 'v1.79.0-rc.1', '', 'v1.79.0/../x']) { + assert.throws(() => shipReviewDocPath(bad), /not a release tag/, bad); + } +}); + +test('#696 ship-задача — по маркеру конвейера или метке track:ship', () => { + assert.equal(isShipIssue({ labels: [], comments: [{ body: `Слияние без ревью модели\n\n${MARKER}` }] }), true); + assert.equal(isShipIssue({ labels: [{ name: 'track:ship' }], comments: [] }), true); + assert.equal(isShipIssue({ labels: ['track:show'], comments: [{ body: 'hp:ship-merge без маркера' }] }), false); + assert.equal(isShipIssue({}), false); +}); + +test('#696 состав — трейлеры диапазона; show и ask в пакет не входят', () => { + const commits = [ + { sha: sha('c'), message: 'fix: text\n\nIssue: #701\nUser-Visible: yes' }, + { sha: sha('b'), message: 'feat: y\n\nIssue: #702\nUser-Visible: yes' }, + { sha: sha('a'), message: 'fix: css\n\nIssue: #701\nUser-Visible: yes' }, + { sha: sha('d'), message: 'chore: упоминание #703 — не трейлер' }, + ]; + const data = { + 701: { title: 'Опечатка', body: 'Отчёт\n\n## ТЗ\n\nЗаменить «Сохранть» на «Сохранить»; видно в диалоге.\n\n## Прочее\nх', labels: [], comments: [{ body: MARKER }] }, + 702: { title: 'Фича', body: '', labels: [{ name: 'track:show' }], comments: [] }, + }; + const ship = shipIssuesInRange({ commits, issueData: (n) => data[n] ?? null }); + assert.deepEqual(ship.map((i) => i.number), [701]); + assert.deepEqual(ship[0].commits.map((c) => c.sha), [sha('a'), sha('c')], 'коммиты по порядку истории'); + assert.equal(ship[0].spec, '## ТЗ\n\nЗаменить «Сохранть» на «Сохранить»; видно в диалоге.'); + const brief = renderShipBrief({ tag: 'v1.79.0-beta.1', candidate: sha('e'), base: { tag: 'v1.78.0', sha: sha('f') }, ship }); + assert.match(brief, /### #701 · Опечатка/); + assert.match(brief, /SHIP-REVIEW-v1\.79\.0-beta\.1\.md/); + assert.match(brief, new RegExp(sha('a'))); + assert.equal(specSection('без раздела'), ''); +}); + +test('#696 гейт: без ship-задач документ не нужен, без документа — отказ с командой', () => { + assert.deepEqual(shipReviewProblems({ tag: 'v1.79.0-beta.1', ship: [], docText: null }), []); + const [problem] = shipReviewProblems({ tag: 'v1.79.0-beta.1', ship: [{ number: 701 }], docText: null }); + assert.match(problem, /#701/); + assert.match(problem, /gh workflow run ship-review\.yml --ref dev -f tag=v1\.79\.0-beta\.1/); +}); + +test('#696 гейт: машинный блок покрывает все задачи и не несёт High', () => { + const tag = 'v1.79.0-beta.1'; + const doc = (fields) => `# Ревью\nИтог: …\n\n${anchorBlock({ tag, candidate: sha('e'), issues: [701, 704], ...fields })}`; + assert.deepEqual(parseAnchorBlock(doc({ high: 0, medium: 1, low: 2 })), { + tag, candidate: sha('e'), issues: [701, 704], high: 0, medium: 1, low: 2, + }); + assert.deepEqual(shipReviewProblems({ tag, ship: [{ number: 701 }, { number: 704 }], docText: doc({ high: 0 }) }), []); + const partial = shipReviewProblems({ tag, ship: [{ number: 701 }, { number: 709 }], docText: doc({ high: 0 }) }); + assert.equal(partial.length, 1); + assert.match(partial[0], /не покрывает ship-задачи #709/); + const high = shipReviewProblems({ tag, ship: [{ number: 701 }], docText: doc({ high: 2 }) }); + assert.equal(high.length, 1); + assert.match(high[0], /High 2/); + assert.match(shipReviewProblems({ tag, ship: [{ number: 701 }], docText: '# без блока' })[0], /без машинного блока/); + assert.match(shipReviewProblems({ tag: 'v1.79.0-beta.2', ship: [{ number: 701 }], docText: doc({ high: 0 }) })[0], /для тега v1\.79\.0-beta\.1/); +}); + +test('#696 индекс и архив знают SHIP-REVIEW: бета в индексе, архив — каталог стабильной линии', () => { + assert.deepEqual(parseDocName('SHIP-REVIEW-v1.79.0-beta.1.md'), { stage: 'ship', issue: null, round: null, suffix: null, tag: 'v1.79.0-beta.1' }); + const md = renderIndex({ entries: [ + { name: 'SHIP-REVIEW-v1.79.0-beta.1.md', stage: 'ship', issue: null, tag: 'v1.79.0-beta.1', verdict: '—', high: 0, medium: 1, findings: [], files: [] }, + { name: 'SHIP-REVIEW-v1.79.0-beta.2.md', stage: 'ship', issue: null, tag: 'v1.79.0-beta.2', verdict: '—', high: 0, medium: 0, findings: [], files: [] }, + { name: 'RELEASE-REVIEW-v1.79.0.md', stage: 'release', issue: null, tag: 'v1.79.0', verdict: '—', high: 1, medium: 0, findings: [], files: [] }, + ] }); + const rows = md.split('\n').filter((line) => line.startsWith('| бета') || line.startsWith('| линия')); + assert.deepEqual(rows.map((r) => r.split('|')[1].trim()), ['линия v1.79.0', 'бета v1.79.0-beta.2', 'бета v1.79.0-beta.1']); + assert.match(rows[1], /пакетное ревью ship · —/); + const plan = archivePlan({ + names: ['SHIP-REVIEW-v1.79.0-beta.1.md', 'SHIP-REVIEW-v1.80.0-beta.1.md'], + lines: [{ tag: 'v1.79.0', issues: [] }], open: [], through: 'v1.79.0', + }); + assert.deepEqual(plan.moves.map((m) => m.to), ['legacy/reviews/v1.79.0/SHIP-REVIEW-v1.79.0-beta.1.md']); + assert.ok(plan.kept.some((k) => k.name === 'SHIP-REVIEW-v1.80.0-beta.1.md')); +}); + +test('#696 ship-review.yml: модель без права записи, документ с машинным блоком в dev', () => { + const workflow = readFileSync(fileURLToPath(new URL('../.github/workflows/ship-review.yml', import.meta.url)), 'utf8'); + const model = workflow.slice(workflow.indexOf('\n model_review:'), workflow.indexOf('\n publish:')); + assert.match(model, /permissions:\n\s+contents: read\n\s+steps:/, 'модель только читает'); + assert.match(model, /github_token: \$\{\{ secrets\.GITHUB_TOKEN \}\}/, 'без обмена OIDC на App-токен (#556)'); + const publish = workflow.slice(workflow.indexOf('\n publish:')); + assert.match(publish, /m\.anchorBlock\(/, 'машинный блок пишет публикация, не модель'); + assert.match(publish, /reviews-index\.mjs --dir=docs\/reviews --strict/); + assert.match(workflow, /if: needs\.prepare\.outputs\.proceed == 'true'/); + assert.match(workflow, /ship-задач в диапазоне нет — ревью не нужно/); +}); + +test('#696 оба пути публикации беты проверяют пакетное ревью ship до выпуска', () => { + const workflow = readFileSync(fileURLToPath(new URL('../.github/workflows/publish-prerelease.yml', import.meta.url)), 'utf8'); + const gate = workflow.slice(workflow.indexOf('\n gate:'), workflow.indexOf('\n publish:')); + assert.match(gate, /node scripts\/ship-review\.mjs check --tag="\$TAG" --candidate="\$SHA"/, 'гейт CI-публикации'); + const local = readFileSync(fileURLToPath(new URL('../scripts/release-prerelease.mjs', import.meta.url)), 'utf8'); + const main = local.slice(local.indexOf('const main = async')); + const check = main.indexOf("'scripts/ship-review.mjs', 'check'"); + assert.ok(check > 0, 'локальная публикация зовёт тот же гейт'); + assert.ok(check < main.indexOf('if (checkOnly) return;'), 'и в режиме --check тоже'); +}); diff --git a/test/validate-gate.test.mjs b/test/validate-gate.test.mjs index 05b6a459..97a36417 100755 --- a/test/validate-gate.test.mjs +++ b/test/validate-gate.test.mjs @@ -2,7 +2,7 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { validateGate, isMutantRun, provesMutants } from '../scripts/validate-gate.mjs'; +import { validateGate, isMutantRun, proofCandidate, provesMutants } from '../scripts/validate-gate.mjs'; import { buildCiProof } from '../scripts/ci-proof.mjs'; import { jobInstanceNames, validateJobs } from '../scripts/workflow-jobs.mjs'; @@ -46,7 +46,7 @@ function fakeOps({ snapshots, onRef = [], jobsById = {} }) { }); return { proof, jobs: [...BASE_JOBS, ...selected], reuseRuns: new Map() }; }, - dispatch: async (ref) => { dispatched.push(ref); }, + dispatch: async (ref, { mutants = true } = {}) => { dispatched.push(mutants ? ref : `${ref}:light`); }, sleep: async (ms) => { clock += ms; }, now: () => clock, }, @@ -211,3 +211,34 @@ test('#636: с ожиданием (умолчание) поведение пре assert.equal(outcome.result, 'green'); assert.ok(fake.ops.now() > 0, 'один poll прошёл'); }); + +// #696: треки show/ship доказываются лёгким Validate — без мутантов по диффу. +test('#696: without mutants a completed green push run on the material is proof, no dispatch', async () => { + const fake = fakeOps({ snapshots: [[run({ event: 'push', databaseId: 7, url: 'https://run/push' })]], jobsById: { 7: OTHER_JOBS } }); + const outcome = await validateGate({ ref: 'issue/1', sha: SHA, ops: fake.ops, mutants: false }); + assert.equal(outcome.result, 'green'); + assert.equal(outcome.url, 'https://run/push'); + assert.deepEqual(fake.dispatched, []); +}); + +test('#696: without mutants a red push run returns the task, a push still running is not waited for', async () => { + const red = fakeOps({ snapshots: [[run({ event: 'push', databaseId: 7, conclusion: 'failure', url: 'https://run/red' })]], jobsById: { 7: OTHER_JOBS } }); + assert.equal((await validateGate({ ref: 'issue/1', sha: SHA, ops: red.ops, mutants: false })).result, 'failed'); + // Идущий push раунд не разбудит (resume слушает dispatch) — гейт диспатчит + // свой лёгкий прогон и ждёт уже его. + const running = [run({ event: 'push', databaseId: 7, status: 'in_progress', conclusion: null })]; + const fake = fakeOps({ snapshots: [running, [...running, run({ databaseId: 8, status: 'in_progress', conclusion: null })]], jobsById: { 8: OTHER_JOBS } }); + const outcome = await validateGate({ ref: 'issue/1', sha: SHA, ops: fake.ops, mutants: false, wait: false, pollMs: 1000 }); + assert.equal(outcome.result, 'pending'); + assert.equal(outcome.runId, 8); + assert.deepEqual(fake.dispatched, ['issue/1:light'], 'the light dispatch asks for no mutants'); +}); + +test('#696: a completed run beats a newer running dispatch only without mutants', async () => { + const done = run({ event: 'push', databaseId: 7, url: 'https://run/push' }); + const running = run({ databaseId: 9, status: 'in_progress', conclusion: null }); + const light = fakeOps({ snapshots: [[running, done]], jobsById: { 7: OTHER_JOBS } }); + assert.equal((await validateGate({ ref: 'issue/1', sha: SHA, ops: light.ops, mutants: false, wait: false })).result, 'green'); + assert.equal(proofCandidate(run({ event: 'push', status: 'in_progress' }), { mutants: false }), false); + assert.equal(proofCandidate(run({ event: 'push' }), { mutants: true }), false); +});