* chore(#4603): retire the test-full CI job Phase 2 (#4591) added test-conformance but left test-full (the pre-existing full-suite Windows/macOS replay) running unchanged, gated on the same full_matrix flag, downgraded only from a hard gate to a non-blocking ::warning:: -- framed as "a non-gating safety net for one release cycle." No phase or issue ever retired it. Result: every full_matrix=true PR ran 10 OS-specific jobs (test-full's 6 + test-conformance's 4, purely additive) instead of the original 6 -- the epic's own goal (reduce runner-minutes) was measurably regressing, not improving, for the majority of PRs. This phase was missing from the original 4-phase epic decomposition; the epic (#4589) has been amended to add it as Phase 5 (see its comment thread), and this issue was filed as the tracked sub-issue. Deletes the test-full job from .github/workflows/test.yml entirely, along with every reference to it: required-tests' needs/FULL_TEST_RESULT warning branch, ci-timeout-report.cjs's JOB_RULES entry, ci-test-job-timeout-budget.test.cjs's LANE_COSTS/staticLanes/testFullRule entries, ci-test-scope.test.cjs's test-full-specific tests (preserving three unrelated tests that were nested in the same describe block, moved under a renamed describe rather than deleted), and docs mentions. test-conformance is now the sole gating signal for real-OS coverage. Two separate defects found and fixed while auditing every test-full reference: - tests/ci-pr-mergeability.test.cjs's GATED['test.yml'] safety-critical array (jobs that must needs: the mergeability preflight) had test-full but was missing test-conformance entirely -- Phase 2 never added it. Verified the real workflow wiring was already correct (test-conformance does have needs: [changes, preflight]); this was a test-coverage gap, not a live defect. Fixed by swapping the array entry. - docs/TESTING-SUITES.md's "## CI matrix" section was substantially stale independent of this phase (predating even #2952's coverage-gate split). Rewritten against the real, current job topology, verified directly against test.yml rather than trusted from memory. An isolated code-review pass found and fixed two minor inaccuracies in the rewritten docs table (two jobs' "Gated on" column didn't match their real if: condition exactly). An isolated security-review pass found no qualifying findings -- every compute-provisioning job already carries needs: preflight directly, unaffected by this deletion. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * fix(ci): isolate 7 more heavy test files from chunk-weight packing `next`'s own push-triggered Tests run failed: `conformance test (windows-latest, 24, shard 2/3)` chunk 3/6 was killed after 600019ms. Root cause: state.test.cjs (weight 21.35, measured) was packed alongside companions by run-tests.cjs's LPT chunk packer, the same failure mode that previously hit codex-config.test.cjs (weight 17.87) twice and got a dedicated fix (ISOLATED_HEAVY_FILES, #4497) -- but state.test.cjs was never added to that set. This is a direct, unintended consequence of epic #4589 Phase 2: the new platform-conformance-tier job packs only ~546 files per shard (vs. the ~950-file full suite the packer used to balance against), so the same absolute-weight outlier now represents a larger share of a smaller, more homogeneous pool -- the LPT packer has fewer light files to pad around it with. This was a real, foreseeable side effect of shrinking the packing pool that nobody checked for when Phase 2 shipped. A first attempt at this fix hand-picked 4 candidates by eyeballing a truncated weight list and missed 3 heavier ones -- caught by an isolated code-review pass (blocker: emitted-attribution.test.cjs at 66.2% of the Windows chunk budget, install-minimal-hooks.test.cjs at 61.1%, install.test.cjs at 47.1%, all above codex-config.test.cjs's own 44.7% -- the ratio that already proved dangerous twice). Corrected by systematically computing weight/budget for every unit-suite file and isolating everything at or above that same ratio: 7 files total, plus the pre-existing codex-config.test.cjs (8 total). Added a durable regression test (tests/run-tests-harness.test.cjs) that re-derives this exact computation from the live tests/test-timings.json on every run, so a future heavy file crossing this threshold fails the test instead of silently reintroducing this failure -- not just a one-time manual sweep. Verified end-to-end: simulated the real 3-way windows shard split of the actual conformance-tier file list with the real packing functions. Max packable-chunk weight across all 3 shards is now 27.04 / 24.10 / 23.91 (shard 2 is the exact shard that failed on next), comfortably under the 40 budget -- versus 40+ and a 600s kill before this fix. A second isolated code-review + security-review pass on the corrected diff found nothing further. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> --------- Co-authored-by: sim <sim@local> Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
231 lines
7.7 KiB
JavaScript
231 lines
7.7 KiB
JavaScript
'use strict';
|
|
|
|
/**
|
|
* scripts/ci-timeout-report.cjs
|
|
*
|
|
* Scheduled CI-timeout trending report (#4036). Polls GitHub's Actions REST
|
|
* API for recently completed jobs across test.yml, mutation.yml, and
|
|
* install-smoke.yml, resolves each job's declared `timeout-minutes` budget,
|
|
* computes elapsed-vs-cap via scripts/lib/ci-job-timing.cjs, and appends
|
|
* new (never-before-seen) records to a JSONL history file. Every record also
|
|
* carries the triggering `runEvent` (e.g. `push`/`pull_request`) so entries
|
|
* for jobs whose matrix genuinely differs by trigger (e.g. `smoke`'s
|
|
* push-only macOS row) can be told apart in the persisted trend — records
|
|
* are never filtered by event, only labeled.
|
|
*
|
|
* Invoked from a GitHub Actions workflow via actions/github-script, e.g.:
|
|
* const report = require(`${process.env.GITHUB_WORKSPACE}/scripts/ci-timeout-report.cjs`);
|
|
* const result = await report.main({ github, context, core });
|
|
*/
|
|
|
|
const yaml = require('js-yaml');
|
|
const fs = require('node:fs');
|
|
const path = require('node:path');
|
|
const {
|
|
computeElapsedPct, isNearCap, formatNearCapNotice,
|
|
} = require('./lib/ci-job-timing.cjs');
|
|
|
|
const HISTORY_PATH = path.join(__dirname, '..', 'tests', 'ci-timeout-budget-history.jsonl');
|
|
const WORKFLOWS_DIR = path.join(__dirname, '..', '.github', 'workflows');
|
|
|
|
// Static job name → job-id rules, first match wins, checked in array order
|
|
// (test-inert before test, since both job names start with "test ").
|
|
const JOB_RULES = [
|
|
{ workflowFile: 'test.yml', jobKey: 'test-inert', test: (name) => name === 'test (inert CI)' },
|
|
{ workflowFile: 'test.yml', jobKey: 'test', test: (name) => name.startsWith('test (') && name !== 'test (inert CI)' },
|
|
{ workflowFile: 'test.yml', jobKey: 'coverage-gate', test: (name) => name === 'Coverage gate (merged shards)' },
|
|
{ workflowFile: 'test.yml', jobKey: 'test-conformance', test: (name) => name.startsWith('conformance test (') },
|
|
{ workflowFile: 'install-smoke.yml', jobKey: 'smoke', test: (name) => name.startsWith('smoke (') },
|
|
];
|
|
|
|
function resolveJobTimeoutMinutes({ jobName, workflowFile, workflowYamlText, covered }) {
|
|
if (workflowFile === 'mutation.yml') {
|
|
const m = jobName.match(/^Stryker \(([^)]+)\)$/);
|
|
if (!m) return null;
|
|
const moduleName = m[1];
|
|
if (!covered || !Object.prototype.hasOwnProperty.call(covered, moduleName)) return null;
|
|
return covered[moduleName].timeoutMinutes || 15;
|
|
}
|
|
|
|
const rule = JOB_RULES.find((r) => r.workflowFile === workflowFile && r.test(jobName));
|
|
if (!rule) return null;
|
|
|
|
const doc = yaml.load(workflowYamlText);
|
|
const budget = doc && doc.jobs && doc.jobs[rule.jobKey] ? doc.jobs[rule.jobKey]['timeout-minutes'] : undefined;
|
|
return typeof budget === 'number' ? budget : null;
|
|
}
|
|
|
|
/**
|
|
* @param {{job: object, workflowFile: string, workflowYamlText: ?string, covered: ?object}} args
|
|
* `job.runEvent` is the triggering event (e.g. `push`/`pull_request`) — carried through to
|
|
* the returned record so entries whose matrix genuinely differs by trigger (e.g. `smoke`'s
|
|
* push-only macOS row) can be distinguished in the persisted history.
|
|
*/
|
|
function parseJobRecord({ job, workflowFile, workflowYamlText, covered }) {
|
|
if (!job.completed_at) return null;
|
|
|
|
const timeoutMinutes = resolveJobTimeoutMinutes({ jobName: job.name, workflowFile, workflowYamlText, covered });
|
|
if (timeoutMinutes == null) return null;
|
|
|
|
const { elapsedMs, pct } = computeElapsedPct({
|
|
startedAt: job.started_at, completedAt: job.completed_at, timeoutMinutes,
|
|
});
|
|
|
|
return {
|
|
runId: job.run_id,
|
|
jobName: job.name,
|
|
workflowFile,
|
|
sha: job.head_sha,
|
|
runEvent: job.runEvent,
|
|
completedAt: job.completed_at,
|
|
elapsedMs,
|
|
timeoutMinutes,
|
|
pct,
|
|
};
|
|
}
|
|
|
|
function buildReportLines(runs, { workflowFile, workflowYamlText, covered }) {
|
|
const records = [];
|
|
for (const { run, jobs } of runs) {
|
|
for (const job of jobs) {
|
|
const rec = parseJobRecord({
|
|
job: {
|
|
...job, run_id: run.id, head_sha: run.head_sha, runEvent: run.event,
|
|
},
|
|
workflowFile,
|
|
workflowYamlText,
|
|
covered,
|
|
});
|
|
if (rec) records.push(rec);
|
|
}
|
|
}
|
|
return records;
|
|
}
|
|
|
|
function dedupeAgainstHistory(newRecords, historyText) {
|
|
const seen = new Set();
|
|
for (const line of String(historyText || '').split('\n')) {
|
|
if (!line.trim()) continue;
|
|
try {
|
|
const rec = JSON.parse(line);
|
|
seen.add(`${rec.runId}::${rec.jobName}`);
|
|
} catch {
|
|
// Malformed history line — skip it rather than crash the whole report.
|
|
}
|
|
}
|
|
return newRecords.filter((r) => !seen.has(`${r.runId}::${r.jobName}`));
|
|
}
|
|
|
|
function formatHistoryLine(record) {
|
|
return `${JSON.stringify(record)}\n`;
|
|
}
|
|
|
|
const WORKFLOW_FILES = ['test.yml', 'mutation.yml', 'install-smoke.yml'];
|
|
const MAX_RUNS_PER_WORKFLOW = 15;
|
|
|
|
/**
|
|
* Orchestration entry point — impure, invoked from actions/github-script.
|
|
*
|
|
* @param {{github: object, context: object, core: object, historyPath?: string, fs?: object}} args
|
|
* @returns {Promise<{added: number, nearCap: number}>}
|
|
*/
|
|
async function main({
|
|
github, context, core, historyPath = HISTORY_PATH, fs: fsImpl = fs,
|
|
}) {
|
|
const { owner, repo } = context.repo;
|
|
const mutationMatrix = require('./mutation-matrix.cjs');
|
|
|
|
const allNewRecords = [];
|
|
|
|
for (const workflowFile of WORKFLOW_FILES) {
|
|
const covered = workflowFile === 'mutation.yml' ? mutationMatrix.COVERED : null;
|
|
const workflowYamlText = workflowFile === 'mutation.yml'
|
|
? null
|
|
: fsImpl.readFileSync(path.join(WORKFLOWS_DIR, workflowFile), 'utf8');
|
|
|
|
let runsList;
|
|
try {
|
|
runsList = await github.paginate(github.rest.actions.listWorkflowRuns, {
|
|
owner,
|
|
repo,
|
|
workflow_id: workflowFile,
|
|
status: 'completed',
|
|
per_page: 30,
|
|
});
|
|
} catch (err) {
|
|
core.warning(`ci-timeout-report: failed to list runs for ${workflowFile}: ${err.message}`);
|
|
continue;
|
|
}
|
|
|
|
const runs = runsList.slice(0, MAX_RUNS_PER_WORKFLOW);
|
|
const runsWithJobs = [];
|
|
|
|
for (const run of runs) {
|
|
try {
|
|
const jobs = await github.paginate(github.rest.actions.listJobsForWorkflowRun, {
|
|
owner,
|
|
repo,
|
|
run_id: run.id,
|
|
per_page: 50,
|
|
});
|
|
runsWithJobs.push({ run, jobs });
|
|
} catch (err) {
|
|
core.warning(`ci-timeout-report: failed to list jobs for ${workflowFile} run ${run.id}: ${err.message}`);
|
|
}
|
|
}
|
|
|
|
const records = buildReportLines(runsWithJobs, { workflowFile, workflowYamlText, covered });
|
|
allNewRecords.push(...records);
|
|
}
|
|
|
|
let historyText = '';
|
|
try {
|
|
historyText = fsImpl.readFileSync(historyPath, 'utf8');
|
|
} catch {
|
|
// First run — history file does not exist yet, treat as empty.
|
|
historyText = '';
|
|
}
|
|
|
|
const deduped = dedupeAgainstHistory(allNewRecords, historyText);
|
|
|
|
if (deduped.length > 0) {
|
|
const newLines = deduped.map(formatHistoryLine).join('');
|
|
fsImpl.appendFileSync(historyPath, newLines);
|
|
}
|
|
// First-run bootstrap when there is nothing new to append is handled by
|
|
// `git add` picking up whatever the history file already contains.
|
|
|
|
let nearCapCount = 0;
|
|
for (const record of deduped) {
|
|
if (!isNearCap(record.pct)) continue;
|
|
nearCapCount += 1;
|
|
|
|
const notice = formatNearCapNotice({
|
|
label: `${record.jobName} (run ${record.runId})`,
|
|
pct: record.pct,
|
|
elapsedMs: record.elapsedMs,
|
|
capMs: record.timeoutMinutes * 60000,
|
|
});
|
|
|
|
core.warning(notice.warningLine.replace(/^::warning title=CI budget::/, ''));
|
|
|
|
if (core.summary) {
|
|
core.summary.addRaw(`${notice.summaryMarkdown}\n`);
|
|
}
|
|
}
|
|
|
|
return { added: deduped.length, nearCap: nearCapCount };
|
|
}
|
|
|
|
module.exports = {
|
|
HISTORY_PATH,
|
|
WORKFLOWS_DIR,
|
|
JOB_RULES,
|
|
resolveJobTimeoutMinutes,
|
|
parseJobRecord,
|
|
buildReportLines,
|
|
dedupeAgainstHistory,
|
|
formatHistoryLine,
|
|
main,
|
|
};
|