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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
124 changes: 122 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ jobs:
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
Expand Down Expand Up @@ -511,6 +512,13 @@ jobs:
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
Expand Down Expand Up @@ -668,7 +676,12 @@ jobs:
# itself, not the backend.
iced-release:
needs: changes
if: needs.changes.outputs.rust == 'true' || needs.changes.outputs.tests == 'true' || needs.changes.outputs.ci == 'true'
# `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.
if: needs.changes.outputs.rust == 'true' || needs.changes.outputs.tests == 'true' || needs.changes.outputs.linux == 'true' || needs.changes.outputs.ci == 'true'
runs-on: ubuntu-latest
timeout-minutes: 45
steps:
Expand Down Expand Up @@ -716,8 +729,20 @@ jobs:
key: cargo-iced-release-${{ runner.os }}-${{ hashFiles('**/Cargo.toml', 'rust-toolchain.toml') }}
restore-keys: cargo-iced-release-${{ runner.os }}-

# One build, not two: [profile.release] is `lto = "thin"` +
# `codegen-units = 1`, and a second cold LTO link would blow the
# job's 45-minute budget. `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.
# The e2e step right below still exercises this same binary as an
# ordinary dev instance, because it pins `ROOST_BUNDLE_PROFILE`; the
# profile-adoption step further down is what actually leaves that
# var unset and checks where the packaged binary lands on its own.
- name: Build Iced + roostctl (release)
run: cargo build --release -p roost-iced -p roost-cli
run: cargo build --release -p roost-iced -p roost-cli --features roost-iced/linux-package

- name: Run Iced release-profile E2E (Linux X11)
env:
Expand All @@ -732,10 +757,105 @@ jobs:
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)"

- name: Collect Iced diagnostics
if: always()
shell: bash
Expand Down
154 changes: 150 additions & 4 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -157,14 +157,26 @@ jobs:
with:
persist-credentials: false

- name: Install GTK4 + libadwaita + build deps
# The .deb ships the iced UI as /usr/bin/roost (linux/scripts/
# build-deb.sh), not roost-linux — GTK dev packages (libgtk-4-dev,
# libadwaita-1-dev) are dead weight now that the package no longer
# contains the GTK UI. pkg-config + libclang-dev build roost-vt's
# bindgen; the rest mirrors ci.yml's `iced-release` job — the display
# + wgpu/vulkan stack the packaged-artifact smoke below needs to
# actually launch the binary under Xvfb.
- name: Install Iced + build deps
run: |
sudo apt-get update
sudo apt-get install -y \
libgtk-4-dev \
libadwaita-1-dev \
pkg-config \
libclang-dev
libclang-dev \
fonts-noto-cjk \
libxkbcommon-x11-0 \
libwayland-client0 \
mesa-vulkan-drivers \
xvfb \
xdotool \
zsh

- name: Install nfpm
env:
Expand Down Expand Up @@ -212,6 +224,140 @@ jobs:
- name: Build .deb
run: ./linux/scripts/build-deb.sh "${GITHUB_REF_NAME#v}"

# This is the only thing in the release path that actually launches
# the artifact being shipped. Nothing upstream (cargo build, nfpm pkg)
# can catch "wrong binary staged" or "linux-package silently didn't
# compile in" — both fail *only* by the running binary binding the
# wrong IPC namespace, which is exactly what this checks. Runs the
# dist/roost build-deb.sh just staged (the real packaged build,
# feature and all) in a throwaway XDG_* sandbox so it can't touch
# anything else on the runner, and deliberately leaves
# ROOST_BUNDLE_PROFILE UNSET — the point is to prove the *compiled-in
# default* lands on the production `roost` namespace (what existing
# GTK users already have on disk), not an env override papering over
# a build that defaults to the dev `roost-iced` namespace.
- name: Smoke the packaged artifact
run: |
set -euo pipefail
export XDG_RUNTIME_DIR="${RUNNER_TEMP}/roost-smoke/runtime"
export XDG_DATA_HOME="${RUNNER_TEMP}/roost-smoke/data"
export XDG_STATE_HOME="${RUNNER_TEMP}/roost-smoke/state"
mkdir -p "${XDG_RUNTIME_DIR}" "${XDG_DATA_HOME}" "${XDG_STATE_HOME}"
chmod 700 "${XDG_RUNTIME_DIR}"

# Smoke what nfpm actually produced, not what build-deb.sh staged.
# Running ./dist/roost would prove the binary works while leaving
# every packaging-layer mistake — a wrong `contents:` destination,
# a dropped entry, a lost exec bit — to be discovered by users.
shopt -s nullglob
debs=(out/*.deb)
if [ "${#debs[@]}" -ne 1 ]; then
echo "::error::expected exactly one out/*.deb to smoke, found ${#debs[@]}: ${debs[*]:-none}"
exit 1
fi
payload="${RUNNER_TEMP}/roost-smoke/payload"
rm -rf "${payload}"; mkdir -p "${payload}"
dpkg-deb -x "${debs[0]}" "${payload}"
for f in usr/bin/roost usr/bin/roostctl; do
if [ ! -x "${payload}/${f}" ]; then
echo "::error::${f} missing or not executable inside ${debs[0]}"
exit 1
fi
done
UI="${payload}/usr/bin/roost"
ROOSTCTL="${payload}/usr/bin/roostctl"

xvfb-run -a --server-args="-screen 0 1280x800x24" "${UI}" \
> "${RUNNER_TEMP}/roost-smoke/app.log" 2>&1 &
APP=$!
trap 'kill "${APP}" 2>/dev/null || true; wait "${APP}" 2>/dev/null || true' EXIT

# Poll a successful `identify` round-trip, NOT socket-file
# existence: a stale socket file can sit there with nothing
# listening, so a file-existence check races and can pass on a
# dead UI.
identify_out=""
ok=0
for _ in $(seq 1 60); do
if identify_out="$("${ROOSTCTL}" identify 2>/dev/null)"; then
ok=1
break
fi
sleep 0.5
done
if [ "${ok}" -ne 1 ]; then
echo "::error::roostctl identify never succeeded against the packaged /usr/bin/roost after 30s — the UI never came up (or never opened its IPC socket)."
tail -50 "${RUNNER_TEMP}/roost-smoke/app.log" || true
exit 1
fi
echo "${identify_out}"

socket_path="$(printf '%s\n' "${identify_out}" | awk -F= '/^socket=/{print $2}')"
if [ -z "${socket_path}" ]; then
echo "::error::identify succeeded but printed no socket= line: ${identify_out}"
exit 1
fi

case "${socket_path}" in
"${XDG_RUNTIME_DIR}/roost/"*)
echo "socket ${socket_path} is under the production roost/ namespace — ok."
;;
*)
echo "::error::packaged roost bound socket '${socket_path}', expected it under ${XDG_RUNTIME_DIR}/roost/ (the production GTK/roost namespace shared with existing users). The linux-package feature may be missing from this build."
exit 1
;;
esac

if [ -d "${XDG_RUNTIME_DIR}/roost-iced" ]; then
echo "::error::${XDG_RUNTIME_DIR}/roost-iced exists — the packaged binary created the DEV iced namespace instead of defaulting to production roost."
exit 1
fi

Comment thread
coderabbitai[bot] marked this conversation as resolved.
# The step above extracts the .deb, so it validates the payload (file
# list, exec bits, destinations) but NOT the dependency closure: this
# runner already carries the whole graphics stack from the build step,
# so a `Depends:` line that forgot a library would still launch here.
# Missing runtime dependencies are the failure mode users actually hit
# — the package installs cleanly and then won't start — so the closure
# gets checked where nothing is pre-installed: a clean ubuntu:24.04
# container with --no-install-recommends, which also proves the
# Recommends-vs-Depends split (no Vulkan loader in there at all).
- name: Verify the dependency closure in a clean container
run: |
set -euo pipefail
shopt -s nullglob
debs=(out/*.deb)
if [ "${#debs[@]}" -ne 1 ]; then
echo "::error::expected exactly one out/*.deb, found ${#debs[@]}"
exit 1
fi
deb="$(cd "$(dirname "${debs[0]}")" && pwd)/$(basename "${debs[0]}")"

docker run --rm -v "${deb}:/tmp/roost.deb:ro" ubuntu:24.04 bash -eu -c '
apt-get update -qq
# --no-install-recommends is the strict case: only what Depends
# actually names gets installed.
apt-get install -y -qq --no-install-recommends /tmp/roost.deb
# xvfb/xauth are the harness, not the package under test.
apt-get install -y -qq --no-install-recommends xvfb xauth
export XDG_RUNTIME_DIR=/tmp/rt
mkdir -p "$XDG_RUNTIME_DIR"; chmod 700 "$XDG_RUNTIME_DIR"
xvfb-run -a --server-args="-screen 0 1280x800x24" bash -c "
/usr/bin/roost >/tmp/app.log 2>&1 &
ok=no
for _ in \$(seq 1 60); do
if /usr/bin/roostctl identify >/dev/null 2>&1; then ok=yes; break; fi
sleep 0.5
done
if [ \"\$ok\" != yes ]; then
echo \"the installed package never came up — likely a missing runtime dependency\"
tail -40 /tmp/app.log || true
exit 1
fi
/usr/bin/roostctl identify
"
' || { echo "::error::the .deb installed but could not launch in a clean container — its Depends: list is incomplete."; exit 1; }

- name: Upload .deb to the Release
env:
GH_TOKEN: ${{ github.token }}
Expand Down
5 changes: 5 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -182,8 +182,13 @@ clippy: ## Lint Rust at CI parity (warnings are errors)
cargo clippy --workspace --exclude roost-linux --all-targets -- -D warnings
cargo clippy -p roost-linux --all-targets -- -D warnings

# `linux-package` is off in every dev build, so without the second test +
# clippy pair the packaging configuration would compile for the first time
# during a release build.
check-iced: fmt-check test-iced ## Iced formatting, lint, tests, and dependency boundaries
cargo clippy -p roost-iced --all-targets -- -D warnings
cargo test -p roost-iced --features linux-package
cargo clippy -p roost-iced --features linux-package --all-targets -- -D warnings
@! cargo tree -p roost-iced | grep -E '(^| )(gtk4|libadwaita|pango|cairo-rs|roost-linux) v' || \
( echo "roost-iced has a forbidden GTK dependency"; exit 1 )
@! cargo tree -p roost-engine | grep -E '(^| )(gtk4|libadwaita|iced|notify-rust|zbus|arboard) v' || \
Expand Down
11 changes: 11 additions & 0 deletions crates/roost-iced/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,17 @@ description = "Roost Iced proof-of-concept UI adapter over roost-engine"
[lints]
workspace = true

# Packaging-only. The Linux .deb builds with this on so the shipped binary
# resolves the production `roost` namespace (socket, state.json, log dir) that
# the GTK package already owns, letting existing users migrate in place. Dev
# builds leave it off and keep the isolated `roost-iced` profile so both UIs
# run side by side.
#
# Never build this crate with `--all-features`: that would hand a dev binary
# the production profile and point it at the user's real state.json.
[features]
linux-package = []

[[bin]]
name = "roost-iced"
path = "src/main.rs"
Expand Down
6 changes: 5 additions & 1 deletion crates/roost-iced/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1093,7 +1093,11 @@ impl App {
palette_present_reply: None,
palette_activate_replies: HashMap::new(),
clipboard: ClipboardQueue::default(),
desktop_notifications: DesktopNotifications::new(runtime.handle(), feed_tx.clone()),
desktop_notifications: DesktopNotifications::new(
runtime.handle(),
feed_tx.clone(),
profile.app_id.to_owned(),
),
runtime_handle: runtime.handle().clone(),
feed_rx,
feed_tx,
Expand Down
Loading
Loading