Merge pull request #354 from charliek/fix/ime-clear-on-route-wayland-… #1103
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/gtk jobs so they run only when | |
| # code that impacts them changes; `ci-success` is the single aggregated required | |
| # check (stable regardless of which jobs run). | |
| on: | |
| push: | |
| branches: [main, poc/iced] | |
| pull_request: | |
| branches: [main, poc/iced] | |
| permissions: | |
| contents: read | |
| jobs: | |
| changes: | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 10 | |
| outputs: | |
| rust: ${{ steps.filter.outputs.rust }} | |
| mac: ${{ steps.filter.outputs.mac }} | |
| linux: ${{ steps.filter.outputs.linux }} | |
| fixtures: ${{ steps.filter.outputs.fixtures }} | |
| tests: ${{ steps.filter.outputs.tests }} | |
| ci: ${{ steps.filter.outputs.ci }} | |
| deb: ${{ steps.filter.outputs.deb }} | |
| macbundle: ${{ steps.filter.outputs.macbundle }} | |
| steps: | |
| - uses: actions/checkout@v6 | |
| with: | |
| persist-credentials: false | |
| - uses: dorny/paths-filter@v4 | |
| id: filter | |
| with: | |
| filters: | | |
| rustcore: &rustcore | |
| - 'Cargo.toml' | |
| - 'Cargo.lock' | |
| - 'rust-toolchain.toml' | |
| # The file is a dotfile. The old 'mise.toml' pattern could | |
| # never match, so a toolchain bump triggered no Rust job. | |
| - '.mise.toml' | |
| - 'clippy.toml' | |
| - 'crates/**' | |
| - 'third_party/ghostty/**' | |
| - 'third_party/swash/**' | |
| rust: | |
| - *rustcore | |
| mac: | |
| - *rustcore | |
| - 'mac/**' | |
| linux: | |
| - *rustcore | |
| - 'linux/**' | |
| - 'packaging/**' | |
| # repo-root shared fixture corpora (parity gates for both UIs), | |
| # consumed only by the unit-test jobs (rust-build, gtk-build, | |
| # swift-mac) — distinct from the `tests` output below, which | |
| # means tools/roosttest/** | |
| fixtures: | |
| - 'tests/**' | |
| tests: | |
| - 'tools/roosttest/**' | |
| - 'tools/roosttest_unit/**' | |
| - 'tools/input/linux/**' | |
| - 'tools/wayland/**' | |
| - 'pyproject.toml' | |
| - 'uv.lock' | |
| # The e2e jobs invoke Makefile targets, so the test-module | |
| # lists (ICED_E2E_TESTS, ICED_RELEASE_E2E_TESTS) live there. | |
| # Without this, editing which tests a lane runs doesn't | |
| # retrigger the lane that runs them. | |
| - 'Makefile' | |
| ci: | |
| - '.github/workflows/ci.yml' | |
| # Deliberately NOT `linux`: that filter folds in the *rustcore | |
| # anchor, so it's true on essentially every Rust PR — gating the | |
| # deb work on it would pay an nfpm download, an nfpm package, and | |
| # a Docker Hub pull on each one. This list is only the paths that | |
| # can actually change what the .deb contains or how it's checked. | |
| # `release.yml` is here because it appears in no other filter at | |
| # all: a PR that edits only the release workflow would otherwise | |
| # trigger nothing capable of validating it. `Cargo.lock` is here | |
| # because a new dependency can pull in a new shared library and | |
| # change the package's runtime closure. | |
| # Manifests, not `crates/**`: enabling a feature on an | |
| # already-locked dependency changes what the binary dlopens | |
| # without touching Cargo.lock, and the closure check is the only | |
| # thing that would notice. Source edits still don't trigger it — | |
| # that's the cost line this filter exists to hold. | |
| deb: | |
| - 'linux/**' | |
| - 'packaging/**' | |
| - 'Cargo.toml' | |
| - 'Cargo.lock' | |
| - 'crates/*/Cargo.toml' | |
| - '.github/workflows/ci.yml' | |
| - '.github/workflows/release.yml' | |
| # Narrow on purpose: gates the `iced-build-e2e` macOS cells' | |
| # bundle-assembly + bundle-smoke steps. The broad `mac` output | |
| # folds in *rustcore, so using it here would make every | |
| # Swift-only PR pay the 2x2 iced matrix. `Makefile` is | |
| # deliberately not repeated — it's already in `tests`, which | |
| # is already OR'd into iced-build-e2e's `if`. | |
| macbundle: | |
| - 'mac/scripts/bundle-lib.sh' | |
| - 'mac/scripts/bundle-iced.sh' | |
| - 'mac/Resources/Info-iced.plist.template' | |
| # Direct bundle-iced.sh inputs shared with the Swift bundle — | |
| # without them an icon- or helper-entitlements-only PR would | |
| # skip the job that assembles Roost-Iced.app. | |
| - 'mac/Resources/roostctl.entitlements' | |
| - 'mac/Resources/AppIcon.icns' | |
| - 'mac/AppIcon.icon/**' | |
| - 'mac/Resources/Roost-Iced.entitlements' | |
| # Sparkle pin (version+SHA in fetch.sh) — a bump must re-run | |
| # the bundle lanes that embed + sign the framework. | |
| - 'third_party/sparkle/**' | |
| rust-lint: | |
| needs: changes | |
| if: needs.changes.outputs.rust == 'true' || needs.changes.outputs.ci == 'true' | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 20 | |
| steps: | |
| - uses: actions/checkout@v6 | |
| with: | |
| persist-credentials: false | |
| - name: Install Rust toolchain (from rust-toolchain.toml) | |
| uses: actions-rust-lang/setup-rust-toolchain@v1 | |
| - name: Cache cargo registry + target | |
| uses: actions/cache@v5 | |
| with: | |
| path: | | |
| ~/.cargo/registry | |
| ~/.cargo/git | |
| target | |
| key: cargo-lint-${{ runner.os }}-${{ hashFiles('**/Cargo.toml', 'rust-toolchain.toml') }} | |
| restore-keys: cargo-lint-${{ runner.os }}- | |
| - name: cargo fmt --check | |
| run: cargo fmt --all -- --check | |
| - name: cargo clippy | |
| # UI crates have native dependencies and dedicated strict lint jobs. | |
| # Keep this runner the toolkit-neutral core lane. | |
| run: cargo clippy --workspace --exclude roost-linux --exclude roost-iced --all-targets -- -D warnings | |
| harness-unit: | |
| needs: changes | |
| if: needs.changes.outputs.tests == 'true' || needs.changes.outputs.ci == 'true' | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 10 | |
| steps: | |
| - uses: actions/checkout@v6 | |
| with: | |
| persist-credentials: false | |
| - name: Functional harness target contract | |
| run: python3 -m unittest discover -s tools/roosttest_unit -v | |
| themes-parity: | |
| needs: changes | |
| if: needs.changes.outputs.rust == 'true' || needs.changes.outputs.mac == 'true' || needs.changes.outputs.ci == 'true' | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 10 | |
| steps: | |
| - uses: actions/checkout@v6 | |
| with: | |
| persist-credentials: false | |
| # The bundled themes live in two trees (Rust crate + Mac SwiftPM | |
| # bundle) because SwiftPM `.copy` can't reach outside mac/. Guard | |
| # the copies against drift — adding a theme to one UI only is a | |
| # classic miss. | |
| - name: Bundled themes byte-identical across UIs | |
| run: diff -r crates/roost-ui-model/src/resources/themes mac/Sources/Roost/Resources/themes | |
| rust-build: | |
| needs: changes | |
| if: needs.changes.outputs.rust == 'true' || needs.changes.outputs.fixtures == 'true' || needs.changes.outputs.ci == 'true' | |
| strategy: | |
| fail-fast: false | |
| matrix: | |
| os: [ubuntu-latest, macos-latest] | |
| runs-on: ${{ matrix.os }} | |
| timeout-minutes: 45 | |
| steps: | |
| - uses: actions/checkout@v6 | |
| with: | |
| persist-credentials: false | |
| - name: Install libclang (Linux) | |
| if: runner.os == 'Linux' | |
| # libclang-dev is needed by bindgen when roost-vt is built with | |
| # `--features ffi`. macOS gets it via Xcode's command-line tools. | |
| run: | | |
| sudo apt-get update | |
| sudo apt-get install -y libclang-dev | |
| - name: Install Rust toolchain (from rust-toolchain.toml) | |
| uses: actions-rust-lang/setup-rust-toolchain@v1 | |
| - uses: jdx/mise-action@v4 | |
| - name: Cache vendored libghostty-vt | |
| id: cache-ghostty | |
| uses: actions/cache@v5 | |
| with: | |
| path: | | |
| third_party/ghostty/out | |
| third_party/ghostty/src | |
| key: vendored-ghostty-${{ runner.os }}-${{ hashFiles('third_party/ghostty/build.sh') }} | |
| - name: Build libghostty-vt | |
| if: steps.cache-ghostty.outputs.cache-hit != 'true' | |
| run: ./third_party/ghostty/build.sh | |
| - name: Cache cargo registry + target | |
| uses: actions/cache@v5 | |
| with: | |
| path: | | |
| ~/.cargo/registry | |
| ~/.cargo/git | |
| target | |
| key: cargo-${{ runner.os }}-${{ hashFiles('**/Cargo.toml', 'rust-toolchain.toml') }} | |
| restore-keys: cargo-${{ runner.os }}- | |
| - name: cargo build (workspace, default features) | |
| # Default features: roost-vt's `ffi` feature is OFF, so this build does | |
| # not consume libghostty-vt. `--exclude roost-linux` (GTK toolchain | |
| # lives in gtk-build). | |
| run: cargo build --workspace --exclude roost-linux --all-targets | |
| - name: cargo build (roost-vt with ffi) | |
| run: cargo build -p roost-vt --features ffi | |
| # The encoder regression tests (key + mouse) live behind the `ffi` | |
| # feature because they exercise libghostty-vt directly; the default | |
| # `cargo test` below can't see them. The archive is built above, so | |
| # run them here as a real gate. | |
| - name: cargo test (roost-vt with ffi) | |
| run: cargo test -p roost-vt --features ffi | |
| - name: cargo test | |
| run: cargo test --workspace --exclude roost-linux | |
| # The experimental Swift-facing facade is feature-gated with no | |
| # production consumer; test it explicitly so the gated code stays green. | |
| - name: cargo test (roost-engine with facade) | |
| run: cargo test -p roost-engine --features facade | |
| swift-mac: | |
| needs: changes | |
| if: needs.changes.outputs.mac == 'true' || needs.changes.outputs.fixtures == 'true' || needs.changes.outputs.ci == 'true' | |
| runs-on: macos-latest | |
| timeout-minutes: 45 | |
| steps: | |
| - uses: actions/checkout@v6 | |
| with: | |
| persist-credentials: false | |
| - name: Show Swift toolchain | |
| run: swift --version | |
| - uses: jdx/mise-action@v4 | |
| - name: Cache vendored libghostty-vt | |
| id: cache-ghostty | |
| uses: actions/cache@v5 | |
| with: | |
| path: | | |
| third_party/ghostty/out | |
| third_party/ghostty/src | |
| key: vendored-ghostty-${{ runner.os }}-${{ hashFiles('third_party/ghostty/build.sh') }} | |
| - name: Build libghostty-vt | |
| if: steps.cache-ghostty.outputs.cache-hit != 'true' | |
| run: ./third_party/ghostty/build.sh | |
| - name: Cache SwiftPM artifacts | |
| uses: actions/cache@v5 | |
| with: | |
| path: | | |
| mac/.build | |
| ~/Library/Caches/org.swift.swiftpm | |
| key: swiftpm-${{ runner.os }}-${{ hashFiles('mac/Package.swift', 'mac/Package.resolved') }} | |
| restore-keys: swiftpm-${{ runner.os }}- | |
| - name: Cache cargo registry + target (for embedded roost-cli build) | |
| uses: actions/cache@v5 | |
| with: | |
| path: | | |
| ~/.cargo/registry | |
| ~/.cargo/git | |
| target | |
| key: cargo-${{ runner.os }}-swiftmac-${{ hashFiles('**/Cargo.toml', 'rust-toolchain.toml') }} | |
| restore-keys: cargo-${{ runner.os }}-swiftmac- | |
| - name: swift build | |
| working-directory: mac | |
| run: swift build -v | |
| - name: Assert no dynamic ghostty-vt link (regression guard) | |
| working-directory: mac | |
| run: | | |
| # shellcheck disable=SC2044 # binary names are fixed ('Roost'/'RoostPackageTests'), no spaces | |
| for bin in $(find .build -type f -perm -u+x -name 'Roost' -o -name 'RoostPackageTests'); do | |
| if otool -L "$bin" 2>/dev/null | grep -q '@rpath/libghostty-vt'; then | |
| echo "FAIL: $bin is dynamically linked to libghostty-vt; reintroduces the dyld @rpath bug fixed in Package.swift's positional-archive linkerSettings." | |
| otool -L "$bin" | |
| exit 1 | |
| fi | |
| done | |
| - name: swift test | |
| working-directory: mac | |
| run: swift test | |
| - name: bundle .app (release) + embed roostctl | |
| run: ./mac/scripts/bundle.sh release | |
| - name: Verify embedded roostctl | |
| run: | | |
| set -euo pipefail | |
| BIN="mac/build/Roost.app/Contents/Resources/bin/roostctl" | |
| test -x "$BIN" || { echo "FAIL: $BIN not embedded"; exit 1; } | |
| "$BIN" --version | |
| codesign -dvv "$BIN" 2>&1 | grep -E 'adhoc|Format=' || true | |
| - name: Verify themes resource bundle shipped (clean-install guard) | |
| run: | | |
| set -euo pipefail | |
| # v0.0.2 crashed on every clean install because the themes bundle | |
| # wasn't where the running app looks. Assert it ships under | |
| # Contents/Resources, which is where `Bundle.roostResources` | |
| # (Theme.swift) resolves it. A deterministic file check — stronger | |
| # and faster than inferring from a launch. GUI-launch coverage is | |
| # the required e2e-mac job; the clean-install live launch is the | |
| # local `make smoke-mac-launch` / pre-release step. | |
| THEMES="mac/build/Roost.app/Contents/Resources/Roost_Roost.bundle/themes" | |
| test -d "$THEMES" || { echo "FAIL: $THEMES missing — themes can't load on a clean install (the v0.0.2 crash)"; exit 1; } | |
| count=$(find "$THEMES" -type f | wc -l | tr -d ' ') | |
| [ "$count" -gt 0 ] || { echo "FAIL: $THEMES shipped empty"; exit 1; } | |
| echo "OK: $count theme files shipped under Contents/Resources" | |
| - name: Verify TCC capture entitlements + usage strings (signed bundle) | |
| run: | | |
| set -euo pipefail | |
| # The signed bundle is the source of truth: the EntitlementsTests | |
| # swift test guards the source templates, but only this proves | |
| # bundle.sh actually signed the keys in. Without the capture | |
| # entitlements, programs hosted in a Roost tab lose mic/camera/ | |
| # apple-events access *silently* under the hardened runtime. | |
| APP="mac/build/Roost.app" | |
| ROOSTCTL="$APP/Contents/Resources/bin/roostctl" | |
| INFO="$APP/Contents/Info.plist" | |
| work=$(mktemp -d) | |
| trap 'rm -rf "$work"' EXIT | |
| # Extract each binary's entitlements as a real plist. codesign's text | |
| # dump format is unstable across macOS versions (XML on some, a | |
| # [Key] listing on others), so we force --xml and parse with | |
| # PlistBuddy for exact, substring-proof key lookup. | |
| app_ent="$work/app.plist"; ctl_ent="$work/roostctl.plist" | |
| # No `|| true`: a failed extraction (unsigned/unreadable binary) must | |
| # fail the job — otherwise the negative-only roostctl check below | |
| # would pass vacuously on an empty plist. | |
| codesign -d --entitlements - --xml "$APP" 2>/dev/null > "$app_ent" | |
| codesign -d --entitlements - --xml "$ROOSTCTL" 2>/dev/null > "$ctl_ent" | |
| has_key() { /usr/libexec/PlistBuddy -c "Print :$2" "$1" >/dev/null 2>&1; } | |
| plist_value() { /usr/libexec/PlistBuddy -c "Print :$2" "$1" 2>/dev/null || true; } | |
| # Each takes: $1=label $2=plist; remaining args=exact key names. | |
| require_true() { # key must exist AND be boolean true (not just present) | |
| local label="$1" plist="$2"; shift 2 | |
| for key in "$@"; do | |
| [ "$(plist_value "$plist" "$key")" = "true" ] \ | |
| || { echo "FAIL: $label must carry $key=true"; exit 1; } | |
| done | |
| } | |
| require_nonempty() { # key must exist AND be a non-whitespace string | |
| local label="$1" plist="$2"; shift 2 | |
| for key in "$@"; do | |
| [ -n "$(plist_value "$plist" "$key" | tr -d '[:space:]')" ] \ | |
| || { echo "FAIL: $label missing non-empty $key"; exit 1; } | |
| done | |
| } | |
| forbid_keys() { # key must be absent | |
| local label="$1" plist="$2"; shift 2 | |
| for key in "$@"; do | |
| if has_key "$plist" "$key"; then | |
| echo "FAIL: $label unexpectedly carries $key"; exit 1 | |
| fi | |
| done | |
| } | |
| CAPTURE=( | |
| com.apple.security.device.audio-input | |
| com.apple.security.device.camera | |
| com.apple.security.automation.apple-events | |
| ) | |
| # Broad / unneeded keys kept out of the app (blast-radius control). | |
| BROAD=( | |
| com.apple.security.personal-information.addressbook | |
| com.apple.security.personal-information.calendars | |
| com.apple.security.personal-information.location | |
| com.apple.security.personal-information.photos-library | |
| com.apple.security.cs.allow-jit | |
| com.apple.security.cs.allow-unsigned-executable-memory | |
| com.apple.security.app-sandbox | |
| com.apple.security.network.client | |
| com.apple.security.network.server | |
| ) | |
| require_true "app" "$app_ent" "${CAPTURE[@]}" # app MUST carry the capture set (=true) | |
| forbid_keys "app" "$app_ent" "${BROAD[@]}" # app MUST NOT carry broad keys | |
| forbid_keys "roostctl" "$ctl_ent" "${CAPTURE[@]}" # helper MUST NOT inherit capture | |
| # The signed bundle's Info.plist MUST carry the paired purpose strings. | |
| require_nonempty "Info.plist" "$INFO" \ | |
| NSMicrophoneUsageDescription \ | |
| NSCameraUsageDescription \ | |
| NSAppleEventsUsageDescription | |
| echo "OK: capture entitlements (=true) + usage strings present; helper + broad keys excluded" | |
| gtk-build: | |
| needs: changes | |
| if: needs.changes.outputs.linux == 'true' || needs.changes.outputs.fixtures == 'true' || needs.changes.outputs.ci == 'true' | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 45 | |
| steps: | |
| - uses: actions/checkout@v6 | |
| with: | |
| persist-credentials: false | |
| - name: Install GTK4 + libadwaita | |
| run: | | |
| sudo apt-get update | |
| sudo apt-get install -y \ | |
| libgtk-4-dev \ | |
| libadwaita-1-dev \ | |
| pkg-config \ | |
| libclang-dev | |
| - name: Install Rust toolchain (from rust-toolchain.toml) | |
| uses: actions-rust-lang/setup-rust-toolchain@v1 | |
| - uses: jdx/mise-action@v4 | |
| - name: Cache vendored libghostty-vt | |
| id: cache-ghostty | |
| uses: actions/cache@v5 | |
| with: | |
| path: | | |
| third_party/ghostty/out | |
| third_party/ghostty/src | |
| key: vendored-ghostty-${{ runner.os }}-${{ hashFiles('third_party/ghostty/build.sh') }} | |
| - name: Build libghostty-vt | |
| if: steps.cache-ghostty.outputs.cache-hit != 'true' | |
| run: ./third_party/ghostty/build.sh | |
| - name: Cache cargo registry + target | |
| uses: actions/cache@v5 | |
| with: | |
| path: | | |
| ~/.cargo/registry | |
| ~/.cargo/git | |
| target | |
| key: cargo-${{ runner.os }}-${{ hashFiles('**/Cargo.toml', 'rust-toolchain.toml') }} | |
| restore-keys: cargo-${{ runner.os }}- | |
| - name: cargo build -p roost-linux | |
| run: cargo build -p roost-linux | |
| # The rust-test job runs `--workspace --exclude roost-linux` (no GTK | |
| # toolchain there), so this is the ONLY place roost-linux's own tests | |
| # can run. Without it the crate's unit tests — including the key | |
| # encoder's Kitty-mode guards — compile but never execute anywhere. | |
| # Headless-safe: these tests never construct a widget or call gtk_init. | |
| - name: cargo test -p roost-linux | |
| run: cargo test -p roost-linux | |
| # roost-linux is clippy-clean (issue #283 closed out the last | |
| # type_complexity holdout), so this runs the same full gate as | |
| # rust-lint / Iced's "Test and lint Iced" step below — no more | |
| # `-A warnings` narrow denylist. disallowed_types (GtkDnD #236) and | |
| # disallowed_methods (raw grab_focus #234) are warn-by-default clippy | |
| # lints, so `-D warnings` still catches regressions on both; clippy.toml | |
| # stays the source of truth for which types/methods are disallowed. | |
| # rust-lint excludes roost-linux because it needs the GTK toolchain | |
| # that only this job has. | |
| - name: cargo clippy -p roost-linux | |
| run: cargo clippy -p roost-linux --all-targets -- -D warnings | |
| # Iced walking skeleton: exact released Iced + libghostty-vt on both host | |
| # platforms, with the common IPC harness driving a real PTY-backed window. | |
| # The Linux leg selects X11 under Xvfb; its wgpu renderer uses Mesa's | |
| # software Vulkan implementation when the runner exposes no physical GPU. | |
| iced-build-e2e: | |
| needs: changes | |
| if: needs.changes.outputs.rust == 'true' || needs.changes.outputs.tests == 'true' || needs.changes.outputs.ci == 'true' || needs.changes.outputs.macbundle == 'true' | |
| strategy: | |
| fail-fast: false | |
| matrix: | |
| os: [ubuntu-latest, macos-latest] | |
| renderer: [wgpu, tiny-skia] | |
| runs-on: ${{ matrix.os }} | |
| # Was 30. The macOS cells now run a second e2e pass (bundle assembly + | |
| # assert + a 2-module smoke) after their existing full functional-E2E + | |
| # exit-on-empty passes; the Linux cells are unchanged (four lanes: | |
| # X11 functional, X11 exit, X11 real-input clipboard, Wayland | |
| # functional + exit). Bumped rather than risk the macOS cells timing | |
| # out under load — the plan explicitly prefers a timeout bump over | |
| # silently dropping the walking-skeleton module from the smoke subset. | |
| timeout-minutes: 40 | |
| steps: | |
| - uses: actions/checkout@v6 | |
| with: | |
| persist-credentials: false | |
| - name: Install Iced native dependencies (Linux) | |
| if: runner.os == 'Linux' | |
| run: | | |
| sudo apt-get update | |
| sudo apt-get install -y \ | |
| fonts-noto-cjk libclang-dev libxkbcommon-x11-0 libwayland-client0 \ | |
| mesa-vulkan-drivers weston xvfb xdotool zsh | |
| - name: Install Rust toolchain (from rust-toolchain.toml) | |
| uses: actions-rust-lang/setup-rust-toolchain@v1 | |
| - uses: jdx/mise-action@v4 | |
| - name: Install uv | |
| uses: astral-sh/setup-uv@v7 | |
| - name: Cache vendored libghostty-vt | |
| id: cache-ghostty | |
| uses: actions/cache@v5 | |
| with: | |
| path: | | |
| third_party/ghostty/out | |
| third_party/ghostty/src | |
| key: vendored-ghostty-${{ runner.os }}-${{ hashFiles('third_party/ghostty/build.sh') }} | |
| - name: Build libghostty-vt | |
| if: steps.cache-ghostty.outputs.cache-hit != 'true' | |
| run: ./third_party/ghostty/build.sh | |
| - name: Cache cargo registry + Iced target | |
| uses: actions/cache@v5 | |
| with: | |
| path: | | |
| ~/.cargo/registry | |
| ~/.cargo/git | |
| target | |
| key: cargo-iced-${{ runner.os }}-${{ hashFiles('**/Cargo.toml', 'rust-toolchain.toml') }} | |
| restore-keys: cargo-iced-${{ runner.os }}- | |
| - name: Build Iced + roostctl | |
| run: cargo build -p roost-iced -p roost-cli | |
| - name: Test and lint Iced | |
| run: | | |
| cargo test -p roost-iced | |
| cargo clippy -p roost-iced --all-targets -- -D warnings | |
| # `linux-package` is off in every dev and CI build, so without this | |
| # pair the packaging configuration would first compile during a | |
| # release build. The clippy pass also carries the real protection | |
| # against a `cfg!(feature = ...)` typo: an unknown feature name is | |
| # an `unexpected_cfgs` warning, which -D warnings turns into an error. | |
| cargo test -p roost-iced --features linux-package | |
| cargo clippy -p roost-iced --features linux-package --all-targets -- -D warnings | |
| - name: Verify toolkit dependency boundaries | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| if cargo tree -p roost-engine | grep -E '(^| )(gtk4|libadwaita|iced) v'; then | |
| echo "FAIL: roost-engine depends on a UI toolkit" | |
| exit 1 | |
| fi | |
| if cargo tree -p roost-ui-model | grep -E '(^| )(gtk4|libadwaita|iced|pango|cairo-rs|wgpu) v'; then | |
| echo "FAIL: roost-ui-model depends on a UI toolkit or renderer" | |
| exit 1 | |
| fi | |
| if cargo tree -p roost-iced | grep -E '(^| )(gtk4|libadwaita|pango|cairo-rs|roost-linux) v'; then | |
| echo "FAIL: roost-iced depends on GTK or roost-linux" | |
| exit 1 | |
| fi | |
| - name: Run Iced functional E2E (Linux X11) | |
| if: runner.os == 'Linux' | |
| env: | |
| ICED_BACKEND: ${{ matrix.renderer }} | |
| RUST_LOG: warn | |
| ROOST_TEST_MODE: "1" | |
| ROOST_TEST_TIMEOUT_SCALE: "3" | |
| ROOST_E2E_ARTIFACT_DIR: ${{ runner.temp }}/roost-iced-e2e-x11-artifacts | |
| ROOST_E2E_LOG_DIR: ${{ runner.temp }}/roost-iced-e2e-x11-logs | |
| run: > | |
| xvfb-run -a --server-args="-screen 0 1920x1080x24" | |
| uv run --group test pytest | |
| tools/roosttest/test_smoke.py | |
| tools/roosttest/test_iced_walking_skeleton.py | |
| tools/roosttest/test_notifications.py | |
| tools/roosttest/test_provider.py | |
| tools/roosttest/test_sidebar_pixels.py | |
| tools/roosttest/test_tab_strip_pixels.py | |
| tools/roosttest/test_focus.py | |
| tools/roosttest/test_palette.py | |
| tools/roosttest/test_z_typography.py | |
| tools/roosttest/test_project_lifecycle.py | |
| tools/roosttest/test_sidebar_resize.py | |
| tools/roosttest/test_osc_pipeline.py | |
| tools/roosttest/test_sprite_pixels.py | |
| tools/roosttest/test_ime.py | |
| tools/roosttest/test_selection.py | |
| tools/roosttest/test_mouse_tracking.py | |
| tools/roosttest/test_dock_badge.py | |
| tools/roosttest/test_menu_bar.py | |
| tools/roosttest/test_sparkle.py | |
| tools/roosttest/test_view_perf.py | |
| tools/roosttest/test_osc52.py | |
| --roost-target iced --roost-fresh -v | |
| # Its own invocation, after the shared-session lane above: this | |
| # module deletes the last project, which ends the app (plan 026 D8), | |
| # so it must own the instance it drives. | |
| - name: Run Iced exit-on-empty E2E (Linux X11) | |
| if: runner.os == 'Linux' | |
| env: | |
| ICED_BACKEND: ${{ matrix.renderer }} | |
| RUST_LOG: warn | |
| ROOST_TEST_MODE: "1" | |
| ROOST_TEST_TIMEOUT_SCALE: "3" | |
| ROOST_E2E_ARTIFACT_DIR: ${{ runner.temp }}/roost-iced-e2e-x11-exit-artifacts | |
| ROOST_E2E_LOG_DIR: ${{ runner.temp }}/roost-iced-e2e-x11-exit-logs | |
| run: > | |
| xvfb-run -a --server-args="-screen 0 1920x1080x24" | |
| uv run --group test pytest | |
| tools/roosttest/test_exit_on_empty.py | |
| --roost-target iced --roost-fresh -v | |
| # Also app-ending (the menu's Quit item), so it needs the same "own | |
| # invocation" isolation as the step above — but not the SAME | |
| # invocation: the session-scoped harness fixture launches one UI per | |
| # pytest run, so bundling two exit-ending modules together would | |
| # strand the second. `app.menu_activate` is macOS-iced-only (plan | |
| # 028 § 3.12), so this self-skips on every non-macOS lane; kept in | |
| # all three lanes anyway for structural symmetry with the | |
| # exit-on-empty step above. | |
| - name: Run Iced menu-Quit E2E (Linux X11) | |
| if: runner.os == 'Linux' | |
| env: | |
| ICED_BACKEND: ${{ matrix.renderer }} | |
| RUST_LOG: warn | |
| ROOST_TEST_MODE: "1" | |
| ROOST_TEST_TIMEOUT_SCALE: "3" | |
| ROOST_E2E_ARTIFACT_DIR: ${{ runner.temp }}/roost-iced-e2e-x11-menu-quit-artifacts | |
| ROOST_E2E_LOG_DIR: ${{ runner.temp }}/roost-iced-e2e-x11-menu-quit-logs | |
| run: > | |
| xvfb-run -a --server-args="-screen 0 1920x1080x24" | |
| uv run --group test pytest | |
| tools/roosttest/test_menu_quit.py | |
| --roost-target iced --roost-fresh -v | |
| - name: Run Iced real-input clipboard (Linux X11) | |
| if: runner.os == 'Linux' | |
| env: | |
| ICED_BACKEND: ${{ matrix.renderer }} | |
| RUST_LOG: warn | |
| ROOST_ICED_BIN: ${{ github.workspace }}/target/debug/roost-iced | |
| ROOST_REQUIRE_REAL_INPUT: "1" | |
| ROOST_TEST_TIMEOUT_SCALE: "3" | |
| ROOST_E2E_ARTIFACT_DIR: ${{ runner.temp }}/roost-iced-e2e-real-input-artifacts | |
| ROOST_E2E_LOG_DIR: ${{ runner.temp }}/roost-iced-e2e-real-input-logs | |
| run: python3 tools/input/linux/iced_clipboard_check.py | |
| - name: Run Iced functional E2E (Linux Wayland) | |
| if: runner.os == 'Linux' | |
| # weston's headless backend has no input seat. Iced 0.14's | |
| # smithay-clipboard correctly refuses wl_data_device ownership without | |
| # a focused seat/serial, so native clipboard coverage runs in the X11 | |
| # lane above. This lane still requires the complete non-clipboard | |
| # Wayland renderer suite; the POC plan records the real-seat evidence | |
| # and remaining programmatic-write limitation. `test_selection.py` is | |
| # in this list because `selection.*` reads UI state over IPC and never | |
| # touches the pasteboard — only `test_osc52.py` is X11-only. | |
| env: | |
| ICED_BACKEND: ${{ matrix.renderer }} | |
| RUST_LOG: warn | |
| ROOST_TEST_MODE: "1" | |
| ROOST_TEST_TIMEOUT_SCALE: "3" | |
| ROOST_E2E_ARTIFACT_DIR: ${{ runner.temp }}/roost-iced-e2e-wayland-artifacts | |
| ROOST_E2E_LOG_DIR: ${{ runner.temp }}/roost-iced-e2e-wayland-logs | |
| run: > | |
| tools/wayland/weston-run.sh | |
| uv run --group test pytest | |
| tools/roosttest/test_smoke.py | |
| tools/roosttest/test_iced_walking_skeleton.py | |
| tools/roosttest/test_notifications.py | |
| tools/roosttest/test_provider.py | |
| tools/roosttest/test_sidebar_pixels.py | |
| tools/roosttest/test_tab_strip_pixels.py | |
| tools/roosttest/test_focus.py | |
| tools/roosttest/test_palette.py | |
| tools/roosttest/test_z_typography.py | |
| tools/roosttest/test_project_lifecycle.py | |
| tools/roosttest/test_sidebar_resize.py | |
| tools/roosttest/test_osc_pipeline.py | |
| tools/roosttest/test_sprite_pixels.py | |
| tools/roosttest/test_ime.py | |
| tools/roosttest/test_selection.py | |
| tools/roosttest/test_mouse_tracking.py | |
| tools/roosttest/test_dock_badge.py | |
| tools/roosttest/test_menu_bar.py | |
| tools/roosttest/test_sparkle.py | |
| tools/roosttest/test_view_perf.py | |
| --roost-target iced --roost-fresh -v | |
| - name: Run Iced exit-on-empty E2E (Linux Wayland) | |
| if: runner.os == 'Linux' | |
| env: | |
| ICED_BACKEND: ${{ matrix.renderer }} | |
| RUST_LOG: warn | |
| ROOST_TEST_MODE: "1" | |
| ROOST_TEST_TIMEOUT_SCALE: "3" | |
| ROOST_E2E_ARTIFACT_DIR: ${{ runner.temp }}/roost-iced-e2e-wayland-exit-artifacts | |
| ROOST_E2E_LOG_DIR: ${{ runner.temp }}/roost-iced-e2e-wayland-exit-logs | |
| run: > | |
| tools/wayland/weston-run.sh | |
| uv run --group test pytest | |
| tools/roosttest/test_exit_on_empty.py | |
| --roost-target iced --roost-fresh -v | |
| # See the Linux X11 lane's comment above the same-named step: kept | |
| # in every lane for structural symmetry, self-skips off macOS. | |
| - name: Run Iced menu-Quit E2E (Linux Wayland) | |
| if: runner.os == 'Linux' | |
| env: | |
| ICED_BACKEND: ${{ matrix.renderer }} | |
| RUST_LOG: warn | |
| ROOST_TEST_MODE: "1" | |
| ROOST_TEST_TIMEOUT_SCALE: "3" | |
| ROOST_E2E_ARTIFACT_DIR: ${{ runner.temp }}/roost-iced-e2e-wayland-menu-quit-artifacts | |
| ROOST_E2E_LOG_DIR: ${{ runner.temp }}/roost-iced-e2e-wayland-menu-quit-logs | |
| run: > | |
| tools/wayland/weston-run.sh | |
| uv run --group test pytest | |
| tools/roosttest/test_menu_quit.py | |
| --roost-target iced --roost-fresh -v | |
| - name: Run Iced functional E2E (macOS) | |
| if: runner.os == 'macOS' | |
| env: | |
| ICED_BACKEND: ${{ matrix.renderer }} | |
| RUST_LOG: warn | |
| ROOST_TEST_MODE: "1" | |
| ROOST_TEST_TIMEOUT_SCALE: "3" | |
| ROOST_E2E_ARTIFACT_DIR: ${{ runner.temp }}/roost-iced-e2e-mac-artifacts | |
| ROOST_E2E_LOG_DIR: ${{ runner.temp }}/roost-iced-e2e-mac-logs | |
| run: > | |
| uv run --group test pytest | |
| tools/roosttest/test_smoke.py | |
| tools/roosttest/test_iced_walking_skeleton.py | |
| tools/roosttest/test_notifications.py | |
| tools/roosttest/test_provider.py | |
| tools/roosttest/test_sidebar_pixels.py | |
| tools/roosttest/test_tab_strip_pixels.py | |
| tools/roosttest/test_focus.py | |
| tools/roosttest/test_palette.py | |
| tools/roosttest/test_z_typography.py | |
| tools/roosttest/test_project_lifecycle.py | |
| tools/roosttest/test_sidebar_resize.py | |
| tools/roosttest/test_osc_pipeline.py | |
| tools/roosttest/test_sprite_pixels.py | |
| tools/roosttest/test_ime.py | |
| tools/roosttest/test_selection.py | |
| tools/roosttest/test_mouse_tracking.py | |
| tools/roosttest/test_dock_badge.py | |
| tools/roosttest/test_menu_bar.py | |
| tools/roosttest/test_sparkle.py | |
| tools/roosttest/test_view_perf.py | |
| tools/roosttest/test_osc52.py | |
| --roost-target iced --roost-fresh -v | |
| - name: Run Iced exit-on-empty E2E (macOS) | |
| if: runner.os == 'macOS' | |
| env: | |
| ICED_BACKEND: ${{ matrix.renderer }} | |
| RUST_LOG: warn | |
| ROOST_TEST_MODE: "1" | |
| ROOST_TEST_TIMEOUT_SCALE: "3" | |
| ROOST_E2E_ARTIFACT_DIR: ${{ runner.temp }}/roost-iced-e2e-mac-exit-artifacts | |
| ROOST_E2E_LOG_DIR: ${{ runner.temp }}/roost-iced-e2e-mac-exit-logs | |
| run: > | |
| uv run --group test pytest | |
| tools/roosttest/test_exit_on_empty.py | |
| --roost-target iced --roost-fresh -v | |
| # Also app-ending (the menu's Quit item) — see the Linux X11 lane's | |
| # comment above its same-named step. This is the lane where it | |
| # actually runs (macOS-iced-only); the bare `roost-iced` binary | |
| # installs its native menu bar the same as a bundled app (plan 028 | |
| # § 3.13 — "NSMenu installs fine on an unbundled process"). | |
| - name: Run Iced menu-Quit E2E (macOS) | |
| if: runner.os == 'macOS' | |
| env: | |
| ICED_BACKEND: ${{ matrix.renderer }} | |
| RUST_LOG: warn | |
| ROOST_TEST_MODE: "1" | |
| ROOST_TEST_TIMEOUT_SCALE: "3" | |
| ROOST_E2E_ARTIFACT_DIR: ${{ runner.temp }}/roost-iced-e2e-mac-menu-quit-artifacts | |
| ROOST_E2E_LOG_DIR: ${{ runner.temp }}/roost-iced-e2e-mac-menu-quit-logs | |
| run: > | |
| uv run --group test pytest | |
| tools/roosttest/test_menu_quit.py | |
| --roost-target iced --roost-fresh -v | |
| # M6 6a (plan 027 W5): the two steps above only ever exercise the bare | |
| # `roost-iced` cargo binary. This assembles + smoke-tests the actual | |
| # macOS deliverable (Roost-Iced.app) so a bundling regression (wrong | |
| # bundle id, missing entitlements, a broken Sparkle embed or signing | |
| # chain) is caught here rather than only by a human running | |
| # `make bundle-iced` locally. Debug profile only — never "upgrade" this to release; the | |
| # release-profile lane is `iced-release`, not this job. | |
| # The Sparkle stage is version+SHA pinned inside fetch.sh, so | |
| # hashFiles on the script is an exact cache key for out/ — a pin | |
| # bump changes the key, and a GitHub release-asset outage can't | |
| # flake the assemble step on a warm cache (plan 028 § 3.10). | |
| - name: Cache Sparkle stage | |
| if: runner.os == 'macOS' | |
| uses: actions/cache@v5 | |
| with: | |
| path: third_party/sparkle/out | |
| key: sparkle-stage-${{ hashFiles('third_party/sparkle/fetch.sh') }} | |
| - name: Assemble Roost-Iced.app | |
| if: runner.os == 'macOS' | |
| run: ./mac/scripts/bundle-iced.sh debug | |
| - name: Assert bundle contents | |
| if: runner.os == 'macOS' | |
| run: | | |
| set -euo pipefail | |
| APP="mac/build/Roost-Iced.app" | |
| BIN="$APP/Contents/MacOS/Roost-Iced" | |
| INFO="$APP/Contents/Info.plist" | |
| plist_value() { /usr/libexec/PlistBuddy -c "Print :$2" "$1" 2>/dev/null || true; } | |
| has_key() { /usr/libexec/PlistBuddy -c "Print :$2" "$1" >/dev/null 2>&1; } | |
| [ "$(plist_value "$INFO" CFBundleIdentifier)" = "ai.stridelabs.Roost.iced" ] \ | |
| || { echo "FAIL: CFBundleIdentifier != ai.stridelabs.Roost.iced"; exit 1; } | |
| echo "OK: CFBundleIdentifier is ai.stridelabs.Roost.iced" | |
| [ "$(plist_value "$INFO" CFBundleExecutable)" = "Roost-Iced" ] \ | |
| || { echo "FAIL: CFBundleExecutable != Roost-Iced"; exit 1; } | |
| echo "OK: CFBundleExecutable is Roost-Iced" | |
| version="$(plist_value "$INFO" CFBundleShortVersionString)" | |
| [ -n "$(echo "$version" | tr -d '[:space:]')" ] \ | |
| || { echo "FAIL: CFBundleShortVersionString is empty"; exit 1; } | |
| [ "$version" != "@VERSION@" ] \ | |
| || { echo "FAIL: CFBundleShortVersionString still has the unsubstituted @VERSION@ placeholder"; exit 1; } | |
| echo "OK: CFBundleShortVersionString is '$version'" | |
| # 6c posture (plan 028): Sparkle MECHANICS ship, feed | |
| # deliberately absent. The default assemble must never carry a | |
| # feed URL or public key — the two apps must not be able to | |
| # offer each other's updates, and feed enablement is the | |
| # explicit ROOST_ICED_SPARKLE_* env pair at bundle time. | |
| for key in SUFeedURL SUPublicEDKey; do | |
| if has_key "$INFO" "$key"; then | |
| echo "FAIL: Info.plist unexpectedly carries $key (default assemble must stay feedless)"; exit 1 | |
| fi | |
| done | |
| echo "OK: no SUFeedURL / SUPublicEDKey" | |
| # Present AND false — with the updater instantiated, an absent | |
| # key would trigger Sparkle's first-run auto-check prompt; | |
| # checks stay strictly user-invoked. | |
| [ "$(plist_value "$INFO" SUEnableAutomaticChecks)" = "false" ] \ | |
| || { echo "FAIL: SUEnableAutomaticChecks missing or not false"; exit 1; } | |
| echo "OK: SUEnableAutomaticChecks present and false" | |
| FRAMEWORK="$APP/Contents/Frameworks/Sparkle.framework" | |
| [ -d "$FRAMEWORK" ] \ | |
| || { echo "FAIL: $FRAMEWORK missing (6c embeds Sparkle)"; exit 1; } | |
| # The runtime dlopen resolves the stable top-level | |
| # Sparkle.framework/Sparkle symlink — a flattened copy (one | |
| # that lost the Versions/ symlink farm) would sign fine but | |
| # break the load path. | |
| { [ -L "$FRAMEWORK/Sparkle" ] && [ -f "$FRAMEWORK/Sparkle" ]; } \ | |
| || { echo "FAIL: $FRAMEWORK/Sparkle top-level symlink missing or dangling"; exit 1; } | |
| echo "OK: Sparkle.framework embedded with its symlink farm intact" | |
| codesign --verify --deep --strict "$APP" | |
| echo "OK: codesign --verify --deep --strict passed" | |
| # Strict-chain proof (plan 028 § 3.10): deep-verify alone | |
| # cannot prove the per-component inner→outer signing order — | |
| # verify each Sparkle component's own signature individually, | |
| # AND prove our re-sign actually ran: the vendor ships every | |
| # component already ad-hoc+runtime signed, so a skipped (or | |
| # --deep'd-then-skipped) chain would pass --verify on the | |
| # vendor signatures alone. Our --force re-sign necessarily | |
| # changes each component's CDHash away from the pristine | |
| # staged copy fetch.sh downloaded — compare against it. | |
| # Downloader.xpc is exempt from the CDHash comparison: our | |
| # ad-hoc re-sign with --preserve-metadata=entitlements | |
| # reproduces the vendor's CodeDirectory byte-for-byte | |
| # (verified locally — identical CDHash), because ad-hoc | |
| # signing is deterministic and every input it hashes is | |
| # preserved. Its chain proof is instead the | |
| # entitlements-present-and-clean check below, which a --deep | |
| # clobber (drops the preserved blob) or an entitlements | |
| # injection would each fail. The other components' re-sign | |
| # provably changes the CDHash (--force replaces the vendor's | |
| # designated requirements); if a Sparkle bump ever makes one | |
| # of them deterministic too, this fails LOUD, not silent. | |
| STAGED="third_party/sparkle/out/Sparkle.framework" | |
| cdhash() { codesign -dvvv "$1" 2>&1 | awk -F= '/^CDHash=/{print $2; exit}'; } | |
| codesign --verify --strict "$FRAMEWORK/Versions/B/XPCServices/Downloader.xpc" \ | |
| || { echo "FAIL: codesign --verify --strict failed for Downloader.xpc"; exit 1; } | |
| for rel in \ | |
| "Versions/B/XPCServices/Installer.xpc" \ | |
| "Versions/B/Autoupdate" \ | |
| "Versions/B/Updater.app"; do | |
| component="$FRAMEWORK/$rel" | |
| codesign --verify --strict "$component" \ | |
| || { echo "FAIL: codesign --verify --strict failed for $component"; exit 1; } | |
| embedded_hash=$(cdhash "$component") | |
| vendor_hash=$(cdhash "$STAGED/$rel") | |
| [ -n "$embedded_hash" ] && [ -n "$vendor_hash" ] \ | |
| || { echo "FAIL: could not read CDHash for $rel"; exit 1; } | |
| [ "$embedded_hash" != "$vendor_hash" ] \ | |
| || { echo "FAIL: $rel still carries the vendor CDHash — the strict re-sign chain did not run on it"; exit 1; } | |
| done | |
| embedded_hash=$(cdhash "$FRAMEWORK") | |
| vendor_hash=$(cdhash "$STAGED") | |
| [ -n "$embedded_hash" ] && [ "$embedded_hash" != "$vendor_hash" ] \ | |
| || { echo "FAIL: Sparkle.framework itself still carries the vendor CDHash"; exit 1; } | |
| echo "OK: Sparkle components individually verify AND the re-sign chain provably ran (CDHash differs from the staged vendor copy; Downloader proven via preserved entitlements)" | |
| # Extract entitlements as a real plist (codesign's text dump | |
| # format is unstable across macOS versions) and parse with | |
| # PlistBuddy for exact, substring-proof key lookup — same | |
| # technique as the swift-mac job's TCC-entitlements check. | |
| work=$(mktemp -d) | |
| trap 'rm -rf "$work"' EXIT | |
| ent="$work/entitlements.plist" | |
| codesign -d --entitlements - --xml "$APP" 2>/dev/null > "$ent" | |
| for key in \ | |
| com.apple.security.device.audio-input \ | |
| com.apple.security.device.camera \ | |
| com.apple.security.automation.apple-events; do | |
| # Present is not enough — a `false` value would pass a key | |
| # check while leaving TCC capture ineffective. | |
| val=$(/usr/libexec/PlistBuddy -c "Print :$key" "$ent" 2>/dev/null) \ | |
| || { echo "FAIL: entitlements missing $key"; exit 1; } | |
| [ "$val" = "true" ] \ | |
| || { echo "FAIL: entitlement $key is '$val', want true"; exit 1; } | |
| done | |
| echo "OK: entitlements carry the three capture keys (all true)" | |
| # Required by the ad-hoc-signed embedded Sparkle under the | |
| # hardened runtime (Roost-Iced.entitlements rationale; | |
| # REMOVE-once-team-signed). | |
| val=$(/usr/libexec/PlistBuddy -c "Print :com.apple.security.cs.disable-library-validation" "$ent" 2>/dev/null) \ | |
| || { echo "FAIL: entitlements missing com.apple.security.cs.disable-library-validation"; exit 1; } | |
| [ "$val" = "true" ] \ | |
| || { echo "FAIL: cs.disable-library-validation is '$val', want true"; exit 1; } | |
| echo "OK: entitlements carry cs.disable-library-validation (true)" | |
| # Downloader.xpc must keep its OWN preserved entitlements | |
| # (--preserve-metadata=entitlements; empty since Sparkle ≥2.6 | |
| # removed its sandbox — sparkle-project/Sparkle#2511). The | |
| # app's entitlements being stamped onto it is exactly the | |
| # clobbering that signs+notarizes clean but breaks at | |
| # update-apply time. | |
| dent="$work/downloader-entitlements.plist" | |
| codesign -d --entitlements - --xml \ | |
| "$FRAMEWORK/Versions/B/XPCServices/Downloader.xpc" 2>/dev/null > "$dent" | |
| for key in \ | |
| com.apple.security.device.audio-input \ | |
| com.apple.security.device.camera \ | |
| com.apple.security.automation.apple-events \ | |
| com.apple.security.cs.disable-library-validation; do | |
| if grep -q "$key" "$dent"; then | |
| echo "FAIL: Downloader.xpc entitlements unexpectedly carry $key (app entitlements leaked into the Sparkle chain)"; exit 1 | |
| fi | |
| done | |
| echo "OK: Downloader.xpc keeps its own preserved (non-Roost) entitlements" | |
| # Capture first, then grep: `codesign -dv | grep -q` trips | |
| # pipefail (grep -q exits on match, codesign's remaining | |
| # unbuffered writes take SIGPIPE/141) — deterministic locally | |
| # once the Sparkle embed grew the CodeDirectory. | |
| sign_info=$(codesign -dv "$APP" 2>&1) | |
| printf '%s\n' "$sign_info" | grep -q 'flags=.*runtime' \ | |
| || { echo "FAIL: hardened runtime flag not present"; exit 1; } | |
| echo "OK: hardened runtime flag present" | |
| # Capture otool's output first WITHOUT masking: a missing or | |
| # non-Mach-O binary must fail here, not read as "no deps". | |
| deps=$(otool -L "$BIN" | tail -n +2 | awk '{print $1}') | |
| [ -n "$deps" ] || { echo "FAIL: otool -L returned no dependencies for $BIN"; exit 1; } | |
| bad=$(printf '%s\n' "$deps" | grep -vE '^(/usr/lib/|/System/)' || true) | |
| if [ -n "$bad" ]; then | |
| echo "FAIL: otool -L closure contains non-system paths:" | |
| echo "$bad" | |
| exit 1 | |
| fi | |
| echo "OK: otool -L closure contains only /usr/lib and /System paths" | |
| - name: Run Iced bundle smoke (macOS) | |
| if: runner.os == 'macOS' | |
| env: | |
| ICED_BACKEND: ${{ matrix.renderer }} | |
| RUST_LOG: warn | |
| ROOST_TEST_MODE: "1" | |
| ROOST_TEST_TIMEOUT_SCALE: "3" | |
| ROOST_ICED_APP: mac/build/Roost-Iced.app | |
| ROOST_E2E_ARTIFACT_DIR: ${{ runner.temp }}/roost-iced-e2e-mac-bundle-artifacts | |
| ROOST_E2E_LOG_DIR: ${{ runner.temp }}/roost-iced-e2e-mac-bundle-logs | |
| run: > | |
| uv run --group test pytest | |
| tools/roosttest/test_smoke.py | |
| tools/roosttest/test_iced_walking_skeleton.py | |
| tools/roosttest/test_menu_bar.py | |
| --roost-target iced --roost-fresh -v | |
| # Deliberately AFTER the keyless assemble + its assertion block: | |
| # this step re-assembles mac/build/Roost-Iced.app in place, with | |
| # the fixture's TEST-ONLY SUPublicEDKey and a dead placeholder | |
| # SUFeedURL, so the shipped-posture assertions above must already | |
| # have run against the keyless bundle. Mirrors `make | |
| # e2e-iced-sparkle` (plan 028 § 3.11); cargo artifacts are already | |
| # built, so the re-assemble is cheap. | |
| - name: Assemble test-keyed Roost-Iced.app (macOS) | |
| if: runner.os == 'macOS' | |
| shell: bash | |
| env: | |
| ROOST_ICED_SPARKLE_FEED_URL: http://127.0.0.1:1/placeholder | |
| run: | | |
| set -euo pipefail | |
| key_file=tools/roosttest/fixtures/sparkle/TEST-ONLY-public-ed-key.txt | |
| test -f "$key_file" || { echo "missing $key_file"; exit 1; } | |
| ROOST_ICED_SPARKLE_ED_PUBLIC_KEY="$(cat "$key_file")" \ | |
| ./mac/scripts/bundle-iced.sh debug | |
| plist="mac/build/Roost-Iced.app/Contents/Info.plist" | |
| /usr/libexec/PlistBuddy -c 'Print :SUPublicEDKey' "$plist" >/dev/null \ | |
| || { echo "FAIL: test-keyed bundle has no SUPublicEDKey"; exit 1; } | |
| # The e2e supplies the real feed URL at runtime via the | |
| # delegate override, so it would pass even if the plist | |
| # insertion silently dropped SUFeedURL — assert the exact | |
| # placeholder value here instead. | |
| [ "$(/usr/libexec/PlistBuddy -c 'Print :SUFeedURL' "$plist")" = "http://127.0.0.1:1/placeholder" ] \ | |
| || { echo "FAIL: test-keyed bundle missing or wrong SUFeedURL"; exit 1; } | |
| echo "OK: test-keyed bundle carries SUFeedURL + SUPublicEDKey" | |
| - name: Run Iced Sparkle E2E (macOS) | |
| if: runner.os == 'macOS' | |
| env: | |
| ICED_BACKEND: ${{ matrix.renderer }} | |
| RUST_LOG: warn | |
| ROOST_TEST_MODE: "1" | |
| ROOST_TEST_TIMEOUT_SCALE: "3" | |
| ROOST_ICED_APP: mac/build/Roost-Iced.app | |
| ROOST_E2E_ARTIFACT_DIR: ${{ runner.temp }}/roost-iced-e2e-mac-sparkle-artifacts | |
| ROOST_E2E_LOG_DIR: ${{ runner.temp }}/roost-iced-e2e-mac-sparkle-logs | |
| run: > | |
| uv run --group test pytest | |
| tools/roosttest/test_sparkle.py | |
| --roost-target iced --roost-fresh -v | |
| - name: Collect Iced diagnostics | |
| if: always() | |
| shell: bash | |
| run: | | |
| mkdir -p diagnostics | |
| # Every lane's capture file is named `roost-iced-ui.log`, so name | |
| # the copy after its lane directory (as the screenshots below | |
| # already do) — otherwise the lanes in this job overwrite one | |
| # another and only the last one's log survives. | |
| while IFS= read -r log; do | |
| suite="$(basename "$(dirname "${log}")")" | |
| cp "${log}" "diagnostics/${suite}-$(basename "${log}")" | |
| done < <(find "${RUNNER_TEMP}" -path '*/roost-iced-e2e-*-logs/*.log' 2>/dev/null) | |
| while IFS= read -r screenshot; do | |
| suite="$(basename "$(dirname "${screenshot}")")" | |
| cp "${screenshot}" "diagnostics/${suite}-$(basename "${screenshot}")" | |
| done < <(find "${RUNNER_TEMP}" -path '*/roost-iced-e2e-*-artifacts/*.png' 2>/dev/null) | |
| if [ "${RUNNER_OS}" = "macOS" ]; then | |
| # Bundle-mode launches write the persistent profile log (and any | |
| # crash reports) under ~/Library/Logs/Roost-iced, not the | |
| # harness's ROOST_E2E_LOG_DIR — collect them explicitly or a | |
| # bundle boot failure leaves no log artifact. | |
| cp "$HOME"/Library/Logs/Roost-iced/roost.log diagnostics/roost-iced-bundle-persistent.log 2>/dev/null || true | |
| cp "$HOME"/Library/Logs/Roost-iced/crash-*.txt diagnostics/ 2>/dev/null || true | |
| cp "$HOME"/Library/Logs/DiagnosticReports/roost-iced*.ips diagnostics/ 2>/dev/null || true | |
| # fnmatch is case-sensitive; the bundled process is named | |
| # `Roost-Iced` (CFBundleExecutable), not `roost-iced`, so its | |
| # crash reports need their own glob. | |
| cp "$HOME"/Library/Logs/DiagnosticReports/Roost-Iced*.ips diagnostics/ 2>/dev/null || true | |
| fi | |
| ls -la diagnostics || true | |
| - name: Upload Iced diagnostics | |
| if: always() | |
| uses: actions/upload-artifact@v7 | |
| with: | |
| name: e2e-iced-${{ matrix.os }}-${{ matrix.renderer }}-diagnostics | |
| path: diagnostics/ | |
| if-no-files-found: ignore | |
| retention-days: 14 | |
| # iced-build-e2e above only ever builds roost-iced in debug, but the deb | |
| # ships a release build (`lto = "thin"`, `codegen-units = 1` in | |
| # `[profile.release]`), and this stack has already produced a release-only | |
| # bug: #299's swash shaping hang didn't reproduce in debug. Single | |
| # ubuntu-latest/wgpu lane, not a matrix — the renderer split is already | |
| # covered in debug above; what's untested here is the release profile | |
| # itself, not the backend. | |
| iced-release: | |
| needs: changes | |
| # `linux` is in here for the packaging paths it covers (linux/** and | |
| # packaging/**): this lane builds the same packaged configuration the deb | |
| # ships, so a build-deb.sh or nfpm.yaml change must be able to reach it. | |
| # Without that, a packaging-only PR triggered no lane that compiles the | |
| # linux-package feature at all. `deb` additionally turns on the packaging | |
| # steps further down (build the real .deb, then smoke it and verify its | |
| # dependency closure). | |
| if: needs.changes.outputs.rust == 'true' || needs.changes.outputs.tests == 'true' || needs.changes.outputs.linux == 'true' || needs.changes.outputs.ci == 'true' || needs.changes.outputs.deb == 'true' | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 45 | |
| steps: | |
| - uses: actions/checkout@v6 | |
| with: | |
| persist-credentials: false | |
| - name: Install Iced native dependencies (Linux) | |
| run: | | |
| sudo apt-get update | |
| # iced-build-e2e's set minus weston: this lane is X11-only | |
| # (xvfb-run), so a Wayland compositor is pure install time. | |
| sudo apt-get install -y \ | |
| fonts-noto-cjk libclang-dev libxkbcommon-x11-0 libwayland-client0 \ | |
| mesa-vulkan-drivers xvfb xdotool zsh | |
| - name: Install Rust toolchain (from rust-toolchain.toml) | |
| uses: actions-rust-lang/setup-rust-toolchain@v1 | |
| - uses: jdx/mise-action@v4 | |
| - name: Install uv | |
| uses: astral-sh/setup-uv@v7 | |
| - name: Cache vendored libghostty-vt | |
| id: cache-ghostty | |
| uses: actions/cache@v5 | |
| with: | |
| path: | | |
| third_party/ghostty/out | |
| third_party/ghostty/src | |
| key: vendored-ghostty-${{ runner.os }}-${{ hashFiles('third_party/ghostty/build.sh') }} | |
| - name: Build libghostty-vt | |
| if: steps.cache-ghostty.outputs.cache-hit != 'true' | |
| run: ./third_party/ghostty/build.sh | |
| - name: Cache cargo registry + Iced target | |
| uses: actions/cache@v5 | |
| with: | |
| path: | | |
| ~/.cargo/registry | |
| ~/.cargo/git | |
| target | |
| key: cargo-iced-release-${{ runner.os }}-${{ hashFiles('**/Cargo.toml', 'rust-toolchain.toml') }} | |
| restore-keys: cargo-iced-release-${{ runner.os }}- | |
| # Must precede the build, because on the `deb` path the build is done by | |
| # build-deb.sh, which ends in `nfpm pkg`. Mirrors release.yml's install | |
| # exactly (same pinned version, same source) rather than inventing a | |
| # second mechanism — this lane is ubuntu-latest, i.e. amd64, so the arch | |
| # is unconditionally x86_64. | |
| - name: Install nfpm | |
| if: needs.changes.outputs.deb == 'true' | |
| run: | | |
| set -euo pipefail | |
| ver="2.46.3" | |
| url="https://github.com/goreleaser/nfpm/releases/download/v${ver}/nfpm_${ver}_Linux_x86_64.tar.gz" | |
| curl -fsSL "${url}" -o /tmp/nfpm.tgz | |
| sudo tar -C /usr/local/bin -xzf /tmp/nfpm.tgz nfpm | |
| nfpm --version | |
| # smoke-deb.sh validates the staged .desktop entries with | |
| # desktop-file-validate; only the deb path stages any. | |
| - name: Install desktop-file-utils | |
| if: needs.changes.outputs.deb == 'true' | |
| run: | | |
| sudo apt-get update | |
| sudo apt-get install -y desktop-file-utils | |
| # One build, not two — and now enforced rather than assumed. | |
| # [profile.release] is `lto = "thin"` + `codegen-units = 1`, so a second | |
| # cold LTO link would blow this job's 45-minute budget. build-deb.sh's | |
| # cargo invocation is byte-identical to the one here, so on the `deb` | |
| # path it subsumes this step; the two `if:`s are exact complements, so | |
| # exactly one always runs. Splitting it this way keeps ONE source of | |
| # truth for the build command instead of relying on an unenforced | |
| # coincidence between a workflow and a script. | |
| # | |
| # `linux-package` is the configuration that actually ships (it's what | |
| # makes a packaged build adopt the production `roost` bundle profile | |
| # instead of the isolated `roost-iced` one, per `default_profile_kind()` | |
| # in `crates/roost-iced/src/main.rs`) — building it here, rather than | |
| # the featureless dev config, is what makes this lane worth gating. | |
| # Either path leaves target/release/{roost-iced,roostctl} exactly where | |
| # every downstream step expects them: the e2e step below exercises the | |
| # binary as an ordinary dev instance because it pins | |
| # `ROOST_BUNDLE_PROFILE`, and the profile-adoption step further down is | |
| # what leaves that var unset and checks where it lands on its own. | |
| - name: Build Iced + roostctl (release) | |
| if: needs.changes.outputs.deb != 'true' | |
| run: cargo build --release -p roost-iced -p roost-cli --features roost-iced/linux-package | |
| # build-deb.sh also re-runs third_party/ghostty/build.sh. That's | |
| # idempotent on a cache hit, but it needs `zig` on PATH *before* it | |
| # reaches its cache check — satisfied by the jdx/mise-action@v4 step | |
| # above, which is why this can't move ahead of it. | |
| # | |
| # Version 0.0.0-ci: nfpm normalizes `-` to `~`, yielding `0.0.0~ci` — a | |
| # valid Debian version that can never be mistaken for a real release. | |
| - name: Build the .deb (also produces the release binaries) | |
| if: needs.changes.outputs.deb == 'true' | |
| run: ./linux/scripts/build-deb.sh 0.0.0-ci | |
| - name: Run Iced release-profile E2E (Linux X11) | |
| env: | |
| ICED_BACKEND: wgpu | |
| RUST_LOG: warn | |
| ROOST_TEST_TIMEOUT_SCALE: "3" | |
| ROOST_ICED_BIN: ${{ github.workspace }}/target/release/roost-iced | |
| # util.py's roostctl fallback is target/debug/roostctl, and it | |
| # cargo-builds a debug CLI when that's missing. No module in the | |
| # release subset needs roostctl today, but adding one later would | |
| # otherwise exercise a debug CLI inside the release gate. | |
| ROOST_ROOSTCTL: ${{ github.workspace }}/target/release/roostctl | |
| ROOST_E2E_ARTIFACT_DIR: ${{ runner.temp }}/roost-iced-e2e-release-x11-artifacts | |
| ROOST_E2E_LOG_DIR: ${{ runner.temp }}/roost-iced-e2e-release-x11-logs | |
| # Even though ROOST_ICED_BIN above is the *packaged* | |
| # (linux-package) binary, it lands on the isolated `roost-iced` | |
| # dev profile here just like a plain dev build: ui.py sets | |
| # ROOST_BUNDLE_PROFILE=iced unconditionally for every "iced" | |
| # target launch (tools/roosttest/ui.py, ~line 481), which outranks | |
| # the compiled-in default per BundleProfile::resolve's precedence | |
| # order. `e2e-iced-release-ci` (Makefile) already guards that | |
| # ROOST_ICED_BIN is non-empty — a blank value would silently fall | |
| # back to a cargo-built debug binary and test the wrong thing — | |
| # so that check isn't duplicated here. | |
| run: > | |
| xvfb-run -a --server-args="-screen 0 1920x1080x24" | |
| make e2e-iced-release-ci | |
| # The only pre-release exercise of the linux-package adoption | |
| # mechanism: without this, "does the packaged binary really land on | |
| # the production `roost` namespace instead of the isolated | |
| # `roost-iced` one" is first proven at tag time, in front of users | |
| # doing the deb upgrade. Runs the SAME already-built binary above, | |
| # but with ROOST_BUNDLE_PROFILE deliberately left unset (unlike the | |
| # e2e step above, which pins it to `iced`) so | |
| # `default_profile_kind()`'s packaged+Linux branch gets to decide | |
| # the profile on its own, exactly as it will for a real user. | |
| - name: Assert the packaged build adopts the production profile | |
| env: | |
| ICED_BACKEND: wgpu | |
| RUST_LOG: warn | |
| XDG_RUNTIME_DIR: ${{ runner.temp }}/iced-release-profile-check/run | |
| XDG_DATA_HOME: ${{ runner.temp }}/iced-release-profile-check/data | |
| XDG_STATE_HOME: ${{ runner.temp }}/iced-release-profile-check/state | |
| run: | | |
| set -euo pipefail | |
| mkdir -p "$XDG_RUNTIME_DIR" "$XDG_DATA_HOME" "$XDG_STATE_HOME" | |
| chmod 700 "$XDG_RUNTIME_DIR" | |
| UI_BIN="${GITHUB_WORKSPACE}/target/release/roost-iced" | |
| ROOSTCTL_BIN="${GITHUB_WORKSPACE}/target/release/roostctl" | |
| UI_LOG="${RUNNER_TEMP}/iced-release-profile-check.log" | |
| xvfb-run -a --server-args="-screen 0 1280x800x24" "$UI_BIN" >"$UI_LOG" 2>&1 & | |
| UI_PID=$! | |
| cleanup() { | |
| # A no-op kill (UI already dead, or never started) must not | |
| # fail the job. | |
| kill "$UI_PID" >/dev/null 2>&1 || true | |
| wait "$UI_PID" 2>/dev/null || true | |
| } | |
| trap cleanup EXIT | |
| # Poll a real `identify` round-trip, NOT the socket file's | |
| # existence — a stale socket file with nothing listening would | |
| # make a file check pass and race the real readiness signal. | |
| IDENTIFY_OUTPUT="" | |
| for _ in $(seq 1 60); do | |
| if IDENTIFY_OUTPUT=$("$ROOSTCTL_BIN" identify 2>/dev/null); then | |
| break | |
| fi | |
| IDENTIFY_OUTPUT="" | |
| sleep 0.5 | |
| done | |
| if [ -z "$IDENTIFY_OUTPUT" ]; then | |
| echo "::error::packaged roost-iced never answered 'roostctl identify' after ~30s" | |
| echo "--- UI log ($UI_LOG) ---" | |
| cat "$UI_LOG" || true | |
| exit 1 | |
| fi | |
| SOCKET_PATH=$(printf '%s\n' "$IDENTIFY_OUTPUT" | awk -F= '$1 == "socket" { print $2 }') | |
| if [ -z "$SOCKET_PATH" ]; then | |
| echo "::error::could not parse a socket= line out of 'roostctl identify' output:" | |
| printf '%s\n' "$IDENTIFY_OUTPUT" | |
| exit 1 | |
| fi | |
| # 1. The reported socket must be under the production | |
| # namespace ($XDG_RUNTIME_DIR/roost/), not the isolated | |
| # roost-iced one. | |
| case "$SOCKET_PATH" in | |
| "$XDG_RUNTIME_DIR"/roost/*) | |
| ;; | |
| *) | |
| echo "::error::packaged build's socket is not under \$XDG_RUNTIME_DIR/roost/ (production namespace): $SOCKET_PATH" | |
| exit 1 | |
| ;; | |
| esac | |
| # 2. The isolated roost-iced/ namespace must not have been | |
| # created at all. | |
| if [ -d "$XDG_RUNTIME_DIR/roost-iced" ]; then | |
| echo "::error::packaged build still created the isolated roost-iced/ namespace at $XDG_RUNTIME_DIR/roost-iced — linux-package adoption did not take effect" | |
| exit 1 | |
| fi | |
| echo "OK: packaged build adopted the production namespace ($SOCKET_PATH)" | |
| # ---- Packaging checks (deb-gated) ------------------------------------- | |
| # These three steps run the *same* scripts release.yml runs. Until now | |
| # their first execution was during an actual release — i.e. after the | |
| # GitHub Release had already been created, with a broken artifact | |
| # already attached to it. Running them on ordinary PRs that touch the | |
| # packaging paths is the whole point: the release path's only real | |
| # artifact checks get proven somewhere other than a real release. | |
| # They sit after the profile-adoption assertion above so the cheap, | |
| # already-proven check still reports first. | |
| # Release-critical shell with no other lint anywhere in the repo. | |
| # shellcheck ships preinstalled on GitHub's ubuntu runners, so this is | |
| # seconds; `bash -n` additionally catches syntax errors in code paths | |
| # shellcheck may not flag. | |
| - name: Lint the release-path shell scripts | |
| if: needs.changes.outputs.deb == 'true' | |
| run: | | |
| set -euo pipefail | |
| shellcheck linux/scripts/*.sh | |
| for f in linux/scripts/*.sh; do | |
| bash -n "$f" | |
| done | |
| - name: Smoke the packaged artifact | |
| if: needs.changes.outputs.deb == 'true' | |
| run: | | |
| set -euo pipefail | |
| deb="$(./linux/scripts/resolve-one-deb.sh out)" | |
| ./linux/scripts/smoke-deb.sh "${deb}" \ | |
| --work-dir "${RUNNER_TEMP}/roost-deb-smoke" \ | |
| --expect-version 0.0.0~ci | |
| # The smoke above extracts the .deb, so it proves the payload but not | |
| # the `Depends:` line — this runner already carries the whole graphics | |
| # stack from the build, so a missing dependency would still launch | |
| # here. The container run is what catches that. | |
| - name: Verify the dependency closure | |
| if: needs.changes.outputs.deb == 'true' | |
| run: | | |
| set -euo pipefail | |
| deb="$(./linux/scripts/resolve-one-deb.sh out)" | |
| ./linux/scripts/verify-deb-closure.sh "${deb}" | |
| - name: Collect Iced diagnostics | |
| if: always() | |
| shell: bash | |
| run: | | |
| mkdir -p diagnostics | |
| find "${RUNNER_TEMP}" -path '*/roost-iced-e2e-*-logs/*.log' \ | |
| -exec cp '{}' diagnostics/ \; 2>/dev/null || true | |
| while IFS= read -r screenshot; do | |
| suite="$(basename "$(dirname "${screenshot}")")" | |
| cp "${screenshot}" "diagnostics/${suite}-$(basename "${screenshot}")" | |
| done < <(find "${RUNNER_TEMP}" -path '*/roost-iced-e2e-*-artifacts/*.png' 2>/dev/null) | |
| ls -la diagnostics || true | |
| - name: Upload Iced diagnostics | |
| if: always() | |
| uses: actions/upload-artifact@v7 | |
| with: | |
| name: e2e-iced-release-diagnostics | |
| path: diagnostics/ | |
| if-no-files-found: ignore | |
| retention-days: 14 | |
| # Functional E2E: launch the real GTK UI headless under Xvfb and drive | |
| # it through the IPC op set with the pytest harness (tools/roosttest). | |
| # Required — Linux is the cheap, reliable headless target. | |
| e2e-gtk: | |
| needs: changes | |
| if: needs.changes.outputs.linux == 'true' || needs.changes.outputs.tests == 'true' || needs.changes.outputs.ci == 'true' | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 30 | |
| steps: | |
| - uses: actions/checkout@v6 | |
| with: | |
| persist-credentials: false | |
| - name: Install GTK4 + libadwaita + Xvfb + zsh | |
| run: | | |
| sudo apt-get update | |
| sudo apt-get install -y \ | |
| libgtk-4-dev libadwaita-1-dev pkg-config libclang-dev xvfb xdotool zsh | |
| - name: Install Rust toolchain (from rust-toolchain.toml) | |
| uses: actions-rust-lang/setup-rust-toolchain@v1 | |
| - uses: jdx/mise-action@v4 | |
| - name: Install uv | |
| uses: astral-sh/setup-uv@v7 | |
| - name: Cache vendored libghostty-vt | |
| id: cache-ghostty | |
| uses: actions/cache@v5 | |
| with: | |
| path: | | |
| third_party/ghostty/out | |
| third_party/ghostty/src | |
| key: vendored-ghostty-${{ runner.os }}-${{ hashFiles('third_party/ghostty/build.sh') }} | |
| - name: Build libghostty-vt | |
| if: steps.cache-ghostty.outputs.cache-hit != 'true' | |
| run: ./third_party/ghostty/build.sh | |
| - name: Cache cargo registry + target | |
| uses: actions/cache@v5 | |
| with: | |
| path: | | |
| ~/.cargo/registry | |
| ~/.cargo/git | |
| target | |
| key: cargo-${{ runner.os }}-${{ hashFiles('**/Cargo.toml', 'rust-toolchain.toml') }} | |
| restore-keys: cargo-${{ runner.os }}- | |
| # roostctl too: `test_agent_lifecycle.py` drives synthetic Claude | |
| # hook payloads through the real `roostctl claude-hook` binary, so | |
| # the adapter -> CLI -> IPC -> workspace path runs end to end. (The | |
| # harness can build it on demand, but that would land inside the | |
| # pytest step.) The Mac leg gets it from bundle.sh, which embeds | |
| # roostctl in the .app. | |
| - name: Build the GTK UI + roostctl | |
| run: cargo build -p roost-linux -p roost-cli | |
| - name: Run E2E (GTK under Xvfb) | |
| env: | |
| GDK_BACKEND: x11 | |
| RUST_LOG: warn | |
| ROOST_TEST_TIMEOUT_SCALE: "3" # shared CI runner is slower/variable | |
| # Unlocks tab.feed_pty_bytes + tab.capture_pty_input on the | |
| # UI side so the OSC-pipeline tests can drive PTY bytes | |
| # end-to-end. The harness reads the same var to skip those | |
| # tests entirely when it's absent (no spurious red). | |
| ROOST_TEST_MODE: "1" | |
| # Where the harness captures the launched UI's stdout+stderr (the | |
| # GTK UI tees its log to stdout). Collected + uploaded below so a | |
| # boot failure under xvfb isn't blind — the Mac twin already does | |
| # this via crash reports; this is the GTK equivalent. | |
| ROOST_E2E_LOG_DIR: ${{ runner.temp }}/roost-e2e-logs | |
| # The harness launches its own `roost` process; under xvfb-run it | |
| # inherits the virtual DISPLAY. tab.dump reads in-process render | |
| # state, so no compositor screen-capture is involved. | |
| # Big virtual screen so window.resize can grow the toplevel to the | |
| # full test width (the default 1280-wide xvfb capped resizes at | |
| # ~1270, registered-skipping the sidebar geometry tests). With room | |
| # to resize, those tests run instead of skipping. | |
| run: > | |
| xvfb-run -a --server-args="-screen 0 2560x1440x24" | |
| uv run --group test pytest tools/roosttest --roost-target gtk --roost-fresh -v | |
| # Real-input regressions: the behaviors that only real pointer/key input | |
| # through the GTK gesture/shortcut stack can exercise (the IPC suite drives | |
| # the op set, never the gesture stack) — click-to-focus + project-switch | |
| # core-sync (#1), Alt+digit / Ctrl+PageDown / cycle_tab / pill-click core- | |
| # sync (#228/#229), tab context-menu no-crash, and the GtkGestureDrag | |
| # tab/project reorder that replaced GTK DnD (whose Wayland drag-icon | |
| # surface aborted in gdksurface-wayland.c:frame_callback). Self-contained — | |
| # it starts its OWN Xvfb + throwaway Roost — so it runs as its own step, | |
| # NOT under xvfb-run. | |
| # | |
| # REQUIRED as of plan 024. This job is in `ci-success`, so a soft step | |
| # inside it made the required gate only a partial one. The | |
| # gather-signal period is over and XTEST-under-Xvfb held up: 21 | |
| # consecutive `main` runs (2026-07-31 → 2026-08-09), every one ending | |
| # `PASS: focus + core-sync …, drag reorder …, palette chrome-click | |
| # no-storm all verified`, with zero failures and zero skips. Measured | |
| # from the step LOGS — with `continue-on-error` set, both the job | |
| # conclusion and the REST API's step `conclusion` report success no | |
| # matter what the step actually did, so neither is evidence. | |
| # | |
| # ROOST_REQUIRE_REAL_INPUT turns the script's self-skip into a failure | |
| # here (xdotool/Xvfb are installed, so a skip means a real setup | |
| # problem, not "unsupported"). | |
| - name: Real-input regressions (Xvfb + xdotool) | |
| env: | |
| GDK_BACKEND: x11 | |
| ROOST_TEST_MODE: "1" | |
| ROOST_TEST_TIMEOUT_SCALE: "3" | |
| ROOST_REQUIRE_REAL_INPUT: "1" | |
| run: uv run --group test python tools/input/linux/real_input_check.py | |
| # Stage the captured UI log into one dir (upload-artifact doesn't | |
| # expand globs/`~`). The harness writes the launched UI's stdout+stderr | |
| # to ROOST_E2E_LOG_DIR; grab it whether or not the run failed. | |
| - name: Collect failure diagnostics | |
| if: always() | |
| run: | | |
| mkdir -p diagnostics | |
| cp "${RUNNER_TEMP}"/roost-e2e-logs/*.log diagnostics/ 2>/dev/null || true | |
| ls -la diagnostics || true | |
| - name: Upload E2E diagnostics | |
| if: always() | |
| uses: actions/upload-artifact@v7 | |
| with: | |
| name: e2e-gtk-diagnostics | |
| path: diagnostics/ | |
| if-no-files-found: ignore | |
| retention-days: 14 | |
| # Functional E2E against the GTK UI under a headless WAYLAND compositor | |
| # (weston) — the twin of e2e-gtk-under-Xvfb. Exists because GTK's | |
| # GDK-Wayland backend (gdksurface-wayland.c) is ONLY exercised here: the | |
| # Xvfb job forces GDK_BACKEND=x11, so Wayland-only bugs (e.g. the DnD | |
| # drag-icon-surface frame_callback abort that crashed tab reorder on | |
| # COSMIC) are invisible to it. | |
| # | |
| # REQUIRED (in ci-success) as of plan 024. The gather-signal period is over | |
| # and the signal was clean: 21 consecutive `main` runs between 2026-07-31 | |
| # and 2026-08-09, every one ending `N passed, M skipped` with zero failures, | |
| # runtime stable at 19-31s. | |
| # | |
| # HOW that was measured matters, because the obvious methods lie. A step | |
| # carrying `continue-on-error` is reported by the REST jobs API with | |
| # `conclusion: success` no matter what it did — that API exposes | |
| # `conclusion` and not `outcome`. So both "the job went green 21 times" and | |
| # a step-level query saying "all steps ok" were true and meaningless. The | |
| # only honest signal is the step's log body, which is where the numbers | |
| # above come from. Do not promote anything here on job- or step-conclusion | |
| # evidence again. | |
| # | |
| # weston headless drives the IPC suite, not pointer input; the real | |
| # pointer-DRAG guard lives in the separate e2e-gtk-wayland-drag job (cage + | |
| # /dev/uinput) below, which is deliberately NOT promoted — see its comment. | |
| e2e-gtk-wayland: | |
| needs: changes | |
| if: needs.changes.outputs.linux == 'true' || needs.changes.outputs.tests == 'true' || needs.changes.outputs.ci == 'true' | |
| runs-on: ubuntu-latest | |
| # Job-level cap so a hung run can't sprawl (a single hung test ate the 15m | |
| # step cap once → the job ran 22m). Sized to survive a cache-COLD run (cold | |
| # libghostty-vt + cargo build add ~10m), not the warm ~2m time. | |
| timeout-minutes: 30 | |
| steps: | |
| - uses: actions/checkout@v6 | |
| with: | |
| persist-credentials: false | |
| - name: Install GTK4 + libadwaita + weston + zsh | |
| run: | | |
| sudo apt-get update | |
| sudo apt-get install -y \ | |
| libgtk-4-dev libadwaita-1-dev pkg-config libclang-dev weston wl-clipboard zsh | |
| - name: Install Rust toolchain (from rust-toolchain.toml) | |
| uses: actions-rust-lang/setup-rust-toolchain@v1 | |
| - uses: jdx/mise-action@v4 | |
| - name: Install uv | |
| uses: astral-sh/setup-uv@v7 | |
| - name: Cache vendored libghostty-vt | |
| id: cache-ghostty | |
| uses: actions/cache@v5 | |
| with: | |
| path: | | |
| third_party/ghostty/out | |
| third_party/ghostty/src | |
| key: vendored-ghostty-${{ runner.os }}-${{ hashFiles('third_party/ghostty/build.sh') }} | |
| - name: Build libghostty-vt | |
| if: steps.cache-ghostty.outputs.cache-hit != 'true' | |
| run: ./third_party/ghostty/build.sh | |
| - name: Cache cargo registry + target | |
| uses: actions/cache@v5 | |
| with: | |
| path: | | |
| ~/.cargo/registry | |
| ~/.cargo/git | |
| target | |
| key: cargo-${{ runner.os }}-${{ hashFiles('**/Cargo.toml', 'rust-toolchain.toml') }} | |
| restore-keys: cargo-${{ runner.os }}- | |
| # roostctl too — same reason as the Xvfb job above (the pytest | |
| # suite's Claude-hook test shells out to the real binary). | |
| - name: Build the GTK UI + roostctl | |
| run: cargo build -p roost-linux -p roost-cli | |
| - name: Run E2E (GTK under headless Wayland / weston) | |
| timeout-minutes: 15 | |
| env: | |
| RUST_LOG: warn | |
| ROOST_TEST_TIMEOUT_SCALE: "3" # shared CI runner is slower/variable | |
| ROOST_TEST_MODE: "1" | |
| ROOST_E2E_LOG_DIR: ${{ runner.temp }}/roost-e2e-wl-logs | |
| # weston-run.sh boots a headless weston, points GDK at it | |
| # (GDK_BACKEND=wayland) and runs the IPC-driven suite — tab.dump reads | |
| # in-process render state, so no screen capture is involved. | |
| # | |
| # Curated subset: skip the clipboard + shell-env tests. On headless | |
| # Wayland there's no clipboard manager, so a GTK clipboard read can | |
| # block indefinitely (the full suite hung ~2h on the runner before | |
| # this cap), and those paths test integration semantics that diverge | |
| # X11↔Wayland — not the GDK-Wayland backend this job exists to cover. | |
| # `test_selection.py` is NOT skipped: `selection.set`/`selection.dump` | |
| # go straight to the TerminalView and never reach GdkClipboard, so | |
| # there's nothing here for a missing clipboard manager to block on. | |
| # `timeout-minutes` is the belt-and-suspenders cap so a future hang can | |
| # never run away again — and now that this step is required, a hang | |
| # reds the gate instead of being swallowed. | |
| # --timeout: per-test hard cap (pytest-timeout) so one hung test fails | |
| # fast instead of consuming the whole step (that's what ran the job to | |
| # 22m). Derived from the same ROOST_TEST_TIMEOUT_SCALE the harness uses | |
| # (30s base * 3 = 90s) so the one knob meant to absorb runner variance | |
| # doesn't get bypassed. `thread` method dumps the stack + fails without | |
| # relying on SIGALRM landing in a GTK/IPC C call. | |
| run: > | |
| tools/wayland/weston-run.sh | |
| uv run --group test pytest tools/roosttest --roost-target gtk --roost-fresh -v | |
| --timeout=$((30 * ROOST_TEST_TIMEOUT_SCALE)) --timeout-method=thread | |
| --ignore=tools/roosttest/test_osc52.py | |
| --ignore=tools/roosttest/test_shell_integration.py | |
| - name: Collect failure diagnostics | |
| if: always() | |
| run: | | |
| mkdir -p diagnostics | |
| cp "${RUNNER_TEMP}"/roost-e2e-wl-logs/*.log diagnostics/ 2>/dev/null || true | |
| ls -la diagnostics || true | |
| - name: Upload E2E diagnostics | |
| if: always() | |
| uses: actions/upload-artifact@v7 | |
| with: | |
| name: e2e-gtk-wayland-diagnostics | |
| path: diagnostics/ | |
| if-no-files-found: ignore | |
| retention-days: 14 | |
| # Real pointer-DRAG under a headless WAYLAND compositor — the one thing the | |
| # IPC-only e2e-gtk-wayland job can't do. Drives an absolute-pointer drag with | |
| # the stdlib /dev/uinput injector (tools/input/linux/inject_pointer.py) under | |
| # `cage` (a kiosk wlroots compositor that fullscreens its single client, so | |
| # window coords == output coords). Guards that the GtkGestureDrag reorder | |
| # neither aborts the process nor emits a Wayland surface critical — the X11 | |
| # real-input job can only exercise the gesture *logic*, not the GDK-Wayland | |
| # backend where the old GtkDnD drag-icon surface crashed. | |
| # | |
| # STILL NON-BLOCKING (continue-on-error + absent from ci-success), and this | |
| # is now a measured decision rather than a pending one. | |
| # | |
| # Plan 024 re-checked the history from the step LOGS — the only signal that | |
| # survives `continue-on-error`, since both the job conclusion and the REST | |
| # API's step `conclusion` report success regardless. Its sibling | |
| # e2e-gtk-wayland came back 21/21 clean and was promoted. This lane did not: | |
| # 3 of the same 21 runs FAILED with | |
| # | |
| # FAIL (real-input required): window_metrics returned no window size | |
| # | |
| # (runs 31134306062, 31144935154, 31151407004 — all within one ~5h window on | |
| # 2026-08-07, so plausibly a runner-image or seat/uinput blip rather than a | |
| # steady 14% rate, but a gate cannot be promoted on a hypothesis). | |
| # | |
| # The failure is harness-shaped, not product-shaped: the injector never got | |
| # a window geometry back, so it could not aim the pointer. Two candidates | |
| # worth investigating before promoting — cage not having the surface mapped | |
| # when the harness asks (needs a wait/retry rather than a single probe), or | |
| # the best-effort `modprobe uinput` + seatd step below silently not taking, | |
| # in which case this should SKIP loudly rather than FAIL. See #328. | |
| # | |
| # The developer confirms the real COSMIC/cosmic-comp behavior separately; | |
| # cage is generic wlroots Wayland, which is where the crash lived. | |
| e2e-gtk-wayland-drag: | |
| needs: changes | |
| if: needs.changes.outputs.linux == 'true' || needs.changes.outputs.tests == 'true' || needs.changes.outputs.ci == 'true' | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 30 | |
| steps: | |
| - uses: actions/checkout@v6 | |
| with: | |
| persist-credentials: false | |
| - name: Install GTK4 + libadwaita + cage + seatd | |
| run: | | |
| sudo apt-get update | |
| sudo apt-get install -y \ | |
| libgtk-4-dev libadwaita-1-dev pkg-config libclang-dev cage seatd | |
| - name: Install Rust toolchain (from rust-toolchain.toml) | |
| uses: actions-rust-lang/setup-rust-toolchain@v1 | |
| - uses: jdx/mise-action@v4 | |
| - name: Install uv | |
| uses: astral-sh/setup-uv@v7 | |
| - name: Cache vendored libghostty-vt | |
| id: cache-ghostty | |
| uses: actions/cache@v5 | |
| with: | |
| path: | | |
| third_party/ghostty/out | |
| third_party/ghostty/src | |
| key: vendored-ghostty-${{ runner.os }}-${{ hashFiles('third_party/ghostty/build.sh') }} | |
| - name: Build libghostty-vt | |
| if: steps.cache-ghostty.outputs.cache-hit != 'true' | |
| run: ./third_party/ghostty/build.sh | |
| - name: Cache cargo registry + target | |
| uses: actions/cache@v5 | |
| with: | |
| path: | | |
| ~/.cargo/registry | |
| ~/.cargo/git | |
| target | |
| key: cargo-${{ runner.os }}-${{ hashFiles('**/Cargo.toml', 'rust-toolchain.toml') }} | |
| restore-keys: cargo-${{ runner.os }}- | |
| - name: Build the GTK UI + roostctl | |
| # roostctl (roost-cli) is what the injector harness drives; a cold | |
| # `-p roost-linux` build doesn't produce it, so build both explicitly. | |
| run: cargo build -p roost-linux -p roost-cli | |
| - name: Enable uinput + a seat for synthetic input | |
| # Headless wlroots has no input devices; to read the uinput device the | |
| # injector creates, cage needs a libinput backend on a seat. modprobe | |
| # uinput works on ubuntu-latest; seatd grants the seat to the runner's | |
| # group. Best-effort — the check SKIPs/FAILs clearly if this didn't take. | |
| run: | | |
| sudo modprobe uinput || true | |
| sudo chmod 0666 /dev/uinput || true | |
| sudo seatd -g "$(id -gn)" >"${RUNNER_TEMP}/seatd.log" 2>&1 & | |
| sleep 1 | |
| echo "LIBSEAT_BACKEND=seatd" >> "$GITHUB_ENV" | |
| - name: Wayland pointer-drag guard (cage + uinput) | |
| continue-on-error: true | |
| timeout-minutes: 12 | |
| env: | |
| ROOST_TEST_MODE: "1" | |
| ROOST_TEST_TIMEOUT_SCALE: "3" | |
| ROOST_REQUIRE_REAL_INPUT: "1" | |
| WLR_BACKENDS: "headless,libinput" | |
| WLR_RENDERER: "pixman" | |
| run: uv run --group test python tools/input/linux/wayland_drag_check.py | |
| # Functional E2E against the real Swift app, driven through the IPC op | |
| # set by the pytest harness (tools/roosttest) — the Mac twin of e2e-gtk. | |
| # Required (in ci-success). The harness clears any stale instance before | |
| # launch (tools/roosttest/ui.py) and timeouts scale up for the slower | |
| # shared runner; see docs/development/test-automation.md. | |
| e2e-mac: | |
| needs: changes | |
| if: needs.changes.outputs.mac == 'true' || needs.changes.outputs.tests == 'true' || needs.changes.outputs.ci == 'true' | |
| runs-on: macos-latest | |
| timeout-minutes: 20 | |
| steps: | |
| - uses: actions/checkout@v6 | |
| with: | |
| persist-credentials: false | |
| - uses: jdx/mise-action@v4 | |
| - name: Install uv | |
| uses: astral-sh/setup-uv@v7 | |
| - name: Install modern bash + version probe | |
| # `test_bash_auto_bootstrap_tracks_cwd` needs bash >= 4.4 for its | |
| # `--posix` + ENV auto-bootstrap path; Apple's /bin/bash is 3.2, | |
| # SIP-locked. Brew lands modern bash at /opt/homebrew/bin/bash on | |
| # ARM runners, /usr/local/bin/bash on Intel (the test's | |
| # `_modern_bash()` probes both). The idempotent `list || install` | |
| # form is robust to runner images that ship bash preinstalled; | |
| # the version probe makes a future preinstall-regression loud in | |
| # CI logs rather than silently re-triggering the test's | |
| # `precondition("no modern bash")` failure. | |
| run: | | |
| brew list bash >/dev/null 2>&1 || brew install bash | |
| which bash || true | |
| for p in /opt/homebrew/bin/bash /usr/local/bin/bash; do | |
| if [ -x "$p" ]; then "$p" --version; fi | |
| done | |
| - name: Cache vendored libghostty-vt | |
| id: cache-ghostty | |
| uses: actions/cache@v5 | |
| with: | |
| path: | | |
| third_party/ghostty/out | |
| third_party/ghostty/src | |
| key: vendored-ghostty-${{ runner.os }}-${{ hashFiles('third_party/ghostty/build.sh') }} | |
| - name: Build libghostty-vt | |
| if: steps.cache-ghostty.outputs.cache-hit != 'true' | |
| run: ./third_party/ghostty/build.sh | |
| - name: Cache SwiftPM artifacts | |
| uses: actions/cache@v5 | |
| with: | |
| path: | | |
| mac/.build | |
| ~/Library/Caches/org.swift.swiftpm | |
| key: swiftpm-${{ runner.os }}-${{ hashFiles('mac/Package.swift', 'mac/Package.resolved') }} | |
| restore-keys: swiftpm-${{ runner.os }}- | |
| - name: Cache cargo registry + target | |
| uses: actions/cache@v5 | |
| with: | |
| path: | | |
| ~/.cargo/registry | |
| ~/.cargo/git | |
| target | |
| key: cargo-${{ runner.os }}-swiftmac-${{ hashFiles('**/Cargo.toml', 'rust-toolchain.toml') }} | |
| restore-keys: cargo-${{ runner.os }}-swiftmac- | |
| - name: Bundle Roost.app | |
| run: ./mac/scripts/bundle.sh debug | |
| - name: Run E2E (Mac app) | |
| env: | |
| ROOST_TEST_TIMEOUT_SCALE: "3" # shared macos-latest runner is slower/variable | |
| # Unlocks the test-only IPC ops in the bundled Mac app | |
| # (tools/roosttest/ui.py forwards this via `open --env`). | |
| # See the GTK job above for full rationale. | |
| ROOST_TEST_MODE: "1" | |
| # --roost-fresh: own a hermetic instance with an isolated, throwaway | |
| # ROOST_STATE_DIR (replaces the old ROOST_TEST_RESET_STATE clean-slate | |
| # hack — the harness no longer deletes the real state.json). | |
| run: > | |
| uv run --group test pytest tools/roosttest --roost-target mac --roost-fresh -v | |
| --junitxml=test-results/e2e-mac.xml | |
| # On failure, salvage what makes a red blocking check debuggable: the | |
| # app's own log, any macOS crash report, and a best-effort live | |
| # screenshot (only lands if the app is still up). Staged into one dir | |
| # because upload-artifact doesn't expand `~`/globs. | |
| - name: Collect failure diagnostics | |
| if: failure() | |
| run: | | |
| mkdir -p diagnostics | |
| cp "$HOME/Library/Logs/Roost/roost.log" diagnostics/ 2>/dev/null || true | |
| cp "$HOME"/Library/Logs/DiagnosticReports/Roost*.ips diagnostics/ 2>/dev/null || true | |
| cp "$HOME"/Library/Logs/DiagnosticReports/Roost*.crash diagnostics/ 2>/dev/null || true | |
| "mac/build/Roost.app/Contents/Resources/bin/roostctl" screenshot --out diagnostics/screen.png 2>/dev/null || true | |
| ls -la diagnostics || true | |
| - name: Upload E2E diagnostics + JUnit | |
| if: always() | |
| uses: actions/upload-artifact@v7 | |
| with: | |
| name: e2e-mac-diagnostics | |
| path: | | |
| diagnostics/ | |
| test-results/ | |
| if-no-files-found: ignore | |
| # Single required check. Always runs; passes iff no gated job failed (skipped | |
| # jobs are fine — that's the path-filter doing its job). | |
| ci-success: | |
| needs: [changes, rust-lint, harness-unit, themes-parity, rust-build, swift-mac, gtk-build, iced-build-e2e, iced-release, e2e-gtk, e2e-gtk-wayland, e2e-mac] | |
| if: always() | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 10 | |
| steps: | |
| - name: Verify no required job failed | |
| # Membership is DERIVED from `needs`, not restated. It used to be | |
| # written out twice — once in `needs:` and once in a hand-built | |
| # `results` string — and the two drifted twice. `toJSON(needs)` makes | |
| # the duplication impossible rather than adding a third thing to keep | |
| # in sync. | |
| # | |
| # Passed through `env:` and never interpolated into the shell body: | |
| # `${{ }}` inside `run:` is textual substitution, which is a script | |
| # injection surface. | |
| env: | |
| NEEDS: ${{ toJSON(needs) }} | |
| run: | | |
| set -euo pipefail | |
| echo "${NEEDS}" | jq -r 'to_entries[] | " \(.key): \(.value.result)"' | |
| # Deriving membership from `needs` means a job DELETED from that | |
| # list silently stops being gated — the gate would still pass, just | |
| # over less. A floor catches that. It is one integer rather than a | |
| # second copy of the list, so it cannot drift the way the old | |
| # hand-written `results` string did; adding a gate is free (the | |
| # count only rises), and lowering this number should be a | |
| # deliberate act with a reason. `release.yml`'s ci-gate trusts this | |
| # check, so "fewer gates than we think" has to be loud. | |
| required_jobs=12 | |
| count="$(echo "${NEEDS}" | jq -r 'length')" | |
| if [ "${count}" -lt "${required_jobs}" ]; then | |
| echo "::error::ci-success is gating ${count} jobs but expects at least ${required_jobs} — a job was removed from its needs list. If that was deliberate, lower required_jobs in the same commit." | |
| exit 1 | |
| fi | |
| # Allowlist, not a denylist. A denylist of failure/cancelled let | |
| # `abandoned` through — the status GitHub assigns when its own | |
| # infrastructure kills a job — so a run where 7 of 9 jobs never | |
| # executed reported this gate GREEN. Observed on PR #306 during a | |
| # GitHub "Failed to resolve action download info" incident. | |
| # Anything that is not an actual pass now fails the gate. | |
| bad="$(echo "${NEEDS}" | jq -r ' | |
| [to_entries[] | |
| | select(.value.result != "success" and .value.result != "skipped") | |
| | "\(.key)=\(.value.result)"] | |
| | join(" ")')" | |
| if [ -n "${bad}" ]; then | |
| echo "::error::a required CI job did not pass: ${bad}" | |
| exit 1 | |
| fi | |
| # `changes` gates every other job, so if IT fails they all report | |
| # `skipped` — which the allowlist accepts — and this gate would go | |
| # green with nothing having been built. Same greenwash shape as | |
| # #306, one level further up. Its result is already covered above | |
| # (it is in `needs`), but assert it explicitly rather than relying on | |
| # a reader noticing why a `skipped` allowance is safe here. | |
| changes_result="$(echo "${NEEDS}" | jq -r '.changes.result')" | |
| if [ "${changes_result}" != "success" ]; then | |
| echo "::error::the 'changes' job did not succeed (${changes_result}) — every other job's 'skipped' is meaningless" | |
| exit 1 | |
| fi | |
| echo "all good" |