From 4500c642983b232fd98abe5d004576e9c8b8ac4e Mon Sep 17 00:00:00 2001 From: Ethan Pippin Date: Mon, 28 Sep 2026 00:31:40 -0400 Subject: [PATCH 1/4] Automate native dependency update pull requests --- .github/dependabot.yml | 11 + .github/workflows/dependency-checks.yml | 28 +++ .github/workflows/dependency-updates.yml | 56 +++++ Build/BUILD.md | 49 +++++ Build/Tests/test_update_dependencies.py | 228 +++++++++++++++++++ Build/update_dependencies.py | 266 +++++++++++++++++++++++ 6 files changed, 638 insertions(+) create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/dependency-checks.yml create mode 100644 .github/workflows/dependency-updates.yml create mode 100644 Build/Tests/test_update_dependencies.py create mode 100644 Build/update_dependencies.py diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..1d0da69 --- /dev/null +++ b/.github/dependabot.yml @@ -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 diff --git a/.github/workflows/dependency-checks.yml b/.github/workflows/dependency-checks.yml new file mode 100644 index 0000000..8459721 --- /dev/null +++ b/.github/workflows/dependency-checks.yml @@ -0,0 +1,28 @@ +name: Dependency update checks + +on: + pull_request: + paths: + - ".github/dependabot.yml" + - ".github/workflows/dependency-*.yml" + - "Build/update_dependencies.py" + - "Build/Tests/test_update_dependencies.py" + - "Build/Inputs.lock.json" + - "Build/Android/Native.lock.json" + - "Build/Patches/**" + - "Build/Android/Patches/**" + workflow_dispatch: + +permissions: + contents: read + +jobs: + updater: + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - run: python3 -m unittest discover -s Build/Tests -p test_update_dependencies.py + - run: python3 Build/update_dependencies.py --validate diff --git a/.github/workflows/dependency-updates.yml b/.github/workflows/dependency-updates.yml new file mode 100644 index 0000000..d2fa407 --- /dev/null +++ b/.github/workflows/dependency-updates.yml @@ -0,0 +1,56 @@ +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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - run: python3 -m unittest discover -s Build/Tests -p test_update_dependencies.py + - 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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + 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@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1 + 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 diff --git a/Build/BUILD.md b/Build/BUILD.md index cda6085..268817c 100644 --- a/Build/BUILD.md +++ b/Build/BUILD.md @@ -35,6 +35,55 @@ Build/mpvbuild generate --check The generation check requires a remote artifact selection. +## Dependency update PRs + +GitHub Actions checks for stable native releases every Monday at 08:17 UTC. Run +**Native dependency updates** from the Actions tab to check immediately. Each +component has one reusable draft PR branch; nothing merges or publishes automatically. + +- FFmpeg and mpv follow stable release tags. The updater resolves the tag to an + immutable commit and applies the ordered Apple patches. When + `Build/Android/Native.lock.json` exists, the updater also checks its patches + independently and changes both platform locks together. A patch conflict fails that component's + job without changing its lockfiles; other components continue. +- Every prebuilt SDK in `Inputs.lock.json` follows its mpvkit release repository. + Libraries shipped together (such as libass and its font libraries) update in one + PR. All SDK and runtime archives must exist; the updater downloads and hashes each + archive and compares GitHub's asset digest when available. Drafts, prereleases, + and development tags are excluded. Numeric release tags and the existing `-fix` + and numeric packaging suffixes are supported. +- Dependabot checks GitHub Actions weekly, grouped in one PR. Add a Gradle entry + for `/Android` in `.github/dependabot.yml` when the Android project lands on `main`. + +The native workflow uses `GITHUB_TOKEN`, with write access limited to its update +jobs. Repository **Settings → Actions → General → Workflow permissions → Allow +GitHub Actions to create and approve pull requests** must be enabled. No personal +token or external service is required. GitHub may require **Approve workflows to +run** on bot-created PRs; the updater also verifies each candidate before opening +its PR. See [GitHub's token behavior](https://docs.github.com/en/actions/concepts/security/github_token). + +Preview or validate locally with Python 3.10+ and Git (no Xcode needed): + +```sh +python3 Build/update_dependencies.py --list +python3 Build/update_dependencies.py --component ffmpeg --dry-run +python3 Build/update_dependencies.py --component sdk-mpvkit-libass-build --dry-run +python3 Build/update_dependencies.py --validate +python3 -m unittest discover -s Build/Tests -p 'test_update_dependencies.py' +``` + +Without `--dry-run`, a component update writes the affected lockfiles. Set +`GH_TOKEN` for authenticated GitHub API requests when checking SDK releases locally. + +Patch application and checksum checks do not prove build or playback compatibility. +Before merging a native update, build a complete Apple release candidate and the +affected Android native libraries when present, then run their consumer/playback tests. Updating +source locks does not replace published binaries: release and adoption remain +separate steps. The updater leaves `Artifacts.lock.json` and `Package.swift` alone. + +Recipe revisions, Xcode/SDK/NDK and build-tool pins, and Android-only native +dependencies remain manual because their versions are coupled to the build recipes. + ## Cleanup Build outputs live in `.build/mpvbuild`. diff --git a/Build/Tests/test_update_dependencies.py b/Build/Tests/test_update_dependencies.py new file mode 100644 index 0000000..1d396dd --- /dev/null +++ b/Build/Tests/test_update_dependencies.py @@ -0,0 +1,228 @@ +import contextlib +import copy +import hashlib +import io +import json +from pathlib import Path +import sys +import tempfile +import unittest +from unittest.mock import Mock, patch + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +import update_dependencies as updater + + +def asset(name, tag="2.0.0", payload=b"archive"): + return { + "name": name, + "browser_download_url": f"https://github.com/mpvkit/example-build/releases/download/{tag}/{name}", + "size": len(payload), + "digest": "sha256:" + hashlib.sha256(payload).hexdigest(), + } + + +def release(tag="2.0.0", **overrides): + return dict({"tag_name": tag, "draft": False, "prerelease": False, + "assets": [asset("example-all.zip", tag), asset("Example.xcframework.zip", tag)]}, **overrides) + + +def dependency(name="example"): + return {"id": name, "version": "1.0.0", "kind": "prebuilt-sdk", "sha256": "a" * 64, + "url": asset("example-all.zip", "1.0.0")["browser_download_url"], + "runtime": [{"target": "Example", "sha256": "b" * 64, + "url": asset("Example.xcframework.zip", "1.0.0")["browser_download_url"]}]} + + +class SDKUpdateTests(unittest.TestCase): + def upstream(self, releases): + return Mock(releases=Mock(return_value=releases), asset_digest=Mock(return_value="c" * 64)) + + def test_selects_highest_stable_version_not_publication_order(self): + upstream = self.upstream([release("1.5.0"), release("3.0.0", prerelease=True), + release("4.0.0", draft=True), release("2.0.0"), release("5.0.0-rc1")]) + dependencies = [dependency()] + updater.update_sdk(dependencies, upstream) + self.assertEqual(dependencies[0]["version"], "2.0.0") + for item in [dependencies[0], *dependencies[0]["runtime"]]: + self.assertIn("/2.0.0/", item["url"]) + self.assertEqual(item["sha256"], "c" * 64) + self.assertEqual(upstream.asset_digest.call_count, 2) + + def test_updates_all_libraries_shipped_in_one_release(self): + dependencies = [dependency("first"), dependency("second")] + updater.update_sdk(dependencies, self.upstream([release()])) + self.assertEqual([item["version"] for item in dependencies], ["2.0.0", "2.0.0"]) + self.assertEqual(len(updater.sdk_groups({"dependencies": dependencies})), 1) + + def test_missing_runtime_asset_leaves_whole_bundle_unchanged(self): + dependencies = [dependency()] + before = copy.deepcopy(dependencies) + upstream = self.upstream([release(assets=[asset("example-all.zip")])]) + with self.assertRaisesRegex(ValueError, "Missing or unexpected asset"): + updater.update_sdk(dependencies, upstream) + self.assertEqual(dependencies, before) + upstream.asset_digest.assert_not_called() + + def test_download_failure_does_not_partially_update_bundle(self): + dependencies = [dependency()] + before = copy.deepcopy(dependencies) + upstream = self.upstream([release()]) + upstream.asset_digest.side_effect = ["c" * 64, ValueError("bad checksum")] + with self.assertRaisesRegex(ValueError, "bad checksum"): + updater.update_sdk(dependencies, upstream) + self.assertEqual(dependencies, before) + + def test_mixed_bundle_versions_and_runtime_origins_rejected(self): + cases = [dependency(), dependency()] + cases[0]["version"] = "0.9.0" + cases[1]["runtime"][0]["url"] = asset("Example.xcframework.zip", "0.9.0")["browser_download_url"] + for item in cases: + with self.subTest(item=item), self.assertRaises(ValueError): + updater.update_sdk([item], self.upstream([release()])) + + def test_equal_or_older_release_never_downloads_or_downgrades(self): + for tag in ("1.0.0", "0.9.0"): + upstream = self.upstream([release(tag)]) + dependencies = [dependency()] + self.assertEqual(updater.update_sdk(dependencies, upstream), []) + self.assertEqual(dependencies, [dependency()]) + upstream.asset_digest.assert_not_called() + + def test_assets_download_without_auth_and_match_digest_and_size(self): + upstream = updater.Upstream() + with patch.dict(updater.os.environ, {"GH_TOKEN": "test-token"}), \ + patch.object(updater, "urlopen", return_value=io.BytesIO(b"archive")) as download: + self.assertEqual(upstream.asset_digest(asset("example-all.zip")), hashlib.sha256(b"archive").hexdigest()) + self.assertEqual(download.call_args.args, (asset("example-all.zip")["browser_download_url"],)) + for bad_asset in (dict(asset("example-all.zip"), size=99), + dict(asset("example-all.zip"), digest="sha256:" + "0" * 64)): + with patch.object(updater, "urlopen", return_value=io.BytesIO(b"archive")), self.assertRaises(ValueError): + upstream.asset_digest(bad_asset) + + def test_release_api_paginates(self): + page = [release()] * 100 + with patch.object(updater, "urlopen", side_effect=[io.BytesIO(json.dumps(page).encode()), io.BytesIO(b"[]")]) as api: + self.assertEqual(len(updater.Upstream().releases("mpvkit/example-build")), 100) + self.assertIn("page=2", api.call_args.args[0].full_url) + + def test_version_order_and_packaging_suffixes(self): + for tag in ("n9.1-dev", "v0.42.0-rc1", "nightly", "3.3.2-xcode26"): + self.assertIsNone(updater.version(tag)) + self.assertGreater(updater.version("9.10"), updater.version("9.9.1")) + self.assertGreater(updater.version("2.1.0-fix"), updater.version("2.1.0")) + self.assertGreater(updater.version("4.15.13-2512"), updater.version("4.15.13-2412")) + + +class SourceUpdateTests(unittest.TestCase): + def setUp(self): + self.temporary = tempfile.TemporaryDirectory() + self.addCleanup(self.temporary.cleanup) + self.root = Path(self.temporary.name) + self.upstream = self.root / "upstream" + self.upstream.mkdir() + self.git("init", "--quiet") + (self.upstream / "file.txt").write_text("original\n") + self.commit() + self.old_commit = self.git("rev-parse", "HEAD") + self.git("tag", "n9.0.1") + (self.upstream / "release.txt").write_text("new release\n") + self.commit() + self.new_commit = self.git("rev-parse", "HEAD") + self.git("-c", "user.name=Test", "-c", "user.email=test@example.com", "tag", "-a", "n9.0.2", "-m", "release") + self.git("tag", "n10.0-dev") + (self.root / "Build/Android").mkdir(parents=True) + first = self.make_patch("first", "apple\n") + second = self.make_patch("second", "apple second\n") + self.git("reset", "--hard", "HEAD") + android_patch = self.make_patch("android", "android\n") + self.git("reset", "--hard", "HEAD") + self.source = {"id": "ffmpeg", "url": str(self.upstream), "ref": "n9.0.1", "version": "9.0.1", + "commit": self.old_commit, "patches": [first, second]} + self.android = {"git": {"ffmpeg": {"url": str(self.upstream), "commit": self.old_commit}}, + "ffmpegPatches": [android_patch]} + self.apple = {"sources": [self.source], "dependencies": []} + for path, value in ((updater.APPLE_LOCK, self.apple), (updater.ANDROID_LOCK, self.android)): + (self.root / path).write_text(json.dumps(value, indent=2) + "\n") + + def git(self, *args): + return updater.run("git", *args, cwd=self.upstream) + + def commit(self): + self.git("add", ".") + self.git("-c", "user.name=Test", "-c", "user.email=test@example.com", "commit", "--quiet", "-m", "fixture") + + def make_patch(self, name, text): + # Diff the second patch against the first patch's result. + self.git("add", ".") + (self.upstream / "file.txt").write_text(text) + content = self.git("diff") + "\n" + path = self.root / f"Build/{name}.patch" + path.write_text(content) + return {"path": str(path.relative_to(self.root)), "sha256": hashlib.sha256(content.encode()).hexdigest()} + + def test_annotated_release_is_peeled_and_development_tag_ignored(self): + self.assertEqual(updater.Upstream().source_release(self.source), ("n9.0.2", self.new_commit)) + + def test_update_verifies_both_patch_chains_and_keeps_platform_pins_together(self): + outputs, changes = updater.prepare_update(self.root, "ffmpeg", updater.Upstream()) + apple = json.loads(outputs[updater.APPLE_LOCK]) + android = json.loads(outputs[updater.ANDROID_LOCK]) + self.assertEqual(apple["sources"][0]["commit"], self.new_commit) + self.assertEqual(apple["sources"][0]["version"], "9.0.2") + self.assertEqual(android["git"]["ffmpeg"]["commit"], self.new_commit) + self.assertIn("both ordered patch sets applied", "\n".join(changes)) + self.assertEqual(json.loads((self.root / updater.APPLE_LOCK).read_text()), self.apple) + + def test_patch_conflict_leaves_both_lockfiles_unchanged(self): + self.android["ffmpegPatches"] = [self.source["patches"][1]] + (self.root / updater.ANDROID_LOCK).write_text(json.dumps(self.android)) + before = {path: (self.root / path).read_bytes() for path in (updater.APPLE_LOCK, updater.ANDROID_LOCK)} + with self.assertRaisesRegex(ValueError, "Android patch failed"): + updater.prepare_update(self.root, "ffmpeg", updater.Upstream()) + for path, content in before.items(): + self.assertEqual((self.root / path).read_bytes(), content) + + def test_patch_checksum_drift_rejected(self): + (self.root / self.source["patches"][0]["path"]).write_text("changed") + with self.assertRaisesRegex(ValueError, "checksum drift"): + updater.prepare_update(self.root, "ffmpeg", updater.Upstream()) + + def test_apple_only_checkout_does_not_require_android(self): + (self.root / updater.ANDROID_LOCK).unlink() + outputs, changes = updater.prepare_update(self.root, "ffmpeg", updater.Upstream()) + self.assertEqual(set(outputs), {updater.APPLE_LOCK}) + self.assertIn("ordered Apple patch set applied", "\n".join(changes)) + + def test_divergent_android_pin_rejected(self): + self.android["git"]["ffmpeg"]["commit"] = "0" * 40 + with self.assertRaisesRegex(ValueError, "pins differ"): + updater.update_source(self.root, self.source, self.android, updater.Upstream()) + + def test_moved_tag_requires_manual_review(self): + upstream = Mock(source_release=Mock(return_value=("n9.0.1", self.new_commit))) + with self.assertRaisesRegex(ValueError, "tag moved"): + updater.update_source(self.root, self.source, self.android, upstream) + + def test_no_downgrade(self): + upstream = Mock(source_release=Mock(return_value=("n8.0", self.new_commit))) + self.assertEqual(updater.update_source(self.root, self.source, self.android, upstream), []) + self.assertEqual(self.source["commit"], self.old_commit) + + def test_cli_dry_run_and_repeated_update_are_idempotent(self): + before = (self.root / updater.APPLE_LOCK).read_bytes() + with patch.object(updater, "ROOT", self.root), contextlib.redirect_stdout(io.StringIO()): + with patch.object(sys, "argv", ["update", "--component", "ffmpeg", "--dry-run"]): + updater.main() + self.assertEqual((self.root / updater.APPLE_LOCK).read_bytes(), before) + with patch.object(sys, "argv", ["update", "--component", "ffmpeg"]): + updater.main() + after = {path: (self.root / path).read_bytes() for path in (updater.APPLE_LOCK, updater.ANDROID_LOCK)} + with patch.object(sys, "argv", ["update", "--component", "ffmpeg"]): + updater.main() + for path, content in after.items(): + self.assertEqual((self.root / path).read_bytes(), content) + + +if __name__ == "__main__": + unittest.main() diff --git a/Build/update_dependencies.py b/Build/update_dependencies.py new file mode 100644 index 0000000..6823c0b --- /dev/null +++ b/Build/update_dependencies.py @@ -0,0 +1,266 @@ +#!/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})).", + ("- Apple and Android commits updated together; both ordered patch sets applied successfully." + if other is not None else "- The ordered Apple patch set applied successfully.")] + + +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"- Downloaded and SHA-256 hashed all {len(hashes)} SDK/runtime assets; checked upstream digests when available."] + + +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 newer stable release found.\n" + return ("Updates pinned native build inputs.\n\n" + "\n".join(changes) + + "\n\nThese checks do not compile the native libraries or test playback. " + "Before merging, build a complete Apple release candidate (and Android native libraries when present), " + "then run their consumer/playback tests. See " + "[Build/BUILD.md](https://github.com/LePips/MPVUI/blob/main/Build/BUILD.md).\n\n" + "Published binaries and `Build/Artifacts.lock.json` are unchanged. " + "Publish and adopt a new native artifact separately to deliver the update to consumers.\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 From aa32c2bd6e098a043ae326ddcc719bea0314093c Mon Sep 17 00:00:00 2001 From: Ethan Pippin Date: Mon, 28 Sep 2026 01:04:02 -0400 Subject: [PATCH 2/4] Simplify dependency update docs and remove test script --- .github/workflows/dependency-checks.yml | 28 --- .github/workflows/dependency-updates.yml | 1 - Build/BUILD.md | 52 +----- Build/Tests/test_update_dependencies.py | 228 ----------------------- Build/update_dependencies.py | 16 +- 5 files changed, 15 insertions(+), 310 deletions(-) delete mode 100644 .github/workflows/dependency-checks.yml delete mode 100644 Build/Tests/test_update_dependencies.py diff --git a/.github/workflows/dependency-checks.yml b/.github/workflows/dependency-checks.yml deleted file mode 100644 index 8459721..0000000 --- a/.github/workflows/dependency-checks.yml +++ /dev/null @@ -1,28 +0,0 @@ -name: Dependency update checks - -on: - pull_request: - paths: - - ".github/dependabot.yml" - - ".github/workflows/dependency-*.yml" - - "Build/update_dependencies.py" - - "Build/Tests/test_update_dependencies.py" - - "Build/Inputs.lock.json" - - "Build/Android/Native.lock.json" - - "Build/Patches/**" - - "Build/Android/Patches/**" - workflow_dispatch: - -permissions: - contents: read - -jobs: - updater: - runs-on: ubuntu-latest - timeout-minutes: 20 - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: false - - run: python3 -m unittest discover -s Build/Tests -p test_update_dependencies.py - - run: python3 Build/update_dependencies.py --validate diff --git a/.github/workflows/dependency-updates.yml b/.github/workflows/dependency-updates.yml index d2fa407..dad32ad 100644 --- a/.github/workflows/dependency-updates.yml +++ b/.github/workflows/dependency-updates.yml @@ -19,7 +19,6 @@ jobs: components: ${{ steps.components.outputs.value }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - run: python3 -m unittest discover -s Build/Tests -p test_update_dependencies.py - id: components run: echo "value=$(python3 Build/update_dependencies.py --list)" >> "$GITHUB_OUTPUT" diff --git a/Build/BUILD.md b/Build/BUILD.md index 268817c..2ee37c6 100644 --- a/Build/BUILD.md +++ b/Build/BUILD.md @@ -35,54 +35,22 @@ Build/mpvbuild generate --check The generation check requires a remote artifact selection. -## Dependency update PRs - -GitHub Actions checks for stable native releases every Monday at 08:17 UTC. Run -**Native dependency updates** from the Actions tab to check immediately. Each -component has one reusable draft PR branch; nothing merges or publishes automatically. - -- FFmpeg and mpv follow stable release tags. The updater resolves the tag to an - immutable commit and applies the ordered Apple patches. When - `Build/Android/Native.lock.json` exists, the updater also checks its patches - independently and changes both platform locks together. A patch conflict fails that component's - job without changing its lockfiles; other components continue. -- Every prebuilt SDK in `Inputs.lock.json` follows its mpvkit release repository. - Libraries shipped together (such as libass and its font libraries) update in one - PR. All SDK and runtime archives must exist; the updater downloads and hashes each - archive and compares GitHub's asset digest when available. Drafts, prereleases, - and development tags are excluded. Numeric release tags and the existing `-fix` - and numeric packaging suffixes are supported. -- Dependabot checks GitHub Actions weekly, grouped in one PR. Add a Gradle entry - for `/Android` in `.github/dependabot.yml` when the Android project lands on `main`. - -The native workflow uses `GITHUB_TOKEN`, with write access limited to its update -jobs. Repository **Settings → Actions → General → Workflow permissions → Allow -GitHub Actions to create and approve pull requests** must be enabled. No personal -token or external service is required. GitHub may require **Approve workflows to -run** on bot-created PRs; the updater also verifies each candidate before opening -its PR. See [GitHub's token behavior](https://docs.github.com/en/actions/concepts/security/github_token). - -Preview or validate locally with Python 3.10+ and Git (no Xcode needed): +## Dependency updates + +GitHub Actions opens draft PRs for stable FFmpeg, mpv, and SDK releases every Monday. Run **Native dependency updates** in Actions to check manually. Dependabot updates GitHub Actions weekly. + +Updates verify patches and archive checksums. Apple and Android source pins update together when the Android lock is present. Build and test before merging, then publish new binaries. + +Enable **Allow GitHub Actions to create and approve pull requests** under repository **Settings → Actions → General**. + +Preview an update with Python 3.10+ and Git: ```sh python3 Build/update_dependencies.py --list python3 Build/update_dependencies.py --component ffmpeg --dry-run -python3 Build/update_dependencies.py --component sdk-mpvkit-libass-build --dry-run -python3 Build/update_dependencies.py --validate -python3 -m unittest discover -s Build/Tests -p 'test_update_dependencies.py' ``` -Without `--dry-run`, a component update writes the affected lockfiles. Set -`GH_TOKEN` for authenticated GitHub API requests when checking SDK releases locally. - -Patch application and checksum checks do not prove build or playback compatibility. -Before merging a native update, build a complete Apple release candidate and the -affected Android native libraries when present, then run their consumer/playback tests. Updating -source locks does not replace published binaries: release and adoption remain -separate steps. The updater leaves `Artifacts.lock.json` and `Package.swift` alone. - -Recipe revisions, Xcode/SDK/NDK and build-tool pins, and Android-only native -dependencies remain manual because their versions are coupled to the build recipes. +Omit `--dry-run` to write the lockfiles. Recipes, toolchains, and Android-only native dependencies require manual updates. ## Cleanup diff --git a/Build/Tests/test_update_dependencies.py b/Build/Tests/test_update_dependencies.py deleted file mode 100644 index 1d396dd..0000000 --- a/Build/Tests/test_update_dependencies.py +++ /dev/null @@ -1,228 +0,0 @@ -import contextlib -import copy -import hashlib -import io -import json -from pathlib import Path -import sys -import tempfile -import unittest -from unittest.mock import Mock, patch - -sys.path.insert(0, str(Path(__file__).resolve().parents[1])) -import update_dependencies as updater - - -def asset(name, tag="2.0.0", payload=b"archive"): - return { - "name": name, - "browser_download_url": f"https://github.com/mpvkit/example-build/releases/download/{tag}/{name}", - "size": len(payload), - "digest": "sha256:" + hashlib.sha256(payload).hexdigest(), - } - - -def release(tag="2.0.0", **overrides): - return dict({"tag_name": tag, "draft": False, "prerelease": False, - "assets": [asset("example-all.zip", tag), asset("Example.xcframework.zip", tag)]}, **overrides) - - -def dependency(name="example"): - return {"id": name, "version": "1.0.0", "kind": "prebuilt-sdk", "sha256": "a" * 64, - "url": asset("example-all.zip", "1.0.0")["browser_download_url"], - "runtime": [{"target": "Example", "sha256": "b" * 64, - "url": asset("Example.xcframework.zip", "1.0.0")["browser_download_url"]}]} - - -class SDKUpdateTests(unittest.TestCase): - def upstream(self, releases): - return Mock(releases=Mock(return_value=releases), asset_digest=Mock(return_value="c" * 64)) - - def test_selects_highest_stable_version_not_publication_order(self): - upstream = self.upstream([release("1.5.0"), release("3.0.0", prerelease=True), - release("4.0.0", draft=True), release("2.0.0"), release("5.0.0-rc1")]) - dependencies = [dependency()] - updater.update_sdk(dependencies, upstream) - self.assertEqual(dependencies[0]["version"], "2.0.0") - for item in [dependencies[0], *dependencies[0]["runtime"]]: - self.assertIn("/2.0.0/", item["url"]) - self.assertEqual(item["sha256"], "c" * 64) - self.assertEqual(upstream.asset_digest.call_count, 2) - - def test_updates_all_libraries_shipped_in_one_release(self): - dependencies = [dependency("first"), dependency("second")] - updater.update_sdk(dependencies, self.upstream([release()])) - self.assertEqual([item["version"] for item in dependencies], ["2.0.0", "2.0.0"]) - self.assertEqual(len(updater.sdk_groups({"dependencies": dependencies})), 1) - - def test_missing_runtime_asset_leaves_whole_bundle_unchanged(self): - dependencies = [dependency()] - before = copy.deepcopy(dependencies) - upstream = self.upstream([release(assets=[asset("example-all.zip")])]) - with self.assertRaisesRegex(ValueError, "Missing or unexpected asset"): - updater.update_sdk(dependencies, upstream) - self.assertEqual(dependencies, before) - upstream.asset_digest.assert_not_called() - - def test_download_failure_does_not_partially_update_bundle(self): - dependencies = [dependency()] - before = copy.deepcopy(dependencies) - upstream = self.upstream([release()]) - upstream.asset_digest.side_effect = ["c" * 64, ValueError("bad checksum")] - with self.assertRaisesRegex(ValueError, "bad checksum"): - updater.update_sdk(dependencies, upstream) - self.assertEqual(dependencies, before) - - def test_mixed_bundle_versions_and_runtime_origins_rejected(self): - cases = [dependency(), dependency()] - cases[0]["version"] = "0.9.0" - cases[1]["runtime"][0]["url"] = asset("Example.xcframework.zip", "0.9.0")["browser_download_url"] - for item in cases: - with self.subTest(item=item), self.assertRaises(ValueError): - updater.update_sdk([item], self.upstream([release()])) - - def test_equal_or_older_release_never_downloads_or_downgrades(self): - for tag in ("1.0.0", "0.9.0"): - upstream = self.upstream([release(tag)]) - dependencies = [dependency()] - self.assertEqual(updater.update_sdk(dependencies, upstream), []) - self.assertEqual(dependencies, [dependency()]) - upstream.asset_digest.assert_not_called() - - def test_assets_download_without_auth_and_match_digest_and_size(self): - upstream = updater.Upstream() - with patch.dict(updater.os.environ, {"GH_TOKEN": "test-token"}), \ - patch.object(updater, "urlopen", return_value=io.BytesIO(b"archive")) as download: - self.assertEqual(upstream.asset_digest(asset("example-all.zip")), hashlib.sha256(b"archive").hexdigest()) - self.assertEqual(download.call_args.args, (asset("example-all.zip")["browser_download_url"],)) - for bad_asset in (dict(asset("example-all.zip"), size=99), - dict(asset("example-all.zip"), digest="sha256:" + "0" * 64)): - with patch.object(updater, "urlopen", return_value=io.BytesIO(b"archive")), self.assertRaises(ValueError): - upstream.asset_digest(bad_asset) - - def test_release_api_paginates(self): - page = [release()] * 100 - with patch.object(updater, "urlopen", side_effect=[io.BytesIO(json.dumps(page).encode()), io.BytesIO(b"[]")]) as api: - self.assertEqual(len(updater.Upstream().releases("mpvkit/example-build")), 100) - self.assertIn("page=2", api.call_args.args[0].full_url) - - def test_version_order_and_packaging_suffixes(self): - for tag in ("n9.1-dev", "v0.42.0-rc1", "nightly", "3.3.2-xcode26"): - self.assertIsNone(updater.version(tag)) - self.assertGreater(updater.version("9.10"), updater.version("9.9.1")) - self.assertGreater(updater.version("2.1.0-fix"), updater.version("2.1.0")) - self.assertGreater(updater.version("4.15.13-2512"), updater.version("4.15.13-2412")) - - -class SourceUpdateTests(unittest.TestCase): - def setUp(self): - self.temporary = tempfile.TemporaryDirectory() - self.addCleanup(self.temporary.cleanup) - self.root = Path(self.temporary.name) - self.upstream = self.root / "upstream" - self.upstream.mkdir() - self.git("init", "--quiet") - (self.upstream / "file.txt").write_text("original\n") - self.commit() - self.old_commit = self.git("rev-parse", "HEAD") - self.git("tag", "n9.0.1") - (self.upstream / "release.txt").write_text("new release\n") - self.commit() - self.new_commit = self.git("rev-parse", "HEAD") - self.git("-c", "user.name=Test", "-c", "user.email=test@example.com", "tag", "-a", "n9.0.2", "-m", "release") - self.git("tag", "n10.0-dev") - (self.root / "Build/Android").mkdir(parents=True) - first = self.make_patch("first", "apple\n") - second = self.make_patch("second", "apple second\n") - self.git("reset", "--hard", "HEAD") - android_patch = self.make_patch("android", "android\n") - self.git("reset", "--hard", "HEAD") - self.source = {"id": "ffmpeg", "url": str(self.upstream), "ref": "n9.0.1", "version": "9.0.1", - "commit": self.old_commit, "patches": [first, second]} - self.android = {"git": {"ffmpeg": {"url": str(self.upstream), "commit": self.old_commit}}, - "ffmpegPatches": [android_patch]} - self.apple = {"sources": [self.source], "dependencies": []} - for path, value in ((updater.APPLE_LOCK, self.apple), (updater.ANDROID_LOCK, self.android)): - (self.root / path).write_text(json.dumps(value, indent=2) + "\n") - - def git(self, *args): - return updater.run("git", *args, cwd=self.upstream) - - def commit(self): - self.git("add", ".") - self.git("-c", "user.name=Test", "-c", "user.email=test@example.com", "commit", "--quiet", "-m", "fixture") - - def make_patch(self, name, text): - # Diff the second patch against the first patch's result. - self.git("add", ".") - (self.upstream / "file.txt").write_text(text) - content = self.git("diff") + "\n" - path = self.root / f"Build/{name}.patch" - path.write_text(content) - return {"path": str(path.relative_to(self.root)), "sha256": hashlib.sha256(content.encode()).hexdigest()} - - def test_annotated_release_is_peeled_and_development_tag_ignored(self): - self.assertEqual(updater.Upstream().source_release(self.source), ("n9.0.2", self.new_commit)) - - def test_update_verifies_both_patch_chains_and_keeps_platform_pins_together(self): - outputs, changes = updater.prepare_update(self.root, "ffmpeg", updater.Upstream()) - apple = json.loads(outputs[updater.APPLE_LOCK]) - android = json.loads(outputs[updater.ANDROID_LOCK]) - self.assertEqual(apple["sources"][0]["commit"], self.new_commit) - self.assertEqual(apple["sources"][0]["version"], "9.0.2") - self.assertEqual(android["git"]["ffmpeg"]["commit"], self.new_commit) - self.assertIn("both ordered patch sets applied", "\n".join(changes)) - self.assertEqual(json.loads((self.root / updater.APPLE_LOCK).read_text()), self.apple) - - def test_patch_conflict_leaves_both_lockfiles_unchanged(self): - self.android["ffmpegPatches"] = [self.source["patches"][1]] - (self.root / updater.ANDROID_LOCK).write_text(json.dumps(self.android)) - before = {path: (self.root / path).read_bytes() for path in (updater.APPLE_LOCK, updater.ANDROID_LOCK)} - with self.assertRaisesRegex(ValueError, "Android patch failed"): - updater.prepare_update(self.root, "ffmpeg", updater.Upstream()) - for path, content in before.items(): - self.assertEqual((self.root / path).read_bytes(), content) - - def test_patch_checksum_drift_rejected(self): - (self.root / self.source["patches"][0]["path"]).write_text("changed") - with self.assertRaisesRegex(ValueError, "checksum drift"): - updater.prepare_update(self.root, "ffmpeg", updater.Upstream()) - - def test_apple_only_checkout_does_not_require_android(self): - (self.root / updater.ANDROID_LOCK).unlink() - outputs, changes = updater.prepare_update(self.root, "ffmpeg", updater.Upstream()) - self.assertEqual(set(outputs), {updater.APPLE_LOCK}) - self.assertIn("ordered Apple patch set applied", "\n".join(changes)) - - def test_divergent_android_pin_rejected(self): - self.android["git"]["ffmpeg"]["commit"] = "0" * 40 - with self.assertRaisesRegex(ValueError, "pins differ"): - updater.update_source(self.root, self.source, self.android, updater.Upstream()) - - def test_moved_tag_requires_manual_review(self): - upstream = Mock(source_release=Mock(return_value=("n9.0.1", self.new_commit))) - with self.assertRaisesRegex(ValueError, "tag moved"): - updater.update_source(self.root, self.source, self.android, upstream) - - def test_no_downgrade(self): - upstream = Mock(source_release=Mock(return_value=("n8.0", self.new_commit))) - self.assertEqual(updater.update_source(self.root, self.source, self.android, upstream), []) - self.assertEqual(self.source["commit"], self.old_commit) - - def test_cli_dry_run_and_repeated_update_are_idempotent(self): - before = (self.root / updater.APPLE_LOCK).read_bytes() - with patch.object(updater, "ROOT", self.root), contextlib.redirect_stdout(io.StringIO()): - with patch.object(sys, "argv", ["update", "--component", "ffmpeg", "--dry-run"]): - updater.main() - self.assertEqual((self.root / updater.APPLE_LOCK).read_bytes(), before) - with patch.object(sys, "argv", ["update", "--component", "ffmpeg"]): - updater.main() - after = {path: (self.root / path).read_bytes() for path in (updater.APPLE_LOCK, updater.ANDROID_LOCK)} - with patch.object(sys, "argv", ["update", "--component", "ffmpeg"]): - updater.main() - for path, content in after.items(): - self.assertEqual((self.root / path).read_bytes(), content) - - -if __name__ == "__main__": - unittest.main() diff --git a/Build/update_dependencies.py b/Build/update_dependencies.py index 6823c0b..32f43a8 100644 --- a/Build/update_dependencies.py +++ b/Build/update_dependencies.py @@ -156,8 +156,8 @@ def update_source(root, source, android, upstream): if other is not None: other["commit"] = commit return [f"- {source['id']}: `{previous}` → `{tag}` ([upstream diff]({source['url']}/compare/{old_commit}...{commit})).", - ("- Apple and Android commits updated together; both ordered patch sets applied successfully." - if other is not None else "- The ordered Apple patch set applied successfully.")] + ("- Verified Apple and Android patches." + if other is not None else "- Verified Apple patches.")] def update_sdk(dependencies, upstream): @@ -196,7 +196,7 @@ def update_sdk(dependencies, upstream): 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"- Downloaded and SHA-256 hashed all {len(hashes)} SDK/runtime assets; checked upstream digests when available."] + f"- Verified checksums for {len(hashes)} SDK and runtime archives."] def prepare_update(root, component, upstream): @@ -216,14 +216,8 @@ def prepare_update(root, component, upstream): def report(changes): if not changes: - return "No newer stable release found.\n" - return ("Updates pinned native build inputs.\n\n" + "\n".join(changes) + - "\n\nThese checks do not compile the native libraries or test playback. " - "Before merging, build a complete Apple release candidate (and Android native libraries when present), " - "then run their consumer/playback tests. See " - "[Build/BUILD.md](https://github.com/LePips/MPVUI/blob/main/Build/BUILD.md).\n\n" - "Published binaries and `Build/Artifacts.lock.json` are unchanged. " - "Publish and adopt a new native artifact separately to deliver the update to consumers.\n") + return "No updates.\n" + return "\n".join(changes) + "\n- Build and test before merging; publish updated binaries separately.\n" def main(): From 4071d7315ce12ab455af524035371010af6ead61 Mon Sep 17 00:00:00 2001 From: Ethan Pippin Date: Mon, 28 Sep 2026 01:14:06 -0400 Subject: [PATCH 3/4] Remove dependency automation documentation from build guide --- Build/BUILD.md | 17 ----------------- 1 file changed, 17 deletions(-) diff --git a/Build/BUILD.md b/Build/BUILD.md index 2ee37c6..cda6085 100644 --- a/Build/BUILD.md +++ b/Build/BUILD.md @@ -35,23 +35,6 @@ Build/mpvbuild generate --check The generation check requires a remote artifact selection. -## Dependency updates - -GitHub Actions opens draft PRs for stable FFmpeg, mpv, and SDK releases every Monday. Run **Native dependency updates** in Actions to check manually. Dependabot updates GitHub Actions weekly. - -Updates verify patches and archive checksums. Apple and Android source pins update together when the Android lock is present. Build and test before merging, then publish new binaries. - -Enable **Allow GitHub Actions to create and approve pull requests** under repository **Settings → Actions → General**. - -Preview an update with Python 3.10+ and Git: - -```sh -python3 Build/update_dependencies.py --list -python3 Build/update_dependencies.py --component ffmpeg --dry-run -``` - -Omit `--dry-run` to write the lockfiles. Recipes, toolchains, and Android-only native dependencies require manual updates. - ## Cleanup Build outputs live in `.build/mpvbuild`. From 69e2cd9e1efd0d78edef735a5cfcd294eca9910f Mon Sep 17 00:00:00 2001 From: Ethan Pippin Date: Mon, 28 Sep 2026 01:15:28 -0400 Subject: [PATCH 4/4] Use action version tags instead of commit hashes --- .github/workflows/dependency-updates.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/dependency-updates.yml b/.github/workflows/dependency-updates.yml index dad32ad..879e325 100644 --- a/.github/workflows/dependency-updates.yml +++ b/.github/workflows/dependency-updates.yml @@ -18,7 +18,7 @@ jobs: outputs: components: ${{ steps.components.outputs.value }} steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/checkout@v7 - id: components run: echo "value=$(python3 Build/update_dependencies.py --list)" >> "$GITHUB_OUTPUT" @@ -35,7 +35,7 @@ jobs: matrix: component: ${{ fromJSON(needs.discover.outputs.components) }} steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/checkout@v7 with: ref: ${{ github.event.repository.default_branch }} - name: Resolve releases and verify update @@ -44,7 +44,7 @@ jobs: 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@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1 + uses: peter-evans/create-pull-request@v8 with: branch: automation/dependencies/${{ matrix.component }} title: "build(deps): update ${{ matrix.component }}"