mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-02 21:01:21 +00:00
Two steps publish a commit and treated every failed push as a moved branch: the release review job (release-review.yml) retried three times with "dev went ahead", and the review document step (_process.yml) rebased and pushed again. A refusal by GitHub itself - a token without the workflow right, a branch rule, a hook - cannot be cured by a retry or a rebase, and the step never said what GitHub answered. Both pushes now keep stderr and hand it to the #705 classifier through the same CLI the rebase guard uses (merge-candidate.mjs --push-refusal). Only a stale lease (rejected / fetch first / stale info) keeps the old retry or rebase. Any other outcome stops the step at once, without retries: the log gets the git answer and the step summary gets the reason and the git answer, both passed through redactSecrets (token, credential URL, Authorization). The review document step takes the classifier from dev, as the rebase guard does: a task branch behind dev may not carry it. The summary text is written by the new --summary option (refusalSummary), not by a multi-line string in run:, and both commit messages are now built line by line into a file instead of a heredoc (PROCESS.md §10.4 item 4). release-review.yml is dispatch-only and is not mirrored to main. PROCESS.md names the rule next to the rebase guard; the #638 trailer witness in test/release-review.test.mjs follows the line-by-line message. test/publish-push-refusal.test.mjs runs both steps as they are with real bash and real git in temporary repositories; only the push transport is replaced: a moved branch is a real neighbour push, a GitHub refusal is a recorded stderr carrying a token, a credential URL and an Authorization header. On the old steps 9 of its 11 tests fail. Issue: #723 User-Visible: no Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
152 lines
11 KiB
JavaScript
152 lines
11 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/, 'индекс тем же коммитом и неизвестное имя сразу блокирует публикацию');
|
||
// #723: сообщение — построчно в файл, без heredoc в run; точный текст
|
||
// коммита судит исполнение шага (test/publish-push-refusal.test.mjs).
|
||
assert.match(publish, /echo "Issue: #638"\n\s+echo "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 '*'`);
|
||
}
|
||
}
|
||
});
|