Skip to content

Commit 44aa752

Browse files
authored
add MSI Windows installer support (#45)
## Summary Stage 1 implementation of MSI Windows Installer support for connector releases: - **New `goreleaser-windows` job** - Runs on Windows runner with WiX Toolset - Builds both `.zip` and `.msi` from the same Windows binary - Native Windows build (not cross-compiled) - Deterministic UUID v5 UpgradeCode from repository name - Supports custom WXS templates via `msi_wxs_path` input - Conditional via `msi` boolean input (default `true`) - Cosign signatures, SBOM, and provenance attestations for all Windows artifacts - **New templates**: - `.wxs-default-template.wxs` - Default WXS for CLI installers (WiX-compatible version format) - `.goreleaser-windows-template.yaml.tmpl` - Windows zip + MSI GoReleaser config - **Updated `goreleaser-binaries`** - Now Linux + macOS only (Windows moved to dedicated job) - **Unified checksums** - Single checksums file containing all platforms, with correct manifest hash - **Updated `merge-manifests`** - Added `-windows-manifest` flag to include Windows assets - **New `generate-windows-manifest`** - Go tool using protobuf types for type-safe Windows manifest generation with signatureHref, certificateHref, and attestation support - **New inputs/secrets**: - `msi` boolean input (default `true`) to opt out of MSI builds - `msi_wxs_path` input for custom WXS templates (with path traversal validation) - `GORELEASER_PRO_KEY` secret (required only when `msi: true`) - **Security hardening**: - Path traversal validation on `msi_wxs_path` input - Heredoc output format for manifest outputs - Randomized heredoc delimiter for checksums - Defense-in-depth path validation in Go manifest tool - Stale checksums hash fix (re-compute after unification) ## Test plan - [x] Tested with baton-runner releases v0.1.12-test.6 through v0.1.12-test.14 - [x] Tested with baton-github-test v0.1.119-test.1 - [x] Verified manifest includes all 7 assets (checksums, 4 binaries, windows zip, windows msi) - [x] Verified unified checksums file contains all platforms - [x] Verified checksums hash in manifest matches actual file - [x] Verified MSI attestations (sig, cert, SBOM, provenance) with cosign - [x] Verified manifest signature with cosign - [x] Verified MSI installer runs successdfully on actual Windows machine (baton-github-test v0.1.119-test.2, verified install/run/uninstall) <img width="746" height="285" alt="Screenshot 2026-03-03 at 11 58 07" src="https://github.com/user-attachments/assets/59fd5b1e-5f30-44b2-8c1f-c1a31a455358" /> **Test manifests:** - baton-github-test (default WXS): https://dist.conductorone.com/releases/ConductorOne/baton-github-test/v0.1.119-test.1/manifest.json - baton-runner (custom WXS): https://dist.conductorone.com/releases/ConductorOne/baton-runner/v0.1.12-test.14/manifest.json ## Follow-up items - [x] Add signatureHref/certificateHref/attestations to Windows manifest generation - [x] Go-based manifest generation for type safety (cmd/generate-windows-manifest) - [x] Add MSI option to registry UI (ConnectorReleases PR #1337 merged) - [ ] Stage 2: Azure Trusted Signing integration (blocked on IT) - [ ] SHA-pin all third-party actions (separate PR)
1 parent 28eb13f commit 44aa752

11 files changed

Lines changed: 1112 additions & 103 deletions

File tree

‎.github/workflows/release.yaml‎

Lines changed: 480 additions & 21 deletions
Large diffs are not rendered by default.

‎README.md‎

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ jobs:
3030
AC_PASSWORD: ${{ secrets.AC_PASSWORD }}
3131
AC_PROVIDER: ${{ secrets.AC_PROVIDER }}
3232
DATADOG_API_KEY: ${{ secrets.DATADOG_API_KEY }}
33+
GORELEASER_PRO_KEY: ${{ secrets.GORELEASER_PRO_KEY }}
3334
```
3435
3536
The release workflow accepts the following input parameters:
@@ -41,6 +42,8 @@ The release workflow accepts the following input parameters:
4142
| `docker` | No | `true` | Whether to release with Docker image support |
4243
| `dockerfile_template` | No | `""` | Path to a custom Dockerfile in your repo (only valid when `lambda: false`) |
4344
| `docker_extra_files` | No | `""` | Comma-separated list of extra files/dirs to include in Docker build context |
45+
| `msi` | No | `true` | Whether to build MSI Windows installers |
46+
| `msi_wxs_path` | No | `""` | Path to custom WXS template for MSI installer (uses default if not set) |
4447

4548
2. Ensure your repository has the following secrets configured:
4649

@@ -50,6 +53,7 @@ The release workflow accepts the following input parameters:
5053
- `AC_PASSWORD`: Apple Connect password
5154
- `AC_PROVIDER`: Apple Connect provider
5255
- `DATADOG_API_KEY`: Datadog API key for monitoring releases
56+
- `GORELEASER_PRO_KEY`: GoReleaser Pro license key (required when `msi: true`, the default)
5357

5458
3. Remove all GoReleaser, gon files, Dockerfile, and Dockerfile.lambda files from your connector repository, if they were previously created there.
5559

@@ -94,6 +98,45 @@ COPY ${TARGETPLATFORM}/${REPO_NAME} /${REPO_NAME}
9498

9599
**Note:** Use `docker_extra_files` to include additional files or directories (comma-separated) in the Docker build context. These are paths relative to your connector repository root.
96100

101+
### Custom MSI Installers
102+
103+
By default, the workflow builds a simple MSI installer that:
104+
- Installs the binary to `C:\Program Files\ConductorOne\<connector-name>`
105+
- Adds the installation directory to the system PATH
106+
107+
For connectors that require custom MSI behavior (Windows Service, registry keys, etc.), provide a custom WXS template:
108+
109+
```yaml
110+
jobs:
111+
release:
112+
uses: ConductorOne/github-workflows/.github/workflows/release.yaml@v4
113+
with:
114+
tag: ${{ github.ref_name }}
115+
msi_wxs_path: ci/app.wxs
116+
secrets:
117+
# ... secrets ...
118+
```
119+
120+
Your custom WXS template can use GoReleaser template variables:
121+
- `{{ .ProjectName }}` - Connector name (e.g., "baton-okta")
122+
- `{{ .Binary }}` - Binary name without extension
123+
- `{{ .Version }}` - Full version string
124+
- `{{ .Major }}`, `{{ .Minor }}`, `{{ .Patch }}` - Version components
125+
126+
The `${UPGRADE_CODE}` placeholder is automatically replaced with a deterministic UUID v5 generated from the repository name, ensuring consistent upgrade behavior across versions.
127+
128+
See [baton-runner/ci/app.wxs](https://github.com/ConductorOne/baton-runner/blob/main/ci/app.wxs) for an example Windows Service installer.
129+
130+
To disable MSI builds entirely (e.g., for connectors that don't need Windows installers):
131+
132+
```yaml
133+
with:
134+
tag: ${{ github.ref_name }}
135+
msi: false
136+
```
137+
138+
When `msi: false`, the `GORELEASER_PRO_KEY` secret is not required.
139+
97140
## Available Actions
98141

99142
### Get Baton
Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,211 @@
1+
package main
2+
3+
import (
4+
"crypto/sha256"
5+
"encoding/hex"
6+
"encoding/json"
7+
"flag"
8+
"fmt"
9+
"io"
10+
"os"
11+
"path/filepath"
12+
"strings"
13+
14+
"google.golang.org/protobuf/encoding/protojson"
15+
16+
pb "github.com/ConductorOne/github-workflows/pb/artifacts/v1"
17+
)
18+
19+
const (
20+
// AttestationTypeInTotoV1 is the in-toto Statement v1 envelope type
21+
AttestationTypeInTotoV1 = "https://in-toto.io/Statement/v1"
22+
// PredicateTypeSLSAProvenanceV1 is the SLSA v1 provenance predicate type
23+
PredicateTypeSLSAProvenanceV1 = "https://slsa.dev/provenance/v1"
24+
// PredicateTypeSPDX is the SPDX SBOM predicate type
25+
PredicateTypeSPDX = "https://spdx.dev/Document"
26+
)
27+
28+
func main() {
29+
var (
30+
distDir string
31+
cdnBaseURL string
32+
s3Dir string
33+
)
34+
flag.StringVar(&distDir, "dist-dir", "", "Path to the dist directory containing Windows artifacts")
35+
flag.StringVar(&cdnBaseURL, "cdn-base-url", "", "CDN base URL for artifact links")
36+
flag.StringVar(&s3Dir, "s3-directory", "", "S3 directory path for artifacts")
37+
flag.Parse()
38+
39+
if distDir == "" || cdnBaseURL == "" || s3Dir == "" {
40+
fmt.Fprintf(os.Stderr, "generate-windows-manifest: error: all flags are required\n")
41+
fmt.Fprintf(os.Stderr, "Usage: generate-windows-manifest -dist-dir <path> -cdn-base-url <url> -s3-directory <dir>\n")
42+
os.Exit(1)
43+
}
44+
45+
// Defense-in-depth: reject path traversal in S3 directory.
46+
// The workflow's semver validation already prevents this, but validate here too.
47+
if strings.Contains(s3Dir, "..") {
48+
fmt.Fprintf(os.Stderr, "generate-windows-manifest: error: s3-directory must not contain '..': %s\n", s3Dir)
49+
os.Exit(1)
50+
}
51+
52+
baseURL := fmt.Sprintf("%s/%s", cdnBaseURL, s3Dir)
53+
assets := make(map[string]*pb.Asset)
54+
55+
// Find and process zip files
56+
zipFiles, err := filepath.Glob(filepath.Join(distDir, "*.zip"))
57+
if err != nil {
58+
fmt.Fprintf(os.Stderr, "generate-windows-manifest: error finding zip files: %v\n", err)
59+
os.Exit(1)
60+
}
61+
62+
for _, zipPath := range zipFiles {
63+
filename := filepath.Base(zipPath)
64+
if strings.Contains(filename, "checksums") {
65+
continue
66+
}
67+
68+
asset, err := buildAsset(zipPath, filename, "application/zip", baseURL, distDir)
69+
if err != nil {
70+
fmt.Fprintf(os.Stderr, "generate-windows-manifest: error processing %s: %v\n", filename, err)
71+
os.Exit(1)
72+
}
73+
74+
// Windows zip uses key "windows-amd64"
75+
assets["windows-amd64"] = asset
76+
fmt.Fprintf(os.Stderr, "✅ Added zip asset: windows-amd64 -> %s\n", filename)
77+
}
78+
79+
// Find and process MSI files (flattened to dist root by workflow)
80+
msiFiles, err := filepath.Glob(filepath.Join(distDir, "*.msi"))
81+
if err != nil {
82+
fmt.Fprintf(os.Stderr, "generate-windows-manifest: error finding MSI files: %v\n", err)
83+
os.Exit(1)
84+
}
85+
86+
for _, msiPath := range msiFiles {
87+
filename := filepath.Base(msiPath)
88+
89+
asset, err := buildAsset(msiPath, filename, "application/x-msi", baseURL, distDir)
90+
if err != nil {
91+
fmt.Fprintf(os.Stderr, "generate-windows-manifest: error processing %s: %v\n", filename, err)
92+
os.Exit(1)
93+
}
94+
95+
// MSI uses key "windows-amd64-msi"
96+
// MSI has cosign signatures and attestations; Azure Trusted Signing (Windows code signing) planned for Stage 2
97+
assets["windows-amd64-msi"] = asset
98+
fmt.Fprintf(os.Stderr, "✅ Added MSI asset: windows-amd64-msi -> %s\n", filename)
99+
}
100+
101+
// Marshal assets map to JSON
102+
// We need to output a map[string]Asset JSON, not a full manifest
103+
output := make(map[string]json.RawMessage)
104+
marshalOpts := protojson.MarshalOptions{
105+
EmitUnpopulated: true,
106+
}
107+
108+
for key, asset := range assets {
109+
jsonBytes, err := marshalOpts.Marshal(asset)
110+
if err != nil {
111+
fmt.Fprintf(os.Stderr, "generate-windows-manifest: error marshaling asset %s: %v\n", key, err)
112+
os.Exit(1)
113+
}
114+
output[key] = jsonBytes
115+
}
116+
117+
// Output JSON to stdout
118+
outputBytes, err := json.Marshal(output)
119+
if err != nil {
120+
fmt.Fprintf(os.Stderr, "generate-windows-manifest: error marshaling output: %v\n", err)
121+
os.Exit(1)
122+
}
123+
124+
fmt.Println(string(outputBytes))
125+
fmt.Fprintf(os.Stderr, "✅ Generated Windows manifest with %d assets\n", len(assets))
126+
}
127+
128+
func buildAsset(filePath, filename, mediaType, baseURL, distDir string) (*pb.Asset, error) {
129+
// Calculate SHA256
130+
hash, err := sha256File(filePath)
131+
if err != nil {
132+
return nil, fmt.Errorf("calculating hash: %w", err)
133+
}
134+
135+
// Get file size
136+
info, err := os.Stat(filePath)
137+
if err != nil {
138+
return nil, fmt.Errorf("getting file info: %w", err)
139+
}
140+
141+
sizeBytes := info.Size()
142+
href := fmt.Sprintf("%s/%s", baseURL, filename)
143+
144+
// Check for signature and certificate files (all in dist root after flatten step)
145+
var signatureHref, certificateHref *string
146+
sigPath := filepath.Join(distDir, filename+".sig")
147+
if _, err := os.Stat(sigPath); err == nil {
148+
s := fmt.Sprintf("%s/%s.sig", baseURL, filename)
149+
signatureHref = &s
150+
}
151+
certPath := filepath.Join(distDir, filename+".cert")
152+
if _, err := os.Stat(certPath); err == nil {
153+
c := fmt.Sprintf("%s/%s.cert", baseURL, filename)
154+
certificateHref = &c
155+
}
156+
157+
// Build attestations array
158+
var attestations []*pb.AttestationDescriptor
159+
160+
// Check for provenance attestation
161+
provenancePath := filepath.Join(distDir, filename+".provenance.sigstore.json")
162+
if _, err := os.Stat(provenancePath); err == nil {
163+
attestationType := AttestationTypeInTotoV1
164+
predicateType := PredicateTypeSLSAProvenanceV1
165+
bundleHref := fmt.Sprintf("%s/%s.provenance.sigstore.json", baseURL, filename)
166+
attestations = append(attestations, pb.AttestationDescriptor_builder{
167+
AttestationType: &attestationType,
168+
PredicateType: &predicateType,
169+
BundleHref: &bundleHref,
170+
}.Build())
171+
}
172+
173+
// Check for SBOM attestation
174+
sbomPath := filepath.Join(distDir, filename+".sbom.sigstore.json")
175+
if _, err := os.Stat(sbomPath); err == nil {
176+
attestationType := AttestationTypeInTotoV1
177+
predicateType := PredicateTypeSPDX
178+
bundleHref := fmt.Sprintf("%s/%s.sbom.sigstore.json", baseURL, filename)
179+
attestations = append(attestations, pb.AttestationDescriptor_builder{
180+
AttestationType: &attestationType,
181+
PredicateType: &predicateType,
182+
BundleHref: &bundleHref,
183+
}.Build())
184+
}
185+
186+
return pb.Asset_builder{
187+
Filename: &filename,
188+
MediaType: &mediaType,
189+
SizeBytes: &sizeBytes,
190+
Sha256: &hash,
191+
Href: &href,
192+
SignatureHref: signatureHref,
193+
CertificateHref: certificateHref,
194+
Attestations: attestations,
195+
}.Build(), nil
196+
}
197+
198+
func sha256File(path string) (string, error) {
199+
f, err := os.Open(path)
200+
if err != nil {
201+
return "", err
202+
}
203+
defer f.Close()
204+
205+
h := sha256.New()
206+
if _, err := io.Copy(h, f); err != nil {
207+
return "", err
208+
}
209+
210+
return hex.EncodeToString(h.Sum(nil)), nil
211+
}

‎cmd/merge-manifests/main.go‎

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,9 +22,11 @@ func main() {
2222
var (
2323
binariesManifest string
2424
imagesManifest string
25+
windowsManifest string
2526
)
2627
flag.StringVar(&binariesManifest, "binaries-manifest", "", "JSON string of binaries manifest")
2728
flag.StringVar(&imagesManifest, "images-manifest", "", "JSON string of images manifest (optional)")
29+
flag.StringVar(&windowsManifest, "windows-manifest", "", "JSON string of Windows assets manifest (optional)")
2830
flag.Parse()
2931

3032
if binariesManifest == "" {
@@ -101,6 +103,41 @@ func main() {
101103
fmt.Fprintln(os.Stderr, "ℹ️ No images to add to manifest (docker job may have been skipped if no Dockerfile)")
102104
}
103105

106+
// Merge Windows assets if present
107+
if windowsManifest != "" && windowsManifest != "{}" {
108+
// Windows manifest format: { "windows-amd64": { "filename": "...", ... }, "windows-amd64-msi": { ... } }
109+
var windowsMapJSON map[string]json.RawMessage
110+
if err := json.Unmarshal([]byte(windowsManifest), &windowsMapJSON); err != nil {
111+
fmt.Fprintf(os.Stderr, "merge-manifests: ::error::Invalid JSON in windows_manifest output\n")
112+
fmt.Fprintf(os.Stderr, "merge-manifests: Raw content:\n%s\n", windowsManifest)
113+
fmt.Fprintf(os.Stderr, "merge-manifests: Error: %v\n", err)
114+
os.Exit(1)
115+
}
116+
117+
// Get or create assets map
118+
assets := manifest.GetAssets()
119+
if assets == nil {
120+
assets = make(map[string]*pb.Asset)
121+
manifest.SetAssets(assets)
122+
}
123+
124+
unmarshalOpts := protojson.UnmarshalOptions{
125+
DiscardUnknown: true,
126+
}
127+
for key, assetJSON := range windowsMapJSON {
128+
asset := &pb.Asset{}
129+
if err := unmarshalOpts.Unmarshal(assetJSON, asset); err != nil {
130+
fmt.Fprintf(os.Stderr, "merge-manifests: error: unmarshaling Windows asset %s: %v\n", key, err)
131+
os.Exit(1)
132+
}
133+
assets[key] = asset
134+
}
135+
136+
fmt.Fprintf(os.Stderr, "✅ Added %d Windows assets to manifest\n", len(windowsMapJSON))
137+
} else {
138+
fmt.Fprintln(os.Stderr, "ℹ️ No Windows assets to add to manifest")
139+
}
140+
104141
// Set manifest-level asset attestation descriptor if any assets have attestations
105142
hasAssetAttestations := false
106143
for _, asset := range manifest.GetAssets() {

‎docs/diagrams/release-workflow.dot‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,9 @@ digraph ReleaseWorkflow {
1717

1818
determine_ref [label="determine-workflows-ref\n• resolve workflow SHA", fillcolor="#f9fafb"];
1919

20-
binaries [label="goreleaser-binaries\n• build archives\n• gon codesign\n• SBOMs (syft)\n• provenance attestations\n• SBOM attestations\n• upload to S3", fillcolor="#ecfeff"];
20+
binaries [label="goreleaser-binaries\n• Linux + macOS archives\n• gon codesign (macOS)\n• SBOMs, provenance\n• upload to S3", fillcolor="#ecfeff"];
21+
22+
windows [label="goreleaser-windows\n• Windows zip + MSI\n• WiX Toolset\n• SBOMs, provenance\n• upload to S3", fillcolor="#ecfeff"];
2123

2224
docker [label="goreleaser-docker\n• multi-arch OCI images\n• Lambda image (arm64)\n• GHCR push\n• ECR Public push\n• image attestations", fillcolor="#ecfeff"];
2325

@@ -38,11 +40,14 @@ digraph ReleaseWorkflow {
3840
tag -> validate;
3941
validate -> determine_ref;
4042
determine_ref -> binaries;
43+
determine_ref -> windows;
4144
determine_ref -> docker;
4245
binaries -> record;
46+
windows -> record;
4347
docker -> record;
4448
docker -> record_lambda;
4549
binaries -> s3 [label="artifacts"];
50+
windows -> s3 [label="artifacts"];
4651
docker -> ghcr [label="push"];
4752
docker -> ecr [label="push"];
4853
record -> s3 [label="manifest"];

‎docs/diagrams/release-workflow.png‎

18.9 KB
Loading

0 commit comments

Comments
 (0)