blob: e25c55d8d1cbd1ee6e3bc1c54a03498fb8af1a84 [file]
#!/usr/bin/env python3
# Copyright (c) 2026, the R8 project authors. Please see the AUTHORS file
# for details. All rights reserved. Use of this source code is governed by a
# BSD-style license that can be found in the LICENSE file.
"""Generates commits.json / version.json and deploys the CI radiator to GCS."""
import argparse
import datetime
import hashlib
import json
import os
import pathlib
import re
import subprocess
import sys
import tempfile
import time
SCRIPT_DIR = pathlib.Path(__file__).resolve().parent
TOOLS_DIR = SCRIPT_DIR.parent
REPO_ROOT = TOOLS_DIR.parent
if str(TOOLS_DIR) not in sys.path:
sys.path.insert(0, str(TOOLS_DIR))
import utils
LUCI_GENERATED_DIR = REPO_ROOT / 'infra' / 'config' / 'global' / 'generated'
RELEASE_BRANCH_RE = re.compile(r'^origin/([0-9]+\.[0-9]+(?:\.[0-9]+)?)$')
DEFAULT_BUCKET = 'r8-test-results'
DEFAULT_PREFIX = 'ci-dashboard'
STATIC_ASSETS = ('index.html', 'dashboard.css', 'dashboard.js')
GENERATED_ASSETS = ('commits.json', 'version.json')
CACHE_HEADER = 'Cache-Control: no-cache, max-age=0'
def git_output(args):
return subprocess.check_output(
['git', '-C', str(REPO_ROOT)] + args,
text=True,
)
def parse_luci_configs():
milo_text = (LUCI_GENERATED_DIR /
'luci-milo.cfg').read_text(encoding='utf-8')
sched_text = (LUCI_GENERATED_DIR /
'luci-scheduler.cfg').read_text(encoding='utf-8')
cq_text = (LUCI_GENERATED_DIR /
'commit-queue.cfg').read_text(encoding='utf-8')
trig_match = re.search(
r'trigger \{\s*id: "main-gitiles-trigger"(.*?)\n\}',
sched_text,
re.S,
)
main_triggered = (set(
re.findall(r'triggers: "([^"]+)"', trig_match.group(1)))
if trig_match else set())
main_console = []
release_console = []
for project, bucket, builder, category, short_name in re.findall(
r'builders \{\s*name: "buildbucket/luci\.([^.]+)\.([^/]+)/([^"]+)"\s*'
r'category: "([^"]+)"\s*short_name: "([^"]+)"\s*\}',
milo_text,
):
if category == 'Release' or category.startswith('Release|'):
release_console.append({
'project': project,
'bucket': bucket,
'builder': builder,
'category': category.removeprefix('Release|'),
'short_name': short_name,
})
else:
main_console.append({
'project': project,
'bucket': bucket,
'builder': builder,
'category': category,
'short_name': short_name,
'auto_triggered': builder in main_triggered,
})
cq_builders = []
for project, bucket, builder, modes_block in re.findall(
r'builders \{\s*name: "([^/]+)/([^/]+)/([^"]+)"'
r'((?:\s*mode_allowlist: "[^"]+")*)\s*\}',
cq_text,
):
cq_builders.append({
'project': project,
'bucket': bucket,
'builder': builder,
'modes': re.findall(r'mode_allowlist: "([^"]+)"', modes_block),
})
return {
'main_console': main_console,
'release_console': release_console,
'cq_builders': cq_builders,
}
def read_branch_commits(ref, limit):
out = git_output([
'log',
'--first-parent',
f'-n{limit}',
'--format=%H%x09%ae%x09%ct%x09%s',
ref,
])
commits = []
for line in out.splitlines():
parts = line.split('\t', 3)
if len(parts) != 4:
continue
sha, email, ts_str, subject = parts
commits.append({
'sha': sha,
'author': email.split('@', 1)[0],
'time': int(ts_str),
'subject': subject,
})
return commits
def discover_active_release_branches(days=90):
cutoff = int(time.time()) - days * 86400
out = git_output([
'for-each-ref',
'--format=%(refname:short)%09%(committerdate:unix)',
'refs/remotes/origin',
])
branches = []
for line in out.splitlines():
parts = line.split('\t')
if len(parts) != 2:
continue
ref, ts_str = parts
m = RELEASE_BRANCH_RE.match(ref)
if m and int(ts_str) >= cutoff:
branches.append(m.group(1))
branches.sort(key=lambda b: [int(p) for p in b.split('.')], reverse=True)
return branches
def read_release_cut(branch):
# Release branches merge main into dev-release; the latest such merge's
# second parent is the main commit the branch was cut from.
parents = git_output([
'log', '-1', '--first-parent', '--merges', '--format=%P',
f'origin/{branch}', '^origin/main'
]).split()
return parents[1] if len(parents) > 1 else None
def generate_metadata(out_dir):
out_path = pathlib.Path(out_dir)
now_iso = (datetime.datetime.now(datetime.timezone.utc).replace(
microsecond=0).isoformat().replace('+00:00', 'Z'))
branches_data = {'main': read_branch_commits('origin/main', 300)}
release_cuts = {}
for br in discover_active_release_branches():
branches_data[br] = read_branch_commits(f'origin/{br}', 80)
cut = read_release_cut(br)
if cut:
release_cuts[br] = cut
h = hashlib.sha256()
for asset in STATIC_ASSETS:
h.update((SCRIPT_DIR / asset).read_bytes())
commits_payload = {
'generated_at': now_iso,
'luci_config': parse_luci_configs(),
'branches': branches_data,
'release_cuts': release_cuts,
}
version_payload = {
'assets_hash': h.hexdigest()[:12],
'deployed_at': now_iso,
}
(out_path / 'commits.json').write_text(json.dumps(commits_payload,
separators=(',', ':')),
encoding='utf-8')
(out_path / 'version.json').write_text(json.dumps(version_payload,
indent=2),
encoding='utf-8')
def deploy_dashboard(bucket=DEFAULT_BUCKET, prefix=DEFAULT_PREFIX):
gcs_root = f'gs://{bucket}/{prefix.strip("/")}'
with tempfile.TemporaryDirectory(prefix='r8_ci_dashboard_') as tmp_dir:
generate_metadata(tmp_dir)
for filename in STATIC_ASSETS:
src = str(SCRIPT_DIR / filename)
utils.upload_file_to_cloud_storage(src,
f'{gcs_root}/{filename}',
header=CACHE_HEADER)
for filename in GENERATED_ASSETS:
src = os.path.join(tmp_dir, filename)
utils.upload_file_to_cloud_storage(src,
f'{gcs_root}/{filename}',
header=CACHE_HEADER)
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--bucket', default=DEFAULT_BUCKET)
parser.add_argument('--prefix', default=DEFAULT_PREFIX)
args = parser.parse_args()
deploy_dashboard(bucket=args.bucket, prefix=args.prefix)
if __name__ == '__main__':
main()