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
+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 });
}
});