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
164 lines
9.7 KiB
JavaScript
164 lines
9.7 KiB
JavaScript
// #766: обвязка исполнения шагов workflow — shell как у раннера, без своих флагов.
|
||
//
|
||
// Тесты исполнения шагов гоняли тела `run:` под `bash -eo pipefail`, а раннер
|
||
// исполняет шаг без `shell:` под `bash -e {0}`. Защиту от упавшей левой части
|
||
// конвейера давала обвязка, а не шаг, и пропущенный в шаге pipefail тесты не
|
||
// видели. Здесь держится сама обвязка (test/helpers/workflow-step.mjs): выбор
|
||
// shell (шаг → job → workflow → не задан), команды раннера, исполнение файлом —
|
||
// и то, что ни один тест не исполняет шаг своими флагами bash.
|
||
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 { fileURLToPath } from 'node:url';
|
||
|
||
import { findStep, runStep, runnerArgv, workflowSteps } from './helpers/workflow-step.mjs';
|
||
|
||
const WORKFLOWS = fileURLToPath(new URL('../.github/workflows/', import.meta.url));
|
||
const TESTS = fileURLToPath(new URL('.', import.meta.url));
|
||
const hasBash = () => process.platform !== 'win32' && spawnSync('bash', ['--version']).status === 0;
|
||
|
||
const WORKFLOW = [
|
||
'name: x',
|
||
'on: push',
|
||
'defaults:',
|
||
' run:',
|
||
' shell: sh',
|
||
'jobs:',
|
||
' a:',
|
||
' defaults:',
|
||
' run:',
|
||
" shell: 'bash'",
|
||
' steps:',
|
||
' - name: own',
|
||
' shell: bash -x {0}',
|
||
' run: echo own',
|
||
' # комментарий между шагами',
|
||
' - name: from job',
|
||
' run: |',
|
||
' echo one',
|
||
'',
|
||
' echo two',
|
||
' - uses: actions/checkout@v4',
|
||
' b:',
|
||
' steps:',
|
||
' - id: from-workflow',
|
||
' run: >-',
|
||
' echo folded',
|
||
' line',
|
||
' - run: "echo \\"quoted\\""',
|
||
'',
|
||
].join('\n');
|
||
|
||
test('#766: shell шага — свой, иначе defaults job, иначе defaults workflow; соседний шаг не влияет', () => {
|
||
const steps = workflowSteps(WORKFLOW, 'синтетика');
|
||
assert.deepEqual(steps.map((s) => [s.job, s.name ?? s.id, s.shell, s.shellFrom]), [
|
||
['a', 'own', 'bash -x {0}', 'step'],
|
||
['a', 'from job', 'bash', 'job'],
|
||
['a', null, 'bash', 'job'],
|
||
['b', 'from-workflow', 'sh', 'workflow'],
|
||
['b', null, 'sh', 'workflow'],
|
||
]);
|
||
assert.deepEqual(steps.map((s) => s.run), ['echo own', 'echo one\n\necho two\n', null, 'echo folded line', 'echo "quoted"']);
|
||
// Без defaults — не задан; `shell:` соседа не переносится.
|
||
const plain = 'jobs:\n a:\n steps:\n - name: x\n shell: bash\n run: a\n - name: y\n run: b\n';
|
||
assert.deepEqual(workflowSteps(plain).map((s) => [s.name, s.shell, s.shellFrom]), [['x', 'bash', 'step'], ['y', null, null]]);
|
||
// Шаг ищется по подстроке (как маркеры тестов) и по полю.
|
||
assert.equal(findStep(WORKFLOW, ' echo two').name, 'from job');
|
||
assert.equal(findStep(WORKFLOW, { id: 'from-workflow' }).shell, 'sh');
|
||
assert.throws(() => findStep(WORKFLOW, ' b:\n'), /вне шагов/);
|
||
// Чего разбор не понимает — отказ, а не догадка.
|
||
assert.throws(() => workflowSteps('jobs:\n a:\n steps:\n - run: { x: 1 }\n'), /потоковый узел/);
|
||
assert.throws(() => workflowSteps('defaults: { run: { shell: bash } }\njobs: {}\n'), /defaults в одну строку/);
|
||
});
|
||
|
||
test('#766: команды раннера — bash -e без pipefail по умолчанию, pipefail только у shell: bash', () => {
|
||
const f = '/t/step.sh';
|
||
assert.deepEqual(runnerArgv(null, f), ['bash', '-e', f], 'не задан: bash -e {0}');
|
||
assert.deepEqual(runnerArgv('bash', f), ['bash', '--noprofile', '--norc', '-e', '-o', 'pipefail', f]);
|
||
assert.deepEqual(runnerArgv('sh', f), ['sh', '-e', f]);
|
||
assert.deepEqual(runnerArgv('python', f), ['python', f]);
|
||
assert.deepEqual(runnerArgv('bash -e {0}', f), ['bash', '-e', f], 'свой шаблон — как написан');
|
||
assert.deepEqual(runnerArgv('perl -w {0} --x', f), ['perl', '-w', f, '--x']);
|
||
assert.throws(() => runnerArgv('zsh', f), /без \{0\}/, 'не встроенный без {0} раннер отвергает');
|
||
assert.throws(() => runnerArgv('pwsh', f), /обвязка его не исполняет/);
|
||
assert.throws(() => runnerArgv("bash -c '{0}'", f), /кавычки/);
|
||
});
|
||
|
||
test('#766: левая часть конвейера падает — шаг без shell зелёный, как у раннера; pipefail даёт только шаг', (t) => {
|
||
if (!hasBash()) { t.skip('bash недоступен'); return; }
|
||
const dir = mkdtempSync(join(tmpdir(), 'hp-766-'));
|
||
t.after(() => rmSync(dir, { recursive: true, force: true }));
|
||
const env = { ...process.env, OUT: join(dir, 'out') };
|
||
const body = 'echo before\nfalse | tee "$OUT"\necho after\n';
|
||
const wf = (stepExtra = '', jobExtra = '') => `jobs:\n a:\n${jobExtra} steps:\n - name: pipe\n${stepExtra} run: |\n${
|
||
body.trimEnd().split('\n').map((line) => ` ${line}`).join('\n')}\n`;
|
||
const run = (text) => runStep(findStep(text, { name: 'pipe' }), undefined, { env });
|
||
|
||
// Отрицательный тест: раннер исполнит это как `bash -e` — код конвейера = код
|
||
// tee, шаг зелёный. Обвязка, добавившая pipefail, покажет здесь красный.
|
||
const silent = run(wf());
|
||
assert.equal(silent.status, 0, `обвязка добавила pipefail к шагу без shell:\n${silent.stderr}`);
|
||
assert.equal(silent.stdout, 'before\nafter\n', 'шаг дошёл до конца');
|
||
|
||
for (const [label, text] of [
|
||
['shell: bash шага', wf(' shell: bash\n')],
|
||
['defaults.run.shell: bash job', wf('', ' defaults:\n run:\n shell: bash\n')],
|
||
['set -o pipefail в теле', wf().replace(' echo before', ' set -o pipefail\n echo before')],
|
||
]) {
|
||
const r = run(text);
|
||
assert.notEqual(r.status, 0, `${label}: упавшая левая часть должна ронять шаг`);
|
||
assert.doesNotMatch(r.stdout, /after/, label);
|
||
}
|
||
// `-e` у шага по умолчанию есть: простая упавшая команда шаг останавливает.
|
||
const errexit = runStep({ shell: null }, 'false\necho after\n', { env });
|
||
assert.notEqual(errexit.status, 0);
|
||
assert.equal(errexit.stdout, '');
|
||
// Тело исполняется файлом, как `{0}` у раннера, а не через -c.
|
||
const file = runStep({ shell: null }, 'echo "$0"\n', { env });
|
||
assert.match(file.stdout, /step\.sh\n$/);
|
||
});
|
||
|
||
test('#766: каждый шаг каждого workflow разбирается и исполним обвязкой', () => {
|
||
let runs = 0;
|
||
for (const name of readdirSync(WORKFLOWS).filter((file) => /\.ya?ml$/.test(file)).sort()) {
|
||
for (const step of workflowSteps(readFileSync(join(WORKFLOWS, name), 'utf8'), name)) {
|
||
if (step.run === null) continue;
|
||
runs += 1;
|
||
assert.doesNotThrow(() => runnerArgv(step.shell, '/t/step.sh'), `${name}:${step.line}`);
|
||
}
|
||
}
|
||
// Свидетель разбора: тел `run:` — сотни, не ноль.
|
||
assert.ok(runs > 150, `тел run: ${runs}`);
|
||
});
|
||
|
||
/** Вызовы bash в тесте, которые исполняют тело своими флагами, а не обвязкой. */
|
||
function ownBashRuns(source) {
|
||
const found = [];
|
||
for (const m of source.matchAll(/\b(?:spawnSync|spawn|execFileSync|execFile)\(\s*'bash',\s*\[([^\]]*)\]/g)) {
|
||
const args = m[1];
|
||
if (/'-n'/.test(args)) continue; // bash -n — проверка синтаксиса, не исполнение
|
||
const flags = /'(?:-e|-eo|-o|--noprofile|--norc|pipefail)'/.test(args);
|
||
const computed = /'-c',\s*(?![\s'])/.test(args); // -c с вычисленным телом, а не литералом
|
||
if (flags || computed) found.push(m[0].replace(/\s+/g, ' '));
|
||
}
|
||
return found;
|
||
}
|
||
|
||
test('#766: ни один тест не исполняет тело своими флагами bash — только через обвязку', () => {
|
||
assert.deepEqual(ownBashRuns("spawnSync('bash', ['--noprofile', '--norc', '-eo', 'pipefail', '-c', script], {"), [
|
||
"spawnSync('bash', ['--noprofile', '--norc', '-eo', 'pipefail', '-c', script]",
|
||
]);
|
||
assert.equal(ownBashRuns("spawnSync('bash', ['-c', script])").length, 1, 'bash -c без -e — тоже не раннер');
|
||
assert.equal(ownBashRuns("execFileSync('bash', ['-eo', 'pipefail', '-c', `x | y`])").length, 1);
|
||
assert.equal(ownBashRuns("spawnSync('bash', ['-n', '-c', body])").length, 0, 'bash -n — синтаксис');
|
||
assert.equal(ownBashRuns("spawnSync('bash', ['-c', 'command -v git'])").length, 0, 'литерал теста — не шаг');
|
||
assert.equal(ownBashRuns("spawnSync('bash', [STAND, stand])").length, 0, 'скрипт по пути — не шаг');
|
||
// Этот файл несёт образцы нарушений в строках — его не сканируем.
|
||
const offenders = readdirSync(TESTS).filter((name) => name.endsWith('.test.mjs') && name !== 'workflow-step.test.mjs').sort()
|
||
.flatMap((name) => ownBashRuns(readFileSync(join(TESTS, name), 'utf8')).map((call) => `${name}: ${call}`));
|
||
assert.deepEqual(offenders, [], 'шаг исполнять runStep(findStep(…)) из test/helpers/workflow-step.mjs');
|
||
});
|