Skip to content

Commit 0c4f89f

Browse files
committed
feat: add MSI Windows installer support
Build MSI installers for Windows using GoReleaser Pro and WiX Toolset: - Add goreleaser-windows job that runs on Windows runner - Generate MSI with deterministic UpgradeCode (UUID v5 from repo name) - Support custom WXS templates via msi_wxs_path input - Include default WXS template for simple CLI installers - Full attestation coverage: sig, cert, SBOM, provenance for MSI - Flatten MSI directory structure to match binaries job pattern - Go-based manifest generation for type safety Tested with baton-runner (custom WXS) and baton-github-test (default WXS).
1 parent baa0422 commit 0c4f89f

11 files changed

Lines changed: 1029 additions & 102 deletions

File tree

.github/workflows/release.yaml

Lines changed: 417 additions & 20 deletions
Large diffs are not rendered by default.

README.md

Lines changed: 32 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,7 @@ 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_wxs_path` | No | `""` | Path to custom WXS template for MSI installer (uses default if not set) |
4446

4547
2. Ensure your repository has the following secrets configured:
4648

@@ -50,6 +52,7 @@ The release workflow accepts the following input parameters:
5052
- `AC_PASSWORD`: Apple Connect password
5153
- `AC_PROVIDER`: Apple Connect provider
5254
- `DATADOG_API_KEY`: Datadog API key for monitoring releases
55+
- `GORELEASER_PRO_KEY`: GoReleaser Pro license key (for MSI builds)
5356

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

@@ -94,6 +97,35 @@ COPY ${TARGETPLATFORM}/${REPO_NAME} /${REPO_NAME}
9497

9598
**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.
9699

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

99131
### Get Baton
Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
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+
baseURL := fmt.Sprintf("%s/%s", cdnBaseURL, s3Dir)
46+
assets := make(map[string]*pb.Asset)
47+
48+
// Find and process zip files
49+
zipFiles, err := filepath.Glob(filepath.Join(distDir, "*.zip"))
50+
if err != nil {
51+
fmt.Fprintf(os.Stderr, "generate-windows-manifest: error finding zip files: %v\n", err)
52+
os.Exit(1)
53+
}
54+
55+
for _, zipPath := range zipFiles {
56+
filename := filepath.Base(zipPath)
57+
if strings.Contains(filename, "checksums") {
58+
continue
59+
}
60+
61+
asset, err := buildAsset(zipPath, filename, "application/zip", baseURL, distDir)
62+
if err != nil {
63+
fmt.Fprintf(os.Stderr, "generate-windows-manifest: error processing %s: %v\n", filename, err)
64+
os.Exit(1)
65+
}
66+
67+
// Windows zip uses key "windows-amd64"
68+
assets["windows-amd64"] = asset
69+
fmt.Fprintf(os.Stderr, "✅ Added zip asset: windows-amd64 -> %s\n", filename)
70+
}
71+
72+
// Find and process MSI files (flattened to dist root by workflow)
73+
msiFiles, err := filepath.Glob(filepath.Join(distDir, "*.msi"))
74+
if err != nil {
75+
fmt.Fprintf(os.Stderr, "generate-windows-manifest: error finding MSI files: %v\n", err)
76+
os.Exit(1)
77+
}
78+
79+
for _, msiPath := range msiFiles {
80+
filename := filepath.Base(msiPath)
81+
82+
asset, err := buildAsset(msiPath, filename, "application/x-msi", baseURL, distDir)
83+
if err != nil {
84+
fmt.Fprintf(os.Stderr, "generate-windows-manifest: error processing %s: %v\n", filename, err)
85+
os.Exit(1)
86+
}
87+
88+
// MSI uses key "windows-amd64-msi"
89+
// MSI has cosign signatures and attestations; Azure Trusted Signing (Windows code signing) planned for Stage 2
90+
assets["windows-amd64-msi"] = asset
91+
fmt.Fprintf(os.Stderr, "✅ Added MSI asset: windows-amd64-msi -> %s\n", filename)
92+
}
93+
94+
// Marshal assets map to JSON
95+
// We need to output a map[string]Asset JSON, not a full manifest
96+
output := make(map[string]json.RawMessage)
97+
marshalOpts := protojson.MarshalOptions{
98+
EmitUnpopulated: true,
99+
}
100+
101+
for key, asset := range assets {
102+
jsonBytes, err := marshalOpts.Marshal(asset)
103+
if err != nil {
104+
fmt.Fprintf(os.Stderr, "generate-windows-manifest: error marshaling asset %s: %v\n", key, err)
105+
os.Exit(1)
106+
}
107+
output[key] = jsonBytes
108+
}
109+
110+
// Output JSON to stdout
111+
outputBytes, err := json.Marshal(output)
112+
if err != nil {
113+
fmt.Fprintf(os.Stderr, "generate-windows-manifest: error marshaling output: %v\n", err)
114+
os.Exit(1)
115+
}
116+
117+
fmt.Println(string(outputBytes))
118+
fmt.Fprintf(os.Stderr, "✅ Generated Windows manifest with %d assets\n", len(assets))
119+
}
120+
121+
func buildAsset(filePath, filename, mediaType, baseURL, distDir string) (*pb.Asset, error) {
122+
// Calculate SHA256
123+
hash, err := sha256File(filePath)
124+
if err != nil {
125+
return nil, fmt.Errorf("calculating hash: %w", err)
126+
}
127+
128+
// Get file size
129+
info, err := os.Stat(filePath)
130+
if err != nil {
131+
return nil, fmt.Errorf("getting file info: %w", err)
132+
}
133+
134+
sizeBytes := info.Size()
135+
href := fmt.Sprintf("%s/%s", baseURL, filename)
136+
137+
// Check for signature and certificate files (all in dist root after flatten step)
138+
var signatureHref, certificateHref *string
139+
sigPath := filepath.Join(distDir, filename+".sig")
140+
if _, err := os.Stat(sigPath); err == nil {
141+
s := fmt.Sprintf("%s/%s.sig", baseURL, filename)
142+
signatureHref = &s
143+
}
144+
certPath := filepath.Join(distDir, filename+".cert")
145+
if _, err := os.Stat(certPath); err == nil {
146+
c := fmt.Sprintf("%s/%s.cert", baseURL, filename)
147+
certificateHref = &c
148+
}
149+
150+
// Build attestations array
151+
var attestations []*pb.AttestationDescriptor
152+
153+
// Check for provenance attestation
154+
provenancePath := filepath.Join(distDir, filename+".provenance.sigstore.json")
155+
if _, err := os.Stat(provenancePath); err == nil {
156+
attestationType := AttestationTypeInTotoV1
157+
predicateType := PredicateTypeSLSAProvenanceV1
158+
bundleHref := fmt.Sprintf("%s/%s.provenance.sigstore.json", baseURL, filename)
159+
attestations = append(attestations, pb.AttestationDescriptor_builder{
160+
AttestationType: &attestationType,
161+
PredicateType: &predicateType,
162+
BundleHref: &bundleHref,
163+
}.Build())
164+
}
165+
166+
// Check for SBOM attestation
167+
sbomPath := filepath.Join(distDir, filename+".sbom.sigstore.json")
168+
if _, err := os.Stat(sbomPath); err == nil {
169+
attestationType := AttestationTypeInTotoV1
170+
predicateType := PredicateTypeSPDX
171+
bundleHref := fmt.Sprintf("%s/%s.sbom.sigstore.json", baseURL, filename)
172+
attestations = append(attestations, pb.AttestationDescriptor_builder{
173+
AttestationType: &attestationType,
174+
PredicateType: &predicateType,
175+
BundleHref: &bundleHref,
176+
}.Build())
177+
}
178+
179+
return pb.Asset_builder{
180+
Filename: &filename,
181+
MediaType: &mediaType,
182+
SizeBytes: &sizeBytes,
183+
Sha256: &hash,
184+
Href: &href,
185+
SignatureHref: signatureHref,
186+
CertificateHref: certificateHref,
187+
Attestations: attestations,
188+
}.Build(), nil
189+
}
190+
191+
func sha256File(path string) (string, error) {
192+
f, err := os.Open(path)
193+
if err != nil {
194+
return "", err
195+
}
196+
defer f.Close()
197+
198+
h := sha256.New()
199+
if _, err := io.Copy(h, f); err != nil {
200+
return "", err
201+
}
202+
203+
return hex.EncodeToString(h.Sum(nil)), nil
204+
}

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)