-
Notifications
You must be signed in to change notification settings - Fork 81
1067 lines (976 loc) · 50.7 KB
/
Copy pathbuild.yml
File metadata and controls
1067 lines (976 loc) · 50.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
name: Build Electron App
on:
push:
tags:
- "v*"
workflow_dispatch:
inputs:
arch:
description: "macOS architecture to build"
required: true
default: "both"
type: choice
options:
- arm64
- x64
- both
release_tag:
description: "Optional release tag to create or update, e.g. v1.5.0"
required: false
type: string
permissions:
contents: write
concurrency:
group: build-${{ github.ref_name }}-${{ github.event.inputs.release_tag || 'artifacts' }}
cancel-in-progress: false
jobs:
build-windows:
name: Windows installer
runs-on: windows-latest
steps:
- name: Checkout code
uses: actions/checkout@v7
- name: Setup Node.js
uses: ./.github/actions/setup
# STT is the bundled whisper-stt-server (whisper.cpp with native DTW token
# timestamps); no VAD model is fetched here. The binary is built by
# build-whisper-stt.yml and staged below — without that step the installer
# ships without speech-to-text. See
# technical-documentation/architecture/transcription-and-captions.md.
- name: Stage whisper-stt binaries
shell: bash
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: bash scripts/stage-whisper-stt.sh win32-x64
- name: Build Windows app
run: npm run build:win -- --publish never
- name: Upload Windows installer
uses: actions/upload-artifact@v7
with:
name: openscreen-windows
path: release/**/Openscreen.Setup.*.exe
if-no-files-found: error
retention-days: 30
build-windows-store:
name: Windows Store package
runs-on: windows-latest
steps:
- name: Checkout code
uses: actions/checkout@v7
- name: Setup Node.js
uses: ./.github/actions/setup
- name: Stage whisper-stt binaries
shell: bash
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: bash scripts/stage-whisper-stt.sh win32-x64
- name: Build Windows Store package
run: npm run build:win:store -- --publish never
# Store certification rejected 1.9.0 under 10.1.1.11 "On Device Tiles" because
# the package carried electron-builder's vendored placeholder tiles: it reads
# them from build/appx/ and, when a name is missing there, silently substitutes
# a blank SampleAppx.*.png instead of failing. Nothing in the build output says
# so — the only way to know is to look inside the package.
#
# The expected filenames come from the generator (`--list`), NOT from build/appx/.
# Deriving them from the directory would defeat the check at the exact moment it
# matters: delete an asset there and it drops out of the expected set too, so the
# loop passes while electron-builder quietly packages a placeholder under that
# name. The generator is the authority; build/appx/ is the artifact being checked.
- name: Verify Store tiles are in the package
shell: pwsh
run: |
$expected = @(node scripts/generate-appx-assets.mjs --list)
if ($LASTEXITCODE -ne 0 -or $expected.Count -eq 0) { throw "generator produced no asset list" }
$problems = @()
# First: the committed directory must match the generator exactly. A deleted or
# hand-added PNG is caught here, before it can reach the package.
$committed = @(Get-ChildItem build/appx -Filter *.png | ForEach-Object { $_.Name })
foreach ($name in $expected) {
if ($committed -notcontains $name) { $problems += "missing from build/appx: $name" }
}
foreach ($name in $committed) {
if ($expected -notcontains $name) { $problems += "unexpected file in build/appx (run npm run assets:appx): $name" }
}
# Then: every expected asset must be in the package, byte-identical.
$appx = Get-ChildItem release -Recurse -Filter *.appx | Select-Object -First 1
if (-not $appx) { throw "no .appx found under release/" }
Add-Type -AssemblyName System.IO.Compression.FileSystem
$archive = [System.IO.Compression.ZipFile]::OpenRead($appx.FullName)
try {
# OPC parts are "/" separated, but normalise anyway rather than trust it.
$entries = @{}
foreach ($e in $archive.Entries) { $entries[$e.FullName.Replace("\", "/")] = $e }
$sha = [System.Security.Cryptography.SHA256]::Create()
foreach ($name in $expected) {
$entry = $entries["assets/$name"]
if (-not $entry) { $problems += "missing from package: $name"; continue }
$stream = $entry.Open()
try { $packaged = [BitConverter]::ToString($sha.ComputeHash($stream)) }
finally { $stream.Dispose() }
$sourceFile = "build/appx/$name"
if (-not (Test-Path $sourceFile)) { continue }
$source = [BitConverter]::ToString($sha.ComputeHash([IO.File]::ReadAllBytes((Resolve-Path $sourceFile))))
if ($packaged -ne $source) { $problems += "packaged copy differs from build/appx: $name" }
}
}
finally { $archive.Dispose() }
if ($problems) {
$problems | ForEach-Object { Write-Output "::error::$_" }
throw "$($problems.Count) tile asset problem(s) in $($appx.Name)"
}
Write-Output "$($expected.Count) tile assets present in $($appx.Name), byte-identical to build/appx/"
# Twice running, a release shipped a dependency that could not be resolved on
# the target machine, and both times someone outside the project found it:
# 1.9.0's compositor addon was reached through PATH, which MSIX ignores, and
# 1.9.1's capture helper needed the Visual C++ Redistributable, which is not
# part of Windows. Each fix arrived with a guard aimed at the failure already
# understood, and neither guard would have caught the other.
#
# This step is the one that generalises: it registers the package and asks the
# Windows loader to resolve every shipped binary for real. It deliberately does
# not record anything — a runner has no useful GPU or desktop session, and a
# flaky gate gets switched off. The loader is what broke both times.
#
# `powershell`, not `pwsh`: the Appx module is not loaded natively in
# PowerShell 7 and needs -UseWindowsPowerShell to work at all.
- name: Verify native binaries load under package identity
shell: powershell
run: |
$ErrorActionPreference = "Stop"
# Loose registration of an unsigned package needs Developer Mode. The runner
# is discarded after the job, so enabling it here costs nothing; the script
# itself refuses to touch this, because on a real machine it is the owner's
# setting to make.
$key = "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\AppModelUnlock"
New-Item -Path $key -Force | Out-Null
Set-ItemProperty -Path $key -Name AllowDevelopmentWithoutDevLicense -Value 1 -Type DWord
$appx = Get-ChildItem release -Recurse -Filter *.appx | Select-Object -First 1
if (-not $appx) { throw "no .appx found under release/" }
# Explicit exit rather than letting the error surface on its own: a
# terminating error does not set $LASTEXITCODE, and the runner's epilogue
# only inspects that. Trusting it would let a failed verification report a
# green step, which is the exact shape of bug this job exists to stop.
try {
& "$env:GITHUB_WORKSPACE\scripts\verify-appx-native.ps1" -Appx $appx.FullName
}
catch {
Write-Output "::error::$($_.Exception.Message)"
exit 1
}
- name: Upload Windows Store package
uses: actions/upload-artifact@v7
with:
name: openscreen-windows-store
path: release/**/Openscreen.Setup.*.appx
if-no-files-found: error
retention-days: 30
build-macos:
name: macOS ${{ matrix.arch }} DMG
# Build each arch NATIVELY. `macos-latest` is Apple Silicon, and everything the
# 1.8.0 macOS path added keys off the HOST arch: fetch-ffmpeg-macos.mjs configures
# with `--arch=${process.arch}`, and build-macos-compositor-addon.mjs installs into
# `darwin-${process.arch}` and runs cargo without `--target`. So the x64 job on an
# arm64 runner produced arm64 output in darwin-arm64/, and packaging then failed
# with "Refusing to package an incomplete macOS payload — looked in darwin-x64".
# v1.7.0 shipped an x64 DMG because it had neither the compositor addon nor a
# vendored ffmpeg to build; both arrived with 1.8.0 and nobody could see the
# breakage while this job sat behind `if: false`.
# Running x64 on an Intel runner fixes it without threading a target arch through
# ffmpeg's configure, cargo and the output paths — four blind changes on a release
# branch, none of them testable without a Mac.
runs-on: ${{ matrix.arch == 'x64' && 'macos-15-intel' || 'macos-latest' }}
strategy:
fail-fast: false
matrix:
arch: ${{ fromJSON((github.event_name == 'workflow_dispatch' && github.event.inputs.arch != 'both') && format('["{0}"]', github.event.inputs.arch) || '["arm64", "x64"]') }}
steps:
- name: Checkout code
uses: actions/checkout@v7
- name: Setup Node.js
uses: ./.github/actions/setup
- name: Setup Python
uses: actions/setup-python@v6
with:
python-version: "3.11"
- name: Ensure sharp prebuilt
run: npm rebuild sharp
env:
npm_config_build_from_source: "false"
- name: Stage whisper-stt binaries
shell: bash
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: bash scripts/stage-whisper-stt.sh darwin-${{ matrix.arch }}
- name: Resolve macOS signing
id: signing
env:
MAC_CERTIFICATE_P12: ${{ secrets.MAC_CERTIFICATE_P12 }}
MAC_CERTIFICATE_PASSWORD: ${{ secrets.MAC_CERTIFICATE_PASSWORD }}
MAC_CSC_NAME: ${{ secrets.MAC_CSC_NAME }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
run: |
if [[ -n "$MAC_CERTIFICATE_P12" && -n "$MAC_CERTIFICATE_PASSWORD" && -n "$MAC_CSC_NAME" && -n "$APPLE_ID" && -n "$APPLE_TEAM_ID" && -n "$APPLE_APP_SPECIFIC_PASSWORD" ]]; then
# `CSC_NAME` must name the identity WITHOUT its certificate type.
# electron-builder picks the type itself and rejects a qualified name
# outright:
#
# ⨯ Please remove prefix "Developer ID Application:" from the
# specified name — appropriate certificate will be chosen
# automatically
#
# It does that at `Package .app bundle`, which sits after the ffmpeg
# build and the compositor addon — about twelve minutes in, and only
# on macOS. Since the same secret also feeds `codesign --sign` at
# `Sign DMG`, the mistake is easy to make: codesign accepts the full
# common name, so the qualified form looks right until electron-builder
# sees it. The short form satisfies both, because codesign matches on a
# substring of the common name.
case "$MAC_CSC_NAME" in
# Every pattern ends at the colon on purpose, so a company whose
# name merely starts with one of these words is not rejected.
"Developer ID Application:"*|"Developer ID Installer:"*|"Apple Development:"*|"Apple Distribution:"*|"3rd Party Mac Developer Application:"*|"3rd Party Mac Developer Installer:"*)
echo "::error::MAC_CSC_NAME carries a certificate-type prefix. Set it to the identity name alone, e.g. 'Jane Doe (AB12CD34EF)' rather than 'Developer ID Application: Jane Doe (AB12CD34EF)'. Read it from: security find-identity -v -p codesigning"
exit 1
;;
esac
echo "enabled=true" >> "$GITHUB_OUTPUT"
else
echo "enabled=false" >> "$GITHUB_OUTPUT"
fi
- name: Import code signing certificate
if: steps.signing.outputs.enabled == 'true'
env:
MAC_CERTIFICATE_P12: ${{ secrets.MAC_CERTIFICATE_P12 }}
MAC_CERTIFICATE_PASSWORD: ${{ secrets.MAC_CERTIFICATE_PASSWORD }}
run: |
KEYCHAIN_PATH="$RUNNER_TEMP/build.keychain-db"
KEYCHAIN_PASSWORD="$(openssl rand -base64 32)"
security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH"
security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
echo "$MAC_CERTIFICATE_P12" | base64 --decode > "$RUNNER_TEMP/certificate.p12"
security import "$RUNNER_TEMP/certificate.p12" \
-k "$KEYCHAIN_PATH" \
-P "$MAC_CERTIFICATE_PASSWORD" \
-T /usr/bin/codesign \
-T /usr/bin/security
security set-key-partition-list -S apple-tool:,apple: -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
security list-keychains -d user -s "$KEYCHAIN_PATH" $(security list-keychains -d user | tr -d '"')
security find-identity -v -p codesigning "$KEYCHAIN_PATH"
rm -f "$RUNNER_TEMP/certificate.p12"
- name: Build Vite + Electron
run: npx tsc && npx vite build
- name: Build native macOS helpers
run: npm run build:native:mac
env:
OPENSCREEN_MAC_HELPER_ARCHS: ${{ matrix.arch }}
# The two steps below are what `npm run build:mac` does and this job did not.
# Windows gets them for free because its job just runs `npm run build:win`,
# which chains fetch:ffmpeg + build:native:compositor; macOS spells its steps
# out (it needs `--dir` plus a hand-rolled DMG and signing) and drifted. The
# result was a .app with no compositor addon — preview and export dead in the
# installed app, silently. `scripts/before-pack.cjs` now refuses to package
# that, so this is also what keeps the job from failing at the pack step.
- name: Cache LGPL ffmpeg tree
uses: actions/cache@v6
with:
# fetch-ffmpeg-macos.mjs BUILDS ffmpeg from source (~5 min): BtbN ships no
# macOS target and every circulating macOS build is GPL, which would
# relicense this MIT app. The script pins the release and checksums it, so
# keying on the script itself busts the cache when the pin moves.
path: crates/thirdparty
key: ffmpeg-macos-${{ matrix.arch }}-${{ hashFiles('scripts/fetch-ffmpeg-macos.mjs') }}
- name: Vendor LGPL ffmpeg
run: npm run fetch:ffmpeg:mac
- name: Cache cargo + compositor build tree
uses: actions/cache@v6
with:
path: |
~/.cargo/registry
~/.cargo/git
crates/target
key: cargo-macos-${{ matrix.arch }}-${{ hashFiles('crates/Cargo.lock') }}
restore-keys: |
cargo-macos-${{ matrix.arch }}-
- name: Build Metal compositor addon
run: npm run build:native:compositor:mac
- name: Package .app bundle
run: npx electron-builder --mac --${{ matrix.arch }} --dir --publish never
env:
CSC_NAME: ${{ secrets.MAC_CSC_NAME }}
CSC_IDENTITY_AUTO_DISCOVERY: ${{ steps.signing.outputs.enabled == 'true' && 'true' || 'false' }}
- name: Get version
id: version
run: |
VERSION="$(node -e "console.log(require('./package.json').version)")"
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
# `--${{ matrix.arch }}` above does NOT restrict the architecture: the
# `arch` list in electron-builder.json5's `mac.target` names both x64 and
# arm64 and the config wins, so BOTH bundles are produced in every job —
# x64 in release/<ver>/mac/, arm64 in release/<ver>/mac-arm64/. The old
# `find release/<ver> ... | head -n1` took whichever came first in
# directory order (x64, in practice), so the arm64 job could package the
# x64 bundle into a DMG named `-arm64-`. Nothing downstream compared the
# name against the contents, so that would have published silently.
- name: Find .app bundle
id: find_app
run: |
VERSION="${{ steps.version.outputs.version }}"
if [[ "${{ matrix.arch }}" == "arm64" ]]; then ARCH_DIR="mac-arm64"; else ARCH_DIR="mac"; fi
APP_BUNDLE="$(find "release/${VERSION}/${ARCH_DIR}" -maxdepth 2 -name "*.app" -type d | head -n1)"
if [[ -z "$APP_BUNDLE" ]]; then
echo "::error::No .app bundle found in release/${VERSION}/${ARCH_DIR}/"
find "release/${VERSION}" -maxdepth 4 -print || true
exit 1
fi
echo "app_bundle=$APP_BUNDLE" >> "$GITHUB_OUTPUT"
# The guard for the above: refuse to build a DMG whose name would not
# match its contents. An Intel bundle on an Apple Silicon Mac runs under
# Rosetta 2 — compositor, encoder and whisper all translated — which is
# slow enough to be unusable, so a mislabelled DMG is a real user harm.
- name: Verify .app architecture matches the job
run: |
BIN="${{ steps.find_app.outputs.app_bundle }}/Contents/MacOS/Openscreen"
if [[ "${{ matrix.arch }}" == "arm64" ]]; then EXPECTED="arm64"; else EXPECTED="x86_64"; fi
ACTUAL="$(lipo -archs "$BIN")"
echo "job arch=${{ matrix.arch }} expected=${EXPECTED} actual=${ACTUAL}"
if [[ " ${ACTUAL} " != *" ${EXPECTED} "* ]]; then
echo "::error::The ${{ matrix.arch }} job produced a '${ACTUAL}' bundle — refusing to publish a mislabelled DMG"
exit 1
fi
# electron-builder used to do this itself. Its macPackager carried a
# `noIdentity && fallBackToAdhoc` branch that handed back `Identity("-")`
# whenever no certificate was found — mandatory on arm64, where an unsigned
# binary will not launch at all. 26.15.3 replaced that path with
# `findSigningIdentity`, which returns null instead, and `sign()` leaves on
# `return false`. Nothing signs the bundle, and what ships is the bare
# linker signature on the Electron binary: `Identifier=Electron`,
# `Sealed Resources=none`.
#
# That is not cosmetic. macOS keys TCC grants to an app's code signature,
# so a bundle signed as "Electron" cannot hold one. v1.9.0-rc.1 asked for
# Accessibility, the user granted it, `AXIsProcessTrusted()` still returned
# false, and the editable-cursor preflight in useScreenRecorder re-opened
# the same dialog on every press of record — recording was impossible.
#
# Signed with the same runtime and entitlements electron-builder applies,
# so a locally signed build and a certificate-signed one differ only in the
# identity. Both arches on purpose: 26.8.1 only fell back on arm64, which
# left Intel DMGs unsigned for their whole existence.
- name: Ad-hoc sign the .app
if: steps.signing.outputs.enabled != 'true'
run: |
codesign --force --deep --sign - \
--options runtime \
--entitlements macos.entitlements \
"${{ steps.find_app.outputs.app_bundle }}"
# UNCONDITIONAL. Gated on `enabled == 'true'`, this step never ran for the
# RC builds — the only ones that could be unsigned — so the regression
# above shipped with every macOS check in this job green.
- name: Verify .app code signature
run: |
APP="${{ steps.find_app.outputs.app_bundle }}"
codesign --verify --deep --strict "$APP"
# The identifier, not just the structure: `--verify` passes on the bare
# linker signature too, so it alone would not have caught this. What
# distinguishes a bundle macOS can attach permissions to is that its
# signing identifier matches the bundle id.
EXPECTED="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleIdentifier' "$APP/Contents/Info.plist")"
ACTUAL="$(codesign -dv --verbose=2 "$APP" 2>&1 | sed -n 's/^Identifier=//p')"
echo "signature identifier=${ACTUAL} expected=${EXPECTED}"
if [[ "$ACTUAL" != "$EXPECTED" ]]; then
echo "::error::The .app is signed as '${ACTUAL}', not '${EXPECTED}' — macOS cannot attach Accessibility or Screen Recording permissions to a bundle whose signature does not carry its own identifier"
exit 1
fi
- name: Create DMG
id: dmg
run: |
VERSION="${{ steps.version.outputs.version }}"
ARCH="${{ matrix.arch }}"
# Name the DMG after the machine, not the instruction set. "x64" reads
# to most people as "the normal 64-bit one" and "arm64" as the exotic
# variant, which is exactly backwards on any Mac sold since 2020 — and
# picking the wrong one silently costs Rosetta 2. `Intel` and
# `Apple-Silicon` are what About This Mac shows the user.
case "$ARCH" in
arm64) ARCH_LABEL="Apple-Silicon" ;;
x64) ARCH_LABEL="Intel" ;;
*) ARCH_LABEL="$ARCH" ;;
esac
DMG_NAME="Openscreen-macOS-${ARCH_LABEL}-${VERSION}.dmg"
RELEASE_DIR="release/${VERSION}"
DMG_OUTPUT="${RELEASE_DIR}/${DMG_NAME}"
STAGING="${RELEASE_DIR}/dmg-staging"
rm -rf "$STAGING"
rm -f "$DMG_OUTPUT"
mkdir -p "$STAGING"
cp -R "${{ steps.find_app.outputs.app_bundle }}" "$STAGING/"
ln -s /Applications "$STAGING/Applications"
hdiutil create \
-srcfolder "$STAGING" \
-volname "Openscreen" \
-fs HFS+ \
-fsargs "-c c=64,a=16,e=16" \
-format UDBZ \
"$DMG_OUTPUT"
rm -rf "$STAGING"
echo "dmg_path=$DMG_OUTPUT" >> "$GITHUB_OUTPUT"
# The four steps below used to carry `&& !contains(github.ref_name, '-')`,
# which skipped them for every pre-release, `-rc.N` tags included. Two
# costs, and the second is the one that mattered.
#
# Testers paid the first: a DMG signed with Developer ID but not notarized
# is still refused by Gatekeeper — `spctl` answers `rejected, source=
# Unnotarized Developer ID` — so every RC tester had to know about
# `xattr -rd com.apple.quarantine` before they could open the thing they
# were being asked to test.
#
# The release paid the second. With the skip in place, notarization never
# ran until the stable tag, so the first exercise of the credentials, the
# certificate chain and Apple's acceptance of every nested Mach-O landed on
# the highest-stakes build there is. That is not theoretical: the run that
# first enabled signing here died in `Package .app bundle` on a malformed
# `MAC_CSC_NAME`, and it was only visible because a full build was run
# deliberately. Notarizing each RC turns every candidate into a rehearsal.
#
# The trade is a few minutes per macOS job and a dependency on Apple's
# notary service being reachable — `--wait` is capped at 15 minutes below.
# If that ever becomes flaky enough to block RCs, the fix is
# `continue-on-error` on pre-releases, not going back to skipping them.
- name: Sign DMG
if: steps.signing.outputs.enabled == 'true'
run: |
codesign --force \
--sign "${{ secrets.MAC_CSC_NAME }}" \
--timestamp \
"${{ steps.dmg.outputs.dmg_path }}"
- name: Notarize DMG
if: steps.signing.outputs.enabled == 'true'
run: |
xcrun notarytool submit "${{ steps.dmg.outputs.dmg_path }}" \
--apple-id "${{ secrets.APPLE_ID }}" \
--team-id "${{ secrets.APPLE_TEAM_ID }}" \
--password "${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}" \
--wait
timeout-minutes: 15
- name: Staple notarization ticket
if: steps.signing.outputs.enabled == 'true'
run: xcrun stapler staple "${{ steps.dmg.outputs.dmg_path }}"
- name: Validate stapled DMG
if: steps.signing.outputs.enabled == 'true'
run: |
xcrun stapler validate "${{ steps.dmg.outputs.dmg_path }}"
spctl -a -vv -t install "${{ steps.dmg.outputs.dmg_path }}"
- name: Upload macOS DMG
uses: actions/upload-artifact@v7
with:
name: openscreen-mac-${{ matrix.arch }}
path: ${{ steps.dmg.outputs.dmg_path }}
if-no-files-found: error
retention-days: 30
- name: Cleanup keychain
if: always() && steps.signing.outputs.enabled == 'true'
run: security delete-keychain "$RUNNER_TEMP/build.keychain-db" || true
build-linux:
name: Linux packages
# PINNED, and not to `ubuntu-latest`. The linker binds every symbol to the newest
# version its BUILD machine offers, so the runner image silently decides the oldest
# distro these packages can run on — nothing in the source asks for any of it.
# On ubuntu-latest (24.04) that floor was glibc 2.38 / GLIBCXX_3.4.32, which put
# Ubuntu 22.04, Debian 12 and RHEL 9 out of range: whisper-stt-server and the ggml
# backends died in ld.so before main(), and compositor_view.node (2.35, from a single
# `hypotf`) died on RHEL 9 as well. Both fail SILENTLY — the app still launches,
# captions just report a developer error and the preview renders nothing — and no
# package format catches it, because the deb/rpm/pacman `depends` lists are
# hand-written in electron-builder.json5 and electron-builder passes fpm none of
# --rpm-autoreq*, so not even dnf generates the libc.so.6(GLIBC_2.38) requirement
# that would have refused the install.
#
# 22.04 is the oldest distro the README claims (it names it as the PipeWire
# baseline), and it is the binding one: glibc 2.35 and libstdc++6 from GCC 12
# (GLIBCXX_3.4.30, CXXABI_1.3.13), against Debian 12's 2.36/3.4.30/1.3.13.
# scripts/before-pack.cjs enforces that ceiling on the built payload, so bumping
# this image alone cannot quietly raise the floor again.
runs-on: ubuntu-22.04
steps:
- name: Checkout code
uses: actions/checkout@v7
- name: Setup Node.js
uses: ./.github/actions/setup
# bsdtar (libarchive-tools) is fpm's mtree generator for the pacman target.
# rpmbuild (rpm) is what fpm shells out to for the rpm target; without it the
# build fails at packaging, not at config parse.
- name: Install Linux packaging dependencies
run: sudo apt-get update && sudo apt-get install -y libarchive-tools rpm
# patchelf is what build-linux-compositor-addon.mjs renames the ffmpeg symbols
# with, so the addon cannot bind to Chromium's bundled ffmpeg — an unconditional
# dependency that resolvePatchelf() throws on.
#
# NOT from apt, which is the whole reason this is its own step: 22.04 carries
# patchelf 0.14.3 and `--rename-dynamic-symbols` first shipped in 0.18.0, so the
# apt copy fails the rename outright. 0.18.0 is also exactly what the 24.04 image
# provided, so the renaming behaviour is unchanged from what already ships.
# resolvePatchelf() checks ~/.local/bin before /usr/bin, so this wins over
# whatever the image happens to carry.
- name: Install patchelf
run: |
curl -fsSLo /tmp/patchelf.tar.gz \
https://github.com/NixOS/patchelf/releases/download/0.18.0/patchelf-0.18.0-x86_64.tar.gz
echo "ce84f2447fb7a8679e58bc54a20dc2b01b37b5802e12c57eece772a6f14bf3f0 /tmp/patchelf.tar.gz" | sha256sum -c -
mkdir -p ~/.local/bin
tar -xzf /tmp/patchelf.tar.gz -C /tmp ./bin/patchelf
mv /tmp/bin/patchelf ~/.local/bin/patchelf
~/.local/bin/patchelf --version
- name: Stage whisper-stt binaries
shell: bash
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: bash scripts/stage-whisper-stt.sh linux-x64
- name: Build Linux app
run: npm run build:linux -- --publish never
# The guard for the trap documented on the upload step below: `if-no-files-found`
# evaluates the union of the globs, so a format that stops being produced is
# invisible there. It is a real failure mode and not a hypothetical one — the rpm
# target was added to electron-builder.json5's `linux.target` alone, where the CLI
# list in `build:linux` overrides it, and the upload glob for it would have matched
# nothing on every release with the job still green. One assertion per format.
- name: Verify every Linux format was produced
run: |
if [[ ! -d release ]]; then
echo "::error::electron-builder produced no release/ directory"
exit 1
fi
MISSING=()
for ext in AppImage deb pacman rpm; do
COUNT="$(find release -type f -name "*.${ext}" -printf . | wc -c)"
echo "${ext}: ${COUNT}"
if [[ "$COUNT" -eq 0 ]]; then MISSING+=("$ext"); fi
done
if [[ "${#MISSING[@]}" -gt 0 ]]; then
echo "::error::No artifact produced for: ${MISSING[*]} — check the target list in package.json's build:linux, which overrides linux.target in electron-builder.json5"
find release -maxdepth 2 -type f -print
exit 1
fi
# The Linux counterpart of the Windows job's "Verify native binaries load under
# package identity", added for the same reason and after the same kind of miss:
# 1.9.1 shipped three sonames that nothing declared and nothing bundled — libgbm.so.1
# and libasound.so.2, needed by the Electron binary itself, so a clean Ubuntu 22.04
# exited 127 before any window, and libgomp.so.1, needed by the whole STT stack, so
# transcription died in ld.so. The symbol-version guard in before-pack.cjs could not
# have seen any of it: it checks how NEW the required symbols are, not whether the
# libraries carrying them are ever installed.
#
# Both misses hid behind the same thing. Desktop metapackages pull all three, so
# every machine anyone tested on had them — libgomp1 only via libfftw3-single3,
# libimagequant0 and libsoxr0, three peripheral media libraries. The check has to run
# somewhere empty or it is not a check, which is why this uses containers rather than
# the runner it is already standing on.
#
# rpm and pacman are verified too, and they are the ones with no other safety net:
# their depends lists are hand-written, no user installs them often enough to report
# a gap quickly, and package names genuinely differ (libgomp.so.1 is `libgomp1` on
# Debian, `libgomp` on Fedora AND on Arch, where it was split out of `gcc-libs`).
#
# The AppImage is deliberately NOT covered. It has no dependency mechanism at all, so
# there is no declaration to verify against — every system soname is "missing" by
# construction and the check would have nothing to say. It stays exposed, which is
# what d3d_linux::diagnose naming the Mesa package is for.
- name: Verify packages resolve on a clean machine
run: |
for fmt in deb rpm pacman; do
PKG="$(find release -type f -name "*.${fmt}" | head -1)"
echo "::group::${fmt}"
bash scripts/verify-linux-package.sh "$fmt" "$PKG"
echo "::endgroup::"
done
- name: Upload Linux packages
uses: actions/upload-artifact@v7
with:
name: openscreen-linux
path: |
release/**/*.AppImage
release/**/*.deb
release/**/*.pacman
release/**/*.rpm
# No *.zsync: nothing produces one. zsync is electron-updater's delta format,
# this repo has no updater (no electron-updater, no autoUpdater, no
# latest-linux.yml), and app-builder-lib 26.x dropped zsync entirely in favour
# of the block map it embeds in the AppImage. The glob had matched nothing
# since the dependency bump, silently — `if-no-files-found: error` evaluates
# the union of these patterns, so one dead glob among live ones never fails.
if-no-files-found: error
retention-days: 30
publish-release:
name: Publish GitHub release
runs-on: ubuntu-latest
needs:
- build-windows
- build-macos
- build-linux
if: ${{ (github.event_name == 'push' && github.ref_type == 'tag') || (github.event_name == 'workflow_dispatch' && github.event.inputs.release_tag != '') }}
steps:
- name: Checkout code
uses: actions/checkout@v7
with:
# Full history + tags: the RC notes below are built from `git log` over the
# range since the previous RC tag, and resolving that tag needs the tags.
fetch-depth: 0
- name: Resolve release tag
id: release
env:
INPUT_TAG: ${{ github.event.inputs.release_tag }}
run: |
if [[ "${GITHUB_REF_TYPE}" == "tag" ]]; then
TAG="${GITHUB_REF_NAME}"
else
TAG="${INPUT_TAG}"
fi
if [[ ! "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-(rc|beta|alpha)\.[0-9]+)?$ ]]; then
echo "::error::Release tag must look like v1.5.0 or v1.5.0-rc.1; got '${TAG}'"
exit 1
fi
VERSION="${TAG#v}"
# For an RC tag (e.g. v1.5.0-rc.1) package.json is at the pre-release version
# (1.5.0-rc.1), not the stable version (1.5.0). Compare against the full tag version.
PACKAGE_VERSION="$(node -p 'require("./package.json").version')"
if [[ "$PACKAGE_VERSION" != "$VERSION" ]]; then
echo "::error::package.json version ${PACKAGE_VERSION} does not match ${VERSION} from tag ${TAG}"
exit 1
fi
if [[ "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+-(rc|beta|alpha)\.[0-9]+$ ]]; then
PRERELEASE_FLAG="--prerelease"
IS_PRERELEASE="true"
else
PRERELEASE_FLAG=""
IS_PRERELEASE="false"
fi
# Compute the previous stable tag for auto-generated release notes. We don't use
# GitHub's "most recent prior release by date" because the fork carries re-published
# upstream releases whose published_at is more recent than the fork's own first release.
# For SemVer X.Y.Z: previous is vX.Y.(Z-1) if Z>0, else vX.(Y-1).0, else v(X-1).0.0.
STABLE_VERSION="${VERSION%%-*}"
IFS='.' read -r PX PY PZ <<< "$STABLE_VERSION"
if (( PZ > 0 )); then
NOTES_START_TAG="v${PX}.${PY}.$((PZ - 1))"
elif (( PY > 0 )); then
NOTES_START_TAG="v${PX}.$((PY - 1)).0"
else
NOTES_START_TAG="v$((PX - 1)).0.0"
fi
# For an RC, compare against the PREVIOUS RC of the same line, not the previous
# stable. Deriving the start tag from STABLE_VERSION alone made every RC of a
# line span the same range, so each re-cut just repeated the last RC's notes
# plus its own handful, and testers could not see what the re-cut changed.
# Walk down from the current rc number so a skipped or failed RC doesn't break it.
if [[ "$IS_PRERELEASE" == "true" ]]; then
RC_NUMBER="${VERSION##*.}"
for (( n = RC_NUMBER - 1; n >= 1; n-- )); do
CANDIDATE="v${STABLE_VERSION}-rc.${n}"
if git rev-parse -q --verify "refs/tags/${CANDIDATE}" >/dev/null; then
NOTES_START_TAG="$CANDIDATE"
break
fi
done
fi
# Everything above computes what the previous release was *called* and
# never checks that it exists. A version line that stopped at its RC
# makes that a name for nothing: 1.9.3 shipped only as rc.1, so
# v1.9.4-rc.1 asked git for v1.9.3..v1.9.4-rc.1 and the publish step
# died on `unknown revision` -- after all four platforms had already
# built, and with publish-msstore sitting behind publish-release, so
# the same gap would silently block a stable release's Store
# deployment too. The rc walk-down above handles a skipped RC; nothing
# handled a skipped stable, and it does not run at all for an rc.1.
if ! git rev-parse -q --verify "refs/tags/${NOTES_START_TAG}" >/dev/null; then
# The nearest tag reachable from the release commit's parent, which
# is what "since the last release" meant in the first place. Left
# EMPTY when no tag is reachable at all, rather than filled with the
# root commit: the stable path hands this to `gh release create
# --notes-start-tag`, which is the API's previous_tag_name and takes
# a tag NAME -- a commit SHA there is not a lenient fallback, it is
# an invalid argument. Each consumer below decides what "no previous
# release" means for it.
NOTES_START_TAG="$(git describe --tags --abbrev=0 "${TAG}^" 2>/dev/null || true)"
echo "Previous-release tag did not exist; using ${NOTES_START_TAG:-<none>}"
fi
echo "Computed notes_start_tag=${NOTES_START_TAG} for tag=${TAG}"
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
echo "stable_version=$STABLE_VERSION" >> "$GITHUB_OUTPUT"
echo "is_prerelease=$IS_PRERELEASE" >> "$GITHUB_OUTPUT"
echo "prerelease_flag=$PRERELEASE_FLAG" >> "$GITHUB_OUTPUT"
echo "notes_start_tag=$NOTES_START_TAG" >> "$GITHUB_OUTPUT"
- name: Download Windows installer
uses: actions/download-artifact@v8
with:
name: openscreen-windows
path: artifacts/windows
- name: Download macOS arm64 DMG
uses: actions/download-artifact@v8
with:
name: openscreen-mac-arm64
path: artifacts/mac-arm64
- name: Download macOS x64 DMG
uses: actions/download-artifact@v8
with:
name: openscreen-mac-x64
path: artifacts/mac-x64
- name: Download Linux packages
uses: actions/download-artifact@v8
with:
name: openscreen-linux
path: artifacts/linux
- name: Publish release assets
env:
GH_TOKEN: ${{ secrets.OPENSCREEN_RELEASE_TOKEN }}
TAG: ${{ steps.release.outputs.tag }}
PRERELEASE_FLAG: ${{ steps.release.outputs.prerelease_flag }}
NOTES_START_TAG: ${{ steps.release.outputs.notes_start_tag }}
run: |
mapfile -t FILES < <(find artifacts -type f | sort)
if [[ "${#FILES[@]}" -eq 0 ]]; then
echo "::error::No installer artifacts were downloaded"
exit 1
fi
if gh release view "$TAG" >/dev/null 2>&1; then
gh release upload "$TAG" "${FILES[@]}" --clobber
else
if [[ -n "$PRERELEASE_FLAG" ]]; then
# RC notes come from `git log`, not --generate-notes. GitHub's generator
# lists only the PRs it manages to associate, and on this repo it silently
# drops real ones — #254 and #261 were merged into the release branch and
# never appeared in v1.9.0-rc.2's body — so an RC could omit the very fix
# the re-cut was for. The commit range is the actual diff and can't lie.
# Stable releases keep --generate-notes below: they're the public-facing
# ones and want the PR links and the New Contributors section.
# With no previous tag at all, the range is the whole history and
# there is nothing to compare against, so say so rather than
# emitting "since " with a blank where a tag should be.
if [[ -n "$NOTES_START_TAG" ]]; then
RC_RANGE="${NOTES_START_TAG}..${TAG}"
RC_HEADING="## Changes since ${NOTES_START_TAG}"
RC_LINK="**Full Changelog**: ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/compare/${NOTES_START_TAG}...${TAG}"
else
RC_RANGE="$TAG"
RC_HEADING="## Changes"
RC_LINK="**Full Changelog**: ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/commits/${TAG}"
fi
{
echo "$RC_HEADING"
echo
git log --no-merges --reverse --pretty='- %s' \
--invert-grep --grep='^chore(release): bump to' \
"$RC_RANGE"
echo
echo "$RC_LINK"
} > "${RUNNER_TEMP}/rc-notes.md"
cat "${RUNNER_TEMP}/rc-notes.md"
NOTES_ARGS=(--notes-file "${RUNNER_TEMP}/rc-notes.md")
else
# --notes-start-tag controls which previous tag GitHub compares against
# when auto-generating the release notes. Default behaviour (most recent
# prior release by date) doesn't work for this fork because the v1.4.0
# release in the fork was re-published after v1.5.0, which makes GitHub
# pick v1.4.0 as the "previous" for any v1.5.x release.
#
# Omitted entirely when there is no previous tag: this maps to the
# API's previous_tag_name, which takes a tag NAME. Passing an empty
# string or a commit SHA is an invalid argument, not a graceful
# degradation. Without it GitHub falls back to its own choice of
# previous release, which is exactly right when there isn't one.
if [[ -n "$NOTES_START_TAG" ]]; then
NOTES_ARGS=(--generate-notes --notes-start-tag "$NOTES_START_TAG")
else
NOTES_ARGS=(--generate-notes)
fi
fi
# shellcheck disable=SC2086
gh release create "$TAG" "${FILES[@]}" \
--target "$GITHUB_SHA" \
--title "$TAG" \
"${NOTES_ARGS[@]}" \
$PRERELEASE_FLAG
fi
if [[ -n "$PRERELEASE_FLAG" ]]; then
gh release edit "$TAG" \
--draft=false \
--latest=false \
--title "$TAG"
else
gh release edit "$TAG" \
--draft=false \
--latest \
--title "$TAG"
fi
- name: Refresh the docs /download page
# Only a stable release changes what /releases/latest resolves to, so a
# pre-release would rebuild the site to byte-identical output.
#
# Dispatched against main on purpose: the github-pages environment only
# permits `main` to deploy, so docs.yml's old `on: release` trigger ran
# with a tag ref and failed its deploy every time. See docs.yml.
if: ${{ steps.release.outputs.is_prerelease == 'false' }}
timeout-minutes: 20
env:
GH_TOKEN: ${{ secrets.OPENSCREEN_RELEASE_TOKEN }}
run: |
latest_dispatch() {
gh run list \
--repo "$GITHUB_REPOSITORY" \
--workflow docs.yml \
--event workflow_dispatch \
--branch main \
--limit 1 \
--json databaseId \
--jq '.[0].databaseId // empty'
}
# `gh workflow run` prints nothing we can key off, so remember which
# dispatch was newest beforehand and wait for a different one to appear.
PREVIOUS_RUN_ID="$(latest_dispatch)"
gh workflow run docs.yml --ref main --repo "$GITHUB_REPOSITORY"
RUN_ID=""
for _ in $(seq 1 30); do
sleep 5
CANDIDATE="$(latest_dispatch)"
if [[ -n "$CANDIDATE" && "$CANDIDATE" != "$PREVIOUS_RUN_ID" ]]; then
RUN_ID="$CANDIDATE"
break
fi
done
if [[ -z "$RUN_ID" ]]; then
echo "::error::Dispatched docs.yml but no new run appeared within 150s"
exit 1
fi
gh run watch "$RUN_ID" --repo "$GITHUB_REPOSITORY" --interval 15 || true
CONCLUSION="$(gh run view "$RUN_ID" --repo "$GITHUB_REPOSITORY" --json conclusion --jq '.conclusion')"
case "$CONCLUSION" in
success)
echo "Docs rebuilt and deployed by run $RUN_ID"
;;
cancelled)
# docs.yml cancels in-flight runs sharing a ref, so a push to main
# landing right now replaces this rebuild with a newer one.
echo "::warning::Docs run $RUN_ID was cancelled, most likely superseded by a newer main run"
;;
*)
echo "::error::Docs run $RUN_ID concluded '$CONCLUSION' - /download may still list the previous release"
exit 1
;;
esac
# Publishing the Store package is the last manual step in the release: the appx
# had to be downloaded from this run's artifacts and uploaded by hand in Partner
# Center. That is also how 1.8.0 ended up with two different packages under one
# version — the artifact was downloaded twice and both copies were uploaded.
#
# This job lives in build.yml rather than beside publish-winget.yml because the
# appx never becomes a release asset: the GitHub release carries only the NSIS
# installer, so `release: published` has nothing to hand a downstream workflow.
# The package exists solely as this run's artifact.
publish-msstore:
name: Publish to Microsoft Store
runs-on: windows-latest
needs:
- build-windows-store
- publish-release
# Stable tags only, and never a fork: an RC reaching the Store would go through
# certification and land on every user's machine as an automatic update.
# `!contains(…, '-')` is what separates v1.9.1 from v1.9.1-rc.2.
if: >-
${{ vars.MSSTORE_PRODUCT_ID != ''
&& ((github.event_name == 'push' && github.ref_type == 'tag' && !contains(github.ref_name, '-'))
|| (github.event_name == 'workflow_dispatch' && github.event.inputs.release_tag != '' && !contains(github.event.inputs.release_tag, '-'))) }}
steps:
# Same all-or-nothing gate as the macOS signing job: a half-configured
# publisher is a misnamed secret, and the quiet failure mode — shipping
# nothing while the release looks complete — is the one worth making loud.
- name: Resolve Store credentials
id: store
shell: bash
env:
AZURE_AD_TENANT_ID: ${{ secrets.AZURE_AD_TENANT_ID }}
AZURE_AD_APPLICATION_CLIENT_ID: ${{ secrets.AZURE_AD_APPLICATION_CLIENT_ID }}
AZURE_AD_APPLICATION_SECRET: ${{ secrets.AZURE_AD_APPLICATION_SECRET }}
SELLER_ID: ${{ secrets.SELLER_ID }}
run: |
required=(AZURE_AD_TENANT_ID AZURE_AD_APPLICATION_CLIENT_ID
AZURE_AD_APPLICATION_SECRET SELLER_ID)
missing=()
for name in "${required[@]}"; do
[[ -n "${!name}" ]] || missing+=("$name")
done
if [[ ${#missing[@]} -eq 0 ]]; then
echo "enabled=true" >> "$GITHUB_OUTPUT"
echo "Store credentials present — the package will be submitted."
elif [[ ${#missing[@]} -eq ${#required[@]} ]]; then
echo "enabled=false" >> "$GITHUB_OUTPUT"
echo "::warning::No Store credentials configured; upload the appx by hand in Partner Center."
else
echo "enabled=false" >> "$GITHUB_OUTPUT"
echo "::error::Store publishing is partially configured; missing: ${missing[*]}"
exit 1
fi