ci: закрепить образ раннера, таймауты job и некруглые cron (#658)

`ubuntu-latest` с 19.10.2026 переезжает на Ubuntu 26, а golden, скриншоты
документации и перф-бюджеты сняты на текущем образе: все 43 job на раннере
теперь явно на `ubuntu-24.04`, один образ на все workflow. 26 job получили
`timeout-minutes` по наблюдённой длительности с запасом; гейт релиза — 180,
больше суммы собственных ожиданий (60 + 60 + 45). Расписания ушли с круглых
минут (ночь 02:17, мутанты 00:43, метрики 05:23, полный перф 04:11), ночь
пишет в summary сдвиг старта и предупреждает, если он больше часа.

test/workflow-hygiene.test.mjs держит все три правила по тексту workflow
(разбор `parseJobSettings` в scripts/workflow-jobs.mjs) и исполняет шаг
сдвига старта настоящим bash; порядок осознанного подъёма образа —
docs/DEVELOPMENT.md.

Issue: #658
User-Visible: no
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
This commit is contained in:
Claude
2026-09-27 13:57:21 +03:00
parent 6fc789e4c8
commit bdac4b4fdc
22 changed files with 302 additions and 54 deletions
+9 -5
View File
@@ -52,7 +52,8 @@ jobs:
# commit/tree, а не самостоятельно читают dev в разное время.
material:
name: "Зафиксировать неизменяемый материал"
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
timeout-minutes: 10
outputs:
sha: ${{ steps.identity.outputs.sha }}
tree: ${{ steps.identity.outputs.tree }}
@@ -112,7 +113,7 @@ jobs:
name: "Мутанты: каждый обязан красить тесты (шард ${{ matrix.shard }} из 6)"
needs: material
if: needs.material.outputs.reuse != 'true'
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
strategy:
fail-fast: false
matrix:
@@ -208,7 +209,8 @@ jobs:
name: "Доказать единый material всех шардов"
needs: [material, mutants]
if: always() && needs.material.outputs.reuse != 'true'
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
@@ -239,7 +241,8 @@ jobs:
name: "Записать маркер зелёного прогона"
needs: [material, mutants, evidence]
if: needs.material.outputs.reuse != 'true' && needs.mutants.result == 'success' && needs.evidence.result == 'success'
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
@@ -275,7 +278,8 @@ jobs:
# является — её шарды пропущены намеренно. Любой другой пропуск шардов
# (упал material) по-прежнему заводит issue.
if: always() && github.event_name == 'schedule' && needs.material.outputs.reuse != 'true' && (needs.mutants.result != 'success' || needs.evidence.result != 'success')
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
timeout-minutes: 10
permissions:
contents: read
actions: read
+21 -1
View File
@@ -32,9 +32,29 @@ permissions:
jobs:
dispatch:
name: "Запустить Validate на dev с полным набором и дождаться результата"
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
timeout-minutes: 90
steps:
# #658: при плане 02:30 UTC ночь фактически стартовала в 07:42–08:05 и
# кончалась в рабочее время владельца. Сдвиг старта больше часа — видимое
# предупреждение, а не молча съеденная ночь. `github.event.schedule` —
# строка cron вызывающего `nightly.yml`; NOW_EPOCH подставляет только тест.
- name: "Сдвиг старта ночи против расписания"
if: github.event_name == 'schedule'
env:
SCHEDULE: ${{ github.event.schedule }}
run: |
set -euo pipefail
read -r minute hour _ <<< "$SCHEDULE"
now=${NOW_EPOCH:-$(date -u +%s)}
planned=$(date -u -d "@$now" +%F)
planned=$(date -u -d "$planned $hour:$minute" +%s)
[ "$planned" -le "$now" ] || planned=$((planned - 86400))
lag=$(( (now - planned) / 60 ))
echo "- Старт по расписанию \`$SCHEDULE\` (UTC): сдвиг $lag мин" >> "$GITHUB_STEP_SUMMARY"
if [ "$lag" -gt 60 ]; then
echo "::warning::ночной прогон стартовал на $lag мин позже расписания ($SCHEDULE UTC) — очередь расписаний GitHub (#658)"
fi
- env:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
+1 -1
View File
@@ -25,7 +25,7 @@ permissions:
jobs:
metrics:
name: "Снимок недели: issue, раунды, прогоны"
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
timeout-minutes: 15
steps:
# Код — из dev, как у reconcile: расписание читается из main, а исполняется
+1 -1
View File
@@ -19,7 +19,7 @@ permissions:
jobs:
reconcile:
name: "Один снимок S4/S7 без polling модели"
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
timeout-minutes: 10
concurrency:
group: process-reconcile
+1 -1
View File
@@ -28,7 +28,7 @@ jobs:
resume:
name: "Разбудить раунд, ждавший этот Validate"
if: github.event.workflow_run.event == 'workflow_dispatch' && startsWith(github.event.workflow_run.head_branch, 'issue/')
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
timeout-minutes: 10
concurrency:
group: process-resume-${{ github.event.workflow_run.head_branch }}
+6 -5
View File
@@ -48,7 +48,8 @@ jobs:
# Только статусные метки этапов ревью запускают конвейер (#499). Остальные
# события помечаются skipped и не занимают место в группе concurrency.
if: github.event.label.name == 'S4-spec-review' || github.event.label.name == 'S7-code-review'
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
timeout-minutes: 10
concurrency:
group: process-issue-${{ github.event.issue.number }}
cancel-in-progress: false
@@ -282,7 +283,7 @@ jobs:
issues: write
needs: guard
if: needs.guard.outputs.stage != ''
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
concurrency:
group: process-issue-${{ github.event.issue.number }}
cancel-in-progress: false
@@ -852,7 +853,7 @@ jobs:
issues: write
needs: [guard, prepare]
if: needs.prepare.outputs.proceed == 'true' && needs.prepare.outputs.reuse != 'true'
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
concurrency:
group: process-issue-${{ github.event.issue.number }}
cancel-in-progress: false
@@ -934,7 +935,7 @@ jobs:
- name: Установить Chromium
if: steps.pw.outputs.cache-hit != 'true'
# Без --with-deps: системные библиотеки Chromium предустановлены в
# образе ubuntu-latest, а apt при промахе кэша съедал минуты из бюджета
# образе раннера, а apt при промахе кэша съедал минуты из бюджета
# ревью и подолгу перебирал недоступное azure-зеркало (#175). Если
# библиотека когда-нибудь пропадёт из образа, Chromium не запустится с
# внятной ошибкой — тогда флаг вернуть.
@@ -1196,7 +1197,7 @@ jobs:
issues: write
needs: [guard, prepare, model_review]
if: always() && needs.guard.outputs.stage != ''
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
concurrency:
group: process-issue-${{ github.event.issue.number }}
cancel-in-progress: false
+2 -1
View File
@@ -43,7 +43,8 @@ jobs:
telegram:
name: Оповещение в Telegram (только стабильные)
if: ${{ github.event_name == 'workflow_dispatch' || (github.event_name == 'workflow_call' && inputs.prerelease == false) }}
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- name: Check out release notes for a reusable call
if: ${{ inputs.reusable == true }}
+2 -1
View File
@@ -31,7 +31,8 @@ permissions:
jobs:
capture:
name: Съёмка и сверка скриншот-индекса
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
timeout-minutes: 20
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
+4 -3
View File
@@ -16,9 +16,10 @@ on:
required: false
default: dev
schedule:
# Каждую ночь, 01:00 UTC (04:00 MSK) — после суток правок и до ночного
# полного Validate (nightly.yml, 02:30 UTC), чтобы не делить раннеры (#513).
- cron: '0 1 * * *'
# Каждую ночь, 00:43 UTC (03:43 MSK) — после суток правок и до ночного
# полного Validate (nightly.yml, 02:17 UTC), чтобы не делить раннеры (#513).
# Минута не круглая: такие старты GitHub сдвигает меньше (#658).
- cron: '43 0 * * *'
permissions:
contents: read
+3 -1
View File
@@ -9,8 +9,10 @@ name: Ночной полный прогон dev
# держит копии равными.
on:
# 02:17 UTC, не на круглой минуте: старты «ровно в час/полчаса» GitHub
# откладывает на часы (факт — 07:42–08:05 при плане 02:30, #658).
schedule:
- cron: '30 2 * * *'
- cron: '17 2 * * *'
workflow_dispatch: {}
permissions:
+3 -2
View File
@@ -10,7 +10,8 @@ on:
- ".github/workflows/**"
- "docs/**"
schedule:
- cron: "0 4 * * 1"
# Минута не круглая (#658): доходит до main вместе со стабильным релизом.
- cron: "11 4 * * 1"
workflow_dispatch:
inputs:
comparison_ref:
@@ -31,7 +32,7 @@ jobs:
# Every profile keeps base and candidate sequential on one hosted runner.
# Independent profile pairs run in parallel: cross-profile timing is never
# compared, while serialising all profile pairs cannot fit the job timeout.
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
timeout-minutes: 60
strategy:
fail-fast: false
+3 -2
View File
@@ -10,8 +10,9 @@ name: Метрики процесса
on:
schedule:
# Понедельник 05:00 UTC — после ночных прогонов, до рабочего дня.
- cron: '0 5 * * 1'
# Понедельник 05:23 UTC — после ночных прогонов, до рабочего дня; минута
# не круглая, чтобы не стоять в очереди «ровных» расписаний (#658).
- cron: '23 5 * * 1'
workflow_dispatch:
inputs:
days:
+7 -3
View File
@@ -21,7 +21,9 @@ concurrency:
jobs:
gate:
name: "Гейт: зелёная Проверка и релизный контракт"
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
# Больше ожидания зелёного Validate в release-gate.mjs (до 60 мин) (#658).
timeout-minutes: 75
outputs:
sha: ${{ steps.candidate.outputs.sha }}
tag: ${{ steps.candidate.outputs.tag }}
@@ -122,7 +124,8 @@ jobs:
publish:
name: Публикация тега и релиза
needs: gate
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
timeout-minutes: 20
outputs:
url: ${{ steps.verify.outputs.url }}
newly_published: ${{ steps.release.outputs.newly_published }}
@@ -289,7 +292,8 @@ jobs:
close-merged:
name: Закрытие вошедших issue
needs: [gate, publish]
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
timeout-minutes: 15
permissions:
contents: read
actions: read
+3 -3
View File
@@ -46,7 +46,7 @@ concurrency:
jobs:
prepare:
name: "Ревью релиза: вход линии"
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
timeout-minutes: 10
permissions:
contents: read
@@ -110,7 +110,7 @@ jobs:
name: "Ревью релиза: работа модели"
needs: prepare
if: needs.prepare.outputs.proceed == 'true'
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
timeout-minutes: 60
# Недоверенная стадия. Прав на запись нет никаких: ни в репозиторий, ни в
# issue. Документ публикует `publish`; находки в issue превращает владелец.
@@ -258,7 +258,7 @@ jobs:
publish:
name: "Ревью релиза: документ в dev"
needs: [prepare, model_review]
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
timeout-minutes: 10
permissions:
contents: read
+13 -6
View File
@@ -48,7 +48,8 @@ jobs:
name: "Кандидат: точный SHA, режим и черновик"
# Публикация беты руками — не наш случай: у бет свой staged-путь.
if: ${{ github.event_name == 'workflow_dispatch' || !github.event.release.prerelease }}
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
timeout-minutes: 10
outputs:
sha: ${{ steps.resolve.outputs.sha }}
tag: ${{ steps.resolve.outputs.tag }}
@@ -142,7 +143,7 @@ jobs:
needs: candidate
if: ${{ needs.candidate.outputs.prerelease != 'true' }}
continue-on-error: true
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
timeout-minutes: 5
permissions:
actions: write
@@ -164,7 +165,10 @@ jobs:
gate:
name: "Гейт: контракт, Validate, Full Performance и E2E на точном SHA"
needs: candidate
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
# Больше суммы собственных ожиданий job: Validate и Full Performance ждутся
# до 60 мин каждый (release-gate.mjs), E2E — до 45 (e2e-gate.mjs): 165 < 180 (#658).
timeout-minutes: 180
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
@@ -234,7 +238,8 @@ jobs:
stage:
name: "Сборка: ассеты, SHA256SUMS и загрузка в черновик"
needs: [candidate, gate]
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
timeout-minutes: 30
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
@@ -374,7 +379,8 @@ jobs:
publish:
name: "Публикация и сверка публичных байтов"
needs: [candidate, gate, stage]
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
timeout-minutes: 15
outputs:
url: ${{ steps.verify.outputs.url }}
name: ${{ steps.verify.outputs.name }}
@@ -474,7 +480,8 @@ jobs:
# make that distribution failure impossible to miss in the release run.
if: ${{ needs.candidate.outputs.prerelease == 'true' }}
needs: [candidate, publish]
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- name: Verify the published tag is the prerelease HACS will discover
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
+26 -15
View File
@@ -50,7 +50,8 @@ jobs:
# действовать — на уровне шагов, с той же гранулярностью в логе.
preflight:
name: "Предполёт: документация, провенанс, процесс"
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
timeout-minutes: 15
# `actions: read` — чтение списка прогонов Validate (#388), `issues: read` —
# проверка 8 процессного гейта. Права перечислены явно, потому что job
# обращается к API сверх содержимого репозитория.
@@ -247,7 +248,8 @@ jobs:
# там делает код-ревью, которое гоняет гейты само (#127).
changes:
name: Классификация изменённых файлов
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
timeout-minutes: 10
# Чтение списка прогонов Validate — единственное, что этой job нужно сверх
# содержимого репозитория (#387).
permissions:
@@ -406,7 +408,8 @@ jobs:
# performance_smoke включает набор, иначе glow-only результат засчитался бы
# прогону, которому нужен изометрический профиль.
needs: changes
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
timeout-minutes: 10
outputs:
smoke: ${{ steps.probe.outputs.smoke }}
golden: ${{ steps.probe.outputs.golden }}
@@ -555,7 +558,8 @@ jobs:
name: "HACS: валидация репозитория"
needs: changes
if: needs.changes.outputs.integration == 'true'
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- name: HACS validation
@@ -567,7 +571,8 @@ jobs:
name: "Hassfest: манифест интеграции"
needs: changes
if: needs.changes.outputs.integration == 'true'
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- name: Hassfest validation
@@ -598,7 +603,7 @@ jobs:
fail-fast: false
matrix:
shard: [1, 2, 3, 4, 5, 6]
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
timeout-minutes: 30
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
@@ -727,7 +732,8 @@ jobs:
name: "Фронтенд: типы, юниты, мутанты, синхрон бандла"
needs: changes
if: needs.changes.outputs.frontend == 'true'
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
timeout-minutes: 40
steps:
# Полная история без блобов (#342): гейт «новый код не добавляет any»
# diff-aware, ему нужен диапазон, а содержимое старых ревизий — нет.
@@ -837,7 +843,7 @@ jobs:
name: "Бандл головы dev для стенда"
needs: frontend
if: github.event_name == 'push' && github.ref == 'refs/heads/dev' && needs.frontend.result == 'success'
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
timeout-minutes: 10
continue-on-error: true
permissions:
@@ -867,7 +873,7 @@ jobs:
# Gated on `frontend` so a typecheck failure does not burn browser minutes.
needs: [changes, frontend, reuse]
if: needs.changes.outputs.heavy == 'true' && needs.reuse.outputs.smoke != 'true'
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
timeout-minutes: 20
# Смоки шардируются: последовательный прогон занимал ~7.5 минут и был
# критическим путём всего Validate. Три шарда режут его примерно вдвое;
@@ -974,7 +980,8 @@ jobs:
name: "Смоки: все шарды зелёные"
needs: [changes, smoke, reuse]
if: needs.changes.outputs.heavy == 'true' && needs.reuse.outputs.smoke != 'true'
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- name: Записать маркер успеха
run: |
@@ -998,7 +1005,8 @@ jobs:
# inexpensive and catches a different class of regressions than timings.
needs: [changes, frontend, reuse]
if: needs.changes.outputs.heavy == 'true' && needs.reuse.outputs.golden != 'true'
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
timeout-minutes: 45
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
@@ -1100,7 +1108,7 @@ jobs:
# prereleases. The expensive same-runner comparison lives in performance.yml.
needs: [changes, frontend, reuse]
if: needs.changes.outputs.heavy == 'true' && needs.reuse.outputs.performance_smoke != 'true'
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
# 15 минут не хватало, когда установка браузера шла через apt: замер
# начинался на исходе окна (#206). Запас на холодный кэш — при попадании
# job укладывается в те же минуты, что и раньше. Диффозависимые профили
@@ -1196,7 +1204,8 @@ jobs:
name: "Геометрия: TS/Python parity исполнена"
needs: [changes, reuse]
if: needs.changes.outputs.geometry_parity == 'true' && needs.reuse.outputs.geometry_parity != 'true'
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
timeout-minutes: 15
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
@@ -1230,7 +1239,8 @@ jobs:
name: "Бэкенд: pytest в Home Assistant"
needs: [changes, reuse]
if: needs.changes.outputs.backend == 'true' && needs.reuse.outputs.backend != 'true'
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
timeout-minutes: 30
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
# Browser fixtures are generated by their real ESM factories and then
@@ -1316,7 +1326,8 @@ jobs:
name: "Доказательство выполненных проверок"
if: always()
needs: [preflight, changes, reuse, hacs, hassfest, changed_mutants, frontend, smoke, smoke_done, golden, performance_smoke, geometry_parity, backend]
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
+1 -1
View File
@@ -1071,7 +1071,7 @@ Matysh/houseplan-card/.github/workflows/_<имя>.yml@dev` с `secrets: inherit`
мутантов не запрашивают** (#601, решение владельца 20.09): мутационный гейт
проверяет тесты, а не продукт (#513), к бете каждая задача прогнана им дважды —
на ревью и на слитом после ребейза кандидате, — а ночью идёт полный реестр
(`mutation-gate.yml`, 01:00 UTC). Релизный гейт (#541) требует полного Validate,
(`mutation-gate.yml`, 00:43 UTC). Релизный гейт (#541) требует полного Validate,
но не mutant-jobs; для ревью и слияния шесть исполненных mutant-jobs остаются
обязательными.
+30
View File
@@ -479,6 +479,36 @@ Two of these are branches upstream, not releases — `home-assistant/actions`
branch head was read. They have no tags to follow; the only honest record is
"this commit, read on this day".
## Moving the runner image (#658)
Every job that runs on a GitHub runner names an explicit image version in
`runs-on:`, and all workflows name the same one; `test/workflow-hygiene.test.mjs`
rejects a floating label such as `ubuntu-latest` and any drift between files.
The reason is the evidence tied to the image: golden baselines, documentation
screenshots and performance budgets were captured on it with the pinned
Playwright/Chromium (#455, #557), and Chromium's system libraries come from the
image itself (the install steps deliberately skip `--with-deps`). A floating
label moves under that evidence — `ubuntu-latest` becomes Ubuntu 26 from
2026-10-19 (actions/runner-images#14748) — and would turn a day with no change
into mass golden `different` and a red performance smoke.
Move the image on purpose, as its own infra issue:
1. change `runs-on:` in every workflow in one commit;
2. capture golden in CI on the new image and accept the differences with
review (`npm run golden:accept -- --reviewed …`), re-capture the documentation
screenshots (`demo/docs/capture.mjs`);
3. run the full Validate and `performance.yml`, and recalibrate a budget only
with measured evidence next to the number (the #483 and #675 pattern);
4. mirror the thin callers into `main` (see `workflow_sync` in `validate.yml`)
and remember that `release.yml`, `performance.yml` and the other files `main`
executes pick the new image up only with the next stable promotion.
The same test requires `timeout-minutes` on every runner job (at most 180; the
default is 360) and keeps `cron` off minutes 0/15/30/45, which GitHub's
scheduler delays by hours. A job that waits for other runs, such as the release
gate, gets a timeout above the sum of its own waits and says so in a comment.
## What the review model is allowed to do (#556)
The `model_review` job is the only untrusted stage of the review pipeline: it
+1 -1
View File
@@ -50,7 +50,7 @@
identity и исход шага прогона, а отдельный агрегатор fail-closed отвергает
смешанные, неполные или прерванные по таймауту evidence даже при частичном
rerun; лог шарда считается зелёным только с итоговой строкой `поймано N из M`)
каждую ночь по расписанию (01:00 UTC); в
каждую ночь по расписанию (00:43 UTC); в
релизном гейте он не участвует — проверяет тесты, а не продукт; отказ сам
заводит issue с отчётом (#472, #513). С #620 ночь по расписанию на дереве, уже
доказанном зелёным полным прогоном, шарды не гоняет: зелёный агрегатор
+39
View File
@@ -125,6 +125,45 @@ export function parseWorkflowJobs(text, file = 'workflow') {
return jobs;
}
/**
* Скалярные ключи уровня job для любого workflow (#658): id →
* { runsOn, timeoutMinutes, uses }. Матрицу и шаги не разбирает — только
* строки `ключ: значение` на четырёх пробелах, поэтому годится и для файлов с
* блочными матрицами, которые `parseWorkflowJobs` честно отвергает.
*/
export function parseJobSettings(text, file = 'workflow') {
const lines = String(text).split(/\r?\n/);
const start = lines.findIndex((line) => /^jobs:\s*(#.*)?$/.test(line));
if (start < 0) throw new Error(`${file}: no top-level "jobs:" key`);
const jobs = new Map();
let job = null;
for (let i = start + 1; i < lines.length; i += 1) {
const line = lines[i];
if (isBlank(line)) continue;
const indent = indentOf(line);
const where = `${file}:${i + 1}`;
if (indent === 0) break;
const key = line.trim().match(/^([A-Za-z0-9_-]+):(.*)$/);
if (indent === 2) {
if (!key || key[2].trim()) throw new Error(`${where}: expected a job id, got ${JSON.stringify(line.trim())}`);
if (jobs.has(key[1])) throw new Error(`${where}: duplicate job id ${key[1]}`);
job = { id: key[1], runsOn: null, timeoutMinutes: null, uses: null };
jobs.set(job.id, job);
continue;
}
if (!job) throw new Error(`${where}: content before the first job id`);
if (indent !== 4 || !key) continue;
const value = unquote(key[2], where);
if (key[1] === 'runs-on') job.runsOn = value;
else if (key[1] === 'timeout-minutes') {
if (!/^\d+$/.test(value)) throw new Error(`${where}: job ${job.id}: timeout-minutes must be a literal integer, got ${JSON.stringify(value)}`);
job.timeoutMinutes = Number(value);
} else if (key[1] === 'uses') job.uses = value;
}
if (!jobs.size) throw new Error(`${file}: "jobs:" has no jobs`);
return jobs;
}
/** Неизменная часть имени: всё до первого `${{`. У матричной job это общий префикс её экземпляров. */
export const staticNamePrefix = (name) => String(name).split('${{')[0];
+1 -1
View File
@@ -294,7 +294,7 @@ const validateWorkflowText = readWorkflowFile(
);
test('#513 AC1: полный мутационный прогон идёт каждую ночь, не раз в неделю и не перед релизом', () => {
assert.match(mutationCaller, /- cron: '0 1 \* \* \*'/, 'ежедневно 01:00 UTC');
assert.match(mutationCaller, /- cron: '43 0 \* \* \*'/, 'ежедневно 00:43 UTC (#658: минута не круглая)');
assert.ok(!/cron: '[^']*\* [0-6]'/.test(mutationCaller), 'недельного расписания (день недели) быть не должно');
assert.ok(!mutationWorkflow.includes('перед стабильным релизом'), 'полный прогон — не шаг релиза');
});
+125
View File
@@ -0,0 +1,125 @@
// #658: гигиена обвязки CI, которую иначе ломает время, а не правка.
//
// 1. Образ раннера закреплён. `ubuntu-latest` с 19.10.2026 переезжает на
// Ubuntu 26 (actions/runner-images#14748), а golden-эталоны, скриншоты
// документации и перф-бюджеты сняты на текущем образе с пинами
// Playwright/Chromium (#455, #557): смена образа «под ногами» дала бы
// массовый `different` и красный перф без единой правки. Образ один на все
// workflow и поднимается осознанно — docs/DEVELOPMENT.md, «Moving the
// runner image».
// 2. У каждой job на раннере свой timeout-minutes: по умолчанию GitHub держит
// зависшую job 360 минут и занимает раннер, нужный очереди ревью.
// 3. Расписания не на круглых минутах: старты «ровно в час» GitHub сдвигает
// на часы (ночь с планом 02:30 стартовала в 07:42–08:05).
import test from 'node:test';
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import { mkdtempSync, readFileSync, readdirSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { parseJobSettings } from '../scripts/workflow-jobs.mjs';
const WORKFLOWS = new URL('../.github/workflows/', import.meta.url);
const files = readdirSync(WORKFLOWS).filter((name) => name.endsWith('.yml')).sort();
const textOf = (name) => readFileSync(new URL(name, WORKFLOWS), 'utf8');
const runnerJobs = files.flatMap((file) => [...parseJobSettings(textOf(file), file).values()]
.filter((job) => !job.uses)
.map((job) => ({ ...job, file })));
test('#658: разбор job-ключей видит каждый workflow и отличает вызов тела от job на раннере', () => {
assert.ok(files.length >= 10, `workflow найдено: ${files.length}`);
assert.ok(runnerJobs.length >= 40, `job на раннере: ${runnerJobs.length}`);
const jobs = parseJobSettings([
'jobs:',
' body:',
' uses: owner/repo/.github/workflows/_x.yml@dev # тело',
' work:',
' name: Работа',
" runs-on: 'ubuntu-24.04' # образ",
' timeout-minutes: 12',
' strategy:',
' matrix:',
' profile:',
' - a',
' steps:',
' - run: echo "runs-on: ubuntu-latest"',
].join('\n'), 'fixture');
assert.deepEqual(jobs.get('body'), { id: 'body', runsOn: null, timeoutMinutes: null, uses: 'owner/repo/.github/workflows/_x.yml@dev' });
assert.deepEqual(jobs.get('work'), { id: 'work', runsOn: 'ubuntu-24.04', timeoutMinutes: 12, uses: null });
assert.throws(() => parseJobSettings('jobs:\n a:\n timeout-minutes: ${{ inputs.t }}\n', 'fixture'),
/timeout-minutes must be a literal integer/);
});
test('#658: образ раннера закреплён явной версией и один на все workflow', () => {
const floating = runnerJobs.filter((job) => !/^ubuntu-\d{2}\.\d{2}$/.test(job.runsOn ?? ''));
assert.deepEqual(floating.map((job) => `${job.file}:${job.id} runs-on=${job.runsOn}`), [],
'плавающая метка (ubuntu-latest) или не-Linux образ: смена образа под ногами = массовый golden different');
const images = [...new Set(runnerJobs.map((job) => job.runsOn))];
assert.equal(images.length, 1, `образы разошлись: ${images.join(', ')} — поднимайте образ во всех workflow разом`);
const development = readFileSync(new URL('../docs/DEVELOPMENT.md', import.meta.url), 'utf8');
assert.match(development, /^## Moving the runner image \(#658\)$/m, 'порядок подъёма образа записан');
});
test('#658: у каждой job на раннере есть timeout-minutes, и он не выше трёх часов', () => {
const missing = runnerJobs.filter((job) => job.timeoutMinutes === null);
assert.deepEqual(missing.map((job) => `${job.file}:${job.id}`), [],
'без timeout-minutes GitHub держит зависшую job 360 минут');
const outOfRange = runnerJobs.filter((job) => job.timeoutMinutes < 1 || job.timeoutMinutes > 180);
assert.deepEqual(outOfRange.map((job) => `${job.file}:${job.id}=${job.timeoutMinutes}`), []);
});
test('#658: расписания cron не стоят на круглых минутах', () => {
const schedules = files.flatMap((file) => [...textOf(file).matchAll(/^\s*- cron:\s*['"]([^'"]+)['"]/gm)]
.map((match) => ({ file, cron: match[1] })));
assert.ok(schedules.length >= 5, `расписаний найдено: ${schedules.length}`);
const round = schedules.filter(({ cron }) => cron.split(/\s+/)[0].split(',')
.some((minute) => !/^\d+$/.test(minute) || Number(minute) % 15 === 0));
assert.deepEqual(round.map(({ file, cron }) => `${file}: ${cron}`), [],
'минута 0/15/30/45 или шаблон — очередь «ровных» расписаний GitHub');
});
// Шаг исполняется настоящим bash по тексту из workflow: проверяется то, что
// запустит раннер, а не пересказ логики.
const lagStep = () => {
const lines = textOf('_nightly.yml').split('\n');
const nameAt = lines.findIndex((line) => line.includes('name: "Сдвиг старта ночи против расписания"'));
assert.ok(nameAt >= 0, 'шаг сдвига старта есть в _nightly.yml');
const runAt = lines.findIndex((line, i) => i > nameAt && /^\s+run: \|\s*$/.test(line));
const runIndent = lines[runAt].length - lines[runAt].trimStart().length;
const body = [];
for (let i = runAt + 1; i < lines.length; i += 1) {
const line = lines[i];
if (line.trim() && line.length - line.trimStart().length <= runIndent) break;
body.push(line.slice(runIndent + 2));
}
return body.join('\n');
};
test('#658: шаг сдвига старта ночи предупреждает при сдвиге больше часа', { skip: process.platform === 'win32' && 'нужен GNU bash и date' }, () => {
const script = lagStep();
const dir = mkdtempSync(join(tmpdir(), 'hp-658-lag-'));
try {
const run = (schedule, nowIso) => {
const summary = join(dir, `summary-${nowIso.replace(/\W/g, '')}.md`);
const result = spawnSync('bash', ['-c', script], {
encoding: 'utf8',
env: { ...process.env, SCHEDULE: schedule, NOW_EPOCH: String(Date.parse(nowIso) / 1000), GITHUB_STEP_SUMMARY: summary },
});
assert.equal(result.status, 0, result.stderr);
return { out: result.stdout, summary: readFileSync(summary, 'utf8') };
};
const late = run('17 2 * * *', '2026-09-27T07:47:00Z');
assert.match(late.summary, /сдвиг 330 мин/);
assert.match(late.out, /^::warning::ночной прогон стартовал на 330 мин позже/m);
const onTime = run('17 2 * * *', '2026-09-27T02:40:00Z');
assert.match(onTime.summary, /сдвиг 23 мин/);
assert.doesNotMatch(onTime.out, /::warning::/);
assert.doesNotMatch(run('17 2 * * *', '2026-09-27T03:17:00Z').out, /::warning::/, 'ровно 60 мин — ещё не предупреждение');
assert.match(run('17 2 * * *', '2026-09-27T03:18:00Z').out, /::warning::/, '61 мин — уже предупреждение');
const pastMidnight = run('50 23 * * *', '2026-09-28T00:10:00Z');
assert.match(pastMidnight.summary, /сдвиг 20 мин/, 'план вчерашнего вечера, старт после полуночи');
} finally {
rmSync(dir, { recursive: true, force: true });
}
});