ci: classify against the last proven-green ancestor

Диапазон классификации брался от `github.event.before` — головы предыдущего
пуша. Это допущение «до этого уже проверено», и оно неверно ровно тогда,
когда прогон предыдущего пуша не завершился. А не завершается он штатно:
concurrency отменяет его следующим пушем.

На #86 (r5) это дало ложный зелёный: push 04da7eb1 тронул dist/** и
frontend/**, его прогон отменили через три минуты; следующий push fa146fb1
тронул только 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:
Matysh
2026-08-30 15:35:56 +03:00
parent 582d673a2d
commit 50851dc3f9
4 changed files with 337 additions and 18 deletions
+53 -18
View File
@@ -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