From 362c27b23525f700ee65c2d06538bed53abeb5ba Mon Sep 17 00:00:00 2001 From: Matt Borgerson Date: Sat, 26 Apr 2025 15:53:02 -0700 Subject: [PATCH] ci: Auto-update subproject wraps periodically --- .github/workflows/bump-subproject-wraps.yml | 74 ++++++++ scripts/bump-subproject-wraps.py | 197 ++++++++++++++++++++ 2 files changed, 271 insertions(+) create mode 100644 .github/workflows/bump-subproject-wraps.yml create mode 100644 scripts/bump-subproject-wraps.py diff --git a/.github/workflows/bump-subproject-wraps.yml b/.github/workflows/bump-subproject-wraps.yml new file mode 100644 index 0000000000..8eb239d107 --- /dev/null +++ b/.github/workflows/bump-subproject-wraps.yml @@ -0,0 +1,74 @@ +name: Bump Meson subprojects + +on: + workflow_dispatch: + schedule: + - cron: '0 6 * * 1' + +permissions: + contents: write + pull-requests: write + +jobs: + bump_wraps: + name: "Bump Meson subprojects" + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 + + - name: Install the latest version of uv + uses: astral-sh/setup-uv@c7f87aa956e4c323abf06d5dec078e358f6b4d04 # v6 + with: + enable-cache: false + + - name: Check for updates + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + uv run -s scripts/bump-subproject-wraps.py -m \ + subprojects/curl.wrap \ + subprojects/genconfig.wrap \ + subprojects/glslang.wrap \ + subprojects/imgui.wrap \ + subprojects/implot.wrap \ + subprojects/json.wrap \ + subprojects/nv2a_vsh_cpu.wrap \ + subprojects/SPIRV-Reflect.wrap \ + subprojects/tomlplusplus.wrap \ + subprojects/volk.wrap \ + subprojects/VulkanMemoryAllocator.wrap \ + subprojects/xxhash.wrap \ + > updated.json + + - name: Create PRs for updates + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + git config user.name "xemu-robot" + git config user.email "robot@xemu.app" + + jq -c '.[]' updated.json | while read -r item; do + path=$(echo "$item" | jq -r '.path') + file_basename=$(basename "$path") + name="${file_basename%%.*}" + + owner=$(echo "$item" | jq -r '.owner') + repo=$(echo "$item" | jq -r '.repo') + old_rev=$(echo "$item" | jq -r '.old_rev') + new_rev=$(echo "$item" | jq -r '.new_rev') + new_tag=$(echo "$item" | jq -r '.new_tag') + + echo "➤ Processing $name" + branch="sync/bump-${name//\//-}-${GITHUB_RUN_ID}" + + git switch --quiet -c "$branch" origin/master + git add "$path" + git commit -m "meson: Bump ${name} to ${new_tag}" + git push -u origin "$branch" + + gh pr create \ + --title "meson: Bump ${name} to ${new_tag}" \ + --body "Automatic bump of \`${name}\` to [${new_tag}](https://github.com/${owner}/${repo}/compare/${old_rev}..${new_rev})." \ + --base master + done diff --git a/scripts/bump-subproject-wraps.py b/scripts/bump-subproject-wraps.py new file mode 100644 index 0000000000..f9768b366a --- /dev/null +++ b/scripts/bump-subproject-wraps.py @@ -0,0 +1,197 @@ +#!/usr/bin/env python +# /// script +# dependencies = ["requests"] +# /// +""" +Update Meson wrap file `revision` fields to point to latest release. +""" +from __future__ import annotations +import argparse +import configparser +import json +import logging +import os +import re +import sys +from pathlib import Path +from dataclasses import dataclass, asdict + +import requests + + +log = logging.getLogger(__name__) + + +SEMVER_RE = re.compile( + r""" + ^v? + (?P0|[1-9]\d*)\. + (?P0|[1-9]\d*)\. + (?P0|[1-9]\d*) + $""", + re.VERBOSE, +) + +ROOT = Path(__file__).resolve().parents[1] +WRAP_DIR = ROOT / "subprojects" +SESSION = requests.Session() +GH_TOKEN = os.getenv("GH_TOKEN", "") +if GH_TOKEN: + SESSION.headers["Authorization"] = f"Bearer {GH_TOKEN}" +SESSION.headers["Accept"] = "application/vnd.github+json" + + +def gh_sha_for_tag(owner: str, repo: str, tag: str) -> str: + data = SESSION.get( + f"https://api.github.com/repos/{owner}/{repo}/git/ref/tags/{tag}", timeout=30 + ).json() + + # First level: get the object it points to + obj_type = data["object"]["type"] + obj_sha = data["object"]["sha"] + + if obj_type == "commit": + # Lightweight tag + return obj_sha + elif obj_type == "tag": + # Annotated tag: need to dereference + tag_obj_url = data["object"]["url"] + tag_data = requests.get(tag_obj_url).json() + return tag_data["object"]["sha"] + else: + raise Exception(f"Unknown object type: {obj_type}") + + +def gh_latest_release( + owner: str, repo: str, pattern: re.Pattern +) -> None | tuple[str, str]: + """ + Return (tag_name, commit_sha) for the most recent matching release. + """ + releases = SESSION.get( + f"https://api.github.com/repos/{owner}/{repo}/releases", timeout=30 + ).json() + viable = [t for t in releases if pattern.match(t["tag_name"])] + + if not viable: + return None + + tag_name = viable[0]["tag_name"] + sha = gh_sha_for_tag(owner, repo, tag_name) + + return tag_name, sha + + +def gh_latest_tag(owner: str, repo: str, pattern: re.Pattern) -> tuple[str, str]: + """ + Return (tag_name, commit_sha) for the most recent matching tag. + """ + tags = SESSION.get( + f"https://api.github.com/repos/{owner}/{repo}/tags", timeout=30 + ).json() + viable = [t for t in tags if pattern.match(t["name"])] + + if not viable: + return None + + return viable[0]["name"], viable[0]["commit"]["sha"] + + +@dataclass +class UpdatedWrap: + path: str + owner: str + repo: str + old_rev: str + new_rev: str + new_tag: str + + +def update_wrap(path: Path) -> None | UpdatedWrap: + """ + Return (tag_name, commit_sha) if updated, otherwise None. + """ + cp = configparser.ConfigParser(interpolation=None) + cp.read(path, encoding="utf-8") + + if "wrap-git" not in cp: + # FIXME: Support wrap-file from wrapdb + return None + + w = cp["wrap-git"] + url = w.get("url", "") + rev = w.get("revision", "").strip() + m = re.match(r".*github\.com[:/](?P[^/]+)/(?P[^/.]+)(?:\.git)?", url) + if not (m and rev): + return None + + owner, repo = m.group("owner"), m.group("repo") + try: + pattern = cp.get("update", "tag_regex", fallback=None) + pattern = re.compile(pattern) if pattern else SEMVER_RE + + latest = gh_latest_release(owner, repo, pattern) + if latest is None: + log.info("Couldn't find latest release for %s/%s", owner, repo) + log.info("Searching for tags directly...") + latest = gh_latest_tag(owner, repo, pattern) + if latest is None: + log.info("Couldn't find latest tag for %s/%s", owner, repo) + return None + tag, sha = latest + except Exception as e: + log.exception(e) + return None + + if sha.startswith(rev): + log.info("%s already at %s (%s)", path.name, tag, sha) + return None + + log.info("%s updated to %s (%s)", path.name, tag, sha) + + w["revision"] = sha + + with open(path, "w", encoding="utf-8") as file: + cp.write(file) + + # XXX: ConfigParser writes two extra newlines. Trim the last one. + file.seek(file.tell() - 1, 0) + file.truncate() + + return UpdatedWrap(str(path), owner, repo, rev, sha, tag) + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument( + "--manifest", + "-m", + action="store_true", + default=False, + help="Print JSON-formatted updated manifest", + ) + ap.add_argument( + "wraps", nargs="*", help="Which wraps to update, or all if unspecified" + ) + args = ap.parse_args() + + wraps = args.wraps + if wraps: + wraps = [Path(p) for p in wraps] + else: + wraps = WRAP_DIR.glob("*.wrap") + + logging.basicConfig(level=logging.INFO) + + updated = [] + for wrap in wraps: + info = update_wrap(wrap) + if info: + updated.append(asdict(info)) + + if args.manifest: + json.dump(updated, sys.stdout, indent=2) + + +if __name__ == "__main__": + main()