mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
ci: разделить стадии ревью по бюджетам (#551)
Issue: #551 User-Visible: no
This commit is contained in:
+414
-92
@@ -254,17 +254,41 @@ jobs:
|
||||
echo "spent=$spent" >> "$GITHUB_OUTPUT"
|
||||
echo "limit=$limit" >> "$GITHUB_OUTPUT"
|
||||
|
||||
review:
|
||||
name: "Ревью (Claude): вердикт в issue"
|
||||
prepare:
|
||||
name: "Ревью: материал и deterministic gates"
|
||||
needs: guard
|
||||
if: needs.guard.outputs.stage != ''
|
||||
runs-on: ubuntu-latest
|
||||
concurrency:
|
||||
group: process-issue-${{ github.event.issue.number }}
|
||||
cancel-in-progress: false
|
||||
# Время — единственный настоящий ограничитель зациклившегося прогона.
|
||||
timeout-minutes: 45
|
||||
# Ожидание Validate не отнимает бюджет у модели (#551). Сам gate может
|
||||
# ждать 45 минут; подготовке оставлен отдельный запас на checkout/rebase.
|
||||
timeout-minutes: 55
|
||||
outputs:
|
||||
proceed: ${{ steps.gate.outputs.proceed }}
|
||||
branch: ${{ steps.branch.outputs.name }}
|
||||
rebase_note: ${{ steps.rebase.outputs.note }}
|
||||
material_sha: ${{ steps.material.outputs.sha }}
|
||||
material_tree: ${{ steps.material.outputs.tree }}
|
||||
material_specs: ${{ steps.material.outputs.specs }}
|
||||
material_issue_body: ${{ steps.material.outputs.issue_body }}
|
||||
reuse: ${{ steps.reuse.outputs.reuse }}
|
||||
reuse_doc: ${{ steps.reuse.outputs.doc }}
|
||||
reuse_round: ${{ steps.reuse.outputs.round }}
|
||||
reuse_tree: ${{ steps.reuse.outputs.tree }}
|
||||
validate_result: ${{ steps.gate.outputs.result }}
|
||||
validate_url: ${{ steps.gate.outputs.url }}
|
||||
validated_note: ${{ steps.validated.outputs.note }}
|
||||
spec_body_changed: ${{ steps.spec_body.outputs.changed }}
|
||||
spec_body_doc: ${{ steps.spec_body.outputs.doc }}
|
||||
spec_body_recorded: ${{ steps.spec_body.outputs.recorded }}
|
||||
duration_seconds: ${{ steps.duration.outputs.seconds }}
|
||||
steps:
|
||||
- name: Начать измерение стадии
|
||||
id: clock
|
||||
run: echo "started=$(date +%s)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
@@ -620,66 +644,13 @@ jobs:
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
if [ -n "$row" ]; then echo "Validate на $short: зелёный"; else echo "Validate на $short: зелёного нет"; fi
|
||||
|
||||
# Зависимости ставятся ПОСЛЕ переключения на ветку задачи: lockfile мог
|
||||
# измениться именно в ней, и установка по копии из dev дала бы не то дерево.
|
||||
- name: Установить зависимости
|
||||
if: steps.gate.outputs.proceed == 'true' && steps.reuse.outputs.reuse != 'true'
|
||||
run: npm ci
|
||||
|
||||
# Браузер нужен не всякому ревью (см. правило выбора гейтов в промпте),
|
||||
# но когда нужен — качать его заново дороже, чем держать в кэше.
|
||||
- name: Кэш браузеров Playwright
|
||||
id: pw
|
||||
if: steps.gate.outputs.proceed == 'true' && steps.reuse.outputs.reuse != 'true'
|
||||
uses: actions/cache@v6
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
key: playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
|
||||
|
||||
- name: Установить Chromium
|
||||
if: steps.gate.outputs.proceed == 'true' && steps.reuse.outputs.reuse != 'true' && steps.pw.outputs.cache-hit != 'true'
|
||||
# Без --with-deps: системные библиотеки Chromium предустановлены в
|
||||
# образе ubuntu-latest, а apt при промахе кэша съедал минуты из бюджета
|
||||
# ревью и подолгу перебирал недоступное azure-зеркало (#175). Если
|
||||
# библиотека когда-нибудь пропадёт из образа, Chromium не запустится с
|
||||
# внятной ошибкой — тогда флаг вернуть.
|
||||
run: npx playwright install chromium
|
||||
|
||||
# Action ревью ставит Claude Code через `claude install`, и с его
|
||||
# v1.0.218 (Claude Code 2.1.265) лаунчер ~/.local/bin/claude на
|
||||
# ubuntu-latest иногда не появляется, хотя установщик рапортует об успехе;
|
||||
# action верит рапорту и падает на ENOENT (anthropics, issue 1817).
|
||||
# Кладём бинарник сами: версию берём ту, что пинит сам action (он уже
|
||||
# скачан в _actions к началу job), контрольную сумму — из манифеста релиза.
|
||||
- name: Установить Claude Code детерминированно
|
||||
id: claude_bin
|
||||
if: steps.gate.outputs.proceed == 'true' && steps.reuse.outputs.reuse != 'true'
|
||||
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"
|
||||
|
||||
# ТЗ живёт в теле issue (#517), а GitHub хранит его правки без diff и без
|
||||
# доступной чтением истории. Единственное, что можно доказать, — «текст
|
||||
# изменился после того, как ревью ТЗ признало его зелёным». Это не отказ:
|
||||
# автор вправе уточнять ТЗ. Это находка, доставляемая ревьюеру кода в
|
||||
# промпт и владельцу в комментарий вердикта: разбор AC в таком заходе
|
||||
# обязан быть полным, а не по дельте.
|
||||
# Это deterministic evidence для промпта, поэтому вычисляется до запуска
|
||||
# модели и передаётся вместе с неизменяемым контрактом материала (#551).
|
||||
- name: "ТЗ менялось после зелёного ревью ТЗ (#517)"
|
||||
id: spec_body
|
||||
if: steps.gate.outputs.proceed == 'true' && steps.reuse.outputs.reuse != 'true' && needs.guard.outputs.stage == 'code'
|
||||
env:
|
||||
NUM: ${{ github.event.issue.number }}
|
||||
BRANCH: ${{ steps.branch.outputs.name }}
|
||||
DIGEST: ${{ steps.material.outputs.issue_body }}
|
||||
run: |
|
||||
if [ -z "$DIGEST" ]; then
|
||||
@@ -702,9 +673,178 @@ jobs:
|
||||
printf '%s\n' "$out"
|
||||
printf '%s\n' "$out" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Собрать контракт материала между стадиями
|
||||
id: prepared
|
||||
if: steps.gate.outputs.proceed == 'true' && steps.reuse.outputs.reuse != 'true'
|
||||
env:
|
||||
NUM: ${{ github.event.issue.number }}
|
||||
STAGE: ${{ needs.guard.outputs.stage }}
|
||||
CYCLE: ${{ needs.guard.outputs.cycle }}
|
||||
BRANCH: ${{ steps.branch.outputs.name }}
|
||||
MATERIAL_SHA: ${{ steps.material.outputs.sha }}
|
||||
MATERIAL_TREE: ${{ steps.material.outputs.tree }}
|
||||
MATERIAL_SPECS: ${{ steps.material.outputs.specs }}
|
||||
MATERIAL_ISSUE_BODY: ${{ steps.material.outputs.issue_body }}
|
||||
VALIDATE_RESULT: ${{ steps.gate.outputs.result }}
|
||||
VALIDATE_URL: ${{ steps.gate.outputs.url }}
|
||||
REBASE_NOTE: ${{ steps.rebase.outputs.note }}
|
||||
VALIDATED_NOTE: ${{ steps.validated.outputs.note }}
|
||||
SPEC_BODY_CHANGED: ${{ steps.spec_body.outputs.changed }}
|
||||
SPEC_BODY_DOC: ${{ steps.spec_body.outputs.doc }}
|
||||
SPEC_BODY_RECORDED: ${{ steps.spec_body.outputs.recorded }}
|
||||
run: |
|
||||
dir="$RUNNER_TEMP/review-prepared"
|
||||
mkdir -p "$dir"
|
||||
jq -n -S \
|
||||
--arg run_id "$GITHUB_RUN_ID" --arg run_attempt "$GITHUB_RUN_ATTEMPT" \
|
||||
--arg issue "$NUM" --arg stage "$STAGE" --arg cycle "$CYCLE" \
|
||||
--arg branch "$BRANCH" --arg material_sha "$MATERIAL_SHA" \
|
||||
--arg material_tree "$MATERIAL_TREE" --arg material_specs "$MATERIAL_SPECS" \
|
||||
--arg material_issue_body "$MATERIAL_ISSUE_BODY" \
|
||||
--arg validate_result "$VALIDATE_RESULT" --arg validate_url "$VALIDATE_URL" \
|
||||
--arg rebase_note "$REBASE_NOTE" --arg validated_note "$VALIDATED_NOTE" \
|
||||
--arg spec_body_changed "$SPEC_BODY_CHANGED" --arg spec_body_doc "$SPEC_BODY_DOC" \
|
||||
--arg spec_body_recorded "$SPEC_BODY_RECORDED" \
|
||||
'{schema:1,run_id:$run_id,run_attempt:$run_attempt,issue:$issue,stage:$stage,cycle:$cycle,branch:$branch,material_sha:$material_sha,material_tree:$material_tree,material_specs:$material_specs,material_issue_body:$material_issue_body,validate_result:$validate_result,validate_url:$validate_url,rebase_note:$rebase_note,validated_note:$validated_note,spec_body_changed:$spec_body_changed,spec_body_doc:$spec_body_doc,spec_body_recorded:$spec_body_recorded}' \
|
||||
> "$dir/prepared.json"
|
||||
(cd "$dir" && sha256sum prepared.json > manifest.sha256)
|
||||
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'
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ${{ steps.prepared.outputs.artifact }}
|
||||
path: ${{ runner.temp }}/review-prepared
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
- name: Зафиксировать длительность подготовки
|
||||
id: duration
|
||||
if: always()
|
||||
env:
|
||||
STARTED: ${{ steps.clock.outputs.started }}
|
||||
run: |
|
||||
seconds=$(( $(date +%s) - STARTED ))
|
||||
echo "seconds=$seconds" >> "$GITHUB_OUTPUT"
|
||||
echo "- deterministic prerequisites: **${seconds}s**" >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
model_review:
|
||||
name: "Ревью: работа модели"
|
||||
needs: [guard, prepare]
|
||||
if: needs.prepare.outputs.proceed == 'true' && needs.prepare.outputs.reuse != 'true'
|
||||
runs-on: ubuntu-latest
|
||||
concurrency:
|
||||
group: process-issue-${{ github.event.issue.number }}
|
||||
cancel-in-progress: false
|
||||
# Весь бюджет принадлежит модели и её локальным проверкам; ожидания Validate
|
||||
# в этом job больше нет (#551).
|
||||
timeout-minutes: 45
|
||||
outputs:
|
||||
duration_seconds: ${{ steps.duration.outputs.seconds }}
|
||||
steps:
|
||||
- name: Начать измерение стадии
|
||||
id: clock
|
||||
run: echo "started=$(date +%s)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: ${{ needs.prepare.outputs.material_sha }}
|
||||
persist-credentials: false
|
||||
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
|
||||
- name: Получить контракт подготовленного материала
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: review-prepared-${{ github.event.issue.number }}-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
path: ${{ runner.temp }}/review-prepared
|
||||
|
||||
- name: Проверить контракт и exact material
|
||||
env:
|
||||
NUM: ${{ github.event.issue.number }}
|
||||
STAGE: ${{ needs.guard.outputs.stage }}
|
||||
CYCLE: ${{ needs.guard.outputs.cycle }}
|
||||
BRANCH: ${{ needs.prepare.outputs.branch }}
|
||||
MATERIAL_SHA: ${{ needs.prepare.outputs.material_sha }}
|
||||
MATERIAL_TREE: ${{ needs.prepare.outputs.material_tree }}
|
||||
MATERIAL_SPECS: ${{ needs.prepare.outputs.material_specs }}
|
||||
MATERIAL_ISSUE_BODY: ${{ needs.prepare.outputs.material_issue_body }}
|
||||
VALIDATE_RESULT: ${{ needs.prepare.outputs.validate_result }}
|
||||
VALIDATE_URL: ${{ needs.prepare.outputs.validate_url }}
|
||||
REBASE_NOTE: ${{ needs.prepare.outputs.rebase_note }}
|
||||
VALIDATED_NOTE: ${{ needs.prepare.outputs.validated_note }}
|
||||
SPEC_BODY_CHANGED: ${{ needs.prepare.outputs.spec_body_changed }}
|
||||
SPEC_BODY_DOC: ${{ needs.prepare.outputs.spec_body_doc }}
|
||||
SPEC_BODY_RECORDED: ${{ needs.prepare.outputs.spec_body_recorded }}
|
||||
run: |
|
||||
dir="$RUNNER_TEMP/review-prepared"
|
||||
(cd "$dir" && sha256sum -c manifest.sha256)
|
||||
jq -e \
|
||||
--arg run_id "$GITHUB_RUN_ID" --arg run_attempt "$GITHUB_RUN_ATTEMPT" \
|
||||
--arg issue "$NUM" --arg stage "$STAGE" --arg cycle "$CYCLE" \
|
||||
--arg branch "$BRANCH" --arg sha "$MATERIAL_SHA" --arg tree "$MATERIAL_TREE" \
|
||||
--arg specs "$MATERIAL_SPECS" --arg body "$MATERIAL_ISSUE_BODY" \
|
||||
--arg validate "$VALIDATE_RESULT" --arg validate_url "$VALIDATE_URL" \
|
||||
--arg rebase_note "$REBASE_NOTE" --arg validated_note "$VALIDATED_NOTE" \
|
||||
--arg spec_changed "$SPEC_BODY_CHANGED" --arg spec_doc "$SPEC_BODY_DOC" \
|
||||
--arg spec_recorded "$SPEC_BODY_RECORDED" \
|
||||
'.schema == 1 and .run_id == $run_id and .run_attempt == $run_attempt and .issue == $issue and .stage == $stage and .cycle == $cycle and .branch == $branch and .material_sha == $sha and .material_tree == $tree and .material_specs == $specs and .material_issue_body == $body and .validate_result == $validate and .validate_url == $validate_url and .rebase_note == $rebase_note and .validated_note == $validated_note and .spec_body_changed == $spec_changed and .spec_body_doc == $spec_doc and .spec_body_recorded == $spec_recorded' \
|
||||
"$dir/prepared.json"
|
||||
test "$(git rev-parse HEAD)" = "$MATERIAL_SHA"
|
||||
test "$(git rev-parse 'HEAD^{tree}')" = "$MATERIAL_TREE"
|
||||
|
||||
# Зависимости ставятся ПОСЛЕ переключения на ветку задачи: lockfile мог
|
||||
# измениться именно в ней, и установка по копии из dev дала бы не то дерево.
|
||||
- name: Установить зависимости
|
||||
run: npm ci
|
||||
|
||||
# Браузер нужен не всякому ревью (см. правило выбора гейтов в промпте),
|
||||
# но когда нужен — качать его заново дороже, чем держать в кэше.
|
||||
- name: Кэш браузеров Playwright
|
||||
id: pw
|
||||
uses: actions/cache@v6
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
key: playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
|
||||
|
||||
- name: Установить Chromium
|
||||
if: steps.pw.outputs.cache-hit != 'true'
|
||||
# Без --with-deps: системные библиотеки Chromium предустановлены в
|
||||
# образе ubuntu-latest, а apt при промахе кэша съедал минуты из бюджета
|
||||
# ревью и подолгу перебирал недоступное azure-зеркало (#175). Если
|
||||
# библиотека когда-нибудь пропадёт из образа, Chromium не запустится с
|
||||
# внятной ошибкой — тогда флаг вернуть.
|
||||
run: npx playwright install chromium
|
||||
|
||||
# Action ревью ставит Claude Code через `claude install`, и с его
|
||||
# v1.0.218 (Claude Code 2.1.265) лаунчер ~/.local/bin/claude на
|
||||
# ubuntu-latest иногда не появляется, хотя установщик рапортует об успехе;
|
||||
# action верит рапорту и падает на ENOENT (anthropics, issue 1817).
|
||||
# Кладём бинарник сами: версию берём ту, что пинит сам action (он уже
|
||||
# скачан в _actions к началу job), контрольную сумму — из манифеста релиза.
|
||||
- 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
|
||||
if: steps.gate.outputs.proceed == 'true' && steps.reuse.outputs.reuse != 'true'
|
||||
uses: anthropics/claude-code-action@v1
|
||||
env:
|
||||
# Вне рабочей копии: восстановление дерева ревьюером не должно
|
||||
@@ -729,9 +869,9 @@ jobs:
|
||||
Номер захода нужен для имени документа — два документа с
|
||||
одинаковым номером затёрли бы друг друга.
|
||||
|
||||
${{ steps.rebase.outputs.note }}
|
||||
${{ needs.prepare.outputs.rebase_note }}
|
||||
|
||||
${{ steps.spec_body.outputs.changed == 'true' && format('ТЗ в теле issue менялось после зелёного ревью ТЗ ({0}, записанный хеш {1}). GitHub хранит правки тела без diff — дельту показать нельзя, поэтому AC сверяются с ТЕКУЩИМ текстом целиком, а не по дельте, и находка называется в вердикте (#517).', steps.spec_body.outputs.doc, steps.spec_body.outputs.recorded) || '' }}
|
||||
${{ needs.prepare.outputs.spec_body_changed == 'true' && format('ТЗ в теле issue менялось после зелёного ревью ТЗ ({0}, записанный хеш {1}). GitHub хранит правки тела без diff — дельту показать нельзя, поэтому AC сверяются с ТЕКУЩИМ текстом целиком, а не по дельте, и находка называется в вердикте (#517).', needs.prepare.outputs.spec_body_doc, needs.prepare.outputs.spec_body_recorded) || '' }}
|
||||
|
||||
**Если цикл не первый — объём разбора по дельте, а не заново**
|
||||
(PROCESS.md §2.9, issue #214). Раньше промпт был одинаковым для
|
||||
@@ -794,7 +934,7 @@ jobs:
|
||||
|
||||
Для этапа code: материал — диапазон `git log --oneline origin/dev..HEAD`
|
||||
и `git diff origin/dev...HEAD`. **Материал ревью — ровно
|
||||
`${{ steps.material.outputs.sha }}`, рабочая копия уже на нём.** Не
|
||||
`${{ needs.prepare.outputs.material_sha }}`, рабочая копия уже на нём.** Не
|
||||
делай `git fetch`, `git pull` и `git checkout` на другой коммит: вердикт
|
||||
привязан к этому SHA (#312), и страж слияния сверяет вершину ветки с
|
||||
ним. Если автор в issue называет более новый коммит, которого в
|
||||
@@ -814,7 +954,7 @@ jobs:
|
||||
правке — не тщательность, а потеря времени: полные наборы это
|
||||
предрелизный гейт (PROCESS.md §8), а не гейт ревью.
|
||||
|
||||
${{ steps.validated.outputs.note }}
|
||||
${{ needs.prepare.outputs.validated_note }}
|
||||
|
||||
Если зелёного прогона на этом SHA нет — прогоняешь сам, они дешёвые,
|
||||
и в повторном раунде тоже: код изменился, а стоят они минуты:
|
||||
@@ -928,25 +1068,174 @@ jobs:
|
||||
--allowedTools Read,Write,Grep,Glob,Bash,mcp__github__add_issue_comment,mcp__github__issue_write,mcp__github__issue_read
|
||||
--json-schema '{"type":"object","properties":{"verdict":{"type":"string","enum":["green","yellow","red"]},"high":{"type":"integer"},"medium":{"type":"integer"},"summary":{"type":"string"}},"required":["verdict","high","medium","summary"]}'
|
||||
|
||||
# Ревьюер пишет только в docs/reviews/. Что именно попадёт в коммит,
|
||||
# решает этот шаг, а не модель: всё остальное откатывается.
|
||||
- name: Опубликовать документ ревью
|
||||
if: steps.gate.outputs.proceed == 'true' && steps.reuse.outputs.reuse != 'true'
|
||||
- name: Запечатать результат модели
|
||||
id: result
|
||||
env:
|
||||
TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
|
||||
BRANCH: ${{ steps.branch.outputs.name }}
|
||||
SOURCE: ${{ runner.temp }}/review-document.md
|
||||
OUT: ${{ steps.review.outputs.structured_output }}
|
||||
NUM: ${{ github.event.issue.number }}
|
||||
STAGE: ${{ needs.guard.outputs.stage }}
|
||||
CYCLE: ${{ needs.guard.outputs.cycle }}
|
||||
SOURCE: ${{ runner.temp }}/review-document.md
|
||||
run: |
|
||||
marker=CODE-REVIEW
|
||||
if [ "$STAGE" = "spec" ]; then marker=SPEC-REVIEW; fi
|
||||
legacy="docs/reviews/${marker}-${NUM}-r${CYCLE}.md"
|
||||
if [ ! -f "$SOURCE" ] && [ -f "$legacy" ]; then cp "$legacy" "$SOURCE"; fi
|
||||
test -s "$SOURCE" || { echo "::error::модель не оставила документ ревью"; exit 1; }
|
||||
printf '%s' "$OUT" | jq -e '
|
||||
(.verdict == "green" or .verdict == "yellow" or .verdict == "red")
|
||||
and (.high | type == "number") and (.medium | type == "number")
|
||||
and (.summary | type == "string")' > "$RUNNER_TEMP/verdict.json"
|
||||
dir="$RUNNER_TEMP/review-result"
|
||||
mkdir -p "$dir"
|
||||
cp "$RUNNER_TEMP/review-prepared/prepared.json" "$dir/prepared.json"
|
||||
cp "$SOURCE" "$dir/review-document.md"
|
||||
cp "$RUNNER_TEMP/verdict.json" "$dir/verdict.json"
|
||||
(cd "$dir" && sha256sum prepared.json review-document.md verdict.json > manifest.sha256)
|
||||
echo "artifact=review-result-${NUM}-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Передать результат интеграции
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ${{ steps.result.outputs.artifact }}
|
||||
path: ${{ runner.temp }}/review-result
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
- name: Зафиксировать длительность модели
|
||||
id: duration
|
||||
if: always()
|
||||
env:
|
||||
STARTED: ${{ steps.clock.outputs.started }}
|
||||
run: |
|
||||
seconds=$(( $(date +%s) - STARTED ))
|
||||
echo "seconds=$seconds" >> "$GITHUB_OUTPUT"
|
||||
echo "- model review: **${seconds}s**" >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
integrate:
|
||||
name: "Ревью: публикация и интеграция"
|
||||
needs: [guard, prepare, model_review]
|
||||
if: always() && needs.guard.outputs.stage != ''
|
||||
runs-on: ubuntu-latest
|
||||
concurrency:
|
||||
group: process-issue-${{ github.event.issue.number }}
|
||||
cancel-in-progress: false
|
||||
# Слияние кандидата при ушедшем dev может само ждать Validate до 45 минут;
|
||||
# оно не должно обрывать уже оплаченный model review (#551).
|
||||
timeout-minutes: 55
|
||||
steps:
|
||||
- name: Начать измерение стадии
|
||||
id: clock
|
||||
run: echo "started=$(date +%s)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Проверить исходы предыдущих стадий
|
||||
id: ready
|
||||
env:
|
||||
PREPARE_RESULT: ${{ needs.prepare.result }}
|
||||
MODEL_RESULT: ${{ needs.model_review.result }}
|
||||
PROCEED: ${{ needs.prepare.outputs.proceed }}
|
||||
REUSE: ${{ needs.prepare.outputs.reuse }}
|
||||
run: |
|
||||
if [ "$PREPARE_RESULT" != "success" ]; then
|
||||
echo "::error::стадия deterministic prerequisites завершилась: $PREPARE_RESULT"
|
||||
exit 1
|
||||
fi
|
||||
if [ "$PROCEED" != "true" ]; then
|
||||
echo "подготовка уже вернула задачу автору; интегрировать нечего"
|
||||
echo "proceed=false" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
if [ "$REUSE" != "true" ] && [ "$MODEL_RESULT" != "success" ]; then
|
||||
echo "::error::стадия model review завершилась: $MODEL_RESULT"
|
||||
exit 1
|
||||
fi
|
||||
echo "proceed=true" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- uses: actions/checkout@v7
|
||||
if: steps.ready.outputs.proceed == 'true'
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: dev
|
||||
persist-credentials: false
|
||||
|
||||
- uses: actions/setup-node@v7
|
||||
if: steps.ready.outputs.proceed == 'true'
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- name: Получить результат модели
|
||||
if: steps.ready.outputs.proceed == 'true' && needs.prepare.outputs.reuse != 'true'
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: review-result-${{ github.event.issue.number }}-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
path: ${{ runner.temp }}/review-result
|
||||
|
||||
- name: Проверить полноту и происхождение результата
|
||||
id: result
|
||||
if: steps.ready.outputs.proceed == 'true' && needs.prepare.outputs.reuse != 'true'
|
||||
env:
|
||||
NUM: ${{ github.event.issue.number }}
|
||||
STAGE: ${{ needs.guard.outputs.stage }}
|
||||
CYCLE: ${{ needs.guard.outputs.cycle }}
|
||||
BRANCH: ${{ needs.prepare.outputs.branch }}
|
||||
MATERIAL_SHA: ${{ needs.prepare.outputs.material_sha }}
|
||||
MATERIAL_TREE: ${{ needs.prepare.outputs.material_tree }}
|
||||
MATERIAL_SPECS: ${{ needs.prepare.outputs.material_specs }}
|
||||
MATERIAL_ISSUE_BODY: ${{ needs.prepare.outputs.material_issue_body }}
|
||||
VALIDATE_RESULT: ${{ needs.prepare.outputs.validate_result }}
|
||||
VALIDATE_URL: ${{ needs.prepare.outputs.validate_url }}
|
||||
REBASE_NOTE: ${{ needs.prepare.outputs.rebase_note }}
|
||||
VALIDATED_NOTE: ${{ needs.prepare.outputs.validated_note }}
|
||||
SPEC_BODY_CHANGED: ${{ needs.prepare.outputs.spec_body_changed }}
|
||||
SPEC_BODY_DOC: ${{ needs.prepare.outputs.spec_body_doc }}
|
||||
SPEC_BODY_RECORDED: ${{ needs.prepare.outputs.spec_body_recorded }}
|
||||
run: |
|
||||
dir="$RUNNER_TEMP/review-result"
|
||||
expected=$(printf '%s\n' manifest.sha256 prepared.json review-document.md verdict.json | sort)
|
||||
actual=$(find "$dir" -maxdepth 1 -type f -printf '%f\n' | sort)
|
||||
test "$actual" = "$expected" || { echo "::error::неполный или неожиданный набор evidence"; printf 'ожидалось:\n%s\nполучено:\n%s\n' "$expected" "$actual"; exit 1; }
|
||||
(cd "$dir" && sha256sum -c manifest.sha256)
|
||||
test -s "$dir/review-document.md"
|
||||
jq -e \
|
||||
--arg run_id "$GITHUB_RUN_ID" --arg run_attempt "$GITHUB_RUN_ATTEMPT" \
|
||||
--arg issue "$NUM" --arg stage "$STAGE" --arg cycle "$CYCLE" \
|
||||
--arg branch "$BRANCH" --arg sha "$MATERIAL_SHA" --arg tree "$MATERIAL_TREE" \
|
||||
--arg specs "$MATERIAL_SPECS" --arg body "$MATERIAL_ISSUE_BODY" \
|
||||
--arg validate "$VALIDATE_RESULT" --arg validate_url "$VALIDATE_URL" \
|
||||
--arg rebase_note "$REBASE_NOTE" --arg validated_note "$VALIDATED_NOTE" \
|
||||
--arg spec_changed "$SPEC_BODY_CHANGED" --arg spec_doc "$SPEC_BODY_DOC" \
|
||||
--arg spec_recorded "$SPEC_BODY_RECORDED" \
|
||||
'.schema == 1 and .run_id == $run_id and .run_attempt == $run_attempt and .issue == $issue and .stage == $stage and .cycle == $cycle and .branch == $branch and .material_sha == $sha and .material_tree == $tree and .material_specs == $specs and .material_issue_body == $body and .validate_result == $validate and .validate_url == $validate_url and .rebase_note == $rebase_note and .validated_note == $validated_note and .spec_body_changed == $spec_changed and .spec_body_doc == $spec_doc and .spec_body_recorded == $spec_recorded' \
|
||||
"$dir/prepared.json"
|
||||
jq -e '
|
||||
(.verdict == "green" or .verdict == "yellow" or .verdict == "red")
|
||||
and (.high | type == "number") and (.medium | type == "number")
|
||||
and (.summary | type == "string")' "$dir/verdict.json" >/dev/null
|
||||
{
|
||||
echo 'structured_output<<EOF_RESULT'
|
||||
cat "$dir/verdict.json"
|
||||
echo 'EOF_RESULT'
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
# Ревьюер пишет только в docs/reviews/. Что именно попадёт в коммит,
|
||||
# решает этот шаг, а не модель: всё остальное откатывается.
|
||||
- name: Опубликовать документ ревью
|
||||
if: steps.ready.outputs.proceed == 'true' && needs.prepare.outputs.reuse != 'true'
|
||||
env:
|
||||
TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
|
||||
BRANCH: ${{ needs.prepare.outputs.branch }}
|
||||
NUM: ${{ github.event.issue.number }}
|
||||
STAGE: ${{ needs.guard.outputs.stage }}
|
||||
CYCLE: ${{ needs.guard.outputs.cycle }}
|
||||
SOURCE: ${{ runner.temp }}/review-result/review-document.md
|
||||
# После ребейза конвейером — якоря приведённого материала (#515).
|
||||
MATERIAL_SHA: ${{ steps.material.outputs.sha }}
|
||||
MATERIAL_TREE: ${{ steps.material.outputs.tree }}
|
||||
MATERIAL_SPECS: ${{ steps.material.outputs.specs }}
|
||||
MATERIAL_ISSUE_BODY: ${{ steps.material.outputs.issue_body }}
|
||||
MATERIAL_SHA: ${{ needs.prepare.outputs.material_sha }}
|
||||
MATERIAL_TREE: ${{ needs.prepare.outputs.material_tree }}
|
||||
MATERIAL_SPECS: ${{ needs.prepare.outputs.material_specs }}
|
||||
MATERIAL_ISSUE_BODY: ${{ needs.prepare.outputs.material_issue_body }}
|
||||
# Вердикт из structured_output попадает в блок якорей (#499): по нему
|
||||
# следующий заход решает, можно ли применить зелёный вердикт повторно.
|
||||
OUT: ${{ steps.review.outputs.structured_output }}
|
||||
OUT: ${{ steps.result.outputs.structured_output }}
|
||||
run: |
|
||||
verdict=$(printf '%s' "$OUT" | jq -r '.verdict // empty' 2>/dev/null || true)
|
||||
high=$(printf '%s' "$OUT" | jq -r '.high // empty' 2>/dev/null || true)
|
||||
@@ -1096,12 +1385,12 @@ jobs:
|
||||
# достижим там из необновлённой локальной ветки. Читателю отчёта от этого
|
||||
# пользы нет — он достанет только то, что есть на origin.
|
||||
- name: "Материал раунда воспроизводим (#413)"
|
||||
if: steps.gate.outputs.proceed == 'true' && steps.reuse.outputs.reuse != 'true'
|
||||
if: steps.ready.outputs.proceed == 'true' && needs.prepare.outputs.reuse != 'true'
|
||||
env:
|
||||
NUM: ${{ github.event.issue.number }}
|
||||
STAGE: ${{ needs.guard.outputs.stage }}
|
||||
CYCLE: ${{ needs.guard.outputs.cycle }}
|
||||
BRANCH: ${{ steps.branch.outputs.name }}
|
||||
BRANCH: ${{ needs.prepare.outputs.branch }}
|
||||
run: |
|
||||
marker=CODE-REVIEW
|
||||
if [ "$STAGE" = "spec" ]; then marker=SPEC-REVIEW; fi
|
||||
@@ -1113,14 +1402,14 @@ jobs:
|
||||
git show "origin/$target:$doc" | node scripts/review-doc-guard.mjs --doc=-
|
||||
- name: Решение по вердикту
|
||||
id: decide
|
||||
if: steps.gate.outputs.proceed == 'true'
|
||||
if: steps.ready.outputs.proceed == 'true'
|
||||
env:
|
||||
OUT: ${{ steps.review.outputs.structured_output }}
|
||||
OUT: ${{ steps.result.outputs.structured_output }}
|
||||
STAGE: ${{ needs.guard.outputs.stage }}
|
||||
REUSE: ${{ steps.reuse.outputs.reuse }}
|
||||
REUSE_DOC: ${{ steps.reuse.outputs.doc }}
|
||||
REUSE_ROUND: ${{ steps.reuse.outputs.round }}
|
||||
REUSE_TREE: ${{ steps.reuse.outputs.tree }}
|
||||
REUSE: ${{ needs.prepare.outputs.reuse }}
|
||||
REUSE_DOC: ${{ needs.prepare.outputs.reuse_doc }}
|
||||
REUSE_ROUND: ${{ needs.prepare.outputs.reuse_round }}
|
||||
REUSE_TREE: ${{ needs.prepare.outputs.reuse_tree }}
|
||||
GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
|
||||
NUM: ${{ github.event.issue.number }}
|
||||
run: |
|
||||
@@ -1168,11 +1457,11 @@ jobs:
|
||||
# dev действительно ушёл и вердикт зелёный, то есть слияние вот-вот
|
||||
# случится (#364).
|
||||
- name: dev ушёл вперёд, пока шло ревью
|
||||
if: steps.gate.outputs.proceed == 'true' && needs.guard.outputs.stage == 'code'
|
||||
if: steps.ready.outputs.proceed == 'true' && needs.guard.outputs.stage == 'code'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
|
||||
NUM: ${{ github.event.issue.number }}
|
||||
MATERIAL: ${{ steps.material.outputs.sha }}
|
||||
MATERIAL: ${{ needs.prepare.outputs.material_sha }}
|
||||
GREEN: ${{ steps.decide.outputs.green }}
|
||||
run: |
|
||||
git fetch -q origin dev
|
||||
@@ -1201,9 +1490,9 @@ jobs:
|
||||
env:
|
||||
HP_PROCESS_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
|
||||
GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
|
||||
BRANCH: ${{ steps.branch.outputs.name }}
|
||||
BRANCH: ${{ needs.prepare.outputs.branch }}
|
||||
NUM: ${{ github.event.issue.number }}
|
||||
MATERIAL_SHA: ${{ steps.material.outputs.sha }}
|
||||
MATERIAL_SHA: ${{ needs.prepare.outputs.material_sha }}
|
||||
run: |
|
||||
if [ -z "$BRANCH" ]; then
|
||||
echo "::error::ветки задачи нет — сливать нечего"
|
||||
@@ -1214,7 +1503,7 @@ jobs:
|
||||
--issue="$NUM" --repo="${{ github.repository }}"
|
||||
|
||||
- name: Переставить метку
|
||||
if: steps.gate.outputs.proceed == 'true'
|
||||
if: steps.ready.outputs.proceed == 'true'
|
||||
env:
|
||||
# Именно PAT: с GITHUB_TOKEN следующий шаг конвейера не запустится.
|
||||
GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
|
||||
@@ -1231,18 +1520,51 @@ jobs:
|
||||
--add-label "$TO" --remove-label "$FROM"
|
||||
echo "$FROM -> $TO"
|
||||
|
||||
- name: Позвать владельца, если ревью упало
|
||||
- name: Сводка длительности стадий
|
||||
if: always()
|
||||
env:
|
||||
STARTED: ${{ steps.clock.outputs.started }}
|
||||
PREPARE_SECONDS: ${{ needs.prepare.outputs.duration_seconds }}
|
||||
MODEL_SECONDS: ${{ needs.model_review.outputs.duration_seconds }}
|
||||
run: |
|
||||
integration=$(( $(date +%s) - STARTED ))
|
||||
{
|
||||
echo "## Бюджеты стадий (#551)"
|
||||
echo ""
|
||||
echo "| Стадия | Длительность | Лимит |"
|
||||
echo "|---|---:|---:|"
|
||||
echo "| deterministic prerequisites | ${PREPARE_SECONDS:-нет полного измерения}s | 55 min |"
|
||||
echo "| model review | ${MODEL_SECONDS:-не запускалась}s | 45 min |"
|
||||
echo "| publication/integration | ${integration}s | 55 min |"
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Позвать владельца, если стадия упала
|
||||
if: failure()
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
|
||||
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||
PREPARE_RESULT: ${{ needs.prepare.result }}
|
||||
MODEL_RESULT: ${{ needs.model_review.result }}
|
||||
PROCEED: ${{ needs.prepare.outputs.proceed }}
|
||||
REUSE: ${{ needs.prepare.outputs.reuse }}
|
||||
run: |
|
||||
stage="публикация/интеграция"
|
||||
detail="Модель уже завершила работу; её запечатанный результат сохранён artifact-ом этого run, но не был применён."
|
||||
if [ "$PREPARE_RESULT" != "success" ]; then
|
||||
stage="deterministic prerequisites"
|
||||
detail="Модель не запускалась, цикл ревью не израсходован."
|
||||
elif [ "$PROCEED" = "true" ] && [ "$REUSE" != "true" ] && [ "$MODEL_RESULT" != "success" ]; then
|
||||
stage="model review"
|
||||
detail="Полного валидного результата модели нет; метка не менялась."
|
||||
fi
|
||||
# Тело через heredoc, а не многострочный --body: строка с нулевым
|
||||
# отступом обрывает блок YAML и оставляет незакрытую кавычку.
|
||||
cat > /tmp/failure.md <<EOF
|
||||
Автоматическое ревью не отработало: [прогон]($RUN_URL). Статусная метка не менялась, задача осталась на месте.
|
||||
Автоматическое ревью не отработало: стадия **$stage** остановилась. [Прогон]($RUN_URL). Статусная метка не менялась, задача осталась на месте.
|
||||
|
||||
Если вердикт выше всё же опубликован — сбой произошёл после него. Перестановку метки в этом случае выполняет чат обслуживания или владелец, но не автор задачи: автор не толкует вердикт о своей же работе.
|
||||
$detail
|
||||
|
||||
Если вердикт выше всё же опубликован — перестановку метки выполняет чат обслуживания или владелец, но не автор задачи: автор не толкует вердикт о своей же работе.
|
||||
EOF
|
||||
gh issue comment "${{ github.event.issue.number }}" \
|
||||
--repo "${{ github.repository }}" --body-file /tmp/failure.md
|
||||
|
||||
@@ -234,17 +234,18 @@ rather than kept in someone's head. Infrastructure work skips specification, not
|
||||
code review.
|
||||
|
||||
**Review starts by itself.** Applying `S4-spec-review` or `S7-code-review` fires the
|
||||
pipeline, which reviews without anyone asking and takes ten to forty-five minutes.
|
||||
pipeline. Deterministic gates, model review and integration have independent
|
||||
55/45/55-minute budgets (#551); typical runs finish well before those ceilings.
|
||||
|
||||
**Having applied one of those labels, wait for the result instead of ending the
|
||||
session.** Reporting "handed over for review" stops a conveyor that could have kept
|
||||
moving on its own. An agent has no clock — it exists only during its own turn — so
|
||||
waiting means polling: every 90 seconds, at most 30 times. A single long sleep hits
|
||||
waiting means polling: every 90 seconds, at most 110 times. A single long sleep hits
|
||||
the command timeout. Do the polling with `node scripts/wait-verdict.mjs --issue NN
|
||||
[--sha <tip>]` (#496): it watches the label, the pipeline's own comments (conflict,
|
||||
cancelled merge, failed run) and optionally Validate on the SHA, prints only when
|
||||
the state changes and exits 0 on a new label, 3 on an event that needs a hand,
|
||||
4 on timeout — the same 90 s × 30 without a model turn per tick. It writes
|
||||
4 on timeout — the same 90 s × 110 without a model turn per tick. It writes
|
||||
nothing. Pipeline comments older than the latest application of `S4`/`S7` are
|
||||
the baseline, not an outcome of the new round; an outcome from the current round
|
||||
which already exists when the waiter starts is still delivered immediately (#546).
|
||||
|
||||
+20
-6
@@ -923,8 +923,8 @@ S7-code-review → код-ревью → слияние в dev → S8-merged л
|
||||
или вида задач за Claude. Ревьюер читает `docs/SCOPE.md`, `AGENTS.md`, этот
|
||||
документ и тело issue, публикует разбор комментарием, заводит issue на
|
||||
Medium-находки вне скоупа задачи (#202), кладёт документ в `docs/reviews/` ветки
|
||||
задачи и возвращает вердикт структурированным JSON. **Метку переставляет отдельный
|
||||
детерминированный шаг по вердикту, а не модель.**
|
||||
задачи и возвращает вердикт структурированным JSON. **Метку переставляет отдельная
|
||||
детерминированная стадия по вердикту, а не модель.**
|
||||
|
||||
Четыре вещи, без которых конвейер молча не работает:
|
||||
|
||||
@@ -948,6 +948,17 @@ Medium-находки вне скоупа задачи (#202), кладёт до
|
||||
За 08–09.09 мутанты на каждом промежуточном пуше стоили 48 из 56 часов
|
||||
job-минут Validate и в основном отменялись следующим пушем.
|
||||
|
||||
Ожидание gates, работа модели и публикация/интеграция — три независимых jobs
|
||||
(#551) с отдельными бюджетами 55, 45 и 55 минут. Поэтому долгий Validate не
|
||||
съедает время модели, а ожидание кандидата после зелёного вердикта не обрывает
|
||||
готовый review. Между jobs передаётся запечатанный artifact: run/attempt, issue,
|
||||
этап, раунд, branch, SHA/tree материала, якоря ТЗ и результат Validate. Получатель
|
||||
сверяет полный набор файлов, SHA-256 и все поля с outputs предыдущей стадии;
|
||||
неполный, чужой или устаревший результат fail-closed не публикуется и не разрешает
|
||||
merge. Timeout/cancel/failure называет конкретную стадию и оставляет метку на месте;
|
||||
если модель не запускалась, цикл ревью не расходуется. Длительности всех трёх
|
||||
стадий печатаются отдельной таблицей в summary прогона.
|
||||
|
||||
Каждый раунд ревью платит только за то, что в нём изменилось (#518). Свидетель
|
||||
судится по **области своего якоря** — строкам патча плюс сорок строк с каждой
|
||||
стороны (`ANCHOR_RADIUS_LINES`): и в отпечатке журнала (#481), и в отборе по
|
||||
@@ -970,7 +981,8 @@ npm ci, Python и Chromium, оставаясь исполненной job: до
|
||||
минут до сорока пяти. Отчёт «передал на ревью» останавливает конвейер там, где он
|
||||
мог идти сам: вердикт придёт, а подхватить его будет некому. У агента нет часов —
|
||||
он существует только в момент своего хода, поэтому ожидание это опрос: раз в 90
|
||||
секунд, не более 30 попыток — `node scripts/wait-verdict.mjs --issue NN` делает его
|
||||
секунд, не более 110 попыток (запас на три независимых бюджета #551) —
|
||||
`node scripts/wait-verdict.mjs --issue NN` делает его
|
||||
детерминированно и говорит только при смене состояния (#496). Смотреть на метку, а
|
||||
не на комментарий: метка и есть состояние. Комментарии конвейера до последнего
|
||||
применения `S4`/`S7` считаются историческим baseline, а уже опубликованный исход
|
||||
@@ -1195,9 +1207,11 @@ Golden, браузерные смоки, performance и полный HA-харн
|
||||
без `S*` → `S7-code-review` ↔ `S6-in-progress` → `S8-merged`. ТЗ и ревью ТЗ нет,
|
||||
код-ревью обязательно.
|
||||
|
||||
Ревью запускается **само** от меток `S4-spec-review` и `S7-code-review` и идёт до
|
||||
45 минут. Поставив такую метку, автор не заканчивает работу, а ждёт смены метки
|
||||
опросом и продолжает по тому, чем она стала.
|
||||
Ревью запускается **само** от меток `S4-spec-review` и `S7-code-review`.
|
||||
Deterministic prerequisites, модель и интеграция имеют отдельные пределы 55/45/55
|
||||
минут; обычно стадии заканчиваются существенно раньше. Поставив такую метку, автор
|
||||
не заканчивает работу, а ждёт смены метки опросом и продолжает по тому, чем она
|
||||
стала.
|
||||
|
||||
- ветка `issue/<NN>-<slug>`, коммиты с трейлерами `Issue: #NN` и `User-Visible: yes|no`;
|
||||
- работаем прямыми коммитами в `dev`, без PR: блокирующий гейт — локальный
|
||||
|
||||
@@ -7847,6 +7847,39 @@ const MUTANT_DEFINITIONS = [
|
||||
replace: ' settled=true; break',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'review-model-checks-out-moving-dev',
|
||||
guard: 'node --test --test-name-pattern="#551" test/review-doc-guard.test.mjs',
|
||||
because: '#551: модель должна читать exact material, подготовленный до её запуска; checkout '
|
||||
+ 'подвижного dev разрывает контракт между зелёным gate и вердиктом',
|
||||
patches: [{
|
||||
file: '.github/workflows/process.yml',
|
||||
find: ' ref: ${{ needs.prepare.outputs.material_sha }}',
|
||||
replace: ' ref: dev # mutant: moving material',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'review-integration-skips-evidence-checksum',
|
||||
guard: 'node --test --test-name-pattern="#551" test/review-doc-guard.test.mjs',
|
||||
because: '#551: artifact между моделью и привилегированной интеграцией — вход доверенной '
|
||||
+ 'стадии; без checksum неполный или подменённый документ можно опубликовать и слить',
|
||||
patches: [{
|
||||
file: '.github/workflows/process.yml',
|
||||
find: ' (cd "$dir" && sha256sum -c manifest.sha256)\n test -s "$dir/review-document.md"',
|
||||
replace: ' # mutant: result contents are trusted\n test -s "$dir/review-document.md"',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'review-integration-trusts-failed-model',
|
||||
guard: 'node --test --test-name-pattern="#551" test/review-doc-guard.test.mjs',
|
||||
because: '#551: timeout/cancel/failure модели не является вердиктом; интеграция обязана '
|
||||
+ 'остановиться, сохранить метку и назвать упавшую стадию',
|
||||
patches: [{
|
||||
file: '.github/workflows/process.yml',
|
||||
find: ' if [ "$REUSE" != "true" ] && [ "$MODEL_RESULT" != "success" ]; then',
|
||||
replace: ' if false; then # mutant: every model result is accepted',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'announce-listens-to-the-release-event-again',
|
||||
guard: 'node --test test/release-workflow.test.mjs',
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
#!/usr/bin/env node
|
||||
// Детерминированное ожидание вердикта / CI (#496).
|
||||
//
|
||||
// Runbook велит автору ждать вердикт опросом: раз в 90 секунд, не более 30 раз,
|
||||
// Runbook велит автору ждать вердикт опросом: раз в 90 секунд, не более 110 раз,
|
||||
// смотреть на метку. В исполнении LLM каждый тик — это ход модели с чтением JSON
|
||||
// и рассуждением «ничего не изменилось» — десятки пустых ходов на одно ревью.
|
||||
// Этот скрипт делает опрос сам и ГОВОРИТ только при смене состояния: одинаковое
|
||||
@@ -16,7 +16,7 @@
|
||||
// Скрипт НИЧЕГО не пишет: ни меток, ни комментариев, ни запусков. Новое ревью
|
||||
// или релиз начинаются только по текущей авторизации человека.
|
||||
//
|
||||
// node scripts/wait-verdict.mjs --issue 437 [--sha <tip>] [--interval 90] [--max 30]
|
||||
// node scripts/wait-verdict.mjs --issue 437 [--sha <tip>] [--interval 90] [--max 110]
|
||||
//
|
||||
// Коды выхода: 0 — статус сменился (вердикт есть, читать метку и комментарий);
|
||||
// 3 — доставлено событие, требующее действия (отказ конвейера, конфликт,
|
||||
@@ -171,7 +171,7 @@ if (isMainModule(import.meta.url)) {
|
||||
const repo = value('repo', 'Matysh/houseplan-card');
|
||||
const sha = value('sha') || null;
|
||||
const intervalMs = Number(value('interval', '90')) * 1000;
|
||||
const maxTicks = Number(value('max', '30'));
|
||||
const maxTicks = Number(value('max', '110'));
|
||||
waitForVerdict({ readSnapshot: ghSnapshotReader({ number, repo, sha }), intervalMs, maxTicks })
|
||||
.then((code) => { process.exitCode = code; })
|
||||
.catch((error) => { console.error(`wait-verdict: ${error.message}`); process.exitCode = 2; });
|
||||
|
||||
@@ -579,11 +579,14 @@ test('конвейер: посторонняя метка не входит в c
|
||||
// Concurrency — на job, не на workflow: иначе любой `labeled` вытеснял ожидающий S7.
|
||||
const head = workflow.slice(0, workflow.indexOf('\njobs:'));
|
||||
assert.doesNotMatch(head, /^concurrency:/m, 'concurrency на уровне workflow снова пустит в группу все метки');
|
||||
const guard = workflow.slice(workflow.indexOf('\n guard:'), workflow.indexOf('\n review:'));
|
||||
const guard = workflow.slice(workflow.indexOf('\n guard:'), workflow.indexOf('\n prepare:'));
|
||||
assert.match(guard, /if: github\.event\.label\.name == 'S4-spec-review' \|\| github\.event\.label\.name == 'S7-code-review'/);
|
||||
assert.match(guard, /concurrency:\n\s+group: process-issue-\$\{\{ github\.event\.issue\.number \}\}/);
|
||||
const review = workflow.slice(workflow.indexOf('\n review:'));
|
||||
assert.match(review, /concurrency:\n\s+group: process-issue-\$\{\{ github\.event\.issue\.number \}\}/);
|
||||
for (const [job, next] of [['prepare', 'model_review'], ['model_review', 'integrate'], ['integrate', null]]) {
|
||||
const start = workflow.indexOf(`\n ${job}:`);
|
||||
const end = next ? workflow.indexOf(`\n ${next}:`) : workflow.length;
|
||||
assert.match(workflow.slice(start, end), /concurrency:\n\s+group: process-issue-\$\{\{ github\.event\.issue\.number \}\}/, `${job} сериализован по issue`);
|
||||
}
|
||||
// Состояние читается текущее, не из снимка события.
|
||||
assert.match(guard, /gh issue view "\$NUM" --repo "\$REPO" --json labels/);
|
||||
assert.doesNotMatch(guard, /contains\(github\.event\.issue\.labels/, 'снимок события больше не источник меток');
|
||||
@@ -593,14 +596,15 @@ test('конвейер: посторонняя метка не входит в c
|
||||
test('конвейер: зелёный вердикт применяется повторно без модели, вердикт пишется в якоря (#499)', () => {
|
||||
const workflow = readFileSync(new URL('../.github/workflows/process.yml', import.meta.url), 'utf8');
|
||||
assert.match(workflow, /review-doc-guard\.mjs --reuse --marker=CODE-REVIEW --num="\$NUM" --head=HEAD/);
|
||||
const reviewStep = workflow.slice(workflow.indexOf(' - name: Review\n'), workflow.indexOf('anthropics/claude-code-action'));
|
||||
assert.match(reviewStep, /steps\.reuse\.outputs\.reuse != 'true'/, 'модель не вызывается при повторном применении');
|
||||
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'/,
|
||||
'при повторном применении вся стадия модели пропускается');
|
||||
const publish = workflow.slice(workflow.indexOf('- name: Опубликовать документ ревью'), workflow.indexOf('- name: Решение по вердикту'));
|
||||
assert.match(publish, /--verdict="\$verdict" --high="\$high"/);
|
||||
const decide = workflow.slice(workflow.indexOf('- name: Решение по вердикту'), workflow.indexOf('- name: dev ушёл вперёд'));
|
||||
assert.match(decide, /if \[ "\$REUSE" = "true" \]; then\n\s+(#[^\n]*\n\s+)*verdict=green; high=0/);
|
||||
// Ревьюер привязан к SHA материала — сам подтягивать новее не должен.
|
||||
assert.match(workflow, /Материал ревью — ровно\s+`\$\{\{ steps\.material\.outputs\.sha \}\}`/);
|
||||
assert.match(workflow, /Материал ревью — ровно\s+`\$\{\{ needs\.prepare\.outputs\.material_sha \}\}`/);
|
||||
});
|
||||
|
||||
test('#510 AC2: конвейер запускает Validate с мутантами на материале и не ревьюит красный', () => {
|
||||
@@ -611,28 +615,28 @@ test('#510 AC2: конвейер запускает Validate с мутантам
|
||||
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');
|
||||
const deps = at(' - name: Установить зависимости\n');
|
||||
const review = at(' - name: Review\n');
|
||||
assert.ok(material < gate && gate < back && back < deps && deps < review, 'гейт стоит после фиксации материала и до установки зависимостей/ревью');
|
||||
assert.ok(material < gate && gate < back && back < modelJob && modelJob < deps && deps < review,
|
||||
'гейт закончен в отдельной стадии до установки зависимостей/ревью');
|
||||
const gateStep = workflow.slice(gate, back);
|
||||
assert.match(gateStep, /node scripts\/validate-gate\.mjs --repo="\$\{\{ github\.repository \}\}" --ref="\$BRANCH" --sha="\$SHA"/);
|
||||
assert.match(gateStep, /if \[ "\$STAGE" != "code" \] \|\| \[ "\$REUSE" = "true" \]/, 'этап spec и reuse гейт не проходят');
|
||||
assert.match(gateStep, /SHA: \$\{\{ steps\.material\.outputs\.sha \}\}/, 'проверяется именно материал');
|
||||
// skip-ветка (spec/reuse) даёт proceed=true: ревью идёт, возврата S7→S6 нет (ревью ТЗ r2)
|
||||
assert.match(gateStep, /\{ echo 'proceed=true'; echo 'result=skipped'; \}/, 'skipped = proceed');
|
||||
assert.doesNotMatch(workflow.slice(back), /if:[^\n]*steps\.gate\.outputs\.result/, 'условия шагов — только по proceed, result идёт в текст комментария');
|
||||
assert.doesNotMatch(workflow.slice(modelJob), /steps\.gate\.outputs/, 'следующие jobs не читают локальные outputs prepare');
|
||||
const backStep = workflow.slice(back, deps);
|
||||
assert.match(backStep, /if: steps\.rebase\.outputs\.conflict != 'true' && steps\.gate\.outputs\.proceed != 'true'/);
|
||||
assert.match(backStep, /--add-label S6-in-progress --remove-label S7-code-review/);
|
||||
assert.match(backStep, /цикл ревью не израсходован/);
|
||||
// всё, что после гейта, условно по proceed — включая перестановку метки и слияние
|
||||
const after = workflow.slice(deps);
|
||||
assert.doesNotMatch(after, /if: steps\.rebase\.outputs\.conflict != 'true'/, 'после гейта нет шагов, условных только по конфликту');
|
||||
for (const name of ['Установить зависимости', 'Review', 'Решение по вердикту', 'Слить ветку в dev', 'Переставить метку']) {
|
||||
const i = at(` - name: ${name}\n`);
|
||||
const chunk = workflow.slice(i, i + 400);
|
||||
assert.match(chunk, /if: (needs\.guard\.outputs\.stage == 'code' && )?steps\.(gate\.outputs\.proceed == 'true'|decide\.outputs\.green == 'true')/, `${name}: условие по proceed/зелёному`);
|
||||
}
|
||||
assert.match(workflow.slice(modelJob, deps), /if: needs\.prepare\.outputs\.proceed == 'true'/,
|
||||
'красный prepare вообще не запускает модель');
|
||||
const integrate = workflow.slice(at('\n integrate:\n'));
|
||||
assert.match(integrate, /PROCEED: \$\{\{ needs\.prepare\.outputs\.proceed \}\}/);
|
||||
assert.match(integrate, /if \[ "\$PROCEED" != "true" \]; then[\s\S]*echo "proceed=false"/,
|
||||
'интеграция не применяется после красного gate');
|
||||
});
|
||||
|
||||
test('#515: якоря материала снимаются ПОСЛЕ ребейза конвейером и публикуются из шага material', () => {
|
||||
@@ -648,7 +652,7 @@ test('#515: якоря материала снимаются ПОСЛЕ ребе
|
||||
const publish = at(' - name: Опубликовать документ ревью\n');
|
||||
const publishStep = workflow.slice(publish, at(' - name: "Материал раунда воспроизводим (#413)"\n'));
|
||||
for (const name of ['SHA', 'TREE', 'SPECS']) {
|
||||
assert.match(publishStep, new RegExp(`MATERIAL_${name}: \\$\\{\\{ steps\\.material\\.outputs\\.${name.toLowerCase()} \\}\\}`), `MATERIAL_${name} из material`);
|
||||
assert.match(publishStep, new RegExp(`MATERIAL_${name}: \\$\\{\\{ needs\\.prepare\\.outputs\\.material_${name.toLowerCase()} \\}\\}`), `MATERIAL_${name} из prepare`);
|
||||
}
|
||||
// до-ребейзные якоря из шага branch никем не читаются: после force-push они мертвы (#508 r1–r3)
|
||||
assert.doesNotMatch(workflow, /steps\.branch\.outputs\.(sha|tree|specs)/, 'якоря из шага branch — осиротевшие после ребейза');
|
||||
@@ -723,7 +727,7 @@ test('#517: конвейер снимает хеш тела на материа
|
||||
assert.match(materialStep, /echo "issue_body=\$digest" >> "\$GITHUB_OUTPUT"/);
|
||||
assert.match(workflow.slice(reuse, specBody), /--issue-body="\$\{ISSUE_BODY\}"/, 'reuse учитывает тело (AC6)');
|
||||
assert.match(workflow, /--issue-body="\$MATERIAL_ISSUE_BODY"/, 'якорь попадает в документ');
|
||||
assert.match(workflow, /steps\.spec_body\.outputs\.changed == 'true' &&/, 'находка уходит в промпт ревьюера');
|
||||
assert.match(workflow, /needs\.prepare\.outputs\.spec_body_changed == 'true' &&/, 'находка уходит в промпт ревьюера');
|
||||
});
|
||||
|
||||
test('#517 AC4: документы процесса не требуют файла ТЗ, индекс docs/specs удалён', () => {
|
||||
@@ -744,6 +748,46 @@ test('#517 AC4: документы процесса не требуют файл
|
||||
assert.doesNotMatch(readme, /Статус ТЗ/);
|
||||
});
|
||||
|
||||
test('#551: gates, модель и интеграция имеют независимые jobs, contracts и бюджеты', () => {
|
||||
const workflow = readFileSync(new URL('../.github/workflows/process.yml', import.meta.url), 'utf8');
|
||||
const job = (name, next) => {
|
||||
const start = workflow.indexOf(`\n ${name}:`);
|
||||
assert.ok(start > 0, `job ${name} найден`);
|
||||
const end = next ? workflow.indexOf(`\n ${next}:`, start + 1) : workflow.length;
|
||||
assert.ok(end > start, `граница job ${name} найдена`);
|
||||
return workflow.slice(start, end);
|
||||
};
|
||||
const prepare = job('prepare', 'model_review');
|
||||
const model = job('model_review', 'integrate');
|
||||
const integrate = job('integrate');
|
||||
|
||||
assert.match(prepare, /timeout-minutes: 55/);
|
||||
assert.match(model, /timeout-minutes: 45/);
|
||||
assert.match(integrate, /timeout-minutes: 55/);
|
||||
assert.match(prepare, /node scripts\/validate-gate\.mjs/);
|
||||
assert.doesNotMatch(model, /validate-gate\.mjs/, 'ожидания Validate нет в бюджете модели');
|
||||
assert.match(model, /needs: \[guard, prepare\]/);
|
||||
assert.match(integrate, /needs: \[guard, prepare, model_review\]/);
|
||||
assert.match(model, /ref: \$\{\{ needs\.prepare\.outputs\.material_sha \}\}/,
|
||||
'модель получает exact material, а не подвижную ветку');
|
||||
|
||||
assert.match(prepare, /review-prepared-\$\{NUM\}-\$\{GITHUB_RUN_ID\}-\$\{GITHUB_RUN_ATTEMPT\}/);
|
||||
assert.equal((workflow.match(/sha256sum -c manifest\.sha256/g) || []).length, 2,
|
||||
'контракт проверяют и модель, и интеграция');
|
||||
assert.match(model, /test "\$\(git rev-parse HEAD\)" = "\$MATERIAL_SHA"/);
|
||||
assert.match(model, /review-result-\$\{NUM\}-\$\{GITHUB_RUN_ID\}-\$\{GITHUB_RUN_ATTEMPT\}/);
|
||||
assert.match(integrate, /неполный или неожиданный набор evidence/);
|
||||
assert.match(integrate, /sha256sum -c manifest\.sha256/);
|
||||
assert.match(integrate, /\.run_id == \$run_id[\s\S]*\.material_sha == \$sha[\s\S]*\.material_tree == \$tree/,
|
||||
'подмена run/SHA/tree между jobs отвергается');
|
||||
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/,
|
||||
'интеграция не доверяет failed/cancelled/skipped модели');
|
||||
assert.match(integrate, /цикл ревью не израсходован/);
|
||||
assert.match(integrate, /Бюджеты стадий \(#551\)/, 'длительности публикуются раздельно');
|
||||
});
|
||||
|
||||
// #539: `workflow_dispatch` принимает только ref, а не SHA. Конвейер сам
|
||||
// переписывает ветку ребейзом и тут же просит GitHub разрешить эту же ссылку:
|
||||
// 12.09 на #536 диспатч встал на ДОпушевый SHA, гейт не нашёл прогона на
|
||||
|
||||
Reference in New Issue
Block a user