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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
206 changes: 187 additions & 19 deletions .github/workflows/build-installers.yml
Original file line number Diff line number Diff line change
Expand Up @@ -35,13 +35,21 @@ 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
uses: actions/checkout@v6
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:
Expand All @@ -62,30 +70,27 @@ 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 }}
ref: ${{ needs.test.outputs.commit }}

- name: Setup Python
uses: actions/setup-python@v6
Expand All @@ -101,28 +106,191 @@ jobs:
- name: Record source commit
shell: bash
env:
PLATFORM: ${{ matrix.platform }}
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
Comment thread
eimexdev marked this conversation as resolved.
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: [test, macos_runtime]
runs-on: macos-15

steps:
- name: Checkout
uses: actions/checkout@v6
with:
ref: ${{ needs.test.outputs.commit }}

- 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 }}
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
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
uses: actions/checkout@v6
with:
ref: ${{ needs.test.outputs.commit }}

- 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: 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
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
26 changes: 22 additions & 4 deletions docs/development.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,17 +37,35 @@ 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.

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 <name>` 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 <name>` 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

Expand All @@ -59,15 +77,15 @@ 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.

## Release Workflow

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-<arch>` 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.
Expand Down
4 changes: 3 additions & 1 deletion docs/install.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-<version>.pkg`
- macOS: `OpenBeat-macos-<version>.pkg`, for both Apple silicon and Intel Macs
- Windows: `OpenBeat-windows-<version>-installer.exe`
3. Close DaVinci Resolve before running the installer.

Expand All @@ -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.
Expand Down
Loading
Loading