Files
houseplan-card/test/workflow-pipefail.test.mjs
T
Claudeandclaude[bot] 25001ef7ab fix(ci): pipefail before every | tee, API base from GITHUB_API_URL (#751)
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
2026-10-01 14:31:06 +00:00

146 lines
9.1 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// #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 по-прежнему пишет вывод`);
}
});