Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
501 changes: 480 additions & 21 deletions .github/workflows/release.yaml

Large diffs are not rendered by default.

43 changes: 43 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ jobs:
AC_PASSWORD: ${{ secrets.AC_PASSWORD }}
AC_PROVIDER: ${{ secrets.AC_PROVIDER }}
DATADOG_API_KEY: ${{ secrets.DATADOG_API_KEY }}
GORELEASER_PRO_KEY: ${{ secrets.GORELEASER_PRO_KEY }}
```

The release workflow accepts the following input parameters:
Expand All @@ -41,6 +42,8 @@ The release workflow accepts the following input parameters:
| `docker` | No | `true` | Whether to release with Docker image support |
| `dockerfile_template` | No | `""` | Path to a custom Dockerfile in your repo (only valid when `lambda: false`) |
| `docker_extra_files` | No | `""` | Comma-separated list of extra files/dirs to include in Docker build context |
| `msi` | No | `true` | Whether to build MSI Windows installers |
| `msi_wxs_path` | No | `""` | Path to custom WXS template for MSI installer (uses default if not set) |

2. Ensure your repository has the following secrets configured:

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

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

Expand Down Expand Up @@ -94,6 +98,45 @@ COPY ${TARGETPLATFORM}/${REPO_NAME} /${REPO_NAME}

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

### Custom MSI Installers

By default, the workflow builds a simple MSI installer that:
- Installs the binary to `C:\Program Files\ConductorOne\<connector-name>`
- Adds the installation directory to the system PATH

For connectors that require custom MSI behavior (Windows Service, registry keys, etc.), provide a custom WXS template:

```yaml
jobs:
release:
uses: ConductorOne/github-workflows/.github/workflows/release.yaml@v4
with:
tag: ${{ github.ref_name }}
msi_wxs_path: ci/app.wxs
secrets:
# ... secrets ...
```

Your custom WXS template can use GoReleaser template variables:
- `{{ .ProjectName }}` - Connector name (e.g., "baton-okta")
- `{{ .Binary }}` - Binary name without extension
- `{{ .Version }}` - Full version string
- `{{ .Major }}`, `{{ .Minor }}`, `{{ .Patch }}` - Version components

The `${UPGRADE_CODE}` placeholder is automatically replaced with a deterministic UUID v5 generated from the repository name, ensuring consistent upgrade behavior across versions.

See [baton-runner/ci/app.wxs](https://github.com/ConductorOne/baton-runner/blob/main/ci/app.wxs) for an example Windows Service installer.

To disable MSI builds entirely (e.g., for connectors that don't need Windows installers):

```yaml
with:
tag: ${{ github.ref_name }}
msi: false
```

When `msi: false`, the `GORELEASER_PRO_KEY` secret is not required.

## Available Actions

### Get Baton
Expand Down
211 changes: 211 additions & 0 deletions cmd/generate-windows-manifest/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,211 @@
package main

import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"flag"
"fmt"
"io"
"os"
"path/filepath"
"strings"

"google.golang.org/protobuf/encoding/protojson"

pb "github.com/ConductorOne/github-workflows/pb/artifacts/v1"
)

const (
// AttestationTypeInTotoV1 is the in-toto Statement v1 envelope type
AttestationTypeInTotoV1 = "https://in-toto.io/Statement/v1"
// PredicateTypeSLSAProvenanceV1 is the SLSA v1 provenance predicate type
PredicateTypeSLSAProvenanceV1 = "https://slsa.dev/provenance/v1"
// PredicateTypeSPDX is the SPDX SBOM predicate type
PredicateTypeSPDX = "https://spdx.dev/Document"
)

func main() {
var (
distDir string
cdnBaseURL string
s3Dir string
)
flag.StringVar(&distDir, "dist-dir", "", "Path to the dist directory containing Windows artifacts")
flag.StringVar(&cdnBaseURL, "cdn-base-url", "", "CDN base URL for artifact links")
flag.StringVar(&s3Dir, "s3-directory", "", "S3 directory path for artifacts")
flag.Parse()

if distDir == "" || cdnBaseURL == "" || s3Dir == "" {
fmt.Fprintf(os.Stderr, "generate-windows-manifest: error: all flags are required\n")
fmt.Fprintf(os.Stderr, "Usage: generate-windows-manifest -dist-dir <path> -cdn-base-url <url> -s3-directory <dir>\n")
os.Exit(1)
}

// Defense-in-depth: reject path traversal in S3 directory.
// The workflow's semver validation already prevents this, but validate here too.
if strings.Contains(s3Dir, "..") {
fmt.Fprintf(os.Stderr, "generate-windows-manifest: error: s3-directory must not contain '..': %s\n", s3Dir)
os.Exit(1)
}

baseURL := fmt.Sprintf("%s/%s", cdnBaseURL, s3Dir)
Copy link
Copy Markdown
Collaborator

@kans kans Jan 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder if someone could add ~ "../../okta" to a tag to get a build into the wrong bucket.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The tag is already validated by strict semver regex at line 76 in the release.yaml file. The character class [0-9a-zA-Z-] doesn't allow / or . in positions that could form .., so ../../okta is impossible as a tag.

But we can add another check here to be safe, incase that other file ever gets changed.

assets := make(map[string]*pb.Asset)

// Find and process zip files
zipFiles, err := filepath.Glob(filepath.Join(distDir, "*.zip"))
if err != nil {
fmt.Fprintf(os.Stderr, "generate-windows-manifest: error finding zip files: %v\n", err)
os.Exit(1)
}

for _, zipPath := range zipFiles {
filename := filepath.Base(zipPath)
if strings.Contains(filename, "checksums") {
continue
}

asset, err := buildAsset(zipPath, filename, "application/zip", baseURL, distDir)
if err != nil {
fmt.Fprintf(os.Stderr, "generate-windows-manifest: error processing %s: %v\n", filename, err)
os.Exit(1)
}

// Windows zip uses key "windows-amd64"
assets["windows-amd64"] = asset
fmt.Fprintf(os.Stderr, "✅ Added zip asset: windows-amd64 -> %s\n", filename)
}

// Find and process MSI files (flattened to dist root by workflow)
msiFiles, err := filepath.Glob(filepath.Join(distDir, "*.msi"))
if err != nil {
fmt.Fprintf(os.Stderr, "generate-windows-manifest: error finding MSI files: %v\n", err)
os.Exit(1)
}

for _, msiPath := range msiFiles {
filename := filepath.Base(msiPath)

asset, err := buildAsset(msiPath, filename, "application/x-msi", baseURL, distDir)
if err != nil {
fmt.Fprintf(os.Stderr, "generate-windows-manifest: error processing %s: %v\n", filename, err)
os.Exit(1)
}

// MSI uses key "windows-amd64-msi"
// MSI has cosign signatures and attestations; Azure Trusted Signing (Windows code signing) planned for Stage 2
assets["windows-amd64-msi"] = asset
fmt.Fprintf(os.Stderr, "✅ Added MSI asset: windows-amd64-msi -> %s\n", filename)
}

// Marshal assets map to JSON
// We need to output a map[string]Asset JSON, not a full manifest
output := make(map[string]json.RawMessage)
marshalOpts := protojson.MarshalOptions{
EmitUnpopulated: true,
}

for key, asset := range assets {
jsonBytes, err := marshalOpts.Marshal(asset)
if err != nil {
fmt.Fprintf(os.Stderr, "generate-windows-manifest: error marshaling asset %s: %v\n", key, err)
os.Exit(1)
}
output[key] = jsonBytes
}

// Output JSON to stdout
outputBytes, err := json.Marshal(output)
if err != nil {
fmt.Fprintf(os.Stderr, "generate-windows-manifest: error marshaling output: %v\n", err)
os.Exit(1)
}

fmt.Println(string(outputBytes))
fmt.Fprintf(os.Stderr, "✅ Generated Windows manifest with %d assets\n", len(assets))
}

func buildAsset(filePath, filename, mediaType, baseURL, distDir string) (*pb.Asset, error) {
// Calculate SHA256
hash, err := sha256File(filePath)
if err != nil {
return nil, fmt.Errorf("calculating hash: %w", err)
}

// Get file size
info, err := os.Stat(filePath)
if err != nil {
return nil, fmt.Errorf("getting file info: %w", err)
}

sizeBytes := info.Size()
href := fmt.Sprintf("%s/%s", baseURL, filename)

// Check for signature and certificate files (all in dist root after flatten step)
var signatureHref, certificateHref *string
sigPath := filepath.Join(distDir, filename+".sig")
if _, err := os.Stat(sigPath); err == nil {
s := fmt.Sprintf("%s/%s.sig", baseURL, filename)
signatureHref = &s
}
certPath := filepath.Join(distDir, filename+".cert")
if _, err := os.Stat(certPath); err == nil {
c := fmt.Sprintf("%s/%s.cert", baseURL, filename)
certificateHref = &c
}

// Build attestations array
var attestations []*pb.AttestationDescriptor

// Check for provenance attestation
provenancePath := filepath.Join(distDir, filename+".provenance.sigstore.json")
if _, err := os.Stat(provenancePath); err == nil {
attestationType := AttestationTypeInTotoV1
predicateType := PredicateTypeSLSAProvenanceV1
bundleHref := fmt.Sprintf("%s/%s.provenance.sigstore.json", baseURL, filename)
attestations = append(attestations, pb.AttestationDescriptor_builder{
AttestationType: &attestationType,
PredicateType: &predicateType,
BundleHref: &bundleHref,
}.Build())
}

// Check for SBOM attestation
sbomPath := filepath.Join(distDir, filename+".sbom.sigstore.json")
if _, err := os.Stat(sbomPath); err == nil {
attestationType := AttestationTypeInTotoV1
predicateType := PredicateTypeSPDX
bundleHref := fmt.Sprintf("%s/%s.sbom.sigstore.json", baseURL, filename)
attestations = append(attestations, pb.AttestationDescriptor_builder{
AttestationType: &attestationType,
PredicateType: &predicateType,
BundleHref: &bundleHref,
}.Build())
}

return pb.Asset_builder{
Filename: &filename,
MediaType: &mediaType,
SizeBytes: &sizeBytes,
Sha256: &hash,
Href: &href,
SignatureHref: signatureHref,
CertificateHref: certificateHref,
Attestations: attestations,
}.Build(), nil
}

func sha256File(path string) (string, error) {
f, err := os.Open(path)
if err != nil {
return "", err
}
defer f.Close()

h := sha256.New()
if _, err := io.Copy(h, f); err != nil {
return "", err
}

return hex.EncodeToString(h.Sum(nil)), nil
}
37 changes: 37 additions & 0 deletions cmd/merge-manifests/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,11 @@ func main() {
var (
binariesManifest string
imagesManifest string
windowsManifest string
)
flag.StringVar(&binariesManifest, "binaries-manifest", "", "JSON string of binaries manifest")
flag.StringVar(&imagesManifest, "images-manifest", "", "JSON string of images manifest (optional)")
flag.StringVar(&windowsManifest, "windows-manifest", "", "JSON string of Windows assets manifest (optional)")
flag.Parse()

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

// Merge Windows assets if present
if windowsManifest != "" && windowsManifest != "{}" {
// Windows manifest format: { "windows-amd64": { "filename": "...", ... }, "windows-amd64-msi": { ... } }
var windowsMapJSON map[string]json.RawMessage
if err := json.Unmarshal([]byte(windowsManifest), &windowsMapJSON); err != nil {
fmt.Fprintf(os.Stderr, "merge-manifests: ::error::Invalid JSON in windows_manifest output\n")
fmt.Fprintf(os.Stderr, "merge-manifests: Raw content:\n%s\n", windowsManifest)
fmt.Fprintf(os.Stderr, "merge-manifests: Error: %v\n", err)
os.Exit(1)
}

// Get or create assets map
assets := manifest.GetAssets()
if assets == nil {
assets = make(map[string]*pb.Asset)
manifest.SetAssets(assets)
}

unmarshalOpts := protojson.UnmarshalOptions{
DiscardUnknown: true,
}
for key, assetJSON := range windowsMapJSON {
asset := &pb.Asset{}
if err := unmarshalOpts.Unmarshal(assetJSON, asset); err != nil {
fmt.Fprintf(os.Stderr, "merge-manifests: error: unmarshaling Windows asset %s: %v\n", key, err)
os.Exit(1)
}
assets[key] = asset
}

fmt.Fprintf(os.Stderr, "✅ Added %d Windows assets to manifest\n", len(windowsMapJSON))
} else {
fmt.Fprintln(os.Stderr, "ℹ️ No Windows assets to add to manifest")
}

// Set manifest-level asset attestation descriptor if any assets have attestations
hasAssetAttestations := false
for _, asset := range manifest.GetAssets() {
Expand Down
7 changes: 6 additions & 1 deletion docs/diagrams/release-workflow.dot
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,9 @@ digraph ReleaseWorkflow {

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

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"];
binaries [label="goreleaser-binaries\n• Linux + macOS archives\n• gon codesign (macOS)\n• SBOMs, provenance\n• upload to S3", fillcolor="#ecfeff"];

windows [label="goreleaser-windows\n• Windows zip + MSI\n• WiX Toolset\n• SBOMs, provenance\n• upload to S3", fillcolor="#ecfeff"];

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"];

Expand All @@ -38,11 +40,14 @@ digraph ReleaseWorkflow {
tag -> validate;
validate -> determine_ref;
determine_ref -> binaries;
determine_ref -> windows;
determine_ref -> docker;
binaries -> record;
windows -> record;
docker -> record;
docker -> record_lambda;
binaries -> s3 [label="artifacts"];
windows -> s3 [label="artifacts"];
docker -> ghcr [label="push"];
docker -> ecr [label="push"];
record -> s3 [label="manifest"];
Expand Down
Binary file modified docs/diagrams/release-workflow.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading