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
+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`
);