mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-02 12:49:56 +00:00
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:
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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(),
|
||||
});
|
||||
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
@@ -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/);
|
||||
|
||||
@@ -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 по-прежнему пишет вывод`);
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user