diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index e5a3cbf0..10c42711 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -140,20 +140,49 @@ jobs: changes: name: Классификация изменённых файлов runs-on: ubuntu-latest + # Чтение списка прогонов Validate — единственное, что этой job нужно сверх + # содержимого репозитория (#387). + permissions: + contents: read + actions: read outputs: frontend: ${{ steps.classify.outputs.frontend }} backend: ${{ steps.classify.outputs.backend }} integration: ${{ steps.classify.outputs.integration }} + base: ${{ steps.base.outputs.base }} steps: # `git diff --name-only` содержимого файлов не читает вовсе, поэтому # блобы истории этой job не нужны ни на одном шаге (#345). - uses: actions/checkout@v7 with: { fetch-depth: 0, filter: 'blob:none' } + # База диапазона — самый новый предок с УСПЕШНО завершённым Validate + # (#387). Прежде бралась голова предыдущего пуша (`github.event.before`), + # то есть допущение «до этого всё проверено». Concurrency отменяет прогон + # предыдущего пуша штатно, и на #86 (r5) это дало зелёный статус ветки + # без единого исполненного тяжёлого гейта. + - id: base + if: github.event_name != 'pull_request' && github.ref != 'refs/heads/dev' + env: + GH_TOKEN: ${{ github.token }} + HEAD_SHA: ${{ github.sha }} + BRANCH: ${{ github.ref_name }} + REPO: ${{ github.repository }} + run: | + git fetch -q origin dev + merge_base=$(git merge-base origin/dev "$HEAD_SHA" || git rev-parse "$HEAD_SHA~1") + # Недоступность API — не отказ гейта: пустой ответ опускает базу до + # merge-base, то есть в сторону БОЛЬШЕГО объёма проверок. + gh api -X GET "repos/$REPO/actions/workflows/validate.yml/runs" \ + -f branch="$BRANCH" -f status=success -F per_page=100 \ + > /tmp/validate-runs.json || echo '{}' > /tmp/validate-runs.json + node scripts/classify-base.mjs --head="$HEAD_SHA" \ + --merge-base="$merge_base" --runs=/tmp/validate-runs.json - id: classify env: EVENT_NAME: ${{ github.event_name }} BEFORE_SHA: ${{ github.event.before }} BASE_SHA: ${{ github.event.pull_request.base.sha }} + GREEN_BASE: ${{ steps.base.outputs.base }} HEAD_SHA: ${{ github.sha }} REF: ${{ github.ref }} run: | @@ -163,32 +192,38 @@ jobs: exit 0 fi zero=$(printf '%040d' 0) - base="$BEFORE_SHA" - if [ "$EVENT_NAME" = "pull_request" ]; then base="$BASE_SHA"; fi # #347: непустой before, которого больше не существует, — это - # force-push (ребейз/аменд переписал историю ветки). Диапазон в - # этом случае НЕ угадывается: merge-base после переписанной истории - # уже выдавал «изменились только два doc-файла» при реальном диффе - # в custom_components/**, и тяжёлые job тихо скипались, а общий - # статус рана оставался success — ровно класс тихого пропуска - # #171/#207. Force-push редок, полный прогон дешевле ложного - # «зелёного». - if [ -n "$base" ] && [ "$base" != "$zero" ] \ - && ! git cat-file -e "$base" 2>/dev/null; then - echo "force-push: before=$base переписан — полный прогон без классификации" + # force-push (ребейз/аменд переписал историю ветки). Диапазон в этом + # случае НЕ угадывается: merge-base после переписанной истории уже + # выдавал «изменились только два doc-файла» при реальном диффе в + # custom_components/**, и тяжёлые job тихо скипались, а общий статус + # рана оставался success — ровно класс тихого пропуска #171/#207. + # Force-push редок, полный прогон дешевле ложного «зелёного». + # + # Проверка сохранена как есть и после #387: там база стала доказуемой + # (зелёный предок), но механизм, из-за которого merge-base врал на + # переписанной истории, не разобран до конца. Снимать защиту, не + # объяснив её, — способ получить #347 второй раз. + if [ -n "$BEFORE_SHA" ] && [ "$BEFORE_SHA" != "$zero" ] \ + && ! git cat-file -e "$BEFORE_SHA" 2>/dev/null; then + echo "force-push: before=$BEFORE_SHA переписан — полный прогон без классификации" { echo "### Классификация изменённых файлов" - echo "\`before\` (=$base) переписан force-push'ем: диапазон недоказуем," + echo "\`before\` (=$BEFORE_SHA) переписан force-push'ем: диапазон недоказуем," echo "все тяжёлые job запускаются без фильтра путей (#347)." } >> "$GITHUB_STEP_SUMMARY" printf 'frontend=true\nbackend=true\nintegration=true\n' >> "$GITHUB_OUTPUT" exit 0 fi - # Новая ветка: before нулевой, диапазон считается от merge-base с dev, - # иначе классифицировалась бы вся история. - if [ -z "$base" ] || [ "$base" = "$zero" ]; then - git fetch -q origin dev - base=$(git merge-base origin/dev "$HEAD_SHA" || echo "$HEAD_SHA~1") + # Диапазон считается от последнего ДОКАЗАННО зелёного предка (#387), + # а не от головы предыдущего пуша: его прогон штатно отменяется + # следующим пушем. PR — исключение: там база задана событием. + base="$GREEN_BASE" + if [ "$EVENT_NAME" = "pull_request" ]; then base="$BASE_SHA"; fi + if [ -z "$base" ]; then + echo "база не определена — полный прогон без классификации" + printf 'frontend=true\nbackend=true\nintegration=true\n' >> "$GITHUB_OUTPUT" + exit 0 fi files=$(git diff --name-only "$base" "$HEAD_SHA") printf '%s\n' "$files" | head -50 diff --git a/scripts/classify-base.mjs b/scripts/classify-base.mjs new file mode 100644 index 00000000..fdc0fc33 --- /dev/null +++ b/scripts/classify-base.mjs @@ -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 +// ..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= --merge-base= [--runs=]\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); + } +} diff --git a/test/classify-base.test.mjs b/test/classify-base.test.mjs new file mode 100644 index 00000000..72e52fa5 --- /dev/null +++ b/test/classify-base.test.mjs @@ -0,0 +1,116 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { execFileSync, spawnSync } from 'node:child_process'; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { MAX_CANDIDATES, baseSummary, greenShas, pickBase } from '../scripts/classify-base.mjs'; + +const SCRIPT = fileURLToPath(new URL('../scripts/classify-base.mjs', import.meta.url)); + +test('зелёными считаются только успешно завершённые прогоны (#387)', () => { + const green = greenShas({ + workflow_runs: [ + { head_sha: 'aaa', conclusion: 'success' }, + // Ровно тот случай, из-за которого заведён #387: прогон предыдущего + // пуша отменён следующим пушем. + { head_sha: 'bbb', conclusion: 'cancelled' }, + { head_sha: 'ccc', conclusion: 'failure' }, + { head_sha: 'ddd', conclusion: null, status: 'in_progress' }, + { head_sha: 'eee', conclusion: 'skipped' }, + ], + }); + assert.deepEqual([...green], ['aaa']); +}); + +test('недоступный или битый ответ API даёт пустой список, а не падение (#387)', () => { + for (const payload of [null, undefined, {}, { workflow_runs: null }, 'мусор', 42]) { + assert.equal(greenShas(payload).size, 0, `${JSON.stringify(payload)}`); + } +}); + +test('база — самый новый зелёный предок (#387)', () => { + const choice = pickBase({ + candidates: ['c3', 'c2', 'c1'], + green: new Set(['c2', 'c1']), + mergeBase: 'mb', + }); + assert.equal(choice.base, 'c2', 'из двух зелёных берётся более новый'); + assert.equal(choice.proven, true); + assert.equal(choice.skipped, 1); +}); + +test('цепочка отменённых прогонов расширяет диапазон до merge-base (#387)', () => { + // Сценарий #86 r5: у предыдущего пуша прогон cancelled, зелёных предков нет. + const choice = pickBase({ candidates: ['fa146fb1', '04da7eb1'], green: new Set(), mergeBase: 'mb' }); + assert.equal(choice.base, 'mb'); + assert.equal(choice.proven, false); + assert.match(baseSummary(choice, { head: 'head1234', mergeBase: 'mb345678' }).join('\n'), + /расширен до/); +}); + +test('ветка без собственных коммитов не ломает выбор (#387)', () => { + const choice = pickBase({ candidates: [], green: new Set(['x']), mergeBase: 'mb' }); + assert.equal(choice.base, 'mb'); + assert.equal(choice.skipped, 0); +}); + +test('обход кандидатов ограничен сверху (#387)', () => { + const many = Array.from({ length: MAX_CANDIDATES + 5 }, (_, i) => `c${i}`); + // Зелёный есть, но лежит за пределом обхода: ответ обязан быть безопасным, + // то есть более широким, а не «ничего не нашли — берём последний». + const choice = pickBase({ candidates: many, green: new Set([`c${MAX_CANDIDATES + 2}`]), mergeBase: 'mb' }); + assert.equal(choice.base, 'mb'); +}); + +test('CLI считает базу по настоящей истории git (#387)', () => { + const dir = mkdtempSync(join(tmpdir(), 'hp-classify-')); + const git = (...args) => execFileSync('git', args, { cwd: dir, encoding: 'utf8' }).trim(); + try { + git('init', '-q', '-b', 'main'); + git('config', 'user.email', 'test@example.com'); + git('config', 'user.name', 'test'); + const commit = (text) => { + writeFileSync(join(dir, 'file.txt'), text); + git('add', '-A'); + git('commit', '-qm', text); + return git('rev-parse', 'HEAD'); + }; + const root = commit('root'); + const green = commit('проверенный'); + const cancelled = commit('прогон отменён'); + const head = commit('текущий пуш'); + + const runsFile = join(dir, 'runs.json'); + const out = join(dir, 'out.txt'); + const run = (runs) => { + writeFileSync(runsFile, JSON.stringify(runs)); + writeFileSync(out, ''); + const result = spawnSync(process.execPath, [ + SCRIPT, `--head=${head}`, `--merge-base=${root}`, `--runs=${runsFile}`, + ], { cwd: dir, encoding: 'utf8', env: { ...process.env, GITHUB_OUTPUT: out } }); + assert.equal(result.status, 0, result.stderr); + return { stdout: result.stdout, output: readFileSync(out, 'utf8') }; + }; + + const withGreen = run({ + workflow_runs: [ + { head_sha: green, conclusion: 'success' }, + { head_sha: cancelled, conclusion: 'cancelled' }, + // HEAD в списке зелёных быть не может, но даже если API его вернёт, + // сам себя коммит проверенным не объявляет. + { head_sha: head, conclusion: 'success' }, + ], + }); + assert.match(withGreen.output, new RegExp(`base=${green}\\n`)); + assert.match(withGreen.output, /proven=true/); + + const noGreen = run({ workflow_runs: [{ head_sha: cancelled, conclusion: 'cancelled' }] }); + assert.match(noGreen.output, new RegExp(`base=${root}\\n`)); + assert.match(noGreen.output, /proven=false/); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); diff --git a/test/validate-workflow.test.mjs b/test/validate-workflow.test.mjs index c575e066..e2e483d7 100644 --- a/test/validate-workflow.test.mjs +++ b/test/validate-workflow.test.mjs @@ -141,3 +141,29 @@ test('упавшая golden называет первопричину, а не // свидетеля и смысл сообщения перевернётся. assert.match(golden, /if: failure\(\) && steps\.fail_note\.outputs\.first == 'true'/); }); + +test('классификация опирается на завершённый прогон, а не на предыдущий пуш (#387)', () => { + const workflow = read('validate.yml'); + const changes = workflow.slice( + workflow.indexOf('\n changes:\n'), workflow.indexOf('\n reuse:\n'), + ); + // `github.event.before` — источник ложного «уже проверено»: прогон + // предыдущего пуша штатно отменяется следующим. Классификация читать его + // больше не имеет права. В процессном гейте он законен: там проверяются + // трейлеры именно отправленных коммитов, а не объём проверок. + // `before` остаётся ровно одной проверкой — жив он или переписан (#347). + // Базой диапазона он больше не служит: его прогон штатно отменяется. + assert.equal(/base="\$BEFORE_SHA"/.test(changes), false, + 'диапазон не имеет права опираться на голову предыдущего пуша (#387)'); + assert.match(changes, /git cat-file -e "\$BEFORE_SHA"/, + 'защита от force-push остаётся на месте (#347)'); + assert.match(changes, /node scripts\/classify-base\.mjs --head=/); + assert.match(changes, /actions: read/, 'чтение прогонов требует прав'); + // База считается только для push вне dev: у PR диапазон задан событием. + assert.match(changes, /if: github\.event_name != 'pull_request' && github\.ref != 'refs\/heads\/dev'/); + // Пустая база означает «доказательства нет» и обязана вести к полному + // прогону, а не к пустому диффу, который выглядел бы как «ничего не менялось». + const empty = changes.slice(changes.indexOf('if [ -z "$base" ]')); + assert.match(empty, /frontend=true\\nbackend=true\\nintegration=true/, + 'без базы классификация обязана раскрываться в полный прогон'); +});