Skip to content

Commit 27dcb2f

Browse files
Sync release-lib.sh: Windows Authenticode signing via Azure Artifact Signing
Vendored from stoatworks-backend/release (979-line canonical). Every rl_* name called by this repo's release scripts and workflows was verified present in the canonical copy before syncing. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent e27b477 commit 27dcb2f

1 file changed

Lines changed: 309 additions & 31 deletions

File tree

scripts/release-lib.sh

Lines changed: 309 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,14 @@
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+
280528
rl_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
@@ -362,6 +610,39 @@ SC
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
366647
Unicode 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"
443724
SectionEnd
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}
456727
NSI
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

685963
rl_summary() {

0 commit comments

Comments
 (0)