From c8a36621cfb3b9dd0a0a3c5c929104d7b3a5ccdb Mon Sep 17 00:00:00 2001 From: eimexdev Date: Wed, 21 Oct 2026 20:23:29 -0700 Subject: [PATCH 1/3] Ship one macOS package for Apple silicon and Intel Macs CI built the macOS runtime only on Apple silicon, so the package did not work on Intel Macs. numpy, scipy, and numba have no universal2 wheels, so build a runtime on each architecture's own runner (macos-15 and macos-15-intel), check its architecture, and smoke-test it there. A packaging job then puts both into one OpenBeat-macos-.pkg: a pkgbuild component wrapped by productbuild with a distribution that declares hostArchitectures="arm64,x86_64" and macOS 14 or later. The job installs the package on its runner and smoke-tests the result. The postinstall picks payloads/arm64 or payloads/x86_64, checking hw.optional.arm64 so Apple silicon gets the arm64 runtime under Rosetta. The runtime artifacts are named without the release prefix so Release still publishes only the two installers. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/build-installers.yml | 181 ++++++++++++++++++++++--- CHANGELOG.md | 1 + docs/development.md | 20 ++- docs/install.md | 4 +- docs/release-checklist.md | 7 +- scripts/build_installers.py | 115 +++++++++++++++- scripts/macos_postinstall.sh | 28 +++- tests/test_build_installers.py | 105 +++++++++++--- tests/test_installer_updates.py | 57 ++++++-- tests/test_release_build.py | 50 ++++++- 10 files changed, 504 insertions(+), 64 deletions(-) diff --git a/.github/workflows/build-installers.yml b/.github/workflows/build-installers.yml index d45cf77..d385abc 100644 --- a/.github/workflows/build-installers.yml +++ b/.github/workflows/build-installers.yml @@ -62,24 +62,169 @@ jobs: - name: Run tests run: python -m unittest discover -s tests -v - build: - name: Build ${{ matrix.platform }} installer + + # PyInstaller builds only for the Mac it runs on, and numpy, scipy, and numba + # publish no universal2 wheels, so each runtime is built on its own runner. + macos_runtime: + name: Build macOS ${{ matrix.arch }} runtime needs: test runs-on: ${{ matrix.runner }} strategy: fail-fast: false matrix: include: - - platform: macos - runner: macos-latest - runtime_path: dist/openbeat - artifact_path: dist/installers/*.pkg - artifact_name: OpenBeat-macos-installer - - platform: windows - runner: windows-latest - runtime_path: dist/openbeat/openbeat.exe - artifact_path: dist/installers/*installer.exe - artifact_name: OpenBeat-windows-installer + - arch: arm64 + runner: macos-15 + - arch: x86_64 + runner: macos-15-intel + + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + ref: ${{ inputs.git_ref || github.sha }} + + - name: Setup Python + uses: actions/setup-python@v6 + with: + python-version: '3.11' + + - name: Install pinned dependencies + run: | + python -m pip install --upgrade pip + python -m pip install --require-hashes -r requirements/build.txt + python -m pip install --no-deps --no-build-isolation . + + - name: Record source commit + shell: bash + env: + BUILT: macOS ${{ matrix.arch }} runtime + REQUESTED_REF: ${{ inputs.git_ref }} + run: | + commit="$(git rev-parse HEAD)" + if [[ "$REQUESTED_REF" =~ ^[0-9a-f]{40}$ && "$REQUESTED_REF" != "$commit" ]]; then + echo "::error::Checked out $commit instead of the requested commit $REQUESTED_REF." + exit 1 + fi + echo "Built the $BUILT from commit \`$commit\`." >> "$GITHUB_STEP_SUMMARY" + + - name: Build runtime + run: python scripts/build_installers.py --platform macos-runtime + + - name: Check runtime architecture + env: + ARCH: ${{ matrix.arch }} + run: | + built="$(lipo -archs dist/openbeat)" + if [[ "$built" != "$ARCH" ]]; then + echo "::error::Built a runtime for $built instead of $ARCH." + exit 1 + fi + + - name: Verify bundled runtime with real audio + run: python scripts/smoke_test_runtime.py dist/openbeat + + # Release publishes every artifact named with artifact_prefix, so the + # runtimes, which only the package job uses, are named without it. + - name: Upload runtime + uses: actions/upload-artifact@v6 + with: + name: openbeat-macos-runtime-${{ matrix.arch }} + path: dist/openbeat + retention-days: 1 + overwrite: true + if-no-files-found: error + + macos_package: + name: Build macos installer + needs: macos_runtime + runs-on: macos-15 + + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + ref: ${{ inputs.git_ref || github.sha }} + + - name: Setup Python + uses: actions/setup-python@v6 + with: + python-version: '3.11' + + - name: Install pinned dependencies + run: | + python -m pip install --upgrade pip + python -m pip install --require-hashes -r requirements/build.txt + python -m pip install --no-deps --no-build-isolation . + + - name: Record source commit + shell: bash + env: + BUILT: macOS installer + REQUESTED_REF: ${{ inputs.git_ref }} + run: | + commit="$(git rev-parse HEAD)" + if [[ "$REQUESTED_REF" =~ ^[0-9a-f]{40}$ && "$REQUESTED_REF" != "$commit" ]]; then + echo "::error::Checked out $commit instead of the requested commit $REQUESTED_REF." + exit 1 + fi + echo "Built the $BUILT from commit \`$commit\`." >> "$GITHUB_STEP_SUMMARY" + + - name: Download runtimes + uses: actions/download-artifact@v8 + with: + pattern: openbeat-macos-runtime-* + path: dist/runtimes + + - name: Build installer package + run: >- + python scripts/build_installers.py --platform macos + --macos-runtime arm64=dist/runtimes/openbeat-macos-runtime-arm64/openbeat + --macos-runtime x86_64=dist/runtimes/openbeat-macos-runtime-x86_64/openbeat + + - name: Check package contents + run: | + expanded="$RUNNER_TEMP/expanded" + pkgutil --expand-full dist/installers/OpenBeat-macos-*.pkg "$expanded" + grep -F 'hostArchitectures="arm64,x86_64"' "$expanded/Distribution" + for arch in arm64 x86_64; do + runtime="$expanded/OpenBeat-component.pkg/Payload/payloads/$arch/Modules/OpenBeat/bin/openbeat" + built="$(lipo -archs "$runtime")" + if [[ "$built" != "$arch" ]]; then + echo "::error::The package's $arch runtime is built for $built." + exit 1 + fi + done + + - name: Install the package on this runner + run: sudo installer -verbose -pkg dist/installers/OpenBeat-macos-*.pkg -target / + + - name: Verify the installed runtime with real audio + run: | + python scripts/smoke_test_runtime.py "$HOME/Library/Application Support/Blackmagic Design/DaVinci Resolve/Fusion/Modules/OpenBeat/bin/openbeat" + if [[ -e "/Library/Application Support/OpenBeat" ]]; then + echo "::error::The installer left its staged payload behind." + exit 1 + fi + + - name: Show installer log + if: failure() + run: grep -i openbeat /var/log/install.log | tail -n 100 + + - name: Upload installer artifact + if: github.event_name == 'workflow_dispatch' || github.event_name == 'workflow_call' + uses: actions/upload-artifact@v6 + with: + name: ${{ inputs.artifact_prefix && format('{0}-{1}', inputs.artifact_prefix, 'OpenBeat-macos-installer') || 'OpenBeat-macos-installer' }} + path: dist/installers/*.pkg + compression-level: 0 + retention-days: 7 + if-no-files-found: error + + windows: + name: Build windows installer + needs: test + runs-on: windows-latest steps: - name: Checkout @@ -101,7 +246,7 @@ jobs: - name: Record source commit shell: bash env: - PLATFORM: ${{ matrix.platform }} + BUILT: Windows installer REQUESTED_REF: ${{ inputs.git_ref }} run: | commit="$(git rev-parse HEAD)" @@ -109,20 +254,20 @@ jobs: echo "::error::Checked out $commit instead of the requested commit $REQUESTED_REF." exit 1 fi - echo "Built the $PLATFORM installer from commit \`$commit\`." >> "$GITHUB_STEP_SUMMARY" + echo "Built the $BUILT from commit \`$commit\`." >> "$GITHUB_STEP_SUMMARY" - name: Build installer bundle - run: python scripts/build_installers.py --platform ${{ matrix.platform }} + run: python scripts/build_installers.py --platform windows - name: Verify bundled runtime with real audio - run: python scripts/smoke_test_runtime.py ${{ matrix.runtime_path }} + run: python scripts/smoke_test_runtime.py dist/openbeat/openbeat.exe - name: Upload installer artifact if: github.event_name == 'workflow_dispatch' || github.event_name == 'workflow_call' uses: actions/upload-artifact@v6 with: - name: ${{ inputs.artifact_prefix && format('{0}-{1}', inputs.artifact_prefix, matrix.artifact_name) || matrix.artifact_name }} - path: ${{ matrix.artifact_path }} + name: ${{ inputs.artifact_prefix && format('{0}-{1}', inputs.artifact_prefix, 'OpenBeat-windows-installer') || 'OpenBeat-windows-installer' }} + path: dist/installers/*installer.exe compression-level: 0 retention-days: 7 if-no-files-found: error diff --git a/CHANGELOG.md b/CHANGELOG.md index d9fb755..eb40169 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ All notable OpenBeat changes should be summarized here for users. GitHub Release ## Unreleased - Installer updates now prepare and verify the new files before replacing the installed ones. If an update fails or is interrupted, the previous installation is kept or restored by running the installer again. +- The macOS installer now works on Intel Macs as well as Apple silicon. One package contains a runtime for each and installs the right one. It requires macOS 14 Sonoma or later. - The macOS installer stages its files in a folder only root can change, instead of a shared temporary folder, and stops with a clear error if it cannot tell which user to install for. - Release builds use one resolved commit for testing, building, and tagging, and pinned build dependencies. - When beat analysis or click-track rendering fails, OpenBeat now shows the helper's error instead of a missing-file error, and no longer imports an older click track as if it were new. diff --git a/docs/development.md b/docs/development.md index 4398104..ced8966 100644 --- a/docs/development.md +++ b/docs/development.md @@ -37,6 +37,22 @@ python scripts/build_installers.py --platform windows Generated installer archives are written to `dist/installers/`. +A local macOS build contains only the runtime for the Mac it was built on, and its package installs only on Macs of that architecture. PyInstaller builds for the architecture of the Python running it, and numpy, scipy, and numba publish no `universal2` wheels, so the runtimes cannot be combined into one universal binary. To package both, build each runtime on a Mac of that architecture, then pass both to the package build: + +```bash +# On an Apple silicon Mac, and again on an Intel Mac: +python scripts/build_installers.py --platform macos-runtime # writes dist/openbeat + +# On either Mac, with both runtimes copied over: +python scripts/build_installers.py --platform macos \ + --macos-runtime arm64=path/to/arm64/openbeat \ + --macos-runtime x86_64=path/to/x86_64/openbeat +``` + +The package build wraps the component package from `pkgbuild` in a product archive from `productbuild`. Its distribution file lists the architectures the package has runtimes for in `hostArchitectures`, so Installer runs the postinstall script natively on Apple silicon instead of asking for Rosetta, and requires macOS 14 or later (`MACOS_MINIMUM` in `scripts/build_installers.py`). The postinstall script picks `payloads/arm64` or `payloads/x86_64`, using `sysctl hw.optional.arm64` so an Apple silicon Mac gets the arm64 runtime even when the script runs under Rosetta. + +The macOS 14 minimum comes from the wheels: on the macOS 15 build machines pip picks the numpy and scipy wheels built for macOS 14 and later, which the runtime bundles. Raise `MACOS_MINIMUM` if a dependency update needs a newer macOS. + ## Build Dependencies `requirements/build.txt` pins every package used to test and build the installers: the runtime dependencies from `pyproject.toml` and the build tools from `requirements/build.in`. It lists exact versions with SHA-256 hashes for macOS, Windows, and Linux; CI uses the Python 3.11 pins, and a few packages have separate pins for newer Python versions. CI installs it with `--require-hashes`, so rebuilding a release commit bundles the same packages. CI still upgrades pip itself before installing; pip installs the pinned packages but is not bundled into the installers. @@ -59,7 +75,7 @@ The macOS package installs its payload into `/Library/Application Support/OpenBe Install Lua 5.1 or a later Lua release, then run `python -m unittest discover -s tests -v`. The suite tests timecode conversion, source selection, marker replacement and rollback, and subtitle timing without an open Resolve project. It also sends generated audio through the real CLI and checks both click-track modes. -After building an installer, test the bundled runtime with `python scripts/smoke_test_runtime.py dist/openbeat` on macOS or `python scripts/smoke_test_runtime.py dist/openbeat/openbeat.exe` on Windows. CI runs these checks on both platform builds. They verify analysis of WAV and MP3 sources, output generation, and the error shown for unreadable files; placement inside Resolve still needs a manual check. +After building an installer, test the bundled runtime with `python scripts/smoke_test_runtime.py dist/openbeat` on macOS or `python scripts/smoke_test_runtime.py dist/openbeat/openbeat.exe` on Windows. CI runs these checks on every runtime it builds: the macOS arm64 runtime on `macos-15`, the macOS x86_64 runtime on the Intel `macos-15-intel` runner, and the Windows runtime on `windows-latest`. The macOS package job then puts both macOS runtimes into one package, checks that each has the right architecture, installs the package on its runner, and runs the smoke test against the installed runtime. They verify analysis of WAV and MP3 sources, output generation, and the error shown for unreadable files; placement inside Resolve still needs a manual check. Installer builds are platform-specific. Build the macOS `.pkg` on macOS and the Windows `.exe` on Windows; PyInstaller does not cross-compile the bundled runtime. @@ -67,7 +83,7 @@ Installer builds are platform-specific. Build the macOS `.pkg` on macOS and the GitHub Actions now has two installer-related workflows: -- `Build Installers`: runs on pull requests and pushes to `main` to test that both platform installers still build. Those automatic runs do not upload workflow artifacts. A manual run builds the same installers and uploads them as workflow artifacts retained for seven days. +- `Build Installers`: runs on pull requests and pushes to `main` to test that both platform installers still build. Those automatic runs do not upload installer artifacts; the macOS runtimes are passed to the package job as artifacts named `openbeat-macos-runtime-` that expire after a day. A manual run builds the same installers and uploads them as workflow artifacts retained for seven days. - `Release`: manual-only. It calls `Build Installers`, receives that called workflow's uploaded artifacts, and attaches the installer files to a GitHub Release. Use a manual `Build Installers` run when a contributor needs downloadable installers for testing a branch, tag, or commit without creating a release. Use `Release` when maintainers are preparing a version for distribution: its GitHub Release assets are the durable, public downloads. Workflow artifacts from a manual build are temporary test outputs, not release downloads. diff --git a/docs/install.md b/docs/install.md index 6e7220c..6cd35df 100644 --- a/docs/install.md +++ b/docs/install.md @@ -8,7 +8,7 @@ Installer builds are free downloads from the [latest GitHub Release](https://git 1. Open the latest GitHub Release. 2. Under **Assets**, download the installer for your operating system: - - macOS: `OpenBeat-macos-.pkg` + - macOS: `OpenBeat-macos-.pkg`, for both Apple silicon and Intel Macs - Windows: `OpenBeat-windows--installer.exe` 3. Close DaVinci Resolve before running the installer. @@ -18,6 +18,8 @@ If you prefer source builds, the public repository includes development setup an ## macOS +OpenBeat needs macOS 14 Sonoma or later. The same package works on Apple silicon and Intel Macs: it contains a runtime for each and installs the one that matches your Mac, without needing Rosetta. + 1. Run the `.pkg` installer. 2. If macOS Gatekeeper blocks the package, open System Settings > Privacy & Security and allow the package, then run it again. 3. Restart DaVinci Resolve. diff --git a/docs/release-checklist.md b/docs/release-checklist.md index ee37259..1ce7ec2 100644 --- a/docs/release-checklist.md +++ b/docs/release-checklist.md @@ -29,16 +29,17 @@ lua -e 'assert(loadfile("resolve/Fusion/Modules/OpenBeat/OpenBeatCommon.lua"))' - Optionally run `Build Installers` manually on the target commit for a preflight test without creating a release. Push and pull-request runs build the installers but do not upload them; manual-run artifacts expire after seven days. - Run the manual `Release` workflow for the target and version tag with **Save as draft** enabled. The workflow calls `Build Installers` and attaches its newly built installers to the draft GitHub Release. - Download both installer assets from the draft GitHub Release. These release assets, not artifacts from a separate manual build, are the files to smoke-test and distribute. -- Confirm the macOS artifact is named `OpenBeat-macos-.pkg`. +- Confirm the macOS artifact is named `OpenBeat-macos-.pkg`. It is a single package for both Apple silicon and Intel Macs; there is no separate Intel download. - Confirm the Windows artifact is named `OpenBeat-windows--installer.exe`. - Verify both files came from the draft GitHub Release, not from a local build or an older workflow run. - Confirm the commit SHA at the top of the draft release notes is the commit you meant to release. ## Smoke Test -On macOS: +On macOS, on an Apple silicon Mac and, when one is available, an Intel Mac: -- Run the `.pkg` installer. +- Run the `.pkg` installer. On Apple silicon it should not ask to install Rosetta. +- In Installer's log (**Window > Installer Log**), confirm the postinstall reports installing the `arm64` runtime on Apple silicon and the `x86_64` runtime on Intel. - Restart Resolve. - Confirm `Workspace > Scripts > OpenBeat` appears. - Run `Create Timeline Markers (Quantized)` on a known test clip. diff --git a/scripts/build_installers.py b/scripts/build_installers.py index 1882bb8..27f53d8 100644 --- a/scripts/build_installers.py +++ b/scripts/build_installers.py @@ -6,6 +6,7 @@ import subprocess import tempfile from pathlib import Path +from xml.sax.saxutils import escape, quoteattr ROOT = Path(__file__).resolve().parents[1] DIST_ROOT = ROOT / "dist" @@ -17,6 +18,13 @@ # it into the user's Resolve folder. It must match OPENBEAT_STAGING in # macos_postinstall.sh, which refuses to run unless root alone can write to it. MACOS_STAGING = "/Library/Application Support/OpenBeat/Installer" +MACOS_IDENTIFIER = "org.openbeat.installer" +# Mac architectures the package can carry a runtime for, as reported by uname -m. +# Each runtime is staged under payloads/; macos_postinstall.sh picks one. +MACOS_ARCHES = ("arm64", "x86_64") +# Oldest macOS the bundled runtimes run on. CI builds on macOS 15, where pip picks +# the numpy and scipy wheels built for macOS 14 and later. +MACOS_MINIMUM = "14.0" def run(cmd: list[str], cwd: Path | None = None) -> None: @@ -135,11 +143,63 @@ def restrict_modes(root: Path) -> None: path.chmod(0o755 if executable else 0o644) -def create_macos_pkg(cli_binary: Path, version: str) -> Path: +def python_machine(python_bin: str) -> str: + """The architecture that python_bin runs as, and so the architecture PyInstaller builds for.""" + result = subprocess.run( + [python_bin, "-c", "import platform; print(platform.machine())"], + check=True, + capture_output=True, + text=True, + ) + return result.stdout.strip() + + +def parse_macos_runtime(value: str) -> tuple[str, Path]: + arch, separator, path = value.partition("=") + if not separator or arch not in MACOS_ARCHES or not path: + raise argparse.ArgumentTypeError(f"expected ARCH=PATH with ARCH one of {', '.join(MACOS_ARCHES)}, got {value!r}") + return arch, Path(path) + + +def macos_distribution(version: str, component: str, arches: list[str]) -> str: + """Product definition for productbuild. + + Listing every architecture the package has a runtime for in hostArchitectures + lets Installer run the postinstall script natively on Apple silicon instead + of asking for Rosetta, and refuses the package on a Mac it has no runtime for. + """ + return f""" + + OpenBeat {escape(version)} + + + + + + + + + + + + + + {escape(component)} + +""" + + +def create_macos_pkg(runtimes: dict[str, Path], version: str) -> Path: + """Build one package holding a runtime for each architecture in runtimes.""" + unknown = sorted(set(runtimes) - set(MACOS_ARCHES)) + if not runtimes or unknown: + raise ValueError(f"Expected runtimes for {', '.join(MACOS_ARCHES)}, got {sorted(runtimes)}") + arches = [arch for arch in MACOS_ARCHES if arch in runtimes] with tempfile.TemporaryDirectory(prefix="openbeat-macos-installer-") as tmp: tmp_path = Path(tmp) root_dir = tmp_path / "root" - build_payload(root_dir / "payload", cli_binary, "macos") + for arch in arches: + build_payload(root_dir / "payloads" / arch, runtimes[arch], "macos") restrict_modes(root_dir) scripts_dir = tmp_path / "scripts" @@ -148,12 +208,14 @@ def create_macos_pkg(cli_binary: Path, version: str) -> Path: shutil.copy2(ROOT / "scripts" / "macos_postinstall.sh", postinstall) postinstall.chmod(0o755) - pkg_path = DIST_INSTALLERS / f"OpenBeat-macos-{version}.pkg" + components = tmp_path / "components" + components.mkdir() + component = "OpenBeat-component.pkg" run( [ "pkgbuild", "--identifier", - "org.openbeat.installer", + MACOS_IDENTIFIER, "--version", version, "--root", @@ -164,6 +226,20 @@ def create_macos_pkg(cli_binary: Path, version: str) -> Path: str(scripts_dir), "--install-location", MACOS_STAGING, + str(components / component), + ] + ) + + distribution = tmp_path / "distribution.xml" + distribution.write_text(macos_distribution(version, component, arches), encoding="utf-8") + pkg_path = DIST_INSTALLERS / f"OpenBeat-macos-{version}.pkg" + run( + [ + "productbuild", + "--distribution", + str(distribution), + "--package-path", + str(components), str(pkg_path), ] ) @@ -209,17 +285,42 @@ def create_windows_exe(cli_binary: Path, version: str, python_bin: str = "python def main() -> int: parser = argparse.ArgumentParser(description="Build OpenBeat single-file installers") - parser.add_argument("--platform", choices=["macos", "windows", "all"], default="all") + parser.add_argument( + "--platform", + choices=["macos", "macos-runtime", "windows", "all"], + default="all", + help="macos-runtime builds only this Mac's runtime, for packaging with --macos-runtime elsewhere", + ) parser.add_argument("--python", default="python", help="Python executable to run PyInstaller") + parser.add_argument( + "--macos-runtime", + action="append", + default=[], + type=parse_macos_runtime, + metavar="ARCH=PATH", + help="Package a prebuilt macOS runtime instead of building one; repeat for each architecture", + ) args = parser.parse_args() + runtimes = dict(args.macos_runtime) + if len(runtimes) != len(args.macos_runtime): + parser.error("--macos-runtime lists the same architecture twice") + if runtimes and args.platform not in ("macos", "all"): + parser.error("--macos-runtime only applies to --platform macos") clean() version = parse_version() outputs: list[Path] = [] + if args.platform == "macos-runtime": + outputs.append(build_cli_binary(platform="macos", python_bin=args.python)) + if args.platform in ("macos", "all"): - cli_binary = build_cli_binary(platform="macos", python_bin=args.python) - outputs.append(create_macos_pkg(cli_binary=cli_binary, version=version)) + if not runtimes: + arch = python_machine(args.python) + if arch not in MACOS_ARCHES: + parser.error(f"Cannot build a macOS runtime for {arch!r}") + runtimes[arch] = build_cli_binary(platform="macos", python_bin=args.python) + outputs.append(create_macos_pkg(runtimes=runtimes, version=version)) if args.platform in ("windows", "all"): cli_binary = build_cli_binary(platform="windows", python_bin=args.python) diff --git a/scripts/macos_postinstall.sh b/scripts/macos_postinstall.sh index fb6eed4..5aabbe2 100755 --- a/scripts/macos_postinstall.sh +++ b/scripts/macos_postinstall.sh @@ -14,7 +14,9 @@ # and discard the .OpenBeat.old folders. While that marker exists the update is # complete, so a later run deletes leftover folders instead of restoring them. # -# The package installs the payload under OPENBEAT_STAGING on the target volume. +# The package installs a payload per Mac architecture under OPENBEAT_STAGING on +# the target volume, in payloads/arm64 and payloads/x86_64, and the script +# installs the one for this Mac. # That folder and every folder above it must belong to root and be writable only # by root, so no other user can place or swap files the script then copies as # root. The script refuses to run otherwise and removes the folder when done. @@ -32,6 +34,7 @@ OPENBEAT_TRUSTED_UID=0 OPENBEAT_ROOT="" OPENBEAT_CREATED="" OPENBEAT_USER="" +OPENBEAT_ARCH="" openbeat_log() { echo "OpenBeat: $*" @@ -354,6 +357,21 @@ openbeat_target_user() { OPENBEAT_USER="$user" } +# Sets OPENBEAT_ARCH to this Mac's architecture. Under Rosetta uname reports +# x86_64 on Apple silicon, so check the hardware first. +openbeat_detect_arch() { + OPENBEAT_ARCH="" + if [ "$(sysctl -n hw.optional.arm64 2>/dev/null)" = "1" ]; then + OPENBEAT_ARCH="arm64" + return 0 + fi + case "$(uname -m 2>/dev/null)" in + arm64) OPENBEAT_ARCH="arm64" ;; + x86_64) OPENBEAT_ARCH="x86_64" ;; + *) return 1 ;; + esac +} + openbeat_main() { set -euo pipefail @@ -361,6 +379,11 @@ openbeat_main() { staging="${volume%/}/$OPENBEAT_STAGING" openbeat_check_staging "$volume" "$OPENBEAT_STAGING" || return 1 + if ! openbeat_detect_arch || [ ! -d "$staging/payloads/$OPENBEAT_ARCH" ]; then + openbeat_log "This package has no OpenBeat runtime for this Mac ($(uname -m 2>/dev/null))" + rm -rf "$staging" + return 1 + fi if ! openbeat_target_user; then rm -rf "$staging" return 1 @@ -375,7 +398,8 @@ openbeat_main() { fi resolve_root="$user_home/Library/Application Support/Blackmagic Design/DaVinci Resolve/Fusion" - openbeat_install_as_user "$OPENBEAT_USER" "$staging/payload" "$resolve_root" || status=$? + openbeat_log "Installing the $OPENBEAT_ARCH runtime for $OPENBEAT_USER" + openbeat_install_as_user "$OPENBEAT_USER" "$staging/payloads/$OPENBEAT_ARCH" "$resolve_root" || status=$? rm -rf "$staging" rmdir "${staging%/*}" 2>/dev/null || true return "$status" diff --git a/tests/test_build_installers.py b/tests/test_build_installers.py index 4405903..16c5ffa 100644 --- a/tests/test_build_installers.py +++ b/tests/test_build_installers.py @@ -1,7 +1,9 @@ +import argparse import unittest from pathlib import Path from tempfile import TemporaryDirectory from unittest import mock +from xml.etree import ElementTree from scripts import build_installers @@ -37,36 +39,95 @@ def fake_run(cmd: list[str], cwd: Path | None = None) -> None: result = build_installers.create_windows_exe(binary, "0.0.2") self.assertEqual(result.read_bytes(), b"installer") - def test_macos_package_runs_the_staged_postinstall_script(self) -> None: + def build_macos_pkg(self, arches: tuple[str, ...]) -> tuple[list[list[str]], str]: + """Runs create_macos_pkg with pkgbuild and productbuild stubbed; returns their commands and the distribution.""" + commands: list[list[str]] = [] + distribution: list[str] = [] with TemporaryDirectory() as temporary: root = Path(temporary) - binary = root / "openbeat" - binary.write_bytes(b"runtime") + runtimes = {} + for arch in arches: + runtimes[arch] = root / arch / "openbeat" + runtimes[arch].parent.mkdir() + runtimes[arch].write_bytes(f"{arch} runtime".encode()) installers = root / "installers" installers.mkdir() - def fake_pkgbuild(cmd: list[str], cwd: Path | None = None) -> None: - package_root = Path(cmd[cmd.index("--root") + 1]) - scripts = Path(cmd[cmd.index("--scripts") + 1]) - self.assertEqual(cmd[cmd.index("--install-location") + 1], build_installers.MACOS_STAGING) - self.assertEqual(cmd[cmd.index("--ownership") + 1], "recommended") - for path in [package_root, *package_root.rglob("*")]: - self.assertEqual(path.stat().st_mode & 0o022, 0, path) - self.assertEqual((package_root / "payload" / "Modules" / "OpenBeat" / "bin" / "openbeat").stat().st_mode & 0o777, 0o755) - postinstall = scripts / "postinstall" - self.assertEqual(postinstall.read_bytes(), (build_installers.ROOT / "scripts" / "macos_postinstall.sh").read_bytes()) - self.assertTrue(postinstall.stat().st_mode & 0o111) - modules = package_root / "payload" / "Modules" / "OpenBeat" - self.assertEqual((modules / "bin" / "openbeat").read_bytes(), b"runtime") - self.assertFalse((modules / "OpenBeatConfig.local.lua").exists()) - self.assertIn(" bin/openbeat\n", (modules.parent / "OpenBeat.sha256").read_text()) - self.assertTrue((package_root / "payload" / "Scripts" / "Utility" / "OpenBeat.sha256").is_file()) - Path(cmd[-1]).write_bytes(b"package") + def fake_run(cmd: list[str], cwd: Path | None = None) -> None: + commands.append(cmd) + if cmd[0] == "pkgbuild": + self.check_component(cmd, arches) + Path(cmd[-1]).write_bytes(b"component") + else: + self.assertEqual(cmd[0], "productbuild") + components = Path(cmd[cmd.index("--package-path") + 1]) + self.assertEqual([path.name for path in components.iterdir()], ["OpenBeat-component.pkg"]) + distribution.append(Path(cmd[cmd.index("--distribution") + 1]).read_text()) + Path(cmd[-1]).write_bytes(b"package") with mock.patch.object(build_installers, "DIST_INSTALLERS", installers): - with mock.patch.object(build_installers, "run", side_effect=fake_pkgbuild): - result = build_installers.create_macos_pkg(binary, "0.0.2") + with mock.patch.object(build_installers, "run", side_effect=fake_run): + result = build_installers.create_macos_pkg(runtimes, "0.0.2") + self.assertEqual(result.name, "OpenBeat-macos-0.0.2.pkg") self.assertEqual(result.read_bytes(), b"package") + self.assertEqual([cmd[0] for cmd in commands], ["pkgbuild", "productbuild"]) + return commands, distribution[0] + + def check_component(self, cmd: list[str], arches: tuple[str, ...]) -> None: + package_root = Path(cmd[cmd.index("--root") + 1]) + scripts = Path(cmd[cmd.index("--scripts") + 1]) + self.assertEqual(cmd[cmd.index("--identifier") + 1], build_installers.MACOS_IDENTIFIER) + self.assertEqual(cmd[cmd.index("--install-location") + 1], build_installers.MACOS_STAGING) + self.assertEqual(cmd[cmd.index("--ownership") + 1], "recommended") + for path in [package_root, *package_root.rglob("*")]: + self.assertEqual(path.stat().st_mode & 0o022, 0, path) + postinstall = scripts / "postinstall" + self.assertEqual(postinstall.read_bytes(), (build_installers.ROOT / "scripts" / "macos_postinstall.sh").read_bytes()) + self.assertTrue(postinstall.stat().st_mode & 0o111) + self.assertEqual(sorted(path.name for path in (package_root / "payloads").iterdir()), sorted(arches)) + for arch in arches: + modules = package_root / "payloads" / arch / "Modules" / "OpenBeat" + self.assertEqual((modules / "bin" / "openbeat").read_bytes(), f"{arch} runtime".encode()) + self.assertEqual((modules / "bin" / "openbeat").stat().st_mode & 0o777, 0o755) + self.assertFalse((modules / "OpenBeatConfig.local.lua").exists()) + self.assertIn(" bin/openbeat\n", (modules.parent / "OpenBeat.sha256").read_text()) + self.assertTrue((package_root / "payloads" / arch / "Scripts" / "Utility" / "OpenBeat.sha256").is_file()) + + def test_macos_package_carries_a_runtime_for_each_architecture(self) -> None: + _, distribution = self.build_macos_pkg(("arm64", "x86_64")) + document = ElementTree.fromstring(distribution) + options = document.find("options") + # Without arm64 here, Apple silicon Macs ask for Rosetta to run the postinstall script. + self.assertEqual(options.get("hostArchitectures"), "arm64,x86_64") + self.assertEqual(options.get("customize"), "never") + self.assertEqual(document.find("domains").get("enable_currentUserHome"), "false") + self.assertEqual(document.find("volume-check/allowed-os-versions/os-version").get("min"), build_installers.MACOS_MINIMUM) + self.assertEqual(document.find("choices-outline/line").get("choice"), build_installers.MACOS_IDENTIFIER) + references = document.findall(".//pkg-ref") + self.assertEqual({ref.get("id") for ref in references}, {build_installers.MACOS_IDENTIFIER}) + component = next(ref for ref in references if ref.text) + self.assertEqual((component.text, component.get("version")), ("OpenBeat-component.pkg", "0.0.2")) + + def test_single_architecture_package_only_installs_on_that_architecture(self) -> None: + _, distribution = self.build_macos_pkg(("x86_64",)) + self.assertEqual(ElementTree.fromstring(distribution).find("options").get("hostArchitectures"), "x86_64") + + def test_macos_package_rejects_unknown_architectures(self) -> None: + for runtimes in ({}, {"i386": Path("openbeat")}, {"arm64": Path("openbeat"), "ppc": Path("openbeat")}): + with self.subTest(runtimes=runtimes), self.assertRaises(ValueError): + build_installers.create_macos_pkg(runtimes, "0.0.2") + + def test_macos_runtime_arguments(self) -> None: + self.assertEqual(build_installers.parse_macos_runtime("x86_64=dist/x/openbeat"), ("x86_64", Path("dist/x/openbeat"))) + for value in ("dist/openbeat", "universal2=dist/openbeat", "arm64="): + with self.subTest(value=value), self.assertRaises(argparse.ArgumentTypeError): + build_installers.parse_macos_runtime(value) + + def test_postinstall_selects_the_architectures_the_package_carries(self) -> None: + postinstall = (build_installers.ROOT / "scripts" / "macos_postinstall.sh").read_text() + self.assertIn('"$staging/payloads/$OPENBEAT_ARCH"', postinstall) + for arch in build_installers.MACOS_ARCHES: + self.assertIn(f'OPENBEAT_ARCH="{arch}"', postinstall) def test_macos_package_stages_where_the_postinstall_script_checks(self) -> None: self.assertTrue(build_installers.MACOS_STAGING.startswith("/Library/")) diff --git a/tests/test_installer_updates.py b/tests/test_installer_updates.py index e77d307..985c1bc 100644 --- a/tests/test_installer_updates.py +++ b/tests/test_installer_updates.py @@ -58,7 +58,8 @@ time.sleep(300) """ -# Stand-ins for the macOS commands openbeat_main uses to find the user and inspect the staging folder. +# Stand-ins for the macOS commands openbeat_main uses to find the user, inspect the staging +# folder, and tell which architecture the Mac has. BASH_MACOS = r""" stat() { if [ "$1" = "-f" ] && [ "$2" = "%Su" ]; then @@ -83,6 +84,15 @@ *"|$1|"*) echo " 1: user:mallory allow add_file,add_subdirectory,delete_child" ;; esac } +sysctl() { + [ "$*" = "-n hw.optional.arm64" ] || return 1 + if [ -z "${FAKE_SYSCTL_ARM64:-}" ]; then + echo "sysctl: unknown oid 'hw.optional.arm64'" >&2 + return 1 + fi + printf '%s\n' "$FAKE_SYSCTL_ARM64" +} +uname() { [ "$*" = "-m" ] && printf '%s\n' "$FAKE_UNAME"; } """ KILL_DRIVER = """ @@ -375,9 +385,11 @@ def setUp(self) -> None: self.home.mkdir() self.volume = self.work / "volume" self.staging = self.volume.joinpath(*build_installers.MACOS_STAGING.strip("/").split("/")) - runtime = self.work / "openbeat" - runtime.write_bytes(b"new runtime") - build_installers.build_payload(self.staging / "payload", runtime, "macos") + for arch in build_installers.MACOS_ARCHES: + runtime = self.work / arch / "openbeat" + runtime.parent.mkdir() + runtime.write_bytes(f"{arch} runtime".encode()) + build_installers.build_payload(self.staging / "payloads" / arch, runtime, "macos") path = self.staging while path != self.volume: path.chmod(0o755) @@ -386,7 +398,10 @@ def setUp(self) -> None: def run_main(self, trusted_uid: int | None = None, **environment: str) -> subprocess.CompletedProcess[str]: env = {key: value for key, value in os.environ.items() if key not in ("USER", "SUDO_USER")} - env.update({"CONSOLE_USER": self.user, "FAKE_HOME": str(self.home)}, **environment) + env.update( + {"CONSOLE_USER": self.user, "FAKE_HOME": str(self.home), "FAKE_SYSCTL_ARM64": "1", "FAKE_UNAME": "arm64"}, + **environment, + ) uid = os.getuid() if trusted_uid is None else trusted_uid command = f'set -u; source "$1"; {BASH_MACOS}\nOPENBEAT_TRUSTED_UID={uid}\nopenbeat_main "$2/OpenBeat.pkg" "$3" "$2"' return subprocess.run( @@ -410,7 +425,7 @@ def assert_refused(self, result: subprocess.CompletedProcess[str], message: str) def test_installs_for_console_user_and_removes_staged_payload(self) -> None: result = self.run_main() self.assertEqual(result.returncode, 0, result.stdout + result.stderr) - self.assertEqual((self.modules / "bin" / "openbeat").read_bytes(), b"new runtime") + self.assertEqual((self.modules / "bin" / "openbeat").read_bytes(), b"arm64 runtime") self.assertIn(f"as user {self.user}", result.stderr) self.assertFalse(self.staging.exists()) self.assertFalse(self.staging.parent.exists()) @@ -478,7 +493,7 @@ def test_refuses_staging_folders_other_users_can_write(self) -> None: folder.chmod(0o755) self.assert_refused(result, f"Refusing to install: {folder} must belong to root") # The payload is left alone rather than deleted as root. - self.assertTrue((self.staging / "payload").is_dir()) + self.assertTrue((self.staging / "payloads").is_dir()) def test_refuses_volume_root_other_users_can_write(self) -> None: self.volume.chmod(0o777) @@ -505,12 +520,38 @@ def test_refuses_symlinked_staging_folder(self) -> None: self.staging.symlink_to(elsewhere, target_is_directory=True) result = self.run_main() self.assert_refused(result, f"Refusing to install: {self.staging} is not a folder") - self.assertTrue((elsewhere / "payload").is_dir()) + self.assertTrue((elsewhere / "payloads").is_dir()) def test_refuses_missing_staging_folder(self) -> None: shutil.rmtree(self.staging) self.assert_refused(self.run_main(), "is not a folder") + def test_installs_the_runtime_for_this_mac(self) -> None: + macs = { + "Apple silicon": ({"FAKE_SYSCTL_ARM64": "1", "FAKE_UNAME": "arm64"}, "arm64"), + "Apple silicon under Rosetta": ({"FAKE_SYSCTL_ARM64": "1", "FAKE_UNAME": "x86_64"}, "arm64"), + "Intel": ({"FAKE_SYSCTL_ARM64": "", "FAKE_UNAME": "x86_64"}, "x86_64"), + "Intel reporting no arm64 support": ({"FAKE_SYSCTL_ARM64": "0", "FAKE_UNAME": "x86_64"}, "x86_64"), + } + for mac, (environment, arch) in macs.items(): + with self.subTest(mac=mac): + self.setUp() + result = self.run_main(**environment) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertIn(f"Installing the {arch} runtime", result.stdout) + self.assertEqual((self.modules / "bin" / "openbeat").read_bytes(), f"{arch} runtime".encode()) + self.assertFalse(self.staging.exists()) + + def test_refuses_a_mac_the_package_has_no_runtime_for(self) -> None: + unknown = self.run_main(FAKE_SYSCTL_ARM64="", FAKE_UNAME="ppc") + self.assert_refused(unknown, "This package has no OpenBeat runtime for this Mac (ppc)") + self.assertFalse(self.staging.exists()) + + self.setUp() + shutil.rmtree(self.staging / "payloads" / "x86_64") + intel = self.run_main(FAKE_SYSCTL_ARM64="", FAKE_UNAME="x86_64") + self.assert_refused(intel, "This package has no OpenBeat runtime for this Mac (x86_64)") + class WindowsStagedInstallUpdateTests(UpdateScenarios, unittest.TestCase): platform = "windows" diff --git a/tests/test_release_build.py b/tests/test_release_build.py index 3347190..9a43fc1 100644 --- a/tests/test_release_build.py +++ b/tests/test_release_build.py @@ -25,7 +25,9 @@ def test_every_release_job_uses_the_resolved_commit(self) -> None: def test_build_jobs_check_out_the_requested_commit(self) -> None: build = (WORKFLOWS / "build-installers.yml").read_text() refs = re.findall(r"^\s*ref: (.+)$", build, re.MULTILINE) - self.assertEqual(refs, ["${{ inputs.git_ref || github.sha }}"] * 2) + jobs = re.findall(r"^ (\w+):$", build.split("\njobs:\n", 1)[1], re.MULTILINE) + self.assertEqual(jobs, ["test", "macos_runtime", "macos_package", "windows"]) + self.assertEqual(refs, ["${{ inputs.git_ref || github.sha }}"] * len(jobs)) def test_build_jobs_install_only_pinned_dependencies(self) -> None: build = (WORKFLOWS / "build-installers.yml").read_text() @@ -36,6 +38,52 @@ def test_build_jobs_install_only_pinned_dependencies(self) -> None: ) +def job(workflow: str, name: str) -> str: + """The text of one job in a workflow.""" + jobs = workflow.split("\njobs:\n", 1)[1] + match = re.search(rf"^ {name}:\n(.*?)(?=^ \w+:\n|\Z)", jobs, re.MULTILINE | re.DOTALL) + assert match, name + return match.group(1) + + +class MacOSBuildWorkflowTests(unittest.TestCase): + build = (WORKFLOWS / "build-installers.yml").read_text() + + def test_each_runtime_is_built_and_tested_on_its_own_architecture(self) -> None: + runtime = job(self.build, "macos_runtime") + matrix = re.findall(r"- arch: (\S+)\n\s+runner: (\S+)", runtime) + self.assertEqual(matrix, [("arm64", "macos-15"), ("x86_64", "macos-15-intel")]) + self.assertIn("runs-on: ${{ matrix.runner }}", runtime) + self.assertIn("--platform macos-runtime", runtime) + self.assertIn('lipo -archs dist/openbeat', runtime) + self.assertIn("smoke_test_runtime.py dist/openbeat", runtime) + self.assertIn("name: openbeat-macos-runtime-${{ matrix.arch }}", runtime) + + def test_one_package_carries_both_runtimes(self) -> None: + package = job(self.build, "macos_package") + self.assertIn("needs: macos_runtime", package) + self.assertIn("name: Build macos installer", package) + self.assertIn("pattern: openbeat-macos-runtime-*", package) + for arch in ("arm64", "x86_64"): + self.assertIn(f"--macos-runtime {arch}=dist/runtimes/openbeat-macos-runtime-{arch}/openbeat", package) + self.assertIn('hostArchitectures="arm64,x86_64"', package) + self.assertIn("sudo installer", package) + + def test_release_publishes_only_the_installers(self) -> None: + # Release downloads every artifact whose name starts with the prefix and attaches it. + release = (WORKFLOWS / "release.yml").read_text() + self.assertIn("artifact_prefix: release-${{ github.run_id }}", release) + self.assertIn("pattern: release-${{ github.run_id }}-*", release) + names = re.findall(r"uses: actions/upload-artifact@\S+\n\s+with:\n\s+name: (.+)$", self.build, re.MULTILINE) + prefixed = [name for name in names if "inputs.artifact_prefix" in name] + self.assertEqual(len(prefixed), 2) + self.assertIn("'OpenBeat-macos-installer'", prefixed[0]) + self.assertIn("'OpenBeat-windows-installer'", prefixed[1]) + self.assertEqual([name for name in names if name not in prefixed], ["openbeat-macos-runtime-${{ matrix.arch }}"]) + self.assertIn("path: dist/installers/*.pkg", job(self.build, "macos_package")) + self.assertIn("path: dist/installers/*installer.exe", job(self.build, "windows")) + + class BuildLockTests(unittest.TestCase): def test_lock_pins_every_requirement_with_hashes(self) -> None: lock = (ROOT / "requirements" / "build.txt").read_text() From 55770eac7ca292a4c3d7b953796bfab39bcfc9e8 Mon Sep 17 00:00:00 2001 From: eimexdev Date: Wed, 21 Oct 2026 20:24:18 -0700 Subject: [PATCH 2/3] Pin numba 0.62 for the Intel Mac runtime numba 0.68.0 and llvmlite 0.50.0, the locked versions, publish no macOS x86_64 wheels; the last that do are numba 0.62.1 and llvmlite 0.45.1. Without them, the hash-locked install on the Intel runner falls back to building llvmlite from source and fails. Constrain numba below 0.63 for macOS x86_64 only and re-lock, which also holds numpy at 2.3.5 there. Every other platform keeps its pins. Co-Authored-By: Claude Opus 5.5 --- docs/development.md | 6 +- requirements/build-constraints.txt | 5 + requirements/build.txt | 145 +++++++++++++++++++++++++++-- 3 files changed, 148 insertions(+), 8 deletions(-) create mode 100644 requirements/build-constraints.txt diff --git a/docs/development.md b/docs/development.md index ced8966..2a44a21 100644 --- a/docs/development.md +++ b/docs/development.md @@ -60,10 +60,12 @@ The macOS 14 minimum comes from the wheels: on the macOS 15 build machines pip p To update the pins, edit `pyproject.toml` or `requirements/build.in` if needed, then regenerate the file with [uv](https://docs.astral.sh/uv/): ```bash -uv pip compile pyproject.toml requirements/build.in --universal --python-version 3.11 --generate-hashes --upgrade --output-file requirements/build.txt +uv pip compile pyproject.toml requirements/build.in --constraint requirements/build-constraints.txt --universal --python-version 3.11 --generate-hashes --upgrade --output-file requirements/build.txt ``` -Replace `--upgrade` with `--upgrade-package ` to move a single package, or omit it to keep the existing pins while adding or removing packages. Review the version changes in the diff and open a pull request; its `Build Installers` run tests, builds, and smoke-tests both installers with the new pins. +Replace `--upgrade` with `--upgrade-package ` to move a single package, or omit it to keep the existing pins while adding or removing packages. + +`requirements/build-constraints.txt` holds Intel Macs on numba 0.62, because numba 0.63 and later, and the llvmlite releases they need, publish no macOS x86_64 wheels. The lock therefore pins numba 0.62.1, llvmlite 0.45.1, and numpy 2.3.5 for the Intel Mac runtime and newer releases everywhere else. Every pin must have a wheel for each platform CI builds on, macOS x86_64 included; without one, `pip install --require-hashes` falls back to building from source, which fails for llvmlite. If a later numba restores macOS x86_64 wheels, or Intel support is dropped, remove the constraint. Review the version changes in the diff and open a pull request; its `Build Installers` run tests, builds, and smoke-tests both installers with the new pins. ## How Installers Replace An Existing Install diff --git a/requirements/build-constraints.txt b/requirements/build-constraints.txt new file mode 100644 index 0000000..cd8759c --- /dev/null +++ b/requirements/build-constraints.txt @@ -0,0 +1,5 @@ +# Constraints applied when compiling requirements/build.txt; see docs/development.md. +# +# numba 0.63 and later, and the llvmlite releases they need, publish no macOS +# x86_64 wheels, so the Intel Mac runtime stays on the last numba that does. +numba<0.63 ; sys_platform == 'darwin' and platform_machine == 'x86_64' diff --git a/requirements/build.txt b/requirements/build.txt index c4e9a4e..7def1dd 100644 --- a/requirements/build.txt +++ b/requirements/build.txt @@ -1,5 +1,5 @@ # This file was autogenerated by uv via the following command: -# uv pip compile pyproject.toml requirements/build.in --universal --python-version 3.11 --generate-hashes --output-file requirements/build.txt +# uv pip compile pyproject.toml requirements/build.in --constraint requirements/build-constraints.txt --universal --python-version 3.11 --generate-hashes --output-file requirements/build.txt altgraph==0.17.5 \ --hash=sha256:c87b395dd12fabde9c99573a9749d67da8d29ef9de0125c7f536699b4a9bc9e7 \ --hash=sha256:f3a22400bce1b0c701683820ac4f3b159cd301acab067c51c653e06961600597 @@ -320,7 +320,30 @@ librosa==1.0.0 ; python_full_version >= '3.12' \ --hash=sha256:5910a6c0e1b2e494b92758c1615a7acbd0515a2315e138927ba2982f2af88857 \ --hash=sha256:73ed480d4022e436e85dfa6f6b06ff38a259b9210039ac99939cd64854b61a57 # via openbeat (pyproject.toml) -llvmlite==0.50.0 \ +llvmlite==0.45.1 ; platform_machine == 'x86_64' and sys_platform == 'darwin' \ + --hash=sha256:02a164db2d79088bbd6e0d9633b4fe4021d6379d7e4ac7cc85ed5f44b06a30c5 \ + --hash=sha256:080e6f8d0778a8239cd47686d402cb66eb165e421efa9391366a9b7e5810a38b \ + --hash=sha256:09430bb9d0bb58fc45a45a57c7eae912850bedc095cd0810a57de109c69e1c32 \ + --hash=sha256:1a53f4b74ee9fd30cb3d27d904dadece67a7575198bd80e687ee76474620735f \ + --hash=sha256:1b1af0c910af0978aa55fa4f60bbb3e9f39b41e97c2a6d94d199897be62ba07a \ + --hash=sha256:28e763aba92fe9c72296911e040231d486447c01d4f90027c8e893d89d49b20e \ + --hash=sha256:2f3377a6db40f563058c9515dedcc8a3e562d8693a106a28f2ddccf2c8fcf6ca \ + --hash=sha256:3aa3dfceda4219ae39cf18806c60eeb518c1680ff834b8b311bd784160b9ce40 \ + --hash=sha256:3adc2355694d6a6fbcc024d59bb756677e7de506037c878022d7b877e7613a36 \ + --hash=sha256:4edb62e685867799e336723cb9787ec6598d51d0b1ed9af0f38e692aa757e898 \ + --hash=sha256:57c48bf2e1083eedbc9406fb83c4e6483017879714916fe8be8a72a9672c995a \ + --hash=sha256:5b3796b1b1e1c14dcae34285d2f4ea488402fbd2c400ccf7137603ca3800864f \ + --hash=sha256:60f92868d5d3af30b4239b50e1717cb4e4e54f6ac1c361a27903b318d0f07f42 \ + --hash=sha256:779e2f2ceefef0f4368548685f0b4adde34e5f4b457e90391f570a10b348d433 \ + --hash=sha256:98baab513e19beb210f1ef39066288784839a44cd504e24fff5d17f1b3cf0860 \ + --hash=sha256:9e6c9949baf25d9aa9cd7cf0f6d011b9ca660dd17f5ba2b23bdbdb77cc86b116 \ + --hash=sha256:c9f3cadee1630ce4ac18ea38adebf2a4f57a89bd2740ce83746876797f6e0bfb \ + --hash=sha256:d9ea9e6f17569a4253515cc01dade70aba536476e3d750b2e18d81d7e670eb15 \ + --hash=sha256:f2d47f34e4029e6df3395de34cc1c66440a8d72712993a6e6168db228686711b \ + --hash=sha256:f7319e5f9f90720578a7f56fbc805bdfb4bc071b507c7611f170d631c3c0f1e0 \ + --hash=sha256:f9c272682d91e0d57f2a76c6d9ebdfccc603a01828cdbe3d15273bdca0c3363a + # via numba +llvmlite==0.50.0 ; platform_machine != 'x86_64' or sys_platform != 'darwin' \ --hash=sha256:0225351ad77ea30501fc5b4c09ff6868169fde50c5a576cdfda1645091157616 \ --hash=sha256:1cb21c420a47dcfa56223228d013c6f9d234e05e06e6819a41638d78bbd78e6c \ --hash=sha256:211da1b088d566aafa1e444d546f64fc7f13b1af56ff0207a1705d88607be6ab \ @@ -459,7 +482,32 @@ narwhals==2.27.1 \ --hash=sha256:aed93076a3ea42d9c32c88e4eb5ea422a21937011cbe1f480f9572a523c82094 \ --hash=sha256:d057df13f5852b8e157596e82eb5e955fad267425df5e420e0ee9863da483b31 # via scikit-learn -numba==0.68.0 \ +numba==0.62.1 ; platform_machine == 'x86_64' and sys_platform == 'darwin' \ + --hash=sha256:03de6d691d6b6e2b76660ba0f38f37b81ece8b2cc524a62f2a0cfae2bfb6f9da \ + --hash=sha256:14432af305ea68627a084cd702124fd5d0c1f5b8a413b05f4e14757202d1cf6c \ + --hash=sha256:1b743b32f8fa5fff22e19c2e906db2f0a340782caf024477b97801b918cf0494 \ + --hash=sha256:44a1412095534a26fb5da2717bc755b57da5f3053965128fe3dc286652cc6a92 \ + --hash=sha256:44e3aa6228039992f058f5ebfcfd372c83798e9464297bdad8cc79febcf7891e \ + --hash=sha256:57cbddc53b9ee02830b828a8428757f5c218831ccc96490a314ef569d8342b7b \ + --hash=sha256:604059730c637c7885386521bb1b0ddcbc91fd56131a6dcc54163d6f1804c872 \ + --hash=sha256:6ef84d0ac19f1bf80431347b6f4ce3c39b7ec13f48f233a48c01e2ec06ecbc59 \ + --hash=sha256:728f91a874192df22d74e3fd42c12900b7ce7190b1aad3574c6c61b08313e4c5 \ + --hash=sha256:7b774242aa890e34c21200a1fc62e5b5757d5286267e71103257f4e2af0d5161 \ + --hash=sha256:8c9460b9e936c5bd2f0570e20a0a5909ee6e8b694fd958b210e3bde3a6dba2d7 \ + --hash=sha256:90fa21b0142bcf08ad8e32a97d25d0b84b1e921bc9423f8dda07d3652860eef6 \ + --hash=sha256:9315cc5e441300e0ca07c828a627d92a6802bcbf27c5487f31ae73783c58da53 \ + --hash=sha256:a323df9d36a0da1ca9c592a6baaddd0176d9f417ef49a65bb81951dce69d941a \ + --hash=sha256:b72489ba8411cc9fdcaa2458d8f7677751e94f0109eeb53e5becfdc818c64afb \ + --hash=sha256:bbf3f88b461514287df66bc8d0307e949b09f2b6f67da92265094e8fa1282dd8 \ + --hash=sha256:d6c540880170bee817011757dc9049dba5a29db0c09b4d2349295991fe3ee55f \ + --hash=sha256:e1e1f4781d3f9f7c23f16eb04e76ca10b5a3516e959634bd226fc48d5d8e7a0a \ + --hash=sha256:f180922adf159ae36c2fe79fb94ffaa74cf5cb3688cb72dba0a904b91e978507 \ + --hash=sha256:f41834909d411b4b8d1c68f745144136f21416547009c1e860cc2098754b4ca7 \ + --hash=sha256:f43e24b057714e480fe44bc6031de499e7cf8150c63eb461192caa6cc8530bc8 + # via + # -c requirements/build-constraints.txt + # librosa +numba==0.68.0 ; platform_machine != 'x86_64' or sys_platform != 'darwin' \ --hash=sha256:080bf1d0dc6adaa834400b6f92e5407de2a7dd80a665f71f74597e95508b2f1f \ --hash=sha256:0fdaa2f0256862ebbcd9632ef01ba2a4b94e6d116029e5051a92340d4050a501 \ --hash=sha256:1a3aa5558ba1c316020a0c2f6042be6ae063cfc6eb0c7badb3a0c77d2b5308b7 \ @@ -492,8 +540,93 @@ numba==0.68.0 \ --hash=sha256:e3ee1f49b62efbbb804f731f2bd602bd1f8b8d3cc13009f25d69955675f82407 \ --hash=sha256:ea11c865265e39a6019e2f0fe62743825127b3b7bc4815916f5d5121fd9b262b \ --hash=sha256:f58c13a6e9bfef062311cb0d3c19f6c159b901213daa325e1db473946010cec7 - # via librosa -numpy==2.4.6 ; python_full_version < '3.12' \ + # via + # -c requirements/build-constraints.txt + # librosa +numpy==2.3.5 ; platform_machine == 'x86_64' and sys_platform == 'darwin' \ + --hash=sha256:00dc4e846108a382c5869e77c6ed514394bdeb3403461d25a829711041217d5b \ + --hash=sha256:0472f11f6ec23a74a906a00b48a4dcf3849209696dff7c189714511268d103ae \ + --hash=sha256:04822c00b5fd0323c8166d66c701dc31b7fbd252c100acd708c48f763968d6a3 \ + --hash=sha256:052e8c42e0c49d2575621c158934920524f6c5da05a1d3b9bab5d8e259e045f0 \ + --hash=sha256:09a1bea522b25109bf8e6f3027bd810f7c1085c64a0c7ce050c1676ad0ba010b \ + --hash=sha256:0cd00b7b36e35398fa2d16af7b907b65304ef8bb4817a550e06e5012929830fa \ + --hash=sha256:0d8163f43acde9a73c2a33605353a4f1bc4798745a8b1d73183b28e5b435ae28 \ + --hash=sha256:1062fde1dcf469571705945b0f221b73928f34a20c904ffb45db101907c3454e \ + --hash=sha256:11e06aa0af8c0f05104d56450d6093ee639e15f24ecf62d417329d06e522e017 \ + --hash=sha256:17531366a2e3a9e30762c000f2c43a9aaa05728712e25c11ce1dbe700c53ad41 \ + --hash=sha256:1978155dd49972084bd6ef388d66ab70f0c323ddee6f693d539376498720fb7e \ + --hash=sha256:1ed1ec893cff7040a02c8aa1c8611b94d395590d553f6b53629a4461dc7f7b63 \ + --hash=sha256:2dcd0808a421a482a080f89859a18beb0b3d1e905b81e617a188bd80422d62e9 \ + --hash=sha256:2e2eb32ddb9ccb817d620ac1d8dae7c3f641c1e5f55f531a33e8ab97960a75b8 \ + --hash=sha256:2feae0d2c91d46e59fcd62784a3a83b3fb677fead592ce51b5a6fbb4f95965ff \ + --hash=sha256:3095bdb8dd297e5920b010e96134ed91d852d81d490e787beca7e35ae1d89cf7 \ + --hash=sha256:30bc11310e8153ca664b14c5f1b73e94bd0503681fcf136a163de856f3a50139 \ + --hash=sha256:3101e5177d114a593d79dd79658650fe28b5a0d8abeb8ce6f437c0e6df5be1a4 \ + --hash=sha256:396084a36abdb603546b119d96528c2f6263921c50df3c8fd7cb28873a237748 \ + --hash=sha256:3997b5b3c9a771e157f9aae01dd579ee35ad7109be18db0e85dbdbe1de06e952 \ + --hash=sha256:414802f3b97f3c1eef41e530aaba3b3c1620649871d8cb38c6eaff034c2e16bd \ + --hash=sha256:51c1e14eb1e154ebd80e860722f9e6ed6ec89714ad2db2d3aa33c31d7c12179b \ + --hash=sha256:51c55fe3451421f3a6ef9a9c1439e82101c57a2c9eab9feb196a62b1a10b58ce \ + --hash=sha256:5ee6609ac3604fa7780e30a03e5e241a7956f8e2fcfe547d51e3afa5247ac47f \ + --hash=sha256:612a95a17655e213502f60cfb9bf9408efdc9eb1d5f50535cc6eb365d11b42b5 \ + --hash=sha256:6203fdf9f3dc5bdaed7319ad8698e685c7a3be10819f41d32a0723e611733b42 \ + --hash=sha256:63c0e9e7eea69588479ebf4a8a270d5ac22763cc5854e9a7eae952a3908103f7 \ + --hash=sha256:66f85ce62c70b843bab1fb14a05d5737741e74e28c7b8b5a064de10142fad248 \ + --hash=sha256:6cf9b429b21df6b99f4dee7a1218b8b7ffbbe7df8764dc0bd60ce8a0708fed1e \ + --hash=sha256:70b37199913c1bd300ff6e2693316c6f869c7ee16378faf10e4f5e3275b299c3 \ + --hash=sha256:727fd05b57df37dc0bcf1a27767a3d9a78cbbc92822445f32cc3436ba797337b \ + --hash=sha256:74ae7b798248fe62021dbf3c914245ad45d1a6b0cb4a29ecb4b31d0bfbc4cc3e \ + --hash=sha256:784db1dcdab56bf0517743e746dfb0f885fc68d948aba86eeec2cba234bdf1c0 \ + --hash=sha256:86945f2ee6d10cdfd67bcb4069c1662dd711f7e2a4343db5cecec06b87cf31aa \ + --hash=sha256:86d835afea1eaa143012a2d7a3f45a3adce2d7adc8b4961f0b362214d800846a \ + --hash=sha256:872a5cf366aec6bb1147336480fef14c9164b154aeb6542327de4970282cd2f5 \ + --hash=sha256:8b973c57ff8e184109db042c842423ff4f60446239bd585a5131cc47f06f789d \ + --hash=sha256:8cba086a43d54ca804ce711b2a940b16e452807acebe7852ff327f1ecd49b0d4 \ + --hash=sha256:8f7f0e05112916223d3f438f293abf0727e1181b5983f413dfa2fefc4098245c \ + --hash=sha256:900218e456384ea676e24ea6a0417f030a3b07306d29d7ad843957b40a9d8d52 \ + --hash=sha256:93eebbcf1aafdf7e2ddd44c2923e2672e1010bddc014138b229e49725b4d6be5 \ + --hash=sha256:9c75442b2209b8470d6d5d8b1c25714270686f14c749028d2199c54e29f20b4d \ + --hash=sha256:9ee2197ef8c4f0dfe405d835f3b6a14f5fee7782b5de51ba06fb65fc9b36e9f1 \ + --hash=sha256:a414504bef8945eae5f2d7cb7be2d4af77c5d1cb5e20b296c2c25b61dff2900c \ + --hash=sha256:a4b9159734b326535f4dd01d947f919c6eefd2d9827466a696c44ced82dfbc18 \ + --hash=sha256:a80afd79f45f3c4a7d341f13acbe058d1ca8ac017c165d3fa0d3de6bc1a079d7 \ + --hash=sha256:aa5bc7c5d59d831d9773d1170acac7893ce3a5e130540605770ade83280e7188 \ + --hash=sha256:acfd89508504a19ed06ef963ad544ec6664518c863436306153e13e94605c218 \ + --hash=sha256:aeffcab3d4b43712bb7a60b65f6044d444e75e563ff6180af8f98dd4b905dfd2 \ + --hash=sha256:afaffc4393205524af9dfa400fa250143a6c3bc646c08c9f5e25a9f4b4d6a903 \ + --hash=sha256:b0c7088a73aef3d687c4deef8452a3ac7c1be4e29ed8bf3b366c8111128ac60c \ + --hash=sha256:b46b4ec24f7293f23adcd2d146960559aaf8020213de8ad1909dba6c013bf89c \ + --hash=sha256:b501b5fa195cc9e24fe102f21ec0a44dffc231d2af79950b451e0d99cea02234 \ + --hash=sha256:bf06bc2af43fa8d32d30fae16ad965663e966b1a3202ed407b84c989c3221e82 \ + --hash=sha256:c804e3a5aba5460c73955c955bdbd5c08c354954e9270a2c1565f62e866bdc39 \ + --hash=sha256:c8a9958e88b65c3b27e22ca2a076311636850b612d6bbfb76e8d156aacde2aaf \ + --hash=sha256:cc0a57f895b96ec78969c34f682c602bf8da1a0270b09bc65673df2e7638ec20 \ + --hash=sha256:cc8920d2ec5fa99875b670bb86ddeb21e295cb07aa331810d9e486e0b969d946 \ + --hash=sha256:ccc933afd4d20aad3c00bcef049cb40049f7f196e0397f1109dba6fed63267b0 \ + --hash=sha256:ce581db493ea1a96c0556360ede6607496e8bf9b3a8efa66e06477267bc831e9 \ + --hash=sha256:d0f23b44f57077c1ede8c5f26b30f706498b4862d3ff0a7298b8411dd2f043ff \ + --hash=sha256:d21644de1b609825ede2f48be98dfde4656aefc713654eeee280e37cadc4e0ad \ + --hash=sha256:d6889ec4ec662a1a37eb4b4fb26b6100841804dac55bd9df579e326cdc146227 \ + --hash=sha256:de5672f4a7b200c15a4127042170a694d4df43c992948f5e1af57f0174beed10 \ + --hash=sha256:e6a0bc88393d65807d751a614207b7129a310ca4fe76a74e5c7da5fa5671417e \ + --hash=sha256:ed89927b86296067b4f81f108a2271d8926467a8868e554eaf370fc27fa3ccaf \ + --hash=sha256:ee3888d9ff7c14604052b2ca5535a30216aa0a58e948cdd3eeb8d3415f638769 \ + --hash=sha256:f0963b55cdd70fad460fa4c1341f12f976bb26cb66021a5580329bd498988310 \ + --hash=sha256:f16417ec91f12f814b10bafe79ef77e70113a2f5f7018640e7425ff979253425 \ + --hash=sha256:f28620fe26bee16243be2b7b874da327312240a7cdc38b769a697578d2100013 \ + --hash=sha256:f4255143f5160d0de972d28c8f9665d882b5f61309d8362fdd3e103cf7bf010c \ + --hash=sha256:ffac52f28a7849ad7576293c0cb7b9f08304e8f7d738a8cb8a90ec4c55a998eb \ + --hash=sha256:ffe22d2b05504f786c867c8395de703937f934272eb67586817b46188b4ded6d \ + --hash=sha256:fffe29a1ef00883599d1dc2c51aa2e5d80afe49523c261a74933df395c15c520 + # via + # openbeat (pyproject.toml) + # librosa + # numba + # scikit-learn + # scipy + # soundfile + # soxr +numpy==2.4.6 ; (python_full_version < '3.12' and platform_machine != 'x86_64') or (python_full_version < '3.12' and sys_platform != 'darwin') \ --hash=sha256:001fbb8e08d942dd57599e781f2472269ee7f2755fae407b4f67b2f0b17da3f1 \ --hash=sha256:0280e0356c0829a18d9de1cb7eee50ec22ca639878d7240307ca0943d73cd2c4 \ --hash=sha256:043191bfa8eab18c776647b62723ac9dddece59743b13f49b2016094129c2b3f \ @@ -574,7 +707,7 @@ numpy==2.4.6 ; python_full_version < '3.12' \ # scipy # soundfile # soxr -numpy==2.5.4 ; python_full_version >= '3.12' \ +numpy==2.5.4 ; (python_full_version >= '3.12' and platform_machine != 'x86_64') or (python_full_version >= '3.12' and sys_platform != 'darwin') \ --hash=sha256:067374eb538c34c745436365cf7b0112595c1d326f21ce4ff340f61230239fbb \ --hash=sha256:0b4724a19de67bea8cfc4970798efa78bcbbe2ac2613cfac16721a42d44de2a5 \ --hash=sha256:0f02a46e49cfb6c73bdb7aea1c0d3461dbae9aba613542b65f657cd3d17b9fab \ From df2386a536d9e5be451ea6eb32c56427a1c5327b Mon Sep 17 00:00:00 2001 From: eimexdev Date: Wed, 21 Oct 2026 22:23:39 -0700 Subject: [PATCH 3/3] Build every installer job from one resolved commit The test job resolves the requested ref once and the macOS runtime, package, and Windows jobs check out that commit, failing if they get another. A branch that moves during a manual run can no longer mix commits in one package. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/build-installers.yml | 31 ++++++++++++++++++++++---- tests/test_release_build.py | 8 +++++-- 2 files changed, 33 insertions(+), 6 deletions(-) diff --git a/.github/workflows/build-installers.yml b/.github/workflows/build-installers.yml index d385abc..8f8cdde 100644 --- a/.github/workflows/build-installers.yml +++ b/.github/workflows/build-installers.yml @@ -35,6 +35,10 @@ jobs: test: name: Test runs-on: ubuntu-latest + # The requested ref is resolved to one commit here, and every build job checks out + # that commit, so a branch that moves mid-run cannot mix commits in one package. + outputs: + commit: ${{ steps.commit.outputs.sha }} steps: - name: Checkout @@ -42,6 +46,10 @@ jobs: with: ref: ${{ inputs.git_ref || github.sha }} + - name: Resolve commit + id: commit + run: echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" + - name: Setup Python uses: actions/setup-python@v6 with: @@ -82,7 +90,7 @@ jobs: - name: Checkout uses: actions/checkout@v6 with: - ref: ${{ inputs.git_ref || github.sha }} + ref: ${{ needs.test.outputs.commit }} - name: Setup Python uses: actions/setup-python@v6 @@ -100,8 +108,13 @@ jobs: env: BUILT: macOS ${{ matrix.arch }} runtime REQUESTED_REF: ${{ inputs.git_ref }} + RESOLVED_COMMIT: ${{ needs.test.outputs.commit }} run: | commit="$(git rev-parse HEAD)" + if [[ "$commit" != "$RESOLVED_COMMIT" ]]; then + echo "::error::Checked out $commit instead of the resolved commit $RESOLVED_COMMIT." + exit 1 + fi if [[ "$REQUESTED_REF" =~ ^[0-9a-f]{40}$ && "$REQUESTED_REF" != "$commit" ]]; then echo "::error::Checked out $commit instead of the requested commit $REQUESTED_REF." exit 1 @@ -137,14 +150,14 @@ jobs: macos_package: name: Build macos installer - needs: macos_runtime + needs: [test, macos_runtime] runs-on: macos-15 steps: - name: Checkout uses: actions/checkout@v6 with: - ref: ${{ inputs.git_ref || github.sha }} + ref: ${{ needs.test.outputs.commit }} - name: Setup Python uses: actions/setup-python@v6 @@ -162,8 +175,13 @@ jobs: env: BUILT: macOS installer REQUESTED_REF: ${{ inputs.git_ref }} + RESOLVED_COMMIT: ${{ needs.test.outputs.commit }} run: | commit="$(git rev-parse HEAD)" + if [[ "$commit" != "$RESOLVED_COMMIT" ]]; then + echo "::error::Checked out $commit instead of the resolved commit $RESOLVED_COMMIT." + exit 1 + fi if [[ "$REQUESTED_REF" =~ ^[0-9a-f]{40}$ && "$REQUESTED_REF" != "$commit" ]]; then echo "::error::Checked out $commit instead of the requested commit $REQUESTED_REF." exit 1 @@ -230,7 +248,7 @@ jobs: - name: Checkout uses: actions/checkout@v6 with: - ref: ${{ inputs.git_ref || github.sha }} + ref: ${{ needs.test.outputs.commit }} - name: Setup Python uses: actions/setup-python@v6 @@ -248,8 +266,13 @@ jobs: env: BUILT: Windows installer REQUESTED_REF: ${{ inputs.git_ref }} + RESOLVED_COMMIT: ${{ needs.test.outputs.commit }} run: | commit="$(git rev-parse HEAD)" + if [[ "$commit" != "$RESOLVED_COMMIT" ]]; then + echo "::error::Checked out $commit instead of the resolved commit $RESOLVED_COMMIT." + exit 1 + fi if [[ "$REQUESTED_REF" =~ ^[0-9a-f]{40}$ && "$REQUESTED_REF" != "$commit" ]]; then echo "::error::Checked out $commit instead of the requested commit $REQUESTED_REF." exit 1 diff --git a/tests/test_release_build.py b/tests/test_release_build.py index 9a43fc1..9ce83de 100644 --- a/tests/test_release_build.py +++ b/tests/test_release_build.py @@ -27,7 +27,11 @@ def test_build_jobs_check_out_the_requested_commit(self) -> None: refs = re.findall(r"^\s*ref: (.+)$", build, re.MULTILINE) jobs = re.findall(r"^ (\w+):$", build.split("\njobs:\n", 1)[1], re.MULTILINE) self.assertEqual(jobs, ["test", "macos_runtime", "macos_package", "windows"]) - self.assertEqual(refs, ["${{ inputs.git_ref || github.sha }}"] * len(jobs)) + # The test job resolves the requested ref once; the build jobs check out that commit. + self.assertEqual(refs, ["${{ inputs.git_ref || github.sha }}"] + ["${{ needs.test.outputs.commit }}"] * 3) + self.assertIn("commit: ${{ steps.commit.outputs.sha }}", job(build, "test")) + for name in ("macos_runtime", "macos_package", "windows"): + self.assertIn('if [[ "$commit" != "$RESOLVED_COMMIT" ]]', job(build, name)) def test_build_jobs_install_only_pinned_dependencies(self) -> None: build = (WORKFLOWS / "build-installers.yml").read_text() @@ -61,7 +65,7 @@ def test_each_runtime_is_built_and_tested_on_its_own_architecture(self) -> None: def test_one_package_carries_both_runtimes(self) -> None: package = job(self.build, "macos_package") - self.assertIn("needs: macos_runtime", package) + self.assertIn("needs: [test, macos_runtime]", package) self.assertIn("name: Build macos installer", package) self.assertIn("pattern: openbeat-macos-runtime-*", package) for arch in ("arm64", "x86_64"):