Files
msd-core/tests/ci-job-timing.test.cjs
Tom Boucher 370cfc6680 enhance(#4036): persist CI shard/job timeout-vs-cap trending, warn at 90% (#4043)
* feat(#4036): persist CI shard/job timeout-vs-cap trending, warn at 90%

Adds two new mechanisms plus an audit-coverage extension:

- scripts/lib/ci-job-timing.cjs: shared elapsed-vs-cap arithmetic
- scripts/ci-check-job-near-cap.cjs: in-job advisory near-cap check,
  wired into test/test-full/mutate/smoke as each job's last step
- scripts/ci-timeout-report.cjs + .github/workflows/ci-timeout-report.yml:
  scheduled REST-API poll that appends new records to
  tests/ci-timeout-budget-history.jsonl and opens a small data-only PR
- tests/ci-test-job-timeout-budget.test.cjs: extended to cover mutate
  (mutation.yml) and smoke (install-smoke.yml), which previously had no
  headroom-factor gate coverage at all

Does not change any timeout-minutes value, shard composition, or shard-1
contents — those stay maintainer policy calls per the issue's own scope.

* fix(#4036): address two-orthogonal-review findings

- Parity tests guarding the two hand-duplicated literals this design
  cannot single-source through GH Actions YAML: CI_JOB_TIMEOUT_MINUTES
  vs each job's own timeout-minutes, and ci-timeout-report.cjs's
  JOB_RULES name-prefixes vs each job's actual name: template.
- Thread run.event through as runEvent on every persisted record, so
  PR-context and push-context install-smoke timings (genuinely
  different matrix shape) are distinguishable in the history rather
  than silently conflated under one job name.
- Replace the Windows near-cap start-time step's ambiguous PowerShell
  +/>> precedence with GitHub's documented string-interpolation form.
- Move github.run_id out of direct ${{ }} shell interpolation into an
  env: var in the new scheduled workflow, per this repo's own
  expression-injection-safe convention.

* test(#4036): regenerate golden install-tree fixtures for scripts/lib/ci-job-timing.cjs

npm run gen:install-tree — scripts/ ships wholesale into the installed
package (per ADR/known-defect precedent from #4012's own PR history: a
new scripts/lib/*.cjs file needs its golden entry regenerated or every
runtime's install-tree test fails). Confirmed via gsd-test: this was the
sole cause of the first real verification run's 25 failures (all in
tests/golden-install-tree.test.cjs, one per runtime). Top-level
scripts/*.cjs files (ci-check-job-near-cap.cjs, ci-timeout-report.cjs)
are not individually tracked in these fixtures — consistent with every
other existing top-level scripts/*.cjs file, so no entry was expected
or added for those two.

* fix(#4036): register new lib file with installer, fix H1 shell policy

- bin/install.js: add ci-job-timing.cjs to GSD_SCRIPTS_LIB_FILES (a
  hand-maintained registry, not generated — tests/install.test.cjs
  asserts every scripts/lib/ file is enumerated here)
- test.yml: replace the two OS-conditional "Record job start time"
  step pairs (test + test-full jobs) with a single unconditional
  `node -e` step. The prior pair's Windows variant declared an
  explicit shell: pwsh, which scripts/workflow-policy.cjs's H1 checker
  statically flags against every OS a job's matrix can realize,
  independent of the step's own if: gate. A single Node one-liner
  needs no shell override at all — it's syntactically valid and
  behaves identically under bash, zsh, and pwsh — which is both H1
  compliant and removes the last OS-specific shell syntax from this
  change entirely.

Both defects were found by a real gsd-test run, not local gates —
lint:ci and build:lib were clean throughout because neither the
scripts/lib/ install-manifest parity check nor the H1 shell-policy
baseline runs as part of lint:ci; both are gsd-test-only suites.

* docs(#4036): how-to for reading CI timeout budget signals

The phase-gate docs check correctly flagged the enablement sequence as
3 real steps (read the near-cap warning, find the accumulated trend
file, pick the right maintainer lever) — a reference table can't carry
a sequence. Adds docs/how-to/read-ci-timeout-signals.md, indexed from
docs/README.md.

* chore(#4036): backfill changeset PR number (4043)

---------

Co-authored-by: sim <sim@local>
2026-08-29 16:13:15 -04:00

199 lines
6.9 KiB
JavaScript

'use strict';
/**
* scripts/lib/ci-job-timing.cjs — pure budget-percentage arithmetic (#4036).
*
* This module is shared by the in-job near-cap check
* (scripts/ci-check-job-near-cap.cjs) and the scheduled trending report
* (scripts/ci-timeout-report.cjs). Both surfaces need to agree on exactly
* what "elapsed" and "near-cap" mean, so the arithmetic is factored out here
* and tested once, at every boundary the two callers depend on: zero
* elapsed, exactly-at-cap, just-under-threshold, exactly-at-threshold,
* just-over-threshold, and past-cap. It also covers the invalid-input
* failure modes (bad timeoutMinutes, malformed timestamps, completedAt
* before startedAt) that both callers need to fail loudly on rather than
* silently misreport.
*/
const test = require('node:test');
const assert = require('node:assert/strict');
const fc = require('fast-check');
const {
THRESHOLD_PCT,
computeElapsedPct,
isNearCap,
formatNearCapNotice,
} = require('../scripts/lib/ci-job-timing.cjs');
test('computeElapsedPct', async (t) => {
await t.test('zero elapsed yields pct exactly 0', () => {
const result = computeElapsedPct({
startedAt: '2026-01-01T00:00:00.000Z',
completedAt: '2026-01-01T00:00:00.000Z',
timeoutMinutes: 15,
});
assert.equal(result.pct, 0);
// Deterministic arithmetic over a fixed literal fixture (not a measured
// wall-clock duration); elapsedMs is part of the function's public
// return-shape contract that formatNearCapNotice reads directly.
// eslint-disable-next-line local/no-elapsed-assertion -- deterministic arithmetic, not measured timing
assert.equal(result.elapsedMs, 0);
assert.equal(result.capMs, 900000);
});
await t.test('elapsed exactly equal to cap yields pct exactly 1 and is near-cap', () => {
const result = computeElapsedPct({
startedAt: '2026-01-01T00:00:00.000Z',
completedAt: '2026-01-01T00:15:00.000Z',
timeoutMinutes: 15,
});
assert.equal(result.pct, 1);
assert.equal(isNearCap(result.pct), true);
});
await t.test('elapsed at 89.99% of cap is NOT near-cap', () => {
const startedAt = new Date('2026-01-01T00:00:00.000Z');
const completedAt = new Date(startedAt.getTime() + 809910);
const result = computeElapsedPct({
startedAt: startedAt.toISOString(),
completedAt: completedAt.toISOString(),
timeoutMinutes: 15,
});
assert.equal(isNearCap(result.pct), false);
});
await t.test('elapsed at exactly 90% of cap IS near-cap (threshold is inclusive)', () => {
const startedAt = new Date('2026-01-01T00:00:00.000Z');
const completedAt = new Date(startedAt.getTime() + 810000);
const result = computeElapsedPct({
startedAt: startedAt.toISOString(),
completedAt: completedAt.toISOString(),
timeoutMinutes: 15,
});
assert.equal(result.pct, 0.9);
assert.equal(isNearCap(result.pct), true);
});
await t.test('elapsed at 90.01% of cap is near-cap', () => {
const startedAt = new Date('2026-01-01T00:00:00.000Z');
const completedAt = new Date(startedAt.getTime() + 810090);
const result = computeElapsedPct({
startedAt: startedAt.toISOString(),
completedAt: completedAt.toISOString(),
timeoutMinutes: 15,
});
assert.equal(isNearCap(result.pct), true);
});
await t.test('elapsed beyond the cap does not throw, pct > 1, near-cap', () => {
const result = computeElapsedPct({
startedAt: '2026-01-01T00:00:00.000Z',
completedAt: '2026-01-01T00:20:00.000Z',
timeoutMinutes: 15,
});
assert.ok(result.pct > 1);
assert.equal(isNearCap(result.pct), true);
});
await t.test('timeoutMinutes of 0 throws', () => {
assert.throws(
() =>
computeElapsedPct({
startedAt: '2026-01-01T00:00:00.000Z',
completedAt: '2026-01-01T00:15:00.000Z',
timeoutMinutes: 0,
}),
/timeoutMinutes must be a positive finite number/,
);
});
await t.test('negative timeoutMinutes throws', () => {
assert.throws(
() =>
computeElapsedPct({
startedAt: '2026-01-01T00:00:00.000Z',
completedAt: '2026-01-01T00:15:00.000Z',
timeoutMinutes: -5,
}),
/timeoutMinutes must be a positive finite number/,
);
});
await t.test('malformed startedAt throws', () => {
assert.throws(
() =>
computeElapsedPct({
startedAt: 'not-a-date',
completedAt: '2026-01-01T00:15:00.000Z',
timeoutMinutes: 15,
}),
/startedAt is not a valid timestamp/,
);
});
await t.test('completedAt before startedAt throws', () => {
assert.throws(
() =>
computeElapsedPct({
startedAt: '2026-01-01T01:00:00.000Z',
completedAt: '2026-01-01T00:00:00.000Z',
timeoutMinutes: 15,
}),
/completedAt .* is before startedAt/,
);
});
});
test('formatNearCapNotice', async (t) => {
await t.test('produces a warningLine and summaryMarkdown containing label and pct', () => {
const { warningLine, summaryMarkdown } = formatNearCapNotice({
label: 'test (ubuntu-latest, 24, shard 1/3)',
pct: 0.92,
elapsedMs: 828000,
capMs: 900000,
});
assert.ok(warningLine.startsWith('::warning'));
assert.ok(warningLine.includes('test (ubuntu-latest, 24, shard 1/3)'));
assert.ok(warningLine.includes('92%'));
assert.ok(summaryMarkdown.includes('test (ubuntu-latest, 24, shard 1/3)'));
assert.ok(summaryMarkdown.includes('92%'));
});
});
test('computeElapsedPct / isNearCap — property: pct matches direct division and threshold predicate agrees', () => {
fc.assert(
fc.property(
fc.record({
timeoutMinutes: fc.integer({ min: 1, max: 500 }),
// Expressed as thousandths of the cap so elapsedMs stays a whole
// number of ms in [0, 10 * capMs] without generating capMs first.
fractionOfCapMilli: fc.integer({ min: 0, max: 10000 }),
}),
({ timeoutMinutes, fractionOfCapMilli }) => {
const capMs = timeoutMinutes * 60000;
const elapsedMs = Math.floor((capMs * fractionOfCapMilli) / 1000);
const startedAt = new Date('2026-01-01T00:00:00.000Z');
const completedAt = new Date(startedAt.getTime() + elapsedMs);
const result = computeElapsedPct({
startedAt: startedAt.toISOString(),
completedAt: completedAt.toISOString(),
timeoutMinutes,
});
// elapsedMs/capMs here are deterministically constructed by this test
// from fast-check inputs (see above), not a measured wall-clock
// duration; this is the core invariant under test.
// eslint-disable-next-line local/no-elapsed-assertion -- deterministic arithmetic, not measured timing
assert.equal(result.pct, elapsedMs / capMs);
assert.equal(isNearCap(result.pct), result.pct >= THRESHOLD_PCT);
},
),
{ numRuns: 200 },
);
});