Repository navigation
windows build - #56
Open
guygrigsby wants to merge 14 commits into
Open
guygrigsby wants to merge 14 commits into
guygrigsby wants to merge 14 commits into
Conversation
guygrigsby
commented
Oct 29, 2026
Contributor
- ci: add a Windows build and test gate
- release: sign Windows binaries with Azure Artifact Signing
- release: sign Windows with a Cloud HSM key through a store-agnostic Jsign hook
The release now cross-compiles windows/amd64 and windows/arm64 (zip
archives) from the existing macOS GoReleaser run; the signing hook
already passes non-darwin targets through. Windows binaries ship
unsigned: there is no Apple-style notarization gate on Windows, only
SmartScreen reputation, and wiring Authenticode signing (Trusted
Signing or a Key Vault certificate) is a separate decision.
A new Windows CI job runs the same make check gate as Linux and
macOS. GNU make comes from chocolatey; the Makefile's SHELL=/bin/bash
does not resolve on Windows, so the job overrides it with the runner's
Git Bash.
Making the suite green under that gate required real fixes, not just
config: the opencode write-failure test used RLIMIT_FSIZE/SIGXFSZ and
now lives behind the unix build tag, and the test suite isolated state
with HOME/XDG only, which os.UserHomeDir and os.UserConfigDir ignore
on Windows (they read USERPROFILE and APPDATA), so tests would have
leaked into the runner profile and broken each other; every isolation
site now sets both. The binary-discovery tests also assumed
extension-less files are executable; Windows only treats PATHEXT
extensions as executable, so the fakes become .bat and the
common-bin-dirs fallback test skips on Windows because FindBinary
probes those dirs for the bare name, which can never match there.
Verified locally: make check green, GOOS=windows go vet clean for both
arches, and a goreleaser snapshot produces aperture-cli_windows_{amd64,
arm64}.zip containing aperture.exe. The Windows CI job itself can only
be exercised by pushing this branch.
Windows zips shipped unsigned, so SmartScreen warned on every release (the hash changes each time, resetting reputation) and fleets that block unsigned executables could not run aperture at all. A second GoReleaser post-hook signs each aperture.exe with Jsign against Artifact Signing, before archiving, so zips and checksums describe signed bytes. Jsign runs on the existing macOS job; signtool would have forced a second job that re-zips and rewrites checksums after upload. Auth is GitHub OIDC through azure/login, so no signing secret is stored. Jsign is pinned by sha256 because it runs with the access token in its environment. A verify-windows job reads the published zips back on windows-latest and requires a Valid, timestamped signature from Tailscale Inc. Not exercised here: a real signature. That needs the Azure account, the Entra federated credential for the release environment and the repo vars/secrets listed in the README. See ADR 0009.
…sign hook The Windows client team does not want signing key material in Actions, and the HSM route Nick pointed at keeps the key in Google Cloud. The previous commit hard-wired Azure Artifact Signing into the hook, so moving to the HSM would have meant rewriting the script, not changing a setting. sign-windows.sh now reads its key store from WINDOWS_SIGN_* variables and knows nothing about any provider. The release sets GOOGLECLOUD with a workload-identity access token; a new ci-windows-signing workflow runs the same hook with PKCS12 and a throwaway self-signed EC P-256 key, builds both architectures, trusts the certificate on a windows-latest runner and has Windows verify the result. That proves the Jsign, timestamp and PE paths, including ECDSA and arm64, before the HSM key or its CA certificate exist. verify-windows now shares verify-windows-signature.ps1 with it. Not exercised: a real HSM signature, workload identity federation, Windows' verdict on the self-signed build (no runner here), or trust in the CA chain. The release fails closed until GCP_* variables and scripts/windows-codesign-chain.pem exist, so do not merge before the key does. See ADR 0009, rewritten to this decision.
Windows CI failed its first run at make check with exit code 2. The runner checks out with core.autocrlf=true, so every file lands with CRLF and the lint step flags all 126 .go files at gofmt -l. Reproduced in a local clone with core.autocrlf=true, fixed there with this file, and rechecked clean.
The Windows team wants NSIS (Nullsoft) when aperture grows an installer, which produces an .exe, so the ADR revisit condition should say so instead of naming an MSI.
The Windows team has settled on WiX Toolset rather than NSIS for an eventual aperture installer. WiX builds an MSI, so the revisit condition goes back to naming one.
The workload identity provider can only assert the release environment, and the environment can only assert a tag, so a tag pushed from a feature branch would be signed as Tailscale Inc. like any other. The check runs against HEAD, not GITHUB_SHA, because a workflow_dispatch run checks out the requested tag while GITHUB_SHA still names the branch it was dispatched from; checking GITHUB_SHA would pass a branch tag dispatched from main. It stops mistakes, not an attacker who can push tags: that tag's workflow file can simply omit the step. The tag ruleset is the control for that.
Fleet admins deploy with msiexec /qn and expect an Add/Remove Programs entry and aperture on PATH; a zip gives them neither. One .wxs serves x64 and arm64 because wix build -arch sets the platform, and an MSI installs only on the platform it names, so each architecture gets its own MSI rather than one carrying both payloads. The build script refuses anything but a stable vX.Y.Z inside 255.255.65535, since Windows Installer has no prerelease field and would misorder one. UpgradeCode is fixed and pinned by a test: changing it makes the next MSI install beside the old one instead of upgrading it. WiX is pinned at 7.0.1, which requires accepting the OSMF EULA (wix7); Tailscale already sponsors wixtoolset, which is what it asks. Windows CI installs WiX so the packaging test builds a real MSI and reads its version and platform back through Windows Installer; elsewhere it skips.
A signed MSI proves nothing about whether it installs, upgrades or comes off cleanly, and the release has no machine it may install on. The smoke test now signs the exe, packages two versions, signs both MSIs, has Windows verify all three, then installs, upgrades, refuses a downgrade and uninstalls on native hardware for each architecture. Building arm64 on the x64 runner, as before, would have left the arm64 MSI never installed anywhere.
An MSI has to be built from the signed exe and then signed itself, and WiX needs Windows, so it cannot happen inside the macOS goreleaser run. windows-msi replaces verify-windows: it verifies the published exes as before, then builds, signs and verifies one MSI per architecture and rewrites checksums.txt to include them. Both jobs refuse a commit not on main before they log in to Google Cloud. The release is published before the MSIs exist, so a failure here leaves zips without MSIs until the job is re-run; every step is idempotent for that. Making the release a draft until this job finishes would close the gap, but the Homebrew cask is pushed from the first job and would point at assets that do not download until the draft is published. Prerelease tags get no MSI, since MSI versions have no prerelease field.
ADR 0009 said no Windows job sits inside the release and named verify-windows; neither is true once the MSI has to be built from the signed exe on Windows. 0010 records why that job exists and what it costs, chiefly a release that is public before its MSIs are. The README tells release owners that the main-only check is not the control against someone who can push tags; the tag ruleset is.
…e key A manual run executes main's workflow but checks out whatever ref it is given, and the main check was a script from that checkout. Dispatching from main with a branch whose copy of the script exits 0 would pass the release environment, get a signing token and run that branch's setup scripts with it, the Apple secrets included. The check is now inline in the workflow, before anything from the checkout runs, and a dispatched ref must be a tag. The script and its test go, since a copy in the checkout is exactly what cannot be trusted. The MSI job also took its exes from the release, which anyone with write access can swap between jobs for any Tailscale-signed binary; it now takes goreleaser's from a run artifact. WiX runs before Google Cloud login, signing is its own step, the auth action writes no credentials file, and GH_TOKEN reaches only the steps that call gh.
A re-run of the MSI job rebuilds at the same version with a new ProductCode, and MajorUpgrade ignores same-version products by default, so anyone who installed the first build would end up with two Add/Remove Programs entries. Skipping the upload when an MSI is already on the release was the other fix, but it strands a release whose first upload half failed. The install test now reinstalls a rebuild between the upgrade and the downgrade.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.