mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-02 04:38:55 +00:00
No workflow sets `shell:`, and GitHub runs such a step as `bash -e {0}`,
without pipefail: the exit code of `… | tee` is tee's, and a failing left
side passed silently. Three steps were unprotected:
- _process-resume.yml: an exception of process-resume.mjs (gh, API) left the
step green and the resume event was lost until process-reconcile;
- release-review.yml: a failed `prepare` went on with an incomplete
GITHUB_OUTPUT and proceed=true;
- validate.yml: a failed `classify-changes.mjs --heavy` left `heavy` empty,
heavy jobs were skipped and job `changes` stayed green.
Each gets `set -o pipefail` as the first line of `run` (validate.yml's step
becomes a block), following #727 and #472. test/workflow-pipefail.test.mjs
walks every .github/workflows/*.yml: a `| tee` line in `run` must follow
`set -[a-z]*o pipefail` or the step must have `shell: bash`; on the old tree
it names exactly the three places, and the _process-resume and validate
steps run on real bash under `bash -e` with a failing node.
ci-proof.mjs exports githubApiBase(env) (GITHUB_API_URL or
https://api.github.com, no trailing slash); githubCandidateTree,
loadGithubProofContext and release-gate's workflowRunsUrl take `apiBase`
with that default instead of the hardcoded host. night-red.mjs passes the
base directly and drops the fetch wrapper that rewrote the prefix. On
github.com the runner's GITHUB_API_URL is the same host, so behaviour there
does not change; archive_download_url stays as the API returned it.
The `mode` input for ship-review is out of scope (thin file in main, #716).
Thin files are not touched: _process-resume.yml is a body, validate.yml and
release-review.yml are not thin.
Issue: #751
User-Visible: no
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
146 lines
9.1 KiB
JavaScript
146 lines
9.1 KiB
JavaScript
// #751: у каждого `| tee` в workflow — pipefail.
|
||
//
|
||
// Ни один workflow не задаёт `shell:`, а шаг без него GitHub на Linux
|
||
// исполняет как `bash -e {0}` — без pipefail (`-eo pipefail` даёт только явный
|
||
// `shell: bash`). Код выхода `… | tee` — код tee, и падение левой части
|
||
// проходило молча: `_process-resume.yml` терял событие возобновления,
|
||
// `release-review.yml` шёл дальше с неполным GITHUB_OUTPUT и `proceed=true`,
|
||
// `validate.yml` оставлял `heavy` пустым, и тяжёлые job пропускались. Образец —
|
||
// #727 (`_ship-review.yml`) и #472 (`_mutation-gate.yml`). Контракт обходит все
|
||
// `.github/workflows/*.yml`: строка с `| tee` в `run` либо идёт после
|
||
// `set -o pipefail` (`set -[a-z]*o pipefail`), либо у шага стоит `shell: bash`.
|
||
import test from 'node:test';
|
||
import assert from 'node:assert/strict';
|
||
import { spawnSync } from 'node:child_process';
|
||
import { mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||
import { tmpdir } from 'node:os';
|
||
import { join } from 'node:path';
|
||
import { fileURLToPath } from 'node:url';
|
||
|
||
const WORKFLOWS = fileURLToPath(new URL('../.github/workflows/', import.meta.url));
|
||
|
||
const indentOf = (line) => line.length - line.trimStart().length;
|
||
const TEE = /(?<!\|)\|(?!\|)\s*tee\b/;
|
||
const PIPEFAIL = /\bset\s+-[A-Za-z]*o\s+pipefail\b/;
|
||
|
||
/**
|
||
* Все `run` файла: тело так, как его прочтёт YAML (блок `|`/`>` кончается на
|
||
* первой непустой строке с отступом не больше ключа), строка начала и `shell:`
|
||
* того же шага — ключ на том же отступе, что и `run`, в пределах элемента `- `.
|
||
*/
|
||
function runBlocks(text) {
|
||
const lines = text.split('\n');
|
||
const blocks = [];
|
||
lines.forEach((line, at) => {
|
||
const m = /^(\s*)(- )?run:\s*(.*)$/.exec(line);
|
||
if (!m) return;
|
||
const keyIndent = m[1].length + (m[2] ? 2 : 0);
|
||
const inline = !/^[|>][-+]?\s*(#.*)?$/.test(m[3]);
|
||
const body = [];
|
||
if (inline) body.push(m[3].replace(/^(['"])(.*)\1$/, '$2'));
|
||
else {
|
||
for (const next of lines.slice(at + 1)) {
|
||
if (next.trim() && indentOf(next) <= keyIndent) break;
|
||
body.push(next.trim() ? next.slice(keyIndent + 2) : '');
|
||
}
|
||
}
|
||
// Шаг — от своего `- ` (отступ ключа минус два) до первой строки левее ключей.
|
||
let start = at;
|
||
const item = new RegExp(`^ {${keyIndent - 2}}- `);
|
||
while (start > 0 && !item.test(lines[start])) start -= 1;
|
||
let end = at + 1;
|
||
while (end < lines.length && !(lines[end].trim() && indentOf(lines[end]) < keyIndent)) end += 1;
|
||
const keys = lines.slice(start, end).map((l, i) => (i === 0 ? l.replace(/^(\s*)- /, '$1 ') : l));
|
||
const shell = keys.find((l) => indentOf(l) === keyIndent && /^\s*shell:/.test(l))?.trim().slice('shell:'.length).trim() ?? '';
|
||
blocks.push({ line: at + 1, inline, body, shell });
|
||
});
|
||
return blocks;
|
||
}
|
||
|
||
/** Строки с `| tee`, перед которыми в том же `run` нет pipefail, а у шага — `shell: bash`. */
|
||
function teeWithoutPipefail(text) {
|
||
const found = [];
|
||
for (const block of runBlocks(text)) {
|
||
if (/^bash\s*$/.test(block.shell) || /pipefail/.test(block.shell)) continue;
|
||
let guarded = false;
|
||
block.body.forEach((raw, i) => {
|
||
const code = raw.trimStart().startsWith('#') ? '' : raw;
|
||
const tee = code.search(TEE);
|
||
if (tee >= 0 && !guarded && !PIPEFAIL.test(code.slice(0, tee))) {
|
||
found.push({ line: block.inline ? block.line : block.line + 1 + i, text: raw.trim() });
|
||
}
|
||
if (PIPEFAIL.test(code)) guarded = true;
|
||
});
|
||
}
|
||
return found;
|
||
}
|
||
|
||
const workflowFiles = () => readdirSync(WORKFLOWS).filter((name) => /\.ya?ml$/.test(name)).sort();
|
||
|
||
test('#751 AC1: разбор находит | tee без pipefail и пропускает защищённые', () => {
|
||
const step = (run, extra = '') => `jobs:\n a:\n steps:\n - name: x\n${extra} run: ${run}\n`;
|
||
const block = (...lines) => `|\n${lines.map((l) => ` ${l}`).join('\n')}`;
|
||
assert.equal(teeWithoutPipefail(step('node a.mjs | tee -a "$GITHUB_OUTPUT"')).length, 1, 'строка в одну строку');
|
||
assert.equal(teeWithoutPipefail(step(block('node a.mjs \\', ' --x=1 | tee out.txt'))).length, 1, 'блок без pipefail');
|
||
assert.equal(teeWithoutPipefail(step(block('set -o pipefail', 'node a.mjs | tee out.txt'))).length, 0);
|
||
assert.equal(teeWithoutPipefail(step(block('set -euo pipefail', 'node a.mjs | tee out.txt'))).length, 0, 'set -euo pipefail');
|
||
assert.equal(teeWithoutPipefail(step(block('node a.mjs | tee out.txt', 'set -o pipefail'))).length, 1, 'pipefail после tee не защищает');
|
||
assert.equal(teeWithoutPipefail(step(block('# set -o pipefail', 'node a.mjs | tee out.txt'))).length, 1, 'комментарий не защищает');
|
||
assert.equal(teeWithoutPipefail(step(block('# вывод идёт | tee в сводку', 'echo ok'))).length, 0, 'комментарий с | tee — не конвейер');
|
||
assert.equal(teeWithoutPipefail(step(block('a || tee x'))).length, 0, '|| — не конвейер');
|
||
assert.equal(teeWithoutPipefail(step(block('tee -a "$GITHUB_OUTPUT" < /tmp/x'))).length, 0, 'tee без конвейера');
|
||
assert.equal(teeWithoutPipefail(step('node a.mjs | tee out.txt', ' shell: bash\n')).length, 0, 'shell: bash даёт -eo pipefail');
|
||
assert.equal(teeWithoutPipefail(step('node a.mjs | tee out.txt', ' shell: bash -e {0}\n')).length, 1, 'свой shell без pipefail');
|
||
// shell соседнего шага — не этого.
|
||
const two = 'jobs:\n a:\n steps:\n - name: x\n shell: bash\n run: echo\n - name: y\n run: node a.mjs | tee out.txt\n';
|
||
assert.deepEqual(teeWithoutPipefail(two).map((f) => f.text), ['node a.mjs | tee out.txt']);
|
||
});
|
||
|
||
test('#751 AC1: у каждого | tee во всех workflow — pipefail; пять известных мест видны разбору', () => {
|
||
const tees = new Set();
|
||
const offenders = [];
|
||
for (const name of workflowFiles()) {
|
||
const text = readFileSync(join(WORKFLOWS, name), 'utf8');
|
||
if (runBlocks(text).some((block) => block.body.some((line) => !line.trimStart().startsWith('#') && TEE.test(line)))) tees.add(name);
|
||
for (const found of teeWithoutPipefail(text)) offenders.push(`${name}:${found.line}: ${found.text}`);
|
||
}
|
||
for (const name of ['_mutation-gate.yml', '_ship-review.yml', '_process-resume.yml', 'release-review.yml', 'validate.yml']) {
|
||
assert.ok(tees.has(name), `${name}: | tee не найден — разбор ослеп`);
|
||
}
|
||
assert.deepEqual(offenders, [], 'добавить `set -o pipefail` первой строкой run (образец #727, #472)');
|
||
});
|
||
|
||
const hasBash = () => process.platform !== 'win32' && spawnSync('bash', ['--version']).status === 0;
|
||
|
||
/** Тело `run` шага `name` файла `file` — как его исполнит раннер. */
|
||
function stepRun(file, name) {
|
||
const text = readFileSync(join(WORKFLOWS, file), 'utf8');
|
||
const at = text.split('\n').findIndex((line) => line === ` - name: ${name}` || line === ` - id: ${name}`);
|
||
assert.ok(at >= 0, `шаг «${name}» в ${file}`);
|
||
const block = runBlocks(text).find((b) => b.line > at + 1);
|
||
assert.ok(block, `у шага «${name}» есть run`);
|
||
return block.body.join('\n');
|
||
}
|
||
|
||
test('#751 AC1 на настоящем bash: упавший скрипт слева от | tee роняет шаг под bash -e, как у раннера', (t) => {
|
||
if (!hasBash()) { t.skip('bash недоступен'); return; }
|
||
const root = mkdtempSync(join(tmpdir(), 'hp-751-tee-'));
|
||
t.after(() => rmSync(root, { recursive: true, force: true }));
|
||
const bin = join(root, 'bin');
|
||
mkdirSync(bin);
|
||
// Подменённый node: печатает строку, как скрипт до исключения, и выходит 1.
|
||
writeFileSync(join(bin, 'node'), '#!/bin/sh\necho "action=partial"\necho "boom: $*" >&2\nexit 1\n', { mode: 0o755 });
|
||
for (const [file, name] of [['_process-resume.yml', 'Решить по маркеру ожидания и переставить S7'], ['validate.yml', 'heavy']]) {
|
||
const out = join(root, `${file}.out`);
|
||
writeFileSync(out, '');
|
||
// Шаг без `shell:` GitHub исполняет как `bash -e {0}`.
|
||
const r = spawnSync('bash', ['--noprofile', '--norc', '-e', '-c', stepRun(file, name)], {
|
||
cwd: root, encoding: 'utf8',
|
||
env: { ...process.env, PATH: `${bin}:${process.env.PATH}`, GITHUB_STEP_SUMMARY: out, GITHUB_OUTPUT: out },
|
||
});
|
||
assert.notEqual(r.status, 0, `${file} «${name}»: падение скрипта прошло зелёным шагом`);
|
||
assert.match(r.stderr, /boom: scripts\//, `${file}: упал именно скрипт шага`);
|
||
assert.equal(readFileSync(out, 'utf8'), 'action=partial\n', `${file}: tee по-прежнему пишет вывод`);
|
||
}
|
||
});
|