1919# minutes expected duration, from the Minutes column of a previous
2020# run's summary (default 1.0). Schedule weight only - configs
2121# run longest-first and --shard balances shards by it; a stale
22- # value just packs the schedule a little worse.
22+ # value just packs the schedule a little worse, but a run
23+ # whose measured time lands more than +/-50% away from it
24+ # draws a warning (never a failure) so it is easy to spot
25+ # and update.
2326# user_settings header staged as <builddir>/user_settings.h before
2427# configure (path relative to the source root); pair it with
2528# --enable-usersettings in "configure"
@@ -88,6 +91,9 @@ class Config:
8891 check : bool = True
8992 prepare : list [list [str ]] = field (default_factory = list )
9093 run : list [list [str ]] = field (default_factory = list )
94+ # Whether "minutes" was given in the JSON (vs the 1.0 default); only an
95+ # explicit estimate is checked for >50% drift against the real time.
96+ minutes_provided : bool = False
9197
9298SRCDIR = Path (__file__ ).resolve ().parents [2 ]
9399ON_GITHUB = os .environ .get ("GITHUB_ACTIONS" ) == "true"
@@ -208,7 +214,8 @@ def load_configs(opts: argparse.Namespace,
208214 entry .get ("ldflags" , opts .ldflags ),
209215 float (minutes ), user_settings , check ,
210216 list (entry .get ("prepare" , [])),
211- list (entry .get ("run" , []))))
217+ list (entry .get ("run" , [])),
218+ minutes_provided = "minutes" in entry ))
212219 if not configs :
213220 error (f"{ opts .json } : no configs" )
214221 return configs
@@ -227,8 +234,16 @@ def privatize_dirs(bdir: Path, dirs: list[str]) -> None:
227234 shutil .copytree (SRCDIR / name , d , symlinks = True )
228235
229236
237+ def gh_escape (data : str ) -> str :
238+ # Percent-encode workflow-command data (GitHub's documented encoding)
239+ # so a stray %, CR or LF - e.g. from a config name or step out of the
240+ # JSON - can't truncate the command or be parsed as a second one.
241+ return data .replace ("%" , "%25" ).replace ("\r " , "%0D" ).replace ("\n " , "%0A" )
242+
243+
230244def dump (title : str , path : Path ) -> None :
231- print (f"::group::{ title } " if ON_GITHUB else f"==== { title } ====" )
245+ # ::group:: is a workflow command; escape its title like warn() does.
246+ print (f"::group::{ gh_escape (title )} " if ON_GITHUB else f"==== { title } ====" )
232247 try :
233248 sys .stdout .write (path .read_text (errors = "replace" ))
234249 except OSError as e :
@@ -238,6 +253,23 @@ def dump(title: str, path: Path) -> None:
238253 sys .stdout .flush ()
239254
240255
256+ def warn (msg : str ) -> None :
257+ # GitHub surfaces ::warning:: as an annotation at the top of the run;
258+ # locally it is just a line. Informational only - never fails the run.
259+ print (f"::warning::{ gh_escape (msg )} " if ON_GITHUB else f"WARNING: { msg } " )
260+
261+
262+ def stale_estimate (cfg : Config , minutes : float ) -> bool :
263+ # "minutes" is only a scheduling estimate (configs run longest-first;
264+ # --shard balances by it), never a pass/fail bound. Flag a finished
265+ # config whose real time drifted past +/-50% of an explicitly given
266+ # estimate so stale values - which pack the schedule worse - are easy
267+ # to find and update. Configs that omit "minutes" ride the 1.0 default
268+ # placeholder and are left alone.
269+ return (cfg .minutes_provided
270+ and not 0.5 * cfg .minutes <= minutes <= 1.5 * cfg .minutes )
271+
272+
241273def run_config (cfg : Config , opts : argparse .Namespace ) -> tuple [str | None ,
242274 float ]:
243275 if opts .fail_fast and stop_event .is_set ():
@@ -344,6 +376,10 @@ def record_failure(step: str) -> str:
344376 # One line per passing config; the full logs would bloat the CI
345377 # log (they stay in build-<name>/make-check.log).
346378 print (f"{ cfg .name } : pass [{ minutes :.1f} min]" )
379+ if stale_estimate (cfg , minutes ):
380+ warn (f"{ cfg .name } : ran { minutes :.1f} min but \" minutes\" "
381+ f"says { cfg .minutes :g} (>50% off) - update it in the "
382+ f"config JSON" )
347383 sys .stdout .flush ()
348384 else :
349385 dump (f"{ cfg .name } : FAIL ({ failed } ) [{ minutes :.1f} min]" , log )
@@ -364,6 +400,11 @@ def summarize(results: list[tuple[Config, str | None, float]],
364400 ok = f":x: FAIL ({ failed } )"
365401 else :
366402 ok = ":white_check_mark: pass"
403+ if stale_estimate (cfg , minutes ):
404+ # Non-fatal nudge mirroring the per-config warning, kept in
405+ # the summary next to the Minutes value to copy over.
406+ ok += (f' :warning: "minutes" { cfg .minutes :g} is >50% off, '
407+ f"update to ~{ minutes :.1f} " )
367408 lines .append (f"| { cfg .name } | { ok } | { minutes :.1f} |" )
368409 # Two views of how efficiently the pool used the machine: thread
369410 # occupancy is the time the workers spent running configs out of the
@@ -495,7 +536,7 @@ def run_one(cfg: Config) -> tuple[Config, str | None, float]:
495536 else "aborted without a recorded failure"
496537 if aborted :
497538 msg += f" ({ aborted } config(s) aborted by fail-fast)"
498- print (f"::error::{ msg } " if ON_GITHUB else msg )
539+ print (f"::error::{ gh_escape ( msg ) } " if ON_GITHUB else msg )
499540 return 1
500541 return 0
501542
0 commit comments