blob: c2ee8d5d33480d136443a911daf7fa2310193965 [file]
#!/usr/bin/env python3
# Copyright (C) 2026 The Android Open Source Project
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Packages the Perfetto UI, as deployed on ui.perfetto.dev, for self-hosting.
For a given release tag, this downloads the exact UI build that the
stable-channel Cloud Build deploy publishes to the public
gs://ui.perfetto.dev bucket, and zips it up into a tree that can be dropped
onto any static file server to self-host that release's UI.
Usage: ./tools/release/package-ui-github-release-artifact v57.1
This will generate perfetto-ui.zip in /tmp/perfetto-v57.1-github-release/
(the same staging dir package-github-release-artifacts uses, so a single
`gh release upload $STAGING/*.zip` picks up both).
"""
import argparse
import base64
import concurrent.futures
import hashlib
import json
import os
import re
import subprocess
import sys
import tempfile
import urllib.error
import urllib.request
import zipfile
_GCS_ROOT = 'https://commondatastorage.googleapis.com/ui.perfetto.dev'
_REPO_ROOT = os.path.abspath(
os.path.join(os.path.dirname(__file__), '..', '..'))
_NUM_WORKERS = 16
def _fetch(url):
with urllib.request.urlopen(url) as resp:
return resp.read()
def _resolve_ui_version(version):
try:
sha = subprocess.check_output(['git', 'rev-parse', f'{version}^{{commit}}'],
cwd=_REPO_ROOT,
stderr=subprocess.PIPE,
text=True).strip()
except subprocess.CalledProcessError as e:
raise SystemExit(
f'ERROR: tag {version} not found in this checkout. Fetch tags '
f'(git fetch --tags) and re-run.\n{e.stderr}')
return f'{version}-{sha[:9]}'
def _download_manifest(ui_version):
url = f'{_GCS_ROOT}/{ui_version}/manifest.json'
try:
return json.loads(_fetch(url))
except urllib.error.HTTPError as e:
if e.code == 404:
raise SystemExit(
f'ERROR: {url} not found (404). The UI for {ui_version} has not '
'been deployed to ui.perfetto.dev yet: the stable-channel Cloud '
'Build deploy runs on push to the stable branch. Wait for it to '
'complete and re-run this script.')
raise
def _download_resource(ui_dir, ui_version, relpath, digest):
url = f'{_GCS_ROOT}/{ui_version}/{relpath}'
data = _fetch(url)
actual = 'sha256-' + base64.b64encode(hashlib.sha256(data).digest()).decode()
if actual != digest:
raise SystemExit(
f'ERROR: checksum mismatch for {relpath}: manifest says {digest}, '
f'downloaded content hashes to {actual}')
dst = os.path.join(ui_dir, ui_version, relpath)
os.makedirs(os.path.dirname(dst), exist_ok=True)
with open(dst, 'wb') as f:
f.write(data)
def _download_ui_tree(ui_dir, ui_version, manifest):
resources = manifest['resources']
print(f'--- Downloading {len(resources)} resources for {ui_version} ---')
with concurrent.futures.ThreadPoolExecutor(max_workers=_NUM_WORKERS) as pool:
futures = [
pool.submit(_download_resource, ui_dir, ui_version, relpath, digest)
for relpath, digest in resources.items()
]
for future in concurrent.futures.as_completed(futures):
future.result() # Re-raises on failure.
version_dir = os.path.join(ui_dir, ui_version)
os.makedirs(version_dir, exist_ok=True)
for extra in ('index.html', 'manifest.json'):
data = _fetch(f'{_GCS_ROOT}/{ui_version}/{extra}')
with open(os.path.join(version_dir, extra), 'wb') as f:
f.write(data)
def _download_root_files(ui_dir):
sw_path = os.path.join(ui_dir, 'service_worker.js')
with open(sw_path, 'wb') as f:
f.write(_fetch(f'{_GCS_ROOT}/service_worker.js'))
try:
data = _fetch(f'{_GCS_ROOT}/service_worker.js.map')
except urllib.error.HTTPError as e:
if e.code != 404:
raise
print('NOTE: service_worker.js.map not found on the bucket; skipping.')
return
with open(os.path.join(ui_dir, 'service_worker.js.map'), 'wb') as f:
f.write(data)
def _write_root_index_html(ui_dir, ui_version):
version_index = os.path.join(ui_dir, ui_version, 'index.html')
with open(version_index) as f:
html = f.read()
version_map = json.dumps({'stable': ui_version})
patched, count = re.subn(
r"data-perfetto_version='[^']*'",
f"data-perfetto_version='{version_map}'",
html,
count=1)
if count == 0:
raise SystemExit(
"ERROR: data-perfetto_version attribute not found in the downloaded "
f'{ui_version}/index.html; the archival copy format may have '
'changed.')
with open(os.path.join(ui_dir, 'index.html'), 'w') as f:
f.write(patched)
def _zip_ui_dir(ui_dir, zip_path):
file_count = 0
with zipfile.ZipFile(
zip_path, 'w', zipfile.ZIP_DEFLATED, compresslevel=9) as zf:
for root, _, files in os.walk(ui_dir):
for name in sorted(files):
abs_path = os.path.join(root, name)
arcname = os.path.relpath(abs_path, ui_dir)
zf.write(abs_path, arcname)
file_count += 1
return file_count
def main():
parser = argparse.ArgumentParser(epilog='Example: %s v57.1' % __file__)
parser.add_argument('version', help='Release version tag (e.g. v57.1)')
parser.add_argument(
'--out-dir',
help='Staging dir (default: /tmp/perfetto-<version>-github-release, '
'matching package-github-release-artifacts).')
args = parser.parse_args()
if not re.match(r'^v\d+\.\d+$', args.version):
print(
f'ERROR: version must match vX.Y (got: {args.version})',
file=sys.stderr)
return 1
out_dir = args.out_dir or f'/tmp/perfetto-{args.version}-github-release'
os.makedirs(out_dir, exist_ok=True)
ui_version = _resolve_ui_version(args.version)
print(f'Resolved {args.version} -> ui_version {ui_version}')
manifest = _download_manifest(ui_version)
# Stage the tree outside out_dir: package-github-release-artifacts'
# verify_downloads() hard-fails on unexpected directories in there.
with tempfile.TemporaryDirectory(prefix='perfetto-ui-') as ui_dir:
_download_ui_tree(ui_dir, ui_version, manifest)
_download_root_files(ui_dir)
_write_root_index_html(ui_dir, ui_version)
zip_path = os.path.join(out_dir, 'perfetto-ui.zip')
file_count = _zip_ui_dir(ui_dir, zip_path)
print('')
print('=' * 70)
print(f'Packaged {file_count} files into {zip_path}')
print(f'Zip size: {os.path.getsize(zip_path)} bytes')
print('=' * 70)
return 0
if __name__ == '__main__':
sys.exit(main())