Repository navigation
1321 lines (1221 loc) · 63.1 KB
/
Copy pathbuild-release.yml
File metadata and controls
1321 lines (1221 loc) · 63.1 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: Build & Release Bundle
on:
push:
branches: [dev]
paths:
- "src-tauri/Cargo.toml" # Triggered by version bump
workflow_dispatch:
inputs:
skip_build:
description: "Skip build and use exe from previous release (for dependency-only updates)"
required: false
default: false
type: boolean
env:
CARGO_TERM_COLOR: always
jobs:
merge-to-main:
# Ports inherit this file on sync merges; only run it in the desktop repo.
if: github.repository == 'StreamNook/StreamNook' || github.repository == 'StreamNook/StreamNook'
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
token: ${{ secrets.GITHUB_TOKEN }}
- name: Merge dev to main
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git fetch origin
# Force-push dev to main (dev is source of truth)
git push origin origin/dev:main --force
# macOS build. Runs in PARALLEL with the Windows job and nothing waits on it:
# finalize-macos joins the two afterwards and patches this job's outputs into
# the manifest. A macOS failure therefore skips finalize-macos and leaves a
# Windows-only release standing, rather than holding up every Windows user.
build-macos:
needs: merge-to-main
runs-on: macos-latest
permissions:
contents: write
outputs:
BUNDLE_NAME: ${{ steps.pack.outputs.BUNDLE_NAME }}
SHA256: ${{ steps.pack.outputs.SHA256 }}
SIZE: ${{ steps.pack.outputs.SIZE }}
SIGNATURE: ${{ steps.sign.outputs.SIGNATURE }}
DOWNLOAD_URL: ${{ steps.pack.outputs.DOWNLOAD_URL }}
steps:
- name: Checkout main branch
uses: actions/checkout@v4
with:
ref: main
# Release-exists guard, the macOS twin of the Windows job's check. This
# workflow fires on ANY `dev` push that touches Cargo.toml, release or
# not, and every upload below is `--clobber`. Without this guard a
# dependency bump after a release would rebuild the macOS bundle from a
# different tree and overwrite the published assets under the same tag,
# so an installed client's pinned hash would no longer match what the
# release serves. When the tag already exists, every step from here on
# is skipped and the job's outputs stay empty; the `build` job already
# treats empty macOS outputs as "no macOS entry in the manifest".
- name: Check if release already exists
id: check_release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
version=$(grep '^version' src-tauri/Cargo.toml | head -1 | sed 's/.*"\(.*\)".*/\1/')
if gh release view "v$version" >/dev/null 2>&1; then
echo "RELEASE_EXISTS=true" >> "$GITHUB_OUTPUT"
echo "Release v$version already exists; skipping the macOS build so its published assets are not clobbered"
else
echo "RELEASE_EXISTS=false" >> "$GITHUB_OUTPUT"
echo "Release v$version does not exist; building the macOS bundle"
fi
- name: Setup Node.js
if: steps.check_release.outputs.RELEASE_EXISTS == 'false'
uses: actions/setup-node@v4
with:
node-version: 20
- name: Setup Rust
if: steps.check_release.outputs.RELEASE_EXISTS == 'false'
uses: dtolnay/rust-toolchain@stable
with:
# Both halves of the universal binary. `universal-apple-darwin` is not
# a toolchain of its own: tauri builds each arch and `lipo`s them, so
# both must be installed or the build fails at the second one.
targets: aarch64-apple-darwin,x86_64-apple-darwin
- name: Rust cache
if: steps.check_release.outputs.RELEASE_EXISTS == 'false'
uses: Swatinem/rust-cache@v2
with:
workspaces: "./src-tauri -> target"
key: macos
- name: Install dependencies
if: steps.check_release.outputs.RELEASE_EXISTS == 'false'
run: npm ci
- name: Build Tauri app (universal)
if: steps.check_release.outputs.RELEASE_EXISTS == 'false'
run: npm run tauri build -- --target universal-apple-darwin
env:
# Same values the Windows job uses; the client IDs are public by
# construction (see the comment on the Windows build step).
VITE_SUPABASE_URL: ${{ secrets.VITE_SUPABASE_URL }}
VITE_SUPABASE_ANON_KEY: ${{ secrets.VITE_SUPABASE_ANON_KEY }}
TWITCH_APP_CLIENT_ID: "1qgws7yzcp21g5ledlzffw3lmqdvie"
TWITCH_APP_CLIENT_SECRET: ${{ secrets.TWITCH_CLIENT_SECRET }}
TWITCH_ANDROID_CLIENT_ID: "kd1unb4b3q4t58fwlpcbzcbnm76a8fp"
TWITCH_WEB_CLIENT_ID: "kimne78kx3ncx6brgo4mv6wki5h1ko"
KICK_APP_CLIENT_ID: "01KMKM0HVR6HY0BMA101JFCTW3"
KICK_APP_CLIENT_SECRET: ${{ secrets.KICK_APP_CLIENT_SECRET }}
- name: Pack the .app into a tarball
if: steps.check_release.outputs.RELEASE_EXISTS == 'false'
id: pack
run: |
set -euo pipefail
version=$(grep '^version' src-tauri/Cargo.toml | head -1 | sed 's/.*"\(.*\)".*/\1/')
app_dir="src-tauri/target/universal-apple-darwin/release/bundle/macos"
test -d "$app_dir/StreamNook.app" || { echo "no .app at $app_dir"; exit 1; }
bundle="StreamNook.app.tar.gz"
# -C so the archive root IS StreamNook.app, not the path to it: the
# updater untars straight over the installed bundle.
#
# macOS tar writes ._* AppleDouble entries for extended attributes,
# which are noise at best and confuse strict extractors. COPYFILE_DISABLE
# suppresses them.
COPYFILE_DISABLE=1 tar -czf "$bundle" -C "$app_dir" StreamNook.app
echo "BUNDLE_NAME=$bundle" >> "$GITHUB_OUTPUT"
echo "SHA256=$(shasum -a 256 "$bundle" | cut -d' ' -f1)" >> "$GITHUB_OUTPUT"
echo "SIZE=$(stat -f%z "$bundle")" >> "$GITHUB_OUTPUT"
echo "DOWNLOAD_URL=https://streamnook.app/download/mac" >> "$GITHUB_OUTPUT"
echo "Packed $bundle ($(du -h "$bundle" | cut -f1)) for v$version"
- name: Sign the tarball with the update key
if: steps.check_release.outputs.RELEASE_EXISTS == 'false'
id: sign
env:
UPDATE_SIGNING_KEY: ${{ secrets.UPDATE_SIGNING_KEY }}
run: |
set -euo pipefail
bundle="${{ steps.pack.outputs.BUNDLE_NAME }}"
if [ -z "${UPDATE_SIGNING_KEY:-}" ]; then
echo "UPDATE_SIGNING_KEY not set - publishing an UNSIGNED macOS update"
echo "SIGNATURE=" >> "$GITHUB_OUTPUT"
exit 0
fi
# The SAME key that signs the Windows bundle. The client pins one
# public half as a compile-time const; a second key would mean a second
# pinned constant, and the value of pinning is that there is one secret
# to protect.
brew install minisign
printf '%s' "$UPDATE_SIGNING_KEY" > ./update.key
trap 'rm -f ./update.key' EXIT
minisign -S -s ./update.key -m "$bundle" -x "$bundle.minisig"
# Collapse to one line: the manifest carries it as a JSON string, and
# the client verifies the signature bytes, not the comment lines.
echo "SIGNATURE<<EOF" >> "$GITHUB_OUTPUT"
cat "$bundle.minisig" >> "$GITHUB_OUTPUT"
echo "EOF" >> "$GITHUB_OUTPUT"
echo "Signed $bundle"
# The GitHub release is created by the Windows `build` job, which runs
# AFTER this one (it needs our outputs for the manifest). So this job can
# never wait for the release: it would wait forever, fail, and take the
# Windows release down with it. Hand the bundle over as a workflow
# artifact instead; `build` attaches it right after `gh release create`.
- name: Hand the macOS bundle to the release job
if: steps.check_release.outputs.RELEASE_EXISTS == 'false'
uses: actions/upload-artifact@v4
with:
name: macos-bundle
path: |
${{ steps.pack.outputs.BUNDLE_NAME }}
${{ steps.pack.outputs.BUNDLE_NAME }}.minisig
if-no-files-found: error
retention-days: 3
- name: Push macOS bundle to Cloudflare R2
if: steps.check_release.outputs.RELEASE_EXISTS == 'false'
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
run: |
set -euo pipefail
bundle="${{ steps.pack.outputs.BUNDLE_NAME }}"
npx --yes wrangler@4 r2 object put "streamnook-downloads/mac/$bundle" \
--file="$bundle" \
--content-type="application/gzip" \
--remote
echo "Uploaded mac/$bundle to R2"
# NOT NOTARIZED YET. minisign proves the bundle came from us; Gatekeeper
# does not care about minisign and wants a Developer ID signature plus
# notarization, which needs the paid Apple Developer Program.
#
# Consequence, precisely: an update installed BY THE UPDATER works, because
# a programmatic download carries no com.apple.quarantine attribute. A
# bundle a human downloads from the website WILL be quarantined and
# Gatekeeper-blocked. So this pipeline is correct for updates and not yet
# correct for first-time installs.
# Linux build. Same independent-and-joined-afterwards shape as build-macos:
# runs in parallel with Windows, nothing waits on it, and finalize-linux
# patches its outputs into the manifest once both have landed. A Linux failure
# leaves the Windows (and macOS) release standing.
build-linux:
needs: merge-to-main
# Linux releases are opt-in: they ship only while the repository variable
# LINUX_RELEASE is "true". Unset or anything else, a release is Windows and
# macOS only, and the manifest carries no linux-x86_64 entry at all (see the
# manifest step), so Linux clients stay on their build and are told "no
# update" instead of being offered the old AppImage under a newer version.
if: vars.LINUX_RELEASE == 'true'
runs-on: ubuntu-22.04
# NOT ubuntu-latest, and this is not stylistic. glibc is forward-compatible
# only: a binary linked against 24.04's glibc refuses to start on 22.04 with
# `GLIBC_2.38 not found`, so the OLDEST supported runner is the correct
# builder and `ubuntu-latest` silently moves that floor under us on GitHub's
# schedule rather than ours. Raise this deliberately or not at all.
permissions:
contents: write
outputs:
BUNDLE_NAME: ${{ steps.pack.outputs.BUNDLE_NAME }}
SHA256: ${{ steps.pack.outputs.SHA256 }}
SIZE: ${{ steps.pack.outputs.SIZE }}
SIGNATURE: ${{ steps.sign.outputs.SIGNATURE }}
DOWNLOAD_URL: ${{ steps.pack.outputs.DOWNLOAD_URL }}
steps:
- name: Checkout main branch
uses: actions/checkout@v4
with:
ref: main
# Release-exists guard, the Linux twin of the macOS and Windows checks.
# Same reasoning: every upload below is --clobber, and this workflow fires
# on ANY dev push touching Cargo.toml, so without the guard a dependency
# bump after a release would rebuild from a different tree and overwrite
# published assets under the same tag, breaking every installed client's
# pinned hash.
- name: Check if release already exists
id: check_release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
version=$(grep '^version' src-tauri/Cargo.toml | head -1 | sed 's/.*"\(.*\)".*/\1/')
if gh release view "v$version" >/dev/null 2>&1; then
echo "RELEASE_EXISTS=true" >> "$GITHUB_OUTPUT"
echo "Release v$version already exists; skipping the Linux build so its published assets are not clobbered"
else
echo "RELEASE_EXISTS=false" >> "$GITHUB_OUTPUT"
echo "Release v$version does not exist; building the Linux bundle"
fi
# Verified against this exact set on Ubuntu 26.04 before the job existed:
# webkit2gtk-4.1, javascriptcoregtk-4.1, gtk+-3.0, libsoup-3.0,
# ayatana-appindicator3-0.1, librsvg-2.0, openssl and libwebp all resolve,
# and the whole crate graph checks and tests clean with no cfg changes.
#
# libssl-dev - openssl-sys, pulled in by tokio-tungstenite's native-tls
# (reqwest is on rustls, tungstenite is not)
# libwebp-dev - libwebp-sys2, via webp-animation
# appindicator + rsvg - the tray icon
# patchelf, file, desktop-file-utils - the AppImage bundler's own tools
# xdg-utils - the bundler shells out to `xdg-mime` and dies with
# "xdg-mime binary not found" without it. Found by running
# this build for real: the Rust half finished and only the
# bundling step failed, which is the one part a `cargo
# build` can never tell you about.
#
# THE GSTREAMER PLUGINS ARE NOT OPTIONAL, and their absence is silent.
# WebKitGTK answers `MediaSource.isTypeSupported()` out of whatever
# GStreamer decoders it can find, and hls.js gates the entire player on
# `isTypeSupported('video/mp4; codecs="avc1.42E01E,mp4a.40.2"')`. With only
# plugins-base and plugins-good installed, MediaSource and SourceBuffer both
# EXIST and every codec query returns false, so the build succeeds, the app
# launches, streams resolve with a full quality ladder, and the player dies
# with "HLS is not supported in this browser". Measured on a real build:
#
# base+good only : h264+aac false, h264 false, aac false, vp9 false
# + libav/bad/ugly: h264+aac TRUE, h264 TRUE, aac TRUE, vp9 TRUE
#
# linuxdeploy-plugin-gstreamer bundles whatever is installed HERE into the
# AppImage, so this list is what every Linux user's decoder set will be.
# vp9 is in that list for YouTube playback, not for Twitch.
- name: Install Linux build dependencies
if: steps.check_release.outputs.RELEASE_EXISTS == 'false'
run: |
set -euo pipefail
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
libwebkit2gtk-4.1-dev \
libjavascriptcoregtk-4.1-dev \
libgtk-3-dev \
libayatana-appindicator3-dev \
librsvg2-dev \
libssl-dev \
libwebp-dev \
libsoup-3.0-dev \
patchelf \
file \
desktop-file-utils \
xdg-utils \
gstreamer1.0-plugins-base \
gstreamer1.0-plugins-good \
gstreamer1.0-plugins-bad \
gstreamer1.0-plugins-ugly \
gstreamer1.0-libav
- name: Setup Node.js
if: steps.check_release.outputs.RELEASE_EXISTS == 'false'
uses: actions/setup-node@v4
with:
node-version: 20
- name: Setup Rust
if: steps.check_release.outputs.RELEASE_EXISTS == 'false'
uses: dtolnay/rust-toolchain@stable
- name: Rust cache
if: steps.check_release.outputs.RELEASE_EXISTS == 'false'
uses: Swatinem/rust-cache@v2
with:
workspaces: "./src-tauri -> target"
key: linux
- name: Install dependencies
if: steps.check_release.outputs.RELEASE_EXISTS == 'false'
run: npm ci
- name: Build Tauri app
if: steps.check_release.outputs.RELEASE_EXISTS == 'false'
run: npm run tauri build
env:
# Same values the Windows and macOS jobs use; the client IDs are
# public by construction (see the comment on the Windows build step).
VITE_SUPABASE_URL: ${{ secrets.VITE_SUPABASE_URL }}
VITE_SUPABASE_ANON_KEY: ${{ secrets.VITE_SUPABASE_ANON_KEY }}
TWITCH_APP_CLIENT_ID: "1qgws7yzcp21g5ledlzffw3lmqdvie"
TWITCH_APP_CLIENT_SECRET: ${{ secrets.TWITCH_CLIENT_SECRET }}
TWITCH_ANDROID_CLIENT_ID: "kd1unb4b3q4t58fwlpcbzcbnm76a8fp"
TWITCH_WEB_CLIENT_ID: "kimne78kx3ncx6brgo4mv6wki5h1ko"
KICK_APP_CLIENT_ID: "01KMKM0HVR6HY0BMA101JFCTW3"
KICK_APP_CLIENT_SECRET: ${{ secrets.KICK_APP_CLIENT_SECRET }}
# Remove the GPU DRIVER LOADERS from the bundle and repack.
#
# `libva` and `libvulkan` are loaders: they dlopen the HOST's GPU driver
# (iHD_drv_video.so, radeonsi_drv_video.so, the Vulkan ICDs), and loader
# and driver must be ABI-compatible. Shipping our own means a user on a
# different distro gets a loader that cannot open their driver,
# va_openDriver() fails, and GStreamer silently falls back to SOFTWARE
# decode - on exactly the machines where hardware decode matters most. The
# user-visible symptom is a stuttering 1080p60 stream on a perfectly
# capable GPU, with nothing logged to explain it.
#
# linuxdeploy already excludes libGL, libEGL, libdrm and libgbm for this
# reason (verified: those come from the host). These four slip past because
# linuxdeploy-plugin-gstreamer copies its plugins' own dependencies without
# consulting that excludelist.
#
# The DECODER PLUGINS stay (libgstva, libgstnvcodec, libgstlibav,
# libgstopenh264): the plugin is ours to ship, the loader is the host's.
# A host with no libva at all just fails to load libgstva and falls through
# to software decode, which is what it would have done anyway.
#
# Verified on a real build: after stripping, the app launches with no
# plugin-load failures and Twitch video still decodes and plays.
- name: Unbundle the host GPU loaders
if: steps.check_release.outputs.RELEASE_EXISTS == 'false'
env:
# linuxdeploy ships as an AppImage and GitHub runners have no FUSE.
APPIMAGE_EXTRACT_AND_RUN: 1
run: |
set -euo pipefail
bundle="src-tauri/target/release/bundle/appimage"
appdir="$bundle/StreamNook.AppDir"
test -d "$appdir" || { echo "no AppDir at $appdir"; ls -R "$bundle" || true; exit 1; }
# Two groups, one principle: anything the HOST's graphics stack loads
# must come from the host.
#
# 1. The GPU driver loaders (libva, libvulkan), which dlopen the host's
# driver and must be ABI-compatible with it.
# 2. Low-level libraries that mesa and the EGL stack themselves pull
# in. These are on the AppImage community excludelist for exactly
# this reason, and leaving them in is NOT cosmetic: a CI artifact
# built here died with "Could not create default EGL display:
# EGL_BAD_PARAMETER. Aborting..." and rendered a BLANK WINDOW on a
# newer host. Deleting these eight from the AppDir made the same
# artifact paint in 12 seconds. Measured, not reasoned.
#
# This is the trap that makes the ubuntu-22.04 pin cut both ways:
# building on the oldest distro is right for the glibc floor, and it
# also guarantees every bundled library is the OLDEST, which maximises
# the mismatch against every user newer than the builder. The
# resolution is to not ship these at all.
removed=0
for lib in libva.so.2 libva-drm.so.2 libva-x11.so.2 libvulkan.so.1 \
libwayland-client.so.0 libwayland-egl.so.1 \
libwayland-server.so.0 libwayland-cursor.so.0 \
libffi.so.8 libzstd.so.1 libelf.so.1 libgomp.so.1; do
while IFS= read -r f; do
rm -f "$f"; echo "unbundled $lib"; removed=$((removed+1))
done < <(find "$appdir" -name "$lib")
done
echo "unbundled $removed host-coupled librar(ies)"
# The decoders must survive; without them the player dies with
# "HLS is not supported in this browser".
for keep in libgstlibav.so libgstopenh264.so; do
find "$appdir" -name "$keep" | grep -q . || { echo "$keep went missing"; exit 1; }
done
# The GTK plugin's AppRun hook forces X11 on every Wayland desktop and
# lets the host's GIO modules load against our older GLib. The script
# explains both and fails the job if the hook no longer matches.
bash scripts/linux-appimage-hooks.sh "$appdir"
# Find the packer rather than assuming one path: tauri caches it under
# ~/.cache/tauri today, but a hardcoded path that moves would abort the
# whole Linux job on a step that is meant to be a refinement.
tool=$(find "$HOME/.cache" -name "linuxdeploy-plugin-appimage*.AppImage" -type f 2>/dev/null | head -1)
if [ -z "$tool" ]; then
echo "::error title=AppImage packer not found::Could not locate linuxdeploy-plugin-appimage under \$HOME/.cache. The GPU driver loaders are still bundled, which silently costs users hardware decode, so this fails rather than shipping that."
exit 1
fi
chmod +x "$tool"
# Repack BESIDE the original and only replace it once the new one
# exists. Deleting first means a failed repack leaves the job with no
# artifact at all, turning a refinement into a lost release.
old=$(find "$bundle" -maxdepth 1 -name '*.AppImage' | head -1)
test -n "$old" || { echo "no AppImage to repack"; exit 1; }
work="$bundle/repack"
rm -rf "$work"; mkdir -p "$work"
( cd "$work" && LINUXDEPLOY_OUTPUT_APP_NAME=StreamNook "$tool" --appdir ../StreamNook.AppDir )
new=$(find "$work" -maxdepth 1 -name '*.AppImage' | head -1)
test -s "$new" || { echo "repack produced nothing; keeping the original"; exit 1; }
mv -f "$new" "$old"
rm -rf "$work"
chmod +x "$old"
ls -la "$bundle"/*.AppImage
- name: Name and hash the AppImage
if: steps.check_release.outputs.RELEASE_EXISTS == 'false'
id: pack
run: |
set -euo pipefail
version=$(grep '^version' src-tauri/Cargo.toml | head -1 | sed 's/.*"\(.*\)".*/\1/')
src_dir="src-tauri/target/release/bundle/appimage"
# The bundler names it StreamNook_<version>_amd64.AppImage. The client
# asks for the STABLE name (default_bundle_name() returns
# "StreamNook.AppImage" and stage_linux_bundle parks it under that), so
# rename rather than teaching the client a version-dependent filename.
built=$(find "$src_dir" -maxdepth 1 -name '*.AppImage' | head -1)
test -n "$built" || { echo "no .AppImage under $src_dir"; ls -R "$src_dir" || true; exit 1; }
bundle="StreamNook.AppImage"
mv "$built" "$bundle"
chmod +x "$bundle"
# Refuse to publish something that is not actually an AppImage. The
# client checks these same magic bytes before installing (see
# looks_like_an_appimage in commands/components.rs), so checking here
# means a bundler change lands as a red runner rather than as a failed
# update on a user's machine. 11 bytes = 22 hex chars: the ELF magic,
# four bytes of ELF identification, then AI\x02 at offset 8.
magic=$(head -c 11 "$bundle" | od -An -tx1 | tr -d ' \n')
case "$magic" in
7f454c46????????414902) ;;
*) echo "built artifact is not a type-2 AppImage (magic: $magic)"; exit 1 ;;
esac
echo "BUNDLE_NAME=$bundle" >> "$GITHUB_OUTPUT"
echo "SHA256=$(sha256sum "$bundle" | cut -d' ' -f1)" >> "$GITHUB_OUTPUT"
echo "SIZE=$(stat -c%s "$bundle")" >> "$GITHUB_OUTPUT"
echo "DOWNLOAD_URL=https://streamnook.app/download/linux" >> "$GITHUB_OUTPUT"
echo "Packed $bundle ($(du -h "$bundle" | cut -f1)) for v$version"
- name: Sign the AppImage with the update key
if: steps.check_release.outputs.RELEASE_EXISTS == 'false'
id: sign
env:
UPDATE_SIGNING_KEY: ${{ secrets.UPDATE_SIGNING_KEY }}
run: |
set -euo pipefail
bundle="${{ steps.pack.outputs.BUNDLE_NAME }}"
if [ -z "${UPDATE_SIGNING_KEY:-}" ]; then
echo "UPDATE_SIGNING_KEY not set - publishing an UNSIGNED Linux update"
echo "SIGNATURE=" >> "$GITHUB_OUTPUT"
exit 0
fi
# The SAME key that signs the Windows and macOS bundles; the client
# pins one public half as a compile-time const.
# The official static binary, pinned, as the Windows job does. apt has no
# `minisign` package on ubuntu-22.04 ("Unable to locate package").
ms_ver=0.12
curl -fsSL -o minisign.tgz "https://github.com/jedisct1/minisign/releases/download/$ms_ver/minisign-$ms_ver-linux.tar.gz"
tar xzf minisign.tgz 2>/dev/null
ms=./minisign-linux/x86_64/minisign
[ -x "$ms" ] || { echo "::error title=minisign missing::$ms not found in minisign-$ms_ver-linux.tar.gz"; exit 1; }
printf '%s' "$UPDATE_SIGNING_KEY" > ./update.key
trap 'rm -f ./update.key' EXIT
"$ms" -S -s ./update.key -m "$bundle" -x "$bundle.minisig"
echo "SIGNATURE<<EOF" >> "$GITHUB_OUTPUT"
cat "$bundle.minisig" >> "$GITHUB_OUTPUT"
echo "EOF" >> "$GITHUB_OUTPUT"
echo "Signed $bundle"
# Same handover as macOS: the GitHub release is created by the Windows
# `build` job, which runs in parallel with this one, so this job can never
# wait for it.
- name: Hand the Linux bundle to the release job
if: steps.check_release.outputs.RELEASE_EXISTS == 'false'
uses: actions/upload-artifact@v4
with:
name: linux-bundle
path: |
${{ steps.pack.outputs.BUNDLE_NAME }}
${{ steps.pack.outputs.BUNDLE_NAME }}.minisig
if-no-files-found: error
retention-days: 3
- name: Push Linux bundle to Cloudflare R2
if: steps.check_release.outputs.RELEASE_EXISTS == 'false'
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
run: |
set -euo pipefail
bundle="${{ steps.pack.outputs.BUNDLE_NAME }}"
npx --yes wrangler@4 r2 object put "streamnook-downloads/linux/$bundle" \
--file="$bundle" \
--content-type="application/x-executable" \
--remote
echo "Uploaded linux/$bundle to R2"
# NO SIGNING GATE HERE, unlike macOS. Linux has no Gatekeeper equivalent:
# an AppImage a human downloads runs once they mark it executable, so this
# pipeline is correct for first-time installs as well as for updates. The
# minisign signature is still published and still the only control that
# survives a compromise of the update host.
build:
# Deliberately does NOT need build-macos. It used to, and the cost was
# measured: build-macos runs 32-51 min, and `needs` makes Windows WAIT for it
# even though the old comment here already said a Mac failure should not
# block "every Windows user's update". It solved the failure case and not the
# latency one. 8.6.1, before macOS existed, shipped in 35 min; 8.6.2 took 96
# and 8.6.3 took 80. The two jobs are independent, so they run in parallel and
# finalize-macos below joins them afterwards.
needs: merge-to-main
if: ${{ !cancelled() && needs.merge-to-main.result == 'success' }}
runs-on: windows-latest
permissions:
contents: write
outputs:
VERSION: ${{ steps.version.outputs.VERSION }}
RELEASE_EXISTS: ${{ steps.check_release.outputs.RELEASE_EXISTS }}
steps:
- name: Checkout main branch
uses: actions/checkout@v4
with:
ref: main
fetch-depth: 0
- name: Get version from Cargo.toml
id: version
shell: pwsh
run: |
$cargo = Get-Content src-tauri/Cargo.toml -Raw
if ($cargo -match 'version\s*=\s*"([^"]+)"') {
$version = $matches[1]
echo "VERSION=$version" >> $env:GITHUB_OUTPUT
echo "Found version: $version"
} else {
echo "Could not find version in Cargo.toml"
exit 1
}
- name: Check if release already exists
id: check_release
shell: pwsh
run: |
$version = "${{ steps.version.outputs.VERSION }}"
$releases = gh release list --limit 100 --json tagName | ConvertFrom-Json
$exists = $releases | Where-Object { $_.tagName -eq "v$version" }
if ($exists) {
echo "RELEASE_EXISTS=true" >> $env:GITHUB_OUTPUT
echo "Release v$version already exists, skipping..."
} else {
echo "RELEASE_EXISTS=false" >> $env:GITHUB_OUTPUT
echo "Release v$version does not exist, will create"
}
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Setup Node.js
if: steps.check_release.outputs.RELEASE_EXISTS == 'false'
uses: actions/setup-node@v4
with:
node-version: "20"
cache: "npm"
- name: Setup Rust
if: steps.check_release.outputs.RELEASE_EXISTS == 'false'
uses: dtolnay/rust-toolchain@stable
- name: Rust cache
if: steps.check_release.outputs.RELEASE_EXISTS == 'false'
uses: Swatinem/rust-cache@v2
with:
workspaces: "./src-tauri -> target"
cache-on-failure: true
shared-key: "rust-build"
- name: Install dependencies
if: steps.check_release.outputs.RELEASE_EXISTS == 'false'
run: npm ci
- name: Pre-install WiX Toolset
if: steps.check_release.outputs.RELEASE_EXISTS == 'false'
shell: pwsh
run: |
# Pre-download WiX to avoid timeout during Tauri build
$wixDir = "$env:LOCALAPPDATA\tauri\WixTools"
if (Test-Path $wixDir) {
echo "WiX Toolset already cached"
} else {
echo "Downloading WiX Toolset..."
New-Item -ItemType Directory -Force -Path $wixDir | Out-Null
# Download with retry and increased timeout
$wixUrl = "https://github.com/wixtoolset/wix3/releases/download/wix3141rtm/wix314-binaries.zip"
$wixZip = "$env:TEMP\wix314-binaries.zip"
$webClient = New-Object System.Net.WebClient
$retries = 3
$success = $false
for ($i = 1; $i -le $retries; $i++) {
try {
echo "Download attempt $i of $retries..."
$webClient.DownloadFile($wixUrl, $wixZip)
$success = $true
break
} catch {
echo "Attempt $i failed: $_"
if ($i -lt $retries) {
Start-Sleep -Seconds 5
}
}
}
if (-not $success) {
echo "Failed to download WiX after $retries attempts"
exit 1
}
echo "Extracting WiX Toolset..."
Expand-Archive -Path $wixZip -DestinationPath $wixDir -Force
Remove-Item $wixZip -Force
echo "WiX Toolset installed to: $wixDir"
}
# Verify WiX installation
if (Test-Path "$wixDir\candle.exe") {
echo "WiX Toolset verified successfully"
} else {
echo "WiX Toolset verification failed"
exit 1
}
- name: Build Tauri app
if: steps.check_release.outputs.RELEASE_EXISTS == 'false' && inputs.skip_build != 'true'
shell: pwsh
run: npm run tauri build
env:
VITE_SUPABASE_URL: ${{ secrets.VITE_SUPABASE_URL }}
VITE_SUPABASE_ANON_KEY: ${{ secrets.VITE_SUPABASE_ANON_KEY }}
# OAuth CLIENT IDs are public by construction: they appear in every
# authorize URL the app opens and in our own wrangler configs. Storing
# them as repo secrets implied a confidentiality they never had, which
# made it look like rotating them would achieve something. Only the
# SECRETS below are real secrets.
TWITCH_APP_CLIENT_ID: "1qgws7yzcp21g5ledlzffw3lmqdvie"
TWITCH_APP_CLIENT_SECRET: ${{ secrets.TWITCH_CLIENT_SECRET }}
TWITCH_ANDROID_CLIENT_ID: "kd1unb4b3q4t58fwlpcbzcbnm76a8fp"
TWITCH_WEB_CLIENT_ID: "kimne78kx3ncx6brgo4mv6wki5h1ko"
KICK_APP_CLIENT_ID: "01KMKM0HVR6HY0BMA101JFCTW3"
KICK_APP_CLIENT_SECRET: ${{ secrets.KICK_APP_CLIENT_SECRET }}
- name: Download StreamNook.exe from previous release (skip_build mode)
if: steps.check_release.outputs.RELEASE_EXISTS == 'false' && inputs.skip_build == 'true'
shell: pwsh
run: |
# Get the latest release
$latestRelease = gh release view --json assets,tagName | ConvertFrom-Json
echo "Latest release: $($latestRelease.tagName)"
# Find the exe or bundle
$exeAsset = $latestRelease.assets | Where-Object { $_.name -like "*.exe" -or $_.name -eq "StreamNook.7z" }
if ($exeAsset) {
echo "Downloading: $($exeAsset.name)"
gh release download $latestRelease.tagName -p $exeAsset.name -D ./previous-release
} else {
echo "No exe found in previous release"
exit 1
}
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Copy StreamNook.exe to bundle
if: steps.check_release.outputs.RELEASE_EXISTS == 'false'
shell: pwsh
run: |
# The bundle dir used to be created by the Streamlink download step.
New-Item -ItemType Directory -Force -Path ./bundle | Out-Null
if ("${{ inputs.skip_build }}" -eq "true") {
# Use exe from previous release
$exe = Get-ChildItem ./previous-release -Filter "*.exe" | Select-Object -First 1
if ($exe) {
Copy-Item $exe.FullName ./bundle/StreamNook.exe
} else {
# Extract from previous bundle
$bundle = Get-ChildItem ./previous-release -Filter "StreamNook.7z" | Select-Object -First 1
if ($bundle) {
7z x $bundle.FullName -o"./previous-release/extracted"
Copy-Item ./previous-release/extracted/StreamNook.exe ./bundle/
}
}
} else {
# Use freshly built exe
Copy-Item src-tauri/target/release/StreamNook.exe ./bundle/
}
echo "StreamNook.exe copied to bundle"
- name: Copy CHANGELOG.md to bundle
if: steps.check_release.outputs.RELEASE_EXISTS == 'false'
shell: pwsh
run: |
Copy-Item CHANGELOG.md ./bundle/
echo "CHANGELOG.md copied to bundle"
# Note: Analytics dashboard is now embedded directly in the exe via rust-embed
# No separate resources folder needed
- name: Generate components.json
if: steps.check_release.outputs.RELEASE_EXISTS == 'false'
shell: pwsh
run: |
$version = "${{ steps.version.outputs.VERSION }}"
# Streamlink/TTV-LOL are no longer bundled. Keep the (now empty) fields
# so the components.json schema still parses on any client.
$streamlinkVersion = ""
$ttvlolVersion = ""
$buildDate = (Get-Date).ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ssZ")
$components = @{
schema_version = 1
streamnook = @{
version = $version
build_date = $buildDate
}
components = @{
streamlink = @{
version = $streamlinkVersion
source_url = "https://github.com/streamlink/windows-builds/releases"
}
ttvlol = @{
version = $ttvlolVersion
source_url = "https://github.com/2bc4/streamlink-ttvlol/releases"
}
}
}
$json = $components | ConvertTo-Json -Depth 10
$json | Out-File -FilePath ./bundle/components.json -Encoding utf8
echo "components.json created:"
echo $json
- name: Create 7z bundle
if: steps.check_release.outputs.RELEASE_EXISTS == 'false'
shell: pwsh
run: |
$bundleName = "StreamNook.7z"
# Create 7z archive (exe only). components.json is uploaded as a
# separate top-level asset for older clients' version check; the new
# updater reads the binary's own compiled-in version instead.
cd bundle
7z a -t7z -mx=9 "../$bundleName" StreamNook.exe
cd ..
echo "BUNDLE_NAME=$bundleName" >> $env:GITHUB_OUTPUT
echo "Created: $bundleName"
# Show bundle size
$size = (Get-Item $bundleName).Length / 1MB
echo "Bundle size: $([math]::Round($size, 2)) MB"
id: bundle
- name: Generate checksums
if: steps.check_release.outputs.RELEASE_EXISTS == 'false'
shell: pwsh
run: |
$bundleName = "${{ steps.bundle.outputs.BUNDLE_NAME }}"
$hash = (Get-FileHash $bundleName -Algorithm SHA256).Hash
"$hash $bundleName" | Out-File -FilePath checksums.txt -Encoding utf8
echo "Checksums generated:"
Get-Content checksums.txt
- name: Extract changelog for this version
if: steps.check_release.outputs.RELEASE_EXISTS == 'false'
id: changelog
shell: pwsh
run: |
$version = "${{ steps.version.outputs.VERSION }}"
$changelog = Get-Content CHANGELOG.md -Raw
# Try to extract section for this version
$pattern = "(?s)## \[?$version\]?.*?(?=## \[|\z)"
if ($changelog -match $pattern) {
$section = $matches[0].Trim()
# Write to temp file for use in release step
$section | Out-File -FilePath ./changelog_section.md -Encoding utf8
echo "Found changelog section for v$version"
} else {
"Release v$version" | Out-File -FilePath ./changelog_section.md -Encoding utf8
echo "No changelog section found, using default"
}
- name: Create GitHub Release
if: steps.check_release.outputs.RELEASE_EXISTS == 'false'
shell: pwsh
run: |
$version = "${{ steps.version.outputs.VERSION }}"
$bundleName = "${{ steps.bundle.outputs.BUNDLE_NAME }}"
# Read changelog section from temp file
$changelogSection = Get-Content ./changelog_section.md -Raw
# Build the install instructions section
$lines = @()
$lines += ""
$lines += "---"
$lines += ""
$lines += "### Installation"
$lines += "1. Download ``$bundleName``"
$lines += "2. Extract the .7z file"
$lines += "3. Run ``StreamNook.exe``"
$componentInfo = $lines -join "`n"
$notes = $changelogSection + $componentInfo
# Write to file for gh release
$notes | Out-File -FilePath ./release_notes.md -Encoding utf8
gh release create "v$version" --title "StreamNook v$version" --notes-file ./release_notes.md "$bundleName" "bundle/components.json" "checksums.txt"
echo "Release v$version created!"
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# The macOS job cannot attach to a release that does not exist yet (it
# runs before this job), so it parks the bundle as a workflow artifact
# and we attach it here. Skipped when the macOS job produced nothing.
- name: Push installer to Cloudflare R2
# Mirrors StreamNook.7z to the R2 bucket that streamnook.app serves at
# /download, so the website's download button always hands out this
# build with no GitHub redirect. Requires two repo secrets:
# CLOUDFLARE_API_TOKEN - token with "Workers R2 Storage: Edit"
# CLOUDFLARE_ACCOUNT_ID - the Cloudflare account id
# If those secrets are absent the step is skipped (release still succeeds).
if: steps.check_release.outputs.RELEASE_EXISTS == 'false' && env.CLOUDFLARE_API_TOKEN != ''
shell: pwsh
run: |
$bundleName = "${{ steps.bundle.outputs.BUNDLE_NAME }}"
$version = "${{ steps.version.outputs.VERSION }}"
Write-Host "Uploading $bundleName (v$version) to R2 bucket streamnook-downloads..."
npx --yes wrangler@4 r2 object put "streamnook-downloads/StreamNook.7z" `
--file="$bundleName" `
--content-type="application/x-7z-compressed" `
--remote
if ($LASTEXITCODE -ne 0) { throw "wrangler r2 upload failed (exit $LASTEXITCODE)" }
Write-Host "Uploaded StreamNook.7z (v$version) to R2 - live at https://streamnook.app/download"
# Build and upload the update manifest the desktop client reads at
# streamnook.app/api/v1/update (served from R2 as latest.json). Without
# this, installed clients keep seeing the previous version and either
# miss the update or get prompted to "update" to an older build.
# Detached minisign signature over the bundle bytes.
#
# This is the only integrity control that survives a compromise of the
# update host: sha256 and the bundle are served by the same server, so
# whoever can serve a bad bundle can serve a matching hash. The secret
# key exists only as a CI secret and must never reach the host serving
# updates. The client pins the PUBLIC half as a compile-time const.
#
# Skipped (not failed) while UPDATE_SIGNING_KEY is unset, so signing can
# be turned on without a release being blocked on it. The client is in
# warn-only mode until its ENFORCE_UPDATE_SIGNATURE flips, and that flip
# must not happen until releases here are reliably signed.
$signature = $null
if ($env:UPDATE_SIGNING_KEY) {
# The official binary, pinned. `npm i -g minisign-cli` was a 404 (that
# package does not exist) and the `minisign` npm package exits 1 with
# no output, so both were silently doing nothing: the moment a signing
# key existed, the `throw` below would have failed every release build.
# Verified end to end locally against 0.12 before this was enabled.
$msVer = '0.12'
$msZip = "minisign-$msVer-win64.zip"
Invoke-WebRequest "https://github.com/jedisct1/minisign/releases/download/$msVer/$msZip" -OutFile $msZip -UseBasicParsing
Expand-Archive $msZip -DestinationPath ./minisign-bin -Force
# The archive carries both architectures; runners are x86_64.
$ms = (Get-ChildItem ./minisign-bin -Recurse -Filter minisign.exe |
Where-Object { $_.FullName -match 'x86_64' } |
Select-Object -First 1).FullName
if (-not $ms) { throw "minisign.exe (x86_64) not found in $msZip" }
$env:UPDATE_SIGNING_KEY | Out-File -FilePath ./update.key -Encoding ascii -NoNewline
try {
& $ms -S -s ./update.key -m "$bundleName" -x "$bundleName.minisig"
if ($LASTEXITCODE -ne 0) { throw "minisign failed (exit $LASTEXITCODE)" }
$signature = (Get-Content "$bundleName.minisig" -Raw).TrimEnd()
Write-Host "Signed $bundleName"
# Also attach it to the GitHub release. The in-app updater reads the
# signature from the R2 manifest, not from here, but people do
# download straight off the releases page and this is what lets them
# verify that copy by hand:
# minisign -Vm StreamNook.7z -P <public key>
gh release upload "v$version" "$bundleName.minisig" --clobber
if ($LASTEXITCODE -ne 0) { Write-Host "WARN: could not attach .minisig to the release" }
} finally {
# Never leave the secret key on the runner filesystem.
Remove-Item ./update.key -Force -ErrorAction SilentlyContinue
}
} else {
Write-Host "UPDATE_SIGNING_KEY not set - publishing an UNSIGNED update"
}
$winSha = (Get-FileHash "$bundleName" -Algorithm SHA256).Hash.ToLower()
$winSize = (Get-Item "$bundleName").Length
# Per-platform builds, keyed <os>-<arch> to match std::env::consts and
# the plugin index's own `platforms` map.
$platforms = [ordered]@{
"windows-x86_64" = [ordered]@{
download_url = "https://streamnook.app/download"
bundle_name = $bundleName
sha256 = $winSha
signature = $signature
size = $winSize
}
}
# The macOS and Linux entries for the NEW version are patched in
# afterwards by finalize-macos / finalize-linux, so Windows users are not
# made to wait 32-51 min for a Mac build to publish their update. An entry
# pointing at a build that does not exist is worse than no entry, because
# the client would offer an update and then fail to download it.
#
# But dropping the non-Windows keys entirely was ALSO wrong, and cost real
# users: with no key for their <os>-<arch>, a Mac gets
# `artifact_for_platform() == None` and is told "no update", traced only
# by one log line. That happened on EVERY release for the 32-51 min
# window, and for the WHOLE cycle whenever build-macos failed, because
# finalize-macos is skipped when its outputs are empty.
#
# So carry the PREVIOUS manifest's macOS and Linux entries forward. They
# point at the last build for that platform, which really does exist, so a
# client checking during the window is offered the previous release rather
# than nothing, and finalize-* overwrites them minutes later. A client
# already on that version simply sees no update, which is correct.
#
# linux-x86_64 joins this list while Linux releases are on, below.
$prevManifest = $null
try {
$prevManifest = Invoke-RestMethod -Uri "https://streamnook.app/api/v1/update" -TimeoutSec 20
} catch {
Write-Host "::warning::Could not read the previous manifest ($($_.Exception.Message)); macOS keys will be absent until finalize-macos runs."
}
# Linux is carried forward only while Linux releases are on
# (vars.LINUX_RELEASE). With them off no Linux build follows, and a
# carried entry would point at the previous AppImage under this
# release's version: the client compares only the manifest version, so