test(harness): run workflow steps the way the runner does (#766)

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
This commit is contained in:
Claude
2026-10-06 23:05:58 +00:00
committed by claude[bot]
parent 32334a173a
commit cb97274ea7
16 changed files with 579 additions and 72 deletions
+5
View File
@@ -44,6 +44,11 @@ golden, а то, чего автоматика не видит, собрано
`c._roomDialog = …`, зелёный и при сломанной кнопке, — это тот же «ничего не
проверено», что в правилах 1–5. Новые записи держит гейт
`no-new-private-writes`, остальное — ревью (раздел ниже).
7. **Шаг workflow исполняется так, как его исполнит раннер** (#766):
`runStep(findStep(…))` из `test/helpers/workflow-step.mjs` — shell шага, job
или workflow, без `shell:` — `bash -e {0}`. Свой `-eo pipefail` обвязки
даёт защиту, которой в шаге нет, и убранный из шага pipefail тест не видит.
Держит `test/workflow-step.test.mjs`.
Проверка: `node scripts/mutation-gate.mjs --check` — якоря патчей живы;
полный прогон — workflow `mutation-gate.yml` (десять чересполосных шардов
+276
View File
@@ -0,0 +1,276 @@
// Шаг workflow — так, как его исполняет раннер GitHub Actions (#766).
//
// Обвязки тестов гоняли тела `run:` под `bash -eo pipefail` «как у Actions»,
// а раннер исполняет шаг без `shell:` под `bash -e {0}`: пропущенный в шаге
// `set -o pipefail` тест не видел — защиту давала сама обвязка (#729, #737,
// #751). Здесь shell выбирается так же, как у раннера, и флагов от себя
// обвязка не добавляет. Порядок выбора:
//
// 1. `shell:` шага;
// 2. `jobs.<job_id>.defaults.run.shell`;
// 3. `defaults.run.shell` workflow;
// 4. не задан.
//
// Команды — таблица `jobs.<job_id>.steps[*].shell` синтаксиса workflow
// (https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsshell)
// и `_defaultArguments` раннера (https://github.com/actions/runner,
// src/Runner.Worker/Handlers/ScriptHandlerHelpers.cs); не Windows:
//
// не задан → bash -e {0} журнал шага: `shell: /usr/bin/bash -e {0}`
// bash → bash --noprofile --norc -e -o pipefail {0}
// sh → sh -e {0}
// python → python {0}
// иначе → «команда [опции] {0} [опции]» как написано: первое слово —
// команда, `{0}` — путь файла; строку без `{0}` раннер отвергает.
//
// `pipefail` даёт только явный `shell: bash` — либо `set -o pipefail` в теле.
// Тело пишется во временный файл и исполняется по пути: раннер тоже пишет
// `$RUNNER_TEMP/<uuid>.sh`, а не передаёт текст через `-c`.
//
// YAML разбирается построчно, как в соседних тестах: блочные отображения и
// списки, скаляры в строку и блочные `|`/`>`. Чего разбор не понимает
// (потоковые `{…}`/`[…]`, якоря, кавычки на несколько строк), то он
// отвергает с номером строки, а не угадывает.
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
/** Встроенные shell раннера; ключ — значение `shell:`. */
export const RUNNER_SHELLS = Object.freeze({
bash: 'bash --noprofile --norc -e -o pipefail {0}',
sh: 'sh -e {0}',
python: 'python {0}',
});
/** Шаг без `shell:` на Linux (bash найден в PATH). */
export const RUNNER_DEFAULT_SHELL = 'bash -e {0}';
/** Встроенные PowerShell и cmd: обвязка их не исполняет — отказ, а не подмена bash. */
const FOREIGN_SHELLS = new Set(['pwsh', 'powershell', 'cmd']);
const indentOf = (line) => line.length - line.trimStart().length;
const blank = (line) => line.trim() === '';
const comment = (line) => line.trimStart().startsWith('#');
const meaningful = (line) => !blank(line) && !comment(line) && line.trim() !== '---';
const KEY = /^(\s*)([A-Za-z_][\w.-]*):(?:\s+(.*))?$/;
const BLOCK_HEADER = /^([|>])(?:([1-9])([-+])?|([-+])([1-9])?)?\s*(?:#.*)?$/;
/** Скаляр в строку: простой, '…' или "…"; блочный `|`/`>` — `null` (читает blockScalar). */
function inlineScalar(raw, where) {
const text = String(raw ?? '').trim();
if (text === '' || text.startsWith('#')) return '';
if (BLOCK_HEADER.test(text)) return null;
if (text.startsWith("'")) {
const m = /^'((?:[^']|'')*)'\s*(?:#.*)?$/.exec(text);
assert.ok(m, `${where}: строка в одинарных кавычках не закрыта`);
return m[1].replaceAll("''", "'");
}
if (text.startsWith('"')) {
const m = /^"((?:[^"\\]|\\.)*)"\s*(?:#.*)?$/.exec(text);
assert.ok(m, `${where}: строка в двойных кавычках не закрыта`);
try { return JSON.parse(`"${m[1]}"`); } catch { assert.fail(`${where}: экранирование не разбираю: ${text}`); }
}
assert.ok(!/^[[{&*!%@`]/.test(text), `${where}: потоковый узел, якорь или тег не разбираю: ${text}`);
return text.replace(/\s+#.*$/, '');
}
/** Блочный скаляр после строки `at` с ключом на отступе `keyIndent`. */
function blockScalar(lines, at, keyIndent, header, where) {
const m = BLOCK_HEADER.exec(header.trim());
const style = m[1];
const explicit = Number(m[2] || m[5] || 0);
const chomp = m[3] || m[4] || '';
let content = explicit ? keyIndent + explicit : null;
let end = at + 1;
for (; end < lines.length; end += 1) {
const line = lines[end];
if (blank(line)) continue;
const indent = indentOf(line);
if (content === null) {
if (indent <= keyIndent) break;
content = indent;
}
if (indent < content) {
assert.ok(indent <= keyIndent, `${where}: строка ${end + 1} левее тела блока и правее ключа`);
break;
}
}
const body = lines.slice(at + 1, end).map((line) => (blank(line) ? '' : line.slice(content ?? 0)));
const kept = [...body];
while (body.length && body.at(-1) === '') body.pop();
let text;
if (style === '|') text = body.join('\n');
else {
// `>`: строки абзаца — через пробел, пустая строка — перевод строки;
// строки с бо́льшим отступом не сворачиваются.
text = '';
body.forEach((line, i) => {
const prev = body[i - 1];
if (i === 0) text = line;
else if (line === '' || prev === '' || /^\s/.test(line) || /^\s/.test(prev)) text += `\n${line}`;
else text += ` ${line}`;
});
}
if (chomp === '-') return { text, end };
if (chomp === '+') return { text: `${text}\n${kept.slice(body.length).map(() => '\n').join('')}`, end };
return { text: body.length ? `${text}\n` : '', end };
}
/**
* Прямые ключи отображения в строках `[from, to)`: отступ — у первой значимой
* строки. Элементы списка на отступе ключа (`steps:` и `- …` вровень)
* принадлежат предыдущему ключу.
*/
function mappingKeys(lines, from, to, where) {
let first = from;
while (first < to && !meaningful(lines[first])) first += 1;
if (first >= to) return [];
const indent = indentOf(lines[first]);
const keys = [];
for (let i = first; i < to; i += 1) {
const line = lines[i];
if (!meaningful(line) || indentOf(line) !== indent) continue;
if (/^\s*-(\s|$)/.test(line) && keys.length) continue;
const m = KEY.exec(line);
assert.ok(m, `${where}: строка ${i + 1} не разбирается как ключ отображения: ${line.trim()}`);
keys.push({ key: m[2], value: m[3] ?? '', at: i, indent });
}
keys.forEach((key, j) => { key.end = j + 1 < keys.length ? keys[j + 1].at : to; });
return keys;
}
const childOf = (lines, node, key, where) => mappingKeys(lines, node.at + 1, node.end, where)
.find((k) => k.key === key) ?? null;
/** Значение ключа: скаляр в строку или блочный скаляр. */
function valueOf(lines, node, where) {
const inline = inlineScalar(node.value, `${where}, строка ${node.at + 1}`);
if (inline !== null) return inline;
return blockScalar(lines, node.at, node.indent, node.value, `${where}, строка ${node.at + 1}`).text;
}
/** `defaults.run.shell` узла (workflow или job) либо `null`. */
function defaultsShell(lines, node, where) {
const defaults = childOf(lines, node, 'defaults', where);
if (!defaults) return null;
const empty = (node) => /^\s*(#.*)?$/.test(node.value);
assert.ok(empty(defaults), `${where}: defaults в одну строку не разбираю`);
const run = childOf(lines, defaults, 'run', where);
if (!run) return null;
assert.ok(empty(run), `${where}: defaults.run в одну строку не разбираю`);
const shell = childOf(lines, run, 'shell', where);
return shell ? valueOf(lines, shell, where) : null;
}
/** Элементы блочного списка в строках `[from, to)`. */
function listItems(lines, from, to, where) {
let first = from;
while (first < to && !meaningful(lines[first])) first += 1;
if (first >= to) return [];
assert.match(lines[first], /^\s*-(\s|$)/, `${where}: строка ${first + 1} — не элемент списка`);
const indent = indentOf(lines[first]);
const starts = [];
for (let i = first; i < to; i += 1) {
if (meaningful(lines[i]) && indentOf(lines[i]) === indent && /^\s*-(\s|$)/.test(lines[i])) starts.push(i);
}
return starts.map((at, j) => ({ at, end: j + 1 < starts.length ? starts[j + 1] : to, indent }));
}
/**
* Все шаги workflow: `{ job, line, last, name, id, run, shell, shellFrom }`
* (`line`…`last` — строки шага, с единицы).
* `run` — тело так, как его прочтёт YAML (`null` у шага `uses:`); `shell` —
* значение по правилам раннера (`null` — не задан), `shellFrom` — откуда оно:
* `step`, `job`, `workflow` или `null`.
*/
export function workflowSteps(text, where = 'workflow') {
const lines = String(text).replace(/\r/g, '').split('\n');
const root = { at: -1, end: lines.length };
const workflowShell = defaultsShell(lines, root, where);
const jobs = childOf(lines, root, 'jobs', where);
if (!jobs) return [];
const steps = [];
for (const job of mappingKeys(lines, jobs.at + 1, jobs.end, where)) {
const jobWhere = `${where}, job ${job.key}`;
const jobShell = defaultsShell(lines, job, jobWhere);
const list = childOf(lines, job, 'steps', jobWhere);
if (!list) continue;
for (const item of listItems(lines, list.at + 1, list.end, jobWhere)) {
// Первый ключ шага стоит на строке `- `: для разбора тире — пробелы.
const view = [...lines];
view[item.at] = lines[item.at].replace(/-(\s|$)/, ' $1');
const keys = mappingKeys(view, item.at, item.end, jobWhere);
const field = (name) => keys.find((k) => k.key === name) ?? null;
const stepWhere = `${jobWhere}, шаг в строке ${item.at + 1}`;
const own = field('shell');
const shell = own ? valueOf(view, own, stepWhere) : jobShell ?? workflowShell;
const run = field('run');
steps.push({
job: job.key,
line: item.at + 1,
last: item.end,
name: field('name') ? valueOf(view, field('name'), stepWhere) : null,
id: field('id') ? valueOf(view, field('id'), stepWhere) : null,
run: run ? valueOf(view, run, stepWhere) : null,
shell,
shellFrom: own ? 'step' : jobShell !== null ? 'job' : workflowShell !== null ? 'workflow' : null,
});
}
}
return steps;
}
/**
* Один шаг: `marker` — подстрока текста workflow (шаг, в строках которого
* лежит её первое вхождение), либо `{ name }` / `{ id }` — ровно один шаг.
*/
export function findStep(text, marker, where = 'workflow') {
const steps = workflowSteps(text, where);
if (typeof marker === 'string') {
const at = String(text).indexOf(marker);
assert.ok(at >= 0, `${where}: нет «${marker.trim()}»`);
const line = String(text).slice(0, at).split('\n').length + (marker.startsWith('\n') ? 1 : 0);
const step = steps.find((s) => s.line <= line && line <= s.last);
assert.ok(step, `${where}: «${marker.trim()}» вне шагов`);
return step;
}
const [key, value] = Object.entries(marker)[0] ?? [];
const found = steps.filter((step) => key && step[key] === value);
assert.equal(found.length, 1, `${where}: шагов с ${key} «${value}» — ${found.length}`);
return found[0];
}
/** argv раннера для `shell` (значение шага или `null`) и файла тела `file`. */
export function runnerArgv(shell, file) {
const value = shell === null || shell === undefined ? null : String(shell).trim();
assert.ok(value === null || !FOREIGN_SHELLS.has(value), `shell «${value}»: обвязка его не исполняет`);
const template = value === null ? RUNNER_DEFAULT_SHELL : RUNNER_SHELLS[value] ?? value;
assert.ok(template.includes('{0}'),
`shell «${value}»: не встроенный и без {0} — раннер такой шаг отвергает`);
assert.ok(!/['"\\]/.test(template), `shell «${value}»: кавычки и экранирование обвязка не разбирает`);
return template.split(/\s+/).filter(Boolean).map((word) => word.replaceAll('{0}', file));
}
/**
* Команда для тела `script` шага `step` (по умолчанию — его `run`): тело
* записано в файл во временном каталоге `dir`, который убирает вызывающий.
*/
export function stepCommand(step, script = step?.run) {
assert.ok(step && 'shell' in step, 'нужен шаг из findStep/workflowSteps (или { shell })');
assert.equal(typeof script, 'string', 'тело шага — строка');
const dir = mkdtempSync(join(tmpdir(), 'hp-step-'));
const file = join(dir, step.shell === 'python' ? 'step.py' : 'step.sh');
writeFileSync(file, script);
const [command, ...args] = runnerArgv(step.shell, file);
return { command, args, dir, file };
}
/** Исполнить тело шага как раннер; результат — spawnSync. */
export function runStep(step, script = step?.run, options = {}) {
const { command, args, dir } = stepCommand(step, script);
try {
return spawnSync(command, args, { encoding: 'utf8', ...options });
} finally {
rmSync(dir, { recursive: true, force: true });
}
}
+4 -1
View File
@@ -11,6 +11,7 @@ import {
usageFromExecutionFile, usageFromMessages,
} from '../scripts/model-usage.mjs';
import { REQUIRED_FILES } from '../scripts/review-result-gate.mjs';
import { findStep, runStep } from './helpers/workflow-step.mjs';
// #737: расход сессии модели — одной машинной строкой в документе ревью.
// Шаг `Review` (claude-code-action) отдаёт `execution_file` — все сообщения
@@ -296,6 +297,8 @@ test('#737 AC5: шаг снятия расхода на настоящем bash
for (const { file, snapshot } of PIPELINES) {
const text = readFileSync(join(WORKFLOWS, file), 'utf8');
const body = runOf(named(stepsOf(jobBlock(text, 'model_review')), 'Снять расход модели'));
// #766: shell шага — по правилам раннера (без `shell:` — `bash -e {0}`).
const step = findStep(text, { name: 'Снять расход модели' }, file);
const dir = tempDir(t);
const exec = join(dir, 'claude-execution-output.json');
writeFileSync(exec, JSON.stringify(session(RESULT), null, 2));
@@ -304,7 +307,7 @@ test('#737 AC5: шаг снятия расхода на настоящем bash
const summary = join(dir, 'summary.md');
rmSync(output, { force: true });
rmSync(summary, { force: true });
const r = spawnSync('bash', ['--noprofile', '--norc', '-e', '-c', body], {
const r = runStep(step, body, {
cwd: ROOT, encoding: 'utf8',
env: { ...process.env, EXEC, GITHUB_OUTPUT: output, GITHUB_STEP_SUMMARY: summary, ...(snapshot ? { TOOLS_SHA: head, RUNNER_TEMP: dir } : {}) },
});
+7 -3
View File
@@ -9,6 +9,7 @@ import { fileURLToPath } from 'node:url';
import { buildCiProof } from '../scripts/ci-proof.mjs';
import { jobInstanceNames, validateJobs } from '../scripts/workflow-jobs.mjs';
import { findStep, stepCommand } from './helpers/workflow-step.mjs';
import {
LIST_LIMIT, NIGHT_RED_MARKER_RE, actionsClient, commentBody, commentVerdict, countsForNightRed, findLastGreen, gitClient,
nightRed, parseNightRedMarkers, rangeSuspects,
@@ -393,9 +394,12 @@ async function runNightStep(t, { cwd, items, issues = {}, fail = false, scripts
rmSync(join(cwd, 'scripts'), { recursive: true, force: true });
symlinkSync(scripts, join(cwd, 'scripts'));
const files = { log: join(root, 'gh.log'), summary: join(root, 'summary.md') };
const step = stepRun(readFileSync(new URL('../.github/workflows/_nightly.yml', import.meta.url), 'utf8'),
'Комментарий в задачи диапазона от последней зелёной ночи до красной');
const child = spawn('bash', ['--noprofile', '--norc', '-e', '-c', step], {
const name = 'Комментарий в задачи диапазона от последней зелёной ночи до красной';
const nightly = readFileSync(new URL('../.github/workflows/_nightly.yml', import.meta.url), 'utf8');
// #766: shell шага — по правилам раннера (без `shell:` — `bash -e {0}`); тело — файлом.
const { command, args, dir } = stepCommand(findStep(nightly, ` - name: "${name}"\n`, '_nightly.yml'), stepRun(nightly, name));
t.after(() => rmSync(dir, { recursive: true, force: true }));
const child = spawn(command, args, {
cwd,
env: {
...ENV, PATH: `${gh.bin}:${process.env.PATH}`, REPO, RED_RUN: '105',
+11 -6
View File
@@ -5,6 +5,8 @@ import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'nod
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { findStep, runStep as runnerStep } from './helpers/workflow-step.mjs';
// #492 §7: ночной workflow обязан ждать дочерний Validate и наследовать его
// исход — успешный dispatch не равен успешной проверке.
@@ -57,6 +59,9 @@ function stepRun(text, name) {
return body.join('\n').replace(/\$\{\{ github\.server_url \}\}/g, 'https://github.com');
}
/** Шаг для исполнения (#766): разобранный шаг — его shell по правилам раннера — и тело. */
const runnable = (text, name) => ({ step: findStep(text, ` - name: "${name}"\n`), script: stepRun(text, name) });
const hasBash = () => process.platform !== 'win32' && spawnSync('bash', ['--version']).status === 0;
/**
@@ -64,7 +69,7 @@ const hasBash = () => process.platform !== 'win32' && spawnSync('bash', ['--vers
* при FAKE_DISPATCH=fail), `run list --jq …` — FAKE_RUN_ID (то, что вернул бы
* фильтр), `run view --jq .headSha` — FAKE_HEAD, `run watch` — код FAKE_WATCH.
*/
function runStep(t, script, env = {}) {
function runStep(t, { step, script }, env = {}) {
const root = mkdtempSync(join(tmpdir(), 'hp-727-night-'));
t.after(() => rmSync(root, { recursive: true, force: true }));
const bin = join(root, 'bin');
@@ -83,7 +88,7 @@ function runStep(t, script, env = {}) {
].join('\n'), { mode: 0o755 });
writeFileSync(join(bin, 'sleep'), '#!/bin/sh\necho "sleep $*" >> "$FAKE_LOG"\n', { mode: 0o755 });
const files = { log: join(root, 'log'), output: join(root, 'output'), summary: join(root, 'summary.md') };
const r = spawnSync('bash', ['--noprofile', '--norc', '-e', '-c', script], {
const r = runnerStep(step, script, {
encoding: 'utf8',
env: {
...process.env, PATH: `${bin}:${process.env.PATH}`, REPO: 'o/r', GH_TOKEN: 'x',
@@ -125,23 +130,23 @@ test('#727 AC6 К6: ночь после Validate при любом его исх
test('#727 AC6 на настоящем bash: SHA прогона Validate — в выходах до ожидания; красный Validate — красная job ожидания', (t) => {
if (!hasBash()) { t.skip('bash недоступен'); return; }
const nightly = read('_nightly.yml');
const found = runStep(t, stepRun(nightly, 'Запустить Validate и найти его прогон'), { FAKE_RUN_ID: '42', FAKE_HEAD: HEAD });
const found = runStep(t, runnable(nightly, 'Запустить Validate и найти его прогон'), { FAKE_RUN_ID: '42', FAKE_HEAD: HEAD });
assert.equal(found.status, 0, found.stderr);
assert.match(found.output, /^run_id=42$/m);
assert.match(found.output, new RegExp(`^head_sha=${HEAD}$`, 'm'));
assert.ok(found.log.includes('gh workflow run validate.yml --repo o/r --ref dev -f full=true'));
assert.ok(!found.log.some((call) => call.startsWith('gh run watch')), 'шаг вывода не ждёт');
const red = runStep(t, stepRun(nightly, 'Дождаться Validate'), { RUN_ID: '42', FAKE_WATCH: '1' });
const red = runStep(t, runnable(nightly, 'Дождаться Validate'), { RUN_ID: '42', FAKE_WATCH: '1' });
assert.equal(red.status, 1, 'красный Validate — красная ночь');
assert.ok(red.log.includes('gh run watch 42 --repo o/r --exit-status --interval 30'));
const lost = runStep(t, stepRun(nightly, 'Запустить Validate и найти его прогон'), { FAKE_RUN_ID: '' });
const lost = runStep(t, runnable(nightly, 'Запустить Validate и найти его прогон'), { FAKE_RUN_ID: '' });
assert.equal(lost.status, 1);
assert.match(lost.stdout, /::error::прогон Validate не появился за 3 минуты/);
});
test('#727 AC6 на настоящем bash: ship-ревью ночью — dispatch с tag=nightly, ждёт только появления; сбой — предупреждение', (t) => {
if (!hasBash()) { t.skip('bash недоступен'); return; }
const step = stepRun(read('_nightly.yml'), 'Запустить ship-ревью и дождаться появления прогона');
const step = runnable(read('_nightly.yml'), 'Запустить ship-ревью и дождаться появления прогона');
const ok = runStep(t, step, { CANDIDATE: HEAD, FAKE_RUN_ID: '77' });
assert.equal(ok.status, 0, ok.stderr);
assert.equal(ok.log[0], `gh workflow run ship-review.yml --ref dev -f tag=nightly -f candidate=${HEAD} --repo o/r`);
+5 -2
View File
@@ -17,6 +17,8 @@ import { tmpdir } from 'node:os';
import { basename, join } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { findStep, runStep } from './helpers/workflow-step.mjs';
const ROOT = fileURLToPath(new URL('..', import.meta.url));
const WORKFLOW = join(ROOT, '.github', 'workflows', '_process.yml');
const TOOLS_STEP = 'Скрипты конвейера — из dev (#749)';
@@ -162,8 +164,9 @@ test('#749 AC2: снимок самодостаточен — шаг как ес
const snapshot = integrateSteps().find((step) => step.name === TOOLS_STEP);
assert.ok(snapshot, `шаг «${TOOLS_STEP}»`);
const output = join(temp, 'output');
// Шаг без `shell:` GitHub исполняет как `bash -e {0}`.
const r = spawnSync('bash', ['--noprofile', '--norc', '-e', '-c', snapshot.run], {
// #766: shell шага — по правилам раннера (без `shell:` — `bash -e {0}`).
const step = findStep(readFileSync(WORKFLOW, 'utf8'), ` - name: ${TOOLS_STEP}\n`, '_process.yml');
const r = runStep(step, snapshot.run, {
cwd: work, encoding: 'utf8', env: { ...GIT_ENV, RUNNER_TEMP: temp, GITHUB_OUTPUT: output },
});
assert.equal(r.status, 0, r.stderr);
+12 -11
View File
@@ -24,6 +24,7 @@ import { fileURLToPath } from 'node:url';
import { issueBodyDigest } from '../scripts/review-doc-guard.mjs';
import { formatUsage } from '../scripts/model-usage.mjs';
import { findStep, runStep } from './helpers/workflow-step.mjs';
const ROOT = fileURLToPath(new URL('..', import.meta.url));
const WORKFLOW = join(ROOT, '.github', 'workflows', '_process.yml');
@@ -203,11 +204,11 @@ function fixture(t) {
writeFileSync(join(bin, 'gh'), `#!/usr/bin/env bash\ncat '${join(root, 'body.md')}'\n`);
chmodSync(join(bin, 'gh'), 0o755);
const env = { ...GIT_ENV, RUNNER_TEMP: temp, PATH: `${bin}:${process.env.PATH}` };
const run = (body, extra) => {
// Шаг (из stepsOf) — тело как есть, shell — по правилам раннера (#766).
const run = (step, extra) => {
const output = join(temp, `output-${Math.random().toString(36).slice(2)}`);
const summary = join(temp, 'summary.md');
// Шаг без `shell:` GitHub исполняет как `bash -e {0}`.
const r = spawnSync('bash', ['--noprofile', '--norc', '-e', '-c', body], {
const r = runStep(findStep(workflow(), ` - name: ${step.name}\n`, '_process.yml'), step.run, {
cwd: work, encoding: 'utf8', env: { ...env, GITHUB_OUTPUT: output, GITHUB_STEP_SUMMARY: summary, ...extra },
});
let out = '';
@@ -223,7 +224,7 @@ test('#765 AC2: шаги prepare на ветке, отставшей от dev и
if (!hasTools()) { t.skip('bash/tar/git недоступны'); return; }
const fx = fixture(t);
const steps = prepareSteps();
const tools = fx.run(named(steps, TOOLS_STEP).run);
const tools = fx.run(named(steps, TOOLS_STEP));
assert.equal(tools.status, 0, tools.stderr);
const dir = outputOf(tools.out, 'dir');
const sha = outputOf(tools.out, 'sha');
@@ -233,17 +234,17 @@ test('#765 AC2: шаги prepare на ветке, отставшей от dev и
fx.git(fx.work, 'checkout', '-q', 'origin/issue/7-x');
const env = { TOOLS: dir, NUM: '7', REPO: 'o/r', GH_TOKEN: 'x' };
const material = fx.run(named(steps, 'Зафиксировать SHA материала ревью').run, env);
const material = fx.run(named(steps, 'Зафиксировать SHA материала ревью'), env);
assert.equal(material.status, 0, material.stderr);
assert.equal(outputOf(material.out, 'sha'), fx.git(fx.work, 'rev-parse', 'HEAD'), 'якорь — материал, а не снимок');
assert.equal(outputOf(material.out, 'issue_body'), issueBodyDigest(BODY), 'хеш тела — функцией dev, не ветки');
const reuse = fx.run(named(steps, 'Зелёный вердикт прошлого захода применим без ревью (#499)').run,
const reuse = fx.run(named(steps, 'Зелёный вердикт прошлого захода применим без ревью (#499)'),
{ ...env, ISSUE_BODY: issueBodyDigest(BODY) });
assert.equal(reuse.status, 0, reuse.stderr);
assert.equal(outputOf(reuse.out, 'reuse'), 'false', 'подменённый скрипт выдал бы себе reuse=true — ревью без модели');
const spec = fx.run(named(steps, 'ТЗ менялось после зелёного ревью ТЗ (#517)').run,
const spec = fx.run(named(steps, 'ТЗ менялось после зелёного ревью ТЗ (#517)'),
{ ...env, DIGEST: issueBodyDigest(BODY) });
assert.equal(spec.status, 0, spec.stderr);
assert.equal(outputOf(spec.out, 'changed'), 'false', 'подменённый скрипт объявил бы ТЗ изменившимся');
@@ -252,7 +253,7 @@ test('#765 AC2: шаги prepare на ветке, отставшей от dev и
test('#765 AC3: расход снимается скриптом закреплённого SHA — на ветке без model-usage.mjs и после сдвига dev', (t) => {
if (!hasTools()) { t.skip('bash/tar/git недоступны'); return; }
const fx = fixture(t);
const tools = fx.run(named(prepareSteps(), TOOLS_STEP).run);
const tools = fx.run(named(prepareSteps(), TOOLS_STEP));
assert.equal(tools.status, 0, tools.stderr);
const sha = outputOf(tools.out, 'sha');
// dev двинулся после подготовки: новая версия скрипта расхода печатает чужое.
@@ -265,12 +266,12 @@ test('#765 AC3: расход снимается скриптом закрепл
fx.git(fx.work, 'checkout', '-q', 'origin/issue/7-x');
const exec = join(fx.temp, 'execution.json');
writeFileSync(exec, JSON.stringify([{ type: 'system' }, { type: 'result', usage: USAGE, num_turns: USAGE.num_turns }]));
const body = named(modelSteps(), USAGE_STEP).run;
const usage = fx.run(body, { EXEC: exec, TOOLS_SHA: sha });
const step = named(modelSteps(), USAGE_STEP);
const usage = fx.run(step, { EXEC: exec, TOOLS_SHA: sha });
assert.equal(usage.status, 0, usage.stderr);
assert.equal(outputOf(usage.out, 'line'), formatUsage(USAGE), 'строка данных, а не missing и не версия позже');
// Нет SHA из prepare — громкий сбой отчётного шага, а не молчаливая строка.
const lost = fx.run(body, { EXEC: exec, TOOLS_SHA: '' });
const lost = fx.run(step, { EXEC: exec, TOOLS_SHA: '' });
assert.notEqual(lost.status, 0);
assert.match(lost.stdout + lost.stderr, /нет SHA снимка/);
});
+15 -11
View File
@@ -16,6 +16,7 @@ import { classify as classifyPath } from '../scripts/change-classes.mjs';
// Пути монолитов — данные для классификатора, а не чтение их текста (#624).
import { CARD_FILE, RUNTIME_FILE } from '../scripts/monolith-metrics.mjs';
import { trackFromLabels as packetTrack } from '../scripts/task-packet.mjs';
import { findStep, runStep } from './helpers/workflow-step.mjs';
// #696: конвейер ревью решает цену захода по треку; трек и рамки ship —
// механические, потому что по ним задача сливается без ревью модели.
@@ -650,6 +651,8 @@ function stepRun(workflow, marker) {
assert.doesNotMatch(text, /\$\{\{/, 'все выражения подставлены');
return text;
}
/** Шаг для исполнения (#766): разобранный шаг — его shell по правилам раннера — и тело. */
const runnable = (workflow, marker) => ({ step: findStep(workflow, marker, '_process.yml'), script: stepRun(workflow, marker) });
const TRACK_STEP = ' - name: "Трек задачи и рамки ship (#696)"\n';
const GUARD_STEP = ' - id: decide\n';
const DECIDE_STEP = ' - name: Решение по вердикту\n';
@@ -791,10 +794,10 @@ function trackSandbox(t, { change, base = () => {} }) {
let tools = '';
const box = {
work, fake,
run(script, env) {
run({ step, script }, env) {
for (const name of ['gh-calls', 'comment.md']) rmSync(join(fake, name), { force: true });
writeFileSync(join(temp, 'output'), '');
const r = spawnSync('bash', ['--noprofile', '--norc', '-eo', 'pipefail', '-c', script], {
const r = runStep(step, script, {
cwd: work, encoding: 'utf8',
env: {
...GIT_ENV, PATH: `${bin}:${process.env.PATH}`, RUNNER_TEMP: temp, FAKE_DIR: fake, GH_TOKEN: 'x', NUM: '7',
@@ -817,7 +820,7 @@ function trackSandbox(t, { change, base = () => {} }) {
},
/** #749/#765: шаг снимка job как есть; его каталог дальше идёт шагам как TOOLS. */
snapshot(step = TOOLS_STEP) {
const r = this.run(stepRun(readFileSync(WORKFLOW, 'utf8'), step), {});
const r = this.run(runnable(readFileSync(WORKFLOW, 'utf8'), step), {});
assert.equal(r.status, 0, `снимок скриптов dev: ${r.stderr}`);
assert.ok(r.output.dir && existsSync(join(r.output.dir, 'scripts', 'process-track.mjs')), 'снимок несёт скрипт трека');
tools = r.output.dir;
@@ -837,7 +840,7 @@ test('#707 AC4: шаг трека на настоящем bash — ship с ри
if (!hasTools()) { t.skip('bash/tar/git недоступны'); return; }
const { readFileSync: read } = await import('node:fs');
const box = trackSandbox(t, { change: touchChange });
const run = stepRun(read(WORKFLOW, 'utf8'), TRACK_STEP);
const run = runnable(read(WORKFLOW, 'utf8'), TRACK_STEP);
box.comments([{ author: { login: 'claude[bot]' }, body: 'Трек: ship — решение владельца', createdAt: '2026-09-30T08:00:00Z' }]);
const r = box.run(run, trackEnv('track:ship,S7-code-review'));
assert.equal(r.status, 0, r.stderr);
@@ -866,7 +869,7 @@ test('#707 AC4: шаг трека на настоящем bash — ship, под
const workflow = read(WORKFLOW, 'utf8');
const box = trackSandbox(t, { change: touchChange });
box.comments([{ author: { login: 'Matysh' }, body: 'Трек: ship — решение владельца', createdAt: '2026-09-30T08:00:00Z' }]);
const r = box.run(stepRun(workflow, TRACK_STEP), trackEnv('track:ship,S7-code-review'));
const r = box.run(runnable(workflow, TRACK_STEP), trackEnv('track:ship,S7-code-review'));
assert.equal(r.status, 0, r.stderr);
assert.match(r.stdout, /^raise=false$/m);
assert.deepEqual(r.calls, ['issue view 7 --repo o/r --json comments'], 'ни комментария, ни смены меток');
@@ -879,7 +882,8 @@ test('#707 AC4: шаг трека на настоящем bash — ship, под
// Комментарий слияния ship: шаг «Решение по вердикту» как есть, с этой строкой риска.
const { SHIP_MERGE_MARKER_RE, shipRiskFrom } = await import('../scripts/ship-review.mjs');
const material = 'a'.repeat(40);
const decide = stepRun(workflow, DECIDE_STEP).replaceAll('/tmp/ship-merge.md', join(box.fake, 'ship-merge.md'));
const step = runnable(workflow, DECIDE_STEP);
const decide = { ...step, script: step.script.replaceAll('/tmp/ship-merge.md', join(box.fake, 'ship-merge.md')) };
const merged = box.run(decide, { OUT: '', STAGE: 'code', REUSE: 'false', SHIP: 'true', SHIP_RISK: r.output.ship_risk, MATERIAL: material, VALIDATE_URL: 'https://v' });
assert.equal(merged.status, 0, merged.stderr);
assert.equal(merged.output.green, 'true');
@@ -909,7 +913,7 @@ const dropStageSize = (work) => {
test('#755 AC3: шаг трека на настоящем bash — ship с удалённым членом интерфейса не повышается', async (t) => {
if (!hasTools()) { t.skip('bash/tar/git недоступны'); return; }
const { readFileSync: read } = await import('node:fs');
const run = stepRun(read(WORKFLOW, 'utf8'), TRACK_STEP);
const run = runnable(read(WORKFLOW, 'utf8'), TRACK_STEP);
const bot = [{ author: { login: 'claude[bot]' }, body: 'Трек: ship — решение владельца', createdAt: '2026-10-01T05:00:00Z' }];
const box = trackSandbox(t, { base: ISO_BASE('export interface IsoOverlayFitEnvelopeInput {'), change: dropStageSize });
@@ -934,7 +938,7 @@ test('#755 AC3: шаг трека на настоящем bash — ship с уд
test('#707 AC4: шаг трека на настоящем bash — комментарии недоступны, этап spec, show', async (t) => {
if (!hasTools()) { t.skip('bash/tar/git недоступны'); return; }
const { readFileSync: read } = await import('node:fs');
const run = stepRun(read(WORKFLOW, 'utf8'), TRACK_STEP);
const run = runnable(read(WORKFLOW, 'utf8'), TRACK_STEP);
const box = trackSandbox(t, { change: touchChange });
box.comments(null);
const unknown = box.run(run, trackEnv('track:ship,S7-code-review'));
@@ -1213,7 +1217,7 @@ test('#726 AC5: шаг решения на настоящем bash — reclassif
if (!hasTools()) { t.skip('bash/tar/git недоступны'); return; }
const box = trackSandbox(t, { change: docsChange([[1, 'жёлтый'], [2, 'жёлтый']]) });
box.snapshot();
const run = stepRun(readFileSync(WORKFLOW, 'utf8'), DECIDE_STEP);
const run = runnable(readFileSync(WORKFLOW, 'utf8'), DECIDE_STEP);
box.labels(['track:show', 'S7-code-review', 'P2']);
const r = box.run(run, decideEnv({ OUT: verdictOut({ route: 'reclassify', criterion: 'undocumented' }) }));
assert.equal(r.status, 0, r.stderr + r.stdout);
@@ -1244,7 +1248,7 @@ test('#726 AC5: шаг решения на настоящем bash — исче
if (!hasTools()) { t.skip('bash/tar/git недоступны'); return; }
const box = trackSandbox(t, { change: docsChange([[1, 'жёлтый'], [2, 'жёлтый']]) });
box.snapshot();
const run = stepRun(readFileSync(WORKFLOW, 'utf8'), DECIDE_STEP);
const run = runnable(readFileSync(WORKFLOW, 'utf8'), DECIDE_STEP);
box.labels(['track:show', 'S7-code-review']);
const commentPath = join(dirname(box.work), 'runner', 'route', 'comment.md');
// show, spent 1, fix — review-4 этим же вердиктом, возврат в S6.
@@ -1287,7 +1291,7 @@ test('#726 AC5: шаг решения на настоящем bash — исче
test('#726 AC5: шаг трека на настоящем bash — confirmed и заметка маршрута для промпта', async (t) => {
if (!hasTools()) { t.skip('bash/tar/git недоступны'); return; }
const box = trackSandbox(t, { change: touchChange });
const run = stepRun(readFileSync(WORKFLOW, 'utf8'), TRACK_STEP);
const run = runnable(readFileSync(WORKFLOW, 'utf8'), TRACK_STEP);
box.comments([]);
const show = box.run(run, trackEnv('track:show,S7-code-review'));
assert.equal(show.status, 0, show.stderr);
+25 -17
View File
@@ -9,6 +9,7 @@ import { fileURLToPath } from 'node:url';
import { PUSH_REFUSAL, classifyPushRefusal, refusalSummary } from '../scripts/merge-candidate.mjs';
import { buildIndex } from '../scripts/reviews-index.mjs';
import { findStep, runStep } from './helpers/workflow-step.mjs';
// #723: два шага публикуют коммит и прежде любой отказ push считали сдвигом
// ветки — документ ревью релиза в `dev` (release-review.yml, три попытки) и
@@ -17,7 +18,8 @@ import { buildIndex } from '../scripts/reviews-index.mjs';
// устаревший lease — прежний повтор/ребейз, отказ GitHub — остановка без
// повторов, причина и ответ git без токена — в журнале и в сводке шага.
//
// Шаги исполняются как есть, настоящим bash и настоящим git во временных
// Шаги исполняются как есть, shell шага по правилам раннера (#766: без
// своего pipefail), настоящим bash и настоящим git во временных
// репозиториях. Подменён только транспорт: `git push` на github.com уходит в
// локальный origin, а заданный отказ GitHub отвечает записанным stderr. Сдвиг
// ветки — настоящий: сосед пушит в origin до push шага, и git сам отвечает
@@ -97,11 +99,17 @@ function stepRun(file, name) {
return body.join('\n').replace(/\$\{\{ github\.repository \}\}/g, 'o/r');
}
const RELEASE_STEP = () => stepRun('release-review.yml', 'Опубликовать документ');
const REVIEW_DOC_STEP = () => stepRun('_process.yml', 'Опубликовать документ ревью');
/** Шаг для исполнения (#766): разобранный шаг — его shell по правилам раннера — и тело. */
const runnable = (file, name) => ({
step: findStep(readFileSync(join(WORKFLOWS, file), 'utf8'), ` - name: ${name}\n`, file),
script: stepRun(file, name),
});
const RELEASE_STEP = () => runnable('release-review.yml', 'Опубликовать документ');
const REVIEW_DOC_STEP = () => runnable('_process.yml', 'Опубликовать документ ревью');
// #749: скрипты job integrate — одним снимком dev; шаги получают каталог выходом `dir`.
const TOOLS_STEP = () => stepRun('_process.yml', 'Скрипты конвейера — из dev (#749)');
const REPRO_STEP = () => stepRun('_process.yml', '"Материал раунда воспроизводим (#413)"');
const TOOLS_STEP = () => runnable('_process.yml', 'Скрипты конвейера — из dev (#749)');
const REPRO_STEP = () => runnable('_process.yml', '"Материал раунда воспроизводим (#413)"');
/**
* Песочница: bare origin, рабочая копия, соседний клон и bin с подменами.
@@ -165,9 +173,9 @@ function sandbox(root) {
writeFileSync(join(fake, `before-push-${n}`), `HEAD:${ref}`);
},
refuse(n, stderr) { writeFileSync(join(fake, `push-${n}.stderr`), stderr); },
run(script, env) {
run({ step, script }, env) {
const summary = join(temp, 'summary.md');
const r = spawnSync('bash', ['--noprofile', '--norc', '-eo', 'pipefail', '-c', script], {
const r = runStep(step, script, {
cwd: work, encoding: 'utf8',
env: {
...ENV, ...env, PATH: `${bin}:${process.env.PATH}`, RUNNER_TEMP: temp, GITHUB_STEP_SUMMARY: summary,
@@ -493,7 +501,7 @@ test('#749 AC1 _process.yml на настоящем bash: якорь и пров
// ---------- #730 _ship-review.yml: SHIP-REVIEW в dev ----------
const SHIP_STEP = () => stepRun('_ship-review.yml', 'Опубликовать документ');
const SHIP_STEP = () => runnable('_ship-review.yml', 'Опубликовать документ');
const BETA = 'v1.79.0-beta.1';
const SHIP_DOC = `docs/reviews/SHIP-REVIEW-${BETA}.md`;
@@ -608,7 +616,7 @@ test('#730 _ship-review.yml на настоящем bash: сдвиг, затем
// ---------- #730 _beta-derived.yml: бот-коммит производных артефактов в dev ----------
const DERIVED_STEP = () => stepRun('_beta-derived.yml', 'Коммит в dev');
const DERIVED_STEP = () => runnable('_beta-derived.yml', 'Коммит в dev');
/** Кадры и эталоны на dev, как после checkout. */
function derivedSandbox(t) {
@@ -688,23 +696,23 @@ for (const [label, stderr, kind, reason] of [
// ---------- AC3 и разбор: тексты — из кода, не из run ----------
test('#723 AC3: в run обоих шагов нет многострочного текста и heredoc; отказ разбирает код слияния', () => {
for (const [label, body, tools] of [['release-review.yml', RELEASE_STEP(), 'scripts'], ['_process.yml', REVIEW_DOC_STEP(), '"$TOOLS/scripts']]) {
for (const [label, body, tools] of [['release-review.yml', RELEASE_STEP().script, 'scripts'], ['_process.yml', REVIEW_DOC_STEP().script, '"$TOOLS/scripts']]) {
assert.doesNotMatch(body, /<<-?\s*['"]?[A-Za-z_]/, `${label}: heredoc в run`);
assert.ok(body.includes(`kind=$(node ${tools}/merge-candidate.mjs`), `${label}: разбор — merge-candidate.mjs --push-refusal`);
assert.match(body, /--push-refusal="\$push_err"[^\n]*\\\n[^\n]*--summary="\$GITHUB_STEP_SUMMARY"\) \|\| kind=unknown/, `${label}: сводку пишет код`);
assert.match(body, /2> "\$push_err"; then/, `${label}: stderr push идёт в разбор`);
}
// Шаг _process.yml берёт разбор из снимка dev (#749): ветка задачи, отставшая от dev, его может не нести.
assert.doesNotMatch(REVIEW_DOC_STEP(), /git archive/, 'своего извлечения у шага нет — снимок job');
assert.match(TOOLS_STEP(), /git archive origin\/dev scripts \.github\/workflows\/validate\.yml \| tar -x -C "\$tools"/);
assert.doesNotMatch(REVIEW_DOC_STEP().script, /git archive/, 'своего извлечения у шага нет — снимок job');
assert.match(TOOLS_STEP().script, /git archive origin\/dev scripts \.github\/workflows\/validate\.yml \| tar -x -C "\$tools"/);
// Блок run не обрезан: последняя строка каждого шага на месте.
assert.match(RELEASE_STEP(), /echo "::error::документ ревью не опубликован в dev за три попытки"\nexit 1\n*$/);
assert.match(REVIEW_DOC_STEP(), /echo "документ опубликован в \$target: \$doc"\n*$/);
assert.match(RELEASE_STEP().script, /echo "::error::документ ревью не опубликован в dev за три попытки"\nexit 1\n*$/);
assert.match(REVIEW_DOC_STEP().script, /echo "документ опубликован в \$target: \$doc"\n*$/);
});
test('#730 AC3: тела _ship-review.yml и _beta-derived.yml — без heredoc, разбор кодом слияния из dev, сводку пишет код', () => {
const read = (name) => readFileSync(join(WORKFLOWS, name), 'utf8');
for (const [label, body] of [['_ship-review.yml', SHIP_STEP()], ['_beta-derived.yml', DERIVED_STEP()]]) {
for (const [label, body] of [['_ship-review.yml', SHIP_STEP().script], ['_beta-derived.yml', DERIVED_STEP().script]]) {
assert.doesNotMatch(body, /<<-?\s*['"]?[A-Za-z_]/, `${label}: heredoc в run`);
assert.ok(body.includes('kind=$(node scripts/merge-candidate.mjs'), `${label}: разбор — merge-candidate.mjs --push-refusal`);
assert.match(body, /--push-refusal="\$push_err"[^\n]*\\\n[^\n]*--summary="\$GITHUB_STEP_SUMMARY"\) \|\| kind=unknown/, `${label}: сводку пишет код`);
@@ -715,13 +723,13 @@ test('#730 AC3: тела _ship-review.yml и _beta-derived.yml — без heredo
const job = (text, name) => text.slice(text.indexOf(`\n ${name}:`));
assert.match(job(read('_ship-review.yml'), 'publish'), /actions\/checkout@[^\n]+\n\s+with:\n\s+fetch-depth: 0\n\s+ref: dev\n/);
assert.match(job(read('_beta-derived.yml'), 'accept'), /actions\/checkout@[^\n]+\n\s+with:\n\s+ref: dev\n/);
const ship = SHIP_STEP();
const ship = SHIP_STEP().script;
const loop = ship.slice(ship.indexOf('for attempt in 1 2 3; do'));
assert.ok(loop.indexOf('git reset -q --hard origin/dev') >= 0
&& loop.indexOf('git reset -q --hard origin/dev') < loop.indexOf('kind=$(node scripts/merge-candidate.mjs'));
// Блок run не обрезан: последняя строка каждого шага на месте.
assert.match(ship, /echo "::error::документ ревью не опубликован в dev за три попытки"\nexit 1\n*$/);
assert.match(DERIVED_STEP(), /в dev — проверить перед кандидатом беты\." >> "\$GITHUB_STEP_SUMMARY"\n*$/);
assert.match(DERIVED_STEP().script, /в dev — проверить перед кандидатом беты\." >> "\$GITHUB_STEP_SUMMARY"\n*$/);
});
test('#730: подписи сводки для публикации ship, производных артефактов и стража ребейза', () => {
+8 -5
View File
@@ -7,6 +7,7 @@ import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { REVIEWS_INDEX_PATH, UPSTREAM_WINS, planStop, rebaseRegenerating } from '../scripts/rebase-generated.mjs';
import { findStep, runStep } from './helpers/workflow-step.mjs';
import { PATCH_ID_EXCLUDES } from '../scripts/merge-candidate.mjs';
import { buildIndex } from '../scripts/reviews-index.mjs';
@@ -202,8 +203,9 @@ test('#643 CLI: отказ — код 3 и по строке на конфлик
// ---------- проводка в process.yml: свидетели и настоящий bash ----------
const WORKFLOW = readFileSync(new URL('../.github/workflows/_process.yml', import.meta.url), 'utf8');
const REBASE_STEP = ' - name: Привести ветку к dev\n';
const rebaseStep = () => WORKFLOW.slice(
WORKFLOW.indexOf(' - name: Привести ветку к dev\n'),
WORKFLOW.indexOf(REBASE_STEP),
WORKFLOW.indexOf(' - name: Зафиксировать SHA материала ревью'),
);
@@ -234,7 +236,7 @@ test('#643: замыкание импортов помощника не выхо
}
});
/** Исполнить ребейзную часть шага как есть (bash -eo pipefail, как у Actions). */
/** Исполнить ребейзную часть шага как есть — shell шага, как у раннера (#766). */
function runStepRebase(work) {
const step = rebaseStep();
const body = step.slice(step.indexOf(' run: |\n') + ' run: |\n'.length)
@@ -249,9 +251,10 @@ function runStepRebase(work) {
// #765: снимок dev подготовки (его шаг исполняет process-prepare-tools.test.mjs).
const tools = join(temp, 'dev-tools');
mkdirSync(tools);
execFileSync('bash', ['-eo', 'pipefail', '-c', `git archive origin/dev scripts | tar -x -C "${tools}"`], { cwd: work, env: ENV });
const archive = execFileSync('git', ['archive', 'origin/dev', 'scripts'], { cwd: work, env: ENV, maxBuffer: 256 * 1024 * 1024 });
execFileSync('tar', ['-x', '-C', tools], { input: archive });
const script = `${body.slice(from, to)}\necho REBASED\n`;
const r = spawnSync('bash', ['--noprofile', '--norc', '-eo', 'pipefail', '-c', script], {
const r = runStep(findStep(WORKFLOW, REBASE_STEP, '_process.yml'), script, {
cwd: work, encoding: 'utf8', env: { ...ENV, RUNNER_TEMP: temp, GITHUB_OUTPUT: output, BRANCH: 'issue/9-fix', TOOLS: tools },
});
return { status: r.status, stdout: r.stdout, stderr: r.stderr, output: readFileSync(output, 'utf8') };
@@ -411,7 +414,7 @@ function runStepPush(pushStderr) {
const summary = join(temp, 'summary.md');
writeFileSync(summary, '');
const script = `TOOLS=${JSON.stringify(resolve(SCRIPTS, '..'))}\nbefore=${'b'.repeat(40)}\n${block}\necho PUSHED\n`;
const r = spawnSync('bash', ['--noprofile', '--norc', '-eo', 'pipefail', '-c', script], {
const r = runStep(findStep(WORKFLOW, REBASE_STEP, '_process.yml'), script, {
encoding: 'utf8',
env: {
...ENV, PATH: `${bin}:${process.env.PATH}`, RUNNER_TEMP: temp, GITHUB_OUTPUT: output, GITHUB_STEP_SUMMARY: summary,
+6 -1
View File
@@ -9,6 +9,8 @@ import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { findStep, runStep } from './helpers/workflow-step.mjs';
const WORKFLOWS = fileURLToPath(new URL('../.github/workflows/', import.meta.url));
const read = (name) => readFileSync(new URL(name, `file://${WORKFLOWS}`), 'utf8');
const workflow = read('release.yml');
@@ -185,7 +187,10 @@ function runReviewStep({ snapshots = [[]], dispatch = 0, appear = 45, poll = 15
writeFileSync(join(bin, 'sleep'), '#!/bin/sh\nexit 0\n', { mode: 0o755 });
const summary = join(dir, 'summary.md');
writeFileSync(summary, '');
const r = spawnSync('bash', ['--noprofile', '--norc', '-e', '-c', reviewStepScript()], {
// #766: shell шага — по правилам раннера (без `shell:` — `bash -e {0}`).
const step = findStep(workflow, { name: 'Поставить в очередь ревью линии' }, 'release.yml');
assert.equal(step.job, 'independent-review');
const r = runStep(step, reviewStepScript(), {
encoding: 'utf8',
env: {
...process.env, PATH: `${bin}:${process.env.PATH}`, GH_TOKEN: 'x', TAG: 'v1.79.0', SHA: 'c'.repeat(40),
+4 -1
View File
@@ -13,6 +13,7 @@ import {
anchorTreeFrom, anchorVerdictFrom, reusableGreenVerdict,
anchorIssueBodyFrom, issueBodyChanged, issueBodyDigest, normalizeIssueBody,
} from '../scripts/review-doc-guard.mjs';
import { findStep, runStep } from './helpers/workflow-step.mjs';
// #365. 28.08 шаг публикации ревью-дока запушил в dev коммит bb2919f с тридцатью
// файлами вместо одного markdown: откатил отревьюженную реализацию #359, вернул
@@ -1062,6 +1063,7 @@ function runGuard(t, { labels, compare = null, branch = true }) {
'',
].join('\n'), { mode: 0o755 });
const workflow = readFileSync(new URL('../.github/workflows/_process.yml', import.meta.url), 'utf8');
const step = findStep(workflow, ' - id: decide\n', '_process.yml');
const lines = workflow.slice(workflow.indexOf(' - id: decide\n')).split('\n');
const from = lines.indexOf(' run: |');
const body = [];
@@ -1073,7 +1075,8 @@ function runGuard(t, { labels, compare = null, branch = true }) {
const script = body.join('\n').replace(/\$\{\{ github\.(\w+) \}\}/g, (_, key) => context[key]);
const output = join(root, 'output');
writeFileSync(output, '');
const r = spawnSync('bash', ['--noprofile', '--norc', '-eo', 'pipefail', '-c', script], {
// #766: shell шага по правилам раннера, без своего pipefail.
const r = runStep(step, script, {
cwd: fileURLToPath(new URL('..', import.meta.url)), encoding: 'utf8',
env: {
...process.env, PATH: `${bin}:${process.env.PATH}`, FAKE_DIR: root, GITHUB_OUTPUT: output,
+12 -5
View File
@@ -14,6 +14,7 @@ import {
} from '../scripts/ship-review.mjs';
import { parseDocName, renderIndex } from '../scripts/reviews-index.mjs';
import { archivePlan } from '../scripts/reviews-archive.mjs';
import { findStep, runStep } from './helpers/workflow-step.mjs';
const sha = (c) => c.repeat(40);
const MARKER = `<!-- hp:ship-merge material=${sha('a')} -->`;
@@ -459,6 +460,12 @@ function stepRun(name) {
return body.join('\n').replace(/\$\{\{ github\.repository \}\}/g, 'o/r');
}
/** Шаг для исполнения (#766): разобранный шаг — его shell по правилам раннера — и тело. */
const runnable = (name) => ({
step: findStep(readFileSync(SHIP_WORKFLOW, 'utf8'), ` - name: ${name}\n`, '_ship-review.yml'),
script: stepRun(name),
});
/** Замыкание относительных импортов скрипта. */
function importClosure(entry, seen = new Set()) {
if (seen.has(entry)) return seen;
@@ -538,9 +545,9 @@ function shipSandbox(t) {
number, title: `Задача ${number}`, body: '## ТЗ\n\nстрока', labels, comments,
}));
},
run(script, env) {
run({ step, script }, env) {
for (const file of ['output', 'summary.md']) rmSync(join(temp, file), { force: true });
const r = spawnSync('bash', ['--noprofile', '--norc', '-e', '-c', script], {
const r = runStep(step, script, {
cwd: work, encoding: 'utf8',
env: {
...GIT_ENV, ...env, PATH: `${bin}:${process.env.PATH}`, RUNNER_TEMP: temp, GH_TOKEN: 'x', TOKEN: 'x',
@@ -557,7 +564,7 @@ function shipSandbox(t) {
return { status: r.status, stdout: r.stdout, stderr: r.stderr, output, summary: read(join(temp, 'summary.md')) };
},
prepare(env) {
return box.run(stepRun('Кандидат, база и ship-задачи'), {
return box.run(runnable('Кандидат, база и ship-задачи'), {
FORCE: 'false', CANDIDATE: '', RUN_URL: 'https://github.com/o/r/actions/runs/1', ...env,
});
},
@@ -574,7 +581,7 @@ function shipSandbox(t) {
TAG: extra.TAG, DOC: o.doc, CANDIDATE: o.candidate, BASE: o.base, ISSUES: o.issues, MODE: o.mode, PATCHES: o.patches,
RUN_URL: 'https://github.com/o/r/actions/runs/2',
};
const published = box.run(stepRun('Опубликовать документ'), env);
const published = box.run(runnable('Опубликовать документ'), env);
git(work, 'fetch', '-q', 'origin');
return published;
},
@@ -714,7 +721,7 @@ test('#727 AC8 _ship-review.yml на настоящем bash: строка о Hi
if (!hasTools()) { t.skip('bash/jq/sha256sum недоступны'); return; }
const box = shipSandbox(t);
const doc = 'docs/reviews/SHIP-REVIEW-v1.0.0-dev-0123456789ab.md';
const step = stepRun('High ночью — строка в задачи документа');
const step = runnable('High ночью — строка в задачи документа');
const run = (mode, high) => {
const dir = join(box.temp, 'ship-review-result');
mkdirSync(dir, { recursive: true });
+4 -1
View File
@@ -19,6 +19,7 @@ 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();
@@ -98,11 +99,13 @@ const lagStep = () => {
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 = spawnSync('bash', ['-c', script], {
const result = runStep(step, script, {
encoding: 'utf8',
env: { ...process.env, SCHEDULE: schedule, NOW_EPOCH: String(Date.parse(nowIso) / 1000), GITHUB_STEP_SUMMARY: summary },
});
+22 -8
View File
@@ -17,6 +17,8 @@ import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { findStep, runStep } from './helpers/workflow-step.mjs';
const WORKFLOWS = fileURLToPath(new URL('../.github/workflows/', import.meta.url));
const indentOf = (line) => line.length - line.trimStart().length;
@@ -112,14 +114,15 @@ test('#751 AC1: у каждого | tee во всех workflow — pipefail; п
const hasBash = () => process.platform !== 'win32' && spawnSync('bash', ['--version']).status === 0;
/** Тело `run` шага `name` файла `file` — как его исполнит раннер. */
/** Шаг `name` файла `file`: разобранный шаг (shell раннера, #766) и тело `run`. */
function stepRun(file, name) {
const text = readFileSync(join(WORKFLOWS, file), 'utf8');
const at = text.split('\n').findIndex((line) => line === ` - name: ${name}` || line === ` - id: ${name}`);
const lines = text.split('\n');
const at = lines.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');
return { step: findStep(text, `${lines[at]}\n`, file), script: block.body.join('\n') };
}
test('#751 AC1 на настоящем bash: упавший скрипт слева от | tee роняет шаг под bash -e, как у раннера', (t) => {
@@ -133,13 +136,24 @@ test('#751 AC1 на настоящем bash: упавший скрипт сле
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 },
});
// Шаг без `shell:` GitHub исполняет как `bash -e {0}` — обвязка тоже (#766).
const { step, script } = stepRun(file, name);
assert.equal(step.shell, null, `${file} «${name}»: shell не задан — pipefail только из тела`);
const run = (body) => {
writeFileSync(out, '');
return runStep(step, body, {
cwd: root, encoding: 'utf8',
env: { ...process.env, PATH: `${bin}:${process.env.PATH}`, GITHUB_STEP_SUMMARY: out, GITHUB_OUTPUT: out },
});
};
const r = run(script);
assert.notEqual(r.status, 0, `${file} «${name}»: падение скрипта прошло зелёным шагом`);
assert.match(r.stderr, /boom: scripts\//, `${file}: упал именно скрипт шага`);
assert.equal(readFileSync(out, 'utf8'), 'action=partial\n', `${file}: tee по-прежнему пишет вывод`);
// #766: без строки pipefail тот же шаг зелёный — обвязка своей защиты не
// добавляет, и убранный из шага pipefail этот тест видит.
const bare = script.split('\n').filter((line) => !PIPEFAIL.test(line)).join('\n');
assert.notEqual(bare, script, `${file}: строка pipefail найдена`);
assert.equal(run(bare).status, 0, `${file} «${name}»: без pipefail обвязка обязана показать зелёный шаг`);
}
});
+163
View File
@@ -0,0 +1,163 @@
// #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');
});