| #!/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. |
| """Rolls the pinned pnpm toolchain. |
| |
| Downloads the official prebuilt pnpm standalone binaries for every platform |
| Perfetto supports, and optionally uploads them to gs://perfetto and rewrites |
| the pins in tools/install-build-deps. |
| |
| Usage: |
| tools/roll_pnpm latest # download binaries locally, print pins |
| tools/roll_pnpm 9.15.4 --upload # upload to GCS and patch deps |
| """ |
| |
| import argparse |
| import hashlib |
| import json |
| import os |
| import re |
| import shutil |
| import subprocess |
| import sys |
| import tempfile |
| import urllib.request |
| |
| ROOT_DIR = os.path.dirname( |
| os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) |
| INSTALL_BUILD_DEPS = os.path.join(ROOT_DIR, 'tools', 'install-build-deps') |
| |
| GCS_BUCKET = 'gs://perfetto' |
| |
| # (suffix used in GCS object name and install-build-deps, candidate asset names regexes) |
| PLATFORMS = [ |
| ('linux-arm64', [r'pnpm-linux-arm64(\.tar\.gz)?$']), |
| ('linux-x64', [r'pnpm-linux-x64(\.tar\.gz)?$']), |
| ('macos-arm64', [r'pnpm-(macos|darwin)-arm64(\.tar\.gz)?$']), |
| ('macos-x64', [r'pnpm-(macos|darwin)-x64(\.tar\.gz)?$']), |
| ] |
| |
| |
| def sha256_file(path): |
| h = hashlib.sha256() |
| with open(path, 'rb') as f: |
| for chunk in iter(lambda: f.read(1 << 20), b''): |
| h.update(chunk) |
| return h.hexdigest() |
| |
| |
| def fetch_release_info(tag): |
| url = f'https://api.github.com/repos/pnpm/pnpm/releases/tags/{tag}' |
| req = urllib.request.Request( |
| url, headers={'User-Agent': 'Perfetto-Roll-Script'}) |
| with urllib.request.urlopen(req) as f: |
| return json.load(f) |
| |
| |
| def download_binary(suffix, patterns, release_assets, version, work_dir): |
| """Downloads and extracts one platform binary. Returns (file path, sha256).""" |
| asset_url = None |
| asset_name = None |
| |
| for asset in release_assets: |
| name = asset['name'] |
| for pat in patterns: |
| if re.match(pat, name): |
| asset_url = asset['browser_download_url'] |
| asset_name = name |
| break |
| if asset_url: |
| break |
| |
| if not asset_url: |
| raise SystemExit( |
| f'Could not find matching asset for platform {suffix} in release v{version}' |
| ) |
| |
| download_file = os.path.join(work_dir, asset_name) |
| binary_path = os.path.join(work_dir, f'pnpm-{suffix}-{version}') |
| |
| print(f'[{suffix}] downloading {asset_url}') |
| subprocess.check_call( |
| ['curl', '-f', '-L', '-#', '-o', download_file, asset_url]) |
| |
| if asset_name.endsWith('.tar.gz') if hasattr( |
| asset_name, 'endsWith') else asset_name.endswith('.tar.gz'): |
| print(f'[{suffix}] extracting tar.gz') |
| extract_dir = os.path.join(work_dir, f'extract-{suffix}') |
| os.makedirs(extract_dir, exist_ok=True) |
| subprocess.check_call(['tar', '-xf', download_file, '-C', extract_dir]) |
| # The binary inside the tar archive is usually named `pnpm` or `pnpm-xxx` |
| extracted_files = [ |
| os.path.join(extract_dir, f) |
| for f in os.listdir(extract_dir) |
| if not os.path.isdir(os.path.join(extract_dir, f)) |
| ] |
| if not extracted_files: |
| # Check subdirectories if present |
| for root, _, files in os.walk(extract_dir): |
| for f in files: |
| extracted_files.append(os.path.join(root, f)) |
| if not extracted_files: |
| raise SystemExit(f'No binary found in tarball for {suffix}') |
| shutil.copy(extracted_files[0], binary_path) |
| shutil.rmtree(extract_dir) |
| os.remove(download_file) |
| else: |
| shutil.move(download_file, binary_path) |
| |
| os.chmod(binary_path, 0o755) |
| digest = sha256_file(binary_path) |
| print(f'[{suffix}] sha256 {digest}') |
| return binary_path, digest |
| |
| |
| def upload(binary_path, version, suffix): |
| target = f'{GCS_BUCKET}/pnpm-{suffix}-{version}' |
| print(f'uploading {os.path.basename(binary_path)} -> {target}') |
| subprocess.check_call([ |
| 'gcloud', 'storage', 'cp', '--no-clobber', '--predefined-acl=publicRead', |
| binary_path, target |
| ]) |
| |
| |
| def patch_install_build_deps(version, digests): |
| with open(INSTALL_BUILD_DEPS) as f: |
| text = f.read() |
| |
| patterns = { |
| 'linux-arm64': |
| re.compile( |
| r"(Dependency\(\s*'third_party/pnpm/pnpm',\s*')[^']+(\s*',\s*')[0-9a-f]{64}(\s*',\s*'linux',\s*'arm64'\))" |
| ), |
| 'linux-x64': |
| re.compile( |
| r"(Dependency\(\s*'third_party/pnpm/pnpm',\s*')[^']+(\s*',\s*')[0-9a-f]{64}(\s*',\s*'linux',\s*'x64'\))" |
| ), |
| 'macos-arm64': |
| re.compile( |
| r"(Dependency\(\s*'third_party/pnpm/pnpm',\s*')[^']+(\s*',\s*')[0-9a-f]{64}(\s*',\s*'darwin',\s*'arm64'\))" |
| ), |
| 'macos-x64': |
| re.compile( |
| r"(Dependency\(\s*'third_party/pnpm/pnpm',\s*')[^']+(\s*',\s*')[0-9a-f]{64}(\s*',\s*'darwin',\s*'x64'\))" |
| ), |
| } |
| |
| for suffix, pat in patterns.items(): |
| digest = digests[suffix] |
| replacement = r"\g<1>https://storage.googleapis.com/perfetto/pnpm-%s-%s\g<2>%s\g<3>" % ( |
| suffix, version, digest) |
| text, n = pat.subn(replacement, text) |
| if n != 1: |
| raise SystemExit( |
| 'Expected exactly one %s pin in install-build-deps, found %d' % |
| (suffix, n)) |
| |
| with open(INSTALL_BUILD_DEPS, 'w') as f: |
| f.write(text) |
| print(f'patched {INSTALL_BUILD_DEPS}') |
| |
| |
| PNPM_RELEASES_URL = 'https://api.github.com/repos/pnpm/pnpm/releases' |
| |
| |
| def check_has_all_assets(release): |
| assets = [a['name'] for a in release.get('assets', [])] |
| for _, patterns in PLATFORMS: |
| matched = False |
| for pat in patterns: |
| if any(re.match(pat, name) for name in assets): |
| matched = True |
| break |
| if not matched: |
| return False |
| return True |
| |
| |
| def resolve_pnpm_version(query): |
| query = query.lstrip('v').strip() |
| |
| if query == 'latest': |
| req = urllib.request.Request( |
| PNPM_RELEASES_URL, headers={'User-Agent': 'Perfetto-Roll-Script'}) |
| with urllib.request.urlopen(req) as f: |
| releases = json.load(f) |
| for r in releases: |
| if not r.get('prerelease', False) and check_has_all_assets(r): |
| return r['tag_name'].lstrip('v') |
| return releases[0]['tag_name'].lstrip('v') |
| |
| return query |
| |
| |
| def main(): |
| parser = argparse.ArgumentParser( |
| description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) |
| parser.add_argument( |
| 'version', help='pnpm release version, e.g. 9.15.4 or latest') |
| parser.add_argument( |
| '--upload', |
| action='store_true', |
| help='upload binaries to GCS and patch install-build-deps') |
| parser.add_argument( |
| '--work-dir', |
| help='scratch dir for downloads (default: temp dir under repo root)') |
| parser.add_argument( |
| '--platforms', |
| help='comma-separated subset of: ' + ', '.join(p[0] for p in PLATFORMS)) |
| parser.add_argument( |
| '--keep', action='store_true', help='keep the work dir when done') |
| args = parser.parse_args() |
| |
| version = resolve_pnpm_version(args.version) |
| print(f'Resolved pnpm version: {version}') |
| |
| release_info = fetch_release_info(f'v{version}') |
| assets = release_info.get('assets', []) |
| |
| wanted = set(args.platforms.split(',')) if args.platforms else None |
| platforms = [p for p in PLATFORMS if wanted is None or p[0] in wanted] |
| |
| owns_work_dir = not args.work_dir |
| work_dir = args.work_dir or tempfile.mkdtemp( |
| prefix='pnpm-roll-', dir=ROOT_DIR) |
| os.makedirs(work_dir, exist_ok=True) |
| print(f'work dir: {work_dir}') |
| |
| cleanup = False |
| try: |
| digests, binaries = {}, {} |
| for suffix, patterns in platforms: |
| bin_path, digest = download_binary(suffix, patterns, assets, version, |
| work_dir) |
| digests[suffix] = digest |
| binaries[suffix] = bin_path |
| |
| if args.upload: |
| for suffix in binaries: |
| upload(binaries[suffix], version, suffix) |
| patch_install_build_deps(version, digests) |
| print('\nDone. Run `tools/install-build-deps` to fetch and verify the ' |
| 'new pnpm toolchain.') |
| cleanup = owns_work_dir and not args.keep |
| else: |
| print('\nDry run (no --upload). Downloaded binaries:') |
| for suffix in binaries: |
| print(' %-12s %s' % (suffix, binaries[suffix])) |
| print(' sha256 %s' % digests[suffix]) |
| print( |
| '\nRe-run with --upload to push to GCS and patch install-build-deps.') |
| finally: |
| if cleanup: |
| shutil.rmtree(work_dir, ignore_errors=True) |
| elif owns_work_dir: |
| print(f'\nBinaries left in {work_dir}') |
| |
| |
| if __name__ == '__main__': |
| main() |