From bdac4b4fdc88c7c3a48604163636a3c031830b63 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 13:57:21 +0300 Subject: [PATCH] =?UTF-8?q?ci:=20=D0=B7=D0=B0=D0=BA=D1=80=D0=B5=D0=BF?= =?UTF-8?q?=D0=B8=D1=82=D1=8C=20=D0=BE=D0=B1=D1=80=D0=B0=D0=B7=20=D1=80?= =?UTF-8?q?=D0=B0=D0=BD=D0=BD=D0=B5=D1=80=D0=B0,=20=D1=82=D0=B0=D0=B9?= =?UTF-8?q?=D0=BC=D0=B0=D1=83=D1=82=D1=8B=20job=20=D0=B8=20=D0=BD=D0=B5?= =?UTF-8?q?=D0=BA=D1=80=D1=83=D0=B3=D0=BB=D1=8B=D0=B5=20cron=20(#658)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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 Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd --- .github/workflows/_mutation-gate.yml | 14 ++- .github/workflows/_nightly.yml | 22 +++- .github/workflows/_process-metrics.yml | 2 +- .github/workflows/_process-reconcile.yml | 2 +- .github/workflows/_process-resume.yml | 2 +- .github/workflows/_process.yml | 11 +- .github/workflows/announce.yml | 3 +- .github/workflows/docs-screenshots.yml | 3 +- .github/workflows/mutation-gate.yml | 7 +- .github/workflows/nightly.yml | 4 +- .github/workflows/performance.yml | 5 +- .github/workflows/process-metrics.yml | 5 +- .github/workflows/publish-prerelease.yml | 10 +- .github/workflows/release-review.yml | 6 +- .github/workflows/release.yml | 19 ++-- .github/workflows/validate.yml | 41 +++++--- PROCESS.md | 2 +- docs/DEVELOPMENT.md | 30 ++++++ docs/TESTING.md | 2 +- scripts/workflow-jobs.mjs | 39 +++++++ test/mutation-gate.test.mjs | 2 +- test/workflow-hygiene.test.mjs | 125 +++++++++++++++++++++++ 22 files changed, 302 insertions(+), 54 deletions(-) create mode 100644 test/workflow-hygiene.test.mjs diff --git a/.github/workflows/_mutation-gate.yml b/.github/workflows/_mutation-gate.yml index 64189d81..feaf0fa2 100644 --- a/.github/workflows/_mutation-gate.yml +++ b/.github/workflows/_mutation-gate.yml @@ -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 diff --git a/.github/workflows/_nightly.yml b/.github/workflows/_nightly.yml index 10dfb655..a1701f4b 100644 --- a/.github/workflows/_nightly.yml +++ b/.github/workflows/_nightly.yml @@ -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 }} diff --git a/.github/workflows/_process-metrics.yml b/.github/workflows/_process-metrics.yml index 80c9c416..8ba0e103 100644 --- a/.github/workflows/_process-metrics.yml +++ b/.github/workflows/_process-metrics.yml @@ -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, а исполняется diff --git a/.github/workflows/_process-reconcile.yml b/.github/workflows/_process-reconcile.yml index 677b01c1..ba09c3d5 100644 --- a/.github/workflows/_process-reconcile.yml +++ b/.github/workflows/_process-reconcile.yml @@ -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 diff --git a/.github/workflows/_process-resume.yml b/.github/workflows/_process-resume.yml index ef125f8c..bd63cede 100644 --- a/.github/workflows/_process-resume.yml +++ b/.github/workflows/_process-resume.yml @@ -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 }} diff --git a/.github/workflows/_process.yml b/.github/workflows/_process.yml index d4568db8..201c52d4 100644 --- a/.github/workflows/_process.yml +++ b/.github/workflows/_process.yml @@ -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 diff --git a/.github/workflows/announce.yml b/.github/workflows/announce.yml index 59f68c47..c57092db 100644 --- a/.github/workflows/announce.yml +++ b/.github/workflows/announce.yml @@ -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 }} diff --git a/.github/workflows/docs-screenshots.yml b/.github/workflows/docs-screenshots.yml index 8f285065..bf81dc75 100644 --- a/.github/workflows/docs-screenshots.yml +++ b/.github/workflows/docs-screenshots.yml @@ -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: diff --git a/.github/workflows/mutation-gate.yml b/.github/workflows/mutation-gate.yml index e40bf53f..7886eeff 100644 --- a/.github/workflows/mutation-gate.yml +++ b/.github/workflows/mutation-gate.yml @@ -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 diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index af2f6caa..81a97e4c 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -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: diff --git a/.github/workflows/performance.yml b/.github/workflows/performance.yml index 287c60c1..e71a69da 100644 --- a/.github/workflows/performance.yml +++ b/.github/workflows/performance.yml @@ -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 diff --git a/.github/workflows/process-metrics.yml b/.github/workflows/process-metrics.yml index 0c9d10dc..3f5004e6 100644 --- a/.github/workflows/process-metrics.yml +++ b/.github/workflows/process-metrics.yml @@ -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: diff --git a/.github/workflows/publish-prerelease.yml b/.github/workflows/publish-prerelease.yml index 50424403..5b7015ac 100644 --- a/.github/workflows/publish-prerelease.yml +++ b/.github/workflows/publish-prerelease.yml @@ -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 diff --git a/.github/workflows/release-review.yml b/.github/workflows/release-review.yml index ad131044..aa909468 100644 --- a/.github/workflows/release-review.yml +++ b/.github/workflows/release-review.yml @@ -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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f488fa12..f63d0fef 100755 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 217c09f5..da2643ab 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -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: diff --git a/PROCESS.md b/PROCESS.md index be231357..1f074086 100644 --- a/PROCESS.md +++ b/PROCESS.md @@ -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 остаются обязательными. diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 3a8b4bfa..ccfbf11c 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -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 diff --git a/docs/TESTING.md b/docs/TESTING.md index 4fb41e9c..a93b1af4 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -50,7 +50,7 @@ identity и исход шага прогона, а отдельный агрегатор fail-closed отвергает смешанные, неполные или прерванные по таймауту evidence даже при частичном rerun; лог шарда считается зелёным только с итоговой строкой `поймано N из M`) -каждую ночь по расписанию (01:00 UTC); в +каждую ночь по расписанию (00:43 UTC); в релизном гейте он не участвует — проверяет тесты, а не продукт; отказ сам заводит issue с отчётом (#472, #513). С #620 ночь по расписанию на дереве, уже доказанном зелёным полным прогоном, шарды не гоняет: зелёный агрегатор diff --git a/scripts/workflow-jobs.mjs b/scripts/workflow-jobs.mjs index 225f4ce6..87e40d81 100644 --- a/scripts/workflow-jobs.mjs +++ b/scripts/workflow-jobs.mjs @@ -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]; diff --git a/test/mutation-gate.test.mjs b/test/mutation-gate.test.mjs index 06958292..3ab3d85d 100644 --- a/test/mutation-gate.test.mjs +++ b/test/mutation-gate.test.mjs @@ -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('перед стабильным релизом'), 'полный прогон — не шаг релиза'); }); diff --git a/test/workflow-hygiene.test.mjs b/test/workflow-hygiene.test.mjs new file mode 100644 index 00000000..41f3b627 --- /dev/null +++ b/test/workflow-hygiene.test.mjs @@ -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 }); + } +});