diff --git a/.github/workflows/_process-resume.yml b/.github/workflows/_process-resume.yml index bd63cede..8462221a 100644 --- a/.github/workflows/_process-resume.yml +++ b/.github/workflows/_process-resume.yml @@ -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" diff --git a/.github/workflows/release-review.yml b/.github/workflows/release-review.yml index cd916378..8beda322 100644 --- a/.github/workflows/release-review.yml +++ b/.github/workflows/release-review.yml @@ -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 diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 849a69d0..8e6dbb16 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -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: diff --git a/scripts/ci-proof.mjs b/scripts/ci-proof.mjs index ebb0a5ee..30787624 100644 --- a/scripts/ci-proof.mjs +++ b/scripts/ci-proof.mjs @@ -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; } diff --git a/scripts/night-red.mjs b/scripts/night-red.mjs index 4a0b82d5..1f0d0c1d 100644 --- a/scripts/night-red.mjs +++ b/scripts/night-red.mjs @@ -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 = //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= обязателен, получено «${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(), }); diff --git a/scripts/release-gate.mjs b/scripts/release-gate.mjs index 2f0b2f7d..c987c0ed 100644 --- a/scripts/release-gate.mjs +++ b/scripts/release-gate.mjs @@ -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` ); diff --git a/test/ci-proof.test.mjs b/test/ci-proof.test.mjs index e2ee23fe..d3b710e1 100644 --- a/test/ci-proof.test.mjs +++ b/test/ci-proof.test.mjs @@ -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', diff --git a/test/night-red.test.mjs b/test/night-red.test.mjs index 344519a5..6362e301 100644 --- a/test/night-red.test.mjs +++ b/test/night-red.test.mjs @@ -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); diff --git a/test/release-gate.test.mjs b/test/release-gate.test.mjs index ee5ce011..8bd717c0 100644 --- a/test/release-gate.test.mjs +++ b/test/release-gate.test.mjs @@ -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/); diff --git a/test/workflow-pipefail.test.mjs b/test/workflow-pipefail.test.mjs new file mode 100644 index 00000000..e643306f --- /dev/null +++ b/test/workflow-pipefail.test.mjs @@ -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 = /(?` кончается на + * первой непустой строке с отступом не больше ключа), строка начала и `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 по-прежнему пишет вывод`); + } +});