| #!/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()) |