diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 000000000..da6ff1c87
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1,16 @@
+# Shaders: the committed .qsb are binary (NUL in the header) but also carry
+# raw CR bytes; a future `* text` would mangle them. Text formats are LF so a
+# Windows checkout with autocrlf cannot turn `2.1.0` into `2.1.0` in a
+# version grep, or CRLF the GLSL/QML sources.
+*.qsb binary
+*.frag text eol=lf
+*.vert text eol=lf
+*.qml text eol=lf
+*.toml text eol=lf
+*.po text eol=lf
+*.sh text eol=lf
+*.ico binary
+# Not in the original plan list, added deliberately: .rs is the largest text
+# set in the tree (639 files) and a CRLF checkout of it is the same class of
+# invisible breakage the .toml line exists to prevent.
+*.rs text eol=lf
diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml
index 08edabcc0..f46c34d9c 100644
--- a/.github/ISSUE_TEMPLATE/bug_report.yml
+++ b/.github/ISSUE_TEMPLATE/bug_report.yml
@@ -1,33 +1,34 @@
name: Bug report
-description: Report something that is not working as expected
-title: "[Bug] "
+description: Tell us about something that is not working as expected
labels: ["bug"]
body:
- type: markdown
attributes:
value: |
- ## ⚠️ Bug reports need logs
- Without logs I am debugging blind, and the report will almost certainly
- stall waiting on `needs: info` instead of getting fixed.
+ Thanks for reporting this. Please describe what happened and, if
+ possible, how to reproduce it. Logs help with most reports, but they
+ are not required when the issue is self-explanatory or unavailable.
- **Please attach your logs in the Logs section below** — it is the single
- most useful thing you can do to get this fixed fast
- (**Settings → Developer Mode → View Logs → Upload** the Terminal *and*
- Console tabs, then paste both URLs). If the app crashes before the UI
- opens, use the terminal fallback described in that section.
+ If you investigated the cause, please include what you found. Patches
+ are welcome, but never required.
- - type: checkboxes
- id: preflight
+ - type: dropdown
+ id: area
attributes:
- label: Before submitting
- description: A bug report without these is much harder to act on.
+ label: Area
+ description: Which part of QBZ is affected?
options:
- - label: I have attached my logs below (Terminal + Console), or explained why I cannot get them.
- required: true
- - label: I searched the existing issues and this is not a duplicate.
- required: true
- - label: I included concrete steps to reproduce.
- required: true
+ - Startup or crash
+ - Playback or audio
+ - Qobuz login or catalog
+ - Local or offline library
+ - Qobuz Connect or casting
+ - Interface or navigation
+ - qbzd or headless use
+ - Installation, update or packaging
+ - Other
+ validations:
+ required: true
- type: dropdown
id: platform
@@ -36,41 +37,29 @@ body:
options:
- Linux
- macOS
+ - Windows
+ - Other
validations:
required: true
- type: textarea
id: description
attributes:
- label: Description
- description: A clear and concise description of the problem.
+ label: What happened?
+ description: Tell us what you observed and what you expected instead.
+ placeholder: Describe the problem in your own words.
validations:
required: true
- type: textarea
id: steps
attributes:
- label: Steps to reproduce
- value: |
- 1.
- 2.
- 3.
- validations:
- required: true
-
- - type: textarea
- id: expected
- attributes:
- label: Expected behavior
- description: What you expected to happen.
- validations:
- required: true
-
- - type: textarea
- id: actual
- attributes:
- label: Actual behavior
- description: What actually happened.
+ label: How can we reproduce it?
+ description: If it is intermittent, describe what you were doing before it appeared.
+ placeholder: |
+ 1. Open...
+ 2. Select...
+ 3. Observe...
validations:
required: true
@@ -78,7 +67,8 @@ body:
id: version
attributes:
label: QBZ version
- placeholder: e.g. v1.2.0
+ description: Find it under About, or include the package version.
+ placeholder: e.g. 2.1.0
validations:
required: true
@@ -88,94 +78,51 @@ body:
label: Installation method
options:
- AppImage
- - .deb
- - .rpm
+ - APT repository or .deb
+ - RPM repository or .rpm
- AUR
- Gentoo overlay
- - Tarball
+ - Nix or NixOS
- Flatpak
- - DMG (macOS)
+ - Snap
+ - Homebrew Cask
+ - Signed macOS DMG
+ - Upstream macOS DMG
+ - Windows MSI or ZIP
+ - Standalone qbzd tarball
- Built from source
+ - Other
validations:
required: true
- - type: input
- id: distro
+ - type: textarea
+ id: environment
attributes:
- label: Distribution / macOS version
- description: Linux distro or macOS version.
- placeholder: e.g. Arch Linux, Ubuntu 24.04, Fedora 43, macOS 15.3 Sequoia
-
- - type: input
- id: de
- attributes:
- label: Desktop environment / WM
- description: Linux only.
- placeholder: e.g. KDE Plasma 6, GNOME 46
-
- - type: dropdown
- id: display
- attributes:
- label: Display server
- description: Linux only.
- options:
- - Wayland
- - X11
- - N/A (macOS)
-
- - type: input
- id: gpu
- attributes:
- label: GPU
- placeholder: e.g. NVIDIA RTX 3070, AMD RX 6600 XT, Apple M3
-
- - type: dropdown
- id: audio_backend
- attributes:
- label: Audio backend
- options:
- - PipeWire
- - ALSA Direct
- - PulseAudio
- - System Default (macOS)
- - Auto
-
- - type: input
- id: dac
- attributes:
- label: Audio device / DAC
- placeholder: e.g. Schiit Bifrost 2, FiiO K17
+ label: Environment details
+ description: Add only what seems relevant to this report.
+ placeholder: |
+ OS or distribution:
+ Desktop environment / display server:
+ Audio backend / DAC:
+ GPU:
- type: textarea
id: logs
attributes:
label: Logs
description: |
- **Sharing your logs when you open an issue helps me fix it much faster — please include them whenever you can.**
+ Logs make most bugs much faster to diagnose and often reveal whether
+ the cause is QBZ, packaging, configuration or the surrounding system.
- 1. Open **Settings > Developer Mode > View Logs**
- 2. Click **Upload** on both the **Terminal** and **Console** tabs
- 3. Paste both URLs here
-
- Where to find the logs (click the image to enlarge):
-
- [](https://raw.githubusercontent.com/vicrodh/qbz/main/docs/release-1.1.15/screenshots/log_access_for_bug_reporting.png)
-
- **If QBZ crashes on startup**, run from terminal:
- `flatpak run com.blitzfc.qbz 2>&1 | tee /tmp/qbz-crash.log`
- Then upload: `curl -F'file=@/tmp/qbz-crash.log' https://0x0.st`
- value: |
- - Terminal log URL:
- - Console log URL:
+ If they are relevant and available, open Settings > Developer Mode >
+ View Logs, upload both Terminal and Console, and paste both links here.
+ For startup failures, terminal output is enough.
+ placeholder: |
+ Terminal log URL:
+ Console log URL:
- type: textarea
- id: screenshots
+ id: attachments
attributes:
- label: Screenshots
- description: If applicable, add screenshots to help illustrate the issue.
-
- - type: textarea
- id: context
- attributes:
- label: Additional context
- description: Anything else that may help reproduce or understand the issue.
+ label: Screenshots or additional context
+ description: Drag in images or files, or add anything else that may help.
diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml
new file mode 100644
index 000000000..2817073b2
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/config.yml
@@ -0,0 +1,5 @@
+blank_issues_enabled: false
+contact_links:
+ - name: Questions and troubleshooting
+ url: https://github.com/vicrodh/qbz/discussions
+ about: Ask for help or discuss QBZ without opening a bug report.
diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml
index 29cd8c16c..9480d1559 100644
--- a/.github/ISSUE_TEMPLATE/feature_request.yml
+++ b/.github/ISSUE_TEMPLATE/feature_request.yml
@@ -1,6 +1,5 @@
name: Feature request
description: Suggest an idea or improvement
-title: "[Feature] "
labels: ["enhancement"]
body:
- type: dropdown
@@ -12,6 +11,7 @@ body:
- All platforms
- Linux
- macOS
+ - Windows
validations:
required: true
diff --git a/.github/ISSUE_TEMPLATE/feedback.yml b/.github/ISSUE_TEMPLATE/feedback.yml
index ee87ec3bc..e8d62d145 100644
--- a/.github/ISSUE_TEMPLATE/feedback.yml
+++ b/.github/ISSUE_TEMPLATE/feedback.yml
@@ -1,6 +1,5 @@
name: Feedback
description: General feedback, UX thoughts, or non-bug discussion
-title: "[Feedback] "
labels: ["feedback"]
body:
- type: dropdown
@@ -11,6 +10,7 @@ body:
- ""
- Linux
- macOS
+ - Windows
validations:
required: false
diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md
index e62b8809e..990770937 100644
--- a/.github/PULL_REQUEST_TEMPLATE.md
+++ b/.github/PULL_REQUEST_TEMPLATE.md
@@ -2,10 +2,40 @@
+## Context and approach
+
+
+
+## Validation
+
+
+
+## AI / LLM assistance
+
+
+
+- **Models and roles:**
+- **Human verification:**
+
## Checklist
-- [ ] My change targets the `crates/` workspace. The Svelte `src/` and Tauri
- `src-tauri/` trees were **removed in 2.0.2** (preserved only at git tag
- `legacy-tauri-svelte` for reference); PRs against those paths can't be
- merged. If your fix targets the old tree, open an issue instead and we'll
- help you port it.
+- [ ] This PR targets `pre-release`, not `main`.
+- [ ] I reviewed the complete diff and described how I verified the change.
+- [ ] My change targets the live Qt/Rust tree. The Slint crates and the deleted
+ Svelte/Tauri trees are historical references and cannot accept fixes.
+- [ ] If I changed UI text, I updated all eight locales.
diff --git a/.github/workflows/build-qt-linux.yml b/.github/workflows/build-qt-linux.yml
new file mode 100644
index 000000000..b030258d7
--- /dev/null
+++ b/.github/workflows/build-qt-linux.yml
@@ -0,0 +1,283 @@
+# Linux build of the Qt frontend — ONE recipe, reused by every Linux release
+# workflow (release-linux, release-linux-aarch64, release-snap, release-flatpak)
+# via `workflow_call`, and dispatchable on its own as the pre-flight ("does the
+# release recipe still build; publish nothing") — the job build-slint.yml used
+# to do. The 2026-07-04 lesson was two copies of the same recipe drifting apart;
+# this file exists so there is exactly one.
+#
+# WHAT CHANGED VS THE SLINT ERA (all measured 2026-08-25, see
+# qbz-nix-docs/cicd/2026-08-25-qt-workflows-contracts/):
+# * No memory wall. `-p qbz-qt` never pulls the 30 GB `qbz-ui` unit: no swap
+# ceremony, no CARGO_BUILD_JOBS=1 / codegen-units=256 / opt-level 2 (those
+# were memory tiers and cost runtime performance for nothing now), no
+# nightly, no mold, no RUSTFLAGS (any of them re-keys the whole cache).
+# * Qt comes from aqt (official binaries), NOT from apt: ubuntu-24.04 ships
+# Qt 6.4.2 and has no QtQuick.Effects module at all; the tree needs 6.8.
+# * The runner is ubuntu-22.04 (glibc 2.35 floor, asserted below), which is
+# also the platform the official Qt binaries are built for.
+# * Two outputs per arch: the BARE binary (AUR / Gentoo / Flathub — ecosystems
+# with their own Qt >= 6.8) and an AppDir with Qt bundled (deb / rpm /
+# AppImage / snap — so Ubuntu 24.04 keeps working). The AppDir is built by
+# linuxdeploy + linuxdeploy-plugin-qt and is self-contained (proven by
+# running it offscreen with the Qt install removed).
+# * The smoke gate runs against THE ARTIFACT THAT SHIPS (the AppDir), not the
+# build tree: offscreen boot, zero QML complaints, core initialized. A
+# `cargo check` sees nothing of the QML — a missing bridge member compiles
+# clean and throws when the user opens the view.
+#
+# CONTRACT: artifact names are consumed downstream by name. Do not rename.
+# qbz-linux- bare binary (stripped by the workspace profile)
+# qbz-linux--unstripped symbolication copy (CARGO_PROFILE_RELEASE_STRIP=none)
+# qbz-appdir- tar of the self-contained AppDir
+# is x86_64 | aarch64.
+
+name: build-qt-linux
+
+on:
+ workflow_call:
+ inputs:
+ arch:
+ description: "x86_64 | aarch64"
+ required: true
+ type: string
+ source_ref:
+ description: "Optional branch, tag or SHA to build"
+ required: false
+ default: ""
+ type: string
+ outputs:
+ version:
+ description: "Workspace version from crates/Cargo.toml"
+ value: ${{ jobs.build.outputs.version }}
+ workflow_dispatch:
+ inputs:
+ arch:
+ description: "Architecture to pre-flight"
+ required: true
+ default: x86_64
+ type: choice
+ options: [x86_64, aarch64]
+ source_ref:
+ description: "Optional branch, tag or SHA to build. Empty = dispatched ref."
+ required: false
+ type: string
+
+# The Qt pin. Repository variables win (set QT_VERSION_LINUX in Settings →
+# Variables to bump without a commit); the literal is the measured floor.
+# 6.8 is the LTS line and the floor the tree needs once the six macOS-only
+# SafeArea/padding lines are guarded (contracts §1 / §4 #0). Keep in sync
+# with the same pin in test-crates.yml.
+jobs:
+ build:
+ name: build (${{ inputs.arch }})
+ # aarch64 on 24.04-arm: aqt's arm64 Qt host tools (6.8.3 AND 6.9.3 qmake)
+ # SIGSEGV on ubuntu-22.04-arm with a clean ldd (2026-08-29, two runs).
+ # Accepted 2026-08-29: the arm64 DESKTOP floor is glibc 2.39; qbzd keeps
+ # 2.35 (Pi OS bookworm) — see the floor gate below.
+ runs-on: ${{ inputs.arch == 'aarch64' && 'ubuntu-24.04-arm' || 'ubuntu-22.04' }}
+ timeout-minutes: 120
+ env:
+ # x86_64 keeps the 6.8 LTS floor. aarch64 pins its own: aqt's 6.8.3
+ # linux_gcc_arm64 qmake SEGFAULTS on ubuntu-22.04-arm (2026-08-29 run,
+ # exit 139 on `qmake -query`), so the arm line takes 6.9.3 until an
+ # arm64 6.8.x that runs there exists. `vars.QT_VERSION_LINUX_ARM64`
+ # overrides without a commit.
+ QT_VERSION: ${{ inputs.arch == 'aarch64' && (vars.QT_VERSION_LINUX_ARM64 || '6.9.3') || (vars.QT_VERSION_LINUX || '6.8.3') }}
+ outputs:
+ version: ${{ steps.version.outputs.version }}
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v7
+ with:
+ ref: ${{ inputs.source_ref || github.ref }}
+
+ - name: Workspace version
+ id: version
+ run: |
+ V="$(grep -m1 '^version = ' crates/Cargo.toml | sed 's/version = \"\(.*\)\"/\1/')"
+ echo "version=$V" >> "$GITHUB_OUTPUT"
+ echo "workspace version: $V"
+
+ # Build-time deps only. fontconfig / freetype / wayland / xcb / mesa dev
+ # headers were Slint's direct needs; Qt links its own. The xcb/wayland
+ # RUNTIME libs are for linuxdeploy-plugin-qt: it resolves libqxcb.so's
+ # dependencies on the runner and dies on a missing libxkbcommon-x11.so.0
+ # (measured). patchelf + file are linuxdeploy's own tools.
+ - name: Install system dependencies
+ run: |
+ sudo apt-get update
+ sudo apt-get install -y --no-install-recommends \
+ build-essential pkg-config cmake clang libclang-dev nasm \
+ libasound2-dev libjack-jackd2-dev libdbus-1-dev libssl-dev \
+ libgl1-mesa-dev libegl1-mesa-dev libxkbcommon-dev libwayland-dev \
+ libxkbcommon-x11-0 libxcb-cursor0 libxcb-icccm4 libxcb-image0 \
+ libxcb-keysyms1 libxcb-render-util0 libxcb-xinerama0 libxcb-xkb1 \
+ libxcb-shape0 libxcb-randr0 libxcb-sync1 libxcb-xfixes0 libxcb-glx0 \
+ libwayland-client0 libwayland-egl1 libwayland-cursor0 libegl1 libgl1 \
+ patchelf file binutils dbus-daemon
+
+ # Official Qt binaries — WITHOUT qtshadertools: with qsb on PATH build.rs
+ # re-bakes shaders by mtime and a fresh checkout is nondeterministic.
+ # The build ships the committed .qsb (build.rs warns and keeps them);
+ # shader health is judged by test-crates.yml's shader-gate. The arm64
+ # host exists in aqt from 6.7 — the first aarch64 dispatch is the proof.
+ - name: Install Qt ${{ env.QT_VERSION }}
+ uses: jurplel/install-qt-action@v4
+ with:
+ version: ${{ env.QT_VERSION }}
+ host: ${{ inputs.arch == 'aarch64' && 'linux_arm64' || 'linux' }}
+ target: desktop
+ arch: ${{ inputs.arch == 'aarch64' && 'linux_gcc_arm64' || 'linux_gcc_64' }}
+ cache: true
+
+ - name: Setup Rust (stable)
+ uses: dtolnay/rust-toolchain@stable
+
+ # Cache key split by toolchain/profile and NEVER shared with the old
+ # `slint-*` keys. Trusted manual preflights on pre-release may seed it,
+ # so release-linux -> Flatpak/Snap and ARM -> Snap do not rebuild the
+ # identical graph from zero. PR/fork refs remain read-only.
+ - name: Rust cache
+ uses: Swatinem/rust-cache@v2
+ with:
+ workspaces: crates -> target
+ shared-key: qt-linux-${{ inputs.arch }}-${{ env.QT_VERSION }}
+ cache-on-failure: true
+ save-if: ${{ github.ref == 'refs/heads/main' || github.ref == 'refs/heads/pre-release' }}
+
+ # NEVER `-p qbz`: crates/qbz (the frozen Slint binary) declares the same
+ # bin name in the same target dir and would silently win the file.
+ - name: Build (release)
+ env:
+ CARGO_INCREMENTAL: "0"
+ # Keep symbols in this build: the unstripped copy is archived for
+ # crash symbolication, then stripped for every shipped artifact.
+ CARGO_PROFILE_RELEASE_STRIP: "none"
+ # EXPLICIT qmake, as the macOS/Windows recipes do. On the arm64
+ # runner cxx-qt-build failed with "Could not find Qt installation:
+ # QtMissing" although QT_ROOT_DIR was correct (2026-08-29 run) —
+ # the PATH lookup is not something to rely on across hosts.
+ QMAKE: ${{ env.QT_ROOT_DIR }}/bin/qmake
+ CMAKE_PREFIX_PATH: ${{ env.QT_ROOT_DIR }}
+ run: |
+ # Diagnostics first: a qmake that cannot run here is the arm64
+ # failure mode (segfault), and cxx-qt-build only says "QtMissing".
+ uname -a; getconf PAGESIZE; ldd --version | head -1
+ file "$QMAKE" || true
+ ldd "$QMAKE" | grep -E 'not found' || true
+ "$QT_ROOT_DIR/libexec/moc" --version || true
+ "$QT_ROOT_DIR/libexec/qmlcachegen" --version || true
+ "$QMAKE" -query QT_VERSION
+ python3 scripts/qt-cargo.py build --release --manifest-path crates/Cargo.toml -p qbz-qt
+ cp crates/target/release/qbz qbz-unstripped
+ strip -o qbz crates/target/release/qbz
+ ls -la qbz qbz-unstripped
+
+ - 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; }
+
+ # The empirical-verification rule: the README/website say what objdump
+ # says, not what the runner image advertises. Two floors, decided
+ # 2026-08-29: x86_64 2.35 (ubuntu-22.04 runner); aarch64 2.39 — the
+ # arm64 Qt host tools only run on ubuntu-24.04-arm — while qbzd stays at
+ # 2.35 on both (its own job, 22.04-arm) for the 1 GB Pi OS boards.
+ # README "Requires glibc" + the website glibcNote must match these.
+ - name: glibc floor gate (x86_64 <= 2.35, aarch64 <= 2.39)
+ run: |
+ set -euo pipefail
+ floor=$(objdump -T qbz | grep -oE 'GLIBC_[0-9]+\.[0-9]+' | sort -Vu | tail -1)
+ limit=$([ "${{ inputs.arch }}" = aarch64 ] && echo GLIBC_2.39 || echo GLIBC_2.35)
+ echo "glibc floor: $floor (limit $limit)"
+ highest=$(printf '%s\n' "$floor" "$limit" | sort -V | tail -1)
+ [ "$highest" = "$limit" ] || { echo "::error::glibc floor $floor exceeds $limit"; exit 1; }
+
+ # Self-contained AppDir. The plugin scans QML_SOURCES_PATHS for imports
+ # and copies exactly the Qt QML modules the app uses (Effects, labs,
+ # Controls, ...) plus the platform plugins — but xcb ONLY unless told
+ # otherwise: Wayland needs both EXTRA_PLATFORM_PLUGINS and
+ # EXTRA_QT_MODULES=waylandcompositor (libQt6WaylandClient + the
+ # wayland-* plugin dirs). offscreen is what the smoke below runs on.
+ # It writes usr/bin/qt.conf and patches RPATH to $ORIGIN/../lib, which
+ # is why the tree works from any install prefix with no env at all.
+ - name: Build AppDir (linuxdeploy + Qt plugin)
+ env:
+ QMAKE: ${{ env.QT_ROOT_DIR }}/bin/qmake
+ QML_SOURCES_PATHS: ${{ github.workspace }}/crates/qbz-qt/qml
+ EXTRA_PLATFORM_PLUGINS: "libqoffscreen.so;libqwayland-generic.so;libqwayland-egl.so"
+ EXTRA_QT_MODULES: waylandcompositor
+ LD_LIBRARY_PATH: ${{ env.QT_ROOT_DIR }}/lib
+ ARCH_TAG: ${{ inputs.arch }}
+ run: |
+ set -euo pipefail
+ curl -fsSL -o linuxdeploy \
+ "https://github.com/linuxdeploy/linuxdeploy/releases/download/continuous/linuxdeploy-${ARCH_TAG}.AppImage"
+ curl -fsSL -o linuxdeploy-plugin-qt \
+ "https://github.com/linuxdeploy/linuxdeploy-plugin-qt/releases/download/continuous/linuxdeploy-plugin-qt-${ARCH_TAG}.AppImage"
+ chmod +x linuxdeploy linuxdeploy-plugin-qt
+ # The desktop file goes in under the MPRIS DesktopEntry basename
+ # (com.blitzfc.qbz.desktop), so an integrated AppImage resolves the
+ # media-widget icon like the deb/rpm do. Icon=qbz -> qbz.png.
+ cp packaging/linux/qbz.desktop com.blitzfc.qbz.desktop
+ cp packaging/icons/256x256.png qbz.png
+ ./linuxdeploy --appimage-extract-and-run --appdir AppDir \
+ -e qbz -d com.blitzfc.qbz.desktop -i qbz.png --plugin qt
+ scripts/packaging/desktop_entry_check.sh --appdir AppDir
+ # Nothing in the app uses Qt SQL / network-information plugins.
+ rm -rf AppDir/usr/plugins/sqldrivers AppDir/usr/plugins/networkinformation
+ mkdir -p AppDir/usr/share/licenses/qbz
+ cp LICENSE AppDir/usr/share/licenses/qbz/LICENSE
+ cp -r licenses AppDir/usr/share/licenses/qbz/third-party
+ echo "platforms: $(find AppDir/usr/plugins/platforms -maxdepth 1 -type f -printf '%f ')"
+ test -f AppDir/usr/plugins/platforms/libqwayland-generic.so
+ test -f AppDir/usr/plugins/platforms/libqoffscreen.so
+ test -f AppDir/usr/qml/QtQuick/Effects/libeffectsplugin.so
+ test -f AppDir/usr/bin/qt.conf
+ test -f AppDir/usr/share/licenses/qbz/third-party/Apache-2.0-dst-decoder.txt
+ du -sh AppDir
+
+ # The shipped AppDir must initialize AND survive the observation window.
+ # Hide the SDK and use a fresh profile/private bus; early exit (even 0)
+ # fails. The helper reaps the launcher and its private process group.
+ - name: Offscreen smoke (the AppDir that ships)
+ run: |
+ python3 scripts/packaging/smoke_release.py ./AppDir/AppRun \
+ --hide-qt "$QT_ROOT_DIR" --seconds 60 --log smoke.log
+
+ - name: Upload deployed startup evidence
+ if: always()
+ uses: actions/upload-artifact@v7
+ with:
+ name: qbz-appdir-smoke-${{ inputs.arch }}
+ path: smoke.log*
+ if-no-files-found: ignore
+ retention-days: 14
+
+ - name: Upload bare binary
+ uses: actions/upload-artifact@v7
+ with:
+ name: qbz-linux-${{ inputs.arch }}
+ path: qbz
+ if-no-files-found: error
+ retention-days: ${{ startsWith(github.ref, 'refs/tags/v') && 90 || 7 }}
+
+ - name: Upload unstripped binary (symbolication)
+ uses: actions/upload-artifact@v7
+ with:
+ name: qbz-linux-${{ inputs.arch }}-unstripped
+ path: qbz-unstripped
+ retention-days: ${{ startsWith(github.ref, 'refs/tags/v') && 90 || 7 }}
+
+ # tar keeps the symlinks (AppRun, .DirIcon) and the exec bits that
+ # upload-artifact's zip would drop.
+ - name: Upload AppDir
+ run: tar -cf appdir.tar AppDir
+ - uses: actions/upload-artifact@v7
+ with:
+ name: qbz-appdir-${{ inputs.arch }}
+ path: appdir.tar
+ if-no-files-found: error
+ retention-days: ${{ startsWith(github.ref, 'refs/tags/v') && 90 || 7 }}
diff --git a/.github/workflows/build-slint-selfhosted.yml b/.github/workflows/build-slint-selfhosted.yml
deleted file mode 100644
index 045b1166a..000000000
--- a/.github/workflows/build-slint-selfhosted.yml
+++ /dev/null
@@ -1,61 +0,0 @@
-name: Build Slint (self-hosted)
-
-# Builds qbz-slint on the self-hosted runner registered on the dev box
-# (`qbz-box`, labels: self-hosted,linux,x64,qbz-builder). The box CAN compile
-# qbz-slint (proven locally), so this is the reliable path to a real CI/CD
-# artifact while the GitHub-hosted private runner (2 vCPU / 8 GB) cannot.
-#
-# Self-hosted runners keep their work directory between runs, so `target/`
-# persists → builds are INCREMENTAL after the first (no `actions/cache` needed).
-#
-# MANUAL trigger only (workflow_dispatch): the box shares RAM with the desktop
-# and earlyoom will SIGTERM the build if RAM runs low. Trigger this when the box
-# is free (close heavy apps / TTY), and do NOT run a local build at the same
-# time (one heavy build at a time — the box hard-freezes on double-build OOM).
-
-on:
- workflow_dispatch:
-
-concurrency:
- group: selfhosted-slint
- cancel-in-progress: true
-
-jobs:
- build:
- runs-on: [self-hosted, qbz-builder]
- timeout-minutes: 180
- steps:
- - uses: actions/checkout@v7
-
- - name: Free RAM check (earlyoom will kill the build if too low)
- run: |
- free -h | head -2
- avail=$(free -m | awk '/^Mem:/ {print $7}')
- echo "MemAvailable: ${avail} MB"
- if [ "${avail}" -lt 16000 ]; then
- echo "::warning::Only ${avail} MB free — earlyoom may SIGTERM the qbz-slint rustc. Close apps / drop to TTY for headroom."
- fi
-
- # Desktop-proof recipe, root-caused 2026-07-04 (ftrace): earlyoom's
- # RAM-only trigger SIGTERM'd rustc whenever global MemAvailable dipped
- # to ~3 GB — the cap margin, not the settings, was the bug. With
- # slint-run.sh anchoring memory.high at avail-9G, this exact recipe
- # (threads=2, cgu=256, opt=3, capped) completed with a 5.7 GB global
- # floor and zero signals while the desktop ran. Same flags as the
- # distribution profile → the runner cache stays warm between runs.
- - name: Build qbz-slint (desktop-proof — capped, threads=2, opt-3)
- run: NORUN=1 CAPPED=1 THREADS=2 CODEGEN_UNITS=256 OPT=3 ./scripts/slint-run.sh
-
- - name: Strip + report size
- run: |
- BIN=crates/target/release/qbz
- echo "unstripped:"; ls -lh "$BIN" | awk '{print $5}'
- cp "$BIN" qbz-linux-x86_64
- strip qbz-linux-x86_64 || true
- echo "stripped:"; ls -lh qbz-linux-x86_64 | awk '{print $5}'
-
- - uses: actions/upload-artifact@v7
- with:
- name: qbz-linux-x86_64
- path: qbz-linux-x86_64
- if-no-files-found: error
diff --git a/.github/workflows/build-slint.yml b/.github/workflows/build-slint.yml
deleted file mode 100644
index 25f147991..000000000
--- a/.github/workflows/build-slint.yml
+++ /dev/null
@@ -1,114 +0,0 @@
-name: Build Slint (linux)
-
-# Builds the Slint app on a stock GitHub-hosted runner. The wall is the qbz_ui
-# rustc (the generated Slint UI = ONE ~1.6M-line module): its peak memory DEMAND
-# is ~30 GB (measured RSS+Swap, 2026-06) and it is FRONTEND-bound — codegen knobs
-# (codegen-units, opt-level, CARGO_BUILD_JOBS, -Z threads) barely move it (jobs=1
-# only shaved ~3 GB vs cargo defaults). So the build is NOT made to fit by tuning;
-# it is made to fit by giving it enough RAM+SWAP to hold ~30 GB. A stock
-# `ubuntu-24.04` runner has ~16 GB RAM, so this job adds a large swapfile so
-# RAM(16) + swap(~4 base + 18) ≈ 38 GB comfortably exceeds the ~30 GB demand: the
-# resident working set stays near RAM size while cold pages page out (slow but it
-# completes). The MIN-tier rustc env below keeps the resident set as low as
-# possible. rust-cache (added below) makes this heavy compile happen ONCE per
-# qbz_ui change, not on every push — that, not the peak, is the minute saver.
-# Build-only for now (no signing/bundling).
-
-on:
- # Manual trigger ONLY (pick the ref in the Actions UI). Repo policy: public
- # workflows fire on release tags, never on plain pushes — a push to main IS
- # a release merge, and the tag that follows immediately fires the real
- # release builds, so an auto-build here would just double-burn ~2h of
- # runner time for nothing.
- workflow_dispatch:
-
-# A new push to slint-mvp cancels the older in-flight build — only the latest
-# commit matters, and stacked ~20-min builds are the main minute-burn.
-concurrency:
- group: build-slint-${{ github.ref }}
- cancel-in-progress: true
-
-jobs:
- build:
- runs-on: ubuntu-24.04
- timeout-minutes: 180
- steps:
- - uses: actions/checkout@v7
-
- - name: Free disk + swap
- run: |
- echo "::group::before"; df -h /; free -h; echo "::endgroup::"
- # On ubuntu-24.04 there is no separate large /mnt disk — everything is
- # on the 72 GB root (~16 GB free after the image). Reclaim the big
- # preinstalled toolchains we don't need (~25-30 GB) so the cargo target
- # fits, then add a modest swap cushion for the qbz-slint rustc peak.
- sudo rm -rf /usr/share/dotnet /opt/ghc /usr/local/lib/android \
- /opt/hostedtoolcache /usr/local/share/boost /usr/share/swift \
- /usr/local/lib/node_modules || true
- sudo docker image prune --all --force || true
- # The runner ships an active /swapfile (~4 GB); add a LARGE extra swap
- # so RAM(16) + swap(~4 + 18) ≈ 38 GB holds the measured ~30 GB qbz_ui
- # demand (frontend-bound; not tunable away). 8 GB was NOT enough
- # (16+4+8 ≈ 28 GB < 30 GB → OOM risk). Guarded so a swap hiccup never
- # fails the job.
- ( sudo fallocate -l 18G /swap_extra \
- && sudo chmod 600 /swap_extra \
- && sudo mkswap /swap_extra \
- && sudo swapon /swap_extra ) || echo "extra swap skipped (non-fatal)"
- echo "::group::after"; df -h /; free -h; echo "::endgroup::"
-
- - name: System dependencies
- run: |
- sudo apt-get update
- sudo apt-get install -y --no-install-recommends \
- build-essential pkg-config cmake clang libclang-dev mold nasm \
- libasound2-dev libjack-jackd2-dev \
- libfontconfig1-dev libfreetype-dev \
- libxkbcommon-dev libwayland-dev libxcb1-dev \
- libgl1-mesa-dev libegl1-mesa-dev \
- libdbus-1-dev libssl-dev \
- libharfbuzz-dev libjbig2dec0-dev libopenjp2-7-dev \
- libjpeg-dev zlib1g-dev libgumbo-dev
-
- - name: Rust toolchain (pinned dated nightly — build uses -Z threads)
- # Pinned (not floating @nightly) so rustc -V is byte-stable across runs:
- # rust-cache embeds the rustc version in its key, and a floating nightly
- # re-keys the cache + invalidates every Cargo fingerprint daily (the
- # from-scratch-every-run waste). Bump this date deliberately when a newer
- # nightly feature is actually needed.
- uses: dtolnay/rust-toolchain@master
- with:
- toolchain: nightly-2026-06-23
-
- - name: Cargo cache (Swatinem/rust-cache)
- # THE biggest minute-saver: caches ~/.cargo + crates/target so a push
- # that only touches controllers restores the prebuilt qbz-ui rlib (the
- # ~20 GB / ~20-min compilation unit) instead of recompiling it; only the
- # cheap qbz bin then rebuilds + links. `workspaces` MUST point at crates/
- # (manifest is crates/Cargo.toml, artifacts land in crates/target — not
- # the default `. -> target`). save-if gates cache WRITES to slint-mvp so
- # dispatch/PR runs read but don't churn the 10 GB Actions cache budget.
- uses: Swatinem/rust-cache@v2
- with:
- workspaces: crates -> target
- shared-key: slint-linux
- cache-on-failure: true
- save-if: ${{ github.ref == 'refs/heads/main' || github.ref == 'refs/heads/slint-mvp' }}
-
- - name: Build qbz-slint (memory-tuned, MIN tier)
- env:
- CARGO_INCREMENTAL: '0'
- CARGO_BUILD_JOBS: '1'
- CARGO_PROFILE_RELEASE_CODEGEN_UNITS: '256'
- CARGO_PROFILE_RELEASE_OPT_LEVEL: '2'
- RUSTFLAGS: '-C link-arg=-fuse-ld=mold -Z threads=1'
- run: |
- cargo build --release --manifest-path crates/Cargo.toml -p qbz
- echo "::group::result"; ls -lh crates/target/release/qbz; df -h /; free -h; echo "::endgroup::"
-
- - name: Upload binary
- uses: actions/upload-artifact@v7
- with:
- name: qbz-linux-x86_64
- path: crates/target/release/qbz
- if-no-files-found: error
diff --git a/.github/workflows/issue-platform-triage.yml b/.github/workflows/issue-platform-triage.yml
index 143da3d96..a2e2ff3e6 100644
--- a/.github/workflows/issue-platform-triage.yml
+++ b/.github/workflows/issue-platform-triage.yml
@@ -52,3 +52,49 @@ jobs:
issue_number: context.payload.issue.number,
labels: ['platform:linux']
});
+
+ - name: Welcome and label Windows reports
+ if: steps.platform.outputs.value == 'Windows'
+ uses: actions/github-script@v9
+ with:
+ script: |
+ const issue = context.payload.issue.number;
+ const label = 'platform:windows';
+
+ try {
+ await github.rest.issues.getLabel({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ name: label
+ });
+ } catch (error) {
+ if (error.status !== 404) throw error;
+ await github.rest.issues.createLabel({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ name: label,
+ color: '0078D4',
+ description: 'Issues specific to Windows'
+ });
+ }
+
+ await github.rest.issues.addLabels({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ issue_number: issue,
+ labels: [label]
+ });
+
+ await github.rest.issues.createComment({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ issue_number: issue,
+ body: [
+ '',
+ 'Thanks for reporting this on Windows — Windows reports are welcome.',
+ '',
+ 'The Windows build comes from the same shared QBZ codebase, so it receives the features and fixes developed for the project as a whole. At the moment, however, Windows does not have a dedicated maintainer or regular testing coverage. Windows-specific problems may therefore take longer to reproduce and resolve.',
+ '',
+ 'We are looking for someone who uses Windows regularly and would like to help test releases or maintain the platform. If that sounds like you, we would love to hear from you.'
+ ].join('\n')
+ });
diff --git a/.github/workflows/qt-gate-windows.yml b/.github/workflows/qt-gate-windows.yml
new file mode 100644
index 000000000..91cd85a9d
--- /dev/null
+++ b/.github/workflows/qt-gate-windows.yml
@@ -0,0 +1,212 @@
+# The Windows sibling of test-crates.yml's `qt-gate` job.
+#
+# Same five gates, same complaint regex, same "did it reach QbzCore" check, so
+# a Windows regression is caught by the same shape of evidence as a Linux one.
+#
+# ── TRIGGERS ──────────────────────────────────────────────────────────────
+#
+# The repository rule, and the same one every release-*.yml follows:
+# AUTOMATIC RUNS ON A TAG ONLY, and a manual dispatch is always available.
+# No branch pushes and no pull-request trigger.
+#
+# That also settles why this is not a job inside test-crates.yml, which runs on
+# every PR to pre-release: a Qt build on a Windows runner is roughly fifteen
+# minutes of a runner billing at twice the Linux rate, and Windows is
+# explicitly not a supported QBZ platform. Gating every Linux contributor's PR
+# on it would block work on a platform nobody promised.
+#
+# On a `v*` tag this runs beside release-windows.yml and answers a different
+# question: the release job proves the artifact BUILDS and boots, this one
+# proves the QML types resolve, the audits pass, the dependency graph is
+# Slint-free and the tests are green. A tag that fails here should not ship.
+#
+# ── WHAT THIS CATCHES THAT NOTHING ELSE DOES ──────────────────────────────
+#
+# `cargo check` sees nothing of QML: a wrong type name or a property that does
+# not exist compiles perfectly and fails at RUNTIME, and because the failing
+# component hangs off AppShell the whole shell fails to instantiate -- the app
+# opens to a GREY SCREEN. That happened during the port and reached the owner.
+# `qmllint` finds it in seconds. The shared qmllint_gate.sh now gives Linux and
+# Windows the same native-type metadata and import checks.
+name: qt-gate-windows
+
+on:
+ workflow_dispatch:
+ inputs:
+ ref:
+ description: "Optional ref to gate (branch, tag or SHA). Empty = the default checkout."
+ required: false
+ type: string
+ push:
+ tags:
+ - "v*"
+
+concurrency:
+ group: qt-gate-windows-${{ github.ref }}
+ cancel-in-progress: true
+
+permissions:
+ contents: read
+
+env:
+ # The WINDOWS pin, which is ahead of the Linux floor on purpose: the port
+ # needs Qt 6.9's ExpandedClientAreaHint for the custom title bar.
+ QT_VERSION: ${{ vars.QT_VERSION_WINDOWS || '6.9.3' }}
+ CARGO_TERM_COLOR: always
+
+jobs:
+ qt-gate-windows:
+ name: qt gate (windows)
+ runs-on: windows-2022
+ timeout-minutes: 90
+ steps:
+ # cxx-qt deep-copies include trees and qmlcachegen writes ~340 .cpp files
+ # under OUT_DIR; the paths get long enough to hit MAX_PATH without this.
+ - name: Enable long paths
+ shell: pwsh
+ run: |
+ Set-ItemProperty -Path 'HKLM:\SYSTEM\CurrentControlSet\Control\FileSystem' `
+ -Name LongPathsEnabled -Value 1
+ git config --system core.longpaths true
+
+ - name: Checkout
+ uses: actions/checkout@v7
+ with:
+ # Empty falls back to the default ref, which is what a tag push wants.
+ ref: ${{ inputs.ref || github.ref }}
+
+ - name: Install Qt ${{ env.QT_VERSION }}
+ uses: jurplel/install-qt-action@v4
+ with:
+ version: ${{ env.QT_VERSION }}
+ host: windows
+ target: desktop
+ arch: win64_msvc2022_64
+ modules: qtshadertools
+ cache: true
+
+ - name: MSVC dev environment
+ uses: ilammy/msvc-dev-cmd@v1
+ with:
+ arch: x64
+
+ # Git for Windows also ships a POSIX `link.exe`; force rustc to use the
+ # MSVC linker selected above instead of trusting PATH ordering.
+ - name: Pin MSVC linker
+ shell: pwsh
+ run: |
+ $linker = Join-Path $env:VCToolsInstallDir "bin\Hostx64\x64\link.exe"
+ if (-not (Test-Path $linker)) { throw "MSVC linker not found at $linker" }
+ "CARGO_TARGET_X86_64_PC_WINDOWS_MSVC_LINKER=$linker" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append
+ Write-Host "Cargo linker: $linker"
+
+ # aws-lc-sys (via rustls) ASSEMBLES on Windows x86_64. Its shipped
+ # prebuilt objects are used only when nasm is ABSENT, and without either
+ # the build fails hard with "Missing dependency: nasm" -- after the whole
+ # compile, which makes it an expensive way to find out.
+ - name: NASM (aws-lc-sys)
+ uses: ilammy/setup-nasm@v1
+
+ - name: Setup Rust
+ uses: dtolnay/rust-toolchain@stable
+
+ - name: Rust cache
+ uses: Swatinem/rust-cache@v2
+ with:
+ workspaces: crates -> target
+ shared-key: qt-gate-windows-${{ env.QT_VERSION }}
+ cache-on-failure: true
+ # Tags and the trusted integration branch may seed the cache. A
+ # manual dispatch on any feature/fork ref remains read-only.
+ save-if: ${{ startsWith(github.ref, 'refs/tags/v') || github.ref == 'refs/heads/pre-release' }}
+
+ # QML is not compile-checked. A bad type name or property is a grey
+ # screen at runtime, not an error here, so this runs FIRST: it is the
+ # cheapest gate and it catches the most expensive failure.
+ - name: qmllint (QML type check)
+ shell: bash
+ run: scripts/qml-audits/qmllint_gate.sh "$QT_ROOT_DIR"
+
+ # The same five the Linux gate runs, and they need Qt's own
+ # plugins.qmltypes to resolve QtQuick types -- hence after the install.
+ - name: QML audits
+ shell: bash
+ env:
+ QBZ_QT_QML_DIR: ${{ env.QT_ROOT_DIR }}/qml
+ run: |
+ set -euo pipefail
+ for a in qml_resolution_audit qml_singleton_xref qml_eager_tab_audit qml_module_registration_audit qml_icon_bake_audit qml_on_prefixed_property_audit; do
+ echo "::group::$a"
+ python "scripts/qml-audits/$a.py" "$PWD/crates/qbz-qt"
+ echo "::endgroup::"
+ done
+
+ - name: Slint-free dep-graph gate (qbz-qt)
+ shell: bash
+ 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; }
+ echo "dep graph is Slint-free"
+
+ - name: cargo test + build (qbz-qt)
+ shell: bash
+ run: |
+ set -euo pipefail
+ python3 scripts/qt-cargo.py test --manifest-path crates/Cargo.toml -j 4 -p qbz-qt --no-fail-fast
+ python3 scripts/qt-cargo.py build --manifest-path crates/Cargo.toml -j 4 -p qbz-qt
+
+ # The Linux gate isolates with private XDG dirs and a private session
+ # bus. Windows needs neither a bus (the single instance is a named mutex)
+ # nor XDG, but it DOES need its own %APPDATA%/%LOCALAPPDATA%: every store
+ # QBZ opens is derived from them, and a smoke that writes into the
+ # runner's real profile is a smoke that can pass because of what a
+ # previous run left behind.
+ - name: Offscreen smoke
+ shell: pwsh
+ run: |
+ $iso = Join-Path $env:RUNNER_TEMP "qbz-smoke"
+ New-Item -ItemType Directory -Force -Path "$iso\roaming", "$iso\local" | Out-Null
+ $env:APPDATA = "$iso\roaming"
+ $env:LOCALAPPDATA = "$iso\local"
+ $env:QT_QPA_PLATFORM = "offscreen"
+ $env:RUST_LOG = "info"
+ $exe = "crates\target\debug\qbz.exe"
+ $p = Start-Process -FilePath $exe -RedirectStandardError smoke.log `
+ -RedirectStandardOutput smoke.out -PassThru
+ if (-not $p.WaitForExit(75000)) { $p.Kill(); Start-Sleep -Seconds 2 }
+
+ $lines = (Get-Content smoke.log -ErrorAction SilentlyContinue | Measure-Object -Line).Lines
+ Write-Host "smoke log: $lines lines"
+ if ($lines -lt 10) {
+ Get-Content smoke.log -ErrorAction SilentlyContinue
+ Write-Host "::error::the app did not start"
+ exit 1
+ }
+
+ # The same complaint set the Linux gate uses, verbatim.
+ $pat = 'is not a type|unavailable|ReferenceError|TypeError|Cannot read|Unable to assign|Cannot open|no such method|non-existent property|failed to load component|is not installed'
+ $errs = Select-String -Path smoke.log -Pattern $pat |
+ Where-Object { $_.Line -notmatch 'propertyCache' }
+ if ($errs) {
+ $errs | Select-Object -First 20 | ForEach-Object { $_.Line }
+ Write-Host "::error::$($errs.Count) QML complaint(s)"
+ exit 1
+ }
+
+ if (-not (Select-String -Path smoke.log -Pattern 'QbzCore initialized' -Quiet)) {
+ Get-Content smoke.log -Tail 20
+ Write-Host "::error::never reached QbzCore init"
+ exit 1
+ }
+ Write-Host "smoke OK (0 QML complaints, core initialized)"
+
+ - name: Upload the smoke log on failure
+ if: failure()
+ uses: actions/upload-artifact@v7
+ with:
+ name: smoke-log-windows
+ path: smoke.log
+ if-no-files-found: ignore
+ retention-days: 7
diff --git a/.github/workflows/redirect-pr-to-prerelease.yml b/.github/workflows/redirect-pr-to-prerelease.yml
index 40a8f6d30..464244080 100644
--- a/.github/workflows/redirect-pr-to-prerelease.yml
+++ b/.github/workflows/redirect-pr-to-prerelease.yml
@@ -1,7 +1,10 @@
name: Redirect PRs to pre-release
on:
- pull_request:
+ # No contributor code is checked out or executed. pull_request_target gives
+ # this API-only workflow the write token it needs for PRs opened from forks;
+ # pull_request downgrades GITHUB_TOKEN to read-only for those PRs.
+ pull_request_target:
branches: [main]
types: [opened]
diff --git a/.github/workflows/release-aur.yml b/.github/workflows/release-aur.yml
index 6ef0bddf2..706641dd1 100644
--- a/.github/workflows/release-aur.yml
+++ b/.github/workflows/release-aur.yml
@@ -1,24 +1,28 @@
name: release-aur
on:
- # Trigger after release-linux completes (it uploads amd64 tarball)
workflow_run:
workflows: ["release-linux"]
types: [completed]
workflow_dispatch:
inputs:
version:
- description: "Version to publish (e.g. 1.0.0)"
+ description: "Version to prepare (e.g. 2.1.0)"
required: true
type: string
pkgrel:
- description: "Package release number (bump for same-version rebuilds)"
+ description: "Package release number"
required: false
default: "1"
type: string
+ publish:
+ description: "Push all four generated packages to AUR"
+ required: false
+ default: false
+ type: boolean
concurrency:
- group: aur-publish
+ group: aur-prepare-publish
cancel-in-progress: false
permissions:
@@ -26,160 +30,268 @@ permissions:
jobs:
update-aur:
- runs-on: ubuntu-latest
- # Only run on manual dispatch, or successful stable tag releases (no -rc)
- if: ${{ github.event_name == 'workflow_dispatch' || (github.event.workflow_run.conclusion == 'success' && startsWith(github.event.workflow_run.head_branch, 'v') && !contains(github.event.workflow_run.head_branch, '-rc')) }}
+ runs-on: ubuntu-24.04
+ # workflow_run prepares verified metadata only. Publishing is deliberately
+ # manual because the maintainer's AUR key requires an interactive passkey.
+ # Accept only a successful tag push produced inside this repository; the
+ # exact tag and main ancestry are checked again after checkout.
+ if: >
+ github.event_name == 'workflow_dispatch' ||
+ (github.event.workflow_run.conclusion == 'success' &&
+ github.event.workflow_run.event == 'push' &&
+ github.event.workflow_run.head_repository.full_name == github.repository &&
+ startsWith(github.event.workflow_run.head_branch, 'v') &&
+ !contains(github.event.workflow_run.head_branch, '-'))
steps:
- - name: Checkout
+ - name: Checkout app repo
uses: actions/checkout@v7
+ with:
+ ref: ${{ github.event_name == 'workflow_run' && github.event.workflow_run.head_sha || github.ref }}
+ fetch-depth: 0
- - name: Determine version
- id: version
+ - name: Resolve and verify release
+ id: release
env:
EVENT_NAME: ${{ github.event_name }}
- WORKFLOW_HEAD_BRANCH: ${{ github.event.workflow_run.head_branch }}
+ RUN_HEAD_BRANCH: ${{ github.event.workflow_run.head_branch }}
+ RUN_HEAD_SHA: ${{ github.event.workflow_run.head_sha }}
DISPATCH_VERSION: ${{ inputs.version }}
+ DISPATCH_PUBLISH: ${{ inputs.publish }}
+ DISPATCH_PKGREL: ${{ inputs.pkgrel }}
run: |
- if [[ "$EVENT_NAME" == "workflow_run" ]]; then
- # Extract version from the triggering workflow's head branch (tag)
- REF="$WORKFLOW_HEAD_BRANCH"
- if [[ ! "$REF" =~ ^v[0-9]+(\.[0-9]+){1,3}([.-][0-9A-Za-z.-]+)?$ ]]; then
- echo "Invalid workflow_run head branch for release: $REF"
- exit 1
- fi
- VERSION="${REF#v}"
+ set -euo pipefail
+
+ if [[ "$EVENT_NAME" == workflow_run ]]; then
+ tag="$RUN_HEAD_BRANCH"
+ version="${tag#v}"
+ pkgrel=1
+ should_publish=false
else
- VERSION="$DISPATCH_VERSION"
+ version="$DISPATCH_VERSION"
+ tag="v${version}"
+ pkgrel="${DISPATCH_PKGREL:-1}"
+ should_publish="${DISPATCH_PUBLISH:-false}"
fi
- echo "version=$VERSION" >> "$GITHUB_OUTPUT"
- echo "Detected version: $VERSION"
- - name: Wait for all release assets
- env:
- VERSION: ${{ steps.version.outputs.version }}
- GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- run: |
- echo "Waiting for release assets to be available..."
- echo "Note: amd64 should be ready (release-linux completed), waiting for aarch64..."
- MAX_WAIT=1800
- INTERVAL=30
- ELAPSED=0
-
- while [ $ELAPSED -lt $MAX_WAIT ]; do
- echo "Checking for release assets (${ELAPSED}s elapsed)..."
-
- # Check if both tarballs exist (follow redirects, GitHub returns 302)
- AMD64_CODE=$(curl -sL -o /dev/null -w "%{http_code}" "https://github.com/vicrodh/qbz/releases/download/v${VERSION}/qbz_${VERSION}_amd64.tar.gz")
- AARCH64_CODE=$(curl -sL -o /dev/null -w "%{http_code}" "https://github.com/vicrodh/qbz/releases/download/v${VERSION}/qbz_${VERSION}_aarch64.tar.gz")
- AMD64_EXISTS=$([[ "$AMD64_CODE" == "200" ]] && echo "true" || echo "false")
- AARCH64_EXISTS=$([[ "$AARCH64_CODE" == "200" ]] && echo "true" || echo "false")
-
- if [ "$AMD64_EXISTS" = "true" ] && [ "$AARCH64_EXISTS" = "true" ]; then
- echo "Both release assets are available!"
- break
- fi
-
- echo "Waiting for assets... (amd64: $AMD64_EXISTS, aarch64: $AARCH64_EXISTS)"
- sleep $INTERVAL
- ELAPSED=$((ELAPSED + INTERVAL))
- done
-
- if [ $ELAPSED -ge $MAX_WAIT ]; then
- echo "Timeout waiting for release assets after 30 minutes"
+ if [[ ! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+([.-][0-9A-Za-z.-]+)?$ ]]; then
+ echo "::error::Invalid release version: $version"
+ exit 1
+ fi
+ if [[ ! "$pkgrel" =~ ^[1-9][0-9]*$ ]]; then
+ echo "::error::Invalid pkgrel: $pkgrel"
+ exit 1
+ fi
+ if [[ "$should_publish" == true && ! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
+ echo "::error::AUR publishing is limited to stable x.y.z releases"
exit 1
fi
- - name: Download release tarballs and get checksums
+ if [[ "$EVENT_NAME" == workflow_run || "$should_publish" == true ]]; then
+ git fetch origin main --tags
+ if ! git show-ref --verify --quiet "refs/tags/${tag}"; then
+ echo "::error::Release tag ${tag} does not exist"
+ exit 1
+ fi
+ tag_sha="$(git rev-parse "refs/tags/${tag}^{commit}")"
+ if [[ "$EVENT_NAME" == workflow_run && "$tag_sha" != "$RUN_HEAD_SHA" ]]; then
+ echo "::error::workflow_run SHA does not match ${tag}"
+ exit 1
+ fi
+ if ! git merge-base --is-ancestor "$tag_sha" origin/main; then
+ echo "::error::${tag} is not on main"
+ exit 1
+ fi
+ fi
+
+ {
+ echo "version=$version"
+ echo "tag=$tag"
+ echo "pkgrel=$pkgrel"
+ echo "should_publish=$should_publish"
+ } >> "$GITHUB_OUTPUT"
+
+ - name: Wait for all release assets
+ env:
+ VERSION: ${{ steps.release.outputs.version }}
+ run: |
+ set -euo pipefail
+ assets=(
+ "qbz_${VERSION}_amd64.tar.gz"
+ "qbz_${VERSION}_aarch64.tar.gz"
+ "qbzd-${VERSION}-linux-amd64.tar.gz"
+ "qbzd-${VERSION}-linux-aarch64.tar.gz"
+ "qbz-${VERSION}-cargo-vendor.tar.xz"
+ )
+
+ for attempt in $(seq 1 60); do
+ missing=()
+ for asset in "${assets[@]}"; do
+ url="https://github.com/vicrodh/qbz/releases/download/v${VERSION}/${asset}"
+ code="$(curl -sL -o /dev/null -w '%{http_code}' "$url")"
+ [[ "$code" == 200 ]] || missing+=("$asset")
+ done
+ if [[ ${#missing[@]} -eq 0 ]]; then
+ echo "All AUR assets are available"
+ break
+ fi
+ if [[ "$attempt" -eq 60 ]]; then
+ printf '::error::Timed out waiting for %s\n' "${missing[*]}"
+ exit 1
+ fi
+ echo "Attempt ${attempt}/60; waiting for: ${missing[*]}"
+ sleep 30
+ done
+
+ - name: Download sources and calculate checksums
id: checksums
env:
- VERSION: ${{ steps.version.outputs.version }}
+ VERSION: ${{ steps.release.outputs.version }}
run: |
- # Download x86_64 tarball
- AMD64_URL="https://github.com/vicrodh/qbz/releases/download/v${VERSION}/qbz_${VERSION}_amd64.tar.gz"
- echo "Downloading: $AMD64_URL"
- curl -fsSL -o /tmp/qbz_amd64.tar.gz "$AMD64_URL"
- SHA256_AMD64=$(sha256sum /tmp/qbz_amd64.tar.gz | cut -d' ' -f1)
- echo "sha256_amd64=$SHA256_AMD64" >> "$GITHUB_OUTPUT"
- echo "x86_64 Checksum: $SHA256_AMD64"
+ set -euo pipefail
+ mkdir -p /tmp/aur-sources
- # Download aarch64 tarball
- AARCH64_URL="https://github.com/vicrodh/qbz/releases/download/v${VERSION}/qbz_${VERSION}_aarch64.tar.gz"
- echo "Downloading: $AARCH64_URL"
- curl -fsSL -o /tmp/qbz_aarch64.tar.gz "$AARCH64_URL"
- SHA256_AARCH64=$(sha256sum /tmp/qbz_aarch64.tar.gz | cut -d' ' -f1)
- echo "sha256_aarch64=$SHA256_AARCH64" >> "$GITHUB_OUTPUT"
- echo "aarch64 Checksum: $SHA256_AARCH64"
+ download_hash() {
+ local output_name="$1" url="$2" file="$3" sum
+ curl -fsSL "$url" -o "/tmp/aur-sources/$file"
+ sum="$(sha256sum "/tmp/aur-sources/$file" | cut -d' ' -f1)"
+ echo "${output_name}=${sum}" >> "$GITHUB_OUTPUT"
+ echo "${file}: ${sum}"
+ }
- - name: Setup SSH for AUR
+ base="https://github.com/vicrodh/qbz/releases/download/v${VERSION}"
+ download_hash qbz_amd64 "$base/qbz_${VERSION}_amd64.tar.gz" qbz-amd64.tar.gz
+ download_hash qbz_aarch64 "$base/qbz_${VERSION}_aarch64.tar.gz" qbz-aarch64.tar.gz
+ download_hash qbzd_amd64 "$base/qbzd-${VERSION}-linux-amd64.tar.gz" qbzd-amd64.tar.gz
+ download_hash qbzd_aarch64 "$base/qbzd-${VERSION}-linux-aarch64.tar.gz" qbzd-aarch64.tar.gz
+ download_hash vendor "$base/qbz-${VERSION}-cargo-vendor.tar.xz" vendor.tar.xz
+ download_hash source \
+ "https://github.com/vicrodh/qbz/archive/refs/tags/v${VERSION}.tar.gz" \
+ source.tar.gz
+
+ - name: Configure AUR SSH
+ if: steps.release.outputs.should_publish == 'true'
env:
AUR_SSH_PRIVATE_KEY: ${{ secrets.AUR_SSH_PRIVATE_KEY }}
run: |
- mkdir -p ~/.ssh
- echo "$AUR_SSH_PRIVATE_KEY" > ~/.ssh/aur
- chmod 600 ~/.ssh/aur
- echo "Host aur.archlinux.org" >> ~/.ssh/config
- echo " IdentityFile ~/.ssh/aur" >> ~/.ssh/config
- echo " User aur" >> ~/.ssh/config
+ set -euo pipefail
+ if [[ -z "$AUR_SSH_PRIVATE_KEY" ]]; then
+ echo "::error::AUR_SSH_PRIVATE_KEY is required for publishing"
+ exit 1
+ fi
+ install -d -m 700 ~/.ssh
+ install -m 600 /dev/null ~/.ssh/aur
+ printf '%s\n' "$AUR_SSH_PRIVATE_KEY" > ~/.ssh/aur
+ {
+ echo "Host aur.archlinux.org"
+ echo " IdentityFile ~/.ssh/aur"
+ echo " IdentitiesOnly yes"
+ echo " User aur"
+ } >> ~/.ssh/config
ssh-keyscan -t ed25519 aur.archlinux.org >> ~/.ssh/known_hosts
- - name: Clone AUR repo
- run: |
- git clone ssh://aur@aur.archlinux.org/qbz-bin.git /tmp/aur-qbz-bin
- cd /tmp/aur-qbz-bin
- # Ensure we are on master (recover from detached HEAD if remote branch was corrupted)
- git checkout master 2>/dev/null || git checkout -b master
-
- - name: Determine pkgrel
- id: pkgrel
- run: |
- if [[ "${{ github.event_name }}" == "release" ]]; then
- echo "pkgrel=1" >> "$GITHUB_OUTPUT"
- else
- echo "pkgrel=${{ inputs.pkgrel || '1' }}" >> "$GITHUB_OUTPUT"
- fi
-
- - name: Update PKGBUILD
+ - name: Clone AUR package repos
env:
- VERSION: ${{ steps.version.outputs.version }}
- PKGREL: ${{ steps.pkgrel.outputs.pkgrel }}
- SHA256_AMD64: ${{ steps.checksums.outputs.sha256_amd64 }}
- SHA256_AARCH64: ${{ steps.checksums.outputs.sha256_aarch64 }}
+ SHOULD_PUBLISH: ${{ steps.release.outputs.should_publish }}
run: |
- cd /tmp/aur-qbz-bin
+ set -euo pipefail
+ for package in qbz-bin qbz qbzd-bin qbzd; do
+ destination="/tmp/aur-${package}"
+ if [[ "$SHOULD_PUBLISH" == true ]]; then
+ url="ssh://aur@aur.archlinux.org/${package}.git"
+ else
+ url="https://aur.archlinux.org/${package}.git"
+ fi
- # Copy PKGBUILD from main repo (source of truth)
- cp ${{ github.workspace }}/packaging/aur/PKGBUILD .
+ if ! git clone "$url" "$destination"; then
+ if [[ "$SHOULD_PUBLISH" == true ]]; then
+ echo "::error::Could not clone AUR repository for ${package}"
+ exit 1
+ fi
+ # qbzd packages do not exist until their first AUR push.
+ mkdir -p "$destination"
+ git -C "$destination" init -b master
+ git -C "$destination" remote add origin "$url"
+ fi
+ git -C "$destination" checkout master 2>/dev/null || \
+ git -C "$destination" checkout -b master
+ done
- # Update version and pkgrel
- sed -i "s/^pkgver=.*/pkgver=${VERSION}/" PKGBUILD
- sed -i "s/^pkgrel=.*/pkgrel=${PKGREL}/" PKGBUILD
-
- # Update checksums for both architectures
- sed -i "s/^sha256sums_x86_64=.*/sha256sums_x86_64=('${SHA256_AMD64}')/" PKGBUILD
- sed -i "s/^sha256sums_aarch64=.*/sha256sums_aarch64=('${SHA256_AARCH64}')/" PKGBUILD
-
- echo "=== Updated PKGBUILD ==="
- cat PKGBUILD
-
- - name: Generate .SRCINFO
- run: |
- cd /tmp/aur-qbz-bin
- docker run --rm -v "$PWD":/pkg -w /pkg -e HOST_UID=$(id -u) -e HOST_GID=$(id -g) archlinux:latest bash -c "
- pacman -Sy --noconfirm pacman-contrib sudo
- useradd -m builder
- chown -R builder:builder /pkg
- sudo -u builder makepkg --printsrcinfo > .SRCINFO
- chown -R \$HOST_UID:\$HOST_GID /pkg
- "
- cat .SRCINFO
-
- - name: Commit and push to AUR
+ - name: Stamp PKGBUILDs
env:
- VERSION: ${{ steps.version.outputs.version }}
+ VERSION: ${{ steps.release.outputs.version }}
+ PKGREL: ${{ steps.release.outputs.pkgrel }}
+ QBZ_AMD64: ${{ steps.checksums.outputs.qbz_amd64 }}
+ QBZ_AARCH64: ${{ steps.checksums.outputs.qbz_aarch64 }}
+ QBZD_AMD64: ${{ steps.checksums.outputs.qbzd_amd64 }}
+ QBZD_AARCH64: ${{ steps.checksums.outputs.qbzd_aarch64 }}
+ SOURCE_SHA: ${{ steps.checksums.outputs.source }}
+ VENDOR_SHA: ${{ steps.checksums.outputs.vendor }}
run: |
- cd /tmp/aur-qbz-bin
- git config user.name "GitHub Actions"
- git config user.email "actions@github.com"
- git add PKGBUILD .SRCINFO
- git commit -m "Update to v${VERSION}"
- git push
+ set -euo pipefail
+ for package in qbz-bin qbz qbzd-bin qbzd; do
+ cp "packaging/aur/${package}/"* "/tmp/aur-${package}/"
+ sed -i "s/^pkgver=.*/pkgver=${VERSION}/" "/tmp/aur-${package}/PKGBUILD"
+ sed -i "s/^pkgrel=.*/pkgrel=${PKGREL}/" "/tmp/aur-${package}/PKGBUILD"
+ done
+
+ sed -i "s/^sha256sums_x86_64=.*/sha256sums_x86_64=('${QBZ_AMD64}')/" /tmp/aur-qbz-bin/PKGBUILD
+ sed -i "s/^sha256sums_aarch64=.*/sha256sums_aarch64=('${QBZ_AARCH64}')/" /tmp/aur-qbz-bin/PKGBUILD
+ sed -i "s/^sha256sums_x86_64=.*/sha256sums_x86_64=('${QBZD_AMD64}')/" /tmp/aur-qbzd-bin/PKGBUILD
+ sed -i "s/^sha256sums_aarch64=.*/sha256sums_aarch64=('${QBZD_AARCH64}')/" /tmp/aur-qbzd-bin/PKGBUILD
+ sed -i "s/^sha256sums=.*/sha256sums=('${SOURCE_SHA}' '${VENDOR_SHA}')/" /tmp/aur-qbz/PKGBUILD
+ sed -i "s/^sha256sums=.*/sha256sums=('${SOURCE_SHA}' '${VENDOR_SHA}')/" /tmp/aur-qbzd/PKGBUILD
+
+ - name: Generate and validate .SRCINFO files
+ run: |
+ set -euo pipefail
+ for package in qbz-bin qbz qbzd-bin qbzd; do
+ docker run --rm \
+ -e HOST_UID="$(id -u)" -e HOST_GID="$(id -g)" \
+ -v "/tmp/aur-${package}:/pkg" archlinux:latest bash -euc '
+ useradd -m builder
+ chown -R builder:builder /pkg
+ runuser -u builder -- bash -c \
+ "cd /pkg && makepkg --printsrcinfo > .SRCINFO"
+ chown -R "${HOST_UID}:${HOST_GID}" /pkg
+ '
+ done
+
+ - name: Commit and push all AUR packages
+ if: steps.release.outputs.should_publish == 'true'
+ env:
+ VERSION: ${{ steps.release.outputs.version }}
+ run: |
+ set -euo pipefail
+ for package in qbz-bin qbz qbzd-bin qbzd; do
+ directory="/tmp/aur-${package}"
+ git -C "$directory" config user.name "GitHub Actions"
+ git -C "$directory" config user.email "actions@github.com"
+ files=(PKGBUILD .SRCINFO)
+ [[ -f "$directory/qbzd.install" ]] && files+=(qbzd.install)
+ git -C "$directory" add "${files[@]}"
+ if git -C "$directory" diff --cached --quiet; then
+ echo "${package}: no metadata changes"
+ continue
+ fi
+ git -C "$directory" commit -m "Update to v${VERSION}"
+ git -C "$directory" push origin master
+ done
+
+ - name: Upload dry-run package metadata
+ if: steps.release.outputs.should_publish != 'true'
+ uses: actions/upload-artifact@v7
+ with:
+ name: aur-four-package-metadata
+ retention-days: 7
+ path: |
+ /tmp/aur-qbz-bin/PKGBUILD
+ /tmp/aur-qbz-bin/.SRCINFO
+ /tmp/aur-qbz/PKGBUILD
+ /tmp/aur-qbz/.SRCINFO
+ /tmp/aur-qbzd-bin/PKGBUILD
+ /tmp/aur-qbzd-bin/.SRCINFO
+ /tmp/aur-qbzd-bin/qbzd.install
+ /tmp/aur-qbzd/PKGBUILD
+ /tmp/aur-qbzd/.SRCINFO
+ /tmp/aur-qbzd/qbzd.install
+ if-no-files-found: error
diff --git a/.github/workflows/release-flatpak.yml b/.github/workflows/release-flatpak.yml
index 3f8e67d6e..825345bc3 100644
--- a/.github/workflows/release-flatpak.yml
+++ b/.github/workflows/release-flatpak.yml
@@ -1,14 +1,10 @@
-# Flatpak bundle for GitHub Releases — Slint binary (crates/ workspace).
+# Flatpak bundle for GitHub Releases — Qt frontend.
#
-# Rewritten 2026-07-02 for the Tauri -> Slint migration (v2.0). Builds
-# packaging/flatpak/com.blitzfc.qbz.yml (network manifest) with
-# flatpak-builder and attaches the .flatpak bundle to the Release.
-# Flathub proper is a separate repo/manifest updated at tag time via
-# scripts/update-flathub.sh (offline sources).
-#
-# The cargo build runs on the HOST's resources even though it happens
-# inside the flatpak sandbox, so this runner needs the same swap headroom
-# as release-linux.yml (qbz_ui rustc peak ~30 GB).
+# Rewritten 2026-08-25 for the Slint -> Qt frontend switch: the bundle is a
+# REPACK of the bare binary from the shared build-qt-linux.yml on the KDE
+# runtime (org.kde.Platform 6.9 provides Qt). No cargo inside flatpak-builder,
+# no swap ceremony. Same shape as the Flathub manifest, which consumes the
+# published release tarball instead.
name: release-flatpak
@@ -16,7 +12,7 @@ on:
workflow_dispatch:
inputs:
tag:
- description: "Optional release tag to build (e.g. v2.0.0). Empty = build HEAD, no Release publish."
+ description: "Optional release tag to build (e.g. v2.1.0). Empty = build HEAD, no Release publish."
required: false
type: string
push:
@@ -28,15 +24,46 @@ concurrency:
cancel-in-progress: false
jobs:
+ build:
+ uses: ./.github/workflows/build-qt-linux.yml
+ with:
+ arch: x86_64
+ source_ref: ${{ inputs.tag || github.ref }}
+
+ # qbzd rides in the bundle too (same as deb/rpm). Slint-free, minutes-class.
+ build-qbzd:
+ runs-on: ubuntu-22.04
+ timeout-minutes: 30
+ steps:
+ - uses: actions/checkout@v7
+ with:
+ ref: ${{ inputs.tag || github.ref }}
+ - uses: dtolnay/rust-toolchain@stable
+ - uses: Swatinem/rust-cache@v2
+ with:
+ workspaces: crates -> target
+ shared-key: qbzd-linux-x86_64
+ cache-on-failure: true
+ save-if: ${{ github.ref == 'refs/heads/main' || github.ref == 'refs/heads/pre-release' }}
+ - run: sudo apt-get update && sudo apt-get install -y libasound2-dev libjack-jackd2-dev libdbus-1-dev libssl-dev
+ - run: cargo build --release --manifest-path crates/Cargo.toml -p qbzd
+ - uses: actions/upload-artifact@v7
+ with:
+ name: qbzd-linux-x86_64-flatpak
+ path: crates/target/release/qbzd
+ retention-days: 7
+
build-flatpak:
+ needs: [build, build-qbzd]
runs-on: ubuntu-24.04
- timeout-minutes: 300
+ timeout-minutes: 60
permissions:
contents: write
steps:
- name: Checkout
uses: actions/checkout@v7
with:
+ ref: ${{ inputs.tag || github.ref }}
fetch-depth: 0
- name: Resolve release version
@@ -65,6 +92,17 @@ jobs:
exit 1
fi
+ - name: Assert Cargo.toml version matches requested 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) != requested version ($VERSION)."
+ exit 1
+ fi
+
# Keep the metainfo's top entry honest (version + date).
- name: Sync metainfo release entry
if: steps.release.outputs.version != ''
@@ -86,35 +124,40 @@ jobs:
p.write_text(text, encoding="utf-8")
PY
- - name: Free disk space and add swap
+ - name: Download bare binary
+ uses: actions/download-artifact@v8
+ with:
+ name: qbz-linux-x86_64
+ path: packaging/flatpak/payload
+ - name: Download qbzd
+ uses: actions/download-artifact@v8
+ with:
+ name: qbzd-linux-x86_64-flatpak
+ path: packaging/flatpak/payload
+
+ - name: Stage payload
run: |
- sudo rm -rf /usr/share/dotnet /opt/ghc /usr/local/lib/android \
- /opt/hostedtoolcache /usr/local/share/boost /usr/share/swift \
- /usr/local/lib/node_modules || true
- sudo docker system prune -af || true
- if sudo fallocate -l 18G /swap_extra; then
- sudo chmod 600 /swap_extra
- sudo mkswap /swap_extra
- sudo swapon /swap_extra
- else
- echo "::warning::could not allocate extra swap; build may OOM"
- fi
- free -h
+ set -euo pipefail
+ P=packaging/flatpak/payload
+ chmod +x "$P/qbz" "$P/qbzd"
+ cp packaging/flatpak/com.blitzfc.qbz.desktop "$P/qbz.desktop"
+ cp packaging/flatpak/com.blitzfc.qbz.metainfo.xml "$P/"
+ cp LICENSE "$P/LICENSE"
+ cp -r licenses "$P/licenses"
+ mkdir -p "$P/icons"; cp packaging/icons/{32x32,48x48,64x64,128x128,256x256,512x512}.png "$P/icons/"
+ ls -la "$P"
- name: Install flatpak tooling
run: |
sudo apt-get update
- sudo apt-get install -y flatpak flatpak-builder
+ sudo apt-get install -y flatpak flatpak-builder dbus-daemon
flatpak remote-add --user --if-not-exists flathub https://flathub.org/repo/flathub.flatpakrepo
- name: Install runtimes
run: |
flatpak install --user -y --noninteractive flathub \
- org.gnome.Platform//49 \
- org.gnome.Sdk//49
- flatpak install --user -y --noninteractive flathub \
- org.freedesktop.Sdk.Extension.rust-stable//25.08 \
- org.freedesktop.Sdk.Extension.llvm20//25.08
+ org.kde.Platform//6.9 \
+ org.kde.Sdk//6.9
- name: Build Flatpak
id: build_flatpak
@@ -132,11 +175,31 @@ jobs:
echo "bundle_name=${BUNDLE_NAME}" >> "$GITHUB_OUTPUT"
ls -la dist
+ # Observe flatpak run until the deadline, with a fresh sandbox profile
+ # and private bus. A crash/early exit after core init must still fail.
+ - name: Offscreen smoke (inside the sandbox)
+ run: |
+ set -euo pipefail
+ flatpak install --user -y --noninteractive --bundle "dist/${{ steps.build_flatpak.outputs.bundle_name }}" || \
+ flatpak install --user -y --noninteractive "dist/${{ steps.build_flatpak.outputs.bundle_name }}"
+ python3 scripts/packaging/smoke_release.py com.blitzfc.qbz \
+ --flatpak --seconds 60 --log smoke.log
+
+ - name: Upload deployed startup evidence
+ if: always()
+ uses: actions/upload-artifact@v7
+ with:
+ name: qbz-flatpak-smoke
+ path: smoke.log*
+ if-no-files-found: ignore
+ retention-days: 14
+
- name: Upload Flatpak bundle
uses: actions/upload-artifact@v7
with:
name: qbz-flatpak
path: dist/${{ steps.build_flatpak.outputs.bundle_name }}
+ retention-days: ${{ startsWith(github.ref, 'refs/tags/v') && 90 || 7 }}
- name: Publish GitHub Release Asset
if: startsWith(github.ref, 'refs/tags/')
diff --git a/.github/workflows/release-gentoo.yml b/.github/workflows/release-gentoo.yml
index c12cfd85d..de973263d 100644
--- a/.github/workflows/release-gentoo.yml
+++ b/.github/workflows/release-gentoo.yml
@@ -1,10 +1,8 @@
name: release-gentoo
-# The overlay repo (vicrodh/qbz-overlay) must NEVER be edited by hand: any
-# ebuild change without a Manifest regeneration bricks emerge with a digest
-# mismatch (issue #557 — the 2.0.0 RESTRICT edit was pushed manually and
-# skipped the Manifest). Template changes between releases are published by
-# re-running THIS workflow via workflow_dispatch with the released version.
+# qbz-overlay is automation-owned because every ebuild or metadata change must
+# regenerate its Manifest. workflow_dispatch is the supported way to publish
+# a template-only fix for an already released version.
on:
workflow_run:
@@ -13,9 +11,14 @@ on:
workflow_dispatch:
inputs:
version:
- description: "Version to publish (e.g. 1.2.0)"
+ description: "Version to prepare (e.g. 2.1.0)"
required: true
type: string
+ publish:
+ description: "Push all four packages and Manifests to qbz-overlay"
+ required: false
+ default: false
+ type: boolean
concurrency:
group: gentoo-overlay
@@ -26,160 +29,206 @@ permissions:
jobs:
update-overlay:
- runs-on: ubuntu-latest
- if: ${{ github.event_name == 'workflow_dispatch' || (github.event.workflow_run.conclusion == 'success' && startsWith(github.event.workflow_run.head_branch, 'v') && !contains(github.event.workflow_run.head_branch, '-rc')) }}
+ runs-on: ubuntu-24.04
+ if: >
+ github.event_name == 'workflow_dispatch' ||
+ (github.event.workflow_run.conclusion == 'success' &&
+ github.event.workflow_run.event == 'push' &&
+ github.event.workflow_run.head_repository.full_name == github.repository &&
+ startsWith(github.event.workflow_run.head_branch, 'v') &&
+ !contains(github.event.workflow_run.head_branch, '-'))
steps:
- name: Checkout app repo
uses: actions/checkout@v7
+ with:
+ ref: ${{ github.event_name == 'workflow_run' && github.event.workflow_run.head_sha || github.ref }}
+ fetch-depth: 0
- - name: Determine version
- id: version
+ - name: Resolve and verify release
+ id: release
env:
EVENT_NAME: ${{ github.event_name }}
- WORKFLOW_HEAD_BRANCH: ${{ github.event.workflow_run.head_branch }}
+ RUN_HEAD_BRANCH: ${{ github.event.workflow_run.head_branch }}
+ RUN_HEAD_SHA: ${{ github.event.workflow_run.head_sha }}
DISPATCH_VERSION: ${{ inputs.version }}
+ DISPATCH_PUBLISH: ${{ inputs.publish }}
run: |
- if [[ "$EVENT_NAME" == "workflow_run" ]]; then
- REF="$WORKFLOW_HEAD_BRANCH"
- if [[ ! "$REF" =~ ^v[0-9]+(\.[0-9]+){1,3}([.-][0-9A-Za-z.-]+)?$ ]]; then
- echo "Invalid tag: $REF"
+ set -euo pipefail
+ if [[ "$EVENT_NAME" == workflow_run ]]; then
+ tag="$RUN_HEAD_BRANCH"
+ version="${tag#v}"
+ should_publish=true
+ else
+ version="$DISPATCH_VERSION"
+ tag="v${version}"
+ should_publish="${DISPATCH_PUBLISH:-false}"
+ fi
+
+ if [[ ! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+([.-][0-9A-Za-z.-]+)?$ ]]; then
+ echo "::error::Invalid release version: $version"
+ exit 1
+ fi
+ if [[ "$should_publish" == true && ! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
+ echo "::error::Overlay publishing is limited to stable x.y.z releases"
+ exit 1
+ fi
+
+ if [[ "$should_publish" == true ]]; then
+ git fetch origin main --tags
+ if ! git show-ref --verify --quiet "refs/tags/${tag}"; then
+ echo "::error::Release tag ${tag} does not exist"
+ exit 1
+ fi
+ tag_sha="$(git rev-parse "refs/tags/${tag}^{commit}")"
+ if [[ "$EVENT_NAME" == workflow_run && "$tag_sha" != "$RUN_HEAD_SHA" ]]; then
+ echo "::error::workflow_run SHA does not match ${tag}"
+ exit 1
+ fi
+ if ! git merge-base --is-ancestor "$tag_sha" origin/main; then
+ echo "::error::${tag} is not on main"
exit 1
fi
- VERSION="${REF#v}"
- else
- VERSION="$DISPATCH_VERSION"
fi
- echo "version=$VERSION" >> "$GITHUB_OUTPUT"
- echo "Version: $VERSION"
+
+ echo "version=$version" >> "$GITHUB_OUTPUT"
+ echo "should_publish=$should_publish" >> "$GITHUB_OUTPUT"
- name: Clone overlay repo
env:
OVERLAY_PAT: ${{ secrets.APT_REPO_TOKEN }}
+ SHOULD_PUBLISH: ${{ steps.release.outputs.should_publish }}
run: |
- git clone "https://x-access-token:${OVERLAY_PAT}@github.com/vicrodh/qbz-overlay.git" /tmp/qbz-overlay
- cd /tmp/qbz-overlay
- git config user.name "GitHub Actions"
- git config user.email "actions@github.com"
+ set -euo pipefail
+ if [[ "$SHOULD_PUBLISH" == true ]]; then
+ if [[ -z "$OVERLAY_PAT" ]]; then
+ echo "::error::APT_REPO_TOKEN is required for publishing"
+ exit 1
+ fi
+ url="https://x-access-token:${OVERLAY_PAT}@github.com/vicrodh/qbz-overlay.git"
+ else
+ url="https://github.com/vicrodh/qbz-overlay.git"
+ fi
+ git clone "$url" /tmp/qbz-overlay
+ git -C /tmp/qbz-overlay config user.name "GitHub Actions"
+ git -C /tmp/qbz-overlay config user.email "actions@github.com"
- - name: Update binary ebuild
+ - name: Stamp all ebuilds and metadata
env:
- VERSION: ${{ steps.version.outputs.version }}
+ VERSION: ${{ steps.release.outputs.version }}
run: |
- cd /tmp/qbz-overlay/media-sound/qbz-bin
+ set -euo pipefail
+ for package in qbz-bin qbz qbzd-bin qbzd; do
+ directory="/tmp/qbz-overlay/media-sound/${package}"
+ mkdir -p "$directory"
+ find "$directory" -maxdepth 1 -type f -name "${package}-*.ebuild" -delete
+ cp "packaging/gentoo/${package}.ebuild.template" \
+ "${directory}/${package}-${VERSION}.ebuild"
+ cp "packaging/gentoo/${package}.metadata.xml" \
+ "${directory}/metadata.xml"
+ done
- # Remove old version ebuilds (keep metadata.xml)
- rm -f qbz-bin-*.ebuild
-
- # Copy template from app repo and update version
- cp "${{ github.workspace }}/packaging/gentoo/qbz-bin.ebuild.template" "qbz-bin-${VERSION}.ebuild"
-
- echo "=== qbz-bin-${VERSION}.ebuild ==="
- cat "qbz-bin-${VERSION}.ebuild"
-
- - name: Update source ebuild
+ - name: Download distfiles
env:
- VERSION: ${{ steps.version.outputs.version }}
+ VERSION: ${{ steps.release.outputs.version }}
run: |
- cd /tmp/qbz-overlay/media-sound/qbz
+ set -euo pipefail
+ mkdir -p /tmp/qbz-distfiles
+ declare -A urls=(
+ ["qbz_${VERSION}_amd64.tar.gz"]="https://github.com/vicrodh/qbz/releases/download/v${VERSION}/qbz_${VERSION}_amd64.tar.gz"
+ ["qbz_${VERSION}_aarch64.tar.gz"]="https://github.com/vicrodh/qbz/releases/download/v${VERSION}/qbz_${VERSION}_aarch64.tar.gz"
+ ["qbzd-${VERSION}-linux-amd64.tar.gz"]="https://github.com/vicrodh/qbz/releases/download/v${VERSION}/qbzd-${VERSION}-linux-amd64.tar.gz"
+ ["qbzd-${VERSION}-linux-aarch64.tar.gz"]="https://github.com/vicrodh/qbz/releases/download/v${VERSION}/qbzd-${VERSION}-linux-aarch64.tar.gz"
+ ["qbz-${VERSION}-cargo-vendor.tar.xz"]="https://github.com/vicrodh/qbz/releases/download/v${VERSION}/qbz-${VERSION}-cargo-vendor.tar.xz"
+ ["qbz-${VERSION}.tar.gz"]="https://github.com/vicrodh/qbz/archive/refs/tags/v${VERSION}.tar.gz"
+ )
- # Remove old version ebuilds (keep metadata.xml)
- rm -f qbz-*.ebuild
-
- # Copy template from app repo and update version
- cp "${{ github.workspace }}/packaging/gentoo/qbz.ebuild.template" "qbz-${VERSION}.ebuild"
-
- echo "=== qbz-${VERSION}.ebuild ==="
- cat "qbz-${VERSION}.ebuild"
-
- - name: Generate Manifests
- env:
- VERSION: ${{ steps.version.outputs.version }}
- run: |
- # Helper: compute BLAKE2B and SHA512 for a file, output Manifest line
- manifest_line() {
- local type="$1" name="$2" file="$3"
- local size blake2b sha512
- size=$(stat -c%s "$file")
- blake2b=$(b2sum "$file" | awk '{print $1}')
- sha512=$(sha512sum "$file" | awk '{print $1}')
- echo "${type} ${name} ${size} BLAKE2B ${blake2b} SHA512 ${sha512}"
- }
-
- # --- qbz-bin Manifest ---
- cd /tmp/qbz-overlay/media-sound/qbz-bin
- MANIFEST=""
-
- # Download DIST files and hash them. amd64 is REQUIRED: publishing a
- # Manifest without its DIST entry bricks emerge for every amd64 user
- # (digest missing), which is worse than failing this job. A short
- # retry absorbs release-asset propagation lag right after the tag.
- for arch_pair in "amd64:amd64" "aarch64:arm64"; do
- arch="${arch_pair%%:*}"
- dist_name="qbz_${VERSION}_${arch}.tar.gz"
- url="https://github.com/vicrodh/qbz/releases/download/v${VERSION}/${dist_name}"
- echo "Downloading ${dist_name}..."
- ok=0
- for attempt in 1 2 3; do
- if curl -sLf "$url" -o "/tmp/${dist_name}"; then
- ok=1
+ for filename in "${!urls[@]}"; do
+ ok=false
+ for attempt in $(seq 1 60); do
+ if curl -fsSL "${urls[$filename]}" -o "/tmp/qbz-distfiles/${filename}"; then
+ ok=true
break
fi
- echo "Attempt ${attempt} failed for ${dist_name}, retrying in 30s..."
+ echo "Attempt ${attempt}/60 failed for ${filename}"
sleep 30
done
- if [[ "$ok" == 1 ]]; then
- MANIFEST+="$(manifest_line DIST "$dist_name" "/tmp/${dist_name}")"$'\n'
- elif [[ "$arch" == "amd64" ]]; then
- echo "ERROR: required asset ${dist_name} not found at ${url}"
+ if [[ "$ok" != true ]]; then
+ echo "::error::Required distfile unavailable: ${filename}"
exit 1
- else
- echo "WARNING: ${dist_name} not found at ${url}, skipping"
fi
done
- # Hash the ebuild itself
- MANIFEST+="$(manifest_line EBUILD "qbz-bin-${VERSION}.ebuild" "qbz-bin-${VERSION}.ebuild")"$'\n'
+ - name: Generate all Manifests
+ env:
+ VERSION: ${{ steps.release.outputs.version }}
+ run: |
+ set -euo pipefail
+ manifest_line() {
+ local type="$1" name="$2" file="$3" size blake2b sha512
+ size="$(stat -c%s "$file")"
+ blake2b="$(b2sum "$file" | awk '{print $1}')"
+ sha512="$(sha512sum "$file" | awk '{print $1}')"
+ printf '%s %s %s BLAKE2B %s SHA512 %s\n' \
+ "$type" "$name" "$size" "$blake2b" "$sha512"
+ }
- # Hash metadata.xml
- MANIFEST+="$(manifest_line MISC "metadata.xml" "metadata.xml")"$'\n'
+ write_manifest() {
+ local package="$1"
+ shift
+ local directory="/tmp/qbz-overlay/media-sound/${package}"
+ {
+ local distfile
+ for distfile in "$@"; do
+ manifest_line DIST "$distfile" "/tmp/qbz-distfiles/${distfile}"
+ done
+ manifest_line EBUILD "${package}-${VERSION}.ebuild" \
+ "${directory}/${package}-${VERSION}.ebuild"
+ manifest_line MISC metadata.xml "${directory}/metadata.xml"
+ } > "${directory}/Manifest"
+ }
- echo "$MANIFEST" > Manifest
- echo "=== qbz-bin Manifest ==="
- cat Manifest
+ write_manifest qbz-bin \
+ "qbz_${VERSION}_amd64.tar.gz" \
+ "qbz_${VERSION}_aarch64.tar.gz"
+ write_manifest qbz \
+ "qbz-${VERSION}.tar.gz" \
+ "qbz-${VERSION}-cargo-vendor.tar.xz"
+ write_manifest qbzd-bin \
+ "qbzd-${VERSION}-linux-amd64.tar.gz" \
+ "qbzd-${VERSION}-linux-aarch64.tar.gz"
+ write_manifest qbzd \
+ "qbz-${VERSION}.tar.gz" \
+ "qbz-${VERSION}-cargo-vendor.tar.xz"
- # --- qbz (source) Manifest ---
- cd /tmp/qbz-overlay/media-sound/qbz
- MANIFEST=""
-
- # Download source tarball
- dist_name="qbz-${VERSION}.tar.gz"
- url="https://github.com/vicrodh/qbz/archive/refs/tags/v${VERSION}.tar.gz"
- echo "Downloading source tarball..."
- if curl -sLf "$url" -o "/tmp/${dist_name}"; then
- MANIFEST+="$(manifest_line DIST "$dist_name" "/tmp/${dist_name}")"$'\n'
- else
- echo "WARNING: source tarball not found, skipping"
- fi
-
- # Hash the ebuild
- MANIFEST+="$(manifest_line EBUILD "qbz-${VERSION}.ebuild" "qbz-${VERSION}.ebuild")"$'\n'
-
- # Hash metadata.xml
- MANIFEST+="$(manifest_line MISC "metadata.xml" "metadata.xml")"$'\n'
-
- echo "$MANIFEST" > Manifest
- echo "=== qbz Manifest ==="
- cat Manifest
+ - name: Validate generated overlay metadata
+ run: |
+ set -euo pipefail
+ for package in qbz-bin qbz qbzd-bin qbzd; do
+ directory="/tmp/qbz-overlay/media-sound/${package}"
+ bash -n "${directory}"/*.ebuild
+ xmllint --noout "${directory}/metadata.xml"
+ test "$(wc -l < "${directory}/Manifest")" -ge 4
+ done
- name: Commit and push
+ if: steps.release.outputs.should_publish == 'true'
env:
- VERSION: ${{ steps.version.outputs.version }}
+ VERSION: ${{ steps.release.outputs.version }}
run: |
- cd /tmp/qbz-overlay
- git add -A
- if git diff --cached --quiet; then
- echo "No changes to commit"
+ set -euo pipefail
+ git -C /tmp/qbz-overlay add -A
+ if git -C /tmp/qbz-overlay diff --cached --quiet; then
+ echo "No overlay changes to publish"
exit 0
fi
- git commit -m "Update to v${VERSION}"
- git push
+ git -C /tmp/qbz-overlay commit -m "Update to v${VERSION}"
+ git -C /tmp/qbz-overlay push
+
+ - name: Upload dry-run overlay metadata
+ if: steps.release.outputs.should_publish != 'true'
+ uses: actions/upload-artifact@v7
+ with:
+ name: gentoo-four-package-dry-run
+ retention-days: 7
+ path: /tmp/qbz-overlay/media-sound/
+ if-no-files-found: error
diff --git a/.github/workflows/release-linux-aarch64.yml b/.github/workflows/release-linux-aarch64.yml
index 2d035096b..2c47d72ba 100644
--- a/.github/workflows/release-linux-aarch64.yml
+++ b/.github/workflows/release-linux-aarch64.yml
@@ -1,14 +1,28 @@
-# Linux aarch64 release — Slint binary (crates/ workspace).
+# Linux aarch64 release — Qt frontend (crates/ workspace, package `qbz-qt`,
+# binary `qbz`).
#
-# Rewritten 2026-07-02 for the Tauri -> Slint migration (v2.0). Same
-# build-once-package-many shape as release-linux.yml (read that file's
-# header); differences: native arm64 runner, no x86 target-features, its
-# own rust-cache key (this workflow is the only writer), and it ATTACHES
-# to the Release that release-linux.yml creates.
+# Rewritten 2026-08-25 for the Slint -> Qt frontend switch. Pattern is still
+# build once, package many — but the build is the shared build-qt-linux.yml
+# (workflow_call) and it yields TWO payloads:
+# * the bare binary -> qbz_${VERSION}_arm64.tar.gz (AUR / Gentoo / Flathub
+# repack: ecosystems that provide Qt >= 6.8 themselves)
+# * the AppDir (Qt bundled, self-contained) -> deb / rpm / AppImage, so
+# distros without a usable Qt (Ubuntu 24.04 = Qt 6.4) keep working.
#
-# CONTRACT: keeps publishing qbz_${VERSION}_aarch64.tar.gz,
-# QBZ_${VERSION}_arm64.deb, QBZ-${VERSION}-1.aarch64.rpm,
-# QBZ_${VERSION}_aarch64.AppImage — AUR/Gentoo/APT/website names.
+# CONTRACT (do not break): the release must keep publishing
+# qbz_${VERSION}_arm64.tar.gz (dir qbz_${VERSION}_arm64/{qbz, qbzd, LICENSE, licenses/, qbz.desktop,
+# com.blitzfc.qbz.metainfo.xml, qbzd.service,
+# completions/, icons/hicolor/...})
+# QBZ_${VERSION}_arm64.deb / QBZ-${VERSION}-1.aarch64.rpm / QBZ_${VERSION}_arm64.AppImage
+# qbzd_${VERSION}_arm64.deb / qbzd-${VERSION}-1.aarch64.rpm / qbzd-${VERSION}-linux-arm64.tar.gz
+# — release-aur.yml, release-gentoo.yml, qbz-apt, qbz-rpm, the updater
+# manifest and the website consume these names.
+#
+# release-linux.yml is this file with the four ARCH_* values changed.
+# Keep them structurally identical.
+#
+# Triggers: version tags (cut from main — enforced by the ancestry guard)
+# + manual dispatch (guard skipped, for testing on any branch).
name: "Linux ARM64 (aarch64) Build"
@@ -16,7 +30,7 @@ on:
workflow_dispatch:
inputs:
tag:
- description: "Optional release tag to build (e.g. v2.0.0). Empty = build HEAD with the Cargo.toml version, no Release publish."
+ 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:
@@ -27,18 +41,23 @@ concurrency:
group: release-linux-aarch64-${{ github.ref }}
cancel-in-progress: false
+env:
+ ARCH: aarch64 # build-qt-linux input + artifact suffix
+ DEB_ARCH: arm64 # QBZ_${V}_arm64.deb, qbz_${V}_arm64.tar.gz
+ RPM_ARCH: aarch64 # QBZ-${V}-1.aarch64.rpm
+ APPIMAGE_ARCH: aarch64 # QBZ_${V}_aarch64.AppImage
+
jobs:
- build:
+ # Release bookkeeping first so a bad tag fails in seconds, not after a build.
+ version:
runs-on: ubuntu-24.04-arm
- timeout-minutes: 300
- continue-on-error: true
outputs:
version: ${{ steps.release.outputs.version }}
prerelease: ${{ steps.release.outputs.prerelease }}
steps:
- - name: Checkout
- uses: actions/checkout@v7
+ - uses: actions/checkout@v7
with:
+ ref: ${{ inputs.tag || github.ref }}
fetch-depth: 0
- name: Resolve release version
@@ -51,6 +70,7 @@ jobs:
VERSION="${{ inputs.tag }}"
VERSION="${VERSION#v}"
else
+ # Manual dispatch without a tag: test build at the Cargo.toml version.
VERSION="$(grep -m1 '^version = ' crates/Cargo.toml | sed 's/version = \"\(.*\)\"/\1/')"
fi
PRERELEASE=false
@@ -59,103 +79,72 @@ jobs:
fi
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
echo "prerelease=$PRERELEASE" >> "$GITHUB_OUTPUT"
+ echo "Building version $VERSION (prerelease=$PRERELEASE)"
+ # Release tags must be cut from main. A tag on any other commit fires
+ # this workflow too (tags are not branch-scoped in Actions), so verify
+ # ancestry instead of trusting convention. Manual dispatch skips this.
- 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."
+ echo "::error::Tag ${GITHUB_REF#refs/tags/} points to a commit that is NOT on main. Releases are cut from main only."
exit 1
fi
- - name: Free disk space and add swap
- run: |
- sudo rm -rf /usr/share/dotnet /opt/ghc /usr/local/lib/android \
- /opt/hostedtoolcache /usr/local/share/boost /usr/share/swift \
- /usr/local/lib/node_modules || true
- sudo docker system prune -af || true
- if sudo fallocate -l 18G /swap_extra; then
- sudo chmod 600 /swap_extra
- sudo mkswap /swap_extra
- sudo swapon /swap_extra
- else
- echo "::warning::could not allocate extra swap; build may OOM"
- fi
- free -h
-
- - name: Install system dependencies
- run: |
- sudo apt-get update
- sudo apt-get install -y \
- mold nasm \
- libasound2-dev libjack-jackd2-dev \
- libfontconfig1-dev libfreetype-dev \
- libxkbcommon-dev libwayland-dev libxcb1-dev \
- libgl1-mesa-dev libegl1-mesa-dev \
- libdbus-1-dev libssl-dev \
- libharfbuzz-dev libjbig2dec0-dev libopenjp2-7-dev \
- libjpeg-dev zlib1g-dev libgumbo-dev
-
- # Pinned nightly: matches build-slint.yml / release-linux.yml.
- - name: Setup Rust
- uses: dtolnay/rust-toolchain@master
- with:
- toolchain: nightly-2026-06-23
-
- - name: Rust cache
- uses: Swatinem/rust-cache@v2
- with:
- workspaces: crates -> target
- shared-key: slint-linux-aarch64
- cache-on-failure: true
- # This workflow is the only aarch64 builder, so it owns its cache.
- save-if: true
-
- - name: Build (release, MIN memory tier)
+ # The binary embeds the workspace version (About, diagnostics); a
+ # mismatched tag would ship a binary that reports the wrong version.
+ - name: Assert Cargo.toml version matches tag
+ if: startsWith(github.ref, 'refs/tags/') || inputs.tag != ''
env:
- CARGO_INCREMENTAL: "0"
- CARGO_BUILD_JOBS: "1"
- CARGO_PROFILE_RELEASE_CODEGEN_UNITS: "256"
- CARGO_PROFILE_RELEASE_OPT_LEVEL: "2"
- CARGO_PROFILE_RELEASE_STRIP: "none"
- # No x86 target-features here (aarch64 baseline has NEON).
- RUSTFLAGS: "-C link-arg=-fuse-ld=mold -Z threads=1"
+ VERSION: ${{ steps.release.outputs.version }}
run: |
- cargo build --release --manifest-path crates/Cargo.toml -p qbz
- cp crates/target/release/qbz qbz-unstripped
- strip -o qbz crates/target/release/qbz
- ls -la qbz qbz-unstripped
+ 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). Bump it before tagging (see RELEASE_CHECKLIST)."
+ exit 1
+ fi
- - name: Upload binary
- uses: actions/upload-artifact@v7
- with:
- name: qbz-linux-aarch64
- path: qbz
- if-no-files-found: error
+ # The media-widget icon invariant (DesktopEntry == installed .desktop
+ # basename, Icon= installed) — regressed more than once, so it is a
+ # gate at the recipe level here and on the built packages below.
+ - name: Gate — desktop entry / icon invariant (recipes)
+ run: scripts/packaging/desktop_entry_check.sh
- - name: Upload unstripped binary (symbolication)
- uses: actions/upload-artifact@v7
- with:
- name: qbz-linux-aarch64-unstripped
- path: qbz-unstripped
- retention-days: 90
+ # No workflow may build the frozen Slint binary: same bin name, same
+ # target dir, last build wins the file — a smoke against "the wrong
+ # qbz" is silent.
+ - name: Gate — no workflow builds `-p qbz`
+ run: |
+ set -euo pipefail
+ hits=$(grep -rnE 'cargo (build|check|test|run|install).* -p qbz([^-a-z]|$)' .github/workflows/ || true)
+ [ -z "$hits" ] || { echo "::error::a workflow still builds the Slint crate:"; echo "$hits"; exit 1; }
- # T15: aarch64 mirror of release-linux.yml's build-qbzd job — see that
- # file's header comment for the glibc-floor rationale. Uses the native
- # ubuntu-22.04-arm runner label rather than cross-compiling.
- #
- # Fallback note (NOT implemented — flagged for pre-flight per the T15
- # brief): if ubuntu-22.04-arm is ever unavailable to this repo, cross-build
- # instead via the existing Cross.toml pattern (crates/Cross.toml,
- # scripts/build-aarch64-linux.sh's CROSS mode) targeting a 2.35-floor
- # image; the objdump gate below stays the acceptance test either way.
+ build:
+ needs: version
+ uses: ./.github/workflows/build-qt-linux.yml
+ with:
+ arch: aarch64
+ source_ref: ${{ inputs.tag || github.ref }}
+
+ # qbzd is minutes-class and Slint-free. Runs on ubuntu-22.04 so the shipped
+ # binary's glibc floor stays <= Pi OS bookworm's 2.36 (the honest-Pi-story
+ # gate); the objdump step verifies that empirically.
build-qbzd:
runs-on: ubuntu-22.04-arm
timeout-minutes: 30
steps:
- uses: actions/checkout@v7
+ with:
+ ref: ${{ inputs.tag || github.ref }}
- uses: dtolnay/rust-toolchain@stable
+ - uses: Swatinem/rust-cache@v2
+ with:
+ workspaces: crates -> target
+ shared-key: qbzd-linux-aarch64
+ cache-on-failure: true
+ save-if: ${{ github.ref == 'refs/heads/main' || github.ref == 'refs/heads/pre-release' }}
- name: system deps
run: sudo apt-get update && sudo apt-get install -y libasound2-dev libjack-jackd2-dev libdbus-1-dev libssl-dev
- name: slint-free dep-graph gate
@@ -174,37 +163,45 @@ jobs:
echo "glibc floor: $floor"
highest=$(printf '%s\n' "$floor" "GLIBC_2.35" | sort -V | tail -1)
[ "$highest" = "GLIBC_2.35" ] || { echo "::error::floor $floor exceeds 2.35"; exit 1; }
- # NOTE: the T15 brief's literal YAML names this artifact
- # `qbzd-linux-arm64`, but T14 (39eb12fc, already merged into this
- # branch) wired the package job's Download-qbzd step below to expect
- # `qbzd-linux-aarch64` — matching what already landed wins over the
- # brief text (see task-15-report.md for the full note).
- uses: actions/upload-artifact@v7
- with: { name: qbzd-linux-aarch64, path: crates/target/release/qbzd }
+ with:
+ name: qbzd-linux-aarch64
+ path: crates/target/release/qbzd
+ retention-days: 7
package:
- needs: [build, build-qbzd]
+ needs: [version, build, build-qbzd]
runs-on: ubuntu-24.04-arm
permissions:
contents: write
env:
- VERSION: ${{ needs.build.outputs.version }}
+ VERSION: ${{ needs.version.outputs.version }}
steps:
- name: Checkout
uses: actions/checkout@v7
+ with:
+ ref: ${{ inputs.tag || github.ref }}
- - name: Download binary
+ - name: Download bare binary
uses: actions/download-artifact@v8
with:
- name: qbz-linux-aarch64
+ name: qbz-linux-${{ env.ARCH }}
+
+ - name: Download AppDir
+ uses: actions/download-artifact@v8
+ with:
+ name: qbz-appdir-${{ env.ARCH }}
- # T14: the slint-free qbzd binary, produced by T15's build-qbzd job — see
- # release-linux.yml's matching step for the full rationale (one
- # artifact feeds nfpm, the main tarball, and the standalone tarball).
- name: Download qbzd binary
uses: actions/download-artifact@v8
with:
- name: qbzd-linux-aarch64
+ name: qbzd-linux-${{ env.ARCH }}
+
+ - name: Unpack AppDir
+ run: |
+ tar -xf appdir.tar
+ chmod +x qbz qbzd
+ ls AppDir; ls AppDir/usr/plugins/platforms
- name: Install nfpm
run: |
@@ -214,37 +211,55 @@ jobs:
sudo apt-get install -y nfpm
nfpm --version
+ # deb/rpm carry the AppDir tree under /opt/qbz (bin, lib, plugins, qml,
+ # qt.conf) and a /usr/bin/qbz symlink. RPATH ($ORIGIN/../lib, patched by
+ # linuxdeploy) resolves through /proc/self/exe, so the symlink needs no
+ # wrapper — and a wrapper exporting LD_LIBRARY_PATH would leak our Qt
+ # into every process qbz spawns (the login browser, xdg-open).
- name: Build deb + rpm (nfpm)
env:
QBZ_VERSION: ${{ env.VERSION }}
- QBZ_ARCH: arm64
- QBZ_BINARY: ./qbz
+ QBZ_ARCH: ${{ env.DEB_ARCH }}
run: |
- chmod +x qbz
- chmod +x qbzd
mkdir -p dist
+ ./qbzd completions bash > qbzd.bash
+ ./qbzd completions zsh > qbzd.zsh
+ ./qbzd completions fish > qbzd.fish
nfpm package -f packaging/nfpm/nfpm.yaml -p deb \
- -t "dist/QBZ_${VERSION}_arm64.deb"
+ -t "dist/QBZ_${VERSION}_${DEB_ARCH}.deb"
nfpm package -f packaging/nfpm/nfpm.yaml -p rpm \
- -t "dist/QBZ-${VERSION}-1.aarch64.rpm"
+ -t "dist/QBZ-${VERSION}-1.${RPM_ARCH}.rpm"
+ # Standalone qbzd packages (daemon-only), same staged ./qbzd.
+ nfpm package -f packaging/nfpm/nfpm-qbzd.yaml -p deb \
+ -t "dist/qbzd_${VERSION}_${DEB_ARCH}.deb"
+ nfpm package -f packaging/nfpm/nfpm-qbzd.yaml -p rpm \
+ -t "dist/qbzd-${VERSION}-1.${RPM_ARCH}.rpm"
+ ls -la dist
+
+ # The same invariant on the ARTIFACTS: the deb/rpm must install
+ # /usr/share/applications/.desktop and its Icon=.
+ - name: Gate — desktop entry / icon invariant (packages)
+ run: |
+ sudo apt-get install -y --no-install-recommends rpm
+ scripts/packaging/desktop_entry_check.sh \
+ --deb "dist/QBZ_${VERSION}_${DEB_ARCH}.deb" \
+ --rpm "dist/QBZ-${VERSION}-1.${RPM_ARCH}.rpm" \
+ --appdir AppDir
- name: Build AppImage
run: |
- APPDIR="QBZ.AppDir"
- mkdir -p "$APPDIR/usr/bin"
- cp qbz "$APPDIR/usr/bin/qbz"
- cp packaging/linux/qbz.desktop "$APPDIR/qbz.desktop"
- cp packaging/icons/256x256.png "$APPDIR/qbz.png"
- cp packaging/icons/256x256.png "$APPDIR/.DirIcon"
- ln -s usr/bin/qbz "$APPDIR/AppRun"
-
curl -fsSL -o appimagetool \
- https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-aarch64.AppImage
+ "https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-${ARCH}.AppImage"
chmod +x appimagetool
- ARCH=aarch64 ./appimagetool --appimage-extract-and-run "$APPDIR" \
- "dist/QBZ_${VERSION}_aarch64.AppImage"
+ ARCH="${ARCH}" ./appimagetool --appimage-extract-and-run AppDir \
+ "dist/QBZ_${VERSION}_${APPIMAGE_ARCH}.AppImage"
+ ls -la dist
- # Tauri-updater bridge — see release-linux.yml for the rationale.
+ # Tauri-updater bridge: v1.2.x AppImage installs poll latest.json and
+ # verify artifacts with the Tauri minisign pubkey baked into the old
+ # binaries. Signing with the SAME private key lets them auto-update
+ # straight into this AppImage. release-updater-manifest.yml collects
+ # the .sig. The app itself has no auto-updater.
- name: Sign AppImage (Tauri-updater bridge)
if: startsWith(github.ref, 'refs/tags/')
env:
@@ -254,14 +269,16 @@ jobs:
npx --yes @tauri-apps/cli@^2 signer sign \
--private-key "$TAURI_SIGNING_PRIVATE_KEY" \
--password "$TAURI_SIGNING_PRIVATE_KEY_PASSWORD" \
- "dist/QBZ_${VERSION}_aarch64.AppImage"
+ "dist/QBZ_${VERSION}_${APPIMAGE_ARCH}.AppImage"
ls -la dist
+ # The BARE binary. Its consumers (AUR, Gentoo, Flathub) provide Qt.
- name: Build AUR/Gentoo tarball
run: |
- # CONTRACT: same name + internal layout as the Tauri-era tarball.
- TARBALL_DIR="qbz_${VERSION}_aarch64"
- mkdir -p "tarball-staging/${TARBALL_DIR}/icons/hicolor"/{32x32,48x48,64x64,128x128,256x256}/apps
+ # CONTRACT: release-aur.yml, the Gentoo binary ebuild, qbz-apt and
+ # the website hard-code this name and internal layout.
+ TARBALL_DIR="qbz_${VERSION}_${DEB_ARCH}"
+ mkdir -p "tarball-staging/${TARBALL_DIR}/icons/hicolor"/{32x32,48x48,64x64,128x128,256x256,512x512}/apps
cp qbz "tarball-staging/${TARBALL_DIR}/qbz"
chmod +x "tarball-staging/${TARBALL_DIR}/qbz"
cp packaging/icons/32x32.png "tarball-staging/${TARBALL_DIR}/icons/hicolor/32x32/apps/qbz.png"
@@ -269,57 +286,52 @@ jobs:
cp packaging/icons/64x64.png "tarball-staging/${TARBALL_DIR}/icons/hicolor/64x64/apps/qbz.png"
cp packaging/icons/128x128.png "tarball-staging/${TARBALL_DIR}/icons/hicolor/128x128/apps/qbz.png"
cp packaging/icons/256x256.png "tarball-staging/${TARBALL_DIR}/icons/hicolor/256x256/apps/qbz.png"
+ cp packaging/icons/512x512.png "tarball-staging/${TARBALL_DIR}/icons/hicolor/512x512/apps/qbz.png"
cp packaging/linux/qbz.desktop "tarball-staging/${TARBALL_DIR}/qbz.desktop"
- # Additive (T14) — see release-linux.yml's matching block for the
- # layout-contract rationale.
+ cp packaging/flatpak/com.blitzfc.qbz.metainfo.xml "tarball-staging/${TARBALL_DIR}/com.blitzfc.qbz.metainfo.xml"
+ cp LICENSE "tarball-staging/${TARBALL_DIR}/LICENSE"
+ cp -r licenses "tarball-staging/${TARBALL_DIR}/licenses"
cp qbzd "tarball-staging/${TARBALL_DIR}/qbzd"
chmod +x "tarball-staging/${TARBALL_DIR}/qbzd"
cp crates/qbzd/service/qbzd.service "tarball-staging/${TARBALL_DIR}/qbzd.service"
mkdir -p "tarball-staging/${TARBALL_DIR}/completions"
- ./qbzd completions bash > "tarball-staging/${TARBALL_DIR}/completions/qbzd.bash"
- ./qbzd completions zsh > "tarball-staging/${TARBALL_DIR}/completions/qbzd.zsh"
- ./qbzd completions fish > "tarball-staging/${TARBALL_DIR}/completions/qbzd.fish"
- tar -czvf "dist/qbz_${VERSION}_aarch64.tar.gz" -C tarball-staging "${TARBALL_DIR}"
+ cp qbzd.bash "tarball-staging/${TARBALL_DIR}/completions/qbzd.bash"
+ cp qbzd.zsh "tarball-staging/${TARBALL_DIR}/completions/qbzd.zsh"
+ cp qbzd.fish "tarball-staging/${TARBALL_DIR}/completions/qbzd.fish"
+ tar -czvf "dist/qbz_${VERSION}_${DEB_ARCH}.tar.gz" -C tarball-staging "${TARBALL_DIR}"
ls -la dist
- # T15 Step 2b (OD3, G0) — see release-linux.yml's matching step for
- # the rationale. Never touches snap/Flathub/AppImage/dmg.
- name: qbzd-tarball
run: |
- QBZD_DIR="qbzd-${VERSION}-linux-aarch64"
+ QBZD_DIR="qbzd-${VERSION}-linux-${DEB_ARCH}"
mkdir -p "qbzd-tarball-staging/${QBZD_DIR}/completions"
cp qbzd "qbzd-tarball-staging/${QBZD_DIR}/qbzd"
chmod +x "qbzd-tarball-staging/${QBZD_DIR}/qbzd"
cp crates/qbzd/service/qbzd.service "qbzd-tarball-staging/${QBZD_DIR}/qbzd.service"
- ./qbzd completions bash > "qbzd-tarball-staging/${QBZD_DIR}/completions/qbzd.bash"
- ./qbzd completions zsh > "qbzd-tarball-staging/${QBZD_DIR}/completions/qbzd.zsh"
- ./qbzd completions fish > "qbzd-tarball-staging/${QBZD_DIR}/completions/qbzd.fish"
+ cp qbzd.bash "qbzd-tarball-staging/${QBZD_DIR}/completions/qbzd.bash"
+ cp qbzd.zsh "qbzd-tarball-staging/${QBZD_DIR}/completions/qbzd.zsh"
+ cp qbzd.fish "qbzd-tarball-staging/${QBZD_DIR}/completions/qbzd.fish"
cp packaging/linux/qbzd-standalone-README.md "qbzd-tarball-staging/${QBZD_DIR}/README.md"
- tar -czvf "dist/qbzd-${VERSION}-linux-aarch64.tar.gz" -C qbzd-tarball-staging "${QBZD_DIR}"
+ cp LICENSE "qbzd-tarball-staging/${QBZD_DIR}/LICENSE"
+ cp -r licenses "qbzd-tarball-staging/${QBZD_DIR}/licenses"
+ tar -czvf "dist/qbzd-${VERSION}-linux-${DEB_ARCH}.tar.gz" -C qbzd-tarball-staging "${QBZD_DIR}"
ls -la dist
- name: Upload artifacts
uses: actions/upload-artifact@v7
with:
- name: qbz-linux-aarch64-packages
+ name: qbz-linux-packages-${{ env.ARCH }}
path: dist/*
+ retention-days: ${{ startsWith(github.ref, 'refs/tags/v') && 90 || 7 }}
- # 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/*
- prerelease: ${{ needs.build.outputs.prerelease }}
- make_latest: ${{ needs.build.outputs.prerelease == 'true' && 'false' || 'true' }}
+ prerelease: ${{ needs.version.outputs.prerelease }}
+ make_latest: ${{ needs.version.outputs.prerelease == 'true' && 'false' || 'true' }}
- # #648: the apt dispatch MUST also fire from here — this workflow is the
- # slow one (native arm64 build), so release-linux.yml's dispatch ran
- # hours earlier, when the Release still held only the x86_64 .deb; the
- # arm64 .deb landed after and nothing re-indexed (apt arm64 stuck at
- # 1.2.15). update-repo.yml is idempotent (re-downloads every *.deb,
- # overwrites the pool, rebuilds both indices), so a second run simply
- # completes whichever arch the first one missed.
- name: Trigger APT repo update
if: startsWith(github.ref, 'refs/tags/')
uses: peter-evans/repository-dispatch@v4
@@ -328,3 +340,14 @@ jobs:
repository: vicrodh/qbz-apt
event-type: new-release
client-payload: '{"tag": "${{ github.ref_name }}"}'
+
+ # Our own signed RPM repo. This is intentionally required: a bad token
+ # must not leave the stable RPM channel stale without failing release CI.
+ - name: Trigger RPM repo update
+ if: startsWith(github.ref, 'refs/tags/')
+ uses: peter-evans/repository-dispatch@v4
+ with:
+ token: ${{ secrets.APT_REPO_TOKEN }}
+ repository: vicrodh/qbz-rpm
+ event-type: new-release
+ client-payload: '{"tag": "${{ github.ref_name }}"}'
diff --git a/.github/workflows/release-linux.yml b/.github/workflows/release-linux.yml
index 1bb546403..e7d7a3efc 100644
--- a/.github/workflows/release-linux.yml
+++ b/.github/workflows/release-linux.yml
@@ -1,17 +1,26 @@
-# Linux x86_64 release — Slint binary (crates/ workspace).
+# Linux x86_64 release — Qt frontend (crates/ workspace, package `qbz-qt`,
+# binary `qbz`).
#
-# Rewritten 2026-07-02 for the Tauri -> Slint migration (v2.0). Pattern:
-# build once, package many. The build job produces the binary with the
-# proven public-runner recipe from build-slint.yml (16 GB RAM + 18 GB extra
-# swap covers the ~30 GB qbz_ui rustc peak, MIN memory tier, mold, pinned
-# nightly); the package job turns it into deb/rpm (nfpm), AppImage, and the
-# AUR/Gentoo/APT tarball, then creates the GitHub Release.
+# Rewritten 2026-08-25 for the Slint -> Qt frontend switch. Pattern is still
+# build once, package many — but the build is the shared build-qt-linux.yml
+# (workflow_call) and it yields TWO payloads:
+# * the bare binary -> qbz_${VERSION}_amd64.tar.gz (AUR / Gentoo / Flathub
+# repack: ecosystems that provide Qt >= 6.8 themselves)
+# * the AppDir (Qt bundled, self-contained) -> deb / rpm / AppImage, so
+# distros without a usable Qt (Ubuntu 24.04 = Qt 6.4) keep working.
#
# CONTRACT (do not break): the release must keep publishing
-# qbz_${VERSION}_amd64.tar.gz (dir qbz_${VERSION}_amd64/{qbz, qbz.desktop, icons/hicolor/...})
+# qbz_${VERSION}_amd64.tar.gz (dir qbz_${VERSION}_amd64/{qbz, qbzd, LICENSE, licenses/, qbz.desktop,
+# com.blitzfc.qbz.metainfo.xml, qbzd.service,
+# completions/, icons/hicolor/...})
# QBZ_${VERSION}_amd64.deb / QBZ-${VERSION}-1.x86_64.rpm / QBZ_${VERSION}_amd64.AppImage
-# — release-aur.yml, release-gentoo.yml, qbz-apt and the website consume
-# these names.
+# qbzd_${VERSION}_amd64.deb / qbzd-${VERSION}-1.x86_64.rpm / qbzd-${VERSION}-linux-amd64.tar.gz
+# qbz-${VERSION}-cargo-vendor.tar.xz (architecture-neutral, x86_64 workflow only)
+# — release-aur.yml, release-gentoo.yml, qbz-apt, qbz-rpm, the updater
+# manifest and the website consume these names.
+#
+# release-linux-aarch64.yml is this file with the four ARCH_* values changed,
+# except that x86_64 publishes the architecture-neutral Cargo vendor asset.
#
# Triggers: version tags (cut from main — enforced by the ancestry guard)
# + manual dispatch (guard skipped, for testing on any branch).
@@ -22,7 +31,7 @@ on:
workflow_dispatch:
inputs:
tag:
- description: "Optional release tag to build (e.g. v2.0.0). Empty = build HEAD with the Cargo.toml version, no Release publish."
+ 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:
@@ -33,17 +42,23 @@ concurrency:
group: release-linux-${{ github.ref }}
cancel-in-progress: false
+env:
+ ARCH: x86_64 # build-qt-linux input + artifact suffix
+ DEB_ARCH: amd64 # QBZ_${V}_amd64.deb, qbz_${V}_amd64.tar.gz
+ RPM_ARCH: x86_64 # QBZ-${V}-1.x86_64.rpm
+ APPIMAGE_ARCH: amd64 # QBZ_${V}_amd64.AppImage
+
jobs:
- build:
+ # Release bookkeeping first so a bad tag fails in seconds, not after a build.
+ version:
runs-on: ubuntu-24.04
- timeout-minutes: 300
outputs:
version: ${{ steps.release.outputs.version }}
prerelease: ${{ steps.release.outputs.prerelease }}
steps:
- - name: Checkout
- uses: actions/checkout@v7
+ - uses: actions/checkout@v7
with:
+ ref: ${{ inputs.tag || github.ref }}
fetch-depth: 0
- name: Resolve release version
@@ -69,8 +84,7 @@ jobs:
# Release tags must be cut from main. A tag on any other commit fires
# this workflow too (tags are not branch-scoped in Actions), so verify
- # ancestry instead of trusting convention. Manual dispatch skips this
- # (that is the testing path).
+ # ancestry instead of trusting convention. Manual dispatch skips this.
- name: Verify tag is on main
if: startsWith(github.ref, 'refs/tags/')
run: |
@@ -80,11 +94,10 @@ jobs:
exit 1
fi
- # Fail fast if the version bump was forgotten: the binary embeds the
- # workspace version (About dialog, diagnostics), so a mismatched tag
- # would ship a binary that reports the wrong version.
+ # The binary embeds the workspace version (About, diagnostics); a
+ # mismatched tag would ship a binary that reports the wrong version.
- name: Assert Cargo.toml version matches tag
- if: startsWith(github.ref, 'refs/tags/')
+ if: startsWith(github.ref, 'refs/tags/') || inputs.tag != ''
env:
VERSION: ${{ steps.release.outputs.version }}
run: |
@@ -94,100 +107,45 @@ jobs:
exit 1
fi
- # The qbz_ui rustc needs ~30 GB (RSS+swap). Public runner: 16 GB RAM
- # + ~4 GB stock swap; free disk by dropping preinstalled toolchains,
- # then add 18 GB swap -> ~38 GB total. Same recipe as build-slint.yml.
- - name: Free disk space and add swap
+ # The media-widget icon invariant (DesktopEntry == installed .desktop
+ # basename, Icon= installed) — regressed more than once, so it is a
+ # gate at the recipe level here and on the built packages below.
+ - name: Gate — desktop entry / icon invariant (recipes)
+ run: scripts/packaging/desktop_entry_check.sh
+
+ # No workflow may build the frozen Slint binary: same bin name, same
+ # target dir, last build wins the file — a smoke against "the wrong
+ # qbz" is silent.
+ - name: Gate — no workflow builds `-p qbz`
run: |
- sudo rm -rf /usr/share/dotnet /opt/ghc /usr/local/lib/android \
- /opt/hostedtoolcache /usr/local/share/boost /usr/share/swift \
- /usr/local/lib/node_modules || true
- sudo docker system prune -af || true
- if sudo fallocate -l 18G /swap_extra; then
- sudo chmod 600 /swap_extra
- sudo mkswap /swap_extra
- sudo swapon /swap_extra
- else
- echo "::warning::could not allocate extra swap; build may OOM"
- fi
- free -h
+ set -euo pipefail
+ hits=$(grep -rnE 'cargo (build|check|test|run|install).* -p qbz([^-a-z]|$)' .github/workflows/ || true)
+ [ -z "$hits" ] || { echo "::error::a workflow still builds the Slint crate:"; echo "$hits"; exit 1; }
- - name: Install system dependencies
- run: |
- sudo apt-get update
- sudo apt-get install -y \
- mold nasm \
- libasound2-dev libjack-jackd2-dev \
- libfontconfig1-dev libfreetype-dev \
- libxkbcommon-dev libwayland-dev libxcb1-dev \
- libgl1-mesa-dev libegl1-mesa-dev \
- libdbus-1-dev libssl-dev \
- libharfbuzz-dev libjbig2dec0-dev libopenjp2-7-dev \
- libjpeg-dev zlib1g-dev libgumbo-dev
+ build:
+ needs: version
+ uses: ./.github/workflows/build-qt-linux.yml
+ with:
+ arch: x86_64
+ source_ref: ${{ inputs.tag || github.ref }}
- # Pinned nightly: matches build-slint.yml (same measured memory
- # behavior, -Z threads support). Bump both files together.
- - name: Setup Rust
- uses: dtolnay/rust-toolchain@master
- with:
- toolchain: nightly-2026-06-23
-
- - name: Rust cache
- uses: Swatinem/rust-cache@v2
- with:
- workspaces: crates -> target
- shared-key: slint-linux
- cache-on-failure: true
- # Own the amd64 `slint-linux` cache: save on main so pre-flights warm
- # it and tag/PR runs restore it. build-slint.yml no longer runs, so
- # deferring to it (save-if: false) left the amd64 dep cache EMPTY and
- # every release/snap amd64 build fully cold. Mirrors release-linux-aarch64.
- save-if: ${{ github.ref == 'refs/heads/main' }}
-
- - name: Build (release, MIN memory tier)
- env:
- CARGO_INCREMENTAL: "0"
- CARGO_BUILD_JOBS: "1"
- CARGO_PROFILE_RELEASE_CODEGEN_UNITS: "256"
- CARGO_PROFILE_RELEASE_OPT_LEVEL: "2"
- # Keep symbols in this build: the unstripped binary is archived for
- # crash symbolication, then stripped for every shipped artifact.
- CARGO_PROFILE_RELEASE_STRIP: "none"
- RUSTFLAGS: "-C link-arg=-fuse-ld=mold -Z threads=1"
- run: |
- cargo build --release --manifest-path crates/Cargo.toml -p qbz
- cp crates/target/release/qbz qbz-unstripped
- strip -o qbz crates/target/release/qbz
- ls -la qbz qbz-unstripped
-
- - name: Upload binary
- uses: actions/upload-artifact@v7
- with:
- name: qbz-linux-x86_64
- path: qbz
- if-no-files-found: error
-
- # Symbolication artifact: panic backtraces in shipped binaries are
- # address-only (profile strips symbols); this copy resolves them.
- - name: Upload unstripped binary (symbolication)
- uses: actions/upload-artifact@v7
- with:
- name: qbz-linux-x86_64-unstripped
- path: qbz-unstripped
- retention-days: 90
-
- # T15: qbzd is minutes-class (no qbz_ui compilation unit), so none of the
- # desktop build's swap/tier ceremony applies. Runs on ubuntu-22.04 (not
- # 24.04 like the desktop `build` job above) so the shipped binary's glibc
- # floor stays <= Pi OS bookworm's 2.36 (D13, the honest-Pi-story gate) —
- # the objdump step below verifies that empirically rather than trusting
- # the runner's advertised glibc version.
+ # qbzd is minutes-class and Slint-free. Runs on ubuntu-22.04 so the shipped
+ # binary's glibc floor stays <= Pi OS bookworm's 2.36 (the honest-Pi-story
+ # gate); the objdump step verifies that empirically.
build-qbzd:
runs-on: ubuntu-22.04
timeout-minutes: 30
steps:
- uses: actions/checkout@v7
+ with:
+ ref: ${{ inputs.tag || github.ref }}
- uses: dtolnay/rust-toolchain@stable
+ - uses: Swatinem/rust-cache@v2
+ with:
+ workspaces: crates -> target
+ shared-key: qbzd-linux-x86_64
+ cache-on-failure: true
+ save-if: ${{ github.ref == 'refs/heads/main' || github.ref == 'refs/heads/pre-release' }}
- name: system deps
run: sudo apt-get update && sudo apt-get install -y libasound2-dev libjack-jackd2-dev libdbus-1-dev libssl-dev
- name: slint-free dep-graph gate
@@ -206,40 +164,48 @@ jobs:
echo "glibc floor: $floor"
highest=$(printf '%s\n' "$floor" "GLIBC_2.35" | sort -V | tail -1)
[ "$highest" = "GLIBC_2.35" ] || { echo "::error::floor $floor exceeds 2.35"; exit 1; }
- # NOTE: the T15 brief's literal YAML names this artifact
- # `qbzd-linux-amd64`, but T14 (39eb12fc, already merged into this
- # branch) wired the package job's Download-qbzd step below to expect
- # `qbzd-linux-x86_64` — matching what already landed wins over the
- # brief text (see task-15-report.md for the full note).
- uses: actions/upload-artifact@v7
- with: { name: qbzd-linux-x86_64, path: crates/target/release/qbzd }
+ with:
+ name: qbzd-linux-x86_64
+ path: crates/target/release/qbzd
+ retention-days: 7
package:
- needs: [build, build-qbzd]
+ needs: [version, build, build-qbzd]
runs-on: ubuntu-24.04
permissions:
contents: write
env:
- VERSION: ${{ needs.build.outputs.version }}
+ VERSION: ${{ needs.version.outputs.version }}
steps:
- name: Checkout
uses: actions/checkout@v7
+ with:
+ ref: ${{ inputs.tag || github.ref }}
- - name: Download binary
+ - name: Install Rust toolchain for source-package vendor archive
+ uses: dtolnay/rust-toolchain@stable
+
+ - name: Download bare binary
uses: actions/download-artifact@v8
with:
- name: qbz-linux-x86_64
+ name: qbz-linux-${{ env.ARCH }}
+
+ - name: Download AppDir
+ uses: actions/download-artifact@v8
+ with:
+ name: qbz-appdir-${{ env.ARCH }}
- # T14: the slint-free qbzd binary, produced by T15's build-qbzd job
- # (a separate ubuntu-22.04 job, not an extension of `build` above —
- # see that job for the glibc-floor rationale). This ONE artifact is
- # consumed for deb/rpm (nfpm), the main AUR/Gentoo tarball below, AND
- # the standalone qbzd tarball (T15 Step 2b) further down — all three
- # ship the same 2.35-glibc-floor binary.
- name: Download qbzd binary
uses: actions/download-artifact@v8
with:
- name: qbzd-linux-x86_64
+ name: qbzd-linux-${{ env.ARCH }}
+
+ - name: Unpack AppDir
+ run: |
+ tar -xf appdir.tar
+ chmod +x qbz qbzd
+ ls AppDir; ls AppDir/usr/plugins/platforms
- name: Install nfpm
run: |
@@ -249,46 +215,55 @@ jobs:
sudo apt-get install -y nfpm
nfpm --version
+ # deb/rpm carry the AppDir tree under /opt/qbz (bin, lib, plugins, qml,
+ # qt.conf) and a /usr/bin/qbz symlink. RPATH ($ORIGIN/../lib, patched by
+ # linuxdeploy) resolves through /proc/self/exe, so the symlink needs no
+ # wrapper — and a wrapper exporting LD_LIBRARY_PATH would leak our Qt
+ # into every process qbz spawns (the login browser, xdg-open).
- name: Build deb + rpm (nfpm)
env:
QBZ_VERSION: ${{ env.VERSION }}
- QBZ_ARCH: amd64
- QBZ_BINARY: ./qbz
+ QBZ_ARCH: ${{ env.DEB_ARCH }}
run: |
- chmod +x qbz
- chmod +x qbzd
mkdir -p dist
+ ./qbzd completions bash > qbzd.bash
+ ./qbzd completions zsh > qbzd.zsh
+ ./qbzd completions fish > qbzd.fish
nfpm package -f packaging/nfpm/nfpm.yaml -p deb \
- -t "dist/QBZ_${VERSION}_amd64.deb"
+ -t "dist/QBZ_${VERSION}_${DEB_ARCH}.deb"
nfpm package -f packaging/nfpm/nfpm.yaml -p rpm \
- -t "dist/QBZ-${VERSION}-1.x86_64.rpm"
+ -t "dist/QBZ-${VERSION}-1.${RPM_ARCH}.rpm"
+ # Standalone qbzd packages (daemon-only), same staged ./qbzd.
+ nfpm package -f packaging/nfpm/nfpm-qbzd.yaml -p deb \
+ -t "dist/qbzd_${VERSION}_${DEB_ARCH}.deb"
+ nfpm package -f packaging/nfpm/nfpm-qbzd.yaml -p rpm \
+ -t "dist/qbzd-${VERSION}-1.${RPM_ARCH}.rpm"
+ ls -la dist
+
+ # The same invariant on the ARTIFACTS: the deb/rpm must install
+ # /usr/share/applications/.desktop and its Icon=.
+ - name: Gate — desktop entry / icon invariant (packages)
+ run: |
+ sudo apt-get install -y --no-install-recommends rpm
+ scripts/packaging/desktop_entry_check.sh \
+ --deb "dist/QBZ_${VERSION}_${DEB_ARCH}.deb" \
+ --rpm "dist/QBZ-${VERSION}-1.${RPM_ARCH}.rpm" \
+ --appdir AppDir
- name: Build AppImage
run: |
- # Minimal AppDir: the measured runtime deps (alsa/fontconfig/
- # freetype/png/bz2/expat/zlib) are base-system libraries on every
- # target distro, so nothing is bundled beyond the binary.
- APPDIR="QBZ.AppDir"
- mkdir -p "$APPDIR/usr/bin"
- cp qbz "$APPDIR/usr/bin/qbz"
- cp packaging/linux/qbz.desktop "$APPDIR/qbz.desktop"
- cp packaging/icons/256x256.png "$APPDIR/qbz.png"
- cp packaging/icons/256x256.png "$APPDIR/.DirIcon"
- ln -s usr/bin/qbz "$APPDIR/AppRun"
-
curl -fsSL -o appimagetool \
- https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-x86_64.AppImage
+ "https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-${ARCH}.AppImage"
chmod +x appimagetool
- ARCH=x86_64 ./appimagetool --appimage-extract-and-run "$APPDIR" \
- "dist/QBZ_${VERSION}_amd64.AppImage"
+ ARCH="${ARCH}" ./appimagetool --appimage-extract-and-run AppDir \
+ "dist/QBZ_${VERSION}_${APPIMAGE_ARCH}.AppImage"
+ ls -la dist
# Tauri-updater bridge: v1.2.x AppImage installs poll latest.json and
# verify artifacts with the Tauri minisign pubkey baked into the old
- # binaries. Signing the Slint AppImage with the SAME private key lets
- # them auto-update straight into v2 (the updater just swaps the
- # AppImage file). release-updater-manifest.yml collects the .sig into
- # latest.json. The Slint app itself has no auto-updater — this exists
- # only to migrate old installs.
+ # binaries. Signing with the SAME private key lets them auto-update
+ # straight into this AppImage. release-updater-manifest.yml collects
+ # the .sig. The app itself has no auto-updater.
- name: Sign AppImage (Tauri-updater bridge)
if: startsWith(github.ref, 'refs/tags/')
env:
@@ -298,15 +273,16 @@ jobs:
npx --yes @tauri-apps/cli@^2 signer sign \
--private-key "$TAURI_SIGNING_PRIVATE_KEY" \
--password "$TAURI_SIGNING_PRIVATE_KEY_PASSWORD" \
- "dist/QBZ_${VERSION}_amd64.AppImage"
+ "dist/QBZ_${VERSION}_${APPIMAGE_ARCH}.AppImage"
ls -la dist
+ # The BARE binary. Its consumers (AUR, Gentoo, Flathub) provide Qt.
- name: Build AUR/Gentoo tarball
run: |
# CONTRACT: release-aur.yml, the Gentoo binary ebuild, qbz-apt and
# the website hard-code this name and internal layout.
- TARBALL_DIR="qbz_${VERSION}_amd64"
- mkdir -p "tarball-staging/${TARBALL_DIR}/icons/hicolor"/{32x32,48x48,64x64,128x128,256x256}/apps
+ TARBALL_DIR="qbz_${VERSION}_${DEB_ARCH}"
+ mkdir -p "tarball-staging/${TARBALL_DIR}/icons/hicolor"/{32x32,48x48,64x64,128x128,256x256,512x512}/apps
cp qbz "tarball-staging/${TARBALL_DIR}/qbz"
chmod +x "tarball-staging/${TARBALL_DIR}/qbz"
cp packaging/icons/32x32.png "tarball-staging/${TARBALL_DIR}/icons/hicolor/32x32/apps/qbz.png"
@@ -314,55 +290,57 @@ jobs:
cp packaging/icons/64x64.png "tarball-staging/${TARBALL_DIR}/icons/hicolor/64x64/apps/qbz.png"
cp packaging/icons/128x128.png "tarball-staging/${TARBALL_DIR}/icons/hicolor/128x128/apps/qbz.png"
cp packaging/icons/256x256.png "tarball-staging/${TARBALL_DIR}/icons/hicolor/256x256/apps/qbz.png"
+ cp packaging/icons/512x512.png "tarball-staging/${TARBALL_DIR}/icons/hicolor/512x512/apps/qbz.png"
cp packaging/linux/qbz.desktop "tarball-staging/${TARBALL_DIR}/qbz.desktop"
- # Additive (T14): qbzd + its user unit + shell completions. Every
- # entry above is untouched — the layout contract (header lines
- # 10-14) forbids renaming/moving existing entries. AUR/Gentoo/flake
- # install-line updates to pick these up are P1 (T22, per D13); the
- # files are inert there until then.
+ cp packaging/flatpak/com.blitzfc.qbz.metainfo.xml "tarball-staging/${TARBALL_DIR}/com.blitzfc.qbz.metainfo.xml"
+ cp LICENSE "tarball-staging/${TARBALL_DIR}/LICENSE"
+ cp -r licenses "tarball-staging/${TARBALL_DIR}/licenses"
cp qbzd "tarball-staging/${TARBALL_DIR}/qbzd"
chmod +x "tarball-staging/${TARBALL_DIR}/qbzd"
cp crates/qbzd/service/qbzd.service "tarball-staging/${TARBALL_DIR}/qbzd.service"
mkdir -p "tarball-staging/${TARBALL_DIR}/completions"
- ./qbzd completions bash > "tarball-staging/${TARBALL_DIR}/completions/qbzd.bash"
- ./qbzd completions zsh > "tarball-staging/${TARBALL_DIR}/completions/qbzd.zsh"
- ./qbzd completions fish > "tarball-staging/${TARBALL_DIR}/completions/qbzd.fish"
- tar -czvf "dist/qbz_${VERSION}_amd64.tar.gz" -C tarball-staging "${TARBALL_DIR}"
+ cp qbzd.bash "tarball-staging/${TARBALL_DIR}/completions/qbzd.bash"
+ cp qbzd.zsh "tarball-staging/${TARBALL_DIR}/completions/qbzd.zsh"
+ cp qbzd.fish "tarball-staging/${TARBALL_DIR}/completions/qbzd.fish"
+ tar -czvf "dist/qbz_${VERSION}_${DEB_ARCH}.tar.gz" -C tarball-staging "${TARBALL_DIR}"
ls -la dist
- # T15 Step 2b (OD3, G0): the standalone qbzd tarball — the independent
- # daemon download the owner asked for, separate from the main qbz
- # tarball above. Never touches snap/Flathub/AppImage/dmg (owner
- # constraint) — this only adds one more file into dist/, which the
- # existing Upload-artifacts and Publish-Release-Assets steps below
- # already pick up unmodified via their dist/* globs.
- name: qbzd-tarball
run: |
- QBZD_DIR="qbzd-${VERSION}-linux-amd64"
+ QBZD_DIR="qbzd-${VERSION}-linux-${DEB_ARCH}"
mkdir -p "qbzd-tarball-staging/${QBZD_DIR}/completions"
cp qbzd "qbzd-tarball-staging/${QBZD_DIR}/qbzd"
chmod +x "qbzd-tarball-staging/${QBZD_DIR}/qbzd"
cp crates/qbzd/service/qbzd.service "qbzd-tarball-staging/${QBZD_DIR}/qbzd.service"
- ./qbzd completions bash > "qbzd-tarball-staging/${QBZD_DIR}/completions/qbzd.bash"
- ./qbzd completions zsh > "qbzd-tarball-staging/${QBZD_DIR}/completions/qbzd.zsh"
- ./qbzd completions fish > "qbzd-tarball-staging/${QBZD_DIR}/completions/qbzd.fish"
+ cp qbzd.bash "qbzd-tarball-staging/${QBZD_DIR}/completions/qbzd.bash"
+ cp qbzd.zsh "qbzd-tarball-staging/${QBZD_DIR}/completions/qbzd.zsh"
+ cp qbzd.fish "qbzd-tarball-staging/${QBZD_DIR}/completions/qbzd.fish"
cp packaging/linux/qbzd-standalone-README.md "qbzd-tarball-staging/${QBZD_DIR}/README.md"
- tar -czvf "dist/qbzd-${VERSION}-linux-amd64.tar.gz" -C qbzd-tarball-staging "${QBZD_DIR}"
+ cp LICENSE "qbzd-tarball-staging/${QBZD_DIR}/LICENSE"
+ cp -r licenses "qbzd-tarball-staging/${QBZD_DIR}/licenses"
+ tar -czvf "dist/qbzd-${VERSION}-linux-${DEB_ARCH}.tar.gz" -C qbzd-tarball-staging "${QBZD_DIR}"
ls -la dist
+ # One architecture-neutral, locked dependency archive serves the qbz
+ # and qbzd source packages in AUR and Gentoo. It is generated only here,
+ # never duplicated by the arm64 release workflow.
+ - name: Build Cargo vendor archive for source packages
+ run: scripts/packaging/cargo_vendor_release.sh "$VERSION" dist
+
- name: Upload artifacts
uses: actions/upload-artifact@v7
with:
- name: qbz-linux-packages
+ name: qbz-linux-packages-${{ env.ARCH }}
path: dist/*
+ retention-days: ${{ startsWith(github.ref, 'refs/tags/v') && 90 || 7 }}
- name: Publish GitHub Release Assets
if: startsWith(github.ref, 'refs/tags/')
uses: softprops/action-gh-release@v3
with:
files: dist/*
- prerelease: ${{ needs.build.outputs.prerelease }}
- make_latest: ${{ needs.build.outputs.prerelease == 'true' && 'false' || 'true' }}
+ prerelease: ${{ needs.version.outputs.prerelease }}
+ make_latest: ${{ needs.version.outputs.prerelease == 'true' && 'false' || 'true' }}
- name: Trigger APT repo update
if: startsWith(github.ref, 'refs/tags/')
@@ -372,3 +350,14 @@ jobs:
repository: vicrodh/qbz-apt
event-type: new-release
client-payload: '{"tag": "${{ github.ref_name }}"}'
+
+ # Our own signed RPM repo. This is intentionally required: a bad token
+ # must not leave the stable RPM channel stale without failing release CI.
+ - name: Trigger RPM repo update
+ if: startsWith(github.ref, 'refs/tags/')
+ uses: peter-evans/repository-dispatch@v4
+ with:
+ token: ${{ secrets.APT_REPO_TOKEN }}
+ repository: vicrodh/qbz-rpm
+ event-type: new-release
+ client-payload: '{"tag": "${{ github.ref_name }}"}'
diff --git a/.github/workflows/release-macos.yml b/.github/workflows/release-macos.yml
index edc981aa6..556890646 100644
--- a/.github/workflows/release-macos.yml
+++ b/.github/workflows/release-macos.yml
@@ -1,16 +1,26 @@
-# macOS release — Slint binary (crates/ workspace).
+# macOS release — Qt frontend (crates/ workspace, package `qbz-qt`, binary `qbz`).
#
-# Rewritten 2026-07-02 for the Tauri -> Slint migration (v2.0). Builds the
-# qbz binary per target, assembles a minimal QBZ.app bundle (Info.plist +
-# icns + binary), ad-hoc signs it (same trust level as the unsigned Tauri
-# builds), and packs a dmg with hdiutil. No Tauri updater: the .app.tar.gz
-# + .sig updater assets are gone, dmg is the distribution.
+# 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 ; 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.
#
-# MEMORY NOTE: hosted macOS runners have ~7 GB RAM; the qbz_ui rustc peaks
-# ~30 GB on Linux. macOS swaps dynamically to SSD so the build may survive
-# (slowly) — if it proves unreliable, point runs-on at a self-hosted runner
-# on the M2 Mini instead. continue-on-error keeps a mac failure from
-# blocking the Linux release.
+# 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
@@ -18,7 +28,7 @@ on:
workflow_dispatch:
inputs:
tag:
- description: "Optional release tag to build (e.g. v2.0.0). Empty = build HEAD with the Cargo.toml version, no Release publish."
+ 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:
@@ -29,27 +39,39 @@ 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
- runs-on: macos-latest
- timeout-minutes: 360
- continue-on-error: true
+ 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
@@ -81,7 +103,7 @@ jobs:
fi
- name: Assert Cargo.toml version matches tag
- if: startsWith(github.ref, 'refs/tags/')
+ if: startsWith(github.ref, 'refs/tags/') || inputs.tag != ''
env:
VERSION: ${{ steps.release.outputs.version }}
run: |
@@ -91,35 +113,48 @@ jobs:
exit 1
fi
- # Same pinned nightly as the Linux builders (-Z threads support,
- # consistent measured behavior). Bump all release workflows together.
- - name: Setup Rust
- uses: dtolnay/rust-toolchain@master
+ # 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:
- toolchain: nightly-2026-06-23
targets: ${{ matrix.target }}
- name: Rust cache
uses: Swatinem/rust-cache@v2
with:
workspaces: crates -> target
- shared-key: slint-macos-${{ matrix.target }}
+ shared-key: qt-macos-${{ matrix.target }}-${{ env.QT_VERSION }}
cache-on-failure: true
- save-if: true
+ save-if: ${{ github.ref == 'refs/heads/main' || github.ref == 'refs/heads/pre-release' }}
- # MIN memory tier — the 7 GB hosted runner relies on macOS dynamic
- # swap for the qbz_ui rustc peak. Binary is stripped by the workspace
- # profile (strip = "symbols"), which rustc applies correctly to Mach-O.
- - name: Build (release, MIN memory tier)
+ # NEVER `-p qbz` (the frozen Slint binary shares the bin name).
+ - name: Build (release)
env:
CARGO_INCREMENTAL: "0"
- CARGO_BUILD_JOBS: "1"
- CARGO_PROFILE_RELEASE_CODEGEN_UNITS: "256"
- CARGO_PROFILE_RELEASE_OPT_LEVEL: "2"
- RUSTFLAGS: "-Z threads=1"
+ CMAKE_PREFIX_PATH: ${{ env.QT_ROOT_DIR }}
+ QMAKE: ${{ env.QT_ROOT_DIR }}/bin/qmake
+ CXXFLAGS: ${{ matrix.cxxflags }}
+ MACOSX_DEPLOYMENT_TARGET: "12.0"
run: |
- cargo build --release --manifest-path crates/Cargo.toml \
- -p qbz --target ${{ matrix.target }}
+ 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:
@@ -131,6 +166,32 @@ jobs:
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='CFBundleIconNameQBZ'
+ 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" <
@@ -142,9 +203,10 @@ jobs:
CFBundleDisplayNameQBZCFBundleExecutableqbzCFBundleIconFileicon
+ ${ICON_NAME_KEY}
CFBundleShortVersionString${VERSION}CFBundleVersion${VERSION}
- LSMinimumSystemVersion11.0
+ LSMinimumSystemVersion12.0NSHighResolutionCapableLSApplicationCategoryTypepublic.app-category.musicNSSupportsAutomaticGraphicsSwitching
@@ -159,10 +221,75 @@ jobs:
PLIST
- # Ad-hoc signature: required for arm64 Macs to launch at all;
- # matches the trust level of the previous unsigned Tauri builds.
- codesign --force --deep -s - "$APP"
- codesign --verify --verbose "$APP"
+
+ # 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:
@@ -174,10 +301,9 @@ jobs:
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. The
- # updater expects QBZ_${ARCH}.app.tar.gz — keep producing it, signed
- # with the SAME key, so old installs update straight into the Slint
- # .app. release-updater-manifest.yml collects the .sig files.
+ # 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:
@@ -196,13 +322,13 @@ jobs:
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; same
- # prerelease logic (the old workflow hardcoded prerelease: true).
+ # 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
diff --git a/.github/workflows/release-snap.yml b/.github/workflows/release-snap.yml
index e28556373..c8d66dbd9 100644
--- a/.github/workflows/release-snap.yml
+++ b/.github/workflows/release-snap.yml
@@ -1,27 +1,21 @@
-name: Release Snap (Slint, all-hosted)
+name: release-snap
-# All-hosted Snap pipeline for the native (Slint) qbz — BOTH arches. NOTHING
-# runs on the dev box, so the lack of systemd/snapd there does not matter. Two
-# independent tracks, each = build-binary -> dump-pack -> publish:
-# amd64: build-binary (ubuntu-24.04, MIN-tier + 18 GB swap) -> snap
-# (packaging/snap-slint/snapcraft.yaml, plugin: dump).
-# arm64: build-binary-arm64 (native ubuntu-24.04-arm, free for public repos,
-# reuses release-linux-aarch64.yml's cargo cache) -> snap-arm64
-# (packaging/snap-slint/snapcraft-arm64.yaml, plugin: dump).
-# The heavy compile is on free public runners; the snap pack (dump, no compile)
-# runs in seconds on a runner that has snapd. This CI upload is the SOLE publish
-# path — no Launchpad / "Build from GitHub" is used (the root snapcraft.yaml is
-# reference/local-dev only and is removed from the CI working copy below).
+# All-hosted Snap pipeline — BOTH arches. Each track = build (the shared
+# build-qt-linux.yml, which yields the self-contained AppDir) -> dump-pack ->
+# publish. No compilation in snapcraft; the pack runs in seconds on a runner
+# with snapd. This CI upload is the SOLE publish path: the Launchpad /
+# "Build from GitHub" source recipe (root snapcraft.yaml) is DELETED — core24
+# has Qt 6.4.2 and the tree needs 6.8, so a source build there cannot even
+# resolve its dependencies (it OOM'd on their amd64 fleet in the Slint era
+# anyway).
#
-# NOTE: build-binary* duplicates the deb/rpm builders' compile. When the release
-# pipeline is unified (build-once -> fan out to deb/rpm/appimage/snap/flatpak),
-# these jobs become `download-artifact` of the shared per-arch binaries instead.
+# Rewritten 2026-08-25 for the Slint -> Qt frontend switch.
on:
workflow_dispatch:
inputs:
tag:
- description: "Release tag to package (e.g. v1.2.16). Defaults to the pushed tag."
+ description: "Release tag to package (e.g. v2.1.0). Defaults to the pushed tag."
required: false
type: string
channel:
@@ -30,6 +24,11 @@ on:
default: edge
type: choice
options: [edge, beta, candidate, stable]
+ publish:
+ description: "Publish to the selected Snap Store channel"
+ required: false
+ default: false
+ type: boolean
push:
tags:
- "v*"
@@ -40,186 +39,133 @@ concurrency:
group: release-snap-${{ github.ref }}
cancel-in-progress: false
+env:
+ # snapcore/action-{build,publish}@v1 still declares node20. Hosted runners
+ # already execute them successfully on Node 24; make that migration explicit
+ # before the Node 20 runtime is removed on 2026-09-16.
+ FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: 'true'
+
jobs:
- build-binary:
+ version:
runs-on: ubuntu-24.04
- timeout-minutes: 180
+ outputs:
+ version: ${{ steps.release.outputs.version }}
steps:
- uses: actions/checkout@v7
-
- - name: Free disk + swap (sized for the ~30 GB qbz_ui peak)
- run: |
- sudo rm -rf /usr/share/dotnet /opt/ghc /usr/local/lib/android \
- /opt/hostedtoolcache /usr/local/share/boost /usr/share/swift \
- /usr/local/lib/node_modules || true
- sudo docker image prune --all --force || true
- ( sudo fallocate -l 18G /swap_extra \
- && sudo chmod 600 /swap_extra \
- && sudo mkswap /swap_extra \
- && sudo swapon /swap_extra ) || echo "extra swap skipped (non-fatal)"
- free -h; df -h /
-
- - name: System dependencies
- run: |
- sudo apt-get update
- sudo apt-get install -y --no-install-recommends \
- build-essential pkg-config cmake clang libclang-dev mold nasm \
- libasound2-dev libjack-jackd2-dev \
- libfontconfig1-dev libfreetype-dev \
- libxkbcommon-dev libwayland-dev libxcb1-dev \
- libgl1-mesa-dev libegl1-mesa-dev \
- libdbus-1-dev libssl-dev \
- libharfbuzz-dev libjbig2dec0-dev libopenjp2-7-dev \
- libjpeg-dev zlib1g-dev libgumbo-dev
-
- - name: Rust toolchain (pinned dated nightly)
- uses: dtolnay/rust-toolchain@master
with:
- toolchain: nightly-2026-06-23
+ ref: ${{ inputs.tag || github.ref }}
+ fetch-depth: 0
- - name: Cargo cache (Swatinem/rust-cache)
- uses: Swatinem/rust-cache@v2
- with:
- workspaces: crates -> target
- shared-key: slint-linux
- cache-on-failure: true
- # share the SAME cache as build-slint.yml (same shared-key) so the
- # heavy qbz_ui rlib is reused across both workflows.
- save-if: false
-
- - name: Build qbz (memory-tuned MIN tier, swap-backed)
+ - name: Resolve and validate package version
+ id: release
env:
- CARGO_INCREMENTAL: '0'
- CARGO_BUILD_JOBS: '1'
- CARGO_PROFILE_RELEASE_CODEGEN_UNITS: '256'
- CARGO_PROFILE_RELEASE_OPT_LEVEL: '2'
- RUSTFLAGS: '-C link-arg=-fuse-ld=mold -Z threads=1'
+ DISPATCH_TAG: ${{ inputs.tag }}
+ DISPATCH_PUBLISH: ${{ inputs.publish }}
run: |
- cargo build --release --manifest-path crates/Cargo.toml -p qbz
- strip crates/target/release/qbz || true
- ls -lh crates/target/release/qbz
+ set -euo pipefail
+ 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
+ if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+([.-][0-9A-Za-z.-]+)?$ ]]; then
+ echo "::error::Invalid Snap version: $VERSION"
+ exit 1
+ fi
+ 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) != requested version ($VERSION)."
+ exit 1
+ fi
+ if [[ "$GITHUB_EVENT_NAME" == workflow_dispatch && "$DISPATCH_PUBLISH" == true && -z "$DISPATCH_TAG" ]]; then
+ echo "::error::Manual publishing requires an explicit release tag"
+ exit 1
+ fi
+ echo "version=$VERSION" >> "$GITHUB_OUTPUT"
- - name: Upload binary artifact
- uses: actions/upload-artifact@v7
- with:
- name: qbz-bin-amd64
- path: crates/target/release/qbz
- if-no-files-found: error
-
- build-binary-arm64:
- # Native arm64 build (mirrors release-linux-aarch64.yml). Reuses that
- # workflow's cargo cache (shared-key slint-linux-aarch64) so it does not
- # recompile qbz_ui from cold when the aarch64 build already ran.
- runs-on: ubuntu-24.04-arm
- timeout-minutes: 180
- steps:
- - uses: actions/checkout@v7
-
- - name: Free disk + swap (sized for the ~30 GB qbz_ui peak)
- run: |
- sudo rm -rf /usr/share/dotnet /opt/ghc /usr/local/lib/android \
- /opt/hostedtoolcache /usr/local/share/boost /usr/share/swift \
- /usr/local/lib/node_modules || true
- sudo docker image prune --all --force || true
- ( sudo fallocate -l 18G /swap_extra \
- && sudo chmod 600 /swap_extra \
- && sudo mkswap /swap_extra \
- && sudo swapon /swap_extra ) || echo "extra swap skipped (non-fatal)"
- free -h; df -h /
-
- - name: System dependencies
- run: |
- sudo apt-get update
- sudo apt-get install -y --no-install-recommends \
- build-essential pkg-config cmake clang libclang-dev mold nasm \
- libasound2-dev libjack-jackd2-dev \
- libfontconfig1-dev libfreetype-dev \
- libxkbcommon-dev libwayland-dev libxcb1-dev \
- libgl1-mesa-dev libegl1-mesa-dev \
- libdbus-1-dev libssl-dev \
- libharfbuzz-dev libjbig2dec0-dev libopenjp2-7-dev \
- libjpeg-dev zlib1g-dev libgumbo-dev
-
- - name: Rust toolchain (pinned dated nightly)
- uses: dtolnay/rust-toolchain@master
- with:
- toolchain: nightly-2026-06-23
-
- - name: Cargo cache (Swatinem/rust-cache)
- uses: Swatinem/rust-cache@v2
- with:
- workspaces: crates -> target
- shared-key: slint-linux-aarch64
- cache-on-failure: true
- # reuse release-linux-aarch64.yml's cache (same shared-key); do not
- # clobber it from here.
- save-if: false
-
- - name: Build qbz (memory-tuned MIN tier, swap-backed)
+ - name: Verify tag is on main
+ if: startsWith(github.ref, 'refs/tags/') || inputs.publish
env:
- CARGO_INCREMENTAL: '0'
- CARGO_BUILD_JOBS: '1'
- CARGO_PROFILE_RELEASE_CODEGEN_UNITS: '256'
- CARGO_PROFILE_RELEASE_OPT_LEVEL: '2'
- # No x86 target-features here (aarch64 baseline has NEON).
- RUSTFLAGS: '-C link-arg=-fuse-ld=mold -Z threads=1'
+ VERSION: ${{ steps.release.outputs.version }}
run: |
- cargo build --release --manifest-path crates/Cargo.toml -p qbz
- strip crates/target/release/qbz || true
- ls -lh crates/target/release/qbz
+ set -euo pipefail
+ tag="v${VERSION}"
+ git fetch origin main --tags
+ if ! git show-ref --verify --quiet "refs/tags/${tag}"; then
+ echo "::error::Release tag ${tag} does not exist."
+ exit 1
+ fi
+ tag_sha="$(git rev-parse "refs/tags/${tag}^{commit}")"
+ checkout_sha="$(git rev-parse HEAD)"
+ if [[ "$tag_sha" != "$checkout_sha" ]]; then
+ echo "::error::Checked-out commit does not match ${tag}."
+ exit 1
+ fi
+ if ! git merge-base --is-ancestor "$tag_sha" origin/main; then
+ echo "::error::Tag ${tag} is not on main. Releases are cut from main only."
+ exit 1
+ fi
- - name: Upload binary artifact
- uses: actions/upload-artifact@v7
- with:
- name: qbz-bin-arm64
- path: crates/target/release/qbz
- if-no-files-found: error
+ build-amd64:
+ needs: version
+ uses: ./.github/workflows/build-qt-linux.yml
+ with:
+ arch: x86_64
+ source_ref: ${{ inputs.tag || github.ref }}
+
+ build-arm64:
+ needs: version
+ uses: ./.github/workflows/build-qt-linux.yml
+ with:
+ arch: aarch64
+ source_ref: ${{ inputs.tag || github.ref }}
snap:
- needs: build-binary
+ needs: [version, build-amd64]
runs-on: ubuntu-24.04
- # 30 min was too tight: the v2.0.0 tag run got axed mid-snapcraft (LXD
- # setup + platform snaps + pack ran past it). The job does no compilation,
- # but snapcraft's container bootstrap alone is highly variable on public
- # runners — give it real headroom.
+ # No compilation here; the headroom is for snapcraft's LXD bootstrap,
+ # which is highly variable on hosted runners (a 30 min cap got the
+ # v2.0.0 run axed mid-pack).
timeout-minutes: 90
# Credentials at job level so the publish step can gate on them: an empty /
- # unset secret SKIPS publish instead of failing the whole run (the snap is
- # still built and uploaded as an artifact).
+ # unset secret SKIPS publish instead of failing the whole run.
env:
SNAPCRAFT_STORE_CREDENTIALS: ${{ secrets.SNAPCRAFT_STORE_CREDENTIALS }}
+ VERSION: ${{ needs.version.outputs.version }}
steps:
- uses: actions/checkout@v7
+ with:
+ ref: ${{ inputs.tag || github.ref }}
- - name: Download prebuilt binary
+ - name: Download AppDir
uses: actions/download-artifact@v8
with:
- name: qbz-bin-amd64
- path: dl
+ name: qbz-appdir-x86_64
- - name: Stage snap payload (binary + desktop + icon) and recipe
+ - name: Stage snap payload (AppDir tree + desktop + icon) and recipe
run: |
set -eux
- TAG="${{ github.event.inputs.tag || github.ref_name }}"
- VER="${TAG#v}"
+ VER="$VERSION"
+ tar -xf appdir.tar
mkdir -p snap-payload
- install -m755 dl/qbz snap-payload/qbz
+ cp -a AppDir/usr/bin AppDir/usr/lib AppDir/usr/plugins AppDir/usr/qml snap-payload/
cp packaging/snap/snap/gui/qbz.desktop snap-payload/qbz.desktop
cp packaging/snap/snap/gui/qbz.png snap-payload/qbz.png
- # Place the dump recipe where snapcraft looks for it, and stamp version.
+ cp LICENSE snap-payload/LICENSE
+ cp -r licenses snap-payload/licenses
+ test -f snap-payload/licenses/Apache-2.0-dst-decoder.txt
mkdir -p snap
- cp packaging/snap-slint/snapcraft.yaml snap/snapcraft.yaml
+ cp packaging/snap-qt/snapcraft.yaml snap/snapcraft.yaml
sed -i "s/^version:.*/version: '${VER}'/" snap/snapcraft.yaml
- # The repo ROOT also carries snapcraft.yaml (the SOURCE-build recipe
- # for Snap Store remote builders) and snapcraft resolves it over
- # snap/snapcraft.yaml — the v2.0.0 run recompiled the whole
- # workspace inside LXD and OOM'd the container. Remove the root
- # recipes from the CI working copy so only the dump recipe exists.
+ # Belt and braces: no root recipe may shadow snap/snapcraft.yaml.
rm -f snapcraft.yaml .snapcraft.yaml
echo "Built snap version: ${VER}"
- cat snap/snapcraft.yaml | head -20
+ head -20 snap/snapcraft.yaml
- - name: Build the snap (no compilation — dump of the prebuilt binary)
+ - name: Build the snap (dump of the prebuilt AppDir)
id: build
uses: snapcore/action-build@v1
@@ -229,51 +175,52 @@ jobs:
name: qbz-snap-amd64
path: ${{ steps.build.outputs.snap }}
if-no-files-found: error
+ retention-days: ${{ startsWith(github.ref, 'refs/tags/v') && 90 || 7 }}
- name: Publish to the Snap Store (skipped when no store credentials)
- if: ${{ env.SNAPCRAFT_STORE_CREDENTIALS != '' }}
+ if: ${{ env.SNAPCRAFT_STORE_CREDENTIALS != '' && (startsWith(github.ref, 'refs/tags/') || inputs.publish) }}
uses: snapcore/action-publish@v1
with:
snap: ${{ steps.build.outputs.snap }}
release: ${{ github.event.inputs.channel || 'edge' }}
snap-arm64:
- needs: build-binary-arm64
+ needs: [version, build-arm64]
runs-on: ubuntu-24.04-arm
- # No compilation here (plugin: dump); the headroom is for snapcraft's LXD
- # bootstrap, which is variable on hosted runners.
timeout-minutes: 90
env:
SNAPCRAFT_STORE_CREDENTIALS: ${{ secrets.SNAPCRAFT_STORE_CREDENTIALS }}
+ VERSION: ${{ needs.version.outputs.version }}
steps:
- uses: actions/checkout@v7
+ with:
+ ref: ${{ inputs.tag || github.ref }}
- - name: Download prebuilt binary
+ - name: Download AppDir
uses: actions/download-artifact@v8
with:
- name: qbz-bin-arm64
- path: dl
+ name: qbz-appdir-aarch64
- - name: Stage snap payload (binary + desktop + icon) and recipe
+ - name: Stage snap payload (AppDir tree + desktop + icon) and recipe
run: |
set -eux
- TAG="${{ github.event.inputs.tag || github.ref_name }}"
- VER="${TAG#v}"
+ VER="$VERSION"
+ tar -xf appdir.tar
mkdir -p snap-payload
- install -m755 dl/qbz snap-payload/qbz
+ cp -a AppDir/usr/bin AppDir/usr/lib AppDir/usr/plugins AppDir/usr/qml snap-payload/
cp packaging/snap/snap/gui/qbz.desktop snap-payload/qbz.desktop
cp packaging/snap/snap/gui/qbz.png snap-payload/qbz.png
- # Place the arm64 dump recipe where snapcraft looks for it, stamp version.
+ cp LICENSE snap-payload/LICENSE
+ cp -r licenses snap-payload/licenses
+ test -f snap-payload/licenses/Apache-2.0-dst-decoder.txt
mkdir -p snap
- cp packaging/snap-slint/snapcraft-arm64.yaml snap/snapcraft.yaml
+ cp packaging/snap-qt/snapcraft-arm64.yaml snap/snapcraft.yaml
sed -i "s/^version:.*/version: '${VER}'/" snap/snapcraft.yaml
- # Remove the root source-build recipe from the CI working copy so
- # snapcraft resolves ONLY the dump recipe (see the amd64 job).
rm -f snapcraft.yaml .snapcraft.yaml
echo "Built snap version: ${VER} (arm64)"
- cat snap/snapcraft.yaml | head -20
+ head -20 snap/snapcraft.yaml
- - name: Build the snap (no compilation — dump of the prebuilt binary)
+ - name: Build the snap (dump of the prebuilt AppDir)
id: build
uses: snapcore/action-build@v1
@@ -283,9 +230,10 @@ jobs:
name: qbz-snap-arm64
path: ${{ steps.build.outputs.snap }}
if-no-files-found: error
+ retention-days: ${{ startsWith(github.ref, 'refs/tags/v') && 90 || 7 }}
- name: Publish to the Snap Store (skipped when no store credentials)
- if: ${{ env.SNAPCRAFT_STORE_CREDENTIALS != '' }}
+ if: ${{ env.SNAPCRAFT_STORE_CREDENTIALS != '' && (startsWith(github.ref, 'refs/tags/') || inputs.publish) }}
uses: snapcore/action-publish@v1
with:
snap: ${{ steps.build.outputs.snap }}
diff --git a/.github/workflows/release-updater-manifest.yml b/.github/workflows/release-updater-manifest.yml
index 2ee9bac5a..7ae425855 100644
--- a/.github/workflows/release-updater-manifest.yml
+++ b/.github/workflows/release-updater-manifest.yml
@@ -18,11 +18,19 @@ jobs:
# Only run on successful tag-triggered builds (not manual dispatches)
if: >
github.event.workflow_run.conclusion == 'success' &&
+ github.event.workflow_run.event == 'push' &&
+ github.event.workflow_run.head_repository.full_name == github.repository &&
startsWith(github.event.workflow_run.head_branch, 'v')
runs-on: ubuntu-latest
permissions:
contents: write
steps:
+ - name: Checkout triggering release commit
+ uses: actions/checkout@v7
+ with:
+ ref: ${{ github.event.workflow_run.head_sha }}
+ fetch-depth: 0
+
- name: Resolve version from tag
id: version
# `head_branch` on a `workflow_run` event is attacker-controllable
@@ -31,14 +39,29 @@ jobs:
# the shape before letting it flow into shell or $GITHUB_OUTPUT.
env:
HEAD_BRANCH: ${{ github.event.workflow_run.head_branch }}
+ HEAD_SHA: ${{ github.event.workflow_run.head_sha }}
run: |
set -euo pipefail
- if ! printf '%s' "$HEAD_BRANCH" | grep -Eq '^v[0-9][0-9A-Za-z._-]{0,63}$'; then
+ if ! printf '%s' "$HEAD_BRANCH" | grep -Eq '^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z][0-9A-Za-z.-]*)?$'; then
echo "::error::Rejected head_branch for manifest job: $HEAD_BRANCH"
exit 1
fi
TAG="$HEAD_BRANCH"
VERSION="${TAG#v}"
+ git fetch origin main --tags
+ if ! git show-ref --verify --quiet "refs/tags/${TAG}"; then
+ echo "::error::Release tag ${TAG} does not exist"
+ exit 1
+ fi
+ TAG_SHA="$(git rev-parse "refs/tags/${TAG}^{commit}")"
+ if [[ "$TAG_SHA" != "$HEAD_SHA" ]]; then
+ echo "::error::workflow_run SHA does not match ${TAG}"
+ exit 1
+ fi
+ if ! git merge-base --is-ancestor "$TAG_SHA" origin/main; then
+ echo "::error::${TAG} is not on main"
+ exit 1
+ fi
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
@@ -48,7 +71,12 @@ jobs:
TAG: ${{ steps.version.outputs.tag }}
run: |
# Wait up to 20 minutes for .sig files from all platforms
- REQUIRED_SIGS=("aarch64.app.tar.gz.sig" "x64.app.tar.gz.sig" "AppImage.sig")
+ REQUIRED_SIGS=(
+ "aarch64.app.tar.gz.sig"
+ "x64.app.tar.gz.sig"
+ "amd64.AppImage.sig"
+ "aarch64.AppImage.sig"
+ )
MAX_ATTEMPTS=40
SLEEP_SECONDS=30
@@ -69,8 +97,8 @@ jobs:
fi
if [ "$attempt" -eq "$MAX_ATTEMPTS" ]; then
- echo "Warning: Not all .sig files found after $MAX_ATTEMPTS attempts. Generating manifest with available assets."
- break
+ echo "::error::Not all updater signatures appeared after $MAX_ATTEMPTS attempts; refusing to replace latest.json with a partial manifest."
+ exit 1
fi
echo "Attempt $attempt/$MAX_ATTEMPTS: waiting for all .sig files..."
@@ -158,6 +186,13 @@ jobs:
'. + {"linux-aarch64": {"signature": $sig, "url": $url}}')
fi
+ PLATFORM_COUNT=$(echo "$PLATFORMS" | jq 'length')
+ if [ "$PLATFORM_COUNT" -ne 4 ]; then
+ echo "::error::Expected four updater platforms, generated $PLATFORM_COUNT."
+ echo "$PLATFORMS" | jq .
+ exit 1
+ fi
+
# Assemble final latest.json
jq -n \
--arg version "$VERSION" \
@@ -175,7 +210,4 @@ jobs:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ steps.version.outputs.tag }}
run: |
- # Delete existing latest.json if present (from a previous run)
- gh release delete-asset "$TAG" latest.json --repo "${{ github.repository }}" --yes 2>/dev/null || true
- # Upload the new one
- gh release upload "$TAG" latest.json --repo "${{ github.repository }}"
+ gh release upload "$TAG" latest.json --repo "${{ github.repository }}" --clobber
diff --git a/.github/workflows/release-windows.yml b/.github/workflows/release-windows.yml
index c13dd491b..276495d76 100644
--- a/.github/workflows/release-windows.yml
+++ b/.github/workflows/release-windows.yml
@@ -106,13 +106,13 @@ jobs:
foreach ($f in "qbz.exe", "platforms\qwindows.dll", "qml\QtQuick\Controls\Basic\qmldir", "imageformats\qsvg.dll", "LICENSE.txt") {
if (-not (Test-Path "$root\$f")) { throw "installed layout is missing $f" }
}
- foreach ($protocol in "qbz", "qobuzapp") {
+ foreach ($protocol in "qbz") {
if (-not (Test-Path "HKCU:\Software\Classes\$protocol")) { throw "$protocol protocol key missing" }
}
if (-not (Test-Path "$env:APPDATA\Microsoft\Windows\Start Menu\Programs\QBZ.lnk")) { throw "Start-menu shortcut missing" }
$uninstall = Start-Process msiexec.exe -ArgumentList "/x `"$msi`" /qn /norestart" -Wait -PassThru
if ($uninstall.ExitCode -ne 0) { throw "synthetic MSI uninstall failed: $($uninstall.ExitCode)" }
- foreach ($protocol in "qbz", "qobuzapp") {
+ foreach ($protocol in "qbz") {
if (Test-Path "HKCU:\Software\Classes\$protocol") { throw "$protocol protocol key survived uninstall" }
}
@@ -171,9 +171,11 @@ jobs:
fi
# MSI ProductVersion must be numeric x.y.z; the full string goes to ARPCOMMENTS.
MSI_VERSION="$(echo "$VERSION" | sed -E 's/^([0-9]+\.[0-9]+\.[0-9]+).*/\1/')"
- echo "version=$VERSION" >> "$GITHUB_OUTPUT"
- echo "msi_version=$MSI_VERSION" >> "$GITHUB_OUTPUT"
- echo "prerelease=$PRERELEASE" >> "$GITHUB_OUTPUT"
+ {
+ echo "version=$VERSION"
+ echo "msi_version=$MSI_VERSION"
+ echo "prerelease=$PRERELEASE"
+ } >> "$GITHUB_OUTPUT"
- name: Verify tag is on main
if: startsWith(github.ref, 'refs/tags/')
@@ -238,7 +240,7 @@ jobs:
uses: Swatinem/rust-cache@v2
with:
workspaces: crates -> target
- shared-key: qt-windows-x86_64
+ shared-key: qt-windows-x86_64-${{ env.QT_VERSION }}
cache-on-failure: true
save-if: ${{ github.ref == 'refs/heads/main' || github.ref == 'refs/heads/pre-release' }}
@@ -258,7 +260,7 @@ jobs:
# the link and let the PDB carry them.
CARGO_PROFILE_RELEASE_STRIP: "none"
run: |
- cargo build --release --manifest-path crates/Cargo.toml \
+ python3 scripts/qt-cargo.py build --release --manifest-path crates/Cargo.toml \
-p qbz-qt --target "$TARGET"
ls -la "crates/target/$TARGET/release/qbz.exe" "crates/target/$TARGET/release/qbz.pdb"
@@ -286,7 +288,7 @@ jobs:
# (the committed .qsb carry HLSL 50 *source*; d3d11 compiles it at runtime).
# Exit code is checked (pipefail); the grep is a separate, informational pass.
"$QT_ROOT_DIR/bin/windeployqt.exe" --release --qmldir crates/qbz-qt/qml \
- --no-translations --no-opengl-sw "$DIST/qbz.exe" 2>&1 | tee windeploy.log
+ --no-translations --no-opengl-sw --no-compiler-runtime "$DIST/qbz.exe" 2>&1 | tee windeploy.log
grep -iE 'error|warning' windeploy.log || true
# Nothing uses Qt SQL, Qt TLS (rustls) or network-information.
rm -rf "$DIST/sqldrivers" "$DIST/tls" "$DIST/networkinformation"
@@ -319,43 +321,14 @@ jobs:
# Hard fail, not a warning — plan A task 14 adds licenses/.
if [ ! -d licenses ]; then echo "::error::licenses/ (Qt LGPL notice) is missing; refusing to package Qt without it"; exit 1; fi
cp -r licenses "$DIST/licenses"
- # offscreen platform plugin: smoke only; removed before packaging.
- cp "$QT_ROOT_DIR/plugins/platforms/qoffscreen.dll" "$DIST/platforms/"
du -sh "$DIST"
- # THE gate: the deployed directory boots without the Qt install. Pass =
- # >= 10 log lines, zero QML/loader complaints, core initialized (login
- # screen reached; `home published` needs a session CI does not have).
- # This is the step that catches the qrc-backslash bug (W1) as `Cannot open`.
- # pwsh, not Git-Bash: MSYS `kill $!` cannot reliably stop a native
- # Windows process (Sol R15), and a survivor locks the deploy dir.
- - name: Offscreen smoke (the directory that ships)
+ # Both products harvest this exact app-local runtime. Audit every EXE,
+ # DLL and plugin (normal + delay imports) before making either artifact.
+ - name: Deploy official MSVC CRT app-local and audit imports
shell: pwsh
- run: |
- $ErrorActionPreference = "Stop"
- $dist = "dist\qbz"
- $qtRoot = $env:QT_ROOT_DIR
- $qtHidden = "$qtRoot.hidden"
- # Move-Item accepts a full destination path. Rename-Item's NewName
- # parameter does not, so using it here can leave the Qt install live
- # and turn this deployed-tree gate into a false positive.
- Move-Item -LiteralPath $qtRoot -Destination $qtHidden
- foreach ($v in "QT_PLUGIN_PATH","QML_IMPORT_PATH","QML2_IMPORT_PATH","QT_ROOT_DIR") { Remove-Item "Env:$v" -ErrorAction SilentlyContinue }
- $env:QT_QPA_PLATFORM = "offscreen"; $env:RUST_LOG = "info"
- $p = Start-Process -FilePath "$dist\qbz.exe" -RedirectStandardOutput smoke.out -RedirectStandardError smoke.err -PassThru -NoNewWindow
- Start-Sleep -Seconds 45
- if (-not $p.HasExited) { Stop-Process -Id $p.Id -Force; Start-Sleep 1 }
- Move-Item -LiteralPath $qtHidden -Destination $qtRoot
- Get-Content smoke.out, smoke.err | Set-Content smoke.log
- $lines = (Get-Content smoke.log | Measure-Object -Line).Lines
- Write-Host "smoke log: $lines lines"
- if ($lines -lt 10) { Get-Content smoke.log; Write-Host "::error::the app did not start"; exit 1 }
- $pat = 'is not a type|unavailable|ReferenceError|TypeError|Cannot read|Unable to assign|Cannot open|no such method|non-existent property|failed to load component|is not installed|could not be found|0xc0000135|STATUS_DLL_NOT_FOUND|This application failed to start'
- $errs = Get-Content smoke.log | Where-Object { $_ -notmatch 'propertyCache' } | Select-String -Pattern $pat
- if ($errs) { $errs | Select-Object -First 20; Write-Host "::error::$($errs.Count) QML/loader complaint(s) in the smoke log"; exit 1 }
- if (-not (Select-String -Path smoke.log -Pattern 'QbzCore initialized' -Quiet)) { Get-Content smoke.log -Tail 20; Write-Host "::error::never reached QbzCore init"; exit 1 }
- Remove-Item "$dist\platforms\qoffscreen.dll"
- Write-Host "smoke OK (0 QML complaints, core initialized)"
+ run: ./scripts/packaging/deploy_windows_crt.ps1 -Dist dist/qbz
+
# D4 = unsigned, so this is a no-op until a cert exists (HAVE_CERT is
# resolved at workflow level — see the env block at the top). PLACEHOLDER
# for an OV cert in a PFX: cloud-issued keys (Azure Trusted Signing,
@@ -400,37 +373,43 @@ jobs:
-o "dist/QBZ_${VERSION}_x64.msi"
ls -la dist
- # The MSI is exercised, not just built: install per-user (no UAC), assert
- # the layout + registry, launch the INSTALLED exe offscreen, uninstall,
- # assert nothing is left. `wix msi validate` is deliberately NOT run:
- # per-user harvested components fail ICE38 by WiX design (issue #8633).
- - name: Install / launch / uninstall the MSI (runner smoke)
+ # Product smoke uses qwindows from the installed payload with an isolated
+ # profile/PATH. A VS runner still does not certify a clean Windows image.
+ - name: Install / upgrade / launch / uninstall the MSI (runner smoke)
shell: pwsh
env:
VERSION: ${{ steps.release.outputs.version }}
run: |
+ $ErrorActionPreference = 'Stop'
$msi = (Resolve-Path "dist\QBZ_$($env:VERSION)_x64.msi").Path
- $p = Start-Process msiexec -ArgumentList "/i `"$msi`" /qn /l*v install.log" -Wait -PassThru
- if ($p.ExitCode -ne 0) { Get-Content install.log -Tail 60; Write-Host "::error::msiexec /i exit $($p.ExitCode)"; exit 1 }
$root = "$env:LOCALAPPDATA\Programs\QBZ"
- foreach ($f in "qbz.exe","platforms\qwindows.dll","qml\QtQuick\Controls\Basic","imageformats\qsvg.dll","LICENSE.txt") {
- if (-not (Test-Path "$root\$f")) { Write-Host "::error::installed layout is missing $f"; exit 1 }
+ $installed = $false
+ try {
+ foreach ($stage in @('install','upgrade')) {
+ $p = Start-Process msiexec -ArgumentList "/i `"$msi`" /qn /l*v $stage.log" -Wait -PassThru
+ if ($p.ExitCode -ne 0) { Get-Content "$stage.log" -Tail 60; throw "msiexec $stage exit $($p.ExitCode)" }
+ $installed = $true
+ }
+ foreach ($f in @('qbz.exe','platforms\qwindows.dll','qml\QtQuick\Controls\Basic','imageformats\qsvg.dll','LICENSE.txt')) {
+ if (-not (Test-Path "$root\$f")) { throw "Installed layout is missing $f" }
+ }
+ if (-not (Test-Path 'HKCU:\Software\Classes\qbz')) { throw 'qbz protocol key missing' }
+ if (-not (Test-Path "$env:APPDATA\Microsoft\Windows\Start Menu\Programs\QBZ.lnk")) { throw 'Start-menu shortcut missing' }
+ $sourceHash = (Get-FileHash 'dist/qbz/licenses/msvc-runtime.json').Hash
+ if ((Get-FileHash "$root/licenses/msvc-runtime.json").Hash -ne $sourceHash) { throw 'MSI CRT inventory differs from shared payload' }
+ python scripts/packaging/windows_crt.py $root --report windows-msi-imports.json
+ if ($LASTEXITCODE -ne 0) { throw 'Installed MSI CRT audit failed' }
+ ./scripts/packaging/smoke_windows_deploy.ps1 -Directory $root -LogPrefix inst
}
- foreach ($protocol in "qbz", "qobuzapp") {
- if (-not (Test-Path "HKCU:\Software\Classes\$protocol")) { Write-Host "::error::$protocol protocol key missing"; exit 1 }
+ finally {
+ if ($installed) {
+ $u = Start-Process msiexec -ArgumentList "/x `"$msi`" /qn /l*v uninstall.log" -Wait -PassThru
+ if ($u.ExitCode -ne 0) { Get-Content uninstall.log -Tail 60; throw "msiexec uninstall exit $($u.ExitCode)" }
+ }
}
- if (-not (Test-Path "$env:APPDATA\Microsoft\Windows\Start Menu\Programs\QBZ.lnk")) { Write-Host "::error::Start-menu shortcut missing"; exit 1 }
- $env:QT_QPA_PLATFORM = "offscreen"; $env:RUST_LOG = "info"
- $app = Start-Process "$root\qbz.exe" -RedirectStandardOutput inst.out -RedirectStandardError inst.err -PassThru -NoNewWindow
- Start-Sleep 25; if (-not $app.HasExited) { Stop-Process -Id $app.Id -Force; Start-Sleep 1 }
- if (-not (Select-String -Path inst.out,inst.err -Pattern 'QbzCore initialized' -Quiet)) { Get-Content inst.out,inst.err -Tail 30; Write-Host "::error::installed app never reached QbzCore init"; exit 1 }
- $u = Start-Process msiexec -ArgumentList "/x `"$msi`" /qn /l*v uninstall.log" -Wait -PassThru
- if ($u.ExitCode -ne 0) { Get-Content uninstall.log -Tail 60; Write-Host "::error::msiexec /x exit $($u.ExitCode)"; exit 1 }
- if (Test-Path "$root\qbz.exe") { Write-Host "::error::uninstall left $root\qbz.exe"; exit 1 }
- foreach ($protocol in "qbz", "qobuzapp") {
- if (Test-Path "HKCU:\Software\Classes\$protocol") { Write-Host "::error::uninstall left the $protocol protocol key"; exit 1 }
- }
- Write-Host "MSI install/launch/uninstall OK"
+ if (Test-Path "$root\qbz.exe") { throw 'Uninstall left qbz.exe' }
+ if (Test-Path 'HKCU:\Software\Classes\qbz') { throw 'Uninstall left the qbz protocol key' }
+ Write-Host 'MSI install/upgrade/GUI launch/uninstall OK'
- name: Sign MSI
if: ${{ env.HAVE_CERT == 'true' }}
@@ -450,6 +429,31 @@ jobs:
run: |
Compress-Archive -Path dist\qbz\* -DestinationPath "dist\qbz_$($env:VERSION)_windows_x64.zip"
Copy-Item "crates\target\$env:TARGET\release\qbz.pdb" "dist\qbz_$($env:VERSION)_windows_x64.pdb"
+ $unpacked = Join-Path $env:RUNNER_TEMP ('qbz-portable-' + [guid]::NewGuid())
+ try {
+ Expand-Archive -LiteralPath "dist\qbz_$($env:VERSION)_windows_x64.zip" -DestinationPath $unpacked
+ if ((Get-FileHash "$unpacked/licenses/msvc-runtime.json").Hash -ne (Get-FileHash 'dist/qbz/licenses/msvc-runtime.json').Hash) { throw 'ZIP CRT inventory differs from shared payload' }
+ python scripts/packaging/windows_crt.py $unpacked --report windows-zip-imports.json
+ if ($LASTEXITCODE -ne 0) { throw 'Portable ZIP CRT audit failed' }
+ ./scripts/packaging/smoke_windows_deploy.ps1 -Directory $unpacked -LogPrefix portable
+ }
+ finally { if (Test-Path $unpacked) { Remove-Item -LiteralPath $unpacked -Recurse -Force } }
+
+ - name: Upload deployment evidence
+ if: always()
+ uses: actions/upload-artifact@v7
+ with:
+ name: qbz-windows-deployment-checks
+ retention-days: 14
+ if-no-files-found: ignore
+ path: |
+ windows-*-imports.json
+ inst.*
+ portable.*
+ install.log
+ upgrade.log
+ uninstall.log
+ dist/qbz/licenses/msvc-runtime.json
- name: Upload artifacts
uses: actions/upload-artifact@v7
diff --git a/.github/workflows/test-crates.yml b/.github/workflows/test-crates.yml
index ff17277f8..c914b6594 100644
--- a/.github/workflows/test-crates.yml
+++ b/.github/workflows/test-crates.yml
@@ -1,16 +1,34 @@
-# Workspace unit/doc tests for the shipping crates/ tree (Slint-era).
+# CI for the crates/ workspace on the integration branch.
#
-# Companion to the release/build workflows, not a substitute:
-# - release-linux / build-slint / release-snap compile -p qbz (full UI, ~30 GB peak)
-# - this job runs `cargo test` on every non-UI crate only (~5 min cold, ~1–2 min warm)
+# Three jobs, three economies:
+# test — validate all eight gettext catalogs, then `cargo test` on
+# every crate that needs no Qt (scripts/cargo-test.sh:
+# excludes the frozen Slint crates and qbz-qt), then the named
+# listen-log suite check (qbz-app::listen_log must exist and be
+# green — the per-user listen store's schema, accumulator, end
+# reasons and pause/clear rules), and the account-migration +
+# portable-blacklist suite check (Settings > Import / Export:
+# snapshot/plan/apply/ledger/local copy, additive blacklist
+# merge), plus QConnect controller handoff/read-vs-write,
+# proto3-zero cursor, manual skip/loop/shuffle/autoplay and
+# consecutive-log repetition regression checks, plus SACD
+# 2048/2064-sector TOC/frame/seek equivalence and scanner import/
+# failed-read preservation. ~5 min cold.
+# qt-gate — the Qt frontend: the five static QML audits, `cargo test -p
+# qbz-qt`, the Slint-free dep-graph gate, and an OFFSCREEN BOOT of
+# debug AND release binaries (including process liveness).
+# Release also boots on Xvfb with a deliberately silent D-Bus.
+# Node executes the actual Local Library filter/tab expressions.
+# This is the thing `cargo check` cannot give:
+# a missing QML component or a call to a bridge member that does
+# not exist compiles clean and throws when the user opens the
+# view. Qt comes from aqt — ubuntu-24.04's own Qt is 6.4.2 and has
+# no QtQuick.Effects module.
#
-# Excludes qbz, qbz-ui, qbz-dac-wizard, qbz-slint-common so the UI compile unit
-# never enters the graph. Cache key is intentionally separate from
-# `slint-linux` (stable + test profile vs nightly + release).
-#
-# Repo policy note: release builds stay tag/dispatch-only. This is the one
-# workflow that runs on pre-release integration so regressions land before a
-# release cut. Path-filtered so packaging/docs PRs skip it.
+# Rewritten 2026-08-25 for the Slint -> Qt frontend switch. Release packaging
+# stays tag/dispatch-only; this workflow also tests optimized startup on
+# pre-release
+# integration so regressions land before a release cut.
name: test-crates
@@ -22,6 +40,14 @@ on:
paths:
- "crates/**"
- "scripts/cargo-test.sh"
+ - "scripts/qt-*.py"
+ - "scripts/qt-*.sh"
+ - "scripts/test_qt_build_gates.py"
+ - "scripts/packaging/windows_crt.py"
+ - "scripts/packaging/test_windows_crt.py"
+ - "scripts/packaging/smoke_release.py"
+ - "scripts/packaging/test_smoke_release.py"
+ - "scripts/qml-audits/**"
- ".github/workflows/test-crates.yml"
push:
# Integration branch only — never main (a push to main is a release merge).
@@ -29,6 +55,14 @@ on:
paths:
- "crates/**"
- "scripts/cargo-test.sh"
+ - "scripts/qt-*.py"
+ - "scripts/qt-*.sh"
+ - "scripts/test_qt_build_gates.py"
+ - "scripts/packaging/windows_crt.py"
+ - "scripts/packaging/test_windows_crt.py"
+ - "scripts/packaging/smoke_release.py"
+ - "scripts/packaging/test_smoke_release.py"
+ - "scripts/qml-audits/**"
- ".github/workflows/test-crates.yml"
workflow_dispatch:
@@ -39,12 +73,17 @@ concurrency:
permissions:
contents: read
+env:
+ # Same pin as build-qt-linux.yml (the Linux floor). Bump both together.
+ QT_VERSION: ${{ vars.QT_VERSION_LINUX || '6.8.3' }}
+
jobs:
test:
name: cargo test (workspace, no UI)
runs-on: ubuntu-24.04
- # Cold first run ~5 min (compile + test); warm ~1–2 min. Headroom for
- # registry lag / cache restore, not for a Slint UI compile.
+ # The rule: --no-fail-fast, 20 minutes. All cores: measured cold on an
+ # equivalent 4-vCPU VM, 392 s (jobs=4) vs 589 s (jobs=2), 2.7 GB peak —
+ # the old jobs=2 was a Slint-era memory tier with nothing left to protect.
timeout-minutes: 20
steps:
- name: Checkout
@@ -52,54 +91,135 @@ jobs:
- name: Install system dependencies
run: |
- # Minimal subset of release-linux.yml deps: non-UI crates still link
- # ALSA / JACK / D-Bus (qbz-audio) and OpenSSL (reqwest). mold matches
- # the release linker for faster test-binary link.
+ # Non-UI crates still link ALSA / JACK / D-Bus (qbz-audio) and
+ # OpenSSL (reqwest).
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
- build-essential pkg-config mold \
+ build-essential pkg-config gettext \
libasound2-dev libjack-jackd2-dev \
libdbus-1-dev libssl-dev
- # Floating stable is fine here: rust-cache keys on rustc -V, so a stable
- # bump just rebuilds the test cache once. Nightly is reserved for the
- # release/UI builds that need -Z threads.
- name: Setup Rust
uses: dtolnay/rust-toolchain@stable
- name: Rust cache
uses: Swatinem/rust-cache@v2
with:
- # Manifest is crates/Cargo.toml → artifacts in crates/target (same
- # workspaces mapping as release-linux / build-slint).
workspaces: crates -> target
shared-key: test-crates-linux
cache-on-failure: true
- # PRs restore only. pre-release pushes write so the integration
- # branch keeps the cache warm (mirrors release-linux save-if policy).
save-if: ${{ github.ref == 'refs/heads/pre-release' }}
- - name: cargo test
+ - name: cargo test (scripts/cargo-test.sh — the local mirror)
env:
CARGO_TERM_COLOR: always
- CARGO_BUILD_JOBS: "2"
- # Faster link; no -Z flags (stable toolchain).
- RUSTFLAGS: "-C link-arg=-fuse-ld=mold"
run: |
- echo "::group::toolchain"
- rustc -V
- cargo -V
- echo "::endgroup::"
+ rustc -V; cargo -V
START=$(date +%s)
./scripts/cargo-test.sh
- END=$(date +%s)
- echo "cargo test wall time: $((END - START))s"
+ echo "cargo test wall time: $(( $(date +%s) - START ))s"
- - name: qbzd slint-free dep-graph gate (01-architecture.md §1.2)
+ # Every shader must bake. Separate job so qsb is never on PATH while cargo
+ # runs (see qt-gate). The BAKE toolchain is pinned apart from the runtime
+ # floor: the committed .qsb need qsb >= 6.9 (plasma/ambient's uint hash
+ # constants vs the GLSL 1.20 variant, which desktop GL <= 3.1 selects at
+ # runtime, so it must stay in the pack) — see shader_bake_gate.sh.
+ shader-gate:
+ name: shader bake gate
+ runs-on: ubuntu-24.04
+ timeout-minutes: 15
+ env:
+ QT_SHADERTOOLS_VERSION: ${{ vars.QT_VERSION_SHADERTOOLS || '6.9.3' }}
+ steps:
+ - uses: actions/checkout@v7
+ - name: Install Qt ${{ env.QT_SHADERTOOLS_VERSION }} + shader tools
+ uses: jurplel/install-qt-action@v4
+ with:
+ version: ${{ env.QT_SHADERTOOLS_VERSION }}
+ host: linux
+ target: desktop
+ arch: linux_gcc_64
+ modules: qtshadertools
+ cache: true
+ - name: Bake every shader to a temp dir
+ run: scripts/qml-audits/shader_bake_gate.sh "$QT_ROOT_DIR/bin/qsb"
+
+ qt-gate:
+ name: qt gate (audits, tests, offscreen boot)
+ runs-on: ubuntu-24.04
+ timeout-minutes: 60
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v7
+
+ - name: Install system dependencies
+ run: |
+ sudo apt-get update
+ sudo apt-get install -y --no-install-recommends \
+ build-essential pkg-config cmake clang libclang-dev lld nasm dbus \
+ libasound2-dev libjack-jackd2-dev libdbus-1-dev libssl-dev \
+ libgl1-mesa-dev libegl1-mesa-dev libxkbcommon-dev libwayland-dev \
+ xvfb nodejs libxcb-cursor0 libxcb-icccm4 libxcb-image0 libxcb-keysyms1 \
+ libxcb-render-util0 libxcb-xinerama0 libxcb-xkb1 libxkbcommon-x11-0
+ # cxx-qt prefers LLD when present; without it, Ubuntu falls back to
+ # the deprecated gold linker. Do not add workspace-wide RUSTFLAGS.
+ ld.lld --version
+
+ # No qtshadertools here ON PURPOSE: with qsb on PATH build.rs re-bakes
+ # shaders by mtime, which makes a fresh checkout nondeterministic. The
+ # build explicitly validates/uses committed .qsb; shader health is the
+ # shader-gate job, independent of tools accidentally present on PATH.
+ - name: Install Qt ${{ env.QT_VERSION }}
+ uses: jurplel/install-qt-action@v4
+ with:
+ version: ${{ env.QT_VERSION }}
+ host: linux
+ target: desktop
+ arch: linux_gcc_64
+ cache: true
+
+ - name: qmllint (QML type check)
+ run: scripts/qml-audits/qmllint_gate.sh "$QT_ROOT_DIR"
+
+ # The audits need Qt's own plugins.qmltypes to resolve QtQuick types —
+ # hence after the Qt install, pointed at it. ~1 s, and the class of
+ # mistake nothing else in CI can see (a missing component, a bridge
+ # member that does not exist).
+ - name: QML audits
+ env:
+ QBZ_QT_QML_DIR: ${{ env.QT_ROOT_DIR }}/qml
run: |
set -euo pipefail
- hits=$(cargo tree --manifest-path crates/Cargo.toml -p qbzd -e normal \
+ for a in qml_resolution_audit qml_singleton_xref qml_eager_tab_audit qml_module_registration_audit qml_icon_bake_audit qml_on_prefixed_property_audit; do
+ echo "::group::$a"
+ python3 "scripts/qml-audits/$a.py" "$PWD/crates/qbz-qt"
+ echo "::endgroup::"
+ done
+
+ - name: Setup Rust
+ uses: dtolnay/rust-toolchain@stable
+
+ - name: Rust cache
+ uses: Swatinem/rust-cache@v2
+ with:
+ workspaces: crates -> target
+ shared-key: qt-gate-${{ env.QT_VERSION }}
+ cache-on-failure: true
+ save-if: ${{ github.ref == 'refs/heads/pre-release' }}
+
+ # The exact QT=1 half of scripts/cargo-test.sh, minus its `test` job
+ # (already run above) and its shader gate (the shader-gate job, so qsb
+ # is never on PATH while cargo runs here): dep-graph gate, cargo test
+ # -p qbz-qt, Local Library/Kiosk artwork/navigation regressions, debug/release
+ # offscreen boot. One shared runtime script.
+ - name: qt gates (scripts/cargo-test.sh QT=1, without the workspace test)
+ env:
+ CARGO_TERM_COLOR: always
+ QBZ_QT_QML_DIR: ${{ env.QT_ROOT_DIR }}/qml
+ QBZ_PREBUILT_SHADERS: '1'
+ 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)
- if [ -n "$hits" ]; then
- echo "::error::qbzd graph resolves Slint crates:"; echo "$hits"; exit 1
- fi
+ [ -z "$hits" ] || { echo "::error::qbz-qt graph resolves Slint crates:"; echo "$hits"; exit 1; }
+ bash scripts/qt-runtime-gate.sh
diff --git a/.gitignore b/.gitignore
index 207f210c0..ba7c2bbba 100644
--- a/.gitignore
+++ b/.gitignore
@@ -50,6 +50,7 @@ static/*upscayl*
/.tmp/
/tmp/
/tools/
+/scratchpad/
# Documentation (kept external)
qbz-nix-docs/
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index bf31e9854..da48d5de4 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -5,16 +5,20 @@ This project is actively evolving. Contributions are welcome, but we have a few
## Where the code lives
The live app is the Rust workspace under `crates/` — a single native process
-with a Slint UI. UI code is in `crates/qbz-ui` (`.slint` + generated bindings)
-and `crates/qbz` (the binary). The old Svelte `src/` and Tauri `src-tauri/`
-trees were **deleted in 2.0.2**; they survive only at the git tag
-`legacy-tauri-svelte` for reference. PRs against those paths cannot be merged —
-port the change to `crates/` instead.
+with a Qt/QML interface. UI code and the desktop binary live in
+`crates/qbz-qt`; shared behavior belongs in the relevant core crate.
+
+The Slint crates (`crates/qbz-ui`, `crates/qbz`, `crates/qbz-slint-common` and
+`crates/qbz-dac-wizard`) are frozen historical references. The old Svelte
+`src/` and Tauri `src-tauri/` trees were deleted in 2.0.2 and survive only at
+the git tag `legacy-tauri-svelte`. PRs against these retired paths cannot be
+merged — port the change to the live Qt/Rust tree instead.
## Quick rules
- Write clear, concise English (no emojis in code, comments, or commit messages).
- Keep PRs focused and small when possible.
+- Disclose every LLM model used and its role in the PR body; see below.
- Do not change app branding or legal disclaimers without discussing it first.
- Do not modify protected audio-backend behavior unless explicitly requested by the maintainer.
@@ -72,9 +76,11 @@ PRs targeting `main` will be closed and asked to retarget to `pre-release`.
- `git checkout pre-release`
- `git merge --no-ff /external/`
5. **Run checks**
- - Build/validate a touched core crate: `cargo check -p ` (run from
- `crates/`). The full UI (`qbz`/`qbz-ui`) is a ~20–30 GB compile — see the
- README "Building from Source" section before attempting it.
+ - Test or check each touched core crate from `crates/`.
+ - For Qt/QML changes, run the relevant QML audits and use
+ `./scripts/qt-run.sh` from the repository root when your platform can
+ build the desktop application.
+ - Never build the frozen Slint crates as validation for a live change.
6. **Push pre-release**
- `git push origin pre-release`
7. **Close the PR with a comment** explaining it was merged to `pre-release`.
@@ -104,9 +110,46 @@ Prefer:
## What to include in PRs
- A short description of the problem and solution.
+- For a bug fix: the observed behavior, the root cause or best-supported
+ diagnosis, and why the proposed change addresses it.
+- For a feature: the user need, the existing flow or components you inspected,
+ and the constraints the design preserves.
- Screenshots for UI changes when possible.
+- The checks or manual tests you ran.
+- The LLM disclosure described below, or `None` if no LLM was used.
- Notes about any breaking changes or migrations.
+## AI / LLM-assisted contributions
+
+LLM-assisted contributions are welcome. If you used one or more models, the
+PR body must identify each model as precisely as the tool exposes it and state
+what role it played. A product, client or provider name alone is not enough
+when the tool shows a more specific model name or version. There is
+deliberately no predefined model list to keep current: report the identifier
+shown by the tool at the time of the contribution.
+
+List different models separately when they handled different stages, such as
+repository/context mapping, problem definition, root-cause analysis,
+plan/contract/dependency graph, implementation, tests or code review. Include
+only the stages that apply and use one line per model:
+
+```text
+- : repository mapping, diagnosis
+- : plan/contract, implementation
+- : tests, code review
+```
+
+Saying only that a change was "prompted" or "AI-generated" does not describe
+the engineering process. The PR should make it possible to see how the problem,
+existing code, constraints and proposed solution were understood before the
+change was submitted.
+
+If the exact model is not shown by the tool, say that instead of guessing.
+Prompt transcripts and private conversations are not required. This disclosure
+helps the maintainer choose an appropriate review strategy; it is not grounds
+for rejecting a contribution. The contributor remains responsible for reading
+the resulting diff and reporting how it was verified.
+
## What not to include
- Large refactors mixed with feature work.
@@ -119,16 +162,16 @@ Prefer:
QBZ ships 8 locales (`en es de fr pt ru ja nl`) as gettext `.po` files, bundled
via the `qbz-i18n` crate. Rules:
-- **No hardcoded UI strings in `.slint`** — every string goes through
- `@tr("...")`.
+- **No hardcoded UI strings in QML** — use
+ `QbzSession.tr("…", QbzSession.trRev)` so live language changes update the
+ binding. Rust uses `qbz_i18n::t()` or `qbz_i18n::tf()`.
- Adding or changing a string means updating **all** locale `.po` files, not
just English.
-- `@tr` property defaults are not reactive — re-seed from Rust on language
- change (see `crates/qbz-i18n` and `select_bundled_translation()`, called
- after `AppWindow::new()`).
+- The English text is the gettext `msgid`; do not introduce dotted keys.
### Checklist for PRs with UI Text
-- [ ] No hardcoded strings in `.slint` — all text via `@tr`
+- [ ] No hardcoded strings in QML — all text uses `QbzSession.tr()` with
+ `trRev`
- [ ] Every new/changed string updated across all 8 `.po` locales
- [ ] Reused an existing string where one already fit
diff --git a/README.md b/README.md
index 4f42f100a..771211222 100644
--- a/README.md
+++ b/README.md
@@ -11,6 +11,7 @@
+
@@ -19,45 +20,149 @@
# QBZ
-QBZ is a free and open source high-fidelity streaming client for Linux and macOS with fully native playback. As of 2.0, the entire app is a single native Rust process built with a Slint UI — no browser engine, no webview — with DAC passthrough, per-track sample rate switching, exclusive mode, and bit-perfect audio delivery.
+QBZ is a free and open source HiFi music player. Originally built only for
+Linux, it is now also available for macOS and Windows (experimental). The
+application started as a Qobuz client for paying subscribers who wanted to
+listen without the audio quality limits of web browsers. Its strengths were
+DAC passthrough, sending audio directly to the DAC or sound card, and switching
+sample rates for each track, all with a focus on bit-perfect audio delivery.
-No API keys needed. No telemetry. No tracking. Just music.
+QBZ has always played both your Qobuz subscription and your local files.
+Local playback was initially an extra; it has since become just as capable.
+QBZ also supports self-hosted music through Plex, Jellyfin and
+Subsonic/Navidrome, all through the same interface and the same audio pipeline.
-## Legal / Branding
+The application requires no API keys, token extraction or anything like that.
+It has zero telemetry, with opt-in integrations for scrobbling, metadata
+enrichment and Discord Rich Presence. QBZ doesn't track you. It just plays
+your music.
+
+## Contents
+
+
+
+
+
+- [What QBZ is and what it is not](#what-qbz-is-and-what-it-is-not)
+- [Legal and branding](#legal-and-branding)
+- [Installation](#installation)
+- [Features](#features)
+- [Headless daemon (qbzd)](#headless-daemon-qbzd)
+- [Kiosk mode](#kiosk-mode)
+- [Tech stack](#tech-stack)
+- [Building from source](#building-from-source)
+
+
+
+
+- [Environment variables](#environment-variables)
+- [Known issues](#known-issues)
+- [Reporting a problem](#reporting-a-problem)
+- [On LLMs and AI tools](#on-llms-and-ai-tools)
+- [Documentation](#documentation)
+- [Contributing](#contributing)
+- [License](#license)
+
+
+
+
+
+## What QBZ is and what it is not
+
+QBZ exists to fill the gap left by the lack of an official Linux client.
+Although it is available on other platforms, QBZ will always be Linux first.
+I believe Linux users are not second-class citizens, and we deserve software
+that works, looks good and, every now and then, is easy to use.
+
+But it is just as important to understand what QBZ is not. QBZ is not a
+stream-ripping tool. Its purchase downloader lets you download music you have
+bought on Qobuz, and only your purchases can be downloaded DRM-free. I built
+QBZ rather than TDL, SPTFY, DZR or PPLMSC out of love for music, because I
+believe it belongs to the artists who create it, and because Qobuz is perhaps
+the fairest platform for artists so far.
+
+You can keep tracks from the service for offline listening and access them
+through your library. These are protected with QBZ's own encryption and cannot
+be used outside the application. Offline listening remains subject to your
+subscription: if Qobuz reports that it is no longer valid, QBZ allows a 30-day
+grace period from that first invalid response, then removes the cached tracks
+unless the subscription has been validated again.
+
+QBZ will never be an all-in-one audio system with a built-in equalizer,
+spatial filters, DSP or similar processing. The point is to send audio to your
+DAC, or whichever output you prefer, untouched, bit for bit. Any processing
+would break that contract. Of course, you can configure your output so that
+once the music leaves QBZ, it goes wherever you want, including an EQ such as
+EasyEffects. PipeWire and JACK make that easy.
+
+Above all, I'm a software engineer, not an audio engineer. I can't ship
+something when I don't understand how it works. I can't take responsibility
+for an EQ copied from somewhere else or written by an AI model without being
+sure I can test it and verify that it works 100%. I already did that with
+another module, and it didn't go well.
+
+## Legal and branding
- This application uses the Qobuz API but is not certified by Qobuz.
-- Qobuz is a trademark of Qobuz. QBZ is not affiliated with, endorsed by, or certified by Qobuz.
-- **Offline cache** is a temporary playback store for listening without an internet connection while you have a valid subscription. If your subscription becomes invalid, QBZ will remove all cached content after 3 days.
-- **Local library** is a "bring your own music" feature — play your own files with bit-perfect audio and the full QBZ interface, no streaming subscription required.
+- Qobuz is a trademark of Qobuz. QBZ is not affiliated with, endorsed by, or
+ certified by Qobuz.
+- **Offline cache** is a temporary playback store for listening without an
+ internet connection, protected with QBZ's own encryption. It is not a
+ DRM-free download of music from your subscription.
+- **Local library** is a "bring your own music" feature — play your own files
+ with bit-perfect audio and the full QBZ interface, no streaming subscription
+ required.
+- **SACD images** are user-supplied local files. QBZ does not provide disc
+ images or tools to bypass SACD copy protection.
+- **SACD / DST decoding** uses `dst-decoder` 0.1.2 solely to decode MPEG-4
+ Direct Stream Transfer audio. The crate declares Apache-2.0 and is based
+ on the MPEG-4 DST reference implementation. QBZ includes the
+ [Apache license](licenses/Apache-2.0-dst-decoder.txt),
+ [provenance notice](licenses/dst-decoder-NOTICE.md) and
+ [full upstream MPEG notice](licenses/dst-decoder-upstream-NOTICE.txt),
+ including its patent notice, with its third-party licenses. QBZ does not
+ claim worldwide patent clearance.
- Qobuz Terms of Service: https://www.qobuz.com/us-en/legal/terms
-## Why QBZ
-
-QBZ exists as the player we wish a streaming company would give its users. It exists to fill the gap of an official client on Linux, because we don't like being second class citizens.
-
-But it is just as important to understand what QBZ is not, and never will be. It is not a web wrapper; I did not just package the web player and "Linuxify" it. It is not a download tool, and it never will be. I built QBZ and not TDL, SPTFY or DZR because I believe music belongs to the artists, and Qobuz is, so far, the fairest option and the one with the best quality. That is why QBZ exists.
-
-It will never have a built-in equalizer or DSP, either. The point is to send audio to your DAC untouched, bit for bit, and any processing would break that. If you want an EQ or effects, add them at the system level, for example with EasyEffects on PipeWire or a JACK graph, which make it easy.
-
-You can download songs to listen to them offline, but they carry the same locks as the official client and the same usage window once your membership expires.
-
-On the technical side: browsers cap audio output at 48 kHz and resample everything through WebAudio. QBZ uses a native playback pipeline with direct device control, so your DAC receives the original resolution, up to 24-bit / 192 kHz, with no forced resampling.
-
## Installation
### Arch Linux (AUR)
+Install the prebuilt packages (recommended):
+
```bash
-yay -S qbz-bin # or paru -S qbz-bin
+yay -S qbz-bin # desktop player; or: paru -S qbz-bin
+yay -S qbzd-bin # optional headless daemon; or: paru -S qbzd-bin
```
+To build from source instead:
+
+```bash
+yay -S qbz # desktop player; or: paru -S qbz
+yay -S qbzd # optional headless daemon; or: paru -S qbzd
+```
+
+Pick either the source or `-bin` variant in each pair; both variants provide
+the same package and therefore conflict with each other.
+
### Flatpak (Flathub)
```bash
flatpak install flathub com.blitzfc.qbz
```
-> **Audiophiles:** Flatpak sandboxing limits PipeWire bit-perfect. Use ALSA Direct backend for guaranteed bit-perfect in Flatpak, or install via native packages for full PipeWire support.
+> **Audiophiles:** bit-perfect works in Flatpak. The sandbox needs one
+> permission grant so QBZ can ask PipeWire to hand the DAC over cleanly (D-Bus
+> device reservation) — without it PipeWire keeps holding the device and other
+> apps keep mixing through it, even with exclusive mode selected:
+>
+> ```bash
+> flatpak override --user --own-name=org.freedesktop.ReserveDevice1.* com.blitzfc.qbz
+> ```
+>
+> QBZ shows this and the other sandbox grants under **Settings → Flatpak** with
+> copyable commands, and the HiFi Wizard covers the rest. Your host audio stack
+> still has to be configured correctly.
### Snap
@@ -67,9 +172,10 @@ sudo snap connect qbz-player:alsa
sudo snap connect qbz-player:pipewire
```
-> **Note:** After installing, connect ALSA and PipeWire interfaces for full audio support. MPRIS media keys work out of the box.
+> **Note:** After installing, connect ALSA and PipeWire interfaces for full
+> audio support. MPRIS media keys work out of the box.
-### APT Repository (Debian/Ubuntu/Mint)
+### APT (Debian / Ubuntu / Mint)
```bash
curl -fsSL https://vicrodh.github.io/qbz-apt/qbz-archive-keyring.gpg | gpg --dearmor | sudo tee /usr/share/keyrings/qbz-archive-keyring.gpg > /dev/null
@@ -84,13 +190,42 @@ EOF
sudo apt update && sudo apt install qbz
```
-> Requires glibc 2.39+ (Ubuntu 24.04+, Debian 13+). For older releases use Flatpak, Snap, or AppImage.
+> **x86_64:** glibc 2.35+ (Ubuntu 22.04+, Debian 12+, Mint 21+).
+> **arm64 (desktop app):** glibc 2.39+ (Ubuntu 24.04+, Debian 13+) — the Qt
+> arm64 build needs it; Raspberry Pi OS *bookworm* is 2.36, so use `qbzd`
+> there (2.35+, see below). For older systems, check Flatpak or Snap runtime
+> support. The arm64 AppImage has the same glibc 2.39 minimum as the native
+> packages.
-### RPM (Fedora/openSUSE)
+### RPM (Fedora / openSUSE)
-Download from [Releases](https://github.com/vicrodh/qbz/releases): `sudo dnf install ./qbz-*.rpm`
+Add the signed QBZ repository and install with DNF:
-> Requires glibc 2.39+ (Fedora 40+, openSUSE Tumbleweed).
+```bash
+sudo curl --fail --location \
+ --output /etc/yum.repos.d/qbz.repo \
+ https://vicrodh.github.io/qbz-rpm/qbz.repo
+sudo dnf install qbz # desktop player, including qbzd
+# or: sudo dnf install qbzd # headless daemon only
+```
+
+On openSUSE, use the same repository definition with Zypper:
+
+```bash
+sudo curl --fail --location \
+ --output /etc/zypp/repos.d/qbz.repo \
+ https://vicrodh.github.io/qbz-rpm/qbz.repo
+sudo zypper refresh qbz
+sudo zypper install qbz # or: sudo zypper install qbzd
+```
+
+The signing-key fingerprint is published on the
+[repository landing page](https://vicrodh.github.io/qbz-rpm/). Individual RPM
+files also remain available from [Releases](https://github.com/vicrodh/qbz/releases).
+
+> **x86_64:** glibc 2.35+ (Fedora 36+, openSUSE Leap 15.6+ / Tumbleweed).
+> **arm64 (desktop app):** glibc 2.39+ (Fedora 40+). `qbzd` packages stay at
+> 2.35+ on both architectures.
### Gentoo
@@ -102,13 +237,17 @@ emerge media-sound/qbz-bin # prebuilt binary (recommended)
emerge media-sound/qbz # build from source
```
-> **Source-build warning (yes, even by Gentoo standards):** QBZ's UI compiles
-> into one giant generated Rust module — the release build peaks at 20–30 GB
-> of RAM and takes a long while (see
-> [The memory wall](#-the-memory-wall-read-this-before-your-first-build)).
-> Every version bump recompiles it. `qbz-bin` spares you all of that; the
-> source ebuild is there for those who enjoy the smell of rustc in the
-> morning.
+For a headless installation, choose the matching daemon package:
+
+```bash
+emerge media-sound/qbzd-bin # prebuilt binary (recommended)
+# or
+emerge media-sound/qbzd # build from source
+```
+
+`qbzd` does not require systemd. The ebuild detects systemd, OpenRC or runit
+and prints the appropriate post-install command; the daemon can generate
+service definitions for all three init systems.
### NixOS / Nix
@@ -144,18 +283,42 @@ inputs.qbz.url = "github:vicrodh/qbz";
### AppImage
-Download from [Releases](https://github.com/vicrodh/qbz/releases): `chmod +x QBZ.AppImage && ./QBZ.AppImage`
+Download from [Releases](https://github.com/vicrodh/qbz/releases):
+`chmod +x QBZ.AppImage && ./QBZ.AppImage`
### macOS
-**QBZ is Linux-first, but as of 2.0 macOS is out of its experimental phase and is a stable, fully supported platform** — a proper player for Linux and Mac. PipeWire, ALSA, and JACK are Linux-specific backends; macOS plays through its own CoreAudio backend, including a Core Audio Direct passthrough path for bit-perfect output. Casting (Chromecast/DLNA) and Qobuz Connect work on macOS as well.
+**QBZ is Linux-first, but macOS is a stable, fully supported platform** — a
+proper player for Linux and Mac. PipeWire, ALSA and JACK are Linux-specific
+backends; macOS plays through its own CoreAudio backend, including a Core Audio
+Direct passthrough path for bit-perfect output. Casting (Chromecast/DLNA) and
+Qobuz Connect work on macOS as well.
-Download the DMG (Apple Silicon or Intel) from [Releases](https://github.com/vicrodh/qbz/releases) and drag QBZ into Applications.
+**Recommended — signed and notarized:** Afonso Ramos independently maintains a
+[Homebrew Cask](https://github.com/afonsojramos/homebrew-qbz), which installs
+without requiring a manual Gatekeeper bypass:
-**First launch:** QBZ is ad-hoc signed but NOT notarized (no Apple Developer
-subscription), so Gatekeeper will block the first run — and on recent macOS
-(Sequoia / 15 and later) the old right-click → Open trick no longer works for
-un-notarized apps. Two ways to unlock it, pick one:
+```bash
+brew install --cask afonsojramos/qbz/qbz
+```
+
+You can also download the Apple Silicon or Intel DMG from the
+[signed macOS releases](https://github.com/afonsojramos/qbz-macos/releases/latest).
+These builds use the upstream QBZ application without recompiling it:
+[@afonsojramos](https://github.com/afonsojramos) replaces its ad-hoc signature
+and DMG container, then notarizes the result. This community-maintained signing
+and distribution is the recommended way to install QBZ on macOS, and the macOS
+version would likely not exist in its current form without Afonso's work. The
+mirror publishes the source commit, original checksums, and signed checksums
+for each release; see its
+[trust and provenance documentation](https://github.com/afonsojramos/qbz-macos#trust-and-provenance).
+
+**Official upstream alternative — ad-hoc signed, not notarized:** if you prefer
+the artifact produced directly by the QBZ project, download the Apple Silicon
+or Intel DMG from [QBZ Releases](https://github.com/vicrodh/qbz/releases) and
+drag QBZ into Applications. Because the project has no Apple Developer
+subscription, Gatekeeper blocks its first run. On recent macOS versions
+(Sequoia / 15 and later), unlock it using either route:
- **Settings route:** try to open QBZ once (it gets blocked), then go to
**System Settings → Privacy & Security**, scroll down to the message that
@@ -172,34 +335,61 @@ un-notarized apps. Two ways to unlock it, pick one:
## Features
-### Audio and Playback
+### Audio and playback
-- **Bit-perfect playback** with DAC passthrough and per-track sample rate switching (44.1–192 kHz)
-- **Linux backends:** PipeWire, ALSA (with a Direct hw: bypass mode), PulseAudio, and JACK — all hardened in 2.0, PipeWire/PulseAudio work out of the box
-- **macOS backend:** CoreAudio, including a Core Audio Direct passthrough path for bit-perfect output
-- **HiFi Wizard** — revamped in 2.0 with real hardware auto-detection and a simpler, guided bit-perfect setup
+- **Bit-perfect playback** with DAC passthrough and per-track sample rate
+ switching (44.1–192 kHz)
+- **Linux backends:** PipeWire, ALSA (with a Direct `hw:` bypass mode),
+ PulseAudio and JACK — PipeWire and PulseAudio work out of the box
+- **macOS backend:** CoreAudio, including a Core Audio Direct passthrough path
+ for bit-perfect output
+- **HiFi Wizard** — hardware auto-detection and a guided bit-perfect setup
- Native decoding: FLAC, MP3, AAC, ALAC, WavPack, Ogg Vorbis, Opus (Symphonia)
-- **DSD support** — DSF/DFF playback with DSD-to-PCM conversion, DoP, and native DSD passthrough (ALSA)
+- **DSD support** — DSF/DFF playback with DSD-to-PCM conversion, DoP, and
+ native DSD passthrough (ALSA)
+- **CD and SACD playback** — audio CDs and SACD discs play directly from the
+ drive; SACD goes out over DoP, with the titles read from the disc
+- **CD ripping to FLAC** — a guided rip wizard with per-track progress
- Gapless playback on all backends
- **Loudness normalization** (EBU R128) with ReplayGain support
- Two-level audio cache with next-track prefetching
-- Streaming playback — start listening before download completes
+- Streaming playback — start listening before the download completes
-### Queue and Library
+### Your music, wherever it lives
-- Queue with shuffle, repeat (track/queue/off), and history
+QBZ treats every source the same way: one queue, one player, one interface.
+
+- **Qobuz** — your subscription, favourites, playlists and purchases
+- **Local files** — directory scanning, metadata extraction, CUE sheets and a
+ SQLite catalog; usable without ever logging into Qobuz
+- **Plex** — browse and play your Plex music library
+- **Jellyfin** — browse and play your Jellyfin music library
+- **Subsonic / Navidrome** — any Subsonic-compatible server
+- **Optical discs** — CD and SACD, played or ripped
+
+### Queue and library
+
+- Queue with shuffle, repeat (track/queue/off) and history
- Favorites and playlists from your Qobuz account
-- **Qobuz playlist follow/unfollow** — subscribe natively, syncs across all Qobuz clients
-- **Local library** — directory scanning, metadata extraction, CUE sheets, SQLite indexing; usable without ever logging into Qobuz
-- **Artist/album blacklist** — block artists or individual albums, not just genres; fully reversible
-- Tag editor with sidecar storage (preserves original files)
+- **Qobuz playlist follow/unfollow** — subscribe natively, syncs across all
+ Qobuz clients
+- **Playlist manager** with folders and tags, plus playlist import from
+ Spotify, Apple Music, Tidal and Deezer
+- **Artist/album blacklist** — block artists or individual albums, not just
+ genres; fully reversible
+- **Metadata editor** — a full tabular editor for your local files, with
+ MusicBrainz and Discogs lookup, album art from local files or from
+ Cover Art Archive / Discogs / Last.fm, and a sidecar mode that leaves your
+ original files untouched
- Virtualized lists for large libraries
### Qobuz Connect
-Multi-device playback control using Qobuz's real-time streaming protocol. Considerably more stable as of 2.0, though full 1:1 parity with the official clients is still in progress.
+Multi-device playback control using Qobuz's real-time streaming protocol. Full
+1:1 parity with the official clients is still in progress.
-- **Renderer mode** — receive playback commands from your phone, tablet, or web player
+- **Renderer mode** — receive playback commands from your phone, tablet or web
+ player
- **Controller mode** — control remote devices from QBZ
- Server-authoritative queue sync across all devices
- Bidirectional transport: play, pause, skip, seek, shuffle, repeat, volume
@@ -214,57 +404,79 @@ Multi-device playback control using Qobuz's real-time streaming protocol. Consid
- **MPRIS** media controls and media keys
- **Last.fm** scrobbling and now-playing
- **ListenBrainz** scrobbling with offline queue
-- **MusicBrainz** artist enrichment, musician credits, relationships (no telemetry — one-way pull)
-- **Discogs** artwork for local library
-- Playlist import from Spotify, Apple Music, Tidal, Deezer
+- **MusicBrainz** artist enrichment, musician credits, relationships (no
+ telemetry — one-way pull)
+- **Discogs** artwork and metadata for the local library
- Desktop notifications with artwork
+- **Listening history** is stored only on your disk, per Qobuz account; it
+ never leaves the machine unless you enable a scrobbler (Settings ›
+ Integrations › Privacy to pause or clear it)
-### Immersive Player
+### Immersive player
-- Full-screen player with tabbed panel system, trimmed and re-tuned in 2.0 to stop being a resource drain
-- Multiple full-bleed view modes — Album Reactive, Coverflow, Static, Spectrum, Wave Bed, Lyrics — plus GPU shader scenes (Plasma, Tunnel, Aurora, Spectral Ribbon, Line Bed)
-- **Search overlay works inside Immersive mode** — switch albums without leaving the view
+- Full-screen player with a tabbed panel system
+- Multiple full-bleed view modes — Album Reactive, Coverflow, Static, Spectrum,
+ Wave Bed, Lyrics — plus GPU shader scenes (Plasma, Tunnel, Aurora, Spectral
+ Ribbon, Line Bed)
+- **Search overlay works inside Immersive mode** — switch albums without
+ leaving the view
- Synchronized lyrics with line-by-line display
- Split-panel layouts: Lyrics, Track Info, Suggestions, Queue
### Discovery
-- **Scene Discovery** — explore artists by location and musical scene (MusicBrainz-powered)
+- **Scene Discovery** — explore artists by location and musical scene
+ (MusicBrainz-powered)
- **3-tab Home:** customizable Home, Editor's Picks, personalized For You
-- **Recommendations** — Last.fm and ListenBrainz/MusicBrainz-powered discovery based on your listening history, similarities, and local-listen vectorization
-- **Live search overlay** with a small cache layer that learns your preferences and stops surfacing results you never touch
+- **Recommendations** — Last.fm and ListenBrainz/MusicBrainz-powered discovery
+ based on your listening history, similarities and local-listen vectorization
+- **Live search overlay** with a small cache layer that learns your preferences
+ and stops surfacing results you never touch
- Genre filtering, artist similarity engine, radio stations
- Musician pages, label pages, album credits
### Interface
-- 30+ themes (Dark, OLED, Nord, Dracula, Tokyo Night, Catppuccin, Breeze, Adwaita...)
+- 30+ themes (Dark, OLED, Nord, Dracula, Tokyo Night, Catppuccin, Breeze,
+ Adwaita...) plus a custom theme editor
- Auto-theme from DE, wallpaper, or custom image
-- Mini player
-- Album booklets download to your device — the old in-app PDF reader was dropped in 2.0 to keep the binary lean
+- Mini player and system tray
+- Album booklets download to your device
- Configurable keyboard shortcuts, UI scale presets (XS–XL)
-- **8 languages:** English, Spanish, German, French, Portuguese, Russian, Japanese, Dutch
-- **Offline mode** usable without ever logging into Qobuz, with fully offline playlists and automatic reconnection
+- **8 languages:** English, Spanish, German, French, Portuguese, Russian,
+ Japanese, Dutch
+- **Offline mode** usable without ever logging into Qobuz, with fully offline
+ playlists and automatic reconnection
-## Headless Daemon (qbzd)
+## Headless daemon (qbzd)