-
Notifications
You must be signed in to change notification settings - Fork 96
1120 lines (1060 loc) · 50.6 KB
/
build-installers.yml
File metadata and controls
1120 lines (1060 loc) · 50.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
# Copyright(C) 2025-2026 Advanced Micro Devices, Inc. All rights reserved.
# SPDX-License-Identifier: MIT
#
# Builds desktop installers (NSIS .exe, DMG, DEB, AppImage) on release tags
# and uploads them to the GitHub Release.
#
# Trigger: tag push (v*), manual workflow_dispatch, or reusable workflow_call
# from publish.yml (after the single approval gate — integration TBD).
#
# Per docs/plans/desktop-installer.mdx §7 Phase G and §9 output matrix.
#
# Design notes:
# • Runs WITHOUT any secrets — produces unsigned installers for free.
# • Code signing is opt-in via secret presence (env-driven).
# - Windows: SignPath (Phase H) via SIGNPATH_API_TOKEN.
# - macOS: Apple Developer ID via APPLE_ID + APPLE_APP_SPECIFIC_PASSWORD
# + APPLE_TEAM_ID + CSC_LINK + CSC_KEY_PASSWORD.
# • macos-latest is Apple Silicon (arm64). Intel requires macos-13 or older.
# • fail-fast is disabled so one platform failure doesn't abort the others.
# • concurrency.cancel-in-progress=false — mid-build cancels waste CI minutes.
# • latest*.yml + .blockmap files are uploaded alongside installers so
# electron-updater can detect updates and apply delta patches.
# • When triggered by a direct tag push, the GitHub Release is created as
# DRAFT to avoid leaking releases before publish.yml's single approval
# gate passes. The publish.yml integration (follow-up PR) will promote
# the draft to a non-draft release once approval completes.
name: Build Installers
on:
# Note: there is intentionally NO `push: tags: v*` trigger.
# `publish.yml` is the canonical entry point for tagged releases — it
# invokes this workflow via `workflow_call` after the validate step,
# then gates publishing on a single approval. Adding a direct `push`
# trigger here would cause two concurrent runs of this workflow on
# every tag push (one direct, one from publish.yml), racing on the
# same draft GitHub Release.
workflow_dispatch:
inputs:
tag:
description: 'Tag to build (leave blank to build HEAD without publishing to a release)'
required: false
default: ''
publish_to_release:
description: 'Upload artifacts to the GitHub Release matching the tag'
required: false
type: boolean
default: false
workflow_call:
inputs:
tag:
description: 'Tag to build'
required: false
type: string
default: ''
publish_to_release:
description: 'Upload artifacts to the GitHub Release matching the tag'
required: false
type: boolean
default: false
# Explicit secret declarations. Callers (publish.yml) must forward
# each one explicitly via `secrets:` — we intentionally do NOT use
# `secrets: inherit` on the caller side so forks / PRs can't leak
# unrelated repo secrets into this reusable workflow. Each secret
# is optional; missing secrets degrade to an unsigned build.
secrets:
SIGNPATH_API_TOKEN:
required: false
SIGNPATH_ORG_ID:
required: false
APPLE_ID:
required: false
APPLE_APP_SPECIFIC_PASSWORD:
required: false
APPLE_TEAM_ID:
required: false
CSC_LINK:
required: false
CSC_KEY_PASSWORD:
required: false
# Validate installer builds on any PR that touches installer-related files.
# Gated on paths so normal PRs don't pay the ~15min cross-platform build cost.
# This catches electron-builder config drift, entitlement breakage, NSIS
# script errors, etc. BEFORE they land on main.
pull_request:
paths:
- '.github/workflows/build-installers.yml'
- 'installer/**'
- 'src/gaia/apps/webui/electron-builder.yml'
- 'src/gaia/apps/webui/package.json'
- 'src/gaia/apps/webui/package-lock.json'
- 'src/gaia/apps/webui/main.cjs'
- 'src/gaia/apps/webui/bin/**'
- 'src/gaia/apps/webui/services/**'
# Cover the installer-smoke test tree (issue #941) so a PR that
# only touches the smoke-test layer still triggers structural smoke.
# Narrower than `tests/electron/**` so Jest-only edits to e.g.
# test_electron_chat_app.js don't re-run the multi-platform build.
- 'tests/electron/_helpers/**'
- 'tests/electron/*-smoke.test.mjs'
- 'tests/electron/fixtures/**'
- 'src/gaia/version.py'
# Least-privilege default. Only the release-upload step needs `contents:
# write`, and it's gated behind `inputs.publish_to_release` — which is
# false for PR triggers, so fork PRs can never get elevated write access
# via this workflow. The `build` job below redeclares `contents: read`
# explicitly for clarity; `id-token: write` stays because SignPath's
# OIDC handshake needs it even on unsigned PR builds (the step itself
# is still gated on secret presence).
permissions:
contents: read
id-token: write # SignPath OIDC (Phase H)
concurrency:
# PR runs should cancel-in-progress to avoid burning ~45 CI minutes per
# push on stale builds. Tag/release runs and manual dispatches must
# NOT cancel — a mid-build cancel on a release tag leaves the draft
# release in a partial state.
group: build-installers-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
build:
name: Build ${{ matrix.platform }} installer
runs-on: ${{ matrix.runner }}
# Build job is strictly read-only — it produces workflow-run artifacts
# that callers (publish.yml) download and re-upload. The opt-in
# GitHub Release upload step elevates with a per-step token (see the
# `softprops/action-gh-release` step and its `inputs.publish_to_release`
# gate, which is false for any PR trigger).
permissions:
contents: read
id-token: write # SignPath OIDC — step is still secret-gated
strategy:
fail-fast: false
matrix:
include:
- platform: windows
runner: windows-latest
npm_script: package:win
artifacts: |
src/gaia/apps/webui/dist-app/*.exe
src/gaia/apps/webui/dist-app/*.exe.blockmap
src/gaia/apps/webui/dist-app/latest.yml
- platform: macos
runner: macos-latest # Apple Silicon (arm64)
npm_script: package:mac
artifacts: |
src/gaia/apps/webui/dist-app/*.dmg
src/gaia/apps/webui/dist-app/*.dmg.blockmap
src/gaia/apps/webui/dist-app/latest-mac.yml
- platform: linux
runner: ubuntu-latest
npm_script: package:linux
artifacts: |
src/gaia/apps/webui/dist-app/*.deb
src/gaia/apps/webui/dist-app/*.AppImage
src/gaia/apps/webui/dist-app/*.blockmap
src/gaia/apps/webui/dist-app/latest-linux.yml
# Job-level env so that step-level `if:` conditions can reference
# secret-derived values (secrets cannot be used in `if:` directly,
# and step-level `env:` is NOT visible to that step's own `if:` —
# only workflow- and job-level env are). Any secret that gates a
# step via `env.X != ''` MUST be declared here. Empty strings are
# fine when the secret is not set — the step is then silently
# skipped, which is the intended opt-in-by-secret-presence behavior.
env:
SIGNPATH_API_TOKEN: ${{ secrets.SIGNPATH_API_TOKEN }}
SIGNPATH_ORG_ID: ${{ secrets.SIGNPATH_ORG_ID }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
CSC_LINK: ${{ secrets.CSC_LINK }}
CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }}
steps:
- name: Checkout
uses: actions/checkout@v6
with:
fetch-depth: 0 # full history for any version calculations
ref: ${{ inputs.tag || github.ref }}
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: '20'
cache: 'npm'
cache-dependency-path: src/gaia/apps/webui/package-lock.json
# electron + electron-builder download sizeable binaries (Electron runtime,
# code-signing helpers, etc.). Caching them saves ~1–2 min per run per
# platform. Cross-runner cache paths are listed together — actions/cache
# silently skips paths that don't exist on the current runner.
- name: Cache electron + electron-builder
uses: actions/cache@v4
with:
path: |
~/.cache/electron
~/.cache/electron-builder
~/Library/Caches/electron
~/Library/Caches/electron-builder
~/AppData/Local/electron/Cache
~/AppData/Local/electron-builder/Cache
key: ${{ matrix.platform }}-electron-${{ hashFiles('src/gaia/apps/webui/package-lock.json') }}
restore-keys: |
${{ matrix.platform }}-electron-
- name: Install npm dependencies
working-directory: src/gaia/apps/webui
shell: bash
run: npm ci
# ─── Fetch uv binary for Linux AppImage (issue #782) ────────────
# The AppImage bundles a pinned uv binary under build/vendor/uv so the
# after-pack hook and runtime can provision the Python env without
# requiring uv on the host. Pin + sha256 verify for supply-chain safety.
- name: Fetch uv binary (Linux)
if: matrix.platform == 'linux'
shell: bash
run: |
set -euo pipefail
UV_VERSION="0.5.14"
UV_TARBALL="uv-x86_64-unknown-linux-gnu.tar.gz"
UV_SHA256="22034760075b92487b326da5aa1a2a3e1917e2e766c12c0fd466fccda77013c7"
UV_URL="https://github.com/astral-sh/uv/releases/download/${UV_VERSION}/${UV_TARBALL}"
DEST_DIR="src/gaia/apps/webui/build/vendor/uv/linux-x64"
mkdir -p "${DEST_DIR}"
tmpdir="$(mktemp -d)"
curl -fsSL -o "${tmpdir}/${UV_TARBALL}" "${UV_URL}"
echo "${UV_SHA256} ${tmpdir}/${UV_TARBALL}" | sha256sum -c -
tar -xzf "${tmpdir}/${UV_TARBALL}" -C "${tmpdir}"
cp "${tmpdir}/uv-x86_64-unknown-linux-gnu/uv" "${DEST_DIR}/uv"
chmod 0755 "${DEST_DIR}/uv"
"${DEST_DIR}/uv" --version
- name: Fetch uv binary (Windows)
if: matrix.platform == 'windows'
shell: bash
run: |
set -euo pipefail
UV_VERSION="0.5.14"
UV_ZIP="uv-x86_64-pc-windows-msvc.zip"
UV_URL="https://github.com/astral-sh/uv/releases/download/${UV_VERSION}/${UV_ZIP}"
# This must be set to the official tarball SHA256 (archive digest).
# DO NOT merge this PR without replacing the placeholder below
# with the real value — the build intentionally fails if the
# checksum is not provided so we never ship an unverified uv.
UV_TARBALL_SHA256="ee2468e40320a0a2a36435e66bbd0d861228c4c06767f22d97876528138f4ba0"
DEST_DIR="src/gaia/apps/webui/build/vendor/uv/win-x64"
mkdir -p "${DEST_DIR}"
tmpdir="$(mktemp -d)"
echo "Downloading ${UV_URL}"
curl -fsSL --retry 3 --retry-delay 5 -o "${tmpdir}/${UV_ZIP}" "${UV_URL}"
if [ -z "${UV_TARBALL_SHA256}" ] || [ "${UV_TARBALL_SHA256}" = "REPLACE_ME_WIN_UV_TARBALL_SHA256" ]; then
echo "ERROR: UV_TARBALL_SHA256 is not set in the workflow. Set it to the upstream tarball SHA256 to proceed." >&2
exit 1
fi
echo "${UV_TARBALL_SHA256} ${tmpdir}/${UV_ZIP}" | sha256sum -c -
unzip -q "${tmpdir}/${UV_ZIP}" -d "${tmpdir}"
# Copy uv.exe from extracted tree — fail if not found.
FOUND=$(find "${tmpdir}" -type f -iname uv.exe | head -n1 || true)
if [ -z "${FOUND}" ]; then
echo "ERROR: uv.exe not found in the downloaded archive" >&2
exit 1
fi
cp "${FOUND}" "${DEST_DIR}/uv.exe"
chmod 0755 "${DEST_DIR}/uv.exe"
"${DEST_DIR}/uv.exe" --version || true
# Compute SHA256 of the extracted binary and inject into source
BIN_SHA=$(sha256sum "${DEST_DIR}/uv.exe" | cut -d' ' -f1)
echo "Computed uv.exe SHA256: ${BIN_SHA}"
# Replace placeholder in backend-installer.cjs so runtime can verify
sed -i "s/<WIN_UV_EXTRACTED_SHA256_PLACEHOLDER>/${BIN_SHA}/g" src/gaia/apps/webui/services/backend-installer.cjs
- name: Fetch uv binary (macOS)
if: matrix.platform == 'macos'
shell: bash
run: |
set -euo pipefail
UV_VERSION="0.5.14"
UV_TARBALL="uv-aarch64-apple-darwin.tar.gz"
# Upstream tarball SHA256 (archive digest)
UV_SHA256="d548dffc256014c4c8c693e148140a3a21bcc2bf066a35e1d5f0d24c91d32112"
UV_URL="https://github.com/astral-sh/uv/releases/download/${UV_VERSION}/${UV_TARBALL}"
DEST_DIR="src/gaia/apps/webui/build/vendor/uv/mac-arm64"
mkdir -p "${DEST_DIR}"
tmpdir="$(mktemp -d)"
echo "Downloading ${UV_URL}"
curl -fsSL --retry 3 --retry-delay 5 -o "${tmpdir}/${UV_TARBALL}" "${UV_URL}"
echo "${UV_SHA256} ${tmpdir}/${UV_TARBALL}" | shasum -a 256 -c -
tar -xzf "${tmpdir}/${UV_TARBALL}" -C "${tmpdir}"
cp "${tmpdir}/uv-aarch64-apple-darwin/uv" "${DEST_DIR}/uv"
chmod 0755 "${DEST_DIR}/uv"
"${DEST_DIR}/uv" --version
# Echo the extracted-binary SHA (pre-codesign). To bump the
# runtime's POST-codesign digest (BUNDLED_UV_SHA256[mac-arm64]),
# run CI and copy the value from the dmg-structural-smoke output.
shasum -a 256 "${DEST_DIR}/uv"
- name: Build frontend (Vite)
working-directory: src/gaia/apps/webui
shell: bash
run: npm run build
# ─── Bundle Lemonade Server MSI (issue #774) ────────────────────
# The Windows NSIS installer embeds lemonade-server-minimal.msi so
# users get a working Lemonade setup on first launch with no runtime
# download. version.nsh exposes ${LEMONADE_VERSION} to NSIS for the
# DetailPrint message; the MSI itself is downloaded from the pinned
# upstream release. Both steps Windows-only — no impact on mac/linux.
- name: Generate installer/version.nsh (Windows)
if: matrix.platform == 'windows'
shell: bash
run: |
# Runs write_version_files() which emits installer/version.nsh containing
# !define LEMONADE_VERSION and !define GAIA_VERSION
python src/gaia/version.py
echo "Generated installer/version.nsh:"
cat installer/version.nsh
- name: Download Lemonade MSI (Windows)
if: matrix.platform == 'windows'
shell: bash
run: |
LEMONADE_VERSION=$(grep -oE 'LEMONADE_VERSION = "[^"]+"' src/gaia/version.py | cut -d'"' -f2)
if [ -z "$LEMONADE_VERSION" ]; then
echo "ERROR: Could not parse LEMONADE_VERSION from src/gaia/version.py" >&2
exit 1
fi
URL="https://github.com/lemonade-sdk/lemonade/releases/download/v${LEMONADE_VERSION}/lemonade-server-minimal.msi"
echo "Downloading Lemonade MSI v${LEMONADE_VERSION} from ${URL}"
curl -fsSL --retry 3 --retry-delay 5 "${URL}" -o installer/lemonade-server-minimal.msi
# Sanity check: the -minimal MSI is a bootstrap installer (~4-6MB) that
# fetches the Lemonade runtime on first run, so we guard against an
# obviously-truncated download (<1MB = not even an MSI header) rather
# than pinning to a specific upstream size that can change between
# minor releases.
SIZE=$(wc -c < installer/lemonade-server-minimal.msi)
echo "Downloaded MSI size: ${SIZE} bytes"
if [ "$SIZE" -lt 1048576 ]; then
echo "ERROR: MSI smaller than 1MB; download likely corrupt or 404 HTML body." >&2
exit 1
fi
# ─── Code signing config (opt-in by secret presence) ─────────────
- name: Detect Windows code signing
if: matrix.platform == 'windows' && env.SIGNPATH_API_TOKEN != ''
shell: bash
run: |
echo "SignPath token detected — Windows installer will be signed."
echo "NOTE: SignPath action integration lands in Phase H."
- name: Detect macOS code signing
if: matrix.platform == 'macos' && env.APPLE_ID != ''
shell: bash
run: |
echo "Apple Developer ID detected — electron-builder will sign + notarize."
echo "CSC_LINK / CSC_KEY_PASSWORD are read automatically by electron-builder."
# ─── Build the installer ────────────────────────────────────────
- name: Build installer
working-directory: src/gaia/apps/webui
shell: bash
env:
# GH_TOKEN is needed by electron-builder's publish step when the
# `publish` field is set in electron-builder.yml. We pass the
# workflow token but DO NOT publish here — we upload via
# softprops/action-gh-release in a later step. `publish: never`
# is implied by the CLI flag below.
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Always allow identity auto-discovery. When APPLE_ID is set,
# electron-builder finds the real Developer ID cert in the keychain.
# When APPLE_ID is absent, the explicit --config.mac.identity=-
# below overrides auto-discovery and forces ad-hoc signing.
# Setting this to 'false' would suppress signing entirely — even
# when an explicit identity is passed on the CLI.
CSC_IDENTITY_AUTO_DISCOVERY: "true"
# --publish never: we upload artifacts ourselves via action-gh-release.
# On macOS without signing secrets, ad-hoc sign with identity="-".
# This produces a valid code signature (sealed resources, correct
# bundle ID) without a Developer ID cert. Users see "cannot be
# verified" (bypassable via right-click → Open) instead of the
# unrecoverable "is damaged" error that identity=null caused.
run: |
EXTRA_ARGS=""
if [ "${{ matrix.platform }}" = "macos" ] && [ -z "$APPLE_ID" ]; then
echo "No APPLE_ID set — using ad-hoc code signing (identity=-)"
# electron-builder skips signing for pull-request builds unless this
# env var is set. Safe to enable for ad-hoc: there are no real signing
# credentials to leak. Remove this if real Developer ID certs are added
# (the security warning in electron-builder's output only matters when
# CSC_LINK / APPLE_ID secrets are present and fork PRs are allowed).
export CSC_FOR_PULL_REQUEST=true
# CSC_LINK is set to "" at the job level (secrets expand to empty
# strings when unset). @electron/osx-sign resolves "" to the working
# directory and fails with "not a file". Unset it so osx-sign skips
# the certificate-file path entirely and honours identity=- directly.
unset CSC_LINK
EXTRA_ARGS="--config.mac.identity=-"
fi
npm run ${{ matrix.npm_script }} -- --publish never $EXTRA_ARGS
# ─── Inspect build output ───────────────────────────────────────
- name: List build artifacts
if: always()
working-directory: src/gaia/apps/webui
shell: bash
run: |
echo "=== dist-app/ contents ==="
if [ -d dist-app ]; then
ls -lh dist-app/ || true
echo ""
echo "=== Artifact sizes ==="
find dist-app -maxdepth 1 -type f \
\( -name "*.exe" -o -name "*.dmg" -o -name "*.deb" \
-o -name "*.AppImage" -o -name "*.blockmap" \
-o -name "*.yml" \) \
-exec ls -lh {} \; || true
else
echo "(dist-app/ does not exist — build may have failed)"
fi
# ─── Verify Lemonade MSI bundling (issue #774) ───────────────────
# Guards against regressions where the MSI stops being bundled.
# Uses 7z to inspect the NSIS installer archive. NSIS solid
# compression can prevent 7z from listing inner files — in that
# case we print a warning but do NOT fail the build (the NSIS File
# directive already fails compilation if the MSI was missing, so a
# built .exe is strong evidence of a successful bundle). We only
# fail if 7z CAN list contents but the MSI name is absent.
- name: Verify Lemonade MSI embedded in installer (Windows)
if: matrix.platform == 'windows'
shell: bash
run: |
INSTALLER=$(ls src/gaia/apps/webui/dist-app/*.exe 2>/dev/null | head -1)
if [ -z "$INSTALLER" ]; then
echo "ERROR: No installer .exe found in dist-app/" >&2
exit 1
fi
echo "Inspecting: ${INSTALLER}"
LISTING=$(7z l "${INSTALLER}" 2>&1)
STATUS=$?
if [ $STATUS -ne 0 ]; then
echo "WARNING: 7z could not list installer contents (NSIS solid compression?). Build passed — NSIS File directive is the compile-time gate."
elif echo "${LISTING}" | grep -q "lemonade-server-minimal.msi"; then
echo "OK: Lemonade MSI is confirmed bundled in the installer."
else
echo "ERROR: 7z can list the installer but lemonade-server-minimal.msi is NOT present." >&2
exit 1
fi
# ─── Verify macOS code signature ─────────────────────────────────
# Guards against shipping a broken .app bundle (the class of bug
# that caused #745 — identity=null produced a linker-only ad-hoc
# signature with no sealed resources, which macOS reported as
# "damaged"). This step fails the build if the signature is invalid.
- name: Verify macOS code signature
if: matrix.platform == 'macos'
working-directory: src/gaia/apps/webui
shell: bash
run: |
APP=$(find dist-app -name "*.app" -maxdepth 2 -print -quit 2>/dev/null)
if [ -z "$APP" ]; then
echo "No .app found in dist-app/ — skipping verification"
exit 0
fi
echo "=== Verifying: $APP ==="
# --deep validates nested code (frameworks, helpers).
# --strict is omitted: it rejects valid ad-hoc signatures on macOS 13+.
codesign --verify --deep "$APP"
echo ""
echo "=== Signature details ==="
codesign -dv --verbose=4 "$APP" 2>&1
echo ""
echo "=== Sealed Resources check ==="
if codesign -dv --verbose=4 "$APP" 2>&1 | grep -q "Sealed Resources"; then
echo "OK: Resources are sealed"
else
echo "FAIL: No sealed resources — the .app bundle has a broken signature"
exit 1
fi
# ─── Upload to workflow run (always, for debugging) ─────────────
# NOTE: this step MUST run before the SignPath step below, because
# SignPath references its artifact-id output (`steps.upload-artifacts.
# outputs.artifact-id`) to know which artifact to fetch, sign, and
# write back. Reordering these two steps will silently break Windows
# signing.
- name: Upload artifacts to workflow run
id: upload-artifacts
uses: actions/upload-artifact@v6
with:
name: ${{ matrix.platform }}-installer
path: ${{ matrix.artifacts }}
retention-days: 14
if-no-files-found: error
# ─── SignPath signing (Phase H — opt-in via secrets) ────────────
# This step uploads the unsigned NSIS .exe to SignPath for code
# signing, then downloads the signed artifact back over the same
# filename. SignPath OSS is free for open-source projects:
# https://signpath.io/solutions/open-source-community
#
# Required GitHub Action secrets (set up once via SignPath onboarding):
# SIGNPATH_API_TOKEN — issued by SignPath after OSS approval
# SIGNPATH_ORG_ID — your SignPath organization UUID
#
# Until both secrets are set, this step is silently skipped and the
# NSIS installer ships unsigned. End-users see a SmartScreen
# warning the first time they run an unsigned installer; the
# troubleshooting guide documents the bypass step.
#
# Ordering: this step runs AFTER `Upload artifacts to workflow run`
# so that step's `id: upload-artifacts` output is populated before
# we read `steps.upload-artifacts.outputs.artifact-id`.
- name: Sign Windows installer (SignPath)
# Both env vars are sourced from the job-level env: block (which
# reads the secrets). GitHub Actions only exposes workflow- and
# job-level env to a step's `if:` expression — step-level env is
# evaluated AFTER `if:`, which is why we can't inline the secret
# forwarding here.
if: matrix.platform == 'windows' && env.SIGNPATH_API_TOKEN != '' && env.SIGNPATH_ORG_ID != ''
uses: signpath/github-action-submit-signing-request@v1
with:
api-token: ${{ env.SIGNPATH_API_TOKEN }}
organization-id: ${{ env.SIGNPATH_ORG_ID }}
project-slug: gaia-agent-ui
signing-policy-slug: release-signing
artifact-configuration-slug: gaia-installer
github-artifact-id: ${{ steps.upload-artifacts.outputs.artifact-id }}
wait-for-completion: true
output-artifact-directory: src/gaia/apps/webui/dist-app/
# ─── Build Python wheel (Linux release builds only) ─────────────
# The wheel is consumed by the AppImage smoke tests so they can
# install the backend from a local file instead of pulling from PyPI.
# This breaks the circular dependency where smoke tests run before
# PyPI publish in the release pipeline.
- name: Build Python wheel
if: matrix.platform == 'linux'
id: build-wheel
shell: bash
run: |
set -euo pipefail
pip install build --quiet
python -m build --wheel --outdir /tmp/gaia-wheel
WHEEL=$(ls /tmp/gaia-wheel/*.whl | head -n1)
echo "path=${WHEEL}" >> "$GITHUB_OUTPUT"
echo "Built wheel: ${WHEEL}"
- name: Upload Python wheel artifact
if: matrix.platform == 'linux' && steps.build-wheel.outputs.path != ''
uses: actions/upload-artifact@v6
with:
name: gaia-wheel
path: /tmp/gaia-wheel/*.whl
retention-days: 14
# ─── AppImage smoke tests (issue #782) ──────────────────────────────
# Consume the linux-installer artifact produced by the `build` matrix
# and run structural + distro-level smoke checks against the AppImage.
# These jobs MUST NOT modify the artifact — they are read-only consumers.
#
# AC mapping (see plan for issue #782):
# AC1 — Ubuntu 24.04 minimal launches without curl / with libfuse2
# AC2 — Arch-equivalent launch (fedora row covers RPM-family SELinux)
# AC3 — pre-built dist/ ships (structural check)
# AC4 — bundled uv present (structural check)
# AC5 — HTML fallback at / (tested separately by unit tests)
# AC6 — port-manager unit tests (tested separately)
# AC8 — Wayland visibility (DEFERRED — see wayland-visibility stub below)
# AC9 — structural guarantees on every release build
appimage-structural-smoke:
name: AppImage structural smoke
needs: build
if: always() && needs.build.result == 'success'
runs-on: ubuntu-24.04
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Download Linux installer artifact
uses: actions/download-artifact@v6
with:
name: linux-installer
path: ${{ runner.temp }}/linux-installer
- name: Locate AppImage
id: locate
shell: bash
run: |
set -euo pipefail
APPIMAGE=$(ls "${RUNNER_TEMP}/linux-installer"/*.AppImage | head -n1)
echo "Found AppImage: ${APPIMAGE}"
chmod +x "${APPIMAGE}"
echo "appimage=${APPIMAGE}" >> "$GITHUB_OUTPUT"
- name: Structural smoke (chrome-sandbox, uv, dist, app.asar)
env:
GAIA_APPIMAGE: ${{ steps.locate.outputs.appimage }}
run: node --test tests/electron/appimage-smoke.test.mjs
# ─── DMG structural smoke (issue #941) ──────────────────────────────
# Mirrors appimage-structural-smoke for the macOS DMG. Catches the
# failure mode that bit v0.17.5: a darwin-arm64 install that hard-fails
# in ensure-uv on first launch because the bundled uv either was never
# shipped or has the wrong SHA256 against BUNDLED_UV_SHA256[mac-arm64]
# in backend-installer.cjs.
#
# MUST be present in build-complete `needs:` below — without that
# wiring, a failing DMG smoke does not block release-readiness.
dmg-structural-smoke:
name: DMG structural smoke
needs: build
if: always() && needs.build.result == 'success'
runs-on: macos-latest # Apple Silicon (arm64) — matches build matrix
permissions:
contents: read
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Download macOS installer artifact
uses: actions/download-artifact@v6
with:
name: macos-installer # ${{ matrix.platform }}-installer with platform=macos
path: ${{ runner.temp }}/macos-installer
- name: Locate DMG
id: locate
shell: bash
run: |
set -euo pipefail
DMG=$(ls "${RUNNER_TEMP}/macos-installer"/*.dmg | head -n1)
echo "Found DMG: ${DMG}"
echo "dmg=${DMG}" >> "$GITHUB_OUTPUT"
- name: Structural smoke (uv binary, mode, sha256, --version)
env:
GAIA_DMG: ${{ steps.locate.outputs.dmg }}
run: node --test tests/electron/dmg-smoke.test.mjs
appimage-distro-matrix:
name: AppImage distro matrix
needs: build
if: always() && needs.build.result == 'success'
runs-on: ubuntu-24.04
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Download Linux installer artifact
uses: actions/download-artifact@v6
with:
name: linux-installer
path: ${{ runner.temp }}/linux-installer
# Download the Python wheel built alongside the installer so the
# AppImage smoke test can install from a local file instead of PyPI.
# continue-on-error: the wheel is absent on PR builds (fine — PyPI
# still has the previous release), but required on release builds.
- name: Download Python wheel
id: download-wheel
continue-on-error: true
uses: actions/download-artifact@v6
with:
name: gaia-wheel
path: ${{ runner.temp }}/gaia-wheel
- name: Locate Python wheel
id: locate-wheel
env:
RELEASE_TAG: ${{ inputs.tag }}
shell: bash
run: |
WHEEL=$(ls "${RUNNER_TEMP}/gaia-wheel"/*.whl 2>/dev/null | head -n1 || true)
echo "path=${WHEEL}" >> "$GITHUB_OUTPUT"
if [ -n "${WHEEL}" ]; then
echo "Found wheel: ${WHEEL} — smoke tests will use GAIA_LOCAL_WHEEL"
elif [ -n "${RELEASE_TAG}" ]; then
echo "::error::No Python wheel artifact found for release build (tag=${RELEASE_TAG}). The wheel is required to avoid the PyPI circular dependency — the Build Python wheel step must have failed."
exit 1
else
echo "No wheel artifact — smoke tests will install from PyPI (non-release build)"
fi
- name: Prepare AppImage
id: prep
shell: bash
run: |
set -euo pipefail
APPIMAGE=$(ls "${RUNNER_TEMP}/linux-installer"/*.AppImage | head -n1)
chmod +x "${APPIMAGE}"
# Share a stable path into the container workdir mount.
cp "${APPIMAGE}" "${RUNNER_TEMP}/linux-installer/gaia-agent-ui.AppImage"
echo "appimage=${RUNNER_TEMP}/linux-installer/gaia-agent-ui.AppImage" >> "$GITHUB_OUTPUT"
# All three rows share one runner. Containers are launched in a
# loop so we pay one VM boot for N distro checks (cost discipline
# per the amended T7 in the plan).
- name: Run distro rows (ubuntu24-libfuse, ubuntu24-nofuse, fedora41)
shell: bash
env:
APPIMAGE_HOST: ${{ steps.prep.outputs.appimage }}
WHEEL_PATH: ${{ steps.locate-wheel.outputs.path }}
run: |
set -euo pipefail
# Build the two Ubuntu/Fedora fixtures locally.
docker build -t gaia-test-u24-min \
-f tests/electron/fixtures/Dockerfile.ubuntu-24-minimal \
tests/electron/fixtures
docker build -t gaia-test-fedora41 \
-f tests/electron/fixtures/Dockerfile.fedora-41 \
tests/electron/fixtures
# If a local wheel is available, mount it and set GAIA_LOCAL_WHEEL
# so the AppImage installs the backend from file instead of PyPI.
# This is the release-pipeline path — on PR builds WHEEL_PATH is
# empty and the app falls back to pulling from PyPI normally.
WHEEL_MOUNTS=()
if [ -n "${WHEEL_PATH}" ]; then
WHEEL_BASENAME=$(basename "${WHEEL_PATH}")
WHEEL_DIR=$(dirname "${WHEEL_PATH}")
WHEEL_MOUNTS=(
-v "${WHEEL_DIR}:/work/wheels:ro"
--env "GAIA_LOCAL_WHEEL=/work/wheels/${WHEEL_BASENAME}"
)
fi
DOCKER_RUN_COMMON=(
--rm
--cap-add SYS_ADMIN
--device /dev/fuse
--security-opt seccomp=unconfined
--security-opt apparmor=unconfined
--ipc=host
-v "${APPIMAGE_HOST}:/work/gaia.AppImage:ro"
# Expand to nothing if WHEEL_MOUNTS is empty (set -u compat)
"${WHEEL_MOUNTS[@]+"${WHEEL_MOUNTS[@]}"}"
)
# ── Row 1: Ubuntu 24.04 + libfuse2, curl purged ──────────────
# Must reach "state: ready" AND /api/health must return service
# string "gaia-agent-ui" (proves the app actually served
# requests, not just logged and died). No FATAL on stderr.
echo "::group::Row 1 — ubuntu:24.04 minimal with libfuse2 (no curl)"
docker run "${DOCKER_RUN_COMMON[@]}" gaia-test-u24-min \
bash -c '
set -eo pipefail
cp /work/gaia.AppImage /tmp/gaia.AppImage
chmod +x /tmp/gaia.AppImage
# Launch under xvfb, background. Capture PID correctly so we
# can health-check and kill cleanly at end.
xvfb-run --auto-servernum /tmp/gaia.AppImage \
>/tmp/stdout.log 2>/tmp/stderr.log &
APP_PID=$!
# Poll for readiness up to 300s — fresh install downloads
# Lemonade + a model on first run; 90s was too tight.
for i in $(seq 1 300); do
if grep -q "state: ready" /tmp/stdout.log 2>/dev/null; then
break
fi
sleep 1
done
grep -q "state: ready" /tmp/stdout.log || {
echo "::error::did not reach state: ready"
tail -n 200 /tmp/stdout.log /tmp/stderr.log || true
kill -9 "$APP_PID" 2>/dev/null || true
exit 1
}
# Prove the API actually serves: /api/health must echo the
# service string. Re-read the port on every attempt because
# main.cjs logs "Starting backend: ... --ui-port <n>" shortly
# AFTER state: ready — the log line may not exist yet on the
# first iteration. Fall back to 4200 for older builds.
HEALTH_OK=0
for i in $(seq 1 30); do
PORT=$(grep -oE "ui-port[ =]+([0-9]+)" /tmp/stdout.log | head -n1 | grep -oE "[0-9]+")
PORT=${PORT:-4200}
# Use bash /dev/tcp instead of curl — curl is purged in this
# container to prove the bundled-uv path eliminates that dep.
# Avoid single quotes inside the outer single-quoted bash -c block
# by using a subshell ( ) with double-quoted printf instead.
RESPONSE=$( (exec 3<>/dev/tcp/127.0.0.1/${PORT} && printf "GET /api/health HTTP/1.0\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n" >&3 && timeout 5 cat <&3) 2>/dev/null || true)
if echo "$RESPONSE" | grep -q "gaia-agent-ui"; then
HEALTH_OK=1
break
fi
sleep 1
done
if [ "$HEALTH_OK" -ne 1 ]; then
echo "::error::/api/health did not report service=gaia-agent-ui on port ${PORT}"
tail -n 200 /tmp/stdout.log /tmp/stderr.log || true
kill -9 "$APP_PID" 2>/dev/null || true
exit 1
fi
# Process must still be alive (did not silently crash).
if ! kill -0 "$APP_PID" 2>/dev/null; then
echo "::error::main process exited after logging state: ready"
exit 1
fi
# Sanity: no FATAL sandbox in stderr.
if grep -qE "FATAL:sandbox|\[Errno 98\]|GLIBC_2\.(3[89]|4[0-9])" /tmp/stderr.log; then
echo "::error::forbidden error pattern in stderr"
cat /tmp/stderr.log
kill -9 "$APP_PID" 2>/dev/null || true
exit 1
fi
# Clean teardown.
kill "$APP_PID" 2>/dev/null || true
wait "$APP_PID" 2>/dev/null || true
echo "Row 1 PASS (state: ready + /api/health 200)"
'
echo "::endgroup::"
# ── Row 2: Ubuntu 24.04 WITHOUT libfuse2 ─────────────────────
# Launching the AppImage directly MUST emit a human-readable
# error message pointing to the missing FUSE dependency, not
# a raw fusermount tracepoint or a silent segfault.
echo "::group::Row 2 — ubuntu:24.04 minimal WITHOUT libfuse2"
docker run "${DOCKER_RUN_COMMON[@]}" ubuntu:24.04 \
bash -c '
set -o pipefail
apt-get update >/dev/null
apt-get install --yes --no-install-recommends file ca-certificates >/dev/null
# Intentionally do NOT install libfuse2.
cp /work/gaia.AppImage /tmp/gaia.AppImage
chmod +x /tmp/gaia.AppImage
set +e
/tmp/gaia.AppImage >/tmp/out.log 2>&1
rc=$?
set -e
cat /tmp/out.log
if [ "$rc" -eq 0 ]; then
echo "::error::AppImage somehow succeeded without libfuse2 — unexpected"
exit 1
fi
# Require diagnostic text. A silent exit (empty output) or a
# bare segfault is the bug we are guarding against. AppImage
# runtime emits a recognizable message when libfuse2 is
# missing — grep for known keywords (fuse/libfuse/FUSE or
# the AppImage runtime hint) as a humane-error contract.
if [ ! -s /tmp/out.log ]; then
echo "::error::AppImage exited silently without libfuse2 — bug"
exit 1
fi
if ! grep -qiE "fuse|libfuse|AppImage|fusermount" /tmp/out.log; then
echo "::error::error output lacks a humane FUSE hint; expected keyword fuse|libfuse|AppImage|fusermount"
exit 1
fi
echo "Row 2 PASS (humane failure referencing fuse/appimage)"
'
echo "::endgroup::"
# ── Row 3: Fedora 41 (RPM family, SELinux headers present) ───
echo "::group::Row 3 — fedora:41"
docker run "${DOCKER_RUN_COMMON[@]}" gaia-test-fedora41 \
bash -c '
set -eo pipefail
cp /work/gaia.AppImage /tmp/gaia.AppImage
chmod +x /tmp/gaia.AppImage
xvfb-run --auto-servernum /tmp/gaia.AppImage \
>/tmp/stdout.log 2>/tmp/stderr.log &
APP_PID=$!
for i in $(seq 1 300); do
if grep -q "state: ready" /tmp/stdout.log 2>/dev/null; then
break
fi
sleep 1
done
grep -q "state: ready" /tmp/stdout.log || {
echo "::error::fedora did not reach state: ready"
tail -n 200 /tmp/stdout.log /tmp/stderr.log || true
kill -9 "$APP_PID" 2>/dev/null || true
exit 1
}
HEALTH_OK=0
for i in $(seq 1 30); do
PORT=$(grep -oE "ui-port[ =]+([0-9]+)" /tmp/stdout.log | head -n1 | grep -oE "[0-9]+")
PORT=${PORT:-4200}
if curl -sSf "http://127.0.0.1:${PORT}/api/health" \
| grep -q "gaia-agent-ui"; then
HEALTH_OK=1
break
fi
sleep 1
done
if [ "$HEALTH_OK" -ne 1 ]; then
echo "::error::fedora: /api/health did not report service=gaia-agent-ui"
kill -9 "$APP_PID" 2>/dev/null || true
exit 1
fi
kill "$APP_PID" 2>/dev/null || true
wait "$APP_PID" 2>/dev/null || true
echo "Row 3 PASS (state: ready + /api/health 200)"
'
echo "::endgroup::"
appimage-userns-restricted:
# Ubuntu 24.04.1+ defaults: kernel.apparmor_restrict_unprivileged_userns=1
# Validates the appImage.executableArgs: [--no-sandbox] fallback lands.
name: AppImage userns-restricted
needs: build
if: always() && needs.build.result == 'success'
runs-on: ubuntu-24.04
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Download Linux installer artifact
uses: actions/download-artifact@v6
with:
name: linux-installer
path: ${{ runner.temp }}/linux-installer
- name: Download Python wheel
id: download-wheel
continue-on-error: true
uses: actions/download-artifact@v6
with:
name: gaia-wheel
path: ${{ runner.temp }}/gaia-wheel
- name: Locate Python wheel
id: locate-wheel
env:
RELEASE_TAG: ${{ inputs.tag }}
shell: bash
run: |
WHEEL=$(ls "${RUNNER_TEMP}/gaia-wheel"/*.whl 2>/dev/null | head -n1 || true)
echo "path=${WHEEL}" >> "$GITHUB_OUTPUT"
if [ -n "${WHEEL}" ]; then
echo "Found wheel: ${WHEEL} — will set GAIA_LOCAL_WHEEL"
elif [ -n "${RELEASE_TAG}" ]; then
echo "::error::No Python wheel artifact found for release build (tag=${RELEASE_TAG}). The wheel is required to avoid the PyPI circular dependency — the Build Python wheel step must have failed."
exit 1
else
echo "No wheel artifact — will install from PyPI (non-release build)"
fi
- name: Enable AppArmor userns restriction on host
shell: bash
run: |
set -euo pipefail
# The sysctl may not exist on older kernels — tolerate that.
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=1 || {
echo "::warning::kernel.apparmor_restrict_unprivileged_userns not supported on this runner; test degenerates to a plain launch"
}
- name: Launch AppImage under xvfb with userns restricted
shell: bash
env:
WHEEL_PATH: ${{ steps.locate-wheel.outputs.path }}
GAIA_SKIP_GAIA_INIT: "1"
run: |
set -euo pipefail
APPIMAGE=$(ls "${RUNNER_TEMP}/linux-installer"/*.AppImage | head -n1)
chmod +x "${APPIMAGE}"
sudo apt-get update
sudo apt-get install --yes --no-install-recommends libfuse2 xvfb xauth
if [ -n "${WHEEL_PATH}" ]; then
export GAIA_LOCAL_WHEEL="${WHEEL_PATH}"
echo "GAIA_LOCAL_WHEEL=${GAIA_LOCAL_WHEEL}"
fi
xvfb-run --auto-servernum "${APPIMAGE}" \
>/tmp/stdout.log 2>/tmp/stderr.log &
APP_PID=$!
# 300s timeout matches the structural and distro-matrix smoke
# jobs — fresh installs download Lemonade + a ~3GB model on
# first run, so 90s starves model-download cases out.
for i in $(seq 1 300); do
if grep -q "state: ready" /tmp/stdout.log 2>/dev/null; then
break
fi
sleep 1
done
if ! grep -q "state: ready" /tmp/stdout.log; then
echo "::error::userns-restricted launch did not reach state: ready — --no-sandbox fallback failed"
tail -n 200 /tmp/stdout.log /tmp/stderr.log || true
kill -9 "$APP_PID" 2>/dev/null || true
exit 1
fi
HEALTH_OK=0
for i in $(seq 1 30); do
PORT=$(grep -oE "ui-port[ =]+([0-9]+)" /tmp/stdout.log | head -n1 | grep -oE "[0-9]+")
PORT=${PORT:-4200}
if curl -sSf "http://127.0.0.1:${PORT}/api/health" \
| grep -q "gaia-agent-ui"; then
HEALTH_OK=1
break
fi
sleep 1