mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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, а исполняется
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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 остаются
|
||||
обязательными.
|
||||
|
||||
|
||||
@@ -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
@@ -50,7 +50,7 @@
|
||||
identity и исход шага прогона, а отдельный агрегатор fail-closed отвергает
|
||||
смешанные, неполные или прерванные по таймауту evidence даже при частичном
|
||||
rerun; лог шарда считается зелёным только с итоговой строкой `поймано N из M`)
|
||||
каждую ночь по расписанию (01:00 UTC); в
|
||||
каждую ночь по расписанию (00:43 UTC); в
|
||||
релизном гейте он не участвует — проверяет тесты, а не продукт; отказ сам
|
||||
заводит issue с отчётом (#472, #513). С #620 ночь по расписанию на дереве, уже
|
||||
доказанном зелёным полным прогоном, шарды не гоняет: зелёный агрегатор
|
||||
|
||||
@@ -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];
|
||||
|
||||
|
||||
@@ -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('перед стабильным релизом'), 'полный прогон — не шаг релиза');
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user