mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-02 21:01:21 +00:00
fix(release): let the stable-line review run when release.yml queues it (#704)
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 <tag>" 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
This commit is contained in:
@@ -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 '*'`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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} мин`);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user