diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index fb67eea..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:
@@ -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,14 +338,34 @@ jobs:
librsvg2-dev patchelf xdg-utils
git config --global --add safe.directory "${GITHUB_WORKSPACE}"
- - uses: actions/checkout@v4
- - uses: actions/setup-node@v4
+ # 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@v7
+ - uses: actions/setup-node@v7
with:
- node-version: 20
+ node-version: 22
- uses: dtolnay/rust-toolchain@stable
- 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
- - uses: actions/upload-artifact@v4
+ # 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@v7
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
+ - uses: actions/checkout@v7
+
+ - 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
+ - uses: actions/download-artifact@v8
with:
- name: packages
+ name: packages-${{ matrix.arch }}
path: artifacts
# The script expects Tauri's bundle layout, not a flat artifact directory.
@@ -392,9 +465,9 @@ 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
+ name: gui-launch-evidence-${{ matrix.arch }}
path: build/gui-test-out/
if-no-files-found: warn
retention-days: 14
@@ -405,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 de9e89f..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.
@@ -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}"
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 b990260..93301f2 100755
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -16,49 +16,94 @@ 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
+ 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
- - 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,11 +165,75 @@ 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@v7
+ 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@v7
+
+ - 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@v8
+ 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/')
- uses: softprops/action-gh-release@v1
+ uses: softprops/action-gh-release@v3
with:
files: |
src-tauri/target/release/bundle/deb/*.deb
@@ -302,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: |
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) {
Looking up the latest release…
-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.
+:::
```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/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
```
:::
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..eea9805 100755
--- a/scripts/test-gui-packages-docker.sh
+++ b/scripts/test-gui-packages-docker.sh
@@ -59,15 +59,58 @@ 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.
+# 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}, appimage: ${APPIMAGE_ARCH})"
+
shopt -s nullglob
# The version currently declared, so the right artifact is picked out of a bundle
@@ -427,7 +470,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 +486,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 +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_amd64.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
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
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
+ );
+ }
+}