Files
houseplan-card/test/release-review.test.mjs
T
Claudeandclaude[bot] 37b1cbf74b 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
2026-09-30 20:30:34 +00:00

150 lines
10 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// #638, PROCESS.md §11.5: независимое ревью линии перед стабильным релизом.
// Вход — issue, доказанные трейлерами в диапазоне «прошлый стабильный..кандидат»;
// модель без права записи; документ в dev публикует детерминированный шаг.
import assert from 'node:assert/strict';
import test from 'node:test';
import { readdirSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import {
buildLineMembership, previousStableTag, productFiles, releaseReviewDocPath, renderBrief,
} from '../scripts/release-review.mjs';
const WORKFLOW = readFileSync(fileURLToPath(new URL('../.github/workflows/release-review.yml', import.meta.url)), 'utf8');
const jobBlock = (name) => {
const start = WORKFLOW.indexOf(`\n ${name}:\n`);
assert.ok(start > 0, `нет job ${name}`);
const rest = WORKFLOW.slice(start + 1);
const next = rest.slice(1).search(/\n {2}[a-z_-]+:\n/);
return next < 0 ? rest : rest.slice(0, next + 1);
};
const sha = (c) => c.repeat(40);
test('#638 документ — только для стабильного тега, имя фиксировано', () => {
assert.equal(releaseReviewDocPath('v1.78.0'), 'docs/reviews/RELEASE-REVIEW-v1.78.0.md');
for (const bad of ['v1.78.0-beta.2', '1.78.0', 'v1.78', 'v01.2.3', '', 'v1.78.0/../x']) {
assert.throws(() => releaseReviewDocPath(bad), /not a stable release tag/, bad);
}
});
test('#638 база линии — прошлый СТАБИЛЬНЫЙ тег: беты, сам тег и более новые не годятся', () => {
const tags = ['v1.76.0', 'v1.77.0-beta.1', 'v1.77.0-beta.5', 'v1.77.0', 'v1.78.0-beta.1', 'v1.78.0-beta.2', 'v1.78.0', 'v1.9.0', 'v2.0.0'];
assert.equal(previousStableTag(tags, 'v1.78.0'), 'v1.77.0', 'бета линии не сжимает диапазон до хвоста');
assert.equal(previousStableTag(tags, 'v1.77.0'), 'v1.76.0');
assert.equal(previousStableTag(['v1.9.0', 'v1.10.0'], 'v1.11.0'), 'v1.10.0', 'сравнение числовое, не строковое');
assert.equal(previousStableTag(['v1.78.0-beta.1'], 'v1.78.0'), null, 'первая стабильная — база не выдумывается');
assert.equal(previousStableTag(['v1.77.1'], 'v1.77.2'), 'v1.77.1', 'патч-релиз судит свой патч-диапазон');
});
test('#638 AC1: issue линии — только доказанные трейлерами, в схеме RELEASE-MEMBERSHIP.json', () => {
const commits = [
{ sha: sha('a'), message: 'fix: x\n\nIssue: #607\nUser-Visible: yes' },
{ sha: sha('b'), message: 'docs: review document for #607\n\nIssue: #607\nUser-Visible: no' },
{ sha: sha('c'), message: 'feat: y\n\nIssue: #611\nUser-Visible: yes' },
{ sha: sha('d'), message: 'chore: упоминание #999 в тексте — не трейлер' },
];
const m = buildLineMembership({ tag: 'v1.78.0', candidate: sha('e'), base: { tag: 'v1.77.0', sha: sha('f') }, commits });
assert.equal(m.schema, 1);
assert.deepEqual(m.issues.map((row) => row.number), [607, 611]);
assert.deepEqual(m.issues[0].commits, [sha('a'), sha('b')]);
assert.throws(() => buildLineMembership({ tag: 'v1.78.0-beta.3', candidate: sha('e'), base: null, commits }), /stable/);
});
test('#638 поверхности — продуктовые файлы (класс A), без бандла и документов', () => {
assert.deepEqual(productFiles([
'src/room-cards.ts', 'src\\i18n\\ru.json', 'dist/houseplan-card.js',
'custom_components/houseplan/frontend/houseplan-card.js', 'custom_components/houseplan/api.py',
'docs/USER-GUIDE.ru.md', 'scripts/release-review.mjs', 'src/room-cards.ts',
]), ['custom_components/houseplan/api.py', 'src/i18n/ru.json', 'src/room-cards.ts']);
});
test('#638 бриф: вход без ТЗ и документов раундов, путь документа и база названы', () => {
const membership = { schema: 1, tag: 'v1.78.0', candidate: sha('e'), base: { tag: 'v1.77.0', sha: sha('f') }, issues: [{ number: 607, commits: [sha('a')] }] };
const brief = renderBrief({ membership, files: ['src/a.ts', 'docs/x.md'], runUrl: 'https://example.test/run/1' });
assert.match(brief, /RELEASE-REVIEW-v1\.78\.0\.md/);
assert.match(brief, /`v1\.77\.0`/);
assert.match(brief, /- #607 · коммитов: 1/);
assert.match(brief, /- `src\/a\.ts`/);
assert.ok(!/docs\/x\.md/.test(brief), 'не-продуктовые файлы в перечень поверхностей не входят');
assert.ok(!/\n\n\n/.test(brief), 'без пустых дыр');
});
test('#638: только ручной/вызванный запуск на dev, модель без права записи, документ пишет publish', () => {
const on = WORKFLOW.slice(WORKFLOW.indexOf('\non:'), WORKFLOW.indexOf('\npermissions:'));
assert.match(on, /^ {2}workflow_dispatch:/m);
assert.ok(!/^ {2}(?:push|schedule|workflow_run|issues|release):/m.test(on), 'не событие ветки по умолчанию — зеркало в main не нужно');
assert.match(WORKFLOW, /\npermissions:\n {2}contents: read\n/, 'потолок прав workflow — чтение');
assert.ok(!/: write/.test(WORKFLOW), 'ни одного права на запись: документ пушится токеном процесса, а не GITHUB_TOKEN');
const model = jobBlock('model_review');
assert.match(model, /github_token: \$\{\{ secrets\.GITHUB_TOKEN \}\}/, '#556: без него action выдаст App-токен с записью');
assert.match(model, /--allowedTools Read,Write,Grep,Glob,Bash\n/, 'модели не даны инструменты GitHub');
assert.ok(!/HP_PROCESS_TOKEN/.test(model), 'токен процесса модели недоступен');
assert.match(model, /ref: \$\{\{ needs\.prepare\.outputs\.candidate \}\}/, 'судится ровно кандидат');
assert.match(model, /Не читай ТЗ задач/, 'независимость: без ТЗ');
assert.match(model, /документы раундов \(docs\/reviews\/\*\*\)/, 'независимость: без раундов');
const publish = jobBlock('publish');
assert.match(publish, /secrets\.HP_PROCESS_TOKEN/);
assert.match(publish, /review-doc-guard\.mjs/, 'в dev уходит только docs\/reviews');
assert.match(publish, /reviews-index\.mjs --dir=docs\/reviews --strict/, 'индекс тем же коммитом и неизвестное имя сразу блокирует публикацию');
assert.match(publish, /Issue: #638\n\s+User-Visible: no/, 'трейлеры провенанса');
});
test('#638: повтор на тот же тег не тратит модель, если документ уже в dev', () => {
const prepare = jobBlock('prepare');
assert.match(prepare, /if \[ "\$FORCE" != "true" \] && git cat-file -e "origin\/dev:\$doc"/);
assert.match(prepare, /echo "proceed=false"/);
assert.match(jobBlock('model_review'), /if: needs\.prepare\.outputs\.proceed == 'true'/);
});
// #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 '*'`);
}
}
});