From 30420b9d8f0c63e75cd8a3feab6a9aec0e49dc78 Mon Sep 17 00:00:00 2001 From: Viet Anh Nguyen Date: Mon, 20 Jul 2026 11:20:58 +0700 Subject: [PATCH 1/6] feat(ci): build, test and package for aarch64 as well as x86_64 ThinkUtils was x86_64-only on the reasoning that thinkpad_acpi is an x86 platform driver so there was no hardware to run on. Half of that is still true and half is not: fan control genuinely cannot work on ARM, but the ThinkPad X13s is aarch64, and battery thresholds, CPU governor and system monitoring are not ThinkPad-specific at all. The app already detects an absent /proc/acpi/ibm/fan and reports degraded rather than failing. Both arches build NATIVELY on their own runner, following the sibling bulwark project. runs-on picks the CPU; container: ubuntu:22.04 pins the glibc floor -- separate concerns, and separating them also retires the standing note in release.yml about the deprecated ubuntu-22.04 label: the floor is now ours to choose rather than GitHub's to withdraw. Not cross-compiled and not emulated. Tauri's bundlers infer architecture from the build host and declare none in the manifest, so --target would stage host artifacts under an aarch64 name and ship an x86_64 binary in an arm64 package. qemu is refused outright rather than tolerated: a WebKit GUI under emulation fails on graphics paths no real user touches, and a misleading red result is worse than no coverage. Three things make the arch axis falsifiable rather than decorative: - every arch job asserts uname -m matches what the matrix claims. If GitHub renames the arm label, runs-on falls back silently and CI would build x86_64 twice, publish it under an arm64 name and stay green. - Verify package contents checks the binary's real machine type via file(1), the last point a mislabelled package is catchable. - gui-launch runs on both arches with the FULL distro matrix, not a thinner arm leg -- the point of those rows is to catch a distro/arch interaction, which a reduced matrix would be most likely to miss. OCR is what makes it meaningful: JavaScriptCore has its own arm64 JIT, so "native shell paints, JS never runs" is arch-specific and a screenshot of it looks like a working window. Packaging follows the release matrix rather than restating it: AUR and COPR list both arches, and the PPA uses Architecture: any because Launchpad builds whatever the series enables -- asserting the mechanism, since narrowing it to amd64 would silently stop producing arm64 builds with nothing else changing. The download page now pins the architecture suffix per selection instead of hardcoding amd64. Its own comment warned about exactly this: find() returns the first match, so an unpinned predicate would hand an arm64 user an x86_64 package -- a download that works and a package that will not install. Detection only picks the default tab; both stay one click away, because navigator.platform is deprecated and wrong under emulation. 132 tests. Arch guards mutation-verified: dropping aarch64 from the PKGBUILD or narrowing the PPA to amd64 fails them by name. --- .github/workflows/ci.yml | 83 ++++++++++++- .github/workflows/release.yml | 178 ++++++++++++++++++++++------ docs/.vitepress/theme/custom.css | 36 ++++++ docs/download.md | 79 +++++++++--- packaging/aur/PKGBUILD | 14 ++- packaging/copr/thinkutils.spec | 12 +- packaging/ppa/debian/control.in | 13 +- scripts/test-gui-packages-docker.sh | 48 ++++++-- src-tauri/tests/packaging.rs | 71 ++++++++++- 9 files changed, 455 insertions(+), 79 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fb67eea..d0875df 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -291,9 +291,31 @@ jobs: # # So: build in the container, hand the artifacts to a normal runner, and let # that runner drive Docker. + # + # Two architectures, built NATIVELY on their own runner. `runs-on` picks the + # CPU; `container` pins the glibc floor. Those are separate concerns and the + # split is what makes this work: ubuntu-24.04-arm gives real aarch64 hardware + # (free for public repos), while ubuntu:22.04 -- a multi-arch image -- keeps + # the floor at 2.35 on both. + # + # NOT cross-compiled, deliberately. Tauri's bundlers infer the architecture + # from the build host and declare none in the manifest, so `--target + # aarch64-...` would stage host artifacts under an aarch64 name and ship an + # x86_64 binary in an arm64 package. No QEMU either: emulating a WebKit GUI is + # slow and fails on graphics paths no real user touches, so a red result would + # be uninformative. bundle: needs: [rust, frontend] - runs-on: ubuntu-24.04 + runs-on: ${{ matrix.runner }} + strategy: + # One arch failing should name that arch rather than cancel its sibling and + # hide how far the damage goes. + fail-fast: false + matrix: + include: + - { arch: x86_64, deb_arch: amd64, runner: ubuntu-24.04 } + - { arch: aarch64, deb_arch: arm64, runner: ubuntu-24.04-arm } + name: bundle (${{ matrix.arch }}) container: image: ubuntu:22.04 defaults: @@ -316,6 +338,21 @@ jobs: librsvg2-dev patchelf xdg-utils git config --global --add safe.directory "${GITHUB_WORKSPACE}" + # The arch axis is only worth having if it is real. If GitHub renames or + # withdraws the arm label, `runs-on` silently falls back and this job would + # build x86_64 twice, publish it under an arm64 name, and stay green -- + # the arm64 half of a channel the download page advertises, untested and + # indistinguishable from working. + - name: This runner really is ${{ matrix.arch }} + run: | + set -euo pipefail + actual="$(uname -m)" + if [ "${actual}" != "${{ matrix.arch }}" ]; then + echo "::error::matrix says ${{ matrix.arch }} but this runner is ${actual} - the arch axis is not testing what it claims" + exit 1 + fi + echo "confirmed ${actual}" + - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: @@ -324,6 +361,11 @@ jobs: - uses: Swatinem/rust-cache@v2 with: workspaces: src-tauri + # Explicit even though the action folds the matrix into its own key: an + # x86_64 cache restored into an aarch64 build poisons target/ with + # foreign object files, and the link error that follows points nowhere + # near the cause. + key: ${{ matrix.arch }} - run: npm ci @@ -349,9 +391,12 @@ jobs: exit 1 ;; esac + # Per-arch name: a single `packages` artifact would have the two matrix + # legs racing to overwrite each other, and the loser's packages would + # simply not exist with nothing reporting it. - uses: actions/upload-artifact@v4 with: - name: packages + name: packages-${{ matrix.arch }} path: | src-tauri/target/release/bundle/deb/*.deb src-tauri/target/release/bundle/rpm/*.rpm @@ -361,16 +406,44 @@ jobs: # Everything above proves the code COMPILES. Not one job starts the app. A # Tauri binary can pass every check here and still die on launch with a dlopen # panic, or come up showing an empty window because the frontend never mounted. + # + # Run on both arches, and NOT with a thinner arm64 leg. The whole point of the + # arm64 rows is to catch a distro/arch interaction, which is exactly what a + # reduced matrix would be most likely to miss. + # + # This is also where arm64 is genuinely proven rather than assumed. + # JavaScriptCore has its own arm64 JIT, so "the native shell paints but the JS + # never runs" is an arch-specific failure mode -- and a screenshot of it looks + # like a working window. The OCR step is what distinguishes them: a blank-but- + # styled window still has hundreds of distinct colours, and only the absence of + # rendered TEXT gives it away. gui-launch: needs: bundle - runs-on: ubuntu-24.04 + runs-on: ${{ matrix.runner }} + strategy: + fail-fast: false + matrix: + include: + - { arch: x86_64, runner: ubuntu-24.04 } + - { arch: aarch64, runner: ubuntu-24.04-arm } + name: gui-launch (${{ matrix.arch }}) timeout-minutes: 45 steps: - uses: actions/checkout@v4 + - name: This runner really is ${{ matrix.arch }} + run: | + set -euo pipefail + actual="$(uname -m)" + if [ "${actual}" != "${{ matrix.arch }}" ]; then + echo "::error::matrix says ${{ matrix.arch }} but this runner is ${actual} - the arch axis is not testing what it claims" + exit 1 + fi + echo "confirmed ${actual}" + - uses: actions/download-artifact@v4 with: - name: packages + name: packages-${{ matrix.arch }} path: artifacts # The script expects Tauri's bundle layout, not a flat artifact directory. @@ -394,7 +467,7 @@ jobs: if: always() uses: actions/upload-artifact@v4 with: - name: gui-launch-evidence + name: gui-launch-evidence-${{ matrix.arch }} path: build/gui-test-out/ if-no-files-found: warn retention-days: 14 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b990260..04b1d27 100755 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -16,21 +16,67 @@ jobs: ci: uses: ./.github/workflows/ci.yml - build-and-release: + # Two architectures, built NATIVELY on their own runner. + # + # `runs-on` picks the CPU, `container` pins the glibc floor -- separate + # concerns, and separating them is what fixes the long-standing note here. + # Tauri's deb bundler reads the floor off the BUILD HOST, so building on 24.04 + # (glibc 2.39) emits a package declaring libc6 (>= 2.39), which apt then + # refuses on Ubuntu 22.04 (2.35) and Debian 12 (2.36). That was previously + # pinned by staying on the `ubuntu-22.04` runner label, which GitHub is + # deprecating; `container: ubuntu:22.04` makes the floor ours to choose rather + # than GitHub's to withdraw, and works identically on both arches because the + # image is multi-arch. + # + # NOT cross-compiled: Tauri's bundlers infer the architecture from the build + # host and declare none in the manifest, so `--target aarch64-...` would stage + # host artifacts under an aarch64 name and ship an x86_64 binary in an arm64 + # package. + build: needs: ci - # Deliberately NOT bumped to ubuntu-24.04 despite 22.04's deprecation. - # Tauri's deb bundler reads the glibc floor off the BUILD HOST, so building - # on 24.04 (glibc 2.39) emits a package declaring libc6 (>= 2.39), which apt - # then refuses to install on Ubuntu 22.04 (2.35) and Debian 12 (2.36). - # Nothing about that is visible at build time -- it surfaces as a broken - # download for users. The correct fix is runs-on: ubuntu-24.04 with - # container: ubuntu:22.04, so the floor is ours to pin rather than GitHub's - # to deprecate. Tracked separately; do not "just bump the runner". - runs-on: ubuntu-22.04 - permissions: - contents: write - + runs-on: ${{ matrix.runner }} + strategy: + fail-fast: false + matrix: + include: + - { arch: x86_64, deb_arch: amd64, rpm_arch: x86_64, file_arch: "x86-64", runner: ubuntu-24.04 } + - { arch: aarch64, deb_arch: arm64, rpm_arch: aarch64, file_arch: "ARM aarch64", runner: ubuntu-24.04-arm } + name: build (${{ matrix.arch }}) + container: + image: ubuntu:22.04 + defaults: + run: + shell: bash + timeout-minutes: 90 + steps: + # Before checkout: the action needs git inside the container, and running + # as root over a runner-owned workspace trips git's dubious-ownership guard. + - name: Bootstrap the 22.04 build image + run: | + set -euo pipefail + export DEBIAN_FRONTEND=noninteractive + apt-get update + apt-get install -y --no-install-recommends \ + ca-certificates curl wget file git jq pkg-config build-essential \ + libssl-dev rpm xz-utils sudo binutils \ + libwebkit2gtk-4.1-dev libgtk-3-dev libayatana-appindicator3-dev \ + librsvg2-dev patchelf xdg-utils + git config --global --add safe.directory "${GITHUB_WORKSPACE}" + + # If GitHub renames or withdraws the arm label, `runs-on` falls back and + # this job would build x86_64 twice and publish it under an arm64 name, + # staying green. Users on arm64 would install a package that cannot run. + - name: This runner really is ${{ matrix.arch }} + run: | + set -euo pipefail + actual="$(uname -m)" + if [ "${actual}" != "${{ matrix.arch }}" ]; then + echo "::error::matrix says ${{ matrix.arch }} but this runner is ${actual} - refusing to publish a mislabelled package" + exit 1 + fi + echo "confirmed ${actual}" + - name: Checkout code uses: actions/checkout@v4 @@ -42,23 +88,22 @@ jobs: - name: Setup Rust uses: dtolnay/rust-toolchain@stable - - name: Install dependencies - run: | - sudo apt-get update - sudo apt-get install -y \ - libwebkit2gtk-4.1-dev \ - build-essential \ - curl \ - wget \ - file \ - libssl-dev \ - libayatana-appindicator3-dev \ - librsvg2-dev + - uses: Swatinem/rust-cache@v2 + with: + workspaces: src-tauri + # An x86_64 cache restored into an aarch64 build poisons target/ with + # foreign objects, and the resulting link error points nowhere near it. + key: ${{ matrix.arch }} - name: Install npm dependencies - run: npm install + run: npm ci - name: Build Tauri app + # linuxdeploy is itself an AppImage and mounts via FUSE, which a container + # does not have. Without this the bundle dies on a fuse error AFTER the + # .deb and .rpm have already built. + env: + APPIMAGE_EXTRACT_AND_RUN: 1 run: npm run tauri build - name: Get version @@ -78,18 +123,18 @@ jobs: cd "$B/deb" DEB_FILE=$(ls *.deb | head -n 1) - mv "$DEB_FILE" "thinkutils_${V}_amd64.deb" + mv "$DEB_FILE" "thinkutils_${V}_${{ matrix.deb_arch }}.deb" cd ../rpm RPM_FILE=$(ls *.rpm | head -n 1) - mv "$RPM_FILE" "thinkutils-${V}.x86_64.rpm" + mv "$RPM_FILE" "thinkutils-${V}.${{ matrix.rpm_arch }}.rpm" # The AppImage was uploaded under whatever Tauri defaulted to while the # deb and rpm carried clean versioned names, so releases shipped one # asset named inconsistently with the other two. cd ../appimage APPIMAGE_FILE=$(ls *.AppImage | head -n 1) - mv "$APPIMAGE_FILE" "thinkutils_${V}_amd64.AppImage" + mv "$APPIMAGE_FILE" "thinkutils_${V}_${{ matrix.deb_arch }}.AppImage" # cargo tauri build exiting 0 only proves the metadata parsed. These assert # the package CONTAINS what it promises, before it is published. @@ -97,7 +142,7 @@ jobs: run: | set -euo pipefail V="${{ steps.version.outputs.version }}" - deb="src-tauri/target/release/bundle/deb/thinkutils_${V}_amd64.deb" + deb="src-tauri/target/release/bundle/deb/thinkutils_${V}_${{ matrix.deb_arch }}.deb" dpkg-deb --contents "$deb" | grep -q 'usr/bin/thinkutils' \ || { echo "::error::.deb does not ship /usr/bin/thinkutils"; exit 1; } @@ -105,10 +150,13 @@ jobs: dpkg-deb --contents "$deb" | grep -q 'applications/.*\.desktop' \ || { echo "::error::.deb ships no .desktop entry - the app would not appear in any launcher"; exit 1; } + # The binary's real machine type, not the matrix's claim about it. This + # is the last point where a cross-compile mishap or a wrong runner is + # still catchable before users get the package. got=$(file -b src-tauri/target/release/thinkutils) case "$got" in - *x86-64*) ;; - *) echo "::error::built binary is '$got', not x86-64"; exit 1 ;; + *"${{ matrix.file_arch }}"*) ;; + *) echo "::error::built binary is '$got', expected ${{ matrix.file_arch }}"; exit 1 ;; esac # Tauri's dependency auto-detection is incomplete, so apt resolving the @@ -117,7 +165,71 @@ jobs: echo "::error::binary has unresolved shared libraries" exit 1 fi - echo "OK: package ships the binary, a desktop entry, and resolves every library" + + floor=$(objdump -T src-tauri/target/release/thinkutils \ + | grep -oE 'GLIBC_[0-9]+\.[0-9]+' | sort -uV | tail -1) + echo "highest glibc symbol required: ${floor}" + case "${floor}" in + GLIBC_2.3[6-9]|GLIBC_2.[4-9][0-9]|GLIBC_[3-9]*) + echo "::error::binary requires ${floor}, above the Ubuntu 22.04 floor (2.35)" + exit 1 ;; + esac + echo "OK: ${{ matrix.arch }} package ships the binary, a desktop entry, resolves every library, and holds the glibc floor" + + - uses: actions/upload-artifact@v4 + with: + name: release-packages-${{ matrix.arch }} + path: | + src-tauri/target/release/bundle/deb/*.deb + src-tauri/target/release/bundle/rpm/*.rpm + src-tauri/target/release/bundle/appimage/*.AppImage + retention-days: 7 + + # One job, after BOTH arches. The APT repository is rebuilt from scratch each + # release, so running this per-arch would have the two legs racing to publish + # a repo containing only their own packages. + release: + needs: build + runs-on: ubuntu-24.04 + permissions: + contents: write + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Get version + id: version + run: | + if [[ $GITHUB_REF == refs/tags/* ]]; then + echo "version=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT + else + echo "version=dev-$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT + fi + + - uses: actions/download-artifact@v4 + with: + pattern: release-packages-* + merge-multiple: true + path: staged + + - name: Restore the bundle layout + run: | + set -euo pipefail + for kind in deb rpm appimage; do + mkdir -p "src-tauri/target/release/bundle/${kind}" + done + find staged -name '*.deb' -exec mv {} src-tauri/target/release/bundle/deb/ ';' + find staged -name '*.rpm' -exec mv {} src-tauri/target/release/bundle/rpm/ ';' + find staged -name '*.AppImage' -exec mv {} src-tauri/target/release/bundle/appimage/ ';' + + # Both arches must be present. A missing one would otherwise publish a + # release quietly short of half its packages -- and the download page + # advertises arm64, so users would follow a link to nothing. + for a in amd64 arm64; do + ls src-tauri/target/release/bundle/deb/*_${a}.deb >/dev/null 2>&1 \ + || { echo "::error::no ${a} .deb among the artifacts - refusing to publish a half release"; exit 1; } + done + find src-tauri/target/release/bundle -type f -print - name: Create Release if: startsWith(github.ref, 'refs/tags/') diff --git a/docs/.vitepress/theme/custom.css b/docs/.vitepress/theme/custom.css index 6328dcf..a197a86 100644 --- a/docs/.vitepress/theme/custom.css +++ b/docs/.vitepress/theme/custom.css @@ -107,6 +107,42 @@ font-size: 0.95rem; } +/* Architecture selector. Deliberately a visible pair of buttons rather than a + silent auto-detect: navigator.platform is deprecated and wrong under some + emulation layers, and handing someone the wrong architecture produces a + download that works and a package that will not install. */ +.dl-arch { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + margin: 1.5rem 0 0.5rem; +} + +.dl-arch-btn { + padding: 0.45rem 0.9rem; + border: 1px solid var(--vp-c-divider); + border-radius: 6px; + background: var(--vp-c-bg-soft); + color: var(--vp-c-text-2); + font-size: 0.85rem; + font-weight: 500; + cursor: pointer; + transition: + border-color 0.2s, + color 0.2s; +} + +.dl-arch-btn:hover { + border-color: var(--vp-c-brand-1); + color: var(--vp-c-text-1); +} + +.dl-arch-btn.active { + border-color: var(--vp-c-brand-1); + background: var(--vp-c-brand-soft); + color: var(--vp-c-brand-1); +} + .dl-grid { display: grid; diff --git a/docs/download.md b/docs/download.md index 8acafe2..2e50a43 100644 --- a/docs/download.md +++ b/docs/download.md @@ -36,15 +36,38 @@ function find(pred) { return release.value?.assets?.find((a) => pred(a.name)) ?? null; } -// Each predicate pins the architecture suffix. ThinkUtils ships x86_64 only -// today, but `find` returns the FIRST match, so a loose predicate would silently -// hand out the wrong package the moment a second architecture is added. The -// failure would be user-side and quiet: the page looks right, the download works, -// and the package refuses to install. +// Releases now carry two architectures, which is exactly the case the previous +// comment here warned about: `find` returns the FIRST match, so a predicate that +// did not pin the suffix would hand an arm64 user an x86_64 package. That fails +// quietly on the user's side — the page looks right, the download works, and the +// package refuses to install. +// +// So the suffix stays pinned, and the arch becomes an explicit choice. +const ARCHES = { + x86_64: { label: "Intel / AMD (x86_64)", deb: "amd64", rpm: "x86_64" }, + aarch64: { label: "ARM (aarch64)", deb: "arm64", rpm: "aarch64" }, +}; + +// Guessed, never assumed. navigator.platform is deprecated and lies under some +// emulation layers, so it only picks the DEFAULT tab — both architectures stay +// one click away rather than being hidden behind a detection that can be wrong. +function detectArch() { + const s = `${navigator.userAgent} ${navigator.platform ?? ""}`.toLowerCase(); + if (/aarch64|arm64/.test(s)) return "aarch64"; + return "x86_64"; +} + +const arch = ref("x86_64"); +onMounted(() => { + arch.value = detectArch(); +}); + +const suffix = computed(() => ARCHES[arch.value]); + const is = { - deb: (n) => n.endsWith("_amd64.deb"), - rpm: (n) => n.endsWith(".x86_64.rpm"), - appimage: (n) => n.endsWith("_amd64.AppImage"), + deb: (n) => n.endsWith(`_${suffix.value.deb}.deb`), + rpm: (n) => n.endsWith(`.${suffix.value.rpm}.rpm`), + appimage: (n) => n.endsWith(`_${suffix.value.deb}.AppImage`), }; // Always yields a working link: the direct asset once a release exists, the @@ -70,12 +93,34 @@ function size(pred) {

-For **Lenovo ThinkPad** laptops running Linux on **x86_64**. Built on Ubuntu 22.04 -(glibc 2.35), so it runs on **Ubuntu 22.04+, Debian 12+ and Fedora 36+**. +For **Lenovo ThinkPad** laptops running Linux on **x86_64 or ARM (aarch64)**. +Built on Ubuntu 22.04 (glibc 2.35), so it runs on **Ubuntu 22.04+, Debian 12+ and +Fedora 36+**. Every release is installed into a clean container and launched under a virtual -display before it ships — on Ubuntu 22.04 and 24.04, Debian 12, and Fedora 41 — -with a screenshot checked by OCR to confirm the interface actually rendered. +display before it ships — on Ubuntu 22.04 and 24.04, Debian 12, and Fedora 41, +**on both architectures, on real hardware rather than emulation** — with a +screenshot checked by OCR to confirm the interface actually rendered. + +
+ +
+ +::: warning Fan control is x86_64 only +On ARM ThinkPads (the X13s and similar) everything works **except fan control**. +That is a kernel limitation, not an app one: fan control goes through +`thinkpad_acpi`, which is an x86 platform driver and does not exist on ARM. + +Battery charge thresholds, CPU governor, power profiles and system monitoring all +work normally — the app detects the missing fan interface and says so rather than +appearing broken. +:::
@@ -93,15 +138,15 @@ with a screenshot checked by OCR to confirm the interface actually rendered.
```bash -# Debian / Ubuntu -sudo apt install ./thinkutils_*_amd64.deb +# Debian / Ubuntu — the glob matches whichever architecture you downloaded +sudo apt install ./thinkutils_*.deb # Fedora / RHEL -sudo dnf install ./thinkutils-*.x86_64.rpm +sudo dnf install ./thinkutils-*.rpm # AppImage — portable, nothing to install -chmod +x thinkutils_*_amd64.AppImage -./thinkutils_*_amd64.AppImage +chmod +x thinkutils_*.AppImage +./thinkutils_*.AppImage ``` ::: tip Use `apt install ./file.deb`, not `dpkg -i` diff --git a/packaging/aur/PKGBUILD b/packaging/aur/PKGBUILD index afe80b0..9daa16e 100644 --- a/packaging/aur/PKGBUILD +++ b/packaging/aur/PKGBUILD @@ -3,9 +3,17 @@ pkgname=thinkutils pkgver=0.1.10 pkgrel=1 pkgdesc="ThinkPad fan control, battery care and system monitoring for Linux" -# thinkpad_acpi is an x86 platform driver and ThinkPads are x86_64. aarch64 would -# build but there is no hardware to run it on -- do not claim it. -arch=('x86_64') +# Both, and the reasoning is narrower than it looks. thinkpad_acpi IS x86-only, +# so fan control genuinely cannot work on aarch64 -- but ThinkUtils is not only +# fan control. Battery charge thresholds go through the generic kernel +# power_supply API, and the CPU governor and system monitor are not ThinkPad +# specific at all. Those work on an X13s (Snapdragon 8cx) exactly as they do on +# an x86 machine. +# +# The app already detects an absent /proc/acpi/ibm/fan and reports degraded +# rather than failing, which is asserted by the container launch tests on both +# arches -- so this is a supported configuration rather than a hopeful one. +arch=('x86_64' 'aarch64') url="https://github.com/vietanhdev/ThinkUtils" license=('LGPL-3.0-or-later') depends=('webkit2gtk-4.1' 'gtk3' 'libayatana-appindicator' 'librsvg' 'polkit') diff --git a/packaging/copr/thinkutils.spec b/packaging/copr/thinkutils.spec index 5f6023c..b2ab732 100644 --- a/packaging/copr/thinkutils.spec +++ b/packaging/copr/thinkutils.spec @@ -28,9 +28,15 @@ Requires: polkit Recommends: lm_sensors -# thinkpad_acpi is an x86 platform driver; there is no ThinkPad to run this on -# anywhere else. -ExclusiveArch: x86_64 +# Fan control needs thinkpad_acpi, which is x86-only. Everything else -- battery +# thresholds via the generic power_supply API, CPU governor, system monitor -- +# is architecture neutral and works on an aarch64 ThinkPad such as the X13s. +# +# Widening this alone does NOT ship aarch64: COPR builds only the chroots the +# project has enabled, so the aarch64 chroots have to be enabled on the COPR +# project too. A spec claiming an arch the project cannot build for produces +# nothing, silently. +ExclusiveArch: x86_64 aarch64 %description ThinkUtils is a desktop utility for Lenovo ThinkPad laptops running Linux, diff --git a/packaging/ppa/debian/control.in b/packaging/ppa/debian/control.in index e6d5b82..5a4213e 100644 --- a/packaging/ppa/debian/control.in +++ b/packaging/ppa/debian/control.in @@ -32,10 +32,15 @@ Vcs-Git: https://github.com/vietanhdev/ThinkUtils.git Rules-Requires-Root: no Package: thinkutils -# amd64 only, deliberately. thinkpad_acpi is an x86 platform driver and -# ThinkPads are x86_64; claiming arm64 would commit Launchpad to builds nobody -# can run. -Architecture: amd64 +# `any`, not a list. This is the one channel that is multi-arch without naming +# an architecture: Launchpad builds whatever the series has enabled, so the +# mechanism is what matters and narrowing this to `amd64` would silently stop +# producing arm64 builds with nothing else in the repo changing. +# +# Fan control needs thinkpad_acpi, which is x86-only; battery thresholds, the +# CPU governor and the system monitor are architecture neutral and work on an +# aarch64 ThinkPad such as the X13s. +Architecture: any Depends: ${shlibs:Depends}, ${misc:Depends}, policykit-1 | polkit Recommends: lm-sensors Suggests: clamav diff --git a/scripts/test-gui-packages-docker.sh b/scripts/test-gui-packages-docker.sh index c91362a..9650e5c 100755 --- a/scripts/test-gui-packages-docker.sh +++ b/scripts/test-gui-packages-docker.sh @@ -59,15 +59,45 @@ trap 'rm -rf "${STAGE}"' EXIT mkdir -p "${OUTDIR}" -# ThinkPads are x86_64. There is deliberately no arm64 matrix and no qemu: -# emulating a WebKit GUI is slow and fails on graphics paths no user touches, so -# a red result would be uninformative rather than useful. +# Runs on whatever architecture it is invoked on, NATIVELY. CI drives it once per +# arch on a matching runner (ubuntu-24.04 / ubuntu-24.04-arm). +# +# Never under qemu: emulating a WebKit GUI is slow and fails on graphics paths no +# real user touches, so a red result would be uninformative rather than useful. +# Refuse rather than produce that result, since a misleading failure is worse +# than no coverage -- people learn to ignore the job. ARCH="$(uname -m)" -if [ "${ARCH}" != "x86_64" ]; then - echo "ERROR: this suite is x86_64-only (got ${ARCH})" >&2 - exit 1 +case "${ARCH}" in + x86_64 | aarch64) ;; + *) + echo "ERROR: unsupported architecture ${ARCH} (expected x86_64 or aarch64)" >&2 + exit 1 + ;; +esac + +if [ -e /proc/sys/fs/binfmt_misc/qemu-aarch64 ] || [ -e /proc/sys/fs/binfmt_misc/qemu-x86_64 ]; then + echo "WARNING: qemu binfmt handlers are registered on this host." >&2 + echo " If these containers are emulated, failures here are not real." >&2 fi +# Each packaging format spells the architecture differently, and Tauri's bundlers +# take it from the build host rather than from any manifest. Deriving all three +# from `uname -m` keeps the artifact patterns below honest on either runner -- +# hardcoding amd64/x86_64 made them match nothing on aarch64, which stage_one +# reports as "no artifact" rather than as an unsupported architecture. +case "${ARCH}" in + x86_64) + DEB_ARCH=amd64 + RPM_ARCH=x86_64 + ;; + aarch64) + DEB_ARCH=arm64 + RPM_ARCH=aarch64 + ;; +esac + +echo "architecture: ${ARCH} (deb: ${DEB_ARCH}, rpm: ${RPM_ARCH})" + shopt -s nullglob # The version currently declared, so the right artifact is picked out of a bundle @@ -427,7 +457,7 @@ for spec in "${TARGETS[@]}"; do case "${kind}" in deb) - pkg="$(stage_one "thinkutils_VERSION_amd64.deb")" || { overall=1; continue; } + pkg="$(stage_one "thinkutils_VERSION_${DEB_ARCH}.deb")" || { overall=1; continue; } docker run --rm -v "${STAGE}:/a:ro" -v "${tout}:/out" "${image:-ubuntu:24.04}" bash -c " set -u export DEBIAN_FRONTEND=noninteractive @@ -443,7 +473,7 @@ for spec in "${TARGETS[@]}"; do " ;; rpm) - pkg="$(stage_one "thinkutils-VERSION-*.x86_64.rpm")" || { overall=1; continue; } + pkg="$(stage_one "thinkutils-VERSION-*.${RPM_ARCH}.rpm")" || { overall=1; continue; } docker run --rm -v "${STAGE}:/a:ro" -v "${tout}:/out" "${image:-fedora:41}" bash -c " set -u ${RETRY} @@ -460,7 +490,7 @@ for spec in "${TARGETS[@]}"; do # --appimage-extract rather than a direct run: mounting an AppImage # needs FUSE, which a container does not have. Extraction exercises # the same payload. - pkg="$(stage_one "thinkutils_VERSION_amd64.AppImage")" || { overall=1; continue; } + pkg="$(stage_one "thinkutils_VERSION_${DEB_ARCH}.AppImage")" || { overall=1; continue; } docker run --rm -v "${STAGE}:/a:ro" -v "${tout}:/out" "${image:-ubuntu:22.04}" bash -c " set -u export DEBIAN_FRONTEND=noninteractive diff --git a/src-tauri/tests/packaging.rs b/src-tauri/tests/packaging.rs index f309f63..4b73b56 100644 --- a/src-tauri/tests/packaging.rs +++ b/src-tauri/tests/packaging.rs @@ -145,12 +145,73 @@ fn packages_ship_the_polkit_rule_under_usr_share() { } } -/// ThinkPads are x86_64 and thinkpad_acpi is an x86 platform driver. Claiming an -/// architecture with no hardware to run on produces builds nobody can use. +/// Every manifest must claim exactly the architectures release.yml actually +/// builds. +/// +/// The release matrix is the single source of truth, because it is the thing +/// that produces artifacts. A manifest claiming an arch the matrix does not +/// build commits a package repository to something that will never arrive; a +/// manifest omitting one silently drops half the users a release was built for. +/// Neither fails anything on its own -- the packages simply do not exist, and +/// the download page keeps advertising them. #[test] -fn packages_claim_x86_64_only() { - assert!(read("packaging/aur/PKGBUILD").contains("arch=('x86_64')")); - assert!(read("packaging/copr/thinkutils.spec").contains("ExclusiveArch: x86_64")); +fn packaging_architectures_match_what_release_actually_builds() { + let release = read(".github/workflows/release.yml"); + + // Read the arches out of the matrix rather than restating them here, so this + // cannot pass while disagreeing with the workflow it is meant to track. + let built: Vec<&str> = ["x86_64", "aarch64"] + .into_iter() + .filter(|a| release.contains(&format!("arch: {},", a))) + .collect(); + assert_eq!( + built.len(), + 2, + "expected release.yml to build x86_64 and aarch64, found {:?}", + built + ); + + let pkgbuild = read("packaging/aur/PKGBUILD"); + for arch in &built { + assert!( + pkgbuild.contains(&format!("'{}'", arch)), + "PKGBUILD arch=() omits {}, which release.yml builds", + arch + ); + } + + let spec = read("packaging/copr/thinkutils.spec"); + let exclusive = spec + .lines() + .find(|l| l.starts_with("ExclusiveArch:")) + .expect("spec declares ExclusiveArch"); + for arch in &built { + assert!( + exclusive.contains(arch), + "spec ExclusiveArch omits {}: {}", + arch, + exclusive + ); + } +} + +/// The PPA is multi-arch without naming an architecture: Launchpad builds +/// whatever the series enables. So assert the MECHANISM, not a list — narrowing +/// this to `amd64` would silently stop producing arm64 builds with nothing else +/// in the repo changing, and Launchpad reports per-arch results by email rather +/// than failing anything here. +#[test] +fn debian_control_delegates_architecture_to_launchpad() { + let control = read("packaging/ppa/debian/control.in"); + let arch_line = directives(&control) + .find(|l| l.starts_with("Architecture:")) + .expect("control.in declares an Architecture"); + + assert_eq!( + arch_line.trim(), + "Architecture: any", + "the PPA must delegate architecture to the series" + ); } /// The version appears in the PKGBUILD and the spec as well as the four files From f54a84f007c94518652ebc5c4c8bdcf87adfbb0c Mon Sep 17 00:00:00 2001 From: Viet Anh Nguyen Date: Mon, 20 Jul 2026 11:26:26 +0700 Subject: [PATCH 2/6] docs(guide): install commands must not pin an architecture getting-started.md hardcoded _amd64.deb and .x86_64.rpm. With arm64 packages shipping, those commands match nothing on an arm64 ThinkPad -- and the failure reads as a missing download rather than as a wrong glob. The reader has downloaded exactly one file, so the architecture in the glob was never carrying information in the first place. --- docs/guide/getting-started.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/guide/getting-started.md b/docs/guide/getting-started.md index 646eada..f661fef 100644 --- a/docs/guide/getting-started.md +++ b/docs/guide/getting-started.md @@ -27,16 +27,16 @@ Grab a package from the [download page](/download), then: ::: code-group ```bash [Debian/Ubuntu] -sudo apt install ./thinkutils_*_amd64.deb +sudo apt install ./thinkutils_*.deb ``` ```bash [Fedora/RHEL] -sudo dnf install ./thinkutils-*.x86_64.rpm +sudo dnf install ./thinkutils-*.rpm ``` ```bash [AppImage] -chmod +x thinkutils_*_amd64.AppImage -./thinkutils_*_amd64.AppImage +chmod +x thinkutils_*.AppImage +./thinkutils_*.AppImage ``` ::: From 7ad7ea2b5b1065ff29a4ba6c0ac4cd87e77821d6 Mon Sep 17 00:00:00 2001 From: Viet Anh Nguyen Date: Mon, 20 Jul 2026 11:36:03 +0700 Subject: [PATCH 3/6] fix(copr): select chroots per architecture, and create the project ExclusiveArch now lists aarch64, but the workflow still resolved chroots with a x86_64-only regex -- so COPR would have built x86_64 only and the widened spec would have shipped nothing extra, silently. Selecting per arch rather than widening the regex is deliberate. Keeping `tail -3` over a combined list turns "newest three releases" into "newest three chroots across both arches". Checked against a realistic chroot list that yields fedora-42-x86_64, fedora-43-aarch64, fedora-43-x86_64 -- two releases of one arch, one of the other, fedora-41 dropped entirely -- and which arch loses coverage shifts every time Fedora branches. (The sibling bulwark project documents this trap as yielding three aarch64 chroots and zero x86_64. That is not what sort -V actually does, since aarch64 sorts before x86_64 within a release; the real failure is uneven, shifting coverage. Same conclusion, different mechanism.) Also creates the COPR project when absent. The project does not exist yet, and every step here assumed it did, so the first publish would have failed on `copr-cli modify`. Matching on "^Name:" because copr-cli list prints a field and an indented block, not a bare name -- grepping the name alone never matches and re-creates an existing project. Adapted from bulwark's scripts/publish-copr.sh. --- .github/workflows/publish-copr.yml | 62 ++++++++++++++++++++++++------ 1 file changed, 51 insertions(+), 11 deletions(-) diff --git a/.github/workflows/publish-copr.yml b/.github/workflows/publish-copr.yml index de9e89f..2b3771d 100644 --- a/.github/workflows/publish-copr.yml +++ b/.github/workflows/publish-copr.yml @@ -81,21 +81,61 @@ jobs: printf '%s\n' "$COPR_API_TOKEN" > ~/.config/copr chmod 600 ~/.config/copr - # The spec fetches crates during %build, which mock forbids by default. - # This is a property of the PROJECT, not the spec, so it does not - # travel with the repository -- hence setting it on every publish - # rather than assuming someone did it once. + # Chroots resolved rather than hardcoded -- a fixed list goes stale + # every six months when Fedora branches. + # + # Selected PER ARCHITECTURE, and that structure is load-bearing rather + # than stylistic. The obvious one-liner -- widening the regex to + # (x86_64|aarch64) and keeping `tail -3` -- takes the last three of the + # COMBINED list, so "newest three releases" silently becomes "newest + # three chroots across both arches". Checked against a realistic chroot + # list, that yields fedora-42-x86_64, fedora-43-aarch64, + # fedora-43-x86_64: two releases of one arch, one of the other, and + # fedora-41 dropped entirely. Which arch loses coverage shifts every + # time Fedora branches, and it exits 0 either way. + # + # This list must stay in step with ExclusiveArch in the spec: a chroot + # for an arch the spec excludes is a build that fails, and an arch in + # the spec with no chroot here is simply never built. Widening + # ExclusiveArch alone ships nothing. + COPR_ARCHES=(x86_64 aarch64) + CHROOTS=() + for a in "${COPR_ARCHES[@]}"; do + mapfile -t arch_chroots < <(copr-cli list-chroots 2>/dev/null \ + | grep -E "^fedora-[0-9]+-${a}$" | sort -V | tail -3) + if [ "${#arch_chroots[@]}" -eq 0 ]; then + echo "::error::no active fedora ${a} chroots found - ${a} would silently never build" + exit 1 + fi + CHROOTS+=("${arch_chroots[@]}") + done + echo "chroots: ${CHROOTS[*]}" + + # `copr-cli list` prints "Name: " plus an indented block, not a + # bare name, so grepping for the name alone never matches and the script + # tries to create a project that already exists. + if ! copr-cli list 2>/dev/null | grep -qE "^Name:[[:space:]]+thinkutils$"; then + echo ">> creating COPR project thinkutils" + # Separate argv entries. "${CHROOTS[@]/#/--chroot }" looks equivalent + # but puts the space INSIDE one argument, so copr-cli sees a single + # unrecognised "--chroot fedora-43-x86_64" token and bails. + CHROOT_ARGS=() + for c in "${CHROOTS[@]}"; do CHROOT_ARGS+=(--chroot "${c}"); done + copr-cli create thinkutils "${CHROOT_ARGS[@]}" \ + --description "ThinkPad fan control, battery care and system monitoring" \ + --enable-net on + fi + + # --enable-net is REQUIRED, not cosmetic: mock disables builder + # networking by default and the spec fetches crates during %build. + # It is a property of the PROJECT, so it does not travel with the + # repository -- hence setting it on every publish rather than trusting + # that someone did it once. copr-cli modify thinkutils --enable-net on || \ echo "::warning::could not set --enable-net; the build will fail if it is off" - # Newest three x86_64 chroots, resolved rather than hardcoded: a fixed - # list goes stale every six months when Fedora branches. - chroots=$(copr-cli list-chroots 2>/dev/null \ - | grep -E '^fedora-[0-9]+-x86_64$' | sort -V | tail -3) - echo "chroots: ${chroots}" - args=() - for c in ${chroots}; do args+=(-r "$c"); done + for c in "${CHROOTS[@]}"; do args+=(-r "$c"); done copr-cli build "${args[@]}" thinkutils "${SRPM}" From 59b5615b380bb9648fccb5418658217879630004 Mon Sep 17 00:00:00 2001 From: Viet Anh Nguyen Date: Mon, 20 Jul 2026 11:41:37 +0700 Subject: [PATCH 4/6] chore(ci): move actions off the deprecated Node 20 runtime GitHub is forcing Node 20 actions onto Node 24 and warning on every job. Six jobs carried the annotation. actions/checkout v4 -> v7 (14x) actions/setup-node v4 -> v7 (4x) actions/upload-artifact v4 -> v7 (5x) actions/download-artifact v4 -> v8 (2x) softprops/action-gh-release v1 -> v3 (1x) Checked the breaking changes rather than assuming a major bump is safe, because two of these are load-bearing here: - download-artifact v8 makes a digest mismatch fail rather than warn. That is a security improvement and pattern/merge-multiple, which release.yml depends on to collect both arches, are unaffected. - setup-node v5 added automatic caching driven by a packageManager field in package.json. There is no such field, so it never triggers and the explicit cache: npm still applies. Also moves the BUILD node from 20 to 22. That is a different layer from the warning -- it is the Node the frontend tooling runs on -- but Node 20 reached end of life in April 2026, so it stopped getting security updates. Verified locally on Node 24: npm ci, npm run validate and the VitePress production build all pass, which is a stronger check than 22. --- .github/workflows/ci.yml | 36 +++++++++++++++--------------- .github/workflows/publish-aur.yml | 2 +- .github/workflows/publish-copr.yml | 2 +- .github/workflows/publish-ppa.yml | 4 ++-- .github/workflows/release.yml | 16 ++++++------- 5 files changed, 30 insertions(+), 30 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d0875df..68511c0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -45,7 +45,7 @@ jobs: version-consistency: runs-on: ubuntu-24.04 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 # One script owns the list, so adding a packaging file cannot leave CI # checking a stale subset of it. - name: Check every version declaration agrees @@ -56,10 +56,10 @@ jobs: frontend: runs-on: ubuntu-24.04 steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 + - uses: actions/checkout@v7 + - uses: actions/setup-node@v7 with: - node-version: 20 + node-version: 22 cache: npm cache-dependency-path: package-lock.json # npm ci, not npm install: install can resolve a tree the lockfile does not @@ -76,7 +76,7 @@ jobs: # Not a workspace: the manifest lives under src-tauri/. working-directory: src-tauri steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - name: Install Rust uses: dtolnay/rust-toolchain@stable @@ -110,7 +110,7 @@ jobs: audit: runs-on: ubuntu-24.04 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - uses: dtolnay/rust-toolchain@stable - run: cargo install cargo-audit --locked @@ -140,7 +140,7 @@ jobs: distro-detection: runs-on: ubuntu-24.04 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - name: Re-fetch os-release from live distro images run: | @@ -219,7 +219,7 @@ jobs: hardware-simulation: runs-on: ubuntu-24.04 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - uses: dtolnay/rust-toolchain@stable - uses: Swatinem/rust-cache@v2 with: @@ -353,10 +353,10 @@ jobs: fi echo "confirmed ${actual}" - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 + - uses: actions/checkout@v7 + - uses: actions/setup-node@v7 with: - node-version: 20 + node-version: 22 - uses: dtolnay/rust-toolchain@stable - uses: Swatinem/rust-cache@v2 with: @@ -394,7 +394,7 @@ jobs: # Per-arch name: a single `packages` artifact would have the two matrix # legs racing to overwrite each other, and the loser's packages would # simply not exist with nothing reporting it. - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@v7 with: name: packages-${{ matrix.arch }} path: | @@ -429,7 +429,7 @@ jobs: name: gui-launch (${{ matrix.arch }}) timeout-minutes: 45 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - name: This runner really is ${{ matrix.arch }} run: | @@ -441,7 +441,7 @@ jobs: fi echo "confirmed ${actual}" - - uses: actions/download-artifact@v4 + - uses: actions/download-artifact@v8 with: name: packages-${{ matrix.arch }} path: artifacts @@ -465,7 +465,7 @@ jobs: # just whatever reached stdout before the container was discarded. - name: Upload screenshots, OCR text and app logs if: always() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: gui-launch-evidence-${{ matrix.arch }} path: build/gui-test-out/ @@ -478,10 +478,10 @@ jobs: docs: runs-on: ubuntu-24.04 steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 + - uses: actions/checkout@v7 + - uses: actions/setup-node@v7 with: - node-version: 20 + node-version: 22 cache: npm cache-dependency-path: package-lock.json - run: npm ci diff --git a/.github/workflows/publish-aur.yml b/.github/workflows/publish-aur.yml index ea0f72a..29df2b8 100644 --- a/.github/workflows/publish-aur.yml +++ b/.github/workflows/publish-aur.yml @@ -28,7 +28,7 @@ jobs: set -euo pipefail pacman -Sy --noconfirm --needed base-devel git openssh pacman-contrib jq - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 # The tag must exist and be pushed BEFORE publishing: PKGBUILD's source # points at the tag tarball, so a queued build would 404 partway through diff --git a/.github/workflows/publish-copr.yml b/.github/workflows/publish-copr.yml index 2b3771d..1f49717 100644 --- a/.github/workflows/publish-copr.yml +++ b/.github/workflows/publish-copr.yml @@ -29,7 +29,7 @@ jobs: # Needed for the pushed-tag check below. git config --global --add safe.directory '*' - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 # Source0 points at the tag tarball, so a queued build 404s partway through # if the tag is not pushed. Cheaper to catch here than in the build log. diff --git a/.github/workflows/publish-ppa.yml b/.github/workflows/publish-ppa.yml index 70d73a6..95b529c 100644 --- a/.github/workflows/publish-ppa.yml +++ b/.github/workflows/publish-ppa.yml @@ -25,7 +25,7 @@ jobs: runs-on: ubuntu-24.04 timeout-minutes: 90 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 with: # build-ppa-source.sh uses `git archive HEAD`, which needs real history # rather than a shallow clone. @@ -119,7 +119,7 @@ jobs: dput ppa:vietanhng/thinkutils "${changes}" done - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@v7 if: always() with: name: ppa-source-packages diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 04b1d27..93301f2 100755 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -78,12 +78,12 @@ jobs: echo "confirmed ${actual}" - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v7 - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v7 with: - node-version: '20' + node-version: '22' - name: Setup Rust uses: dtolnay/rust-toolchain@stable @@ -176,7 +176,7 @@ jobs: esac echo "OK: ${{ matrix.arch }} package ships the binary, a desktop entry, resolves every library, and holds the glibc floor" - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@v7 with: name: release-packages-${{ matrix.arch }} path: | @@ -195,7 +195,7 @@ jobs: contents: write steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v7 - name: Get version id: version @@ -206,7 +206,7 @@ jobs: echo "version=dev-$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT fi - - uses: actions/download-artifact@v4 + - uses: actions/download-artifact@v8 with: pattern: release-packages-* merge-multiple: true @@ -233,7 +233,7 @@ jobs: - name: Create Release if: startsWith(github.ref, 'refs/tags/') - uses: softprops/action-gh-release@v1 + uses: softprops/action-gh-release@v3 with: files: | src-tauri/target/release/bundle/deb/*.deb @@ -414,7 +414,7 @@ jobs: - name: Upload artifacts (non-tag builds) if: "!startsWith(github.ref, 'refs/tags/')" - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: thinkutils-packages-${{ steps.version.outputs.version }} path: | From a23c6088ca247205394fe75b28f85bc926a1027d Mon Sep 17 00:00:00 2001 From: Viet Anh Nguyen Date: Mon, 20 Jul 2026 11:53:36 +0700 Subject: [PATCH 5/6] fix(ci): AppImage has its own architecture spelling gui-launch (aarch64) failed looking for thinkutils_0.1.10_arm64.AppImage while Tauri had produced thinkutils_0.1.10_aarch64.AppImage. Tauri names architectures per format, and they do not agree: format x86_64 aarch64 .deb amd64 arm64 .rpm x86_64 aarch64 .AppImage amd64 aarch64 So AppImage follows the deb convention on x86_64 and the rpm convention on aarch64. Deriving it from DEB_ARCH is therefore right on x86_64 by coincidence and wrong on ARM -- which is why this passed every x86_64 run and failed the first time anything executed the suite on aarch64. Verified against the real filenames from both CI jobs rather than from reasoning: all six patterns match. Worth recording what the failure actually proved. Four of five aarch64 targets had already PASSED -- deb on ubuntu 22.04, 24.04 and debian 12, rpm on fedora 41 -- each reporting degraded hardware mode correctly, painting its templates, and exiting cleanly. The app runs on ARM. Only the harness could not find one file. --- scripts/test-gui-packages-docker.sh | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/scripts/test-gui-packages-docker.sh b/scripts/test-gui-packages-docker.sh index 9650e5c..eea9805 100755 --- a/scripts/test-gui-packages-docker.sh +++ b/scripts/test-gui-packages-docker.sh @@ -85,18 +85,31 @@ fi # from `uname -m` keeps the artifact patterns below honest on either runner -- # hardcoding amd64/x86_64 made them match nothing on aarch64, which stage_one # reports as "no artifact" rather than as an unsupported architecture. +# All three differ, and AppImage is the trap: it follows the DEB convention on +# x86_64 (amd64) but the RPM convention on aarch64 (aarch64, not arm64). So it +# cannot be derived from either of the others, and a script that reuses DEB_ARCH +# for it is correct on x86_64 by coincidence and broken on ARM. That is exactly +# how it shipped -- the mismatch is invisible until something actually runs the +# suite on aarch64. +# +# format x86_64 aarch64 +# .deb amd64 arm64 +# .rpm x86_64 aarch64 +# .AppImage amd64 aarch64 case "${ARCH}" in x86_64) DEB_ARCH=amd64 RPM_ARCH=x86_64 + APPIMAGE_ARCH=amd64 ;; aarch64) DEB_ARCH=arm64 RPM_ARCH=aarch64 + APPIMAGE_ARCH=aarch64 ;; esac -echo "architecture: ${ARCH} (deb: ${DEB_ARCH}, rpm: ${RPM_ARCH})" +echo "architecture: ${ARCH} (deb: ${DEB_ARCH}, rpm: ${RPM_ARCH}, appimage: ${APPIMAGE_ARCH})" shopt -s nullglob @@ -490,7 +503,7 @@ for spec in "${TARGETS[@]}"; do # --appimage-extract rather than a direct run: mounting an AppImage # needs FUSE, which a container does not have. Extraction exercises # the same payload. - pkg="$(stage_one "thinkutils_VERSION_${DEB_ARCH}.AppImage")" || { overall=1; continue; } + pkg="$(stage_one "thinkutils_VERSION_${APPIMAGE_ARCH}.AppImage")" || { overall=1; continue; } docker run --rm -v "${STAGE}:/a:ro" -v "${tout}:/out" "${image:-ubuntu:22.04}" bash -c " set -u export DEBIAN_FRONTEND=noninteractive From 00787520c158d19e5aee65a72b3205b56e65e61d Mon Sep 17 00:00:00 2001 From: Viet Anh Nguyen Date: Mon, 20 Jul 2026 11:57:09 +0700 Subject: [PATCH 6/6] test: the download page must match the assets a release publishes release.yml renames every artifact and docs/download.md matches release assets by suffix. Neither file imports the other, and when they disagree NOTHING fails: url() falls back to the releases page, so every button still works while silently no longer linking the actual package -- for whichever architecture drifted. That is easy to get wrong because Tauri spells architectures per format and inconsistently between them: format x86_64 aarch64 .deb amd64 arm64 .rpm x86_64 aarch64 .AppImage amd64 aarch64 <- deb's spelling on x86, rpm's on ARM The AppImage row already cost a red run this session. Both sides are PARSED rather than restated. The first version of this built the expected suffixes out of release.yml's matrix and compared them to filenames also built from that matrix -- comparing a thing to itself, which passed regardless of what the page said. Caught it by mutating the page's rpm suffix: only the second test failed. Now the page's ARCHES table is parsed and the first test fails too, naming the consequence: on aarch64: release publishes thinkutils-9.9.9.aarch64.rpm but the download page looks for *.arm64.rpm - the button would silently fall back to the releases page 134 tests. --- src-tauri/tests/release_asset_names.rs | 207 +++++++++++++++++++++++++ 1 file changed, 207 insertions(+) create mode 100644 src-tauri/tests/release_asset_names.rs diff --git a/src-tauri/tests/release_asset_names.rs b/src-tauri/tests/release_asset_names.rs new file mode 100644 index 0000000..459ad93 --- /dev/null +++ b/src-tauri/tests/release_asset_names.rs @@ -0,0 +1,207 @@ +//! The download page must look for the filenames the release actually produces. +//! +//! Two files have to agree and neither imports the other: `release.yml` renames +//! each artifact after building it, and `docs/download.md` matches release +//! assets by suffix to build its download buttons. +//! +//! When they disagree nothing fails. `url()` falls back to the releases page, so +//! every button still works — it just stops linking the actual package, silently, +//! for whichever architecture drifted. The page looks entirely correct. +//! +//! Tauri makes this easy to get wrong by naming architectures differently per +//! format, and inconsistently between them: +//! +//! ```text +//! format x86_64 aarch64 +//! .deb amd64 arm64 +//! .rpm x86_64 aarch64 +//! .AppImage amd64 aarch64 <- deb's spelling on x86_64, rpm's on ARM +//! ``` +//! +//! That last row already cost a red CI run: the launch suite derived AppImage +//! from the deb architecture, which is correct on x86_64 by coincidence and +//! wrong on aarch64. + +use std::path::PathBuf; + +fn repo_file(rel: &str) -> String { + let p = PathBuf::from(concat!(env!("CARGO_MANIFEST_DIR"), "/..")).join(rel); + std::fs::read_to_string(&p).unwrap_or_else(|e| panic!("cannot read {}: {}", p.display(), e)) +} + +/// `(arch, deb_arch, rpm_arch)` as declared by release.yml's build matrix. +fn matrix_arches() -> Vec<(String, String, String)> { + let release = repo_file(".github/workflows/release.yml"); + let mut out = Vec::new(); + + for line in release.lines() { + let l = line.trim(); + if !l.starts_with("- { arch:") { + continue; + } + let field = |name: &str| -> Option { + let at = l.find(&format!("{}:", name))? + name.len() + 1; + Some( + l[at..] + .trim_start() + .split([',', '}']) + .next()? + .trim() + .trim_matches('"') + .to_string(), + ) + }; + if let (Some(a), Some(d), Some(r)) = (field("arch"), field("deb_arch"), field("rpm_arch")) { + out.push((a, d, r)); + } + } + + assert_eq!( + out.len(), + 2, + "expected two architectures in release.yml's build matrix, parsed {:?}", + out + ); + out +} + +/// The exact asset names a release publishes, per architecture. +fn published_names(deb: &str, rpm: &str) -> [String; 3] { + let release = repo_file(".github/workflows/release.yml"); + assert_predicates_unchanged(); + + // Assert the rename templates are still the ones this test models, rather + // than silently checking names nothing produces. + for template in [ + "thinkutils_${V}_${{ matrix.deb_arch }}.deb", + "thinkutils-${V}.${{ matrix.rpm_arch }}.rpm", + "thinkutils_${V}_${{ matrix.deb_arch }}.AppImage", + ] { + assert!( + release.contains(template), + "release.yml no longer renames to {} - this test is modelling names that are not produced", + template + ); + } + + [ + format!("thinkutils_9.9.9_{}.deb", deb), + format!("thinkutils-9.9.9.{}.rpm", rpm), + format!("thinkutils_9.9.9_{}.AppImage", deb), + ] +} + +/// The arch table `docs/download.md` actually declares, parsed from the page. +/// +/// Parsed rather than reconstructed from the matrix: building the expected +/// suffixes out of the matrix values and then comparing them to matrix-derived +/// filenames compares a thing to itself and passes no matter what the page says. +fn page_arches() -> Vec<(String, String, String)> { + let page = repo_file("docs/download.md"); + let mut out = Vec::new(); + + for line in page.lines() { + let l = line.trim(); + // e.g. aarch64: { label: "ARM (aarch64)", deb: "arm64", rpm: "aarch64" }, + if !l.contains("deb:") || !l.contains("rpm:") || !l.contains("label:") { + continue; + } + let Some((arch, rest)) = l.split_once(':') else { + continue; + }; + let field = |name: &str| -> Option { + let at = rest.find(&format!("{}:", name))? + name.len() + 1; + Some( + rest[at..] + .trim_start() + .trim_start_matches('"') + .split('"') + .next()? + .to_string(), + ) + }; + if let (Some(d), Some(r)) = (field("deb"), field("rpm")) { + out.push((arch.trim().to_string(), d, r)); + } + } + + assert_eq!( + out.len(), + 2, + "expected two architectures in download.md's ARCHES table, parsed {:?}", + out + ); + out +} + +#[test] +fn the_download_page_matches_every_published_asset() { + let matrix = matrix_arches(); + let page = page_arches(); + + for (arch, deb, rpm) in &matrix { + let (_, page_deb, page_rpm) = page + .iter() + .find(|(a, _, _)| a == arch) + .unwrap_or_else(|| panic!("download.md has no entry for {}", arch)); + + // Names the release publishes, against suffixes the PAGE declares. + let names = published_names(deb, rpm); + let suffixes = [ + format!("_{}.deb", page_deb), + format!(".{}.rpm", page_rpm), + format!("_{}.AppImage", page_deb), + ]; + + for (name, suffix) in names.iter().zip(suffixes.iter()) { + assert!( + name.ends_with(suffix.as_str()), + "on {}: release publishes {} but the download page looks for *{} - \ + the button would silently fall back to the releases page", + arch, + name, + suffix + ); + } + } +} + +/// The page's architecture table must cover exactly the matrix, or a built +/// architecture has no way to be selected and its packages are unreachable. +#[test] +fn the_download_page_offers_every_built_architecture() { + let page = repo_file("docs/download.md"); + + for (arch, deb, rpm) in matrix_arches() { + assert!( + page.contains(&format!("{}:", arch)), + "download.md has no entry for {}, which release.yml builds", + arch + ); + assert!( + page.contains(&format!("deb: \"{}\"", deb)) + && page.contains(&format!("rpm: \"{}\"", rpm)), + "download.md's {} entry does not carry deb: {} / rpm: {}", + arch, + deb, + rpm + ); + } +} + +/// The page must still match by suffix at all. If the predicates are rewritten, +/// the parsing above models something the page no longer does. +fn assert_predicates_unchanged() { + let page = repo_file("docs/download.md"); + for pred in [ + "n.endsWith(`_${suffix.value.deb}.deb`)", + "n.endsWith(`.${suffix.value.rpm}.rpm`)", + "n.endsWith(`_${suffix.value.deb}.AppImage`)", + ] { + assert!( + page.contains(pred), + "download.md no longer uses the predicate {} - this test models matching it does not do", + pred + ); + } +}