mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-03 05:08:53 +00:00
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
150 lines
10 KiB
JavaScript
150 lines
10 KiB
JavaScript
// #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 '*'`);
|
||
}
|
||
}
|
||
});
|