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
This commit is contained in:
Claude
2026-10-01 14:31:06 +00:00
committed by claude[bot]
parent d93bcf2628
commit 25001ef7ab
10 changed files with 292 additions and 23 deletions
+4
View File
@@ -54,7 +54,11 @@ jobs:
SHA: ${{ github.event.workflow_run.head_sha }}
EVENT: ${{ github.event.workflow_run.event }}
STATUS: ${{ github.event.workflow_run.status }}
# #751: без pipefail код конвейера — код tee, и исключение скрипта (gh,
# API) проходило зелёным шагом: событие возобновления терялось до
# прохода process-reconcile.
run: |
set -o pipefail
node scripts/process-resume.mjs \
--repo="$REPO" --branch="$BRANCH" --sha="$SHA" \
--event="$EVENT" --status="$STATUS" --apply=true | tee -a "$GITHUB_STEP_SUMMARY"
+3
View File
@@ -75,6 +75,9 @@ jobs:
FORCE: ${{ inputs.force }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
run: |
# Отказ prepare за `| tee` не должен идти дальше с неполным
# GITHUB_OUTPUT и proceed=true (#751).
set -o pipefail
doc=$(node scripts/release-review.mjs doc --tag="$TAG")
git fetch -q --tags origin
if [ -z "$CANDIDATE" ]; then
+5 -1
View File
@@ -368,7 +368,11 @@ jobs:
FULL_INPUT: ${{ inputs.full }}
MUTANTS_INPUT: ${{ inputs.mutants }}
REF_NAME: ${{ github.ref_name }}
run: node scripts/classify-changes.mjs --heavy | tee -a "$GITHUB_OUTPUT"
# #751: без pipefail упавший classify-changes оставлял heavy пустым —
# тяжёлые job молча пропускались, а job changes зеленела.
run: |
set -o pipefail
node scripts/classify-changes.mjs --heavy | tee -a "$GITHUB_OUTPUT"
- id: base
if: github.event_name != 'pull_request'
env:
+17 -8
View File
@@ -495,23 +495,32 @@ const apiHeaders = (token) => ({
'User-Agent': 'houseplan-ci-proof', 'X-GitHub-Api-Version': '2022-11-28',
});
/**
* База REST API (#751): `GITHUB_API_URL`, как у раннера, без хвостового `/`.
* На github.com это тот же `https://api.github.com`; на GHES — `…/api/v3`.
* `archive_download_url` приходит из API абсолютным и базу не берёт.
*/
export const githubApiBase = (env = process.env) => String(env.GITHUB_API_URL || 'https://api.github.com').replace(/\/+$/, '');
async function githubJson(url, token, fetchImpl) {
const response = await fetchImpl(url, { headers: apiHeaders(token) });
if (!response.ok) throw new Error(`GitHub API ${response.status}: ${await response.text()}`);
return response.json();
}
export async function githubCandidateTree({ repo, sha, token, fetchImpl = fetch }) {
const row = await githubJson(`https://api.github.com/repos/${repo}/git/commits/${sha}`, token, fetchImpl);
export async function githubCandidateTree({ repo, sha, token, fetchImpl = fetch, apiBase = githubApiBase() }) {
const row = await githubJson(`${apiBase}/repos/${repo}/git/commits/${sha}`, token, fetchImpl);
return row?.tree?.sha || null;
}
export async function loadGithubProofContext({ repo, run, token, fetchImpl = fetch, withReviewedRun = false }) {
export async function loadGithubProofContext({
repo, run, token, fetchImpl = fetch, withReviewedRun = false, apiBase = githubApiBase(),
}) {
const runId = runIdOf(run);
const attempt = runAttemptOf(run);
const name = ciProofArtifactName(runId, attempt);
const list = await githubJson(
`https://api.github.com/repos/${repo}/actions/runs/${runId}/artifacts?name=${encodeURIComponent(name)}`,
`${apiBase}/repos/${repo}/actions/runs/${runId}/artifacts?name=${encodeURIComponent(name)}`,
token, fetchImpl,
);
const artifact = (list?.artifacts || []).find((item) => item.name === name && !item.expired);
@@ -522,7 +531,7 @@ export async function loadGithubProofContext({ repo, run, token, fetchImpl = fet
proof = readCiProofArtifact(Buffer.from(await response.arrayBuffer()));
}
const jobsBody = await githubJson(
`https://api.github.com/repos/${repo}/actions/runs/${runId}/jobs?per_page=100`, token, fetchImpl,
`${apiBase}/repos/${repo}/actions/runs/${runId}/jobs?per_page=100`, token, fetchImpl,
);
const jobs = jobsBody?.jobs || [];
const reuseRuns = new Map();
@@ -532,10 +541,10 @@ export async function loadGithubProofContext({ repo, run, token, fetchImpl = fet
const sourceKey = reuseSourceKey(sourceId, sourceAttempt);
if (!sourceId || !sourceAttempt || reuseRuns.has(sourceKey)) continue;
const sourceRun = await githubJson(
`https://api.github.com/repos/${repo}/actions/runs/${sourceId}/attempts/${sourceAttempt}`, token, fetchImpl,
`${apiBase}/repos/${repo}/actions/runs/${sourceId}/attempts/${sourceAttempt}`, token, fetchImpl,
);
const sourceJobs = await githubJson(
`https://api.github.com/repos/${repo}/actions/runs/${sourceId}/attempts/${sourceAttempt}/jobs?per_page=100`,
`${apiBase}/repos/${repo}/actions/runs/${sourceId}/attempts/${sourceAttempt}/jobs?per_page=100`,
token, fetchImpl,
);
reuseRuns.set(sourceKey, { run: sourceRun, jobs: sourceJobs?.jobs || [] });
@@ -547,7 +556,7 @@ export async function loadGithubProofContext({ repo, run, token, fetchImpl = fet
const declared = proof?.evidence?.baselines?.reviewedRun;
if (withReviewedRun && declared) {
try {
reviewedRun = { run: await githubJson(`https://api.github.com/repos/${repo}/actions/runs/${declared}`, token, fetchImpl) };
reviewedRun = { run: await githubJson(`${apiBase}/repos/${repo}/actions/runs/${declared}`, token, fetchImpl) };
} catch {
reviewedRun = null;
}
+11 -9
View File
@@ -36,7 +36,7 @@ import { isMainModule } from './spawn-portable.mjs';
import { classify } from './change-classes.mjs';
import { issueTrailers } from './release-membership.mjs';
import { hasReleaseTrailer } from './ship-review.mjs';
import { CI_PROOF_POLICIES, evaluateCiProof, loadGithubProofContext } from './ci-proof.mjs';
import { CI_PROOF_POLICIES, evaluateCiProof, githubApiBase, loadGithubProofContext } from './ci-proof.mjs';
/** Сколько последних dispatch-прогонов Validate на `dev` смотрит поиск `G`. */
export const RUN_WINDOW = 50;
@@ -44,7 +44,6 @@ export const RUN_WINDOW = 50;
export const LIST_LIMIT = 10;
export const NIGHT_RED_MARKER_RE = /<!-- hp:night-red green=([0-9a-f]{40,64}) red=([0-9a-f]{40,64}) commits=([0-9a-f+]*) -->/g;
const GITHUB_API = 'https://api.github.com';
const short = (sha, n) => String(sha || '').slice(0, n);
const stamp = (run) => Date.parse(run?.created_at || '') || 0;
const runUrl = (repo, run) => run?.html_url || `https://github.com/${repo}/actions/runs/${run?.id}`;
@@ -236,12 +235,15 @@ export function gitClient({ cwd } = {}) {
};
}
/** Actions API: `token` — `github.token`; база — `GITHUB_API_URL`, как у раннера. */
export function actionsClient({ repo, token, apiBase = GITHUB_API, fetchImpl = fetch }) {
const base = String(apiBase || GITHUB_API).replace(/\/+$/, '');
const fetchApi = (url, init) => fetchImpl(String(url).startsWith(GITHUB_API) ? base + String(url).slice(GITHUB_API.length) : url, init);
/**
* Actions API: `token` — `github.token`; база — `GITHUB_API_URL`, как у раннера.
* #751: база идёт в `loadGithubProofContext` параметром — переписывать URL
* обёрткой над `fetch` больше незачем.
*/
export function actionsClient({ repo, token, apiBase = githubApiBase(), fetchImpl = fetch }) {
const base = String(apiBase || githubApiBase()).replace(/\/+$/, '');
const json = async (path) => {
const response = await fetchApi(`${GITHUB_API}/repos/${repo}${path}`, {
const response = await fetchImpl(`${base}/repos/${repo}${path}`, {
headers: {
Accept: 'application/vnd.github+json', Authorization: `Bearer ${token}`,
'User-Agent': 'houseplan-night-red', 'X-GitHub-Api-Version': '2022-11-28',
@@ -256,7 +258,7 @@ export function actionsClient({ repo, token, apiBase = GITHUB_API, fetchImpl = f
?.workflow_runs || [],
failedJobs: async (id) => ((await json(`/actions/runs/${id}/jobs?per_page=100`))?.jobs || [])
.filter((job) => job?.conclusion === 'failure').map((job) => job.name),
proofContext: (run) => loadGithubProofContext({ repo, run, token, fetchImpl: fetchApi }),
proofContext: (run) => loadGithubProofContext({ repo, run, token, fetchImpl, apiBase: base }),
};
}
@@ -284,7 +286,7 @@ if (isMainModule(import.meta.url)) {
if (!/^[1-9]\d*$/.test(redRunId)) throw new Error(`--red-run=<id прогона> обязателен, получено «${redRunId}»`);
const result = await nightRed({
repo, redRunId,
api: actionsClient({ repo, token: process.env.ACTIONS_TOKEN || '', apiBase: process.env.GITHUB_API_URL || GITHUB_API }),
api: actionsClient({ repo, token: process.env.ACTIONS_TOKEN || '', apiBase: githubApiBase() }),
issues: ghIssues({ repo }),
git: gitClient(),
});
+4 -3
View File
@@ -5,7 +5,7 @@ import { execFileSync } from 'node:child_process';
import { resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import {
CI_PROOF_POLICIES, evaluateCiProof, githubCandidateTree, localEvidence,
CI_PROOF_POLICIES, evaluateCiProof, githubApiBase, githubCandidateTree, localEvidence,
loadGithubProofContext, selectCiProofVerdict,
} from './ci-proof.mjs';
@@ -85,8 +85,9 @@ export async function classifyValidateProofs({
return selectCiProofVerdict(evaluations);
}
export const workflowRunsUrl = ({ repo, workflow, sha }) => (
`https://api.github.com/repos/${repo}/actions/workflows/${encodeURIComponent(workflow)}`
// #751: база — `GITHUB_API_URL` раннера (githubApiBase), не зашитый api.github.com.
export const workflowRunsUrl = ({ repo, workflow, sha, apiBase = githubApiBase() }) => (
`${apiBase}/repos/${repo}/actions/workflows/${encodeURIComponent(workflow)}`
+ `/runs?head_sha=${encodeURIComponent(sha)}&per_page=100`
);
+59 -1
View File
@@ -5,7 +5,8 @@ import { deflateRawSync } from 'node:zlib';
import { fileURLToPath } from 'node:url';
import {
CI_PROOF_POLICIES, MUTANT_JOB_PREFIX, baselineReviewedRun, buildCiProof, evaluateCiProof, loadGithubProofContext, localEvidence,
CI_PROOF_POLICIES, MUTANT_JOB_PREFIX, baselineReviewedRun, buildCiProof, evaluateCiProof, githubApiBase, githubCandidateTree,
loadGithubProofContext, localEvidence,
parseReuseMarker, productTreeId, readCiProofArtifact, requiredCheckIds, selectCiProofVerdict,
} from '../scripts/ci-proof.mjs';
import { REUSE_JOBS } from '../scripts/check-inputs.mjs';
@@ -442,6 +443,63 @@ test('#573 r1 M1: reviewed run спрашивается у GitHub только
assert.ok(urls.some((url) => url.endsWith('/actions/runs/34853080375')), 'release спрашивает объявленный run');
});
// #751: база REST API — `GITHUB_API_URL` раннера, а не зашитый api.github.com.
// На github.com раннер даёт тот же адрес; на GHES — `…/api/v3`.
const GHE = 'https://ghe.example/api/v3';
/** `GITHUB_API_URL` процесса на время `body`; прежнее значение возвращается. */
async function withApiUrl(value, body) {
const before = process.env.GITHUB_API_URL;
if (value === undefined) delete process.env.GITHUB_API_URL; else process.env.GITHUB_API_URL = value;
try { return await body(); } finally {
if (before === undefined) delete process.env.GITHUB_API_URL; else process.env.GITHUB_API_URL = before;
}
}
test('#751 AC2: githubApiBase — GITHUB_API_URL без хвостового /, без него — api.github.com', () => {
assert.equal(githubApiBase({}), 'https://api.github.com');
assert.equal(githubApiBase({ GITHUB_API_URL: '' }), 'https://api.github.com');
assert.equal(githubApiBase({ GITHUB_API_URL: `${GHE}/` }), GHE);
assert.equal(githubApiBase({ GITHUB_API_URL: 'https://api.github.com' }), 'https://api.github.com');
});
test('#751 AC2: loadGithubProofContext и githubCandidateTree ходят в apiBase; ссылка на архив — как отдал API', async () => {
const proof = {
reusedChecks: ['unit'], checks: { unit: { reuse: { sourceRun: 11, sourceAttempt: 2 } } },
evidence: { baselines: { reviewedRun: 99 } },
};
const archive = 'https://objects.example/artifacts/7/zip';
const urls = [];
const fetchImpl = async (url) => {
urls.push(String(url));
if (url === archive) return { ok: true, arrayBuffer: async () => zipWith(proof) };
if (/\/artifacts\?name=/.test(url)) return { ok: true, json: async () => ({ artifacts: [{ name: 'ci-proof-20-1', expired: false, archive_download_url: archive }] }) };
if (/\/jobs\?/.test(url)) return { ok: true, json: async () => ({ jobs: [] }) };
if (/\/git\/commits\//.test(url)) return { ok: true, json: async () => ({ tree: { sha: TREE } }) };
return { ok: true, json: async () => ({ id: 1 }) };
};
const run = { id: 20, run_attempt: 1 };
await loadGithubProofContext({ repo: 'x/y', run, token: 't', fetchImpl, withReviewedRun: true, apiBase: GHE });
assert.equal(await githubCandidateTree({ repo: 'x/y', sha: SHA, token: 't', fetchImpl, apiBase: GHE }), TREE);
assert.deepEqual(urls, [
`${GHE}/repos/x/y/actions/runs/20/artifacts?name=ci-proof-20-1`,
archive,
`${GHE}/repos/x/y/actions/runs/20/jobs?per_page=100`,
`${GHE}/repos/x/y/actions/runs/11/attempts/2`,
`${GHE}/repos/x/y/actions/runs/11/attempts/2/jobs?per_page=100`,
`${GHE}/repos/x/y/actions/runs/99`,
`${GHE}/repos/x/y/git/commits/${SHA}`,
]);
// Без параметра база — из окружения в момент вызова.
urls.length = 0;
await withApiUrl(`${GHE}/`, () => githubCandidateTree({ repo: 'x/y', sha: SHA, token: 't', fetchImpl }));
await withApiUrl(undefined, () => githubCandidateTree({ repo: 'x/y', sha: SHA, token: 't', fetchImpl }));
await withApiUrl(`${GHE}/`, () => loadGithubProofContext({ repo: 'x/y', run, token: 't', fetchImpl }));
assert.equal(urls[0], `${GHE}/repos/x/y/git/commits/${SHA}`);
assert.equal(urls[1], `https://api.github.com/repos/x/y/git/commits/${SHA}`);
assert.equal(urls[2], `${GHE}/repos/x/y/actions/runs/20/artifacts?name=ci-proof-20-1`);
});
test('#573: identity продуктового дерева не видит overlay эталонов, но видит всё остальное', () => {
const lines = [
'100644 blob 1111\tsrc/logic.ts',
+29 -1
View File
@@ -10,7 +10,7 @@ import { fileURLToPath } from 'node:url';
import { buildCiProof } from '../scripts/ci-proof.mjs';
import { jobInstanceNames, validateJobs } from '../scripts/workflow-jobs.mjs';
import {
LIST_LIMIT, NIGHT_RED_MARKER_RE, commentBody, commentVerdict, countsForNightRed, findLastGreen, gitClient,
LIST_LIMIT, NIGHT_RED_MARKER_RE, actionsClient, commentBody, commentVerdict, countsForNightRed, findLastGreen, gitClient,
nightRed, parseNightRedMarkers, rangeSuspects,
} from '../scripts/night-red.mjs';
@@ -413,6 +413,34 @@ async function runNightStep(t, { cwd, items, issues = {}, fail = false, scripts
return { status, stdout, stderr, requests: api.requests, log: text(files.log).split('\n').filter(Boolean), summary: text(files.summary), comment };
}
// #751: база API идёт в loadGithubProofContext параметром; обёртки над fetch,
// переписывавшей префикс api.github.com, больше нет. Ссылку на архив
// доказательства API отдаёт абсолютной — она не трогается.
test('#751 AC2: actionsClient — прогоны, job и доказательство из apiBase, архив — по ссылке API', async () => {
const base = 'https://ghe.example/api/v3';
const archive = 'https://objects.example/artifacts/5/zip';
const urls = [];
const fetchImpl = async (url) => {
urls.push(String(url));
if (url === archive) return { ok: true, arrayBuffer: async () => zipWith({}) };
if (/\/artifacts\?name=/.test(url)) return { ok: true, json: async () => ({ artifacts: [{ name: 'ci-proof-5-1', expired: false, archive_download_url: archive }] }) };
return { ok: true, json: async () => ({ id: 5, workflow_runs: [], jobs: [] }) };
};
const api = actionsClient({ repo: REPO, token: 'actions-token', apiBase: `${base}/`, fetchImpl });
await api.run(5);
await api.runs();
await api.failedJobs(5);
await api.proofContext({ id: 5, run_attempt: 1 });
assert.deepEqual(urls, [
`${base}/repos/${REPO}/actions/runs/5`,
`${base}/repos/${REPO}/actions/workflows/validate.yml/runs?branch=dev&event=workflow_dispatch&per_page=50`,
`${base}/repos/${REPO}/actions/runs/5/jobs?per_page=100`,
`${base}/repos/${REPO}/actions/runs/5/artifacts?name=ci-proof-5-1`,
archive,
`${base}/repos/${REPO}/actions/runs/5/jobs?per_page=100`,
]);
});
test('#736 AC2/AC3 на настоящем bash: шаг ночи читает Actions токеном ночи, пишет issue токеном процесса', async (t) => {
if (!hasBash()) { t.skip('bash недоступен'); return; }
const { cwd, sha } = history(t);
+15
View File
@@ -136,6 +136,21 @@ test('release gate can target the dedicated exact-SHA performance workflow', ()
);
});
test('#751 AC2: workflowRunsUrl — база из apiBase или GITHUB_API_URL раннера', () => {
const args = { repo: 'Matysh/houseplan-card', workflow: 'validate.yml', sha: 'abc' };
const tail = '/repos/Matysh/houseplan-card/actions/workflows/validate.yml/runs?head_sha=abc&per_page=100';
assert.equal(workflowRunsUrl({ ...args, apiBase: 'https://ghe.example/api/v3' }), `https://ghe.example/api/v3${tail}`);
const before = process.env.GITHUB_API_URL;
try {
process.env.GITHUB_API_URL = 'https://ghe.example/api/v3/';
assert.equal(workflowRunsUrl(args), `https://ghe.example/api/v3${tail}`, 'без параметра — окружение раннера');
delete process.env.GITHUB_API_URL;
assert.equal(workflowRunsUrl(args), `https://api.github.com${tail}`, 'без GITHUB_API_URL — прежний адрес');
} finally {
if (before === undefined) delete process.env.GITHUB_API_URL; else process.env.GITHUB_API_URL = before;
}
});
test('#541: the release documents describe proof semantics', () => {
const development = readFileSync(new URL('../docs/DEVELOPMENT.md', import.meta.url), 'utf8');
assert.match(development, /requires a complete Validate proof for its SHA and\nGit tree/);
+145
View File
@@ -0,0 +1,145 @@
// #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 по-прежнему пишет вывод`);
}
});