mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
ci: classify against the last proven-green ancestor
Диапазон классификации брался от `github.event.before` — головы предыдущего пуша. Это допущение «до этого уже проверено», и оно неверно ровно тогда, когда прогон предыдущего пуша не завершился. А не завершается он штатно: concurrency отменяет его следующим пушем. На #86 (r5) это дало ложный зелёный: push04da7eb1тронул dist/** и frontend/**, его прогон отменили через три минуты; следующий pushfa146fb1тронул только docs/images/**, классификация сравнила эти два коммита и выставила frontend=false. Job «Фронтенд», а за ней golden, smoke и backend оказались skipped — прогон при этом success. Маркеры переиспользования эти гейты тоже не подтверждали: `Cache not found` по всем четырём. Теперь база — самый новый предок HEAD, для которого Validate ДЕЙСТВИТЕЛЬНО завершился успешно; если такого нет, диапазон расширяется до merge-base с dev, то есть до всего вклада ветки. Работает по индукции: цепочка узких диффов покрывает всё изменённое с последней настоящей проверки, а одно незавершённое звено теперь расширяет диапазон, а не сужает. Недоступность API не роняет job: пустой ответ опускает базу до merge-base, то есть в сторону большего объёма проверок. Защита от force-push (#347) сохранена: механизм, из-за которого merge-base врал на переписанной истории, до конца не разобран, и снимать защиту, не объяснив её, — способ получить #347 второй раз. Issue: #387 User-Visible: no
This commit is contained in:
@@ -0,0 +1,142 @@
|
||||
// База для классификации изменённых файлов (issue #387).
|
||||
//
|
||||
// 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, то есть весь вклад ветки.
|
||||
//
|
||||
// Почему этого достаточно — индукция. Если каждое звено цепочки
|
||||
// «предыдущий зелёный → текущий» классифицировано от зелёного предка, то
|
||||
// объединение диффов покрывает всё, что ветка изменила с последней настоящей
|
||||
// проверки. Одно незавершённое звено рвёт цепочку — и именно оно теперь
|
||||
// заставляет расширить диапазон, а не сузить.
|
||||
//
|
||||
// `github.event.before` больше не читается вовсе. Отдельная ветка про
|
||||
// force-push (#347) поэтому не нужна: кандидаты берутся из `rev-list
|
||||
// <merge-base>..HEAD`, то есть предки HEAD по построению, а переписанная
|
||||
// история просто не даёт зелёных совпадений и опускает базу до merge-base.
|
||||
|
||||
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 list = Array.isArray(candidates) ? candidates.slice(0, MAX_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], reason: 'green-ancestor', proven: true, skipped: i };
|
||||
}
|
||||
}
|
||||
return { base: mergeBase, reason: 'merge-base', proven: false, skipped: list.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}`,
|
||||
];
|
||||
}
|
||||
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 mergeBase = arg(argv, 'merge-base');
|
||||
const runsFile = arg(argv, 'runs');
|
||||
if (!head || !mergeBase) {
|
||||
process.stderr.write('usage: classify-base.mjs --head=<sha> --merge-base=<sha> [--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: его прогон — это текущий, зелёным он быть не
|
||||
// может по определению.
|
||||
const candidates = execFileSync('git', [
|
||||
'rev-list', `--max-count=${MAX_CANDIDATES}`, '--skip=1', `${mergeBase}..${head}`,
|
||||
], { encoding: 'utf8' }).split('\n').map((line) => line.trim()).filter(Boolean);
|
||||
|
||||
const choice = pickBase({ candidates, green: greenShas(payload), mergeBase });
|
||||
const summary = baseSummary(choice, { head, mergeBase });
|
||||
process.stdout.write(`${summary.join('\n')}\n`);
|
||||
if (process.env.GITHUB_OUTPUT) {
|
||||
appendFileSync(process.env.GITHUB_OUTPUT, `base=${choice.base}\nproven=${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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user