diff --git a/.github/workflows/build_linux_arm64.yml b/.github/workflows/build_linux_arm64.yml index 33589c9..b21cdac 100644 --- a/.github/workflows/build_linux_arm64.yml +++ b/.github/workflows/build_linux_arm64.yml @@ -72,13 +72,32 @@ jobs: python -m pip install --upgrade pip setuptools wheel python -m pip install -e . - - name: Cache native Blender ARM64 dependencies - uses: actions/cache@v4 + - name: Resolve ARM64 dependency bundle version + id: deps_version + run: | + python - <<'PY' + import os + from pathlib import Path + from buildbpy.arm64_deps import BundleSpec + + version = os.environ["TAG"].removeprefix("v") + spec = BundleSpec.for_blender_version(version) + with Path(os.environ["GITHUB_ENV"]).open("a") as environment: + print(f"BLENDER_VERSION={version}", file=environment) + print(f"DEPS_VERSION={spec.blender_version}", file=environment) + with Path(os.environ["GITHUB_OUTPUT"]).open("a") as outputs: + print( + f"is_base_version={str(version == spec.blender_version).lower()}", + file=outputs, + ) + PY + + - name: Restore native Blender ARM64 dependency cache + id: deps_cache + uses: actions/cache/restore@v4 with: - path: | - ~/.buildbpy/build_linux/deps_arm64 - ~/.buildbpy/blender/lib/linux_arm64 - key: blender-${{ env.TAG }}-${{ env.PYTHON_VERSION }}-linux-arm64-deps-v1 + path: ~/.buildbpy/build_linux/deps_arm64 + key: blender-${{ env.DEPS_VERSION }}-${{ env.PYTHON_VERSION }}-linux-arm64-deps-v1 - name: Build and install bpy env: @@ -130,9 +149,8 @@ jobs: env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - VERSION="${TAG#v}" - RELEASE_TAG="deps-blender-${VERSION}-linux-arm64-v1" - ARCHIVE="blender-${VERSION}-linux-arm64-ubuntu24.04-gcc13-v1.tar.zst" + RELEASE_TAG="deps-blender-${DEPS_VERSION}-linux-arm64-v1" + ARCHIVE="blender-${DEPS_VERSION}-linux-arm64-ubuntu24.04-gcc13-v1.tar.zst" if gh release view "${RELEASE_TAG}" --repo "${GITHUB_REPOSITORY}" >/dev/null 2>&1; then ASSETS="$(gh release view "${RELEASE_TAG}" \ --repo "${GITHUB_REPOSITORY}" \ @@ -151,7 +169,7 @@ jobs: fi - name: Package verified ARM64 dependency bundle - if: steps.deps_release.outputs.exists != 'true' + if: steps.deps_release.outputs.exists != 'true' && steps.deps_version.outputs.is_base_version == 'true' run: | python - <<'PY' import os @@ -162,14 +180,21 @@ jobs: root = Path.home() / ".buildbpy" version = os.environ["TAG"].removeprefix("v") + deps_version = os.environ["DEPS_VERSION"] commit = subprocess.check_output( - ["git", "-C", str(root / "blender"), "rev-parse", "HEAD"], + [ + "git", + "-C", + str(root / "blender"), + "rev-parse", + f"v{deps_version}^{{commit}}", + ], text=True, ).strip() artifacts = create_bundle( root / "blender/lib/linux_arm64", root / "deps-dist", - BundleSpec(version), + BundleSpec.for_blender_version(version), blender_commit=commit, python_version=os.environ["PYTHON_VERSION"], ) @@ -179,16 +204,15 @@ jobs: PY - name: Publish verified ARM64 dependency bundle - if: steps.deps_release.outputs.exists != 'true' + if: steps.deps_release.outputs.exists != 'true' && steps.deps_version.outputs.is_base_version == 'true' env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - VERSION="${TAG#v}" - RELEASE_TAG="deps-blender-${VERSION}-linux-arm64-v1" + RELEASE_TAG="deps-blender-${DEPS_VERSION}-linux-arm64-v1" gh release create "${RELEASE_TAG}" \ --repo "${GITHUB_REPOSITORY}" \ --target "${GITHUB_SHA}" \ - --title "Blender ${VERSION} Linux ARM64 dependencies v1" \ + --title "Blender ${DEPS_VERSION} Linux ARM64 dependencies v1" \ --notes "Native Linux ARM64 dependency bundle validated by the bpy wheel build and import smoke test." \ --draft gh release upload "${RELEASE_TAG}" \ @@ -198,6 +222,24 @@ jobs: --repo "${GITHUB_REPOSITORY}" \ --draft=false + - name: Check native dependency cache contents + id: deps_cache_path + if: steps.deps_version.outputs.is_base_version == 'true' + run: | + if [[ -d ~/.buildbpy/build_linux/deps_arm64 ]] && \ + find ~/.buildbpy/build_linux/deps_arm64 -mindepth 1 -print -quit | grep -q .; then + echo "exists=true" >> "${GITHUB_OUTPUT}" + else + echo "exists=false" >> "${GITHUB_OUTPUT}" + fi + + - name: Save native Blender ARM64 dependency cache + if: steps.deps_version.outputs.is_base_version == 'true' && steps.deps_cache_path.outputs.exists == 'true' && steps.deps_cache.outputs.cache-hit != 'true' + uses: actions/cache/save@v4 + with: + path: ~/.buildbpy/build_linux/deps_arm64 + key: blender-${{ env.DEPS_VERSION }}-${{ env.PYTHON_VERSION }}-linux-arm64-deps-v1 + - name: Upload ARM64 wheel artifact uses: actions/upload-artifact@v4 with: diff --git a/README.md b/README.md index 9303f7e..52640bc 100644 --- a/README.md +++ b/README.md @@ -73,9 +73,13 @@ Linux ARM64 that it publishes for Linux x86-64. On ARM64, `buildbpy` therefore: Bundles use immutable release tags such as `deps-blender-5.2.0-linux-arm64-v1`; published assets are never clobbered in -place. A successful ARM64 CI build packages and publishes the exact dependency -tree only after the generated wheel installs and its exact requested Blender -version passes `import bpy`. +place. Every Blender 5.2 patch release (`5.2.x`) reuses that `5.2.0` dependency +bundle, cache key, and release identity. The manifest remains pinned to the +Blender `v5.2.0` tag commit while the generated wheel is validated against the +exact requested Blender patch version. A successful ARM64 CI build packages and +publishes a dependency tree only when the selected immutable bundle does not +already exist and only after the generated wheel installs and passes `import +bpy`. The bundle, manifest, and checksum are trusted through GitHub HTTPS and the repository's release-write access. SHA-256 detects corruption and mixed assets; diff --git a/src/buildbpy/arm64_deps.py b/src/buildbpy/arm64_deps.py index be8c8bb..166caf7 100644 --- a/src/buildbpy/arm64_deps.py +++ b/src/buildbpy/arm64_deps.py @@ -7,6 +7,7 @@ import os from pathlib import Path, PurePosixPath import platform +import re import subprocess import tarfile import tempfile @@ -26,6 +27,19 @@ class BundleSpec: compiler: str = "gcc13" architecture: str = "arm64" + @classmethod + def for_blender_version(cls, blender_version: str) -> "BundleSpec": + """Select the immutable dependency bundle for a Blender release.""" + if re.fullmatch(r"[0-9]+\.[0-9]+\.[0-9]+", blender_version) is None: + raise ValueError( + f"Blender dependency bundle requires a semantic version: {blender_version!r}" + ) + parts = blender_version.split(".") + dependency_version = ( + "5.2.0" if len(parts) == 3 and parts[:2] == ["5", "2"] else blender_version + ) + return cls(dependency_version) + @property def release_tag(self) -> str: return ( diff --git a/src/buildbpy/main.py b/src/buildbpy/main.py index c3a91b9..1ccebd2 100644 --- a/src/buildbpy/main.py +++ b/src/buildbpy/main.py @@ -690,13 +690,25 @@ def _install_arm64_bundle( if extract_root.exists(): shutil.rmtree(extract_root) logger.info("Verifying Linux ARM64 dependency bundle %s", artifacts.archive) + if spec.blender_version == self.version_strategy.minor_version: + expected_blender_commit = self.version_strategy.commit_hash + else: + expected_blender_commit = subprocess.check_output( + [ + "git", + "rev-parse", + f"v{spec.blender_version}^{{commit}}", + ], + cwd=self.blender_repo_dir, + text=True, + ).strip() verify_and_extract_bundle( artifacts.archive, artifacts.manifest, artifacts.checksum, extract_root, spec, - expected_blender_commit=self.version_strategy.commit_hash, + expected_blender_commit=expected_blender_commit, expected_python_series=f"{sys.version_info.major}.{sys.version_info.minor}", ) target = self.lib_dir / "linux_arm64" @@ -718,7 +730,13 @@ def setup_build_environment(self): "./build_files/utils/make_update.py --no-libraries", self.blender_repo_dir, ) - spec = BundleSpec(self.version_strategy.minor_version) + spec = BundleSpec.for_blender_version(self.version_strategy.minor_version) + if spec.blender_version != self.version_strategy.minor_version: + logger.info( + "Blender %s uses Linux ARM64 dependency bundle %s", + self.version_strategy.minor_version, + spec.blender_version, + ) archive_value = os.environ.get("BUILDBPY_ARM64_DEPS_ARCHIVE") if archive_value: archive = Path(archive_value).expanduser().resolve() diff --git a/tests/test_arm64_deps_bundle.py b/tests/test_arm64_deps_bundle.py index f8595a4..a487646 100644 --- a/tests/test_arm64_deps_bundle.py +++ b/tests/test_arm64_deps_bundle.py @@ -50,9 +50,33 @@ def test_bundle_spec_has_stable_release_names(): assert spec.release_tag == "deps-blender-5.2.0-linux-arm64-v1" assert spec.archive_name == "blender-5.2.0-linux-arm64-ubuntu24.04-gcc13-v1.tar.zst" assert spec.manifest_name == f"{spec.archive_name}.manifest.json" + + +@pytest.mark.parametrize("blender_version", ["5.2.0", "5.2.1", "5.2.99"]) +def test_all_blender_5_2_patch_releases_select_5_2_0_dependency_bundle( + blender_version: str, +): + spec = BundleSpec.for_blender_version(blender_version) + + assert spec.blender_version == "5.2.0" + assert spec.release_tag == "deps-blender-5.2.0-linux-arm64-v1" assert spec.checksum_name == f"{spec.archive_name}.sha256" +def test_other_blender_series_keep_their_exact_dependency_version(): + spec = BundleSpec.for_blender_version("5.3.0") + + assert spec.blender_version == "5.3.0" + + +@pytest.mark.parametrize("blender_version", ["5.2", "v5.2.1", "5.2.1\nBAD=value"]) +def test_dependency_bundle_selector_rejects_non_semantic_versions( + blender_version: str, +): + with pytest.raises(ValueError, match="semantic version"): + BundleSpec.for_blender_version(blender_version) + + def test_open_zstd_tar_reports_missing_decoder_and_closes_stderr(tmp_path: Path): stderr_file = MagicMock() with ( @@ -506,6 +530,72 @@ def fake_download(client, repository, token, bundle_spec, output_dir): assert (blender_repo / "lib/linux_arm64/lib/libexample.a").read_bytes() == b"release" +@requires_bundle_tools +def test_linux_arm64_5_2_patch_reuses_5_2_0_release_bundle( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +): + blender_repo = tmp_path / "blender" + blender_repo.mkdir() + subprocess.run(["git", "init"], cwd=blender_repo, check=True, capture_output=True) + subprocess.run( + ["git", "config", "user.name", "Buildbpy Test"], cwd=blender_repo, check=True + ) + subprocess.run( + ["git", "config", "user.email", "buildbpy-test@example.invalid"], + cwd=blender_repo, + check=True, + ) + (blender_repo / "version.txt").write_text("5.2.0") + subprocess.run(["git", "add", "version.txt"], cwd=blender_repo, check=True) + subprocess.run(["git", "commit", "-m", "5.2.0"], cwd=blender_repo, check=True, capture_output=True) + subprocess.run(["git", "tag", "v5.2.0"], cwd=blender_repo, check=True) + bundle_commit = subprocess.check_output( + ["git", "rev-parse", "v5.2.0^{commit}"], cwd=blender_repo, text=True + ).strip() + (blender_repo / "version.txt").write_text("5.2.1") + subprocess.run(["git", "add", "version.txt"], cwd=blender_repo, check=True) + subprocess.run(["git", "commit", "-m", "5.2.1"], cwd=blender_repo, check=True, capture_output=True) + checkout_commit = subprocess.check_output( + ["git", "rev-parse", "HEAD"], cwd=blender_repo, text=True + ).strip() + + source = tmp_path / "source" / "linux_arm64" + (source / "lib").mkdir(parents=True) + (source / "lib" / "libexample.a").write_bytes(b"5.2-series-release") + bundle_spec = BundleSpec(blender_version="5.2.0", revision=1) + artifacts = create_bundle( + source, + tmp_path / "release", + bundle_spec, + blender_commit=bundle_commit, + python_version=f"{sys.version_info.major}.{sys.version_info.minor}.0", + ) + version = ReleaseVersionCycleStrategy("5.2", "5.2.1", "release", checkout_commit) + with patch("platform.machine", return_value="aarch64"): + strategy = LinuxOSStrategy(version, tmp_path, blender_repo, httpx.Client()) + commands = [] + requested_specs = [] + monkeypatch.delenv("BUILDBPY_ARM64_DEPS_ARCHIVE", raising=False) + monkeypatch.delenv("BUILDBPY_ARM64_DEPS_USE_RELEASE", raising=False) + monkeypatch.setenv("GITHUB_REPOSITORY", "michaelgold/buildbpy") + monkeypatch.setenv("GITHUB_TOKEN", "token") + monkeypatch.setattr(strategy, "run_command", lambda command, cwd: commands.append(command)) + + def fake_download(client, repository, token, spec, output_dir): + requested_specs.append(spec) + return artifacts + + monkeypatch.setattr("buildbpy.main.download_release_bundle", fake_download) + + strategy.setup_build_environment() + + assert requested_specs == [bundle_spec] + assert commands == ["./build_files/utils/make_update.py --no-libraries"] + assert ( + blender_repo / "lib/linux_arm64/lib/libexample.a" + ).read_bytes() == b"5.2-series-release" + + @requires_bundle_tools def test_linux_arm64_falls_back_when_release_bundle_is_invalid( tmp_path: Path, monkeypatch: pytest.MonkeyPatch @@ -613,7 +703,28 @@ def test_arm64_workflow_validates_exact_tag_and_publishes_immutable_release(): assert "id: deps_release" in workflow assert workflow.count("steps.deps_release.outputs.exists != 'true'") == 2 assert "already exists and is immutable" not in workflow - assert "key: blender-${{ env.TAG }}-${{ env.PYTHON_VERSION }}-linux-arm64-deps-v1" in workflow + assert "DEPS_VERSION={spec.blender_version}" in workflow + assert "BLENDER_VERSION={version}" in workflow + assert "is_base_version={str(version == spec.blender_version).lower()}" in workflow + assert ( + "key: blender-${{ env.DEPS_VERSION }}-${{ env.PYTHON_VERSION }}-linux-arm64-deps-v1" + in workflow + ) + assert "uses: actions/cache/restore@v4" in workflow + assert "uses: actions/cache/save@v4" in workflow + assert "uses: actions/cache@v4" not in workflow + assert "~/.buildbpy/blender/lib/linux_arm64" not in workflow + assert "steps.deps_cache_path.outputs.exists == 'true'" in workflow + assert "steps.deps_cache.outputs.cache-hit != 'true'" in workflow + save_cache_offset = workflow.index("- name: Save native Blender ARM64 dependency cache") + save_cache_section = workflow[save_cache_offset:] + assert "steps.deps_version.outputs.is_base_version == 'true'" in save_cache_section + assert 'RELEASE_TAG="deps-blender-${DEPS_VERSION}-linux-arm64-v1"' in workflow + assert 'ARCHIVE="blender-${DEPS_VERSION}-linux-arm64-ubuntu24.04-gcc13-v1.tar.zst"' in workflow + assert "BundleSpec.for_blender_version(version)" in workflow + assert workflow.count("steps.deps_version.outputs.is_base_version == 'true'") == 4 + assert 'f"v{deps_version}^{{commit}}"' in workflow + assert '["git", "-C", str(root / "blender"), "rev-parse", "HEAD"]' not in workflow def test_build_all_includes_linux_arm64_in_aggregate_success_barrier():