mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-05 22:29:05 +00:00
process: the review pipeline prices each round by track (#696)
show/ship stop paying for diff mutants and for every move of dev: - scripts/process-track.mjs resolves the track from the current labels and the diff (show for unlabelled infra, ask for unlabelled product work) and checks the mechanical ship limits; outside them the pipeline comments and relabels track:ship -> track:show in the same round. - Validate on the review material is light on show/ship: a completed push run on the exact SHA is proof, a dispatch asks mutants=false. ask and the ci:mutants label keep the mutant dispatch. - show/ship skip the pre-review rebase when git merge-tree with dev is clean; the candidate is rebased once at merge and still passes Validate before the push to dev. The light merge waits for the push run of the candidate and dispatches only when none appears. - ship inside the limits merges after the light Validate without a model review; the issue gets a machine marker hp:ship-merge. - ship-review.yml + scripts/ship-review.mjs read the code of all ship tasks of a beta range in one model session and publish docs/reviews/SHIP-REVIEW-<tag>.md; both beta publication paths refuse a range with ship tasks the document does not cover or that carries a High. - show reviews judge correctness and AC; the spec review installs neither npm ci nor Chromium, the show review installs Chromium only when the issue names a smoke. Canon: PROCESS.md §5, §5.1, §10.4, new §11.7; REVIEWER.md, AUTHOR.md and AGENTS.md digests. Issue: #696 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:
@@ -25,9 +25,16 @@ export const CI_PROOF_STATES = Object.freeze([
|
||||
// требует (#601): к кандидату беты каждая задача прогнана ими на ревью и на
|
||||
// слитом кандидате, а `Release:` мутантов больше не запрашивает — политика
|
||||
// с `mutants: true` объявляла бы каждый кандидат беты `stale`.
|
||||
//
|
||||
// #696: треки `show` и `ship` (PROCESS §5) мутантов по диффу до слияния не
|
||||
// запрашивают — их доказательство лёгкое, `reviewLight`/`mergeLight`. Мутанты
|
||||
// у них остаются в ночном полном реестре; `track:ask` и метка `ci:mutants`
|
||||
// идут по прежним `review`/`merge`.
|
||||
export const CI_PROOF_POLICIES = Object.freeze({
|
||||
review: Object.freeze({ name: 'review', full: false, mutants: true }),
|
||||
merge: Object.freeze({ name: 'merge', full: false, mutants: true }),
|
||||
reviewLight: Object.freeze({ name: 'review-light', full: false, mutants: false }),
|
||||
mergeLight: Object.freeze({ name: 'merge-light', full: false, mutants: false }),
|
||||
release: Object.freeze({ name: 'release', full: true, mutants: false }),
|
||||
});
|
||||
|
||||
|
||||
+33
-11
@@ -132,6 +132,7 @@ export function realOps({
|
||||
now = Date.now, exec = sh,
|
||||
candidateTree = (sha) => githubCandidateTree({ repo, sha, token }),
|
||||
proofContext = (run) => loadGithubProofContext({ repo, run, token }),
|
||||
mutants = true,
|
||||
}) {
|
||||
const pushUrl = `https://x-access-token:${token}@github.com/${repo}`;
|
||||
const git = (...args) => exec('git', args);
|
||||
@@ -187,8 +188,8 @@ export function realOps({
|
||||
// Мутанты по диффу бегут только по запросу (#510): кандидат после ребейза —
|
||||
// новое дерево, поэтому слияние запускает Validate с мутантами само и ждёт
|
||||
// именно этот dispatch-прогон; push-прогон на том же SHA их не содержит.
|
||||
dispatchValidate: (ref) => {
|
||||
const r = exec('gh', ['workflow', 'run', workflow, '--repo', repo, '--ref', ref, '-f', 'full=false', '-f', 'mutants=true']);
|
||||
dispatchValidate: (ref, { mutants: withMutants = mutants } = {}) => {
|
||||
const r = exec('gh', ['workflow', 'run', workflow, '--repo', repo, '--ref', ref, '-f', 'full=false', '-f', `mutants=${withMutants ? 'true' : 'false'}`]);
|
||||
if (r.status !== 0) throw new Error(`gh workflow run ${workflow}: ${r.stderr || r.stdout}`);
|
||||
},
|
||||
waitValidate: async (sha, { event = 'workflow_dispatch' } = {}) => {
|
||||
@@ -208,7 +209,7 @@ export function realOps({
|
||||
try { context = await proofContext(run); }
|
||||
catch { context = { proof: null, jobs: [], reuseRuns: new Map() }; }
|
||||
const verdict = evaluateCiProof({
|
||||
run, ...context, candidate: { sha, tree }, policy: CI_PROOF_POLICIES.merge,
|
||||
run, ...context, candidate: { sha, tree }, policy: mutants ? CI_PROOF_POLICIES.merge : CI_PROOF_POLICIES.mergeLight,
|
||||
});
|
||||
if (verdict.status === 'green' || verdict.status === 'failed')
|
||||
return { result: verdict.status, url: verdict.url, note: verdict.note };
|
||||
@@ -234,7 +235,7 @@ export function realOps({
|
||||
/**
|
||||
* Слияние по алгоритму §4.2. Возвращает { merged, to, action, candidate }.
|
||||
*/
|
||||
export async function mergeCandidate({ branch, material, issue, ops, maxAttempts = MAX_ATTEMPTS }) {
|
||||
export async function mergeCandidate({ branch, material, issue, ops, maxAttempts = MAX_ATTEMPTS, mutants = true }) {
|
||||
ops.fetch('dev', branch);
|
||||
const actual = ops.revParse(`origin/${branch}`);
|
||||
const reviewedFresh = actual === material
|
||||
@@ -280,10 +281,29 @@ export async function mergeCandidate({ branch, material, issue, ops, maxAttempts
|
||||
tip = candidate;
|
||||
if (!patchIdEqual) return finish(decideMerge({ fresh: true, devMoved: true, patchIdEqual: false }), { candidate, devNow });
|
||||
|
||||
// мутанты по диффу — по запросу (#510): dispatch на ветке, где теперь стоит кандидат
|
||||
ops.dispatchValidate(branch);
|
||||
ops.log(`Validate с мутантами на кандидате ${candidate.slice(0, 8)} — ждём`);
|
||||
const { result, url } = await ops.waitValidate(candidate, { event: 'workflow_dispatch' });
|
||||
// мутанты по диффу — по запросу (#510): dispatch на ветке, где теперь стоит
|
||||
// кандидат.
|
||||
//
|
||||
// #696: на треках show/ship мутантов нет, и лёгкий Validate на кандидате
|
||||
// уже запустил сам push выше — второй, dispatch-прогон, повторил бы его
|
||||
// целиком. Ждётся push-прогон; dispatch — только если его нет: push, в
|
||||
// котором сдвинулись одни docs/reviews/**, Validate не запускает
|
||||
// (paths-ignore), а отменённый concurrency прогон заменить некому.
|
||||
let result;
|
||||
let url;
|
||||
if (mutants) {
|
||||
ops.dispatchValidate(branch);
|
||||
ops.log(`Validate с мутантами на кандидате ${candidate.slice(0, 8)} — ждём`);
|
||||
({ result, url } = await ops.waitValidate(candidate, { event: 'workflow_dispatch' }));
|
||||
} else {
|
||||
ops.log(`лёгкий Validate на кандидате ${candidate.slice(0, 8)} — ждём push-прогон`);
|
||||
({ result, url } = await ops.waitValidate(candidate, { event: 'push' }));
|
||||
if (result === 'missing') {
|
||||
ops.log('push-прогона на кандидате нет — лёгкий dispatch');
|
||||
ops.dispatchValidate(branch);
|
||||
({ result, url } = await ops.waitValidate(candidate, { event: 'workflow_dispatch' }));
|
||||
}
|
||||
}
|
||||
let decision = decideMerge({ fresh: true, devMoved: true, patchIdEqual: true, validate: result, attempt, maxAttempts });
|
||||
if (decision.action !== 'push') return finish(decision, { candidate, devNow, runUrl: url });
|
||||
|
||||
@@ -306,11 +326,13 @@ if (isMainModule(import.meta.url)) { // #496: переносимо для Window
|
||||
const repo = arg('repo') || process.env.GITHUB_REPOSITORY;
|
||||
const token = process.env.HP_PROCESS_TOKEN || process.env.TOKEN;
|
||||
if (!branch || !material || !issue || !repo || !token) {
|
||||
console.error('usage: merge-candidate.mjs --branch=<issue branch> --material=<sha> --issue=<n> [--repo=owner/name]; HP_PROCESS_TOKEN in env');
|
||||
console.error('usage: merge-candidate.mjs --branch=<issue branch> --material=<sha> --issue=<n> [--repo=owner/name] [--mutants=false]; HP_PROCESS_TOKEN in env');
|
||||
process.exit(2);
|
||||
}
|
||||
const ops = realOps({ repo, token, issue });
|
||||
mergeCandidate({ branch, material, issue, ops }).then((r) => {
|
||||
// #696: `--mutants=false` — треки show/ship сливаются по лёгкому Validate.
|
||||
const mutants = arg('mutants') !== 'false';
|
||||
const ops = realOps({ repo, token, issue, mutants });
|
||||
mergeCandidate({ branch, material, issue, ops, mutants }).then((r) => {
|
||||
const out = `merged=${r.merged}\nto=${r.to || ''}\naction=${r.action}\ncandidate=${r.candidate}\n`;
|
||||
if (process.env.GITHUB_OUTPUT) appendFileSync(process.env.GITHUB_OUTPUT, out);
|
||||
process.stdout.write(out);
|
||||
|
||||
@@ -5494,8 +5494,8 @@ const MUTANT_DEFINITIONS = [
|
||||
+ 'it without a green Validate on that SHA is the false-green the audit reproduced (#492 §4)',
|
||||
patches: [{
|
||||
file: 'scripts/merge-candidate.mjs',
|
||||
find: " const { result, url } = await ops.waitValidate(candidate, { event: 'workflow_dispatch' });",
|
||||
replace: " const { result, url } = { result: 'green', url: 'skipped' }; // mutant: no validation",
|
||||
find: " ops.log(`Validate с мутантами на кандидате ${candidate.slice(0, 8)} — ждём`);\n ({ result, url } = await ops.waitValidate(candidate, { event: 'workflow_dispatch' }));",
|
||||
replace: " ops.log(`Validate с мутантами на кандидате ${candidate.slice(0, 8)} — ждём`);\n ({ result, url } = { result: 'green', url: 'skipped' }); // mutant: no validation",
|
||||
}],
|
||||
},
|
||||
{
|
||||
@@ -10613,7 +10613,7 @@ const MUTANT_DEFINITIONS = [
|
||||
+ 'остановиться, сохранить метку и назвать упавшую стадию',
|
||||
patches: [{
|
||||
file: '.github/workflows/_process.yml',
|
||||
find: ' if [ "$REUSE" != "true" ] && [ "$MODEL_RESULT" != "success" ]; then',
|
||||
find: ' if [ "$REUSE" != "true" ] && [ "$SHIP" != "true" ] && [ "$MODEL_RESULT" != "success" ]; then',
|
||||
replace: ' if false; then # mutant: every model result is accepted',
|
||||
}],
|
||||
},
|
||||
@@ -12239,8 +12239,8 @@ const MUTANT_DEFINITIONS = [
|
||||
+ 'wait for that run, not for the push run that carries no mutants (#510 AC3)',
|
||||
patches: [{
|
||||
file: 'scripts/merge-candidate.mjs',
|
||||
find: " ops.dispatchValidate(branch);\n ops.log(`Validate с мутантами на кандидате ${candidate.slice(0, 8)} — ждём`);\n const { result, url } = await ops.waitValidate(candidate, { event: 'workflow_dispatch' });",
|
||||
replace: " ops.log(`Validate на кандидате ${candidate.slice(0, 8)} — ждём`);\n const { result, url } = await ops.waitValidate(candidate); // mutant: push run, no dispatch",
|
||||
find: " ops.dispatchValidate(branch);\n ops.log(`Validate с мутантами на кандидате ${candidate.slice(0, 8)} — ждём`);\n ({ result, url } = await ops.waitValidate(candidate, { event: 'workflow_dispatch' }));",
|
||||
replace: " ops.log(`Validate на кандидате ${candidate.slice(0, 8)} — ждём`);\n ({ result, url } = await ops.waitValidate(candidate)); // mutant: push run, no dispatch",
|
||||
}],
|
||||
},
|
||||
{
|
||||
@@ -13357,6 +13357,104 @@ const MUTANT_DEFINITIONS = [
|
||||
replace: ' const infraTrack = trackFromLabels(labels); // mutant: unlabelled infra reads as ask',
|
||||
}],
|
||||
},
|
||||
// #696: цена захода по треку — мутанты, рамки ship, пакетное ревью перед бетой.
|
||||
{
|
||||
id: 'track-show-pays-for-mutants',
|
||||
guard: 'node --test --test-name-pattern="мутанты по диффу — только ask" test/process-track.test.mjs',
|
||||
because: '#696: show/ship request no diff mutants before merge; only ask and the ci:mutants '
|
||||
+ 'label do — otherwise every small task pays the 28-minute mutant run again',
|
||||
patches: [{
|
||||
file: 'scripts/process-track.mjs',
|
||||
find: " const mutants = track === 'ask' || labels.includes('ci:mutants');",
|
||||
replace: ' const mutants = true; // mutant: every track pays for mutants',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'ship-limits-miss-new-src-file',
|
||||
guard: 'node --test --test-name-pattern="рамки ship: новые файлы" test/process-track.test.mjs',
|
||||
because: '#696: a new file in src/** is outside ship limits — ship merges without a model '
|
||||
+ 'review, so the mechanical limits are the only thing standing between it and dev',
|
||||
patches: [{
|
||||
file: 'scripts/process-track.mjs',
|
||||
find: ' if (added.length) out.push(',
|
||||
replace: ' if (false && added.length) out.push(',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'ship-limit-off-by-one',
|
||||
guard: 'node --test --test-name-pattern="граница включительна" test/process-track.test.mjs',
|
||||
because: '#696: the ship limit is 30 src lines inclusive (owner decision); 30 lines still ship',
|
||||
patches: [{
|
||||
file: 'scripts/process-track.mjs',
|
||||
find: ' if (lines > SHIP_SRC_LINE_LIMIT) out.push(',
|
||||
replace: ' if (lines >= SHIP_SRC_LINE_LIMIT) out.push(',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'pipeline-ship-ignores-limits',
|
||||
guard: 'node --test --test-name-pattern="трек снимается до ребейза" test/process-track.test.mjs',
|
||||
because: '#696: ship skips the model only inside the limits; ignoring the limits merges '
|
||||
+ 'unread code of any size',
|
||||
patches: [{
|
||||
file: '.github/workflows/_process.yml',
|
||||
find: " if printf '%s\\n' \"$limits\" | grep -qx 'ship=true'; then",
|
||||
replace: ' if true; then # mutant: ship limits ignored',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'light-review-waits-running-push',
|
||||
guard: 'node --test --test-name-pattern="#696" test/validate-gate.test.mjs',
|
||||
because: '#696: a running push run cannot wake the round (process-resume listens to '
|
||||
+ 'dispatch only); treating it as proof candidate parks the round forever',
|
||||
patches: [{
|
||||
file: 'scripts/validate-gate.mjs',
|
||||
find: " return run?.event === 'workflow_dispatch' || (run?.event === 'push' && run?.status === 'completed');",
|
||||
replace: " return run?.event === 'workflow_dispatch' || run?.event === 'push'; // mutant: running push counts",
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'light-merge-dispatches-second-run',
|
||||
guard: 'node --test --test-name-pattern="слияние ждёт push-прогон" test/merge-candidate.test.mjs',
|
||||
because: '#696: the candidate push already starts the light Validate; a second dispatch '
|
||||
+ 'doubles runner minutes on every show/ship merge',
|
||||
patches: [{
|
||||
file: 'scripts/merge-candidate.mjs',
|
||||
find: " ({ result, url } = await ops.waitValidate(candidate, { event: 'push' }));",
|
||||
replace: " ops.dispatchValidate(branch); ({ result, url } = await ops.waitValidate(candidate, { event: 'workflow_dispatch' })); // mutant: second run",
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'ship-review-ignores-merge-marker',
|
||||
guard: 'node --test --test-name-pattern="ship-задача — по маркеру" test/ship-review.test.mjs',
|
||||
because: '#696: the pipeline marker proves a merge without model review even when the '
|
||||
+ 'label was changed afterwards; missing it lets unread code into a beta',
|
||||
patches: [{
|
||||
file: 'scripts/ship-review.mjs',
|
||||
find: " return names.includes('track:ship') || comments.some((c) => SHIP_MERGE_MARKER_RE.test(String(c?.body ?? '')));",
|
||||
replace: " return names.includes('track:ship'); // mutant: marker ignored",
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'ship-review-accepts-partial-coverage',
|
||||
guard: 'node --test --test-name-pattern="машинный блок покрывает" test/ship-review.test.mjs',
|
||||
because: '#696: a ship task merged after the batch review ran was read by nobody; the beta '
|
||||
+ 'gate must refuse until the review is re-run',
|
||||
patches: [{
|
||||
file: 'scripts/ship-review.mjs',
|
||||
find: ' if (missing.length) {',
|
||||
replace: ' if (false && missing.length) {',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'ship-review-accepts-high',
|
||||
guard: 'node --test --test-name-pattern="машинный блок покрывает" test/ship-review.test.mjs',
|
||||
because: '#696: a High in the batch review is a defect a beta user would see; it blocks the beta',
|
||||
patches: [{
|
||||
file: 'scripts/ship-review.mjs',
|
||||
find: ' else if (block.high > 0) problems.push(',
|
||||
replace: ' else if (false && block.high > 0) problems.push(',
|
||||
}],
|
||||
},
|
||||
];
|
||||
|
||||
const mutationCardSource = readFileSync(join(repoRoot, 'src/houseplan-card.ts'), 'utf8');
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Трек задачи и рамки `ship` (PROCESS.md §5, #695/#696).
|
||||
*
|
||||
* node scripts/process-track.mjs resolve --labels="a,b" --base=<ref> --head=<ref>
|
||||
* node scripts/process-track.mjs ship-limits --base=<ref> --head=<ref>
|
||||
*
|
||||
* `resolve` печатает `track=ship|show|ask` и `mutants=true|false` — то, что
|
||||
* конвейер ревью читает, решая, сколько стоит заход: мутанты по диффу нужны
|
||||
* только `ask` и метке `ci:mutants`. Инфраструктурная задача без трековой
|
||||
* метки — `show` (§5.1); признак инфраструктуры механический, как в §1: в
|
||||
* диффе ни одного файла класса A.
|
||||
*
|
||||
* `ship-limits` печатает `ship=true|false` и по строке `violation=…` на каждое
|
||||
* нарушение рамок: дифф `src/**` не больше 30 строк, без новых файлов в
|
||||
* `src/**`, без i18n, без полей конфига и без Python. Рамки механические
|
||||
* намеренно: по ним конвейер сливает задачу без ревью модели, и решать их
|
||||
* «на глаз» некому.
|
||||
*/
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { appendFileSync } from 'node:fs';
|
||||
import { isMainModule } from './spawn-portable.mjs';
|
||||
import { classify } from './process-gate.mjs';
|
||||
|
||||
export const SHIP_SRC_LINE_LIMIT = 30;
|
||||
|
||||
/**
|
||||
* Трек по меткам: `track:*` главнее прежних меток; `trivial` и `small`
|
||||
* читаются как `show` (§5.1); задача без трековой метки — `ask`. Метки трека
|
||||
* не доказывают продуктовый поток: инфраструктурной задаче владелец тоже может
|
||||
* поставить `track:*`, чтобы задать цену конвейера.
|
||||
*/
|
||||
export function trackFromLabels(labels = []) {
|
||||
if (labels.includes('track:ship')) return 'ship';
|
||||
if (labels.includes('track:show')) return 'show';
|
||||
if (labels.includes('track:ask')) return 'ask';
|
||||
if (labels.includes('trivial') || labels.includes('small')) return 'show';
|
||||
return 'ask';
|
||||
}
|
||||
|
||||
/** Есть ли у задачи трековая метка вообще — новая или прежняя. */
|
||||
export const hasTrackLabel = (labels = []) => ['track:ship', 'track:show', 'track:ask', 'trivial', 'small']
|
||||
.some((label) => labels.includes(label));
|
||||
|
||||
/**
|
||||
* Трек, по которому конвейер оценивает заход. Явная метка решает всё; без неё
|
||||
* инфраструктурная задача (ни одного файла класса A в диффе) — `show`, прочие —
|
||||
* `ask`. Мутанты по диффу — только на `ask` или по метке `ci:mutants`.
|
||||
*/
|
||||
export function resolveTrack({ labels = [], files = [] } = {}) {
|
||||
const infrastructure = files.length > 0 && files.every((file) => classify(file) !== 'A');
|
||||
const track = hasTrackLabel(labels) ? trackFromLabels(labels) : (infrastructure ? 'show' : 'ask');
|
||||
const mutants = track === 'ask' || labels.includes('ci:mutants');
|
||||
return { track, mutants, infrastructure };
|
||||
}
|
||||
|
||||
const I18N = [/^src\/i18n\//, /^custom_components\/[^/]+\/translations\//];
|
||||
const CONFIG = [/^src\/types\.ts$/, /^src\/config-[^/]+\.ts$/];
|
||||
const PYTHON = /\.py$/;
|
||||
|
||||
/**
|
||||
* Нарушения рамок `ship` по `git diff --numstat` и `--name-status` базы и
|
||||
* вершины. Пустой список — задача укладывается в рамки.
|
||||
*
|
||||
* @param {{added:number|null, deleted:number|null, path:string}[]} numstat
|
||||
* @param {{status:string, path:string}[]} nameStatus
|
||||
*/
|
||||
export function shipLimitViolations({ numstat = [], nameStatus = [] } = {}) {
|
||||
const out = [];
|
||||
const src = numstat.filter((row) => row.path.startsWith('src/'));
|
||||
const binary = src.filter((row) => row.added === null || row.deleted === null);
|
||||
const lines = src.reduce((sum, row) => sum + (row.added ?? 0) + (row.deleted ?? 0), 0);
|
||||
if (lines > SHIP_SRC_LINE_LIMIT) out.push(`дифф src/** — ${lines} строк при рамке ${SHIP_SRC_LINE_LIMIT}`);
|
||||
if (binary.length) out.push(`двоичные файлы в src/**: ${binary.map((row) => row.path).join(', ')}`);
|
||||
const added = nameStatus.filter((row) => row.status.startsWith('A') && row.path.startsWith('src/'));
|
||||
if (added.length) out.push(`новые файлы в src/**: ${added.map((row) => row.path).join(', ')}`);
|
||||
const paths = [...new Set([...numstat, ...nameStatus].map((row) => row.path))];
|
||||
const i18n = paths.filter((path) => I18N.some((re) => re.test(path)));
|
||||
if (i18n.length) out.push(`ключи i18n: ${i18n.join(', ')}`);
|
||||
const config = paths.filter((path) => CONFIG.some((re) => re.test(path)));
|
||||
if (config.length) out.push(`поля конфига: ${config.join(', ')}`);
|
||||
const python = paths.filter((path) => PYTHON.test(path));
|
||||
if (python.length) out.push(`Python: ${python.join(', ')}`);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** `git diff --numstat` → строки; двоичный файл даёт `-\t-`. */
|
||||
export function parseNumstat(text = '') {
|
||||
return String(text).split('\n').map((line) => line.trim()).filter(Boolean).map((line) => {
|
||||
const [added, deleted, ...rest] = line.split('\t');
|
||||
return { added: added === '-' ? null : Number(added), deleted: deleted === '-' ? null : Number(deleted), path: rest.at(-1) };
|
||||
});
|
||||
}
|
||||
|
||||
/** `git diff --name-status` → строки; у переименования путь — новый. */
|
||||
export function parseNameStatus(text = '') {
|
||||
return String(text).split('\n').map((line) => line.trim()).filter(Boolean).map((line) => {
|
||||
const [status, ...paths] = line.split('\t');
|
||||
return { status, path: paths.at(-1) };
|
||||
});
|
||||
}
|
||||
|
||||
function git(args) {
|
||||
const r = spawnSync('git', args, { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
|
||||
if (r.status !== 0) throw new Error(`git ${args.join(' ')}: ${(r.stderr || '').trim()}`);
|
||||
return r.stdout;
|
||||
}
|
||||
|
||||
if (isMainModule(import.meta.url)) {
|
||||
const [command, ...rest] = process.argv.slice(2);
|
||||
const value = (name) => rest.find((a) => a.startsWith(`--${name}=`))?.slice(name.length + 3) ?? '';
|
||||
const emit = (lines) => {
|
||||
for (const line of lines) console.log(line);
|
||||
if (process.env.GITHUB_OUTPUT) appendFileSync(process.env.GITHUB_OUTPUT, `${lines.join('\n')}\n`);
|
||||
};
|
||||
try {
|
||||
const base = value('base');
|
||||
const head = value('head') || 'HEAD';
|
||||
const range = base ? `${base}...${head}` : null;
|
||||
if (command === 'resolve') {
|
||||
const labels = value('labels').split(',').map((s) => s.trim()).filter(Boolean);
|
||||
const files = range ? git(['diff', '--name-only', range]).split('\n').filter(Boolean) : [];
|
||||
const { track, mutants } = resolveTrack({ labels, files });
|
||||
emit([`track=${track}`, `mutants=${mutants}`]);
|
||||
} else if (command === 'ship-limits') {
|
||||
if (!range) throw new Error('--base is required');
|
||||
const violations = shipLimitViolations({
|
||||
numstat: parseNumstat(git(['diff', '--numstat', range])),
|
||||
nameStatus: parseNameStatus(git(['diff', '--name-status', range])),
|
||||
});
|
||||
emit([`ship=${violations.length === 0}`, `violations=${violations.join('; ')}`]);
|
||||
} else {
|
||||
throw new Error('usage: process-track.mjs resolve --labels=a,b [--base=<ref> --head=<ref>] | ship-limits --base=<ref> [--head=<ref>]');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`::error::${error.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
@@ -503,6 +503,11 @@ if (invokedDirectly) {
|
||||
const bundleSha256 = bundleSnapshot.entrySha256;
|
||||
const validateRuns = await assertGreenValidate(sha);
|
||||
validateIssues();
|
||||
// #696, PROCESS.md §11.7: ship-задачи диапазона слиты без ревью модели —
|
||||
// бета только после пакетного ревью их кода (`ship-review.yml`).
|
||||
run(process.execPath, [
|
||||
'scripts/ship-review.mjs', 'check', `--tag=${tag}`, `--candidate=${sha}`, `--repo=${repo}`,
|
||||
], { inherit: true });
|
||||
const history = readCandidateHistory(sha);
|
||||
const generatedMembership = buildReleaseMembership({
|
||||
tag, candidate: sha, base: history.base, commits: history.commits, issueNumbers: issues,
|
||||
|
||||
@@ -88,9 +88,11 @@ export function archivePlan({ names, lines, open, through, addedIn = new Map() }
|
||||
if (name === INDEX_FILE) continue;
|
||||
const doc = parseDocName(name);
|
||||
if (!doc) { kept.push({ name, reason: 'вне схемы имён' }); continue; }
|
||||
if (doc.stage === 'release') {
|
||||
if (tags.has(doc.tag)) moves.push({ name, from: `${LIVE_DIR}/${name}`, to: `${ARCHIVE_DIR}/${doc.tag}/${name}`, tag: doc.tag, issue: null });
|
||||
else kept.push({ name, reason: `ревью линии ${doc.tag} не входит в архивируемые линии` });
|
||||
if (doc.stage === 'release' || doc.stage === 'ship') {
|
||||
// #696: пакетное ревью беты уходит в каталог своей стабильной линии.
|
||||
const line = doc.tag.replace(/-beta\.\d+$/, '');
|
||||
if (tags.has(line)) moves.push({ name, from: `${LIVE_DIR}/${name}`, to: `${ARCHIVE_DIR}/${line}/${name}`, tag: line, issue: null });
|
||||
else kept.push({ name, reason: `ревью линии ${line} не входит в архивируемые линии` });
|
||||
continue;
|
||||
}
|
||||
if (openSet.has(doc.issue)) { kept.push({ name, issue: doc.issue, reason: 'задача есть в открытой линии' }); continue; }
|
||||
|
||||
@@ -28,6 +28,8 @@ import { isMainModule } from './spawn-portable.mjs';
|
||||
export const INDEX_FILE = 'INDEX.md';
|
||||
const DOC_NAME = /^(CODE|SPEC)-REVIEW-(?:issue-)?(\d+)(?:-r(\d+))?(?:-([a-z0-9-]+))?\.md$/i;
|
||||
const RELEASE_DOC_NAME = /^RELEASE-REVIEW-(v(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*))\.md$/i;
|
||||
// #696: пакетное ревью ship-задач перед бетой (PROCESS.md §11.7) — по тегу беты.
|
||||
const SHIP_DOC_NAME = /^SHIP-REVIEW-(v(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-beta\.(?:0|[1-9]\d*))?)\.md$/i;
|
||||
const COLOUR = {
|
||||
'зелёный': 'зелёный', 'зеленый': 'зелёный', green: 'зелёный',
|
||||
'жёлтый': 'жёлтый', 'желтый': 'жёлтый', yellow: 'жёлтый',
|
||||
@@ -43,6 +45,8 @@ const VERDICT_OWN_LINE_RE = /^[ \t]*(?:[-*]\s*)?\**(?:Вердикт|Verdict)[^\
|
||||
export function parseDocName(name) {
|
||||
const release = RELEASE_DOC_NAME.exec(String(name));
|
||||
if (release) return { stage: 'release', issue: null, round: null, suffix: null, tag: release[1] };
|
||||
const ship = SHIP_DOC_NAME.exec(String(name));
|
||||
if (ship) return { stage: 'ship', issue: null, round: null, suffix: null, tag: ship[1] };
|
||||
const match = DOC_NAME.exec(String(name));
|
||||
if (!match) return null;
|
||||
return {
|
||||
@@ -301,14 +305,20 @@ export function assertAllDocumentsIndexed({ skipped }) {
|
||||
const badge = (verdict) => ({ 'зелёный': '🟢', 'жёлтый': '🟡', 'красный': '🔴' }[verdict] || '⚪');
|
||||
|
||||
export function renderIndex({ entries, skipped = [] }) {
|
||||
const releaseDocs = entries.filter((entry) => entry.stage === 'release').sort((a, b) => {
|
||||
const av = a.tag.slice(1).split('.').map(Number);
|
||||
const bv = b.tag.slice(1).split('.').map(Number);
|
||||
for (let i = 0; i < 3; i += 1) if (av[i] !== bv[i]) return bv[i] - av[i];
|
||||
return 0;
|
||||
// Документы линии и беты (release, ship) — без issue; свежий тег выше.
|
||||
// Стабильный тег старше своих бет: `v1.79.0` выше `v1.79.0-beta.3`.
|
||||
const tagKey = (tag) => {
|
||||
const [core, beta] = tag.slice(1).split('-beta.');
|
||||
return [...core.split('.').map(Number), beta == null ? Infinity : Number(beta)];
|
||||
};
|
||||
const releaseDocs = entries.filter((entry) => entry.issue == null).sort((a, b) => {
|
||||
const av = tagKey(a.tag);
|
||||
const bv = tagKey(b.tag);
|
||||
for (let i = 0; i < 4; i += 1) if (av[i] !== bv[i]) return bv[i] - av[i];
|
||||
return a.stage.localeCompare(b.stage);
|
||||
});
|
||||
const byIssue = new Map();
|
||||
for (const entry of entries.filter((item) => item.stage !== 'release')) {
|
||||
for (const entry of entries.filter((item) => item.issue != null)) {
|
||||
const list = byIssue.get(entry.issue) || [];
|
||||
list.push(entry);
|
||||
byIssue.set(entry.issue, list);
|
||||
@@ -322,7 +332,8 @@ export function renderIndex({ entries, skipped = [] }) {
|
||||
lines.push('| Issue | Документ | Этап · раунд | Вердикт | H | M | Находки | Файлы |');
|
||||
lines.push('|---|---|---|---|---:|---:|---|---|');
|
||||
for (const doc of releaseDocs) {
|
||||
lines.push(`| линия ${doc.tag} | [${doc.name}](${doc.name}) | ревью линии · — | ${badge(doc.verdict)} ${doc.verdict} | ${doc.high} | ${doc.medium} | ${doc.findings.join('; ').replace(/\|/g, '\\|') || '—'} | ${(doc.files || []).map((f) => `\`${f}\``).join(' ') || '—'} |`);
|
||||
const [who, what] = doc.stage === 'ship' ? ['бета', 'пакетное ревью ship'] : ['линия', 'ревью линии'];
|
||||
lines.push(`| ${who} ${doc.tag} | [${doc.name}](${doc.name}) | ${what} · — | ${badge(doc.verdict)} ${doc.verdict} | ${doc.high} | ${doc.medium} | ${doc.findings.join('; ').replace(/\|/g, '\\|') || '—'} | ${(doc.files || []).map((f) => `\`${f}\``).join(' ') || '—'} |`);
|
||||
}
|
||||
for (const issue of issues) {
|
||||
const docs = byIssue.get(issue).sort((a, b) => (a.stage === b.stage ? (a.round || 0) - (b.round || 0) : a.stage === 'spec' ? -1 : 1));
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Пакетное ревью задач `track:ship` перед бетой (#696, PROCESS.md §11.7).
|
||||
*
|
||||
* node scripts/ship-review.mjs doc --tag=v1.79.0-beta.1
|
||||
* node scripts/ship-review.mjs prepare --tag=<тег> --candidate=<sha> --out=<dir> [--repo=owner/name]
|
||||
* node scripts/ship-review.mjs check --tag=<тег> --candidate=<sha> [--repo=owner/name]
|
||||
*
|
||||
* `ship` сливается без ревью модели (§5): правка в механических рамках и
|
||||
* зелёный лёгкий Validate. Прочитать её код обязан кто-то до того, как она
|
||||
* уйдёт пользователям, — это пакетное ревью всех ship-задач диапазона
|
||||
* «прошлый тег..кандидат беты» одной сессией модели.
|
||||
*
|
||||
* Какие задачи — ship, доказывает конвейер, а не метка: слияние без модели
|
||||
* оставляет в issue машинный маркер `<!-- hp:ship-merge material=<sha> -->`.
|
||||
* Метка `track:ship` на issue в диапазоне тоже включает задачу в пакет —
|
||||
* лишний разбор дешевле пропущенного. Состав диапазона — трейлеры `Issue: #NN`,
|
||||
* тот же построитель, что у `RELEASE-MEMBERSHIP.json` (#547).
|
||||
*
|
||||
* `check` — гейт публикации беты: если в диапазоне есть ship-задачи, документ
|
||||
* `docs/reviews/SHIP-REVIEW-<тег>.md` обязан быть в кандидате или в `dev`,
|
||||
* покрывать их все машинным блоком и не нести High.
|
||||
*/
|
||||
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 { issueTrailers, readCandidateHistory } from './release-membership.mjs';
|
||||
|
||||
export const SHIP_REVIEW_DIR = 'docs/reviews';
|
||||
export const SHIP_MERGE_MARKER_RE = /<!-- hp:ship-merge material=([0-9a-f]{40}) -->/;
|
||||
export const SHIP_REVIEW_ANCHOR = '<!-- hp-ship-review-anchors -->';
|
||||
export const RELEASE_TAG_RE = /^v(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-beta\.(0|[1-9]\d*))?$/;
|
||||
const SHA_RE = /^[0-9a-f]{40,64}$/;
|
||||
const TZ_LIMIT = 1500;
|
||||
|
||||
/** Путь документа пакетного ревью для тега беты или стабильного. */
|
||||
export function shipReviewDocPath(tag) {
|
||||
if (!RELEASE_TAG_RE.test(String(tag))) throw new Error(`not a release tag: ${tag}`);
|
||||
return `${SHIP_REVIEW_DIR}/SHIP-REVIEW-${tag}.md`;
|
||||
}
|
||||
|
||||
/** Задача — ship, если конвейер слил её без модели или на ней стоит `track:ship`. */
|
||||
export function isShipIssue({ labels = [], comments = [] } = {}) {
|
||||
const names = labels.map((label) => (typeof label === 'string' ? label : label?.name));
|
||||
return names.includes('track:ship') || comments.some((c) => SHIP_MERGE_MARKER_RE.test(String(c?.body ?? '')));
|
||||
}
|
||||
|
||||
/** Раздел `## ТЗ` тела issue — для ship это строка «что меняется и чем проверить». */
|
||||
export function specSection(body = '') {
|
||||
const text = String(body);
|
||||
const start = text.search(/^#{1,3}\s*ТЗ(?![\p{L}\p{N}_])/mu);
|
||||
if (start < 0) return '';
|
||||
const rest = text.slice(start).split('\n');
|
||||
const out = [rest[0]];
|
||||
for (const line of rest.slice(1)) {
|
||||
if (/^#{1,3}\s/.test(line)) break;
|
||||
out.push(line);
|
||||
}
|
||||
const section = out.join('\n').trim();
|
||||
return section.length > TZ_LIMIT ? `${section.slice(0, TZ_LIMIT)}…` : section;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ship-задачи диапазона: номера из трейлеров коммитов, признак ship — из
|
||||
* данных issue. `issueData(number)` → `{ title, body, labels, comments }` или null.
|
||||
*/
|
||||
export function shipIssuesInRange({ commits = [], issueData }) {
|
||||
const byIssue = new Map();
|
||||
for (const commit of commits) {
|
||||
for (const number of issueTrailers(commit.message)) {
|
||||
if (!byIssue.has(number)) byIssue.set(number, []);
|
||||
byIssue.get(number).push({ sha: commit.sha, subject: String(commit.message).split('\n')[0] });
|
||||
}
|
||||
}
|
||||
const out = [];
|
||||
for (const [number, list] of [...byIssue].sort((a, b) => a[0] - b[0])) {
|
||||
const data = issueData(number);
|
||||
if (!data || !isShipIssue(data)) continue;
|
||||
out.push({ number, title: data.title || '', spec: specSection(data.body), commits: list.reverse() });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function renderShipBrief({ tag, candidate, base, ship, runUrl = '' }) {
|
||||
const lines = [
|
||||
`# Вход пакетного ревью ship ${tag}`,
|
||||
'',
|
||||
`- Кандидат: \`${candidate}\``,
|
||||
`- Диапазон: ${base ? `\`${base.tag}\` · \`${base.sha}\`` : 'нет прошлого тега — всё дерево'} .. кандидат`,
|
||||
...(runUrl ? [`- Прогон: ${runUrl}`] : []),
|
||||
`- Документ: \`${shipReviewDocPath(tag)}\``,
|
||||
'',
|
||||
`## Задачи ship (${ship.length}) — слиты без ревью модели`,
|
||||
'',
|
||||
];
|
||||
for (const issue of ship) {
|
||||
lines.push(`### #${issue.number} · ${issue.title}`, '');
|
||||
lines.push(issue.spec ? issue.spec : '(раздела «## ТЗ» в теле нет — ТЗ задачи не записано, это находка)', '');
|
||||
lines.push('Коммиты (`git show <sha>`):', '');
|
||||
for (const commit of issue.commits) lines.push(`- \`${commit.sha}\` ${commit.subject}`);
|
||||
lines.push('');
|
||||
}
|
||||
return `${lines.join('\n').replace(/\n{3,}/g, '\n\n').trim()}\n`;
|
||||
}
|
||||
|
||||
/** Машинный блок документа: его пишет публикация, читает `check`. */
|
||||
export function anchorBlock({ tag, candidate, base = null, issues = [], high = 0, medium = 0, low = 0, runUrl = '' }) {
|
||||
return [
|
||||
SHIP_REVIEW_ANCHOR,
|
||||
'### Материал пакетного ревью',
|
||||
'',
|
||||
'```',
|
||||
`tag ${tag}`,
|
||||
`candidate ${candidate}`,
|
||||
`base ${base || '—'}`,
|
||||
`issues ${issues.length ? issues.join(',') : '—'}`,
|
||||
`high ${high}`,
|
||||
`medium ${medium}`,
|
||||
`low ${low}`,
|
||||
`run ${runUrl || '—'}`,
|
||||
'```',
|
||||
'',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
export function parseAnchorBlock(text = '') {
|
||||
const at = String(text).lastIndexOf(SHIP_REVIEW_ANCHOR);
|
||||
if (at < 0) return null;
|
||||
const fence = /```\n([\s\S]*?)\n```/.exec(String(text).slice(at));
|
||||
if (!fence) return null;
|
||||
const fields = Object.fromEntries(fence[1].split('\n').map((line) => {
|
||||
const space = line.indexOf(' ');
|
||||
return space < 0 ? [line, ''] : [line.slice(0, space), line.slice(space + 1).trim()];
|
||||
}));
|
||||
const number = (value) => (/^\d+$/.test(String(value)) ? Number(value) : null);
|
||||
return {
|
||||
tag: fields.tag || null,
|
||||
candidate: fields.candidate || null,
|
||||
issues: String(fields.issues || '').split(',').map((s) => number(s.trim())).filter((n) => n != null),
|
||||
high: number(fields.high),
|
||||
medium: number(fields.medium),
|
||||
low: number(fields.low),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Причины не публиковать бету; пустой список — гейт пройден. Без ship-задач в
|
||||
* диапазоне документ не нужен: пакетному ревью нечего читать.
|
||||
*/
|
||||
export function shipReviewProblems({ tag, ship = [], docText = null }) {
|
||||
if (!ship.length) return [];
|
||||
const doc = shipReviewDocPath(tag);
|
||||
const numbers = ship.map((issue) => issue.number);
|
||||
const run = `gh workflow run ship-review.yml --ref dev -f tag=${tag}`;
|
||||
if (!docText) {
|
||||
return [`${doc} нет ни в кандидате, ни в dev: ship-задачи ${numbers.map((n) => `#${n}`).join(', ')} слиты без ревью модели, и их код до беты не читал никто (PROCESS.md §11.7). Запустить: ${run}`];
|
||||
}
|
||||
const block = parseAnchorBlock(docText);
|
||||
if (!block) return [`${doc} без машинного блока ${SHIP_REVIEW_ANCHOR}: покрытие задач не доказано. Переснять: ${run} -f force=true`];
|
||||
const problems = [];
|
||||
if (block.tag !== tag) problems.push(`${doc} записан для тега ${block.tag}, а публикуется ${tag}`);
|
||||
const missing = numbers.filter((n) => !block.issues.includes(n));
|
||||
if (missing.length) {
|
||||
problems.push(`${doc} не покрывает ship-задачи ${missing.map((n) => `#${n}`).join(', ')} — они слиты после ревью. Переснять: ${run} -f force=true`);
|
||||
}
|
||||
if (block.high == null) problems.push(`${doc}: в машинном блоке нет числа High`);
|
||||
else if (block.high > 0) problems.push(`${doc}: High ${block.high} — бета ждёт починки: находка чинится отдельной задачей, затем ревью переснимается`);
|
||||
return problems;
|
||||
}
|
||||
|
||||
function git(args, { allowFailure = false } = {}) {
|
||||
const r = spawnSync('git', args, { encoding: 'utf8', maxBuffer: 256 * 1024 * 1024 });
|
||||
if (r.status !== 0 && !allowFailure) throw new Error(`git ${args.join(' ')} failed: ${(r.stderr || '').trim()}`);
|
||||
return r.status === 0 ? r.stdout : null;
|
||||
}
|
||||
|
||||
function ghIssue(repo, number) {
|
||||
const r = spawnSync('gh', ['issue', 'view', String(number), '--repo', repo, '--json', 'number,title,body,labels,comments'], {
|
||||
encoding: 'utf8', maxBuffer: 64 * 1024 * 1024,
|
||||
});
|
||||
if (r.status !== 0) throw new Error(`gh issue view ${number}: ${(r.stderr || r.stdout || '').trim()}`);
|
||||
return JSON.parse(r.stdout);
|
||||
}
|
||||
|
||||
/** Документ — из кандидата, иначе из `origin/dev`: ревью могло лечь в dev после Release-коммита. */
|
||||
export function readShipDoc(tag, candidate) {
|
||||
const path = shipReviewDocPath(tag);
|
||||
return git(['show', `${candidate}:${path}`], { allowFailure: true })
|
||||
?? git(['show', `origin/dev:${path}`], { allowFailure: true });
|
||||
}
|
||||
|
||||
function collect({ tag, candidate, repo }) {
|
||||
shipReviewDocPath(tag);
|
||||
if (!SHA_RE.test(String(candidate))) throw new Error(`invalid candidate SHA: ${candidate}`);
|
||||
if (!repo) throw new Error('--repo (или GITHUB_REPOSITORY) обязателен');
|
||||
const { base, commits } = readCandidateHistory(candidate);
|
||||
const ship = shipIssuesInRange({ commits, issueData: (number) => ghIssue(repo, number) });
|
||||
return { base, ship };
|
||||
}
|
||||
|
||||
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');
|
||||
const repo = value('repo') || process.env.GITHUB_REPOSITORY || '';
|
||||
if (command === 'doc') {
|
||||
console.log(shipReviewDocPath(tag));
|
||||
} else if (command === 'prepare') {
|
||||
const candidate = value('candidate');
|
||||
const out = resolve(value('out') || '.');
|
||||
const { base, ship } = collect({ tag, candidate, repo });
|
||||
mkdirSync(out, { recursive: true });
|
||||
writeFileSync(join(out, 'ship-issues.json'), `${JSON.stringify({ schema: 1, tag, candidate, base, issues: ship }, null, 2)}\n`);
|
||||
writeFileSync(join(out, 'brief.md'), renderShipBrief({ tag, candidate, base, ship, runUrl: value('run-url') }));
|
||||
console.log(`doc=${shipReviewDocPath(tag)}`);
|
||||
console.log(`base=${base ? base.tag : ''}`);
|
||||
console.log(`issues=${ship.map((issue) => issue.number).join(',')}`);
|
||||
} else if (command === 'check') {
|
||||
const candidate = value('candidate');
|
||||
const { ship } = collect({ tag, candidate, repo });
|
||||
const problems = shipReviewProblems({ tag, ship, docText: readShipDoc(tag, candidate) });
|
||||
if (!ship.length) console.log('ship-задач в диапазоне нет — пакетное ревью не требуется');
|
||||
else if (!problems.length) console.log(`пакетное ревью ${shipReviewDocPath(tag)} покрывает ship-задачи ${ship.map((i) => `#${i.number}`).join(', ')}`);
|
||||
for (const problem of problems) console.error(`::error::${problem}`);
|
||||
process.exit(problems.length ? 1 : 0);
|
||||
} else {
|
||||
throw new Error('usage: ship-review.mjs doc --tag=<tag> | prepare --tag=<tag> --candidate=<sha> --out=<dir> [--repo=o/r] | check --tag=<tag> --candidate=<sha> [--repo=o/r]');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`::error::${error.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
+3
-18
@@ -20,6 +20,7 @@ import { spawnSync } from 'node:child_process';
|
||||
import { isMainModule } from './spawn-portable.mjs';
|
||||
import { anchorTreeFrom, anchorVerdictFrom, verdictDeclaration } from './review-doc-guard.mjs';
|
||||
import { classify } from './process-gate.mjs';
|
||||
import { hasTrackLabel, trackFromLabels } from './process-track.mjs';
|
||||
|
||||
export const STATUS_LABELS = ['S1-new', 'S2-analysis', 'S3-spec', 'S4-spec-review', 'S5-ready', 'S6-in-progress', 'S7-code-review', 'S8-merged'];
|
||||
|
||||
@@ -126,24 +127,8 @@ export function branchIsInfrastructure(changedFiles = []) {
|
||||
|
||||
const PRE_CODE_STATUSES = ['S1-new', 'S2-analysis', 'S3-spec', 'S4-spec-review', 'S5-ready'];
|
||||
|
||||
/**
|
||||
* Трек продуктовой задачи по меткам (PROCESS §5, #695): `track:ship`,
|
||||
* `track:show` или `track:ask`; прежние `trivial` и `small` читаются как `show`
|
||||
* (§5.1), задача без трековой метки — как `ask`. Метки трека не доказывают
|
||||
* продуктовый поток: инфраструктурной задаче владелец тоже может поставить
|
||||
* `track:*`, чтобы задать ей цену конвейера.
|
||||
*/
|
||||
export function trackFromLabels(labels = []) {
|
||||
if (labels.includes('track:ship')) return 'ship';
|
||||
if (labels.includes('track:show')) return 'show';
|
||||
if (labels.includes('track:ask')) return 'ask';
|
||||
if (labels.includes('trivial') || labels.includes('small')) return 'show';
|
||||
return 'ask';
|
||||
}
|
||||
|
||||
/** Есть ли у задачи трековая метка — новая или прежняя (§5.1). */
|
||||
export const hasTrackLabel = (labels = []) => ['track:ship', 'track:show', 'track:ask', 'trivial', 'small']
|
||||
.some((label) => labels.includes(label));
|
||||
// Трек по меткам — одна функция на конвейер и пакет (#696): process-track.mjs.
|
||||
export { hasTrackLabel, trackFromLabels };
|
||||
|
||||
/**
|
||||
* Признаки продуктового S-flow (#632). Инфраструктурная задача входит в поток
|
||||
|
||||
+35
-12
@@ -7,7 +7,7 @@
|
||||
* доказательство для ревью — dispatch-прогон на точном SHA материала. Push-
|
||||
* прогон на том же SHA зелёный не считается: в нём мутантов нет.
|
||||
*
|
||||
* node scripts/validate-gate.mjs --repo=<owner/repo> --ref=<ветка> --sha=<sha> [--workflow=validate.yml] [--no-wait]
|
||||
* node scripts/validate-gate.mjs --repo=<owner/repo> --ref=<ветка> --sha=<sha> [--workflow=validate.yml] [--no-wait] [--mutants=false]
|
||||
*
|
||||
* Печатает `result=green|failed|missing|pending`, `url=…`, `run_id=…` (и в
|
||||
* $GITHUB_OUTPUT, если он задан); код выхода 0 только при green, 2 — pending.
|
||||
@@ -45,6 +45,19 @@ export function isMutantRun(run) {
|
||||
return run?.event === 'workflow_dispatch';
|
||||
}
|
||||
|
||||
/**
|
||||
* #696: какие прогоны годятся в доказательство. С мутантами — только dispatch
|
||||
* (push их не запрашивает). Без мутантов (`show`/`ship`) годится и
|
||||
* завершённый push-прогон на материале: лёгкий Validate на этом SHA уже
|
||||
* исполнен, и dispatch повторил бы его. Ждать же можно только dispatch:
|
||||
* продолжение раунда будит его завершение (process-resume.yml слушает
|
||||
* `workflow_dispatch`), и push, который ещё идёт, раунд не разбудил бы.
|
||||
*/
|
||||
export function proofCandidate(run, { mutants = true } = {}) {
|
||||
if (mutants) return isMutantRun(run);
|
||||
return run?.event === 'workflow_dispatch' || (run?.event === 'push' && run?.status === 'completed');
|
||||
}
|
||||
|
||||
// #622: префикс — из контракта ci-proof (JOB_RULES.mutants), который сверяется
|
||||
// с validate.yml; своей строки здесь больше нет.
|
||||
export { MUTANT_JOB_PREFIX };
|
||||
@@ -72,7 +85,10 @@ export function provesMutants(jobs) {
|
||||
*/
|
||||
export async function validateGate({
|
||||
ref, sha, ops, appearMs = VALIDATE_APPEAR_MS, totalMs = VALIDATE_TOTAL_MS, pollMs = POLL_MS, wait = true,
|
||||
mutants = true,
|
||||
}) {
|
||||
const policy = mutants ? CI_PROOF_POLICIES.review : CI_PROOF_POLICIES.reviewLight;
|
||||
const label = mutants ? 'Validate с мутантами' : 'Validate';
|
||||
const started = ops.now();
|
||||
const candidateTree = await ops.candidateTree(sha);
|
||||
const ignored = new Set(); // завершённые dispatch без применимого proof
|
||||
@@ -80,14 +96,19 @@ export async function validateGate({
|
||||
let dispatchedAt = null;
|
||||
let attempts = 0;
|
||||
while (ops.now() - started < totalMs) {
|
||||
const runs = (await ops.listRuns(sha)).filter((x) => isMutantRun(x) && !ignored.has(x.databaseId));
|
||||
const run = runs.find((x) => tracked && x.databaseId === tracked) || runs[0];
|
||||
const runs = (await ops.listRuns(sha)).filter((x) => proofCandidate(x, { mutants }) && !ignored.has(x.databaseId));
|
||||
// Без мутантов завершённый прогон на материале предпочтительнее идущего
|
||||
// dispatch: доказательство уже есть, ждать нечего (#696). С мутантами
|
||||
// порядок прежний — свежий прогон решает.
|
||||
const run = runs.find((x) => tracked && x.databaseId === tracked)
|
||||
|| (!mutants && runs.find((x) => x.status === 'completed'))
|
||||
|| runs[0];
|
||||
if (run) {
|
||||
tracked = run.databaseId;
|
||||
if (run.status === 'completed') {
|
||||
const context = await ops.proof(run);
|
||||
const verdict = evaluateCiProof({
|
||||
run, ...context, candidate: { sha, tree: candidateTree }, policy: CI_PROOF_POLICIES.review,
|
||||
run, ...context, candidate: { sha, tree: candidateTree }, policy,
|
||||
});
|
||||
if (verdict.status === 'green') return { result: 'green', url: verdict.url, note: verdict.note };
|
||||
if (verdict.status === 'failed') return { result: 'failed', url: verdict.url, note: verdict.note };
|
||||
@@ -100,11 +121,11 @@ export async function validateGate({
|
||||
// #636: прогон найден и идёт — ждать его будет событие, не раннер.
|
||||
return {
|
||||
result: 'pending', url: run.url || null, runId: run.databaseId,
|
||||
note: `Validate с мутантами идёт (${run.status}); продолжение — по завершении прогона`,
|
||||
note: `${label} идёт (${run.status}); продолжение — по завершении прогона`,
|
||||
};
|
||||
}
|
||||
} else if (dispatchedAt === null) {
|
||||
await ops.dispatch(ref);
|
||||
await ops.dispatch(ref, { mutants });
|
||||
dispatchedAt = ops.now();
|
||||
attempts = 1;
|
||||
} else if (ops.now() - dispatchedAt > appearMs) {
|
||||
@@ -116,7 +137,7 @@ export async function validateGate({
|
||||
// чужой коммит переживёт и вторую попытку.
|
||||
const elsewhere = (await ops.listRunsOnRef(ref)).filter(isMutantRun).find((x) => x.headSha && x.headSha !== sha);
|
||||
if (elsewhere && attempts < DISPATCH_ATTEMPTS) {
|
||||
await ops.dispatch(ref);
|
||||
await ops.dispatch(ref, { mutants });
|
||||
dispatchedAt = ops.now();
|
||||
attempts += 1;
|
||||
await ops.sleep(pollMs);
|
||||
@@ -131,7 +152,7 @@ export async function validateGate({
|
||||
}
|
||||
await ops.sleep(pollMs);
|
||||
}
|
||||
return { result: 'failed', url: null, note: 'Validate с мутантами не завершился за 45 минут' };
|
||||
return { result: 'failed', url: null, note: `${label} не завершился за 45 минут` };
|
||||
}
|
||||
|
||||
const sh = (cmd, args) => spawnSync(cmd, args, { encoding: 'utf8' });
|
||||
@@ -151,8 +172,8 @@ export function realOps({ repo, workflow = 'validate.yml', token = process.env.G
|
||||
catch { return { proof: null, jobs: [], reuseRuns: new Map() }; }
|
||||
},
|
||||
listRunsOnRef: async (ref) => parse(sh('gh', ['run', 'list', '--repo', repo, '--workflow', workflow, '--branch', ref, '--event', 'workflow_dispatch', '--json', fields, '--limit', '5'])),
|
||||
dispatch: async (ref) => {
|
||||
const r = sh('gh', ['workflow', 'run', workflow, '--repo', repo, '--ref', ref, '-f', 'full=false', '-f', 'mutants=true']);
|
||||
dispatch: async (ref, { mutants = true } = {}) => {
|
||||
const r = sh('gh', ['workflow', 'run', workflow, '--repo', repo, '--ref', ref, '-f', 'full=false', '-f', `mutants=${mutants ? 'true' : 'false'}`]);
|
||||
if (r.status !== 0) throw new Error(`gh workflow run: ${r.stderr || r.stdout}`);
|
||||
},
|
||||
sleep: (ms) => new Promise((done) => setTimeout(done, ms)),
|
||||
@@ -167,11 +188,13 @@ if (invokedDirectly) {
|
||||
const ref = arg('ref');
|
||||
const sha = arg('sha');
|
||||
if (!repo || !ref || !sha) {
|
||||
console.error('usage: validate-gate.mjs --repo=<owner/repo> --ref=<branch> --sha=<sha> [--workflow=validate.yml]');
|
||||
console.error('usage: validate-gate.mjs --repo=<owner/repo> --ref=<branch> --sha=<sha> [--workflow=validate.yml] [--mutants=false] [--no-wait]');
|
||||
process.exit(2);
|
||||
}
|
||||
const wait = !process.argv.includes('--no-wait');
|
||||
const outcome = await validateGate({ ref, sha, wait, ops: realOps({ repo, workflow: arg('workflow') || 'validate.yml' }) });
|
||||
// #696: `--mutants=false` — лёгкое доказательство треков show/ship.
|
||||
const mutants = arg('mutants') !== 'false';
|
||||
const outcome = await validateGate({ ref, sha, wait, mutants, ops: realOps({ repo, workflow: arg('workflow') || 'validate.yml' }) });
|
||||
const lines = [`result=${outcome.result}`, `url=${outcome.url || ''}`, `run_id=${outcome.runId || ''}`, `note=${outcome.note}`];
|
||||
for (const line of lines) console.log(line);
|
||||
if (process.env.GITHUB_OUTPUT) appendFileSync(process.env.GITHUB_OUTPUT, `${lines.join('\n')}\n`);
|
||||
|
||||
Reference in New Issue
Block a user