@@ -174,13 +174,22 @@ create_git_tag() {
174174 fi
175175
176176 if [ " $DRY_RUN " = true ]; then
177- log_info " [DRY-RUN] Would create git tag: $tag "
177+ log_info " [DRY-RUN] Would create signed git tag: $tag "
178178 log_info " [DRY-RUN] Would push tag to remote"
179179 return 0
180180 fi
181181
182- git tag -a " $tag " -m " Release $tag "
183- log_success " Created git tag: $tag "
182+ if [ -n " ${RELEASE_GPG_KEY:- } " ]; then
183+ git -c gpg.format=openpgp -c user.signingkey=" $RELEASE_GPG_KEY " \
184+ tag -s " $tag " -m " Release $tag "
185+ else
186+ local ssh_key
187+ ssh_key=$( release_ssh_signing_key) ||
188+ die " Release signing requires RELEASE_GPG_KEY or RELEASE_SSH_KEY (or an SSH user.signingkey in Git config)"
189+ git -c gpg.format=ssh -c user.signingkey=" $ssh_key " \
190+ tag -s " $tag " -m " Release $tag "
191+ fi
192+ log_success " Created signed git tag: $tag "
184193 git push origin " $tag "
185194 log_success " Pushed tag to remote"
186195 TAG_CREATED=true
@@ -884,27 +893,107 @@ write_release_provenance() {
884893 log_success " Wrote PROVENANCE.txt"
885894}
886895
887- sign_checksums_if_possible () {
896+ release_ssh_signing_key () {
897+ if [ -n " ${RELEASE_SSH_KEY:- } " ]; then
898+ printf ' %s\n' " $RELEASE_SSH_KEY "
899+ return 0
900+ fi
901+
902+ [ " $( git config --get gpg.format 2> /dev/null || true) " = " ssh" ] || return 1
903+ git config --get user.signingkey 2> /dev/null
904+ }
905+
906+ release_signing_identity () {
907+ if [ -n " ${RELEASE_SIGNING_IDENTITY:- } " ]; then
908+ printf ' %s\n' " $RELEASE_SIGNING_IDENTITY "
909+ return 0
910+ fi
911+
912+ git config --get user.email 2> /dev/null
913+ }
914+
915+ preflight_release_signing () {
888916 if [ " $DRY_RUN " = true ]; then
889- log_info " [DRY-RUN] Would sign SHA256SUMS.txt if gpg is available"
917+ log_info " [DRY-RUN] Would verify the release signing key"
918+ return 0
919+ fi
920+
921+ if [ -n " ${RELEASE_GPG_KEY:- } " ]; then
922+ command -v gpg > /dev/null 2>&1 ||
923+ die " gpg is required when RELEASE_GPG_KEY is set"
924+ gpg --batch --list-secret-keys " $RELEASE_GPG_KEY " > /dev/null 2>&1 ||
925+ die " No GPG secret key found for RELEASE_GPG_KEY=$RELEASE_GPG_KEY "
926+ log_success " GPG release signing key is available"
890927 return 0
891928 fi
892929
893- [ -f dist/releases/SHA256SUMS.txt ] || return 0
930+ local ssh_key identity
931+ ssh_key=$( release_ssh_signing_key) ||
932+ die " Release signing requires RELEASE_GPG_KEY or RELEASE_SSH_KEY (or an SSH user.signingkey in Git config)"
933+ identity=$( release_signing_identity) ||
934+ die " SSH release signing requires RELEASE_SIGNING_IDENTITY or user.email in Git config"
935+ [ -n " $identity " ] ||
936+ die " SSH release signing requires RELEASE_SIGNING_IDENTITY or user.email in Git config"
937+ [ -f " $ssh_key " ] ||
938+ die " SSH release signing key not found: $ssh_key "
939+ command -v ssh-keygen > /dev/null 2>&1 ||
940+ die " ssh-keygen is required for SSH release signing"
941+ log_success " SSH release signing key is available"
942+ }
894943
895- if [ -z " ${RELEASE_GPG_KEY:- } " ]; then
896- log_warn " RELEASE_GPG_KEY not set; SHA256SUMS.txt will not be signed"
944+ sign_release_checksums () {
945+ if [ " $DRY_RUN " = true ]; then
946+ log_info " [DRY-RUN] Would sign and verify SHA256SUMS.txt"
897947 return 0
898948 fi
899949
900- if ! command -v gpg > /dev/null 2>&1 ; then
901- log_warn " gpg not found; SHA256SUMS.txt will not be signed"
950+ local checksums=" dist/releases/SHA256SUMS.txt"
951+ [ -s " $checksums " ] || die " Missing or empty $checksums "
952+
953+ if [ -n " ${RELEASE_GPG_KEY:- } " ]; then
954+ local gpg_signature=" ${checksums} .asc"
955+ gpg --batch --yes --armor --detach-sign --local-user " $RELEASE_GPG_KEY " \
956+ -o " $gpg_signature " " $checksums " ||
957+ die " Failed to sign SHA256SUMS.txt with GPG"
958+ [ -s " $gpg_signature " ] || die " GPG did not create $gpg_signature "
959+ gpg --batch --verify " $gpg_signature " " $checksums " > /dev/null 2>&1 ||
960+ die " Generated GPG release signature could not be verified"
961+ log_success " Signed and verified SHA256SUMS.txt -> SHA256SUMS.txt.asc"
902962 return 0
903963 fi
904964
905- gpg --batch --yes --armor --detach-sign --local-user " $RELEASE_GPG_KEY " \
906- -o dist/releases/SHA256SUMS.txt.asc dist/releases/SHA256SUMS.txt
907- log_success " Signed SHA256SUMS.txt -> SHA256SUMS.txt.asc"
965+ local ssh_key identity ssh_signature public_key allowed_signers
966+ ssh_key=$( release_ssh_signing_key) ||
967+ die " Release signing requires RELEASE_GPG_KEY or RELEASE_SSH_KEY (or an SSH user.signingkey in Git config)"
968+ identity=$( release_signing_identity) ||
969+ die " SSH release signing requires RELEASE_SIGNING_IDENTITY or user.email in Git config"
970+ ssh_signature=" ${checksums} .sig"
971+
972+ ssh-keygen -Y sign -f " $ssh_key " -n windshift-release " $checksums " > /dev/null ||
973+ die " Failed to sign SHA256SUMS.txt with SSH"
974+ [ -s " $ssh_signature " ] || die " SSH signing did not create $ssh_signature "
975+
976+ if [[ " $ssh_key " == * .pub ]]; then
977+ public_key=$( awk ' NR == 1 { print $1 " " $2 }' " $ssh_key " )
978+ else
979+ public_key=$( ssh-keygen -y -f " $ssh_key " ) ||
980+ die " Could not derive the public key for release signature verification"
981+ fi
982+ [ -n " $public_key " ] || die " Could not derive the public release signing key"
983+
984+ allowed_signers=" dist/releases/.allowed_signers.tmp"
985+ printf ' %s %s\n' " $identity " " $public_key " > " $allowed_signers "
986+ if ! ssh-keygen -Y verify \
987+ -f " $allowed_signers " \
988+ -I " $identity " \
989+ -n windshift-release \
990+ -s " $ssh_signature " \
991+ < " $checksums " > /dev/null 2>&1 ; then
992+ rm -f " $allowed_signers "
993+ die " Generated SSH release signature could not be verified"
994+ fi
995+ rm -f " $allowed_signers "
996+ log_success " Signed and verified SHA256SUMS.txt -> SHA256SUMS.txt.sig"
908997}
909998
910999ensure_buildx () {
@@ -1019,6 +1108,18 @@ create_github_release() {
10191108
10201109 check_gh_cli
10211110
1111+ if [ " $DRY_RUN " = false ]; then
1112+ local signature_found=false
1113+ for signature in dist/releases/SHA256SUMS.txt.asc dist/releases/SHA256SUMS.txt.sig; do
1114+ if [ -s " $signature " ]; then
1115+ signature_found=true
1116+ break
1117+ fi
1118+ done
1119+ [ " $signature_found " = true ] ||
1120+ die " Refusing to publish a release without a SHA256SUMS.txt signature"
1121+ fi
1122+
10221123 # Create git tag if needed
10231124 local current_tag=$( get_git_tag)
10241125 if [ -z " $current_tag " ]; then
@@ -1033,7 +1134,7 @@ create_github_release() {
10331134
10341135 # Collect assets
10351136 local assets=()
1036- for file in dist/releases/* .tar.gz dist/releases/* .zip dist/releases/* .dmg dist/releases/SHA256SUMS.txt dist/releases/SHA256SUMS.txt.asc dist/releases/PROVENANCE.txt; do
1137+ for file in dist/releases/* .tar.gz dist/releases/* .zip dist/releases/* .dmg dist/releases/SHA256SUMS.txt dist/releases/SHA256SUMS.txt.asc dist/releases/SHA256SUMS.txt.sig dist/releases/ PROVENANCE.txt; do
10371138 [ -f " $file " ] && assets+=(" $file " )
10381139 done
10391140
@@ -1133,6 +1234,7 @@ cmd_release() {
11331234 determine_version
11341235
11351236 REQUIRE_SIGNED_DMG=true
1237+ preflight_release_signing
11361238
11371239 if [ " $CONFIRM " = true ] && [ " $DRY_RUN " = false ]; then
11381240 echo " "
@@ -1170,7 +1272,7 @@ cmd_release() {
11701272 build_desktop_mac
11711273 write_release_provenance
11721274 generate_checksums
1173- sign_checksums_if_possible
1275+ sign_release_checksums
11741276 build_docker
11751277 create_github_release
11761278
@@ -1217,13 +1319,21 @@ GHCR push access is probed for 'push' and 'release', and an expired 1Password
12171319session is refreshed via 'op signin' whenever the DMG will be built. Bad
12181320credentials therefore fail in seconds rather than after a full build.
12191321
1220- Desktop signing (optional, only consulted when running on macOS):
1322+ Desktop signing (required for an official macOS release ):
12211323 APPLE_SIGNING_IDENTITY Developer ID Application cert name in your keychain
12221324 APPLE_ID Apple ID email (for notarization)
12231325 APPLE_PASSWORD App-specific password (for notarization)
12241326 APPLE_PASSWORD_OP_REF 1Password item ID — alternative to APPLE_PASSWORD
12251327 APPLE_TEAM_ID Apple Developer team ID (for notarization)
1226- RELEASE_GPG_KEY Optional GPG key id/email used to sign SHA256SUMS.txt
1328+
1329+ Release signing (required for 'release'):
1330+ RELEASE_GPG_KEY GPG key id/email used to create SHA256SUMS.txt.asc
1331+ RELEASE_SSH_KEY SSH private/public key path used to create SHA256SUMS.txt.sig
1332+ Defaults to Git's SSH user.signingkey when configured.
1333+ RELEASE_SIGNING_IDENTITY
1334+ SSH signer identity; defaults to Git's user.email.
1335+
1336+ Desktop behavior:
12271337 For 'build' and 'push': when unset, the DMG is produced unsigned and
12281338 unnotarized — Gatekeeper will block double-click on download, users must
12291339 right-click → Open.
@@ -1335,4 +1445,6 @@ main() {
13351445 esac
13361446}
13371447
1338- main " $@ "
1448+ if [[ " ${BASH_SOURCE[0]} " == " $0 " ]]; then
1449+ main " $@ "
1450+ fi
0 commit comments