From 25badcd47b573c9a9b6fce2c6bd9a82bf67e7f3b Mon Sep 17 00:00:00 2001 From: Philippe Matray Date: Sat, 5 Sep 2026 01:17:30 +0200 Subject: [PATCH 1/4] feat: resolve the data directory instead of assuming the checkout modules/, config/layouts.toml and fonts/*.codepoints were found at os.path.dirname(os.path.dirname(__file__)) -- one directory above the python package. That is the repo root in a git checkout and nothing useful anywhere else: installed as a system package, macarchy_touchbar/ sits in site-packages/, so the daemon would look for modules/ in /usr/lib/pythonX.Y/site-packages/ and come up with an empty bar. macarchy-install#16 found this before the PKGBUILD was written, which is the only reason it is not a shipped artifact that installs cleanly and then fails to start. paths.data_root() resolves three candidates in order: $MACARCHY_TOUCHBAR_DATA, then /usr/share/macarchy-touchbar if it really holds modules/, then the checkout root. The env var is honoured even when it points nowhere -- someone who sets it meant it, and a silent fallback would hide the typo until the bar came up empty. The checkout fallback is the old behaviour unchanged, so ./install.sh keeps working exactly as before; verified by starting the daemon headless from a checkout and watching all five modules plus the Jarvis plugin load. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01SYBhT1xfp3MQ1w3687F4Mp --- macarchy_touchbar/daemon.py | 8 +++--- macarchy_touchbar/draw.py | 5 ++-- macarchy_touchbar/paths.py | 30 ++++++++++++++++++++++ tests/test_data_root.py | 51 +++++++++++++++++++++++++++++++++++++ 4 files changed, 88 insertions(+), 6 deletions(-) create mode 100644 macarchy_touchbar/paths.py create mode 100644 tests/test_data_root.py diff --git a/macarchy_touchbar/daemon.py b/macarchy_touchbar/daemon.py index 6e613a8..f52f375 100644 --- a/macarchy_touchbar/daemon.py +++ b/macarchy_touchbar/daemon.py @@ -20,7 +20,7 @@ from .uinput import VirtualKeyboard from .widgets import Sprite -ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +from macarchy_touchbar.paths import data_root # noqa: E402 HOME = os.path.expanduser("~") CFG = os.path.join(os.environ.get("XDG_CONFIG_HOME") or f"{HOME}/.config", "macarchy-touchbar", "layouts.toml") PLUGINS = os.path.join(os.environ.get("XDG_CONFIG_HOME") or f"{HOME}/.config", "omarchy", "plugins") @@ -40,7 +40,7 @@ def build(loop, output, config, plugins_dir=None, shell_json=None): host = ModuleHost(loop, None, registry) bar = Bar(output, loop, Painter(output.surface), config, registry, host) host.hooks = bar - specs = discover(os.path.join(ROOT, "modules"), plugins_dir or PLUGINS, + specs = discover(os.path.join(data_root(), "modules"), plugins_dir or PLUGINS, _shell_json() if shell_json is None else shell_json) for spec in specs: host.load(spec) @@ -68,7 +68,7 @@ def _load_config(path): return Config.load(path) except (OSError, ValueError) as e: log(f"{path}: {e}; using the shipped layouts") - return Config.load(os.path.join(ROOT, "config", "layouts.toml")) + return Config.load(os.path.join(data_root(), "config", "layouts.toml")) def run_daemon(headless=False, config_path=CFG): @@ -155,7 +155,7 @@ def deliver(gs): def reload(): nonlocal config config = _load_config(config_path) - rediscover(host, os.path.join(ROOT, "modules"), PLUGINS, _shell_json()) + rediscover(host, os.path.join(data_root(), "modules"), PLUGINS, _shell_json()) bar.reload_config(config) return "reloaded" diff --git a/macarchy_touchbar/draw.py b/macarchy_touchbar/draw.py index ef29c6a..023c5d4 100644 --- a/macarchy_touchbar/draw.py +++ b/macarchy_touchbar/draw.py @@ -21,8 +21,9 @@ except (ValueError, ImportError): Gdk = None -ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) -CODEPOINTS = os.path.join(ROOT, "fonts", "MaterialSymbolsRounded.codepoints") +from macarchy_touchbar.paths import data_root + +CODEPOINTS = os.path.join(data_root(), "fonts", "MaterialSymbolsRounded.codepoints") class Theme: diff --git a/macarchy_touchbar/paths.py b/macarchy_touchbar/paths.py new file mode 100644 index 0000000..c834a0d --- /dev/null +++ b/macarchy_touchbar/paths.py @@ -0,0 +1,30 @@ +"""Where the daemon's data lives. + +modules/, config/layouts.toml and fonts/*.codepoints used to be found at +`os.path.dirname(os.path.dirname(__file__))` — one directory above the python +package. That is the repo root in a git checkout and nothing useful anywhere +else: installed as a system package, macarchy_touchbar/ sits in site-packages/, +so the daemon looked for modules/ in /usr/lib/pythonX.Y/site-packages/. +macarchy-install#16. + +Three candidates, in order: + + 1. $MACARCHY_TOUCHBAR_DATA — honoured even when it does not exist. Someone who + sets it meant it, and falling back would hide their typo until the bar came + up empty. + 2. /usr/share/macarchy-touchbar — a package's data, used only if really there. + 3. the checkout root — the old behaviour, and still the common one. +""" +import os + +PACKAGED = "/usr/share/macarchy-touchbar" +_CHECKOUT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + + +def data_root(): + override = os.environ.get("MACARCHY_TOUCHBAR_DATA") + if override: + return override + if os.path.isdir(os.path.join(PACKAGED, "modules")): + return PACKAGED + return _CHECKOUT diff --git a/tests/test_data_root.py b/tests/test_data_root.py new file mode 100644 index 0000000..1a1a55c --- /dev/null +++ b/tests/test_data_root.py @@ -0,0 +1,51 @@ +"""Where the daemon looks for modules/, config/ and fonts/. + +It used to be one directory above the python package — true in a git checkout, +where that is the repo root, and false in every other layout. A system package +puts macarchy_touchbar/ in site-packages/, so `ROOT` became +/usr/lib/pythonX.Y/site-packages/ and the daemon looked for modules/ there. +macarchy-install#16. + +The checkout fallback is the one that must never break: ./install.sh symlinks +bin/macarchy-touchbar out of the repo and everything still has to resolve. +""" +import os + +import pytest + +from macarchy_touchbar import paths + + +def test_the_env_var_wins(tmp_path, monkeypatch): + monkeypatch.setenv("MACARCHY_TOUCHBAR_DATA", str(tmp_path)) + assert paths.data_root() == str(tmp_path) + + +def test_the_packaged_directory_is_used_when_it_exists(tmp_path, monkeypatch): + monkeypatch.delenv("MACARCHY_TOUCHBAR_DATA", raising=False) + packaged = tmp_path / "usr" / "share" / "macarchy-touchbar" + (packaged / "modules").mkdir(parents=True) + monkeypatch.setattr(paths, "PACKAGED", str(packaged)) + assert paths.data_root() == str(packaged) + + +def test_the_checkout_is_the_fallback(tmp_path, monkeypatch): + # No env var, no packaged directory: the repo root, exactly as before. + monkeypatch.delenv("MACARCHY_TOUCHBAR_DATA", raising=False) + monkeypatch.setattr(paths, "PACKAGED", str(tmp_path / "nowhere")) + assert os.path.isdir(os.path.join(paths.data_root(), "modules")) + + +def test_a_checkout_really_resolves_layouts_toml(monkeypatch): + # The bug from the other side: a root that resolves but holds no config. + monkeypatch.delenv("MACARCHY_TOUCHBAR_DATA", raising=False) + assert os.path.isfile(os.path.join(paths.data_root(), "config", "layouts.toml")) + + +def test_an_env_var_pointing_nowhere_is_still_honoured(tmp_path, monkeypatch): + # Explicit beats clever: if someone sets it, they meant it, and a silent + # fallback to the checkout would hide their typo until the daemon drew a + # bar with no modules on it. + missing = tmp_path / "gone" + monkeypatch.setenv("MACARCHY_TOUCHBAR_DATA", str(missing)) + assert paths.data_root() == str(missing) From 9684fefe0ae2b8ad263b6f9b0fbab7ac53c9650e Mon Sep 17 00:00:00 2001 From: Philippe Matray Date: Sat, 5 Sep 2026 01:21:12 +0200 Subject: [PATCH 2/4] feat(pkg): a PKGBUILD that mirrors install.sh Installs system-wide what ./install.sh installs into $HOME: the CLI, the python package, modules/ and config/ under /usr/share, the udev rule, the modules-load drop-in and the user unit. Built for real on the target hardware from the v0.4.0 tarball -- 5.3 MB, and every path verified with `tar -tf` rather than assumed. Two things the real build taught, neither of which the tests saw first: The Material Symbols font is fetched by install.sh:27 from `master`, unpinned. Downloading it today from the commit this PKGBUILD pins gives a DIFFERENT file from the copy installed on 2 Sep -- 15,107,604 bytes against 15,090,976. Two machines set up a week apart do not have the same font. The commit is pinned and both checksums are real. The .codepoints file is gitignored, so it is absent from the release tarball -- and draw.py:57 opens it. The first version of this PKGBUILD had `|| true` on that install line, which silently produced a package with an empty fonts/ directory and a bar with no icons. It is now a third pinned source and the `|| true` is gone; a test asserts no `|| true` survives on a non-comment line. pkgver carries release-please's `x-release-please-version` marker and the config lists PKGBUILD under extra-files, so the version is maintained rather than hand-bumped. A test pins both -- without them a release ships a package built from the previous tag's number, silently. tests/test_pkgbuild.py keeps the two install channels from drifting: everything install.sh copies must appear in package(). It also records the asymmetry that caused this issue -- macarchy_touchbar/, modules/ and config/ are NOT copied by install.sh, because the daemon reads them in place from the checkout. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01SYBhT1xfp3MQ1w3687F4Mp --- PKGBUILD | 60 +++++++++++++++++++++ release-please-config.json | 5 +- tests/test_pkgbuild.py | 105 +++++++++++++++++++++++++++++++++++++ 3 files changed, 169 insertions(+), 1 deletion(-) create mode 100644 PKGBUILD create mode 100644 tests/test_pkgbuild.py diff --git a/PKGBUILD b/PKGBUILD new file mode 100644 index 0000000..67357e3 --- /dev/null +++ b/PKGBUILD @@ -0,0 +1,60 @@ +# Maintainer: Philippe Matray +# +# Installs system-wide what ./install.sh installs into $HOME. The two channels +# are kept honest by tests/test_pkgbuild.py, which fails if either grows a file +# the other does not carry. +pkgname=macarchy-touchbar +pkgver=0.4.0 # x-release-please-version +pkgrel=1 +pkgdesc="A Touch Bar daemon for MacBooks on Linux — draws every pixel over DRM, follows the focused app, takes modules" +arch=('any') +url="https://github.com/macarchy/macarchy-touchbar" +license=('MIT') +depends=('python' 'python-cairo' 'python-gobject' 'brightnessctl') +optdepends=('papirus-icon-theme: application icons on the bar' + 'tiny-dfr: what install.sh --uninstall hands the bar back to') +# install.sh:27 curls this from master, unpinned -- the file changed between +# 2 Sep and 5 Sep 2026. A package has to be reproducible, so the commit is +# pinned and the checksum is real. +source=("$pkgname-$pkgver.tar.gz::$url/archive/refs/tags/v$pkgver.tar.gz" + "MaterialSymbolsRounded.ttf::https://raw.githubusercontent.com/google/material-design-icons/0cbb08816df07faaae3dca060d4ebb10b66c214f/variablefont/MaterialSymbolsRounded%5BFILL%2CGRAD%2Copsz%2Cwght%5D.ttf" + "MaterialSymbolsRounded.codepoints::https://raw.githubusercontent.com/google/material-design-icons/0cbb08816df07faaae3dca060d4ebb10b66c214f/variablefont/MaterialSymbolsRounded%5BFILL%2CGRAD%2Copsz%2Cwght%5D.codepoints") +sha256sums=('SKIP' + '24f9f678388abc5a0e2c5bf722eeab7aea08a0a058459920d5eb117bf0f8557b' + 'cbea7bfbd34d1d4f8dd2628c34587e447f935cf4f2219b264988da48736eca75') + +package() { + cd "$srcdir/$pkgname-$pkgver" + + install -Dm755 bin/macarchy-touchbar "$pkgdir/usr/bin/macarchy-touchbar" + + # Derived, never hardcoded: it carries the interpreter version. + local site + site=$(python -c 'import sysconfig; print(sysconfig.get_paths()["purelib"])') + install -d "$pkgdir$site" + cp -r macarchy_touchbar "$pkgdir$site/" + + # What the daemon reads at runtime. paths.data_root() looks here when it + # exists, and falls back to the checkout otherwise, so both channels work. + install -d "$pkgdir/usr/share/$pkgname" + cp -r modules config "$pkgdir/usr/share/$pkgname/" + + install -Dm644 "$srcdir/MaterialSymbolsRounded.ttf" \ + "$pkgdir/usr/share/fonts/TTF/MaterialSymbolsRounded.ttf" + # draw.py:57 opens this. It is gitignored, so it is NOT in the release tarball + # and has to come from the same pinned commit as the font. No `|| true`: a + # missing codepoints file means a bar with no icons, and the build should say + # so rather than ship one. + install -Dm644 "$srcdir/MaterialSymbolsRounded.codepoints" \ + "$pkgdir/usr/share/$pkgname/fonts/MaterialSymbolsRounded.codepoints" + + install -Dm644 udev/70-macarchy-touchbar.rules \ + "$pkgdir/usr/lib/udev/rules.d/70-macarchy-touchbar.rules" + install -Dm644 modules-load.d/macarchy-touchbar.conf \ + "$pkgdir/usr/lib/modules-load.d/macarchy-touchbar.conf" + install -Dm644 systemd/macarchy-touchbar.service \ + "$pkgdir/usr/lib/systemd/user/macarchy-touchbar.service" + + install -Dm644 LICENSE "$pkgdir/usr/share/licenses/$pkgname/LICENSE" + install -Dm644 README.md "$pkgdir/usr/share/doc/$pkgname/README.md" +} diff --git a/release-please-config.json b/release-please-config.json index 0a52ab3..ae0c7cc 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -7,7 +7,10 @@ "release-type": "simple", "package-name": "macarchy-touchbar", "include-component-in-tag": false, - "changelog-path": "CHANGELOG.md" + "changelog-path": "CHANGELOG.md", + "extra-files": [ + "PKGBUILD" + ] } } } diff --git a/tests/test_pkgbuild.py b/tests/test_pkgbuild.py new file mode 100644 index 0000000..71547c2 --- /dev/null +++ b/tests/test_pkgbuild.py @@ -0,0 +1,105 @@ +"""The PKGBUILD must land everything install.sh lands. + +Two install channels that drift apart are worse than one: the package would +install cleanly and be missing a file nobody notices until the daemon needs it. +So this reads both and asserts they agree on WHAT is installed, not on where — +install.sh works in $HOME, the package works in /usr. macarchy-install#16. +""" +import json +import re +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +PKGBUILD = (ROOT / "PKGBUILD").read_text() +INSTALL = (ROOT / "install.sh").read_text() + +# What install.sh actually COPIES onto the machine. The package must carry each +# one too; only the destination differs ($HOME versus /usr). +INSTALLED = [ + "bin/macarchy-touchbar", + "udev/70-macarchy-touchbar.rules", + "modules-load.d/macarchy-touchbar.conf", + "systemd/macarchy-touchbar.service", + "MaterialSymbolsRounded", +] + +# What install.sh does NOT copy, because the daemon reads it in place from the +# checkout: the python package itself, modules/ and config/. That asymmetry IS +# the packaging bug — a package has nowhere to read "in place" from, which is +# why paths.data_root() exists. So the PKGBUILD must carry them and install.sh +# never will. +USED_IN_PLACE = ["macarchy_touchbar", "modules", "config"] +ARTEFACTS = INSTALLED + USED_IN_PLACE + + +def test_the_package_carries_everything_install_sh_does(): + missing = [a for a in ARTEFACTS if a not in PKGBUILD] + assert not missing, f"install.sh installs {missing}; PKGBUILD does not mention them" + + +def test_install_sh_still_installs_what_this_test_claims(): + # The other half of the drift guard: if install.sh stops shipping one of + # these, the list above is stale and the first assertion checks a fiction. + for a in INSTALLED: + stem = a.split("/")[-1] + assert stem in INSTALL, f"{a} is in INSTALLED but install.sh no longer mentions it" + + +def test_the_in_place_data_really_is_read_through_data_root(): + # USED_IN_PLACE is only correct while the daemon resolves those directories + # rather than assuming the checkout. If that regressed, the package would + # ship files nothing reads. + daemon = (ROOT / "macarchy_touchbar" / "daemon.py").read_text() + assert "data_root()" in daemon + assert 'os.path.dirname(os.path.dirname(os.path.abspath(__file__)))' not in daemon + + +def test_the_font_is_pinned_with_a_checksum(): + # install.sh curls it from master, unpinned: the file changed between 2 Sep + # and 5 Sep. A package must be reproducible, so the URL carries a commit and + # the source carries a real sha256 rather than SKIP. + assert re.search(r"raw\.githubusercontent\.com/google/material-design-icons/[0-9a-f]{40}/", PKGBUILD) + sums = re.search(r"sha256sums=\((.*?)\)", PKGBUILD, re.S).group(1).split() + assert len(sums) == 3, "expected three sources: the tarball, the font and its codepoints" + assert any(re.fullmatch(r"'[0-9a-f]{64}'", s) for s in sums), "the font must carry a real checksum" + + +def test_site_packages_is_derived_not_hardcoded(): + # It carries the interpreter version (python3.14 today). + assert "python3." not in PKGBUILD.replace("python3 ", "") + assert "sysconfig" in PKGBUILD + + +def test_the_package_is_arch_independent(): + assert "arch=('any')" in PKGBUILD + + +def test_pkgver_is_maintained_by_release_please(): + # Without the marker, release-please stops bumping pkgver and a release + # ships a package whose version is the previous tag's — silently. + assert "x-release-please-version" in PKGBUILD + cfg = json.loads((ROOT / "release-please-config.json").read_text()) + assert "PKGBUILD" in cfg["packages"]["."]["extra-files"] + + +def test_the_codepoints_are_shipped_and_not_skipped(): + # draw.py:57 opens them; they are gitignored so they are absent from the + # release tarball. A `|| true` here would ship a bar with no icons. + assert "codepoints" in PKGBUILD + code = [l for l in PKGBUILD.splitlines() if not l.lstrip().startswith("#")] + assert not [l for l in code if "|| true" in l], "a silent skip in package()" + + +def test_the_workflow_only_runs_on_a_published_release(): + # The PKGBUILD's source is the release tarball, which does not exist before + # the tag does — a push or pull_request trigger could only ever fail. + wf = (ROOT / ".github" / "workflows" / "package.yml").read_text() + assert "release:" in wf and "types: [published]" in wf + code = [l for l in wf.splitlines() if not l.lstrip().startswith("#")] + assert not [l for l in code if l.strip() in ("push:", "pull_request:")] + + +def test_the_upload_globs_and_clobbers(): + wf = (ROOT / ".github" / "workflows" / "package.yml").read_text() + assert "*.pkg.tar.*" in wf, "hardcoding an extension uploads nothing when PKGEXT differs" + assert "--clobber" in wf, "a re-run must replace the asset, not fail" From 72f2a3d8e13977c3541603e3baa0f4e3c45dbb0b Mon Sep 17 00:00:00 2001 From: Philippe Matray Date: Sat, 5 Sep 2026 01:21:12 +0200 Subject: [PATCH 3/4] feat(ci): attach the package to every release Builds the PKGBUILD in archlinux:base-devel when a release is published and uploads the result onto that release, so `pacman -U` becomes a real way to install this. macarchy-install#16. Release-only on purpose: the PKGBUILD's source is the release tarball, which does not exist until the tag does, so a push or pull_request trigger could only ever fail. workflow_dispatch takes a tag for re-running a job that failed. The upload globs `*.pkg.tar.*` rather than naming an extension -- PKGEXT is .zst in this container and .xz on the maintainer's machine, and hardcoding either uploads nothing on the other. --clobber so a re-run replaces the asset. makepkg runs as a throwaway non-root user because it refuses to run as root, and with --nodeps because `depends` is a runtime contract for the target machine, not a build requirement for the container. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01SYBhT1xfp3MQ1w3687F4Mp --- .github/workflows/package.yml | 61 +++++++++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 .github/workflows/package.yml diff --git a/.github/workflows/package.yml b/.github/workflows/package.yml new file mode 100644 index 0000000..9a98297 --- /dev/null +++ b/.github/workflows/package.yml @@ -0,0 +1,61 @@ +name: package + +# Until now a release carried only the source zip GitHub attaches to any tag, so +# "install macarchy-touchbar" meant "clone the repo and run install.sh". This +# builds the PKGBUILD against the tag that was just released and attaches the +# result, so a release is something you can actually install: +# +# pacman -U macarchy-touchbar--any.pkg.tar.zst +# +# macarchy-install#16. install.sh is untouched and keeps working — the package is +# an additional channel, not a replacement. + +on: + # ONLY on a published release. Never on push or pull_request: the PKGBUILD's + # source is the release tarball, which does not exist until the tag does. + release: + types: [published] + # Manual re-run for a release whose job failed. Idempotent: the upload clobbers. + workflow_dispatch: + inputs: + tag: + description: "Tag to build and attach (e.g. v0.4.1)" + required: true + +permissions: + contents: write # to upload the asset onto the release + +concurrency: + group: package-${{ github.event.release.tag_name || inputs.tag }} + cancel-in-progress: false + +jobs: + build: + runs-on: ubuntu-latest + # arch=('any') — the package is architecture-independent by construction, so + # the x86_64 image is fine and the aarch64 runner would buy nothing. A repo + # that actually compiles needs the other treatment (see macarchy-install#18). + container: archlinux:base-devel + timeout-minutes: 20 + steps: + - uses: actions/checkout@v4 + + - name: Build the package + # makepkg refuses to run as root, and the container is root — so build as + # a throwaway user that owns the tree. --nodeps because `depends` is a + # RUNTIME contract for the target machine, not a build requirement here. + run: | + pacman -Sy --noconfirm --needed git python + useradd -m build + chown -R build:build . + su build -c 'makepkg -f --nodeps --noconfirm' + ls -l ./*.pkg.tar.* + + - name: Attach it to the release + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ github.event.release.tag_name || inputs.tag }} + # A GLOB, not a name: PKGEXT differs between machines (.zst in this + # container, .xz on the maintainer's box), and hardcoding one silently + # uploads nothing. --clobber so a re-run replaces instead of failing. + run: gh release upload "$TAG" ./*.pkg.tar.* --clobber --repo "$GITHUB_REPOSITORY" From 70d5251375d0b04f4d6db886421338c0a97ffd82 Mon Sep 17 00:00:00 2001 From: Philippe Matray Date: Sat, 5 Sep 2026 01:32:05 +0200 Subject: [PATCH 4/4] fix: address code-review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Eleven findings, and the first would have made the whole feature a decoration. CRITICAL -- `release: [published]` would never have fired. release-please creates the Release with the default GITHUB_TOKEN, and GitHub raises no workflow run from a GITHUB_TOKEN event. Every release would have published with no package and no failed run anywhere to notice. This repo has already paid for that lesson once, which is why the Tauri build was folded into release-please.yml; package.yml is deleted and the job now hangs off release-please's own `release_created` output. HIGH -- the shipped unit says ExecStart=%h/.local/bin/…, written for install.sh's symlink. A package writes nothing into $HOME, so it would have given 203/EXEC, ten restarts to StartLimitBurst and an OnFailure toast, from a package that installed perfectly. package() now rewrites it to /usr/bin and greps to confirm. HIGH -- `gh` lives on the runner, not inside the container; the upload would have died with command not found. github-cli added, and `pacman -Sy` became -Syu so a partial upgrade cannot link the fresh python against a glibc the image lacks. HIGH -- arch=('any') with a site-packages path derived from the BUILDING interpreter bakes that container's python version into the artifact; Asahi's python trails it, and the target gets ImportError while depends=('python') claims otherwise. Fixed by co-locating: macarchy_touchbar/ now installs NEXT TO modules/ and config/ under /usr/share/macarchy-touchbar, so "one directory above the package" resolves in both layouts and there is one rule instead of two. That also dissolves the finding that PACKAGED outranked the checkout and let an installed package hijack every checkout on the machine -- there is no PACKAGED any more. MEDIUM -- pacman cannot do install.sh's non-file half (video group, uinput, masking tiny-dfr), so a package-only install left a blank bar with no explanation. macarchy-touchbar.install now prints those steps, restarts on upgrade and hands the panel back to tiny-dfr on removal. MEDIUM -- workflow_dispatch checked out the default branch, so re-running for an older tag built main's PKGBUILD and clobbered that package onto the old release. It takes an explicit ref now, and the upload refuses a package whose filename does not carry the target tag. LOW -- the drift test matched the whole file including comments, which name every artefact, so deleting an install line still passed: it reads comment-stripped code now. CODEPOINTS froze data_root() at import while daemon.py called it per use; it is codepoints_path() (renamed to avoid colliding with the existing _codepoints cache). The checkout test never patched PACKAGED and would have started asserting against packaged data once this shipped. Rebuilt and re-verified rather than assumed: the unit reads ExecStart=/usr/bin/macarchy-touchbar, nothing lands in site-packages, and the launcher resolves from a checkout, from a simulated /usr prefix via the env var, and fails with the list of places it looked when the tree is genuinely absent. Suite: 159 passed, 2 skipped. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01SYBhT1xfp3MQ1w3687F4Mp --- .github/workflows/package.yml | 61 ----------------------- .github/workflows/release-please.yml | 64 +++++++++++++++++++++++- PKGBUILD | 26 ++++++---- bin/macarchy-touchbar | 24 ++++++++- macarchy-touchbar.install | 32 ++++++++++++ macarchy_touchbar/draw.py | 9 +++- macarchy_touchbar/paths.py | 35 ++++++------- tests/test_data_root.py | 73 +++++++++++++++++----------- tests/test_draw.py | 4 +- tests/test_pkgbuild.py | 68 +++++++++++++++++++------- 10 files changed, 251 insertions(+), 145 deletions(-) delete mode 100644 .github/workflows/package.yml create mode 100644 macarchy-touchbar.install diff --git a/.github/workflows/package.yml b/.github/workflows/package.yml deleted file mode 100644 index 9a98297..0000000 --- a/.github/workflows/package.yml +++ /dev/null @@ -1,61 +0,0 @@ -name: package - -# Until now a release carried only the source zip GitHub attaches to any tag, so -# "install macarchy-touchbar" meant "clone the repo and run install.sh". This -# builds the PKGBUILD against the tag that was just released and attaches the -# result, so a release is something you can actually install: -# -# pacman -U macarchy-touchbar--any.pkg.tar.zst -# -# macarchy-install#16. install.sh is untouched and keeps working — the package is -# an additional channel, not a replacement. - -on: - # ONLY on a published release. Never on push or pull_request: the PKGBUILD's - # source is the release tarball, which does not exist until the tag does. - release: - types: [published] - # Manual re-run for a release whose job failed. Idempotent: the upload clobbers. - workflow_dispatch: - inputs: - tag: - description: "Tag to build and attach (e.g. v0.4.1)" - required: true - -permissions: - contents: write # to upload the asset onto the release - -concurrency: - group: package-${{ github.event.release.tag_name || inputs.tag }} - cancel-in-progress: false - -jobs: - build: - runs-on: ubuntu-latest - # arch=('any') — the package is architecture-independent by construction, so - # the x86_64 image is fine and the aarch64 runner would buy nothing. A repo - # that actually compiles needs the other treatment (see macarchy-install#18). - container: archlinux:base-devel - timeout-minutes: 20 - steps: - - uses: actions/checkout@v4 - - - name: Build the package - # makepkg refuses to run as root, and the container is root — so build as - # a throwaway user that owns the tree. --nodeps because `depends` is a - # RUNTIME contract for the target machine, not a build requirement here. - run: | - pacman -Sy --noconfirm --needed git python - useradd -m build - chown -R build:build . - su build -c 'makepkg -f --nodeps --noconfirm' - ls -l ./*.pkg.tar.* - - - name: Attach it to the release - env: - GH_TOKEN: ${{ github.token }} - TAG: ${{ github.event.release.tag_name || inputs.tag }} - # A GLOB, not a name: PKGEXT differs between machines (.zst in this - # container, .xz on the maintainer's box), and hardcoding one silently - # uploads nothing. --clobber so a re-run replaces instead of failing. - run: gh release upload "$TAG" ./*.pkg.tar.* --clobber --repo "$GITHUB_REPOSITORY" diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml index 328c14e..7196360 100644 --- a/.github/workflows/release-please.yml +++ b/.github/workflows/release-please.yml @@ -14,8 +14,14 @@ on: push: branches: [main] # Manual re-run, e.g. after a token or permission failure. Idempotent: it re-opens or refreshes - # the same release PR rather than creating a second one. + # the same release PR rather than creating a second one. `package_tag` re-runs + # ONLY the packaging job for an existing tag, for a build that failed. workflow_dispatch: + inputs: + package_tag: + description: "Re-build and re-attach the package for this tag (e.g. v0.4.1). Leave empty for a normal run." + required: false + default: "" permissions: contents: write @@ -31,9 +37,65 @@ jobs: release-please: runs-on: ubuntu-latest timeout-minutes: 15 + outputs: + release_created: ${{ steps.release.outputs.release_created }} + tag_name: ${{ steps.release.outputs.tag_name }} steps: - uses: googleapis/release-please-action@v5 + id: release with: target-branch: main config-file: release-please-config.json manifest-file: .release-please-manifest.json + + # Attaches an installable package to the release the job above just cut, so a + # release is something you can `pacman -U` instead of a source zip. + # + # IT LIVES HERE, not in a workflow keyed on `release: [published]`, and that is + # the whole point: release-please creates the Release with the default + # GITHUB_TOKEN, and GitHub does not start workflow runs from events raised by + # GITHUB_TOKEN. A `release: published` trigger would never fire on the real + # path -- and would leave no failed run in the Actions tab to notice. This repo + # has already paid for that lesson once. + package: + needs: release-please + if: needs.release-please.outputs.release_created == 'true' || inputs.package_tag != '' + runs-on: ubuntu-latest + # arch=('any'), so the x86_64 image is honest here: nothing is compiled, and + # the python package is co-located with its data rather than dropped into the + # BUILDING interpreter's site-packages. A repo that really compiles needs the + # other treatment -- macarchy-install#18. + container: archlinux:base-devel + timeout-minutes: 20 + steps: + - uses: actions/checkout@v4 + with: + # The tag, never the default branch: re-running for an older tag must + # not build main's PKGBUILD and clobber that package onto the old release. + ref: ${{ needs.release-please.outputs.tag_name || inputs.package_tag }} + + - name: Build the package + # -Syu, not -Sy: a partial upgrade can link the fresh python against a + # glibc this image does not have, and it reads as a build bug. + # github-cli because `gh` lives on the RUNNER, not inside the container. + # makepkg refuses to run as root, so it gets a throwaway user. + # --nodeps because `depends` is a runtime contract for the target machine. + run: | + pacman -Syu --noconfirm --needed git python github-cli + useradd -m build && chown -R build:build . + su build -c 'makepkg -f --nodeps --noconfirm' + ls -l ./*.pkg.tar.* + + - name: Attach it to the release + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ needs.release-please.outputs.tag_name || inputs.package_tag }} + # A GLOB, not a name: PKGEXT is .zst here and .xz on the maintainer's + # machine, and hardcoding either uploads nothing on the other. The version + # check is what stops a mis-targeted manual re-run from clobbering an old + # release with a package built from a different tag. + run: | + test -n "$(echo ./*.pkg.tar.* )" + ls ./*.pkg.tar.* | grep -q "${TAG#v}" \ + || { echo "built package does not carry $TAG — refusing to upload"; exit 1; } + gh release upload "$TAG" ./*.pkg.tar.* --clobber --repo "$GITHUB_REPOSITORY" diff --git a/PKGBUILD b/PKGBUILD index 67357e3..84728b7 100644 --- a/PKGBUILD +++ b/PKGBUILD @@ -10,6 +10,7 @@ pkgdesc="A Touch Bar daemon for MacBooks on Linux — draws every pixel over DRM arch=('any') url="https://github.com/macarchy/macarchy-touchbar" license=('MIT') +install=macarchy-touchbar.install depends=('python' 'python-cairo' 'python-gobject' 'brightnessctl') optdepends=('papirus-icon-theme: application icons on the bar' 'tiny-dfr: what install.sh --uninstall hands the bar back to') @@ -28,16 +29,14 @@ package() { install -Dm755 bin/macarchy-touchbar "$pkgdir/usr/bin/macarchy-touchbar" - # Derived, never hardcoded: it carries the interpreter version. - local site - site=$(python -c 'import sysconfig; print(sysconfig.get_paths()["purelib"])') - install -d "$pkgdir$site" - cp -r macarchy_touchbar "$pkgdir$site/" - - # What the daemon reads at runtime. paths.data_root() looks here when it - # exists, and falls back to the checkout otherwise, so both channels work. + # Code and data together under /usr/share, NOT the python package in + # site-packages. site-packages would bake the BUILDING interpreter's version + # into an arch=('any') artifact: this is built in a container whose python is + # routinely ahead of Asahi's, and the target would then get ImportError while + # depends=('python') claims to be satisfied. Co-located, "one directory above + # the package" resolves in both layouts and there is one rule, not two. install -d "$pkgdir/usr/share/$pkgname" - cp -r modules config "$pkgdir/usr/share/$pkgname/" + cp -r macarchy_touchbar modules config "$pkgdir/usr/share/$pkgname/" install -Dm644 "$srcdir/MaterialSymbolsRounded.ttf" \ "$pkgdir/usr/share/fonts/TTF/MaterialSymbolsRounded.ttf" @@ -52,7 +51,14 @@ package() { "$pkgdir/usr/lib/udev/rules.d/70-macarchy-touchbar.rules" install -Dm644 modules-load.d/macarchy-touchbar.conf \ "$pkgdir/usr/lib/modules-load.d/macarchy-touchbar.conf" - install -Dm644 systemd/macarchy-touchbar.service \ + # The shipped unit says ExecStart=%h/.local/bin/… because install.sh symlinks + # the CLI there. A package install never writes into $HOME, so shipping it + # verbatim would give 203/EXEC, ten restarts to StartLimitBurst, and an + # OnFailure toast -- from a package that installed perfectly. + sed 's|%h/\.local/bin/|/usr/bin/|' systemd/macarchy-touchbar.service \ + > "$srcdir/macarchy-touchbar.service.pkg" + grep -q '^ExecStart=/usr/bin/' "$srcdir/macarchy-touchbar.service.pkg" # or fail the build + install -Dm644 "$srcdir/macarchy-touchbar.service.pkg" \ "$pkgdir/usr/lib/systemd/user/macarchy-touchbar.service" install -Dm644 LICENSE "$pkgdir/usr/share/licenses/$pkgname/LICENSE" diff --git a/bin/macarchy-touchbar b/bin/macarchy-touchbar index 225c686..d80a4d9 100755 --- a/bin/macarchy-touchbar +++ b/bin/macarchy-touchbar @@ -3,8 +3,28 @@ import os import sys -ROOT = os.path.dirname(os.path.dirname(os.path.realpath(__file__))) -sys.path.insert(0, ROOT) +# Find the tree that holds macarchy_touchbar/ next to modules/ and config/, and +# put it on sys.path. Code and data are co-located ON PURPOSE: everything below +# then resolves relative to the package, exactly as it did when the only layout +# was a git checkout. +# +# The alternative -- python package in site-packages, data in /usr/share -- was +# tried and rejected: it bakes the CI container's interpreter version into an +# arch=('any') package, so a target whose python differs by one minor version +# gets ImportError while depends=('python') claims to be satisfied. +_HERE = os.path.dirname(os.path.dirname(os.path.realpath(__file__))) +_CANDIDATES = (os.environ.get("MACARCHY_TOUCHBAR_DATA"), _HERE, "/usr/share/macarchy-touchbar") +for _root in _CANDIDATES: + if _root and os.path.isdir(os.path.join(_root, "macarchy_touchbar")): + sys.path.insert(0, _root) + break +else: + # Say which places were tried. Falling through to the import would raise a + # bare ImportError naming a module, which tells the reader nothing about the + # real problem: the tree is somewhere this binary did not look. + sys.exit("macarchy-touchbar: cannot find macarchy_touchbar/ — looked in " + + ", ".join(repr(c) for c in _CANDIDATES if c) + + "\nSet MACARCHY_TOUCHBAR_DATA to the tree that holds it.") if len(sys.argv) > 1 and sys.argv[1] == "daemon": from macarchy_touchbar.daemon import main # noqa: E402 diff --git a/macarchy-touchbar.install b/macarchy-touchbar.install new file mode 100644 index 0000000..873b35e --- /dev/null +++ b/macarchy-touchbar.install @@ -0,0 +1,32 @@ +post_install() { + cat <<'NOTE' + + macarchy-touchbar is installed, but pacman cannot do the half of install.sh + that is not file copying. Until these are done the bar will not come up: + + sudo usermod -aG video "$USER" # open the Touch Bar's DRM card (needs a relogin) + sudo modprobe uinput # the rule is shipped; the module must be loaded once + sudo systemctl disable --now tiny-dfr && sudo systemctl mask tiny-dfr + # tiny-dfr drives the same panel; two owners means neither works + + systemctl --user enable --now macarchy-touchbar.service + + A dead bar looks exactly like a bar with nothing on it, so if it stays blank: + journalctl --user -u macarchy-touchbar -b + +NOTE +} + +post_upgrade() { + systemctl --user try-restart macarchy-touchbar.service 2>/dev/null || true +} + +pre_remove() { + systemctl --user disable --now macarchy-touchbar.service 2>/dev/null || true + cat <<'NOTE' + + The Touch Bar is unowned now. To hand it back to tiny-dfr: + sudo systemctl unmask tiny-dfr && sudo systemctl enable --now tiny-dfr + +NOTE +} diff --git a/macarchy_touchbar/draw.py b/macarchy_touchbar/draw.py index 023c5d4..70eac44 100644 --- a/macarchy_touchbar/draw.py +++ b/macarchy_touchbar/draw.py @@ -23,7 +23,12 @@ from macarchy_touchbar.paths import data_root -CODEPOINTS = os.path.join(data_root(), "fonts", "MaterialSymbolsRounded.codepoints") +def codepoints_path(): + # A call, not a module constant: data_root() reads the environment, and a + # constant frozen at import would move modules/ and config/ without moving + # the icon font -- the OSError below is swallowed and every icon silently + # resolves to None. (_codepoints, below, is the parsed cache; different thing.) + return os.path.join(data_root(), "fonts", "MaterialSymbolsRounded.codepoints") class Theme: @@ -54,7 +59,7 @@ def icon_codepoint(name): if _codepoints is None: _codepoints = {} try: - with open(CODEPOINTS) as f: + with open(codepoints_path()) as f: for line in f: n, _, hexcode = line.strip().partition(" ") if hexcode: diff --git a/macarchy_touchbar/paths.py b/macarchy_touchbar/paths.py index c834a0d..466e010 100644 --- a/macarchy_touchbar/paths.py +++ b/macarchy_touchbar/paths.py @@ -1,30 +1,23 @@ """Where the daemon's data lives. -modules/, config/layouts.toml and fonts/*.codepoints used to be found at -`os.path.dirname(os.path.dirname(__file__))` — one directory above the python -package. That is the repo root in a git checkout and nothing useful anywhere -else: installed as a system package, macarchy_touchbar/ sits in site-packages/, -so the daemon looked for modules/ in /usr/lib/pythonX.Y/site-packages/. -macarchy-install#16. +modules/, config/layouts.toml and fonts/*.codepoints sit one directory above the +python package. That was already true in a git checkout, and the packaging work +kept it true rather than inventing a second layout: the package installs +macarchy_touchbar/ NEXT TO modules/ and config/ under /usr/share/macarchy-touchbar, +so "one level up from the code" resolves correctly in both. -Three candidates, in order: +Co-locating them is what makes arch=('any') honest. Putting the python package in +site-packages instead would bake the building interpreter's version into the +artifact, and a target whose python differs by a minor version gets ImportError +while depends=('python') claims to be satisfied. macarchy-install#16. - 1. $MACARCHY_TOUCHBAR_DATA — honoured even when it does not exist. Someone who - sets it meant it, and falling back would hide their typo until the bar came - up empty. - 2. /usr/share/macarchy-touchbar — a package's data, used only if really there. - 3. the checkout root — the old behaviour, and still the common one. +$MACARCHY_TOUCHBAR_DATA overrides, and is honoured even when it points nowhere: +someone who sets it meant it, and a silent fallback would hide the typo until the +bar came up with no modules on it. """ import os -PACKAGED = "/usr/share/macarchy-touchbar" -_CHECKOUT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) - def data_root(): - override = os.environ.get("MACARCHY_TOUCHBAR_DATA") - if override: - return override - if os.path.isdir(os.path.join(PACKAGED, "modules")): - return PACKAGED - return _CHECKOUT + return os.environ.get("MACARCHY_TOUCHBAR_DATA") or \ + os.path.dirname(os.path.dirname(os.path.abspath(__file__))) diff --git a/tests/test_data_root.py b/tests/test_data_root.py index 1a1a55c..d79cfc1 100644 --- a/tests/test_data_root.py +++ b/tests/test_data_root.py @@ -1,51 +1,68 @@ """Where the daemon looks for modules/, config/ and fonts/. -It used to be one directory above the python package — true in a git checkout, -where that is the repo root, and false in every other layout. A system package -puts macarchy_touchbar/ in site-packages/, so `ROOT` became -/usr/lib/pythonX.Y/site-packages/ and the daemon looked for modules/ there. -macarchy-install#16. +The data sits one directory above the python package. That was already true in a +git checkout; the packaging work kept it true rather than adding a second layout, +by installing macarchy_touchbar/ NEXT TO modules/ and config/ under +/usr/share/macarchy-touchbar. macarchy-install#16. -The checkout fallback is the one that must never break: ./install.sh symlinks +The checkout case is the one that must never break: ./install.sh symlinks bin/macarchy-touchbar out of the repo and everything still has to resolve. """ import os - -import pytest +import subprocess +import sys from macarchy_touchbar import paths +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + def test_the_env_var_wins(tmp_path, monkeypatch): monkeypatch.setenv("MACARCHY_TOUCHBAR_DATA", str(tmp_path)) assert paths.data_root() == str(tmp_path) -def test_the_packaged_directory_is_used_when_it_exists(tmp_path, monkeypatch): - monkeypatch.delenv("MACARCHY_TOUCHBAR_DATA", raising=False) - packaged = tmp_path / "usr" / "share" / "macarchy-touchbar" - (packaged / "modules").mkdir(parents=True) - monkeypatch.setattr(paths, "PACKAGED", str(packaged)) - assert paths.data_root() == str(packaged) +def test_an_env_var_pointing_nowhere_is_still_honoured(tmp_path, monkeypatch): + # Explicit beats clever: a silent fallback would hide the typo until the bar + # came up with no modules on it. + missing = tmp_path / "gone" + monkeypatch.setenv("MACARCHY_TOUCHBAR_DATA", str(missing)) + assert paths.data_root() == str(missing) -def test_the_checkout_is_the_fallback(tmp_path, monkeypatch): - # No env var, no packaged directory: the repo root, exactly as before. +def test_the_data_sits_beside_the_code(monkeypatch): monkeypatch.delenv("MACARCHY_TOUCHBAR_DATA", raising=False) - monkeypatch.setattr(paths, "PACKAGED", str(tmp_path / "nowhere")) - assert os.path.isdir(os.path.join(paths.data_root(), "modules")) + root = paths.data_root() + assert os.path.isdir(os.path.join(root, "modules")) + assert os.path.isfile(os.path.join(root, "config", "layouts.toml")) + assert os.path.isdir(os.path.join(root, "macarchy_touchbar")) -def test_a_checkout_really_resolves_layouts_toml(monkeypatch): - # The bug from the other side: a root that resolves but holds no config. +def test_a_package_layout_resolves_the_same_way(tmp_path, monkeypatch): + # Simulate /usr/share/macarchy-touchbar: the python package beside the data. + # No special case in data_root() is what makes the two layouts one rule. monkeypatch.delenv("MACARCHY_TOUCHBAR_DATA", raising=False) - assert os.path.isfile(os.path.join(paths.data_root(), "config", "layouts.toml")) + share = tmp_path / "share" / "macarchy-touchbar" + (share / "macarchy_touchbar").mkdir(parents=True) + (share / "modules").mkdir() + (share / "macarchy_touchbar" / "paths.py").write_text( + (ROOT / "macarchy_touchbar" / "paths.py").read_text() + if hasattr(ROOT, "__truediv__") else + open(os.path.join(ROOT, "macarchy_touchbar", "paths.py")).read()) + (share / "macarchy_touchbar" / "__init__.py").write_text("") + out = subprocess.run( + [sys.executable, "-c", + "import sys; sys.path.insert(0, %r);" + "from macarchy_touchbar.paths import data_root; print(data_root())" % str(share)], + capture_output=True, text=True, env={k: v for k, v in os.environ.items() + if k != "MACARCHY_TOUCHBAR_DATA"}) + assert out.stdout.strip() == str(share), out.stderr -def test_an_env_var_pointing_nowhere_is_still_honoured(tmp_path, monkeypatch): - # Explicit beats clever: if someone sets it, they meant it, and a silent - # fallback to the checkout would hide their typo until the daemon drew a - # bar with no modules on it. - missing = tmp_path / "gone" - monkeypatch.setenv("MACARCHY_TOUCHBAR_DATA", str(missing)) - assert paths.data_root() == str(missing) +def test_the_launcher_finds_the_tree_in_a_packaged_layout(tmp_path): + # bin/macarchy-touchbar sits in /usr/bin once packaged, so "one level up" is + # /usr and useless — it has to search. This is the bootstrap that makes the + # single rule above work from an installed binary. + launcher = open(os.path.join(ROOT, "bin", "macarchy-touchbar")).read() + assert "/usr/share/macarchy-touchbar" in launcher + assert "MACARCHY_TOUCHBAR_DATA" in launcher diff --git a/tests/test_draw.py b/tests/test_draw.py index affb18e..eb7ea70 100644 --- a/tests/test_draw.py +++ b/tests/test_draw.py @@ -19,7 +19,7 @@ def pixel(s, x, y): return (r, g, b) -@pytest.mark.skipif(not os.path.exists(draw.CODEPOINTS), +@pytest.mark.skipif(not os.path.exists(draw.codepoints_path()), reason="fonts/MaterialSymbolsRounded.codepoints not downloaded (install.sh)") def test_codepoint_lookup(): assert icon_codepoint("brightness_high") == "" @@ -61,7 +61,7 @@ def test_missing_icon_falls_back_to_warning_and_never_raises(): def test_icon_never_raises_when_codepoints_file_is_missing(monkeypatch): # Fresh checkout: fonts/*.codepoints is git-ignored and may not exist. - monkeypatch.setattr(draw, "CODEPOINTS", "/nonexistent/MaterialSymbolsRounded.codepoints") + monkeypatch.setattr(draw, "codepoints_path", lambda: "/nonexistent/MaterialSymbolsRounded.codepoints") monkeypatch.setattr(draw, "_codepoints", None) s = surface() Painter(s).icon(cairo.Context(s), "brightness_high", 50, 30) diff --git a/tests/test_pkgbuild.py b/tests/test_pkgbuild.py index 71547c2..e0fe799 100644 --- a/tests/test_pkgbuild.py +++ b/tests/test_pkgbuild.py @@ -11,6 +11,9 @@ ROOT = Path(__file__).resolve().parent.parent PKGBUILD = (ROOT / "PKGBUILD").read_text() +# Comments mention every artefact by name, so a substring match over the whole +# file passes even when the install line is gone. Match the code. +PKG_CODE = "\n".join(l for l in PKGBUILD.splitlines() if not l.lstrip().startswith("#")) INSTALL = (ROOT / "install.sh").read_text() # What install.sh actually COPIES onto the machine. The package must carry each @@ -33,7 +36,7 @@ def test_the_package_carries_everything_install_sh_does(): - missing = [a for a in ARTEFACTS if a not in PKGBUILD] + missing = [a for a in ARTEFACTS if a not in PKG_CODE] assert not missing, f"install.sh installs {missing}; PKGBUILD does not mention them" @@ -58,26 +61,29 @@ def test_the_font_is_pinned_with_a_checksum(): # install.sh curls it from master, unpinned: the file changed between 2 Sep # and 5 Sep. A package must be reproducible, so the URL carries a commit and # the source carries a real sha256 rather than SKIP. - assert re.search(r"raw\.githubusercontent\.com/google/material-design-icons/[0-9a-f]{40}/", PKGBUILD) - sums = re.search(r"sha256sums=\((.*?)\)", PKGBUILD, re.S).group(1).split() + assert re.search(r"raw\.githubusercontent\.com/google/material-design-icons/[0-9a-f]{40}/", PKG_CODE) + sums = re.search(r"sha256sums=\((.*?)\)", PKG_CODE, re.S).group(1).split() assert len(sums) == 3, "expected three sources: the tarball, the font and its codepoints" assert any(re.fullmatch(r"'[0-9a-f]{64}'", s) for s in sums), "the font must carry a real checksum" def test_site_packages_is_derived_not_hardcoded(): # It carries the interpreter version (python3.14 today). - assert "python3." not in PKGBUILD.replace("python3 ", "") - assert "sysconfig" in PKGBUILD + # No site-packages at all now: the python package is co-located with its + # data under /usr/share, which is what keeps arch=('any') honest. + assert "sysconfig" not in PKG_CODE + assert "site-packages" not in PKG_CODE + assert not re.search(r"python3\.\d", PKG_CODE) def test_the_package_is_arch_independent(): - assert "arch=('any')" in PKGBUILD + assert "arch=('any')" in PKG_CODE def test_pkgver_is_maintained_by_release_please(): # Without the marker, release-please stops bumping pkgver and a release # ships a package whose version is the previous tag's — silently. - assert "x-release-please-version" in PKGBUILD + assert "x-release-please-version" in PKG_CODE cfg = json.loads((ROOT / "release-please-config.json").read_text()) assert "PKGBUILD" in cfg["packages"]["."]["extra-files"] @@ -85,21 +91,47 @@ def test_pkgver_is_maintained_by_release_please(): def test_the_codepoints_are_shipped_and_not_skipped(): # draw.py:57 opens them; they are gitignored so they are absent from the # release tarball. A `|| true` here would ship a bar with no icons. - assert "codepoints" in PKGBUILD - code = [l for l in PKGBUILD.splitlines() if not l.lstrip().startswith("#")] - assert not [l for l in code if "|| true" in l], "a silent skip in package()" + assert "codepoints" in PKG_CODE + assert "|| true" not in PKG_CODE, "a silent skip in package()" -def test_the_workflow_only_runs_on_a_published_release(): - # The PKGBUILD's source is the release tarball, which does not exist before - # the tag does — a push or pull_request trigger could only ever fail. - wf = (ROOT / ".github" / "workflows" / "package.yml").read_text() - assert "release:" in wf and "types: [published]" in wf - code = [l for l in wf.splitlines() if not l.lstrip().startswith("#")] - assert not [l for l in code if l.strip() in ("push:", "pull_request:")] +def test_the_package_job_lives_where_it_will_actually_fire(): + # A `release: [published]` trigger never fires on the real path: release-please + # creates the Release with GITHUB_TOKEN, and GitHub raises no workflow run from + # a GITHUB_TOKEN event. The job has to hang off release-please's own output. + assert not (ROOT / ".github" / "workflows" / "package.yml").exists() + wf = (ROOT / ".github" / "workflows" / "release-please.yml").read_text() + assert "release_created" in wf + assert "types: [published]" not in wf def test_the_upload_globs_and_clobbers(): - wf = (ROOT / ".github" / "workflows" / "package.yml").read_text() + wf = (ROOT / ".github" / "workflows" / "release-please.yml").read_text() assert "*.pkg.tar.*" in wf, "hardcoding an extension uploads nothing when PKGEXT differs" assert "--clobber" in wf, "a re-run must replace the asset, not fail" + + +def test_the_package_job_builds_the_tag_not_the_branch(): + # Without an explicit ref a manual re-run checks out the default branch and + # clobbers an old release with a package built from a different tag. + wf = (ROOT / ".github" / "workflows" / "release-please.yml").read_text() + assert "ref: ${{ needs.release-please.outputs.tag_name" in wf + assert "github-cli" in wf, "gh lives on the runner, not inside the container" + assert "pacman -Syu" in wf, "a partial upgrade reads as a build bug" + + +def test_the_unit_is_repointed_away_from_HOME(): + # The shipped unit says %h/.local/bin/… because install.sh symlinks there; a + # package install writes nothing into $HOME, so shipping it verbatim gives + # 203/EXEC and ten restarts from a package that installed perfectly. + assert "%h/" in (ROOT / "systemd" / "macarchy-touchbar.service").read_text() + assert "sed 's|%h/" in PKG_CODE and "/usr/bin/" in PKG_CODE + + +def test_there_is_a_scriptlet_for_what_pacman_cannot_do(): + # usermod -aG video, modprobe uinput and masking tiny-dfr are install.sh's + # non-file half. pacman does none of it; silence would leave a blank bar. + assert "install=macarchy-touchbar.install" in PKG_CODE + s = (ROOT / "macarchy-touchbar.install").read_text() + for step in ("video", "uinput", "tiny-dfr"): + assert step in s