forked from debpalash/VoiceStudio
-
Notifications
You must be signed in to change notification settings - Fork 0
1393 lines (1316 loc) · 72.6 KB
/
Copy pathrelease.yml
File metadata and controls
1393 lines (1316 loc) · 72.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
# Desktop release pipeline — self-updating binaries for mac/linux/windows.
#
# Triggers:
# - push of a tag matching `v*` (e.g. `v0.2.0`) → full STABLE release,
# publishes artifacts + signed updater manifest (`latest.json`) to the
# tag's GH Release. This is the default Stable updater channel.
# - schedule (nightly, 07:00 UTC) → rolling `preview` PRERELEASE built from
# `main` with its own signed `latest.json` at releases/download/preview/.
# Feeds the opt-in Preview updater channel (Settings → About → Update
# channel). The `preview-gate` job skips the matrix on nights when `main`
# didn't move, so an idle day costs only a ~30s gate job — keeping Preview
# ≤24h behind `main` at a predictable ~1-matrix/day cost. The stable
# `latest` release is untouched.
# - workflow_dispatch (publish_preview=true) → the same preview build on
# demand from the selected branch (e.g. to preview a feature branch, or to
# refresh immediately without waiting for the nightly).
# - workflow_dispatch (publish_preview=false) → on-demand build (prior
# behavior; draft release named after the branch).
#
# Strategy: matrix builds per target. Each runner produces a PyInstaller
# frozen backend + Tauri bundle. `tauri-apps/tauri-action` signs the updater
# payloads with TAURI_SIGNING_PRIVATE_KEY and uploads to the GH Release for
# the tag. The built-in updater plugin polls the release's `latest.json` on
# client boot.
#
# Windows/Linux support: first-pass enabled. Expect the first few runs on
# each to surface PyInstaller/Tauri issues that never showed up locally on
# macOS — iterate on CI.
name: Desktop Release
on:
push:
tags: ['v*']
schedule:
# 07:00 UTC daily — rolling `preview` prerelease from `main`. The
# preview-gate job no-ops the matrix when main hasn't moved in a day.
- cron: '0 7 * * *'
workflow_dispatch:
inputs:
draft:
description: "Create as draft release (tag push only)"
required: false
default: "true"
publish_preview:
description: "Publish a rolling 'preview' prerelease (updater Preview channel). Previews ALWAYS build from main — dispatching from any other branch fails the preview-gate."
required: false
type: boolean
default: false
permissions:
contents: write # needed to attach artifacts + updater manifest to GH Release
# Every preview build publishes to the SAME rolling `preview` release, and the
# updater manifest is rebuilt from whatever assets are on it. Two overlapping
# preview runs (the nightly schedule and a manual dispatch, say) would upload
# into each other's asset set, and the version-less macOS tarballs carry
# nothing saying which run produced them — so one run could publish a manifest
# advertising its own version while serving the other run's macOS binaries
# (greptile). Serialize instead. Keyed on the ref, so a `v*` tag push (which
# builds its own release and never touches `preview`) is never queued behind a
# nightly.
concurrency:
group: desktop-release-${{ github.ref }}
cancel-in-progress: false
env:
# Run all JavaScript actions on Node 24 (GH deprecates Node 20 in Sep 2026).
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
jobs:
# Fast gating job — runs backend pytest + frontend node:test + tsc on a
# single Linux runner. The matrix build below waits on this via `needs:`
# so we don't burn 4× platform-matrix minutes on a broken commit.
test:
name: Tests (backend + frontend)
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v4
- name: Setup Python 3.11
uses: actions/setup-python@v5
with:
python-version: "3.11"
# enable-cache persists ~/.cache/uv keyed on uv.lock.
- name: Install uv
uses: astral-sh/setup-uv@v3
with:
enable-cache: true
cache-dependency-glob: "uv.lock"
# Node 22 is needed for --experimental-strip-types so node:test can
# import .ts files directly from frontend/src/api/*.
- name: Setup Node 22
uses: actions/setup-node@v4
with:
node-version: '22'
- name: Setup Bun
uses: oven-sh/setup-bun@v1
# Backend tests need ffmpeg (subprocess calls in fixtures). Cache the
# resolved .debs so warm runs skip the apt-get update + install.
- name: System deps (ffmpeg)
uses: awalsh128/cache-apt-pkgs-action@v1.6.3
with:
packages: ffmpeg
version: 1.0
- name: Install Python deps
run: bash scripts/uv-sync-retry.sh
- name: Run pytest
run: uv run --no-sync pytest tests/ -q --tb=short
- name: Cache bun deps
uses: actions/cache@v4
with:
path: ~/.bun/install/cache
key: ${{ runner.os }}-bun-${{ hashFiles('frontend/bun.lock', 'bun.lock') }}
restore-keys: |
${{ runner.os }}-bun-
- name: Install frontend deps
working-directory: frontend
run: bun install
# Single-sourced typecheck command (mirrors ci.yml). The `typecheck:ci`
# script in frontend/package.json sets `--checkJs false` so pre-existing
# JS-side errors don't block the release; only .ts/.tsx files gate.
# Drift between CI and release-time typecheck flags broke v0.3.x release
# runs — keep this command identical to ci.yml's step.
- name: Frontend typecheck
working-directory: frontend
run: bun run typecheck:ci
# Invoke node directly (not `bun run test`) because `bun run` auto-aliases
# `node` to `bun` in script bodies, and bun doesn't support
# --experimental-strip-types.
- name: Run frontend node:test
working-directory: frontend
run: node --experimental-strip-types --no-warnings --test ../tests/frontend/*.test.mjs
# Decide preview-vs-stable, and for nightly runs whether `main` actually
# moved in the last day. Outputs gate the expensive matrix (`build`) and the
# `preview-notes` job, so a no-commit night costs only this ~30s job.
preview-gate:
name: Preview gate
runs-on: ubuntu-22.04
permissions:
contents: read
outputs:
is_preview: ${{ steps.decide.outputs.is_preview }}
proceed: ${{ steps.decide.outputs.proceed }}
stable_tag: ${{ steps.decide.outputs.stable_tag }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 50
- id: decide
shell: bash
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
event="${{ github.event_name }}"
if [ "$event" = "schedule" ] || { [ "$event" = "workflow_dispatch" ] && [ "${{ inputs.publish_preview }}" = "true" ]; }; then
# Preview channel policy (owner-set 2026-07-16): previews ALWAYS
# build from main. The preview updater manifest and the Docker
# rolling tags (:latest/:main/:rocm) all track main — a preview
# cut from a side branch would desync the channels and could
# ship code that never merged. Merge to main first.
if [ "${{ github.ref }}" != "refs/heads/main" ]; then
echo "::error::Preview builds publish from main only (got '${{ github.ref }}'). Merge to main, then dispatch with publish_preview=true."
exit 1
fi
echo "is_preview=true" >> "$GITHUB_OUTPUT"
# Resolve once before the matrix starts so every platform stamps
# against the same immutable Stable-channel snapshot.
STABLE_TAG=$(gh release view --repo "$GITHUB_REPOSITORY" --json tagName --jq .tagName)
[[ "$STABLE_TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]] || {
echo "::error::latest stable release has an invalid tag"; exit 1;
}
echo "stable_tag=$STABLE_TAG" >> "$GITHUB_OUTPUT"
else
echo "is_preview=false" >> "$GITHUB_OUTPUT"
fi
# Nightly: skip the matrix when main hasn't moved in the last day.
if [ "$event" = "schedule" ] && [ -z "$(git log --since='25 hours ago' --oneline)" ]; then
echo "No new commits on main in the last day — skipping nightly preview."
echo "proceed=false" >> "$GITHUB_OUTPUT"
else
echo "proceed=true" >> "$GITHUB_OUTPUT"
fi
build:
needs: [test, preview-gate]
# Nightly runs with no new commits on main skip the 4-platform matrix.
if: needs.preview-gate.outputs.proceed == 'true'
strategy:
fail-fast: false
matrix:
include:
- os: macos-14
arch: aarch64-apple-darwin
label: "macOS Apple Silicon"
rust_target: aarch64-apple-darwin
bundles: "app,dmg,updater"
# macOS Intel (#279): reinstated. The earlier "Rosetta 2 runs the
# ARM build" rationale for dropping it was backwards — Rosetta only
# translates x86_64→arm64, so Intel Macs (supported through macOS
# Sequoia) simply cannot run the aarch64 bundle and had NO
# installable artifact. Runner: `macos-15-intel`, GitHub's
# designated migration target after macos-13 retired (Dec 2025);
# it's a standard (public-repo-free) image supported through
# August 2027 — the last x86_64 image Actions will offer. Building
# natively (not cross-compiling from the arm64 leg) keeps the
# per-TRIPLE uv/ffmpeg sidecar fetches, the DMG installer smoke,
# and the ad-hoc signing verification (scripts/
# verify-macos-signing.sh, PR #290) all exercising the real
# x86_64 artifact on real Intel hardware. The macos-13 queue
# backlog that motivated the original drop is contained by
# fail-fast:false — a slow Intel leg can delay the release run but
# can't fail the other targets.
#
# #889 (2026-07): Intel macOS is now UNSUPPORTED for the local
# backend — torch ≥2.3 ships no macOS x86_64 wheels, so the venv
# bootstrap can never succeed on Intel. The shipped x64 artifact is
# effectively UI-only (usable with a remote backend); the app now
# pre-fails first-run bootstrap with an honest message on Intel.
# Whether to keep shipping this x64 leg (UI-only) or drop it is an
# OWNER CALL — deliberately not changed in the #889 PR.
- os: macos-15-intel
arch: x86_64-apple-darwin
label: "macOS Intel"
rust_target: x86_64-apple-darwin
bundles: "app,dmg,updater"
# Windows: force MSI bundling via --bundles. NSIS fails at makensis
# because our PyInstaller payload approaches its ~2 GB stub limit.
- os: windows-2022
arch: x86_64-pc-windows-msvc
label: "Windows x64"
rust_target: x86_64-pc-windows-msvc
bundles: "msi,updater"
# Linux: ship .AppImage only. AppImage is universal (no distro
# package-manager dep), runs on any glibc-2.39+ host, and is the
# Linux auto-update target. The .deb target was dropped: tauri-bundler
# fails it with "Failed to create control scripts: No such file or
# directory" (no custom deb config of ours is at fault) — revisit on a
# tauri-cli bump. FUSE unavailability on GH runners is handled via
# APPIMAGE_EXTRACT_AND_RUN=1.
#
# Bumped from ubuntu-22.04 → ubuntu-24.04 (#961): the AppImage
# bundles whatever `libwebkit2gtk-4.1-dev` the build runner's apt
# repos resolve (see the "Linux system deps" step below) — 22.04's
# was meaningfully stale relative to what current Ubuntu/Fedora
# ship, and AppRun's LD_LIBRARY_PATH makes that bundled, stale copy
# take priority over a healthy system WebKitGTK at runtime. Raises
# the AppImage's glibc floor from 2.35 to 2.39 — pre-2022 distros
# (Ubuntu <22.04, Debian <12) lose support; no report of anyone on
# something that old has come in, and the project's own install
# docs already assume Debian 12 / Ubuntu 22.04+.
- os: ubuntu-24.04
arch: x86_64-unknown-linux-gnu
label: "Linux x64"
rust_target: x86_64-unknown-linux-gnu
bundles: "appimage,updater"
runs-on: ${{ matrix.os }}
name: ${{ matrix.label }}
steps:
- uses: actions/checkout@v4
# ── Language runtimes ──────────────────────────────────────────────
- name: Setup Rust (stable)
uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.rust_target }}
# Cache ~/.cargo/registry + {target}/ per rust_target. Cargo dep
# compile is the long pole of the build — cold is ~5-7 min, warm
# drops to ~1-2 min.
- name: Rust cache
uses: Swatinem/rust-cache@v2
with:
workspaces: frontend/src-tauri -> target
key: ${{ matrix.rust_target }}
- name: Setup Bun
uses: oven-sh/setup-bun@v1
# ── Platform deps (Tauri host requirements only — no Python here) ─
# The runtime Python/uv bootstrap happens on the user's machine at
# first launch, not in CI. CI only packages the source (pyproject.toml,
# uv.lock, backend/*.py) into the Tauri installer as resources.
- name: macOS system deps
if: runner.os == 'macOS'
run: |
brew install ffmpeg || true
- name: Linux system deps
if: runner.os == 'Linux'
run: |
sudo apt-get update
sudo apt-get install -y \
libwebkit2gtk-4.1-dev \
build-essential curl wget file libxdo-dev libssl-dev \
libayatana-appindicator3-dev librsvg2-dev \
libasound2-dev ffmpeg
# ── Frontend build ─────────────────────────────────────────────────
- name: Cache bun deps
uses: actions/cache@v4
with:
path: ~/.bun/install/cache
key: ${{ runner.os }}-bun-${{ hashFiles('frontend/bun.lock', 'bun.lock') }}
restore-keys: |
${{ runner.os }}-bun-
- name: Install frontend deps
working-directory: frontend
run: bun install
# ── Tauri build + sign + publish ───────────────────────────────────
# tauri-action handles: bundle, sign updater payload with the
# TAURI_SIGNING_PRIVATE_KEY secret, attach to release, update
# latest.json with per-platform download URLs & signatures. The
# installer ships the repo's pyproject.toml + uv.lock + backend/
# tree as Tauri resources; lib.rs::ensure_venv_ready recreates the
# venv on first launch via `uv sync --frozen --no-dev`.
# Fetch the standalone `uv` binary for the current matrix target and
# drop it at `binaries/uv-<rust-target-triple>{ext}`. tauri.conf.json
# references `binaries/uv` via `bundle.externalBin`, and tauri-bundler
# picks up the per-target file automatically. The runtime then uses
# the bundled binary instead of downloading uv on first launch.
#
# Pinned uv version mirrors the `UV_VERSION` constant in lib.rs; bump
# both together when refreshing.
- name: Bundle uv (${{ matrix.rust_target }})
shell: bash
env:
UV_VERSION: "0.11.7"
TRIPLE: ${{ matrix.rust_target }}
run: |
set -euo pipefail
mkdir -p frontend/src-tauri/binaries
case "$TRIPLE" in
aarch64-apple-darwin|x86_64-apple-darwin|x86_64-unknown-linux-gnu)
ARCHIVE="tar.gz"
;;
x86_64-pc-windows-msvc)
ARCHIVE="zip"
;;
*)
echo "Unsupported target for uv bundling: $TRIPLE"
exit 1
;;
esac
URL="https://github.com/astral-sh/uv/releases/download/${UV_VERSION}/uv-${TRIPLE}.${ARCHIVE}"
echo "Fetching $URL"
WORK=$(mktemp -d)
if [ "$ARCHIVE" = "zip" ]; then
curl -fsSL "$URL" -o "$WORK/uv.zip"
unzip -j -o "$WORK/uv.zip" -d "$WORK"
mv "$WORK/uv.exe" "frontend/src-tauri/binaries/uv-${TRIPLE}.exe"
else
curl -fsSL "$URL" | tar -xz -C "$WORK"
mv "$WORK/uv-${TRIPLE}/uv" "frontend/src-tauri/binaries/uv-${TRIPLE}"
chmod +x "frontend/src-tauri/binaries/uv-${TRIPLE}"
fi
ls -la "frontend/src-tauri/binaries/"
# Download static ffmpeg + ffprobe binaries and drop them into the
# Tauri sidecar directory. Sources:
# macOS: evermeet.cx — individual .zip per binary (x86_64,
# runs fine on Apple Silicon via Rosetta 2)
# Linux/Windows: BtbN/FFmpeg-Builds — single archive with both bins
# Pinned BtbN/FFmpeg-Builds version for Linux + Windows ffmpeg
# bundling. The string appears *twice* in each URL (once as the
# release tag, once inside the archive filename) — BtbN tags their
# autobuilds `autobuild-YYYY-MM-DD-HH-MM` and the inner filenames
# use the same datestamp. Driving both from one variable means a
# maintainer pin is a one-line edit: change `latest` to a specific
# autobuild tag (https://github.com/BtbN/FFmpeg-Builds/releases) to
# get reproducible installer builds. Same constant lives in
# frontend/src-tauri/src/tools.rs:FFMPEG_BTBN_VERSION — bump
# together.
- name: Bundle ffmpeg + ffprobe (${{ matrix.rust_target }})
shell: bash
env:
TRIPLE: ${{ matrix.rust_target }}
FFMPEG_BTBN_VERSION: "latest"
run: |
set -euo pipefail
BINDIR="frontend/src-tauri/binaries"
mkdir -p "$BINDIR"
WORK=$(mktemp -d)
case "$TRIPLE" in
aarch64-apple-darwin|x86_64-apple-darwin)
# evermeet.cx ships each binary as a separate .zip containing
# a single x86_64 Mach-O executable — natively correct on the
# Intel leg, and runs via Rosetta 2 on the arm64 leg. Both
# darwin TRIPLEs therefore bundle the same payload; only the
# sidecar filename suffix differs.
for TOOL in ffmpeg ffprobe; do
if [ "$TOOL" = "ffmpeg" ]; then
URL="https://evermeet.cx/ffmpeg/getrelease/zip"
else
URL="https://evermeet.cx/ffmpeg/getrelease/${TOOL}/zip"
fi
echo "Fetching $TOOL from evermeet.cx"
curl -fsSL "$URL" -o "$WORK/${TOOL}.zip"
unzip -o -j "$WORK/${TOOL}.zip" -d "$WORK"
mv "$WORK/${TOOL}" "$BINDIR/${TOOL}-${TRIPLE}"
chmod +x "$BINDIR/${TOOL}-${TRIPLE}"
done
;;
x86_64-unknown-linux-gnu)
URL="https://github.com/BtbN/FFmpeg-Builds/releases/download/${FFMPEG_BTBN_VERSION}/ffmpeg-master-${FFMPEG_BTBN_VERSION}-linux64-gpl.tar.xz"
echo "Fetching ffmpeg from BtbN (linux64) — version=${FFMPEG_BTBN_VERSION}"
curl -fsSL "$URL" -o "$WORK/ffmpeg.tar.xz"
tar -xJf "$WORK/ffmpeg.tar.xz" -C "$WORK"
# Archive extracts to ffmpeg-master-latest-linux64-gpl/bin/
EXTRACTED=$(find "$WORK" -type d -name "bin" | head -1)
mv "$EXTRACTED/ffmpeg" "$BINDIR/ffmpeg-${TRIPLE}"
mv "$EXTRACTED/ffprobe" "$BINDIR/ffprobe-${TRIPLE}"
chmod +x "$BINDIR/ffmpeg-${TRIPLE}" "$BINDIR/ffprobe-${TRIPLE}"
;;
x86_64-pc-windows-msvc)
URL="https://github.com/BtbN/FFmpeg-Builds/releases/download/${FFMPEG_BTBN_VERSION}/ffmpeg-master-${FFMPEG_BTBN_VERSION}-win64-gpl.zip"
echo "Fetching ffmpeg from BtbN (win64) — version=${FFMPEG_BTBN_VERSION}"
curl -fsSL "$URL" -o "$WORK/ffmpeg.zip"
unzip -o "$WORK/ffmpeg.zip" -d "$WORK"
EXTRACTED=$(find "$WORK" -type f -name "ffmpeg.exe" | head -1)
EXTRACTED_DIR=$(dirname "$EXTRACTED")
mv "$EXTRACTED_DIR/ffmpeg.exe" "$BINDIR/ffmpeg-${TRIPLE}.exe"
mv "$EXTRACTED_DIR/ffprobe.exe" "$BINDIR/ffprobe-${TRIPLE}.exe"
;;
*)
echo "⚠ No ffmpeg bundling for target: $TRIPLE (will download at first run)"
;;
esac
ls -la "$BINDIR/"
# Extract the matching CHANGELOG.md section so the release body has
# real notes instead of "see commit log". Falls back to a one-liner
# if the tag has no matching `## [X.Y.Z]` section yet — keeps the
# release publishable even when CHANGELOG hasn't been updated.
- name: Extract CHANGELOG section for tag
id: changelog
shell: bash
run: |
TAG="${GITHUB_REF_NAME#v}"
BODY=""
if [ -f CHANGELOG.md ]; then
BODY=$(awk -v tag="$TAG" '
/^## \[/ {
if (in_section) exit
if ($0 ~ "\\[" tag "\\]") { in_section = 1; next }
}
in_section { print }
' CHANGELOG.md | sed -e :a -e '/^\n*$/{$d;N;ba' -e '}')
fi
if [ -z "$BODY" ]; then
BODY="Auto-generated release for ${GITHUB_REF_NAME}. See [CHANGELOG.md](https://github.com/${GITHUB_REPOSITORY}/blob/main/CHANGELOG.md) and the commit log for details."
fi
{
echo 'body<<RELEASE_BODY_EOF'
echo "$BODY"
echo 'RELEASE_BODY_EOF'
} >> "$GITHUB_OUTPUT"
# Apple code-signing is OPT-IN and OFF by default. Export the APPLE_*
# secrets to $GITHUB_ENV ONLY for a stable `v*` release with the
# MACOS_SIGNING_ENABLED repo variable set. On every other path (preview,
# or stable without the var) the APPLE_* vars stay ABSENT — NOT empty.
# This matters: Tauri's macOS bundler runs `security import` whenever
# APPLE_CERTIFICATE is *present* (even ""), which fails the whole build;
# absence makes it skip cert import and bundle unsigned (users clear
# quarantine via `xattr -cr`, see docs/install/macos.md). A static `env:`
# on the build step can't express "absent", so signing lives here.
# To enable signed stable releases: fix the signing secrets, then set the
# repo variable MACOS_SIGNING_ENABLED = true.
- name: Configure Apple signing (stable, opt-in)
if: runner.os == 'macOS' && github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') && vars.MACOS_SIGNING_ENABLED == 'true'
env:
C: ${{ secrets.APPLE_CERTIFICATE }}
CP: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
SI: ${{ secrets.APPLE_SIGNING_IDENTITY }}
AID: ${{ secrets.APPLE_ID }}
AP: ${{ secrets.APPLE_PASSWORD }}
TID: ${{ secrets.APPLE_TEAM_ID }}
run: |
{
echo "APPLE_CERTIFICATE<<__OV_EOF__"
echo "$C"
echo "__OV_EOF__"
echo "APPLE_CERTIFICATE_PASSWORD=$CP"
echo "APPLE_SIGNING_IDENTITY=$SI"
echo "APPLE_ID=$AID"
echo "APPLE_PASSWORD=$AP"
echo "APPLE_TEAM_ID=$TID"
} >> "$GITHUB_ENV"
# Stamp each preview with a numeric prerelease that is strictly above the
# latest stable release. Main may intentionally retain the released
# version while AUTO_VERSION_BUMP is disabled; in that case the helper
# advances the preview base by one patch so stable users can still opt in
# and receive it. The edit is ephemeral and never committed.
- name: Stamp preview version
if: needs.preview-gate.outputs.is_preview == 'true'
shell: bash
env:
STABLE_TAG: ${{ needs.preview-gate.outputs.stable_tag }}
run: |
set -euo pipefail
PREVIEW_VERSION=$(python scripts/stamp-preview-version.py \
--package-json frontend/package.json \
--stable-tag "$STABLE_TAG" \
--run-number "${{ github.run_number }}")
echo "Stamped preview version: $PREVIEW_VERSION"
# The rolling `preview` release is REUSED every night, and macOS updater
# artifacts are the only ones Tauri names WITHOUT the version:
#
# VoiceStudio_0.4.1-103_x64.dmg <- unique per run, uploads fine
# VoiceStudio_x64.app.tar.gz <- constant, collides
#
# So every preview build after the first failed the macOS legs with
# `Validation Failed: {"resource":"ReleaseAsset","code":"already_exists"}`
# — and it failed AFTER the dmg upload, so the run went red while looking
# partially successful. The macOS updater bundles on `preview` went stale
# on 2026-07-04/05 and stayed that way for three weeks: Preview-channel
# macOS users had no working update path, and the nightly run was red
# every night.
#
# Delete this arch's updater bundle before uploading the new one. Scoped
# to the preview path (a `v*` tag makes a fresh release, nothing to
# collide with) and to this job's own arch, so the parallel aarch64/x64
# legs never touch each other's assets.
#
# ONLY an absent release/asset is benign. Auth, permission, rate-limit and
# network failures must not be swallowed: the step would report success
# while the stale asset survived, the upload would then die with
# `already_exists`, and we would be back to the exact outage this step
# exists to prevent — minus the red step that explains why. Since GH_TOKEN
# is scoped to this same repo, a 404 really does mean "not there".
- name: Clear this arch's stale preview updater bundle (macOS)
if: needs.preview-gate.outputs.is_preview == 'true' && runner.os == 'macOS'
shell: bash
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -uo pipefail
# aarch64-apple-darwin -> aarch64 ; x86_64-apple-darwin -> x64
case "${{ matrix.arch }}" in
aarch64-*) SUFFIX=aarch64 ;;
x86_64-*) SUFFIX=x64 ;;
*) echo "::error::unexpected arch ${{ matrix.arch }}"; exit 1 ;;
esac
# Match the STORED name, not the uploaded one. GitHub rewrites
# spaces to dots, which is why the pre-rename product ("OmniVoice
# Studio") was stored as "OmniVoice.Studio_x64.app.tar.gz".
# "VoiceStudio" has no space and so needs no translation — the
# pattern below matches both, so a preview release still holding
# pre-rename assets is still cleaned up.
if ! gh release view preview --json assets -q '.assets[].name' \
> /tmp/preview-assets.txt 2> /tmp/gh-view-err.txt; then
if grep -qiE 'not found|HTTP 404' /tmp/gh-view-err.txt; then
echo "No preview release yet — nothing to clear."
exit 0
fi
echo "::error::Could not read the preview release, so a stale ${SUFFIX} bundle may still be there."
echo "Refusing to continue blind — the Tauri upload would fail with already_exists."
cat /tmp/gh-view-err.txt
exit 1
fi
grep -E "(^VoiceStudio|[ .]Studio)_${SUFFIX}\.app\.tar\.gz(\.sig)?$" /tmp/preview-assets.txt \
> /tmp/stale.txt || true
if [ ! -s /tmp/stale.txt ]; then
echo "No stale ${SUFFIX} updater bundle on preview — nothing to clear."
exit 0
fi
while IFS= read -r name; do
echo "Removing stale preview asset: $name"
if ! gh release delete-asset preview "$name" --yes \
2> /tmp/gh-del-err.txt; then
# Already gone is fine — a re-run or the sibling leg beat us to
# it, and the goal (no asset under this name) is met either way.
if grep -qiE 'not found|HTTP 404' /tmp/gh-del-err.txt; then
echo " (already gone — nothing to collide with)"
continue
fi
echo "::error::Failed to delete stale preview asset $name."
cat /tmp/gh-del-err.txt
exit 1
fi
done < /tmp/stale.txt
# A retried job reuses its version and can collide with installers it
# uploaded before a later step failed. Keep other versions/arches intact;
# macOS versionless updater archives are scoped by release tag and arch.
- name: Clear this target's installer assets on retry
if: github.run_attempt > 1
shell: bash
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
RELEASE_TAG: ${{ (needs.preview-gate.outputs.is_preview == 'true') && 'preview' || github.ref_name }}
RELEASE_TARGET: ${{ matrix.rust_target }}
run: |
VERSION=$(python -c 'import json; print(json.load(open("frontend/package.json"))["version"])')
python scripts/clear-release-rerun-assets.py \
--tag "$RELEASE_TAG" --version "$VERSION" --target "$RELEASE_TARGET"
- name: Build + release (Tauri)
uses: tauri-apps/tauri-action@v0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Analytics destination, injected at BUILD time (never committed — a
# token-shaped literal in the repo trips the secret scanner, and the
# frontend bundle is where a publishable client key belongs). Absent =>
# the build has no destination, the Privacy toggle isn't offered, and
# nothing can be sent. Analytics still requires the user to opt in.
VITE_POSTHOG_KEY: ${{ secrets.POSTHOG_PROJECT_TOKEN }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
# macOS Apple signing (#134 / #72) is configured by the preceding
# "Configure Apple signing" step — it exports APPLE_* to $GITHUB_ENV
# only on the opt-in stable path, leaving them ABSENT (not "") on
# preview/unsigned paths so Tauri's bundler skips cert import. A static
# env: here would always set them to "" and break the mac build.
# Unsigned paths still get a VALID ad-hoc seal from tauri.conf.json
# (bundle.macOS.signingIdentity = "-"), so a downloaded build shows the
# GUI-bypassable "unidentified developer" prompt (right-click → Open /
# Settings → "Open Anyway") instead of the un-bypassable "damaged"
# error. On the signed path APPLE_SIGNING_IDENTITY (env) overrides the
# "-" default; once notarized, Gatekeeper accepts it with no prompt.
# GH runners disable FUSE, so linuxdeploy's AppImage can't mount
# itself at bundle time. This env tells linuxdeploy to extract-and-run
# instead, which works without FUSE.
APPIMAGE_EXTRACT_AND_RUN: 1
with:
projectPath: frontend
args: --target ${{ matrix.rust_target }} --bundles ${{ matrix.bundles }}
# Preview path (workflow_dispatch + publish_preview=true) targets a
# rolling `preview` prerelease for the updater's Preview channel.
# Every other invocation — crucially the `v*` tag-push stable release
# — evaluates these expressions to exactly their prior values.
tagName: ${{ (needs.preview-gate.outputs.is_preview == 'true') && 'preview' || github.ref_name }}
# Version-first so the tag is readable in GitHub's truncated
# release-list sidebar (which clips the title mid-string).
releaseName: ${{ (needs.preview-gate.outputs.is_preview == 'true') && 'Preview — VoiceStudio' || format('{0} — VoiceStudio', github.ref_name) }}
releaseBody: ${{ steps.changelog.outputs.body }}
releaseDraft: ${{ (needs.preview-gate.outputs.is_preview == 'true') && 'false' || (inputs.draft || 'true') }}
prerelease: ${{ needs.preview-gate.outputs.is_preview == 'true' }}
updaterJsonPreferNsis: false
includeUpdaterJson: true
- name: Build per-user Windows MSI
if: runner.os == 'Windows'
shell: bash
working-directory: frontend
env:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
run: |
set -euo pipefail
python ../scripts/render-per-user-wix.py \
--source src-tauri/wix/main.wxs \
--system-wxs src-tauri/target/${{ matrix.rust_target }}/release/wix/x64/main.wxs \
--output src-tauri/target/wix-per-user/main.wxs
bunx tauri build --target ${{ matrix.rust_target }} --bundles msi \
--config src-tauri/tauri.per-user.conf.json
DIR="src-tauri/target/${{ matrix.rust_target }}/release/bundle/msi"
while IFS= read -r artifact; do
safe=${artifact// (Current User)/_Current_User}
[ "$safe" = "$artifact" ] || mv "$artifact" "$safe"
done < <(find "$DIR" -maxdepth 1 -type f -name '*Current*User*.msi*')
- name: Publish per-user Windows updater channel
if: runner.os == 'Windows'
shell: bash
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
RELEASE_TAG: ${{ (needs.preview-gate.outputs.is_preview == 'true') && 'preview' || github.ref_name }}
run: |
set -euo pipefail
DIR="frontend/src-tauri/target/${{ matrix.rust_target }}/release/bundle/msi"
MSI=$(find "$DIR" -name '*Current*User*.msi' -type f | head -1)
[ -n "$MSI" ] || { echo "per-user MSI missing"; find "$DIR" -type f; exit 1; }
[ -f "$MSI.sig" ] || { echo "per-user MSI signature missing"; exit 1; }
VERSION=$(jq -r .version frontend/package.json)
python scripts/build_windows_user_manifest.py \
--repo "$GITHUB_REPOSITORY" --tag "$RELEASE_TAG" --version "$VERSION" \
--asset "$(basename "$MSI")" --signature-file "$MSI.sig" \
--output latest-user.json
gh release upload "$RELEASE_TAG" "$MSI" "$MSI.sig" latest-user.json \
--clobber --repo "$GITHUB_REPOSITORY"
# ── Installer smoke (Phase 0 GATE-03) ─────────────────────────────
# Structural verification of the installed/extracted bundle. The thin
# uv-venv installer ships NO frozen backend binary (the venv is built on
# first launch via the bundled `uv`), so there is nothing to boot with
# `--health-check` here. Instead assert the bundle carries the shell
# binary, the bundled `uv` sidecar, and the backend source resources
# (pyproject.toml + backend/main.py) — the real "is the bundle complete"
# regression that ci.yml's in-process smoke can't catch.
- name: Installer smoke (macOS)
if: runner.os == 'macOS'
timeout-minutes: 5
shell: bash
run: |
set -euo pipefail
DMG=$(find frontend/src-tauri/target/${{ matrix.rust_target }}/release/bundle/dmg -name "*.dmg" | head -1)
echo "Smoke-testing DMG: $DMG"
# Grab the full mount path with a grep rather than `awk '{print $3}'`.
# The volume name used to contain a space ("OmniVoice Studio"), which
# awk truncated to /Volumes/OmniVoice; "VoiceStudio" has no space, so
# this is now belt-and-braces rather than load-bearing.
MOUNT=$(hdiutil attach -nobrowse -readonly "$DMG" | tail -1 | grep -oE '/Volumes/.*$')
APP=$(find "$MOUNT" -maxdepth 2 -name "*.app" | head -1)
fail() { echo "FAIL — $1"; find "$APP/Contents" -maxdepth 4 -type f 2>/dev/null | head -40; hdiutil detach "$MOUNT" || true; exit 1; }
[ -n "$APP" ] || { echo "FAIL — no .app inside DMG"; hdiutil detach "$MOUNT" || true; exit 1; }
# Thin uv-venv installer ships no frozen backend to boot — verify the
# bundle is complete: shell binary + bundled uv sidecar + backend source.
ls "$APP/Contents/MacOS"/* >/dev/null 2>&1 || fail "no shell binary in Contents/MacOS"
find "$APP/Contents" -type f -name 'uv' | grep -q . || fail "bundled uv sidecar missing"
find "$APP/Contents" -type f -name 'pyproject.toml' | grep -q . || fail "backend resource pyproject.toml missing"
find "$APP/Contents" -type f -path '*/backend/main.py' | grep -q . || fail "backend source backend/main.py missing"
echo "OK — bundle has shell + uv + backend resources"
hdiutil detach "$MOUNT" || true
# ── Signing / Gatekeeper / notarization verification ──────────────
# Runs codesign --verify, spctl (Gatekeeper), nested-binary, and
# stapler checks against the built .app (see docs/macos-signing-verification.md).
# STRICT (--require-signed) only on the opt-in signed stable path — same
# condition as "Configure Apple signing" above — so a failed or missing
# signature/notarization FAILS the job and STOPS the release instead of
# publishing an unsigned artifact. On every other (unsigned dev/preview)
# path it runs report-only and never breaks the build.
- name: Verify macOS signing
if: runner.os == 'macOS'
shell: bash
env:
STRICT: ${{ (github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') && vars.MACOS_SIGNING_ENABLED == 'true') && '1' || '0' }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
run: |
set -uo pipefail
APP=$(find "frontend/src-tauri/target/${{ matrix.rust_target }}/release/bundle/macos" -maxdepth 1 -name '*.app' | head -1)
[ -n "$APP" ] || { echo "FAIL — no .app found to verify"; exit 1; }
MODE=""
if [ "$STRICT" = "1" ]; then
MODE="--require-signed"
echo "Signed stable release → STRICT verification (release stops on failure)."
else
echo "Unsigned dev/preview path → report-only verification."
fi
bash scripts/verify-macos-signing.sh "$APP" $MODE
- name: Installer smoke (Windows)
if: runner.os == 'Windows'
timeout-minutes: 5
shell: bash
run: |
set -euo pipefail
MSI=$(find frontend/src-tauri/target/${{ matrix.rust_target }}/release/bundle/msi -name "*.msi" ! -name '*Current*User*' | head -1)
echo "Smoke-testing MSI: $MSI"
powershell.exe -NoProfile -ExecutionPolicy Bypass -File scripts/verify-windows-msi.ps1 -MsiPath "$(cygpath -w "$MSI")"
# /quiet = no UI, /norestart = don't reboot the runner if a dep asks
msiexec.exe //i "$(cygpath -w "$MSI")" //quiet //norestart
INSTALL="/c/Program Files/VoiceStudio"
fail() { echo "FAIL — $1. Contents:"; find "$INSTALL" -maxdepth 4 -type f 2>/dev/null | head -40; exit 1; }
# Thin uv-venv installer ships no frozen backend .exe — verify the
# install is complete: shell exe + bundled uv + backend source resources.
test -f "$INSTALL/omnivoice-studio.exe" || fail "shell exe missing"
test -f "$INSTALL/uv.exe" || fail "bundled uv missing"
find "$INSTALL" -type f -name 'pyproject.toml' | grep -q . || fail "backend resource pyproject.toml missing"
find "$INSTALL" -type f -path '*backend*main.py' | grep -q . || fail "backend source main.py missing"
echo "OK — MSI installed shell + uv + backend resources"
- name: Per-user installer smoke (Windows, non-admin account)
if: runner.os == 'Windows'
timeout-minutes: 8
shell: bash
run: |
set -euo pipefail
MSI=$(find frontend/src-tauri/target/${{ matrix.rust_target }}/release/bundle/msi -name '*Current*User*.msi' | head -1)
powershell.exe -NoProfile -ExecutionPolicy Bypass \
-File scripts/smoke-per-user-msi.ps1 -MsiPath "$(cygpath -w "$MSI")" -PrepareHostedRunner
# linuxdeploy re-links .DirIcon as an ABSOLUTE symlink into the build
# machine AFTER tauri's files-map has placed the real icon bytes — the
# exact bug #1518 guarded against, resurfacing on the first real tag
# build (v0.5.0). The seam tauri-action leaves us is post-upload: repack
# the AppImage with the icon as a REGULAR FILE, re-sign it (the updater
# signature covered the old bytes), and clobber the draft release's
# asset + the linux signature inside latest.json. The smoke below then
# validates the repaired artifact, not the broken one.
- name: Repair AppImage .DirIcon, re-sign, re-upload
if: runner.os == 'Linux'
timeout-minutes: 10
shell: bash
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
# Data, not shell source (zizmor template-injection): a crafted ref
# must never expand inside a script that holds the signing key.
TAG: ${{ (needs.preview-gate.outputs.is_preview == 'true') && 'preview' || github.ref_name }}
run: |
set -euo pipefail
APPIMAGE=$(find frontend/src-tauri/target/${{ matrix.rust_target }}/release/bundle/appimage -name "*.AppImage" | head -1)
APPIMAGE=$(realpath "$APPIMAGE")
WORK="$(mktemp -d)"; cd "$WORK"
"$APPIMAGE" --appimage-extract >/dev/null
ROOT="$WORK/squashfs-root"
ICON=$(readlink -f "$ROOT/.DirIcon" 2>/dev/null || true)
if [ -n "$ICON" ] && [ -f "$ICON" ] && case "$ICON" in "$ROOT"/*) true;; *) false;; esac; then
echo ".DirIcon already resolves inside the bundle — no repair needed"
exit 0
fi
# The real bytes are at the AppDir root (linuxdeploy put them there
# before mislinking). Ship a regular file: nothing left to dangle.
SRC=$(find "$ROOT" -maxdepth 1 -name "*.png" | head -1)
[ -n "$SRC" ] || SRC=$(find "$ROOT/usr/share/icons" -name "*.png" | head -1)
[ -n "$SRC" ] || { echo "no icon bytes found in bundle"; exit 1; }
rm -f "$ROOT/.DirIcon"
cp "$SRC" "$ROOT/.DirIcon"
# Pinned immutable release + checksum: this binary runs with the
# updater signing key and a release-write token in its environment,
# so a mutable 'continuous' asset is not acceptable supply chain.
AIT_URL="https://github.com/AppImage/appimagetool/releases/download/1.9.1/appimagetool-x86_64.AppImage"
AIT_SHA256="ed4ce84f0d9caff66f50bcca6ff6f35aae54ce8135408b3fa33abfc3cb384eb0"
curl -fsSL --retry 3 -o "$WORK/appimagetool" "$AIT_URL"
echo "$AIT_SHA256 $WORK/appimagetool" | sha256sum -c - || { echo "appimagetool checksum mismatch"; exit 1; }
chmod +x "$WORK/appimagetool"
# Same FUSE-less trick the build itself uses.
APPIMAGE_EXTRACT_AND_RUN=1 ARCH=x86_64 "$WORK/appimagetool" --no-appstream "$ROOT" "$APPIMAGE"
cd "$GITHUB_WORKSPACE/frontend"
bunx tauri signer sign "$APPIMAGE"
gh release upload "$TAG" "$APPIMAGE" "$APPIMAGE.sig" --clobber --repo "$GITHUB_REPOSITORY"
# latest.json is NOT patched here: every tauri-action leg re-uploads
# the shared manifest, so an in-leg patch races the other platforms —
# the repair-updater-manifest job below is the single final writer.
echo "repacked, re-signed, re-uploaded"
- name: Installer smoke (Linux)
if: runner.os == 'Linux'
timeout-minutes: 5
shell: bash
run: |
set -euo pipefail
# Use the AppImage — single-file, no installer needed.
APPIMAGE=$(find frontend/src-tauri/target/${{ matrix.rust_target }}/release/bundle/appimage -name "*.AppImage" | head -1)
# Resolve to an absolute path BEFORE the cd below — `--appimage-extract`
# always writes ./squashfs-root into the CWD, so we cd into a temp dir,
# at which point a relative AppImage path would no longer resolve.
APPIMAGE=$(realpath "$APPIMAGE")
echo "Smoke-testing AppImage: $APPIMAGE"
chmod +x "$APPIMAGE"
# GH runners have no FUSE — extract before running (mirrors APPIMAGE_EXTRACT_AND_RUN=1 used at build time).
EXTRACT_DIR="$(mktemp -d)"
cd "$EXTRACT_DIR"
"$APPIMAGE" --appimage-extract >/dev/null
ROOT="$EXTRACT_DIR/squashfs-root"
fail() { echo "FAIL — $1"; find "$ROOT" -maxdepth 5 -type f 2>/dev/null | head -40; exit 1; }
# linuxdeploy's GTK/GStreamer hooks wrap the seeded launcher as
# AppRun.wrapped. Verify the complete launcher chain, not only the
# small hook runner installed at the AppImage root.
bash "$GITHUB_WORKSPACE/scripts/verify-apprun-bundle.sh" \
"$ROOT" \
"$GITHUB_WORKSPACE/frontend/src-tauri/appimage/AppRun" \
"$GITHUB_WORKSPACE/frontend/src-tauri/target/.tauri/bundled-webkitgtk-version"
# Thin uv-venv installer: verify the AppImage carries the shell binary,
# the bundled uv sidecar, and the backend source resources.
{ [ -f "$ROOT/AppRun" ] || find "$ROOT" -type f \( -name "VoiceStudio" -o -name "omnivoice-studio" \) | grep -q .; } || fail "shell binary / AppRun missing"
find "$ROOT" -type f -name 'uv' | grep -q . || fail "bundled uv sidecar missing"
find "$ROOT" -type f -name 'pyproject.toml' | grep -q . || fail "backend resource pyproject.toml missing"
find "$ROOT" -type f -path '*/backend/main.py' | grep -q . || fail "backend source backend/main.py missing"
echo "OK — AppImage has shell + uv + backend resources"
# ── Compute SHA-256 checksums (Phase 0 GATE-05) ───────────────────
# Native OS tools: shasum -a 256 (POSIX) / Get-FileHash (Windows).
# Writes SHA256SUMS-<label>.txt for the user-verifiable path AND
# captures the content into $GITHUB_OUTPUT for body append.
- name: Compute SHA-256 checksums
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')
id: checksums
shell: bash
run: |
set -euo pipefail
BUNDLE_DIR="frontend/src-tauri/target/${{ matrix.rust_target }}/release/bundle"
OUT="SHA256SUMS-${{ matrix.label }}.txt"
# Gather artifact paths per matrix leg's `bundles` (msi/app/dmg/deb/appimage/updater).
# `find` is portable across all three runners (Git Bash on Windows).
# NB: macOS runners use /bin/bash 3.2, which has no `mapfile` (a bash 4+
# builtin) — using it 127'd this step and dropped the macOS SHA256SUMS
# for v0.3.1 and v0.3.2. A `while read` loop is portable to bash 3.2.
ARTIFACTS=()
while IFS= read -r artifact; do
ARTIFACTS+=("$artifact")
done < <(find "$BUNDLE_DIR" -type f \
\( -name "*.dmg" -o -name "*.app.tar.gz" -o -name "*.app.tar.gz.sig" \
-o -name "*.msi" -o -name "*.msi.sig" \
-o -name "*.AppImage" -o -name "*.AppImage.sig" \
-o -name "*.deb" \) 2>/dev/null | sort)
if [ ${#ARTIFACTS[@]} -eq 0 ]; then
echo "FAIL — no artifacts found under $BUNDLE_DIR"
find "$BUNDLE_DIR" -type f | head -50
exit 1
fi
# shasum is available on macOS by default, on ubuntu-22.04 (perl pkg),
# and on windows-2022 Git Bash. Falls back to sha256sum on Linux.
if command -v shasum >/dev/null 2>&1; then
HASHER="shasum -a 256"
else
HASHER="sha256sum"
fi
{
echo "### ${{ matrix.label }} artifacts"
echo ""
echo '```'
for f in "${ARTIFACTS[@]}"; do
# Strip the long bundle prefix from the printed path for readability;
# the hash itself is computed against the full file.
( cd "$(dirname "$f")" && $HASHER "$(basename "$f")" )
done
echo '```'
echo ""
} | tee "$OUT"
echo "checksums_file=$OUT" >> "$GITHUB_OUTPUT"
- name: Append checksums to release + attach SHA256SUMS file
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ github.ref_name }}
append_body: true
body_path: ${{ steps.checksums.outputs.checksums_file }}
files: ${{ steps.checksums.outputs.checksums_file }}
fail_on_unmatched_files: true
# ── Uninstall scripts as release assets (#1089) ───────────────────────────
# The in-app uninstaller (Settings → Storage → Remove all data) is the primary
# path, but a user who wants to clean up WITHOUT launching the app — or after
# already deleting it — has no repo to run scripts/uninstall.sh from. Ship the
# two scripts alongside the installers so they're one download away.
#
# MUST use `gh release upload` (attach to the EXISTING release), NOT
# softprops/action-gh-release: a second softprops publish races tauri-action's
# per-matrix draft and splits the platform installers across two releases for
# the tag (v0.3.20 shipped with only the Linux AppImage that way). `needs:
# [build]` guarantees the release already exists; `--clobber` makes a re-run
# idempotent. This can never create a second release.
# The Linux leg may repack + re-sign its AppImage (see the repair step in
# the build matrix); every tauri-action leg also re-uploads the SHARED
# latest.json, so patching the manifest inside any leg races the others.
# This job runs once after the whole matrix as the single final writer:
# it makes the manifest's linux signature agree with the .sig asset that
# actually shipped, and refuses to leave a mismatch behind.
repair-updater-manifest:
needs: [build, preview-gate]
runs-on: ubuntu-latest
timeout-minutes: 10
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Data, not shell source — same zizmor rule as the leg step.
TAG: ${{ (needs.preview-gate.outputs.is_preview == 'true') && 'preview' || github.ref_name }}
steps:
- name: Align latest.json's linux signature with the shipped .sig asset
shell: bash
run: |
set -euo pipefail
WORK="$(mktemp -d)"
HAS_MANIFEST=$(gh release view "$TAG" --repo "$GITHUB_REPOSITORY" --json assets --jq '[.assets[].name]|contains(["latest.json"])')
if [ "$HAS_MANIFEST" != "true" ]; then
echo "no latest.json on the release — nothing to align"; exit 0
fi
gh release download "$TAG" --pattern latest.json --output "$WORK/latest.json" --repo "$GITHUB_REPOSITORY"
# Same fail-closed rule as the manifest: absence is checked against
# the asset LIST; an actual download failure must fail the job, or
# the manifest keeps a signature nobody shipped.
HAS_SIG=$(gh release view "$TAG" --repo "$GITHUB_REPOSITORY" --json assets --jq '[.assets[].name|select(endswith(".AppImage.sig"))]|length > 0')
if [ "$HAS_SIG" != "true" ]; then
echo "no AppImage .sig asset on the release — nothing to align"; exit 0
fi