|
| 1 | +#!/usr/bin/env python3 |
| 2 | +# ******************************************************************************* |
| 3 | +# Copyright (c) 2026 Contributors to the Eclipse Foundation |
| 4 | +# |
| 5 | +# See the NOTICE file(s) distributed with this work for additional |
| 6 | +# information regarding copyright ownership. |
| 7 | +# |
| 8 | +# This program and the accompanying materials are made available under the |
| 9 | +# terms of the Apache License Version 2.0 which is available at |
| 10 | +# https://www.apache.org/licenses/LICENSE-2.0 |
| 11 | +# |
| 12 | +# SPDX-License-Identifier: Apache-2.0 |
| 13 | +# ******************************************************************************* |
| 14 | +"""Refresh URLs and sha256 digests in tool lockfiles after a version bump. |
| 15 | +
|
| 16 | +Intended to run as a Renovate ``postUpgradeTask``: Renovate rewrites the |
| 17 | +``version`` field (and the version embedded in each binary URL) via its |
| 18 | +``custom.regex`` manager, but it cannot compute the new sha256 digests. This |
| 19 | +script reads the (possibly new) version from each lockfile, fetches the |
| 20 | +corresponding GitHub release, and rewrites every binary's ``url`` and |
| 21 | +``sha256`` from the release's asset list. |
| 22 | +
|
| 23 | +Usage: |
| 24 | + python3 tools/internal/refresh_lockfile_digests.py [LOCKFILE ...] |
| 25 | +
|
| 26 | +With no arguments, refreshes all ``tools/lockfiles/*.lock.json`` files that |
| 27 | +contain a ``binaries`` list. Exits non-zero if any lockfile cannot be |
| 28 | +refreshed (e.g. the release or an expected asset is missing). |
| 29 | +""" |
| 30 | + |
| 31 | +import json |
| 32 | +import os |
| 33 | +import sys |
| 34 | +import urllib.request |
| 35 | + |
| 36 | +GITHUB_API = "https://api.github.com" |
| 37 | + |
| 38 | + |
| 39 | +def _api_get(url, token=None): |
| 40 | + req = urllib.request.Request( |
| 41 | + url, |
| 42 | + headers={ |
| 43 | + "Accept": "application/vnd.github+json", |
| 44 | + "User-Agent": "score-devcontainer-lockfile-refresh", |
| 45 | + **({"Authorization": f"Bearer {token}"} if token else {}), |
| 46 | + }, |
| 47 | + ) |
| 48 | + with urllib.request.urlopen(req) as resp: |
| 49 | + return json.load(resp) |
| 50 | + |
| 51 | + |
| 52 | +def _asset_name(url): |
| 53 | + """Extract the asset filename from a GitHub release download URL.""" |
| 54 | + return url.rsplit("/", 1)[-1] |
| 55 | + |
| 56 | + |
| 57 | +def refresh_lockfile(path, token=None): |
| 58 | + """Refresh one lockfile in place. Returns a list of changed binary names.""" |
| 59 | + with open(path, encoding="utf-8") as f: |
| 60 | + data = json.load(f) |
| 61 | + |
| 62 | + changed = [] |
| 63 | + for tool_name, tool in data.items(): |
| 64 | + if not isinstance(tool, dict) or "binaries" not in tool: |
| 65 | + continue |
| 66 | + version = tool.get("version") |
| 67 | + if not version: |
| 68 | + continue |
| 69 | + |
| 70 | + # Derive the GitHub repo from the first binary URL: |
| 71 | + # https://github.com/<owner>/<repo>/releases/download/v<ver>/<asset> |
| 72 | + first_url = tool["binaries"][0].get("url", "") |
| 73 | + parts = first_url.split("/") |
| 74 | + if len(parts) < 8 or parts[2] != "github.com" or parts[5] != "releases": |
| 75 | + print( |
| 76 | + f" ! {tool_name}: cannot derive repo from {first_url!r}", |
| 77 | + file=sys.stderr, |
| 78 | + ) |
| 79 | + continue |
| 80 | + owner, repo = parts[3], parts[4] |
| 81 | + |
| 82 | + release = _api_get( |
| 83 | + f"{GITHUB_API}/repos/{owner}/{repo}/releases/tags/v{version}", token |
| 84 | + ) |
| 85 | + digests = {a["name"]: a.get("digest", "") for a in release.get("assets", [])} |
| 86 | + |
| 87 | + for binary in tool["binaries"]: |
| 88 | + asset = _asset_name(binary.get("url", "")) |
| 89 | + digest = digests.get(asset) |
| 90 | + if not digest: |
| 91 | + print( |
| 92 | + f" ! {tool_name}: no digest for asset {asset!r} in v{version}", |
| 93 | + file=sys.stderr, |
| 94 | + ) |
| 95 | + continue |
| 96 | + sha = digest.split(":", 1)[1] if digest.startswith("sha256:") else digest |
| 97 | + # Rebuild the URL from the version so the file is consistent |
| 98 | + # whether or not Renovate already updated the URL. |
| 99 | + new_url = f"https://github.com/{owner}/{repo}/releases/download/v{version}/{asset}" |
| 100 | + if binary.get("sha256") != sha or binary.get("url") != new_url: |
| 101 | + binary["url"] = new_url |
| 102 | + binary["sha256"] = sha |
| 103 | + changed.append(f"{tool_name}/{asset}") |
| 104 | + |
| 105 | + if changed: |
| 106 | + with open(path, "w", encoding="utf-8") as f: |
| 107 | + json.dump(data, f, indent=2) |
| 108 | + f.write("\n") |
| 109 | + return changed |
| 110 | + |
| 111 | + |
| 112 | +def main(argv): |
| 113 | + token = os.environ.get("RENOVATE_TOKEN") or os.environ.get("GITHUB_TOKEN") |
| 114 | + if not argv[1:]: |
| 115 | + lockdir = os.path.join(os.path.dirname(__file__), "..", "lockfiles") |
| 116 | + lockdir = os.path.abspath(lockdir) |
| 117 | + argv = [argv[0]] + [ |
| 118 | + os.path.join(lockdir, name) |
| 119 | + for name in sorted(os.listdir(lockdir)) |
| 120 | + if name.endswith(".lock.json") |
| 121 | + ] |
| 122 | + |
| 123 | + failed = False |
| 124 | + for path in argv[1:]: |
| 125 | + print(f"Refreshing {path}") |
| 126 | + try: |
| 127 | + changed = refresh_lockfile(path, token) |
| 128 | + except Exception as exc: |
| 129 | + print(f" ! failed: {exc}", file=sys.stderr) |
| 130 | + failed = True |
| 131 | + continue |
| 132 | + if changed: |
| 133 | + for c in changed: |
| 134 | + print(f" ~ {c}") |
| 135 | + else: |
| 136 | + print(" = up to date") |
| 137 | + |
| 138 | + return 1 if failed else 0 |
| 139 | + |
| 140 | + |
| 141 | +if __name__ == "__main__": |
| 142 | + sys.exit(main(sys.argv)) |
0 commit comments