mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
PROCESS.md §11.5: перед стабильным релизом — одно ревью поверхностей всей линии бет «с нуля», без ТЗ и документов раундов, по SCOPE и USER-GUIDE. - scripts/release-review.mjs: вход линии — прошлый стабильный тег, issue по трейлерам в схеме RELEASE-MEMBERSHIP.json, продуктовые файлы; бриф промпта. - .github/workflows/release-review.yml (workflow_dispatch, исполняется с dev): prepare → model_review (модель без прав на запись, github_token #556) → publish (docs/reviews/RELEASE-REVIEW-vX.Y.Z.md в dev токеном процесса, индекс тем же коммитом, review-doc-guard). Повтор на тот же тег не тратит модель, если документ уже в dev. - release.yml: job independent-review ставит ревью в очередь сразу после candidate, continue-on-error; ни один job выпуска от него не зависит (решение владельца 2026-09-25). - REVIEWER.md, AGENTS.md, DEVELOPMENT.md; тесты и четыре мутанта. Issue: #638 User-Visible: no
145 lines
7.5 KiB
JavaScript
145 lines
7.5 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* Вход независимого ревью линии перед стабильным релизом (#638, PROCESS.md §11.5).
|
|
*
|
|
* node scripts/release-review.mjs prepare --tag=v1.78.0 --candidate=<sha> --out=<dir>
|
|
* node scripts/release-review.mjs doc --tag=v1.78.0
|
|
*
|
|
* Ревью «с нуля» судит поверхности, изменённые всей линией бет, а не дифф
|
|
* одной задачи. Поэтому база — прошлый СТАБИЛЬНЫЙ тег, достижимый из
|
|
* кандидата (беты этой линии внутри диапазона), а список issue доказывается
|
|
* трейлерами `Issue: #NN` тем же построителем и в той же схеме, что
|
|
* `RELEASE-MEMBERSHIP.json` беты (#547): метка S8 — не доказательство.
|
|
*
|
|
* `prepare` пишет в `--out` два файла — `line-membership.json` (манифест линии)
|
|
* и `brief.md` (вход промпта) — и печатает `key=value` для `$GITHUB_OUTPUT`.
|
|
* Ни ТЗ, ни документов раундов в брифе нет намеренно: ревью независимое.
|
|
*/
|
|
import { mkdirSync, writeFileSync } from 'node:fs';
|
|
import { join, resolve } from 'node:path';
|
|
import { spawnSync } from 'node:child_process';
|
|
import { isMainModule } from './spawn-portable.mjs';
|
|
import { buildReleaseMembership, issueTrailers, validateReleaseMembership } from './release-membership.mjs';
|
|
import { classify } from './process-gate.mjs';
|
|
|
|
export const RELEASE_REVIEW_DIR = 'docs/reviews';
|
|
export const STABLE_TAG_RE = /^v(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/;
|
|
const SHA_RE = /^[0-9a-f]{40,64}$/;
|
|
|
|
/** Путь документа ревью для стабильного тега; бета и мусор — отказ. */
|
|
export function releaseReviewDocPath(tag) {
|
|
if (!STABLE_TAG_RE.test(String(tag))) throw new Error(`not a stable release tag: ${tag}`);
|
|
return `${RELEASE_REVIEW_DIR}/RELEASE-REVIEW-${tag}.md`;
|
|
}
|
|
|
|
const parts = (tag) => STABLE_TAG_RE.exec(tag).slice(1, 4).map(Number);
|
|
const compare = (a, b) => {
|
|
const [x, y] = [parts(a), parts(b)];
|
|
for (let i = 0; i < 3; i++) if (x[i] !== y[i]) return x[i] - y[i];
|
|
return 0;
|
|
};
|
|
|
|
/**
|
|
* Прошлый стабильный тег среди `reachable` (тегов, достижимых из кандидата):
|
|
* наибольший стабильный строго ниже `tag`. Беты и сам тег не годятся — иначе
|
|
* диапазон сжался бы до последней беты и ревью линии стало бы ревью хвоста.
|
|
*/
|
|
export function previousStableTag(reachable, tag) {
|
|
releaseReviewDocPath(tag);
|
|
const older = reachable.filter((name) => STABLE_TAG_RE.test(name) && compare(name, tag) < 0);
|
|
older.sort(compare);
|
|
return older.at(-1) ?? null;
|
|
}
|
|
|
|
/** Продуктовые файлы (класс A) из диффа линии — поверхности для ревью. */
|
|
export function productFiles(files) {
|
|
return [...new Set(files.map((file) => file.replaceAll('\\', '/')))]
|
|
.filter((file) => classify(file) === 'A')
|
|
.sort();
|
|
}
|
|
|
|
/** Манифест линии: все issue, чьи трейлеры есть в диапазоне base..candidate. */
|
|
export function buildLineMembership({ tag, candidate, base, commits }) {
|
|
releaseReviewDocPath(tag);
|
|
const issueNumbers = [...new Set(commits.flatMap((commit) => issueTrailers(commit.message)))];
|
|
const { manifest } = buildReleaseMembership({ tag, candidate, base, commits, issueNumbers });
|
|
return validateReleaseMembership(manifest, { tag, candidate });
|
|
}
|
|
|
|
export function renderBrief({ membership, files, runUrl = '' }) {
|
|
const product = productFiles(files);
|
|
const lines = [
|
|
`# Вход независимого ревью ${membership.tag}`,
|
|
'',
|
|
`- Кандидат: \`${membership.candidate}\``,
|
|
`- База линии (прошлый стабильный): ${membership.base ? `\`${membership.base.tag}\` · \`${membership.base.sha}\`` : 'нет — первая стабильная версия, судится всё дерево'}`,
|
|
...(runUrl ? [`- Прогон: ${runUrl}`] : []),
|
|
`- Документ: \`${releaseReviewDocPath(membership.tag)}\``,
|
|
'',
|
|
`## Issue линии (${membership.issues.length}) — доказаны трейлерами \`Issue: #NN\``,
|
|
'',
|
|
...(membership.issues.length
|
|
? membership.issues.map((row) => `- #${row.number} · коммитов: ${row.commits.length}`)
|
|
: ['- (нет)']),
|
|
'',
|
|
`## Изменённые продуктовые файлы (класс A, ${product.length}) — из них выводятся поверхности`,
|
|
'',
|
|
...(product.length ? product.map((file) => `- \`${file}\``) : ['- (нет)']),
|
|
'',
|
|
`Всего файлов в диффе линии: ${files.length}.`,
|
|
'',
|
|
];
|
|
return lines.filter((line, index) => line !== '' || lines[index - 1] !== '').join('\n');
|
|
}
|
|
|
|
function git(args) {
|
|
const r = spawnSync('git', args, { encoding: 'utf8', maxBuffer: 256 * 1024 * 1024 });
|
|
if (r.status !== 0) throw new Error(`git ${args.join(' ')} failed: ${(r.stderr || '').trim()}`);
|
|
return r.stdout.trim();
|
|
}
|
|
|
|
/** Кандидат, база и история линии из git (без сети). */
|
|
export function readLine(tag, candidate) {
|
|
if (!SHA_RE.test(String(candidate))) throw new Error(`invalid candidate SHA: ${candidate}`);
|
|
const reachable = git(['tag', '--merged', candidate]).split('\n').map((s) => s.trim()).filter(Boolean);
|
|
const baseTag = previousStableTag(reachable, tag);
|
|
const base = baseTag ? { tag: baseTag, sha: git(['rev-list', '-n', '1', baseTag]) } : null;
|
|
const range = base ? `${base.sha}..${candidate}` : candidate;
|
|
const raw = git(['log', '--format=%H%x1f%B%x1e', range]);
|
|
const commits = raw.split('\x1e').map((r) => r.trim()).filter(Boolean).map((record) => {
|
|
const at = record.indexOf('\x1f');
|
|
return { sha: record.slice(0, at).trim(), message: record.slice(at + 1) };
|
|
});
|
|
const files = base
|
|
? git(['diff', '--name-only', base.sha, candidate]).split('\n').filter(Boolean)
|
|
: git(['ls-tree', '-r', '--name-only', candidate]).split('\n').filter(Boolean);
|
|
return { base, commits, files };
|
|
}
|
|
|
|
if (isMainModule(import.meta.url)) {
|
|
try {
|
|
const [command, ...rest] = process.argv.slice(2);
|
|
const value = (name) => rest.find((a) => a.startsWith(`--${name}=`))?.slice(name.length + 3) ?? '';
|
|
const tag = value('tag');
|
|
if (command === 'doc') {
|
|
console.log(releaseReviewDocPath(tag));
|
|
} else if (command === 'prepare') {
|
|
const candidate = value('candidate');
|
|
const out = resolve(value('out') || '.');
|
|
const { base, commits, files } = readLine(tag, candidate);
|
|
const membership = buildLineMembership({ tag, candidate, base, commits });
|
|
mkdirSync(out, { recursive: true });
|
|
writeFileSync(join(out, 'line-membership.json'), `${JSON.stringify(membership, null, 2)}\n`);
|
|
writeFileSync(join(out, 'brief.md'), renderBrief({ membership, files, runUrl: value('run-url') }));
|
|
console.log(`doc=${releaseReviewDocPath(tag)}`);
|
|
console.log(`base=${base ? base.tag : ''}`);
|
|
console.log(`issues=${membership.issues.map((row) => row.number).join(',')}`);
|
|
} else {
|
|
throw new Error('usage: release-review.mjs prepare --tag=vX.Y.Z --candidate=<sha> --out=<dir> | doc --tag=vX.Y.Z');
|
|
}
|
|
} catch (error) {
|
|
console.error(`::error::${error.message}`);
|
|
process.exit(1);
|
|
}
|
|
}
|