-
Notifications
You must be signed in to change notification settings - Fork 0
1969 lines (1793 loc) · 88.9 KB
/
Copy pathci.yml
File metadata and controls
1969 lines (1793 loc) · 88.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
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