From 37b1cbf74b951fac5094bceca91c2955942b3ce5 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 21:41:13 +0300 Subject: [PATCH] fix(release): let the stable-line review run when release.yml queues it (#704) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit release.yml dispatches release-review.yml with GITHUB_TOKEN, so the run is started by github-actions[bot], and claude-code-action refused it: "Workflow initiated by non-human actor: github-actions (type: Bot). Add bot to allowed_bots list" (v1.78.0: release run 36468444979, review 36468505112). The release went out and nobody learned that the review never ran. The review step now allows exactly github-actions[bot]. At the pinned SHA (9cdae7f0) the action compares allowed_bots entries and the actor case-insensitively with the `[bot]` suffix stripped, so this entry matches GITHUB_ACTOR; any other bot is still refused, and a human dispatch never consults the list. independent-review no longer stops at the dispatch: it looks the run up by workflow, branch dev, event, time and run-name "Release review " for up to three minutes and writes the link and status to the step summary. A run that did not appear or did not start is a warning; the release is not blocked. Neither file is executed from main, so no mirror is needed (§10.4). Issue: #704 User-Visible: no Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd --- .github/workflows/release-review.yml | 8 ++ .github/workflows/release.yml | 52 +++++++++++- PROCESS.md | 4 + test/release-review.test.mjs | 55 ++++++++++++- test/release-workflow.test.mjs | 117 ++++++++++++++++++++++++++- 5 files changed, 231 insertions(+), 5 deletions(-) diff --git a/.github/workflows/release-review.yml b/.github/workflows/release-review.yml index 85ea0255..b7e117c6 100644 --- a/.github/workflows/release-review.yml +++ b/.github/workflows/release-review.yml @@ -183,6 +183,14 @@ jobs: with: claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} github_token: ${{ secrets.GITHUB_TOKEN }} + # #704: `release.yml` ставит ревью в очередь токеном GITHUB_TOKEN, и + # прогон начинает `github-actions[bot]` (тип Bot). Action отказывает + # ботам, которых нет в списке («Workflow initiated by non-human actor: + # github-actions (type: Bot)», v1.78.0). Разрешён ровно этот бот — + # не '*': любой другой бот по-прежнему отклоняется. Action сравнивает + # имена без регистра и без суффикса `[bot]`. Человек, запустивший + # ревью руками, списка не касается. + allowed_bots: "github-actions[bot]" path_to_claude_code_executable: ${{ steps.claude_bin.outputs.path }} prompt: | Ты независимый ревьюер релиза House Plan. Язык ответа — русский. diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f63d0fef..8bfb6f04 100755 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -153,14 +153,60 @@ jobs: GH_TOKEN: ${{ github.token }} TAG: ${{ needs.candidate.outputs.tag }} SHA: ${{ needs.candidate.outputs.sha }} + # #704: сколько ждать, что dispatch стал прогоном, — выпуск не ждёт + # дольше нескольких минут; укладывается в timeout-minutes job. + APPEAR_SECONDS: 180 + POLL_SECONDS: 15 run: | - if gh workflow run release-review.yml --repo "${{ github.repository }}" --ref dev \ + repo="${{ github.repository }}" + # Отсчёт окна — до dispatch, с запасом минута на расхождение часов. + since=$(( $(date -u +%s) - 60 )) + if ! gh workflow run release-review.yml --repo "${{ github.repository }}" --ref dev \ -f tag="$TAG" -f candidate="$SHA"; then - echo "Независимое ревью $TAG поставлено в очередь: release-review.yml на dev" >> "$GITHUB_STEP_SUMMARY" - else echo "::warning::ревью линии $TAG не запущено — выпуск продолжается; запустить руками: gh workflow run release-review.yml --ref dev -f tag=$TAG -f candidate=$SHA" + echo "Независимое ревью $TAG: **не запущено** — dispatch release-review.yml отклонён." >> "$GITHUB_STEP_SUMMARY" exit 1 fi + # #704: dispatch не возвращает прогона, и v1.78.0 показал цену: ревью + # упало в прогоне, о котором выпуск не знал. Прогон ищется по workflow, + # ветке dev, событию, времени и run-name «Release review <тег>». + # Найден и стартовал — ссылка и статус в сводке; нет — предупреждение, + # выпуск не блокируется. + title="Release review $TAG" + run="" + started=false + tries=$(( APPEAR_SECONDS / POLL_SECONDS )) + for try in $(seq 1 "$tries"); do + found=$(gh run list --repo "$repo" --workflow release-review.yml --branch dev \ + --event workflow_dispatch --limit 20 \ + --json databaseId,url,status,conclusion,displayTitle,createdAt \ + | jq -c --arg title "$title" --argjson since "$since" \ + '[.[] | select(.displayTitle == $title and (.createdAt | sub("\\.[0-9]+Z$"; "Z") | fromdateiso8601) >= $since)] | sort_by(.createdAt) | last // empty') || found="" + if [ -n "$found" ]; then + run="$found" + case "$(jq -r .status <<< "$run")" in + in_progress|completed) started=true; break ;; + esac + fi + if [ "$try" -lt "$tries" ]; then sleep "$POLL_SECONDS"; fi + done + minutes=$(( (APPEAR_SECONDS + 59) / 60 )) + if [ -z "$run" ]; then + echo "::warning::прогон ревью линии $TAG не появился за $minutes мин — выпуск продолжается; проверить Actions → release-review.yml" + echo "Независимое ревью $TAG: **не стартовало за $minutes мин** — прогон release-review.yml на dev не найден." >> "$GITHUB_STEP_SUMMARY" + exit 0 + fi + url=$(jq -r .url <<< "$run") + status=$(jq -r '.status + (if (.conclusion // "") != "" then " · " + .conclusion else "" end)' <<< "$run") + if [ "$started" != "true" ]; then + echo "::warning::ревью линии $TAG в очереди и не стартовало за $minutes мин: $url" + echo "Независимое ревью $TAG: **не стартовало за $minutes мин** (в очереди) — [прогон]($url), статус $status." >> "$GITHUB_STEP_SUMMARY" + exit 0 + fi + if [ "$(jq -r '.conclusion // ""' <<< "$run")" = "failure" ]; then + echo "::warning::ревью линии $TAG уже упало: $url" + fi + echo "Независимое ревью $TAG **запущено**: [прогон]($url), статус $status. Выпуск его не ждёт." >> "$GITHUB_STEP_SUMMARY" gate: name: "Гейт: контракт, Validate, Full Performance и E2E на точном SHA" diff --git a/PROCESS.md b/PROCESS.md index 0691d25f..295ba043 100644 --- a/PROCESS.md +++ b/PROCESS.md @@ -1506,6 +1506,10 @@ Golden, браузерные смоки, performance и полный HA-харн **Выпуск не блокирует.** `release.yml` ставит ревью в очередь job `independent-review` сразу после закрепления SHA кандидата — параллельно гейтам; ни один job выпуска от него не зависит, его отказ — предупреждение. +Dispatch идёт токеном `GITHUB_TOKEN`, прогон начинает `github-actions[bot]`, и +action ревью пускает ровно этого бота (`allowed_bots`, не `'*'`). Job находит +поставленный прогон и пишет в сводку выпуска ссылку и статус; прогон, не +стартовавший за три минуты, — предупреждение (#704). Документ — рекомендация: владелец берёт находки в работу (issue в очередь следующей беты) либо оставляет без действий. Автоматически находки в issue не превращаются. diff --git a/test/release-review.test.mjs b/test/release-review.test.mjs index af8b0749..39513b1a 100644 --- a/test/release-review.test.mjs +++ b/test/release-review.test.mjs @@ -3,7 +3,8 @@ // модель без права записи; документ в dev публикует детерминированный шаг. import assert from 'node:assert/strict'; import test from 'node:test'; -import { readFileSync } from 'node:fs'; +import { readdirSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { buildLineMembership, previousStableTag, productFiles, releaseReviewDocPath, renderBrief, @@ -94,3 +95,55 @@ test('#638: повтор на тот же тег не тратит модель, assert.match(prepare, /echo "proceed=false"/); assert.match(jobBlock('model_review'), /if: needs\.prepare\.outputs\.proceed == 'true'/); }); + +// #704: `release.yml` ставит ревью в очередь токеном GITHUB_TOKEN — прогон +// начинает `github-actions[bot]`, и claude-code-action без списка ботов +// отказывал ему (v1.78.0: release run 36468444979, ревью 36468505112). Список — +// ровно этот бот, не '*': любой другой бот отклоняется, как и прежде. +const EXPECTED_BOT = 'github-actions[bot]'; + +/** Ключи `with:` шага action ревью — строки с отступом на уровень глубже `with:`. */ +function reviewStepInputs() { + const model = jobBlock('model_review'); + const start = model.indexOf(' - name: Review\n'); + assert.ok(start > 0, 'шаг Review найден'); + const rest = model.slice(start + 1); + const next = rest.search(/\n {6}- name: /); + const step = next < 0 ? rest : rest.slice(0, next + 1); + assert.match(step, /^ {8}uses: anthropics\/claude-code-action@[0-9a-f]{40} /m, 'action пиннут полным SHA'); + const withAt = step.indexOf('\n with:\n'); + assert.ok(withAt > 0, 'у шага есть with:'); + const inputs = new Map(); + for (const line of step.slice(withAt + '\n with:\n'.length).split('\n')) { + if (line.trim() && !/^ {10}/.test(line)) break; + const m = /^ {10}([a-z_]+):\s*(.*)$/.exec(line); + if (m) inputs.set(m[1], m[2].trim()); + } + return inputs; +} + +test('#704 AC1/AC3: action ревью разрешает ровно бота, который ставит его в очередь, и не всех ботов', () => { + const inputs = reviewStepInputs(); + assert.ok(inputs.has('allowed_bots'), 'allowed_bots на месте: без него прогон от github-actions[bot] отклоняется'); + const raw = inputs.get('allowed_bots'); + const value = raw.replace(/^(['"])(.*)\1$/, '$2'); + assert.notEqual(value.trim(), '*', "'*' пустил бы любого бота"); + const bots = value.split(',').map((bot) => bot.trim()).filter(Boolean); + assert.deepEqual(bots, [EXPECTED_BOT], 'ровно один бот — тот, от имени которого dispatch'); + // Ожидаемое имя держится за то, как release.yml ставит ревью в очередь: dispatch + // токеном GITHUB_TOKEN — это и есть github-actions[bot]. + const release = readFileSync(fileURLToPath(new URL('../.github/workflows/release.yml', import.meta.url)), 'utf8'); + const review = release.slice(release.indexOf('\n independent-review:\n'), release.indexOf('\n gate:\n')); + assert.match(review, /GH_TOKEN: \$\{\{ github\.token \}\}\n/, 'dispatch идёт токеном GITHUB_TOKEN'); + assert.match(review, /gh workflow run release-review\.yml/); +}); + +test("#704: ни один workflow не пускает к action всех ботов ('*')", () => { + const dir = fileURLToPath(new URL('../.github/workflows/', import.meta.url)); + for (const name of readdirSync(dir).filter((file) => /\.ya?ml$/.test(file))) { + const text = readFileSync(join(dir, name), 'utf8'); + for (const m of text.matchAll(/^\s+allowed_bots:\s*(.*)$/gm)) { + assert.doesNotMatch(m[1], /^['"]?\s*\*\s*['"]?$|(^|,)\s*\*\s*(,|$)/, `${name}: allowed_bots '*'`); + } + } +}); diff --git a/test/release-workflow.test.mjs b/test/release-workflow.test.mjs index 68d10cc8..07222e70 100644 --- a/test/release-workflow.test.mjs +++ b/test/release-workflow.test.mjs @@ -3,7 +3,10 @@ // only after the gates saw the very same bytes. import assert from 'node:assert/strict'; import test from 'node:test'; -import { readdirSync, readFileSync } from 'node:fs'; +import { spawnSync } from 'node:child_process'; +import { mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; import { fileURLToPath } from 'node:url'; const WORKFLOWS = fileURLToPath(new URL('../.github/workflows/', import.meta.url)); @@ -134,3 +137,115 @@ test('#638 AC2: ревью линии ставится в очередь пар assert.ok(!/needs\.independent-review/.test(job(name)), `${name} не читает результат ревью линии`); } }); + +// #704: dispatch не возвращает прогона — v1.78.0 выпустился, а ревью линии +// упало в прогоне, о котором выпуск не знал (release run 36468444979, ревью +// 36468505112). Job находит поставленный прогон и пишет ссылку и статус в +// сводку; не нашёл за несколько минут — предупреждение, выпуск не блокируется. +// Шаг исполняется настоящим bash по тексту из release.yml; gh и sleep подменены. +const reviewStepScript = () => { + const block = job('independent-review'); + const lines = block.split('\n'); + const runAt = lines.findIndex((line) => /^ {8}run: \|\s*$/.test(line)); + assert.ok(runAt > 0, 'у шага есть run: |'); + const body = []; + for (const line of lines.slice(runAt + 1)) { + if (line.trim() && !line.startsWith(' ')) break; + body.push(line.slice(10)); + } + return body.join('\n').replace(/\$\{\{ github\.repository \}\}/g, 'o/r'); +}; + +const hasTools = () => process.platform !== 'win32' + && ['bash', 'jq'].every((tool) => spawnSync(tool, ['--version']).status === 0); + +/** + * `snapshots` — ответы `gh run list` по порядку опросов (последний повторяется), + * `dispatch` — код `gh workflow run`. + */ +function runReviewStep({ snapshots = [[]], dispatch = 0, appear = 45, poll = 15 } = {}) { + const dir = mkdtempSync(join(tmpdir(), 'hp-704-')); + try { + const bin = join(dir, 'bin'); + mkdirSync(bin); + snapshots.forEach((rows, i) => writeFileSync(join(dir, `runs-${i + 1}.json`), JSON.stringify(rows))); + writeFileSync(join(bin, 'gh'), [ + '#!/bin/bash', + `dir=${JSON.stringify(dir)}`, + 'echo "$*" >> "$dir/gh.log"', + `if [ "$1 $2" = "workflow run" ]; then exit ${dispatch}; fi`, + 'if [ "$1 $2" = "run list" ]; then', + ' n=$(( $(cat "$dir/n" 2>/dev/null || echo 0) + 1 )); echo "$n" > "$dir/n"', + ` f="$dir/runs-$n.json"; [ -f "$f" ] || f="$dir/runs-${snapshots.length}.json"`, + ' cat "$f"; exit 0', + 'fi', + 'echo "unexpected gh $*" >&2; exit 97', + '', + ].join('\n'), { mode: 0o755 }); + writeFileSync(join(bin, 'sleep'), '#!/bin/sh\nexit 0\n', { mode: 0o755 }); + const summary = join(dir, 'summary.md'); + writeFileSync(summary, ''); + const r = spawnSync('bash', ['--noprofile', '--norc', '-e', '-c', reviewStepScript()], { + encoding: 'utf8', + env: { + ...process.env, PATH: `${bin}:${process.env.PATH}`, GH_TOKEN: 'x', TAG: 'v1.79.0', SHA: 'c'.repeat(40), + APPEAR_SECONDS: String(appear), POLL_SECONDS: String(poll), GITHUB_STEP_SUMMARY: summary, + }, + }); + const log = (() => { try { return readFileSync(join(dir, 'gh.log'), 'utf8'); } catch { return ''; } })(); + return { status: r.status, stdout: r.stdout, stderr: r.stderr, summary: readFileSync(summary, 'utf8'), gh: log.split('\n').filter(Boolean) }; + } finally { + rmSync(dir, { recursive: true, force: true }); + } +} + +const iso = (offsetSeconds) => new Date(Date.now() + offsetSeconds * 1000).toISOString().replace(/\.\d+Z$/, 'Z'); +const reviewRun = (id, status, extra = {}) => ({ + databaseId: id, url: `https://github.com/o/r/actions/runs/${id}`, status, conclusion: '', + displayTitle: 'Release review v1.79.0', createdAt: iso(0), ...extra, +}); + +test('#704 AC2: прогон ревью найден и стартовал — ссылка и статус в сводке, без предупреждения', { skip: !hasTools() && 'нужны bash и jq' }, () => { + const older = reviewRun(1, 'completed', { conclusion: 'failure', createdAt: iso(-3600) }); + const otherTag = reviewRun(2, 'in_progress', { displayTitle: 'Release review v1.78.0' }); + const r = runReviewStep({ snapshots: [[older, otherTag], [older, otherTag, reviewRun(3, 'queued')], [older, otherTag, reviewRun(3, 'in_progress')]] }); + assert.equal(r.status, 0, r.stderr); + assert.doesNotMatch(r.stdout, /::warning::/); + assert.match(r.summary, /Независимое ревью v1\.79\.0 \*\*запущено\*\*: \[прогон\]\(https:\/\/github\.com\/o\/r\/actions\/runs\/3\), статус in_progress\. Выпуск его не ждёт\./); + const lists = r.gh.filter((line) => line.startsWith('run list')); + assert.equal(lists.length, 3, 'опрос остановился, как только прогон стартовал'); + assert.match(lists[0], /--workflow release-review\.yml --branch dev --event workflow_dispatch/); + assert.match(r.gh[0], /^workflow run release-review\.yml --repo o\/r --ref dev -f tag=v1\.79\.0 -f candidate=c{40}$/, 'dispatch — до поиска'); +}); + +test('#704 AC2: прогон не появился — предупреждение «не стартовало за N мин», шаг не красный', { skip: !hasTools() && 'нужны bash и jq' }, () => { + const stale = reviewRun(1, 'completed', { conclusion: 'failure', createdAt: iso(-3600) }); + const r = runReviewStep({ snapshots: [[stale]], appear: 180, poll: 15 }); + assert.equal(r.status, 0, 'выпуск не блокируется'); + assert.match(r.stdout, /^::warning::прогон ревью линии v1\.79\.0 не появился за 3 мин/m); + assert.match(r.summary, /Независимое ревью v1\.79\.0: \*\*не стартовало за 3 мин\*\*/); + assert.equal(r.gh.filter((line) => line.startsWith('run list')).length, 12, 'опрос ограничен окном: 180 с / 15 с'); + assert.doesNotMatch(r.summary, /runs\/1/, 'прогон прошлого запуска — не этот'); +}); + +test('#704 AC2: прогон в очереди всё окно — предупреждение со ссылкой; отказ dispatch — прежний', { skip: !hasTools() && 'нужны bash и jq' }, () => { + const queued = runReviewStep({ snapshots: [[reviewRun(5, 'queued')]], appear: 30, poll: 15 }); + assert.equal(queued.status, 0); + assert.match(queued.stdout, /^::warning::ревью линии v1\.79\.0 в очереди и не стартовало за 1 мин: https:\/\/github\.com\/o\/r\/actions\/runs\/5$/m); + assert.match(queued.summary, /\*\*не стартовало за 1 мин\*\* \(в очереди\) — \[прогон\]\(https:\/\/github\.com\/o\/r\/actions\/runs\/5\), статус queued\./); + const refused = runReviewStep({ dispatch: 1 }); + assert.equal(refused.status, 1, 'job с continue-on-error: отказ dispatch виден, выпуск идёт'); + assert.match(refused.stdout, /^::warning::ревью линии v1\.79\.0 не запущено — выпуск продолжается/m); + assert.match(refused.summary, /\*\*не запущено\*\*/); + assert.ok(!refused.gh.some((line) => line.startsWith('run list')), 'без dispatch искать нечего'); +}); + +test('#704 AC2: ожидание прогона укладывается в бюджет job', () => { + const block = job('independent-review'); + const appear = Number(/^ {10}APPEAR_SECONDS: (\d+)$/m.exec(block)?.[1]); + const poll = Number(/^ {10}POLL_SECONDS: (\d+)$/m.exec(block)?.[1]); + const timeout = Number(/^ {4}timeout-minutes: (\d+)$/m.exec(block)?.[1]); + assert.ok(appear > 0 && poll > 0 && timeout > 0, JSON.stringify({ appear, poll, timeout })); + assert.ok(appear <= 5 * 60, 'ждать не дольше нескольких минут'); + assert.ok(appear + 60 < timeout * 60, `окно ${appear} с + запас на dispatch и опросы < timeout ${timeout} мин`); +});