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/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/.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 diff --git a/electron-builder.json5 b/electron-builder.json5 index c3e3b9d40..35dde88ad 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,16 +179,32 @@ "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. + // + // `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", @@ -179,7 +215,10 @@ "snappy", "libnotify", "libappindicator-gtk3", - "vulkan-swrast" + "vulkan-swrast", + "mesa", + "alsa-lib", + "libgomp" ] }, "rpm": { @@ -195,6 +234,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 +250,10 @@ "at-spi2-core", "(libuuid or libuuid1)", "libsecret", - "mesa-vulkan-drivers" + "mesa-vulkan-drivers", + "mesa-libgbm", + "alsa-lib", + "libgomp" ] }, "win": { 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"; 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": { 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.