Skip to content

docs: close the stale sidebar-resize rows ahead of the 3e polish pass #1001

docs: close the stale sidebar-resize rows ahead of the 3e polish pass

docs: close the stale sidebar-resize rows ahead of the 3e polish pass #1001

Workflow file for this run

name: CI
# CI for Roost (Rust + Swift). Runs on main + PRs targeting main. A
# `changes` job (path filter) gates the rust/swift/gtk jobs so they run only when
# code that impacts them changes; `ci-success` is the single aggregated required
# check (stable regardless of which jobs run).
on:
push:
branches: [main, poc/iced]
pull_request:
branches: [main, poc/iced]
permissions:
contents: read
jobs:
changes:
runs-on: ubuntu-latest
timeout-minutes: 10
outputs:
rust: ${{ steps.filter.outputs.rust }}
mac: ${{ steps.filter.outputs.mac }}
linux: ${{ steps.filter.outputs.linux }}
fixtures: ${{ steps.filter.outputs.fixtures }}
tests: ${{ steps.filter.outputs.tests }}
ci: ${{ steps.filter.outputs.ci }}
steps:
- uses: actions/checkout@v6
with:
persist-credentials: false
- uses: dorny/paths-filter@v4
id: filter
with:
filters: |
rustcore: &rustcore
- 'Cargo.toml'
- 'Cargo.lock'
- 'rust-toolchain.toml'
- 'mise.toml'
- 'clippy.toml'
- 'crates/**'
- 'third_party/ghostty/**'
- 'third_party/swash/**'
rust:
- *rustcore
mac:
- *rustcore
- 'mac/**'
linux:
- *rustcore
- 'linux/**'
# repo-root shared fixture corpora (parity gates for both UIs),
# consumed only by the unit-test jobs (rust-build, gtk-build,
# swift-mac) — distinct from the `tests` output below, which
# means tools/roosttest/**
fixtures:
- 'tests/**'
tests:
- 'tools/roosttest/**'
- 'tools/roosttest_unit/**'
- 'tools/input/linux/**'
- 'tools/wayland/**'
- 'pyproject.toml'
- 'uv.lock'
ci:
- '.github/workflows/ci.yml'
rust-lint:
needs: changes
if: needs.changes.outputs.rust == 'true' || needs.changes.outputs.ci == 'true'
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v6
with:
persist-credentials: false
- name: Install Rust toolchain (from rust-toolchain.toml)
uses: actions-rust-lang/setup-rust-toolchain@v1
- name: Cache cargo registry + target
uses: actions/cache@v5
with:
path: |
~/.cargo/registry
~/.cargo/git
target
key: cargo-lint-${{ runner.os }}-${{ hashFiles('**/Cargo.toml', 'rust-toolchain.toml') }}
restore-keys: cargo-lint-${{ runner.os }}-
- name: cargo fmt --check
run: cargo fmt --all -- --check
- name: cargo clippy
# UI crates have native dependencies and dedicated strict lint jobs.
# Keep this runner the toolkit-neutral core lane.
run: cargo clippy --workspace --exclude roost-linux --exclude roost-iced --all-targets -- -D warnings
harness-unit:
needs: changes
if: needs.changes.outputs.tests == 'true' || needs.changes.outputs.ci == 'true'
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v6
with:
persist-credentials: false
- name: Functional harness target contract
run: python3 -m unittest discover -s tools/roosttest_unit -v
themes-parity:
needs: changes
if: needs.changes.outputs.rust == 'true' || needs.changes.outputs.mac == 'true' || needs.changes.outputs.ci == 'true'
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v6
with:
persist-credentials: false
# The bundled themes live in two trees (Rust crate + Mac SwiftPM
# bundle) because SwiftPM `.copy` can't reach outside mac/. Guard
# the copies against drift — adding a theme to one UI only is a
# classic miss.
- name: Bundled themes byte-identical across UIs
run: diff -r crates/roost-ui-model/src/resources/themes mac/Sources/Roost/Resources/themes
rust-build:
needs: changes
if: needs.changes.outputs.rust == 'true' || needs.changes.outputs.fixtures == 'true' || needs.changes.outputs.ci == 'true'
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest]
runs-on: ${{ matrix.os }}
timeout-minutes: 45
steps:
- uses: actions/checkout@v6
with:
persist-credentials: false
- name: Install libclang (Linux)
if: runner.os == 'Linux'
# libclang-dev is needed by bindgen when roost-vt is built with
# `--features ffi`. macOS gets it via Xcode's command-line tools.
run: |
sudo apt-get update
sudo apt-get install -y libclang-dev
- name: Install Rust toolchain (from rust-toolchain.toml)
uses: actions-rust-lang/setup-rust-toolchain@v1
- uses: jdx/mise-action@v4
- name: Cache vendored libghostty-vt
id: cache-ghostty
uses: actions/cache@v5
with:
path: |
third_party/ghostty/out
third_party/ghostty/src
key: vendored-ghostty-${{ runner.os }}-${{ hashFiles('third_party/ghostty/build.sh') }}
- name: Build libghostty-vt
if: steps.cache-ghostty.outputs.cache-hit != 'true'
run: ./third_party/ghostty/build.sh
- name: Cache cargo registry + target
uses: actions/cache@v5
with:
path: |
~/.cargo/registry
~/.cargo/git
target
key: cargo-${{ runner.os }}-${{ hashFiles('**/Cargo.toml', 'rust-toolchain.toml') }}
restore-keys: cargo-${{ runner.os }}-
- name: cargo build (workspace, default features)
# Default features: roost-vt's `ffi` feature is OFF, so this build does
# not consume libghostty-vt. `--exclude roost-linux` (GTK toolchain
# lives in gtk-build).
run: cargo build --workspace --exclude roost-linux --all-targets
- name: cargo build (roost-vt with ffi)
run: cargo build -p roost-vt --features ffi
# The encoder regression tests (key + mouse) live behind the `ffi`
# feature because they exercise libghostty-vt directly; the default
# `cargo test` below can't see them. The archive is built above, so
# run them here as a real gate.
- name: cargo test (roost-vt with ffi)
run: cargo test -p roost-vt --features ffi
- name: cargo test
run: cargo test --workspace --exclude roost-linux
# The experimental Swift-facing facade is feature-gated with no
# production consumer; test it explicitly so the gated code stays green.
- name: cargo test (roost-engine with facade)
run: cargo test -p roost-engine --features facade
swift-mac:
needs: changes
if: needs.changes.outputs.mac == 'true' || needs.changes.outputs.fixtures == 'true' || needs.changes.outputs.ci == 'true'
runs-on: macos-latest
timeout-minutes: 45
steps:
- uses: actions/checkout@v6
with:
persist-credentials: false
- name: Show Swift toolchain
run: swift --version
- uses: jdx/mise-action@v4
- name: Cache vendored libghostty-vt
id: cache-ghostty
uses: actions/cache@v5
with:
path: |
third_party/ghostty/out
third_party/ghostty/src
key: vendored-ghostty-${{ runner.os }}-${{ hashFiles('third_party/ghostty/build.sh') }}
- name: Build libghostty-vt
if: steps.cache-ghostty.outputs.cache-hit != 'true'
run: ./third_party/ghostty/build.sh
- name: Cache SwiftPM artifacts
uses: actions/cache@v5
with:
path: |
mac/.build
~/Library/Caches/org.swift.swiftpm
key: swiftpm-${{ runner.os }}-${{ hashFiles('mac/Package.swift', 'mac/Package.resolved') }}
restore-keys: swiftpm-${{ runner.os }}-
- name: Cache cargo registry + target (for embedded roost-cli build)
uses: actions/cache@v5
with:
path: |
~/.cargo/registry
~/.cargo/git
target
key: cargo-${{ runner.os }}-swiftmac-${{ hashFiles('**/Cargo.toml', 'rust-toolchain.toml') }}
restore-keys: cargo-${{ runner.os }}-swiftmac-
- name: swift build
working-directory: mac
run: swift build -v
- name: Assert no dynamic ghostty-vt link (regression guard)
working-directory: mac
run: |
# shellcheck disable=SC2044 # binary names are fixed ('Roost'/'RoostPackageTests'), no spaces
for bin in $(find .build -type f -perm -u+x -name 'Roost' -o -name 'RoostPackageTests'); do
if otool -L "$bin" 2>/dev/null | grep -q '@rpath/libghostty-vt'; then
echo "FAIL: $bin is dynamically linked to libghostty-vt; reintroduces the dyld @rpath bug fixed in Package.swift's positional-archive linkerSettings."
otool -L "$bin"
exit 1
fi
done
- name: swift test
working-directory: mac
run: swift test
- name: bundle .app (release) + embed roostctl
run: ./mac/scripts/bundle.sh release
- name: Verify embedded roostctl
run: |
set -euo pipefail
BIN="mac/build/Roost.app/Contents/Resources/bin/roostctl"
test -x "$BIN" || { echo "FAIL: $BIN not embedded"; exit 1; }
"$BIN" --version
codesign -dvv "$BIN" 2>&1 | grep -E 'adhoc|Format=' || true
- name: Verify themes resource bundle shipped (clean-install guard)
run: |
set -euo pipefail
# v0.0.2 crashed on every clean install because the themes bundle
# wasn't where the running app looks. Assert it ships under
# Contents/Resources, which is where `Bundle.roostResources`
# (Theme.swift) resolves it. A deterministic file check — stronger
# and faster than inferring from a launch. GUI-launch coverage is
# the required e2e-mac job; the clean-install live launch is the
# local `make smoke-mac-launch` / pre-release step.
THEMES="mac/build/Roost.app/Contents/Resources/Roost_Roost.bundle/themes"
test -d "$THEMES" || { echo "FAIL: $THEMES missing — themes can't load on a clean install (the v0.0.2 crash)"; exit 1; }
count=$(find "$THEMES" -type f | wc -l | tr -d ' ')
[ "$count" -gt 0 ] || { echo "FAIL: $THEMES shipped empty"; exit 1; }
echo "OK: $count theme files shipped under Contents/Resources"
- name: Verify TCC capture entitlements + usage strings (signed bundle)
run: |
set -euo pipefail
# The signed bundle is the source of truth: the EntitlementsTests
# swift test guards the source templates, but only this proves
# bundle.sh actually signed the keys in. Without the capture
# entitlements, programs hosted in a Roost tab lose mic/camera/
# apple-events access *silently* under the hardened runtime.
APP="mac/build/Roost.app"
ROOSTCTL="$APP/Contents/Resources/bin/roostctl"
INFO="$APP/Contents/Info.plist"
work=$(mktemp -d)
trap 'rm -rf "$work"' EXIT
# Extract each binary's entitlements as a real plist. codesign's text
# dump format is unstable across macOS versions (XML on some, a
# [Key] listing on others), so we force --xml and parse with
# PlistBuddy for exact, substring-proof key lookup.
app_ent="$work/app.plist"; ctl_ent="$work/roostctl.plist"
# No `|| true`: a failed extraction (unsigned/unreadable binary) must
# fail the job — otherwise the negative-only roostctl check below
# would pass vacuously on an empty plist.
codesign -d --entitlements - --xml "$APP" 2>/dev/null > "$app_ent"
codesign -d --entitlements - --xml "$ROOSTCTL" 2>/dev/null > "$ctl_ent"
has_key() { /usr/libexec/PlistBuddy -c "Print :$2" "$1" >/dev/null 2>&1; }
plist_value() { /usr/libexec/PlistBuddy -c "Print :$2" "$1" 2>/dev/null || true; }
# Each takes: $1=label $2=plist; remaining args=exact key names.
require_true() { # key must exist AND be boolean true (not just present)
local label="$1" plist="$2"; shift 2
for key in "$@"; do
[ "$(plist_value "$plist" "$key")" = "true" ] \
|| { echo "FAIL: $label must carry $key=true"; exit 1; }
done
}
require_nonempty() { # key must exist AND be a non-whitespace string
local label="$1" plist="$2"; shift 2
for key in "$@"; do
[ -n "$(plist_value "$plist" "$key" | tr -d '[:space:]')" ] \
|| { echo "FAIL: $label missing non-empty $key"; exit 1; }
done
}
forbid_keys() { # key must be absent
local label="$1" plist="$2"; shift 2
for key in "$@"; do
if has_key "$plist" "$key"; then
echo "FAIL: $label unexpectedly carries $key"; exit 1
fi
done
}
CAPTURE=(
com.apple.security.device.audio-input
com.apple.security.device.camera
com.apple.security.automation.apple-events
)
# Broad / unneeded keys kept out of the app (blast-radius control).
BROAD=(
com.apple.security.personal-information.addressbook
com.apple.security.personal-information.calendars
com.apple.security.personal-information.location
com.apple.security.personal-information.photos-library
com.apple.security.cs.allow-jit
com.apple.security.cs.allow-unsigned-executable-memory
com.apple.security.app-sandbox
com.apple.security.network.client
com.apple.security.network.server
)
require_true "app" "$app_ent" "${CAPTURE[@]}" # app MUST carry the capture set (=true)
forbid_keys "app" "$app_ent" "${BROAD[@]}" # app MUST NOT carry broad keys
forbid_keys "roostctl" "$ctl_ent" "${CAPTURE[@]}" # helper MUST NOT inherit capture
# The signed bundle's Info.plist MUST carry the paired purpose strings.
require_nonempty "Info.plist" "$INFO" \
NSMicrophoneUsageDescription \
NSCameraUsageDescription \
NSAppleEventsUsageDescription
echo "OK: capture entitlements (=true) + usage strings present; helper + broad keys excluded"
gtk-build:
needs: changes
if: needs.changes.outputs.linux == 'true' || needs.changes.outputs.fixtures == 'true' || needs.changes.outputs.ci == 'true'
runs-on: ubuntu-latest
timeout-minutes: 45
steps:
- uses: actions/checkout@v6
with:
persist-credentials: false
- name: Install GTK4 + libadwaita
run: |
sudo apt-get update
sudo apt-get install -y \
libgtk-4-dev \
libadwaita-1-dev \
pkg-config \
libclang-dev
- name: Install Rust toolchain (from rust-toolchain.toml)
uses: actions-rust-lang/setup-rust-toolchain@v1
- uses: jdx/mise-action@v4
- name: Cache vendored libghostty-vt
id: cache-ghostty
uses: actions/cache@v5
with:
path: |
third_party/ghostty/out
third_party/ghostty/src
key: vendored-ghostty-${{ runner.os }}-${{ hashFiles('third_party/ghostty/build.sh') }}
- name: Build libghostty-vt
if: steps.cache-ghostty.outputs.cache-hit != 'true'
run: ./third_party/ghostty/build.sh
- name: Cache cargo registry + target
uses: actions/cache@v5
with:
path: |
~/.cargo/registry
~/.cargo/git
target
key: cargo-${{ runner.os }}-${{ hashFiles('**/Cargo.toml', 'rust-toolchain.toml') }}
restore-keys: cargo-${{ runner.os }}-
- name: cargo build -p roost-linux
run: cargo build -p roost-linux
# The rust-test job runs `--workspace --exclude roost-linux` (no GTK
# toolchain there), so this is the ONLY place roost-linux's own tests
# can run. Without it the crate's unit tests — including the key
# encoder's Kitty-mode guards — compile but never execute anywhere.
# Headless-safe: these tests never construct a widget or call gtk_init.
- name: cargo test -p roost-linux
run: cargo test -p roost-linux
# roost-linux is clippy-clean (issue #283 closed out the last
# type_complexity holdout), so this runs the same full gate as
# rust-lint / Iced's "Test and lint Iced" step below — no more
# `-A warnings` narrow denylist. disallowed_types (GtkDnD #236) and
# disallowed_methods (raw grab_focus #234) are warn-by-default clippy
# lints, so `-D warnings` still catches regressions on both; clippy.toml
# stays the source of truth for which types/methods are disallowed.
# rust-lint excludes roost-linux because it needs the GTK toolchain
# that only this job has.
- name: cargo clippy -p roost-linux
run: cargo clippy -p roost-linux --all-targets -- -D warnings
# Iced walking skeleton: exact released Iced + libghostty-vt on both host
# platforms, with the common IPC harness driving a real PTY-backed window.
# The Linux leg selects X11 under Xvfb; its wgpu renderer uses Mesa's
# software Vulkan implementation when the runner exposes no physical GPU.
iced-build-e2e:
needs: changes
if: needs.changes.outputs.rust == 'true' || needs.changes.outputs.tests == 'true' || needs.changes.outputs.ci == 'true'
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest]
renderer: [wgpu, tiny-skia]
runs-on: ${{ matrix.os }}
timeout-minutes: 30
steps:
- uses: actions/checkout@v6
with:
persist-credentials: false
- name: Install Iced native dependencies (Linux)
if: runner.os == 'Linux'
run: |
sudo apt-get update
sudo apt-get install -y \
fonts-noto-cjk libclang-dev libxkbcommon-x11-0 libwayland-client0 \
mesa-vulkan-drivers weston xvfb xdotool zsh
- name: Install Rust toolchain (from rust-toolchain.toml)
uses: actions-rust-lang/setup-rust-toolchain@v1
- uses: jdx/mise-action@v4
- name: Install uv
uses: astral-sh/setup-uv@v7
- name: Cache vendored libghostty-vt
id: cache-ghostty
uses: actions/cache@v5
with:
path: |
third_party/ghostty/out
third_party/ghostty/src
key: vendored-ghostty-${{ runner.os }}-${{ hashFiles('third_party/ghostty/build.sh') }}
- name: Build libghostty-vt
if: steps.cache-ghostty.outputs.cache-hit != 'true'
run: ./third_party/ghostty/build.sh
- name: Cache cargo registry + Iced target
uses: actions/cache@v5
with:
path: |
~/.cargo/registry
~/.cargo/git
target
key: cargo-iced-${{ runner.os }}-${{ hashFiles('**/Cargo.toml', 'rust-toolchain.toml') }}
restore-keys: cargo-iced-${{ runner.os }}-
- name: Build Iced + roostctl
run: cargo build -p roost-iced -p roost-cli
- name: Test and lint Iced
run: |
cargo test -p roost-iced
cargo clippy -p roost-iced --all-targets -- -D warnings
- name: Verify toolkit dependency boundaries
shell: bash
run: |
set -euo pipefail
if cargo tree -p roost-engine | grep -E '(^| )(gtk4|libadwaita|iced) v'; then
echo "FAIL: roost-engine depends on a UI toolkit"
exit 1
fi
if cargo tree -p roost-ui-model | grep -E '(^| )(gtk4|libadwaita|iced|pango|cairo-rs|wgpu) v'; then
echo "FAIL: roost-ui-model depends on a UI toolkit or renderer"
exit 1
fi
if cargo tree -p roost-iced | grep -E '(^| )(gtk4|libadwaita|pango|cairo-rs|roost-linux) v'; then
echo "FAIL: roost-iced depends on GTK or roost-linux"
exit 1
fi
- name: Run Iced functional E2E (Linux X11)
if: runner.os == 'Linux'
env:
ICED_BACKEND: ${{ matrix.renderer }}
RUST_LOG: warn
ROOST_TEST_MODE: "1"
ROOST_TEST_TIMEOUT_SCALE: "3"
ROOST_E2E_ARTIFACT_DIR: ${{ runner.temp }}/roost-iced-e2e-x11-artifacts
ROOST_E2E_LOG_DIR: ${{ runner.temp }}/roost-iced-e2e-x11-logs
run: >
xvfb-run -a --server-args="-screen 0 1920x1080x24"
uv run --group test pytest
tools/roosttest/test_smoke.py
tools/roosttest/test_iced_walking_skeleton.py
tools/roosttest/test_notifications.py
tools/roosttest/test_provider.py
tools/roosttest/test_sidebar_pixels.py
tools/roosttest/test_focus.py
tools/roosttest/test_palette.py
tools/roosttest/test_project_lifecycle.py
tools/roosttest/test_sidebar_resize.py
tools/roosttest/test_selection.py
tools/roosttest/test_osc52.py
--roost-target iced --roost-fresh -v
- name: Run Iced real-input clipboard (Linux X11)
if: runner.os == 'Linux'
env:
ICED_BACKEND: ${{ matrix.renderer }}
RUST_LOG: warn
ROOST_ICED_BIN: ${{ github.workspace }}/target/debug/roost-iced
ROOST_REQUIRE_REAL_INPUT: "1"
ROOST_TEST_TIMEOUT_SCALE: "3"
ROOST_E2E_ARTIFACT_DIR: ${{ runner.temp }}/roost-iced-e2e-real-input-artifacts
ROOST_E2E_LOG_DIR: ${{ runner.temp }}/roost-iced-e2e-real-input-logs
run: python3 tools/input/linux/iced_clipboard_check.py
- name: Run Iced functional E2E (Linux Wayland)
if: runner.os == 'Linux'
# weston's headless backend has no input seat. Iced 0.14's
# smithay-clipboard correctly refuses wl_data_device ownership without
# a focused seat/serial, so native clipboard coverage runs in the X11
# lane above. This lane still requires the complete non-clipboard
# Wayland renderer suite; the POC plan records the real-seat evidence
# and remaining programmatic-write limitation.
env:
ICED_BACKEND: ${{ matrix.renderer }}
RUST_LOG: warn
ROOST_TEST_MODE: "1"
ROOST_TEST_TIMEOUT_SCALE: "3"
ROOST_E2E_ARTIFACT_DIR: ${{ runner.temp }}/roost-iced-e2e-wayland-artifacts
ROOST_E2E_LOG_DIR: ${{ runner.temp }}/roost-iced-e2e-wayland-logs
run: >
tools/wayland/weston-run.sh
uv run --group test pytest
tools/roosttest/test_smoke.py
tools/roosttest/test_iced_walking_skeleton.py
tools/roosttest/test_notifications.py
tools/roosttest/test_provider.py
tools/roosttest/test_sidebar_pixels.py
tools/roosttest/test_focus.py
tools/roosttest/test_palette.py
tools/roosttest/test_project_lifecycle.py
tools/roosttest/test_sidebar_resize.py
--roost-target iced --roost-fresh -v
- name: Run Iced functional E2E (macOS)
if: runner.os == 'macOS'
env:
ICED_BACKEND: ${{ matrix.renderer }}
RUST_LOG: warn
ROOST_TEST_MODE: "1"
ROOST_TEST_TIMEOUT_SCALE: "3"
ROOST_E2E_ARTIFACT_DIR: ${{ runner.temp }}/roost-iced-e2e-mac-artifacts
ROOST_E2E_LOG_DIR: ${{ runner.temp }}/roost-iced-e2e-mac-logs
run: >
uv run --group test pytest
tools/roosttest/test_smoke.py
tools/roosttest/test_iced_walking_skeleton.py
tools/roosttest/test_notifications.py
tools/roosttest/test_provider.py
tools/roosttest/test_sidebar_pixels.py
tools/roosttest/test_focus.py
tools/roosttest/test_palette.py
tools/roosttest/test_project_lifecycle.py
tools/roosttest/test_sidebar_resize.py
tools/roosttest/test_selection.py
tools/roosttest/test_osc52.py
--roost-target iced --roost-fresh -v
- name: Collect Iced diagnostics
if: always()
shell: bash
run: |
mkdir -p diagnostics
find "${RUNNER_TEMP}" -path '*/roost-iced-e2e-*-logs/*.log' \
-exec cp '{}' diagnostics/ \; 2>/dev/null || true
while IFS= read -r screenshot; do
suite="$(basename "$(dirname "${screenshot}")")"
cp "${screenshot}" "diagnostics/${suite}-$(basename "${screenshot}")"
done < <(find "${RUNNER_TEMP}" -path '*/roost-iced-e2e-*-artifacts/*.png' 2>/dev/null)
if [ "${RUNNER_OS}" = "macOS" ]; then
cp "$HOME"/Library/Logs/DiagnosticReports/roost-iced*.ips diagnostics/ 2>/dev/null || true
fi
ls -la diagnostics || true
- name: Upload Iced diagnostics
if: always()
uses: actions/upload-artifact@v7
with:
name: e2e-iced-${{ matrix.os }}-${{ matrix.renderer }}-diagnostics
path: diagnostics/
if-no-files-found: ignore
retention-days: 14
# Functional E2E: launch the real GTK UI headless under Xvfb and drive
# it through the IPC op set with the pytest harness (tools/roosttest).
# Required — Linux is the cheap, reliable headless target.
e2e-gtk:
needs: changes
if: needs.changes.outputs.linux == 'true' || needs.changes.outputs.tests == 'true' || needs.changes.outputs.ci == 'true'
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v6
with:
persist-credentials: false
- name: Install GTK4 + libadwaita + Xvfb + zsh
run: |
sudo apt-get update
sudo apt-get install -y \
libgtk-4-dev libadwaita-1-dev pkg-config libclang-dev xvfb xdotool zsh
- name: Install Rust toolchain (from rust-toolchain.toml)
uses: actions-rust-lang/setup-rust-toolchain@v1
- uses: jdx/mise-action@v4
- name: Install uv
uses: astral-sh/setup-uv@v7
- name: Cache vendored libghostty-vt
id: cache-ghostty
uses: actions/cache@v5
with:
path: |
third_party/ghostty/out
third_party/ghostty/src
key: vendored-ghostty-${{ runner.os }}-${{ hashFiles('third_party/ghostty/build.sh') }}
- name: Build libghostty-vt
if: steps.cache-ghostty.outputs.cache-hit != 'true'
run: ./third_party/ghostty/build.sh
- name: Cache cargo registry + target
uses: actions/cache@v5
with:
path: |
~/.cargo/registry
~/.cargo/git
target
key: cargo-${{ runner.os }}-${{ hashFiles('**/Cargo.toml', 'rust-toolchain.toml') }}
restore-keys: cargo-${{ runner.os }}-
# roostctl too: `test_agent_lifecycle.py` drives synthetic Claude
# hook payloads through the real `roostctl claude-hook` binary, so
# the adapter -> CLI -> IPC -> workspace path runs end to end. (The
# harness can build it on demand, but that would land inside the
# pytest step.) The Mac leg gets it from bundle.sh, which embeds
# roostctl in the .app.
- name: Build the GTK UI + roostctl
run: cargo build -p roost-linux -p roost-cli
- name: Run E2E (GTK under Xvfb)
env:
GDK_BACKEND: x11
RUST_LOG: warn
ROOST_TEST_TIMEOUT_SCALE: "3" # shared CI runner is slower/variable
# Unlocks tab.feed_pty_bytes + tab.capture_pty_input on the
# UI side so the OSC-pipeline tests can drive PTY bytes
# end-to-end. The harness reads the same var to skip those
# tests entirely when it's absent (no spurious red).
ROOST_TEST_MODE: "1"
# Where the harness captures the launched UI's stdout+stderr (the
# GTK UI tees its log to stdout). Collected + uploaded below so a
# boot failure under xvfb isn't blind — the Mac twin already does
# this via crash reports; this is the GTK equivalent.
ROOST_E2E_LOG_DIR: ${{ runner.temp }}/roost-e2e-logs
# The harness launches its own `roost` process; under xvfb-run it
# inherits the virtual DISPLAY. tab.dump reads in-process render
# state, so no compositor screen-capture is involved.
# Big virtual screen so window.resize can grow the toplevel to the
# full test width (the default 1280-wide xvfb capped resizes at
# ~1270, registered-skipping the sidebar geometry tests). With room
# to resize, those tests run instead of skipping.
run: >
xvfb-run -a --server-args="-screen 0 2560x1440x24"
uv run --group test pytest tools/roosttest --roost-target gtk --roost-fresh -v
# Real-input regressions: the behaviors that only real pointer/key input
# through the GTK gesture/shortcut stack can exercise (the IPC suite drives
# the op set, never the gesture stack) — click-to-focus + project-switch
# core-sync (#1), Alt+digit / Ctrl+PageDown / cycle_tab / pill-click core-
# sync (#228/#229), tab context-menu no-crash, and the GtkGestureDrag
# tab/project reorder that replaced GTK DnD (whose Wayland drag-icon
# surface aborted in gdksurface-wayland.c:frame_callback). Self-contained —
# it starts its OWN Xvfb + throwaway Roost — so it runs as its own step,
# NOT under xvfb-run. continue-on-error while we gather signal on XTEST-
# under-Xvfb reliability on shared runners; promote to a required gate once
# proven stable. ROOST_REQUIRE_REAL_INPUT turns the script's self-skip into
# a failure here (xdotool/Xvfb are installed, so a skip means a real setup
# problem, not "unsupported").
- name: Real-input regressions (Xvfb + xdotool)
continue-on-error: true
env:
GDK_BACKEND: x11
ROOST_TEST_MODE: "1"
ROOST_TEST_TIMEOUT_SCALE: "3"
ROOST_REQUIRE_REAL_INPUT: "1"
run: uv run --group test python tools/input/linux/real_input_check.py
# Stage the captured UI log into one dir (upload-artifact doesn't
# expand globs/`~`). The harness writes the launched UI's stdout+stderr
# to ROOST_E2E_LOG_DIR; grab it whether or not the run failed.
- name: Collect failure diagnostics
if: always()
run: |
mkdir -p diagnostics
cp "${RUNNER_TEMP}"/roost-e2e-logs/*.log diagnostics/ 2>/dev/null || true
ls -la diagnostics || true
- name: Upload E2E diagnostics
if: always()
uses: actions/upload-artifact@v7
with:
name: e2e-gtk-diagnostics
path: diagnostics/
if-no-files-found: ignore
retention-days: 14
# Functional E2E against the GTK UI under a headless WAYLAND compositor
# (weston) — the twin of e2e-gtk-under-Xvfb. Exists because GTK's
# GDK-Wayland backend (gdksurface-wayland.c) is ONLY exercised here: the
# Xvfb job forces GDK_BACKEND=x11, so Wayland-only bugs (e.g. the DnD
# drag-icon-surface frame_callback abort that crashed tab reorder on
# COSMIC) are invisible to it.
#
# NON-BLOCKING for now (continue-on-error + absent from ci-success) while
# we triage the real-runner Wayland pass profile — Wayland clipboard +
# selection semantics differ from X11, so a few OSC52/selection tests may
# legitimately diverge. Promote into ci-success once the profile is clean
# (mirrors the "real-click focus regression" gather-signal-then-promote
# pattern above). weston headless drives the IPC suite, not pointer input;
# the real pointer-DRAG guard lives in the separate e2e-gtk-wayland-drag job
# (cage + /dev/uinput) below.
e2e-gtk-wayland:
needs: changes
if: needs.changes.outputs.linux == 'true' || needs.changes.outputs.tests == 'true' || needs.changes.outputs.ci == 'true'
runs-on: ubuntu-latest
# Job-level cap so a hung run can't sprawl (a single hung test ate the 15m
# step cap once → the job ran 22m). Sized to survive a cache-COLD run (cold
# libghostty-vt + cargo build add ~10m), not the warm ~2m time.
timeout-minutes: 30
steps:
- uses: actions/checkout@v6
with:
persist-credentials: false
- name: Install GTK4 + libadwaita + weston + zsh
run: |
sudo apt-get update
sudo apt-get install -y \
libgtk-4-dev libadwaita-1-dev pkg-config libclang-dev weston wl-clipboard zsh
- name: Install Rust toolchain (from rust-toolchain.toml)
uses: actions-rust-lang/setup-rust-toolchain@v1
- uses: jdx/mise-action@v4
- name: Install uv
uses: astral-sh/setup-uv@v7
- name: Cache vendored libghostty-vt
id: cache-ghostty
uses: actions/cache@v5
with:
path: |
third_party/ghostty/out
third_party/ghostty/src
key: vendored-ghostty-${{ runner.os }}-${{ hashFiles('third_party/ghostty/build.sh') }}
- name: Build libghostty-vt
if: steps.cache-ghostty.outputs.cache-hit != 'true'
run: ./third_party/ghostty/build.sh
- name: Cache cargo registry + target
uses: actions/cache@v5
with:
path: |
~/.cargo/registry
~/.cargo/git
target
key: cargo-${{ runner.os }}-${{ hashFiles('**/Cargo.toml', 'rust-toolchain.toml') }}
restore-keys: cargo-${{ runner.os }}-
# roostctl too — same reason as the Xvfb job above (the pytest
# suite's Claude-hook test shells out to the real binary).
- name: Build the GTK UI + roostctl
run: cargo build -p roost-linux -p roost-cli
- name: Run E2E (GTK under headless Wayland / weston)
continue-on-error: true
timeout-minutes: 15
env:
RUST_LOG: warn
ROOST_TEST_TIMEOUT_SCALE: "3" # shared CI runner is slower/variable
ROOST_TEST_MODE: "1"
ROOST_E2E_LOG_DIR: ${{ runner.temp }}/roost-e2e-wl-logs
# weston-run.sh boots a headless weston, points GDK at it
# (GDK_BACKEND=wayland) and runs the IPC-driven suite — tab.dump reads
# in-process render state, so no screen capture is involved.
#
# Curated subset: skip the clipboard/selection + shell-env tests. On
# headless Wayland there's no clipboard manager, so a GTK clipboard read
# can block indefinitely (the full suite hung ~2h on the runner before
# this cap), and those paths test integration semantics that diverge
# X11↔Wayland — not the GDK-Wayland backend this job exists to cover.
# `timeout-minutes` is the belt-and-suspenders cap so a future hang can
# never run away again (continue-on-error swallows the timed-out step).
# --timeout: per-test hard cap (pytest-timeout) so one hung test fails
# fast instead of consuming the whole step (that's what ran the job to
# 22m). Derived from the same ROOST_TEST_TIMEOUT_SCALE the harness uses
# (30s base * 3 = 90s) so the one knob meant to absorb runner variance
# doesn't get bypassed. `thread` method dumps the stack + fails without
# relying on SIGALRM landing in a GTK/IPC C call.
run: >
tools/wayland/weston-run.sh
uv run --group test pytest tools/roosttest --roost-target gtk --roost-fresh -v
--timeout=$((30 * ROOST_TEST_TIMEOUT_SCALE)) --timeout-method=thread
--ignore=tools/roosttest/test_osc52.py
--ignore=tools/roosttest/test_selection.py
--ignore=tools/roosttest/test_shell_integration.py
- name: Collect failure diagnostics
if: always()
run: |
mkdir -p diagnostics
cp "${RUNNER_TEMP}"/roost-e2e-wl-logs/*.log diagnostics/ 2>/dev/null || true
ls -la diagnostics || true
- name: Upload E2E diagnostics
if: always()
uses: actions/upload-artifact@v7
with:
name: e2e-gtk-wayland-diagnostics
path: diagnostics/
if-no-files-found: ignore
retention-days: 14
# Real pointer-DRAG under a headless WAYLAND compositor — the one thing the
# IPC-only e2e-gtk-wayland job can't do. Drives an absolute-pointer drag with
# the stdlib /dev/uinput injector (tools/input/linux/inject_pointer.py) under
# `cage` (a kiosk wlroots compositor that fullscreens its single client, so
# window coords == output coords). Guards that the GtkGestureDrag reorder
# neither aborts the process nor emits a Wayland surface critical — the X11
# real-input job can only exercise the gesture *logic*, not the GDK-Wayland
# backend where the old GtkDnD drag-icon surface crashed.
#
# NON-BLOCKING (continue-on-error + absent from ci-success): synthetic Wayland
# input + the seat/libinput/uinput plumbing is new and may be flaky on shared
# runners. The check SKIPs cleanly if a dep is missing and reports a clear
# FAIL otherwise (ROOST_REQUIRE_REAL_INPUT=1). Promote to a gate once it has
# green history on main (same gather-signal pattern as the jobs above). The
# developer confirms the real COSMIC/cosmic-comp behavior separately; cage is
# generic wlroots Wayland, which is where the crash lived.
e2e-gtk-wayland-drag:
needs: changes
if: needs.changes.outputs.linux == 'true' || needs.changes.outputs.tests == 'true' || needs.changes.outputs.ci == 'true'
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v6
with:
persist-credentials: false
- name: Install GTK4 + libadwaita + cage + seatd
run: |
sudo apt-get update
sudo apt-get install -y \
libgtk-4-dev libadwaita-1-dev pkg-config libclang-dev cage seatd
- name: Install Rust toolchain (from rust-toolchain.toml)
uses: actions-rust-lang/setup-rust-toolchain@v1
- uses: jdx/mise-action@v4
- name: Install uv
uses: astral-sh/setup-uv@v7
- name: Cache vendored libghostty-vt
id: cache-ghostty
uses: actions/cache@v5
with:
path: |
third_party/ghostty/out
third_party/ghostty/src
key: vendored-ghostty-${{ runner.os }}-${{ hashFiles('third_party/ghostty/build.sh') }}
- name: Build libghostty-vt
if: steps.cache-ghostty.outputs.cache-hit != 'true'
run: ./third_party/ghostty/build.sh
- name: Cache cargo registry + target
uses: actions/cache@v5
with:
path: |
~/.cargo/registry
~/.cargo/git
target
key: cargo-${{ runner.os }}-${{ hashFiles('**/Cargo.toml', 'rust-toolchain.toml') }}
restore-keys: cargo-${{ runner.os }}-
- name: Build the GTK UI + roostctl
# roostctl (roost-cli) is what the injector harness drives; a cold
# `-p roost-linux` build doesn't produce it, so build both explicitly.
run: cargo build -p roost-linux -p roost-cli
- name: Enable uinput + a seat for synthetic input
# Headless wlroots has no input devices; to read the uinput device the
# injector creates, cage needs a libinput backend on a seat. modprobe
# uinput works on ubuntu-latest; seatd grants the seat to the runner's
# group. Best-effort — the check SKIPs/FAILs clearly if this didn't take.
run: |
sudo modprobe uinput || true
sudo chmod 0666 /dev/uinput || true
sudo seatd -g "$(id -gn)" >"${RUNNER_TEMP}/seatd.log" 2>&1 &
sleep 1
echo "LIBSEAT_BACKEND=seatd" >> "$GITHUB_ENV"
- name: Wayland pointer-drag guard (cage + uinput)
continue-on-error: true
timeout-minutes: 12
env:
ROOST_TEST_MODE: "1"
ROOST_TEST_TIMEOUT_SCALE: "3"
ROOST_REQUIRE_REAL_INPUT: "1"
WLR_BACKENDS: "headless,libinput"
WLR_RENDERER: "pixman"
run: uv run --group test python tools/input/linux/wayland_drag_check.py
# Functional E2E against the real Swift app, driven through the IPC op
# set by the pytest harness (tools/roosttest) — the Mac twin of e2e-gtk.
# Required (in ci-success). The harness clears any stale instance before
# launch (tools/roosttest/ui.py) and timeouts scale up for the slower
# shared runner; see docs/development/test-automation.md.
e2e-mac:
needs: changes
if: needs.changes.outputs.mac == 'true' || needs.changes.outputs.tests == 'true' || needs.changes.outputs.ci == 'true'
runs-on: macos-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v6
with:
persist-credentials: false
- uses: jdx/mise-action@v4
- name: Install uv
uses: astral-sh/setup-uv@v7
- name: Install modern bash + version probe
# `test_bash_auto_bootstrap_tracks_cwd` needs bash >= 4.4 for its
# `--posix` + ENV auto-bootstrap path; Apple's /bin/bash is 3.2,
# SIP-locked. Brew lands modern bash at /opt/homebrew/bin/bash on
# ARM runners, /usr/local/bin/bash on Intel (the test's
# `_modern_bash()` probes both). The idempotent `list || install`
# form is robust to runner images that ship bash preinstalled;
# the version probe makes a future preinstall-regression loud in
# CI logs rather than silently re-triggering the test's
# `precondition("no modern bash")` failure.
run: |
brew list bash >/dev/null 2>&1 || brew install bash
which bash || true
for p in /opt/homebrew/bin/bash /usr/local/bin/bash; do
if [ -x "$p" ]; then "$p" --version; fi
done
- name: Cache vendored libghostty-vt
id: cache-ghostty
uses: actions/cache@v5
with:
path: |
third_party/ghostty/out
third_party/ghostty/src
key: vendored-ghostty-${{ runner.os }}-${{ hashFiles('third_party/ghostty/build.sh') }}
- name: Build libghostty-vt
if: steps.cache-ghostty.outputs.cache-hit != 'true'
run: ./third_party/ghostty/build.sh
- name: Cache SwiftPM artifacts
uses: actions/cache@v5
with:
path: |
mac/.build
~/Library/Caches/org.swift.swiftpm
key: swiftpm-${{ runner.os }}-${{ hashFiles('mac/Package.swift', 'mac/Package.resolved') }}
restore-keys: swiftpm-${{ runner.os }}-
- name: Cache cargo registry + target
uses: actions/cache@v5
with:
path: |
~/.cargo/registry
~/.cargo/git
target
key: cargo-${{ runner.os }}-swiftmac-${{ hashFiles('**/Cargo.toml', 'rust-toolchain.toml') }}
restore-keys: cargo-${{ runner.os }}-swiftmac-
- name: Bundle Roost.app
run: ./mac/scripts/bundle.sh debug
- name: Run E2E (Mac app)
env:
ROOST_TEST_TIMEOUT_SCALE: "3" # shared macos-latest runner is slower/variable
# Unlocks the test-only IPC ops in the bundled Mac app
# (tools/roosttest/ui.py forwards this via `open --env`).
# See the GTK job above for full rationale.
ROOST_TEST_MODE: "1"
# --roost-fresh: own a hermetic instance with an isolated, throwaway
# ROOST_STATE_DIR (replaces the old ROOST_TEST_RESET_STATE clean-slate
# hack — the harness no longer deletes the real state.json).
run: >
uv run --group test pytest tools/roosttest --roost-target mac --roost-fresh -v
--junitxml=test-results/e2e-mac.xml
# On failure, salvage what makes a red blocking check debuggable: the
# app's own log, any macOS crash report, and a best-effort live
# screenshot (only lands if the app is still up). Staged into one dir
# because upload-artifact doesn't expand `~`/globs.
- name: Collect failure diagnostics
if: failure()
run: |
mkdir -p diagnostics
cp "$HOME/Library/Logs/Roost/roost.log" diagnostics/ 2>/dev/null || true
cp "$HOME"/Library/Logs/DiagnosticReports/Roost*.ips diagnostics/ 2>/dev/null || true
cp "$HOME"/Library/Logs/DiagnosticReports/Roost*.crash diagnostics/ 2>/dev/null || true
"mac/build/Roost.app/Contents/Resources/bin/roostctl" screenshot --out diagnostics/screen.png 2>/dev/null || true
ls -la diagnostics || true
- name: Upload E2E diagnostics + JUnit
if: always()
uses: actions/upload-artifact@v7
with:
name: e2e-mac-diagnostics
path: |
diagnostics/
test-results/
if-no-files-found: ignore
# Single required check. Always runs; passes iff no gated job failed (skipped
# jobs are fine — that's the path-filter doing its job).
ci-success:
needs: [changes, rust-lint, harness-unit, themes-parity, rust-build, swift-mac, gtk-build, iced-build-e2e, e2e-gtk, e2e-mac]
if: always()
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Verify no required job failed
run: |
results="${{ needs.rust-lint.result }} ${{ needs.harness-unit.result }} ${{ needs.themes-parity.result }} ${{ needs.rust-build.result }} ${{ needs.swift-mac.result }} ${{ needs.gtk-build.result }} ${{ needs.iced-build-e2e.result }} ${{ needs.e2e-gtk.result }} ${{ needs.e2e-mac.result }}"
echo "job results: ${results}"
for r in ${results}; do
if [ "${r}" = "failure" ] || [ "${r}" = "cancelled" ]; then
echo "::error::a CI job failed (${results})"
exit 1
fi
done
echo "all good"