Skip to content

build: bump libghostty-vt to ghostty main tip + zig 0.16 #1124

build: bump libghostty-vt to ghostty main tip + zig 0.16

build: bump libghostty-vt to ghostty main tip + zig 0.16 #1124

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/iced 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 }}
docnav: ${{ steps.filter.outputs.docnav }}
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'
- '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, 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/**'
# e2e-iced-wayland-drag's check imports pngtool from here (and
# the screenshot harness shares helpers with tools/input), so a
# change there can break a lane that lists none of its files.
- 'tools/screenshot/**'
- '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'
# test_update_appcast.py and test_sparkle_plist.py drive the
# real scripts/templates via subprocess, so editing them
# should retrigger harness-unit too.
- 'mac/scripts/update-appcast.py'
- 'mac/scripts/bundle-lib.sh'
- 'mac/scripts/bundle-iced.sh'
- 'mac/Resources/Info-iced.plist.template'
# roost-cli's `doc_anchors_resolve` test reads the site nav and
# every page it links, so those files are inputs to a RUST test
# even though no Rust file changes when they move. Without this,
# a docs-only PR can delete or rename what the test reads and
# `rust-build` never runs to notice — which is exactly how the
# MkDocs -> Zensical migration left main red while CI was green.
# Same class as the `.mise.toml` dotfile miss noted above.
docnav:
- 'zensical.toml'
- 'docs/**'
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/**'
# The release-packaging scripts the DMG + sign->appcast smoke
# steps drive (plan 030 C4). Without these, a script-only fix
# to either one would re-run nothing that packages a DMG.
- 'mac/scripts/make-dmg.sh'
- 'mac/scripts/update-appcast.py'
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
# Keep this runner the toolkit-neutral core lane. roost-iced stays
# excluded because its clippy has to run once per feature set
# (default + `linux-package`) — that lives in iced-build-e2e's
# "Test and lint Iced" step, which is required just like this one.
run: cargo clippy --workspace --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
# `docnav` rides along here only — this is the job that runs `cargo
# test`, and widening `rust` itself would drag four other heavy jobs
# onto every docs typo.
if: needs.changes.outputs.rust == 'true' || needs.changes.outputs.fixtures == 'true' || needs.changes.outputs.ci == 'true' || needs.changes.outputs.docnav == '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.
run: cargo build --workspace --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
# 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"
# 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) v'; then
echo "FAIL: roost-iced depends on GTK"
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
# Branch-side proof of the release path's packaging half (plan 030
# § 3.5): release.yml's `mac-iced` job runs this same script with the
# same overrides, and the regression that matters is the staging leaf —
# a DMG that contains "Roost.app" would drag-install OVER the Swift app.
# Version string is arbitrary here; only the layout is under test.
- name: Smoke the iced DMG (macOS)
if: runner.os == 'macOS'
shell: bash
env:
ROOST_DMG_APP_DIR: mac/build/Roost-Iced.app
ROOST_DMG_BASENAME: Roost-Iced-ci-smoke
run: |
set -euo pipefail
./mac/scripts/make-dmg.sh 0.0.0-ci
dmg="mac/build/Roost-Iced-ci-smoke.dmg"
mnt="$(mktemp -d)"
hdiutil attach "$dmg" -readonly -nobrowse -mountpoint "$mnt" >/dev/null
trap 'hdiutil detach "$mnt" >/dev/null 2>&1 || true' EXIT
ls -la "$mnt"
[ -d "$mnt/Roost-Iced.app" ] \
|| { echo "FAIL: $dmg does not contain Roost-Iced.app at its root"; exit 1; }
[ ! -e "$mnt/Roost.app" ] \
|| { echo "FAIL: $dmg contains Roost.app — the iced DMG would drag-install over the Swift app"; exit 1; }
hdiutil detach "$mnt" >/dev/null
trap - EXIT
rmdir "$mnt"
echo "OK: iced DMG mounts with Roost-Iced.app (and no Roost.app) at its root"
# The other half: sign_update -> update-appcast.py, the exact chain
# release.yml's mac-iced + appcast-iced jobs run, against a scratch copy
# of the committed seed feed. Proves the seed parses, the iced overrides
# reach the enclosure, and the writer's output is well-formed XML.
- name: Smoke the iced sign -> appcast chain (macOS)
if: runner.os == 'macOS'
shell: bash
run: |
set -euo pipefail
W="$(mktemp -d)"
chmod 700 "$W"
trap 'rm -rf "$W"' EXIT
# Deliberately NOT generate_keys: it only ever writes the private
# half into the login Keychain (-x exports an existing key, -f
# imports one — there is no file-only generation mode), and a CI
# step must not leave key material in the runner's Keychain. A
# Sparkle key FILE is base64 of the 32-byte ed25519 seed (see
# `generate_keys --help`, -x), so minting the ephemeral half
# directly is the same input sign_update would get.
openssl rand 32 | base64 > "$W/ephemeral-private.key"
third_party/sparkle/out/bin/sign_update \
--ed-key-file "$W/ephemeral-private.key" \
mac/build/Roost-Iced-ci-smoke.dmg > "$W/sign-iced.txt"
cat "$W/sign-iced.txt"
cp docs/appcast-iced.xml "$W/appcast-iced.xml"
ROOST_VERSION=0.0.0-ci-smoke \
ROOST_TAG=v0.0.0-ci-smoke \
ROOST_APPCAST="$W/appcast-iced.xml" \
ROOST_DMG_NAME=Roost-Iced-ci-smoke.dmg \
ROOST_SIGN_FILE="$W/sign-iced.txt" \
python3 mac/scripts/update-appcast.py
xmllint --noout "$W/appcast-iced.xml"
grep -q 'Roost-Iced-ci-smoke\.dmg' "$W/appcast-iced.xml" \
|| { echo "FAIL: the appcast entry does not carry the iced enclosure name"; exit 1; }
echo "OK: sign_update -> update-appcast.py chain produced a valid iced feed entry"
- 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
# Real pointer-DRAG + system-clipboard proof for the Iced UI under a headless
# WAYLAND compositor — the one thing the IPC-driven Wayland lane inside
# `iced-build-e2e` cannot do. Runs roost-iced fullscreen under `cage` (a kiosk
# wlroots compositor, so window coords == output coords) and drives the stdlib
# /dev/uinput key + pointer injectors (tools/input/linux/inject_key.py,
# inject_pointer.py) through tools/input/linux/iced_wayland_clipboard_check.py,
# whose checks include `_wayland_tab_reorder` — a real compositor-seat tab drag.
#
# NON-BLOCKING (continue-on-error + absent from ci-success). Its retired gtk
# predecessor (e2e-gtk-wayland-drag) failed 3 of 21 sampled `main` runs with
# "window_metrics returned no window size" — harness-shaped (the injector never
# got a window geometry back), not product-shaped. Gather signal on this lane
# before promoting it to required; see #328.
#
# ROOST_REQUIRE_REAL_INPUT=1 is LOAD-BEARING: without it the script exits 0
# with a SKIP line whenever cage, /dev/uinput, or the binary is missing, and
# the lane would be green-by-skip forever. Mirrors `make test-iced-wayland-input`.
e2e-iced-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 Iced build deps + cage + seatd
# No GTK dev packages — roost-iced is GTK-free, and iced-build-e2e's
# boundary grep enforces that independently. pkg-config + libclang-dev
# build roost-vt's bindgen; the xkbcommon / wayland-client libs are what
# winit dlopens; cage + seatd provide the headless compositor and the
# seat the uinput injectors need.
run: |
sudo apt-get update
sudo apt-get install -y \
pkg-config libclang-dev libxkbcommon-x11-0 libwayland-client0 \
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 + 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
# The check drives the UI over IPC from Python, but roostctl is the
# documented companion binary for reproducing a failure by hand and a
# cold `-p roost-iced` build does not produce it.
run: cargo build -p roost-iced -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 here — ROOST_REQUIRE_REAL_INPUT=1 below turns the
# check's SKIP into a loud FAIL if it did not 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: Iced Wayland clipboard + pointer-drag guard (cage + uinput)
continue-on-error: true
timeout-minutes: 15
env:
ROOST_TEST_MODE: "1"
ROOST_TEST_TIMEOUT_SCALE: "3"
# Load-bearing — see the job comment. Without it a missing cage or
# /dev/uinput is a SKIP with exit 0 and this lane never tests anything.
ROOST_REQUIRE_REAL_INPUT: "1"
ICED_BACKEND: tiny-skia
WLR_BACKENDS: "headless,libinput"
WLR_RENDERER: "pixman"
# The script deletes its temp dir in `finally` and dumps the cage + app
# logs to stderr only on failure, so this step's own combined output is
# the ONLY durable record — tee it to a file and upload it below.
# `shell: bash` already implies `-eo pipefail`; the explicit `set` keeps
# the script's exit status (not tee's) as the step result even if that
# shell line is ever dropped, so continue-on-error still records a real
# `outcome: failure`.
shell: bash
run: |
set -o pipefail
uv run --group test python tools/input/linux/iced_wayland_clipboard_check.py 2>&1 \
| tee e2e-iced-wayland-drag.log
- name: Upload drag-guard diagnostics
if: always()
uses: actions/upload-artifact@v7
with:
name: e2e-iced-wayland-drag-diagnostics
path: e2e-iced-wayland-drag.log
if-no-files-found: ignore
retention-days: 14
# Functional E2E against the real Swift app, driven through the IPC op set
# by the pytest harness (tools/roosttest) — the Mac twin of the Linux
# functional lanes inside iced-build-e2e. 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 Iced E2E steps 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, iced-build-e2e, iced-release, 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.
# 12 -> 9 in plan 031: the gtk UI and its three CI jobs (gtk-build,
# e2e-gtk, e2e-gtk-wayland) were deleted with crates/roost-linux.
# e2e-iced-wayland-drag is deliberately NOT in `needs` — it is a
# non-blocking signal lane (see its job comment).
required_jobs=9
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"