Repository navigation
1135 lines (992 loc) · 49.6 KB
/
Copy pathci.yml
File metadata and controls
1135 lines (992 loc) · 49.6 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
on:
push:
branches: [main]
pull_request:
branches: [main]
permissions:
contents: read
# Cancel outdated runs on the same PR — only latest commit matters
concurrency:
group: ci-${{ github.event.pull_request.number || github.sha }}
cancel-in-progress: true
env:
CARGO_TERM_COLOR: always
RUST_BACKTRACE: 1
# CI builds are always from a cache-restored or cold state; incremental
# compilation only adds overhead and bloats the rust-cache snapshots.
CARGO_INCREMENTAL: 0
jobs:
fmt:
name: Format
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: dtolnay/rust-toolchain@stable
with:
components: rustfmt
- name: Format check
run: cargo fmt --all -- --check
licenses:
name: License Consistency
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- name: License consistency check
run: ./scripts/check-license-consistency.sh
# crates.io renders a crate's README from its archive and a published
# version is immutable, so a crate that ships without one stays bare on
# the registry until the next release. Cargo infers the README silently
# and omits it just as silently, so nothing else catches the miss.
- name: Crate README presence
run: ./scripts/check-crate-readmes.sh
# The NIP-44 vectors are a crypto conformance fixture vendored into the
# tree. Pinning them to the checksum the spec publishes is what stops a
# green suite from being achieved by editing the vectors.
- name: NIP-44 vector integrity
run: ./scripts/check-nip44-vectors.sh
# The NIP-44 vectors above are vendored, so a checksum the upstream spec
# publishes is what pins them. The spec vectors in this repository have no
# such external publisher: they are pinned instead by being regenerable from
# a second implementation that never reads the crates under test.
#
# Without this job the Rust tests still compare code against the vector
# files, but nothing compares the vector files against anything, so a red
# test could be turned green by editing an expectation -- exactly the move
# every vector file's header forbids. Regenerating and diffing makes that
# edit impossible without also editing the generator, and that diff reads as
# what it is: a change to the specified wire format.
spec-vectors:
name: Spec Vector Provenance
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-python@v7
with:
python-version: "3.12"
# Standard library only, by design: the generator is a conformance
# artifact, and a dependency is a second thing that has to be trusted to
# compute a wire format correctly.
- name: Regenerate the spec vectors and fail on any difference
run: python3 tools/spec-vectors/generate.py --check
# The License Consistency job catches the three THIRD-PARTY-NOTICES.md copies
# drifting from each other, but a dependency bump leaves all three identical
# and uniformly stale — regenerating after Cargo.lock changes was manual
# discipline with nothing to catch a miss. Mirror the python-bindings job's
# regenerate-and-diff pattern: rerun cargo-about and fail on any diff.
#
# cargo-about consults clearlydefined.io for license texts, so in rare cases
# an upstream data change can surface here as drift with no Cargo.lock change;
# the fix is the same either way — regenerate and commit.
notices:
name: Third-Party Notices Drift
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: dtolnay/rust-toolchain@stable
# The cargo-about version is pinned in the regeneration script (output
# formatting changes across releases would read as false drift); read it
# from there so this job can never disagree with local regeneration.
- name: Read pinned cargo-about version
id: pin
run: |
version="$(sed -n 's/^CARGO_ABOUT_VERSION="\(.*\)"$/\1/p' scripts/generate-third-party-notices.sh)"
test -n "$version"
echo "version=$version" >> "$GITHUB_OUTPUT"
- name: Cache cargo-about binary
uses: actions/cache@v4
with:
path: ~/.cargo/bin/cargo-about
key: cargo-about-${{ steps.pin.outputs.version }}-${{ runner.os }}
# `--features cli` is required: without it the install exits 0 but ships
# no binary.
- name: Install cargo-about
run: |
if ! cargo about --version 2>/dev/null | grep -qFx "cargo-about ${{ steps.pin.outputs.version }}"; then
cargo install cargo-about --version "${{ steps.pin.outputs.version }}" --features cli --locked --force
fi
- name: Regenerate third-party notices
run: ./scripts/generate-third-party-notices.sh
- name: Fail on stale THIRD-PARTY-NOTICES.md
run: |
if ! git diff --exit-code -- \
THIRD-PARTY-NOTICES.md \
bindings/react-native/THIRD-PARTY-NOTICES.md \
bindings/python/THIRD-PARTY-NOTICES.md; then
echo "::error::THIRD-PARTY-NOTICES.md is stale — the shipped-binary dependency graph changed without regenerating attribution."
echo "Run scripts/generate-third-party-notices.sh and commit the result."
exit 1
fi
clippy:
name: Clippy
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: dtolnay/rust-toolchain@stable
with:
components: clippy
- uses: Swatinem/rust-cache@v2
with:
shared-key: ci-clippy
- name: Clippy
run: cargo clippy --workspace --locked -- -D warnings
# The messaging-only build. `data` is a default-on feature so that a
# native consumer can drop the CRDT engine and its ~1.5 MB, and every
# other gate in this file builds default features only — without this
# step the opt-out path we promise crates.io consumers would never be
# compiled at all, and would rot behind a `#[cfg]` nobody exercises.
- name: Clippy (no default features)
# No `--all-targets`, matching the workspace job above: the lint set
# includes `unwrap_used = deny`, which test code across the workspace
# does not satisfy, so linting test targets fails on main today.
run: cargo clippy --package offline-protocol --no-default-features --locked -- -D warnings
docs:
name: Rustdoc
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
with:
shared-key: ci-docs
# Gates the *public* doc surface, so no --document-private-items: a
# public item linking to a private one is precisely the breakage this
# catches (the link silently resolves to nothing for every reader who
# is not building the crate themselves).
- name: Build docs
run: cargo doc --workspace --no-deps --locked
env:
RUSTDOCFLAGS: -D warnings
msrv:
name: MSRV (Rust 1.87)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: dtolnay/rust-toolchain@1.87.0
- uses: Swatinem/rust-cache@v2
with:
shared-key: ci-msrv
- name: Check workspace on MSRV
run: cargo check --workspace --all-targets --locked
embedded-core:
name: Embedded core (bare metal)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: dtolnay/rust-toolchain@stable
with:
targets: thumbv8m.main-none-eabihf,riscv32imac-unknown-none-elf,riscv32imc-unknown-none-elf,thumbv6m-none-eabi,wasm32-wasip1
components: clippy, llvm-tools
- uses: Swatinem/rust-cache@v2
with:
shared-key: ci-embedded
# The gate. `offline-protocol-core` and `offline-protocol-sealed` are the
# two crates a constrained leaf node links, and nothing else in this file
# compiles either without `std`. Without these steps the no_std
# configuration rots back to std-only the first time someone adds a
# `use std::` to a file that has no reason to look bare-metal-specific,
# and the failure surfaces on someone's hardware rather than here.
- name: Build core for bare metal
run: >
cargo build --package offline-protocol-core
--no-default-features --locked --target thumbv8m.main-none-eabihf
- name: Clippy core for bare metal
run: >
cargo clippy --package offline-protocol-core
--no-default-features --locked --target thumbv8m.main-none-eabihf
-- -D warnings
# Publish isolation. Core declares seven dependencies locally rather than
# inheriting them, which is what makes `default-features = false` take
# effect; the cost is that a missing `std` re-add is invisible inside the
# workspace, where another member's features paper over it, and only
# fails when cargo builds the crate on its own at release time. Selecting
# the single package reproduces that isolation here instead.
- name: Build core standalone with default features
run: cargo build --package offline-protocol-core --locked
# The sealed layer: the envelope codec, address derivation and canonical
# signing payloads a leaf shares with the engine (ADR 0022). It carries
# the only crypto dependency in the bare-metal build, and the same two
# traps as core: local dependency declarations that must keep
# `default-features = false`, and a `std` feature that is invisible to
# lose inside the workspace.
- name: Build the sealed layer for bare metal
run: >
cargo build --package offline-protocol-sealed
--no-default-features --locked --target thumbv8m.main-none-eabihf
- name: Clippy the sealed layer for bare metal
run: >
cargo clippy --package offline-protocol-sealed
--no-default-features --locked --target thumbv8m.main-none-eabihf
-- -D warnings
- name: Build the sealed layer standalone with default features
run: cargo build --package offline-protocol-sealed --locked
# The leaf node itself. `bare-metal-rng` selects getrandom's custom
# backend, which the target needs because getrandom has none for it and
# refuses to compile otherwise; the crate still registers no
# implementation, because that is the firmware's TRNG to wire up.
#
# This step is the only thing that catches an mls-rs error formatted with
# `{}`: mls-rs implements `Display` on its error only under `std`, so the
# host build accepts what the device build rejects.
- name: Build the leaf node for bare metal
run: >
cargo build --package offline-protocol-leaf
--no-default-features --features bare-metal-rng
--locked --target thumbv8m.main-none-eabihf
- name: Clippy the leaf node for bare metal
run: >
cargo clippy --package offline-protocol-leaf
--no-default-features --features bare-metal-rng
--locked --target thumbv8m.main-none-eabihf
-- -D warnings
# The same three crates beyond the Cortex-M33. `riscv32imac` stands for
# ESP32-C6 and ESP32-H2. The other two are the processors with no atomic
# compare-and-swap at all: `riscv32imc` for ESP32-C3 and ESP32-C2,
# `thumbv6m` for every Cortex-M0. On those `alloc::sync` does not exist,
# so an `Arc` imported from it compiles on every host and in every test
# and fails only here. `build` runs as well as `clippy` because the
# atomics fallbacks are partly inline assembly, which only code
# generation reaches.
#
# What this does not prove: nothing is linked into an image and nothing
# runs on a board. ESP32-C3 and ESP32-C2 also cannot be built on esp-hal
# today, whatever this crate does: mls-rs selects portable-atomic's
# `critical-section` backend on them, esp-hal selects
# `unsafe-assume-single-core`, and portable-atomic refuses the pair.
- name: Build and clippy the bare-metal crates on RISC-V and Cortex-M0
run: |
set -euo pipefail
for target in riscv32imac-unknown-none-elf riscv32imc-unknown-none-elf thumbv6m-none-eabi; do
for crate in offline-protocol-core offline-protocol-sealed offline-protocol-leaf; do
features=()
if [ "$crate" = offline-protocol-leaf ]; then
features=(--features bare-metal-rng)
fi
echo "::group::$crate on $target"
cargo build --package "$crate" --no-default-features ${features[@]+"${features[@]}"} \
--locked --target "$target"
cargo clippy --package "$crate" --no-default-features ${features[@]+"${features[@]}"} \
--locked --target "$target" -- -D warnings
echo "::endgroup::"
done
done
- name: Build the leaf node standalone with default features
run: cargo build --package offline-protocol-leaf --locked
# The leaf on a WebAssembly host, on the WASI target. Two halves are
# pinned. The `no_std` half builds with no `bare-metal-rng`, because on
# `wasm32-wasip1` getrandom's backend is chosen by the target ahead of
# any feature and reads the runtime's `random_get`: the host's entropy
# is what the MLS library draws, and there is no symbol for the host to
# register. The `std` half builds through the host shim example, which
# is the shape a runtime would run, so the example cannot rot into a
# file nobody compiles.
#
# `wasm32-unknown-unknown` is deliberately not claimed. On that target
# the pinned mls-rs enables getrandom's `js` feature, and getrandom 0.2
# selects `js` ahead of `custom`, so a host that registered its own
# entropy backend would supply a symbol nothing calls and the module
# would import browser glue. A green build there proves nothing about a
# non-browser host, which is why this job builds the one target where
# the claim is true and no other.
- name: Build the leaf node for WASI
run: >
cargo build --package offline-protocol-leaf
--no-default-features --locked --target wasm32-wasip1
- name: Clippy the leaf node for WASI
run: >
cargo clippy --package offline-protocol-leaf
--no-default-features --locked --target wasm32-wasip1
-- -D warnings
- name: Build the WASI host shim example
run: >
cargo build --package offline-protocol-leaf --example wasi_host_shim
--locked --target wasm32-wasip1
# `embedded-footprint` has its own workspace, so the repo's Clippy job
# does not reach it. The leaf configurations are linted individually
# because each is a separate link with a different feature set.
- name: Clippy the footprint harness
working-directory: tools/embedded-footprint
run: |
for features in "" "leaf" "leaf-full"; do
cargo clippy --release --locked --target thumbv8m.main-none-eabihf \
${features:+--features "$features"} -- -D warnings
done
# Report-only: the size is information, not a threshold. A number that
# fails a build invites tuning the number.
- name: Measure embedded footprint
run: ./tools/embedded-footprint/measure.sh >> "$GITHUB_STEP_SUMMARY"
mls-interop:
name: MLS interop (phone <-> leaf)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: dtolnay/rust-toolchain@stable
with:
components: clippy
- uses: Swatinem/rust-cache@v2
with:
shared-key: ci-mls-interop
workspaces: tools/mls-interop
# The workspace Clippy and Format jobs stop at the SDK's workspace
# boundary, and this crate has its own. Without this step it is the only
# Rust in the repo that nothing lints.
- name: Clippy
working-directory: tools/mls-interop
run: cargo clippy --locked --all-targets -- -D warnings
# The other half of ADR 0021. `embedded-footprint` proves a leaf's MLS
# stack fits; this proves it is talking to the phone rather than only to
# itself. Both library versions are pinned, so this fails when a bump
# changes behaviour, which is the point: an interop claim is about two
# specific versions.
#
# The step 0 lines are negative controls: each restores one mls-rs
# default and requires the phone to refuse the result, except 0.3, which
# pins a cap OpenMLS declares and never applies. If any of them flips,
# read the harness README before touching anything.
- name: Phone and leaf MLS stacks interoperate
working-directory: tools/mls-interop
run: cargo run --release --locked
test:
name: Test (${{ matrix.os }})
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
include:
- os: ubuntu-latest
release_target: x86_64-unknown-linux-gnu
- os: macos-latest
release_target: aarch64-apple-darwin
- os: windows-latest
release_target: x86_64-pc-windows-msvc
steps:
- uses: actions/checkout@v7
- uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.release_target }}
- uses: Swatinem/rust-cache@v2
with:
shared-key: ci-test
- name: Test
run: cargo test --workspace --locked
- name: Build release cdylib
run: cargo build --release --locked --target ${{ matrix.release_target }} --package offline-protocol-uniffi
security:
name: Dependency Audit
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: EmbarkStudios/cargo-deny-action@v2
python-bindings:
name: Python Bindings
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
# Static — no bindgen, no toolchain — so a regression in the
# single-entry-point rule fails in seconds rather than after the install
# and build below. The drift gate at the end of this job only catches a
# partial regeneration that someone actually committed; this catches the
# script that would produce one.
- name: Guard the shared binding generator
run: |
shellcheck --severity=warning \
scripts/generate-bindings.sh \
scripts/tests/test-generate-bindings.sh
bash scripts/tests/test-generate-bindings.sh
# The container image's entrypoint and the demo client: nothing else
# runs them, and the entrypoint's flag order is a contract (an explicit
# flag must win over its variable).
- name: Guard the container entrypoint and the demo client
run: |
shellcheck --severity=warning \
bindings/python/docker/entrypoint.sh \
examples/http-front/client.sh \
scripts/tests/test-docker-entrypoint.sh
bash scripts/tests/test-docker-entrypoint.sh
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
with:
shared-key: ci-python-bindings
- uses: actions/setup-python@v7
with:
python-version: "3.12"
cache: "pip"
cache-dependency-path: bindings/python/pyproject.toml
# Keep in sync with the `uniffi` pin in crates/offline-protocol-uniffi/Cargo.toml;
# scripts/generate-bindings.sh fails the build if the two disagree.
- name: Install uniffi-bindgen
run: cargo install uniffi --version 0.30.0 --features cli --locked
# Delete the generated files first, so the drift gate below proves they
# were *written* and not merely unchanged. `git diff --exit-code` on a
# path nothing wrote to reports no diff, which makes "up to date" and
# "never regenerated" indistinguishable — so a mistyped output path in
# the generator would sail through this job green while every UDL change
# afterwards shipped stale bindings. Deleting turns that into a diff.
#
# Only the generated members: `java/` also holds hand-written
# com/offlineprotocol/**, and the Python package dir is mostly
# hand-written too. `git diff` restores nothing, so if a later step fails
# the checkout is discarded with the runner.
- name: Remove generated bindings so regeneration must replace them
run: |
rm -rf bindings/react-native/ios/Generated \
bindings/react-native/android/src/main/java/uniffi
rm -f bindings/python/offline_protocol_sdk/offline_protocol.py
# Regenerate before building anything. Bindgen reads the UDL and nothing
# else, so putting this ahead of the cdylib compile means drift surfaces
# in seconds rather than after a full debug build — and a broken desktop
# build stops costing us Swift/Kotlin drift coverage it never needed.
- name: Regenerate all UniFFI bindings
run: bash scripts/generate-bindings.sh
# One regeneration, one gate: the three files are one artifact set off one
# UDL, so checking them separately would let a half-updated commit pass
# the half of the gate it happened to satisfy.
- name: Fail on stale bindings
run: |
if ! git diff --exit-code -- \
bindings/python/offline_protocol_sdk/offline_protocol.py \
bindings/react-native/ios/Generated/ \
bindings/react-native/android/src/main/java/uniffi/; then
echo "::error::Committed UniFFI bindings are out of date."
echo "Run: bash scripts/generate-bindings.sh and commit all three."
exit 1
fi
# Builds the desktop cdylib pytest loads. It re-runs the generator on the
# way out, which is idempotent and is what keeps the one-entry-point rule
# true for the script developers actually invoke.
- name: Build the desktop library
run: bash bindings/python/scripts/build-desktop.sh
- name: Install Python binding
run: pip install -e 'bindings/python[dev]'
- name: Run pytest
run: pytest bindings/python/tests/ -v
# Again on the newest Python the package declares, over the same built
# library. Interpreter changes reach the bindings' lifecycle: on 3.13+
# an executor hand-off can complete without suspending, which once left
# a stopped manager pinned (and its store locks held) where 3.12 never
# showed it.
- uses: actions/setup-python@v7
with:
python-version: "3.13"
cache: "pip"
cache-dependency-path: bindings/python/pyproject.toml
- name: Run pytest on Python 3.13
run: |
pip install -e 'bindings/python[dev]'
pytest bindings/python/tests/ -v
# And on the oldest it declares. Collection timing differs at that end
# too: on 3.10 an exception a teardown caught kept the stopped manager
# (and its store locks) alive until the collector ran, where every
# later Python frees it at once.
- uses: actions/setup-python@v7
with:
python-version: "3.10"
cache: "pip"
cache-dependency-path: bindings/python/pyproject.toml
- name: Run pytest on Python 3.10
run: |
pip install -e 'bindings/python[dev]'
pytest bindings/python/tests/ -v
# The Python suite on the two platforms the job above never reaches, from
# a wheel built the way the release builds one. release.yml installs each
# wheel on its platform and runs the suite before publishing; without this
# its first execution on macOS and Windows would be inside a release. The
# Linux leg is the job above. A debug library: under test are the suite on
# this platform and the wheel's plumbing, not the optimised build.
python-wheel-platforms:
name: Python Wheel (${{ matrix.platform }})
runs-on: ${{ matrix.runner }}
strategy:
fail-fast: false
matrix:
include:
- platform: macos-arm64
runner: macos-14
target: aarch64-apple-darwin
library: liboffline_protocol_uniffi.dylib
- platform: windows-x86_64
runner: windows-latest
target: x86_64-pc-windows-msvc
library: offline_protocol_uniffi.dll
steps:
- uses: actions/checkout@v7
- uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
- uses: Swatinem/rust-cache@v2
with:
shared-key: ci-python-wheel-${{ matrix.platform }}
- uses: actions/setup-python@v7
with:
python-version: "3.12"
# Keep in sync with the `uniffi` pin in crates/offline-protocol-uniffi/Cargo.toml.
- name: Cache uniffi-bindgen
id: cache-uniffi
uses: actions/cache@v6
with:
path: ~/.cargo/bin/uniffi-bindgen*
key: uniffi-bindgen-${{ runner.os }}-0.30.0
- name: Install uniffi-bindgen
if: steps.cache-uniffi.outputs.cache-hit != 'true'
run: cargo install uniffi --version 0.30.0 --features cli --locked
- name: Build the desktop library and the bindings
shell: bash
run: bash bindings/python/scripts/build-desktop.sh --target ${{ matrix.target }} --debug
# Safe: matrix values are author-controlled literals, not external input.
- name: Build the wheel
shell: bash
run: |
python -m pip install build wheel
bash bindings/python/scripts/build-wheel.sh ${{ matrix.platform }} \
"target/${{ matrix.target }}/debug/${{ matrix.library }}" 0.0.0-dev
- name: Install the wheel
shell: bash
run: |
set -euo pipefail
WHEELS=(bindings/python/dist/*.whl)
[ "${#WHEELS[@]}" = 1 ] || { echo "::error::expected one wheel, found ${#WHEELS[*]}"; exit 1; }
python -m pip install "${WHEELS[0]}[dev]"
# From inside tests/, as the release does: the source package has the
# library too here, so a test that reached it would pass against the
# wrong copy.
- name: Run the suite against the installed wheel
shell: bash
working-directory: bindings/python/tests
run: |
set -euo pipefail
python -I -c "import offline_protocol_sdk as m; print('testing', m.__file__)"
pytest -p no:cacheprovider .
# release.yml runs only on a `v*` tag, so every line of it used to reach
# production untested — the packaging step's first real execution would be
# during a release, with a half-built asset set as the failure mode. The
# logic lives in scripts/package-release-assets.sh precisely so it can be
# driven here against a fixture tree mirroring what the download steps
# produce, failure paths included.
release-packaging:
name: Release Packaging
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
# --severity=warning, not the default: this gates on real defects
# (unquoted expansions, masked exit codes) without failing the release
# pipeline over style preferences on a shellcheck version bump.
- name: Shellcheck the packaging scripts
run: |
shellcheck --severity=warning \
scripts/package-release-assets.sh \
scripts/tests/test-package-release-assets.sh \
scripts/pep440-version.sh \
scripts/tests/test-pep440-version.sh \
bindings/python/scripts/build-wheel.sh \
scripts/tests/test-build-wheel.sh \
bindings/react-native/scripts/shared/xcframework.sh \
bindings/react-native/scripts/build-uniffi-ios.sh \
scripts/tests/test-ios-min-os.sh
- name: Package release assets against a fixture tree
run: bash scripts/tests/test-package-release-assets.sh
# The wheel's number and its platform tag are the two claims a release
# makes about a wheel that nothing after it re-checks: pip on a newer
# runner accepts a tag that is too old.
- name: Test the Python version conversion
run: bash scripts/tests/test-pep440-version.sh
- uses: actions/setup-python@v7
with:
python-version: "3.12"
- name: Build wheels from libraries with a known glibc floor
run: |
python -m pip install build wheel
bash scripts/tests/test-build-wheel.sh
# The gate reads a real archive with otool, so it runs where the iOS
# library is built: in a release, on all three archives, and in the
# Swift Package job below, on the simulator archive of every pull
# request. This drives everything in it that is text handling, and the
# packaging function the gate sits inside, with stand-ins for otool,
# lipo and xcodebuild, on a runner that has none of them.
- name: Test the iOS deployment-target gate
run: bash scripts/tests/test-ios-min-os.sh
react-native-typecheck:
name: React Native Typecheck
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
# Node version and the install/build pair below deliberately mirror the
# npm-publish job in release.yml. `npm run build` (tsc) had been reachable
# *only* from that job, which runs immediately before `npm publish` — so a
# type error or a tsconfig incompatibility surfaced during a release
# instead of on the PR that caused it. A dependabot bump to TypeScript 7
# (#178) passed all of CI for exactly this reason while failing to build.
#
# This job does not run scripts/prepare-npm.sh: that validates the
# pre-built iOS XCFramework and Android .so binaries, which don't exist
# outside a release run. The packaging invariants that need no binaries
# are asserted directly below instead.
#
# Node 24 because release.yml is pinned there: it publishes to npm with
# trusted publishing, which needs the npm >= 11.5.1 that Node 24 bundles.
# Keeping the two in lockstep is the whole point of this job, so this
# version tracks that one rather than being chosen independently.
- uses: actions/setup-node@v7
with:
node-version: "24"
cache: "npm"
cache-dependency-path: bindings/react-native/package-lock.json
# iOS autolinking invariants (#312). None of these need a native binary,
# and every one of them failed *silently* before that fix: React Native
# resolves a dependency's podspec by globbing "*.podspec" in the package
# root without recursing, so a podspec under ios/ is invisible and the
# dependency is skipped with no error anywhere — the consumer only finds
# out at runtime, from a linking error whose remedy is "run pod install".
- name: Verify iOS autolinking invariants
working-directory: bindings/react-native
run: |
test -f MeshSdk.podspec || {
echo "::error::MeshSdk.podspec must stay at the package root — autolinking does not recurse into ios/"
exit 1
}
if compgen -G "ios/*.podspec" > /dev/null; then
echo "::error::A podspec under ios/ is invisible to React Native autolinking; keep it at the package root"
exit 1
fi
node -e '
const ios = require("./react-native.config.js")?.dependency?.platforms?.ios;
if (ios === null || ios === undefined) {
console.error("::error::react-native.config.js disables iOS autolinking (dependency.platforms.ios is " + ios + ")");
process.exit(1);
}
'
node -e '
if (!(require("./package.json").files || []).includes("MeshSdk.podspec")) {
console.error("::error::package.json files[] must ship MeshSdk.podspec, or the published package has no podspec at its root");
process.exit(1);
}
'
echo "iOS autolinking invariants OK"
- name: Install dependencies
working-directory: bindings/react-native
run: npm ci
- name: Typecheck TypeScript
working-directory: bindings/react-native
run: npm run build
# Behavior, not types: the TypeScript layer is the one part of the bridge
# neither the Rust text-guards (which read source as text) nor the
# Android/iOS harnesses (which cover the native halves) can exercise.
# See bindings/react-native/js-ci-harness/README.md.
- name: React Native JS behavior tests
working-directory: bindings/react-native
run: npm run test:js
android-unit-tests:
name: Android Unit Tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-java@v6
with:
distribution: temurin
java-version: "17"
# ubuntu-latest ships an Android SDK, but provision it explicitly so the
# toolchain (and license acceptance) doesn't drift out from under us.
# `packages` defaults to `tools platform-tools`; the obsolete `tools`
# package is gone from the SDK repository and sdkmanager exits 1 on it.
- uses: android-actions/setup-android@v4
with:
packages: platform-tools
- uses: gradle/actions/setup-gradle@v6
with:
gradle-version: "8.9"
# Runs the RN Android module's JVM unit tests (InboundFragmentBuffer,
# OutboundFragmentQueue, the GATT CCCD/long-read helpers, MeshController,
# ...) via a standalone harness, since the module can't build on its own.
# See bindings/react-native/android-ci-harness/README.md.
- name: Android unit tests
working-directory: bindings/react-native/android-ci-harness
run: gradle :offlineprotocol:testDebugUnitTest --no-daemon --stacktrace
ios-unit-tests:
name: iOS Unit Tests
runs-on: macos-latest
steps:
- uses: actions/checkout@v7
# Runs the RN iOS module's standalone policy-class suites (the relay
# translator/rate-limiter/presence/in-flight-tracker/timestamp helpers
# that mirror the Android module) via the SwiftPM harness in
# bindings/react-native/ios/Package.swift, since the pod can't build
# on its own outside an app workspace.
- name: iOS unit tests
run: swift test --package-path bindings/react-native/ios
# The largest bridge files are on Package.swift's `exclude:` list (they
# need CoreBluetooth / Network / React / the generated
# UniFFI module, none of which the SwiftPM harness can supply), so
# `swift test` above compiles none of them and every change to them has
# historically shipped with zero compile coverage. They CAN be
# type-checked directly against the iphoneos SDK plus the checked-in
# Generated/ modulemap, which at least catches API drift and syntax
# breakage before a release.
# `-typecheck` emits no binary; warnings do not fail the build.
#
# BleManager / WifiDirectManager / ReticulumManager joined this list in
# #300: only their mesh/ and ble/ collaborators were missing, and adding
# those three sources is enough to typecheck all of them.
# OfflineProtocolModule.swift needs real React headers, so it is checked
# by the next step instead.
- name: iOS bridge typecheck (files excluded from the SwiftPM harness)
working-directory: bindings/react-native/ios
run: |
xcrun --sdk iphoneos swiftc -typecheck \
-target arm64-apple-ios15.1 \
-sdk "$(xcrun --sdk iphoneos --show-sdk-path)" \
-Xcc -fmodule-map-file=Generated/offline_protocolFFI.modulemap \
-Xcc -IGenerated \
InternetManager.swift NostrManager.swift TransportManager.swift \
BleManager.swift WifiDirectManager.swift PeerStreamFraming.swift \
PeerStreamSession.swift ReticulumManager.swift mesh/Mesh.swift ble/MeshConnectionRegistry.swift \
ble/BleDiscoveryBootstrapPolicy.swift \
PeripheralRestorationAgeOutPolicy.swift \
BleHelloPolicy.swift BleMessageNotificationPolicy.swift \
BleAppTag.swift BleDensityPolicy.swift BleServiceInstanceSelection.swift \
Generated/offline_protocol.swift ForcedPresenceCheckQueue.swift \
AddressDeclarationPolicy.swift \
GatewayAttachPolicy.swift GatewayVerdictTracker.swift \
InboundFragmentBuffer.swift OutboundFragmentQueue.swift \
PeerIdentityBinding.swift RelayAnswerPrefixes.swift \
RelayControlOpTranslator.swift RelayGroupSnapshotBridge.swift \
RelayRateLimiter.swift LegacyRelayMessage.swift MonotonicClock.swift \
NostrQueryTracker.swift \
PresenceWatchPolicy.swift RecipientInFlightTracker.swift \
SocketGenerationTracker.swift SupersededLatchPolicy.swift \
RelayTimestamps.swift WriteStallWatchdog.swift
# OfflineProtocolModule.swift is the file every iOS app compiles through
# the podspec, and it had no compile coverage at all: a parameter named
# `transportType` shadowed the method `transportType(from:)` in two
# callers, main stopped building for every iOS app, and CI stayed green.
# This step typechecks it together with every other hand-written source,
# against the React Native headers from the pinned devDependency,
# through the symlink farm ios/BRIDGE_MAINTENANCE.md describes. The file
# list is a glob, not the enumerated list above, so a new source is
# covered without being registered here.
- uses: actions/setup-node@v7
with:
node-version: "24"
cache: "npm"
cache-dependency-path: bindings/react-native/package-lock.json
- name: Install React Native headers
working-directory: bindings/react-native
run: npm ci --ignore-scripts --no-audit --no-fund
- name: iOS bridge typecheck (OfflineProtocolModule.swift with React)
working-directory: bindings/react-native/ios
run: |
set -euo pipefail
RN="$(cd ../node_modules/react-native && pwd)"
OUT="$(mktemp -d)"
mkdir -p "$OUT/React" "$OUT/RCTDeprecation"
find "$RN/React" "$RN/Libraries" -name '*.h' -exec ln -sf {} "$OUT/React/" \;
find "$RN" -name 'RCTDeprecation.h' -exec ln -sf {} "$OUT/RCTDeprecation/" \;
printf '#import <React/RCTBridgeModule.h>\n#import <React/RCTEventEmitter.h>\n#import <React/RCTBridge.h>\n' > "$OUT/React/ReactUmbrella.h"
printf 'module React {\n header "ReactUmbrella.h"\n export *\n}\n' > "$OUT/React/module.modulemap"
printf 'module RCTDeprecation {\n header "RCTDeprecation.h"\n export *\n}\n' > "$OUT/RCTDeprecation/module.modulemap"
FILES=()
for f in *.swift; do [ "$f" = Package.swift ] || FILES+=("$f"); done
# An empty or partial list would pass vacuously.
printf '%s\n' "${FILES[@]}" | grep -qx OfflineProtocolModule.swift
xcrun --sdk iphoneos swiftc -typecheck \
-target arm64-apple-ios15.1 \
-sdk "$(xcrun --sdk iphoneos --show-sdk-path)" \
-I"$OUT" -Xcc -I"$OUT" \
-Xcc -fmodule-map-file=Generated/offline_protocolFFI.modulemap \
-Xcc -IGenerated \
"${FILES[@]}" \
mesh/Mesh.swift ble/MeshConnectionRegistry.swift \
ble/BleDiscoveryBootstrapPolicy.swift \
Generated/offline_protocol.swift
# ---------------------------------------------------------------------------
# Native packages (ADR 0025)
#
# The Swift package and the Android library are built from the bridge
# sources the React Native module compiles, where they are, less the files
# that need React. These jobs are the check that the rest still builds
# without it: a shared source that gains a React import passes every job
# above and fails here.
# ---------------------------------------------------------------------------
native-package-scripts:
name: Native Package Scripts
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- name: Shellcheck the packaging scripts
run: |
shellcheck --severity=warning \
scripts/assemble-swift-package.sh \
scripts/package-swiftpm-xcframework.sh \
scripts/ios-deployment-target.sh \
scripts/pick-ios-simulator.sh \
scripts/test-swift-package.sh \
scripts/tests/test-assemble-swift-package.sh \
scripts/publish-swift-package.sh \
scripts/swiftpm-archive-url.sh \
scripts/tests/test-publish-swift-package.sh \
scripts/maven-central-bundle.sh \
scripts/maven-central-upload.sh \
scripts/tests/test-maven-central.sh
# Exactly what the script writes, and what it refuses. The job below
# runs its success path only. Once a release pushes its output to a
# public repository and tags it, that cannot be taken back (ADR 0025).
- name: Assemble the Swift package against a fixture tree
run: bash scripts/tests/test-assemble-swift-package.sh
- name: Test the AAR check
run: python3 -B -m unittest scripts/tests/test_check_android_aar.py
# What the release does with the package and the library once they are
# built, against a local bare repository and a stand-in Portal. Both
# channels are permanent: a moved tag or a version on Maven Central
# cannot be taken back, so the refusals are pinned here, not found in a
# release.
- name: Publish the Swift package to a local repository
run: bash scripts/tests/test-publish-swift-package.sh
- name: Sign, bundle and upload the Android library to a stand-in Portal
run: bash scripts/tests/test-maven-central.sh
swift-package:
name: Swift Package
runs-on: macos-latest
# A simulator that does not boot holds a runner for six hours.
timeout-minutes: 45
steps:
- uses: actions/checkout@v7
- uses: dtolnay/rust-toolchain@stable
with:
targets: aarch64-apple-ios-sim,x86_64-apple-ios
- uses: Swatinem/rust-cache@v2
with:
# The deployment target is the podspec's, and cargo does not rebuild
# a Rust crate when IPHONEOS_DEPLOYMENT_TARGET changes. Without the
# hash a lowered target restores objects stamped for the old one, and
# the minimum-OS gate fails on them with no cargo clean to reach. It
# goes in shared-key: rust-cache ignores `key` when shared-key is set.
shared-key: ci-swift-package-${{ hashFiles('bindings/react-native/MeshSdk.podspec') }}
# One simulator slice, for the architecture of this runner. The release
# builds the device slice and both simulator architectures.
#
# For the pod's deployment target, which the C compiler learns only
# from the environment: unset, it stamps its objects for the SDK of
# this image, and every link below warns about each of them.
#
# Then the gate the release runs (docs/bridges/swift.md, S9), on the
# archive this job built. This is the one job that builds the iOS
# library on a pull request, so it is where a crate that stops honouring