Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
version: 2
updates:
- package-ecosystem: github-actions
directory: /
schedule:
interval: weekly
day: monday
groups:
github-actions:
patterns: ["*"]
open-pull-requests-limit: 5
55 changes: 55 additions & 0 deletions .github/workflows/dependency-updates.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
name: Native dependency updates

on:
schedule:
- cron: "17 8 * * 1"
workflow_dispatch:

permissions:
contents: read

concurrency:
group: native-dependency-updates
cancel-in-progress: false

jobs:
discover:
runs-on: ubuntu-latest
outputs:
components: ${{ steps.components.outputs.value }}
steps:
- uses: actions/checkout@v7
- id: components
run: echo "value=$(python3 Build/update_dependencies.py --list)" >> "$GITHUB_OUTPUT"

update:
needs: discover
runs-on: ubuntu-latest
timeout-minutes: 45
permissions:
contents: write
pull-requests: write
strategy:
fail-fast: false
max-parallel: 3
matrix:
component: ${{ fromJSON(needs.discover.outputs.components) }}
steps:
- uses: actions/checkout@v7
with:
ref: ${{ github.event.repository.default_branch }}
- name: Resolve releases and verify update
env:
GH_TOKEN: ${{ github.token }}
COMPONENT: ${{ matrix.component }}
run: python3 Build/update_dependencies.py --component "$COMPONENT" --report .build/dependency-update.md
- name: Open or refresh draft PR
uses: peter-evans/create-pull-request@v8
with:
branch: automation/dependencies/${{ matrix.component }}
title: "build(deps): update ${{ matrix.component }}"
commit-message: "build(deps): update ${{ matrix.component }}"
body-path: .build/dependency-update.md
draft: always-true
# Git's pathspec also matches the Android lock when that project is present.
add-paths: Build/*.lock.json
260 changes: 260 additions & 0 deletions Build/update_dependencies.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,260 @@
#!/usr/bin/env python3
"""Prepare reviewable native dependency updates using Python 3.10+ and Git."""

import argparse
import copy
import hashlib
import json
import os
from pathlib import Path
import re
import subprocess
import tempfile
from urllib.parse import quote
from urllib.request import Request, urlopen


ROOT = Path(__file__).resolve().parents[1]
APPLE_LOCK = Path("Build/Inputs.lock.json")
ANDROID_LOCK = Path("Build/Android/Native.lock.json")


def run(*args, cwd=None):
return subprocess.run(args, cwd=cwd, check=True, text=True, capture_output=True).stdout.strip()


def version(tag):
# mpvkit also publishes stable packaging fixes, e.g. 2.1.0-fix and 4.15.13-2512.
match = re.fullmatch(r"[nv]?(\d+(?:\.\d+){1,3})(?:-(fix|\d+))?", tag)
if not match:
return None
numbers = tuple(map(int, match[1].split(".")))
suffix = match[2]
return numbers + (0,) * (4 - len(numbers)) + (1 if suffix == "fix" else int(suffix or 0),)


def release_url(url):
match = re.fullmatch(r"https://github.com/([^/]+/[^/]+)/releases/download/([^/]+)/([^/]+)", url)
if not match:
raise ValueError(f"Unsupported SDK asset URL: {url}")
return match.groups()


def sdk_groups(lock):
groups = {}
for dependency in lock["dependencies"]:
repository, _, _ = release_url(dependency["url"])
groups.setdefault("sdk-" + repository.replace("/", "-"), []).append(dependency)
return groups


def components(lock):
return [source["id"] for source in lock["sources"]] + list(sdk_groups(lock))


class Upstream:
def releases(self, repository):
result = []
page = 1
while True:
headers = {"Accept": "application/vnd.github+json", "User-Agent": "MPVUI-dependency-updater"}
token = os.environ.get("GH_TOKEN") or os.environ.get("GITHUB_TOKEN")
if token:
headers["Authorization"] = "Bearer " + token
request = Request(f"https://api.github.com/repos/{repository}/releases?per_page=100&page={page}", headers=headers)
with urlopen(request, timeout=60) as response:
batch = json.load(response)
result.extend(batch)
if len(batch) < 100:
return result
page += 1

def source_release(self, source):
prefix = {"ffmpeg": "n", "mpv": "v"}[source["id"]]
refs = {}
for line in run("git", "ls-remote", "--tags", source["url"]).splitlines():
commit, ref = line.split()
refs[ref.removeprefix("refs/tags/")] = commit
candidates = [tag for tag in refs if re.fullmatch(prefix + r"\d+(?:\.\d+){1,3}", tag)]
if not candidates:
raise ValueError(f"No stable tags for {source['id']}")
tag = max(candidates, key=version)
commit = refs.get(tag + "^{}", refs[tag])
if not re.fullmatch(r"[0-9a-f]{40}", commit):
raise ValueError(f"Invalid upstream commit for {tag}")
return tag, commit

def asset_digest(self, asset):
# Never pass the repository token to a download URL or its CDN redirects.
digest = hashlib.sha256()
size = 0
with urlopen(asset["browser_download_url"], timeout=120) as response:
while chunk := response.read(1024 * 1024):
digest.update(chunk)
size += len(chunk)
result = digest.hexdigest()
if size != asset["size"]:
raise ValueError(f"Incomplete asset: {asset['name']}")
if asset.get("digest") and asset["digest"] != "sha256:" + result:
raise ValueError(f"Upstream checksum mismatch: {asset['name']}")
return result


def verify_patches(root, source, patch_sets):
"""Apply each platform's ordered patches independently in a disposable checkout."""
with tempfile.TemporaryDirectory(prefix="mpvui-update-") as temporary:
checkout = Path(temporary)
run("git", "init", "--quiet", str(checkout))
run("git", "fetch", "--quiet", "--no-tags", "--depth=1", source["url"], source["commit"], cwd=checkout)
run("git", "checkout", "--quiet", "--detach", "FETCH_HEAD", cwd=checkout)
for platform, patches in patch_sets.items():
run("git", "reset", "--hard", "--quiet", "HEAD", cwd=checkout)
run("git", "clean", "-fdx", "--quiet", cwd=checkout)
for patch in patches:
path = (root / patch["path"]).resolve()
if not path.is_relative_to(root.resolve()):
raise ValueError(f"Patch outside repository: {patch['path']}")
if hashlib.sha256(path.read_bytes()).hexdigest() != patch["sha256"]:
raise ValueError(f"Patch checksum drift: {patch['path']}")
try:
run("git", "apply", "--check", str(path), cwd=checkout)
run("git", "apply", str(path), cwd=checkout)
except subprocess.CalledProcessError as error:
raise ValueError(f"{source['id']} {platform} patch failed: {patch['path']}\n{error.stderr}") from error


def shared_source(source, android):
if android is None:
return None
other = android["git"][source["id"]]
if other["url"].removesuffix(".git") != source["url"].removesuffix(".git") or other["commit"] != source["commit"]:
raise ValueError(f"Apple/Android source pins differ for {source['id']}; reconcile them before updating")
return other


def patch_sets(source, android):
result = {"Apple": source["patches"]}
if android is not None:
result["Android"] = android[source["id"] + "Patches"]
return result


def update_source(root, source, android, upstream):
other = shared_source(source, android)
tag, commit = upstream.source_release(source)
current = version(source["ref"])
if current is None:
raise ValueError(f"Expected a stable release ref for {source['id']}: {source['ref']}")
if version(tag) <= current:
if tag == source["ref"] and commit != source["commit"]:
raise ValueError(f"Upstream tag moved: {tag}; inspect the change manually")
return []
previous = source["ref"]
old_commit = source["commit"]
source.update(ref=tag, commit=commit, version=tag[1:])
verify_patches(root, source, patch_sets(source, android))
if other is not None:
other["commit"] = commit
return [f"- {source['id']}: `{previous}` → `{tag}` ([upstream diff]({source['url']}/compare/{old_commit}...{commit})).",
("- Verified Apple and Android patches."
if other is not None else "- Verified Apple patches.")]


def update_sdk(dependencies, upstream):
repository, _, _ = release_url(dependencies[0]["url"])
releases = [release for release in upstream.releases(repository)
if not release["draft"] and not release["prerelease"] and version(release["tag_name"]) is not None]
if not releases:
raise ValueError(f"No stable releases for {repository}")
release = max(releases, key=lambda item: version(item["tag_name"]))
tag = release["tag_name"]
current_tags = {release_url(dependency["url"])[1] for dependency in dependencies}
if len(current_tags) != 1 or any(dependency["version"] not in current_tags for dependency in dependencies):
raise ValueError(f"SDK bundle versions differ for {repository}")
previous = current_tags.pop()
if version(previous) is None:
raise ValueError(f"Unsupported locked release: {previous}")
if version(tag) <= version(previous):
return []
assets = {asset["name"]: asset for asset in release["assets"]}
pending = []
# Resolve the complete SDK/runtime set before downloading or changing any pins.
for dependency in dependencies:
for item in [dependency] + dependency["runtime"]:
repo, locked_tag, name = release_url(item["url"])
if repo != repository or locked_tag != previous:
raise ValueError(f"SDK/runtime release mismatch: {item['url']}")
expected = f"https://github.com/{repository}/releases/download/{quote(tag, safe='')}/{name}"
asset = assets.get(name)
if asset is None or asset["browser_download_url"] != expected:
raise ValueError(f"Missing or unexpected asset in {repository} {tag}: {name}")
pending.append((item, asset))
hashes = [(item, asset, upstream.asset_digest(asset)) for item, asset in pending]
for item, asset, checksum in hashes:
item.update(url=asset["browser_download_url"], sha256=checksum)
for dependency in dependencies:
dependency["version"] = tag
names = ", ".join(dependency["id"] for dependency in dependencies)
return [f"- {names}: `{previous}` → `{tag}` ([release notes](https://github.com/{repository}/releases/tag/{quote(tag, safe='')})).",
f"- Verified checksums for {len(hashes)} SDK and runtime archives."]


def prepare_update(root, component, upstream):
original = {APPLE_LOCK: json.loads((root / APPLE_LOCK).read_text())}
if (root / ANDROID_LOCK).exists():
original[ANDROID_LOCK] = json.loads((root / ANDROID_LOCK).read_text())
updated = copy.deepcopy(original)
apple, android = updated[APPLE_LOCK], updated.get(ANDROID_LOCK)
if component in sdk_groups(apple):
changes = update_sdk(sdk_groups(apple)[component], upstream)
else:
source = next(source for source in apple["sources"] if source["id"] == component)
changes = update_source(root, source, android, upstream)
outputs = {path: json.dumps(value, indent=2) + "\n" for path, value in updated.items() if value != original[path]}
return outputs, changes


def report(changes):
if not changes:
return "No updates.\n"
return "\n".join(changes) + "\n- Build and test before merging; publish updated binaries separately.\n"


def main():
parser = argparse.ArgumentParser(description=__doc__)
mode = parser.add_mutually_exclusive_group(required=True)
mode.add_argument("--list", action="store_true", help="Print the workflow component matrix")
mode.add_argument("--component", help="Update one source or SDK release bundle")
mode.add_argument("--validate", action="store_true", help="Check committed source pins and both platforms' patches")
parser.add_argument("--dry-run", action="store_true", help="Resolve and verify an update without writing locks")
parser.add_argument("--report", type=Path, help="Write Markdown suitable for the update PR body")
args = parser.parse_args()
apple = json.loads((ROOT / APPLE_LOCK).read_text())
if args.list:
print(json.dumps(components(apple)))
return
if args.validate:
android = json.loads((ROOT / ANDROID_LOCK).read_text()) if (ROOT / ANDROID_LOCK).exists() else None
for source in apple["sources"]:
shared_source(source, android)
verify_patches(ROOT, source, patch_sets(source, android))
print("Source pins and ordered patches verified for all available platforms.")
return
if args.component not in components(apple):
parser.error("Unknown component; use --list to see supported components")
outputs, changes = prepare_update(ROOT, args.component, Upstream())
if not args.dry_run:
for path, content in outputs.items():
(ROOT / path).write_text(content)
body = report(changes)
print(body)
if args.report:
args.report.parent.mkdir(parents=True, exist_ok=True)
args.report.write_text(body)


if __name__ == "__main__":
try:
main()
except subprocess.CalledProcessError as error:
raise SystemExit(f"Command failed: {error.cmd}\n{error.stderr}") from error