mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-07 23:19:14 +00:00
Thirteen test harnesses executed workflow `run:` bodies with their own bash
flags. Four of them used `bash -eo pipefail` "as in Actions", but the runner
executes a step without `shell:` as `bash -e {0}`: pipefail comes only from an
explicit `shell: bash` or from `set -o pipefail` in the body. The harness
supplied protection the step did not have, so a step that lost its pipefail
stayed green in tests (#729, #737, #751); the reverse also happened - the
#793 guard test was red only because of the harness flag.
test/helpers/workflow-step.mjs resolves the shell like the runner (step ->
jobs.<id>.defaults.run.shell -> workflow defaults.run.shell -> unset), maps it
to the runner's command lines (unset -> `bash -e {0}`, bash -> `bash
--noprofile --norc -e -o pipefail {0}`, sh, python, custom templates with
{0}), writes the body to a file and executes it by path. Unsupported YAML or
shells are refused loudly instead of guessed. All step-executing tests now go
through runStep(findStep(...)).
Witnesses: a step whose left pipeline side fails is green without a shell
(negative test) and red with `shell: bash`, job defaults or `set -o pipefail`;
the #751 executed test now also shows that the same real steps without their
pipefail line stay green, i.e. the test sees a removed pipefail; a guard fails
on any test that runs a step with its own bash flags. TESTING.md rule 7 names
the helper.
Issue: #766
User-Visible: no
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
129 lines
8.1 KiB
JavaScript
129 lines
8.1 KiB
JavaScript
// #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';
|
||
import { findStep, runStep } from './helpers/workflow-step.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();
|
||
// #766: shell шага — по правилам раннера (без `shell:` — `bash -e {0}`, а не голый bash).
|
||
const step = findStep(textOf('_nightly.yml'), { name: 'Сдвиг старта ночи против расписания' }, '_nightly.yml');
|
||
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 = runStep(step, 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 });
|
||
}
|
||
});
|