Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 68 additions & 2 deletions .github/workflows/linux-bundles.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,10 @@ name: Linux bundles

# Builds the Linux AppImage and .deb of the desktop app with
# scripts/build-linux.sh and keeps them as workflow artifacts for 3 days, so a
# Linux user (or a reviewer) can try a build of a pull request. It publishes
# Linux user (or a reviewer) can try a build of a pull request. Then
# scripts/linux-smoke.sh starts both on a virtual display with an empty HOME,
# on the build image (22.04) and on ubuntu-latest, and checks that the app comes
# up as a new user would see it; screenshots and logs are kept for 3 days too. It publishes
# nothing: no release, no tag, no secret, no signing (there is no signing and no
# updater on Linux). Runs only on GitHub-hosted runners (this repo is public);
# never a self-hosted one (tests/test_workflows_guard.py).
Expand All @@ -17,6 +20,7 @@ on:
paths:
- "desktop/**"
- "scripts/build-linux.sh"
- "scripts/linux-smoke.sh"
- ".github/workflows/linux-bundles.yml"

permissions:
Expand All @@ -37,7 +41,8 @@ jobs:
run: |
sudo apt-get update
sudo apt-get install -y libwebkit2gtk-4.1-dev libgtk-3-dev \
libayatana-appindicator3-dev librsvg2-dev patchelf
libayatana-appindicator3-dev librsvg2-dev patchelf \
xvfb x11-utils imagemagick

# The sidecar is frozen with Python 3.14, the version the macOS release uses.
- uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
Expand Down Expand Up @@ -87,3 +92,64 @@ jobs:
path: desktop/src-tauri/target/release/bundle/deb/*.deb
if-no-files-found: error
retention-days: 3

# After the uploads, so the bundles are there even when a check fails.
- name: Smoke-test the AppImage
run: OUT=smoke scripts/linux-smoke.sh appimage desktop/src-tauri/target/release/bundle/appimage/*.AppImage

- name: Smoke-test the .deb
if: ${{ !cancelled() }}
run: OUT=smoke scripts/linux-smoke.sh deb desktop/src-tauri/target/release/bundle/deb/*.deb

- name: Upload the smoke-test screenshots and logs
if: ${{ !cancelled() }}
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: conductloop-linux-smoke-ubuntu-22.04
path: smoke/
if-no-files-found: warn
retention-days: 3

# The same bundles on the newest image: they are built on 22.04 for an older
# glibc, so check that they also start (and the .deb installs) on a newer one.
linux-smoke:
needs: linux-bundles
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

# Plus the graphics libraries every Linux desktop has: an AppImage takes
# libEGL/libGL from the system (they are on AppImage's exclude list), and
# this runner image is a server without them ("libEGL.so.1: cannot open
# shared object file" otherwise). The .deb gets them from its dependencies.
- name: Display tools and a desktop's graphics libraries
run: |
sudo apt-get update
sudo apt-get install -y xvfb x11-utils imagemagick libegl1 libgl1 libgles2 libgbm1

- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: conductloop-linux-appimage
path: bundles/appimage

- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: conductloop-linux-deb
path: bundles/deb

- name: Smoke-test the AppImage
run: OUT=smoke scripts/linux-smoke.sh appimage bundles/appimage/*.AppImage

- name: Smoke-test the .deb
if: ${{ !cancelled() }}
run: OUT=smoke scripts/linux-smoke.sh deb bundles/deb/*.deb

- name: Upload the smoke-test screenshots and logs
if: ${{ !cancelled() }}
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: conductloop-linux-smoke-ubuntu-latest
path: smoke/
if-no-files-found: warn
retention-days: 3
3 changes: 2 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ User-facing changes to ConductLoop (the desktop app and its local server), newes

## Unreleased

- CI only: the Linux bundles workflow now **starts the AppImage and the .deb** it builds, on Ubuntu 22.04 (the build image) and on ubuntu-latest, on a virtual display with an empty HOME and no keyring. `scripts/linux-smoke.sh` checks that the local server answers, that `/api/platform` says Linux with the in-app browser off, that it is a first run with the welcome page, that a ConductLoop window is on screen, and that closing the app stops the server; screenshots and logs are kept for 3 days. Until now the Linux packages were only built, never run. The script also works by hand on a Linux machine.
- CI only: a "Windows (compile and tests)" workflow checks on every relevant pull request that the desktop wrapper still compiles for Windows and that the Windows-safe part of the Python tests passes there. It builds no installer, signs nothing, publishes nothing and is not a required check. Windows is still not supported.
- Windows: a CI workflow, "Windows bundles", builds the NSIS installer and the .msi on `windows-latest` and keeps them for 3 days as downloadable artifacts. It only runs when started by hand (it is not a check of pull requests), signs nothing and publishes nothing. CI only: the app does not change.
- **Windows: a script to build an installer (experimental, unsigned).** `scripts/build-windows.ps1` freezes the server with PyInstaller and runs `tauri build --bundles nsis` (an `.msi` with `-Bundles nsis,msi`); the Windows settings live in `desktop/src-tauri/tauri.windows.conf.json`, so the macOS build does not change. The installer is not signed, so Windows SmartScreen warns when it runs ("More info", then "Run anyway"), and it needs WebView2 (included in Windows 11 and current Windows 10; the installer downloads it if missing). No updates on Windows: the app does not look for updates at start-up there. On Windows the bundled server now runs in UTF-8 mode. Not tested on a real Windows machine yet; Windows is still not a supported platform. `docs/PORTABILITY.md` has the status.

Expand All @@ -15,7 +17,6 @@ changes is the first part of the list below (the notes home, the Brain without
a key, Setup); the Linux and Windows items are groundwork for those systems and
leave the Mac app as it was.

- CI only: a "Windows (compile and tests)" workflow checks on every relevant pull request that the desktop wrapper still compiles for Windows and that the Windows-safe part of the Python tests passes there. It builds no installer, signs nothing, publishes nothing and is not a required check. Windows is still not supported.
- **A home for your notes.** Without projects (a fresh install has none), the
dashboard opened on an empty project canvas and search hid behind ⌘P. It now
opens on "Your notes": how many are indexed, a box to ask a question (it goes
Expand Down
179 changes: 179 additions & 0 deletions scripts/linux-smoke.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
#!/usr/bin/env bash
# Smoke-test a Linux build of the desktop app: start it on a display with an
# empty HOME, and check that it comes up the way a new user would see it.
#
# scripts/linux-smoke.sh deb path/to/ConductLoop_<v>_amd64.deb
# scripts/linux-smoke.sh appimage path/to/ConductLoop_<v>_amd64.AppImage
#
# Checks, in order (each prints ✓ or ✗; the exit status is 1 if any failed):
# 1. the local server answers /api/health on 127.0.0.1:7777 within $WAIT s;
# 2. /api/platform says Linux, with the in-app browser off;
# 3. it is a first run: /api/onboarding/state is fresh and /welcome is served;
# 4. a ConductLoop window is on the display (needs xwininfo; skipped without);
# 5. closing the app stops its server (the port is free within 15 s).
# A screenshot of the display after start-up goes to $OUT/linux-smoke-<mode>.png
# (needs ImageMagick's `import`) and the app's output to linux-smoke-<mode>.log.
#
# The .deb is installed with `sudo apt-get install`. The AppImage runs with
# --appimage-extract-and-run, so it needs no FUSE. Without $DISPLAY the script
# starts Xvfb on :99. Everything the app writes goes to a temporary HOME (data,
# config, logs); there is no D-Bus session, so no keyring either: the app must
# say so and keep working. Publishes nothing.
#
# Needs: Linux x86_64, curl, python3, Xvfb when there is no display; optional:
# xwininfo (x11-utils) and import (imagemagick).
set -euo pipefail

MODE="${1:-}"
PKG="${2:-}"
OUT="${OUT:-$PWD}"
WAIT="${WAIT:-120}"
BASE="http://127.0.0.1:7777"

usage() { sed -n '2,/^set -euo/p' "${BASH_SOURCE[0]}" | sed '$d' | sed 's/^# \{0,1\}//'; }
case "$MODE" in
deb|appimage) ;;
-h|--help) usage; exit 0 ;;
*) usage >&2; exit 2 ;;
esac
if [[ -z "$PKG" || ! -f "$PKG" ]]; then
echo "✗ no package at '$PKG'" >&2
exit 2
fi
if [[ "$(uname -s)" != "Linux" ]]; then
echo "✗ linux-smoke.sh must run on Linux (this is $(uname -s))" >&2
exit 2
fi
PKG="$(realpath "$PKG")"
mkdir -p "$OUT"
LOG="$OUT/linux-smoke-$MODE.log"
SHOT="$OUT/linux-smoke-$MODE.png"
failures=0
report() { echo "$1"; [[ -n "${GITHUB_STEP_SUMMARY:-}" ]] && echo "- $1" >> "$GITHUB_STEP_SUMMARY"; return 0; }
ok() { report "✓ $1"; }
bad() { report "✗ $1"; failures=$((failures + 1)); }

if curl -s -m 2 -o /dev/null "$BASE/api/health"; then
echo "✗ something already answers on $BASE: stop it first" >&2
exit 2
fi

case "$MODE" in
deb)
# The log belongs to the user running the script, not to root.
# shellcheck disable=SC2024
sudo apt-get install -y "$PKG" > "$OUT/linux-smoke-apt.log" 2>&1 || {
tail -20 "$OUT/linux-smoke-apt.log"; echo "✗ apt could not install $PKG" >&2; exit 1; }
# dpkg-deb lists "usr/bin/x" or "./usr/bin/x": make it absolute either way.
BIN="$(dpkg-deb -c "$PKG" | awk '{print $NF}' | sed -E 's#^\.?/?#/#' | grep '/conductloop-desktop$' | head -1)"
ARGS=()
;;
appimage)
chmod +x "$PKG"
BIN="$PKG"
ARGS=(--appimage-extract-and-run)
;;
esac
[[ -n "$BIN" && -x "$BIN" ]] || { echo "✗ no conductloop-desktop binary in $PKG" >&2; exit 1; }
# shellcheck source=/dev/null
[[ -n "${GITHUB_STEP_SUMMARY:-}" ]] && echo "### Linux smoke test: $MODE ($(. /etc/os-release && echo "$PRETTY_NAME"))" >> "$GITHUB_STEP_SUMMARY"

TMP="$(mktemp -d)"
XVFB_PID=""
APP_PID=""
cleanup() {
[[ -n "$APP_PID" ]] && kill "$APP_PID" 2>/dev/null || true
pkill -f conductloop-desktop 2>/dev/null || true
pkill -f conductloop-server 2>/dev/null || true
[[ -n "$XVFB_PID" ]] && kill "$XVFB_PID" 2>/dev/null || true
rm -rf "$TMP"
}
trap cleanup EXIT

if [[ -z "${DISPLAY:-}" ]]; then
Xvfb :99 -screen 0 1280x800x24 -nolisten tcp > "$OUT/linux-smoke-xvfb.log" 2>&1 &
XVFB_PID=$!
export DISPLAY=:99
sleep 2
fi

# A new user: empty HOME, no session bus (so no Secret Service), nothing set.
# WEBKIT_DISABLE_DMABUF_RENDERER: Xvfb has no GPU device for WebKitGTK's DMA-BUF
# renderer, which otherwise draws an empty window there.
mkdir -p "$TMP/home"
started=$(date +%s)
env -i PATH="/usr/local/bin:/usr/bin:/bin" HOME="$TMP/home" DISPLAY="$DISPLAY" \
LANG=C.UTF-8 NO_AT_BRIDGE=1 WEBKIT_DISABLE_DMABUF_RENDERER=1 \
TMPDIR="$TMP" "$BIN" "${ARGS[@]}" > "$LOG" 2>&1 &
APP_PID=$!

# 1. The server comes up.
up=""
for _ in $(seq 1 "$WAIT"); do
if curl -s -m 2 -o /dev/null -w '%{http_code}' "$BASE/api/health" | grep -q '^200$'; then up=1; break; fi
kill -0 "$APP_PID" 2>/dev/null || break
sleep 1
done
if [[ -n "$up" ]]; then
version="$(curl -s "$BASE/api/health" | python3 -c 'import json,sys; print(json.load(sys.stdin).get("version"))')"
ok "server answered in $(( $(date +%s) - started )) s as $version"
else
bad "the server did not answer within $WAIT s (app $(kill -0 "$APP_PID" 2>/dev/null && echo running || echo exited))"
tail -40 "$LOG" || true
exit 1
fi

# 2. It knows it is on Linux.
if curl -s "$BASE/api/platform" | python3 -c '
import json, sys
p = json.load(sys.stdin)
for k in ("browser", "code_execution", "keychain"):
v = p.get(k) or {}
print((" %s: available=%s %s %s" % (k, v.get("available"), v.get("reason") or "", v.get("message") or "")).rstrip())
sys.exit(0 if p.get("platform") == "linux" and (p.get("browser") or {}).get("available") is False else 1)
'; then
ok "/api/platform: linux, in-app browser off"
else
bad "/api/platform does not say linux with the browser off"
fi

# 3. A first run.
if curl -s "$BASE/api/onboarding/state" | python3 -c 'import json,sys; sys.exit(0 if json.load(sys.stdin).get("fresh") is True else 1)' \
&& curl -s "$BASE/welcome" | grep -q "ConductLoop"; then
ok "first run: onboarding is fresh and /welcome is served"
else
bad "first run: onboarding not fresh or /welcome missing"
fi

# 4. A window, and what it shows.
sleep 8
if command -v xwininfo >/dev/null; then
if xwininfo -root -tree | grep -q '"ConductLoop"'; then ok "a ConductLoop window is on the display"; else bad "no ConductLoop window on the display"; fi
fi
if command -v import >/dev/null; then
import -window root "$SHOT" 2>/dev/null && echo " screenshot: $SHOT" || echo " (no screenshot)"
fi

# 5. Closing the app stops its server.
pkill -TERM -f conductloop-desktop 2>/dev/null || true
kill "$APP_PID" 2>/dev/null || true
# The port closes before the process has exited: wait for both.
freed=""
for i in $(seq 1 15); do
if ! curl -s -m 1 -o /dev/null "$BASE/api/health" && ! pgrep -f conductloop-server >/dev/null; then freed=$i; break; fi
sleep 1
done
if [[ -n "$freed" ]]; then
ok "closing the app stopped its server (${freed} s)"
else
bad "the server kept running 15 s after the app was closed"
ps -o pid,ppid,stat,etime,args -p "$(pgrep -d, -f conductloop-server)" 2>/dev/null || true
curl -s -m 1 -o /dev/null -w ' port 7777: HTTP %{http_code}\n' "$BASE/api/health" || echo " port 7777: closed"
fi
APP_PID=""

if (( failures )); then
echo "--- last lines of the app's output"
tail -40 "$LOG" || true
exit 1
fi
Loading
Loading