4"""Gate: no CI runner may move its wall clock underneath a job (#509).
6GitHub records every step's ``started_at`` and ``completed_at`` from the
7*runner's own clock*. So a runner whose clock steps backward writes the
8evidence into the API itself: a step that finished before it started, or a step
9that started before the previous one finished. Neither is physically possible;
10both are cheap to look for; and looking turns "the fleet's clocks are fine"
11from an assumption into an observation.
13It is not a cosmetic complaint about log timestamps. Every gate whose contract
14is a *duration* rather than a *result* is measured on that clock:
16* ``fuzz-sweep`` -- libFuzzer derives ``-max_total_time`` from
17 ``system_clock`` (compiler-rt ``FuzzerInternal.h``), as an unsigned second
18 count. Step the clock back and the budget looks spent, the sweep stops after
19 seconds, and it exits 0. A 60 s sweep was observed reporting
20 ``Done 7363327 runs in 251 second(s)`` and, twice, a NEGATIVE duration --
21 while the job was green. ``run_fuzz.sh`` now measures itself on
22 ``CLOCK_MONOTONIC`` and refuses to certify such a run, but that protects one
23 gate; this checker is what notices the *host* is broken.
24* ``timeout-minutes`` on any job -- the runner enforces it.
25* Any benchmark or latency gate.
27The signature was first found on the ``win-ci-*`` WSL2 runners, where the VM's
28RTC ran ~4 minutes fast and ``timesyncd`` stepped it back rather than slewing
29it. A build farm wants slew.
33 check_runner_clock.py # scan recent completed runs
34 check_runner_clock.py --hours 6 # only runs that started recently
35 check_runner_clock.py --runs 60 # widen the scan
36 check_runner_clock.py --selftest # prove the detector still detects
38Exit 0 when every step on every runner is time-ordered, 1 when any is not, and
392 when the scan could not be performed at all -- which is a failure, not a
40pass. A clock checker that quietly reports "clean" because it had no token is
41worse than no checker: it also removes the reason to look.
44from __future__
import annotations
61K_API_BASE =
"https://api.github.com"
66K_UNREACHABLE =
"unreachable: _fail() exits"
73K_OVERLAP_TOLERANCE_S = 5
81def _fail(message: str) ->
None:
82 """Print a fatal message and exit 2 (scan impossible, not scan clean)."""
83 print(f
"ERROR: {message}", file=sys.stderr)
88 """Return an API token, or exit 2 naming every way one could have been given.
90 In CI it is the workflow's own ``GITHUB_TOKEN``, whose rate budget is
91 per-repository and separate from the shared user quota. On a developer box
92 it falls back to whatever ``gh`` is already logged in as, so the gate runs
93 locally with nothing exported. No token at all is a failure, never a clean
96 for name
in (
"GH_TOKEN",
"GITHUB_TOKEN"):
97 value = os.environ.get(name,
"").strip()
100 exe = shutil.which(
"gh")
102 proc = subprocess.run(
103 [str(exe),
"auth",
"token"],
108 if proc.returncode == 0
and proc.stdout.strip():
109 return proc.stdout.strip()
111 "no GitHub API token. Set GH_TOKEN or GITHUB_TOKEN (in a workflow, "
112 "`env: GH_TOKEN: ${{ github.token }}`), or log in with `gh auth login`. "
113 "This gate reads step timestamps from the Actions API; without a token "
114 "there is no scan to report on."
116 raise AssertionError(K_UNREACHABLE)
119def _api(path: str, token: str) -> object:
120 """GET one Actions API path and return the decoded JSON, or exit 2 saying why."""
121 url = f
"{K_API_BASE}/{path.lstrip('/')}"
122 if not url.startswith(f
"{K_API_BASE}/"):
123 _fail(f
"refusing to fetch a URL outside {K_API_BASE}: {url}")
124 request = urllib.request.Request(
127 "Accept":
"application/vnd.github+json",
128 "Authorization": f
"Bearer {token}",
129 "User-Agent":
"ra8-firmware-runner-clock",
130 "X-GitHub-Api-Version":
"2022-11-28",
134 with urllib.request.urlopen(request, timeout=30)
as response:
135 return json.loads(response.read().decode(
"utf-8"))
136 except urllib.error.HTTPError
as exc:
137 _fail(f
"GET {url} failed: HTTP {exc.code} {exc.reason}")
138 except (urllib.error.URLError, TimeoutError, json.JSONDecodeError)
as exc:
139 _fail(f
"GET {url} failed: {exc}")
140 raise AssertionError(K_UNREACHABLE)
143def _parse_ts(value: str |
None) -> dt.datetime |
None:
144 """Parse a GitHub ISO-8601 timestamp, or None when the field is absent."""
148 return dt.datetime.fromisoformat(value)
153def scan_job(job: dict) -> list[dict]:
154 """Return every time-ordering violation in one job's step list.
156 Pure: takes the job dictionary the API returns and reads nothing else, so
157 ``--selftest`` can drive both directions without touching the network.
159 Two rules, both physically impossible on a sane clock:
161 * a step whose ``completed_at`` precedes its own ``started_at``;
162 * a step whose ``started_at`` precedes the previous step's ``completed_at``
163 by more than ``K_OVERLAP_TOLERANCE_S`` (steps within a job are strictly
166 findings: list[dict] = []
167 previous_end: dt.datetime |
None =
None
169 for step
in job.get(
"steps")
or []:
170 start = _parse_ts(step.get(
"started_at"))
171 end = _parse_ts(step.get(
"completed_at"))
172 name = step.get(
"name",
"?")
173 if start
is not None and end
is not None and end < start:
176 "kind":
"finished-before-it-started",
178 "detail": f
"{start:%Y-%m-%dT%H:%M:%SZ} -> {end:%Y-%m-%dT%H:%M:%SZ}",
179 "seconds": (end - start).total_seconds(),
183 if start
is not None and previous_end
is not None:
184 gap = (start - previous_end).total_seconds()
185 if gap < -K_OVERLAP_TOLERANCE_S:
188 "kind":
"started-before-the-previous-step-finished",
191 f
"'{previous_name}' ended {previous_end:%Y-%m-%dT%H:%M:%SZ}, "
192 f
"'{name}' began {start:%Y-%m-%dT%H:%M:%SZ}"
204def _runs(repo: str, token: str, limit: int, hours: int |
None) -> list[dict]:
205 """Fetch the most recent completed workflow runs, newest first."""
206 payload = _api(f
"repos/{repo}/actions/runs?status=completed&per_page={min(limit, 100)}", token)
207 runs = payload.get(
"workflow_runs", [])
if isinstance(payload, dict)
else []
210 cutoff = dt.datetime.now(dt.UTC) - dt.timedelta(hours=hours)
213 started = _parse_ts(run.get(
"run_started_at")
or run.get(
"created_at"))
214 if started
is not None and started >= cutoff:
219def _report(findings: list[dict], scanned: dict) -> int:
220 """Print the scan result and return the process exit status."""
222 f
"runner clock scan: {scanned['runs']} completed runs, "
223 f
"{scanned['jobs']} jobs, {scanned['steps']} steps"
226 print(
"every step on every runner is time-ordered.")
228 for item
in sorted(findings, key=
lambda f: f[
"at"], reverse=
True):
230 print(f
"SKEW runner={item['runner']} {item['workflow']} / {item['job']}")
231 print(f
" step '{item['step']}' {item['kind']}")
232 print(f
" {item['detail']} ({item['seconds']:+.0f}s)")
233 per_runner: dict[str, int] = {}
234 for item
in findings:
235 per_runner[item[
"runner"]] = per_runner.get(item[
"runner"], 0) + 1
238 for runner, count
in sorted(per_runner.items(), key=
lambda kv: -kv[1]):
239 print(f
" {runner}: {count} skewed step(s)")
242 f
"FAIL: {len(findings)} step(s) on {len(per_runner)} runner(s) are not "
243 f
"time-ordered. Those runners moved their wall clock underneath a "
244 f
"running job, so every time-budgeted gate that lands on them measures "
245 f
"the wrong thing (#509). Fix the host's clock discipline -- slew, not "
251def scan(repo: str, limit: int, hours: int |
None) -> int:
252 """Scan recent runs of ``repo`` and report every clock-skew finding."""
254 findings: list[dict] = []
255 scanned = {
"runs": 0,
"jobs": 0,
"steps": 0}
256 for run
in _runs(repo, token, limit, hours):
258 payload = _api(f
"repos/{repo}/actions/runs/{run['id']}/jobs?per_page=100", token)
259 jobs = payload.get(
"jobs", [])
if isinstance(payload, dict)
else []
262 scanned[
"steps"] += len(job.get(
"steps")
or [])
263 for finding
in scan_job(job):
264 finding[
"runner"] = job.get(
"runner_name")
or "(unknown runner)"
265 finding[
"job"] = job.get(
"name",
"?")
266 finding[
"workflow"] = run.get(
"name",
"?")
267 findings.append(finding)
268 if scanned[
"steps"] == 0:
270 "the scan saw zero steps. That is not a clean fleet, it is a scan "
271 "that did not happen -- widen --hours/--runs or check gh's auth."
273 return _report(findings, scanned)
276def _case(name: str, job: dict, expected: int) -> bool:
277 """Assert ``scan_job`` returns ``expected`` findings for ``job``."""
278 got = len(scan_job(job))
280 print(f
" {'ok ' if ok else 'FAIL'} {name}: expected {expected}, got {got}")
284def _step(name: str, start: str |
None, end: str |
None) -> dict:
285 """Build one API-shaped step record for the selftest."""
286 return {
"name": name,
"started_at": start,
"completed_at": end}
289def selftest() -> int:
290 """Drive the detector in both directions with synthetic API records."""
291 print(
"check_runner_clock.py selftest:")
294 _step(
"Set up job",
"2026-07-28T06:00:00Z",
"2026-07-28T06:00:04Z"),
295 _step(
"checkout",
"2026-07-28T06:00:04Z",
"2026-07-28T06:00:09Z"),
304 _step(
"Set up job",
"2026-07-28T06:38:16Z",
"2026-07-28T06:38:18Z"),
305 _step(
"checkout",
"2026-07-28T06:38:18Z",
"2026-07-28T06:34:12Z"),
308 backward_only = {
"steps": [_step(
"gate",
"2026-07-28T06:23:40Z",
"2026-07-28T06:21:12Z")]}
311 _step(
"a",
"2026-07-28T06:00:00Z",
"2026-07-28T06:05:00Z"),
312 _step(
"b",
"2026-07-28T06:01:00Z",
"2026-07-28T06:06:00Z"),
317 _step(
"a",
"2026-07-28T06:00:00Z",
"2026-07-28T06:00:05Z"),
318 _step(
"b",
"2026-07-28T06:00:04Z",
"2026-07-28T06:00:09Z"),
321 skipped = {
"steps": [_step(
"never ran",
None,
None)]}
323 _case(
"a time-ordered job is clean", healthy, 0),
324 _case(
"the observed win-ci-3 checkout (#509)", observed, 1),
325 _case(
"a step that finished before it started", backward_only, 1),
326 _case(
"a step that started before the previous finished", overlap_only, 1),
327 _case(
"one second of rounding is not a fault", rounding, 0),
328 _case(
"a skipped step has no timestamps to judge", skipped, 0),
329 _case(
"a job with no steps at all", {}, 0),
332 print(
"SELFTEST FAILED: the clock detector no longer behaves as documented.")
334 print(f
" {len(results)}/{len(results)} cases as documented.")
339 """Parse arguments and run either the selftest or a scan."""
340 parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
341 parser.add_argument(
"--repo", default=
"bsikar/ra8-firmware", help=
"owner/name")
342 parser.add_argument(
"--runs", type=int, default=K_DEFAULT_RUNS, help=
"completed runs to scan")
343 parser.add_argument(
"--hours", type=int, default=
None, help=
"only runs started within N hours")
344 parser.add_argument(
"--selftest", action=
"store_true", help=
"prove the detector, then exit")
345 args = parser.parse_args()
349 _fail(
"--runs must be at least 1; a scan of nothing proves nothing.")
350 return scan(args.repo, args.runs, args.hours)
353if __name__ ==
"__main__":
void main(void)
The application entry point Reset_Handler hands control to.