From 72bb94e355c8d750d6f58bb75e1fab70c84b83f3 Mon Sep 17 00:00:00 2001 From: Virgil Date: Wed, 1 Apr 2026 10:53:02 +0000 Subject: [PATCH] feat(build): add Windows signtool signing --- cmd/build/cmd_project.go | 6 +-- docs/architecture.md | 2 +- docs/index.md | 2 +- pkg/build/signing/sign.go | 49 +++++++++++++------- pkg/build/signing/signer.go | 6 ++- pkg/build/signing/signing_test.go | 46 +++++++++++++++++-- pkg/build/signing/signtool.go | 75 ++++++++++++++++++++++++++++--- 7 files changed, 153 insertions(+), 33 deletions(-) diff --git a/cmd/build/cmd_project.go b/cmd/build/cmd_project.go index 83678fa..f4f6f46 100644 --- a/cmd/build/cmd_project.go +++ b/cmd/build/cmd_project.go @@ -160,7 +160,7 @@ func runProjectBuild(ctx context.Context, buildType string, ciMode bool, targets } } - // Sign macOS binaries if enabled + // Sign binaries if enabled. signCfg := buildConfig.Sign if notarize { signCfg.MacOS.Notarize = true @@ -169,7 +169,7 @@ func runProjectBuild(ctx context.Context, buildType string, ciMode bool, targets signCfg.Enabled = false } - if signCfg.Enabled && runtime.GOOS == "darwin" { + if signCfg.Enabled && (runtime.GOOS == "darwin" || runtime.GOOS == "windows") { if verbose && !ciMode { cli.Blank() cli.Print("%s %s\n", buildHeaderStyle.Render(i18n.T("cmd.build.label.sign")), i18n.T("cmd.build.signing_binaries")) @@ -188,7 +188,7 @@ func runProjectBuild(ctx context.Context, buildType string, ciMode bool, targets return err } - if signCfg.MacOS.Notarize { + if runtime.GOOS == "darwin" && signCfg.MacOS.Notarize { if err := signing.NotarizeBinaries(ctx, filesystem, signCfg, signingArtifacts); err != nil { if !ciMode { cli.Print("%s %s: %v\n", buildErrorStyle.Render(i18n.T("common.label.error")), i18n.T("cmd.build.error.notarization_failed"), err) diff --git a/docs/architecture.md b/docs/architecture.md index acbce2c..b966c3e 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -86,7 +86,7 @@ Three implementations: - **GPGSigner** -- `gpg --detach-sign --armor --local-user {key}`. Produces `.asc` files. - **MacOSSigner** -- `codesign --sign {identity} --timestamp --options runtime --force`. Notarisation via `xcrun notarytool submit --wait` then `xcrun stapler staple`. -- **WindowsSigner** -- Placeholder (returns `Available() == false`). +- **WindowsSigner** -- Uses `signtool` on Windows when a certificate is configured. Configuration supports `$ENV` expansion in all credential fields, so secrets can come from environment variables without being written to YAML. diff --git a/docs/index.md b/docs/index.md index 9c10dec..f93c322 100644 --- a/docs/index.md +++ b/docs/index.md @@ -9,7 +9,7 @@ description: Build system, release pipeline, and SDK generation for the Core eco - **Auto-detecting builders** for Go, Wails, Node, PHP, Docker, LinuxKit, C++, and Taskfile projects - **Cross-compilation** with per-target archiving (tar.gz, tar.xz, zip) and SHA-256 checksums -- **Code signing** -- macOS codesign with notarisation, GPG detached signatures, Windows signtool (placeholder) +- **Code signing** -- macOS codesign with notarisation, GPG detached signatures, Windows signtool - **Release automation** -- semantic versioning from git tags, conventional-commit changelogs, multi-target publishing - **SDK generation** -- OpenAPI spec diffing for breaking-change detection, code generation for TypeScript, Python, Go, and PHP - **CLI integration** -- registers `core build`, `core ci`, and `core sdk` commands via the Core CLI framework diff --git a/pkg/build/signing/sign.go b/pkg/build/signing/sign.go index 661b159..4d8859f 100644 --- a/pkg/build/signing/sign.go +++ b/pkg/build/signing/sign.go @@ -19,8 +19,9 @@ type Artifact struct { Arch string } -// SignBinaries signs macOS binaries in the artifacts list. -// Only signs darwin binaries when running on macOS with a configured identity. +// SignBinaries signs binaries for the current host OS in the artifacts list. +// On macOS it signs darwin artifacts with codesign; on Windows it signs windows +// artifacts with signtool when the relevant credentials are configured. // // err := signing.SignBinaries(ctx, io.Local, cfg, artifacts) func SignBinaries(ctx context.Context, fs io.Medium, cfg SignConfig, artifacts []Artifact) error { @@ -28,28 +29,25 @@ func SignBinaries(ctx context.Context, fs io.Medium, cfg SignConfig, artifacts [ return nil } - // Only sign on macOS - if runtime.GOOS != "darwin" { + var signer Signer + var targetOS string + + switch runtime.GOOS { + case "darwin": + signer = NewMacOSSigner(cfg.MacOS) + targetOS = "darwin" + case "windows": + signer = NewWindowsSigner(cfg.Windows) + targetOS = "windows" + default: return nil } - signer := NewMacOSSigner(cfg.MacOS) if !signer.Available() { return nil // Silently skip if not configured } - for _, artifact := range artifacts { - if artifact.OS != "darwin" { - continue - } - - core.Print(nil, " Signing %s...", artifact.Path) - if err := signer.Sign(ctx, fs, artifact.Path); err != nil { - return coreerr.E("signing.SignBinaries", "failed to sign "+artifact.Path, err) - } - } - - return nil + return signArtifactsWithSigner(ctx, fs, signer, targetOS, artifacts) } // NotarizeBinaries notarizes macOS binaries if enabled. @@ -103,3 +101,20 @@ func SignChecksums(ctx context.Context, fs io.Medium, cfg SignConfig, checksumFi return nil } + +func signArtifactsWithSigner(ctx context.Context, fs io.Medium, signer Signer, targetOS string, artifacts []Artifact) error { + _ = fs + + for _, artifact := range artifacts { + if artifact.OS != targetOS { + continue + } + + core.Print(nil, " Signing %s...", artifact.Path) + if err := signer.Sign(ctx, fs, artifact.Path); err != nil { + return coreerr.E("signing.SignBinaries", "failed to sign "+artifact.Path, err) + } + } + + return nil +} diff --git a/pkg/build/signing/signer.go b/pkg/build/signing/signer.go index dea5636..0672724 100644 --- a/pkg/build/signing/signer.go +++ b/pkg/build/signing/signer.go @@ -49,7 +49,7 @@ type MacOSConfig struct { AppPassword string `yaml:"app_password"` // App-specific password } -// WindowsConfig holds Windows signtool configuration (placeholder). +// WindowsConfig holds Windows signtool configuration. // // cfg := signing.WindowsConfig{Certificate: "cert.pfx", Password: "secret"} type WindowsConfig struct { @@ -72,6 +72,10 @@ func DefaultSignConfig() SignConfig { TeamID: core.Env("APPLE_TEAM_ID"), AppPassword: core.Env("APPLE_APP_PASSWORD"), }, + Windows: WindowsConfig{ + Certificate: core.Env("SIGNTOOL_CERTIFICATE"), + Password: core.Env("SIGNTOOL_PASSWORD"), + }, } } diff --git a/pkg/build/signing/signing_test.go b/pkg/build/signing/signing_test.go index c420afd..7d887eb 100644 --- a/pkg/build/signing/signing_test.go +++ b/pkg/build/signing/signing_test.go @@ -7,6 +7,7 @@ import ( "dappco.re/go/core/io" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestSigning_SignBinariesSkipsNonDarwin_Good(t *testing.T) { @@ -155,10 +156,17 @@ func TestSigning_SignConfigExpandEnv_Good(t *testing.T) { func TestSigning_WindowsSigner_Good(t *testing.T) { fs := io.Local - s := NewWindowsSigner(WindowsConfig{}) + s := NewWindowsSigner(WindowsConfig{Certificate: "cert.pfx"}) assert.Equal(t, "signtool", s.Name()) - assert.False(t, s.Available()) - assert.NoError(t, s.Sign(context.Background(), fs, "test.exe")) + + if runtime.GOOS != "windows" { + assert.False(t, s.Available()) + assert.Error(t, s.Sign(context.Background(), fs, "test.exe")) + return + } + + // On Windows, availability depends on the SDK toolchain being installed. + _ = s.Available() } // mockSigner is a test double that records calls to Sign. @@ -230,6 +238,38 @@ func TestSigning_SignBinariesMockSigner_Good(t *testing.T) { }) } +func TestSigning_signArtifactsWithSigner_Good(t *testing.T) { + signer := &mockSigner{name: "mock", available: true} + artifacts := []Artifact{ + {Path: "/dist/linux_amd64/myapp", OS: "linux", Arch: "amd64"}, + {Path: "/dist/windows_amd64/myapp.exe", OS: "windows", Arch: "amd64"}, + {Path: "/dist/windows_arm64/myapp.exe", OS: "windows", Arch: "arm64"}, + } + + err := signArtifactsWithSigner(context.Background(), io.Local, signer, "windows", artifacts) + assert.NoError(t, err) + assert.Equal(t, []string{"/dist/windows_amd64/myapp.exe", "/dist/windows_arm64/myapp.exe"}, signer.signedPaths) +} + +func TestSigning_ResolveSigntoolCli_Good(t *testing.T) { + fallbackDir := t.TempDir() + fallbackPath := fallbackDir + "/signtool.exe" + require.NoError(t, io.Local.Write(fallbackPath, "#!/bin/sh\nexit 0\n")) + t.Setenv("PATH", "") + + command, err := resolveSigntoolCli(fallbackPath) + require.NoError(t, err) + assert.Equal(t, fallbackPath, command) +} + +func TestSigning_ResolveSigntoolCli_Bad(t *testing.T) { + t.Setenv("PATH", "") + + _, err := resolveSigntoolCli(t.TempDir() + "/missing-signtool.exe") + require.Error(t, err) + assert.Contains(t, err.Error(), "signtool tool not found") +} + func TestSigning_SignChecksumsMockSigner_Good(t *testing.T) { t.Run("skips when GPG key is empty", func(t *testing.T) { cfg := SignConfig{ diff --git a/pkg/build/signing/signtool.go b/pkg/build/signing/signtool.go index c4731ae..73e7528 100644 --- a/pkg/build/signing/signtool.go +++ b/pkg/build/signing/signtool.go @@ -2,11 +2,14 @@ package signing import ( "context" + "runtime" + "dappco.re/go/core/build/internal/ax" "dappco.re/go/core/io" + coreerr "dappco.re/go/core/log" ) -// WindowsSigner signs binaries using Windows signtool (placeholder). +// WindowsSigner signs binaries using Windows signtool. // // s := signing.NewWindowsSigner(cfg.Windows) type WindowsSigner struct { @@ -30,17 +33,75 @@ func (s *WindowsSigner) Name() string { return "signtool" } -// Available returns false (not yet implemented). +// Available checks if running on Windows with signtool and certificate configured. // -// ok := s.Available() // → false (placeholder) +// ok := s.Available() // → true if on Windows with certificate configured func (s *WindowsSigner) Available() bool { - return false + if runtime.GOOS != "windows" { + return false + } + if s.config.Certificate == "" { + return false + } + _, err := resolveSigntoolCli() + return err == nil } -// Sign is a placeholder that does nothing. +// Sign signs a binary using signtool and a PFX certificate. // -// err := s.Sign(ctx, io.Local, "dist/myapp.exe") // no-op until implemented +// err := s.Sign(ctx, io.Local, "dist/myapp.exe") func (s *WindowsSigner) Sign(ctx context.Context, fs io.Medium, binary string) error { - // TODO: Implement Windows signing + _ = fs + + if !s.Available() { + if runtime.GOOS != "windows" { + return coreerr.E("signtool.Sign", "signtool is only available on Windows", nil) + } + if s.config.Certificate == "" { + return coreerr.E("signtool.Sign", "signtool certificate not configured", nil) + } + return coreerr.E("signtool.Sign", "signtool tool not found in PATH", nil) + } + + signtoolCommand, err := resolveSigntoolCli() + if err != nil { + return coreerr.E("signtool.Sign", "signtool tool not found in PATH", err) + } + + args := []string{ + "sign", + "/f", s.config.Certificate, + "/fd", "sha256", + "/tr", "http://timestamp.digicert.com", + "/td", "sha256", + } + if s.config.Password != "" { + args = append(args, "/p", s.config.Password) + } + args = append(args, binary) + + output, err := ax.CombinedOutput(ctx, "", nil, signtoolCommand, args...) + if err != nil { + return coreerr.E("signtool.Sign", output, err) + } + return nil } + +func resolveSigntoolCli(paths ...string) (string, error) { + if len(paths) == 0 { + paths = []string{ + `C:\\Program Files (x86)\\Windows Kits\\10\\bin\\x64\\signtool.exe`, + `C:\\Program Files (x86)\\Windows Kits\\10\\bin\\x86\\signtool.exe`, + `C:\\Program Files\\Windows Kits\\10\\bin\\x64\\signtool.exe`, + `C:\\Program Files\\Windows Kits\\10\\bin\\x86\\signtool.exe`, + } + } + + command, err := ax.ResolveCommand("signtool", paths...) + if err != nil { + return "", coreerr.E("signtool.resolveSigntoolCli", "signtool tool not found. Install the Windows SDK.", err) + } + + return command, nil +}