Repository navigation
869 lines (761 loc) · 37.5 KB
/
Copy pathcd.yml
File metadata and controls
869 lines (761 loc) · 37.5 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
name: Continuous Deployment
permissions:
contents: read
on:
push:
tags:
- "v*.*.*"
workflow_dispatch:
env:
CARGO_TERM_COLOR: always
BINARY_NAME: spotatui
jobs:
build:
name: Build ${{ matrix.target }}
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
include:
# Multi-source features (local-files, subsonic, internet-radio, youtube, qobuz)
# ship on all three platforms: they share the rodio output engine, which
# is now opened on macOS too (the #9/#20 crashes were librespot's own
# older rodio backend, not this one — librespot still uses portaudio
# there, which is why `portaudio-backend` stays on the macOS rows).
# The youtube source needs a yt-dlp binary at runtime (ffmpeg
# recommended); without one it shows an actionable error.
# `soloist` adds the opt-in Soloist engine; it needs the Soloist binary
# the user downloads from Spotify, so nothing changes until it is set.
- os: ubuntu-latest
target: x86_64-unknown-linux-gnu
artifact_prefix: linux-x86_64 # most Linux distros
binary_postfix: ""
audio_features: "audio-viz,audio-viz-cpal,mpris,local-files,subsonic,internet-radio,youtube,qobuz,soloist"
- os: ubuntu-24.04-arm # free native ARM64 runner for public repos
target: aarch64-unknown-linux-gnu
artifact_prefix: linux-aarch64 # Raspberry Pi 4/5, ARM servers, Asahi Linux
binary_postfix: ""
audio_features: "audio-viz,audio-viz-cpal,mpris,local-files,subsonic,internet-radio,youtube,qobuz,soloist"
- os: macos-15-intel # Intel runner for x86_64 (available until Aug 2027)
target: x86_64-apple-darwin
artifact_prefix: macos-x86_64
binary_postfix: ""
audio_features: "audio-viz-cpal,portaudio-backend,macos-media,local-files,subsonic,internet-radio,youtube,qobuz,soloist,apple-music"
- os: macos-latest # Apple Silicon runner for aarch64
target: aarch64-apple-darwin
artifact_prefix: macos-aarch64
binary_postfix: ""
audio_features: "audio-viz-cpal,portaudio-backend,macos-media,local-files,subsonic,internet-radio,youtube,qobuz,soloist,apple-music"
- os: windows-latest
target: x86_64-pc-windows-msvc
artifact_prefix: windows-x86_64
binary_postfix: ".exe"
audio_features: "audio-viz-cpal,windows-media,local-files,subsonic,internet-radio,youtube,qobuz,soloist"
steps:
- name: Checkout sources
uses: actions/checkout@v7
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
- name: Install Linux dependencies
if: runner.os == 'Linux'
run: |
sudo apt-get update
sudo apt-get install -y -qq pkg-config libssl-dev libxcb1-dev libxcb-render0-dev libxcb-shape0-dev libxcb-xfixes0-dev libpipewire-0.3-dev libspa-0.2-dev libasound2-dev
- name: Install cargo-deb
if: runner.os == 'Linux'
run: cargo install cargo-deb --locked
# Static portaudio, so the install.sh binary runs without Homebrew (checked below).
- name: Install macOS dependencies
if: runner.os == 'macOS'
run: |
brew install openssl@3 portaudio
echo "PKG_CONFIG_ALL_STATIC=1" >> "$GITHUB_ENV"
- name: Build release binary
run: |
if [ -z "${{ matrix.audio_features }}" ]; then
cargo build --release --target ${{ matrix.target }} --no-default-features --features telemetry,tui,streaming,discord-rpc,cover-art,self-update,scripting,mcp-server,ai-dj
else
cargo build --release --target ${{ matrix.target }} --no-default-features --features telemetry,tui,streaming,discord-rpc,cover-art,self-update,scripting,mcp-server,ai-dj,${{ matrix.audio_features }}
fi
shell: bash
- name: Check the macOS binary links only system libraries
if: runner.os == 'macOS'
run: |
bin=target/${{ matrix.target }}/release/${{ env.BINARY_NAME }}
otool -L "$bin"
foreign=$(otool -L "$bin" | tail -n +2 | awk '{print $1}' | grep -v -e '^/System/' -e '^/usr/lib/' || true)
if [ -n "$foreign" ]; then
echo "::error::$bin links libraries outside /System and /usr/lib: $foreign"
exit 1
fi
- name: Package binary (Unix)
if: runner.os != 'Windows'
shell: bash
run: |
cd target/${{ matrix.target }}/release
BINARY=${{ env.BINARY_NAME }}${{ matrix.binary_postfix }}
strip $BINARY || true
RELEASE_NAME=${{ env.BINARY_NAME }}-${{ matrix.artifact_prefix }}
tar czvf ../../../$RELEASE_NAME.tar.gz $BINARY
cd ../../..
# Self-update up to v0.45 refuses to update without this exact file. Never remove it.
shasum -a 256 $RELEASE_NAME.tar.gz > $RELEASE_NAME.tar.gz.sha256
- name: Package Debian (.deb)
if: runner.os == 'Linux'
shell: bash
run: |
cargo deb --no-build --target ${{ matrix.target }}
DEB_PATH=$(ls target/${{ matrix.target }}/debian/*.deb)
cp "$DEB_PATH" .
- name: Package binary (Windows)
if: runner.os == 'Windows'
shell: bash
run: |
cd target/${{ matrix.target }}/release
BINARY=${{ env.BINARY_NAME }}${{ matrix.binary_postfix }}
RELEASE_NAME=${{ env.BINARY_NAME }}-${{ matrix.artifact_prefix }}
7z a ../../../$RELEASE_NAME.zip $BINARY
cd ../../..
# Self-update up to v0.45 refuses to update without this exact file. Never remove it.
certutil -hashfile $RELEASE_NAME.zip sha256 | grep -E [A-Fa-f0-9]{64} > $RELEASE_NAME.zip.sha256
- name: Upload artifact (Unix)
if: runner.os != 'Windows'
uses: actions/upload-artifact@v7
with:
name: ${{ env.BINARY_NAME }}-${{ matrix.artifact_prefix }}
path: |
${{ env.BINARY_NAME }}-${{ matrix.artifact_prefix }}.tar.gz
${{ env.BINARY_NAME }}-${{ matrix.artifact_prefix }}.tar.gz.sha256
- name: Upload artifact (Linux .deb)
if: runner.os == 'Linux'
uses: actions/upload-artifact@v7
with:
name: ${{ env.BINARY_NAME }}-${{ matrix.artifact_prefix }}-deb
path: "*.deb"
- name: Upload artifact (Windows)
if: runner.os == 'Windows'
uses: actions/upload-artifact@v7
with:
name: ${{ env.BINARY_NAME }}-${{ matrix.artifact_prefix }}
path: |
${{ env.BINARY_NAME }}-${{ matrix.artifact_prefix }}.zip
${{ env.BINARY_NAME }}-${{ matrix.artifact_prefix }}.zip.sha256
# Creates the release as a draft. The `publish` job makes it public after the
# desktop installers and checksums.txt are on it, so every link works from the
# start, and installed self-updaters never see a half-built release.
release:
name: Create GitHub Release
needs: build
runs-on: ubuntu-latest
permissions:
contents: write
env:
BASE: ${{ github.server_url }}/${{ github.repository }}/releases/download/${{ github.ref_name }}
steps:
- name: Checkout sources
uses: actions/checkout@v7
- name: Download all artifacts
uses: actions/download-artifact@v8
with:
path: artifacts
pattern: spotatui-*
merge-multiple: true
- name: Resolve .deb file names
id: deb
# cargo-deb names the package from Cargo.toml, not from the tag, so the
# download links must use the file names that were built.
run: |
for arch in amd64 arm64; do
f=$(find artifacts -maxdepth 1 -type f -name "spotatui_*_${arch}.deb" -print -quit)
if [ -z "$f" ]; then
echo "No ${arch} .deb found in artifacts/" >&2
exit 1
fi
echo "${arch}=$(basename "$f")" >> "$GITHUB_OUTPUT"
done
# Tauri names the installers from desktop/Cargo.toml, which the release commit keeps equal to Cargo.toml.
- name: Read the desktop version
id: desktop
run: echo "version=$(grep -m1 '^version' desktop/Cargo.toml | cut -d'"' -f2)" >> "$GITHUB_OUTPUT"
# Pins the generated change list to the last public release, so a release
# never repeats the one before it (v0.45.0 repeated v0.44.0).
- name: Find the previous release
id: prev
env:
GH_TOKEN: ${{ github.token }}
run: echo "tag=$(gh release list --repo "$GITHUB_REPOSITORY" --exclude-drafts --exclude-pre-releases --limit 1 --json tagName --jq '.[0].tagName // ""')" >> "$GITHUB_OUTPUT"
- name: Create Release
uses: softprops/action-gh-release@v3
with:
files: artifacts/*
draft: true
generate_release_notes: true
previous_tag: ${{ steps.prev.outputs.tag }}
prerelease: ${{ contains(github.ref_name, '-') }}
body: |
**Install:** `curl -fsSL https://spotatui.com/install.sh | bash` on macOS and Linux, or `irm https://spotatui.com/install.ps1 | iex` on Windows. Already installed? Run `spotatui update --install`, or update with your package manager.
## Terminal app
| | Download |
|---|---|
| **Windows** 10/11 | [64-bit .zip](${{ env.BASE }}/spotatui-windows-x86_64.zip) |
| **macOS** | [Apple Silicon](${{ env.BASE }}/spotatui-macos-aarch64.tar.gz) · [Intel](${{ env.BASE }}/spotatui-macos-x86_64.tar.gz) |
| **Linux** | [x86_64](${{ env.BASE }}/spotatui-linux-x86_64.tar.gz) · [ARM64](${{ env.BASE }}/spotatui-linux-aarch64.tar.gz) (Raspberry Pi 4/5) |
| **Debian, Ubuntu** | [.deb amd64](${{ env.BASE }}/${{ steps.deb.outputs.amd64 }}) · [.deb arm64](${{ env.BASE }}/${{ steps.deb.outputs.arm64 }}) |
## Desktop app (preview)
| | Download |
|---|---|
| **Windows** 10/11 | [Installer .exe](${{ env.BASE }}/spotatui-desktop_${{ steps.desktop.outputs.version }}_x64-setup.exe) |
| **macOS** | [Apple Silicon .dmg](${{ env.BASE }}/spotatui_${{ steps.desktop.outputs.version }}_aarch64.dmg) · [Intel .dmg](${{ env.BASE }}/spotatui_${{ steps.desktop.outputs.version }}_x64.dmg) |
| **Linux x86_64** | [AppImage](${{ env.BASE }}/spotatui-desktop_${{ steps.desktop.outputs.version }}_amd64.AppImage) · [.deb](${{ env.BASE }}/spotatui-desktop_${{ steps.desktop.outputs.version }}_amd64.deb) · [.rpm](${{ env.BASE }}/spotatui-desktop-${{ steps.desktop.outputs.version }}-1.x86_64.rpm) |
| **Linux ARM64** | [AppImage](${{ env.BASE }}/spotatui-desktop_${{ steps.desktop.outputs.version }}_aarch64.AppImage) · [.deb](${{ env.BASE }}/spotatui-desktop_${{ steps.desktop.outputs.version }}_arm64.deb) · [.rpm](${{ env.BASE }}/spotatui-desktop-${{ steps.desktop.outputs.version }}-1.aarch64.rpm) |
The Windows installer, the macOS app and the AppImage update themselves. A .deb or .rpm install updates with a new download from this page. Linux needs glibc 2.39 and WebKitGTK 4.1 (Ubuntu 24.04+, Debian 13, Fedora 40+, Arch).
<details>
<summary>First launch on Windows and macOS</summary>
- **Windows:** the installer is not signed yet, so SmartScreen warns. Click **More info**, then **Run anyway**.
- **macOS:** the app is not notarized, so macOS blocks the first launch. Close the dialog, open **System Settings > Privacy & Security**, click **Open Anyway** next to the spotatui message, and confirm.
</details>
Both apps play Spotify (Premium), Local Files, Subsonic, Internet Radio, YouTube and Qobuz, plus Apple Music on macOS. YouTube needs [yt-dlp](https://github.com/yt-dlp/yt-dlp) (ffmpeg recommended).
**Verify a download:** [checksums.txt](${{ env.BASE }}/checksums.txt) has the SHA-256 of every file: `sha256sum -c --ignore-missing checksums.txt`.
[Full changelog](${{ github.server_url }}/${{ github.repository }}/blob/${{ github.ref_name }}/CHANGELOG.md) · [Code signing policy](${{ github.server_url }}/${{ github.repository }}#code-signing-policy)
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Upate Nix Version Reference
id: nix_version
run: |
OLD_VERSION=$(grep -oP 'version\s*=\s*"\K[^"]+' flake.nix)
echo "Version: $OLD_VERSION"
NEW_VERSION=$(grep '^version = ' Cargo.toml | head -1 | sed 's/version = "\(.*\)"/\1/')
echo "Version: $NEW_VERSION"
sed -i "s/version *= *\"$OLD_VERSION\"/version = \"$NEW_VERSION\"/" flake.nix
# The Tauri desktop app (desktop/), attached to the draft the job above
# created. A failed row can be re-run alone; `publish` waits for every row.
# The installer names carry no OS word, so self-update's OS+arch fallback and
# the .deb, winget and Homebrew lookups never pick one up.
# The updater bundles (.AppImage, -setup.exe, .app.tar.gz) are signed with the
# TAURI_SIGNING_PRIVATE_KEY secret; only this job turns them on.
desktop:
name: Desktop ${{ matrix.os }}
needs: release
if: ${{ startsWith(github.ref, 'refs/tags/') }}
runs-on: ${{ matrix.os }}
permissions:
contents: write
env:
GH_TOKEN: ${{ github.token }}
defaults:
run:
shell: bash
strategy:
fail-fast: false
matrix:
include:
- os: ubuntu-24.04
bundles: deb,rpm,appimage
- os: ubuntu-24.04-arm # AppImage on arm64 is unverified
bundles: deb,rpm,appimage
- os: macos-15-intel
bundles: app,dmg
arch: x64
- os: macos-latest
bundles: app,dmg
arch: aarch64
- os: windows-latest
bundles: nsis
steps:
# The build runs dependency code; `gh` reads GH_TOKEN, so no token stays in .git/config.
- name: Checkout sources
uses: actions/checkout@v7
with:
persist-credentials: false
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
- name: Install Node
uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version: 24
cache: npm
cache-dependency-path: gui/package-lock.json
# desktop/build.rs refuses a release build without gui/dist.
- name: Build the page
working-directory: gui
run: npm ci && npm run build
# libfuse2t64 is for the AppImage tooling.
- name: Install Linux dependencies
if: runner.os == 'Linux'
run: |
sudo apt-get update
sudo apt-get install -y -qq pkg-config libssl-dev libxcb1-dev libxcb-render0-dev libxcb-shape0-dev libxcb-xfixes0-dev libpipewire-0.3-dev libspa-0.2-dev libasound2-dev libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev libxdo-dev patchelf libfuse2t64
# Static portaudio, so the app runs without Homebrew (checked below).
- name: Install macOS dependencies
if: runner.os == 'macOS'
run: |
brew install portaudio
echo "PKG_CONFIG_ALL_STATIC=1" >> "$GITHUB_ENV"
# No --locked: desktop/Cargo.lock is refreshed in the release commit.
# The prebuilt CLI from npm: `cargo install tauri-cli` compiled for up to 19 minutes per row.
# The updater bundles are on here only, so local and PR builds need no key.
- name: Build the desktop bundles
working-directory: desktop
env:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
run: npx --yes @tauri-apps/cli@2.12.2 build --ci --bundles ${{ matrix.bundles }} --config '{"bundle":{"createUpdaterArtifacts":true}}'
- name: Warn when desktop/Cargo.lock is stale
run: git diff --quiet -- desktop/Cargo.lock || echo "::warning file=desktop/Cargo.lock::desktop/Cargo.lock was stale for this release; refresh it in the next release commit."
- name: Check the macOS app links only system libraries
if: runner.os == 'macOS'
working-directory: desktop/target/release/bundle/macos
run: |
status=0
for bin in *.app/Contents/MacOS/*; do
otool -L "$bin"
foreign=$(otool -L "$bin" | tail -n +2 | awk '{print $1}' | grep -v -e '^/System/' -e '^/usr/lib/' || true)
if [ -n "$foreign" ]; then
echo "::error::$bin links libraries outside /System and /usr/lib: $foreign"
status=1
fi
done
# The oldest macOS the portaudio bottle supports, for minimumSystemVersion.
otool -l "$(brew --prefix portaudio)/lib/libportaudio.dylib" | grep -A4 LC_BUILD_VERSION || true
exit $status
- name: Show the Linux binary's shared libraries
if: runner.os == 'Linux'
run: ldd desktop/target/release/spotatui-desktop || echo "::warning::ldd failed on desktop/target/release/spotatui-desktop"
# Both macOS rows build <productName>.app.tar.gz; the arch keeps them apart.
- name: Name the macOS update bundle by arch
if: runner.os == 'macOS'
working-directory: desktop/target/release/bundle/macos
run: |
shopt -s nullglob
src=(*.app.tar.gz)
if [ ${#src[@]} -ne 1 ] || [ ! -f "${src[0]}.sig" ]; then
echo "::error::Expected one signed .app.tar.gz, found: ${src[*]}"
exit 1
fi
name="spotatui_${GITHUB_REF_NAME#v}_${{ matrix.arch }}.app.tar.gz"
mv "${src[0]}" "$name"
mv "${src[0]}.sig" "$name.sig"
- name: Upload installers to the release
working-directory: desktop/target/release/bundle
run: |
shopt -s nullglob
files=(deb/*.deb rpm/*.rpm appimage/*.AppImage dmg/*.dmg nsis/*-setup.exe)
if [ ${#files[@]} -eq 0 ]; then
echo "::error::No installers found under desktop/target/release/bundle"
exit 1
fi
# desktop-manifest copies each .sig into latest.json; publish then deletes them from the release.
upload=("${files[@]}" appimage/*.AppImage.sig nsis/*-setup.exe.sig macos/*.app.tar.gz macos/*.app.tar.gz.sig)
gh release upload "$GITHUB_REF_NAME" "${upload[@]}" --clobber --repo "$GITHUB_REPOSITORY"
# latest.json, the file the desktop updater reads from the latest release.
# Stable tags only, so a prerelease never reaches users. One failed desktop
# row skips this job, so no manifest ships without a platform; re-running that
# row runs it again.
desktop-manifest:
name: Desktop update manifest
needs: desktop
if: ${{ startsWith(github.ref, 'refs/tags/') && !contains(github.ref_name, '-') }}
runs-on: ubuntu-latest
permissions:
contents: write
env:
GH_TOKEN: ${{ github.token }}
steps:
- name: Checkout sources
uses: actions/checkout@v7
- name: Download the update signatures
run: gh release download "$GITHUB_REF_NAME" --pattern '*.sig' --dir sigs --repo "$GITHUB_REPOSITORY"
- name: Build and upload latest.json
env:
TAG: ${{ github.ref_name }}
BASE_URL: ${{ github.server_url }}/${{ github.repository }}/releases/download/${{ github.ref_name }}
CHANGELOG_URL: ${{ github.server_url }}/${{ github.repository }}/blob/${{ github.ref_name }}/CHANGELOG.md
run: |
heading=$(grep -m1 "^## \[$TAG\]" CHANGELOG.md || true)
if [ -n "$heading" ]; then
anchor=$(printf '%s' "${heading#\#\# }" | tr '[:upper:]' '[:lower:]' | tr -cd 'a-z0-9 -' | tr ' ' '-')
CHANGELOG_URL="$CHANGELOG_URL#$anchor"
fi
platforms='{}'
add() {
local key=$1 pattern f name
shift
for pattern in "$@"; do
f=$(find sigs -maxdepth 1 -type f -name "$pattern" -print -quit)
if [ -n "$f" ]; then
name=$(basename "$f" .sig)
platforms=$(jq --arg k "$key" --arg sig "$(cat "$f")" --arg url "$BASE_URL/$name" '. + {($k): {signature: $sig, url: $url}}' <<<"$platforms")
return
fi
done
echo "::warning::No $key update bundle in $TAG"
}
add windows-x86_64 '*_x64-setup.exe.sig'
add darwin-x86_64 '*_x64.app.tar.gz.sig'
add darwin-aarch64 '*_aarch64.app.tar.gz.sig'
add linux-x86_64 '*_amd64.AppImage.sig' '*_x86_64.AppImage.sig'
add linux-aarch64 '*_aarch64.AppImage.sig' '*_arm64.AppImage.sig'
for key in windows-x86_64 linux-x86_64; do
if ! jq -e --arg k "$key" 'has($k)' <<<"$platforms" >/dev/null; then
echo "::error::latest.json needs $key"
exit 1
fi
done
jq -n --arg version "${TAG#v}" --arg notes "What changed: $CHANGELOG_URL" \
--arg pub_date "$(date -u +%Y-%m-%dT%H:%M:%SZ)" --argjson platforms "$platforms" \
'{version: $version, notes: $notes, pub_date: $pub_date, platforms: $platforms}' > latest.json
cat latest.json
gh release upload "$TAG" latest.json --clobber --repo "$GITHUB_REPOSITORY"
# Writes checksums.txt, checks every link in the notes, then makes a stable
# release public. A release candidate stays a draft: installed self-updaters
# up to v0.45 install a public prerelease without checking it.
publish:
name: Checksums and publish
needs: [release, desktop, desktop-manifest]
if: ${{ !cancelled() && startsWith(github.ref, 'refs/tags/') && needs.release.result == 'success' && needs.desktop.result == 'success' && needs.desktop-manifest.result != 'failure' }}
runs-on: ubuntu-latest
permissions:
contents: write
env:
GH_TOKEN: ${{ github.token }}
GH_REPO: ${{ github.repository }}
TAG: ${{ github.ref_name }}
steps:
- name: Write checksums.txt
run: |
patterns=(--pattern 'spotatui*')
if [[ "$TAG" != *-* ]]; then
patterns+=(--pattern 'latest.json')
fi
gh release download "$TAG" "${patterns[@]}" --dir dl
rm -f dl/*.sha256 dl/*.sig
(cd dl && sha256sum * | sort -k2) > checksums.txt
cat checksums.txt
gh release upload "$TAG" checksums.txt --clobber
- name: Check every download link
run: |
assets=$(gh release view "$TAG" --json assets --jq '.assets[].name')
missing=0
for name in $(gh release view "$TAG" --json body --jq .body | grep -oE "releases/download/$TAG/[^)]+" | sed "s|.*/||" | sort -u); do
if ! grep -qxF "$name" <<<"$assets"; then
echo "::error::The notes link $name, which is not on the release"
missing=1
fi
done
exit $missing
# latest.json carries the signatures, so the .sig files leave the page just
# before it goes public. Here, so desktop-manifest can still be re-run.
- name: Publish the release
run: |
if [[ "$TAG" == *-* ]]; then
echo "::notice::$TAG is a release candidate, so it stays a draft."
exit 0
fi
for name in $(gh release view "$TAG" --json assets --jq '.assets[].name | select(endswith(".sig"))'); do
gh release delete-asset "$TAG" "$name" --yes || echo "::warning::Could not delete $name"
done
gh release edit "$TAG" --draft=false --latest
announce-discord:
name: Announce on Discord
needs: publish
runs-on: ubuntu-latest
if: ${{ startsWith(github.ref, 'refs/tags/') && !contains(github.ref_name, '-') }}
steps:
- name: Checkout sources
uses: actions/checkout@v7
- name: Post the release and ping Release Pings
env:
DISCORD_WEBHOOK: ${{ secrets.DISCORD_RELEASE_WEBHOOK }}
TAG: ${{ github.ref_name }}
URL: ${{ github.server_url }}/${{ github.repository }}/releases/tag/${{ github.ref_name }}
CHANGELOG_URL: ${{ github.server_url }}/${{ github.repository }}/blob/${{ github.ref_name }}/CHANGELOG.md
run: |
heading=$(grep -m1 "^## \[$TAG\]" CHANGELOG.md || true)
if [ -n "$heading" ]; then
anchor=$(printf '%s' "${heading#\#\# }" | tr '[:upper:]' '[:lower:]' | tr -cd 'a-z0-9 -' | tr ' ' '-')
CHANGELOG_URL="$CHANGELOG_URL#$anchor"
fi
jq -n --arg tag "$TAG" --arg url "$URL" --arg changelog "$CHANGELOG_URL" '{
content: "<@&1534823673851150377> spotatui \($tag) is out: \($url)\nWhat changed: <\($changelog)>",
allowed_mentions: {roles: ["1534823673851150377"]}
}' | curl -fsS -H "Content-Type: application/json" -d @- "$DISCORD_WEBHOOK"
publish-cargo:
name: Publish to crates.io
needs: publish
runs-on: ubuntu-latest
if: ${{ !contains(github.ref_name, '-') }}
steps:
- name: Checkout sources
uses: actions/checkout@v7
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
- name: Install Linux dependencies
run: |
sudo apt-get update
sudo apt-get install -y -qq pkg-config libssl-dev libxcb1-dev libxcb-render0-dev libxcb-shape0-dev libxcb-xfixes0-dev libasound2-dev
- name: Publish to crates.io
run: cargo publish --token ${{ secrets.CARGO_REGISTRY_TOKEN }} --allow-dirty
publish-aur:
name: Publish to AUR
needs: publish
runs-on: ubuntu-latest
if: ${{ !contains(github.ref_name, '-') }}
steps:
- name: Checkout sources
uses: actions/checkout@v7
- name: Extract version
id: version
run: |
VERSION=$(grep '^version = ' Cargo.toml | head -1 | sed 's/version = "\(.*\)"/\1/')
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "Version: $VERSION"
- name: Calculate SHA256
id: sha256
run: |
TARBALL_URL="https://github.com/${{ github.repository }}/archive/refs/tags/v${{ steps.version.outputs.version }}.tar.gz"
SHA256=$(curl -sL "$TARBALL_URL" | sha256sum | awk '{print $1}')
echo "sha256=$SHA256" >> $GITHUB_OUTPUT
echo "SHA256: $SHA256"
- name: Setup SSH for AUR
run: |
mkdir -p ~/.ssh
echo "${{ secrets.AUR_SSH_PRIVATE_KEY }}" > ~/.ssh/aur
chmod 600 ~/.ssh/aur
ssh-keyscan aur.archlinux.org >> ~/.ssh/known_hosts
git config --global user.name "${{ secrets.AUR_USERNAME }}"
git config --global user.email "${{ secrets.AUR_EMAIL }}"
- name: Clone AUR repository
run: |
GIT_SSH_COMMAND='ssh -i ~/.ssh/aur -o IdentitiesOnly=yes' git clone ssh://aur@aur.archlinux.org/spotatui.git aur
- name: Update PKGBUILD
working-directory: aur
run: |
sed -i "s/^pkgver=.*/pkgver=${{ steps.version.outputs.version }}/" PKGBUILD
sed -i "s/^pkgrel=.*/pkgrel=1/" PKGBUILD
sed -i "s/^sha256sums=.*/sha256sums=('${{ steps.sha256.outputs.sha256 }}')/" PKGBUILD
# Ensure AUR builds work with default system linker (avoids LLVM bitcode/LTO linker errors).
if ! grep -q "CARGO_PROFILE_RELEASE_LTO=false" PKGBUILD; then
sed -i "/^[[:space:]]*build() {[[:space:]]*$/a\\ export CARGO_PROFILE_RELEASE_LTO=false" PKGBUILD
fi
- name: Generate .SRCINFO
working-directory: aur
run: |
docker run --rm -v $PWD:/pkg archlinux:latest /bin/bash -c "
pacman -Syu --noconfirm --needed base-devel &&
useradd -m builder &&
chown -R builder:builder /pkg &&
cd /pkg &&
su builder -c 'makepkg --printsrcinfo' > .SRCINFO
"
sudo chown -R $(id -u):$(id -g) .
- name: Commit and push to AUR
working-directory: aur
run: |
git add PKGBUILD .SRCINFO
git commit -m "Update to ${{ steps.version.outputs.version }}"
GIT_SSH_COMMAND='ssh -i ~/.ssh/aur -o IdentitiesOnly=yes' git push
publish-aur-bin:
name: Publish to AUR (spotatui-bin)
needs: publish
runs-on: ubuntu-latest
if: ${{ !contains(github.ref_name, '-') }}
steps:
- name: Checkout sources
uses: actions/checkout@v7
- name: Extract version
id: version
run: |
VERSION=$(grep '^version = ' Cargo.toml | head -1 | sed 's/version = "\(.*\)"/\1/')
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "Version: $VERSION"
- name: Calculate SHA256 checksums
id: sha256
run: |
VERSION="${{ steps.version.outputs.version }}"
# SHA256 for the binary tarball
BINARY_URL="https://github.com/${{ github.repository }}/releases/download/v${VERSION}/spotatui-linux-x86_64.tar.gz"
SHA256_BINARY=$(curl -sL "$BINARY_URL" | sha256sum | awk '{print $1}')
echo "sha256_binary=$SHA256_BINARY" >> $GITHUB_OUTPUT
echo "Binary SHA256: $SHA256_BINARY"
# SHA256 for LICENSE
LICENSE_URL="https://raw.githubusercontent.com/${{ github.repository }}/v${VERSION}/LICENSE"
SHA256_LICENSE=$(curl -sL "$LICENSE_URL" | sha256sum | awk '{print $1}')
echo "sha256_license=$SHA256_LICENSE" >> $GITHUB_OUTPUT
echo "LICENSE SHA256: $SHA256_LICENSE"
# SHA256 for README.md
README_URL="https://raw.githubusercontent.com/${{ github.repository }}/v${VERSION}/README.md"
SHA256_README=$(curl -sL "$README_URL" | sha256sum | awk '{print $1}')
echo "sha256_readme=$SHA256_README" >> $GITHUB_OUTPUT
echo "README SHA256: $SHA256_README"
- name: Setup SSH for AUR
run: |
mkdir -p ~/.ssh
echo "${{ secrets.AUR_SSH_PRIVATE_KEY }}" > ~/.ssh/aur
chmod 600 ~/.ssh/aur
ssh-keyscan aur.archlinux.org >> ~/.ssh/known_hosts
git config --global user.name "${{ secrets.AUR_USERNAME }}"
git config --global user.email "${{ secrets.AUR_EMAIL }}"
- name: Clone AUR repository
run: |
GIT_SSH_COMMAND='ssh -i ~/.ssh/aur -o IdentitiesOnly=yes' git clone ssh://aur@aur.archlinux.org/spotatui-bin.git aur-bin
- name: Update PKGBUILD
working-directory: aur-bin
run: |
# Update version and release
sed -i "s/^pkgver=.*/pkgver=${{ steps.version.outputs.version }}/" PKGBUILD
sed -i "s/^pkgrel=.*/pkgrel=1/" PKGBUILD
# Remove existing checksum lines (handles both single and multi-line)
sed -i '/^sha256sums=/d' PKGBUILD
sed -i '/^sha256sums_x86_64=/d' PKGBUILD
sed -i "/^[[:space:]]*'[a-f0-9]\{64\}')/d" PKGBUILD
# Insert new single-line checksums before package()
SHA_L="${{ steps.sha256.outputs.sha256_license }}"
SHA_R="${{ steps.sha256.outputs.sha256_readme }}"
SHA_B="${{ steps.sha256.outputs.sha256_binary }}"
sed -i "/^package()/i sha256sums=('${SHA_L}' '${SHA_R}')\nsha256sums_x86_64=('${SHA_B}')" PKGBUILD
# Validation
echo "=== Validating PKGBUILD ==="
grep "^pkgver=" PKGBUILD
grep "^sha256sums=" PKGBUILD || { echo "ERROR: sha256sums not found"; exit 1; }
grep "^sha256sums_x86_64=" PKGBUILD || { echo "ERROR: sha256sums_x86_64 not found"; exit 1; }
grep -q "package()" PKGBUILD || { echo "ERROR: package() function missing"; exit 1; }
echo "=== PKGBUILD validation successful ==="
- name: Generate .SRCINFO
working-directory: aur-bin
run: |
docker run --rm -v $PWD:/pkg archlinux:latest /bin/bash -c "
pacman -Syu --noconfirm --needed base-devel &&
useradd -m builder &&
chown -R builder:builder /pkg &&
cd /pkg &&
su builder -c 'makepkg --printsrcinfo' > .SRCINFO
"
sudo chown -R $(id -u):$(id -g) .
- name: Commit and push to AUR
working-directory: aur-bin
run: |
git add PKGBUILD .SRCINFO
git commit -m "Update to ${{ steps.version.outputs.version }}"
GIT_SSH_COMMAND='ssh -i ~/.ssh/aur -o IdentitiesOnly=yes' git push
publish-winget:
name: Publish to winget
needs: publish
runs-on: windows-latest
if: ${{ !contains(github.ref_name, '-') }}
steps:
- name: Install cargo-binstall
uses: cargo-bins/cargo-binstall@main
env:
GITHUB_TOKEN: ${{ github.token }}
- name: Install komac
shell: pwsh
run: cargo binstall komac@2.15.0 -y
env:
GITHUB_TOKEN: ${{ github.token }}
- name: Submit to winget-pkgs
shell: pwsh
run: |
$ReleaseInfo = Invoke-RestMethod `
-Uri "https://api.github.com/repos/${{ github.repository }}/releases/tags/${{ github.ref_name }}" `
-Headers @{ Authorization = "token $env:GITHUB_TOKEN" }
$Version = "${{ github.ref_name }}" -replace '^v'
$Urls = $ReleaseInfo.assets.Where({ $_.name -match '-windows-x86_64\.zip$' }).browser_download_url -join ' '
if ([string]::IsNullOrWhiteSpace($Urls)) {
Write-Error "No Windows x86_64 zip release asset found"
exit 1
}
komac update 'LargeModGames.spotatui' --version "$Version" --submit --urls $Urls
env:
GITHUB_TOKEN: ${{ secrets.WINGET_TOKEN }}
KOMAC_FORK_OWNER: LargeModGames
KOMAC_CREATED_WITH: spotatui CD workflow
KOMAC_CREATED_WITH_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
publish-homebrew:
name: Publish to Homebrew
needs: publish
runs-on: ubuntu-latest
if: ${{ !contains(github.ref_name, '-') }}
steps:
- name: Checkout sources
uses: actions/checkout@v7
- name: Extract version
id: version
run: |
VERSION=$(grep '^version = ' Cargo.toml | head -1 | sed 's/version = "\(.*\)"/\1/')
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "Version: $VERSION"
- name: Calculate SHA256 checksums
id: sha256
run: |
VERSION="${{ steps.version.outputs.version }}"
# macOS aarch64
AARCH64_URL="https://github.com/${{ github.repository }}/releases/download/v${VERSION}/spotatui-macos-aarch64.tar.gz"
SHA256_AARCH64=$(curl -sL "$AARCH64_URL" | sha256sum | awk '{print $1}')
echo "sha256_aarch64=$SHA256_AARCH64" >> $GITHUB_OUTPUT
echo "macOS aarch64 SHA256: $SHA256_AARCH64"
# macOS x86_64
X86_64_URL="https://github.com/${{ github.repository }}/releases/download/v${VERSION}/spotatui-macos-x86_64.tar.gz"
SHA256_X86_64=$(curl -sL "$X86_64_URL" | sha256sum | awk '{print $1}')
echo "sha256_x86_64=$SHA256_X86_64" >> $GITHUB_OUTPUT
echo "macOS x86_64 SHA256: $SHA256_X86_64"
- name: Setup SSH for Homebrew tap
run: |
mkdir -p ~/.ssh
echo "${{ secrets.HOMEBREW_TAP_SSH_KEY }}" > ~/.ssh/homebrew_tap
chmod 600 ~/.ssh/homebrew_tap
ssh-keyscan github.com >> ~/.ssh/known_hosts
git config --global user.name "github-actions[bot]"
git config --global user.email "github-actions[bot]@users.noreply.github.com"
- name: Clone Homebrew tap repository
run: |
GIT_SSH_COMMAND='ssh -i ~/.ssh/homebrew_tap -o IdentitiesOnly=yes' \
git clone git@github.com:LargeModGames/homebrew-spotatui.git homebrew-tap
- name: Update formula
working-directory: homebrew-tap
run: |
cat > Formula/spotatui.rb << 'FORMULA_EOF'
# typed: false
# frozen_string_literal: true
# This formula is automatically updated by the CD workflow on each release.
# Manual changes will be overwritten.
class Spotatui < Formula
desc "Terminal music player for Spotify, local files, Subsonic, radio, YouTube, Qobuz"
homepage "https://spotatui.com"
version "${{ steps.version.outputs.version }}"
license "MIT"
depends_on :macos
depends_on "portaudio"
depends_on "openssl@3"
on_arm do
url "https://github.com/LargeModGames/spotatui/releases/download/v#{version}/spotatui-macos-aarch64.tar.gz"
sha256 "${{ steps.sha256.outputs.sha256_aarch64 }}"
end
on_intel do
url "https://github.com/LargeModGames/spotatui/releases/download/v#{version}/spotatui-macos-x86_64.tar.gz"
sha256 "${{ steps.sha256.outputs.sha256_x86_64 }}"
end
def install
bin.install "spotatui"
end
def caveats
<<~EOS
Spotify in spotatui needs a Spotify Premium account. Run `spotatui`,
pick Spotify, and confirm the code it shows on Spotify's pairing page.
Note: Native streaming on macOS requires 'portaudio' (installed as a dependency).
More info: https://github.com/LargeModGames/spotatui
EOS
end
test do
assert_match "spotatui", shell_output("#{bin}/spotatui --version")
end
end
FORMULA_EOF
- name: Commit and push to Homebrew tap
working-directory: homebrew-tap
run: |
git add Formula/spotatui.rb
git diff --cached --quiet && echo "No changes to commit" && exit 0
git commit -m "Update spotatui to ${{ steps.version.outputs.version }}"
GIT_SSH_COMMAND='ssh -i ~/.ssh/homebrew_tap -o IdentitiesOnly=yes' git push