From 8277d080baf908097dad33e570d9376843b08b0f Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Mon, 10 Aug 2026 10:52:27 +0200 Subject: [PATCH 1/6] fix(linux): declare the three libraries the packages need and never asked for MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The same shape as the glibc ceiling this release already fixed, and invisible to the guard that fixed it. before-pack.cjs checks how NEW a required symbol is; it has nothing to say about whether the library carrying it is ever installed. So 1.9.1 shipped three sonames that nothing declared and nothing bundled. Measured on the published 1.9.1 .deb, installed into a bare ubuntu:22.04: libgbm.so.1 the Electron binary itself. The app exits 127 on "error while loading shared libraries" before any window. libasound.so.2 same binary, same result. libgomp.so.1 GCC's OpenMP runtime, needed by all 32 ELFs of the STT stack (whisper-stt-server, the libggml*, libwhisper, libparakeet). The app starts and only transcription dies in ld.so. None is in electron-builder's default deb list, which never followed Chromium to GBM, and `depends` REPLACES that default rather than extending it, so there was no layer left to catch them. All three hid behind the same accident: desktop metapackages pull every one, and libgomp1 only via libfftw3-single3, libimagequant0 and libsoxr0 — three peripheral media libraries no desktop needs. Every machine anyone tested on had them, including this one, which is why a package that cannot start on a clean distro read as fine for two releases. A minimal install or a hand-assembled WM has none of those guarantees, and the AppImage has no way to declare any of it. Names resolved per distro rather than guessed, each in a container of that distro. It matters: libgomp.so.1 is `libgomp1` on Debian but `libgomp` on Fedora AND on Arch, where it was split out of `gcc-libs` — the obvious guess is wrong. `libasound2` is right for both Ubuntu 22.04 and 24.04, where the real package is `libasound2t64` and Provides the short name, exactly as `libgtk-3-0` already relies on. Fedora turns out not to have been broken: its base image plus gtk3's closure already supply all three, and the rpm passes without this change. Declaring them is what makes that true rather than lucky. --- electron-builder.json5 | 51 +++++++++++++++++++++++++++++++++++++----- 1 file changed, 45 insertions(+), 6 deletions(-) diff --git a/electron-builder.json5 b/electron-builder.json5 index c3e3b9d40..ec32f1340 100644 --- a/electron-builder.json5 +++ b/electron-builder.json5 @@ -142,12 +142,32 @@ // Mesa est ce qui garantit qu'au minimum le rastériseur logiciel existe. // // `depends` REMPLACE la liste par défaut d'electron-builder au lieu de s'y ajouter - // (app-builder-lib, FpmTarget.getDefaultDepends) : les entrées reprises ci-dessous - // sont donc ce défaut, verbatim, plus la nôtre en dernier. En retirer une casse le - // paquet silencieusement. + // (app-builder-lib, FpmTarget.getDefaultDepends) : les NEUF premières entrées sont + // donc ce défaut, verbatim, suivies des quatre qui nous sont propres. En retirer une + // casse le paquet silencieusement. // // L'AppImage n'a pas de mécanisme de dépendances et reste donc exposée : c'est // pour elle que `d3d_linux::diagnose` nomme le paquet à installer. + // + // Les trois dernières comblent des sonames que rien ne déclarait ni n'embarquait, + // mesurés sur la 1.9.1 installée dans une Ubuntu 22.04 nue : + // + // libgbm1 — réclamé par le binaire Electron LUI-MÊME. Sans lui l'app sort en + // 127 sur `libgbm.so.1: cannot open shared object file`, avant toute + // fenêtre. Absent du défaut d'electron-builder, qui n'a jamais suivi + // le passage de Chromium à GBM. + // libasound2 — même binaire, même conséquence. Sur 24.04+ le paquet réel est + // `libasound2t64`, qui `Provides: libasound2` : le nom court résout + // sur les deux, comme `libgtk-3-0` plus haut. + // libgomp1 — l'OpenMP de GCC, réclamé par les 32 ELF de la pile STT + // (whisper-stt-server, les libggml*, libwhisper, libparakeet). Sans + // lui l'app démarre et seule la transcription meurt dans ld.so. + // + // Aucun n'a été vu avant parce que les métapaquets de bureau les tirent tous les + // trois — libgomp1 seulement via libfftw3-single3, libimagequant0 et libsoxr0, trois + // libs média périphériques. Une install minimale ou un WM monté à la main n'a + // aucune de ces garanties. C'est ce que le job `verify-linux-package` prouve + // désormais à chaque build, en interrogeant le loader plutôt que cette liste. "deb": { "depends": [ "libgtk-3-0", @@ -159,11 +179,18 @@ "libatspi2.0-0", "libuuid1", "libsecret-1-0", - "mesa-vulkan-drivers" + "mesa-vulkan-drivers", + "libgbm1", + "libasound2", + "libgomp1" ] }, "pacman": { // `vulkan-swrast` est le lavapipe d'Arch ; il tire `vulkan-icd-loader` avec lui. + // + // Les trois derniers sont les équivalents Arch des sonames décrits sur `deb`. + // Noms relevés dans un conteneur archlinux, pas devinés : `libgomp.so.1` + // appartient à `libgomp` (core), PAS à `gcc-libs` — il en a été sorti. "depends": [ "c-ares", "ffmpeg", @@ -179,7 +206,10 @@ "snappy", "libnotify", "libappindicator-gtk3", - "vulkan-swrast" + "vulkan-swrast", + "mesa", + "alsa-lib", + "libgomp" ] }, "rpm": { @@ -195,6 +225,12 @@ // (electron/ai-edition/llm-config-store.ts) et passe par le Secret Service : // sans lui, `isEncryptionAvailable()` répond faux et l'enregistrement d'une clé // lève. Une omission d'electron-builder, pas un choix. + // + // Les trois derniers sont les équivalents Fedora des sonames décrits sur `deb`, + // relevés par `dnf provides` : `libgbm.so.1` vient de `mesa-libgbm` et non du + // `mesa` d'Arch. `libgomp` fait partie de l'install de base sur Fedora, donc le + // rpm y était moins exposé que le deb — le déclarer reste ce qui rend la + // contrainte vraie plutôt que chanceuse. "depends": [ "gtk3", "libnotify", @@ -205,7 +241,10 @@ "at-spi2-core", "(libuuid or libuuid1)", "libsecret", - "mesa-vulkan-drivers" + "mesa-vulkan-drivers", + "mesa-libgbm", + "alsa-lib", + "libgomp" ] }, "win": { From 72f1a6ee0ee09fef88186debe2cc8a59ee268af7 Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Mon, 10 Aug 2026 10:52:54 +0200 Subject: [PATCH 2/6] fix(linux): stop the Arch package depending on one Arch deleted MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The .pacman artifact of 1.9.1 cannot be installed at all. Not degraded, not missing a feature — pacman refuses the whole transaction: warning: cannot resolve "http-parser", a dependency of "openscreen" error: failed to prepare transaction (could not satisfy dependencies) :: unable to satisfy dependency 'http-parser' required by openscreen `http-parser` is part of electron-builder's default pacman `depends`, written when Node still used it. Node moved to llhttp years ago and Arch dropped the package; nothing in the repos Provides it today. Nothing in our payload wants it either — it appears in none of the 49 DT_NEEDED sonames the package carries. This is the one entry in this file that deliberately departs from the electron-builder default, against the rule the deb block states, so it gets said out loud in a comment: removing a default is normally how a package breaks silently, and this is the case where keeping one is. `libappindicator-gtk3`, immediately below, is stale in exactly the same way and was left alone on purpose — `libappindicator` declares it in both Provides and Replaces, so it still resolves. Checked, not assumed. Verified on archlinux:latest by installing the published 1.9.1 package with --assume-installed http-parser, which is precisely what removing the line does: the install succeeds, all 39 shipped ELFs resolve, and the main binary reaches main() instead of dying in ld.so. Found by the verification job that follows this commit, on its first run. --- electron-builder.json5 | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/electron-builder.json5 b/electron-builder.json5 index ec32f1340..35dde88ad 100644 --- a/electron-builder.json5 +++ b/electron-builder.json5 @@ -191,11 +191,20 @@ // Les trois derniers sont les équivalents Arch des sonames décrits sur `deb`. // Noms relevés dans un conteneur archlinux, pas devinés : `libgomp.so.1` // appartient à `libgomp` (core), PAS à `gcc-libs` — il en a été sorti. + // + // `http-parser` A ÉTÉ RETIRÉ, et c'est la seule entrée de ce fichier qui s'écarte + // volontairement du défaut d'electron-builder. Arch l'a supprimé de ses dépôts + // (Node est passé à llhttp il y a des années) et RIEN ne le fournit plus, donc + // pacman refusait la transaction entière : « unable to satisfy dependency + // 'http-parser' ». Le paquet Arch de la 1.9.1 est intégralement non installable, + // pour cette seule ligne. Aucun des 49 sonames du payload ne le réclame. + // + // `libappindicator-gtk3` est dans le même état de péremption mais reste correct : + // `libappindicator` le déclare en `Provides` ET en `Replaces`, donc il résout. "depends": [ "c-ares", "ffmpeg", "gtk3", - "http-parser", "libevent", "libvpx", "libxslt", From fd76fe11ab38a7a3561a990ad09989a5fec2dd4d Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Mon, 10 Aug 2026 10:53:15 +0200 Subject: [PATCH 3/6] ci(linux): prove the packages resolve on a machine with nothing installed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three Linux releases in a row shipped a dependency the target machine could not resolve, and each fix arrived with a guard aimed at the failure already understood. The colocation check would never have caught the glibc floor; the symbol-version check would never have caught a library that is simply not installed anywhere. Both are worth keeping and neither generalises, which is the actual problem — a static check only ever knows about the mistakes already made. Windows reached this conclusion one release earlier (verify-appx-native.ps1); this is the same answer for Linux. So ask the loader. verify-linux-package.sh installs a built package into a BARE container of the target distro, runs ldd over every ELF that ships, and reports any soname the package neither declares nor bundles. Then it starts openscreen, whisper-stt-server and openscreen-pipewire-helper, because reaching main() is the part ldd cannot show: a binary the loader rejects exits 127 with "error while loading shared libraries", while one that starts prints its own usage or its own structured error. The container is the point rather than an implementation detail. Every failure of this shape has one cause — the machines we build on have more installed than the machines we ship to — so a check running on the runner is not a check. For the same reason the probe installs no tooling inside the container: binutils would arrive with a transitive closure that could mask what is being measured, and ldd is glibc, already there. Two things it gets right that a first attempt did not. Libraries are judged by whether they ship, not by RUNPATH: the bundled ffmpeg objects carry no RUNPATH of their own and reach libavutil only through the binary that loads them, so ldd run against one of THEM reports three sonames missing that are present and fine. And it asserts it found at least ten ELFs, because a probe that silently stops looking reports clean forever after. rpm and pacman are covered too, and they are the ones with no other safety net: nobody installs them often enough to report a gap quickly. That paid for itself immediately — the pacman run is what surfaced http-parser, an entry that made the Arch package uninstallable and that no amount of reading the list would have revealed. Verified both ways before wiring it up. Against the published 1.9.1 .deb it fails with exactly libgbm.so.1, libasound.so.2 and libgomp.so.1 and no false positives; against the same package repacked with those three declared it passes, 39 ELFs resolving and all three executables reaching main(). The negative case is the one that matters here. The AppImage is deliberately not covered. It has no dependency mechanism, so there is no declaration to verify against and every system soname is missing by construction. It stays exposed — that is what d3d_linux::diagnose naming the Mesa package is for. The job runs before the upload step, and publish-release needs build-linux, so a package that cannot resolve blocks both its own artifact and the release. --- .github/workflows/build.yml | 33 +++ scripts/verify-linux-package.sh | 188 ++++++++++++++++++ .../engineering/build-and-packaging.md | 22 +- 3 files changed, 242 insertions(+), 1 deletion(-) create mode 100755 scripts/verify-linux-package.sh diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 9c3a6ebac..62fc16677 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -617,6 +617,39 @@ jobs: exit 1 fi + # The Linux counterpart of the Windows job's "Verify native binaries load under + # package identity", added for the same reason and after the same kind of miss: + # 1.9.1 shipped three sonames that nothing declared and nothing bundled — libgbm.so.1 + # and libasound.so.2, needed by the Electron binary itself, so a clean Ubuntu 22.04 + # exited 127 before any window, and libgomp.so.1, needed by the whole STT stack, so + # transcription died in ld.so. The symbol-version guard in before-pack.cjs could not + # have seen any of it: it checks how NEW the required symbols are, not whether the + # libraries carrying them are ever installed. + # + # Both misses hid behind the same thing. Desktop metapackages pull all three, so + # every machine anyone tested on had them — libgomp1 only via libfftw3-single3, + # libimagequant0 and libsoxr0, three peripheral media libraries. The check has to run + # somewhere empty or it is not a check, which is why this uses containers rather than + # the runner it is already standing on. + # + # rpm and pacman are verified too, and they are the ones with no other safety net: + # their depends lists are hand-written, no user installs them often enough to report + # a gap quickly, and package names genuinely differ (libgomp.so.1 is `libgomp1` on + # Debian, `libgomp` on Fedora AND on Arch, where it was split out of `gcc-libs`). + # + # The AppImage is deliberately NOT covered. It has no dependency mechanism at all, so + # there is no declaration to verify against — every system soname is "missing" by + # construction and the check would have nothing to say. It stays exposed, which is + # what d3d_linux::diagnose naming the Mesa package is for. + - name: Verify packages resolve on a clean machine + run: | + for fmt in deb rpm pacman; do + PKG="$(find release -type f -name "*.${fmt}" | head -1)" + echo "::group::${fmt}" + bash scripts/verify-linux-package.sh "$fmt" "$PKG" + echo "::endgroup::" + done + - name: Upload Linux packages uses: actions/upload-artifact@v7 with: diff --git a/scripts/verify-linux-package.sh b/scripts/verify-linux-package.sh new file mode 100755 index 000000000..9420843af --- /dev/null +++ b/scripts/verify-linux-package.sh @@ -0,0 +1,188 @@ +#!/usr/bin/env bash +# +# Prove a built Linux package resolves on a machine that has nothing installed. +# +# The Linux twin of verify-appx-native.ps1, and it exists for the same reason that +# script does: every guard we have aims at a failure already understood. before-pack.cjs +# checks the symbol-version ceiling because 1.9.0 shipped GLIBC_2.38; it would never have +# caught 1.9.1, which shipped three sonames that nothing declared and nothing bundled — +# libgbm.so.1 and libasound.so.2 (needed by the Electron binary itself, so the app exited +# 127 before any window) and libgomp.so.1 (needed by all 32 ELFs of the STT stack, so +# transcription died in ld.so). A static check only ever knows about the mistakes already +# made. +# +# So ask the loader instead. This installs the package into a BARE container of the +# oldest distro the README claims and runs ldd over everything that ships. Whatever the +# next unresolvable dependency turns out to be, this fails on it. +# +# Why a container and not the runner: the runner is the problem. Every failure of this +# shape has the same cause — the machines we build on have more installed than the +# machines we ship to — so "it resolved in CI" is evidence about nothing unless CI is +# empty. The images below are chosen for what is NOT in them, which is also why nothing +# here may apt-get install a convenience: binutils would arrive with its own transitive +# closure and could mask the very thing being measured. ldd is glibc, already present. +# +# It deliberately starts no GUI. A runner has no useful GPU or desktop session, and a +# flaky gate gets switched off. The loader is what broke, and the loader can be asked +# with neither: a binary it rejects exits 127 with "error while loading shared +# libraries" and prints nothing else, while one that reaches main() prints its usage. +# +# Usage: scripts/verify-linux-package.sh deb|rpm|pacman +# +# To see it fail on purpose, run it against a package with a `depends` entry removed +# from electron-builder.json5 — the negative case is the one that matters, and it is +# what proved this script sees anything at all (1.9.1 fails it on all three sonames). + +set -euo pipefail + +FORMAT="${1:?usage: verify-linux-package.sh deb|rpm|pacman }" +PACKAGE="${2:?usage: verify-linux-package.sh deb|rpm|pacman }" + +if [[ ! -f "$PACKAGE" ]]; then + echo "::error::No such package: $PACKAGE" + exit 1 +fi + +# Ubuntu 22.04 is the floor build.yml pins its runner to and the oldest distro the README +# claims. Fedora and Arch have no equivalent claim, so their images track a current +# release: those two lists are hand-written and, until this script, were never validated +# by anything at all. +case "$FORMAT" in +deb) + IMAGE="docker.io/library/ubuntu:22.04" + INSTALL='apt-get update -qq && DEBIAN_FRONTEND=noninteractive apt-get install -y -qq "$PKG"' + ;; +rpm) + IMAGE="docker.io/library/fedora:40" + INSTALL='dnf install -y -q "$PKG"' + ;; +pacman) + IMAGE="docker.io/library/archlinux:latest" + INSTALL='pacman -Sy --noconfirm >/dev/null && pacman -U --noconfirm "$PKG"' + ;; +*) + echo "::error::Unknown format '$FORMAT' (expected deb, rpm or pacman)" + exit 1 + ;; +esac + +# docker on GitHub runners, podman on the machines this gets debugged from. +RUNTIME="" +for candidate in docker podman; do + if command -v "$candidate" >/dev/null 2>&1; then + RUNTIME="$candidate" + break + fi +done +if [[ -z "$RUNTIME" ]]; then + echo "::error::Neither docker nor podman is available; cannot verify $FORMAT in a clean room" + exit 1 +fi + +echo "Verifying $(basename "$PACKAGE") in a bare $IMAGE via $RUNTIME" + +PROBE=$( + cat <<'INNER' +set -uo pipefail +PKG="$1" + +if ! eval "$INSTALL_CMD" >/tmp/install.log 2>&1; then + echo "FAIL install: the declared depends could not be resolved" + tail -20 /tmp/install.log + exit 1 +fi +echo "ok install: declared depends resolved" + +ROOT=/opt/Openscreen +if [[ ! -d "$ROOT" ]]; then + echo "FAIL layout: $ROOT does not exist after install" + exit 1 +fi + +# Every ELF that ships, by magic rather than by extension: the helpers, +# whisper-stt-server and ffmpeg have none. `head -c`, not bash's `read`, which stops at +# the NUL bytes an ELF header is full of. +mapfile -t ELVES < <( + find "$ROOT" -type f -print0 2>/dev/null | while IFS= read -r -d '' f; do + if head -c 4 "$f" 2>/dev/null | grep -q $'^\x7fELF'; then printf '%s\n' "$f"; fi + done +) + +# The same assertion the Windows probe makes, for the same reason: a guard that silently +# stops looking reports "clean" for the rest of the project's life. This payload ships +# dozens of ELFs, so finding almost none means the scan broke, not that the package is +# pure script. +if [[ "${#ELVES[@]}" -lt 10 ]]; then + echo "FAIL scan: found only ${#ELVES[@]} ELF files under $ROOT — the scan is broken, not the package" + exit 1 +fi + +# What ships, by soname, so a bundled library is never reported as missing. This is the +# distinction that matters and it is easy to get wrong: the ffmpeg shared objects carry +# no RUNPATH of their own and find libavutil only through the binary that loads them, so +# ldd run against one of THEM reports three sonames missing that are present and fine. +# The question this script answers is narrower and is the one that broke twice — which +# sonames must the SYSTEM provide — and those are exactly the ones shipped nowhere. +declare -A SHIPPED=() +while IFS= read -r name; do SHIPPED["$name"]=1; done < <( + find "$ROOT" -type f -name '*.so*' -printf '%f\n' 2>/dev/null +) + +declare -A UNRESOLVED=() +for f in "${ELVES[@]}"; do + while read -r soname; do + [[ -n "${SHIPPED[$soname]:-}" ]] && continue + UNRESOLVED["$soname"]=1 + done < <(ldd "$f" 2>/dev/null | awk '/not found/{print $1}') +done + +if [[ "${#UNRESOLVED[@]}" -gt 0 ]]; then + echo "FAIL loader: ${#UNRESOLVED[@]} soname(s) that the package neither declares nor ships:" + printf ' %s\n' "${!UNRESOLVED[@]}" + echo + echo "Add the package providing each to the '$FORMAT_LABEL' depends list in electron-builder.json5." + echo "The name differs per distro — resolve it rather than guessing:" + echo " deb apt-file search " + echo " rpm dnf provides '()(64bit)'" + echo " pacman pacman -F usr/lib/" + exit 1 +fi +echo "ok loader: all ${#ELVES[@]} ELF files resolve" + +# Reaching main() is the part ldd cannot show. A binary the loader rejects exits 127 and +# prints "error while loading shared libraries"; one that starts prints its own usage or +# its own structured error, whatever that is. Only the first case is a packaging failure +# — these are all EXPECTED to fail on a runner with no display and no PipeWire, just not +# in ld.so. `timeout` because the Electron binary is the one thing here that might not +# choose to exit. +STARTED=0 +for exe in "$ROOT/openscreen" \ + "$ROOT/resources/electron/native/bin/linux-x64/whisper-stt-server" \ + "$ROOT/resources/electron/native/bin/linux-x64/openscreen-pipewire-helper"; do + [[ -x "$exe" ]] || continue + err=$(timeout 60 "$exe" --help 2>&1 >/dev/null || true) + if grep -q "error while loading shared libraries" <<<"$err"; then + echo "FAIL start: $(basename "$exe") died in ld.so" + echo " ${err%%$'\n'*}" + exit 1 + fi + STARTED=$((STARTED + 1)) + echo "ok start: $(basename "$exe") reached main()" +done + +if [[ "$STARTED" -eq 0 ]]; then + echo "FAIL start: none of the expected executables were found — the paths in this script are stale" + exit 1 +fi + +echo "PASS" +INNER +) + +MOUNT_DIR="$(cd "$(dirname "$PACKAGE")" && pwd)" +"$RUNTIME" run --rm \ + -v "$MOUNT_DIR:/pkg:ro" \ + -e "INSTALL_CMD=$INSTALL" \ + -e "FORMAT_LABEL=$FORMAT" \ + "$IMAGE" \ + bash -c "$PROBE" _ "/pkg/$(basename "$PACKAGE")" diff --git a/technical-documentation/engineering/build-and-packaging.md b/technical-documentation/engineering/build-and-packaging.md index 7ff612b84..8ef6af70b 100644 --- a/technical-documentation/engineering/build-and-packaging.md +++ b/technical-documentation/engineering/build-and-packaging.md @@ -109,6 +109,26 @@ Add `-KeepRegistered` to leave the package installed and click through the app a Two things it deliberately does not do. It never records: a real capture needs a GPU and a desktop session that a CI runner does not usefully have, and a flaky gate gets switched off — the loader is the part that broke both times, and it can be tested without either. And it proves nothing about a machine that lacks a runtime, because every runner and every developer machine has the Visual C++ Redistributable; that half is held by the import-table check in `before-pack.cjs`. +### Verifying a Linux package resolves on a clean machine + +Linux repeated the Windows lesson one release later. 1.9.1 fixed the symbol-version floor and shipped three sonames that nothing declared and nothing bundled: `libgbm.so.1` and `libasound.so.2`, needed by the Electron binary itself, so a clean Ubuntu 22.04 exited `127` before any window appeared; and `libgomp.so.1`, needed by all 32 ELFs of the STT stack, so the app started and only transcription died in `ld.so`. + +The symbol-version guard could not have seen it. It checks how *new* the required symbols are, not whether the libraries carrying them are ever installed — the same shape as the colocation check being blind to the redistributable. And nothing else was watching: the `deb`/`rpm`/`pacman` `depends` lists are hand-written, and electron-builder passes fpm none of `--rpm-autoreq*`, so no package format derives its own requirements. + +All three hid behind the same accident. Desktop metapackages pull every one of them, `libgomp1` only via `libfftw3-single3`, `libimagequant0` and `libsoxr0` — three peripheral media libraries no desktop actually needs. Every machine anyone tested on therefore had them. + +`scripts/verify-linux-package.sh` installs a built package into a **bare container** of the target distro and runs `ldd` over every ELF that ships, reporting any soname the package neither declares nor bundles. It then starts `openscreen`, `whisper-stt-server` and `openscreen-pipewire-helper`, because reaching `main()` is the part `ldd` cannot show: a binary the loader rejects exits `127` with `error while loading shared libraries`, while one that starts prints its own usage or its own structured error. + +```bash +bash scripts/verify-linux-package.sh deb release/1.9.2/Openscreen-Linux-1.9.2.deb +``` + +The container is the point, not an implementation detail — the runner has more installed than the machines we ship to, so a check that runs on it is not a check. For the same reason the script installs no convenience tooling inside the container: `binutils` would arrive with a transitive closure that could mask what is being measured, and `ldd` is glibc, already there. + +`rpm` and `pacman` are verified too, and they are the ones with no other safety net: nobody installs them often enough to report a gap quickly, and the package names genuinely differ. `libgomp.so.1` is `libgomp1` on Debian and `libgomp` on both Fedora and Arch, where it was split out of `gcc-libs` — a guess would have been wrong. + +The AppImage is deliberately not covered. It has no dependency mechanism, so there is no declaration to verify against and every system soname is missing by construction. It stays exposed, which is what `d3d_linux::diagnose` naming the Mesa package is for. + ### Testing without the build machine's advantages Every failure in this section shares one shape: **the machines we test on have more installed than the machines we ship to.** A developer box carries the Visual C++ Redistributable because Visual Studio put it there; a CI runner carries a newer glibc than the distros the README claims. Nothing run on either can reveal an absence, so "it works here" is not evidence about anything, however many times it is repeated. @@ -116,7 +136,7 @@ Every failure in this section shares one shape: **the machines we test on have m Three layers address it, and the order matters: 1. **Remove the dependency at the source.** A statically linked CRT cannot be missing; a runner pinned to the oldest supported distro cannot bind symbols the user does not have. This is the only layer that needs no testing at all, so prefer it whenever there is a choice. -2. **Prove it automatically.** Static analysis for absences that are known and enumerable (`before-pack.cjs` reads import tables and ELF symbol-version needs), and a real load for everything else (`verify-appx-native.ps1` under package identity). +2. **Prove it automatically.** Static analysis for absences that are known and enumerable (`before-pack.cjs` reads import tables and ELF symbol-version needs), and a real load for everything else — `verify-appx-native.ps1` under package identity on Windows, `verify-linux-package.sh` in a bare container on Linux. Both exist because static analysis only ever knows about the mistakes already made. 3. **A pristine machine before a Store submission.** The only layer that catches a failure nobody has thought of yet. For layer 3, keep a virtual machine whose entire value is what is *not* installed in it. VirtualBox and VMware Workstation both run on Windows 11 Home, which has neither Windows Sandbox nor Hyper-V; Microsoft publishes Windows 11 ISOs at no cost, and an unactivated install is fine for this. From e6dff1b75558a92f8c681d4fbdc3f40d00746217 Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Mon, 10 Aug 2026 11:40:42 +0200 Subject: [PATCH 4/6] ci(release): announce off the release itself, not off the ceremony that made it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Discord announcement was a step of prerelease.yml and promote.yml, so it could only ever announce what those two workflows did. A release cut by pushing a tag straight at build.yml — which is how 1.9.2 ships — runs neither, and would have gone out silent. That is the same class of miss the standalone dispatch was extracted for after v1.9.0, one level up: the recovery path was fixed and the trigger was not. Move it onto `release: published`, the one event every path shares, and read `github.event.release.prerelease` for the stable/rc split that used to be hand-passed. This works only because build.yml creates releases with OPENSCREEN_RELEASE_TOKEN — a release created with the default GITHUB_TOKEN triggers no workflows, and this change would silently do nothing. It also fixes a bug in the RC path that was invisible while it lived there: prerelease.yml announced immediately after DISPATCHING build.yml rather than after it completed, so #rc-testing was told to go test a build whose artifacts would not exist for another twenty minutes. `release: published` means, by definition, that the assets are attached. Both call sites are removed rather than left as a fallback, because two triggers on one event post twice. Nothing is lost with them: promote.yml passed RC_TAG to get a "Promoted from " line the script only ever renders under `isRc && rcTag`, and it passed KIND: stable, so that line has never appeared in a stable announcement. `release_notes_extra` no longer reaches Discord automatically — prepending something stays possible through the dispatch inputs, which are kept. STRICT is now "1" on every path. It was "0" inside the ceremonies for a real reason — the release was already out and a failed announcement had to not report it as broken — and standing alone inverts that: nothing depends on this job, so a red run costs a notification while a green run that posted nothing recreates the exact failure this exists to prevent. --- .github/workflows/announce-release.yml | 55 ++++++++++++++++++++------ .github/workflows/prerelease.yml | 18 ++++----- .github/workflows/promote.yml | 20 +++++----- 3 files changed, 59 insertions(+), 34 deletions(-) diff --git a/.github/workflows/announce-release.yml b/.github/workflows/announce-release.yml index de53beafe..078195c88 100644 --- a/.github/workflows/announce-release.yml +++ b/.github/workflows/announce-release.yml @@ -5,12 +5,28 @@ # failure, with no way to send it afterwards short of re-running the whole # promotion over an already-tagged release. # -# This exposes the same script on its own, so a missed announcement is a dispatch -# rather than a recovery operation. It reads nothing but the tag and the matching -# milestone, so it produces the identical message whenever it runs. +# It now runs off the release itself, which is the only event every path shares. +# Being a step of the two ceremony workflows meant it announced what those two +# workflows happened to do, not what actually shipped: a release cut by pushing a +# tag straight to build.yml — which is how 1.9.2 shipped — went out silent, +# because neither ceremony ran. Worse, prerelease.yml announced immediately after +# DISPATCHING build.yml rather than after it finished, so the RC message could +# precede the artifacts it pointed at by the length of a full matrix build. +# +# `release: published` fires for both, and `github.event.release.prerelease` +# already carries the stable/rc distinction that was previously hand-passed. This +# works only because build.yml creates the release with OPENSCREEN_RELEASE_TOKEN: +# releases created with the default GITHUB_TOKEN trigger no workflows at all. +# +# The dispatch inputs stay, for the case this was extracted for — a missed +# announcement is still a dispatch rather than a recovery operation. The script +# reads nothing but the tag and the matching milestone, so it produces the +# identical message whichever way it is entered. name: Announce a release on Discord on: + release: + types: [published] workflow_dispatch: inputs: tag: @@ -34,12 +50,24 @@ permissions: jobs: announce: - name: Announce ${{ inputs.tag }} + name: Announce ${{ inputs.tag || github.event.release.tag_name }} runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v7 + # Resolved once, in one place, because the channel selection below needs to + # branch on `kind` and a step cannot read an `env:` entry declared beside it. + # On a release event `inputs.*` are empty strings, which GitHub expressions + # treat as falsy, so `||` picks the event side without any explicit test. + - name: Resolve what is being announced + id: ctx + run: | + { + echo "tag=${{ inputs.tag || github.event.release.tag_name }}" + echo "kind=${{ inputs.kind || (github.event.release.prerelease && 'rc' || 'stable') }}" + } >> "$GITHUB_OUTPUT" + - name: Setup Node.js uses: ./.github/actions/setup @@ -49,19 +77,20 @@ jobs: # The script prefers the RC channel whenever that variable is non-empty, # so exactly one of these may be set — blanking the other is what selects # the destination, not the KIND value. - DISCORD_RELEASE_CHANNEL_ID: ${{ inputs.kind == 'stable' && vars.DISCORD_RELEASE_CHANNEL_ID || '' }} - DISCORD_RC_TESTING_CHANNEL_ID: ${{ inputs.kind == 'rc' && vars.DISCORD_RC_TESTING_CHANNEL_ID || '' }} + DISCORD_RELEASE_CHANNEL_ID: ${{ steps.ctx.outputs.kind == 'stable' && vars.DISCORD_RELEASE_CHANNEL_ID || '' }} + DISCORD_RC_TESTING_CHANNEL_ID: ${{ steps.ctx.outputs.kind == 'rc' && vars.DISCORD_RC_TESTING_CHANNEL_ID || '' }} # Only used to list the closed issues of the matching milestone; the # announcement still posts without it, just without that section. GITHUB_TOKEN: ${{ secrets.OPENSCREEN_RELEASE_TOKEN }} - STABLE_TAG: ${{ inputs.tag }} + STABLE_TAG: ${{ steps.ctx.outputs.tag }} EXTRA: ${{ inputs.release_notes_extra }} - KIND: ${{ inputs.kind }} + KIND: ${{ steps.ctx.outputs.kind }} # Every path that ends without posting exits 0 by default, which is what - # prerelease.yml and promote.yml need — the release is already out and a - # failed announcement must not report it as broken. Here the contract is - # inverted: this workflow is dispatched *because* an announcement was - # missed, so a green run that posted nothing would recreate exactly the - # failure it was invoked to repair. That is how v1.9.0 shipped silent. + # this was when it lived inside prerelease.yml and promote.yml — the + # release was already out, and a failed announcement had to not report it + # as broken. Standing on its own inverts that: nothing downstream depends + # on this job, so a red run costs a notification and a green run that + # posted nothing recreates exactly the failure it exists to prevent. That + # is how v1.9.0 shipped silent. STRICT: "1" run: node .github/scripts/discord-release-announce.mjs diff --git a/.github/workflows/prerelease.yml b/.github/workflows/prerelease.yml index 110406c17..965a6e93e 100644 --- a/.github/workflows/prerelease.yml +++ b/.github/workflows/prerelease.yml @@ -159,15 +159,12 @@ jobs: -f arch=both \ --repo "$GITHUB_REPOSITORY" - - name: Announce RC on Discord (#rc-testing) - if: success() - env: - DISCORD_BOT_TOKEN: ${{ secrets.DISCORD_BOT_TOKEN }} - DISCORD_RC_TESTING_CHANNEL_ID: ${{ vars.DISCORD_RC_TESTING_CHANNEL_ID }} - GITHUB_TOKEN: ${{ secrets.OPENSCREEN_RELEASE_TOKEN }} - STABLE_TAG: ${{ steps.version.outputs.rc_tag }} - KIND: rc - run: node .github/scripts/discord-release-announce.mjs + # No announcement step here any more — see the note in promote.yml. This one + # was the worse of the two: it ran immediately after DISPATCHING build.yml, + # not after it finished, so #rc-testing was told to go test a build whose + # artifacts would not exist for another twenty minutes. announce-release.yml + # now fires on `release: published`, which by definition means the assets are + # attached, and reads `github.event.release.prerelease` to pick this channel. - name: Workflow summary run: | @@ -177,5 +174,6 @@ jobs: echo "- RC tag: \`${{ steps.version.outputs.rc_tag }}\`" echo "- Stable target: \`v${{ steps.version.outputs.next }}\`" echo "- Build workflow triggered by the tag push will publish the GitHub pre-release." - echo "- Announce in #rc-testing on Discord, then run \`Promote RC to stable\` when QA is green." + echo "- #rc-testing is announced automatically once the build publishes the pre-release." + echo "- Run \`Promote RC to stable\` when QA is green." } >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/promote.yml b/.github/workflows/promote.yml index ee4cc1fb3..7bc090534 100644 --- a/.github/workflows/promote.yml +++ b/.github/workflows/promote.yml @@ -127,17 +127,15 @@ jobs: -f arch=both \ --repo "$GITHUB_REPOSITORY" - - name: Announce stable on Discord - if: success() - env: - DISCORD_BOT_TOKEN: ${{ secrets.DISCORD_BOT_TOKEN }} - DISCORD_RELEASE_CHANNEL_ID: ${{ vars.DISCORD_RELEASE_CHANNEL_ID }} - GITHUB_TOKEN: ${{ secrets.OPENSCREEN_RELEASE_TOKEN }} - STABLE_TAG: ${{ steps.version.outputs.stable_tag }} - RC_TAG: ${{ steps.version.outputs.rc_tag }} - EXTRA: ${{ inputs.release_notes_extra }} - KIND: stable - run: node .github/scripts/discord-release-announce.mjs + # No announcement step here any more. It moved to announce-release.yml, which + # runs on `release: published` — the one event every release path shares, + # including a tag pushed straight to build.yml with no promotion at all. This + # step could only ever announce what THIS workflow did, and it announced it + # from here, before build.yml had produced a single asset. + # + # `release_notes_extra` consequently no longer reaches Discord from this + # workflow. Pass it to the announce dispatch instead when there is something + # to prepend; the automatic run posts the standard message. # Bookkeeping, not publishing: the release is already out by now. Allowed to fail # so a stuck PR never masks a successful release — the summary reports it and it From e8c56d87aeb2373e589362ccfc0569105ae1ce6f Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Mon, 10 Aug 2026 12:08:51 +0200 Subject: [PATCH 5/6] chore(release): bump to 1.9.2 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Carried in this PR rather than as a separate commit on main, because 1.9.2 does not go through prerelease.yml: there is no RC to promote, so nothing else would write the version, and build.yml's publish guard rejects a tag whose version does not match package.json. Both manifests, via set-release-version.mjs — a sed over package.json alone is what shipped three releases with a lockfile disagreeing with the package it locks. --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 91d1ed5d8..59efaa908 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "openscreen", - "version": "1.8.0", + "version": "1.9.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "openscreen", - "version": "1.8.0", + "version": "1.9.2", "dependencies": { "@fix-webm-duration/fix": "^1.0.1", "@langchain/anthropic": "^1.3.26", diff --git a/package.json b/package.json index 287e8bb79..ba1945b3e 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "openscreen", "private": true, - "version": "1.9.0", + "version": "1.9.2", "type": "module", "packageManager": "npm@10.9.4", "engines": { From e61b468128a9668b0c870ea8fbe5cc3fcefb3a5f Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Mon, 10 Aug 2026 12:11:13 +0200 Subject: [PATCH 6/6] fix(nix): refresh npmDepsHash, stale since well before this branch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit nix-check.yml only runs on PRs touching package-lock.json or nix/**, so it fires here on a two-line version bump and reports a mismatch this branch did not cause. The recorded hash was pinned to a dependency set that main's lockfile stopped resolving to some time ago — exactly the drift that workflow was added to surface, now surfaced by the first PR to touch the lockfile since. The bump changes two lines, both the `version` field, and no dependency at all, so nothing here alters what npm resolves. Setting it to what `prefetch-npm-deps package-lock.json` reports makes `nix build` work again. That bump also repairs a related inconsistency: main's package-lock.json still said 1.8.0 while package.json said 1.9.0, which is the exact failure set-release-version.mjs exists to prevent and which predates it. --- nix/package.nix | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nix/package.nix b/nix/package.nix index 6c1a1364e..111787a1d 100644 --- a/nix/package.nix +++ b/nix/package.nix @@ -37,7 +37,7 @@ buildNpmPackage { ); }; - npmDepsHash = "sha256-ArM73XLVYUB+92Pz7sCbh4Fj67xwakEusaaOSjxMEe8="; + npmDepsHash = "sha256-I0UeoZ8kWHwg2dZDHhCjBIo5BkPWi5c0DwrMywiphIo="; env.ELECTRON_SKIP_BINARY_DOWNLOAD = "1";