Repository navigation
1337 lines (1242 loc) · 64 KB
/
Copy pathrelease.yml
File metadata and controls
1337 lines (1242 loc) · 64 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
on:
push:
tags:
- "v*"
workflow_dispatch:
inputs:
dry_run:
description: "Build archives only. A manual run never signs or publishes, whatever this says."
type: boolean
default: true
permissions:
contents: read
# One run at a time per tag (or per branch for a manual dry run). A running release is never
# cancelled: a later run for the same ref waits for it, and GitHub keeps one waiting run per group.
concurrency:
group: release-${{ github.ref }}
cancel-in-progress: false
env:
CARGO_TERM_COLOR: always
BIN_NAME: hodeishield
jobs:
# Resolves the release version once so every job/archive name agrees on it.
version:
name: version
runs-on: ubuntu-latest
outputs:
version: ${{ steps.version.outputs.version }}
dry_run: ${{ steps.version.outputs.dry_run }}
sign_macos: ${{ steps.signing.outputs.sign_macos }}
sign_windows: ${{ steps.signing.outputs.sign_windows }}
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Determine version and mode
id: version
env:
REF_NAME: ${{ github.ref_name }}
EVENT_NAME: ${{ github.event_name }}
DRY_RUN_INPUT: ${{ inputs.dry_run }}
run: |
set -euo pipefail
if [ "$EVENT_NAME" = "workflow_dispatch" ]; then
echo "version=0.0.0-dryrun" >> "$GITHUB_OUTPUT"
echo "dry_run=${DRY_RUN_INPUT}" >> "$GITHUB_OUTPUT"
else
# REF_NAME is the pushed tag, e.g. "v0.1.0" -> "0.1.0".
echo "version=${REF_NAME#v}" >> "$GITHUB_OUTPUT"
echo "dry_run=false" >> "$GITHUB_OUTPUT"
fi
# A tag cannot be released without its notes: fail before building anything.
- name: Check the changelog has an entry for this version
if: github.event_name == 'push'
env:
VERSION: ${{ steps.version.outputs.version }}
run: sh .github/scripts/changelog-section.sh "$VERSION" > /dev/null
# Platform signing happens only for a pushed release tag, and only once the maintainers have
# provisioned it and set the variable to exactly "true". A manual run never signs. GitHub's
# comparison is case-sensitive, so "True" or "1" would silently mean "off": refuse them.
- name: Decide platform signing
id: signing
env:
EVENT_NAME: ${{ github.event_name }}
REF: ${{ github.ref }}
MACOS_SIGNING_ENABLED: ${{ vars.MACOS_SIGNING_ENABLED }}
WINDOWS_SIGNING_ENABLED: ${{ vars.WINDOWS_SIGNING_ENABLED }}
run: |
set -euo pipefail
for name in MACOS_SIGNING_ENABLED WINDOWS_SIGNING_ENABLED; do
case "${!name}" in
""|true|false) ;;
*) echo "::error::$name must be exactly 'true', 'false' or unset."; exit 1 ;;
esac
done
release=false
if [ "$EVENT_NAME" = "push" ] && [[ "$REF" == refs/tags/v* ]]; then
release=true
fi
sign_macos=false
sign_windows=false
if [ "$release" = true ] && [ "$MACOS_SIGNING_ENABLED" = true ]; then sign_macos=true; fi
if [ "$release" = true ] && [ "$WINDOWS_SIGNING_ENABLED" = true ]; then sign_windows=true; fi
echo "sign_macos=$sign_macos" >> "$GITHUB_OUTPUT"
echo "sign_windows=$sign_windows" >> "$GITHUB_OUTPUT"
echo "macOS signing: $sign_macos; Windows signing: $sign_windows"
# No release with a known exploitable vulnerability (CONTRIBUTING.md, "Known vulnerabilities at
# release"): the tagged commit is checked against the RustSec advisory database with the same
# pinned tools as CI. Only advisories accepted in deny.toml, each with a reason and a review date,
# are let through. The signing jobs and the publication wait for it, so nothing is signed for a
# release that cannot be published. Its result goes into the release notes.
advisories:
name: advisories
needs: version
runs-on: ubuntu-latest
timeout-minutes: 30
permissions:
contents: read
outputs:
result: ${{ steps.check.outputs.result }}
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Install pinned toolchain
run: rustup show
# The versions of the deny and audit jobs in ci.yml.
- name: Install cargo-deny and cargo-audit
run: |
cargo install cargo-deny --locked --version 0.20.2
cargo install cargo-audit --locked --version 0.22.2
# The same script as the audit job of ci.yml; its last line is the result.
- name: Check the advisories
id: check
run: |
set -euo pipefail
bash .github/scripts/advisory-gate.sh | tee "$RUNNER_TEMP/advisories.txt"
result="$(tail -n 1 "$RUNNER_TEMP/advisories.txt")"
case "$result" in
"No unaddressed security advisory"*) ;;
*) echo "::error::the advisory check printed no result"; exit 1 ;;
esac
echo "result=$result" >> "$GITHUB_OUTPUT"
echo "$result" >> "$GITHUB_STEP_SUMMARY"
build-linux:
name: build (${{ matrix.target }})
needs: version
strategy:
fail-fast: false
matrix:
include:
- os: ubuntu-24.04
target: x86_64-unknown-linux-musl
- os: ubuntu-24.04-arm
target: aarch64-unknown-linux-musl
runs-on: ${{ matrix.os }}
env:
TARGET: ${{ matrix.target }}
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
# Used for the man pages only; the binary is built in the pinned image.
- name: Install pinned toolchain
run: rustup show
# In the image pinned in packaging/build/image, so that anyone can rebuild the same bytes from
# the tag (docs/verifying-releases.md); reproducible-build.yml does it for every release.
- name: Build
run: packaging/build/build.sh "$TARGET"
- name: Generate man pages
run: cargo xtask man target/man
- name: Package archive
env:
VERSION: ${{ needs.version.outputs.version }}
run: |
set -euo pipefail
stage="$(mktemp -d)/hodeishield-${VERSION}-${TARGET}"
mkdir -p "$stage"
cp "target/build-image/${TARGET}/release/${BIN_NAME}" "$stage/"
cp LICENSE NOTICE README.md "$stage/"
cp docs/user-information.md "$stage/USER-INFORMATION.md"
mkdir -p "$stage/man"
cp target/man/*.1 "$stage/man/"
tar -C "$(dirname "$stage")" -czf "hodeishield-${VERSION}-${TARGET}.tar.gz" "$(basename "$stage")"
- name: Upload archive
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: archive-${{ matrix.target }}
path: hodeishield-*.tar.gz
if-no-files-found: error
retention-days: 7
build-macos:
name: build (universal-apple-darwin)
needs: version
runs-on: macos-latest
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Install pinned toolchain
run: rustup show
- name: Add Darwin targets
run: |
rustup target add x86_64-apple-darwin
rustup target add aarch64-apple-darwin
- name: Build x86_64
run: cargo build --release --locked --target x86_64-apple-darwin -p hodeishield-cli
- name: Build aarch64
run: cargo build --release --locked --target aarch64-apple-darwin -p hodeishield-cli
- name: Combine into a universal binary
run: |
set -euo pipefail
mkdir -p target/universal-apple-darwin/release
lipo -create -output "target/universal-apple-darwin/release/${BIN_NAME}" \
"target/x86_64-apple-darwin/release/${BIN_NAME}" \
"target/aarch64-apple-darwin/release/${BIN_NAME}"
lipo -info "target/universal-apple-darwin/release/${BIN_NAME}"
- name: Generate man pages
run: cargo xtask man target/man
- name: Stage the unsigned binary for the signing job
run: |
set -euo pipefail
mkdir -p stage/man
cp "target/universal-apple-darwin/release/${BIN_NAME}" LICENSE NOTICE README.md stage/
cp docs/user-information.md stage/USER-INFORMATION.md
cp target/man/*.1 stage/man/
- name: Upload the unsigned binary
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: unsigned-universal-apple-darwin
path: stage/*
if-no-files-found: error
retention-days: 1
# Signs, notarizes and packages the macOS binary. Split from the build on purpose: the Developer
# ID keys and the App Store Connect key must never share a runner with `cargo build`, which runs
# every dependency's build script. This job checks nothing out and runs no repository code, only
# inline steps and pinned actions.
#
# Two artifacts when signing is on:
# - the .tar.gz as before, with a signed and notarized binary. A bare executable cannot carry a
# stapled ticket, so Gatekeeper confirms its notarization online on first run; curl and
# scripts are unaffected (they set no quarantine attribute).
# - a .pkg that installs /usr/local/bin/hodeishield, signed with Developer ID Installer,
# notarized and stapled, so it also passes Gatekeeper offline and can be deployed by MDM.
#
# The same import, sign, notarize and verify chain runs without a release in
# macos-signing-check.yml; keep the two in step.
#
# Environment secrets (release-signing): MACOS_CERTIFICATE and MACOS_CERTIFICATE_PASSWORD
# (Developer ID Application, base64 .p12), MACOS_SIGNING_IDENTITY, MACOS_INSTALLER_CERTIFICATE,
# MACOS_INSTALLER_CERTIFICATE_PASSWORD, MACOS_INSTALLER_IDENTITY (Developer ID Installer),
# APPLE_NOTARIZATION_KEY (base64 .p8), APPLE_NOTARIZATION_KEY_ID, APPLE_NOTARIZATION_ISSUER_ID.
sign-macos:
name: sign and package (universal-apple-darwin)
needs: [version, advisories, build-macos]
runs-on: macos-latest
timeout-minutes: 60
# The environment, and its required reviewer, only when this run signs. An empty name means
# no environment, so a dry run never waits for approval.
environment: ${{ needs.version.outputs.sign_macos == 'true' && 'release-signing' || '' }}
permissions:
contents: read
env:
VERSION: ${{ needs.version.outputs.version }}
TARGET: universal-apple-darwin
SIGN: ${{ needs.version.outputs.sign_macos }}
# The Apple Team ID the Developer ID certificates must belong to: repository policy, changed
# only through a reviewed pull request. Empty until a maintainer sets it, so signing fails
# closed. The same value is in macos-signing-check.yml; keep the two in step.
MACOS_TEAM_ID: "8UW5SZ932N"
steps:
- name: Download the unsigned binary
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: unsigned-universal-apple-darwin
path: stage
# Anything else in the hand-off would be packaged, and possibly signed, as ours.
- name: Refuse anything but the expected files
run: |
set -euo pipefail
# Top level: exactly the binary, the licence files, the README and the user information,
# plus `man/`. In `man/`: only regular `hodeishield*.1` files, at least one. Nothing else,
# of any type.
top="$(cd stage && find . -mindepth 1 -maxdepth 1 | sed 's|^\./||' | LC_ALL=C sort | tr '\n' ' ')"
if [ "$top" != "LICENSE NOTICE README.md USER-INFORMATION.md hodeishield man " ]; then
echo "::error::Unexpected hand-off contents: $top"
exit 1
fi
if [ -n "$(find stage -mindepth 1 -maxdepth 1 ! -type f ! -name man)" ] || [ ! -d stage/man ] || [ -L stage/man ]; then
echo "::error::The hand-off holds something that is not a regular file."
exit 1
fi
odd="$(find stage/man -mindepth 1 \( ! -type f -o ! -name 'hodeishield*.1' \))"
pages="$(find stage/man -mindepth 1 -type f -name 'hodeishield*.1' | wc -l)"
if [ -n "$odd" ] || [ "$pages" -lt 1 ]; then
echo "::error::man/ must hold only hodeishield*.1 files (found $pages pages; unexpected: $odd)"
exit 1
fi
chmod 0755 "stage/${BIN_NAME}"
find stage/man -type f -exec chmod 0644 {} \;
# A skip must never read as a pass: say so in the run summary and as an annotation.
- name: Record that macOS signing was skipped
if: env.SIGN != 'true'
env:
EVENT_NAME: ${{ github.event_name }}
run: |
set -euo pipefail
if [ "$EVENT_NAME" = push ]; then
why="MACOS_SIGNING_ENABLED is not true"
else
why="this is a manual dry run, which never signs"
fi
msg="macOS signing: skipped — ${why}; the binary is not Developer ID signed or notarized."
echo "::notice title=macOS signing skipped::${msg}"
printf '### macOS signing\n\n%s\n' "$msg" >> "$GITHUB_STEP_SUMMARY"
- name: Check the signing inputs are present
if: env.SIGN == 'true'
env:
MACOS_CERTIFICATE: ${{ secrets.MACOS_CERTIFICATE }}
MACOS_CERTIFICATE_PASSWORD: ${{ secrets.MACOS_CERTIFICATE_PASSWORD }}
MACOS_SIGNING_IDENTITY: ${{ secrets.MACOS_SIGNING_IDENTITY }}
MACOS_INSTALLER_CERTIFICATE: ${{ secrets.MACOS_INSTALLER_CERTIFICATE }}
MACOS_INSTALLER_CERTIFICATE_PASSWORD: ${{ secrets.MACOS_INSTALLER_CERTIFICATE_PASSWORD }}
MACOS_INSTALLER_IDENTITY: ${{ secrets.MACOS_INSTALLER_IDENTITY }}
APPLE_NOTARIZATION_KEY: ${{ secrets.APPLE_NOTARIZATION_KEY }}
APPLE_NOTARIZATION_KEY_ID: ${{ secrets.APPLE_NOTARIZATION_KEY_ID }}
APPLE_NOTARIZATION_ISSUER_ID: ${{ secrets.APPLE_NOTARIZATION_ISSUER_ID }}
run: |
set -euo pipefail
# The expected Apple Team ID is repository policy (MACOS_TEAM_ID above), not a secret: the
# certificates in the environment must belong to it. Empty or malformed fails closed.
if ! [[ "$MACOS_TEAM_ID" =~ ^[A-Z0-9]{10}$ ]]; then
echo "::error::MACOS_TEAM_ID is not set to a 10-character Apple Team ID in this workflow."
exit 1
fi
missing=0
for name in MACOS_CERTIFICATE MACOS_CERTIFICATE_PASSWORD MACOS_SIGNING_IDENTITY \
MACOS_INSTALLER_CERTIFICATE MACOS_INSTALLER_CERTIFICATE_PASSWORD \
MACOS_INSTALLER_IDENTITY APPLE_NOTARIZATION_KEY APPLE_NOTARIZATION_KEY_ID \
APPLE_NOTARIZATION_ISSUER_ID; do
if [ -z "${!name}" ]; then
echo "::error::MACOS_SIGNING_ENABLED is true but the secret $name is missing."
missing=1
fi
done
exit "$missing"
- name: Import the Developer ID certificates into a temporary keychain
if: env.SIGN == 'true'
env:
MACOS_CERTIFICATE: ${{ secrets.MACOS_CERTIFICATE }}
MACOS_CERTIFICATE_PASSWORD: ${{ secrets.MACOS_CERTIFICATE_PASSWORD }}
MACOS_INSTALLER_CERTIFICATE: ${{ secrets.MACOS_INSTALLER_CERTIFICATE }}
MACOS_INSTALLER_CERTIFICATE_PASSWORD: ${{ secrets.MACOS_INSTALLER_CERTIFICATE_PASSWORD }}
run: |
set -euo pipefail
keychain="$RUNNER_TEMP/signing.keychain-db"
password="$(openssl rand -hex 24)"
echo "::add-mask::$password"
security create-keychain -p "$password" "$keychain"
security set-keychain-settings -lut 1800 "$keychain"
security unlock-keychain -p "$password" "$keychain"
printf '%s' "$MACOS_CERTIFICATE" | base64 --decode > "$RUNNER_TEMP/application.p12"
printf '%s' "$MACOS_INSTALLER_CERTIFICATE" | base64 --decode > "$RUNNER_TEMP/installer.p12"
security import "$RUNNER_TEMP/application.p12" -k "$keychain" \
-P "$MACOS_CERTIFICATE_PASSWORD" -T /usr/bin/codesign
security import "$RUNNER_TEMP/installer.p12" -k "$keychain" \
-P "$MACOS_INSTALLER_CERTIFICATE_PASSWORD" -T /usr/bin/productsign
rm -f "$RUNNER_TEMP/application.p12" "$RUNNER_TEMP/installer.p12"
security list-keychains -d user -s "$keychain" login.keychain
security set-key-partition-list -S apple-tool:,apple: -s -k "$password" "$keychain" > /dev/null
- name: Sign the binary (hardened runtime, secure timestamp)
if: env.SIGN == 'true'
env:
MACOS_SIGNING_IDENTITY: ${{ secrets.MACOS_SIGNING_IDENTITY }}
run: |
set -euo pipefail
codesign --force --timestamp --options runtime \
--identifier com.hodeitek.hodeishield \
--keychain "$RUNNER_TEMP/signing.keychain-db" \
--sign "$MACOS_SIGNING_IDENTITY" "stage/${BIN_NAME}"
codesign --verify --strict --verbose=2 "stage/${BIN_NAME}"
# The signature must come from a Developer ID Application certificate of a team, with the
# hardened runtime and a secure timestamp. codesign prints no secrets.
details="$(codesign --display -vv "stage/${BIN_NAME}" 2>&1)"
echo "$details"
team="$(sed -n 's/^TeamIdentifier=//p' <<< "$details")"
for expected in '^Authority=Developer ID Application: ' '^CodeDirectory .*flags=.*runtime' '^Timestamp='; do
if ! grep -q -- "$expected" <<< "$details"; then
echo "::error::The signature lacks the expected '${expected#^}'."
exit 1
fi
done
if [ "$team" != "$MACOS_TEAM_ID" ]; then
echo "::error::The signature's TeamIdentifier is not the expected MACOS_TEAM_ID."
exit 1
fi
- name: Build and sign the installer package
if: env.SIGN == 'true'
env:
MACOS_INSTALLER_IDENTITY: ${{ secrets.MACOS_INSTALLER_IDENTITY }}
run: |
set -euo pipefail
root="$RUNNER_TEMP/pkgroot"
mkdir -p "$root/usr/local/bin"
cp -p "stage/${BIN_NAME}" "$root/usr/local/bin/"
pkgbuild --root "$root" --install-location / \
--identifier com.hodeitek.hodeishield --version "$VERSION" \
"$RUNNER_TEMP/unsigned.pkg"
mkdir -p dist
productsign --timestamp --keychain "$RUNNER_TEMP/signing.keychain-db" \
--sign "$MACOS_INSTALLER_IDENTITY" \
"$RUNNER_TEMP/unsigned.pkg" "dist/hodeishield-${VERSION}-${TARGET}.pkg"
pkgutil --check-signature "dist/hodeishield-${VERSION}-${TARGET}.pkg"
- name: Remove the temporary keychain
if: always() && env.SIGN == 'true'
run: |
security delete-keychain "$RUNNER_TEMP/signing.keychain-db" 2>/dev/null || true
rm -f "$RUNNER_TEMP/application.p12" "$RUNNER_TEMP/installer.p12"
# notarytool takes a zip, a pkg or a dmg, not a bare executable or a tar.gz: the binary goes
# in a zip used only for the submission. Its ticket is then known to Gatekeeper online.
- name: Notarize the binary and the package, staple the package
if: env.SIGN == 'true'
env:
APPLE_NOTARIZATION_KEY: ${{ secrets.APPLE_NOTARIZATION_KEY }}
APPLE_NOTARIZATION_KEY_ID: ${{ secrets.APPLE_NOTARIZATION_KEY_ID }}
APPLE_NOTARIZATION_ISSUER_ID: ${{ secrets.APPLE_NOTARIZATION_ISSUER_ID }}
run: |
set -euo pipefail
key="$RUNNER_TEMP/notary.p8"
trap 'rm -f "$key"' EXIT
printf '%s' "$APPLE_NOTARIZATION_KEY" | base64 --decode > "$key"
notarize() {
xcrun notarytool submit "$1" --key "$key" --key-id "$APPLE_NOTARIZATION_KEY_ID" \
--issuer "$APPLE_NOTARIZATION_ISSUER_ID" --wait --timeout 30m
}
ditto -c -k "stage/${BIN_NAME}" "$RUNNER_TEMP/notarize.zip"
notarize "$RUNNER_TEMP/notarize.zip"
pkg="dist/hodeishield-${VERSION}-${TARGET}.pkg"
notarize "$pkg"
xcrun stapler staple "$pkg"
xcrun stapler validate "$pkg"
spctl --assess --type install --verbose=2 "$pkg"
# What Gatekeeper and the system tools say about the finished files, and the run summary. The
# binary cannot be stapled, so Gatekeeper looks its ticket up online; that can lag a little
# behind the notarization, hence the retries.
- name: Verify the signed and notarized files
if: env.SIGN == 'true'
run: |
set -euo pipefail
bin="stage/${BIN_NAME}"
pkg="dist/hodeishield-${VERSION}-${TARGET}.pkg"
team="$(codesign --display -vv "$bin" 2>&1 | sed -n 's/^TeamIdentifier=//p')"
if [ "$team" != "$MACOS_TEAM_ID" ]; then
echo "::error::The signature's TeamIdentifier is not the expected MACOS_TEAM_ID."
exit 1
fi
accepted=false
for _ in 1 2 3 4 5 6; do
out="$(spctl --assess --type execute --verbose=2 "$bin" 2>&1 || true)"
if grep -qx 'source=Notarized Developer ID' <<< "$out"; then accepted=true; break; fi
sleep 20
done
echo "$out"
if [ "$accepted" != true ]; then
echo "::error::Gatekeeper does not report the binary as 'Notarized Developer ID'."
exit 1
fi
sig="$(pkgutil --check-signature "$pkg")"
echo "$sig"
if ! grep -q 'Developer ID Installer: ' <<< "$sig"; then
echo "::error::The package is not signed with a Developer ID Installer certificate."
exit 1
fi
# The installer certificate's Team ID is the last parenthesis of its name.
pkg_team="$(sed -n 's/^ *[0-9]*\. Developer ID Installer: .* (\([A-Z0-9]\{10\}\))$/\1/p' <<< "$sig")"
if [ "$pkg_team" != "$MACOS_TEAM_ID" ]; then
echo "::error::The installer certificate's Team ID is not the expected MACOS_TEAM_ID."
exit 1
fi
xcrun stapler validate "$pkg"
spctl --assess --type install --verbose=2 "$pkg"
{
printf '### macOS signing\n\n'
printf 'macOS signing: signed and notarized with the Developer ID of team %s.\n\n' "$team"
printf -- "- \`codesign --verify --strict\`: passed; Developer ID Application authority, hardened runtime and secure timestamp present.\n"
printf -- "- \`spctl --assess --type execute\` on the binary: \`source=Notarized Developer ID\`.\n"
printf -- "- \`pkgutil --check-signature\` on the package: signed with a Developer ID Installer certificate.\n"
printf -- "- \`xcrun stapler validate\` and \`spctl --assess --type install\` on the package: passed.\n"
} >> "$GITHUB_STEP_SUMMARY"
- name: Package archive
run: |
set -euo pipefail
mkdir -p dist
dir="$RUNNER_TEMP/hodeishield-${VERSION}-${TARGET}"
mkdir -p "$dir"
cp -Rp stage/* "$dir/"
tar -C "$RUNNER_TEMP" -czf "dist/hodeishield-${VERSION}-${TARGET}.tar.gz" "$(basename "$dir")"
ls -l dist
- name: Upload archive
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: archive-universal-apple-darwin
path: dist/*
if-no-files-found: error
retention-days: 7
# x86_64 builds on the x64 runner. arm64 builds natively on the GitHub-hosted Windows 11 Arm
# runner (as aarch64 Linux does on ubuntu-24.04-arm), so the C code of aws-lc-sys is compiled by
# the toolchain that will also run the CI tests, with no cross-compilation.
build-windows:
name: build (${{ matrix.target }})
needs: version
strategy:
fail-fast: false
matrix:
include:
- os: windows-latest
target: x86_64-pc-windows-msvc
- os: windows-11-arm
target: aarch64-pc-windows-msvc
runs-on: ${{ matrix.os }}
env:
TARGET: ${{ matrix.target }}
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Install pinned toolchain
run: rustup show
- name: Build
shell: pwsh
run: cargo build --release --locked --target "$env:TARGET" -p hodeishield-cli
- name: Stage the unsigned executable for the signing job
shell: pwsh
run: |
New-Item -ItemType Directory -Path stage | Out-Null
Copy-Item -Path "target/$env:TARGET/release/$env:BIN_NAME.exe", "LICENSE", "NOTICE", "README.md" -Destination stage
Copy-Item -Path "docs/user-information.md" -Destination stage/USER-INFORMATION.md
- name: Upload the unsigned executable
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: unsigned-${{ matrix.target }}
path: stage/*
if-no-files-found: error
retention-days: 1
# Signs hodeishield.exe with Authenticode through Azure Artifact Signing, then packages it.
# There is no certificate, password or .pfx anywhere: the key stays in Microsoft's HSM.
# azure/login trades this job's GitHub OIDC token for an Azure session, through a federated
# credential that trusts only this repository's `release-signing` environment, and the signing
# action uses that session. Split from the build for the same reason as `sign-macos`: the job
# that can mint the token checks nothing out and runs no repository or dependency code.
#
# Environment secrets (release-signing): AZURE_CLIENT_ID, AZURE_TENANT_ID, AZURE_SUBSCRIPTION_ID
# (directory identifiers, kept out of public logs). Environment variables: AZURE_SIGNING_ENDPOINT,
# AZURE_SIGNING_ACCOUNT_NAME, AZURE_CERTIFICATE_PROFILE_NAME.
#
# One job per architecture; each pauses for the same `release-signing` approval. The signing
# itself does not depend on the architecture of the exe, so both run on the x64 runner, which
# also hosts the x64 dumpbin used to inspect them.
sign-windows:
name: sign and package (${{ matrix.target }})
needs: [version, advisories, build-windows]
runs-on: windows-latest
timeout-minutes: 30
strategy:
fail-fast: false
matrix:
include:
- target: x86_64-pc-windows-msvc
key: x64
machine: "8664 machine (x64)"
- target: aarch64-pc-windows-msvc
key: arm64
machine: "AA64 machine (ARM64)"
environment: ${{ needs.version.outputs.sign_windows == 'true' && 'release-signing' || '' }}
permissions:
contents: read
id-token: write # OIDC federation to Azure; used only when this run signs
env:
VERSION: ${{ needs.version.outputs.version }}
SIGN: ${{ needs.version.outputs.sign_windows }}
TARGET: ${{ matrix.target }}
MACHINE: ${{ matrix.machine }}
KEY: ${{ matrix.key }}
# The digest of the exe this job hands to the MSI build. A job output cannot be rewritten by
# another job, so `windows-msi` checks what it downloads against this, not against a name. The
# name carries the architecture, because the legs of a matrix share their outputs otherwise:
# each leg sets only its own (output names cannot use expressions).
outputs:
exe_sha256_x64: ${{ steps.handoff.outputs.exe_sha256_x64 }}
exe_sha256_arm64: ${{ steps.handoff.outputs.exe_sha256_arm64 }}
steps:
- name: Download the unsigned executable
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: unsigned-${{ matrix.target }}
path: stage
# Anything else in the hand-off would be packaged, and possibly signed, as ours.
- name: Refuse anything but the expected files
shell: pwsh
run: |
$ErrorActionPreference = "Stop"
$root = (Resolve-Path stage).Path
$found = @(Get-ChildItem -LiteralPath stage -Recurse -Force | ForEach-Object {
$_.FullName.Substring($root.Length).TrimStart('\', '/')
} | Sort-Object)
$expected = @("hodeishield.exe", "LICENSE", "NOTICE", "README.md", "USER-INFORMATION.md") | Sort-Object
if (Compare-Object $found $expected) {
Write-Host "::error::Unexpected hand-off contents: $($found -join ', ')"
exit 1
}
# The Visual C++ runtime is not part of Windows. Every .exe that gets signed and packaged
# (zip and MSI) is inspected, found by search and not by name; none found is a failure.
- name: Check the executables need no Visual C++ runtime
shell: pwsh
run: |
$ErrorActionPreference = "Stop"
$vswhere = Join-Path ${env:ProgramFiles(x86)} "Microsoft Visual Studio\Installer\vswhere.exe"
if (-not (Test-Path -LiteralPath $vswhere)) { throw "vswhere.exe not found at $vswhere" }
$dumpbin = & $vswhere -latest -products * -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -find "VC\Tools\MSVC\**\bin\Hostx64\x64\dumpbin.exe" | Select-Object -First 1
if (-not $dumpbin -or -not (Test-Path -LiteralPath $dumpbin)) { throw "dumpbin.exe not found through vswhere" }
$exes = @(Get-ChildItem -LiteralPath stage -Recurse -File -Filter *.exe)
if ($exes.Count -eq 0) { throw "No .exe found to inspect in stage" }
$failed = $false
foreach ($exe in $exes) {
$output = & $dumpbin /nologo /dependents $exe.FullName
if ($LASTEXITCODE -ne 0) { throw "dumpbin failed on $($exe.FullName)" }
$imports = @($output | ForEach-Object { $_.Trim() } | Where-Object { $_ -match '^\S+\.dll$' })
if ($imports.Count -eq 0) { throw "No imports parsed from $($exe.FullName)" }
Write-Host "$($exe.Name): $($imports -join ', ')"
$runtime = @($imports | Where-Object { $_ -match '^(VCRUNTIME|MSVCP|CONCRT|VCOMP|VCCORLIB|MSVCR\d)' })
if ($runtime.Count -gt 0) {
Write-Host "::error::$($exe.Name) imports the Visual C++ runtime ($($runtime -join ', ')), which Windows does not include."
$failed = $true
}
# The archive name says which architecture this is: the file must really be that one.
$headers = & $dumpbin /nologo /headers $exe.FullName
if ($LASTEXITCODE -ne 0) { throw "dumpbin /headers failed on $($exe.FullName)" }
if (-not ($headers | Select-String -SimpleMatch $env:MACHINE -Quiet)) {
Write-Host "::error::$($exe.Name) is not a '$env:MACHINE' executable, which $env:TARGET requires."
$failed = $true
}
}
if ($failed) { exit 1 }
- name: Check the signing inputs are present
if: env.SIGN == 'true'
shell: pwsh
env:
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
AZURE_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
AZURE_SIGNING_ENDPOINT: ${{ vars.AZURE_SIGNING_ENDPOINT }}
AZURE_SIGNING_ACCOUNT_NAME: ${{ vars.AZURE_SIGNING_ACCOUNT_NAME }}
AZURE_CERTIFICATE_PROFILE_NAME: ${{ vars.AZURE_CERTIFICATE_PROFILE_NAME }}
run: |
$missing = @()
foreach ($name in @("AZURE_CLIENT_ID", "AZURE_TENANT_ID", "AZURE_SUBSCRIPTION_ID",
"AZURE_SIGNING_ACCOUNT_NAME", "AZURE_CERTIFICATE_PROFILE_NAME")) {
if ([string]::IsNullOrWhiteSpace([Environment]::GetEnvironmentVariable($name))) { $missing += $name }
}
if ($env:AZURE_SIGNING_ENDPOINT -notmatch '^https://[a-z0-9]+\.codesigning\.azure\.net/?$') {
$missing += "AZURE_SIGNING_ENDPOINT (https://<region>.codesigning.azure.net)"
}
foreach ($item in $missing) {
Write-Host "::error::WINDOWS_SIGNING_ENABLED is true but $item is missing or malformed."
}
if ($missing.Count -gt 0) { exit 1 }
- name: Azure login (OIDC)
if: env.SIGN == 'true'
uses: Azure/login@a641126d1b8aa4d1fa005f4f92df94a3a4c4c906 # v3.1.0
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
# The file is named, never a folder or a pattern: only what this job expects gets signed.
- name: Sign hodeishield.exe (Azure Artifact Signing)
if: env.SIGN == 'true'
uses: Azure/artifact-signing-action@c7ab2a863ab5f9a846ddb8265964877ef296ee82 # v2.0.0
with:
endpoint: ${{ vars.AZURE_SIGNING_ENDPOINT }}
signing-account-name: ${{ vars.AZURE_SIGNING_ACCOUNT_NAME }}
certificate-profile-name: ${{ vars.AZURE_CERTIFICATE_PROFILE_NAME }}
files: ${{ github.workspace }}\stage\hodeishield.exe
file-digest: SHA256
timestamp-rfc3161: http://timestamp.acs.microsoft.com
timestamp-digest: SHA256
# Drop the session before any other action runs. `always()` so it also runs after a failure.
- name: Azure logout
if: always() && env.SIGN == 'true'
shell: pwsh
run: |
az logout 2>$null
exit 0
- name: Verify the Authenticode signature
if: env.SIGN == 'true'
shell: pwsh
run: |
$ErrorActionPreference = "Stop"
$signature = Get-AuthenticodeSignature -LiteralPath stage\hodeishield.exe
$signature | Format-List Status, StatusMessage, SignerCertificate, TimeStamperCertificate
if ($signature.Status -ne "Valid" -or -not $signature.TimeStamperCertificate) {
Write-Host "::error::hodeishield.exe is not validly signed and timestamped."
exit 1
}
- name: Record the digest of the executable handed off
id: handoff
shell: pwsh
run: |
$hash = (Get-FileHash -LiteralPath stage\hodeishield.exe -Algorithm SHA256).Hash.ToLower()
"exe_sha256_$env:KEY=$hash" | Out-File -Append -Encoding utf8 $env:GITHUB_OUTPUT
Write-Host "hodeishield.exe $hash"
# The MSI is built for x64 only (an arm64 MSI is a follow-up).
- name: Upload the signed executable for the MSI build
if: matrix.key == 'x64'
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: windows-msi-input
path: stage/*
if-no-files-found: error
retention-days: 1
- name: Package archive
shell: pwsh
run: |
$ErrorActionPreference = "Stop"
$stageName = "hodeishield-$env:VERSION-$env:TARGET"
$dir = Join-Path $env:RUNNER_TEMP $stageName
New-Item -ItemType Directory -Path $dir, dist | Out-Null
Copy-Item stage/* $dir
Compress-Archive -Path "$dir/*" -DestinationPath "dist/$stageName.zip"
- name: Upload archive
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: archive-${{ matrix.target }}
path: dist/*.zip
if-no-files-found: error
retention-days: 7
# The Windows Installer package, built with WiX from the exe `sign-windows` signed. This job can
# NOT sign: it has no id-token permission and no environment, because `dotnet tool install` and
# the WiX build run third-party code. It installs the package silently, checks it, and removes it.
windows-msi:
name: msi (build and test)
needs: [version, sign-windows]
runs-on: windows-latest
timeout-minutes: 30
permissions:
contents: read
env:
VERSION: ${{ needs.version.outputs.version }}
EXE_SHA256: ${{ needs.sign-windows.outputs.exe_sha256_x64 }}
# Pinned to 5.x: later WiX releases add maintenance-fee terms to their licence, to be reviewed
# before moving. Bumped by hand in the periodic dependency review (CONTRIBUTING.md).
WIX_VERSION: "5.0.2"
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Download the signed executable
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: windows-msi-input
path: msi-input
- name: Check it is the executable sign-windows handed off
shell: pwsh
run: |
$ErrorActionPreference = "Stop"
$hash = (Get-FileHash -LiteralPath msi-input\hodeishield.exe -Algorithm SHA256).Hash.ToLower()
if ($hash -ne $env:EXE_SHA256) {
Write-Host "::error::hodeishield.exe is $hash, but sign-windows handed off $env:EXE_SHA256"
exit 1
}
- name: Install WiX
shell: pwsh
run: |
$ErrorActionPreference = "Stop"
dotnet tool install --global wix --version $env:WIX_VERSION
if ($LASTEXITCODE -ne 0) { exit 1 }
"$env:USERPROFILE\.dotnet\tools" | Out-File -Append -Encoding utf8 $env:GITHUB_PATH
- name: Build the MSI
shell: pwsh
run: |
$ErrorActionPreference = "Stop"
# Windows Installer versions are numeric: 0.3.0-rc.1 becomes 0.3.0.
$msiVersion = ($env:VERSION -split '-')[0]
New-Item -ItemType Directory -Path msi-out | Out-Null
wix build -arch x64 -d "Version=$msiVersion" -d "SourceDir=$PWD\msi-input" `
-o "msi-out\hodeishield-$env:VERSION-x86_64-pc-windows-msvc.msi" packaging\windows\hodeishield.wxs
if ($LASTEXITCODE -ne 0) { exit 1 }
Get-ChildItem msi-out
- name: Install, check and uninstall the MSI silently
shell: pwsh
run: |
$ErrorActionPreference = "Stop"
$msi = (Resolve-Path "msi-out\hodeishield-$env:VERSION-x86_64-pc-windows-msvc.msi").Path
$dir = Join-Path $env:ProgramFiles "HodeiShield CLI"
$envKey = "HKLM:\SYSTEM\CurrentControlSet\Control\Session Manager\Environment"
$p = Start-Process msiexec.exe -ArgumentList "/i `"$msi`" /qn /norestart /l*v install.log" -Wait -PassThru
if ($p.ExitCode -ne 0) { Get-Content install.log -Tail 50; throw "install exited with $($p.ExitCode)" }
$exe = Join-Path $dir "hodeishield.exe"
if (-not (Test-Path $exe)) { throw "$exe is missing after install" }
if ((Get-FileHash $exe -Algorithm SHA256).Hash.ToLower() -ne $env:EXE_SHA256) { throw "installed exe differs" }
$path = (Get-ItemProperty $envKey).Path
if (($path -split ';') -notcontains "$dir\") { throw "PATH does not contain $dir\ : $path" }
& $exe --version
$p = Start-Process msiexec.exe -ArgumentList "/x `"$msi`" /qn /norestart /l*v uninstall.log" -Wait -PassThru
if ($p.ExitCode -ne 0) { Get-Content uninstall.log -Tail 50; throw "uninstall exited with $($p.ExitCode)" }
if (Test-Path $exe) { throw "$exe is still there after uninstall" }
$path = (Get-ItemProperty $envKey).Path
if (($path -split ';') -contains "$dir\") { throw "PATH still contains $dir\ after uninstall" }
Write-Host "Install, PATH entry and uninstall: OK"
- name: Upload the unsigned MSI
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: windows-msi-unsigned
path: msi-out/*.msi
if-no-files-found: error
retention-days: 1
# Signs the MSI with the same Azure Artifact Signing profile as the exe. Like `sign-windows`, it
# runs no repository or WiX code: the checkout is only the reference copy of LICENSE, NOTICE and
# user-information.md that the extracted payload is compared with. The MSI is published only when signed.
sign-msi:
name: msi (sign)
needs: [version, sign-windows, windows-msi]
runs-on: windows-latest
timeout-minutes: 20
environment: ${{ needs.version.outputs.sign_windows == 'true' && 'release-signing' || '' }}
permissions:
contents: read
id-token: write # OIDC federation to Azure; used only when this run signs
env:
VERSION: ${{ needs.version.outputs.version }}
SIGN: ${{ needs.version.outputs.sign_windows }}
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Download the MSI
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: windows-msi-unsigned
path: msi
- name: Refuse anything but the expected file
shell: pwsh
run: |
$found = @(Get-ChildItem -LiteralPath msi -Recurse -Force | ForEach-Object { $_.Name })
$expected = "hodeishield-$env:VERSION-x86_64-pc-windows-msvc.msi"
if ($found.Count -ne 1 -or $found[0] -ne $expected) {
Write-Host "::error::Unexpected hand-off contents: $($found -join ', ')"
exit 1
}
# The MSI was built by a job that ran WiX, third-party code: do not sign it on trust. Using
# only Windows' own Installer API, check that it carries no custom actions or embedded
# binaries (so nothing runs at install time but standard actions), and that its administrative
# sequences hold only passive standard actions: `msiexec /a` runs AdminExecuteSequence, and
# actions such as SelfRegModules would load a DLL shipped as a payload file. Then extract it
# with an administrative install and check its payload is exactly the exe sign-windows signed,
# plus LICENSE, NOTICE and USER-INFORMATION.md byte for byte as in the checked-out commit.
- name: Check the MSI before signing it
shell: powershell
env:
EXE_SHA256: ${{ needs.sign-windows.outputs.exe_sha256_x64 }}
run: |
$ErrorActionPreference = "Stop"
$msi = (Resolve-Path "msi\hodeishield-$env:VERSION-x86_64-pc-windows-msvc.msi").Path
$installer = New-Object -ComObject WindowsInstaller.Installer
$db = $installer.OpenDatabase($msi, 0)
# The COM calls return values PowerShell would add to the function's output: discard them.
# Fails closed: only a table that is not in _Tables is empty; any other error stops the job.
function Get-Rows([string]$table, [string[]]$columns) {
$list = ($columns | ForEach-Object { "``$_``" }) -join ", "
$view = $db.OpenView("SELECT $list FROM ``$table``")
[void]$view.Execute()
$rows = @()
while ($null -ne ($record = $view.Fetch())) {
$rows += ,((1..$columns.Count | ForEach-Object { $record.StringData($_) }) -join "|")
}
[void]$view.Close()
return $rows
}
$bad = @()
# Every table must be one of those WiX writes for this package (those of the published
# 0.5.1 MSI). Anything else, CustomAction, Binary, SelfReg, Class, TypeLib, MsiAssembly,
# ServiceInstall, RemoveFile, Shortcut, IniFile... included, is refused by not being listed.
$tables = @(Get-Rows "_Tables" @("Name"))
Write-Host "Tables: $($tables -join ', ')"
$knownTables = @("Property", "Upgrade", "LaunchCondition", "Directory", "File", "Component", "Environment", "Registry", "Feature", "FeatureComponents", "AdminUISequence", "AdminExecuteSequence", "AdvtExecuteSequence", "InstallUISequence", "InstallExecuteSequence", "Media", "MsiFileHash", "_Validation")
$extraTables = @($tables | Where-Object { $knownTables -notcontains $_ })
if ($extraTables.Count -gt 0) { $bad += "tables ($($extraTables -join ', '))" }
# Only these standard actions may appear in a sequence (a subset: a WiX that omits one
# still passes). `msiexec /a` runs AdminExecuteSequence, so a custom or payload-loading
# action there would run code on this runner.
$allowedActions = @{
AdminExecuteSequence = @("CostInitialize", "FileCost", "CostFinalize", "InstallValidate", "InstallInitialize", "InstallAdminPackage", "InstallFiles", "InstallFinalize")
AdminUISequence = @("CostInitialize", "FileCost", "CostFinalize", "ExecuteAction")
AdvtExecuteSequence = @("CostInitialize", "CostFinalize", "InstallValidate", "InstallInitialize", "InstallFinalize", "PublishFeatures", "PublishProduct")
InstallUISequence = @("CostInitialize", "FileCost", "CostFinalize", "ExecuteAction", "FindRelatedProducts", "LaunchConditions", "ValidateProductID", "MigrateFeatureStates")
InstallExecuteSequence = @("CostInitialize", "FileCost", "CostFinalize", "InstallValidate", "InstallInitialize", "InstallFiles", "InstallFinalize", "PublishFeatures", "PublishProduct", "FindRelatedProducts", "LaunchConditions", "ValidateProductID", "MigrateFeatureStates", "ProcessComponents", "UnpublishFeatures", "RemoveRegistryValues", "RemoveEnvironmentStrings", "RemoveFiles", "RemoveFolders", "CreateFolders", "WriteRegistryValues", "WriteEnvironmentStrings", "RegisterUser", "RegisterProduct", "RemoveExistingProducts")
}
foreach ($sequence in $allowedActions.Keys) {
if ($tables -notcontains $sequence) { continue }
$found = @(Get-Rows $sequence @("Action"))
Write-Host "$sequence actions: $($found -join ', ')"
$extra = @($found | Where-Object { $allowedActions[$sequence] -notcontains $_ })
if ($extra.Count -gt 0) { $bad += "$sequence ($($extra -join ', '))" }
}
# The only state the package changes: its InstallDir value and the machine PATH entry.
$pinned = @{
Registry = @{ Columns = @("Root", "Key", "Name", "Value", "Component_"); Rows = @("2|Software\Hodeitek\HodeiShield CLI|InstallDir|[INSTALLFOLDER]|SystemPath") }
Environment = @{ Columns = @("Environment", "Name", "Value", "Component_"); Rows = @("PathEntry|=-*PATH|[~];[INSTALLFOLDER]|SystemPath") }
}
foreach ($table in $pinned.Keys) {
if ($tables -notcontains $table) { continue }
$rows = @(Get-Rows $table $pinned[$table].Columns)
Write-Host "$table rows: $($rows -join ' ; ')"
$extra = @($rows | Where-Object { $pinned[$table].Rows -notcontains $_ })
if ($extra.Count -gt 0) { $bad += "$table ($($extra -join ' ; '))" }
}
$files = @(Get-Rows "File" @("File")).Count
Write-Host "File rows: $files"
if ($files -ne 4) { $bad += "File ($files rows, expected 4)" }
[System.Runtime.InteropServices.Marshal]::ReleaseComObject($db) | Out-Null
if ($bad.Count -gt 0) { Write-Host "::error::the MSI has unexpected content: $($bad -join ', ')"; exit 1 }
$target = Join-Path $env:RUNNER_TEMP "msi-admin"
$p = Start-Process msiexec.exe -ArgumentList "/a `"$msi`" /qn TARGETDIR=`"$target`"" -Wait -PassThru
if ($p.ExitCode -ne 0) { throw "administrative extraction exited with $($p.ExitCode)" }
$payload = @(Get-ChildItem -LiteralPath $target -Recurse -File | Where-Object { $_.Extension -ne ".msi" })
$names = @($payload | ForEach-Object { $_.Name } | Sort-Object)
if (($names -join ",") -ne "hodeishield.exe,LICENSE,NOTICE,USER-INFORMATION.md") { Write-Host "::error::MSI payload: $($names -join ', ')"; exit 1 }
$exe = $payload | Where-Object { $_.Name -eq "hodeishield.exe" }
$hash = (Get-FileHash -LiteralPath $exe.FullName -Algorithm SHA256).Hash.ToLower()
if ($hash -ne $env:EXE_SHA256) { Write-Host "::error::the exe in the MSI is $hash, not the signed $env:EXE_SHA256"; exit 1 }
# The texts must be the ones in the repository at this commit, which windows-msi packaged.
$reference = @{ "LICENSE" = "LICENSE"; "NOTICE" = "NOTICE"; "USER-INFORMATION.md" = "docs\user-information.md" }
foreach ($name in $reference.Keys) {
$inMsi = (Get-FileHash -LiteralPath ($payload | Where-Object { $_.Name -eq $name }).FullName -Algorithm SHA256).Hash
$inRepo = (Get-FileHash -LiteralPath $reference[$name] -Algorithm SHA256).Hash
if ($inMsi -ne $inRepo) { Write-Host "::error::$name in the MSI ($inMsi) differs from the repository's ($inRepo)"; exit 1 }
}
Remove-Item -Recurse -Force $target
Write-Host "MSI content: OK (no custom actions or binaries; only passive admin actions; payload is the signed exe and the repository's LICENSE, NOTICE, USER-INFORMATION.md)"
- name: Azure login (OIDC)
if: env.SIGN == 'true'
uses: Azure/login@a641126d1b8aa4d1fa005f4f92df94a3a4c4c906 # v3.1.0
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}