1616#
1717# brew install makensis create-dmg
1818#
19- # NOTHING HERE IS CODE-SIGNED. Unsigned .pkg and .dmg payloads are quarantined
20- # by Gatekeeper on download, and approving the outer app does NOT unquarantine
21- # nested helper binaries — they get SIGKILLed silently. Ship the documented
22- # `xattr -dr com.apple.quarantine` step with every macOS artefact.
19+ # macOS artefacts are NOT code-signed. Unsigned .pkg and .dmg payloads are
20+ # quarantined by Gatekeeper on download, and approving the outer app does NOT
21+ # unquarantine nested helper binaries — they get SIGKILLed silently. Ship the
22+ # documented `xattr -dr com.apple.quarantine` step with every macOS artefact.
23+ #
24+ # Windows artefacts ARE Authenticode-signed when the RL_SIGN_* variables are
25+ # set — see the Windows signing section. Unset, they skip rather than fail, so
26+ # an unconfigured host still cuts a valid (unsigned) release.
2327#
2428# Usage:
2529# source .../release-lib.sh
@@ -266,6 +270,147 @@ rl_targz() { # rl_targz <label> <stagedir>
266270 rl_note " $( basename " $f " ) "
267271}
268272
273+ # -------------------------------------------------------- Windows signing ---
274+ #
275+ # Authenticode signing via Azure Artifact Signing, driven from the Mac by jsign.
276+ #
277+ # brew install jsign
278+ #
279+ # Why jsign and not signtool: since June 2023 the CA/Browser Forum baseline
280+ # requires every publicly-trusted code-signing key to live on FIPS 140-2 L2
281+ # hardware, so there is no .pfx to hand to signtool and no key that could sit
282+ # in a GitHub secret. Artifact Signing keeps the key in Microsoft's HSM and
283+ # mints a fresh certificate per signature; jsign speaks that protocol over
284+ # HTTPS and runs anywhere, which is what lets the whole fleet stay on this Mac
285+ # instead of moving packaging into the Parallels guest.
286+ #
287+ # TIMESTAMPING IS NOT OPTIONAL HERE. An Artifact Signing certificate is valid for
288+ # 72 hours. Without a countersignature from a TSA the signature is judged
289+ # against wall-clock time, so an unstamped installer verifies fine on the day
290+ # it is cut and is broken by the weekend — and nothing in the build tells you,
291+ # because signing itself succeeded. Every path below stamps.
292+ #
293+ # Configuration (all required; unset means "skip signing", never "fail"):
294+ #
295+ # RL_SIGN_ENDPOINT regional endpoint, e.g. https://eus.codesigning.azure.net
296+ # RL_SIGN_ACCOUNT Artifact Signing account name
297+ # RL_SIGN_PROFILE certificate profile name
298+ #
299+ # Renamed from "Trusted Signing" in 2026; both names still appear in the wild.
300+ # ELIGIBILITY: organizations only in the UK/EU; individual developers must be
301+ # in the US or Canada. A UK sole trader qualifies under neither.
302+
303+ # AZURE_TENANT_ID service principal, as for any Azure SDK client
304+ # AZURE_CLIENT_ID
305+ # AZURE_CLIENT_SECRET
306+ #
307+ # Keep the secret in the keychain and wrap invocation the way cf-run does for
308+ # Cloudflare, rather than exporting it from a dotfile. Store the *client
309+ # secret* only — never a cached access token: tokens are multi-kilobyte JWTs
310+ # and `security add-generic-password -w` silently truncates at 128 bytes, so a
311+ # stored token comes back corrupted with no error. Tokens are cheap; fetch one
312+ # per release.
313+
314+ RL_SIGN_TSA=" ${RL_SIGN_TSA:- http:// timestamp.acs.microsoft.com} "
315+ RL_SIGNED_COUNT=0
316+
317+ # Are we configured to sign? Quiet predicate — callers decide how to report.
318+ rl_sign_ready () {
319+ [[ -n " ${RL_SIGN_ENDPOINT:- } " && -n " ${RL_SIGN_ACCOUNT:- } " && -n " ${RL_SIGN_PROFILE:- } " ]] \
320+ && command -v jsign > /dev/null 2>&1
321+ }
322+
323+ # Artifact Signing authenticates with a bearer token for the code-signing
324+ # resource. jsign will shell out to `az` itself, but only if the Azure CLI is
325+ # installed and logged in interactively — no use in an unattended release. So
326+ # mint the token directly from the service principal and cache it for this
327+ # process: tokens last an hour, a fleet release takes minutes, and re-fetching
328+ # per file would be dozens of round trips.
329+ RL_SIGN_TOKEN=" "
330+ rl_sign_token () {
331+ if [[ -n " $RL_SIGN_TOKEN " ]]; then printf ' %s' " $RL_SIGN_TOKEN " ; return 0; fi
332+ local resp
333+ resp=$( curl -fsS -X POST \
334+ " https://login.microsoftonline.com/${AZURE_TENANT_ID} /oauth2/v2.0/token" \
335+ -d " client_id=${AZURE_CLIENT_ID} " \
336+ -d " client_secret=${AZURE_CLIENT_SECRET} " \
337+ -d " scope=https://codesigning.azure.net/.default" \
338+ -d " grant_type=client_credentials" 2> /dev/null) || return 1
339+ # Avoid a jq dependency; the token is a single flat string field.
340+ RL_SIGN_TOKEN=$( sed -n ' s/.*"access_token":"\([^"]*\)".*/\1/p' <<< " $resp" )
341+ [[ -n " $RL_SIGN_TOKEN " ]] || return 1
342+ printf ' %s' " $RL_SIGN_TOKEN "
343+ }
344+
345+ # Sign one PE file in place. Returns non-zero on real failure so callers can
346+ # abort a release rather than publish a half-signed set.
347+ rl_sign_file () { # rl_sign_file <path-to-exe-or-dll>
348+ local f=" $1 " tok
349+ [[ -f " $f " ]] || return 0
350+
351+ # Already signed? Re-signing appends rather than replaces on some toolchains,
352+ # and a doubly-signed binary is a support call nobody enjoys diagnosing.
353+ if command -v osslsigncode > /dev/null 2>&1 \
354+ && osslsigncode verify " $f " > /dev/null 2>&1 ; then
355+ rl_note " already signed: $( basename " $f " ) "
356+ return 0
357+ fi
358+
359+ tok=$( rl_sign_token) || { echo " could not obtain an Azure token" >&2 ; return 1; }
360+
361+ jsign --storetype TRUSTEDSIGNING \
362+ --keystore " $RL_SIGN_ENDPOINT " \
363+ --storepass " $tok " \
364+ --alias " ${RL_SIGN_ACCOUNT} /${RL_SIGN_PROFILE} " \
365+ --alg SHA-256 \
366+ --tsaurl " $RL_SIGN_TSA " \
367+ --tsmode RFC3161 \
368+ --name " $RL_NAME " \
369+ --url " $RL_URL " \
370+ " $f " > /dev/null 2>&1 || { echo " jsign failed on $f " >&2 ; return 1; }
371+
372+ # Verify rather than trust the exit status. This library already learned that
373+ # lesson from makensis, which exits 0 after aborting; and an unstamped or
374+ # malformed signature is exactly the failure that stays invisible until a
375+ # user reports it weeks later.
376+ if command -v osslsigncode > /dev/null 2>&1 ; then
377+ if ! osslsigncode verify " $f " 2>&1 | grep -q ' Signature verification: ok' ; then
378+ echo " signature did not verify: $f " >&2 ; return 1
379+ fi
380+ if ! osslsigncode verify " $f " 2>&1 | grep -qi ' timestamp' ; then
381+ echo " signed but NOT timestamped (expires in 72h): $f " >&2 ; return 1
382+ fi
383+ fi
384+
385+ RL_SIGNED_COUNT=$(( RL_SIGNED_COUNT + 1 ))
386+ rl_note " signed $( basename " $f " ) "
387+ }
388+
389+ # Sign every PE file in a staging tree, before it is zipped or packed into an
390+ # installer. Order matters: payload first, installer last, because the
391+ # installer's signature covers the compressed payload as-is.
392+ rl_sign_windows () { # rl_sign_windows <stagedir-or-file> [...]
393+ local target
394+ if ! rl_sign_ready; then
395+ if [[ -n " ${RL_SIGN_ENDPOINT:- } " ]] && ! command -v jsign > /dev/null 2>&1 ; then
396+ rl_skip " Windows signing (jsign not installed: brew install jsign)"
397+ else
398+ rl_skip " Windows signing (not configured)"
399+ fi
400+ return 0
401+ fi
402+ rl_step " sign windows"
403+ for target in " $@ " ; do
404+ if [[ -d " $target " ]]; then
405+ while IFS= read -r f; do
406+ rl_sign_file " $f " || return 1
407+ done < <( find " $target " -type f \( -name ' *.exe' -o -name ' *.dll' \) | sort)
408+ else
409+ rl_sign_file " $target " || return 1
410+ fi
411+ done
412+ }
413+
269414# ------------------------------------------------------------------- NSIS ---
270415#
271416# Two shapes of Windows installer:
@@ -277,6 +422,109 @@ rl_targz() { # rl_targz <label> <stagedir>
277422# it. `makensis` on macOS is case-sensitive about the staging paths but writes
278423# Windows-style paths into the script, hence the sed dance.
279424
425+ # makensis builds its language tables by transcoding the BOM'd .nlf files.
426+ # Under LC_CTYPE=C that conversion throws std::bad_alloc and aborts *with a
427+ # zero exit status*, so force a UTF-8 locale and verify the file was written
428+ # rather than trusting the return code.
429+ #
430+ # Which UTF-8 locale actually *works* varies and cannot be inferred from the
431+ # name: macOS lists C.UTF-8 but treats it as plain C, which is exactly the case
432+ # that aborts. So try candidates in order and keep whichever produces a file —
433+ # the only reliable test, given makensis exits 0 even when it dies.
434+ rl_makensis () { # rl_makensis <nsi> <expected-output> <logfile>
435+ local nsi=" $1 " outfile=" $2 " log=" $3 " loc
436+ rm -f " $outfile "
437+ for loc in en_US.UTF-8 C.UTF-8 en_GB.UTF-8 UTF-8; do
438+ LC_ALL=" $loc " LANG=" $loc " makensis -V2 " $nsi " > " $log " 2>&1 || true
439+ [[ -s " $outfile " ]] && return 0
440+ done
441+ return 1
442+ }
443+
444+ # ------------------------------------------- NSIS signed uninstaller (opt-in) --
445+ #
446+ # NSIS cannot emit an uninstaller at compile time: Uninstall.exe is produced by
447+ # the installer *stub at run time*, which means it cannot be signed on this Mac
448+ # the way every other artefact is. The standard workaround is a two-pass build —
449+ # compile a throwaway installer whose only job is to call WriteUninstaller, run
450+ # it on Windows, retrieve the uninstaller it drops, sign that, and `File` it
451+ # into the real installer instead of generating a fresh one.
452+ #
453+ # Running it needs Windows, so this costs a Parallels round-trip and is opt-in
454+ # via RL_SIGN_UNINSTALLER=1. It is off by default deliberately: Uninstall.exe is
455+ # written to disk locally rather than downloaded, so it never carries a
456+ # Mark-of-the-Web and SmartScreen — which only consults reputation for
457+ # MOTW-tagged files — never looks at it. The whole benefit is that the UAC
458+ # prompt at uninstall time reads "Stoatworks Labs" instead of a yellow "Unknown
459+ # publisher". Worth having, not worth blocking a release on.
460+ #
461+ # Guest requirement: none beyond a booted VM. The stub is a plain 32-bit x86
462+ # NSIS installer, which the ARM64 guest runs under emulation happily — unlike
463+ # Tauri's bundled makensis.exe, it is only unpacking itself.
464+
465+ rl_nsis_uninstaller () { # rl_nsis_uninstaller <work> <unsection-body> -> prints path
466+ local work=" $1 " unsection=" $2 "
467+ local vm=" ${RL_VM_NAME:- Windows 11} "
468+ local staging=" $HOME /Projects/.release-vm"
469+ local gen=" $staging /uninstgen-${RL_SLUG} .exe"
470+
471+ command -v prlctl > /dev/null 2>&1 || { rl_skip " signed uninstaller (no prlctl)" ; return 1; }
472+ mkdir -p " $staging "
473+
474+ # SilentInstall silent so the stub writes the uninstaller and exits without
475+ # ever drawing UI — there is nobody in the guest to click Next.
476+ cat > " $work /uninstgen.nsi" << NSI
477+ Unicode true
478+ Name "${RL_NAME} "
479+ OutFile "${gen} "
480+ InstallDir "\$ TEMP\\ ${RL_SLUG} -uninstgen"
481+ RequestExecutionLevel user
482+ SilentInstall silent
483+
484+ VIProductVersion "$( rl_numver) "
485+ VIAddVersionKey "ProductName" "${RL_NAME} "
486+ VIAddVersionKey "CompanyName" "${RL_PUBLISHER} "
487+ VIAddVersionKey "FileDescription" "${RL_NAME} uninstaller"
488+ VIAddVersionKey "FileVersion" "${RL_VERSION} "
489+ VIAddVersionKey "ProductVersion" "${RL_VERSION} "
490+ VIAddVersionKey "LegalCopyright" "${RL_PUBLISHER} "
491+
492+ Section "Install"
493+ SetOutPath "\$ INSTDIR"
494+ WriteUninstaller "\$ INSTDIR\\ Uninstall.exe"
495+ SectionEnd
496+
497+ ${unsection}
498+ NSI
499+
500+ rl_makensis " $work /uninstgen.nsi" " $gen " " $work /uninstgen.log" || {
501+ rl_skip " signed uninstaller (stub compile failed)" ; return 1; }
502+
503+ # \\psf\Projects is the same share release-windows-vm.sh uses.
504+ cat > " $staging /uninstgen.ps1" << PS1
505+ \$ ErrorActionPreference = 'Continue'
506+ \$ dir = "\$ env:TEMP\\ ${RL_SLUG} -uninstgen"
507+ if (Test-Path \$ dir) { Remove-Item \$ dir -Recurse -Force -EA 0 }
508+ Start-Process -FilePath '\\\\ psf\\ Projects\\ .release-vm\\ uninstgen-${RL_SLUG} .exe' -Wait
509+ if (-not (Test-Path "\$ dir\\ Uninstall.exe")) { Write-Output 'NO UNINSTALLER'; exit 1 }
510+ Copy-Item "\$ dir\\ Uninstall.exe" '\\\\ psf\\ Projects\\ .release-vm\\ Uninstall-${RL_SLUG} .exe' -Force
511+ Write-Output 'OK'
512+ exit 0
513+ PS1
514+
515+ rm -f " $staging /Uninstall-${RL_SLUG} .exe"
516+ prlctl exec " $vm " powershell -NoProfile -ExecutionPolicy Bypass \
517+ -File " \\\\ psf\\ Projects\\ .release-vm\\ uninstgen.ps1" > /dev/null 2>&1 || true
518+
519+ local un=" $staging /Uninstall-${RL_SLUG} .exe"
520+ [[ -s " $un " ]] || { rl_skip " signed uninstaller (guest produced nothing)" ; return 1; }
521+
522+ cp " $un " " $work /Uninstall.exe"
523+ rm -f " $un " " $gen " " $staging /uninstgen.ps1"
524+ rl_sign_file " $work /Uninstall.exe" || return 1
525+ printf ' %s' " $work /Uninstall.exe"
526+ }
527+
280528rl_nsis () { # rl_nsis <label> <stagedir> --cli | --gui <exe>
281529 # RL_EULA (optional, from rl_eula) adds a licence page. Required when the NDI
282530 # runtime is bundled — that is the condition Vizrt's redistribution grant
362610)
363611 fi
364612
613+ # The uninstall section is assembled separately because the signed-uninstaller
614+ # two-pass has to compile a stub containing an identical copy of it — the
615+ # uninstaller the stub drops is the one that ships, so any divergence would
616+ # mean shipping an uninstaller that does not match the installer.
617+ local unsection
618+ unsection=$( cat << UNS
619+ Section "Uninstall"
620+ SetRegView 64
621+ SetShellVarContext all
622+ ${unshortcuts}
623+ ${uninstall_files}
624+ Delete "\$ INSTDIR\\ Uninstall.exe"
625+ ${uninstall_dirs}
626+ RMDir "\$ INSTDIR"
627+ DeleteRegKey HKLM "Software\\ Microsoft\\ Windows\\ CurrentVersion\\ Uninstall\\ ${RL_SLUG} "
628+ DeleteRegKey HKLM "Software\\ ${RL_PUBLISHER} \\ ${RL_NAME} "
629+ SectionEnd
630+ UNS
631+ )
632+
633+ # Pass one, when enabled: get a signed Uninstall.exe to embed. `File` after
634+ # WriteUninstaller overwrites the freshly generated one — WriteUninstaller
635+ # itself has to stay, because makensis refuses to compile an Uninstall
636+ # section without it.
637+ local writeuninst=" WriteUninstaller \"\$ INSTDIR\\ Uninstall.exe\" "
638+ if [[ " ${RL_SIGN_UNINSTALLER:- 0} " == " 1" ]] && rl_sign_ready; then
639+ local signedun
640+ if signedun=$( rl_nsis_uninstaller " $work " " $unsection " ) ; then
641+ writeuninst=" ${writeuninst}
642+ File \" /oname=Uninstall.exe\" \" ${signedun} \" "
643+ fi
644+ fi
645+
365646 cat > " $nsi " << NSI
366647Unicode true
367648!include "MUI2.nsh"
@@ -433,7 +714,7 @@ ${install_lines}
433714 SetOutPath "\$ INSTDIR"
434715${shortcuts}
435716${pathblock}
436- WriteUninstaller " \$ INSTDIR \\ Uninstall.exe"
717+ ${writeuninst}
437718 WriteRegStr HKLM "Software\\ ${RL_PUBLISHER} \\ ${RL_NAME} " "InstallDir" "\$ INSTDIR"
438719 WriteRegStr HKLM "Software\\ Microsoft\\ Windows\\ CurrentVersion\\ Uninstall\\ ${RL_SLUG} " "DisplayName" "${RL_NAME} "
439720 WriteRegStr HKLM "Software\\ Microsoft\\ Windows\\ CurrentVersion\\ Uninstall\\ ${RL_SLUG} " "DisplayVersion" "${RL_VERSION} "
@@ -442,37 +723,19 @@ ${pathblock}
442723 WriteRegStr HKLM "Software\\ Microsoft\\ Windows\\ CurrentVersion\\ Uninstall\\ ${RL_SLUG} " "UninstallString" "\$ INSTDIR\\ Uninstall.exe"
443724SectionEnd
444725
445- Section "Uninstall"
446- SetRegView 64
447- SetShellVarContext all
448- ${unshortcuts}
449- ${uninstall_files}
450- Delete "\$ INSTDIR\\ Uninstall.exe"
451- ${uninstall_dirs}
452- RMDir "\$ INSTDIR"
453- DeleteRegKey HKLM "Software\\ Microsoft\\ Windows\\ CurrentVersion\\ Uninstall\\ ${RL_SLUG} "
454- DeleteRegKey HKLM "Software\\ ${RL_PUBLISHER} \\ ${RL_NAME} "
455- SectionEnd
726+ ${unsection}
456727NSI
457728
458- # makensis builds its language tables by transcoding the BOM'd .nlf files.
459- # Under LC_CTYPE=C that conversion throws std::bad_alloc and aborts *with a
460- # zero exit status*, so force a UTF-8 locale and verify the file was written
461- # rather than trusting the return code.
462- #
463- # Which UTF-8 locale actually *works* varies and cannot be inferred from the
464- # name: macOS lists C.UTF-8 but treats it as plain C, which is exactly the
465- # case that aborts. So try candidates in order and keep whichever produces a
466- # file — the only reliable test, given makensis exits 0 even when it dies.
467- rm -f " $outfile "
468- local loc ok=0
469- for loc in en_US.UTF-8 C.UTF-8 en_GB.UTF-8 UTF-8; do
470- LC_ALL=" $loc " LANG=" $loc " makensis -V2 " $nsi " > " $work /makensis.log" 2>&1 || true
471- if [[ -s " $outfile " ]]; then ok=1; break ; fi
472- done
729+ local ok=0
730+ rl_makensis " $nsi " " $outfile " " $work /makensis.log" && ok=1
473731
474732 if (( ok )) ; then
475733 rl_note " $( basename " $outfile " ) "
734+ # Sign last: the installer's signature covers its compressed payload, so
735+ # the staging tree must already have been signed before it was packed.
736+ if rl_sign_ready; then
737+ rl_sign_file " $outfile " || { rm -rf " $work " ; return 1; }
738+ fi
476739 else
477740 echo " makensis failed for ${label} (tried every UTF-8 locale):" >&2
478741 tail -30 " $work /makensis.log" >&2
@@ -680,6 +943,21 @@ rl_dmg() { # rl_dmg <label> <stagedir> [--app <BundleName>]
680943 fi
681944}
682945
946+ # --------------------------------------------------- signing status blurb ---
947+ #
948+ # The one sentence about signing that goes into GitHub release notes. It has to
949+ # track what actually happened during *this* run: claiming "unsigned" on a
950+ # signed installer trains users to click through warnings, and claiming signed
951+ # on an unsigned one is worse. Driven by RL_SIGNED_COUNT, which only
952+ # rl_sign_file increments, so it cannot drift from reality.
953+ rl_notes_signing () {
954+ if (( RL_SIGNED_COUNT > 0 )) ; then
955+ printf ' %s' " Windows artefacts are Authenticode-signed and timestamped. macOS artefacts are unsigned: see the README for the quarantine step."
956+ else
957+ printf ' %s' " Unsigned: see the README for the macOS quarantine step."
958+ fi
959+ }
960+
683961# ------------------------------------------------------------------ report --
684962
685963rl_summary () {
0 commit comments