-
Notifications
You must be signed in to change notification settings - Fork 0
817 lines (776 loc) · 43 KB
/
Copy pathrelease.yml
File metadata and controls
817 lines (776 loc) · 43 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
name: release
# One-tag, fully-automated release. Push a `v*` tag (e.g. `v0.1.0`) and this builds EVERYTHING
# and publishes it to a single GitHub Release:
#
# • haven-relay static binary → x86_64 / aarch64 / armv7 / armv6 (musl) + macOS arm64/x86_64
# + Windows .exe, named `haven-relay-<target>` (exactly what relay/install.sh downloads) + .deb packages
# • Desktop installers → Windows .msi + NSIS .exe, Linux .deb/.rpm/AppImage
# • SteamOS Flatpak bundle → haven.flatpak (built from the freshly-built .deb)
# • A version-pinned Flatpak manifest (com.blaineam.haven.yml) with the real .deb sha256,
# so `flatpak-builder` against the release manifest Just Works.
#
# ── CHANNEL POLICY ─────────────────────────────────────────────────────────────────────────
# The public GitHub Release is the download channel for ONLY the things that have no app store:
# the **Linux desktop app** (.deb/.rpm/AppImage + haven.flatpak) and the **haven-relay CLI**
# for every arch. The store apps (free, as of August 2026) go through *proper* channels — iOS/macOS via
# the App Store (submitted by apple-store.yml), Android via Google Play, Windows via the
# Microsoft Store — and must NOT accumulate a GitHub Release history.
#
# We still BUILD the Windows GUI installers here (CI must verify they compile, and the
# Microsoft-Store submit step consumes the MSIX) — the policy is only about what gets ATTACHED
# to the public Release. Because the Microsoft Store listing does not exist yet, removing
# Windows from GH before it's live would strand Windows users with no install path. So the
# attach is governed by a repo variable and defaults to ON:
#
# PUBLISH_WINDOWS_TO_GH unset/anything ≠ "false" → Windows installers ride the Release (stopgap)
# "false" → Windows comes off GH (set this the day
# the Microsoft Store listing goes public)
#
# The one-line flip lives in the `publish` job below; the rationale + the exact trigger are in
# docs/RELEASING.md ("Release channels"). (Android's equivalent toggle is PUBLISH_ANDROID_TO_GH
# in android.yml.)
#
# rc tags (`vX.Y.Z-rc.N`) publish a PRE-RELEASE under their own tag so testers have somewhere to
# download a candidate. They never become "Latest" and never touch the stable release's assets; the
# AUR push stays stable-only.
#
# The tag drives the version: it's stamped into tauri.conf.json + the relay crate before build.
# Cut a release by tagging: git tag v0.1.0 && git push origin v0.1.0
# (relay-only hotfix? use a `relay-v*` tag → relay-release.yml instead.)
on:
push:
tags: ["v*"]
workflow_dispatch:
inputs:
version:
description: "Version to build, e.g. v0.1.0 (dry-run: builds but only publishes on a tag)"
required: false
permissions:
contents: write
jobs:
# ── The version gate ───────────────────────────────────────────────────────────────────
# Haven ships ONE product version across every store and every download. `apple/project.yml`
# MARKETING_VERSION is the source of truth (App Store Connect is the slowest, least
# forgiving publisher, so it sets the number); the tag must agree with it. This job fails
# the release rather than let the two drift — that drift is exactly how we ended up shipping
# the same commit as "0.1.0-beta.40" on GitHub and "1.0.4" on the App Store.
# See docs/RELEASING.md.
meta:
runs-on: ubuntu-latest
outputs:
version: ${{ steps.v.outputs.version }}
is_rc: ${{ steps.v.outputs.is_rc }}
steps:
- uses: actions/checkout@v4
- id: v
# Via env, not `${{ }}` interpolation — a tag name is attacker-controllable text and
# must never be pasted into a shell.
env:
INPUT_VERSION: ${{ github.event.inputs.version }}
REF_NAME: ${{ github.ref_name }}
REF_TYPE: ${{ github.ref_type }}
run: |
set -euo pipefail
asc="$(python3 - <<'PY'
import re, pathlib, sys
t = pathlib.Path("apple/project.yml").read_text()
vs = set(re.findall(r'(?m)^\s*MARKETING_VERSION:\s*"([^"]+)"', t))
if not vs:
sys.exit("no MARKETING_VERSION found in apple/project.yml")
if len(vs) > 1:
sys.exit(f"apple/project.yml disagrees with itself: MARKETING_VERSION = {sorted(vs)}")
print(vs.pop())
PY
)"
raw="${INPUT_VERSION:-}"
if [ -z "$raw" ]; then
if [ "$REF_TYPE" = "tag" ]; then
raw="$REF_NAME"
else
# workflow_dispatch dry-run off a branch: build what the next release WOULD be,
# instead of stamping the branch name ("main") in as a version.
raw="v$asc"
fi
fi
version="${raw#v}"
# A release-candidate tag is `vX.Y.Z-rc.N`: it BUILDS + QAs (see qa.yml)
# but publishes NOTHING to a production store (guards below + in android.yml).
# `-rc.N` is the ONLY allowed pre-release suffix; -beta/-alpha stay rejected.
# The core X.Y.Z still has to match MARKETING_VERSION so an rc can't drift.
core="${version%%-rc.*}"
is_rc=false
[ "$core" != "$version" ] && is_rc=true
if [ "$is_rc" = true ] && ! printf '%s' "$version" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+-rc\.[0-9]+$'; then
echo "::error::'$raw' — the only allowed pre-release suffix is -rc.N (release candidate)." \
"No -beta/-alpha. See docs/QA.md + docs/RELEASING.md."
exit 1
fi
if ! printf '%s' "$core" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$'; then
echo "::error::'$raw' is not a plain semver x.y.z (optionally -rc.N). Haven releases track" \
"the App Store marketing version. See docs/RELEASING.md."
exit 1
fi
if [ "$core" != "$asc" ]; then
echo "::error::Version drift: tag says '$core' but apple/project.yml" \
"MARKETING_VERSION says '$asc'. One product, one version — bump" \
"MARKETING_VERSION and re-tag. See docs/RELEASING.md."
exit 1
fi
# Downstream stamping always uses the clean X.Y.Z (an rc stamps the same
# version it's a candidate for).
echo "version=$core" >> "$GITHUB_OUTPUT"
echo "is_rc=$is_rc" >> "$GITHUB_OUTPUT"
if [ "$is_rc" = true ]; then
echo "Haven **$core** — release CANDIDATE ($raw): builds + QAs, no production publish." >> "$GITHUB_STEP_SUMMARY"
else
echo "Haven **$core** — matches apple/project.yml MARKETING_VERSION." >> "$GITHUB_STEP_SUMMARY"
fi
# ── Relay: static binary for every installer arch + macOS, plus .deb packages ───────────
relay:
needs: meta
strategy:
fail-fast: false
matrix:
include:
- os: ubuntu-latest
target: x86_64-unknown-linux-musl
deb: true
- os: ubuntu-latest
target: aarch64-unknown-linux-musl
deb: true
- os: ubuntu-latest
target: armv7-unknown-linux-musleabihf
deb: true
- os: ubuntu-latest
target: arm-unknown-linux-musleabihf
deb: false
- os: macos-latest
target: aarch64-apple-darwin
deb: false
- os: macos-latest
target: x86_64-apple-darwin
deb: false
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
- uses: Swatinem/rust-cache@v2
with:
workspaces: core
- name: Stamp version
run: |
python3 - <<'PY'
import re, pathlib
v = "${{ needs.meta.outputs.version }}"
p = pathlib.Path("core/haven-relay/Cargo.toml")
t = p.read_text()
t = re.sub(r'(?m)^version = ".*"', f'version = "{v}"', t, count=1)
p.write_text(t)
PY
- name: Install zig (for cargo-zigbuild cross-compiles)
if: runner.os == 'Linux'
run: |
ZIG_VER=0.13.0
curl -fsSL "https://ziglang.org/download/${ZIG_VER}/zig-linux-x86_64-${ZIG_VER}.tar.xz" -o /tmp/zig.tar.xz
mkdir -p /tmp/zig && tar -xJf /tmp/zig.tar.xz -C /tmp/zig --strip-components=1
echo "/tmp/zig" >> "$GITHUB_PATH"
- name: Install cargo-zigbuild + cargo-deb
if: runner.os == 'Linux'
run: cargo install cargo-zigbuild cargo-deb --locked
- name: Build (Linux, musl)
if: runner.os == 'Linux'
working-directory: core
run: cargo zigbuild --release -p haven-relay --target ${{ matrix.target }}
- name: Build (macOS)
if: runner.os == 'macOS'
working-directory: core
run: cargo build --release -p haven-relay --target ${{ matrix.target }}
- name: Stage assets
shell: bash
run: |
mkdir -p dist
cp "core/target/${{ matrix.target }}/release/haven-relay" "dist/haven-relay-${{ matrix.target }}"
- name: Build .deb
if: matrix.deb
# --no-strip: the host x86_64 `strip` can't strip a cross-built arm binary.
# continue-on-error: a .deb hiccup must never block the (critical) binary + publish.
continue-on-error: true
working-directory: core
run: cargo deb -p haven-relay --no-build --no-strip --target ${{ matrix.target }} --output ../dist/
- uses: actions/upload-artifact@v4
with:
name: relay-${{ matrix.target }}
path: dist/*
if-no-files-found: error
# ── Relay: Windows .exe (x86-64 + Arm64) ────────────────────────────────────────────────
relay-windows:
needs: meta
strategy:
fail-fast: false
matrix:
target: [x86_64-pc-windows-msvc, aarch64-pc-windows-msvc]
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
- uses: Swatinem/rust-cache@v2
with:
workspaces: core
- name: Stamp version
shell: bash
run: |
python -c "import re,pathlib; p=pathlib.Path('core/haven-relay/Cargo.toml'); t=p.read_text(); p.write_text(re.sub(r'(?m)^version = \".*\"','version = \"${{ needs.meta.outputs.version }}\"',t,count=1))"
- name: Build haven-relay (${{ matrix.target }})
working-directory: core
run: cargo build --release -p haven-relay --target ${{ matrix.target }}
- name: Stage binary
shell: bash
run: |
mkdir -p dist
cp "core/target/${{ matrix.target }}/release/haven-relay.exe" "dist/haven-relay-${{ matrix.target }}.exe"
- uses: actions/upload-artifact@v4
with:
name: relay-${{ matrix.target }}
path: dist/*
if-no-files-found: error
# ── Desktop installers (Windows + Linux) ───────────────────────────────────────────────
desktop:
needs: meta
# Partner Center / Microsoft Store credentials, surfaced as job env so the MSIX steps' `if:`
# gates can test them. All blank until you add the secrets → the Store steps stay inert and
# normal releases (installers only) are unaffected. See docs/STORE-AUTOPUBLISH.md.
env:
# PACKAGING identity — still used, by the "Package MSIX" step.
STORE_IDENTITY_NAME: ${{ secrets.STORE_IDENTITY_NAME }}
STORE_PUBLISHER: ${{ secrets.STORE_PUBLISHER }}
STORE_PUBLISHER_DISPLAY: ${{ secrets.STORE_PUBLISHER_DISPLAY }}
# SUBMISSION credentials — used by "Submit MSIX to the Microsoft Store" when the repo variable
# MSSTORE_PUBLISH is "true" (Haven is a free product, which is what msstore publish supports
# over Actions); otherwise the manual-submission notice fires and these sit unused.
STORE_TENANT_ID: ${{ secrets.STORE_TENANT_ID }}
STORE_CLIENT_ID: ${{ secrets.STORE_CLIENT_ID }}
STORE_CLIENT_SECRET: ${{ secrets.STORE_CLIENT_SECRET }}
STORE_SELLER_ID: ${{ secrets.STORE_SELLER_ID }}
STORE_APP_ID: ${{ secrets.STORE_APP_ID }}
strategy:
fail-fast: false
matrix:
include:
- os: windows-latest
name: windows
- os: ubuntu-latest
name: linux
# Native ARM64 Linux GUI build (free arm64-hosted runner on public repos). Produces the
# arm64 .deb/.rpm/.AppImage so Windows-on-ARM parity isn't the only arch story.
- os: ubuntu-24.04-arm
name: linux-arm64
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
with:
workspaces: desktop/src-tauri
- name: Linux build deps
if: startsWith(matrix.os, 'ubuntu')
run: |
sudo apt-get update
sudo apt-get install -y \
libwebkit2gtk-4.1-dev libgtk-3-dev libsoup-3.0-dev \
libayatana-appindicator3-dev librsvg2-dev patchelf file \
xdg-utils nasm
# nasm: rav1e (AVIF encode for the preview tier) assembles with it — same fix as
# desktop.yml; this job has its own deps list and was missed when that one was patched.
# xdg-utils (xdg-mime) is preinstalled on ubuntu-latest (x64) but NOT on the leaner
# ubuntu-24.04-arm image, and Tauri's .deb/AppImage bundler shells out to xdg-mime.
- name: Stamp version
shell: bash
run: |
node -e "const f='desktop/src-tauri/tauri.conf.json';const c=require('fs');const j=JSON.parse(c.readFileSync(f));j.version='${{ needs.meta.outputs.version }}'.split('-')[0];c.writeFileSync(f,JSON.stringify(j,null,2));"
- name: Install Tauri CLI
run: cargo install tauri-cli --version "^2" --locked
# Cloudflare Quick Tunnel helper — bundled as Tauri externalBin so hosted relays get a free
# HTTPS front door without a user-installed cloudflared. Version is CLOUDFLARED_VERSION in
# cfquicktunnel.rs (fetch script reads it). Always --force so a pin bump never reuses a stale
# binary. Signing: MSIX is Store-re-signed on upload; no manual codesign.
- name: Fetch cloudflared for this runner
shell: bash
run: tools/fetch-cloudflared.sh --force
- name: Build
working-directory: desktop/src-tauri
run: cargo tauri build
- name: Collect installers
shell: bash
run: |
mkdir -p out
find desktop/src-tauri/target/release/bundle -type f \
\( -name '*.msi' -o -name '*.exe' -o -name '*.deb' -o -name '*.rpm' -o -name '*.AppImage' \) \
-exec cp {} out/ \;
ls -la out
# ── Microsoft Store: package an MSIX and submit it (Windows · tags · only when the Store
# secrets are set). Inert otherwise, so installer-only releases are untouched. The MSIX is
# also dropped in out/ — but per the CHANNEL POLICY (top of file) it only *rides the
# GitHub Release* as a stopgap while PUBLISH_WINDOWS_TO_GH ≠ "false"; the `publish` job
# strips it (and the .msi/.exe) once the Microsoft Store listing is live. It is always
# submitted to the Store here and always kept as the `desktop-windows` CI artifact.
- name: Package MSIX
id: msix
# Also runs on manual workflow_dispatch so we can produce a corrected MSIX (e.g. a manifest
# DisplayName / name-reservation fix) for a manual Partner Center upload WITHOUT cutting a
# version tag. The dispatch build only produces the `desktop-windows` artifact — the Store
# auto-submit below stays tag-only.
if: matrix.os == 'windows-latest' && (startsWith(github.ref, 'refs/tags/v') || github.event_name == 'workflow_dispatch') && env.STORE_IDENTITY_NAME != ''
# Non-fatal: the Windows installer + exe already built. An MSIX/Store-packaging issue must never
# block the (critical) installers + the GitHub Release publish job that depends on this matrix leg.
continue-on-error: true
shell: pwsh
run: |
$ver = "${{ needs.meta.outputs.version }}".Split('-')[0]
$ver4 = "$ver.0"
$makeappx = (Get-ChildItem "C:\Program Files (x86)\Windows Kits\10\bin\*\x64\makeappx.exe" -ErrorAction SilentlyContinue |
Sort-Object FullName -Descending | Select-Object -First 1).FullName
if (-not $makeappx) { throw "makeappx.exe not found in the Windows SDK" }
# Stage + pack one arch-specific MSIX from an already-built Haven.exe + bundled
# cloudflared (Tauri externalBin). The Microsoft Store re-signs the whole package on
# Partner Center upload — no Authenticode / signtool step here (by design; see
# docs/STORE-AUTOPUBLISH.md). We only need the helper present next to Haven.exe so the
# app can spawn a Quick Tunnel without a user-installed cloudflared.
function Pack([string]$arch, [string]$relDir) {
$exe = if (Test-Path "$relDir\Haven.exe") { "$relDir\Haven.exe" } else { "$relDir\haven-desktop.exe" }
if (-not (Test-Path $exe)) { throw "no exe for $arch in $relDir" }
$stage = "$env:RUNNER_TEMP\msix-$arch"
if (Test-Path $stage) { Remove-Item $stage -Recurse -Force }
New-Item -ItemType Directory -Force -Path "$stage\Assets" | Out-Null
Copy-Item $exe "$stage\Haven.exe" -Force
# cloudflared: Tauri externalBin lands next to the exe as cloudflared.exe (strip triple).
# Fall back to the triple-named file under binaries/ (fetch step). Arm64 package reuses
# the amd64 helper — Cloudflare has no windows-arm64 asset for this pin; WoA runs it
# under x64 emulation.
$cfdCandidates = @(
"$relDir\cloudflared.exe",
"desktop\src-tauri\binaries\cloudflared-x86_64-pc-windows-msvc.exe",
"desktop\src-tauri\binaries\cloudflared.exe"
)
$cfd = $cfdCandidates | Where-Object { Test-Path $_ } | Select-Object -First 1
if ($cfd) {
Copy-Item $cfd "$stage\cloudflared.exe" -Force
# ${arch}, NOT $arch — in a double-quoted PowerShell string `$arch:` parses as a SCOPE
# qualifier (like $env:PATH), and with a space after the colon the scope name is empty,
# so the whole script fails to parse. It reported at the next structural token (the
# `} else {` below), and because this step is continue-on-error it went out as a green
# "Package MSIX" that produced no package at all — every release since.
Write-Host " MSIX ${arch}: bundled cloudflared from $cfd"
} else {
Write-Host "::warning::MSIX ${arch}: cloudflared.exe not found — Quick Tunnel will try PATH/download at runtime"
}
foreach ($a in 'StoreLogo','Square150x150Logo','Square44x44Logo','Square71x71Logo','Square310x310Logo') {
Copy-Item "desktop\src-tauri\icons\$a.png" "$stage\Assets\$a.png" -Force
}
$m = (Get-Content "desktop\msix\AppxManifest.xml.in" -Raw).
Replace('@IDENTITY_NAME@', $env:STORE_IDENTITY_NAME).
Replace('@PUBLISHER@', $env:STORE_PUBLISHER).
Replace('@PUBLISHER_DISPLAY@',$env:STORE_PUBLISHER_DISPLAY).
Replace('@VERSION@', $ver4).
Replace('@ARCH@', $arch)
Set-Content "$stage\AppxManifest.xml" $m -Encoding UTF8
New-Item -ItemType Directory -Force -Path "$env:RUNNER_TEMP\pkgs" | Out-Null
& $makeappx pack /d "$stage" /p "$env:RUNNER_TEMP\pkgs\Haven-$arch.msix" /o
if ($LASTEXITCODE -ne 0) { throw "makeappx pack failed for $arch" }
}
# x64 is built by the Build step above. Also cross-build a NATIVE arm64 binary so the Store
# gets a real ARM64 package — Windows-on-ARM dropped AArch32 and only emulates x64 (slowly).
# Kept best-effort: if the arm64 cross-build can't complete on the runner, we ship x64-only
# rather than regress. Only the exe is cross-built (the frontend dist from the x64 tauri
# build is already embedded via tauri-build) — no arm64 NSIS/MSI bundler needed.
$haveArm = $false
try {
rustup target add aarch64-pc-windows-msvc
# Tauri's externalBin resolves sidecars by TARGET TRIPLE, so an arm64 build demands
# binaries\cloudflared-aarch64-pc-windows-msvc.exe and hard-fails its build script
# without it ("resource path ... doesn't exist", exit 101) — before compiling a line.
# Cloudflare publishes no windows-arm64 asset for this pin, which is why the fetch step
# only ever produced the x64 one, and why every arm64 attempt has quietly fallen back to
# an x64-only package. Give it the x64 helper under the arm64 name: exactly the reuse the
# MSIX staging below already documents (WoA runs the helper under x64 emulation; the app
# itself is native arm64, which is the part that matters for performance).
$x64cfd = "desktop\src-tauri\binaries\cloudflared-x86_64-pc-windows-msvc.exe"
$armcfd = "desktop\src-tauri\binaries\cloudflared-aarch64-pc-windows-msvc.exe"
if ((Test-Path $x64cfd) -and -not (Test-Path $armcfd)) {
Copy-Item $x64cfd $armcfd -Force
Write-Host "arm64: reusing the x64 cloudflared helper as $armcfd"
}
cargo build --release --manifest-path desktop\src-tauri\Cargo.toml --target aarch64-pc-windows-msvc
if ($LASTEXITCODE -eq 0) { $haveArm = $true } else { Write-Host "arm64 cargo build exit $LASTEXITCODE" }
} catch { Write-Host "arm64 cross-build failed: $_" }
New-Item -ItemType Directory -Force -Path out | Out-Null
Pack "x64" "desktop\src-tauri\target\release"
if ($haveArm) {
Pack "arm64" "desktop\src-tauri\target\aarch64-pc-windows-msvc\release"
# One .msixbundle carrying both arches — the Store hands each device its native package.
$msix = "out\Haven-$ver.msixbundle"
& $makeappx bundle /d "$env:RUNNER_TEMP\pkgs" /p "$msix" /bv $ver4 /o
if ($LASTEXITCODE -ne 0) { throw "makeappx bundle failed" }
} else {
Write-Host "::warning::ARM64 cross-build unavailable — shipping x64-only MSIX (no arm64 flavor this build)."
$msix = "out\Haven-$ver.msix"
Copy-Item "$env:RUNNER_TEMP\pkgs\Haven-x64.msix" "$msix" -Force
}
"msix=$msix" | Out-File -FilePath $env:GITHUB_OUTPUT -Append
# A packaging step that produced NOTHING must not read as a green release. `Package MSIX` is
# continue-on-error (an MSIX problem must never block the installers or the Release), and that
# is exactly how a PowerShell parse error — `"$arch:"` being read as a scope qualifier — shipped
# release after release as a successful step with no package behind it. The bug was invisible
# from the outside: the job was green, the Release looked complete, and the missing bundle only
# surfaced when a human went looking for something to upload to Partner Center.
- name: Verify the MSIX was actually produced
if: matrix.os == 'windows-latest' && (startsWith(github.ref, 'refs/tags/v') || github.event_name == 'workflow_dispatch') && env.STORE_IDENTITY_NAME != ''
shell: pwsh
run: |
$m = "${{ steps.msix.outputs.msix }}"
if (-not $m -or -not (Test-Path $m)) {
Write-Host "::error::Package MSIX produced no package — the Microsoft Store update for this release CANNOT be uploaded. Check that step's log for a PowerShell ParserError."
exit 1
}
$size = [math]::Round((Get-Item $m).Length / 1MB, 1)
Write-Host "MSIX ok: $m ($size MB)"
# x64-only is a SILENT downgrade: the file exists, the job is green, and Windows-on-ARM
# users get an emulated x64 build without anyone noticing. Say it loudly on a real release.
if ($m -notlike "*.msixbundle") {
Write-Host "::warning::MSIX is x64-ONLY (no .msixbundle) — Windows-on-ARM will run the emulated x64 build. Check the ARM64 cross-build output above."
} else {
Write-Host "MSIX carries both arches (bundle)."
}
# ── Microsoft Store submission ──────────────────────────────────────────────────────────
# Haven is FREE on the Store now, which is exactly the case `msstore publish` supports over
# GitHub Actions (paid products are still "a future release" on Microsoft's side — the reason
# this step was ripped out before: on a paid listing the command exits non-zero, and under
# `continue-on-error: true` that read as a green job that had submitted nothing).
#
# So the step is back, with two rules learned the hard way:
# • it is OPT-IN via the repo variable MSSTORE_PUBLISH = "true" — flip it only AFTER the
# Partner Center price is set to Free (Pricing and availability → Base price → Free);
# until then the manual-submission notice below still fires.
# • it is NOT continue-on-error. A Store-side failure goes red on this leg. The Linux app +
# relay CLI still ship (the `publish` job only needs the artifacts, which were uploaded
# before this point), and a red step is the only honest signal.
# The Store submit moved to the `msix-submit` job: it consumes the desktop-windows
# artifact, so a submit-step failure retries in ~2 minutes via `gh run rerun --failed`
# instead of repeating this 35-minute build (four 40-minute cycles died on 5-second
# environmental failures the night 1.7.0 shipped).
- name: Microsoft Store — manual submission required (MSSTORE_PUBLISH is not "true")
if: steps.msix.outputs.msix != '' && startsWith(github.ref, 'refs/tags/v') && !contains(github.ref, '-rc') && vars.MSSTORE_PUBLISH != 'true'
shell: pwsh
run: |
Write-Host "::notice title=Microsoft Store::MSIX packaged. The Store update is a MANUAL step until the repo variable MSSTORE_PUBLISH is 'true' (set it once the Partner Center price is Free): upload the bundle in Partner Center and submit for certification. See docs/STORE-AUTOPUBLISH.md."
# desktop-windows is the PRIVATE download channel for the Windows GUI (Microsoft Store is the
# public one; PUBLISH_WINDOWS_TO_GH=false keeps it off the Release). Short retention so it
# stays recent-only rather than accumulating a long artifact history.
- uses: actions/upload-artifact@v4
with:
name: desktop-${{ matrix.name }}
path: out/*
if-no-files-found: error
retention-days: 7
# Separate from the build ON PURPOSE: environmental failures here retry in ~2 minutes via
# `gh run rerun --failed` (or the msix_run_id dispatch) without rebuilding anything.
#
# Pure Ingestion API via Scripts/ms-store-publish.mjs — msstore-cli is GONE from nuget.org,
# refused paid-product updates, and only ever submitted the package. This lane pushes the
# LISTING (all 9 languages, release notes included) and the package in ONE submission.
msix-submit:
needs: [meta, desktop]
if: startsWith(github.ref, 'refs/tags/v') && !contains(github.ref, '-rc') && vars.MSSTORE_PUBLISH == 'true'
runs-on: ubuntu-latest
env:
STORE_TENANT_ID: ${{ secrets.STORE_TENANT_ID }}
STORE_CLIENT_ID: ${{ secrets.STORE_CLIENT_ID }}
STORE_CLIENT_SECRET: ${{ secrets.STORE_CLIENT_SECRET }}
STORE_APP_ID: ${{ secrets.STORE_APP_ID }}
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v4
with:
name: desktop-windows
path: out
- name: Submit listing + package to the Microsoft Store
if: env.STORE_TENANT_ID != ''
run: |
set -euo pipefail
VERSION=$(grep -m1 'MARKETING_VERSION:' apple/project.yml | sed 's/[^0-9.]*//g') # colon = the assignment, not the $(MARKETING_VERSION) reference above it
MSIX=$(find out -name '*.msix' | head -1)
[ -n "$MSIX" ] || { echo '::error::no .msix in the desktop-windows artifact'; exit 1; }
echo "submitting $MSIX as $VERSION"
node Scripts/ms-store-publish.mjs --version "$VERSION" --msix "$MSIX"
flatpak:
needs: [meta, desktop]
runs-on: ubuntu-latest
container:
image: ghcr.io/flathub-infra/flatpak-github-actions:gnome-47
options: --privileged
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v4
with:
name: desktop-linux
path: linux-build
- name: Point the manifest at the local .deb
run: |
deb=$(find linux-build -name '*.deb' | head -n1)
cp "$deb" desktop/flatpak/haven.deb
python3 - <<'PY'
import re, pathlib
m = pathlib.Path("desktop/flatpak/com.blaineam.haven.yml")
t = m.read_text()
t = re.sub(
r" - type: file\n # CI uploads.*?\n url:.*?\n sha256:.*?\n dest-filename: haven\.deb",
" - type: file\n path: haven.deb",
t, flags=re.S)
m.write_text(t)
PY
# The AppStream <release> is what a Flatpak/Flathub client shows as "what version is
# this?". Hand-maintaining it is how it ended up pinned at 0.1.0 for 40 releases, so
# stamp it from the tag exactly like tauri.conf.json and the relay crate above.
- name: Stamp the metainfo release
env:
VERSION: ${{ needs.meta.outputs.version }}
run: |
set -euo pipefail
python3 - <<'PY'
import os, re, pathlib, datetime
v = os.environ["VERSION"]
d = datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%d")
p = pathlib.Path("desktop/flatpak/com.blaineam.haven.metainfo.xml")
t = p.read_text()
t, n = re.subn(r'<release version="[^"]*" date="[^"]*"\s*/>',
f'<release version="{v}" date="{d}"/>', t, count=1)
if n != 1:
raise SystemExit("metainfo <release> line not found — did its shape change?")
p.write_text(t)
print(f"stamped {v} ({d})")
PY
# flatpak-builder only warns on bad AppStream data; a Flathub reviewer will not. Fail the
# release here instead — shipping a broken listing is worse than not shipping. A *missing
# validator* is not itself a reason to block, so that case only warns.
# --no-net: the screenshot URLs are remote and the builder has no business fetching them.
- name: Validate the metainfo
run: |
set -euo pipefail
f=desktop/flatpak/com.blaineam.haven.metainfo.xml
if command -v appstreamcli >/dev/null 2>&1; then
appstreamcli validate --no-net "$f"
elif command -v appstream-util >/dev/null 2>&1; then
appstream-util validate-relax "$f"
else
echo "::warning::no appstreamcli/appstream-util in the image; skipped validation"
fi
- name: Build Flatpak bundle
uses: flatpak/flatpak-github-actions/flatpak-builder@v6
with:
bundle: haven.flatpak
manifest-path: desktop/flatpak/com.blaineam.haven.yml
cache-key: flatpak-${{ github.sha }}
- uses: actions/upload-artifact@v4
with:
name: flatpak
path: haven.flatpak
if-no-files-found: error
# ── Publish one Release with every artifact + a version-pinned Flatpak manifest ─────────
publish:
needs: [meta, relay, relay-windows, desktop, flatpak]
# Tags cut/refresh their release. A manual workflow_dispatch ALSO updates the existing release
# for the current version (v<MARKETING_VERSION>) in place — so rebuilding to add an arch (arm64)
# or fix an asset actually refreshes the download, instead of stranding artifacts in a CI run.
#
# rc TAGS PUBLISH TOO, as a PRE-RELEASE under their own tag (`v1.1.4-rc.31`), never under the
# clean `v1.1.4`. Testers need somewhere to download a candidate from — a build that exists only
# as a CI artifact is a build nobody can install. Two properties keep this safe:
# • `tag_name` is the ACTUAL tag, so an rc can never overwrite the stable release's assets.
# • `prerelease: true` + `make_latest: false`, so `/releases/latest` and the website's download
# cards keep pointing at the last STABLE release, not at a candidate.
# The `aur` job stays stable-only (it publishes to Arch) — see its own guard.
if: startsWith(github.ref, 'refs/tags/') || github.event_name == 'workflow_dispatch'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v4
with:
path: dist
merge-multiple: true
# ── CHANNEL POLICY enforcement (see the header of this file + docs/RELEASING.md) ────────
# The public Release carries ONLY the Linux desktop app + the haven-relay CLI. Windows is
# a *paid Microsoft-Store app*; it does NOT belong in the GitHub Release history once the
# Store listing is live. This is the ONE line that flips it: set the repo variable
# PUBLISH_WINDOWS_TO_GH = false
# (repo → Settings ▸ Secrets and variables ▸ Actions ▸ Variables) the day the Store goes
# public. UNSET is the safe default — Windows stays attached so users are never stranded
# without a download before the Store exists. We strip only the Windows *GUI* installers:
# • *.msi / *-setup.exe (NSIS) / *.msix — the Haven desktop app installers
# The haven-relay Windows binary (`haven-relay-*-pc-windows-*.exe`) is a self-host CLI, not
# a store app, so it STAYS on the Release — those globs never match it.
- name: Enforce channel policy — keep the Windows Store app off the public Release
if: vars.PUBLISH_WINDOWS_TO_GH == 'false'
run: |
echo "PUBLISH_WINDOWS_TO_GH=false → stripping Windows GUI installers from the Release (Store is the channel)."
rm -f dist/*.msi dist/*.msix dist/*.msixbundle dist/*setup.exe
echo "Remaining Release assets:"
ls -la dist
- name: Pin the Flatpak manifest to the released .deb
env:
TAG: ${{ github.ref_name }}
REPO: ${{ github.repository }}
run: |
python3 - <<'PY'
import re, hashlib, glob, os, pathlib
tag, repo = os.environ["TAG"], os.environ["REPO"]
debs = glob.glob("dist/Haven_*amd64*.deb") or glob.glob("dist/haven_*amd64*.deb")
if debs:
deb = debs[0]; name = os.path.basename(deb)
sha = hashlib.sha256(open(deb, "rb").read()).hexdigest()
url = f"https://github.com/{repo}/releases/download/{tag}/{name}"
t = pathlib.Path("desktop/flatpak/com.blaineam.haven.yml").read_text()
t = re.sub(r" url:.*", f" url: {url}", t, count=1)
t = re.sub(r" sha256:.*", f" sha256: {sha}", t, count=1)
pathlib.Path("dist/com.blaineam.haven.yml").write_text(t)
print("pinned", name, sha)
else:
print("no desktop .deb found; skipping manifest pin")
PY
- name: Generate release notes
# Notes must match the assets: the paid-store apps only show up here as a stopgap while
# their store listing isn't public yet (the PUBLISH_*_TO_GH toggles). Say so honestly.
env:
WIN_ON_GH: ${{ vars.PUBLISH_WINDOWS_TO_GH }}
AND_ON_GH: ${{ vars.PUBLISH_ANDROID_TO_GH }}
run: |
cat > NOTES.md <<EOF
Haven ${{ needs.meta.outputs.version }}
**This GitHub Release is the download channel for the Linux desktop app and the relay
daemon.** Haven is free everywhere; the store builds ship through their stores:
**iPhone · iPad · Mac** — App Store: https://apps.apple.com/app/id6782147901
**Android** — Google Play.
**Windows** — Microsoft Store.
**Linux desktop GUI** — \`.deb\` / \`.rpm\` / AppImage · SteamOS / Steam Deck
(\`haven.flatpak\` + the pinned \`com.blaineam.haven.yml\`).
**Relay daemon** (\`haven-relay-<target>\`) — x86_64 / aarch64 / armv7 / armv6 (every
Raspberry Pi) + macOS + Windows, plus .deb packages. Installed by \`relay/install.sh\`.
See [docs/LINUX.md](https://github.com/${{ github.repository }}/blob/main/docs/LINUX.md)
for per-distro install instructions.
EOF
# Transition stopgaps — only while the store listing isn't public (toggle ≠ "false").
if [ "$WIN_ON_GH" != "false" ]; then
printf '\n> _Windows (.msi / NSIS .exe / .msix) is also attached here as a stopgap until the Microsoft Store listing is public._\n' >> NOTES.md
fi
if [ "$AND_ON_GH" != "false" ]; then
printf '> _Android (.apk / .aab) is attached by the `android` workflow as a stopgap until the Google Play listing is public._\n' >> NOTES.md
fi
- name: Publish
uses: softprops/action-gh-release@v2
with:
# A tag push publishes to THAT tag verbatim — which for an rc is `vX.Y.Z-rc.N`, so a
# candidate can never overwrite the stable `vX.Y.Z` release's assets. A workflow_dispatch
# (no tag) updates the existing v<MARKETING_VERSION> release in place. Same-named assets
# are replaced.
tag_name: ${{ github.ref_type == 'tag' && github.ref_name || format('v{0}', needs.meta.outputs.version) }}
files: dist/*
body_path: NOTES.md
fail_on_unmatched_files: true
# A candidate is a pre-release and must never become "Latest": the website download cards
# and `/releases/latest` read that, and pointing them at an rc would ship a candidate to
# everyone who clicks Download.
prerelease: ${{ needs.meta.outputs.is_rc == 'true' }}
make_latest: ${{ needs.meta.outputs.is_rc != 'true' }}
# ── AUR: push the PKGBUILDs to aur.archlinux.org (tags · only when AUR_SSH_PRIVATE_KEY is
# set). Inert otherwise — exactly like the Play/Microsoft Store steps — so a release
# without the secret stays green and simply skips Arch.
#
# The recipes build from the *tag* we just published, so this must run after `publish`
# (a failed release must not leave the AUR pointing at a release that isn't there).
# Setup is a one-time thing the maintainer does by hand — see docs/RELEASING.md.
aur:
needs: [meta, publish]
# Stable tags only. Arch users get releases, not candidates — and the PKGBUILDs build FROM the
# tag, so pointing them at an rc would make the AUR package a moving target.
if: startsWith(github.ref, 'refs/tags/') && !contains(github.ref, '-rc')
runs-on: ubuntu-latest
container: archlinux:base-devel
strategy:
fail-fast: false
matrix:
pkg: [haven-desktop, haven-relay]
steps:
# Gate first, so a release without the secret costs a few seconds rather than a full
# Arch pull + system upgrade. (Job-level `if:` can't read `secrets`, hence the step gate.)
- name: Check AUR credentials
id: gate
env:
KEY: ${{ secrets.AUR_SSH_PRIVATE_KEY }}
run: |
if [ -n "$KEY" ]; then
echo "publish=true" >> "$GITHUB_OUTPUT"
else
echo "publish=false" >> "$GITHUB_OUTPUT"
echo "::notice::No AUR_SSH_PRIVATE_KEY secret — skipping the AUR push for ${{ matrix.pkg }}. See docs/RELEASING.md."
fi
- name: Container deps
if: steps.gate.outputs.publish == 'true'
run: pacman -Syu --noconfirm --needed git openssh sudo namcap
- uses: actions/checkout@v4
if: steps.gate.outputs.publish == 'true'
- name: Stamp pkgver from the tag + regenerate .SRCINFO
if: steps.gate.outputs.publish == 'true'
env:
VERSION: ${{ needs.meta.outputs.version }}
PKG: ${{ matrix.pkg }}
run: |
set -euo pipefail
cd "packaging/aur/$PKG"
sed -i -E "s/^pkgver=.*/pkgver=$VERSION/; s/^pkgrel=.*/pkgrel=1/" PKGBUILD
# .SRCINFO is mandatory on the AUR and must agree with the PKGBUILD, so generate it
# rather than hand-maintain it. makepkg refuses to run as root.
useradd -m builder
chown -R builder:builder .
sudo -u builder makepkg --printsrcinfo > .SRCINFO
namcap PKGBUILD || true
cat .SRCINFO
- name: Push to the AUR
if: steps.gate.outputs.publish == 'true'
env:
AUR_SSH_PRIVATE_KEY: ${{ secrets.AUR_SSH_PRIVATE_KEY }}
VERSION: ${{ needs.meta.outputs.version }}
PKG: ${{ matrix.pkg }}
run: |
set -euo pipefail
install -dm700 ~/.ssh
printf '%s\n' "$AUR_SSH_PRIVATE_KEY" > ~/.ssh/aur
chmod 600 ~/.ssh/aur
# TOFU on the AUR host key (what every AUR-publish action does). The blast radius is
# bounded: this key can only write these two AUR packages, and it is not the identity
# anchor for anything else.
ssh-keyscan -t ed25519,rsa aur.archlinux.org >> ~/.ssh/known_hosts 2>/dev/null
cat > ~/.ssh/config <<'EOF'
Host aur.archlinux.org
User aur
IdentityFile ~/.ssh/aur
IdentitiesOnly yes
EOF
git config --global user.name "Haven release"
git config --global user.email "blaine@wemiller.com"
git config --global --add safe.directory '*'
# Cloning a package that does not exist yet yields an EMPTY repo — the first push is
# what creates it. So this one step both creates and updates the AUR package.
git clone "ssh://aur@aur.archlinux.org/$PKG.git" "/tmp/aur-$PKG"
cd "/tmp/aur-$PKG"
# Drop tracked files first so deletions propagate, then lay the recipe back down.
find . -mindepth 1 -maxdepth 1 -not -name .git -exec rm -rf {} +
cp -r "$GITHUB_WORKSPACE/packaging/aur/$PKG/." .
git add -A
if git diff --cached --quiet; then
echo "::notice::$PKG is already at $VERSION on the AUR — nothing to push."
exit 0
fi
git commit -m "$PKG $VERSION"
git push origin HEAD:master