2"""Run the repo's checks: all of them, a group, or some by name.
4 tools/check every check: the eval group, then ship
5 tools/check --group eval the checks that need only urbit and Python
6 tools/check --group ship the checks that need the fake ~zod
7 tools/check golden 'type-*' only the checks these names or globs match
8 tools/check --list the checks, their groups and commands
9 tools/check -j 3 at most 3 eval checks at once
11CHECKS below is the one list of the repo's checks: docs/development.md
12sends you here before pushing, and `nix flake check` runs this tool too
13(see flake.nix), so a new check goes in the list and nowhere else.
15The eval checks run under `urbit eval` without a ship, several at once
16(-j, default: as many as there are CPUs), in the order of the list, so
17the slowest come first. The ship checks then run one at a time, in
18order, on the ship tools/ship talks to: $JIBB_PIER, or /root/urbit/zod
19(tools/ship also reads $JIBB_CLICK and $JIBB_SHIP_LOG). When that ship
20isn't running, tools/check starts it first and stops it again at the end.
22Each check is one command, run from the repo root, and passes when it
23exits 0. Output is kept and printed only for a check that fails (all of
24it with -v); --logs DIR keeps every check's output in DIR/<name>.log.
26Exit status: 0 when every check ran and passed, 1 when a check failed,
272 when the checks couldn't run (an unknown name, a ship that won't start).
31import concurrent.futures
43ROOT = os.path.dirname(os.path.dirname(os.path.realpath(__file__)))
44SHIP = os.path.join('tools', 'ship')
48 def __init__(self, name, group, command):
50 self.group = group # 'eval' or 'ship'
51 self.command = command # argv, run from ROOT
54 return ' '.join(c if re.match(r'^[\w./=-]+$', c) else "'%s'" % c
55 for c in self.command)
58def tool(name, group='eval', *args):
59 """A check that runs tools/<name> and passes when it exits 0."""
60 base = os.path.basename(name)
61 return Check(base, group, [os.path.join('tools', name)] + list(args))
64# Every check, in the order they start: the eval group, slowest first,
65# then the ship group, in the order they must run. Times are from one
66# run on four CPUs (2026-11-03).
68# arms-1 to arms-4 run the test- arms of every tests/lib suite under
69# eval, in shards of about equal time (tools/test-arms --list --shard
70# 2/4 shows the second). A new suite in tests/lib joins a shard by
71# itself; a slow one belongs in COSTS in tools/test-arms.
75 tool('testdata/selftest'), # about 115 s
76 tool('golden'), # about 60 to 100 s
78 Check('arms-%d' % i, 'eval', # 55 to 80 s each
79 ['tools/test-arms', '--shard', '%d/%d' % (i, ARM_SHARDS)])
80 for i in range(1, ARM_SHARDS + 1)
82 Check('doc-blocks', 'eval', ['tools/doc-blocks', 'docs/spec']),
83 # The ship group. ship-selftest leaves its fixture files on the
84 # jibb-dev desk, and ship-test, which syncs the desk to the repo
85 # first, puts the real ones back, so it must come after.
86 tool('testdata/ship-selftest', 'ship'),
87 Check('ship-test', 'ship', [SHIP, 'test']),
88 Check('check-commit', 'ship', [SHIP, 'check-commit']),
89 tool('clay-fixtures', 'ship'),
92GROUPS = ('eval', 'ship')
95# ---------------------------------------------------------------- running
99 def __init__(self, check):
108_lock = threading.Lock()
109_running = set() # process groups to kill if we're interrupted
114 print(msg, flush=True)
117def run(check, timeout, env):
118 """Run one check; a Result."""
121 with tempfile.TemporaryFile() as out:
123 proc = subprocess.Popen(
124 check.command, cwd=ROOT, env=env, stdin=subprocess.DEVNULL,
125 stdout=out, stderr=subprocess.STDOUT,
126 start_new_session=True)
128 res.why = 'could not run %s: %s' % (check.command[0], e)
131 _running.add(proc.pid)
133 res.status = proc.wait(timeout=timeout)
134 except subprocess.TimeoutExpired:
137 res.why = 'timed out after %d s' % timeout
140 _running.discard(proc.pid)
141 kill(proc.pid) # anything it left running, such as urbit eval
143 res.output = out.read().decode('utf-8', 'replace')
144 res.seconds = time.time() - start
148 res.why = 'exit status %d' % res.status
156 os.killpg(pgid, signal.SIGKILL)
157 except (ProcessLookupError, PermissionError):
163 pids = list(_running)
168def report(res, verbose, logs, limit=300):
169 mark = 'PASS' if res.ok else 'FAIL'
170 line = '%s %-34s %5d s' % (mark, res.check.name, round(res.seconds))
172 line += ' (%s)' % res.why
173 lines = res.output.rstrip('\n').split('\n') if res.output.strip() else []
175 with open(os.path.join(logs, res.check.name + '.log'), 'w') as f:
176 f.write('$ %s\n%s' % (res.check.shown(), res.output))
177 if res.ok and not verbose:
181 if not verbose and len(lines) > limit:
182 shown = ['(%d lines cut; --logs DIR keeps them all)'
183 % (len(lines) - limit)] + lines[-limit:]
184 body = [' $ ' + res.check.shown()] + [' ' + l for l in shown]
185 say('\n'.join([line] + body))
188# ---------------------------------------------------------------- the ship
191def ship(env, *args, **kw):
192 return subprocess.run([SHIP] + list(args), cwd=ROOT, env=env, **kw)
195def ship_running(env):
196 """Whether tools/ship's ship runs: status exits 3 when it doesn't."""
197 res = ship(env, 'status', stdout=subprocess.DEVNULL,
198 stderr=subprocess.DEVNULL)
199 return res.returncode != 3
202# ---------------------------------------------------------------- main
205def select(names, group):
206 """The checks to run, in list order; exits 2 on a name nothing matches."""
207 chosen = [c for c in CHECKS if group in (None, c.group)]
212 hits = [c.name for c in chosen if fnmatch.fnmatchcase(c.name, name)]
214 where = ' in the %s group' % group if group else ''
215 print('check: no check %s%s; tools/check --list lists them'
216 % (name, where), file=sys.stderr)
219 return [c for c in chosen if c.name in picked]
223 ap = argparse.ArgumentParser(
224 prog='tools/check', description=__doc__.split('\n\n')[0],
225 formatter_class=argparse.RawDescriptionHelpFormatter,
226 epilog='\n\n'.join(__doc__.split('\n\n')[1:]))
227 ap.add_argument('names', nargs='*', metavar='NAME',
228 help='checks to run, by name or glob (default: all)')
229 ap.add_argument('--group', choices=GROUPS,
230 help='only the checks in this group')
231 ap.add_argument('--list', action='store_true',
232 help='list the checks and exit')
233 ap.add_argument('-j', '--jobs', type=int, default=os.cpu_count() or 1,
234 metavar='N', help='eval checks to run at once '
235 '(default: the number of CPUs, %(default)s)')
236 ap.add_argument('--timeout', type=int, default=2400, metavar='SECONDS',
237 help='seconds each check may take (default %(default)s)')
238 ap.add_argument('--logs', metavar='DIR',
239 help="keep every check's output in DIR/<name>.log")
240 ap.add_argument('-v', '--verbose', action='store_true',
241 help="print every check's output, not only a failure's")
242 args = ap.parse_args(argv)
244 checks = select(args.names, args.group)
247 print('%-34s %-5s %s' % (c.name, c.group, c.shown()))
250 ap.error('-j must be at least 1')
252 os.makedirs(args.logs, exist_ok=True)
254 env = dict(os.environ, PYTHONDONTWRITEBYTECODE='1')
255 signal.signal(signal.SIGTERM, lambda *_: (kill_all(), sys.exit(143)))
256 started = time.time()
260 evals = [c for c in checks if c.group == 'eval']
262 say('running %d eval check%s, %d at a time'
263 % (len(evals), '' if len(evals) == 1 else 's', args.jobs))
264 with concurrent.futures.ThreadPoolExecutor(args.jobs) as pool:
265 futures = [pool.submit(run, c, args.timeout, env)
267 for f in concurrent.futures.as_completed(futures):
270 report(res, args.verbose, args.logs)
271 ships = [c for c in checks if c.group == 'ship']
274 if not ship_running(env):
275 say('starting the ship for %d ship check%s'
276 % (len(ships), '' if len(ships) == 1 else 's'))
277 start = ship(env, 'start')
278 if start.returncode != 0:
279 say('check: the ship would not start, so the ship '
280 'checks did not run')
283 started_ship = not unable
285 say('running %d ship check%s on the running ship'
286 % (len(ships), '' if len(ships) == 1 else 's'))
289 res = run(c, args.timeout, env)
291 report(res, args.verbose, args.logs)
294 say('stopping the ship tools/check started')
295 ship(env, 'stop', '--force')
296 except KeyboardInterrupt:
298 print('check: interrupted', file=sys.stderr)
301 failed = [r for r in results if not r.ok]
302 order = {c.name: i for i, c in enumerate(CHECKS)}
304 say('%d passed, %d failed%s in %d s' % (
305 len(results) - len(failed), len(failed),
306 ', ship checks not run' if unable else '',
307 round(time.time() - started)))
308 for r in sorted(failed, key=lambda r: order[r.check.name]):
309 say(' failed: %s (%s)' % (r.check.name, r.why))
312 return 2 if unable else 0
315if __name__ == '__main__':
316 sys.exit(main(sys.argv[1:]))
keysesc to close
- j k
- move down, up
- enter
- open
- esc
- back, or up a level
- 1 2 3 4
- readme, files, commits, refs
- /
- filter
- y
- copy the clone command
- c
- compare the chosen commit with another (or shift-click both)
- ?
- show or hide this list