Skip to content

Merge pull request #348 from charliek/feature/plan-029-latency-cursor… #1089

Merge pull request #348 from charliek/feature/plan-029-latency-cursor…

Merge pull request #348 from charliek/feature/plan-029-latency-cursor… #1089

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 }}
deb: ${{ steps.filter.outputs.deb }}
macbundle: ${{ steps.filter.outputs.macbundle }}
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'
# The file is a dotfile. The old 'mise.toml' pattern could
# never match, so a toolchain bump triggered no Rust job.
- '.mise.toml'
- 'clippy.toml'
- 'crates/**'
- 'third_party/ghostty/**'
- 'third_party/swash/**'
rust:
- *rustcore
mac:
- *rustcore
- 'mac/**'
linux:
- *rustcore
- 'linux/**'
- 'packaging/**'
# 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'
# The e2e jobs invoke Makefile targets, so the test-module
# lists (ICED_E2E_TESTS, ICED_RELEASE_E2E_TESTS) live there.
# Without this, editing which tests a lane runs doesn't
# retrigger the lane that runs them.
- 'Makefile'
ci:
- '.github/workflows/ci.yml'
# Deliberately NOT `linux`: that filter folds in the *rustcore
# anchor, so it's true on essentially every Rust PR — gating the
# deb work on it would pay an nfpm download, an nfpm package, and
# a Docker Hub pull on each one. This list is only the paths that
# can actually change what the .deb contains or how it's checked.
# `release.yml` is here because it appears in no other filter at
# all: a PR that edits only the release workflow would otherwise
# trigger nothing capable of validating it. `Cargo.lock` is here
# because a new dependency can pull in a new shared library and
# change the package's runtime closure.
# Manifests, not `crates/**`: enabling a feature on an
# already-locked dependency changes what the binary dlopens
# without touching Cargo.lock, and the closure check is the only
# thing that would notice. Source edits still don't trigger it —
# that's the cost line this filter exists to hold.
deb:
- 'linux/**'
- 'packaging/**'
- 'Cargo.toml'
- 'Cargo.lock'
- 'crates/*/Cargo.toml'
- '.github/workflows/ci.yml'
- '.github/workflows/release.yml'
# Narrow on purpose: gates the `iced-build-e2e` macOS cells'
# bundle-assembly + bundle-smoke steps. The broad `mac` output
# folds in *rustcore, so using it here would make every
# Swift-only PR pay the 2x2 iced matrix. `Makefile` is
# deliberately not repeated — it's already in `tests`, which
# is already OR'd into iced-build-e2e's `if`.
macbundle:
- 'mac/scripts/bundle-lib.sh'
- 'mac/scripts/bundle-iced.sh'
- 'mac/Resources/Info-iced.plist.template'
# Direct bundle-iced.sh inputs shared with the Swift bundle —
# without them an icon- or helper-entitlements-only PR would
# skip the job that assembles Roost-Iced.app.
- 'mac/Resources/roostctl.entitlements'
- 'mac/Resources/AppIcon.icns'
- 'mac/AppIcon.icon/**'
- 'mac/Resources/Roost-Iced.entitlements'
# Sparkle pin (version+SHA in fetch.sh) — a bump must re-run
# the bundle lanes that embed + sign the framework.
- 'third_party/sparkle/**'
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' || needs.changes.outputs.macbundle == 'true'
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest]
renderer: [wgpu, tiny-skia]
runs-on: ${{ matrix.os }}
# Was 30. The macOS cells now run a second e2e pass (bundle assembly +
# assert + a 2-module smoke) after their existing full functional-E2E +
# exit-on-empty passes; the Linux cells are unchanged (four lanes:
# X11 functional, X11 exit, X11 real-input clipboard, Wayland
# functional + exit). Bumped rather than risk the macOS cells timing
# out under load — the plan explicitly prefers a timeout bump over
# silently dropping the walking-skeleton module from the smoke subset.
timeout-minutes: 40
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
# `linux-package` is off in every dev and CI build, so without this
# pair the packaging configuration would first compile during a
# release build. The clippy pass also carries the real protection
# against a `cfg!(feature = ...)` typo: an unknown feature name is
# an `unexpected_cfgs` warning, which -D warnings turns into an error.
cargo test -p roost-iced --features linux-package
cargo clippy -p roost-iced --features linux-package --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_tab_strip_pixels.py
tools/roosttest/test_focus.py
tools/roosttest/test_palette.py
tools/roosttest/test_z_typography.py
tools/roosttest/test_project_lifecycle.py
tools/roosttest/test_sidebar_resize.py
tools/roosttest/test_osc_pipeline.py
tools/roosttest/test_sprite_pixels.py
tools/roosttest/test_ime.py
tools/roosttest/test_selection.py
tools/roosttest/test_mouse_tracking.py
tools/roosttest/test_dock_badge.py
tools/roosttest/test_menu_bar.py
tools/roosttest/test_sparkle.py
tools/roosttest/test_view_perf.py
tools/roosttest/test_osc52.py
--roost-target iced --roost-fresh -v
# Its own invocation, after the shared-session lane above: this
# module deletes the last project, which ends the app (plan 026 D8),
# so it must own the instance it drives.
- name: Run Iced exit-on-empty 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-exit-artifacts
ROOST_E2E_LOG_DIR: ${{ runner.temp }}/roost-iced-e2e-x11-exit-logs
run: >
xvfb-run -a --server-args="-screen 0 1920x1080x24"
uv run --group test pytest
tools/roosttest/test_exit_on_empty.py
--roost-target iced --roost-fresh -v
# Also app-ending (the menu's Quit item), so it needs the same "own
# invocation" isolation as the step above — but not the SAME
# invocation: the session-scoped harness fixture launches one UI per
# pytest run, so bundling two exit-ending modules together would
# strand the second. `app.menu_activate` is macOS-iced-only (plan
# 028 § 3.12), so this self-skips on every non-macOS lane; kept in
# all three lanes anyway for structural symmetry with the
# exit-on-empty step above.
- name: Run Iced menu-Quit 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-menu-quit-artifacts
ROOST_E2E_LOG_DIR: ${{ runner.temp }}/roost-iced-e2e-x11-menu-quit-logs
run: >
xvfb-run -a --server-args="-screen 0 1920x1080x24"
uv run --group test pytest
tools/roosttest/test_menu_quit.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. `test_selection.py` is
# in this list because `selection.*` reads UI state over IPC and never
# touches the pasteboard — only `test_osc52.py` is X11-only.
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_tab_strip_pixels.py
tools/roosttest/test_focus.py
tools/roosttest/test_palette.py
tools/roosttest/test_z_typography.py
tools/roosttest/test_project_lifecycle.py
tools/roosttest/test_sidebar_resize.py
tools/roosttest/test_osc_pipeline.py
tools/roosttest/test_sprite_pixels.py
tools/roosttest/test_ime.py
tools/roosttest/test_selection.py
tools/roosttest/test_mouse_tracking.py
tools/roosttest/test_dock_badge.py
tools/roosttest/test_menu_bar.py
tools/roosttest/test_sparkle.py
tools/roosttest/test_view_perf.py
--roost-target iced --roost-fresh -v
- name: Run Iced exit-on-empty E2E (Linux Wayland)
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-wayland-exit-artifacts
ROOST_E2E_LOG_DIR: ${{ runner.temp }}/roost-iced-e2e-wayland-exit-logs
run: >
tools/wayland/weston-run.sh
uv run --group test pytest
tools/roosttest/test_exit_on_empty.py
--roost-target iced --roost-fresh -v
# See the Linux X11 lane's comment above the same-named step: kept
# in every lane for structural symmetry, self-skips off macOS.
- name: Run Iced menu-Quit E2E (Linux Wayland)
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-wayland-menu-quit-artifacts
ROOST_E2E_LOG_DIR: ${{ runner.temp }}/roost-iced-e2e-wayland-menu-quit-logs
run: >
tools/wayland/weston-run.sh
uv run --group test pytest
tools/roosttest/test_menu_quit.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_tab_strip_pixels.py
tools/roosttest/test_focus.py
tools/roosttest/test_palette.py
tools/roosttest/test_z_typography.py
tools/roosttest/test_project_lifecycle.py
tools/roosttest/test_sidebar_resize.py
tools/roosttest/test_osc_pipeline.py
tools/roosttest/test_sprite_pixels.py
tools/roosttest/test_ime.py
tools/roosttest/test_selection.py
tools/roosttest/test_mouse_tracking.py
tools/roosttest/test_dock_badge.py
tools/roosttest/test_menu_bar.py
tools/roosttest/test_sparkle.py
tools/roosttest/test_view_perf.py
tools/roosttest/test_osc52.py
--roost-target iced --roost-fresh -v
- name: Run Iced exit-on-empty 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-exit-artifacts
ROOST_E2E_LOG_DIR: ${{ runner.temp }}/roost-iced-e2e-mac-exit-logs
run: >
uv run --group test pytest
tools/roosttest/test_exit_on_empty.py
--roost-target iced --roost-fresh -v
# Also app-ending (the menu's Quit item) — see the Linux X11 lane's
# comment above its same-named step. This is the lane where it
# actually runs (macOS-iced-only); the bare `roost-iced` binary
# installs its native menu bar the same as a bundled app (plan 028
# § 3.13 — "NSMenu installs fine on an unbundled process").
- name: Run Iced menu-Quit 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-menu-quit-artifacts
ROOST_E2E_LOG_DIR: ${{ runner.temp }}/roost-iced-e2e-mac-menu-quit-logs
run: >
uv run --group test pytest
tools/roosttest/test_menu_quit.py
--roost-target iced --roost-fresh -v
# M6 6a (plan 027 W5): the two steps above only ever exercise the bare
# `roost-iced` cargo binary. This assembles + smoke-tests the actual
# macOS deliverable (Roost-Iced.app) so a bundling regression (wrong
# bundle id, missing entitlements, a broken Sparkle embed or signing
# chain) is caught here rather than only by a human running
# `make bundle-iced` locally. Debug profile only — never "upgrade" this to release; the
# release-profile lane is `iced-release`, not this job.
# The Sparkle stage is version+SHA pinned inside fetch.sh, so
# hashFiles on the script is an exact cache key for out/ — a pin
# bump changes the key, and a GitHub release-asset outage can't
# flake the assemble step on a warm cache (plan 028 § 3.10).
- name: Cache Sparkle stage
if: runner.os == 'macOS'
uses: actions/cache@v5
with:
path: third_party/sparkle/out
key: sparkle-stage-${{ hashFiles('third_party/sparkle/fetch.sh') }}
- name: Assemble Roost-Iced.app
if: runner.os == 'macOS'
run: ./mac/scripts/bundle-iced.sh debug
- name: Assert bundle contents
if: runner.os == 'macOS'
run: |
set -euo pipefail
APP="mac/build/Roost-Iced.app"
BIN="$APP/Contents/MacOS/Roost-Iced"
INFO="$APP/Contents/Info.plist"
plist_value() { /usr/libexec/PlistBuddy -c "Print :$2" "$1" 2>/dev/null || true; }
has_key() { /usr/libexec/PlistBuddy -c "Print :$2" "$1" >/dev/null 2>&1; }
[ "$(plist_value "$INFO" CFBundleIdentifier)" = "ai.stridelabs.Roost.iced" ] \
|| { echo "FAIL: CFBundleIdentifier != ai.stridelabs.Roost.iced"; exit 1; }
echo "OK: CFBundleIdentifier is ai.stridelabs.Roost.iced"
[ "$(plist_value "$INFO" CFBundleExecutable)" = "Roost-Iced" ] \
|| { echo "FAIL: CFBundleExecutable != Roost-Iced"; exit 1; }
echo "OK: CFBundleExecutable is Roost-Iced"
version="$(plist_value "$INFO" CFBundleShortVersionString)"
[ -n "$(echo "$version" | tr -d '[:space:]')" ] \
|| { echo "FAIL: CFBundleShortVersionString is empty"; exit 1; }
[ "$version" != "@VERSION@" ] \
|| { echo "FAIL: CFBundleShortVersionString still has the unsubstituted @VERSION@ placeholder"; exit 1; }
echo "OK: CFBundleShortVersionString is '$version'"
# 6c posture (plan 028): Sparkle MECHANICS ship, feed
# deliberately absent. The default assemble must never carry a
# feed URL or public key — the two apps must not be able to
# offer each other's updates, and feed enablement is the
# explicit ROOST_ICED_SPARKLE_* env pair at bundle time.
for key in SUFeedURL SUPublicEDKey; do
if has_key "$INFO" "$key"; then
echo "FAIL: Info.plist unexpectedly carries $key (default assemble must stay feedless)"; exit 1
fi
done
echo "OK: no SUFeedURL / SUPublicEDKey"
# Present AND false — with the updater instantiated, an absent
# key would trigger Sparkle's first-run auto-check prompt;
# checks stay strictly user-invoked.
[ "$(plist_value "$INFO" SUEnableAutomaticChecks)" = "false" ] \
|| { echo "FAIL: SUEnableAutomaticChecks missing or not false"; exit 1; }
echo "OK: SUEnableAutomaticChecks present and false"
FRAMEWORK="$APP/Contents/Frameworks/Sparkle.framework"
[ -d "$FRAMEWORK" ] \
|| { echo "FAIL: $FRAMEWORK missing (6c embeds Sparkle)"; exit 1; }
# The runtime dlopen resolves the stable top-level
# Sparkle.framework/Sparkle symlink — a flattened copy (one
# that lost the Versions/ symlink farm) would sign fine but
# break the load path.
{ [ -L "$FRAMEWORK/Sparkle" ] && [ -f "$FRAMEWORK/Sparkle" ]; } \
|| { echo "FAIL: $FRAMEWORK/Sparkle top-level symlink missing or dangling"; exit 1; }
echo "OK: Sparkle.framework embedded with its symlink farm intact"
codesign --verify --deep --strict "$APP"
echo "OK: codesign --verify --deep --strict passed"
# Strict-chain proof (plan 028 § 3.10): deep-verify alone
# cannot prove the per-component inner→outer signing order —
# verify each Sparkle component's own signature individually,
# AND prove our re-sign actually ran: the vendor ships every
# component already ad-hoc+runtime signed, so a skipped (or
# --deep'd-then-skipped) chain would pass --verify on the
# vendor signatures alone. Our --force re-sign necessarily
# changes each component's CDHash away from the pristine
# staged copy fetch.sh downloaded — compare against it.
# Downloader.xpc is exempt from the CDHash comparison: our
# ad-hoc re-sign with --preserve-metadata=entitlements
# reproduces the vendor's CodeDirectory byte-for-byte
# (verified locally — identical CDHash), because ad-hoc
# signing is deterministic and every input it hashes is
# preserved. Its chain proof is instead the
# entitlements-present-and-clean check below, which a --deep
# clobber (drops the preserved blob) or an entitlements
# injection would each fail. The other components' re-sign
# provably changes the CDHash (--force replaces the vendor's
# designated requirements); if a Sparkle bump ever makes one
# of them deterministic too, this fails LOUD, not silent.
STAGED="third_party/sparkle/out/Sparkle.framework"
cdhash() { codesign -dvvv "$1" 2>&1 | awk -F= '/^CDHash=/{print $2; exit}'; }
codesign --verify --strict "$FRAMEWORK/Versions/B/XPCServices/Downloader.xpc" \
|| { echo "FAIL: codesign --verify --strict failed for Downloader.xpc"; exit 1; }
for rel in \
"Versions/B/XPCServices/Installer.xpc" \
"Versions/B/Autoupdate" \
"Versions/B/Updater.app"; do
component="$FRAMEWORK/$rel"
codesign --verify --strict "$component" \
|| { echo "FAIL: codesign --verify --strict failed for $component"; exit 1; }
embedded_hash=$(cdhash "$component")
vendor_hash=$(cdhash "$STAGED/$rel")
[ -n "$embedded_hash" ] && [ -n "$vendor_hash" ] \
|| { echo "FAIL: could not read CDHash for $rel"; exit 1; }
[ "$embedded_hash" != "$vendor_hash" ] \
|| { echo "FAIL: $rel still carries the vendor CDHash — the strict re-sign chain did not run on it"; exit 1; }
done
embedded_hash=$(cdhash "$FRAMEWORK")
vendor_hash=$(cdhash "$STAGED")
[ -n "$embedded_hash" ] && [ "$embedded_hash" != "$vendor_hash" ] \
|| { echo "FAIL: Sparkle.framework itself still carries the vendor CDHash"; exit 1; }
echo "OK: Sparkle components individually verify AND the re-sign chain provably ran (CDHash differs from the staged vendor copy; Downloader proven via preserved entitlements)"
# Extract entitlements as a real plist (codesign's text dump
# format is unstable across macOS versions) and parse with
# PlistBuddy for exact, substring-proof key lookup — same
# technique as the swift-mac job's TCC-entitlements check.
work=$(mktemp -d)
trap 'rm -rf "$work"' EXIT
ent="$work/entitlements.plist"
codesign -d --entitlements - --xml "$APP" 2>/dev/null > "$ent"
for key in \
com.apple.security.device.audio-input \
com.apple.security.device.camera \
com.apple.security.automation.apple-events; do
# Present is not enough — a `false` value would pass a key
# check while leaving TCC capture ineffective.
val=$(/usr/libexec/PlistBuddy -c "Print :$key" "$ent" 2>/dev/null) \
|| { echo "FAIL: entitlements missing $key"; exit 1; }
[ "$val" = "true" ] \
|| { echo "FAIL: entitlement $key is '$val', want true"; exit 1; }
done
echo "OK: entitlements carry the three capture keys (all true)"
# Required by the ad-hoc-signed embedded Sparkle under the
# hardened runtime (Roost-Iced.entitlements rationale;
# REMOVE-once-team-signed).
val=$(/usr/libexec/PlistBuddy -c "Print :com.apple.security.cs.disable-library-validation" "$ent" 2>/dev/null) \
|| { echo "FAIL: entitlements missing com.apple.security.cs.disable-library-validation"; exit 1; }
[ "$val" = "true" ] \
|| { echo "FAIL: cs.disable-library-validation is '$val', want true"; exit 1; }
echo "OK: entitlements carry cs.disable-library-validation (true)"
# Downloader.xpc must keep its OWN preserved entitlements
# (--preserve-metadata=entitlements; empty since Sparkle ≥2.6
# removed its sandbox — sparkle-project/Sparkle#2511). The
# app's entitlements being stamped onto it is exactly the
# clobbering that signs+notarizes clean but breaks at
# update-apply time.
dent="$work/downloader-entitlements.plist"
codesign -d --entitlements - --xml \
"$FRAMEWORK/Versions/B/XPCServices/Downloader.xpc" 2>/dev/null > "$dent"
for key in \
com.apple.security.device.audio-input \
com.apple.security.device.camera \
com.apple.security.automation.apple-events \
com.apple.security.cs.disable-library-validation; do
if grep -q "$key" "$dent"; then
echo "FAIL: Downloader.xpc entitlements unexpectedly carry $key (app entitlements leaked into the Sparkle chain)"; exit 1
fi
done
echo "OK: Downloader.xpc keeps its own preserved (non-Roost) entitlements"
# Capture first, then grep: `codesign -dv | grep -q` trips
# pipefail (grep -q exits on match, codesign's remaining
# unbuffered writes take SIGPIPE/141) — deterministic locally
# once the Sparkle embed grew the CodeDirectory.
sign_info=$(codesign -dv "$APP" 2>&1)
printf '%s\n' "$sign_info" | grep -q 'flags=.*runtime' \
|| { echo "FAIL: hardened runtime flag not present"; exit 1; }
echo "OK: hardened runtime flag present"
# Capture otool's output first WITHOUT masking: a missing or
# non-Mach-O binary must fail here, not read as "no deps".
deps=$(otool -L "$BIN" | tail -n +2 | awk '{print $1}')
[ -n "$deps" ] || { echo "FAIL: otool -L returned no dependencies for $BIN"; exit 1; }
bad=$(printf '%s\n' "$deps" | grep -vE '^(/usr/lib/|/System/)' || true)
if [ -n "$bad" ]; then
echo "FAIL: otool -L closure contains non-system paths:"
echo "$bad"
exit 1
fi
echo "OK: otool -L closure contains only /usr/lib and /System paths"
- name: Run Iced bundle smoke (macOS)
if: runner.os == 'macOS'
env:
ICED_BACKEND: ${{ matrix.renderer }}
RUST_LOG: warn
ROOST_TEST_MODE: "1"
ROOST_TEST_TIMEOUT_SCALE: "3"
ROOST_ICED_APP: mac/build/Roost-Iced.app
ROOST_E2E_ARTIFACT_DIR: ${{ runner.temp }}/roost-iced-e2e-mac-bundle-artifacts
ROOST_E2E_LOG_DIR: ${{ runner.temp }}/roost-iced-e2e-mac-bundle-logs
run: >
uv run --group test pytest
tools/roosttest/test_smoke.py
tools/roosttest/test_iced_walking_skeleton.py
tools/roosttest/test_menu_bar.py
--roost-target iced --roost-fresh -v
# Deliberately AFTER the keyless assemble + its assertion block:
# this step re-assembles mac/build/Roost-Iced.app in place, with
# the fixture's TEST-ONLY SUPublicEDKey and a dead placeholder
# SUFeedURL, so the shipped-posture assertions above must already
# have run against the keyless bundle. Mirrors `make
# e2e-iced-sparkle` (plan 028 § 3.11); cargo artifacts are already
# built, so the re-assemble is cheap.
- name: Assemble test-keyed Roost-Iced.app (macOS)
if: runner.os == 'macOS'
shell: bash
env:
ROOST_ICED_SPARKLE_FEED_URL: http://127.0.0.1:1/placeholder
run: |
set -euo pipefail
key_file=tools/roosttest/fixtures/sparkle/TEST-ONLY-public-ed-key.txt
test -f "$key_file" || { echo "missing $key_file"; exit 1; }
ROOST_ICED_SPARKLE_ED_PUBLIC_KEY="$(cat "$key_file")" \
./mac/scripts/bundle-iced.sh debug
plist="mac/build/Roost-Iced.app/Contents/Info.plist"
/usr/libexec/PlistBuddy -c 'Print :SUPublicEDKey' "$plist" >/dev/null \
|| { echo "FAIL: test-keyed bundle has no SUPublicEDKey"; exit 1; }
# The e2e supplies the real feed URL at runtime via the
# delegate override, so it would pass even if the plist
# insertion silently dropped SUFeedURL — assert the exact
# placeholder value here instead.
[ "$(/usr/libexec/PlistBuddy -c 'Print :SUFeedURL' "$plist")" = "http://127.0.0.1:1/placeholder" ] \
|| { echo "FAIL: test-keyed bundle missing or wrong SUFeedURL"; exit 1; }
echo "OK: test-keyed bundle carries SUFeedURL + SUPublicEDKey"
- name: Run Iced Sparkle E2E (macOS)
if: runner.os == 'macOS'
env:
ICED_BACKEND: ${{ matrix.renderer }}
RUST_LOG: warn
ROOST_TEST_MODE: "1"
ROOST_TEST_TIMEOUT_SCALE: "3"
ROOST_ICED_APP: mac/build/Roost-Iced.app
ROOST_E2E_ARTIFACT_DIR: ${{ runner.temp }}/roost-iced-e2e-mac-sparkle-artifacts
ROOST_E2E_LOG_DIR: ${{ runner.temp }}/roost-iced-e2e-mac-sparkle-logs
run: >
uv run --group test pytest
tools/roosttest/test_sparkle.py
--roost-target iced --roost-fresh -v
- name: Collect Iced diagnostics
if: always()
shell: bash
run: |
mkdir -p diagnostics
# Every lane's capture file is named `roost-iced-ui.log`, so name
# the copy after its lane directory (as the screenshots below
# already do) — otherwise the lanes in this job overwrite one
# another and only the last one's log survives.
while IFS= read -r log; do
suite="$(basename "$(dirname "${log}")")"
cp "${log}" "diagnostics/${suite}-$(basename "${log}")"
done < <(find "${RUNNER_TEMP}" -path '*/roost-iced-e2e-*-logs/*.log' 2>/dev/null)
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
# Bundle-mode launches write the persistent profile log (and any
# crash reports) under ~/Library/Logs/Roost-iced, not the
# harness's ROOST_E2E_LOG_DIR — collect them explicitly or a
# bundle boot failure leaves no log artifact.
cp "$HOME"/Library/Logs/Roost-iced/roost.log diagnostics/roost-iced-bundle-persistent.log 2>/dev/null || true
cp "$HOME"/Library/Logs/Roost-iced/crash-*.txt diagnostics/ 2>/dev/null || true
cp "$HOME"/Library/Logs/DiagnosticReports/roost-iced*.ips diagnostics/ 2>/dev/null || true
# fnmatch is case-sensitive; the bundled process is named
# `Roost-Iced` (CFBundleExecutable), not `roost-iced`, so its
# crash reports need their own glob.
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
# iced-build-e2e above only ever builds roost-iced in debug, but the deb
# ships a release build (`lto = "thin"`, `codegen-units = 1` in
# `[profile.release]`), and this stack has already produced a release-only
# bug: #299's swash shaping hang didn't reproduce in debug. Single
# ubuntu-latest/wgpu lane, not a matrix — the renderer split is already
# covered in debug above; what's untested here is the release profile
# itself, not the backend.
iced-release:
needs: changes
# `linux` is in here for the packaging paths it covers (linux/** and
# packaging/**): this lane builds the same packaged configuration the deb
# ships, so a build-deb.sh or nfpm.yaml change must be able to reach it.
# Without that, a packaging-only PR triggered no lane that compiles the
# linux-package feature at all. `deb` additionally turns on the packaging
# steps further down (build the real .deb, then smoke it and verify its
# dependency closure).
if: needs.changes.outputs.rust == 'true' || needs.changes.outputs.tests == 'true' || needs.changes.outputs.linux == 'true' || needs.changes.outputs.ci == 'true' || needs.changes.outputs.deb == 'true'
runs-on: ubuntu-latest
timeout-minutes: 45
steps:
- uses: actions/checkout@v6
with:
persist-credentials: false
- name: Install Iced native dependencies (Linux)
run: |
sudo apt-get update
# iced-build-e2e's set minus weston: this lane is X11-only
# (xvfb-run), so a Wayland compositor is pure install time.
sudo apt-get install -y \
fonts-noto-cjk libclang-dev libxkbcommon-x11-0 libwayland-client0 \
mesa-vulkan-drivers 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-release-${{ runner.os }}-${{ hashFiles('**/Cargo.toml', 'rust-toolchain.toml') }}
restore-keys: cargo-iced-release-${{ runner.os }}-
# Must precede the build, because on the `deb` path the build is done by
# build-deb.sh, which ends in `nfpm pkg`. Mirrors release.yml's install
# exactly (same pinned version, same source) rather than inventing a
# second mechanism — this lane is ubuntu-latest, i.e. amd64, so the arch
# is unconditionally x86_64.
- name: Install nfpm
if: needs.changes.outputs.deb == 'true'
run: |
set -euo pipefail
ver="2.46.3"
url="https://github.com/goreleaser/nfpm/releases/download/v${ver}/nfpm_${ver}_Linux_x86_64.tar.gz"
curl -fsSL "${url}" -o /tmp/nfpm.tgz
sudo tar -C /usr/local/bin -xzf /tmp/nfpm.tgz nfpm
nfpm --version
# smoke-deb.sh validates the staged .desktop entries with
# desktop-file-validate; only the deb path stages any.
- name: Install desktop-file-utils
if: needs.changes.outputs.deb == 'true'
run: |
sudo apt-get update
sudo apt-get install -y desktop-file-utils
# One build, not two — and now enforced rather than assumed.
# [profile.release] is `lto = "thin"` + `codegen-units = 1`, so a second
# cold LTO link would blow this job's 45-minute budget. build-deb.sh's
# cargo invocation is byte-identical to the one here, so on the `deb`
# path it subsumes this step; the two `if:`s are exact complements, so
# exactly one always runs. Splitting it this way keeps ONE source of
# truth for the build command instead of relying on an unenforced
# coincidence between a workflow and a script.
#
# `linux-package` is the configuration that actually ships (it's what
# makes a packaged build adopt the production `roost` bundle profile
# instead of the isolated `roost-iced` one, per `default_profile_kind()`
# in `crates/roost-iced/src/main.rs`) — building it here, rather than
# the featureless dev config, is what makes this lane worth gating.
# Either path leaves target/release/{roost-iced,roostctl} exactly where
# every downstream step expects them: the e2e step below exercises the
# binary as an ordinary dev instance because it pins
# `ROOST_BUNDLE_PROFILE`, and the profile-adoption step further down is
# what leaves that var unset and checks where it lands on its own.
- name: Build Iced + roostctl (release)
if: needs.changes.outputs.deb != 'true'
run: cargo build --release -p roost-iced -p roost-cli --features roost-iced/linux-package
# build-deb.sh also re-runs third_party/ghostty/build.sh. That's
# idempotent on a cache hit, but it needs `zig` on PATH *before* it
# reaches its cache check — satisfied by the jdx/mise-action@v4 step
# above, which is why this can't move ahead of it.
#
# Version 0.0.0-ci: nfpm normalizes `-` to `~`, yielding `0.0.0~ci` — a
# valid Debian version that can never be mistaken for a real release.
- name: Build the .deb (also produces the release binaries)
if: needs.changes.outputs.deb == 'true'
run: ./linux/scripts/build-deb.sh 0.0.0-ci
- name: Run Iced release-profile E2E (Linux X11)
env:
ICED_BACKEND: wgpu
RUST_LOG: warn
ROOST_TEST_TIMEOUT_SCALE: "3"
ROOST_ICED_BIN: ${{ github.workspace }}/target/release/roost-iced
# util.py's roostctl fallback is target/debug/roostctl, and it
# cargo-builds a debug CLI when that's missing. No module in the
# release subset needs roostctl today, but adding one later would
# otherwise exercise a debug CLI inside the release gate.
ROOST_ROOSTCTL: ${{ github.workspace }}/target/release/roostctl
ROOST_E2E_ARTIFACT_DIR: ${{ runner.temp }}/roost-iced-e2e-release-x11-artifacts
ROOST_E2E_LOG_DIR: ${{ runner.temp }}/roost-iced-e2e-release-x11-logs
# Even though ROOST_ICED_BIN above is the *packaged*
# (linux-package) binary, it lands on the isolated `roost-iced`
# dev profile here just like a plain dev build: ui.py sets
# ROOST_BUNDLE_PROFILE=iced unconditionally for every "iced"
# target launch (tools/roosttest/ui.py, ~line 481), which outranks
# the compiled-in default per BundleProfile::resolve's precedence
# order. `e2e-iced-release-ci` (Makefile) already guards that
# ROOST_ICED_BIN is non-empty — a blank value would silently fall
# back to a cargo-built debug binary and test the wrong thing —
# so that check isn't duplicated here.
run: >
xvfb-run -a --server-args="-screen 0 1920x1080x24"
make e2e-iced-release-ci
# The only pre-release exercise of the linux-package adoption
# mechanism: without this, "does the packaged binary really land on
# the production `roost` namespace instead of the isolated
# `roost-iced` one" is first proven at tag time, in front of users
# doing the deb upgrade. Runs the SAME already-built binary above,
# but with ROOST_BUNDLE_PROFILE deliberately left unset (unlike the
# e2e step above, which pins it to `iced`) so
# `default_profile_kind()`'s packaged+Linux branch gets to decide
# the profile on its own, exactly as it will for a real user.
- name: Assert the packaged build adopts the production profile
env:
ICED_BACKEND: wgpu
RUST_LOG: warn
XDG_RUNTIME_DIR: ${{ runner.temp }}/iced-release-profile-check/run
XDG_DATA_HOME: ${{ runner.temp }}/iced-release-profile-check/data
XDG_STATE_HOME: ${{ runner.temp }}/iced-release-profile-check/state
run: |
set -euo pipefail
mkdir -p "$XDG_RUNTIME_DIR" "$XDG_DATA_HOME" "$XDG_STATE_HOME"
chmod 700 "$XDG_RUNTIME_DIR"
UI_BIN="${GITHUB_WORKSPACE}/target/release/roost-iced"
ROOSTCTL_BIN="${GITHUB_WORKSPACE}/target/release/roostctl"
UI_LOG="${RUNNER_TEMP}/iced-release-profile-check.log"
xvfb-run -a --server-args="-screen 0 1280x800x24" "$UI_BIN" >"$UI_LOG" 2>&1 &
UI_PID=$!
cleanup() {
# A no-op kill (UI already dead, or never started) must not
# fail the job.
kill "$UI_PID" >/dev/null 2>&1 || true
wait "$UI_PID" 2>/dev/null || true
}
trap cleanup EXIT
# Poll a real `identify` round-trip, NOT the socket file's
# existence — a stale socket file with nothing listening would
# make a file check pass and race the real readiness signal.
IDENTIFY_OUTPUT=""
for _ in $(seq 1 60); do
if IDENTIFY_OUTPUT=$("$ROOSTCTL_BIN" identify 2>/dev/null); then
break
fi
IDENTIFY_OUTPUT=""
sleep 0.5
done
if [ -z "$IDENTIFY_OUTPUT" ]; then
echo "::error::packaged roost-iced never answered 'roostctl identify' after ~30s"
echo "--- UI log ($UI_LOG) ---"
cat "$UI_LOG" || true
exit 1
fi
SOCKET_PATH=$(printf '%s\n' "$IDENTIFY_OUTPUT" | awk -F= '$1 == "socket" { print $2 }')
if [ -z "$SOCKET_PATH" ]; then
echo "::error::could not parse a socket= line out of 'roostctl identify' output:"
printf '%s\n' "$IDENTIFY_OUTPUT"
exit 1
fi
# 1. The reported socket must be under the production
# namespace ($XDG_RUNTIME_DIR/roost/), not the isolated
# roost-iced one.
case "$SOCKET_PATH" in
"$XDG_RUNTIME_DIR"/roost/*)
;;
*)
echo "::error::packaged build's socket is not under \$XDG_RUNTIME_DIR/roost/ (production namespace): $SOCKET_PATH"
exit 1
;;
esac
# 2. The isolated roost-iced/ namespace must not have been
# created at all.
if [ -d "$XDG_RUNTIME_DIR/roost-iced" ]; then
echo "::error::packaged build still created the isolated roost-iced/ namespace at $XDG_RUNTIME_DIR/roost-iced — linux-package adoption did not take effect"
exit 1
fi
echo "OK: packaged build adopted the production namespace ($SOCKET_PATH)"
# ---- Packaging checks (deb-gated) -------------------------------------
# These three steps run the *same* scripts release.yml runs. Until now
# their first execution was during an actual release — i.e. after the
# GitHub Release had already been created, with a broken artifact
# already attached to it. Running them on ordinary PRs that touch the
# packaging paths is the whole point: the release path's only real
# artifact checks get proven somewhere other than a real release.
# They sit after the profile-adoption assertion above so the cheap,
# already-proven check still reports first.
# Release-critical shell with no other lint anywhere in the repo.
# shellcheck ships preinstalled on GitHub's ubuntu runners, so this is
# seconds; `bash -n` additionally catches syntax errors in code paths
# shellcheck may not flag.
- name: Lint the release-path shell scripts
if: needs.changes.outputs.deb == 'true'
run: |
set -euo pipefail
shellcheck linux/scripts/*.sh
for f in linux/scripts/*.sh; do
bash -n "$f"
done
- name: Smoke the packaged artifact
if: needs.changes.outputs.deb == 'true'
run: |
set -euo pipefail
deb="$(./linux/scripts/resolve-one-deb.sh out)"
./linux/scripts/smoke-deb.sh "${deb}" \
--work-dir "${RUNNER_TEMP}/roost-deb-smoke" \
--expect-version 0.0.0~ci
# The smoke above extracts the .deb, so it proves the payload but not
# the `Depends:` line — this runner already carries the whole graphics
# stack from the build, so a missing dependency would still launch
# here. The container run is what catches that.
- name: Verify the dependency closure
if: needs.changes.outputs.deb == 'true'
run: |
set -euo pipefail
deb="$(./linux/scripts/resolve-one-deb.sh out)"
./linux/scripts/verify-deb-closure.sh "${deb}"
- 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)
ls -la diagnostics || true
- name: Upload Iced diagnostics
if: always()
uses: actions/upload-artifact@v7
with:
name: e2e-iced-release-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.
#
# REQUIRED as of plan 024. This job is in `ci-success`, so a soft step
# inside it made the required gate only a partial one. The
# gather-signal period is over and XTEST-under-Xvfb held up: 21
# consecutive `main` runs (2026-07-31 → 2026-08-09), every one ending
# `PASS: focus + core-sync …, drag reorder …, palette chrome-click
# no-storm all verified`, with zero failures and zero skips. Measured
# from the step LOGS — with `continue-on-error` set, both the job
# conclusion and the REST API's step `conclusion` report success no
# matter what the step actually did, so neither is evidence.
#
# 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)
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.
#
# REQUIRED (in ci-success) as of plan 024. The gather-signal period is over
# and the signal was clean: 21 consecutive `main` runs between 2026-07-31
# and 2026-08-09, every one ending `N passed, M skipped` with zero failures,
# runtime stable at 19-31s.
#
# HOW that was measured matters, because the obvious methods lie. A step
# carrying `continue-on-error` is reported by the REST jobs API with
# `conclusion: success` no matter what it did — that API exposes
# `conclusion` and not `outcome`. So both "the job went green 21 times" and
# a step-level query saying "all steps ok" were true and meaningless. The
# only honest signal is the step's log body, which is where the numbers
# above come from. Do not promote anything here on job- or step-conclusion
# evidence again.
#
# 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, which is deliberately NOT promoted — see its comment.
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)
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 + 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.
# `test_selection.py` is NOT skipped: `selection.set`/`selection.dump`
# go straight to the TerminalView and never reach GdkClipboard, so
# there's nothing here for a missing clipboard manager to block on.
# `timeout-minutes` is the belt-and-suspenders cap so a future hang can
# never run away again — and now that this step is required, a hang
# reds the gate instead of being swallowed.
# --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_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.
#
# STILL NON-BLOCKING (continue-on-error + absent from ci-success), and this
# is now a measured decision rather than a pending one.
#
# Plan 024 re-checked the history from the step LOGS — the only signal that
# survives `continue-on-error`, since both the job conclusion and the REST
# API's step `conclusion` report success regardless. Its sibling
# e2e-gtk-wayland came back 21/21 clean and was promoted. This lane did not:
# 3 of the same 21 runs FAILED with
#
# FAIL (real-input required): window_metrics returned no window size
#
# (runs 31134306062, 31144935154, 31151407004 — all within one ~5h window on
# 2026-08-07, so plausibly a runner-image or seat/uinput blip rather than a
# steady 14% rate, but a gate cannot be promoted on a hypothesis).
#
# The failure is harness-shaped, not product-shaped: the injector never got
# a window geometry back, so it could not aim the pointer. Two candidates
# worth investigating before promoting — cage not having the surface mapped
# when the harness asks (needs a wait/retry rather than a single probe), or
# the best-effort `modprobe uinput` + seatd step below silently not taking,
# in which case this should SKIP loudly rather than FAIL. See #328.
#
# 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, iced-release, e2e-gtk, e2e-gtk-wayland, e2e-mac]
if: always()
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Verify no required job failed
# Membership is DERIVED from `needs`, not restated. It used to be
# written out twice — once in `needs:` and once in a hand-built
# `results` string — and the two drifted twice. `toJSON(needs)` makes
# the duplication impossible rather than adding a third thing to keep
# in sync.
#
# Passed through `env:` and never interpolated into the shell body:
# `${{ }}` inside `run:` is textual substitution, which is a script
# injection surface.
env:
NEEDS: ${{ toJSON(needs) }}
run: |
set -euo pipefail
echo "${NEEDS}" | jq -r 'to_entries[] | " \(.key): \(.value.result)"'
# Deriving membership from `needs` means a job DELETED from that
# list silently stops being gated — the gate would still pass, just
# over less. A floor catches that. It is one integer rather than a
# second copy of the list, so it cannot drift the way the old
# hand-written `results` string did; adding a gate is free (the
# count only rises), and lowering this number should be a
# deliberate act with a reason. `release.yml`'s ci-gate trusts this
# check, so "fewer gates than we think" has to be loud.
required_jobs=12
count="$(echo "${NEEDS}" | jq -r 'length')"
if [ "${count}" -lt "${required_jobs}" ]; then
echo "::error::ci-success is gating ${count} jobs but expects at least ${required_jobs} — a job was removed from its needs list. If that was deliberate, lower required_jobs in the same commit."
exit 1
fi
# Allowlist, not a denylist. A denylist of failure/cancelled let
# `abandoned` through — the status GitHub assigns when its own
# infrastructure kills a job — so a run where 7 of 9 jobs never
# executed reported this gate GREEN. Observed on PR #306 during a
# GitHub "Failed to resolve action download info" incident.
# Anything that is not an actual pass now fails the gate.
bad="$(echo "${NEEDS}" | jq -r '
[to_entries[]
| select(.value.result != "success" and .value.result != "skipped")
| "\(.key)=\(.value.result)"]
| join(" ")')"
if [ -n "${bad}" ]; then
echo "::error::a required CI job did not pass: ${bad}"
exit 1
fi
# `changes` gates every other job, so if IT fails they all report
# `skipped` — which the allowlist accepts — and this gate would go
# green with nothing having been built. Same greenwash shape as
# #306, one level further up. Its result is already covered above
# (it is in `needs`), but assert it explicitly rather than relying on
# a reader noticing why a `skipped` allowance is safe here.
changes_result="$(echo "${NEEDS}" | jq -r '.changes.result')"
if [ "${changes_result}" != "success" ]; then
echo "::error::the 'changes' job did not succeed (${changes_result}) — every other job's 'skipped' is meaningless"
exit 1
fi
echo "all good"