Files
houseplan-card/scripts/classify-base.mjs
T
Matysh 65339f63ad ci: judge the push range from the last proven-green ancestor
#387 закрыл классификацию — какие job запускать. Здесь остаток того же
дефекта: гейты, которые судят сам диапазон коммитов. Провенанс, процессный
гейт и «новый код не добавляет any» брали диапазон от головы предыдущего
пуша, а concurrency отменяет прогон предыдущего пуша штатно. Тогда его
коммиты не судит никто: свой прогон отменён, а следующий пуш сравнивает уже
с ними. Окно не закрывается никогда.

Уязвим был прямой пуш в dev — основной режим конвейера. На ветках дефекта
нет: no-new-any там всегда считает от merge-base, а resolveValidationRange
подменяет осиротевший before на origin/dev (#315).

База стала последним предком с успешно завершённым Validate. Фолбэк, когда
такого нет, сознательно оставлен прежним — before, но с пометкой в summary
«диапазон недоказуем». Расширять диапазон здесь нельзя: гейт, который сам
красит прогон, лишил бы следующий пуш зелёного предка и запер dev в
красноте навсегда. Фолбэк обязан не зависеть от собственного успеха гейта.
Дыра сужается с «всегда, когда прогон предыдущего пуша отменён» до «когда
во всём окне обхода нет ни одного успешного прогона».

Находки no-new-any теперь называют коммит, добавивший строку: диапазон стал
шире, и без имени источника сообщение обвиняло бы того, кто пушнул
следующим, — ровно то, что чинили в #386 для golden.

Issue: #388
User-Visible: no
2026-08-30 15:55:08 +03:00

214 lines
12 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.
// База диапазона: до какого коммита назад считать, что «уже проверено»
// (issue #387 — классификация файлов, issue #388 — гейты диапазона).
//
// Job `changes` решает, запускать ли тяжёлые гейты, по списку файлов в
// диапазоне. Раньше диапазон брался от `github.event.before` — головы ветки на
// момент ПРЕДЫДУЩЕГО push. Это молчаливое допущение: «то, что было до, уже
// проверено». Допущение неверно ровно тогда, когда прогон предыдущего пуша не
// завершился, — а он не завершается штатно, потому что concurrency отменяет
// его следующим пушем.
//
// Так вышло на #86 (r5): push `04da7eb1` тронул dist/** и frontend/**, его
// прогон отменили через три минуты; следующий push `fa146fb1` тронул только
// docs/images/**, классификация сравнила эти два коммита, выставила
// frontend=false — и job «Фронтенд», а за ней golden, smoke и backend оказались
// skipped. Прогон при этом success. Зелёный статус ветки не подтверждался
// исполнением ни одного тяжёлого гейта.
//
// Здесь допущение заменяется проверяемым фактом: база — самый новый предок
// HEAD, для которого Validate ДЕЙСТВИТЕЛЬНО завершился успешно; если такого
// нет — merge-base с dev, то есть весь вклад ветки.
//
// Почему этого достаточно — индукция. Если каждое звено цепочки
// «предыдущий зелёный → текущий» классифицировано от зелёного предка, то
// объединение диффов покрывает всё, что ветка изменила с последней настоящей
// проверки. Одно незавершённое звено рвёт цепочку — и именно оно теперь
// заставляет расширить диапазон, а не сузить.
//
// Два режима, и разница между ними только в фолбэке — см. `pickBase` и
// `pickRangeBase`. Классификация (#387) может опуститься до merge-base с dev;
// гейтам диапазона (#388) на пуше прямо в dev опускаться некуда, и там фолбэк
// остаётся прежним `before`, но с явной пометкой «недоказуемо».
import { execFileSync } from 'node:child_process';
import { appendFileSync, readFileSync } from 'node:fs';
/**
* Ограничение обхода: на застоявшейся ветке rev-list бывает длинным, а ответ
* при этом всё равно один — merge-base.
*/
export const MAX_CANDIDATES = 300;
/**
* SHA прогонов, завершившихся успешно. Вход — тело ответа
* `/actions/workflows/validate.yml/runs`; всё, что не массив прогонов,
* считается пустым списком: недоступность API обязана вести к более широкому
* диапазону, а не к падению job.
*/
export function greenShas(payload) {
const runs = payload && Array.isArray(payload.workflow_runs) ? payload.workflow_runs : [];
return new Set(
runs
.filter((run) => run && run.conclusion === 'success' && typeof run.head_sha === 'string')
.map((run) => run.head_sha),
);
}
/**
* Выбор базы.
*
* @param candidates SHA предков HEAD от новых к старым, БЕЗ самого HEAD:
* у текущего пуша зелёного прогона быть не может — он идёт.
* @param green множество SHA с успешным Validate.
* @param mergeBase merge-base с dev: пол, ниже которого опускаться незачем.
*/
export function pickBase({ candidates, green, mergeBase }) {
const found = firstGreen(candidates, green);
if (found) return { ...found, reason: 'green-ancestor', proven: true };
return {
base: mergeBase,
reason: 'merge-base',
proven: false,
skipped: capped(candidates).length,
};
}
/** Кандидаты в пределах обхода. */
const capped = (candidates) =>
(Array.isArray(candidates) ? candidates : []).slice(0, MAX_CANDIDATES);
/** Самый новый зелёный предок либо null. */
function firstGreen(candidates, green) {
const list = capped(candidates);
const proven = green instanceof Set ? green : new Set();
for (let i = 0; i < list.length; i += 1) {
if (proven.has(list[i])) return { base: list[i], skipped: i };
}
return null;
}
/**
* База для гейтов, судящих САМ диапазон коммитов, — провенанс, процессный гейт,
* `no-new-any` (issue #388). Отличие от классификации принципиальное, и оно в
* фолбэке.
*
* У классификации есть естественный пол — merge-base с dev. У пуша прямо в dev
* пола нет: merge-base совпадает с HEAD, и такой фолбэк дал бы ПУСТОЙ диапазон,
* то есть молча проходящий гейт. Поэтому здесь фолбэк — `before` события, как
* было до #388, но с явной пометкой «диапазон недоказуем».
*
* Почему не расширять диапазон, когда зелёного предка не нашлось. Расширение
* кажется строже, но у него два своих провала: процессный гейт начал бы судить
* старые коммиты по сегодняшним правилам, а главное — гейт, который сам красит
* прогон, лишает следующий пуш зелёного предка и запирает dev в красноте
* навсегда. Фолбэк обязан не зависеть от собственного успеха этого гейта.
*
* Дыра при этом не остаётся прежней: она сужается с «всегда, когда прогон
* предыдущего пуша отменён» до «когда во всём окне обхода нет ни одного
* успешного прогона». Первое случается ежедневно, второе — при сломанном CI,
* где красный Validate и так уместен.
*/
export function pickRangeBase({ candidates, green, fallback }) {
const found = firstGreen(candidates, green);
if (found) return { ...found, reason: 'green-ancestor', proven: true };
return {
base: fallback || '',
reason: 'fallback',
proven: false,
skipped: capped(candidates).length,
};
}
const short = (sha) => (typeof sha === 'string' ? sha.slice(0, 8) : '?');
/** Строки для summary: почему диапазон именно такой. */
export function baseSummary(choice, { head, mergeBase }) {
if (choice.reason === 'green-ancestor') {
const skipped = choice.skipped
? ` Пропущено коммитов без завершённого прогона: ${choice.skipped}.`
: '';
return [
'### База классификации (#387)',
`Диапазон \`${short(choice.base)}..${short(head)}\`: это самый новый предок,`
+ ` для которого Validate завершился успешно.${skipped}`,
];
}
if (choice.reason === 'fallback') {
return [
'### База диапазона (#388)',
`Ни у одного из ${choice.skipped} предков нет завершённого зелёного Validate.`
+ ` Диапазон взят от \`${short(choice.base)}\` — головы предыдущего пуша,`
+ ' и это НЕ доказательство проверенности: прогон того пуша мог быть отменён.'
+ ' Коммиты в этом окне могли не пройти ни одного гейта.',
];
}
return [
'### База классификации (#387)',
'Ни у одного предка до merge-base с dev нет завершённого зелёного Validate,'
+ ` поэтому диапазон расширен до \`${short(mergeBase)}..${short(head)}\` —`
+ ' весь вклад ветки. Узкий диапазон здесь означал бы «проверено» про то,'
+ ' чего никто не проверял.',
];
}
const arg = (argv, name, fallback = '') =>
(argv.find((a) => a.startsWith(`--${name}=`)) || '').slice(name.length + 3) || fallback;
function main(argv) {
const head = arg(argv, 'head');
const mode = arg(argv, 'mode', 'classify');
const mergeBase = arg(argv, 'merge-base', mode === 'range' ? head : '');
const runsFile = arg(argv, 'runs');
if (!head || !mergeBase) {
process.stderr.write('usage: classify-base.mjs --head=<sha>'
+ ' [--mode=classify|range] [--merge-base=<sha>] [--fallback=<sha>]'
+ ' [--name=<output>] [--runs=<file>]\n');
process.exit(2);
}
let payload = null;
if (runsFile) {
try {
payload = JSON.parse(readFileSync(runsFile, 'utf8'));
} catch {
// Пустой или битый ответ — сознательно не ошибка: см. greenShas.
payload = null;
}
}
// `--skip=1` убирает сам HEAD: его прогон — это текущий, зелёным он быть не
// может по определению.
//
// В режиме `range` (пуш прямо в dev) пола нет: обход идёт по истории до
// предела MAX_CANDIDATES, потому что merge-base с dev здесь совпал бы с HEAD.
const span = mode === 'range' ? head : `${mergeBase}..${head}`;
const candidates = execFileSync('git', [
'rev-list', `--max-count=${MAX_CANDIDATES}`, '--skip=1', span,
], { encoding: 'utf8' }).split('\n').map((line) => line.trim()).filter(Boolean);
const green = greenShas(payload);
const choice = mode === 'range'
? pickRangeBase({ candidates, green, fallback: arg(argv, 'fallback') })
: pickBase({ candidates, green, mergeBase });
const summary = baseSummary(choice, { head, mergeBase });
process.stdout.write(`${summary.join('\n')}\n`);
// Имя выхода задаётся явно: одна и та же job считает базу для двух разных
// потребителей, и общее имя `base` для обоих было бы ловушкой — потребитель
// молча взял бы чужую базу, а разницу между режимами видно только здесь.
const name = arg(argv, 'name', 'base');
if (process.env.GITHUB_OUTPUT) {
appendFileSync(process.env.GITHUB_OUTPUT,
`${name}=${choice.base}\n${name}_proven=${choice.proven}\n`);
}
if (process.env.GITHUB_STEP_SUMMARY) {
appendFileSync(process.env.GITHUB_STEP_SUMMARY, `${summary.join('\n\n')}\n`);
}
}
if (process.argv[1] && process.argv[1].endsWith('classify-base.mjs')) {
try {
main(process.argv.slice(2));
} catch (err) {
process.stderr.write(`classify-base: ${err.message}\n`);
process.exit(1);
}
}