Skip to content
Closed
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
405 changes: 398 additions & 7 deletions .github/workflows/release.yaml

Large diffs are not rendered by default.

208 changes: 208 additions & 0 deletions cmd/generate-brew-formula/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
// Command generate-brew-formula renders a Homebrew formula from a merged release manifest.
//
// GoReleaser used to render this formula as part of the binaries run, but that run now only
// builds the darwin targets (linux moved to its own job so it can compile in parallel), so
// GoReleaser can no longer see the linux archives the tap's on_linux blocks need. This tool
// renders the formula from the merged manifest instead, where every platform is present.
//
// Download URLs point at the CDN rather than GitHub release assets: the CDN is the canonical
// distribution channel and is what the connector registry records.
package main

import (
"bytes"
"flag"
"fmt"
"os"
"strings"
"text/template"

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

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

// platformBlock is one `url`/`sha256` pair rendered inside an on_macos/on_linux block.
type platformBlock struct {
Condition string
URL string
SHA256 string
}

type formulaData struct {
Class string
Binary string
Homepage string
Version string
MacOS []platformBlock
Linux []platformBlock
}

// formulaTemplate mirrors the layout GoReleaser produced so the tap diff stays reviewable.
const formulaTemplate = `# typed: false
# frozen_string_literal: true

# This file was generated by ConductorOne/github-workflows. DO NOT EDIT.
class {{ .Class }} < Formula
desc ""
homepage "{{ .Homepage }}"
version "{{ .Version }}"
{{ if .MacOS }}
on_macos do
{{- range .MacOS }}
if {{ .Condition }}
url "{{ .URL }}"
sha256 "{{ .SHA256 }}"

def install
bin.install "{{ $.Binary }}"
end
end
{{- end }}
end
{{- end }}
{{ if .Linux }}
on_linux do
{{- range .Linux }}
if {{ .Condition }}
url "{{ .URL }}"
sha256 "{{ .SHA256 }}"

def install
bin.install "{{ $.Binary }}"
end
end
{{- end }}
end
{{- end }}

test do
system "#{bin}/{{ .Binary }} -v"
end
end
`

func main() {
var (
manifestPath string
outputPath string
homepage string
)
flag.StringVar(&manifestPath, "manifest", "", "Path to the merged manifest.json")
flag.StringVar(&outputPath, "output", "", "Path to write the rendered formula")
flag.StringVar(&homepage, "homepage", "https://conductorone.com", "Formula homepage")
flag.Parse()

if manifestPath == "" || outputPath == "" {
fmt.Fprintf(os.Stderr, "generate-brew-formula: error: manifest and output are required\n")
os.Exit(1)
}

raw, err := os.ReadFile(manifestPath)
if err != nil {
fmt.Fprintf(os.Stderr, "generate-brew-formula: error: reading manifest: %v\n", err)
os.Exit(1)
}

manifest := &pb.Manifest{}
if err := (protojson.UnmarshalOptions{DiscardUnknown: true}).Unmarshal(raw, manifest); err != nil {
fmt.Fprintf(os.Stderr, "generate-brew-formula: error: parsing manifest: %v\n", err)
os.Exit(1)
}

name := manifest.GetName()
if name == "" {
fmt.Fprintf(os.Stderr, "generate-brew-formula: ::error::Manifest is missing 'name'\n")
os.Exit(1)
}

data := formulaData{
Class: formulaClass(name),
Binary: name,
Homepage: homepage,
Version: strings.TrimPrefix(manifest.GetSemver(), "v"),
}

// Ordering matches GoReleaser's output: intel before arm within each OS block.
macOSPlatforms := []struct{ key, condition string }{
{"darwin-amd64", "Hardware::CPU.intel?"},
{"darwin-arm64", "Hardware::CPU.arm?"},
}
linuxPlatforms := []struct{ key, condition string }{
{"linux-amd64", "Hardware::CPU.intel? && Hardware::CPU.is_64_bit?"},
{"linux-arm64", "Hardware::CPU.arm? && Hardware::CPU.is_64_bit?"},
}

assets := manifest.GetAssets()
for _, p := range macOSPlatforms {
if block, ok := blockFor(assets, p.key, p.condition); ok {
data.MacOS = append(data.MacOS, block)
}
}
for _, p := range linuxPlatforms {
if block, ok := blockFor(assets, p.key, p.condition); ok {
data.Linux = append(data.Linux, block)
}
}

// A formula with no platforms would silently uninstall the connector for every user.
if len(data.MacOS) == 0 && len(data.Linux) == 0 {
fmt.Fprintf(os.Stderr, "generate-brew-formula: ::error::Manifest contains no darwin or linux assets\n")
os.Exit(1)
}

rendered, err := renderFormula(data)
if err != nil {
fmt.Fprintf(os.Stderr, "generate-brew-formula: error: rendering formula: %v\n", err)
os.Exit(1)
}

if err := os.WriteFile(outputPath, rendered, 0o644); err != nil {
fmt.Fprintf(os.Stderr, "generate-brew-formula: error: writing formula: %v\n", err)
os.Exit(1)
}

fmt.Fprintf(os.Stderr, "✅ Generated formula for %s (%d macOS, %d linux platforms)\n",
name, len(data.MacOS), len(data.Linux))
}

// renderFormula renders the Homebrew formula for data.
func renderFormula(data formulaData) ([]byte, error) {
tmpl, err := template.New("formula").Parse(formulaTemplate)
if err != nil {
return nil, fmt.Errorf("parsing template: %w", err)
}

var buf bytes.Buffer
if err := tmpl.Execute(&buf, data); err != nil {
return nil, fmt.Errorf("executing template: %w", err)
}
return buf.Bytes(), nil
}

// blockFor builds the template block for one platform, reporting false when the manifest
// has no asset for it (for example a connector that does not ship a darwin build).
func blockFor(assets map[string]*pb.Asset, key, condition string) (platformBlock, bool) {
asset, ok := assets[key]
if !ok || asset.GetHref() == "" || asset.GetSha256() == "" {
return platformBlock{}, false
}
return platformBlock{
Condition: condition,
URL: asset.GetHref(),
SHA256: asset.GetSha256(),
}, true
}

// formulaClass converts a repository name into the Ruby class name Homebrew expects,
// matching GoReleaser's behaviour: "baton-okta" becomes "BatonOkta".
func formulaClass(name string) string {
var b strings.Builder
for _, part := range strings.FieldsFunc(name, func(r rune) bool {
return r == '-' || r == '_' || r == '.' || r == ' '
}) {
b.WriteString(strings.ToUpper(part[:1]))
b.WriteString(part[1:])
}
return b.String()
}
114 changes: 114 additions & 0 deletions cmd/generate-brew-formula/main_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
package main

import (
"strings"
"testing"

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

func asset(href, sha string) *pb.Asset {
return pb.Asset_builder{Href: &href, Sha256: &sha}.Build()
}

func TestFormulaClass(t *testing.T) {
for name, want := range map[string]string{
"baton-okta": "BatonOkta",
"baton-aws": "BatonAws",
"bridge-client": "BridgeClient",
"cone": "Cone",
"baton_some_thing": "BatonSomeThing",
"baton-multi-part-x": "BatonMultiPartX",
} {
if got := formulaClass(name); got != want {
t.Errorf("formulaClass(%q) = %q, want %q", name, got, want)
}
}
}

func TestBlockForSkipsIncompleteAssets(t *testing.T) {
assets := map[string]*pb.Asset{
"linux-amd64": asset("https://example.test/a.tar.gz", "abc"),
"linux-arm64": asset("", "abc"),
"darwin-arm64": asset("https://example.test/b.zip", ""),
}

if _, ok := blockFor(assets, "linux-amd64", "cond"); !ok {
t.Error("complete asset should produce a block")
}
// An asset missing an href or hash would render a formula that cannot install.
if _, ok := blockFor(assets, "linux-arm64", "cond"); ok {
t.Error("asset without href should be skipped")
}
if _, ok := blockFor(assets, "darwin-arm64", "cond"); ok {
t.Error("asset without sha256 should be skipped")
}
if _, ok := blockFor(assets, "windows-amd64", "cond"); ok {
t.Error("absent asset should be skipped")
}
}

func TestFormulaClassEmptySegments(t *testing.T) {
// FieldsFunc drops empty segments, so a stray separator must not panic on part[:1].
if got := formulaClass("baton--okta-"); got != "BatonOkta" {
t.Errorf("formulaClass with repeated separators = %q", got)
}
}

func TestRenderFormulaMatchesTapLayout(t *testing.T) {
out := mustRender(t, formulaData{
Class: "BatonOkta",
Binary: "baton-okta",
Homepage: "https://conductorone.com",
Version: "0.5.36",
MacOS: []platformBlock{
{Condition: "Hardware::CPU.intel?", URL: "https://cdn.test/darwin-amd64.zip", SHA256: "aaa"},
},
Linux: []platformBlock{
{Condition: "Hardware::CPU.intel? && Hardware::CPU.is_64_bit?", URL: "https://cdn.test/linux-amd64.tar.gz", SHA256: "bbb"},
},
})

for _, needle := range []string{
"class BatonOkta < Formula",
`version "0.5.36"`,
"on_macos do",
"on_linux do",
`url "https://cdn.test/darwin-amd64.zip"`,
`sha256 "bbb"`,
`bin.install "baton-okta"`,
`system "#{bin}/baton-okta -v"`,
} {
if !strings.Contains(out, needle) {
t.Errorf("rendered formula missing %q\n%s", needle, out)
}
}
}

func TestRenderFormulaOmitsEmptyOSBlock(t *testing.T) {
// A connector with no darwin build must not emit a dangling empty on_macos block.
out := mustRender(t, formulaData{
Class: "BatonLinuxOnly",
Binary: "baton-linux-only",
Version: "1.0.0",
Linux: []platformBlock{
{Condition: "Hardware::CPU.intel? && Hardware::CPU.is_64_bit?", URL: "https://cdn.test/l.tar.gz", SHA256: "ccc"},
},
})

if strings.Contains(out, "on_macos") {
t.Errorf("formula with no darwin assets should omit on_macos\n%s", out)
}
if !strings.Contains(out, "on_linux do") {
t.Errorf("formula should retain on_linux\n%s", out)
}
}

func mustRender(t *testing.T, data formulaData) string {
t.Helper()
out, err := renderFormula(data)
if err != nil {
t.Fatalf("renderFormula: %v", err)
}
return string(out)
}
42 changes: 42 additions & 0 deletions cmd/merge-manifests/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,12 @@ const (
func main() {
var (
binariesManifest string
linuxManifest string
imagesManifest string
windowsManifest string
)
flag.StringVar(&binariesManifest, "binaries-manifest", "", "JSON string of binaries manifest")
flag.StringVar(&linuxManifest, "linux-manifest", "", "JSON string of linux manifest (optional)")
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()
Expand All @@ -51,6 +53,46 @@ func main() {
os.Exit(1)
}

// Merge linux assets if present.
// The linux job runs the same generate-manifest tool, so this is a full manifest rather
// than the bare asset map the Windows job emits. Only its assets are merged; the
// top-level fields (version, semver, hrefs) already come from the binaries manifest.
if linuxManifest != "" && linuxManifest != "{}" {
linuxParsed := &pb.Manifest{}
if err := opts.Unmarshal([]byte(linuxManifest), linuxParsed); err != nil {
fmt.Fprintf(os.Stderr, "merge-manifests: ::error::Invalid JSON in linux_manifest output\n")
fmt.Fprintf(os.Stderr, "merge-manifests: Raw content:\n%s\n", linuxManifest)
fmt.Fprintf(os.Stderr, "merge-manifests: Error: %v\n", err)
os.Exit(1)
}

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

added := 0
for key, asset := range linuxParsed.GetAssets() {
// Each build job generates a partial checksums file covering only its own
// archives. publish-release-manifest concatenates them and rewrites this
// entry, so the linux job's copy would only overwrite it with a partial hash.
if key == "checksums" {
continue
}
assets[key] = asset
added++
}

if added == 0 {
fmt.Fprintf(os.Stderr, "merge-manifests: ::error::linux_manifest contained no assets\n")
os.Exit(1)
}
fmt.Fprintf(os.Stderr, "✅ Added %d linux assets to manifest\n", added)
} else {
fmt.Fprintln(os.Stderr, "ℹ️ No linux assets to add to manifest")
}

// Marshal options with frontend consumption in mind. Ensures all fields are present for predictable structure.
marshalOpts := protojson.MarshalOptions{
Multiline: true,
Expand Down
Loading