ci: a force-push runs everything — the classifier stops guessing a rewritten range (#347)

github.event.before dies with a force-push, and the merge-base fallback then
guessed a diff range: on issue/333 it reported two review-doc files while the
real diff touched custom_components/** — frontend and backend jobs silently
skipped and the run stayed success, the exact #171/#207 class of silent pass
that nearly hid a genuine backend regression from code review.

The classifier now distinguishes the two fallback cases instead of merging
them: a ZERO before is a genuinely new branch and keeps the merge-base
range; a NON-ZERO before that no longer exists is a rewritten history, and
the range is not provable — frontend/backend/integration all go true, with
a loud note in the step summary. A force-push is rare and almost always
follows a rebase, where the full run is what an honest signal costs.

The three branches of the decision are pinned by a workflow-contract unit
next to the existing performance-workflow contracts.

Issue: #347
User-Visible: no
This commit is contained in:
Codex
2026-08-28 10:19:37 +03:00
parent 918fc9e2fe
commit b573590a96
2 changed files with 43 additions and 2 deletions
+20 -2
View File
@@ -165,10 +165,28 @@ jobs:
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 переписан — полный прогон без классификации"
{
echo "### Классификация изменённых файлов"
echo "\`before\` (=$base) переписан 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" ] \
|| ! git cat-file -e "$base" 2>/dev/null; then
if [ -z "$base" ] || [ "$base" = "$zero" ]; then
git fetch -q origin dev
base=$(git merge-base origin/dev "$HEAD_SHA" || echo "$HEAD_SHA~1")
fi
+23
View File
@@ -46,3 +46,26 @@ test('full performance is isolated to stable, scheduled and manual entry points'
assert.ok(release.includes('if: ${{ !github.event.release.prerelease }}'));
assert.ok(release.includes('--workflow=performance.yml --label="Полные бенчмарки производительности"'));
});
test('#347: a rewritten before forces the full run instead of guessing the range', () => {
// Force-push kills github.event.before; the merge-base fallback then
// guessed a range that hid a real custom_components/** diff behind two doc
// files, and the heavy jobs silently skipped while the run stayed green —
// the #171/#207 class of silent pass. The contract: a non-zero before that
// no longer exists switches classification to an unconditional full run
// with a loud step-summary note, and the merge-base fallback remains ONLY
// for the genuinely new branch (zero before).
const workflow = readWorkflow('validate.yml');
const classify = workflow.slice(
workflow.indexOf('Классификация изменённых файлов'),
workflow.indexOf('reuse:'),
);
assert.ok(
/force-push[\s\S]*?frontend=true[\s\S]*?backend=true[\s\S]*?integration=true/.test(classify),
'мёртвый before обязан включать полный прогон, не merge-base-угадывание');
assert.ok(classify.includes('GITHUB_STEP_SUMMARY'),
'пропуск классификации обязан быть громким в summary');
const fallback = classify.slice(classify.indexOf('Новая ветка'));
assert.ok(!fallback.includes('cat-file'),
'merge-base-фолбэк остаётся только для нулевого before — без повторной проверки существования');
});