Files
qbz/.github/workflows/release-macos.yml
T
vicrodh 21e81a4b42 fix(ci): reject early exits in packaged Qt startup smokes
Observe process survival through the full smoke window, isolate profiles and the session bus, restore hidden SDKs and reap private process groups. Reuse the Qt log gate and retain smoke evidence on failures. Add subprocess regressions to the test-crates gate.

cx
2026-09-06 19:24:15 -06:00

342 lines
15 KiB
YAML

# macOS release — Qt frontend (crates/ workspace, package `qbz-qt`, binary `qbz`).
#
# Rewritten 2026-08-25 for the Slint -> Qt frontend switch. Recipe measured on
# the Mac mini M2 (qbz-nix-docs/cicd/2026-08-25-qt-workflows-contracts/ V5b):
# * Qt from aqt (official, UNIVERSAL frameworks, min macOS 12) — NOT Homebrew:
# Homebrew splits Qt across formulae and macdeployqt cannot resolve
# @rpath/QtQml.framework in that layout (600 rpath errors, the .app dies
# with "no Qt platform plugin"). Homebrew bottles also target the runner's
# own macOS and would silently raise LSMinimumSystemVersion.
# * `macdeployqt -qmldir` is MANDATORY: a Qt binary is not self-contained.
# The Slint workflow copied the bare Mach-O into Contents/MacOS; a Qt
# .app assembled that way dies on launch on every machine that is not
# the build box.
# * CXXFLAGS=-include arm_acle.h (arm64 only): Qt 6.9.x's qyieldcpu.h calls
# __yield() without <arm_acle.h>; Apple clang 21 makes it a hard error.
# Harmless where the compiler does not care. Drop when the pin has the fix.
# * MACOSX_DEPLOYMENT_TARGET=12.0 -> `minos 12.0` in the Mach-O (rustc's
# default is 11.0, cc-rs otherwise picks the host SDK).
# * The smoke gate runs the BUNDLE offscreen with the Qt install removed.
#
# macOS pins 6.9 (not the Linux 6.8 LTS): the traffic-lights fix uses
# Window paddings + SafeArea (Qt 6.9), and Qt is always bundled here so a
# higher floor costs no reach. `vars.QT_VERSION_MACOS` overrides.
name: release-macos
on:
workflow_dispatch:
inputs:
tag:
description: "Optional release tag to build (e.g. v2.1.0). Empty = build HEAD with the Cargo.toml version, no Release publish."
required: false
type: string
push:
tags:
- "v*"
concurrency:
group: release-macos-${{ github.ref }}
cancel-in-progress: false
env:
QT_VERSION: ${{ vars.QT_VERSION_MACOS || '6.9.3' }}
jobs:
build-macos:
strategy:
fail-fast: false
matrix:
include:
- target: aarch64-apple-darwin
runner: macos-latest
artifact: qbz-macos-aarch64
arch: aarch64
# See header: Qt 6.9 header bug on Apple clang 21 (arm64 code path).
cxxflags: "-include arm_acle.h"
- target: x86_64-apple-darwin
# macos-latest is arm64; Intel needs an Intel runner (arm_acle.h
# is ARM-only, so no CXXFLAGS here). macos-26-intel so both legs
# carry Xcode 26's actool for the Icon Composer app icon.
runner: macos-26-intel
artifact: qbz-macos-x86_64
# Historical dmg naming: intel assets ship as "x64".
arch: x64
cxxflags: ""
runs-on: ${{ matrix.runner }}
timeout-minutes: 120
permissions:
contents: write
steps:
- name: Checkout
uses: actions/checkout@v7
with:
ref: ${{ inputs.tag || github.ref }}
fetch-depth: 0
- name: Resolve release version
id: release
run: |
VERSION=""
if [[ "${GITHUB_REF}" == refs/tags/v* ]]; then
VERSION="${GITHUB_REF#refs/tags/v}"
elif [[ -n "${{ inputs.tag }}" ]]; then
VERSION="${{ inputs.tag }}"
VERSION="${VERSION#v}"
else
VERSION="$(grep -m1 '^version = ' crates/Cargo.toml | sed 's/version = \"\(.*\)\"/\1/')"
fi
PRERELEASE=false
if [[ "$VERSION" =~ -(rc|beta|alpha|pre) ]]; then
PRERELEASE=true
fi
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
echo "prerelease=$PRERELEASE" >> "$GITHUB_OUTPUT"
- name: Verify tag is on main
if: startsWith(github.ref, 'refs/tags/')
run: |
git fetch origin main
if ! git merge-base --is-ancestor "$GITHUB_SHA" origin/main; then
echo "::error::Tag ${GITHUB_REF#refs/tags/} is not on main. Releases are cut from main only."
exit 1
fi
- name: Assert Cargo.toml version matches tag
if: startsWith(github.ref, 'refs/tags/') || inputs.tag != ''
env:
VERSION: ${{ steps.release.outputs.version }}
run: |
CARGO_VERSION="$(grep -m1 '^version = ' crates/Cargo.toml | sed 's/version = \"\(.*\)\"/\1/')"
if [[ "$CARGO_VERSION" != "$VERSION" ]]; then
echo "::error::crates/Cargo.toml workspace version ($CARGO_VERSION) != tag version ($VERSION)."
exit 1
fi
# Without qtshadertools: the committed .qsb ship (see build-qt-linux.yml).
- name: Install Qt ${{ env.QT_VERSION }} (universal)
uses: jurplel/install-qt-action@v4
with:
version: ${{ env.QT_VERSION }}
host: mac
target: desktop
arch: clang_64
cache: true
- name: Setup Rust (stable)
uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
- name: Rust cache
uses: Swatinem/rust-cache@v2
with:
workspaces: crates -> target
shared-key: qt-macos-${{ matrix.target }}-${{ env.QT_VERSION }}
cache-on-failure: true
save-if: ${{ github.ref == 'refs/heads/main' || github.ref == 'refs/heads/pre-release' }}
# NEVER `-p qbz` (the frozen Slint binary shares the bin name).
- name: Build (release)
env:
CARGO_INCREMENTAL: "0"
CMAKE_PREFIX_PATH: ${{ env.QT_ROOT_DIR }}
QMAKE: ${{ env.QT_ROOT_DIR }}/bin/qmake
CXXFLAGS: ${{ matrix.cxxflags }}
MACOSX_DEPLOYMENT_TARGET: "12.0"
run: |
python3 scripts/qt-cargo.py build --release --manifest-path crates/Cargo.toml \
-p qbz-qt --target ${{ matrix.target }}
otool -l "crates/target/${{ matrix.target }}/release/qbz" | grep -A4 LC_BUILD_VERSION | grep minos
- name: Slint-free dep-graph gate (qbz-qt)
run: |
set -euo pipefail
hits=$(cargo tree --manifest-path crates/Cargo.toml -p qbz-qt -e normal \
| grep -E '\b(slint|qbz-ui|qbz-slint-common|qbz-dac-wizard) v' || true)
[ -z "$hits" ] || { echo "::error::qbz-qt graph resolves Slint crates:"; echo "$hits"; exit 1; }
- name: Assemble QBZ.app
env:
VERSION: ${{ steps.release.outputs.version }}
run: |
set -euo pipefail
APP="dist/QBZ.app"
mkdir -p "$APP/Contents/MacOS" "$APP/Contents/Resources"
cp "crates/target/${{ matrix.target }}/release/qbz" "$APP/Contents/MacOS/qbz"
chmod +x "$APP/Contents/MacOS/qbz"
cp packaging/icons/icon.icns "$APP/Contents/Resources/icon.icns"
cp LICENSE "$APP/Contents/Resources/LICENSE.txt"
cp -r licenses "$APP/Contents/Resources/licenses"
# The Liquid Glass app icon (packaging/icons/QBZ.icon, Icon Composer
# source, issue #712): compiled by Xcode 26's actool into
# Assets.car + CFBundleIconName=QBZ. macOS 26 renders the layered
# icon; older macOS and a runner without Xcode 26 fall back to
# icon.icns via CFBundleIconFile, which is always written.
ICON_NAME_KEY=""
if xcrun --find actool >/dev/null 2>&1 \
&& [[ "$(xcodebuild -version | head -1 | grep -oE '[0-9]+' | head -1)" -ge 26 ]]; then
xcrun actool packaging/icons/QBZ.icon \
--compile "$APP/Contents/Resources" \
--app-icon QBZ \
--platform macosx --minimum-deployment-target 12.0 \
--include-all-app-icons \
--output-partial-info-plist icon-partial.plist \
--errors --warnings --notices 2>&1 | tee actool.log
test -f "$APP/Contents/Resources/Assets.car" \
|| { echo "::error::actool produced no Assets.car"; exit 1; }
/usr/libexec/PlistBuddy -c 'Print :CFBundleIconName' icon-partial.plist \
|| { echo "::error::actool partial plist has no CFBundleIconName"; exit 1; }
ICON_NAME_KEY='<key>CFBundleIconName</key><string>QBZ</string>'
echo "app icon: Icon Composer source compiled (Assets.car)"
else
echo "::warning::Xcode 26 actool not available — shipping icon.icns only"
fi
cat > "$APP/Contents/Info.plist" <<PLIST
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundlePackageType</key><string>APPL</string>
<key>CFBundleIdentifier</key><string>com.blitzfc.qbz</string>
<key>CFBundleName</key><string>QBZ</string>
<key>CFBundleDisplayName</key><string>QBZ</string>
<key>CFBundleExecutable</key><string>qbz</string>
<key>CFBundleIconFile</key><string>icon</string>
${ICON_NAME_KEY}
<key>CFBundleShortVersionString</key><string>${VERSION}</string>
<key>CFBundleVersion</key><string>${VERSION}</string>
<key>LSMinimumSystemVersion</key><string>12.0</string>
<key>NSHighResolutionCapable</key><true/>
<key>LSApplicationCategoryType</key><string>public.app-category.music</string>
<key>NSSupportsAutomaticGraphicsSwitching</key><true/>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLName</key><string>com.blitzfc.qbz</string>
<key>CFBundleURLSchemes</key>
<array><string>qbz</string><string>qobuzapp</string></array>
</dict>
</array>
</dict>
</plist>
PLIST
# Bundle the Qt frameworks, the cocoa platform plugin and the QML
# modules the app imports (Effects, labs.qmlmodels, Controls, ...).
# Measured: 21 s, ~225 MB. The optional sqldrivers plugins (iodbc,
# mimer, libpq) are the only deploy "errors" and nothing uses Qt SQL.
- name: macdeployqt
run: |
set -euo pipefail
APP="dist/QBZ.app"
# Preserve the deployer's exit status. The old `... | grep ERROR ||
# true` made every macdeployqt failure look successful because the
# final `true` swallowed the whole pipeline's status.
set +e
"$QT_ROOT_DIR/bin/macdeployqt" "$APP" -qmldir=crates/qbz-qt/qml -verbose=1 2>&1 | tee macdeploy.log
deploy_status=${PIPESTATUS[0]}
set -e
grep -E 'ERROR' macdeploy.log || true
if (( deploy_status != 0 )); then
echo "::error::macdeployqt failed with exit code $deploy_status"
exit "$deploy_status"
fi
rm -rf "$APP/Contents/PlugIns/sqldrivers"
test -f "$APP/Contents/PlugIns/platforms/libqcocoa.dylib"
test -d "$APP/Contents/Resources/qml/QtQuick/Effects"
test -f "$APP/Contents/Resources/qt.conf"
# Every Qt reference must now be bundle-relative. Qt 6.9's
# macdeployqt leaves the install names as @rpath/Qt*.framework and
# resolves them through an LC_RPATH of @executable_path/../Frameworks
# (measured 2026-08-29, macos-26 runner) — so the assert is: no
# ABSOLUTE Qt path survives, and that rpath is present. The offscreen
# smoke below (Qt install hidden) is the end-to-end proof.
if otool -L "$APP/Contents/MacOS/qbz" | grep -E 'Qt.*\.framework' | grep -Eq '^\s*/'; then
otool -L "$APP/Contents/MacOS/qbz"; echo "::error::binary still links Qt by absolute path outside the bundle"; exit 1
fi
if ! otool -l "$APP/Contents/MacOS/qbz" | grep -A2 LC_RPATH | grep -Eq '@(executable|loader)_path/\.\./Frameworks'; then
otool -l "$APP/Contents/MacOS/qbz" | grep -A2 LC_RPATH; echo "::error::no bundle-relative LC_RPATH for the Qt frameworks"; exit 1
fi
# offscreen platform plugin: only for the smoke below (macdeployqt
# deploys cocoa alone). Removed again before signing/packing.
cp "$QT_ROOT_DIR/plugins/platforms/libqoffscreen.dylib" "$APP/Contents/PlugIns/platforms/"
du -sh "$APP"
# The bundle must initialize AND survive without the Qt SDK. The helper
# uses a private native macOS home (XDG alone is ineffective), restores
# the SDK on failure/cancellation and reaps the observed process group.
- name: Offscreen smoke (the bundle that ships)
run: |
set -euo pipefail
APP="dist/QBZ.app"
python3 scripts/packaging/smoke_release.py "$APP/Contents/MacOS/qbz" \
--hide-qt "$QT_ROOT_DIR" --seconds 45 --log smoke.log
rm -f "$APP/Contents/PlugIns/platforms/libqoffscreen.dylib"
- name: Upload deployed startup evidence
if: always()
uses: actions/upload-artifact@v7
with:
name: qbz-macos-smoke-${{ matrix.arch }}
path: smoke.log*
if-no-files-found: ignore
retention-days: 14
# Ad-hoc signature: required for arm64 Macs to launch at all. AFTER
# macdeployqt (it rewrites install names) and after the smoke plugin
# is gone, so the seal matches what ships.
- name: Sign
run: |
codesign --force --deep -s - "dist/QBZ.app"
codesign --verify --verbose "dist/QBZ.app"
- name: Build dmg
env:
VERSION: ${{ steps.release.outputs.version }}
ARCH: ${{ matrix.arch }}
run: |
hdiutil create -volname "QBZ" -srcfolder dist/QBZ.app -ov \
-format UDZO "dist/QBZ_${VERSION}_${ARCH}.dmg"
ls -la dist
# Tauri-updater bridge: v1.2.x installs poll latest.json and verify
# with the Tauri minisign pubkey baked into the old binaries. Keep
# producing QBZ_${ARCH}.app.tar.gz signed with the SAME key so old
# installs update straight into this .app.
- name: Package .app.tar.gz + sign (Tauri-updater bridge)
if: startsWith(github.ref, 'refs/tags/')
env:
ARCH: ${{ matrix.arch }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
run: |
tar -czf "dist/QBZ_${ARCH}.app.tar.gz" -C dist QBZ.app
npx --yes @tauri-apps/cli@^2 signer sign \
--private-key "$TAURI_SIGNING_PRIVATE_KEY" \
--password "$TAURI_SIGNING_PRIVATE_KEY_PASSWORD" \
"dist/QBZ_${ARCH}.app.tar.gz"
ls -la dist
- name: Upload artifacts
uses: actions/upload-artifact@v7
with:
name: ${{ matrix.artifact }}
retention-days: ${{ startsWith(github.ref, 'refs/tags/v') && 90 || 7 }}
path: |
dist/*.dmg
dist/*.app.tar.gz
dist/*.app.tar.gz.sig
# Attaches to the Release created by release-linux.yml.
- name: Publish GitHub Release Assets
if: startsWith(github.ref, 'refs/tags/')
uses: softprops/action-gh-release@v3
with:
files: |
dist/*.dmg
dist/*.app.tar.gz
dist/*.app.tar.gz.sig
prerelease: ${{ steps.release.outputs.prerelease }}
make_latest: ${{ steps.release.outputs.prerelease == 'true' && 'false' || 'true' }}