Ctrl K
ComunidadGuías…

scripts/check-ci-health.mjs

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
#!/usr/bin/env node
// Weekly CI health scan: four deterministic detectors over the workflows that
// gate merges, each finding kept as one rolling GitHub issue.
//
//   flaky     a job failed, then passed when the same run was re-run. Pull
//             request runs count: a re-run tests the same commit, so a flip is
//             a flake whatever the branch. The failed attempt's log names the
//             failing tests, and a flake whose named test files have all
//             changed on main since it failed is not reported, unless the log
//             came from a fork, which writes its own (#10013, #10016
//             were filed days after their fixes merged). A recurrence after
//             that change is newer than it, so it is reported.
//   timeout   a job on a push to main was cancelled after running 10+ minutes
//             (push runs on main are not superseded, so this is a job timeout;
//             unit-shard (5) hung silently for 14 minutes on 2098d920ae)
//   failing   a workflow's two newest decided runs on main both failed
//   slower    a job's median time on successful main runs rose 20% and 30s+
//             week over week. Samples are 15 runs spread across each week: the
//             15 newest are one busy day, and runner speed alone moves a job
//             between ~125s and ~175s from one day to the next (#10018). A
//             path-filtered job that ran in fewer than 5 of them is not judged;
//             covering those would mean reading every main run's jobs.
//
// Issues are matched by exact title and must have been opened by the scan
// itself (github-actions[bot]). An open match has its body replaced with the
// newest evidence; otherwise a new issue is opened. Nothing is closed
// automatically: a person decides when a finding is fixed.
//
// Workflow and job names reach issues from pull_request runs, whose workflow
// file a fork controls, so every name is reduced to a plain label first.
import { execFileSync } from 'node:child_process';
import { appendFileSync, writeFileSync } from 'node:fs';
import { parseArgs } from 'node:util';
import { corroborateRunListings, reduceRunListings } from './lib/gh-run-listing.mjs';
import { isMainModule } from './lib/main-module.mjs';

export const MONITORED_WORKFLOWS = ['test.yml', 'typecheck.yml', 'lint-code.yml', 'proto-check.yml'];
export const ISSUE_AUTHOR = 'github-actions[bot]';
const DAY_MS = 86_400_000;
const WINDOW_DAYS = 7;
// The weekly cron fires late by minutes to hours. Event detectors look back a
// little further than a week so nothing falls between two scans; an overlap
// only re-updates an issue.
const EVENT_OVERLAP_MS = 6 * 3_600_000;
const TIMEOUT_MIN_SECONDS = 600;
const RERUNS_PER_WORKFLOW = 60;
const MAIN_LISTING_SIZE = 30;
// An aggregate job (`unit`, `proto-freshness`) starts after the jobs it needs
// finish and fails within seconds when one of them failed. That failure is the
// dependency's, not its own. Independent jobs start with the run, so they are
// never excused this way, however fast they fail.
const DEPENDENT_FAILURE_SECONDS = 15;
export const SLOWDOWN = { ratio: 1.2, minSeconds: 30, minSamples: 5, samplesPerWindow: 15 };
// Failed-attempt logs read per scan, newest flakes first. Each is one API call
// plus one lookup per named test file.
const FLAKE_LOGS_PER_SCAN = 40;
const TESTS_PER_EVIDENCE = 5;
const FILES_PER_ATTRIBUTION = 20;
const ASSOCIATION_LOOKUPS = 3;
// A test path from a fork-controlled log is copied into an issue body, which
// GitHub rejects above 65,536 characters. Real paths here stay under 120.
const MAX_TEST_PATH = 200;

// `count` runs spread evenly over `runs` ordered by creation time, oldest
// first, always keeping the oldest and the newest.
export function spreadSample(runs, count) {
  const ordered = [...runs].sort((a, b) => Date.parse(a.created_at) - Date.parse(b.created_at));
  if (ordered.length <= count) return ordered;
  if (count === 1) return [ordered.at(-1)];
  return Array.from({ length: count }, (_, index) => ordered[Math.round(index * (ordered.length - 1) / (count - 1))]);
}

// A repo-relative test path and line, or nothing. Logs are fork-controlled on
// pull_request runs, so only this exact shape ever reaches an issue: a plain
// top-level directory (tests are spread over a dozen, src-tauri/ and
// consumer-prices-core/ among them), no scheme, no `..`, no markup.
const TEST_PATH = /^[a-z][\w-]*\/(?:[\w-][\w.-]*\/)*[\w-][\w.-]*\.(?:test|spec)\.[cm]?[jt]sx?(?::\d+(?::\d+)?)?$/;
const FAILING_TEST_PATTERNS = [
  // node:test, in its "failing tests:" summary
  /^test at (\S+)$/,
  // scripts/run-data-tests.mjs watchdog
  /^Data test file still running after --file-timeout=\d+ms \(a test hung or a handle was left open\): (\S+)$/,
  // Playwright list reporter: "  1) [chromium] › e2e/x.spec.ts:474:3 › title"
  /^\d+\) \[[\w-]+\] › (\S+) ›/,
  // vitest: " FAIL  tests/dom/x.test.mts > suite > test"
  /^FAIL\s+(\S+)(?:\s|$)/,
];

export function parseFailingTests(log) {
  const found = new Set();
  for (const raw of String(log).split('\n')) {
    // Strip the runner's timestamp prefix and colour codes.
    const line = raw.replace(/^\d{4}-\d\d-\d\dT[\d:.]+Z ?/, '').replace(/\x1b\[[\d;]*m/g, '').trim();
    for (const pattern of FAILING_TEST_PATTERNS) {
      const path = pattern.exec(line)?.[1]?.replace(/^\.\//, '');
      if (path && path.length <= MAX_TEST_PATH && TEST_PATH.test(path)) found.add(path);
    }
  }
  // Prefer file:line entries; drop a bare file when a line entry names it.
  return [...found].filter((path) => path.includes(':') || ![...found].some((other) => other.startsWith(`${path}:`)));
}

function runOrigin(run) {
  if (run.event === 'push' && run.head_branch === 'main') return 'main';
  if (run.event === 'pull_request') return 'pull request';
  return safeLabel(run.event ?? 'unknown');
}

export function median(values) {
  if (!values.length) return null;
  const sorted = [...values].sort((a, b) => a - b);
  const mid = Math.floor(sorted.length / 2);
  return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
}

export function jobSeconds(job) {
  if (!job.started_at || !job.completed_at) return null;
  return (Date.parse(job.completed_at) - Date.parse(job.started_at)) / 1000;
}

// Keep names readable but inert: no mentions, issue references, URLs, markup
// or table breaks. GitHub autolinks a bare `www.` host even after `_`.
export function safeLabel(value) {
  const label = String(value ?? '').replace(/[^\w .,()+-]/g, '_').replace(/www\./gi, 'www_')
    .replace(/\s+/g, ' ').trim();
  return (label || '_').slice(0, 100);
}

function evidence(run, detail) {
  return { url: run.html_url, sha: run.head_sha.slice(0, 10), at: run.created_at, detail };
}

function addFinding(findings, finding, item) {
  const existing = findings.get(finding.title);
  if (existing) existing.evidence.push(item);
  else findings.set(finding.title, { ...finding, evidence: [item] });
}

export function inheritedFailure(job, jobs) {
  const seconds = jobSeconds(job);
  if (seconds === null || seconds >= DEPENDENT_FAILURE_SECONDS) return false;
  const started = Date.parse(job.started_at);
  // A dependency that timed out ends `cancelled`, not `failure`.
  return jobs.some((other) => other !== job && (other.conclusion === 'failure' || other.conclusion === 'cancelled')
    && other.completed_at && Date.parse(other.completed_at) <= started);
}

// reruns: [{ run, attempts: [[job, ...] per attempt, oldest first] }]
export function findFlakyJobs(reruns) {
  const findings = new Map();
  for (const { run, attempts } of reruns) {
    const final = attempts.at(-1) ?? [];
    for (const job of final) {
      if (job.conclusion !== 'success') continue;
      const isOwnFailure = (earlier, jobs) => earlier.name === job.name && earlier.conclusion === 'failure' && !inheritedFailure(earlier, jobs);
      const failedAt = attempts.slice(0, -1).findIndex((jobs) => jobs.some((earlier) => isOwnFailure(earlier, jobs)));
      if (failedAt === -1) continue;
      // Every failed attempt of this job: each can fail in a different test.
      const failedJobIds = attempts.slice(0, -1)
        .flatMap((jobs) => jobs.filter((earlier) => isOwnFailure(earlier, jobs)))
        .map((earlier) => earlier.id)
        .filter(Boolean);
      const workflow = safeLabel(run.name);
      const name = safeLabel(job.name);
      addFinding(findings, {
        kind: 'flaky',
        title: `CI health (flaky): ${workflow} / ${name}`,
        summary: `\`${name}\` in **${workflow}** failed, then passed when the same commit was re-run.`,
      }, {
        ...evidence(run, `${runOrigin(run)}: failed on attempt ${failedAt + 1}, passed on attempt ${attempts.length}`),
        failedJobIds,
        pullRequests: (run.pull_requests ?? []).map((pull) => pull.number).filter(Number.isSafeInteger),
        // A fork's job log is the contributor's own text: it may name tests in
        // the issue but never decides that a flake was fixed. Unknown
        // provenance counts as a fork.
        trustedLog: Boolean(run.repository?.full_name) && run.head_repository?.full_name === run.repository.full_name,
      });
    }
  }
  return [...findings.values()];
}

// Name each flake's failing tests from its failed attempt's log, and drop a
// flake whose named test files have all changed on main since it failed.
//   readLog(jobId) -> log text (throws when unreadable)
//   changeSince(file, iso, pullRequests) -> short sha of the first main commit
//     touching `file` at or after `iso`, ignoring the merge of the flaking pull
//     request itself, or null
// A row whose log or history is unreadable, or whose log names no test, is
// kept: without a name there is nothing to prove the flake was addressed. So is
// a row from a fork, whose log the contributor writes and could fill with any
// recently changed test.
export function attributeFlakes(findings, { readLog, changeSince, limit = FLAKE_LOGS_PER_SCAN }) {
  const rows = findings.filter((finding) => finding.kind === 'flaky')
    .flatMap((finding) => finding.evidence)
    .filter((row) => row.failedJobIds?.length)
    .sort((a, b) => Date.parse(b.at) - Date.parse(a.at))
    .slice(0, limit);
  const warnings = [];
  for (const row of rows) {
    // Every failed attempt must name its tests before any can be called fixed.
    const tests = new Set();
    let attributed = true;
    for (const jobId of row.failedJobIds) {
      let named;
      try {
        named = parseFailingTests(readLog(jobId));
      } catch (error) {
        warnings.push(`log for job ${jobId} unreadable: ${safeLabel(error.message)}`);
        attributed = false;
        continue;
      }
      if (!named.length) attributed = false;
      named.forEach((test) => tests.add(test));
    }
    if (!tests.size) continue;
    const shown = [...tests].slice(0, TESTS_PER_EVIDENCE);
    const more = tests.size - shown.length;
    row.detail += ` · failing: ${shown.map((test) => `\`${test}\``).join(', ')}${more ? ` and ${more} more` : ''}`;
    if (!attributed || !row.trustedLog) continue;
    // The display is capped; the decision reads every named file.
    const files = [...new Set([...tests].map((test) => test.replace(/:\d+(?::\d+)?$/, '')))];
    // A mass failure is a broken run, not one flake: keep it rather than spend
    // a history lookup per file.
    if (files.length > FILES_PER_ATTRIBUTION) continue;
    const changes = files.map((file) => {
      try {
        return changeSince(file, row.at, row.pullRequests ?? []);
      } catch (error) {
        warnings.push(`history of ${file} unreadable: ${safeLabel(error.message)}`);
        return null;
      }
    });
    if (changes.every(Boolean)) {
      row.changedSince = [...new Set(changes)];
      row.detail += ` · changed on main since: ${row.changedSince.map((sha) => `\`${sha}\``).join(', ')}`;
    }
  }
  const kept = [];
  const skipped = [];
  for (const finding of findings) {
    if (finding.kind === 'flaky' && finding.evidence.every((row) => row.changedSince)) skipped.push(finding.title);
    else kept.push(finding);
  }
  return { findings: kept, skipped, warnings };
}

// mainAttempts: [{ run, attempt, jobs }] for push runs on main
export function findTimeouts(mainAttempts) {
  const findings = new Map();
  for (const { run, attempt, jobs } of mainAttempts) {
    for (const job of jobs) {
      const seconds = jobSeconds(job);
      if (job.conclusion !== 'cancelled' || seconds === null || seconds < TIMEOUT_MIN_SECONDS) continue;
      const workflow = safeLabel(run.name);
      const name = safeLabel(job.name);
      addFinding(findings, {
        kind: 'timeout',
        title: `CI health (timeout): ${workflow} / ${name}`,
        summary: `\`${name}\` in **${workflow}** was cancelled on main after running ${Math.round(seconds / 60)}+ minutes, which on a push to main means it hit its job timeout.`,
      }, evidence(run, `attempt ${attempt} cancelled after ${Math.round(seconds)}s`));
    }
  }
  return [...findings.values()];
}

// mainRuns: push runs on main for one workflow, any order
export function findFailingMain(mainRuns) {
  const decided = mainRuns
    .filter((run) => run.status === 'completed' && (run.conclusion === 'success' || run.conclusion === 'failure'))
    .sort((a, b) => Date.parse(b.created_at) - Date.parse(a.created_at));
  if (decided.length < 2 || decided[0].conclusion !== 'failure' || decided[1].conclusion !== 'failure') return [];
  const firstPass = decided.findIndex((run) => run.conclusion !== 'failure');
  const streak = firstPass === -1 ? decided : decided.slice(0, firstPass);
  const workflow = safeLabel(decided[0].name);
  return [{
    kind: 'failing',
    title: `CI health (failing on main): ${workflow}`,
    summary: `**${workflow}**'s ${streak.length} newest decided runs on main failed.`,
    evidence: streak.slice(0, 10).map((run) => evidence(run, 'failure')),
  }];
}

// samples: [{ workflow, job, window: 'current' | 'previous', seconds, steps, run }]
export function findSlowdowns(samples) {
  const groups = new Map();
  for (const sample of samples) {
    const key = `${sample.workflow}\u0000${sample.job}`;
    if (!groups.has(key)) groups.set(key, { current: [], previous: [], steps: { current: new Set(), previous: new Set() }, sample });
    const group = groups.get(key);
    group[sample.window].push(sample.seconds);
    for (const step of sample.steps ?? []) group.steps[sample.window].add(step);
  }
  const findings = [];
  for (const { current, previous, steps, sample } of groups.values()) {
    if (current.length < SLOWDOWN.minSamples || previous.length < SLOWDOWN.minSamples) continue;
    const now = median(current);
    const before = median(previous);
    if (now < before * SLOWDOWN.ratio || now - before < SLOWDOWN.minSeconds) continue;
    const workflow = safeLabel(sample.workflow);
    const name = safeLabel(sample.job);
    // A slowdown is often a step someone added on purpose. Say so up front.
    const added = [...steps.current].filter((step) => !steps.previous.has(step)).map(safeLabel);
    findings.push({
      kind: 'slower',
      title: `CI health (slower): ${workflow} / ${name}`,
      summary: `\`${name}\` in **${workflow}** takes a median ${Math.round(now)}s on successful main runs this week, up from ${Math.round(before)}s the week before (+${Math.round((now / before - 1) * 100)}%).`
        + (added.length ? ` Steps new this week: ${added.map((step) => `\`${step}\``).join(', ')}.` : ''),
      evidence: [{ url: sample.run.html_url, sha: sample.run.head_sha.slice(0, 10), at: sample.run.created_at, detail: `${current.length} runs this week, ${previous.length} the week before` }],
    });
  }
  return findings;
}

export function formatIssueBody(finding, generatedAt) {
  const rows = finding.evidence.slice(0, 20).map((item) => `| ${item.at.slice(0, 16).replace('T', ' ')} | \`${item.sha}\` | ${item.detail} | [run](${item.url}) |`);
  return [
    finding.summary,
    '',
    '| When (UTC) | Commit | Detail | Run |',
    '|---|---|---|---|',
    ...rows,
    '',
    `Updated by the weekly CI health scan (\`scripts/check-ci-health.mjs\`) on ${generatedAt.slice(0, 10)}. Close this issue once it is fixed; a later recurrence opens a new one.`,
  ].join('\n');
}

export function syncIssues({ findings, issues, generatedAt }) {
  const actions = [];
  for (const finding of findings) {
    const body = formatIssueBody(finding, generatedAt);
    // One rejected write must not drop every finding after it.
    try {
      const open = issues.search(finding.title);
      if (open) {
        issues.update(open.number, body);
        actions.push({ action: 'updated', number: open.number, title: finding.title });
      } else {
        const created = issues.create(finding.title, body);
        actions.push({ action: 'created', number: created.number, title: finding.title });
      }
    } catch (error) {
      actions.push({ action: 'failed', number: null, title: finding.title, error: safeLabel(error.message) });
    }
  }
  return actions;
}

function defaultGh(args, options = {}) {
  return execFileSync('gh', args, { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024, ...options });
}

// Only an issue the scan opened may be rewritten: the repository is public,
// so anyone can open an issue whose title matches a finding.
export function pickOwnedIssue(items, title) {
  return items.find((issue) => issue.title === title && issue.user?.login === ISSUE_AUTHOR && !issue.pull_request) ?? null;
}

function githubIssues(gh, repository) {
  return {
    search(title) {
      const { items } = JSON.parse(gh([
        'api', '-X', 'GET', 'search/issues',
        '-f', `q=repo:${repository} is:issue is:open in:title "${title}"`,
        '-f', 'per_page=50',
      ]));
      return pickOwnedIssue(items, title);
    },
    create(title, body) {
      return JSON.parse(gh(['api', `repos/${repository}/issues`, '--input', '-'], { input: JSON.stringify({ title, body }) }));
    },
    update(number, body) {
      gh(['api', `repos/${repository}/issues/${number}`, '--method', 'PATCH', '--input', '-'], { input: JSON.stringify({ body }) });
    },
  };
}

function api(gh, path) {
  return JSON.parse(gh(['api', path]));
}

function listRuns(gh, repository, workflow, query) {
  const runs = [];
  for (let page = 1; ; page += 1) {
    const { workflow_runs: batch } = api(gh, `repos/${repository}/actions/workflows/${workflow}/runs?${query}&per_page=100&page=${page}`);
    runs.push(...batch);
    if (batch.length < 100) return runs;
  }
}

function day(ms) {
  return new Date(ms).toISOString().slice(0, 10);
}

// The newest main runs, read three times and reduced the way the deploy
// monitors do: GitHub sometimes answers with a stale index snapshot, and a
// stale view could raise or hide a failing-main alarm.
function newestMainRuns(gh, repository, workflow) {
  const listings = corroborateRunListings({
    workflowFile: workflow,
    read: () => {
      const payload = api(gh, `repos/${repository}/actions/workflows/${workflow}/runs?event=push&branch=main&per_page=${MAIN_LISTING_SIZE}`);
      return { runs: payload.workflow_runs, totalCount: payload.total_count };
    },
  });
  return reduceRunListings(listings);
}

export function collect({ gh, repository, now }) {
  const jobsCache = new Map();
  const attemptJobs = (run, attempt) => {
    const key = `${run.id}/${attempt}`;
    if (!jobsCache.has(key)) jobsCache.set(key, api(gh, `repos/${repository}/actions/runs/${run.id}/attempts/${attempt}/jobs?per_page=100`).jobs);
    return jobsCache.get(key);
  };
  const allAttempts = (run) => Array.from({ length: run.run_attempt }, (_, index) => attemptJobs(run, index + 1));

  const eventStart = now - WINDOW_DAYS * DAY_MS - EVENT_OVERLAP_MS;
  const currentStart = now - WINDOW_DAYS * DAY_MS;
  const reruns = [];
  const mainAttempts = [];
  const samples = [];
  const failing = [];
  for (const workflow of MONITORED_WORKFLOWS) {
    // The runs API stops at 1,000 results per filtered query, and these
    // workflows run ~150 times a day, so the window is read one calendar day
    // at a time, then cut to the exact window.
    const days = new Set();
    for (let ms = eventStart; ms < now + DAY_MS; ms += DAY_MS) days.add(day(Math.min(ms, now)));
    const window = [...days]
      .flatMap((date) => listRuns(gh, repository, workflow, `created=${date}`))
      .filter((run) => Date.parse(run.created_at) >= eventStart && run.status === 'completed');

    // Days are read oldest first; the cap keeps the newest re-runs.
    const newestReruns = window.filter((item) => item.run_attempt > 1)
      .sort((a, b) => Date.parse(b.created_at) - Date.parse(a.created_at))
      .slice(0, RERUNS_PER_WORKFLOW);
    for (const run of newestReruns) {
      reruns.push({ run, attempts: allAttempts(run) });
    }
    // A timed-out attempt survives a later successful re-run, so every
    // attempt of a re-run or unsuccessful main push is read.
    for (const run of window.filter((item) => item.event === 'push' && item.head_branch === 'main'
      && (item.run_attempt > 1 || item.conclusion === 'cancelled' || item.conclusion === 'failure'))) {
      allAttempts(run).forEach((jobs, index) => mainAttempts.push({ run, attempt: index + 1, jobs }));
    }

    failing.push(...findFailingMain(newestMainRuns(gh, repository, workflow)));

    const main = listRuns(gh, repository, workflow, `event=push&branch=main&status=success&created=>=${day(now - 2 * WINDOW_DAYS * DAY_MS)}`)
      .filter((item) => item.run_attempt === 1 && Date.parse(item.created_at) >= now - 2 * WINDOW_DAYS * DAY_MS);
    for (const [period, runs] of [
      ['current', main.filter((item) => Date.parse(item.created_at) >= currentStart)],
      ['previous', main.filter((item) => Date.parse(item.created_at) < currentStart)],
    ]) {
      // Newest first: a finding links the first sample it saw.
      for (const run of spreadSample(runs, SLOWDOWN.samplesPerWindow).reverse()) {
        for (const job of attemptJobs(run, 1)) {
          const seconds = jobSeconds(job);
          if (job.conclusion !== 'success' || seconds === null) continue;
          samples.push({ workflow: run.name, job: job.name, window: period, seconds, steps: (job.steps ?? []).map((step) => step.name), run });
        }
      }
    }
  }
  const all = [...failing, ...findTimeouts(mainAttempts), ...findFlakyJobs(reruns), ...findSlowdowns(samples)];
  return attributeFlakes(all, {
    // The log endpoint answers raw text that may carry colour codes, which gh
    // refuses to print unless told to.
    readLog: (jobId) => gh(['api', '--allow-escape-sequences', `repos/${repository}/actions/jobs/${jobId}/logs`]),
    changeSince: (file, iso, pullRequests) => {
      const commits = api(gh, `repos/${repository}/commits?sha=main&path=${encodeURIComponent(file)}&since=${encodeURIComponent(iso)}&per_page=20`);
      const ownSubject = (message) => pullRequests.some((number) => String(message ?? '').split('\n')[0].includes(`(#${number})`));
      // Oldest first: the first change after the flake that is not the flaking
      // pull request's own merge. A merge subject may lack `(#N)`, so ask
      // GitHub which pull requests each candidate belongs to, a few at most;
      // an unresolved candidate keeps the flake reported.
      const candidates = commits.filter((commit) => !ownSubject(commit.commit?.message)).reverse();
      if (!pullRequests.length) return candidates[0]?.sha.slice(0, 10) ?? null;
      for (const commit of candidates.slice(0, ASSOCIATION_LOOKUPS)) {
        const pulls = api(gh, `repos/${repository}/commits/${commit.sha}/pulls`);
        if (!pulls.some((pull) => pullRequests.includes(pull.number))) return commit.sha.slice(0, 10);
      }
      return null;
    },
  });
}

export function checkCiHealth({ gh = defaultGh, repository, now = Date.now(), dryRun = false, issues } = {}) {
  const { findings, skipped, warnings } = collect({ gh, repository, now });
  const generatedAt = new Date(now).toISOString();
  const actions = dryRun ? [] : syncIssues({ findings, issues: issues ?? githubIssues(gh, repository), generatedAt });
  return { findings, actions, skipped, warnings };
}

if (isMainModule(import.meta.url, process.argv[1])) {
  const { values } = parseArgs({
    options: {
      repo: { type: 'string', default: process.env.GITHUB_REPOSITORY || 'koala73/worldmonitor' },
      'dry-run': { type: 'boolean', default: false },
      output: { type: 'string' },
    },
  });
  try {
    const { findings, actions, skipped, warnings } = checkCiHealth({ repository: values.repo, dryRun: values['dry-run'] });
    if (values.output) writeFileSync(values.output, `${JSON.stringify(findings, null, 2)}\n`);
    const lines = findings.length
      ? findings.map((finding) => `- ${finding.title} (${finding.evidence.length} occurrence${finding.evidence.length === 1 ? '' : 's'})`)
      : ['- No findings.'];
    for (const title of skipped) lines.push(`- not reported, its failing tests changed on main since: ${title}`);
    for (const warning of warnings) lines.push(`- warning: ${warning}`);
    for (const action of actions) {
      lines.push(action.action === 'failed'
        ? `- failed to sync: ${action.title} (${action.error})`
        : `- ${action.action} #${action.number}: ${action.title}`);
    }
    if (actions.some((action) => action.action === 'failed')) process.exitCode = 1;
    console.log(lines.join('\n'));
    if (process.env.GITHUB_STEP_SUMMARY) appendFileSync(process.env.GITHUB_STEP_SUMMARY, `## CI health\n\n${lines.join('\n')}\n`);
  } catch (error) {
    console.error(error.message);
    process.exitCode = 1;
  }
}