Repository navigation
1118 lines (1063 loc) · 52.3 KB
/
Copy pathrelease.yml
File metadata and controls
1118 lines (1063 loc) · 52.3 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: Release
# Builds every release asset and attaches it to the DRAFT release for a tag.
#
# publish-release.yml calls this while the release is still a draft, waits
# for it, checks the assets, and only then makes the release public. It
# used to be the other way round: the release went public first and was
# built afterwards. v0.3.4 had no Windows installer and no latest.json for
# 1 h 38 min after it was published. In that window install.ps1 and the
# in-app updater found nothing, and the publish jobs that needed the
# Windows files failed and had to be run again. v0.3.3's Windows files were
# 1 h 06 min late.
#
# There is deliberately no `release: published` trigger any more. Promoting
# the draft is the last step, after this has finished, and a build started
# by it would replace a public release's assets with new builds, whose
# digests no longer match what the package managers just published.
#
# `workflow_dispatch` stays, for rebuilding a draft's assets by hand. The
# `guard` job refuses a release that is already public, for the same
# reason.
on:
workflow_call:
inputs:
tag:
description: 'Tag of the draft release to build and upload assets for'
required: true
type: string
workflow_dispatch:
inputs:
tag:
description: 'Tag of the DRAFT release to build and upload assets for (e.g. v0.1.0)'
required: true
type: string
# Keyed on the tag. Two runs uploading assets for one tag would race each
# other, and the second would replace half of the first's files.
#
# `cancel-in-progress: false` deliberately, unlike ci.yml/site.yml. A cancelled
# matrix leg leaves the draft with some assets uploaded and some missing.
# Nothing public depends on a draft, but a queued duplicate costs only time:
# it rebuilds and re-uploads every file with --clobber.
concurrency:
group: release-${{ inputs.tag }}
cancel-in-progress: false
# `inputs` carries the tag on both triggers.
env:
TAG: ${{ inputs.tag }}
jobs:
# Every job below needs this one, so nothing builds, signs or uploads
# unless the tag is well formed, the run is on the default branch, and the
# release is still a draft.
#
# The default branch, because the `release-signing` environment the
# signing jobs use deploys from `main` only (its settings, see
# docs/RELEASE.md). A run from anywhere else would build and then be
# refused at its first signing job.
guard:
name: Check the release is a draft
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
# Write, for a read. GitHub shows draft releases only to a token that
# can push, so with `contents: read` the draft would look missing.
contents: write
steps:
- name: Refuse a malformed tag, another branch, or a public release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_REPO: ${{ github.repository }}
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
run: |
set -euo pipefail
# The same shape lib.sh's validate_tag accepts. The tag reaches
# checkout refs, asset URLs and `gh` arguments below.
if [[ ! "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.]+)?$ ]]; then
echo "::error::refusing to build malformed tag '$TAG'"
exit 1
fi
if [[ "$GITHUB_REF" != "refs/heads/${DEFAULT_BRANCH}" ]]; then
echo "::error::run this from ${DEFAULT_BRANCH}, not ${GITHUB_REF}. The signing jobs may only deploy from ${DEFAULT_BRANCH}."
exit 1
fi
if ! is_draft=$(gh release view "$TAG" --json isDraft --jq '.isDraft' 2>/dev/null); then
echo "::error::no release found for ${TAG}. release-drafter makes the draft; run it or draft one by hand."
exit 1
fi
if [[ "$is_draft" != "true" ]]; then
echo "::error::${TAG} is already public. Rebuilding would change every digest the package managers have published."
exit 1
fi
echo "${TAG} is a draft; building its assets."
# Both jobs below set `timeout-minutes`; GitHub's default is 360. A stalled
# `apt-get` once held CI jobs at that default for six hours (see ci.yml),
# and these run the same system-dependency installs.
#
# A timeout here is safe to recover from: the job uploads to a release that
# is still a draft, so a missing asset is re-uploaded by re-running the job,
# and publish-release.yml will not make the release public until every
# asset is there.
#
# Sized against measured runs: the slowest observed job is the Windows GUI
# installer at ~7 minutes, so 60 leaves ample room for a cold cache,
# cross-compilation and bundling while still bounding a genuine hang.
release:
name: Build and Release
needs: guard
runs-on: ${{ matrix.os }}
timeout-minutes: 60
permissions:
contents: write
# id-token: write is required for sigstore keyless signing. Cosign
# exchanges the OIDC token issued by GitHub Actions for a short-lived
# certificate from Fulcio bound to this workflow + commit + tag. No
# long-lived secrets to manage.
id-token: write
strategy:
fail-fast: false
matrix:
include:
- os: ubuntu-latest
target: x86_64-unknown-linux-gnu
binary_name: netscli
asset_name: netscli-linux-x86_64
build_flags: ""
pcap: false
- os: ubuntu-latest
target: x86_64-unknown-linux-gnu
binary_name: netscli
asset_name: netscli-linux-x86_64-pcap
build_flags: --features pcap
pcap: true
- os: ubuntu-latest
target: x86_64-unknown-linux-musl
binary_name: netscli
asset_name: netscli-linux-x86_64-musl
build_flags: ""
pcap: false
- os: ubuntu-24.04-arm
target: aarch64-unknown-linux-gnu
binary_name: netscli
asset_name: netscli-linux-aarch64
build_flags: ""
pcap: false
- os: ubuntu-24.04-arm
target: aarch64-unknown-linux-gnu
binary_name: netscli
asset_name: netscli-linux-aarch64-pcap
build_flags: --features pcap
pcap: true
- os: windows-latest
target: x86_64-pc-windows-msvc
binary_name: netscli.exe
asset_name: netscli-windows-x86_64.exe
build_flags: ""
pcap: false
- os: windows-latest
target: x86_64-pc-windows-msvc
binary_name: netscli.exe
asset_name: netscli-windows-x86_64-pcap.exe
build_flags: --features pcap
pcap: true
- os: macos-latest
target: x86_64-apple-darwin
binary_name: netscli
asset_name: netscli-macos-x86_64
build_flags: ""
pcap: false
- os: macos-latest
target: x86_64-apple-darwin
binary_name: netscli
asset_name: netscli-macos-x86_64-pcap
build_flags: --features pcap
pcap: true
- os: macos-latest
target: aarch64-apple-darwin
binary_name: netscli
asset_name: netscli-macos-aarch64
build_flags: ""
pcap: false
- os: macos-latest
target: aarch64-apple-darwin
binary_name: netscli
asset_name: netscli-macos-aarch64-pcap
build_flags: --features pcap
pcap: true
steps:
# The tag, explicitly. The run itself is on the default branch.
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
ref: ${{ env.TAG }}
- name: Install Rust
uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable
with:
toolchain: 1.96.0
targets: ${{ matrix.target }}
- name: Install libpcap (Linux, pcap)
if: runner.os == 'Linux' && matrix.pcap
run: sudo apt-get update || true; sudo apt-get install -y libpcap-dev pkg-config
- name: Install musl toolchain (Linux)
if: matrix.target == 'x86_64-unknown-linux-musl'
run: sudo apt-get update || true; sudo apt-get install -y musl-tools
- name: Install Npcap SDK (Windows, pcap)
if: runner.os == 'Windows' && matrix.pcap
shell: pwsh
run: |
$sdkUrl = "https://npcap.com/dist/npcap-sdk-1.13.zip"
# Pinned digest for npcap-sdk-1.13.zip (364,431 bytes). npcap.com
# is a third-party host outside our control, and whatever this
# step unpacks gets linked into a binary we then cosign-sign — so
# the signature would attest to a build that trusted an
# unverified download. Verify before extracting.
# Re-pin deliberately when bumping the SDK version.
$sdkSha256 = "dad1f2bf1b02b787be08ca4862f99e39a876c1f274bac4ac0cedc9bbc58f94fd"
$zipPath = Join-Path $env:RUNNER_TEMP "npcap-sdk.zip"
$destPath = Join-Path $env:RUNNER_TEMP "npcap-sdk"
Invoke-WebRequest -Uri $sdkUrl -OutFile $zipPath
$actual = (Get-FileHash -Algorithm SHA256 -Path $zipPath).Hash.ToLowerInvariant()
if ($actual -ne $sdkSha256) {
Write-Error "Npcap SDK checksum mismatch. Expected $sdkSha256, got $actual"
exit 1
}
Write-Host "Npcap SDK checksum verified."
Expand-Archive -Path $zipPath -DestinationPath $destPath -Force
# The 1.13 zip has Lib/ and Include/ at the archive root, no
# wrapping npcap-sdk/ folder. Point LIB/INCLUDE at $destPath
# directly.
$libPath = Join-Path $destPath "Lib\x64"
$includePath = Join-Path $destPath "Include"
"LIB=$libPath;$env:LIB" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
"INCLUDE=$includePath;$env:INCLUDE" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
- name: Build CLI
run: cargo build --locked --release -p netscli --target ${{ matrix.target }} ${{ matrix.build_flags }}
- name: Prepare release asset (rename)
shell: bash
run: cp target/${{ matrix.target }}/release/${{ matrix.binary_name }} target/${{ matrix.target }}/release/${{ matrix.asset_name }}
- name: Generate SHA256 (Unix)
if: runner.os != 'Windows'
shell: bash
run: |
set -euo pipefail
if command -v sha256sum >/dev/null 2>&1; then
sha256sum "${{ matrix.asset_name }}" > "${{ matrix.asset_name }}.sha256"
else
shasum -a 256 "${{ matrix.asset_name }}" > "${{ matrix.asset_name }}.sha256"
fi
working-directory: target/${{ matrix.target }}/release
# Windows stops here and hands off to `sign-windows`.
#
# Everything below -- the digest, the sigstore signature, the release
# upload -- has to happen AFTER Authenticode signing, because signing
# rewrites the file. A digest taken here would describe bytes nobody
# ever downloads. So the Windows legs upload the unsigned binary as a
# workflow artifact instead, and `sign-windows` does the rest.
#
# It also means an unsigned .exe is never a release asset, not even on
# the draft. A draft can still be published by hand from the web UI,
# and whatever it carries at that moment goes public with it.
- name: Hand the unsigned binary to the signing job
if: runner.os == 'Windows'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: unsigned-${{ matrix.asset_name }}
path: target/${{ matrix.target }}/release/${{ matrix.asset_name }}
retention-days: 1
if-no-files-found: error
# Sigstore keyless signing via the GitHub Actions OIDC token. Produces
# a .sig and .pem alongside each binary; verifiable by anyone with:
# cosign verify-blob --signature <asset>.sig --certificate <asset>.pem \
# --certificate-identity-regexp '^https://github\.com/fstubner/netscli/\.github/workflows/release\.yml@refs/(heads/main|tags/v[0-9.]+)$' \
# --certificate-oidc-issuer https://token.actions.githubusercontent.com \
# <asset>
# The identity is this file on main (or, before v0.3.1, on the release
# tag), also when publish-release.yml calls it: Fulcio names the called
# workflow, not the caller. A looser `release\.yml@.*` would accept a
# signature made by this file on any branch anyone with write access
# can push.
- name: Install cosign
if: runner.os != 'Windows'
uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
with:
# Pinned, not left to the action's default.
#
# The action version and the cosign version are different things,
# and that gap is what broke the v0.3.1 release. Dependabot moved
# this action from `@v3` to `@v4.1.2` in #166; the action's default
# `cosign-release` moved with it, from v2.5.2 to v3.0.6. Nothing in
# the diff mentioned cosign, and no release was cut between the
# bump and v0.3.1, so the first time the new binary ever ran was
# the release itself -- where all 15 matrix legs failed at signing.
cosign-release: v3.0.6
- name: Sign release asset (sigstore keyless)
if: runner.os != 'Windows'
shell: bash
env:
COSIGN_EXPERIMENTAL: "1"
run: |
cd target/${{ matrix.target }}/release
# Two flags, both load-bearing, and both defaulting to TRUE in
# cosign v3.
#
# `--use-signing-config` is the one that actually broke v0.3.1.
# With a signing config in play, cosign refuses to sign at all
# unless a bundle is requested. From signcommon/common.go at
# v3.0.6, lines 660-661:
#
# if (useSigningConfig || signingConfigPath != "") &&
# !newBundleFormat && bundlePath == "" {
# return fmt.Errorf("must provide --new-bundle-format or
# --bundle ... with --signing-config or --use-signing-config")
#
# An earlier fix set only `--new-bundle-format=false`. That
# satisfies one half of that condition and leaves the other
# standing, so every leg that reached this step failed with
# exactly the error above. Turning the signing config off is what
# disarms the gate, and it is the only gate that applies: the
# neighbouring checks at :649 and :656 both require a signing
# config as well.
#
# `--new-bundle-format=false` is still needed on its own account.
# With it true, cosign writes a bundle to --bundle -- unset here
# -- instead of the detached files.
#
# With both off, sign_blob.go closes its `if ko.BundlePath != ""`
# block (168-229) before the `outputSignature` (231) and
# `outputCertificate` (250) writes, so the sidecars are produced.
#
# The sidecars are not incidental. `.sig` + `.pem` are what
# packaging/README.md and the published install guide tell people
# to pass to `cosign verify-blob`, and v0.2.6 shipped 64 assets as
# .sha256/.sig/.pem triples. Moving to bundles is a real option,
# but it rewrites those instructions and belongs in its own change.
cosign sign-blob --yes \
--use-signing-config=false \
--new-bundle-format=false \
--output-signature "${{ matrix.asset_name }}.sig" \
--output-certificate "${{ matrix.asset_name }}.pem" \
"${{ matrix.asset_name }}"
# `gh release upload`, not softprops/action-gh-release. That action
# PATCHes the release on every upload, rewriting its name, body and
# draft flag from what it read a moment earlier, and with `draft`
# unset it publishes the draft once its files are up. Either is wrong
# for a release that must stay a draft until publish-release.yml has
# checked every asset. `gh` finds the draft by its tag, uploads, and
# touches nothing else. `--clobber` so a re-run replaces its own files.
- name: Upload Release Asset
if: runner.os != 'Windows'
shell: bash
working-directory: target/${{ matrix.target }}/release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_REPO: ${{ github.repository }}
ASSET: ${{ matrix.asset_name }}
run: gh release upload "$TAG" "$ASSET" "$ASSET.sha256" "$ASSET.sig" "$ASSET.pem" --clobber
# ─── Desktop GUI installers ───────────────────────────────────────────
# Builds the Tauri 2 desktop app on each native runner. .msi/.dmg/.deb/
# .AppImage outputs from `tauri.conf.json:bundle.targets = "all"` get
# renamed to `netscli-gui-{os}-{arch}.{ext}`, sigstore-signed, and
# uploaded alongside the CLI binaries on the same release.
#
# GUI installers intentionally do not pass `--features pcap`: packet capture
# requires libpcap/Npcap at runtime, and NetsCLI does not redistribute Npcap
# inside the Windows desktop installer.
#
# macOS .dmg ships ad-hoc signed and not notarized: Gatekeeper blocks the
# first launch until the user clicks Open Anyway in System Settings →
# Privacy & Security (macOS 15 removed right-click → Open). Notarization
# needs a paid Apple Developer membership. Linux ARM64 + Windows ARM64 GUI builds
# are out of matrix scope for v0.2.1 to keep CI budget reasonable.
gui:
name: Build GUI installer
needs: guard
runs-on: ${{ matrix.os }}
timeout-minutes: 60
# For TAURI_SIGNING_PRIVATE_KEY, the in-app updater's signing key, and on
# Windows the Certum credentials. Same environment as sign-windows. It
# deploys from `main` only, which the `guard` job checks before
# anything is built.
environment: release-signing
permissions:
contents: write
id-token: write
strategy:
fail-fast: false
matrix:
include:
- os: ubuntu-latest
target: x86_64-unknown-linux-gnu
asset_prefix: netscli-gui-linux-x86_64
bundle_dir: bundle
bundle_globs: "deb/*.deb appimage/*.AppImage"
- os: macos-latest
target: aarch64-apple-darwin
asset_prefix: netscli-gui-macos-aarch64
bundle_dir: bundle
bundle_globs: "dmg/*.dmg"
- os: macos-latest
target: x86_64-apple-darwin
asset_prefix: netscli-gui-macos-x86_64
bundle_dir: bundle
bundle_globs: "dmg/*.dmg"
- os: windows-latest
target: x86_64-pc-windows-msvc
asset_prefix: netscli-gui-windows-x86_64
bundle_dir: bundle
bundle_globs: "msi/*.msi"
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
ref: ${{ env.TAG }}
- name: Install Rust
uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable
with:
toolchain: 1.96.0
targets: ${{ matrix.target }}
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version-file: .nvmrc
cache: npm
cache-dependency-path: apps/netscli-gui/package-lock.json
# Tauri 2 needs the same GTK/WebKit stack the lint job already
# installs in ci.yml. Mirror it here so the bundle step can link.
- name: Install Linux GUI dependencies
if: runner.os == 'Linux'
run: |
# `|| true` so a broken third-party apt index cannot fail this job;
# the install below still fails hard. See the first occurrence in
# ci.yml for the full reasoning.
sudo apt-get update || true
sudo apt-get install -y \
libwebkit2gtk-4.1-dev libgtk-3-dev libayatana-appindicator3-dev \
librsvg2-dev libsoup-3.0-dev libjavascriptcoregtk-4.1-dev
# `--ignore-scripts`: this job later hands the updater key and, on
# Windows, the Certum credentials to tools from node_modules, so no
# package gets to run code while it is being installed. The lockfile
# has two install scripts, edgedriver (the end-to-end suite's driver
# download) and fsevents (an optional macOS file watcher), and the
# build needs neither.
- name: npm ci
run: npm ci --ignore-scripts
working-directory: apps/netscli-gui
# The trailing `-- --locked` is forwarded by the Tauri CLI to cargo, so
# the Rust half of the installer gets the same lockfile enforcement the
# CLI job above already has. Without it a signed .msi/.dmg could be
# built against resolved-at-CI-time dependency versions that no
# committed Cargo.lock ever pinned. `npm ci` covers the frontend half.
# The public key is compiled into the app, and every copy of it will
# only ever accept updates signed by the matching private key. Shipping
# the placeholder would build an app that can check for updates but
# never install one -- and that app could not be fixed by an update.
- name: Refuse to build with the placeholder updater key
shell: bash
run: |
if grep -q '@@UPDATER_PUBKEY@@' apps/netscli-gui/src-tauri/tauri.conf.json; then
echo "::error::plugins.updater.pubkey in tauri.conf.json is still the placeholder. Set it to the public key from 'tauri signer generate'."
exit 1
fi
# Windows only: sign the app's .exe *inside* the MSI, during the build.
#
# sign-windows signs the MSI afterwards, but the executable WiX packs
# into it can only be signed before packing -- so the bundler does it,
# through bundle.windows.signCommand, calling a wrapper around ssign.
# Until this, v0.3.3 included, the installer was signed and the program
# it installs was not; SmartScreen and antivirus judge the program.
#
# The two scripts come from the default branch rather than from the
# tag this job has checked out, for the reason publish.yml and
# sign-windows already give: release tooling at the tag cannot be
# fixed for the release that needs the fix.
#
# The config is written here rather than committed because signCommand
# needs absolute paths, which only exist on the runner. It also limits
# the Windows bundle to MSI: NSIS is not released, and every bundle
# built is one more round-trip to Certum's signing service.
- name: Prepare Authenticode signing inside the build (Windows)
if: runner.os == 'Windows'
shell: pwsh
env:
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
run: |
$tools = Join-Path $env:RUNNER_TEMP 'sign-tools'
New-Item -ItemType Directory -Force $tools | Out-Null
git fetch --quiet --depth 1 origin $env:DEFAULT_BRANCH
foreach ($name in 'sign-windows-pe.ps1', 'check-msi-signed.ps1') {
git show "FETCH_HEAD:scripts/release/$name" | Set-Content -Encoding utf8 (Join-Path $tools $name)
if ($LASTEXITCODE -ne 0) { Write-Error "could not read scripts/release/$name from $env:DEFAULT_BRANCH"; exit 1 }
}
# Pinned by version and digest, as sign-windows.sh pins the Linux build.
$zip = Join-Path $env:RUNNER_TEMP 'ssign.zip'
Invoke-WebRequest -Uri 'https://github.com/Le-Syl21/ssign/releases/download/v0.1.6/ssign-windows-x86_64.zip' -OutFile $zip
$want = '8a2a16738f0e1f368b4640cc6cb53fac8072a38df9334a72a96e071169b730f0'
$got = (Get-FileHash -Algorithm SHA256 $zip).Hash.ToLowerInvariant()
if ($got -ne $want) { Write-Error "ssign-windows-x86_64.zip digest $got, expected $want"; exit 1 }
Expand-Archive $zip -DestinationPath $tools -Force
$config = @{
bundle = @{
targets = @('msi')
windows = @{
signCommand = @{
cmd = 'pwsh'
args = @('-NoProfile', '-NonInteractive', '-File', (Join-Path $tools 'sign-windows-pe.ps1'), '%1')
}
}
}
} | ConvertTo-Json -Depth 8
$configPath = Join-Path $env:RUNNER_TEMP 'sign.conf.json'
Set-Content -Encoding utf8 -Path $configPath -Value $config
Get-Content $configPath
"NETSCLI_SSIGN=$(Join-Path $tools 'ssign.exe')" >> $env:GITHUB_ENV
"NETSCLI_SIGN_CONFIG=$configPath" >> $env:GITHUB_ENV
"NETSCLI_SIGN_TOOLS=$tools" >> $env:GITHUB_ENV
# createUpdaterArtifacts is switched on by tauri.release.conf.json
# rather than in tauri.conf.json, so a local `tauri build` still works
# without the private key. It makes the bundler emit the macOS
# .app.tar.gz the updater installs from, plus a signature for every
# update file. A file rather than inline JSON because this also runs
# in PowerShell, where quotes inside an argument do not survive the
# trip through `npm run`.
#
# Only the macOS signatures survive as-is. The AppImage is repacked
# below and the MSI is Authenticode-signed in sign-windows, and both
# change the bytes, so those two are signed again after that.
#
# Two steps, which is all `tauri build` is: compile, then bundle
# (crates/tauri-cli/src/build.rs at tauri-cli-v2.12.1 calls the same
# bundle function `tauri bundle` does). Split, so that the step which
# compiles every Rust dependency's build scripts and proc macros and
# runs `beforeBuildCommand` (tsc and Vite) has no secret in its
# environment. The keys are needed only by the bundler, which signs
# the update files and, through signCommand, the Windows executable.
#
# Both steps take the same --config files, so the app compiled here
# embeds the same configuration a single `tauri build` would.
#
# A step boundary is not a sandbox: code run by the first step could
# leave something behind for the second. What it removes is the
# simplest case, a dependency reading credentials out of its own
# environment while it builds.
- name: tauri build (compile only, no secrets)
run: >-
npm run tauri -- build --no-bundle --target ${{ matrix.target }}
--config src-tauri/tauri.release.conf.json
${{ runner.os == 'Windows' && format('--config {0}', env.NETSCLI_SIGN_CONFIG) || '' }}
-- --locked
working-directory: apps/netscli-gui
- name: tauri bundle (installers and their signatures)
run: >-
npm run tauri -- bundle --target ${{ matrix.target }}
--config src-tauri/tauri.release.conf.json
${{ runner.os == 'Windows' && format('--config {0}', env.NETSCLI_SIGN_CONFIG) || '' }}
working-directory: apps/netscli-gui
env:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
# Read by ssign inside the signCommand wrapper; Windows leg only.
CERTUM_EMAIL: ${{ runner.os == 'Windows' && secrets.CERTUM_EMAIL || '' }}
CERTUM_OTP: ${{ runner.os == 'Windows' && secrets.CERTUM_OTP || '' }}
# The signCommand wrapper already verifies each file it signs. This is
# the check at the level of what ships: unpack the MSI and look at what
# is actually inside it. A wrapper that was never called -- a config
# that did not merge, a Tauri change to when it signs -- passes every
# per-file check and fails this one.
- name: Check the app inside the MSI is signed (Windows)
if: runner.os == 'Windows'
shell: pwsh
env:
TARGET: ${{ matrix.target }}
run: |
$msis = @(Get-ChildItem "target/$env:TARGET/release/bundle/msi/*.msi")
if ($msis.Count -ne 1) { Write-Error "expected one .msi, found $($msis.Count)"; exit 1 }
pwsh -NoProfile -NonInteractive -File (Join-Path $env:NETSCLI_SIGN_TOOLS 'check-msi-signed.ps1') $msis[0].FullName
exit $LASTEXITCODE
# The same check for macOS, where the failure was real: 0.3.3 shipped a
# NetsCLI.app whose only signature was the one Apple's linker puts on
# every arm64 executable. That signature says the bundle's resources
# are sealed, and nothing sealed them, so `codesign --verify` fails
# with "code has no resources but signature indicates they must be
# present" and Gatekeeper rejects the app as broken rather than as
# merely unnotarized (checked on a GitHub macOS runner against the
# released .dmg, 2026-09-26). `bundle.macOS.signingIdentity: "-"`
# makes Tauri ad-hoc sign the whole bundle, which seals it. This
# mounts the .dmg that ships and verifies the app inside it, so a
# config that stops applying fails the release instead of a user.
- name: Check the app inside the DMG is sealed (macOS)
if: runner.os == 'macOS'
shell: bash
env:
TARGET: ${{ matrix.target }}
run: |
set -euo pipefail
dmgs=(target/"${TARGET}"/release/bundle/dmg/*.dmg)
if [ "${#dmgs[@]}" -ne 1 ] || [ ! -f "${dmgs[0]}" ]; then
echo "::error::expected one .dmg under target/${TARGET}/release/bundle/dmg/, found: ${dmgs[*]}"
exit 1
fi
mnt="$(mktemp -d)"
hdiutil attach -nobrowse -readonly "${dmgs[0]}" -mountpoint "$mnt" >/dev/null
trap 'hdiutil detach "$mnt" >/dev/null' EXIT
codesign --verify --deep --strict --verbose=2 "$mnt/NetsCLI.app"
# linuxdeploy bundles a copy of the wayland/xcb client stack and puts
# it ahead of the host's on LD_LIBRARY_PATH, which aborts the app on
# any host whose Mesa is newer than the runner's (#378). Nothing in
# Tauri's bundler can switch that off, so the AppImage is corrected
# here, before it is renamed, hashed or signed.
#
# Failing when the glob matches nothing is the point of the check
# below. Without it a change to Tauri's output path would turn this
# step into a no-op that still reports success, and the first sign of
# it would be a bug report against a shipped release.
- name: Unbundle host display libraries from the .AppImage
if: runner.os == 'Linux'
shell: bash
env:
TARGET: ${{ matrix.target }}
run: |
set -euo pipefail
shopt -s nullglob
images=(target/"${TARGET}"/release/bundle/appimage/*.AppImage)
if [ ${#images[@]} -eq 0 ]; then
echo "::error::no .AppImage found to fix under target/${TARGET}/release/bundle/appimage/" >&2
exit 1
fi
for image in "${images[@]}"; do
echo "--- ${image} ---"
bash scripts/release/fix-appimage-host-libs.sh "${image}"
done
# Tauri puts bundles under apps/netscli-gui/src-tauri/target/<TARGET>/
# release/bundle/{deb,appimage,dmg,msi}/. Rename each artifact to a
# predictable name then sigstore-sign it.
- name: Collect, rename, and sign GUI artifacts
shell: bash
env:
ASSET_PREFIX: ${{ matrix.asset_prefix }}
BUNDLE_GLOBS: ${{ matrix.bundle_globs }}
TARGET: ${{ matrix.target }}
run: |
set -euo pipefail
# Cargo workspaces use the workspace-root `target/` regardless
# of the cwd cargo was invoked from. Tauri puts its bundles in
# target/<TARGET>/release/bundle/{deb,appimage,dmg,msi}/.
BUNDLE_ROOT="target/${TARGET}/release/bundle"
OUT_DIR="${RUNNER_TEMP}/gui-out"
mkdir -p "${OUT_DIR}"
# Walk each glob, copy with the canonical asset name preserving
# the file extension. Tauri produces e.g. NetsCLI_0.2.1_amd64.deb;
# we want netscli-gui-linux-x86_64.deb.
for glob in ${BUNDLE_GLOBS}; do
for src in ${BUNDLE_ROOT}/${glob}; do
[ -e "${src}" ] || continue
ext="${src##*.}"
dst="${OUT_DIR}/${ASSET_PREFIX}.${ext}"
cp "${src}" "${dst}"
echo "Collected ${src} -> ${dst}"
done
done
ls -la "${OUT_DIR}"
# The update file and its signature, for latest.json.
#
# macOS updates install from a .app.tar.gz, a separate file from the
# .dmg people download; it joins the release assets here so the digest
# and sigstore steps below cover it like everything else. Its bundler
# signature is final, since nothing touches the tarball afterwards,
# and the bundler binds it to the app's version.
#
# Linux updates install the AppImage itself, which the host-library
# fix above has already rewritten, so it is signed again now, on the
# bytes that ship. The Windows MSI is handled in sign-windows.
#
# `--app-version` binds the signature to this version. The app sets
# requireSignedVersion and refuses an update whose signature names no
# version, which 0.3.4's AppImage signature did not, because this
# re-sign did not pass one. updater-manifest.mjs now refuses such a
# signature before latest.json is built.
#
# The signatures go to a workflow artifact, never to the release: the
# sigstore sidecars below are also named `<asset>.sig`, and one would
# overwrite the other.
- name: Sign the update file for the in-app updater
if: runner.os != 'Windows'
shell: bash
env:
ASSET_PREFIX: ${{ matrix.asset_prefix }}
TARGET: ${{ matrix.target }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
run: |
set -euo pipefail
OUT_DIR="${RUNNER_TEMP}/gui-out"
SIG_DIR="${RUNNER_TEMP}/updater-sigs"
mkdir -p "${SIG_DIR}"
shopt -s nullglob
if [ "${RUNNER_OS}" = "macOS" ]; then
tarballs=(target/"${TARGET}"/release/bundle/macos/*.app.tar.gz)
if [ ${#tarballs[@]} -ne 1 ] || [ ! -f "${tarballs[0]}.sig" ]; then
echo "::error::expected one .app.tar.gz with a .sig under target/${TARGET}/release/bundle/macos/, found ${#tarballs[@]}"
exit 1
fi
cp "${tarballs[0]}" "${OUT_DIR}/${ASSET_PREFIX}.app.tar.gz"
cp "${tarballs[0]}.sig" "${SIG_DIR}/${ASSET_PREFIX}.app.tar.gz.sig"
else
image="${OUT_DIR}/${ASSET_PREFIX}.AppImage"
[ -f "${image}" ] || { echo "::error::${image} missing"; exit 1; }
(cd apps/netscli-gui && npm run tauri -- signer sign --app-version "${TAG#v}" "${image}")
mv "${image}.sig" "${SIG_DIR}/${ASSET_PREFIX}.AppImage.sig"
fi
ls -la "${SIG_DIR}"
- name: Hand the updater signature to latest.json
if: runner.os != 'Windows'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: updater-sig-${{ matrix.asset_prefix }}
path: ${{ runner.temp }}/updater-sigs/*
retention-days: 1
if-no-files-found: error
# The Windows leg stops here, for the same reason the CLI job's does:
# Authenticode signing rewrites the .msi, so a digest or a sigstore
# signature taken now would describe bytes that never ship. The
# `sign-windows` job picks this up and finishes it.
- name: Hand the unsigned installer to the signing job
if: runner.os == 'Windows'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: unsigned-${{ matrix.asset_prefix }}
path: ${{ runner.temp }}/gui-out/${{ matrix.asset_prefix }}.*
retention-days: 1
if-no-files-found: error
- name: Generate SHA256
if: runner.os != 'Windows'
shell: bash
env:
ASSET_PREFIX: ${{ matrix.asset_prefix }}
run: |
set -euo pipefail
OUT_DIR="${RUNNER_TEMP}/gui-out"
cd "${OUT_DIR}"
for f in ${ASSET_PREFIX}.*; do
[ -f "$f" ] || continue
if command -v sha256sum >/dev/null 2>&1; then
sha256sum "$f" > "${f}.sha256"
else
shasum -a 256 "$f" > "${f}.sha256"
fi
done
ls -la
- name: Install cosign
if: runner.os != 'Windows'
uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
with:
# Pinned for the same reason as the CLI job above: the action
# version and the cosign version move independently, and the
# action's default carried us from cosign v2.5.2 to v3.0.6 without
# saying so. Keep both installers on the same pin.
cosign-release: v3.0.6
- name: Sign GUI artifacts (sigstore keyless)
if: runner.os != 'Windows'
shell: bash
env:
COSIGN_EXPERIMENTAL: "1"
ASSET_PREFIX: ${{ matrix.asset_prefix }}
run: |
set -euo pipefail
OUT_DIR="${RUNNER_TEMP}/gui-out"
cd "${OUT_DIR}"
for f in ${ASSET_PREFIX}.*; do
# Skip the sha256 + any pre-existing sig/pem on re-runs.
case "$f" in
*.sha256|*.sig|*.pem) continue ;;
esac
# Both flags for the same reasons as the CLI job above -- see
# that step for the full explanation. Each defaults to true in
# cosign v3: the signing config is what refuses to sign at all
# without a bundle, and the bundle format is what would divert
# output away from the --output-* files.
#
# The skip-list above stays keyed on .sig/.pem because that is
# still what this produces. If this ever moves to bundles, that
# list needs a .bundle arm or a re-run will sign its own output.
cosign sign-blob --yes \
--use-signing-config=false \
--new-bundle-format=false \
--output-signature "${f}.sig" \
--output-certificate "${f}.pem" \
"$f"
done
# `gh`, for the reasons given at the CLI job's upload.
- name: Upload Release Asset
if: runner.os != 'Windows'
shell: bash
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_REPO: ${{ github.repository }}
ASSET_PREFIX: ${{ matrix.asset_prefix }}
run: gh release upload "$TAG" "${RUNNER_TEMP}/gui-out/${ASSET_PREFIX}".* --clobber
# ─── Authenticode signing for the Windows artifacts ───────────────────
# Every Windows artifact is signed here, and nowhere else. The two matrix
# jobs above hand their Windows outputs over as workflow artifacts and do
# nothing further with them, so this job is the only path by which a .exe
# or .msi becomes a release asset.
#
# That is deliberate rather than tidy. Signing rewrites the file, so the
# digest, the sigstore signature and the upload all have to come after it
# — and a release that carried some signed artifacts and some unsigned
# ones would be worse than one that carried none, because nothing would
# say which was which.
#
# It runs on Linux despite signing Windows binaries. The key is a Certum
# cloud certificate reached over HTTPS, not a file `signtool` could load,
# so the runner's OS stops mattering. See scripts/release/sign-windows.sh.
#
# `environment: release-signing` scopes the credentials to this job. The
# environment deploys from `main` only, and has no required reviewers. A reviewer would hold the release at this job,
# but on a draft that holds nothing public, so it is possible now that the
# release is built before it is published. Not added, because a stolen
# maintainer token can approve its own deployment through the API.
#
# It waits for every build leg but no longer needs them all to succeed.
# `!cancelled()` lets it run when a Linux or macOS leg failed. Before, one
# failed leg anywhere skipped Windows signing, the step that broke the
# last two releases, so its own problems only showed up on the re-run.
# Now one attempt reports both, and everything that did build lands on
# the draft. The Windows legs are still required: "Check what arrived"
# fails unless all three Windows files are here. `guard` must have
# passed, or nothing was built and there is nothing to sign.
sign-windows:
name: Sign Windows artifacts
needs: [guard, release, gui]
if: ${{ !cancelled() && needs.guard.result == 'success' }}
runs-on: ubuntu-latest
timeout-minutes: 30
environment: release-signing
permissions:
contents: write
id-token: write
steps:
# The default branch, NOT the tag, and for the same reason
# publish-release.yml checks out the default branch: the only thing
# this checkout provides is the signing script, which is release
# tooling rather than part of what is being released. The artifacts
# come from the two build jobs, already compiled from the tag.
#
# Checking out the tag makes a fix to the script unreachable by the
# release that needs it. v0.3.3 hit exactly that: the verification
# step had a bug, the fix merged to main minutes later, and re-running
# the job would still have run the broken copy frozen at the tag. The
# only ways out would have been moving a published tag or shipping
# without Windows binaries.
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
ref: ${{ github.event.repository.default_branch }}
# libengine-pkcs11-openssl is not optional and not obvious.
# osslsigncode's `-pkcs11module` does not load the module directly --
# it goes through OpenSSL's pkcs11 engine, which ships in that
# separate package and is not installed on ubuntu-latest. Without it
# the MSI step fails at `Failed to find and load 'pkcs11' engine`,
# having never reached Certum, and the message names the engine rather
# than the missing package. Reproduced in an ubuntu:24.04 container;
# with the package present the same command reports `Engine "pkcs11"
# set.` and gets as far as authentication.
- name: Install osslsigncode and the pkcs11 engine
run: |
sudo apt-get update || true
sudo apt-get install -y osslsigncode libengine-pkcs11-openssl
# `merge-multiple` flattens the three `unsigned-*` artifacts into one
# directory, which is what the signing script takes.
- name: Collect the unsigned Windows artifacts
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
pattern: unsigned-*
path: ${{ runner.temp }}/win
merge-multiple: true
# Fails when nothing arrived. Without this the signing script would
# also fail, but one job up: a matrix leg that died leaves this job
# with an empty directory, and "signed 0 files" must never read as
# success.
- name: Check what arrived
shell: bash
run: |
set -euo pipefail
cd "${RUNNER_TEMP}/win"
ls -la
count=$(find . -maxdepth 1 \( -name '*.exe' -o -name '*.msi' \) | wc -l)
if [[ "$count" -ne 3 ]]; then
echo "::error::expected 3 Windows artifacts (2 .exe + 1 .msi), found ${count}"
exit 1
fi
- name: Authenticode sign
env:
CERTUM_EMAIL: ${{ secrets.CERTUM_EMAIL }}
CERTUM_OTP: ${{ secrets.CERTUM_OTP }}
run: bash scripts/release/sign-windows.sh "${RUNNER_TEMP}/win"
# The Tauri CLI, for `signer sign` below. Installed in a step of its own,
# with no secret in its environment and no package's install scripts
# run, because the next step hands it the updater key. This used to be
# `npx --yes` inside that step, so the download ran beside the key.
#
# The version is the one apps/netscli-gui/package-lock.json locks on
# the default branch checked out above, so it follows Dependabot's
# bumps. It used to be typed here by hand "to match package.json", and
# had drifted to 2.11.5 while the lockfile moved to 2.12.1.
- name: Install the Tauri CLI
shell: bash
run: |
set -euo pipefail
version="$(node -p "require('./apps/netscli-gui/package-lock.json').packages['node_modules/@tauri-apps/cli'].version")"
npm install --prefix "${RUNNER_TEMP}/tauri-cli" --no-save --no-audit --no-fund --ignore-scripts "@tauri-apps/cli@${version}"
"${RUNNER_TEMP}/tauri-cli/node_modules/.bin/tauri" --version
# The in-app updater's signature, over the Authenticode-signed MSI.
#
# It has to come after Authenticode: that rewrites the file, so the
# signature Tauri made at build time covers bytes nobody downloads.
# And it goes to a workflow artifact, not the release, because the
# sigstore step below writes `<asset>.sig` too.
#
# `--app-version` binds the signature to this version, which the app
# requires (requireSignedVersion). 0.3.4's MSI signature named no
# version, because this step did not pass one.
- name: Sign the MSI for the in-app updater
shell: bash
env:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
run: |
set -euo pipefail
msi="${RUNNER_TEMP}/win/netscli-gui-windows-x86_64.msi"
[ -f "${msi}" ] || { echo "::error::${msi} missing"; exit 1; }
"${RUNNER_TEMP}/tauri-cli/node_modules/.bin/tauri" signer sign --app-version "${TAG#v}" "${msi}"
mkdir -p "${RUNNER_TEMP}/updater-sigs"
mv "${msi}.sig" "${RUNNER_TEMP}/updater-sigs/"
- name: Hand the updater signature to latest.json
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: updater-sig-netscli-gui-windows-x86_64
path: ${{ runner.temp }}/updater-sigs/*
retention-days: 1
if-no-files-found: error
# From here on it is the same tail the other matrix legs run, against
# the signed bytes.
- name: Generate SHA256
shell: bash
run: |
set -euo pipefail
cd "${RUNNER_TEMP}/win"
for f in *.exe *.msi; do
[ -f "$f" ] || continue
sha256sum "$f" > "${f}.sha256"
done
cat ./*.sha256
- name: Install cosign
uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
with:
# Same pin as the two jobs above; see the CLI job for why the
# action version and the cosign version are pinned separately.