src/github/actions.ts
404 lines and 22 functions in DrDustinEdwards/capsid, at commit daa875b on 2026-10-24. This file on GitHub.
Functions
| Function | Lines | Complexity | Covered | CRAP | Mutation |
|---|---|---|---|---|---|
| ciStatus | 46 to 112 | 24 | 94% | WatchCRAP 24.1 | No data: mutation testing has not run on this repo yet |
| stepLog | 149 to 191 | 9 | 100% | WatchCRAP 9 | No data: mutation testing has not run on this repo yet |
| <anonymous:161> | 161 to 166 | 4 | 100% | OkCRAP 4 | No data: mutation testing has not run on this repo yet |
| errorBody | 195 to 195 | 2 | 100% | OkCRAP 2 | No data: mutation testing has not run on this repo yet |
| ciStatusFromRuns | 197 to 281 | 11 | 100% | WatchCRAP 11 | No data: mutation testing has not run on this repo yet |
| <anonymous:206> | 206 to 217 | 2 | 100% | OkCRAP 2 | No data: mutation testing has not run on this repo yet |
| <anonymous:231> | 231 to 231 | 1 | 100% | OkCRAP 1 | No data: mutation testing has not run on this repo yet |
| <anonymous:251> | 251 to 251 | 1 | 100% | OkCRAP 1 | No data: mutation testing has not run on this repo yet |
| <anonymous:252> | 252 to 255 | 2 | 100% | OkCRAP 2 | No data: mutation testing has not run on this repo yet |
| <anonymous:254> | 254 to 254 | 1 | 100% | OkCRAP 1 | No data: mutation testing has not run on this repo yet |
| <anonymous:254> | 254 to 254 | 1 | 100% | OkCRAP 1 | No data: mutation testing has not run on this repo yet |
| <anonymous:256> | 256 to 256 | 1 | 100% | OkCRAP 1 | No data: mutation testing has not run on this repo yet |
| <anonymous:266> | 266 to 266 | 1 | 100% | OkCRAP 1 | No data: mutation testing has not run on this repo yet |
| stepView | 307 to 314 | 3 | 100% | OkCRAP 3 | No data: mutation testing has not run on this repo yet |
| jobView | 316 to 325 | 5 | 100% | OkCRAP 5 | No data: mutation testing has not run on this repo yet |
| pickOne | 330 to 350 | 8 | 100% | WatchCRAP 8 | No data: mutation testing has not run on this repo yet |
| <anonymous:338> | 338 to 338 | 1 | 100% | OkCRAP 1 | No data: mutation testing has not run on this repo yet |
| <anonymous:341> | 341 to 341 | 1 | 100% | OkCRAP 1 | No data: mutation testing has not run on this repo yet |
| <anonymous:348> | 348 to 348 | 1 | 100% | OkCRAP 1 | No data: mutation testing has not run on this repo yet |
| ciRunJobs | 355 to 429 | 15 | 95% | WatchCRAP 15 | No data: mutation testing has not run on this repo yet |
| <anonymous:408> | 408 to 408 | 1 | 100% | OkCRAP 1 | No data: mutation testing has not run on this repo yet |
| <anonymous:412> | 412 to 412 | 1 | 100% | OkCRAP 1 | No data: mutation testing has not run on this repo yet |
Imports
It imports src/env.ts, src/github/client.ts, src/redact.ts. Imported by src/github.ts.
Source
import type { RepoEnv as Env } from "../env";
import { redact, redactText, type Redacted } from "../redact";
import {
assertRepoArg,
cachedGet,
ghFetch,
resolveRepo,
} from "./client";
// Recent CI workflow runs for a namespace's repo, via the GitHub App. Read-only.
// For the most recent failed run it also returns the failing jobs and steps and a
// bounded log tail. Needs the App's Actions: Read permission; a 403 is surfaced as
// a named error.
//
// The log tail is withheld from read-only keys. Run metadata (name, sha, conclusion)
// is inert; a build log carries whatever the workflow echoed: resolved binding ids,
// account ids, wrangler output, and any variable a step printed by accident. The runs
// list stays open to read-only keys. Every log text returned, to anyone, is redacted
// first (src/redact.ts). With run_id, jobs/job/step drill into any run the same way
// (ciRunJobs below), behind the same log gate.
// A ref matching this shape is filtered as a head sha, anything else as a branch.
// GitHub has two query parameters for these and none that accepts either, so the
// tool takes one `ref` and decides here.
const SHA_SHAPE = /^[0-9a-f]{7,40}$/i;
interface CiRunsOpts {
limit?: number;
logTail?: boolean;
ref?: string;
runId?: number;
}
interface CiDrillOpts extends CiRunsOpts {
jobs?: boolean;
job?: string;
step?: string;
}
type CiRunsResult = Awaited<ReturnType<typeof ciStatusFromRuns>>;
type CiJobsResult = Awaited<ReturnType<typeof ciRunJobs>>;
// Without the drill-in arguments the answer is always the run list, and the callers
// that read `runs` (job-outcomes.ts, watcher.ts) keep that type. Only a call that
// passes jobs, job or step can get the jobs view back.
export async function ciStatus(env: Env, namespace: string, repoSelector?: string, opts?: CiRunsOpts): Promise<CiRunsResult>;
export async function ciStatus(env: Env, namespace: string, repoSelector: string | undefined, opts: CiDrillOpts): Promise<CiRunsResult | CiJobsResult>;
export async function ciStatus(
env: Env,
namespace: string,
repoSelector?: string,
opts: CiDrillOpts = {}
) {
// The drill-in arguments narrow one run, so each needs the one above it. Refused
// before any GitHub call rather than ignored, so a caller never reads a run list
// believing it is the step it asked for.
if (opts.step !== undefined && opts.job === undefined) {
throw new Error("ci_status: step needs job, the job (name or id) the step ran in, and run_id");
}
if (opts.job !== undefined && opts.runId === undefined) {
throw new Error("ci_status: job needs run_id, the run the job belongs to. capsid_inspect_repo action ci without it lists runs and their ids");
}
if (opts.jobs === true && opts.runId === undefined) {
throw new Error("ci_status: jobs needs run_id, the run whose jobs to list. capsid_inspect_repo action ci without it lists runs and their ids");
}
if (opts.jobs === true && opts.job !== undefined) {
throw new Error("ci_status: pass jobs: true to list every job in the run, or job to read one, not both");
}
if (opts.ref) assertRepoArg("ref", opts.ref);
const { owner, repo, full } = await resolveRepo(env, namespace, repoSelector);
const limit = opts.limit && opts.limit > 0 ? Math.min(opts.limit, 20) : 10;
let query = `/repos/${owner}/${repo}/actions/runs?per_page=${limit}`;
if (opts.runId) {
// One run, by id. Wrapped back into the list's shape so the failed-run drill-in
// has one code path.
const one = await ghFetch(env, owner, repo, `/repos/${owner}/${repo}/actions/runs/${opts.runId}`);
if (one.status === 404) throw new Error(`ci_status: run ${opts.runId} does not exist on ${full}`);
if (!one.ok) throw new Error(`ci_status run lookup failed (${one.status}): ${await errorBody(one)}`);
const run = await one.json();
if (opts.jobs === true || opts.job !== undefined) {
return ciRunJobs(env, { owner, repo, full }, run as CiRun, { job: opts.job, step: opts.step, logTail: opts.logTail === true });
}
return ciStatusFromRuns(env, owner, repo, full, [run as CiRun], opts.logTail === true, { run_id: opts.runId });
}
if (opts.ref) {
if (SHA_SHAPE.test(opts.ref)) {
// An abbreviated sha is expanded first. GitHub matches head_sha exactly and
// documents nothing, so a short sha returns an empty run list rather than an
// error. One extra GET, only when the ref is short.
let full40 = opts.ref;
if (opts.ref.length < 40) {
const commit = await cachedGet(env, owner, repo, `/repos/${owner}/${repo}/commits/${encodeURIComponent(opts.ref)}`);
if (!commit.ok) {
throw new Error(
`ci_status: ${opts.ref} does not resolve to a commit on ${full} (${commit.status}), so runs cannot be filtered by it`
);
}
full40 = ((await commit.json()) as { sha: string }).sha;
}
query += `&head_sha=${encodeURIComponent(full40)}`;
} else {
query += `&branch=${encodeURIComponent(opts.ref)}`;
}
}
const resp = await ghFetch(env, owner, repo, query);
if (resp.status === 403) {
throw new Error(
"ci_status: the capsid-repo-access GitHub App lacks Actions: Read. Add that permission in the App settings and accept the installation prompt, then retry."
);
}
if (!resp.ok) throw new Error(`ci_status failed (${resp.status}): ${redactText(await resp.text())}`);
const data = (await resp.json()) as { workflow_runs: CiRun[] };
return ciStatusFromRuns(env, owner, repo, full, data.workflow_runs, opts.logTail === true, opts.ref ? { ref: opts.ref } : {});
}
interface CiRun {
id: number;
name: string;
head_sha: string;
status: string;
conclusion: string | null;
event: string;
created_at: string;
updated_at?: string;
html_url: string;
}
// The failing step's log, 64KB from its end, for write-grant keys. A job log ends
// with post-run cleanup, so the last few thousand characters of a failed run are
// `git config --unset-all` lines and the failure is far earlier. The region is found
// by the failing step and then tailed. The read-only tier is unchanged.
export const CI_LOG_BUDGET = 64 * 1024;
// Actions labels groups by command, not step name, so a step's name appears nowhere
// in its own log and a name search matches an incidental later occurrence. The
// failing step is found by its started_at..completed_at window instead: every log
// line carries an ISO timestamp.
//
// The jobs API truncates those to the second while log lines carry sub-second
// stamps, so the upper bound is extended to the end of that second, or the last
// second of output (where the error is) is dropped. Truncating down only widens the
// lower bound, so `from` needs no adjustment.
const STEP_STAMP_PRECISION_MS = 1000;
// Every log text ci_status returns is redacted (src/redact.ts), and redacted BEFORE the
// budget cut: a cut first could split a secret so its remaining half no longer matches
// any pattern, or keep a private key's body without its BEGIN line.
//
// `label` is how the region is named: "failing step" for the failed-run drill-in, whose
// wording callers already read, and "step" for a step the caller asked for by name.
function stepLog(
log: string,
step: { name?: string; started_at?: string | null; completed_at?: string | null } | undefined,
label: "failing step" | "step" = "failing step"
): { text: string; how: string; redactions: Redacted["redactions"] } {
const name = step?.name;
const from = step?.started_at ? Date.parse(step.started_at) : NaN;
const to = step?.completed_at ? Date.parse(step.completed_at) : NaN;
if (Number.isFinite(from) && Number.isFinite(to)) {
const windowed = log
.split("\n")
.filter((line) => {
const stamp = /^(\S+Z)/.exec(line);
if (!stamp) return false;
const at = Date.parse(stamp[1]);
return Number.isFinite(at) && at >= from && at < to + STEP_STAMP_PRECISION_MS;
})
.join("\n");
if (windowed.length > 0) {
const clean = redact(windowed);
return {
text: clean.text.length > CI_LOG_BUDGET ? clean.text.slice(-CI_LOG_BUDGET) : clean.text,
how:
clean.text.length > CI_LOG_BUDGET
? `${label} "${name}" by timestamp window, last ${CI_LOG_BUDGET} bytes of it`
: `${label} "${name}" by timestamp window, whole (${clean.text.length} bytes)`,
redactions: clean.redactions,
};
}
}
// NAMED FALLBACK, not a silent one. "The end of the job log" and "the failing
// step" are different claims and the caller is told which one this is.
const clean = redact(log);
return {
text: clean.text.slice(-CI_LOG_BUDGET),
how: name
? `step "${name}" had no usable timestamp window, so this is the last ${CI_LOG_BUDGET} bytes of the whole job, cleanup included`
: `no failing step was named, so this is the last ${CI_LOG_BUDGET} bytes of the whole job, cleanup included`,
redactions: clean.redactions,
};
}
// A GitHub error body is echoed into a result too, so it is redacted as well, before
// its 200-character cut for the same reason as above.
const errorBody = async (resp: Response): Promise<string> => redactText(await resp.text()).slice(0, 200) || "no response body";
async function ciStatusFromRuns(
env: Env,
owner: string,
repo: string,
full: string,
workflowRuns: CiRun[],
logTail: boolean,
filter: { ref?: string; run_id?: number }
) {
const runs = workflowRuns.map((r) => ({
name: r.name,
head_sha: r.head_sha?.slice(0, 7),
status: r.status,
conclusion: r.conclusion,
event: r.event,
created_at: r.created_at,
// When a completed run finished: the watcher's deploy-drift check waits a settle
// window after master's own CI run ends (src/watcher-detect.ts, deployVerdict).
updated_at: r.updated_at ?? null,
url: r.html_url,
}));
const result: {
repo: string;
filter?: { ref?: string; run_id?: number };
runs: typeof runs;
failed_run?: unknown;
} = { repo: full, runs };
if (filter.ref || filter.run_id) result.filter = filter;
// Drill into the most recent failed run so the caller sees why, not just that.
// Every degraded path is named: an errored jobs fetch or log fetch is reported
// rather than dropped, so a write-grant caller can tell "no log" from "the log was
// refused for you".
const failed = workflowRuns.find((r) => r.conclusion === "failure");
if (failed) {
const failedRun: Record<string, unknown> = {
name: failed.name,
head_sha: failed.head_sha?.slice(0, 7),
url: failed.html_url,
};
const jobsResp = await ghFetch(env, owner, repo, `/repos/${owner}/${repo}/actions/runs/${failed.id}/jobs`);
if (!jobsResp.ok) {
failedRun.jobs_unavailable = `${jobsResp.status}: ${await errorBody(jobsResp)}`;
} else {
const jobsData = (await jobsResp.json()) as {
jobs: Array<{
id: number;
name: string;
conclusion: string | null;
steps?: Array<{ name: string; conclusion: string | null; started_at?: string | null; completed_at?: string | null }>;
}>;
};
failedRun.jobs = jobsData.jobs
.filter((j) => j.conclusion === "failure")
.map((j) => ({
name: j.name,
failed_steps: (j.steps ?? []).filter((s) => s.conclusion === "failure").map((s) => s.name),
}));
const firstFailedJob = jobsData.jobs.find((j) => j.conclusion === "failure");
if (!logTail) {
// Unchanged: this is the read-only tier's boundary, not a degraded path.
failedRun.log_tail_withheld =
"read-only key: run metadata only. A write-grant key returns the failing step's log.";
} else if (!firstFailedJob) {
failedRun.log_tail_unavailable = "the run is marked failed but no job in it is, so there is no job log to read";
} else {
const logResp = await ghFetch(env, owner, repo, `/repos/${owner}/${repo}/actions/jobs/${firstFailedJob.id}/logs`);
if (logResp.ok) {
const failedStep = (firstFailedJob.steps ?? []).find((s) => s.conclusion === "failure");
const picked = stepLog(await logResp.text(), failedStep);
failedRun.failing_job = firstFailedJob.name;
failedRun.failing_step = failedStep?.name ?? null;
failedRun.log_region = picked.how;
failedRun.log = picked.text;
if (Object.keys(picked.redactions).length > 0) failedRun.log_redactions = picked.redactions;
} else {
failedRun.log_tail_unavailable = `${logResp.status}: ${await errorBody(logResp)}`;
}
}
}
result.failed_run = failedRun;
}
return result;
}
// One page of a run's jobs, the latest attempt only. GitHub's page limit, and the cap:
// a run with more jobs than this says so rather than being paged through.
export const CI_JOBS_MAX = 100;
interface CiJob {
id: number;
name: string;
status: string;
conclusion: string | null;
started_at?: string | null;
completed_at?: string | null;
html_url?: string;
steps?: CiStep[];
}
interface CiStep {
number: number;
name: string;
status: string;
conclusion: string | null;
started_at?: string | null;
completed_at?: string | null;
}
const stepView = (s: CiStep) => ({
number: s.number,
name: s.name,
status: s.status,
conclusion: s.conclusion,
started_at: s.started_at ?? null,
completed_at: s.completed_at ?? null,
});
const jobView = (j: CiJob) => ({
id: j.id,
name: j.name,
status: j.status,
conclusion: j.conclusion,
started_at: j.started_at ?? null,
completed_at: j.completed_at ?? null,
url: j.html_url ?? null,
steps: (j.steps ?? []).map(stepView),
});
// A job or step named by the caller: a number is tried as the id (a job) or the number
// (a step) first, then as a name. A name two entries share is refused with what tells
// them apart, never resolved to the first.
function pickOne<T extends { name: string }>(
items: T[],
wanted: string,
byNumber: (item: T) => number,
what: string,
where: string
): T {
if (/^\d+$/.test(wanted)) {
const numbered = items.find((item) => byNumber(item) === Number(wanted));
if (numbered) return numbered;
}
const named = items.filter((item) => item.name === wanted);
if (named.length === 1) return named[0];
if (named.length > 1) {
throw new Error(
`ci_status: ${named.length} ${what}s in ${where} are named "${wanted}". Pass one by its ${what === "job" ? "id" : "number"}: ${named.map(byNumber).join(", ")}`
);
}
const known = items.slice(0, 30).map((item) => `"${item.name}" (${byNumber(item)})`);
throw new Error(`ci_status: no ${what} "${wanted}" in ${where}. It has: ${known.join(", ") || "none"}${items.length > 30 ? ", and more" : ""}`);
}
/** Every job of one run with its steps (jobs: true), or one job, or one step of it with
* that step's log. For any run, not only a failed one. The log is withheld from a
* read-only caller exactly as the failed-run tail is, and redacted for everyone else. */
async function ciRunJobs(
env: Env,
target: { owner: string; repo: string; full: string },
run: CiRun,
opts: { job?: string; step?: string; logTail: boolean }
) {
const { owner, repo, full } = target;
const resp = await ghFetch(
env,
owner,
repo,
`/repos/${owner}/${repo}/actions/runs/${run.id}/jobs?filter=latest&per_page=${CI_JOBS_MAX}`
);
if (resp.status === 403) {
throw new Error(
"ci_status: the capsid-repo-access GitHub App lacks Actions: Read. Add that permission in the App settings and accept the installation prompt, then retry."
);
}
if (!resp.ok) throw new Error(`ci_status jobs lookup for run ${run.id} failed (${resp.status}): ${await errorBody(resp)}`);
const data = (await resp.json()) as { total_count?: number; jobs: CiJob[] };
const jobs = data.jobs.slice(0, CI_JOBS_MAX);
const total = typeof data.total_count === "number" ? data.total_count : jobs.length;
const result: Record<string, unknown> = {
repo: full,
filter: {
run_id: run.id,
...(opts.job === undefined ? { jobs: true } : { job: opts.job }),
...(opts.step === undefined ? {} : { step: opts.step }),
},
run: {
name: run.name,
head_sha: run.head_sha?.slice(0, 7),
status: run.status,
conclusion: run.conclusion,
event: run.event,
created_at: run.created_at,
url: run.html_url,
},
};
const truncated =
total > jobs.length
? `the run has ${total} jobs in its latest attempt and only the first ${jobs.length} were read (at most ${CI_JOBS_MAX})`
: null;
if (opts.job === undefined) {
result.jobs = jobs.map(jobView);
result.jobs_total = total;
if (truncated) result.jobs_truncated = truncated;
return result;
}
const where = `run ${run.id}${truncated ? ` (${truncated})` : ""}`;
const job = pickOne(jobs, opts.job, (j) => j.id, "job", where);
result.job = jobView(job);
if (opts.step === undefined) return result;
const step = pickOne(job.steps ?? [], opts.step, (s) => s.number, "step", `job "${job.name}"`);
result.step = stepView(step);
if (!opts.logTail) {
// The read-only tier's boundary, worded as the failed-run tail's is.
result.log_withheld = "read-only key: step metadata only. A write-grant key returns the step's log.";
return result;
}
const logResp = await ghFetch(env, owner, repo, `/repos/${owner}/${repo}/actions/jobs/${job.id}/logs`);
if (!logResp.ok) {
result.log_unavailable = `${logResp.status}: ${await errorBody(logResp)}`;
return result;
}
const picked = stepLog(await logResp.text(), step, "step");
result.log_region = picked.how;
result.log = picked.text;
if (Object.keys(picked.redactions).length > 0) result.log_redactions = picked.redactions;
return result;
}