Merge pull request #358 from charliek/feature/host-sessions-roadmap #1119
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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" |