ci.py file-level

at main · View file ↗ · Intel ↗

History
1 files
1 commits
0 hotspots
0 🧊 dead
0 πŸ’₯ blast risk
sha256:c docs(KD-STAGING): sync governance after KD-6b DONE Mirror workspace go… · aaronrene · Jul 10, 2026
1 """CI gate runner for ``muse code test --ci``.
2
3 Reads ``.muse/ci.toml`` and executes each configured gate (lint, type-check,
4 tests, custom shell commands) in order. Returns a fully-typed
5 :class:`CiRunResult` suitable for JSON serialisation and programmatic
6 inspection.
7
8 ``.muse/ci.toml`` schema
9 ------------------------
10 ::
11
12 version = 1
13
14 [settings]
15 test_budget_s = 300 # max seconds for the test gate
16 workers = 4 # parallel pytest partitions
17 env_allowlist = ["CI", "MUSE_TEST_ENV"] # extra env vars for subprocesses
18
19 [[gate]]
20 name = "type-check"
21 command = ["python", "-m", "mypy", "muse/"]
22 timeout_s = 120
23 required = true
24
25 [[gate]]
26 name = "tests"
27 command = ["python", "-m", "pytest", "tests/", "-q"]
28 timeout_s = 300
29 required = true
30
31 [[gate]]
32 name = "typing-audit"
33 command = ["python", "tools/typing_audit.py", "--dirs", "muse/", "tests/", "--max-any", "0"]
34 timeout_s = 60
35 required = true
36
37 [[gate]]
38 name = "custom-lint"
39 command = ["sh", "-c", "ruff check muse/"]
40 timeout_s = 30
41 required = false # failure is a warning, not a blocker
42
43 Built-in defaults
44 -----------------
45 When ``.muse/ci.toml`` does not exist, a minimal default configuration is
46 used that runs mypy and pytest sequentially.
47
48 Security
49 --------
50 * All commands are executed as ``list[str]`` with ``shell=False``.
51 * The subprocess environment is sanitised identically to :mod:`muse.core.test_runner`.
52 * Command strings are never passed to a shell interpreter.
53 * No user-supplied data is ``eval``-ed or ``exec``-ed.
54 """
55
56 from __future__ import annotations
57
58 import logging
59 import os
60 import pathlib
61 import subprocess
62 import sys
63 import time
64 from collections.abc import Sequence
65 from typing import NotRequired, TypedDict
66
67 import tomllib
68
69 from muse.core.store import MsgpackValue, MsgpackDict, Metadata, _int_val, _str_list, _str_val
70
71 logger = logging.getLogger(__name__)
72
73 # ---------------------------------------------------------------------------
74 # Public type definitions
75 # ---------------------------------------------------------------------------
76
77
78 class CiGate(TypedDict):
79 """A single CI gate (one external command)."""
80
81 name: str
82 """Human-readable gate name shown in output."""
83
84 command: list[str]
85 """Command to execute as a subprocess list (never passed to shell)."""
86
87 timeout_s: float
88 """Maximum wall-clock time for this gate in seconds. ``0`` = unlimited."""
89
90 required: bool
91 """When False, a failure is reported as a warning but does not block
92 subsequent gates or mark the overall CI run as failed."""
93
94
95 class CiSettings(TypedDict):
96 """Global settings section of ``.muse/ci.toml``."""
97
98 test_budget_s: float
99 """Total seconds budget for the test gate specifically."""
100
101 workers: int
102 """Parallel pytest subprocess partitions for the test gate."""
103
104 env_allowlist: list[str]
105 """Environment variable names forwarded to gate subprocesses."""
106
107
108 class CiConfig(TypedDict):
109 """Parsed ``.muse/ci.toml`` configuration."""
110
111 version: int
112 """Config schema version (currently 1)."""
113
114 settings: CiSettings
115 """Global settings."""
116
117 gates: list[CiGate]
118 """Ordered list of gates to execute."""
119
120
121 class GateResult(TypedDict):
122 """Outcome for a single CI gate."""
123
124 name: str
125 """Gate name."""
126
127 command: list[str]
128 """Command that was executed."""
129
130 exit_code: int
131 """Subprocess exit code."""
132
133 duration_ms: float
134 """Wall-clock execution time in milliseconds."""
135
136 stdout: str
137 """Captured standard output."""
138
139 stderr: str
140 """Captured standard error."""
141
142 required: bool
143 """Whether this gate was required."""
144
145 passed: bool
146 """``True`` when the gate exited with code 0."""
147
148 timed_out: bool
149 """``True`` when the gate was killed due to ``timeout_s``."""
150
151 warning: NotRequired[str]
152 """Present when a non-required gate failed (explains advisory status)."""
153
154
155 class CiRunResult(TypedDict):
156 """Aggregated result of a full CI run."""
157
158 passed: bool
159 """Overall CI status: ``True`` only when all *required* gates passed."""
160
161 gates: list[GateResult]
162 """Results for every gate in order of execution."""
163
164 total_duration_ms: float
165 """Wall-clock time for all gates combined."""
166
167 timestamp: str
168 """ISO 8601 UTC timestamp of when the CI run began."""
169
170
171 # ---------------------------------------------------------------------------
172 # Default configuration
173 # ---------------------------------------------------------------------------
174
175 _DEFAULT_GATES: list[CiGate] = [
176 CiGate(
177 name="type-check",
178 command=[sys.executable, "-m", "mypy", "muse/"],
179 timeout_s=120.0,
180 required=True,
181 ),
182 CiGate(
183 name="typing-audit",
184 command=[
185 sys.executable,
186 "tools/typing_audit.py",
187 "--dirs",
188 "muse/",
189 "tests/",
190 "--max-any",
191 "0",
192 ],
193 timeout_s=60.0,
194 required=True,
195 ),
196 CiGate(
197 name="tests",
198 command=[sys.executable, "-m", "pytest", "tests/", "-q"],
199 timeout_s=300.0,
200 required=True,
201 ),
202 ]
203
204 _DEFAULT_SETTINGS = CiSettings(
205 test_budget_s=300.0,
206 workers=1,
207 env_allowlist=[],
208 )
209
210 _DEFAULT_CONFIG = CiConfig(
211 version=1,
212 settings=_DEFAULT_SETTINGS,
213 gates=_DEFAULT_GATES,
214 )
215
216 _CI_FILE = ".muse/ci.toml"
217
218 # ---------------------------------------------------------------------------
219 # TOML loading
220 # ---------------------------------------------------------------------------
221
222
223 def _ci_path(root: pathlib.Path) -> pathlib.Path:
224 return root / _CI_FILE
225
226
227 def _parse_gate(raw: MsgpackValue, index: int) -> CiGate:
228 """Parse one ``[[gate]]`` entry from the TOML document."""
229 if not isinstance(raw, dict):
230 raise ValueError(f"gate[{index}] must be a TOML table, got {type(raw).__name__}")
231
232 raw_dict: MsgpackDict = raw
233 name = _str_val(raw_dict, "name", f"gate-{index}")
234 command_raw = raw_dict.get("command")
235 timeout_raw = raw_dict.get("timeout_s", 0.0)
236 required_raw = raw_dict.get("required", True)
237
238 if not isinstance(command_raw, list) or not all(isinstance(c, str) for c in command_raw):
239 raise ValueError(
240 f"gate[{index}] 'command' must be a list of strings, got {command_raw!r}"
241 )
242 command: list[str] = [c for c in command_raw if isinstance(c, str)]
243 if not command:
244 raise ValueError(f"gate[{index}] 'command' must not be empty")
245
246 timeout_s = (
247 float(timeout_raw)
248 if isinstance(timeout_raw, (int, float)) and not isinstance(timeout_raw, bool)
249 else 0.0
250 )
251 required = bool(required_raw) if isinstance(required_raw, bool) else True
252
253 return CiGate(
254 name=name,
255 command=command,
256 timeout_s=timeout_s,
257 required=required,
258 )
259
260
261 def _parse_settings(raw: MsgpackValue) -> CiSettings:
262 """Parse the ``[settings]`` section from the TOML document."""
263 if not isinstance(raw, dict):
264 return _DEFAULT_SETTINGS
265 raw_dict: MsgpackDict = raw
266 budget_raw = raw_dict.get("test_budget_s", 300.0)
267 budget = (
268 float(budget_raw)
269 if isinstance(budget_raw, (int, float)) and not isinstance(budget_raw, bool)
270 else 300.0
271 )
272 return CiSettings(
273 test_budget_s=budget,
274 workers=_int_val(raw_dict, "workers", 1),
275 env_allowlist=_str_list(raw_dict, "env_allowlist"),
276 )
277
278
279 def load_ci_config(root: pathlib.Path) -> CiConfig:
280 """Load and parse ``.muse/ci.toml``.
281
282 Returns the built-in default configuration when the file does not exist.
283
284 Args:
285 root: Repository root directory.
286
287 Raises:
288 ValueError: If the TOML file exists but cannot be parsed or contains
289 invalid gate definitions.
290 """
291 path = _ci_path(root)
292 if not path.exists():
293 logger.debug("ci: no %s found β€” using default gates", _CI_FILE)
294 return _DEFAULT_CONFIG
295
296 try:
297 raw_bytes = path.read_bytes()
298 # tomllib.loads returns a Python object tree equivalent to MsgpackValue
299 # (str, int, float, bool, list, dict β€” no bytes or None at top level).
300 # We annotate as MsgpackValue after the isinstance guard below.
301 parsed = tomllib.loads(raw_bytes.decode())
302 except Exception as exc:
303 raise ValueError(f"Failed to parse {_CI_FILE}: {exc}") from exc
304
305 if not isinstance(parsed, dict):
306 raise ValueError(f"{_CI_FILE} must be a TOML table at the top level")
307
308 doc: MsgpackDict = parsed # safe: TOML dict βŠ† MsgpackValue
309 version = _int_val(doc, "version", 1)
310 settings = _parse_settings(doc.get("settings", {}))
311
312 raw_gates = doc.get("gate", [])
313 if not isinstance(raw_gates, list):
314 raise ValueError(f"{_CI_FILE} 'gate' must be an array of tables")
315
316 gates: list[CiGate] = [_parse_gate(g, i) for i, g in enumerate(raw_gates)]
317
318 return CiConfig(version=version, settings=settings, gates=gates)
319
320
321 # ---------------------------------------------------------------------------
322 # Gate execution
323 # ---------------------------------------------------------------------------
324
325
326 def _mandatory_env() -> Metadata:
327 """Return the minimal environment variables Python needs to start."""
328 _MANDATORY = frozenset(
329 {
330 "PATH",
331 "HOME",
332 "PYTHONPATH",
333 "VIRTUAL_ENV",
334 "PYTHONHOME",
335 "PYTHONDONTWRITEBYTECODE",
336 "TMPDIR",
337 "TMP",
338 "TEMP",
339 "LANG",
340 "LC_ALL",
341 "LC_CTYPE",
342 }
343 )
344 return {k: v for k, v in os.environ.items() if k in _MANDATORY}
345
346
347 def _build_gate_env(allowlist: Sequence[str]) -> Metadata:
348 """Build the subprocess environment for a gate (mandatory + allowlist)."""
349 env = _mandatory_env()
350 for key in allowlist:
351 if key in os.environ:
352 env[key] = os.environ[key]
353 return env
354
355
356 def _run_gate(gate: CiGate, env: Metadata, cwd: str) -> GateResult:
357 """Execute a single CI gate subprocess and return its result."""
358 timeout: float | None = gate["timeout_s"] if gate["timeout_s"] > 0 else None
359 timed_out = False
360 exit_code = 0
361 stdout = ""
362 stderr = ""
363
364 t0 = time.monotonic()
365 try:
366 proc = subprocess.run(
367 gate["command"],
368 capture_output=True,
369 text=True,
370 timeout=timeout,
371 env=env,
372 cwd=cwd,
373 shell=False,
374 )
375 exit_code = proc.returncode
376 stdout = proc.stdout or ""
377 stderr = proc.stderr or ""
378 except subprocess.TimeoutExpired as exc:
379 timed_out = True
380 exit_code = 124
381 stdout = (
382 (exc.stdout or b"").decode(errors="replace")
383 if isinstance(exc.stdout, bytes)
384 else (exc.stdout or "")
385 )
386 stderr = (
387 (exc.stderr or b"").decode(errors="replace")
388 if isinstance(exc.stderr, bytes)
389 else (exc.stderr or "")
390 )
391 logger.warning("⚠️ ci: gate %r timed out after %.1f s", gate["name"], gate["timeout_s"])
392 except OSError as exc:
393 exit_code = 127
394 stderr = f"Failed to launch command: {exc}"
395 logger.error("❌ ci: gate %r failed to launch: %s", gate["name"], exc)
396
397 duration_ms = (time.monotonic() - t0) * 1000.0
398 passed = exit_code == 0 and not timed_out
399
400 result = GateResult(
401 name=gate["name"],
402 command=gate["command"],
403 exit_code=exit_code,
404 duration_ms=duration_ms,
405 stdout=stdout,
406 stderr=stderr,
407 required=gate["required"],
408 passed=passed,
409 timed_out=timed_out,
410 )
411 if not passed and not gate["required"]:
412 result["warning"] = (
413 f"Non-required gate '{gate['name']}' failed (exit {exit_code})"
414 )
415 return result
416
417
418 # ---------------------------------------------------------------------------
419 # Public API
420 # ---------------------------------------------------------------------------
421
422
423 def run_ci(
424 root: pathlib.Path,
425 config: CiConfig,
426 ) -> CiRunResult:
427 """Execute all gates in *config* sequentially and return a :class:`CiRunResult`.
428
429 Gates are executed in order. When a *required* gate fails, subsequent
430 gates are still executed to collect the full failure picture β€” the run
431 is only stopped immediately on timeout of a required gate to avoid
432 wasting budget.
433
434 Args:
435 root: Repository root (used as the subprocess working directory).
436 config: Parsed CI configuration (from :func:`load_ci_config`).
437
438 Returns:
439 :class:`CiRunResult` with per-gate outcomes and an overall pass/fail.
440 """
441 from muse.core.test_history import iso_now
442
443 timestamp = iso_now()
444 env = _build_gate_env(config["settings"]["env_allowlist"])
445 cwd = str(root)
446
447 t_start = time.monotonic()
448 gate_results: list[GateResult] = []
449 overall_passed = True
450
451 for gate in config["gates"]:
452 logger.info("πŸ”· ci: running gate %r: %s", gate["name"], " ".join(gate["command"]))
453 result = _run_gate(gate, env, cwd)
454 gate_results.append(result)
455
456 if not result["passed"] and gate["required"]:
457 overall_passed = False
458 logger.error(
459 "❌ ci: required gate %r failed (exit %d)",
460 gate["name"],
461 result["exit_code"],
462 )
463 if result["timed_out"]:
464 logger.error("❌ ci: aborting remaining gates due to timeout")
465 break
466 elif not result["passed"] and not gate["required"]:
467 logger.warning(
468 "⚠️ ci: non-required gate %r failed (exit %d) β€” continuing",
469 gate["name"],
470 result["exit_code"],
471 )
472 else:
473 logger.info("βœ… ci: gate %r passed", gate["name"])
474
475 total_duration_ms = (time.monotonic() - t_start) * 1000.0
476
477 return CiRunResult(
478 passed=overall_passed,
479 gates=gate_results,
480 total_duration_ms=total_duration_ms,
481 timestamp=timestamp,
482 )